summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--.gitlab-ci.yml2
-rw-r--r--.gitlab/CODEOWNERS2
-rw-r--r--.gitlab/ci/docs.gitlab-ci.yml2
-rw-r--r--.gitlab/ci/frontend.gitlab-ci.yml6
-rw-r--r--.gitlab/ci/global.gitlab-ci.yml15
-rw-r--r--.gitlab/ci/rails.gitlab-ci.yml39
-rw-r--r--.gitlab/ci/review.gitlab-ci.yml2
-rw-r--r--.gitlab/issue_templates/Feature Flag Roll Out.md8
-rw-r--r--.gitlab/issue_templates/Feature proposal.md18
-rw-r--r--.gitlab/issue_templates/Implementation.md62
-rw-r--r--.gitlab/issue_templates/Security Release Tracking Issue.md2
-rw-r--r--.gitlab/issue_templates/Snowplow event tracking.md42
-rw-r--r--.gitlab/issue_templates/actionable_insight.md6
-rw-r--r--.gitlab/merge_request_templates/Change Documentation Location.md2
-rw-r--r--.gitlab/merge_request_templates/Documentation.md5
-rw-r--r--.gitlab/merge_request_templates/Security Release.md2
-rw-r--r--.haml-lint_todo.yml733
-rw-r--r--.rubocop_todo.yml37
-rw-r--r--.test_license_encryption_key.pub9
-rw-r--r--CHANGELOG.md56
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--GITLAB_KAS_VERSION2
-rw-r--r--GITLAB_PAGES_VERSION2
-rw-r--r--GITLAB_WORKHORSE_VERSION2
-rw-r--r--Gemfile16
-rw-r--r--Gemfile.lock147
-rw-r--r--app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue6
-rw-r--r--app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue8
-rw-r--r--app/assets/javascripts/alert_handler.js22
-rw-r--r--app/assets/javascripts/alert_management/components/alert_details.vue120
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_table.vue4
-rw-r--r--app/assets/javascripts/alert_management/components/alert_sidebar.vue1
-rw-r--r--app/assets/javascripts/alert_management/components/alert_status.vue30
-rw-r--r--app/assets/javascripts/alert_management/components/alert_summary_row.vue18
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue8
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue64
-rw-r--r--app/assets/javascripts/alert_management/components/system_notes/system_note.vue27
-rw-r--r--app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue8
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue14
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/components/app.vue14
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue64
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql32
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/index.js24
-rw-r--r--app/assets/javascripts/analytics/shared/components/metric_card.vue80
-rw-r--r--app/assets/javascripts/api.js58
-rw-r--r--app/assets/javascripts/awards_handler.js2
-rw-r--r--app/assets/javascripts/badges/components/badge_form.vue4
-rw-r--r--app/assets/javascripts/badges/components/badge_list_row.vue29
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue5
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_dropdown.vue111
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_item.vue17
-rw-r--r--app/assets/javascripts/batch_comments/components/publish_button.vue20
-rw-r--r--app/assets/javascripts/batch_comments/components/review_bar.vue47
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js22
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js7
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js16
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js2
-rw-r--r--app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js10
-rw-r--r--app/assets/javascripts/behaviors/index.js3
-rw-r--r--app/assets/javascripts/behaviors/load_startup_css.js15
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js4
-rw-r--r--app/assets/javascripts/blob/components/blob_header_filepath.vue1
-rw-r--r--app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue50
-rw-r--r--app/assets/javascripts/blob/suggest_web_ide_ci/index.js20
-rw-r--r--app/assets/javascripts/blob/viewer/index.js4
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js6
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.vue104
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue5
-rw-r--r--app/assets/javascripts/boards/components/board_configuration_options.vue65
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue25
-rw-r--r--app/assets/javascripts/boards/components/modal/tabs.vue34
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_editable_item.vue4
-rw-r--r--app/assets/javascripts/boards/ee_functions.js2
-rw-r--r--app/assets/javascripts/boards/index.js40
-rw-r--r--app/assets/javascripts/boards/queries/board.mutation.graphql11
-rw-r--r--app/assets/javascripts/boards/stores/actions.js27
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js63
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js4
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js31
-rw-r--r--app/assets/javascripts/boards/stores/state.js1
-rw-r--r--app/assets/javascripts/build_artifacts.js18
-rw-r--r--app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue143
-rw-r--r--app/assets/javascripts/ci_settings_pipeline_triggers/index.js36
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue16
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue5
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue2
-rw-r--r--app/assets/javascripts/clusters/components/crossplane_provider_stack.vue18
-rw-r--r--app/assets/javascripts/clusters/components/fluentd_output_settings.vue35
-rw-r--r--app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue42
-rw-r--r--app/assets/javascripts/clusters/components/knative_domain_editor.vue26
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters.vue33
-rw-r--r--app/assets/javascripts/clusters_list/components/node_error_help_text.vue53
-rw-r--r--app/assets/javascripts/clusters_list/constants.js43
-rw-r--r--app/assets/javascripts/clusters_list/index.js18
-rw-r--r--app/assets/javascripts/clusters_list/load_clusters.js18
-rw-r--r--app/assets/javascripts/code_navigation/index.js10
-rw-r--r--app/assets/javascripts/code_navigation/store/index.js11
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue124
-rw-r--r--app/assets/javascripts/commons/index.js1
-rw-r--r--app/assets/javascripts/commons/jquery.js4
-rw-r--r--app/assets/javascripts/confirm_danger_modal.js10
-rw-r--r--app/assets/javascripts/confirm_modal.js12
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue5
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue299
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/index.js2
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/actions.js5
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js3
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/state.js1
-rw-r--r--app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue8
-rw-r--r--app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue23
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue8
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js6
-rw-r--r--app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue18
-rw-r--r--app/assets/javascripts/design_management/components/delete_button.vue2
-rw-r--r--app/assets/javascripts/design_management/components/design_sidebar.vue2
-rw-r--r--app/assets/javascripts/design_management/components/list/item.vue1
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/design_navigation.vue4
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/index.vue8
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql1
-rw-r--r--app/assets/javascripts/design_management/mixins/all_designs.js26
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue33
-rw-r--r--app/assets/javascripts/design_management/utils/cache_update.js1
-rw-r--r--app/assets/javascripts/design_management/utils/design_management_utils.js4
-rw-r--r--app/assets/javascripts/diff_notes/components/comment_resolve_btn.js65
-rw-r--r--app/assets/javascripts/diff_notes/components/diff_note_avatars.js189
-rw-r--r--app/assets/javascripts/diff_notes/components/jump_to_discussion.js210
-rw-r--r--app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js28
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_btn.js145
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_count.js28
-rw-r--r--app/assets/javascripts/diff_notes/diff_notes_bundle.js72
-rw-r--r--app/assets/javascripts/diff_notes/icons/collapse_icon.svg1
-rw-r--r--app/assets/javascripts/diff_notes/mixins/discussion.js37
-rw-r--r--app/assets/javascripts/diff_notes/models/discussion.js99
-rw-r--r--app/assets/javascripts/diff_notes/models/note.js14
-rw-r--r--app/assets/javascripts/diff_notes/services/resolve.js86
-rw-r--r--app/assets/javascripts/diff_notes/stores/comments.js56
-rw-r--r--app/assets/javascripts/diffs/components/app.vue4
-rw-r--r--app/assets/javascripts/diffs/components/collapsed_files_warning.vue2
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue10
-rw-r--r--app/assets/javascripts/diffs/components/commit_widget.vue13
-rw-r--r--app/assets/javascripts/diffs/components/compare_dropdown_layout.vue2
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue46
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue212
-rw-r--r--app/assets/javascripts/diffs/components/diff_row_utils.js99
-rw-r--r--app/assets/javascripts/diffs/components/diff_stats.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_table_cell.vue206
-rw-r--r--app/assets/javascripts/diffs/components/edit_button.vue64
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_table_row.vue95
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue8
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_table_row.vue182
-rw-r--r--app/assets/javascripts/diffs/diff_file.js12
-rw-r--r--app/assets/javascripts/diffs/i18n.js14
-rw-r--r--app/assets/javascripts/diffs/store/actions.js9
-rw-r--r--app/assets/javascripts/diffs/store/getters.js50
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js4
-rw-r--r--app/assets/javascripts/emoji/index.js125
-rw-r--r--app/assets/javascripts/environments/components/enable_review_app_button.vue2
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue103
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue1
-rw-r--r--app/assets/javascripts/environments/components/stop_environment_modal.vue47
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.vue24
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace_entry.vue4
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/app.vue18
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue15
-rw-r--r--app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue254
-rw-r--r--app/assets/javascripts/feature_flags/components/edit_feature_flag.vue184
-rw-r--r--app/assets/javascripts/feature_flags/components/environments_dropdown.vue184
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags.vue354
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_tab.vue108
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_table.vue274
-rw-r--r--app/assets/javascripts/feature_flags/components/form.vue616
-rw-r--r--app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue106
-rw-r--r--app/assets/javascripts/feature_flags/components/new_feature_flag.vue134
-rw-r--r--app/assets/javascripts/feature_flags/components/strategy.vue327
-rw-r--r--app/assets/javascripts/feature_flags/components/user_lists_table.vue122
-rw-r--r--app/assets/javascripts/feature_flags/constants.js28
-rw-r--r--app/assets/javascripts/feature_flags/edit.js33
-rw-r--r--app/assets/javascripts/feature_flags/index.js41
-rw-r--r--app/assets/javascripts/feature_flags/new.js32
-rw-r--r--app/assets/javascripts/feature_flags/store/index.js18
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/edit/actions.js75
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/edit/index.js10
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/edit/mutation_types.js12
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/edit/mutations.js45
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/edit/state.js18
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/helpers.js213
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/index/actions.js107
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/index/index.js10
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/index/mutation_types.js26
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/index/mutations.js125
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/index/state.js18
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/new/actions.js51
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/new/index.js10
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/new/mutation_types.js6
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/new/mutations.js21
-rw-r--r--app/assets/javascripts/feature_flags/store/modules/new/state.js6
-rw-r--r--app/assets/javascripts/feature_flags/utils.js48
-rw-r--r--app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js43
-rw-r--r--app/assets/javascripts/filtered_search/available_dropdown_mappings.js5
-rw-r--r--app/assets/javascripts/filtered_search/constants.js2
-rw-r--r--app/assets/javascripts/frequent_items/utils.js2
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js29
-rw-r--r--app/assets/javascripts/grafana_integration/components/grafana_integration.vue8
-rw-r--r--app/assets/javascripts/groups/components/group_folder.vue6
-rw-r--r--app/assets/javascripts/groups/components/item_actions.vue2
-rw-r--r--app/assets/javascripts/groups/components/item_stats_value.vue3
-rw-r--r--app/assets/javascripts/groups/members/components/app.vue7
-rw-r--r--app/assets/javascripts/groups/members/index.js3
-rw-r--r--app/assets/javascripts/ide/components/activity_bar.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/actions.vue25
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/message_field.vue18
-rw-r--r--app/assets/javascripts/ide/components/editor_mode_dropdown.vue6
-rw-r--r--app/assets/javascripts/ide/components/file_templates/dropdown.vue5
-rw-r--r--app/assets/javascripts/ide/components/ide.vue58
-rw-r--r--app/assets/javascripts/ide/components/ide_review.vue30
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue18
-rw-r--r--app/assets/javascripts/ide/components/ide_tree.vue32
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue30
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail.vue7
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue1
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue44
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue18
-rw-r--r--app/assets/javascripts/ide/constants.js6
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue196
-rw-r--r--app/assets/javascripts/incidents/constants.js7
-rw-r--r--app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql15
-rw-r--r--app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql6
-rw-r--r--app/assets/javascripts/incidents/list.js6
-rw-r--r--app/assets/javascripts/incidents_settings/components/alerts_form.vue22
-rw-r--r--app/assets/javascripts/incidents_settings/components/pagerduty_form.vue40
-rw-r--r--app/assets/javascripts/init_issuable_sidebar.js8
-rw-r--r--app/assets/javascripts/integrations/edit/components/confirmation_modal.vue60
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue26
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue224
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue38
-rw-r--r--app/assets/javascripts/invite_members/event_hub.js3
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js25
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_trigger.js20
-rw-r--r--app/assets/javascripts/issuable_context.js1
-rw-r--r--app/assets/javascripts/issuable_create/components/issuable_form.vue1
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description_template.vue11
-rw-r--r--app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue17
-rw-r--r--app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue9
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js2
-rw-r--r--app/assets/javascripts/issue_show/utils/parse_data.js10
-rw-r--r--app/assets/javascripts/jira_import/index.js2
-rw-r--r--app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql1
-rw-r--r--app/assets/javascripts/jira_import/utils/cache_update.js21
-rw-r--r--app/assets/javascripts/jobs/components/commit_block.vue33
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue15
-rw-r--r--app/assets/javascripts/jobs/store/utils.js4
-rw-r--r--app/assets/javascripts/layout_nav.js9
-rw-r--r--app/assets/javascripts/lib/dompurify.js53
-rw-r--r--app/assets/javascripts/lib/graphql.js1
-rw-r--r--app/assets/javascripts/lib/utils/axios_startup_calls.js2
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js1
-rw-r--r--app/assets/javascripts/lib/utils/csrf.js8
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js19
-rw-r--r--app/assets/javascripts/lib/utils/experimentation.js3
-rw-r--r--app/assets/javascripts/lib/utils/highlight.js2
-rw-r--r--app/assets/javascripts/lib/utils/rails_ujs.js20
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js33
-rw-r--r--app/assets/javascripts/main.js13
-rw-r--r--app/assets/javascripts/members.js32
-rw-r--r--app/assets/javascripts/merge_request.js74
-rw-r--r--app/assets/javascripts/merge_request_tabs.js4
-rw-r--r--app/assets/javascripts/milestone.js11
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget_form.vue19
-rw-r--r--app/assets/javascripts/monitoring/components/group_empty_state.vue8
-rw-r--r--app/assets/javascripts/namespaces/leave_by_url.js3
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue2
-rw-r--r--app/assets/javascripts/notebook/cells/output/html.vue2
-rw-r--r--app/assets/javascripts/notes.js45
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue15
-rw-r--r--app/assets/javascripts/notes/components/diff_discussion_header.vue7
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue2
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue105
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue67
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue5
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue3
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue13
-rw-r--r--app/assets/javascripts/notes/components/sort_discussion.vue46
-rw-r--r--app/assets/javascripts/notes/components/toggle_replies_widget.vue10
-rw-r--r--app/assets/javascripts/notes/index.js8
-rw-r--r--app/assets/javascripts/notes/stores/getters.js3
-rw-r--r--app/assets/javascripts/notifications_dropdown.js7
-rw-r--r--app/assets/javascripts/operation_settings/components/metrics_settings.vue8
-rw-r--r--app/assets/javascripts/packages/details/store/getters.js2
-rw-r--r--app/assets/javascripts/packages/list/components/package_title.vue47
-rw-r--r--app/assets/javascripts/packages/list/components/packages_list_app.vue78
-rw-r--r--app/assets/javascripts/packages/list/components/packages_sort.vue2
-rw-r--r--app/assets/javascripts/packages/list/constants.js34
-rw-r--r--app/assets/javascripts/packages/list/utils.js5
-rw-r--r--app/assets/javascripts/packages/shared/components/package_list_row.vue16
-rw-r--r--app/assets/javascripts/packages/shared/components/package_path.vue71
-rw-r--r--app/assets/javascripts/packages/shared/components/publish_method.vue3
-rw-r--r--app/assets/javascripts/pages/admin/instance_statistics/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/keys/index.js5
-rw-r--r--app/assets/javascripts/pages/admin/users/keys/index.js5
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js24
-rw-r--r--app/assets/javascripts/pages/profiles/keys/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js44
-rw-r--r--app/assets/javascripts/pages/projects/clusters/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/commit/pipelines/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js19
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js22
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js18
-rw-r--r--app/assets/javascripts/pages/projects/incidents/show/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue11
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg1
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/tags/index/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/tags/remove_tag.js16
-rw-r--r--app/assets/javascripts/pages/projects/tags/show/index.js10
-rw-r--r--app/assets/javascripts/pages/search/show/index.js3
-rw-r--r--app/assets/javascripts/performance_bar/performance_bar_log.js25
-rw-r--r--app/assets/javascripts/performance_constants.js22
-rw-r--r--app/assets/javascripts/performance_utils.js12
-rw-r--r--app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue113
-rw-r--r--app/assets/javascripts/pipelines/components/dag/constants.js6
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag.vue11
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag_graph.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/graph/action_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue40
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue21
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue166
-rw-r--r--app/assets/javascripts/pipelines/components/legacy_header_component.vue132
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue9
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue7
-rw-r--r--app/assets/javascripts/pipelines/constants.js10
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql30
-rw-r--r--app/assets/javascripts/pipelines/mixins/graph_component_mixin.js54
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js16
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_header.js41
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/utils.js12
-rw-r--r--app/assets/javascripts/profile/account/components/update_username.vue10
-rw-r--r--app/assets/javascripts/profile/profile.js11
-rw-r--r--app/assets/javascripts/project_find_file.js2
-rw-r--r--app/assets/javascripts/projects/commit_box/info/index.js18
-rw-r--r--app/assets/javascripts/projects/commit_box/info/load_branches.js20
-rw-r--r--app/assets/javascripts/projects/default_project_templates.js4
-rw-r--r--app/assets/javascripts/projects/settings/access_dropdown.js136
-rw-r--r--app/assets/javascripts/projects/settings/constants.js7
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue12
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue42
-rw-r--r--app/assets/javascripts/protected_branches/constants.js2
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js4
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue38
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue6
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue3
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue79
-rw-r--r--app/assets/javascripts/registry/explorer/constants/expiration_policies.js4
-rw-r--r--app/assets/javascripts/registry/explorer/pages/details.vue20
-rw-r--r--app/assets/javascripts/registry/settings/components/registry_settings_app.vue50
-rw-r--r--app/assets/javascripts/registry/settings/components/settings_form.vue134
-rw-r--r--app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql8
-rw-r--r--app/assets/javascripts/registry/settings/graphql/index.js14
-rw-r--r--app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql10
-rw-r--r--app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql9
-rw-r--r--app/assets/javascripts/registry/settings/graphql/utils/cache_update.js22
-rw-r--r--app/assets/javascripts/registry/settings/registry_settings_bundle.js12
-rw-r--r--app/assets/javascripts/registry/settings/store/actions.js30
-rw-r--r--app/assets/javascripts/registry/settings/store/getters.js26
-rw-r--r--app/assets/javascripts/registry/settings/store/index.js18
-rw-r--r--app/assets/javascripts/registry/settings/store/mutation_types.js5
-rw-r--r--app/assets/javascripts/registry/settings/store/mutations.js29
-rw-r--r--app/assets/javascripts/registry/settings/store/state.js42
-rw-r--r--app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue32
-rw-r--r--app/assets/javascripts/registry/shared/constants.js24
-rw-r--r--app/assets/javascripts/registry/shared/utils.js27
-rw-r--r--app/assets/javascripts/related_issues/components/add_issuable_form.vue3
-rw-r--r--app/assets/javascripts/related_issues/components/issue_token.vue2
-rw-r--r--app/assets/javascripts/related_issues/components/related_issuable_input.vue3
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue35
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue49
-rw-r--r--app/assets/javascripts/releases/components/app_show.vue6
-rw-r--r--app/assets/javascripts/releases/components/evidence_block.vue6
-rw-r--r--app/assets/javascripts/releases/components/release_block_assets.vue2
-rw-r--r--app/assets/javascripts/releases/components/release_block_header.vue8
-rw-r--r--app/assets/javascripts/releases/components/release_skeleton_loader.vue51
-rw-r--r--app/assets/javascripts/releases/components/releases_pagination_graphql.vue6
-rw-r--r--app/assets/javascripts/releases/components/releases_pagination_rest.vue8
-rw-r--r--app/assets/javascripts/releases/constants.js2
-rw-r--r--app/assets/javascripts/releases/queries/all_releases.query.graphql11
-rw-r--r--app/assets/javascripts/releases/stores/getters.js11
-rw-r--r--app/assets/javascripts/releases/stores/index.js2
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/actions.js3
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/mutation_types.js1
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/mutations.js5
-rw-r--r--app/assets/javascripts/releases/stores/modules/list/actions.js109
-rw-r--r--app/assets/javascripts/releases/stores/modules/list/mutations.js7
-rw-r--r--app/assets/javascripts/releases/stores/modules/list/state.js3
-rw-r--r--app/assets/javascripts/releases/util.js6
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue30
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue5
-rw-r--r--app/assets/javascripts/repository/index.js8
-rw-r--r--app/assets/javascripts/repository/log_tree.js22
-rw-r--r--app/assets/javascripts/right_sidebar.js19
-rw-r--r--app/assets/javascripts/search/components/dropdown_filter.vue111
-rw-r--r--app/assets/javascripts/search/confidential_filter/constants.js28
-rw-r--r--app/assets/javascripts/search/confidential_filter/index.js39
-rw-r--r--app/assets/javascripts/search/state_filter/components/state_filter.vue94
-rw-r--r--app/assets/javascripts/search/state_filter/constants.js4
-rw-r--r--app/assets/javascripts/search/state_filter/index.js27
-rw-r--r--app/assets/javascripts/self_monitor/components/self_monitor_form.vue7
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue12
-rw-r--r--app/assets/javascripts/serverless/components/missing_prometheus.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue20
-rw-r--r--app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue18
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue24
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue107
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue43
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue84
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue64
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewers.vue72
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue107
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue103
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue7
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js135
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js12
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js29
-rw-r--r--app/assets/javascripts/single_file_diff.js10
-rw-r--r--app/assets/javascripts/snippet/snippet_show.js48
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue2
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue1
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_edit.vue2
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_view.vue7
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_edit.vue1
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue2
-rw-r--r--app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql26
-rw-r--r--app/assets/javascripts/snippets/index.js16
-rw-r--r--app/assets/javascripts/snippets/mixins/snippets.js11
-rw-r--r--app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql10
-rw-r--r--app/assets/javascripts/snippets/queries/snippet.query.graphql12
-rw-r--r--app/assets/javascripts/static_site_editor/components/publish_toolbar.vue2
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/index.js2
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql5
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql1
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js17
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js31
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/typedefs.graphql6
-rw-r--r--app/assets/javascripts/static_site_editor/index.js12
-rw-r--r--app/assets/javascripts/static_site_editor/pages/home.vue30
-rw-r--r--app/assets/javascripts/static_site_editor/pages/success.vue46
-rw-r--r--app/assets/javascripts/static_site_editor/services/front_matterify.js73
-rw-r--r--app/assets/javascripts/static_site_editor/services/parse_source_file.js17
-rw-r--r--app/assets/javascripts/static_site_editor/services/submit_content_changes.js18
-rw-r--r--app/assets/javascripts/static_site_editor/services/templater.js2
-rw-r--r--app/assets/javascripts/task_list.js9
-rw-r--r--app/assets/javascripts/tooltips/index.js2
-rw-r--r--app/assets/javascripts/user_lists/components/add_user_modal.vue72
-rw-r--r--app/assets/javascripts/user_lists/components/edit_user_list.vue74
-rw-r--r--app/assets/javascripts/user_lists/components/new_user_list.vue50
-rw-r--r--app/assets/javascripts/user_lists/components/user_list.vue142
-rw-r--r--app/assets/javascripts/user_lists/components/user_list_form.vue97
-rw-r--r--app/assets/javascripts/user_lists/constants/edit.js6
-rw-r--r--app/assets/javascripts/user_lists/constants/show.js8
-rw-r--r--app/assets/javascripts/user_lists/store/edit/actions.js22
-rw-r--r--app/assets/javascripts/user_lists/store/edit/index.js11
-rw-r--r--app/assets/javascripts/user_lists/store/edit/mutation_types.js5
-rw-r--r--app/assets/javascripts/user_lists/store/edit/mutations.js19
-rw-r--r--app/assets/javascripts/user_lists/store/edit/state.js9
-rw-r--r--app/assets/javascripts/user_lists/store/new/actions.js15
-rw-r--r--app/assets/javascripts/user_lists/store/new/index.js11
-rw-r--r--app/assets/javascripts/user_lists/store/new/mutation_types.js3
-rw-r--r--app/assets/javascripts/user_lists/store/new/mutations.js10
-rw-r--r--app/assets/javascripts/user_lists/store/new/state.js5
-rw-r--r--app/assets/javascripts/user_lists/store/show/actions.js32
-rw-r--r--app/assets/javascripts/user_lists/store/show/index.js11
-rw-r--r--app/assets/javascripts/user_lists/store/show/mutation_types.js8
-rw-r--r--app/assets/javascripts/user_lists/store/show/mutations.js29
-rw-r--r--app/assets/javascripts/user_lists/store/show/state.js9
-rw-r--r--app/assets/javascripts/user_lists/store/utils.js5
-rw-r--r--app/assets/javascripts/user_popovers.js2
-rw-r--r--app/assets/javascripts/users_select/index.js38
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue24
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue34
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/alert_details_table.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_modal.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue92
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_mentions.vue55
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue44
-rw-r--r--app/assets/javascripts/vue_shared/components/local_storage_sync.vue41
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue32
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue34
-rw-r--r--app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue32
-rw-r--r--app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue80
-rw-r--r--app/assets/javascripts/vue_shared/components/members/constants.js66
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/created_at.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/expires_at.vue66
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/member_source.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/members_table.vue82
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue50
-rw-r--r--app/assets/javascripts/vue_shared/components/members/utils.js19
-rw-r--r--app/assets/javascripts/vue_shared/components/modal_copy_button.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/navigation_tabs.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/title_area.vue76
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js18
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js22
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/todo_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue12
-rw-r--r--app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js4
-rw-r--r--app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js2
-rw-r--r--app/assets/javascripts/vuex_shared/modules/members/state.js3
-rw-r--r--app/assets/javascripts/whats_new/components/app.vue45
-rw-r--r--app/assets/javascripts/whats_new/index.js1
-rw-r--r--app/assets/javascripts/whats_new/store/actions.js6
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss5
-rw-r--r--app/assets/stylesheets/application.scss20
-rw-r--r--app/assets/stylesheets/application_utilities.scss12
-rw-r--r--app/assets/stylesheets/application_utilities_dark.scss3
-rw-r--r--app/assets/stylesheets/components/design_management/design.scss4
-rw-r--r--app/assets/stylesheets/components/whats_new.scss25
-rw-r--r--app/assets/stylesheets/fontawesome_custom.scss9
-rw-r--r--app/assets/stylesheets/framework/animations.scss3
-rw-r--r--app/assets/stylesheets/framework/buttons.scss6
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss109
-rw-r--r--app/assets/stylesheets/framework/files.scss4
-rw-r--r--app/assets/stylesheets/framework/header.scss12
-rw-r--r--app/assets/stylesheets/framework/icons.scss1
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss4
-rw-r--r--app/assets/stylesheets/framework/typography.scss4
-rw-r--r--app/assets/stylesheets/framework/variables.scss4
-rw-r--r--app/assets/stylesheets/framework/wells.scss6
-rw-r--r--app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss7
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss613
-rw-r--r--app/assets/stylesheets/page_bundles/cycle_analytics.scss355
-rw-r--r--app/assets/stylesheets/page_bundles/issues.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/milestone.scss254
-rw-r--r--app/assets/stylesheets/pages/alert_management/details.scss2
-rw-r--r--app/assets/stylesheets/pages/boards.scss613
-rw-r--r--app/assets/stylesheets/pages/builds.scss11
-rw-r--r--app/assets/stylesheets/pages/commits.scss1
-rw-r--r--app/assets/stylesheets/pages/cycle_analytics.scss353
-rw-r--r--app/assets/stylesheets/pages/diff.scss19
-rw-r--r--app/assets/stylesheets/pages/experimental_separate_sign_up.scss4
-rw-r--r--app/assets/stylesheets/pages/incident_management_list.scss5
-rw-r--r--app/assets/stylesheets/pages/issuable.scss13
-rw-r--r--app/assets/stylesheets/pages/labels.scss70
-rw-r--r--app/assets/stylesheets/pages/members.scss17
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss9
-rw-r--r--app/assets/stylesheets/pages/milestone.scss252
-rw-r--r--app/assets/stylesheets/pages/notes.scss50
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss699
-rw-r--r--app/assets/stylesheets/pages/profile.scss21
-rw-r--r--app/assets/stylesheets/pages/projects.scss7
-rw-r--r--app/assets/stylesheets/pages/settings_ci_cd.scss4
-rw-r--r--app/assets/stylesheets/pages/tags.scss3
-rw-r--r--app/assets/stylesheets/pages/ui_dev_kit.scss17
-rw-r--r--app/assets/stylesheets/themes/_dark.scss2
-rw-r--r--app/assets/stylesheets/utilities.scss15
-rw-r--r--app/channels/application_cable/connection.rb6
-rw-r--r--app/controllers/admin/application_settings_controller.rb3
-rw-r--r--app/controllers/admin/concerns/authenticates_2fa_for_admin_mode.rb110
-rw-r--r--app/controllers/admin/integrations_controller.rb3
-rw-r--r--app/controllers/admin/runners_controller.rb8
-rw-r--r--app/controllers/admin/sessions_controller.rb2
-rw-r--r--app/controllers/admin/users_controller.rb1
-rw-r--r--app/controllers/application_controller.rb2
-rw-r--r--app/controllers/boards/issues_controller.rb2
-rw-r--r--app/controllers/boards/lists_controller.rb4
-rw-r--r--app/controllers/clusters/clusters_controller.rb29
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb21
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb107
-rw-r--r--app/controllers/concerns/boards_actions.rb4
-rw-r--r--app/controllers/concerns/controller_with_feature_category.rb29
-rw-r--r--app/controllers/concerns/controller_with_feature_category/config.rb38
-rw-r--r--app/controllers/concerns/enforces_two_factor_authentication.rb2
-rw-r--r--app/controllers/concerns/integrations_actions.rb2
-rw-r--r--app/controllers/concerns/issuable_collections_action.rb5
-rw-r--r--app/controllers/concerns/milestone_actions.rb14
-rw-r--r--app/controllers/concerns/multiple_boards_actions.rb6
-rw-r--r--app/controllers/concerns/redis_tracking.rb1
-rw-r--r--app/controllers/concerns/runner_setup_scripts.rb25
-rw-r--r--app/controllers/concerns/show_inherited_labels_checker.rb11
-rw-r--r--app/controllers/concerns/wiki_actions.rb2
-rw-r--r--app/controllers/confirmations_controller.rb2
-rw-r--r--app/controllers/dashboard/groups_controller.rb2
-rw-r--r--app/controllers/dashboard/labels_controller.rb2
-rw-r--r--app/controllers/dashboard/milestones_controller.rb2
-rw-r--r--app/controllers/dashboard/projects_controller.rb2
-rw-r--r--app/controllers/dashboard/snippets_controller.rb2
-rw-r--r--app/controllers/dashboard/todos_controller.rb2
-rw-r--r--app/controllers/dashboard_controller.rb4
-rw-r--r--app/controllers/explore/groups_controller.rb2
-rw-r--r--app/controllers/explore/projects_controller.rb2
-rw-r--r--app/controllers/explore/snippets_controller.rb2
-rw-r--r--app/controllers/groups/group_links_controller.rb9
-rw-r--r--app/controllers/groups/labels_controller.rb28
-rw-r--r--app/controllers/groups/milestones_controller.rb2
-rw-r--r--app/controllers/groups/registry/repositories_controller.rb4
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb6
-rw-r--r--app/controllers/import/bulk_imports_controller.rb51
-rw-r--r--app/controllers/import/fogbugz_controller.rb2
-rw-r--r--app/controllers/import/github_controller.rb2
-rw-r--r--app/controllers/import/manifest_controller.rb11
-rw-r--r--app/controllers/invites_controller.rb57
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb3
-rw-r--r--app/controllers/projects/blob_controller.rb3
-rw-r--r--app/controllers/projects/ci/daily_build_group_report_results_controller.rb7
-rw-r--r--app/controllers/projects/graphs_controller.rb1
-rw-r--r--app/controllers/projects/group_links_controller.rb13
-rw-r--r--app/controllers/projects/incidents_controller.rb46
-rw-r--r--app/controllers/projects/issues_controller.rb4
-rw-r--r--app/controllers/projects/jobs_controller.rb5
-rw-r--r--app/controllers/projects/labels_controller.rb3
-rw-r--r--app/controllers/projects/merge_requests/application_controller.rb1
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests_controller.rb23
-rw-r--r--app/controllers/projects/milestones_controller.rb4
-rw-r--r--app/controllers/projects/packages/packages_controller.rb9
-rw-r--r--app/controllers/projects/pipelines_controller.rb3
-rw-r--r--app/controllers/projects/protected_refs_controller.rb2
-rw-r--r--app/controllers/projects/registry/repositories_controller.rb6
-rw-r--r--app/controllers/projects/registry/tags_controller.rb9
-rw-r--r--app/controllers/projects/releases_controller.rb2
-rw-r--r--app/controllers/projects/runners_controller.rb4
-rw-r--r--app/controllers/projects/services_controller.rb2
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb13
-rw-r--r--app/controllers/projects/settings/repository_controller.rb2
-rw-r--r--app/controllers/projects/static_site_editor_controller.rb20
-rw-r--r--app/controllers/projects/tags_controller.rb23
-rw-r--r--app/controllers/runner_setup_controller.rb7
-rw-r--r--app/controllers/search_controller.rb5
-rw-r--r--app/controllers/uploads_controller.rb1
-rw-r--r--app/controllers/users_controller.rb2
-rw-r--r--app/finders/group_labels_finder.rb29
-rw-r--r--app/finders/group_members_finder.rb28
-rw-r--r--app/finders/groups_finder.rb12
-rw-r--r--app/finders/merge_requests/by_approvals_finder.rb93
-rw-r--r--app/finders/merge_requests_finder.rb16
-rw-r--r--app/finders/packages/generic/package_finder.rb22
-rw-r--r--app/graphql/mutations/base_mutation.rb1
-rw-r--r--app/graphql/mutations/boards/lists/destroy.rb41
-rw-r--r--app/graphql/mutations/ci/base.rb7
-rw-r--r--app/graphql/mutations/design_management/move.rb11
-rw-r--r--app/graphql/mutations/metrics/dashboard/annotations/create.rb2
-rw-r--r--app/graphql/resolvers/base_resolver.rb1
-rw-r--r--app/graphql/resolvers/board_list_issues_resolver.rb2
-rw-r--r--app/graphql/resolvers/board_lists_resolver.rb2
-rw-r--r--app/graphql/resolvers/board_resolver.rb27
-rw-r--r--app/graphql/resolvers/boards_resolver.rb2
-rw-r--r--app/graphql/resolvers/concerns/issue_resolver_arguments.rb6
-rw-r--r--app/graphql/resolvers/concerns/looks_ahead.rb4
-rw-r--r--app/graphql/resolvers/concerns/resolves_merge_requests.rb2
-rw-r--r--app/graphql/resolvers/projects_resolver.rb7
-rw-r--r--app/graphql/resolvers/snippets/blobs_resolver.rb40
-rw-r--r--app/graphql/resolvers/terraform/states_resolver.rb23
-rw-r--r--app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb4
-rw-r--r--app/graphql/types/design_management/design_collection_copy_state_enum.rb27
-rw-r--r--app/graphql/types/design_management/design_collection_type.rb4
-rw-r--r--app/graphql/types/global_id_type.rb16
-rw-r--r--app/graphql/types/group_type.rb2
-rw-r--r--app/graphql/types/issue_sort_enum.rb2
-rw-r--r--app/graphql/types/issue_type.rb21
-rw-r--r--app/graphql/types/merge_request_type.rb4
-rw-r--r--app/graphql/types/mutation_type.rb2
-rw-r--r--app/graphql/types/notes/noteable_type.rb32
-rw-r--r--app/graphql/types/project_member_type.rb7
-rw-r--r--app/graphql/types/project_type.rb8
-rw-r--r--app/graphql/types/query_type.rb15
-rw-r--r--app/graphql/types/snippet_type.rb19
-rw-r--r--app/graphql/types/sort_enum.rb15
-rw-r--r--app/graphql/types/terraform/state_type.rb37
-rw-r--r--app/helpers/analytics/navbar_helper.rb2
-rw-r--r--app/helpers/analytics/unique_visits_helper.rb3
-rw-r--r--app/helpers/application_helper.rb18
-rw-r--r--app/helpers/application_settings_helper.rb9
-rw-r--r--app/helpers/avatars_helper.rb2
-rw-r--r--app/helpers/boards_helper.rb1
-rw-r--r--app/helpers/clusters_helper.rb20
-rw-r--r--app/helpers/emails_helper.rb20
-rw-r--r--app/helpers/events_helper.rb16
-rw-r--r--app/helpers/feature_flags_helper.rb19
-rw-r--r--app/helpers/gitlab_routing_helper.rb12
-rw-r--r--app/helpers/gitpod_helper.rb10
-rw-r--r--app/helpers/groups/group_members_helper.rb21
-rw-r--r--app/helpers/invite_members_helper.rb7
-rw-r--r--app/helpers/issuables_helper.rb19
-rw-r--r--app/helpers/issues_helper.rb15
-rw-r--r--app/helpers/labels_helper.rb4
-rw-r--r--app/helpers/merge_requests_helper.rb4
-rw-r--r--app/helpers/namespaces_helper.rb2
-rw-r--r--app/helpers/nav_helper.rb3
-rw-r--r--app/helpers/packages_helper.rb10
-rw-r--r--app/helpers/page_layout_helper.rb8
-rw-r--r--app/helpers/preferences_helper.rb4
-rw-r--r--app/helpers/profiles_helper.rb15
-rw-r--r--app/helpers/projects/incidents_helper.rb7
-rw-r--r--app/helpers/projects_helper.rb17
-rw-r--r--app/helpers/search_helper.rb14
-rw-r--r--app/helpers/services_helper.rb4
-rw-r--r--app/helpers/suggest_pipeline_helper.rb2
-rw-r--r--app/helpers/system_note_helper.rb6
-rw-r--r--app/helpers/tags_helper.rb9
-rw-r--r--app/helpers/timeboxes_helper.rb4
-rw-r--r--app/helpers/todos_helper.rb9
-rw-r--r--app/helpers/tree_helper.rb10
-rw-r--r--app/helpers/user_callouts_helper.rb5
-rw-r--r--app/helpers/whats_new_helper.rb22
-rw-r--r--app/mailers/abuse_report_mailer.rb4
-rw-r--r--app/mailers/emails/members.rb9
-rw-r--r--app/mailers/emails/merge_requests.rb11
-rw-r--r--app/mailers/emails/projects.rb10
-rw-r--r--app/models/alert_management/alert.rb2
-rw-r--r--app/models/alert_management/http_integration.rb41
-rw-r--r--app/models/analytics/instance_statistics/measurement.rb22
-rw-r--r--app/models/application_setting.rb2
-rw-r--r--app/models/application_setting/term.rb2
-rw-r--r--app/models/audit_event.rb13
-rw-r--r--app/models/authentication_event.rb10
-rw-r--r--app/models/ci/build.rb18
-rw-r--r--app/models/ci/build_pending_state.rb6
-rw-r--r--app/models/ci/build_trace_chunk.rb38
-rw-r--r--app/models/ci/build_trace_chunks/database.rb2
-rw-r--r--app/models/ci/pipeline.rb7
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/clusters/platforms/kubernetes.rb3
-rw-r--r--app/models/commit.rb32
-rw-r--r--app/models/commit_status.rb9
-rw-r--r--app/models/concerns/approvable_base.rb24
-rw-r--r--app/models/concerns/avatarable.rb15
-rw-r--r--app/models/concerns/checksummable.rb8
-rw-r--r--app/models/concerns/counter_attribute.rb29
-rw-r--r--app/models/concerns/integration.rb4
-rw-r--r--app/models/concerns/issuable.rb11
-rw-r--r--app/models/concerns/issue_available_features.rb23
-rw-r--r--app/models/concerns/mentionable.rb16
-rw-r--r--app/models/concerns/reactive_caching.rb3
-rw-r--r--app/models/concerns/reactive_service.rb1
-rw-r--r--app/models/concerns/referable.rb2
-rw-r--r--app/models/concerns/relative_positioning.rb6
-rw-r--r--app/models/concerns/update_project_statistics.rb5
-rw-r--r--app/models/container_repository.rb8
-rw-r--r--app/models/data_list.rb10
-rw-r--r--app/models/design_management/design.rb10
-rw-r--r--app/models/design_management/design_collection.rb1
-rw-r--r--app/models/environment.rb1
-rw-r--r--app/models/event.rb14
-rw-r--r--app/models/group.rb109
-rw-r--r--app/models/group_import_state.rb3
-rw-r--r--app/models/issuable_severity.rb7
-rw-r--r--app/models/issue.rb17
-rw-r--r--app/models/issue_email_participant.rb13
-rw-r--r--app/models/iteration.rb16
-rw-r--r--app/models/member.rb13
-rw-r--r--app/models/merge_request.rb28
-rw-r--r--app/models/merge_request_context_commit.rb4
-rw-r--r--app/models/merge_request_diff.rb6
-rw-r--r--app/models/namespace.rb50
-rw-r--r--app/models/note.rb2
-rw-r--r--app/models/notification_reason.rb2
-rw-r--r--app/models/notification_recipient.rb2
-rw-r--r--app/models/notification_setting.rb1
-rw-r--r--app/models/operations/feature_flags/strategy.rb33
-rw-r--r--app/models/packages/event.rb26
-rw-r--r--app/models/packages/package.rb12
-rw-r--r--app/models/pages_deployment.rb4
-rw-r--r--app/models/postgresql/replication_slot.rb4
-rw-r--r--app/models/project.rb33
-rw-r--r--app/models/project_services/drone_ci_service.rb4
-rw-r--r--app/models/project_statistics.rb31
-rw-r--r--app/models/project_tracing_setting.rb15
-rw-r--r--app/models/prometheus_alert.rb2
-rw-r--r--app/models/prometheus_metric.rb2
-rw-r--r--app/models/repository.rb4
-rw-r--r--app/models/resource_label_event.rb11
-rw-r--r--app/models/resource_state_event.rb25
-rw-r--r--app/models/resource_timebox_event.rb15
-rw-r--r--app/models/resource_weight_event.rb10
-rw-r--r--app/models/service.rb23
-rw-r--r--app/models/service_list.rb12
-rw-r--r--app/models/snippet_input_action_collection.rb6
-rw-r--r--app/models/snippet_repository.rb2
-rw-r--r--app/models/snippet_statistics.rb2
-rw-r--r--app/models/system_note_metadata.rb4
-rw-r--r--app/models/terraform/state.rb13
-rw-r--r--app/models/terraform/state_version.rb6
-rw-r--r--app/models/todo.rb2
-rw-r--r--app/models/user.rb18
-rw-r--r--app/models/user_callout.rb2
-rw-r--r--app/models/user_interacted_project.rb2
-rw-r--r--app/models/user_preference.rb3
-rw-r--r--app/models/wiki.rb4
-rw-r--r--app/models/wiki_directory.rb39
-rw-r--r--app/models/wiki_page.rb23
-rw-r--r--app/policies/base_policy.rb5
-rw-r--r--app/policies/group_policy.rb37
-rw-r--r--app/policies/issue_policy.rb9
-rw-r--r--app/policies/project_policy.rb12
-rw-r--r--app/policies/releases/evidence_policy.rb1
-rw-r--r--app/policies/terraform/state_policy.rb9
-rw-r--r--app/presenters/alert_management/alert_presenter.rb33
-rw-r--r--app/presenters/event_presenter.rb22
-rw-r--r--app/presenters/merge_request_presenter.rb2
-rw-r--r--app/presenters/project_presenter.rb6
-rw-r--r--app/presenters/projects/prometheus/alert_presenter.rb179
-rw-r--r--app/presenters/sentry_error_presenter.rb2
-rw-r--r--app/presenters/snippet_presenter.rb10
-rw-r--r--app/serializers/ci/trigger_entity.rb42
-rw-r--r--app/serializers/ci/trigger_serializer.rb7
-rw-r--r--app/serializers/cluster_entity.rb2
-rw-r--r--app/serializers/cluster_serializer.rb1
-rw-r--r--app/serializers/container_repository_entity.rb1
-rw-r--r--app/serializers/diff_file_base_entity.rb14
-rw-r--r--app/serializers/diffs_entity.rb4
-rw-r--r--app/serializers/feature_flag_entity.rb48
-rw-r--r--app/serializers/feature_flag_scope_entity.rb12
-rw-r--r--app/serializers/feature_flag_serializer.rb10
-rw-r--r--app/serializers/feature_flag_summary_entity.rb19
-rw-r--r--app/serializers/feature_flag_summary_serializer.rb5
-rw-r--r--app/serializers/feature_flags/scope_entity.rb8
-rw-r--r--app/serializers/feature_flags/strategy_entity.rb11
-rw-r--r--app/serializers/feature_flags/user_list_entity.rb10
-rw-r--r--app/serializers/feature_flags_client_entity.rb7
-rw-r--r--app/serializers/feature_flags_client_serializer.rb9
-rw-r--r--app/serializers/group_group_link_entity.rb20
-rw-r--r--app/serializers/merge_request_basic_entity.rb1
-rw-r--r--app/serializers/merge_request_poll_cached_widget_entity.rb12
-rw-r--r--app/serializers/merge_request_poll_widget_entity.rb16
-rw-r--r--app/serializers/merge_request_widget_entity.rb6
-rw-r--r--app/serializers/paginated_diff_entity.rb2
-rw-r--r--app/serializers/pipeline_serializer.rb10
-rw-r--r--app/serializers/test_case_entity.rb1
-rw-r--r--app/services/admin/propagate_integration_service.rb52
-rw-r--r--app/services/admin/propagate_service_template.rb6
-rw-r--r--app/services/alert_management/process_prometheus_alert_service.rb2
-rw-r--r--app/services/audit_event_service.rb21
-rw-r--r--app/services/boards/create_service.rb18
-rw-r--r--app/services/boards/lists/destroy_service.rb8
-rw-r--r--app/services/bulk_create_integration_service.rb59
-rw-r--r--app/services/bulk_update_integration_service.rb32
-rw-r--r--app/services/ci/create_job_artifacts_service.rb9
-rw-r--r--app/services/ci/create_pipeline_service.rb3
-rw-r--r--app/services/ci/daily_build_group_report_result_service.rb2
-rw-r--r--app/services/ci/expire_pipeline_cache_service.rb13
-rw-r--r--app/services/ci/pipelines/create_artifact_service.rb1
-rw-r--r--app/services/ci/process_pipeline_service.rb4
-rw-r--r--app/services/ci/retry_build_service.rb2
-rw-r--r--app/services/ci/update_build_queue_service.rb4
-rw-r--r--app/services/ci/update_build_state_service.rb118
-rw-r--r--app/services/concerns/admin/propagate_service.rb51
-rw-r--r--app/services/design_management/copy_design_collection.rb6
-rw-r--r--app/services/design_management/copy_design_collection/copy_service.rb306
-rw-r--r--app/services/design_management/copy_design_collection/queue_service.rb42
-rw-r--r--app/services/design_management/design_service.rb1
-rw-r--r--app/services/design_management/generate_image_versions_service.rb3
-rw-r--r--app/services/design_management/runs_design_actions.rb11
-rw-r--r--app/services/design_management/save_designs_service.rb9
-rw-r--r--app/services/feature_flags/base_service.rb55
-rw-r--r--app/services/feature_flags/create_service.rb52
-rw-r--r--app/services/feature_flags/destroy_service.rb33
-rw-r--r--app/services/feature_flags/disable_service.rb46
-rw-r--r--app/services/feature_flags/enable_service.rb93
-rw-r--r--app/services/feature_flags/update_service.rb87
-rw-r--r--app/services/git/branch_hooks_service.rb8
-rw-r--r--app/services/git/wiki_push_service.rb4
-rw-r--r--app/services/groups/create_service.rb18
-rw-r--r--app/services/groups/import_export/import_service.rb2
-rw-r--r--app/services/groups/transfer_service.rb14
-rw-r--r--app/services/groups/update_service.rb13
-rw-r--r--app/services/groups/update_shared_runners_service.rb32
-rw-r--r--app/services/incident_management/incidents/create_service.rb6
-rw-r--r--app/services/incident_management/incidents/update_severity_service.rb38
-rw-r--r--app/services/incident_management/pager_duty/process_webhook_service.rb2
-rw-r--r--app/services/issuable/clone/attributes_rewriter.rb2
-rw-r--r--app/services/issuable_base_service.rb3
-rw-r--r--app/services/issues/move_service.rb25
-rw-r--r--app/services/lfs/push_service.rb16
-rw-r--r--app/services/members/invitation_reminder_email_service.rb54
-rw-r--r--app/services/merge_requests/base_service.rb9
-rw-r--r--app/services/merge_requests/export_csv_service.rb50
-rw-r--r--app/services/merge_requests/ff_merge_service.rb2
-rw-r--r--app/services/merge_requests/merge_service.rb2
-rw-r--r--app/services/merge_requests/mergeability_check_service.rb2
-rw-r--r--app/services/merge_requests/refresh_service.rb2
-rw-r--r--app/services/merge_requests/update_service.rb2
-rw-r--r--app/services/metrics/dashboard/custom_dashboard_service.rb6
-rw-r--r--app/services/namespace_settings/update_service.rb25
-rw-r--r--app/services/notification_recipients/builder/default.rb3
-rw-r--r--app/services/notification_service.rb27
-rw-r--r--app/services/packages/create_event_service.rb37
-rw-r--r--app/services/packages/create_package_service.rb1
-rw-r--r--app/services/packages/generic/create_package_file_service.rb38
-rw-r--r--app/services/packages/generic/find_or_create_package_service.rb15
-rw-r--r--app/services/pod_logs/base_service.rb2
-rw-r--r--app/services/pod_logs/elasticsearch_service.rb1
-rw-r--r--app/services/pod_logs/kubernetes_service.rb1
-rw-r--r--app/services/projects/alerting/notify_service.rb75
-rw-r--r--app/services/projects/container_repository/cleanup_tags_service.rb5
-rw-r--r--app/services/projects/container_repository/delete_tags_service.rb9
-rw-r--r--app/services/projects/create_service.rb15
-rw-r--r--app/services/projects/prometheus/alerts/notify_service.rb14
-rw-r--r--app/services/projects/transfer_service.rb4
-rw-r--r--app/services/projects/update_remote_mirror_service.rb10
-rw-r--r--app/services/projects/update_service.rb4
-rw-r--r--app/services/quick_actions/target_service.rb4
-rw-r--r--app/services/resource_access_tokens/create_service.rb13
-rw-r--r--app/services/search/global_service.rb10
-rw-r--r--app/services/search/group_service.rb2
-rw-r--r--app/services/search/project_service.rb18
-rw-r--r--app/services/snippets/base_service.rb18
-rw-r--r--app/services/snippets/create_service.rb9
-rw-r--r--app/services/snippets/update_service.rb39
-rw-r--r--app/services/spam/spam_action_service.rb2
-rw-r--r--app/services/static_site_editor/config_service.rb66
-rw-r--r--app/services/system_note_service.rb8
-rw-r--r--app/services/system_notes/incident_service.rb29
-rw-r--r--app/services/system_notes/issuables_service.rb61
-rw-r--r--app/services/users/build_service.rb3
-rw-r--r--app/uploaders/pages/deployment_uploader.rb23
-rw-r--r--app/validators/addressable_url_validator.rb2
-rw-r--r--app/validators/ip_address_validator.rb39
-rw-r--r--app/views/admin/application_settings/_abuse.html.haml4
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml3
-rw-r--r--app/views/admin/application_settings/_diff_limits.html.haml3
-rw-r--r--app/views/admin/application_settings/_external_authorization_service_form.html.haml3
-rw-r--r--app/views/admin/application_settings/_gitpod.html.haml3
-rw-r--r--app/views/admin/application_settings/_initial_branch_name.html.haml3
-rw-r--r--app/views/admin/application_settings/_repository_check.html.haml6
-rw-r--r--app/views/admin/application_settings/_repository_mirrors_form.html.haml3
-rw-r--r--app/views/admin/application_settings/_repository_static_objects.html.haml3
-rw-r--r--app/views/admin/application_settings/_repository_storage.html.haml3
-rw-r--r--app/views/admin/application_settings/_signin.html.haml3
-rw-r--r--app/views/admin/application_settings/_signup.html.haml11
-rw-r--r--app/views/admin/application_settings/_terminal.html.haml3
-rw-r--r--app/views/admin/application_settings/_terms.html.haml3
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml3
-rw-r--r--app/views/admin/application_settings/general.html.haml3
-rw-r--r--app/views/admin/broadcast_messages/_form.html.haml6
-rw-r--r--app/views/admin/dashboard/_billable_users_text.html.haml1
-rw-r--r--app/views/admin/dashboard/index.html.haml10
-rw-r--r--app/views/admin/dashboard/stats.html.haml2
-rw-r--r--app/views/admin/groups/_form.html.haml2
-rw-r--r--app/views/admin/groups/show.html.haml2
-rw-r--r--app/views/admin/hook_logs/show.html.haml2
-rw-r--r--app/views/admin/hooks/edit.html.haml4
-rw-r--r--app/views/admin/hooks/index.html.haml2
-rw-r--r--app/views/admin/labels/_form.html.haml4
-rw-r--r--app/views/admin/labels/_label.html.haml4
-rw-r--r--app/views/admin/labels/index.html.haml2
-rw-r--r--app/views/admin/projects/show.html.haml5
-rw-r--r--app/views/admin/users/_modals.html.haml2
-rw-r--r--app/views/admin/users/_user.html.haml7
-rw-r--r--app/views/admin/users/index.html.haml3
-rw-r--r--app/views/clusters/clusters/_advanced_settings.html.haml21
-rw-r--r--app/views/clusters/clusters/_buttons.html.haml6
-rw-r--r--app/views/clusters/clusters/_cluster.html.haml19
-rw-r--r--app/views/clusters/clusters/_cluster_list.html.haml12
-rw-r--r--app/views/clusters/clusters/_empty_state.html.haml8
-rw-r--r--app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml18
-rw-r--r--app/views/clusters/clusters/_provider_details_form.html.haml12
-rw-r--r--app/views/clusters/clusters/aws/_new.html.haml1
-rw-r--r--app/views/clusters/clusters/gcp/_form.html.haml8
-rw-r--r--app/views/clusters/clusters/index.html.haml46
-rw-r--r--app/views/clusters/clusters/user/_form.html.haml8
-rw-r--r--app/views/dashboard/milestones/index.html.haml1
-rw-r--r--app/views/dashboard/todos/_todo.html.haml2
-rw-r--r--app/views/dashboard/todos/index.html.haml2
-rw-r--r--app/views/devise/mailer/confirmation_instructions.text.erb2
-rw-r--r--app/views/discussions/_diff_with_notes.html.haml2
-rw-r--r--app/views/discussions/_jump_to_next.html.haml9
-rw-r--r--app/views/discussions/_new_issue_for_all_discussions.html.haml8
-rw-r--r--app/views/discussions/_new_issue_for_discussion.html.haml10
-rw-r--r--app/views/discussions/_notes.html.haml22
-rw-r--r--app/views/events/event/_common.html.haml2
-rw-r--r--app/views/groups/_invite_members_modal.html.haml6
-rw-r--r--app/views/groups/_invite_members_side_nav_link.html.haml3
-rw-r--r--app/views/groups/issues.html.haml5
-rw-r--r--app/views/groups/labels/destroy.js.haml2
-rw-r--r--app/views/groups/labels/index.html.haml4
-rw-r--r--app/views/groups/milestones/index.html.haml1
-rw-r--r--app/views/groups/milestones/show.html.haml1
-rw-r--r--app/views/groups/registry/repositories/index.html.haml2
-rw-r--r--app/views/groups/settings/_advanced.html.haml6
-rw-r--r--app/views/groups/settings/_export.html.haml5
-rw-r--r--app/views/groups/settings/_general.html.haml3
-rw-r--r--app/views/groups/settings/_permanent_deletion.html.haml3
-rw-r--r--app/views/groups/settings/_permissions.html.haml3
-rw-r--r--app/views/groups/show.html.haml2
-rw-r--r--app/views/ide/_show.html.haml3
-rw-r--r--app/views/import/bulk_imports/status.html.haml1
-rw-r--r--app/views/import/shared/_errors.html.haml8
-rw-r--r--app/views/invites/decline.html.haml8
-rw-r--r--app/views/jira_connect/subscriptions/index.html.haml2
-rw-r--r--app/views/layouts/_head.html.haml22
-rw-r--r--app/views/layouts/_page.html.haml1
-rw-r--r--app/views/layouts/_startup_css.haml2
-rw-r--r--app/views/layouts/_startup_css_activation.haml1
-rw-r--r--app/views/layouts/group.html.haml4
-rw-r--r--app/views/layouts/header/_default.html.haml6
-rw-r--r--app/views/layouts/header/_new_dropdown.haml2
-rw-r--r--app/views/layouts/jira_connect.html.haml1
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml10
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml9
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml10
-rw-r--r--app/views/layouts/project.html.haml4
-rw-r--r--app/views/notify/_failed_builds.html.haml2
-rw-r--r--app/views/notify/autodevops_disabled_email.html.haml2
-rw-r--r--app/views/notify/autodevops_disabled_email.text.erb2
-rw-r--r--app/views/notify/changed_reviewer_of_merge_request_email.html.haml2
-rw-r--r--app/views/notify/changed_reviewer_of_merge_request_email.text.erb1
-rw-r--r--app/views/notify/issue_status_changed_email.text.erb1
-rw-r--r--app/views/notify/pipeline_failed_email.html.haml2
-rw-r--r--app/views/notify/pipeline_failed_email.text.erb2
-rw-r--r--app/views/notify/prometheus_alert_fired_email.html.haml7
-rw-r--r--app/views/notify/prometheus_alert_fired_email.text.erb6
-rw-r--r--app/views/profiles/keys/_form.html.haml6
-rw-r--r--app/views/profiles/keys/_key.html.haml11
-rw-r--r--app/views/profiles/keys/_key_details.html.haml2
-rw-r--r--app/views/profiles/preferences/_gitpod.html.haml4
-rw-r--r--app/views/profiles/preferences/show.html.haml2
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml13
-rw-r--r--app/views/projects/_export.html.haml5
-rw-r--r--app/views/projects/_files.html.haml2
-rw-r--r--app/views/projects/_stat_anchor_list.html.haml3
-rw-r--r--app/views/projects/_visibility_modal.html.haml2
-rw-r--r--app/views/projects/artifacts/_artifact.html.haml2
-rw-r--r--app/views/projects/blob/_content.html.haml4
-rw-r--r--app/views/projects/blob/_editor.html.haml2
-rw-r--r--app/views/projects/blob/_new_dir.html.haml4
-rw-r--r--app/views/projects/blob/_remove.html.haml4
-rw-r--r--app/views/projects/blob/_upload.html.haml6
-rw-r--r--app/views/projects/blob/_viewer_switcher.html.haml4
-rw-r--r--app/views/projects/blob/edit.html.haml3
-rw-r--r--app/views/projects/buttons/_fork.html.haml4
-rw-r--r--app/views/projects/buttons/_remove_tag.html.haml6
-rw-r--r--app/views/projects/buttons/_star.html.haml6
-rw-r--r--app/views/projects/ci/builds/_build.html.haml12
-rw-r--r--app/views/projects/ci/lints/show.html.haml6
-rw-r--r--app/views/projects/cleanup/_show.html.haml3
-rw-r--r--app/views/projects/commit/_commit_box.html.haml4
-rw-r--r--app/views/projects/commits/_commits.html.haml3
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml1
-rw-r--r--app/views/projects/default_branch/_show.html.haml3
-rw-r--r--app/views/projects/deployments/_commit.html.haml2
-rw-r--r--app/views/projects/diffs/_file_header.html.haml2
-rw-r--r--app/views/projects/diffs/_line.html.haml3
-rw-r--r--app/views/projects/diffs/_parallel_view.html.haml6
-rw-r--r--app/views/projects/diffs/_stats.html.haml4
-rw-r--r--app/views/projects/diffs/_text_file.html.haml2
-rw-r--r--app/views/projects/edit.html.haml21
-rw-r--r--app/views/projects/empty.html.haml2
-rw-r--r--app/views/projects/environments/show.html.haml6
-rw-r--r--app/views/projects/feature_flags/_errors.html.haml4
-rw-r--r--app/views/projects/feature_flags/edit.html.haml2
-rw-r--r--app/views/projects/forks/_fork_button.html.haml4
-rw-r--r--app/views/projects/forks/index.html.haml4
-rw-r--r--app/views/projects/group_links/update.js.haml4
-rw-r--r--app/views/projects/hook_logs/show.html.haml2
-rw-r--r--app/views/projects/hooks/edit.html.haml4
-rw-r--r--app/views/projects/hooks/index.html.haml3
-rw-r--r--app/views/projects/incidents/_new_branch.html.haml1
-rw-r--r--app/views/projects/incidents/index.html.haml2
-rw-r--r--app/views/projects/incidents/show.html.haml1
-rw-r--r--app/views/projects/issues/_discussion.html.haml1
-rw-r--r--app/views/projects/issues/_issues.html.haml6
-rw-r--r--app/views/projects/issues/_new_branch.html.haml2
-rw-r--r--app/views/projects/issues/index.html.haml1
-rw-r--r--app/views/projects/issues/show.html.haml12
-rw-r--r--app/views/projects/jobs/show.html.haml4
-rw-r--r--app/views/projects/labels/index.html.haml4
-rw-r--r--app/views/projects/merge_requests/_description.html.haml3
-rw-r--r--app/views/projects/merge_requests/_discussion.html.haml11
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml9
-rw-r--r--app/views/projects/merge_requests/diffs/_commit_widget.html.haml11
-rw-r--r--app/views/projects/merge_requests/diffs/_different_base.html.haml11
-rw-r--r--app/views/projects/merge_requests/diffs/_diffs.html.haml21
-rw-r--r--app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml17
-rw-r--r--app/views/projects/merge_requests/diffs/_version_controls.html.haml73
-rw-r--r--app/views/projects/merge_requests/invalid.html.haml15
-rw-r--r--app/views/projects/merge_requests/show.html.haml5
-rw-r--r--app/views/projects/milestones/index.html.haml1
-rw-r--r--app/views/projects/milestones/show.html.haml3
-rw-r--r--app/views/projects/milestones/update.js.haml2
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml2
-rw-r--r--app/views/projects/notes/_actions.html.haml2
-rw-r--r--app/views/projects/pages/_destroy.haml2
-rw-r--r--app/views/projects/pages/_list.html.haml4
-rw-r--r--app/views/projects/pages/show.html.haml2
-rw-r--r--app/views/projects/pages_domains/_form.html.haml3
-rw-r--r--app/views/projects/pipeline_schedules/index.html.haml2
-rw-r--r--app/views/projects/pipelines/_info.html.haml4
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml2
-rw-r--r--app/views/projects/pipelines/new.html.haml2
-rw-r--r--app/views/projects/pipelines/show.html.haml3
-rw-r--r--app/views/projects/project_members/import.html.haml4
-rw-r--r--app/views/projects/protected_branches/_create_protected_branch.html.haml4
-rw-r--r--app/views/projects/protected_tags/shared/_create_protected_tag.html.haml2
-rw-r--r--app/views/projects/registry/repositories/index.html.haml3
-rw-r--r--app/views/projects/registry/settings/_index.haml1
-rw-r--r--app/views/projects/releases/show.html.haml1
-rw-r--r--app/views/projects/services/prometheus/_configuration_banner.html.haml4
-rw-r--r--app/views/projects/services/prometheus/_custom_metrics.html.haml2
-rw-r--r--app/views/projects/settings/_archive.html.haml7
-rw-r--r--app/views/projects/settings/_general.html.haml3
-rw-r--r--app/views/projects/settings/operations/_alert_management.html.haml2
-rw-r--r--app/views/projects/tags/_tag.html.haml8
-rw-r--r--app/views/projects/tags/destroy.js.haml4
-rw-r--r--app/views/projects/tags/show.html.haml3
-rw-r--r--app/views/projects/triggers/_index.html.haml35
-rw-r--r--app/views/projects/triggers/_trigger.html.haml4
-rw-r--r--app/views/search/_category.html.haml1
-rw-r--r--app/views/search/_results.html.haml10
-rw-r--r--app/views/search/results/_blob.html.haml2
-rw-r--r--app/views/search/results/_filters.html.haml7
-rw-r--r--app/views/shared/_confirm_modal.html.haml2
-rw-r--r--app/views/shared/_label.html.haml2
-rw-r--r--app/views/shared/_label_row.html.haml37
-rw-r--r--app/views/shared/boards/_show.html.haml1
-rw-r--r--app/views/shared/deploy_keys/_project_group_form.html.haml2
-rw-r--r--app/views/shared/deploy_tokens/_form.html.haml2
-rw-r--r--app/views/shared/icons/_next_discussion.svg1
-rw-r--r--app/views/shared/issuable/_approved_by_dropdown.html.haml16
-rw-r--r--app/views/shared/issuable/_close_reopen_button.html.haml7
-rw-r--r--app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml37
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml15
-rw-r--r--app/views/shared/issuable/_sidebar_reviewers.html.haml56
-rw-r--r--app/views/shared/issuable/form/_type_selector.html.haml2
-rw-r--r--app/views/shared/labels/_form.html.haml6
-rw-r--r--app/views/shared/labels/_nav.html.haml6
-rw-r--r--app/views/shared/members/_group.html.haml11
-rw-r--r--app/views/shared/members/_member.html.haml2
-rw-r--r--app/views/shared/milestones/_issuable.html.haml2
-rw-r--r--app/views/shared/milestones/_issuables.html.haml2
-rw-r--r--app/views/shared/milestones/_issues_tab.html.haml3
-rw-r--r--app/views/shared/milestones/_merge_requests_tab.haml3
-rw-r--r--app/views/shared/milestones/_milestone.html.haml4
-rw-r--r--app/views/shared/milestones/_tabs.html.haml17
-rw-r--r--app/views/shared/notes/_edit.html.haml2
-rw-r--r--app/views/shared/notifications/_button.html.haml2
-rw-r--r--app/views/shared/snippets/_form.html.haml16
-rw-r--r--app/views/shared/snippets/_header.html.haml8
-rw-r--r--app/views/shared/snippets/_snippet.html.haml2
-rw-r--r--app/views/shared/wikis/_pages_wiki_page.html.haml2
-rw-r--r--app/views/shared/wikis/_wiki_directory.html.haml4
-rw-r--r--app/views/users/show.html.haml2
-rw-r--r--app/workers/all_queues.yml56
-rw-r--r--app/workers/analytics/instance_statistics/count_job_trigger_worker.rb3
-rw-r--r--app/workers/authorized_project_update/periodic_recalculate_worker.rb4
-rw-r--r--app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb4
-rw-r--r--app/workers/cleanup_container_repository_worker.rb12
-rw-r--r--app/workers/concerns/limited_capacity/job_tracker.rb74
-rw-r--r--app/workers/concerns/limited_capacity/worker.rb164
-rw-r--r--app/workers/design_management/copy_design_collection_worker.rb26
-rw-r--r--app/workers/design_management/new_version_worker.rb4
-rw-r--r--app/workers/git_garbage_collect_worker.rb4
-rw-r--r--app/workers/group_import_worker.rb2
-rw-r--r--app/workers/incident_management/add_severity_system_note_worker.rb22
-rw-r--r--app/workers/issue_placement_worker.rb8
-rw-r--r--app/workers/issue_rebalancing_worker.rb3
-rw-r--r--app/workers/member_invitation_reminder_emails_worker.rb15
-rw-r--r--app/workers/metrics/dashboard/sync_dashboards_worker.rb22
-rw-r--r--app/workers/propagate_integration_group_worker.rb19
-rw-r--r--app/workers/propagate_integration_inherit_worker.rb19
-rw-r--r--app/workers/propagate_integration_project_worker.rb19
-rw-r--r--app/workers/propagate_integration_worker.rb3
-rwxr-xr-xbin/feature-flag19
-rw-r--r--changelogs/unreleased/13426-bugfix-copyState-and-padding.yml5
-rw-r--r--changelogs/unreleased/13426-copy-designs.yml5
-rw-r--r--changelogs/unreleased/13426-graphql-fe.yml5
-rw-r--r--changelogs/unreleased/13426-graphql.yml5
-rw-r--r--changelogs/unreleased/17673-wiki-pages-tree-structure.yml5
-rw-r--r--changelogs/unreleased/197227-load-milestone-issues-tab-async.yml5
-rw-r--r--changelogs/unreleased/198413-CI-Pre-Collapsed-Sections.yml5
-rw-r--r--changelogs/unreleased/198612-monaco-ci-default.yml5
-rw-r--r--changelogs/unreleased/207347-terraform-versions-api.yml5
-rw-r--r--changelogs/unreleased/208193-add-expiration-started-at-to-container-repositories.yml5
-rw-r--r--changelogs/unreleased/208260-fix-mr-task-lists-starting-with-new-line.yml5
-rw-r--r--changelogs/unreleased/209831-propagate-integrations-using-batching-and-queues.yml5
-rw-r--r--changelogs/unreleased/212373-249588-enable-ci_new_artifact_file_reader.yml5
-rw-r--r--changelogs/unreleased/215697-allow-groups-to-disable-2fa-requirement-for-subgroups-migration.yml6
-rw-r--r--changelogs/unreleased/21654-ide-button-in-mr-diff-files.yml5
-rw-r--r--changelogs/unreleased/216571-terraform-states-graphql-endpoint.yml5
-rw-r--r--changelogs/unreleased/216642-display-youtube-iframes.yml5
-rw-r--r--changelogs/unreleased/217115-assign-epic-to-a-group-when-creating-within-another-epic.yml5
-rw-r--r--changelogs/unreleased/217882-allow-designs-to-be-added-to-moved-issues.yml5
-rw-r--r--changelogs/unreleased/219915-cleanup-policy-not-working.yml5
-rw-r--r--changelogs/unreleased/220182-remove-feature-flag.yml5
-rw-r--r--changelogs/unreleased/220203-gitlab-com-sso-create-no-access-role-fe-changes.yml5
-rw-r--r--changelogs/unreleased/220420-migration-validate-designs-filename-text-limit.yml5
-rw-r--r--changelogs/unreleased/220503_bug_add_branches_to_be_notified_to_hangouts_service_api.yml5
-rw-r--r--changelogs/unreleased/223101-follow-up-from-replace-double-angle-icons-with-chevron.yml5
-rw-r--r--changelogs/unreleased/225164-add-a-title-section-to-the-package-registry-ui.yml5
-rw-r--r--changelogs/unreleased/225175-change-the-default-order-for-packages-in-the-package-registry-list.yml5
-rw-r--r--changelogs/unreleased/225293-emaill-participants.yml5
-rw-r--r--changelogs/unreleased/225644-package-registry-in-the-group-registry-view-make-the-project-ui-mo.yml5
-rw-r--r--changelogs/unreleased/225655-alert-new-page.yml5
-rw-r--r--changelogs/unreleased/227668-boards_resolver_id_required.yml5
-rw-r--r--changelogs/unreleased/228674-no-dependencies-on-deploy-ecs.yml5
-rw-r--r--changelogs/unreleased/229284-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml5
-rw-r--r--changelogs/unreleased/229343-update-lock-form-buttons.yml5
-rw-r--r--changelogs/unreleased/229344-update-confidential-form-buttons.yml5
-rw-r--r--changelogs/unreleased/229404-filter-capabilities.yml5
-rw-r--r--changelogs/unreleased/229534-incident-sorting-backend.yml5
-rw-r--r--changelogs/unreleased/229534-incidents-sorting.yml5
-rw-r--r--changelogs/unreleased/229834-destroy-board-list-graphql.yml5
-rw-r--r--changelogs/unreleased/230438-usage-data-optimize-usage_activity_by_stage-counters.yml5
-rw-r--r--changelogs/unreleased/230719-tabs-vue-migrate-app-assets-javascripts-environments-components-en.yml5
-rw-r--r--changelogs/unreleased/230720-tabs-vue-migrate-app-assets-javascripts-environments-folder-enviro.yml5
-rw-r--r--changelogs/unreleased/230725-update-boards-modal-tabs.yml5
-rw-r--r--changelogs/unreleased/230827-fix-epics-filter.yml5
-rw-r--r--changelogs/unreleased/230841-update-alert-gfm-like-reference.yml5
-rw-r--r--changelogs/unreleased/231072-project-members-import-button.yml5
-rw-r--r--changelogs/unreleased/231180-ui-button-proj-hook_logs.yml5
-rw-r--r--changelogs/unreleased/231209-labels-buttons.yml5
-rw-r--r--changelogs/unreleased/231306-approve-activity-in-core.yml5
-rw-r--r--changelogs/unreleased/233016_fix_jira_dvcs_user_avatars.yml5
-rw-r--r--changelogs/unreleased/233430-design-repo-in-backup.yml5
-rw-r--r--changelogs/unreleased/233647-replace-bootstrap-alerts-to-gitlab-ui.yml5
-rw-r--r--changelogs/unreleased/233660-remove-bootstrap-alert-gcp.yml5
-rw-r--r--changelogs/unreleased/233674-remove-bootstrap-usage-invalid.yml5
-rw-r--r--changelogs/unreleased/233686-replace-bootstrap-alerts-in-app-views-admin-projects-show-html-ham.yml5
-rw-r--r--changelogs/unreleased/233693-remove-bootstrap-pages.yml5
-rw-r--r--changelogs/unreleased/233703-replace-bootstrap-alerts-in-app-views-import-shared-_errors-html-h.yml5
-rw-r--r--changelogs/unreleased/233719-project-creating-incidents-usage-ping.yml5
-rw-r--r--changelogs/unreleased/235822-group-package-permissions.yml5
-rw-r--r--changelogs/unreleased/235857-sentryerrortype-project_id-returns-a-project-global-id.yml5
-rw-r--r--changelogs/unreleased/235945-replace-the-delete-ssh-confirmation-dialog-with-a-pajamas-modal.yml5
-rw-r--r--changelogs/unreleased/237923-error-when-quickly-reordering-designs.yml5
-rw-r--r--changelogs/unreleased/238570-hide-instance-level-integrations-on-gitlab-com.yml5
-rw-r--r--changelogs/unreleased/238605-optimise-lfs-cleanup.yml5
-rw-r--r--changelogs/unreleased/238605-remove-cleanup-lfs-during-gc-feature-flag.yml5
-rw-r--r--changelogs/unreleased/238628-design-specs-for-confirmation-modal.yml5
-rw-r--r--changelogs/unreleased/239130-conan-recipe-display-be.yml5
-rw-r--r--changelogs/unreleased/239327-fj-fix-snippet-update-edge-case.yml5
-rw-r--r--changelogs/unreleased/241165-sse-config-image-upload-path.yml5
-rw-r--r--changelogs/unreleased/241166-sse-config-mounts.yml5
-rw-r--r--changelogs/unreleased/242016-fj-add-instance-gitpod-enabled-to-usage-data.yml5
-rw-r--r--changelogs/unreleased/242016-fj-add-user-preferences-gitpod-enabled-to-usage-data.yml5
-rw-r--r--changelogs/unreleased/243563-move-approval-code-to-ce.yml5
-rw-r--r--changelogs/unreleased/243750-display-messages-and-warning-for-partially-executed-cleanup-polici.yml5
-rw-r--r--changelogs/unreleased/244355-add-javascript-example-on-how-we-could-track-ui-events-using-usage.yml5
-rw-r--r--changelogs/unreleased/244484-remove-redundant-cluster-agents-index.yml5
-rw-r--r--changelogs/unreleased/244828-drop-iglu-registry-url-column.yml5
-rw-r--r--changelogs/unreleased/244873-perceived-ux-success-screen.yml5
-rw-r--r--changelogs/unreleased/245304-reference-pages_deployments-in-pages_metadata.yml5
-rw-r--r--changelogs/unreleased/245325-refactor-the-invites-controller-member-method.yml5
-rw-r--r--changelogs/unreleased/246619-add-package-events.yml5
-rw-r--r--changelogs/unreleased/246783-setting-button-position.yml5
-rw-r--r--changelogs/unreleased/246793-mr-coverage-only-latest-build.yml5
-rw-r--r--changelogs/unreleased/246847-store-pipeline-counts-by-status.yml5
-rw-r--r--changelogs/unreleased/247476-standardise-usage-of-angle-brackets.yml5
-rw-r--r--changelogs/unreleased/248564-remove-web-ide-suggestion-banner-on-blob-edit.yml5
-rw-r--r--changelogs/unreleased/249368-unit-test-report-test-cases-with-errored-status-show-skipped-icon.yml5
-rw-r--r--changelogs/unreleased/249561-update-workhorse.yml5
-rw-r--r--changelogs/unreleased/249590-project-count-admin-view.yml5
-rw-r--r--changelogs/unreleased/249811-remove-coverage-report-view-feature-flag.yml5
-rw-r--r--changelogs/unreleased/250041-does-not-refresh-project-snippet-statistics-on-a-read-only-instanc.yml5
-rw-r--r--changelogs/unreleased/250355-iterations-overlap-validation-bug.yml5
-rw-r--r--changelogs/unreleased/250580-fix-graphql-api-token-authentication.yml5
-rw-r--r--changelogs/unreleased/250654-get-lfs-push-mirror-working-for-github.yml5
-rw-r--r--changelogs/unreleased/250671-don-t-init-http-metrics-in-sidekiq.yml5
-rw-r--r--changelogs/unreleased/250685-fix-carets.yml5
-rw-r--r--changelogs/unreleased/250727-use-fuzzaldrinplus-search-for-labels.yml5
-rw-r--r--changelogs/unreleased/251048-modify-time-period-for-batch-counting-performance-of-the-last-port.yml5
-rw-r--r--changelogs/unreleased/254197-merge-request-diff-has-an-extra-spacing-on-mobile.yml5
-rw-r--r--changelogs/unreleased/254224-notification-icons-missing-icon-for-custom.yml5
-rw-r--r--changelogs/unreleased/254250-add-time-tracking-to-incidents.yml5
-rw-r--r--changelogs/unreleased/254281-add-user-agent-to-git-lfs-client.yml5
-rw-r--r--changelogs/unreleased/254721-enable-store_ci_pipeline_counts_by_status-ff-by-default.yml5
-rw-r--r--changelogs/unreleased/254903-add-multiple-assignee-filter-graphql.yml5
-rw-r--r--changelogs/unreleased/254932-fix-search-results-blob-link-to-branch.yml5
-rw-r--r--changelogs/unreleased/254946-add-missing-fa-icons-for-file_type_icon_class.yml5
-rw-r--r--changelogs/unreleased/255322-search-sort-issues-and-mrs.yml5
-rw-r--r--changelogs/unreleased/255347-add-a-project-configured-with-gitpod-to-project-templates.yml5
-rw-r--r--changelogs/unreleased/255938-delete-user-dialog-text-contains-formatting-strings.yml5
-rw-r--r--changelogs/unreleased/256094-fix-copy-indexes-migration-helper-opclass-replication.yml6
-rw-r--r--changelogs/unreleased/256121-add-schema-to-migration-helpers-that-query-the-pg-catalog.yml5
-rw-r--r--changelogs/unreleased/257198-align-recognized-markdown-extensions-with-file-icon.yml5
-rw-r--r--changelogs/unreleased/257881-migration-user-admin-approval-toggle.yml6
-rw-r--r--changelogs/unreleased/258203-add-api-fuzzing-plan-limits-db.yml5
-rw-r--r--changelogs/unreleased/258668-update-icons-in-nav.yml5
-rw-r--r--changelogs/unreleased/258964-update-node-sass-from-4-12-to-4-14.yml5
-rw-r--r--changelogs/unreleased/35223-drop-unused-user-id-column-on-cluster-providers-aws.yml5
-rw-r--r--changelogs/unreleased/40174-snowflake-npm-forward.yml5
-rw-r--r--changelogs/unreleased/42426-migrate-slack-button.yml5
-rw-r--r--changelogs/unreleased/42910-use-create-wiki-method-on-ensure-wiki-exists-in-update-service.yml5
-rw-r--r--changelogs/unreleased/42916-pypi-install-command.yml5
-rw-r--r--changelogs/unreleased/42917_golang_vendor_templates.yml5
-rw-r--r--changelogs/unreleased/42989-update-deactivation-threshold-to-90-days.yml5
-rw-r--r--changelogs/unreleased/43009-update-docs-link-packages.yml5
-rw-r--r--changelogs/unreleased/43499-update-commit-message-popover.yml5
-rw-r--r--changelogs/unreleased/Replace-GIDeprecatedDropdown-in-app-assets-javascripts-alert_management.yml5
-rw-r--r--changelogs/unreleased/Replace-GlDeprecatedDropdown-with-GlDropdown-in-app-assets-javascripts-cl.yml5
-rw-r--r--changelogs/unreleased/ab-reindexing-actiontracking.yml5
-rw-r--r--changelogs/unreleased/ab-reindexing-index-model.yml5
-rw-r--r--changelogs/unreleased/ab-reindexing-limit-schema.yml5
-rw-r--r--changelogs/unreleased/add-abuse-notification-email.yml5
-rw-r--r--changelogs/unreleased/add-dast-site-validation-worker-245209.yml5
-rw-r--r--changelogs/unreleased/add-golang-to-packages.yml5
-rw-r--r--changelogs/unreleased/add-notification-setting-for-merge-request-reviewers.yml5
-rw-r--r--changelogs/unreleased/add-sse-config-file-3.yml6
-rw-r--r--changelogs/unreleased/add_index_for_sha_to_deployments.yml5
-rw-r--r--changelogs/unreleased/ajk-219380-fix-design-type-formatting-in-activity-streams.yml5
-rw-r--r--changelogs/unreleased/ajk-issue-reposition-take.yml5
-rw-r--r--changelogs/unreleased/ajk-remove-design_management_reference_filter_gfm_pipeline-ff.yml5
-rw-r--r--changelogs/unreleased/ajk-remove-graphql_lookahead_support.yml5
-rw-r--r--changelogs/unreleased/ajk-remove-wiki_events_on_git_push-ff.yml5
-rw-r--r--changelogs/unreleased/alipniagov-update-wh-to-8-49.yml5
-rw-r--r--changelogs/unreleased/alipniagov-update-wh-version.yml5
-rw-r--r--changelogs/unreleased/always-show-clear-cluster-cache-button.yml5
-rw-r--r--changelogs/unreleased/api-expose-gpg.yml5
-rw-r--r--changelogs/unreleased/bjk-rack_metric_methods.yml5
-rw-r--r--changelogs/unreleased/cat-2fa-404-redirect.yml5
-rw-r--r--changelogs/unreleased/change-transfer-update-create-services.yml5
-rw-r--r--changelogs/unreleased/cngo-clear-label-select-on-enter.yml5
-rw-r--r--changelogs/unreleased/dblessing-auth-events-usage-ping.yml5
-rw-r--r--changelogs/unreleased/dblessing-ip-address-validator.yml5
-rw-r--r--changelogs/unreleased/dblessing_user_created_by_id.yml5
-rw-r--r--changelogs/unreleased/debian_packages.yml5
-rw-r--r--changelogs/unreleased/debian_regexp.yml5
-rw-r--r--changelogs/unreleased/display-contributor-and-author-badges-on-notes.yml5
-rw-r--r--changelogs/unreleased/drop-an-element.yml5
-rw-r--r--changelogs/unreleased/drop-instance-statistics-visibility-private-column.yml5
-rw-r--r--changelogs/unreleased/enable-track-unique-visits-ff-by-default.yml5
-rw-r--r--changelogs/unreleased/eread-migrate-badge-list-row-buttons-to-new-buttons.yml5
-rw-r--r--changelogs/unreleased/expose-clusters-namespace-per-environment-flag.yml6
-rw-r--r--changelogs/unreleased/expose-junit-spec-file-path.yml5
-rw-r--r--changelogs/unreleased/feature-gb-validate-build-traces.yml5
-rw-r--r--changelogs/unreleased/fix-encoding-for-nil-diff-files.yml5
-rw-r--r--changelogs/unreleased/fix-pipeline-stage-action-alignment.yml5
-rw-r--r--changelogs/unreleased/fix-retry-job-button-422.yml5
-rw-r--r--changelogs/unreleased/fix-theme-switcher.yml5
-rw-r--r--changelogs/unreleased/fix-wrong-scope-in-approved-by.yml5
-rw-r--r--changelogs/unreleased/gaga5lala-227175-carrierwave-error-handle.yml5
-rw-r--r--changelogs/unreleased/georgekoltsov-add-group-import-usage-ping.yml5
-rw-r--r--changelogs/unreleased/gitlab_buttons_ci_builds.yml5
-rw-r--r--changelogs/unreleased/gitlab_buttons_projects_blob.yml5
-rw-r--r--changelogs/unreleased/gitlab_buttons_projects_forks.yml5
-rw-r--r--changelogs/unreleased/id-add-approval-rules-approvers-usage-ping.yml5
-rw-r--r--changelogs/unreleased/id-fix-squash-messages.yml5
-rw-r--r--changelogs/unreleased/improve-emoji-support.yml5
-rw-r--r--changelogs/unreleased/isolate-mentions-migration.yml5
-rw-r--r--changelogs/unreleased/issuable-award-fuzzy-search.yml5
-rw-r--r--changelogs/unreleased/issue_205151.yml4
-rw-r--r--changelogs/unreleased/jdb-refactor-diff-rows-utils.yml5
-rw-r--r--changelogs/unreleased/jivanvl-runner-guided-install-backend.yml5
-rw-r--r--changelogs/unreleased/jv-job-waiter-key-leak.yml5
-rw-r--r--changelogs/unreleased/jyavorska-master-patch-96769.yml5
-rw-r--r--changelogs/unreleased/kassio-do-not-add-missing-not-if-importer-user-is-mapped.yml5
-rw-r--r--changelogs/unreleased/kassio-fix-missing-author-notes-on-importers.yml5
-rw-r--r--changelogs/unreleased/khanchi-designs-patch.yml5
-rw-r--r--changelogs/unreleased/lm-improve-n-1-pl-serializer.yml5
-rw-r--r--changelogs/unreleased/mc-feature-ci-config-render-api-endpoint.yml5
-rw-r--r--changelogs/unreleased/migrate-recover-hidden-stage.yml5
-rw-r--r--changelogs/unreleased/mk-enforce-not-null-file-store-on-package-files.yml6
-rw-r--r--changelogs/unreleased/move-referrer-policy-to-header.yml5
-rw-r--r--changelogs/unreleased/mr-state-index.yml5
-rw-r--r--changelogs/unreleased/mw-description-template-replace-fa-icons.yml5
-rw-r--r--changelogs/unreleased/mw-feature-flags-replace-chevron-fa-icon.yml5
-rw-r--r--changelogs/unreleased/mw-replace-external-link-icon-group-folder.yml5
-rw-r--r--changelogs/unreleased/mw-replace-fa-search-icons.yml5
-rw-r--r--changelogs/unreleased/mw-replace-fa-warning-in-limit-warning-component.yml5
-rw-r--r--changelogs/unreleased/mw-vsa-limit-warnings-icon-refactor.yml5
-rw-r--r--changelogs/unreleased/nfriend-add-release-og-description.yml5
-rw-r--r--changelogs/unreleased/nfriend-fix-markdown-preview-on-new-release-page.yml5
-rw-r--r--changelogs/unreleased/nfriend-fix-release-asset-link-length.yml5
-rw-r--r--changelogs/unreleased/nfriend-fix-release-edit-button-size.yml5
-rw-r--r--changelogs/unreleased/nfriend-strip-markdown-from-og-description.yml5
-rw-r--r--changelogs/unreleased/nfriend-update-releases-page-skeleton-loader-shape.yml5
-rw-r--r--changelogs/unreleased/notificaion-button-class.yml5
-rw-r--r--changelogs/unreleased/ntepluhina-refactor-toolbar-to-gitlab-ui.yml5
-rw-r--r--changelogs/unreleased/okr-alert-widget-form.yml5
-rw-r--r--changelogs/unreleased/okr-fluentd-output-settings.yml5
-rw-r--r--changelogs/unreleased/okr-ingress-modsecurity-settings.yml5
-rw-r--r--changelogs/unreleased/okr-modal-copy-button.yml5
-rw-r--r--changelogs/unreleased/pages-1-26-0.yml5
-rw-r--r--changelogs/unreleased/pat-bot-terms.yml5
-rw-r--r--changelogs/unreleased/ph-199725-diffHeaderActionDropdown.yml5
-rw-r--r--changelogs/unreleased/ph-212334-fixTwoButtonReviewSubmit.yml5
-rw-r--r--changelogs/unreleased/ph-224141-hideBatchSuggestionsWithOnlyOneSuggestion.yml5
-rw-r--r--changelogs/unreleased/ph-227421-changeMROptionsDropdownToIncludeDraft.yml5
-rw-r--r--changelogs/unreleased/pl-tracing-core-2-usage-data.yml5
-rw-r--r--changelogs/unreleased/psi-do-not-extend-2.yml5
-rw-r--r--changelogs/unreleased/put_extra_graphql_logs.yml5
-rw-r--r--changelogs/unreleased/rails-save-bang-33.yml5
-rw-r--r--changelogs/unreleased/rails-save-bang-34.yml5
-rw-r--r--changelogs/unreleased/rails-save-bang-35.yml5
-rw-r--r--changelogs/unreleased/rails-save-bang-36.yml5
-rw-r--r--changelogs/unreleased/rails-save-bang-37.yml5
-rw-r--r--changelogs/unreleased/reminder-emails-invitation-declined-screen.yml5
-rw-r--r--changelogs/unreleased/remove-add_severity_system_note-feature-flag.yml5
-rw-r--r--changelogs/unreleased/remove-tmp-index.yml5
-rw-r--r--changelogs/unreleased/revert-42465-and-42343.yml5
-rw-r--r--changelogs/unreleased/revert-a6875e5f.yml5
-rw-r--r--changelogs/unreleased/revise-tooltip-text-of-note-role-badge.yml5
-rw-r--r--changelogs/unreleased/sarnold-deprecate-lowercase-enums.yml5
-rw-r--r--changelogs/unreleased/scoped-label-dashboard.yml5
-rw-r--r--changelogs/unreleased/security-update-runner-version.yml5
-rw-r--r--changelogs/unreleased/set-performance-bar-cookie-expiry.yml5
-rw-r--r--changelogs/unreleased/sh-fix-pipeline-notification-email-warning.yml5
-rw-r--r--changelogs/unreleased/sh-lfs-smudge-api.yml5
-rw-r--r--changelogs/unreleased/spec_job_token.yml5
-rw-r--r--changelogs/unreleased/sy-alert-integrations-table.yml5
-rw-r--r--changelogs/unreleased/sy-allow-guests-to-view-incidents.yml5
-rw-r--r--changelogs/unreleased/sy-improve-generic-alert-system-note.yml5
-rw-r--r--changelogs/unreleased/tr-filter-internal-strings.yml5
-rw-r--r--changelogs/unreleased/unleash-api-with-private-repo.yml5
-rw-r--r--changelogs/unreleased/update-clipboard-button.yml5
-rw-r--r--changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-21-0.yml5
-rw-r--r--changelogs/unreleased/zj-update-lang-colors.yml5
-rw-r--r--config/application.rb71
-rw-r--r--config/database.yml.postgresql4
-rw-r--r--config/feature_categories.yml9
-rw-r--r--config/feature_flags/development/additional_snowplow_tracking.yml7
-rw-r--r--config/feature_flags/development/admin_approval_for_new_user_signups.yml7
-rw-r--r--config/feature_flags/development/ajax_new_deploy_token.yml4
-rw-r--r--config/feature_flags/development/allow_group_deploy_token.yml4
-rw-r--r--config/feature_flags/development/allow_possible_spam.yml6
-rw-r--r--config/feature_flags/development/allow_unsafe_ruby_regexp.yml4
-rw-r--r--config/feature_flags/development/api_commits_without_count.yml7
-rw-r--r--config/feature_flags/development/api_kaminari_count_with_limit.yml7
-rw-r--r--config/feature_flags/development/approval_rule.yml7
-rw-r--r--config/feature_flags/development/artifacts_management_page.yml6
-rw-r--r--config/feature_flags/development/auto_create_cluster_management_project.yml4
-rw-r--r--config/feature_flags/development/backfill_partitioned_audit_events.yml7
-rw-r--r--config/feature_flags/development/boards_with_swimlanes.yml6
-rw-r--r--config/feature_flags/development/broadcast_issue_updates.yml6
-rw-r--r--config/feature_flags/development/build_service_proxy.yml6
-rw-r--r--config/feature_flags/development/bulk_import.yml7
-rw-r--r--config/feature_flags/development/bulk_update_health_status.yml6
-rw-r--r--config/feature_flags/development/burnup_charts.yml6
-rw-r--r--config/feature_flags/development/ci_bulk_insert_on_create.yml7
-rw-r--r--config/feature_flags/development/ci_daily_code_coverage.yml7
-rw-r--r--config/feature_flags/development/ci_disable_validates_dependencies.yml6
-rw-r--r--config/feature_flags/development/ci_download_daily_code_coverage.yml7
-rw-r--r--config/feature_flags/development/ci_job_heartbeats_runner.yml7
-rw-r--r--config/feature_flags/development/ci_job_jwt.yml4
-rw-r--r--config/feature_flags/development/ci_key_autocomplete.yml7
-rw-r--r--config/feature_flags/development/ci_new_artifact_file_reader.yml2
-rw-r--r--config/feature_flags/development/ci_pipeline_rewind_iid.yml7
-rw-r--r--config/feature_flags/development/ci_pipeline_status_omit_commit_sha_in_cache_key.yml4
-rw-r--r--config/feature_flags/development/ci_pipeline_triggers_settings_vue_ui.yml7
-rw-r--r--config/feature_flags/development/ci_skip_persistent_ref_existence_check.yml4
-rw-r--r--config/feature_flags/development/ci_synchronous_artifact_parsing.yml4
-rw-r--r--config/feature_flags/development/ci_update_queues_for_online_runners.yml7
-rw-r--r--config/feature_flags/development/cleanup_lfs_during_gc.yml7
-rw-r--r--config/feature_flags/development/cluster_agent_list.yml7
-rw-r--r--config/feature_flags/development/cluster_management_project.yml4
-rw-r--r--config/feature_flags/development/clusters_list_redesign.yml7
-rw-r--r--config/feature_flags/development/container_registry_fast_tag_delete.yml7
-rw-r--r--config/feature_flags/development/create_cloud_run_clusters.yml6
-rw-r--r--config/feature_flags/development/dag_pipeline_tab.yml7
-rw-r--r--config/feature_flags/development/dashboard_pipeline_status.yml6
-rw-r--r--config/feature_flags/development/debian_packages.yml7
-rw-r--r--config/feature_flags/development/default_merge_ref_for_diffs.yml7
-rw-r--r--config/feature_flags/development/deploy_from_footer.yml7
-rw-r--r--config/feature_flags/development/deploy_keys_on_protected_branches.yml7
-rw-r--r--config/feature_flags/development/deploy_tokens_api.yml6
-rw-r--r--config/feature_flags/development/design_management_allow_dangerous_images.yml6
-rw-r--r--config/feature_flags/development/design_management_copy_designs.yml7
-rw-r--r--config/feature_flags/development/design_management_reference_filter_gfm_pipeline.yml7
-rw-r--r--config/feature_flags/development/disable_metric_dashboard_refresh_rate.yml6
-rw-r--r--config/feature_flags/development/disable_shared_runners_on_group.yml7
-rw-r--r--config/feature_flags/development/drop_license_management_artifact.yml6
-rw-r--r--config/feature_flags/development/epics_search.yml7
-rw-r--r--config/feature_flags/development/export_lfs_objects_projects.yml7
-rw-r--r--config/feature_flags/development/export_reduce_relation_batch_size.yml6
-rw-r--r--config/feature_flags/development/feature_flags_new_version.yml7
-rw-r--r--config/feature_flags/development/forward_deployment_enabled.yml4
-rw-r--r--config/feature_flags/development/git_push_create_all_pipelines.yml6
-rw-r--r--config/feature_flags/development/go_proxy.yml6
-rw-r--r--config/feature_flags/development/go_proxy_disable_gomod_validation.yml6
-rw-r--r--config/feature_flags/development/graphql_board_lists.yml6
-rw-r--r--config/feature_flags/development/graphql_lookahead_support.yml7
-rw-r--r--config/feature_flags/development/graphql_milestone_stats.yml6
-rw-r--r--config/feature_flags/development/graphql_pipeline_header.yml7
-rw-r--r--config/feature_flags/development/graphql_release_data.yml6
-rw-r--r--config/feature_flags/development/graphql_releases_page.yml7
-rw-r--r--config/feature_flags/development/group_export_ndjson.yml6
-rw-r--r--config/feature_flags/development/group_import_export.yml6
-rw-r--r--config/feature_flags/development/group_import_ndjson.yml6
-rw-r--r--config/feature_flags/development/group_level_integrations.yml2
-rw-r--r--config/feature_flags/development/improved_mr_merged_at_queries.yml7
-rw-r--r--config/feature_flags/development/inactive_policy_condition.yml7
-rw-r--r--config/feature_flags/development/increased_diff_limits.yml7
-rw-r--r--config/feature_flags/development/ingress_modsecurity.yml6
-rw-r--r--config/feature_flags/development/invisible_captcha.yml6
-rw-r--r--config/feature_flags/development/invite_email_experiment.yml6
-rw-r--r--config/feature_flags/development/invite_members_group_modal.yml7
-rw-r--r--config/feature_flags/development/issues_incident_details.yml7
-rw-r--r--config/feature_flags/development/junit_pipeline_screenshots_view.yml6
-rw-r--r--config/feature_flags/development/limit_projects_in_groups_api.yml6
-rw-r--r--config/feature_flags/development/log_import_export_relation_creation.yml6
-rw-r--r--config/feature_flags/development/maintenance_mode.yml6
-rw-r--r--config/feature_flags/development/merge_orchestration_service.yml4
-rw-r--r--config/feature_flags/development/merge_ref_head_comments.yml7
-rw-r--r--config/feature_flags/development/merge_request_cached_pipeline_serializer.yml7
-rw-r--r--config/feature_flags/development/merge_request_short_pipeline_serializer.yml7
-rw-r--r--config/feature_flags/development/merge_request_widget_graphql.yml6
-rw-r--r--config/feature_flags/development/metrics_dashboard.yml6
-rw-r--r--config/feature_flags/development/migrate_bio_to_user_details.yml7
-rw-r--r--config/feature_flags/development/migrate_user_mentions.yml4
-rw-r--r--config/feature_flags/development/monaco_blobs.yml6
-rw-r--r--config/feature_flags/development/monaco_ci.yml8
-rw-r--r--config/feature_flags/development/multi_select_board.yml6
-rw-r--r--config/feature_flags/development/multiline_comments.yml6
-rw-r--r--config/feature_flags/development/new_pipeline_form.yml6
-rw-r--r--config/feature_flags/development/new_release_page.yml6
-rw-r--r--config/feature_flags/development/not_issuable_queries.yml6
-rw-r--r--config/feature_flags/development/one_dimensional_matrix.yml7
-rw-r--r--config/feature_flags/development/paginated_notes.yml6
-rw-r--r--config/feature_flags/development/periodic_project_authorization_recalculation.yml7
-rw-r--r--config/feature_flags/development/phabricator_import.yml6
-rw-r--r--config/feature_flags/development/pipelines_security_report_summary.yml6
-rw-r--r--config/feature_flags/development/product_analytics.yml6
-rw-r--r--config/feature_flags/development/project_export_as_ndjson.yml6
-rw-r--r--config/feature_flags/development/project_import_ndjson.yml6
-rw-r--r--config/feature_flags/development/project_transactionless_destroy.yml6
-rw-r--r--config/feature_flags/development/prometheus_computed_alerts.yml6
-rw-r--r--config/feature_flags/development/reactive_caching_limit_environment.yml6
-rw-r--r--config/feature_flags/development/real_time_issue_sidebar.yml6
-rw-r--r--config/feature_flags/development/release_asset_link_editing.yml6
-rw-r--r--config/feature_flags/development/release_asset_link_type.yml6
-rw-r--r--config/feature_flags/development/release_evidence.yml4
-rw-r--r--config/feature_flags/development/release_evidence_collection.yml6
-rw-r--r--config/feature_flags/development/release_issue_summary.yml6
-rw-r--r--config/feature_flags/development/release_mr_issue_urls.yml6
-rw-r--r--config/feature_flags/development/release_show_page.yml6
-rw-r--r--config/feature_flags/development/remove_legacy_github_client.yml6
-rw-r--r--config/feature_flags/development/repack_after_shard_migration.yml6
-rw-r--r--config/feature_flags/development/resource_access_token.yml7
-rw-r--r--config/feature_flags/development/safezip_use_rubyzip.yml7
-rw-r--r--config/feature_flags/development/save_raw_usage_data.yml6
-rw-r--r--config/feature_flags/development/schema_linting.yml6
-rw-r--r--config/feature_flags/development/search_filter_by_confidential.yml7
-rw-r--r--config/feature_flags/development/serverless_domain.yml4
-rw-r--r--config/feature_flags/development/service_desk_custom_address.yml2
-rw-r--r--config/feature_flags/development/settings_operations_prometheus_service.yml6
-rw-r--r--config/feature_flags/development/show_contributor_on_note.yml7
-rw-r--r--config/feature_flags/development/show_inherited_labels.yml7
-rw-r--r--config/feature_flags/development/similarity_search.yml6
-rw-r--r--config/feature_flags/development/snippet_multiple_files.yml6
-rw-r--r--config/feature_flags/development/snippets_binary_blob.yml6
-rw-r--r--config/feature_flags/development/snippets_vue.yml6
-rw-r--r--config/feature_flags/development/soft_email_confirmation.yml6
-rw-r--r--config/feature_flags/development/specialized_project_authorization_project_share_worker.yml6
-rw-r--r--config/feature_flags/development/specialized_project_authorization_workers.yml6
-rw-r--r--config/feature_flags/development/sql_set_operators.yml6
-rw-r--r--config/feature_flags/development/store_ci_pipeline_counts_by_status.yml7
-rw-r--r--config/feature_flags/development/store_mentioned_users_to_db.yml7
-rw-r--r--config/feature_flags/development/suggest_pipeline.yml7
-rw-r--r--config/feature_flags/development/sync_metrics_dashboards.yml7
-rw-r--r--config/feature_flags/development/track_resource_state_change_events.yml7
-rw-r--r--config/feature_flags/development/track_unique_visits.yml6
-rw-r--r--config/feature_flags/development/tribute_autocomplete.yml6
-rw-r--r--config/feature_flags/development/unified_diff_lines.yml2
-rw-r--r--config/feature_flags/development/usage_data_a_compliance_audit_events_api.yml7
-rw-r--r--config/feature_flags/development/usage_data_api.yml4
-rw-r--r--config/feature_flags/development/usage_data_g_compliance_dashboard.yml6
-rw-r--r--config/feature_flags/development/usage_data_i_source_code_code_intelligence.yml7
-rw-r--r--config/feature_flags/development/usage_data_incident_management_alert_assigned.yml6
-rw-r--r--config/feature_flags/development/usage_data_incident_management_alert_status_changed.yml6
-rw-r--r--config/feature_flags/development/usage_data_incident_management_alert_todo.yml6
-rw-r--r--config/feature_flags/development/usage_data_incident_management_incident_assigned.yml6
-rw-r--r--config/feature_flags/development/usage_data_incident_management_incident_change_confidential.yml6
-rw-r--r--config/feature_flags/development/usage_data_incident_management_incident_closed.yml6
-rw-r--r--config/feature_flags/development/usage_data_incident_management_incident_comment.yml6
-rw-r--r--config/feature_flags/development/usage_data_incident_management_incident_created.yml6
-rw-r--r--config/feature_flags/development/usage_data_incident_management_incident_relate.yml6
-rw-r--r--config/feature_flags/development/usage_data_incident_management_incident_reopened.yml6
-rw-r--r--config/feature_flags/development/usage_data_incident_management_incident_todo.yml6
-rw-r--r--config/feature_flags/development/usage_data_incident_management_incident_unrelate.yml6
-rw-r--r--config/feature_flags/development/usage_data_incident_management_incident_zoom_meeting.yml6
-rw-r--r--config/feature_flags/development/users_search.yml7
-rw-r--r--config/feature_flags/development/validate_import_decompressed_archive_size.yml6
-rw-r--r--config/feature_flags/development/view_diffs_file_by_file.yml6
-rw-r--r--config/feature_flags/development/vue_issuable_sidebar.yml6
-rw-r--r--config/feature_flags/development/vue_issuables_list.yml6
-rw-r--r--config/feature_flags/development/vue_sidebar_labels.yml7
-rw-r--r--config/feature_flags/development/web_ide_primary_edit.yml4
-rw-r--r--config/feature_flags/development/webperf_experiment.yml6
-rw-r--r--config/feature_flags/development/whats_new_drawer.yml6
-rw-r--r--config/feature_flags/development/widget_visibility_polling.yml6
-rw-r--r--config/feature_flags/development/wiki_events_on_git_push.yml7
-rw-r--r--config/feature_flags/licensed/minimal_access_role.yml7
-rw-r--r--config/feature_flags/licensed/resource_access_token.yml7
-rw-r--r--config/feature_flags/ops/api_kaminari_count_with_limit.yml7
-rw-r--r--config/feature_flags/ops/database_reindexing.yml7
-rw-r--r--config/feature_flags/ops/gitlab_sidekiq_enable_semi_reliable_fetcher.yml7
-rw-r--r--config/feature_flags/ops/gitlab_sidekiq_reliable_fetcher.yml7
-rw-r--r--config/gitlab.yml.example16
-rw-r--r--config/initializers/0_license.rb3
-rw-r--r--config/initializers/1_settings.rb11
-rw-r--r--config/initializers/7_prometheus_metrics.rb4
-rw-r--r--config/initializers/forbid_sidekiq_in_transactions.rb2
-rw-r--r--config/initializers/lograge.rb7
-rw-r--r--config/initializers/sidekiq.rb4
-rw-r--r--config/initializers/static_files.rb6
-rw-r--r--config/initializers_before_autoloader/000_inflections.rb4
-rw-r--r--config/locales/devise.en.yml2
-rw-r--r--config/locales/en.yml2
-rw-r--r--config/routes.rb7
-rw-r--r--config/routes/admin.rb3
-rw-r--r--config/routes/group.rb2
-rw-r--r--config/routes/import.rb5
-rw-r--r--config/routes/project.rb6
-rw-r--r--config/sidekiq_queues.yml12
-rw-r--r--config/webpack.config.js8
-rw-r--r--danger/metadata/Dangerfile7
-rw-r--r--danger/product_analytics/Dangerfile48
-rw-r--r--danger/telemetry/Dangerfile38
-rw-r--r--data/whats_new/202008180001_12_10.yml4
-rw-r--r--db/fixtures/development/29_instance_statistics.rb6
-rw-r--r--db/migrate/20200905013247_add_golang_package_max_file_size_to_plan_limits.rb9
-rw-r--r--db/migrate/20200907092610_add_user_id_to_group_import_states.rb26
-rw-r--r--db/migrate/20200908094810_add_new_setting_to_namespace_setting.rb19
-rw-r--r--db/migrate/20200909040555_create_package_events.rb19
-rw-r--r--db/migrate/20200909083339_add_change_reviewer_merge_request_to_notification_settings.rb9
-rw-r--r--db/migrate/20200912152943_rename_admin_notification_email_application_setting.rb17
-rw-r--r--db/migrate/20200914070140_add_expiration_policy_started_at_to_container_repositories.rb13
-rw-r--r--db/migrate/20200915134004_add_indices_to_approval_project_rules.rb25
-rw-r--r--db/migrate/20200916135044_add_state_id_index_to_merge_requests.rb17
-rw-r--r--db/migrate/20200916151442_add_result_index_to_authentication_events.rb18
-rw-r--r--db/migrate/20200916165232_add_debian_max_file_size_to_plan_limits.rb9
-rw-r--r--db/migrate/20200921093826_add_index_to_user_preferences.rb17
-rw-r--r--db/migrate/20200921130028_add_pages_deployment_id_to_pages_metadata.rb9
-rw-r--r--db/migrate/20200921131313_add_foreign_key_to_pages_deployment_id_in_project_pages_metadata.rb20
-rw-r--r--db/migrate/20200921203231_remove_duplicate_cluster_agents_index.rb18
-rw-r--r--db/migrate/20200922052316_create_issue_email_participants.rb32
-rw-r--r--db/migrate/20200922093004_add_postgres_index_view.rb33
-rw-r--r--db/migrate/20200923071622_add_description_to_requirements.rb15
-rw-r--r--db/migrate/20200923071644_add_text_limit_to_requirements_description.rb17
-rw-r--r--db/migrate/20200923102312_update_programming_language_colors.rb21
-rw-r--r--db/migrate/20200923130057_remove_tmp_container_scanning_index.rb20
-rw-r--r--db/migrate/20200923140404_add_postgres_reindex_actions_table.rb26
-rw-r--r--db/migrate/20200927224750_add_incident_issue_type_index_to_issues.rb20
-rw-r--r--db/migrate/20200928095732_add_state_to_dast_site_validation.rb12
-rw-r--r--db/migrate/20200928100408_add_text_limit_to_dast_site_validation_state.rb17
-rw-r--r--db/migrate/20200928203531_create_alert_management_http_integrations.rb37
-rw-r--r--db/migrate/20200928210524_add_http_integrations_project_foreign_key.rb19
-rw-r--r--db/migrate/20200928233632_remove_terraform_state_verification_indexes.rb21
-rw-r--r--db/migrate/20200929063159_add_require_admin_approval_after_user_signup_to_application_settings.rb9
-rw-r--r--db/migrate/20200930094812_update_postgres_indexes_view.rb54
-rw-r--r--db/migrate/20200930131343_add_index_on_project_id_and_sha_to_deployments.rb20
-rw-r--r--db/migrate/20200930132319_add_api_fuzzing_to_plan_limits.rb9
-rw-r--r--db/post_migrate/20200511083541_cleanup_projects_with_missing_namespace.rb2
-rw-r--r--db/post_migrate/20200907092715_add_not_null_constraint_to_user_on_group_import_states.rb17
-rw-r--r--db/post_migrate/20200908064229_add_partial_index_to_ci_builds_table_on_user_id_name.rb37
-rw-r--r--db/post_migrate/20200909161624_cleanup_group_import_states_with_null_user_id.rb80
-rw-r--r--db/post_migrate/20200912153218_cleanup_admin_notification_email_application_setting_rename.rb17
-rw-r--r--db/post_migrate/20200915185707_ensure_filled_file_store_on_package_files.rb33
-rw-r--r--db/post_migrate/20200915191156_validate_not_null_file_store_on_package_files.rb21
-rw-r--r--db/post_migrate/20200917135802_remove_duplicated_cs_findings_without_vulnerability_id.rb30
-rw-r--r--db/post_migrate/20200917165525_update_index_on_namespaces_for_type_and_id.rb23
-rw-r--r--db/post_migrate/20200922054642_drop_snowplow_iglu_registry_url_from_application_settings.rb9
-rw-r--r--db/post_migrate/20200922095954_remove_instance_statistics_visibility_private_from_application_settings.rb13
-rw-r--r--db/post_migrate/20200922170907_change_index_on_pipeline_status.rb21
-rw-r--r--db/post_migrate/20200922231755_remove_created_by_user_id_from_cluster_providers_aws.rb26
-rw-r--r--db/post_migrate/20201001022100_validate_designs_filename_text_limit.rb16
-rw-r--r--db/schema_migrations/202009050132471
-rw-r--r--db/schema_migrations/202009070926101
-rw-r--r--db/schema_migrations/202009070927151
-rw-r--r--db/schema_migrations/202009080642291
-rw-r--r--db/schema_migrations/202009080948101
-rw-r--r--db/schema_migrations/202009090405551
-rw-r--r--db/schema_migrations/202009090833391
-rw-r--r--db/schema_migrations/202009091616241
-rw-r--r--db/schema_migrations/202009121529431
-rw-r--r--db/schema_migrations/202009121532181
-rw-r--r--db/schema_migrations/202009140701401
-rw-r--r--db/schema_migrations/202009151340041
-rw-r--r--db/schema_migrations/202009151857071
-rw-r--r--db/schema_migrations/202009151911561
-rw-r--r--db/schema_migrations/202009161350441
-rw-r--r--db/schema_migrations/202009161514421
-rw-r--r--db/schema_migrations/202009161652321
-rw-r--r--db/schema_migrations/202009171358021
-rw-r--r--db/schema_migrations/202009171655251
-rw-r--r--db/schema_migrations/202009210938261
-rw-r--r--db/schema_migrations/202009211300281
-rw-r--r--db/schema_migrations/202009211313131
-rw-r--r--db/schema_migrations/202009212032311
-rw-r--r--db/schema_migrations/202009220523161
-rw-r--r--db/schema_migrations/202009220546421
-rw-r--r--db/schema_migrations/202009220930041
-rw-r--r--db/schema_migrations/202009220959541
-rw-r--r--db/schema_migrations/202009221709071
-rw-r--r--db/schema_migrations/202009222317551
-rw-r--r--db/schema_migrations/202009230716221
-rw-r--r--db/schema_migrations/202009230716441
-rw-r--r--db/schema_migrations/202009231023121
-rw-r--r--db/schema_migrations/202009231300571
-rw-r--r--db/schema_migrations/202009231404041
-rw-r--r--db/schema_migrations/202009272247501
-rw-r--r--db/schema_migrations/202009280957321
-rw-r--r--db/schema_migrations/202009281004081
-rw-r--r--db/schema_migrations/202009282035311
-rw-r--r--db/schema_migrations/202009282105241
-rw-r--r--db/schema_migrations/202009282336321
-rw-r--r--db/schema_migrations/202009290631591
-rw-r--r--db/schema_migrations/202009300948121
-rw-r--r--db/schema_migrations/202009301313431
-rw-r--r--db/schema_migrations/202009301323191
-rw-r--r--db/schema_migrations/202010010221001
-rw-r--r--db/structure.sql229
-rw-r--r--doc/.vale/gitlab/InclusionAbleism.yml14
-rw-r--r--doc/.vale/gitlab/InclusionCultural.yml16
-rw-r--r--doc/.vale/gitlab/InclusionGender.yml18
-rw-r--r--doc/.vale/gitlab/SubstitutionWarning.yml2
-rw-r--r--doc/.vale/gitlab/spelling-exceptions.txt2
-rw-r--r--doc/README.md6
-rw-r--r--doc/administration/audit_events.md9
-rw-r--r--doc/administration/auth/README.md2
-rw-r--r--doc/administration/geo/disaster_recovery/index.md15
-rw-r--r--doc/administration/geo/disaster_recovery/planned_failover.md2
-rw-r--r--doc/administration/geo/disaster_recovery/promotion_runbook.md268
-rw-r--r--doc/administration/geo/disaster_recovery/runbooks/planned_failover_multi_node.md274
-rw-r--r--doc/administration/geo/disaster_recovery/runbooks/planned_failover_single_node.md269
-rw-r--r--doc/administration/geo/index.md10
-rw-r--r--doc/administration/geo/replication/datatypes.md68
-rw-r--r--doc/administration/geo/replication/disable_geo.md2
-rw-r--r--doc/administration/geo/replication/faq.md4
-rw-r--r--doc/administration/geo/replication/geo_validation_tests.md16
-rw-r--r--doc/administration/geo/replication/multiple_servers.md2
-rw-r--r--doc/administration/geo/replication/troubleshooting.md13
-rw-r--r--doc/administration/geo/replication/updating_the_geo_nodes.md7
-rw-r--r--doc/administration/geo/replication/version_specific_updates.md4
-rw-r--r--doc/administration/geo/setup/database.md12
-rw-r--r--doc/administration/gitaly/index.md52
-rw-r--r--doc/administration/gitaly/praefect.md8
-rw-r--r--doc/administration/gitaly/reference.md4
-rw-r--r--doc/administration/housekeeping.md3
-rw-r--r--doc/administration/img/export_audit_log_v13_4.pngbin127518 -> 46643 bytes
-rw-r--r--doc/administration/incoming_email.md2
-rw-r--r--doc/administration/index.md8
-rw-r--r--doc/administration/instance_limits.md5
-rw-r--r--doc/administration/job_artifacts.md29
-rw-r--r--doc/administration/job_logs.md9
-rw-r--r--doc/administration/libravatar.md11
-rw-r--r--doc/administration/logs.md6
-rw-r--r--doc/administration/merge_request_diffs.md45
-rw-r--r--doc/administration/monitoring/gitlab_self_monitoring_project/index.md26
-rw-r--r--doc/administration/monitoring/performance/index.md2
-rw-r--r--doc/administration/monitoring/prometheus/gitlab_metrics.md15
-rw-r--r--doc/administration/monitoring/prometheus/index.md6
-rw-r--r--doc/administration/nfs.md19
-rw-r--r--doc/administration/object_storage.md12
-rw-r--r--doc/administration/packages/container_registry.md163
-rw-r--r--doc/administration/packages/dependency_proxy.md36
-rw-r--r--doc/administration/packages/index.md50
-rw-r--r--doc/administration/pages/index.md10
-rw-r--r--doc/administration/postgresql/external.md6
-rw-r--r--doc/administration/postgresql/index.md5
-rw-r--r--doc/administration/postgresql/pgbouncer.md3
-rw-r--r--doc/administration/postgresql/replication_and_failover.md110
-rw-r--r--doc/administration/postgresql/standalone.md14
-rw-r--r--doc/administration/raketasks/check.md31
-rw-r--r--doc/administration/raketasks/doctor.md5
-rw-r--r--doc/administration/raketasks/github_import.md5
-rw-r--r--doc/administration/read_only_gitlab.md125
-rw-r--r--doc/administration/redis/replication_and_failover.md6
-rw-r--r--doc/administration/redis/replication_and_failover_external.md12
-rw-r--r--doc/administration/redis/troubleshooting.md6
-rw-r--r--doc/administration/reference_architectures/10k_users.md117
-rw-r--r--doc/administration/reference_architectures/1k_users.md23
-rw-r--r--doc/administration/reference_architectures/25k_users.md117
-rw-r--r--doc/administration/reference_architectures/2k_users.md61
-rw-r--r--doc/administration/reference_architectures/3k_users.md86
-rw-r--r--doc/administration/reference_architectures/50k_users.md117
-rw-r--r--doc/administration/reference_architectures/5k_users.md86
-rw-r--r--doc/administration/reference_architectures/index.md2
-rw-r--r--doc/administration/reply_by_email_postfix_setup.md4
-rw-r--r--doc/administration/smime_signing_email.md4
-rw-r--r--doc/administration/snippets/index.md4
-rw-r--r--doc/administration/troubleshooting/elasticsearch.md6
-rw-r--r--doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md4
-rw-r--r--doc/administration/troubleshooting/kubernetes_cheat_sheet.md3
-rw-r--r--doc/administration/troubleshooting/ssl.md2
-rw-r--r--doc/administration/uploads.md6
-rw-r--r--doc/api/README.md45
-rw-r--r--doc/api/api_resources.md2
-rw-r--r--doc/api/container_registry.md37
-rw-r--r--doc/api/epics.md8
-rw-r--r--doc/api/feature_flags.md2
-rw-r--r--doc/api/geo_nodes.md16
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql1375
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json3495
-rw-r--r--doc/api/graphql/reference/index.md239
-rw-r--r--doc/api/groups.md85
-rw-r--r--doc/api/issue_links.md22
-rw-r--r--doc/api/issues.md161
-rw-r--r--doc/api/license.md4
-rw-r--r--doc/api/lint.md49
-rw-r--r--doc/api/members.md52
-rw-r--r--doc/api/merge_request_approvals.md2
-rw-r--r--doc/api/merge_requests.md6
-rw-r--r--doc/api/namespaces.md20
-rw-r--r--doc/api/openapi/openapi.yaml4
-rw-r--r--doc/api/packages.md17
-rw-r--r--doc/api/releases/index.md2
-rw-r--r--doc/api/repositories.md3
-rw-r--r--doc/api/resource_iteration_events.md29
-rw-r--r--doc/api/runners.md47
-rw-r--r--doc/api/search.md72
-rw-r--r--doc/api/services.md4
-rw-r--r--doc/api/settings.md37
-rw-r--r--doc/api/todos.md22
-rw-r--r--doc/api/users.md2
-rw-r--r--doc/api/vulnerabilities.md50
-rw-r--r--doc/api/vulnerability_exports.md22
-rw-r--r--doc/architecture/blueprints/cloud_native_build_logs/index.md137
-rw-r--r--doc/architecture/blueprints/cloud_native_gitlab_pages/index.md131
-rw-r--r--doc/architecture/blueprints/feature_flags_development/index.md136
-rw-r--r--doc/architecture/index.md9
-rw-r--r--doc/ci/README.md2
-rw-r--r--doc/ci/ci_cd_for_external_repos/bitbucket_integration.md2
-rw-r--r--doc/ci/docker/using_docker_build.md22
-rw-r--r--doc/ci/docker/using_docker_images.md37
-rw-r--r--doc/ci/environments/index.md105
-rw-r--r--doc/ci/environments/protected_environments.md2
-rw-r--r--doc/ci/examples/authenticating-with-hashicorp-vault/index.md17
-rw-r--r--doc/ci/examples/end_to_end_testing_webdriverio/index.md2
-rw-r--r--doc/ci/examples/laravel_with_gitlab_and_envoy/index.md6
-rw-r--r--doc/ci/img/ci_lint.pngbin37745 -> 11525 bytes
-rw-r--r--doc/ci/img/ci_lint_dry_run.pngbin18688 -> 6811 bytes
-rw-r--r--doc/ci/img/gitlab_vault_workflow_v13_4.pngbin0 -> 47541 bytes
-rw-r--r--doc/ci/introduction/index.md2
-rw-r--r--doc/ci/metrics_reports.md2
-rw-r--r--doc/ci/migration/jenkins.md4
-rw-r--r--doc/ci/multi_project_pipelines.md8
-rw-r--r--doc/ci/parent_child_pipelines.md4
-rw-r--r--doc/ci/pipelines/img/ci_efficiency_pipeline_dag_critical_path.pngbin124100 -> 35397 bytes
-rw-r--r--doc/ci/pipelines/img/ci_efficiency_pipeline_health_grafana_dashboard.pngbin241128 -> 55200 bytes
-rw-r--r--doc/ci/pipelines/index.md22
-rw-r--r--doc/ci/pipelines/job_artifacts.md11
-rw-r--r--doc/ci/pipelines/settings.md52
-rw-r--r--doc/ci/review_apps/img/toolbar_feeback_form.pngbin24599 -> 0 bytes
-rw-r--r--doc/ci/review_apps/img/toolbar_feedback_form_v13_5.pngbin0 -> 13176 bytes
-rw-r--r--doc/ci/review_apps/index.md92
-rw-r--r--doc/ci/runners/README.md5
-rw-r--r--doc/ci/secrets/index.md27
-rw-r--r--doc/ci/triggers/README.md22
-rw-r--r--doc/ci/unit_test_reports.md2
-rw-r--r--doc/ci/variables/README.md15
-rw-r--r--doc/ci/variables/predefined_variables.md3
-rw-r--r--doc/ci/yaml/README.md31
-rw-r--r--doc/customization/welcome_message.md4
-rw-r--r--doc/development/README.md1
-rw-r--r--doc/development/adding_service_component.md2
-rw-r--r--doc/development/api_graphql_styleguide.md69
-rw-r--r--doc/development/api_styleguide.md2
-rw-r--r--doc/development/architecture.md196
-rw-r--r--doc/development/background_migrations.md23
-rw-r--r--doc/development/changelog.md3
-rw-r--r--doc/development/code_review.md5
-rw-r--r--doc/development/contributing/issue_workflow.md5
-rw-r--r--doc/development/contributing/merge_request_workflow.md2
-rw-r--r--doc/development/database/index.md1
-rw-r--r--doc/development/database/table_partitioning.md253
-rw-r--r--doc/development/database_debugging.md2
-rw-r--r--doc/development/database_review.md4
-rw-r--r--doc/development/documentation/feature_flags.md4
-rw-r--r--doc/development/documentation/graphql_styleguide.md92
-rw-r--r--doc/development/documentation/index.md48
-rw-r--r--doc/development/documentation/restful_api_styleguide.md180
-rw-r--r--doc/development/documentation/styleguide.md382
-rw-r--r--doc/development/elasticsearch.md2
-rw-r--r--doc/development/emails.md2
-rw-r--r--doc/development/experiment_guide/index.md141
-rw-r--r--doc/development/fe_guide/architecture.md4
-rw-r--r--doc/development/fe_guide/dependencies.md6
-rw-r--r--doc/development/fe_guide/icons.md28
-rw-r--r--doc/development/fe_guide/tooling.md5
-rw-r--r--doc/development/fe_guide/vuex.md5
-rw-r--r--doc/development/feature_categorization/index.md36
-rw-r--r--doc/development/feature_flags/development.md4
-rw-r--r--doc/development/frontend.md7
-rw-r--r--doc/development/geo/framework.md42
-rw-r--r--doc/development/gotchas.md15
-rw-r--r--doc/development/graphql_guide/index.md2
-rw-r--r--doc/development/i18n/externalization.md2
-rw-r--r--doc/development/i18n/index.md6
-rw-r--r--doc/development/integrations/secure.md6
-rw-r--r--doc/development/integrations/secure_partner_integration.md8
-rw-r--r--doc/development/kubernetes.md51
-rw-r--r--doc/development/migration_style_guide.md2
-rw-r--r--doc/development/new_fe_guide/development/accessibility.md2
-rw-r--r--doc/development/new_fe_guide/development/index.md2
-rw-r--r--doc/development/packages.md2
-rw-r--r--doc/development/pipelines.md2
-rw-r--r--doc/development/profiling.md3
-rw-r--r--doc/development/prometheus.md10
-rw-r--r--doc/development/reactive_caching.md6
-rw-r--r--doc/development/redis.md10
-rw-r--r--doc/development/reference_processing.md16
-rw-r--r--doc/development/secure_coding_guidelines.md3
-rw-r--r--doc/development/shell_scripting_guide/index.md4
-rw-r--r--doc/development/sql.md2
-rw-r--r--doc/development/telemetry/snowplow.md5
-rw-r--r--doc/development/telemetry/usage_ping.md69
-rw-r--r--doc/development/testing_guide/best_practices.md14
-rw-r--r--doc/development/testing_guide/ci.md3
-rw-r--r--doc/development/testing_guide/end_to_end/best_practices.md53
-rw-r--r--doc/development/testing_guide/end_to_end/feature_flags.md57
-rw-r--r--doc/gitlab-basics/README.md2
-rw-r--r--doc/gitlab-basics/create-branch.md4
-rw-r--r--doc/gitlab-basics/create-project.md4
-rw-r--r--doc/gitlab-geo/after_setup.md5
-rw-r--r--doc/gitlab-geo/bring-primary-back.md5
-rw-r--r--doc/gitlab-geo/configuration.md5
-rw-r--r--doc/gitlab-geo/configuration_source.md5
-rw-r--r--doc/gitlab-geo/database.md5
-rw-r--r--doc/gitlab-geo/database_source.md5
-rw-r--r--doc/gitlab-geo/disaster-recovery.md5
-rw-r--r--doc/gitlab-geo/docker_registry.md5
-rw-r--r--doc/gitlab-geo/faq.md5
-rw-r--r--doc/gitlab-geo/ha.md5
-rw-r--r--doc/gitlab-geo/object_storage.md5
-rw-r--r--doc/gitlab-geo/planned-failover.md5
-rw-r--r--doc/gitlab-geo/security-review.md5
-rw-r--r--doc/gitlab-geo/ssh.md5
-rw-r--r--doc/gitlab-geo/troubleshooting.md5
-rw-r--r--doc/gitlab-geo/tuning.md5
-rw-r--r--doc/gitlab-geo/updating_the_geo_nodes.md5
-rw-r--r--doc/gitlab-geo/using_a_geo_server.md5
-rw-r--r--doc/install/README.md5
-rw-r--r--doc/install/aws/index.md3
-rw-r--r--doc/install/azure/index.md2
-rw-r--r--doc/install/docker.md3
-rw-r--r--doc/install/google_cloud_platform/index.md3
-rw-r--r--doc/install/installation.md3
-rw-r--r--doc/install/openshift_and_gitlab/index.md6
-rw-r--r--doc/install/relative_url.md3
-rw-r--r--doc/install/requirements.md3
-rw-r--r--doc/install/structure.md2
-rw-r--r--doc/integration/README.md2
-rw-r--r--doc/integration/cas.md8
-rw-r--r--doc/integration/elasticsearch.md230
-rw-r--r--doc/integration/facebook.md2
-rw-r--r--doc/integration/github.md24
-rw-r--r--doc/integration/gitlab.md7
-rw-r--r--doc/integration/gitpod.md1
-rw-r--r--doc/integration/google.md7
-rw-r--r--doc/integration/jira_development_panel.md2
-rw-r--r--doc/integration/kerberos.md3
-rw-r--r--doc/integration/omniauth.md28
-rw-r--r--doc/integration/salesforce.md2
-rw-r--r--doc/integration/saml.md116
-rw-r--r--doc/integration/twitter.md3
-rw-r--r--doc/intro/README.md2
-rw-r--r--doc/operations/feature_flags.md31
-rw-r--r--doc/operations/incident_management/alert_details.md199
-rw-r--r--doc/operations/incident_management/alert_integrations.md132
-rw-r--r--doc/operations/incident_management/alert_notifications.md35
-rw-r--r--doc/operations/incident_management/alerts.md307
-rw-r--r--doc/operations/incident_management/generic_alerts.md125
-rw-r--r--doc/operations/incident_management/incidents.md9
-rw-r--r--doc/operations/incident_management/index.md25
-rw-r--r--doc/operations/index.md2
-rw-r--r--doc/operations/metrics/alerts.md5
-rw-r--r--doc/operations/metrics/dashboards/img/metrics_dashboard_panel_preview_v13_3.pngbin67972 -> 45857 bytes
-rw-r--r--doc/operations/metrics/dashboards/index.md8
-rw-r--r--doc/operations/metrics/dashboards/panel_types.md70
-rw-r--r--doc/operations/metrics/dashboards/yaml.md12
-rw-r--r--doc/push_rules/push_rules.md40
-rw-r--r--doc/raketasks/README.md2
-rw-r--r--doc/raketasks/backup_restore.md629
-rw-r--r--doc/security/rack_attack.md2
-rw-r--r--doc/security/two_factor_authentication.md11
-rw-r--r--doc/ssh/README.md2
-rw-r--r--doc/topics/autodevops/customize.md2
-rw-r--r--doc/topics/autodevops/index.md17
-rw-r--r--doc/topics/autodevops/stages.md17
-rw-r--r--doc/topics/autodevops/upgrading_auto_deploy_dependencies.md262
-rw-r--r--doc/topics/autodevops/upgrading_chart.md75
-rw-r--r--doc/university/README.md2
-rw-r--r--doc/university/training/index.md2
-rw-r--r--doc/update/10.0-ce-to-ee.md5
-rw-r--r--doc/update/10.0-to-10.1.md5
-rw-r--r--doc/update/10.1-ce-to-ee.md5
-rw-r--r--doc/update/10.1-to-10.2.md5
-rw-r--r--doc/update/10.2-ce-to-ee.md5
-rw-r--r--doc/update/10.2-to-10.3.md5
-rw-r--r--doc/update/10.3-ce-to-ee.md5
-rw-r--r--doc/update/10.3-to-10.4.md5
-rw-r--r--doc/update/10.4-ce-to-ee.md5
-rw-r--r--doc/update/10.4-to-10.5.md5
-rw-r--r--doc/update/10.5-ce-to-ee.md5
-rw-r--r--doc/update/10.5-to-10.6.md5
-rw-r--r--doc/update/10.6-ce-to-ee.md5
-rw-r--r--doc/update/10.6-to-10.7.md5
-rw-r--r--doc/update/10.7-ce-to-ee.md5
-rw-r--r--doc/update/10.7-to-10.8.md5
-rw-r--r--doc/update/10.8-ce-to-ee.md5
-rw-r--r--doc/update/10.8-to-11.0.md5
-rw-r--r--doc/update/11.0-ce-to-ee.md5
-rw-r--r--doc/update/11.0-to-11.1.md5
-rw-r--r--doc/update/11.1-ce-to-ee.md5
-rw-r--r--doc/update/11.1-to-11.2.md5
-rw-r--r--doc/update/11.2-ce-to-ee.md5
-rw-r--r--doc/update/11.2-to-11.3.md5
-rw-r--r--doc/update/11.3-ce-to-ee.md5
-rw-r--r--doc/update/11.3-to-11.4.md5
-rw-r--r--doc/update/11.4-ce-to-ee.md5
-rw-r--r--doc/update/11.4-to-11.5.md5
-rw-r--r--doc/update/11.5-ce-to-ee.md5
-rw-r--r--doc/update/11.5-to-11.6.md5
-rw-r--r--doc/update/11.6-ce-to-ee.md5
-rw-r--r--doc/update/11.6-to-11.7.md5
-rw-r--r--doc/update/11.7-ce-to-ee.md5
-rw-r--r--doc/update/11.7-to-11.8.md5
-rw-r--r--doc/update/11.8-ce-to-ee.md5
-rw-r--r--doc/update/2.6-to-3.0.md5
-rw-r--r--doc/update/2.9-to-3.0.md5
-rw-r--r--doc/update/3.0-to-3.1.md5
-rw-r--r--doc/update/3.1-to-4.0.md5
-rw-r--r--doc/update/4.0-to-4.1.md5
-rw-r--r--doc/update/4.1-to-4.2.md5
-rw-r--r--doc/update/4.2-to-5.0.md5
-rw-r--r--doc/update/5.0-to-5.1.md5
-rw-r--r--doc/update/5.1-to-5.2.md5
-rw-r--r--doc/update/5.1-to-5.4.md5
-rw-r--r--doc/update/5.1-to-6.0.md5
-rw-r--r--doc/update/5.2-to-5.3.md5
-rw-r--r--doc/update/5.3-to-5.4.md5
-rw-r--r--doc/update/5.4-to-6.0.md5
-rw-r--r--doc/update/6.0-ce-to-ee.md5
-rw-r--r--doc/update/6.0-to-6.1.md5
-rw-r--r--doc/update/6.1-ce-to-ee.md5
-rw-r--r--doc/update/6.1-to-6.2.md5
-rw-r--r--doc/update/6.2-ce-to-ee.md5
-rw-r--r--doc/update/6.2-to-6.3.md5
-rw-r--r--doc/update/6.3-ce-to-ee.md5
-rw-r--r--doc/update/6.3-to-6.4.md5
-rw-r--r--doc/update/6.4-ce-to-ee.md5
-rw-r--r--doc/update/6.4-to-6.5.md5
-rw-r--r--doc/update/6.5-ce-to-ee.md5
-rw-r--r--doc/update/6.5-to-6.6.md5
-rw-r--r--doc/update/6.6-ce-to-ee.md5
-rw-r--r--doc/update/6.6-to-6.7.md5
-rw-r--r--doc/update/6.7-ce-to-ee.md5
-rw-r--r--doc/update/6.7-to-6.8.md5
-rw-r--r--doc/update/6.8-ce-to-ee.md5
-rw-r--r--doc/update/6.8-to-6.9.md5
-rw-r--r--doc/update/6.9-ce-to-ee.md5
-rw-r--r--doc/update/6.9-to-7.0.md5
-rw-r--r--doc/update/6.x-or-7.x-to-7.14.md5
-rw-r--r--doc/update/7.0-ce-to-ee.md5
-rw-r--r--doc/update/7.0-to-7.1.md5
-rw-r--r--doc/update/7.1-ce-to-ee.md5
-rw-r--r--doc/update/7.1-to-7.2.md5
-rw-r--r--doc/update/7.10-ce-to-ee.md5
-rw-r--r--doc/update/7.10-to-7.11.md5
-rw-r--r--doc/update/7.11-ce-to-ee.md5
-rw-r--r--doc/update/7.11-to-7.12.md5
-rw-r--r--doc/update/7.12-ce-to-ee.md5
-rw-r--r--doc/update/7.12-to-7.13.md5
-rw-r--r--doc/update/7.13-ce-to-ee.md5
-rw-r--r--doc/update/7.13-to-7.14.md5
-rw-r--r--doc/update/7.14-ce-to-ee.md5
-rw-r--r--doc/update/7.14-to-8.0.md5
-rw-r--r--doc/update/7.2-to-7.3.md5
-rw-r--r--doc/update/7.3-ce-to-ee.md5
-rw-r--r--doc/update/7.3-to-7.4.md5
-rw-r--r--doc/update/7.4-ce-to-ee.md5
-rw-r--r--doc/update/7.4-to-7.5.md5
-rw-r--r--doc/update/7.5-ce-to-ee.md5
-rw-r--r--doc/update/7.5-to-7.6.md5
-rw-r--r--doc/update/7.6-ce-to-ee.md5
-rw-r--r--doc/update/7.6-to-7.7.md5
-rw-r--r--doc/update/7.7-ce-to-ee.md5
-rw-r--r--doc/update/7.7-to-7.8.md5
-rw-r--r--doc/update/7.8-ce-to-ee.md5
-rw-r--r--doc/update/7.8-to-7.9.md5
-rw-r--r--doc/update/7.9-ce-to-ee.md5
-rw-r--r--doc/update/7.9-to-7.10.md5
-rw-r--r--doc/update/8.0-ce-to-ee.md5
-rw-r--r--doc/update/8.0-to-8.1.md5
-rw-r--r--doc/update/8.1-ce-to-ee.md5
-rw-r--r--doc/update/8.1-to-8.2.md5
-rw-r--r--doc/update/8.10-ce-to-ee.md5
-rw-r--r--doc/update/8.10-to-8.11.md5
-rw-r--r--doc/update/8.11-ce-to-ee.md5
-rw-r--r--doc/update/8.11-to-8.12.md5
-rw-r--r--doc/update/8.12-ce-to-ee.md5
-rw-r--r--doc/update/8.12-to-8.13.md5
-rw-r--r--doc/update/8.13-ce-to-ee.md5
-rw-r--r--doc/update/8.13-to-8.14.md5
-rw-r--r--doc/update/8.14-ce-to-ee.md5
-rw-r--r--doc/update/8.14-to-8.15.md5
-rw-r--r--doc/update/8.15-ce-to-ee.md5
-rw-r--r--doc/update/8.15-to-8.16.md5
-rw-r--r--doc/update/8.16-ce-to-ee.md5
-rw-r--r--doc/update/8.16-to-8.17.md5
-rw-r--r--doc/update/8.17-ce-to-ee.md5
-rw-r--r--doc/update/8.17-to-9.0.md5
-rw-r--r--doc/update/8.2-ce-to-ee.md5
-rw-r--r--doc/update/8.2-to-8.3.md5
-rw-r--r--doc/update/8.3-ce-to-ee.md5
-rw-r--r--doc/update/8.3-to-8.4.md5
-rw-r--r--doc/update/8.4-ce-to-ee.md5
-rw-r--r--doc/update/8.4-to-8.5.md5
-rw-r--r--doc/update/8.5-ce-to-ee.md5
-rw-r--r--doc/update/8.5-to-8.6.md5
-rw-r--r--doc/update/8.6-ce-to-ee.md5
-rw-r--r--doc/update/8.6-to-8.7.md5
-rw-r--r--doc/update/8.7-ce-to-ee.md5
-rw-r--r--doc/update/8.7-to-8.8.md5
-rw-r--r--doc/update/8.8-ce-to-ee.md5
-rw-r--r--doc/update/8.8-to-8.9.md5
-rw-r--r--doc/update/8.9-ce-to-ee.md5
-rw-r--r--doc/update/8.9-to-8.10.md5
-rw-r--r--doc/update/9.0-ce-to-ee.md5
-rw-r--r--doc/update/9.0-to-9.1.md5
-rw-r--r--doc/update/9.1-ce-to-ee.md5
-rw-r--r--doc/update/9.1-to-9.2.md5
-rw-r--r--doc/update/9.2-ce-to-ee.md5
-rw-r--r--doc/update/9.2-to-9.3.md5
-rw-r--r--doc/update/9.3-ce-to-ee.md5
-rw-r--r--doc/update/9.3-to-9.4.md5
-rw-r--r--doc/update/9.4-ce-to-ee.md5
-rw-r--r--doc/update/9.4-to-9.5.md5
-rw-r--r--doc/update/9.5-ce-to-ee.md5
-rw-r--r--doc/update/9.5-to-10.0.md5
-rw-r--r--doc/user/admin_area/abuse_reports.md18
-rw-r--r--doc/user/admin_area/activating_deactivating_users.md2
-rw-r--r--doc/user/admin_area/analytics/img/cohorts_v13_4.pngbin92729 -> 31518 bytes
-rw-r--r--doc/user/admin_area/analytics/img/dev_ops_report_v13_4.pngbin167541 -> 51758 bytes
-rw-r--r--doc/user/admin_area/analytics/index.md3
-rw-r--r--doc/user/admin_area/analytics/instance_statistics.md18
-rw-r--r--doc/user/admin_area/credentials_inventory.md2
-rw-r--r--doc/user/admin_area/settings/gitaly_timeouts.md40
-rw-r--r--doc/user/admin_area/settings/img/gitaly_timeouts.pngbin24654 -> 0 bytes
-rw-r--r--doc/user/admin_area/settings/index.md4
-rw-r--r--doc/user/admin_area/settings/instance_template_repository.md2
-rw-r--r--doc/user/analytics/img/mr_throughput_chart_v13_3.pngbin62510 -> 17870 bytes
-rw-r--r--doc/user/analytics/img/mr_throughput_table_v13_3.pngbin55637 -> 20273 bytes
-rw-r--r--doc/user/analytics/img/new_value_stream_v13_3.pngbin82797 -> 31215 bytes
-rw-r--r--doc/user/analytics/img/vsa_filter_bar_v13.3.pngbin117834 -> 33694 bytes
-rw-r--r--doc/user/analytics/productivity_analytics.md2
-rw-r--r--doc/user/analytics/value_stream_analytics.md22
-rw-r--r--doc/user/application_security/configuration/index.md10
-rw-r--r--doc/user/application_security/container_scanning/index.md16
-rw-r--r--doc/user/application_security/dast/img/dast_v13_2.pngbin6763 -> 0 bytes
-rw-r--r--doc/user/application_security/dast/img/dast_v13_4.pngbin0 -> 6558 bytes
-rw-r--r--doc/user/application_security/dast/index.md88
-rw-r--r--doc/user/application_security/dependency_scanning/index.md118
-rw-r--r--doc/user/application_security/img/cve_request_communication.pngbin45402 -> 17386 bytes
-rw-r--r--doc/user/application_security/img/cve_request_communication_publication.pngbin66617 -> 24126 bytes
-rw-r--r--doc/user/application_security/img/new_cve_request_issue.pngbin96795 -> 36847 bytes
-rw-r--r--doc/user/application_security/img/unconfigured_security_approval_rules_and_enabled_jobs_v13_4.pngbin99883 -> 35553 bytes
-rw-r--r--doc/user/application_security/img/unconfigured_security_approval_rules_and_jobs_v13_4.pngbin82526 -> 29773 bytes
-rw-r--r--doc/user/application_security/img/vulnerability-check_v13_4.pngbin75105 -> 25832 bytes
-rw-r--r--doc/user/application_security/img/vulnerability_solution.pngbin3419 -> 9750 bytes
-rw-r--r--doc/user/application_security/index.md29
-rw-r--r--doc/user/application_security/sast/analyzers.md6
-rw-r--r--doc/user/application_security/sast/index.md83
-rw-r--r--doc/user/application_security/secret_detection/index.md57
-rw-r--r--doc/user/application_security/security_dashboard/img/group_vulnerability_report_v13_4.pngbin42099 -> 50648 bytes
-rw-r--r--doc/user/application_security/security_dashboard/img/instance_security_center_settings_v13_4.pngbin0 -> 30034 bytes
-rw-r--r--doc/user/application_security/security_dashboard/img/instance_security_dashboard_empty_v13_4.pngbin38731 -> 13264 bytes
-rw-r--r--doc/user/application_security/security_dashboard/img/instance_security_dashboard_v13_4.pngbin62615 -> 29797 bytes
-rw-r--r--doc/user/application_security/security_dashboard/img/project_security_dashboard_dismissal_v13_4.pngbin0 -> 53541 bytes
-rw-r--r--doc/user/application_security/security_dashboard/img/project_security_dashboard_v13_3.pngbin168847 -> 69112 bytes
-rw-r--r--doc/user/application_security/security_dashboard/img/project_security_dashboard_v13_4.pngbin0 -> 58167 bytes
-rw-r--r--doc/user/application_security/security_dashboard/img/vulnerability_list_table_v13_4.pngbin79904 -> 61329 bytes
-rw-r--r--doc/user/application_security/security_dashboard/index.md141
-rw-r--r--doc/user/application_security/terminology/index.md1
-rw-r--r--doc/user/application_security/threat_monitoring/index.md24
-rw-r--r--doc/user/application_security/vulnerabilities/index.md11
-rw-r--r--doc/user/clusters/agent/index.md226
-rw-r--r--doc/user/clusters/applications.md358
-rw-r--r--doc/user/compliance/compliance_dashboard/img/compliance_dashboard_v13_3_1.pngbin298542 -> 71374 bytes
-rw-r--r--doc/user/compliance/license_compliance/img/license-check_v13_4.pngbin74407 -> 25590 bytes
-rw-r--r--doc/user/compliance/license_compliance/index.md7
-rw-r--r--doc/user/gitlab_com/index.md12
-rw-r--r--doc/user/group/clusters/index.md3
-rw-r--r--doc/user/group/contribution_analytics/index.md2
-rw-r--r--doc/user/group/index.md2
-rw-r--r--doc/user/group/repositories_analytics/index.md2
-rw-r--r--doc/user/group/saml_sso/index.md49
-rw-r--r--doc/user/group/saml_sso/scim_setup.md18
-rw-r--r--doc/user/img/feature_flags_history_note_info_v13_2.pngbin67034 -> 21794 bytes
-rw-r--r--doc/user/img/version_history_notes_collapsed_v13_2.pngbin27528 -> 7770 bytes
-rw-r--r--doc/user/index.md6
-rw-r--r--doc/user/infrastructure/index.md4
-rw-r--r--doc/user/instance/clusters/index.md4
-rw-r--r--doc/user/packages/composer_repository/img/project_id_v13_5.pngbin0 -> 9264 bytes
-rw-r--r--doc/user/packages/composer_repository/index.md227
-rw-r--r--doc/user/packages/conan_repository/img/conan_package_view.pngbin46391 -> 0 bytes
-rw-r--r--doc/user/packages/conan_repository/index.md382
-rw-r--r--doc/user/packages/container_registry/img/container_registry_group_repositories_v13_1.pngbin45865 -> 0 bytes
-rw-r--r--doc/user/packages/container_registry/img/container_registry_hover_path_13_4.pngbin0 -> 13597 bytes
-rw-r--r--doc/user/packages/container_registry/img/container_registry_repositories_v13_1.pngbin46734 -> 0 bytes
-rw-r--r--doc/user/packages/container_registry/img/container_registry_repositories_with_quickstart_v13_1.pngbin51791 -> 0 bytes
-rw-r--r--doc/user/packages/container_registry/img/container_registry_repository_details_v13.0.pngbin32673 -> 0 bytes
-rw-r--r--doc/user/packages/container_registry/index.md161
-rw-r--r--doc/user/packages/dependency_proxy/img/group_dependency_proxy.pngbin29334 -> 0 bytes
-rw-r--r--doc/user/packages/dependency_proxy/index.md92
-rw-r--r--doc/user/packages/go_proxy/index.md2
-rw-r--r--doc/user/packages/npm_registry/index.md19
-rw-r--r--doc/user/packages/nuget_repository/index.md30
-rw-r--r--doc/user/packages/package_registry/index.md12
-rw-r--r--doc/user/packages/pypi_repository/index.md4
-rw-r--r--doc/user/packages/workflows/project_registry.md4
-rw-r--r--doc/user/permissions.md28
-rw-r--r--doc/user/profile/account/create_accounts.md6
-rw-r--r--doc/user/profile/personal_access_tokens.md2
-rw-r--r--doc/user/project/clusters/add_eks_clusters.md34
-rw-r--r--doc/user/project/clusters/add_remove_clusters.md18
-rw-r--r--doc/user/project/clusters/index.md50
-rw-r--r--doc/user/project/clusters/serverless/aws.md7
-rw-r--r--doc/user/project/deploy_keys/index.md2
-rw-r--r--doc/user/project/description_templates.md7
-rw-r--r--doc/user/project/img/issue_board_default_lists_v13_4.pngbin0 -> 14866 bytes
-rw-r--r--doc/user/project/img/issue_board_welcome_message.pngbin13519 -> 0 bytes
-rw-r--r--doc/user/project/import/bitbucket.md3
-rw-r--r--doc/user/project/import/bitbucket_server.md46
-rw-r--r--doc/user/project/import/github.md33
-rw-r--r--doc/user/project/import/img/manifest_status_v13_3.pngbin90811 -> 31313 bytes
-rw-r--r--doc/user/project/index.md6
-rw-r--r--doc/user/project/integrations/prometheus_library/kubernetes.md2
-rw-r--r--doc/user/project/issue_board.md23
-rw-r--r--doc/user/project/issues/design_management.md39
-rw-r--r--doc/user/project/issues/due_dates.md2
-rw-r--r--doc/user/project/issues/img/designs_reordering_v13_3.gifbin7068938 -> 0 bytes
-rw-r--r--doc/user/project/issues/index.md5
-rw-r--r--doc/user/project/issues/issue_data_and_actions.md6
-rw-r--r--doc/user/project/issues/managing_issues.md1
-rw-r--r--doc/user/project/issues/multiple_assignees_for_issues.md2
-rw-r--r--doc/user/project/issues/related_issues.md23
-rw-r--r--doc/user/project/labels.md2
-rw-r--r--doc/user/project/merge_requests/img/commit_nav_v13_4.pngbin0 -> 21170 bytes
-rw-r--r--doc/user/project/merge_requests/reviewing_and_managing_merge_requests.md2
-rw-r--r--doc/user/project/merge_requests/test_coverage_visualization.md101
-rw-r--r--doc/user/project/milestones/burndown_charts.md2
-rw-r--r--doc/user/project/milestones/index.md2
-rw-r--r--doc/user/project/new_ci_build_permissions_model.md5
-rw-r--r--doc/user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md2
-rw-r--r--doc/user/project/pages/getting_started_part_three.md4
-rw-r--r--doc/user/project/pages/index.md21
-rw-r--r--doc/user/project/quick_actions.md4
-rw-r--r--doc/user/project/repository/reducing_the_repo_size_using_git.md14
-rw-r--r--doc/user/project/service_desk.md4
-rw-r--r--doc/user/project/settings/index.md2
-rw-r--r--doc/user/project/settings/project_access_tokens.md7
-rw-r--r--doc/user/project/static_site_editor/img/front_matter_ui_v13_4.pngbin0 -> 36431 bytes
-rw-r--r--doc/user/project/static_site_editor/index.md12
-rw-r--r--doc/user/project/web_ide/index.md63
-rw-r--r--doc/user/project/wiki/index.md37
-rw-r--r--doc/user/search/advanced_global_search.md4
-rw-r--r--doc/user/search/advanced_search_syntax.md6
-rw-r--r--doc/user/search/img/basic_search.pngbin0 -> 88486 bytes
-rw-r--r--doc/user/search/img/basic_search_results.pngbin0 -> 19901 bytes
-rw-r--r--doc/user/search/index.md79
-rw-r--r--doc/user/snippets.md4
-rw-r--r--doc/user/todos.md69
-rw-r--r--geo_architecture.pngbin0 -> 226694 bytes
-rw-r--r--haml_lint/linter/documentation_links.rb2
-rw-r--r--lib/api/admin/instance_clusters.rb2
-rw-r--r--lib/api/api.rb5
-rw-r--r--lib/api/boards_responses.rb2
-rw-r--r--lib/api/ci/runner.rb1
-rw-r--r--lib/api/commits.rb30
-rw-r--r--lib/api/composer_packages.rb2
-rw-r--r--lib/api/conan_package_endpoints.rb2
-rw-r--r--lib/api/debian_group_packages.rb21
-rw-r--r--lib/api/debian_package_endpoints.rb124
-rw-r--r--lib/api/debian_project_packages.rb56
-rw-r--r--lib/api/entities/cluster.rb2
-rw-r--r--lib/api/entities/container_registry.rb1
-rw-r--r--lib/api/entities/feature_flag.rb16
-rw-r--r--lib/api/entities/feature_flag/detailed_legacy_scope.rb11
-rw-r--r--lib/api/entities/feature_flag/legacy_scope.rb16
-rw-r--r--lib/api/entities/feature_flag/scope.rb12
-rw-r--r--lib/api/entities/feature_flag/strategy.rb14
-rw-r--r--lib/api/entities/feature_flag/user_list.rb27
-rw-r--r--lib/api/entities/package.rb14
-rw-r--r--lib/api/entities/unleash_feature.rb32
-rw-r--r--lib/api/entities/unleash_gitlab_user_list_strategy.rb14
-rw-r--r--lib/api/entities/unleash_legacy_strategy.rb14
-rw-r--r--lib/api/entities/unleash_strategy.rb10
-rw-r--r--lib/api/entities/user_with_admin.rb2
-rw-r--r--lib/api/generic_packages.rb88
-rw-r--r--lib/api/github/entities.rb4
-rw-r--r--lib/api/group_clusters.rb2
-rw-r--r--lib/api/group_container_repositories.rb4
-rw-r--r--lib/api/groups.rb20
-rw-r--r--lib/api/helpers.rb1
-rw-r--r--lib/api/helpers/packages/conan/api_helpers.rb4
-rw-r--r--lib/api/helpers/packages/dependency_proxy_helpers.rb1
-rw-r--r--lib/api/helpers/packages_helpers.rb5
-rw-r--r--lib/api/helpers/pagination.rb4
-rw-r--r--lib/api/helpers/presentable.rb2
-rw-r--r--lib/api/helpers/runner.rb4
-rw-r--r--lib/api/helpers/services_helpers.rb6
-rw-r--r--lib/api/helpers/settings_helpers.rb1
-rw-r--r--lib/api/internal/lfs.rb54
-rw-r--r--lib/api/lint.rb17
-rw-r--r--lib/api/maven_packages.rb8
-rw-r--r--lib/api/npm_packages.rb4
-rw-r--r--lib/api/nuget_packages.rb10
-rw-r--r--lib/api/project_clusters.rb2
-rw-r--r--lib/api/project_container_repositories.rb11
-rw-r--r--lib/api/project_export.rb2
-rw-r--r--lib/api/project_import.rb2
-rw-r--r--lib/api/pypi_packages.rb6
-rw-r--r--lib/api/search.rb10
-rw-r--r--lib/api/settings.rb8
-rw-r--r--lib/api/terraform/state_version.rb68
-rw-r--r--lib/api/unleash.rb77
-rw-r--r--lib/api/users.rb4
-rw-r--r--lib/api/v3/github.rb2
-rw-r--r--lib/backup/artifacts.rb4
-rw-r--r--lib/backup/builds.rb4
-rw-r--r--lib/backup/lfs.rb4
-rw-r--r--lib/backup/pages.rb4
-rw-r--r--lib/backup/registry.rb4
-rw-r--r--lib/backup/repositories.rb236
-rw-r--r--lib/backup/repository.rb265
-rw-r--r--lib/backup/uploads.rb4
-rw-r--r--lib/banzai/filter/design_reference_filter.rb14
-rw-r--r--lib/banzai/reference_parser/mentioned_group_parser.rb2
-rw-r--r--lib/feature.rb2
-rw-r--r--lib/feature/definition.rb33
-rw-r--r--lib/feature/shared.rb12
-rw-r--r--lib/gitlab/alert_management/alert_params.rb46
-rw-r--r--lib/gitlab/alert_management/alert_status_counts.rb2
-rw-r--r--lib/gitlab/alert_management/payload/generic.rb4
-rw-r--r--lib/gitlab/alerting/alert.rb215
-rw-r--r--lib/gitlab/alerting/alert_annotation.rb11
-rw-r--r--lib/gitlab/alerting/notification_payload_parser.rb107
-rw-r--r--lib/gitlab/auth.rb2
-rw-r--r--lib/gitlab/auth/auth_finders.rb2
-rw-r--r--lib/gitlab/background_migration/backfill_snippet_repositories.rb2
-rw-r--r--lib/gitlab/background_migration/migrate_users_bio_to_user_details.rb2
-rw-r--r--lib/gitlab/background_migration/remove_duplicated_cs_findings_without_vulnerability_id.rb13
-rw-r--r--lib/gitlab/background_migration/user_mentions/lib/banzai/reference_parser.rb25
-rw-r--r--lib/gitlab/background_migration/user_mentions/lib/banzai/reference_parser/isolated_mentioned_group_parser.rb25
-rw-r--r--lib/gitlab/background_migration/user_mentions/lib/gitlab/isolated_reference_extractor.rb30
-rw-r--r--lib/gitlab/background_migration/user_mentions/models/concerns/isolated_mentionable.rb5
-rw-r--r--lib/gitlab/background_migration/user_mentions/models/concerns/namespace/recursive_traversal.rb74
-rw-r--r--lib/gitlab/background_migration/user_mentions/models/group.rb91
-rw-r--r--lib/gitlab/background_migration/user_mentions/models/namespace.rb33
-rw-r--r--lib/gitlab/checks/matching_merge_request.rb2
-rw-r--r--lib/gitlab/ci/ansi2json/converter.rb23
-rw-r--r--lib/gitlab/ci/ansi2json/line.rb7
-rw-r--r--lib/gitlab/ci/ansi2json/state.rb3
-rw-r--r--lib/gitlab/ci/config/entry/product/matrix.rb2
-rw-r--r--lib/gitlab/ci/config/entry/product/variables.rb6
-rw-r--r--lib/gitlab/ci/features.rb18
-rw-r--r--lib/gitlab/ci/pipeline/chain/command.rb2
-rw-r--r--lib/gitlab/ci/pipeline/chain/create.rb2
-rw-r--r--lib/gitlab/ci/runner/backoff.rb75
-rw-r--r--lib/gitlab/ci/templates/Jobs/Deploy.latest.gitlab-ci.yml9
-rw-r--r--lib/gitlab/ci/templates/Jobs/Deploy/ECS.gitlab-ci.yml1
-rw-r--r--lib/gitlab/ci/trace.rb7
-rw-r--r--lib/gitlab/ci/trace/checksum.rb77
-rw-r--r--lib/gitlab/ci/trace/metrics.rb5
-rw-r--r--lib/gitlab/ci/variables/collection/item.rb4
-rw-r--r--lib/gitlab/ci/yaml_processor/result.rb4
-rw-r--r--lib/gitlab/cleanup/orphan_lfs_file_references.rb5
-rw-r--r--lib/gitlab/code_navigation_path.rb1
-rw-r--r--lib/gitlab/config/entry/factory.rb2
-rw-r--r--lib/gitlab/config/entry/simplifiable.rb2
-rw-r--r--lib/gitlab/conflict/file_collection.rb8
-rw-r--r--lib/gitlab/danger/commit_linter.rb4
-rw-r--r--lib/gitlab/danger/helper.rb6
-rw-r--r--lib/gitlab/danger/roulette.rb10
-rw-r--r--lib/gitlab/danger/teammate.rb7
-rw-r--r--lib/gitlab/database.rb26
-rw-r--r--lib/gitlab/database/batch_count.rb25
-rw-r--r--lib/gitlab/database/concurrent_reindex.rb143
-rw-r--r--lib/gitlab/database/count/reltuples_count_strategy.rb3
-rw-r--r--lib/gitlab/database/migration_helpers.rb37
-rw-r--r--lib/gitlab/database/partitioning/partition_creator.rb4
-rw-r--r--lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table.rb2
-rw-r--r--lib/gitlab/database/postgres_index.rb31
-rw-r--r--lib/gitlab/database/reindexing.rb22
-rw-r--r--lib/gitlab/database/reindexing/concurrent_reindex.rb120
-rw-r--r--lib/gitlab/database/reindexing/reindex_action.rb35
-rw-r--r--lib/gitlab/database/schema_helpers.rb17
-rw-r--r--lib/gitlab/database/similarity_score.rb11
-rw-r--r--lib/gitlab/database/with_lock_retries.rb8
-rw-r--r--lib/gitlab/design_management/copy_design_collection_model_attributes.yml43
-rw-r--r--lib/gitlab/diff/file_collection/merge_request_diff_base.rb7
-rw-r--r--lib/gitlab/diff/highlight_cache.rb43
-rw-r--r--lib/gitlab/exclusive_lease_helpers.rb2
-rw-r--r--lib/gitlab/experimentation.rb32
-rw-r--r--lib/gitlab/git/diff.rb12
-rw-r--r--lib/gitlab/git/diff_collection.rb16
-rw-r--r--lib/gitlab/git/diff_stats_collection.rb4
-rw-r--r--lib/gitlab/git_access.rb2
-rw-r--r--lib/gitlab/git_access_snippet.rb8
-rw-r--r--lib/gitlab/gon_helper.rb3
-rw-r--r--lib/gitlab/graphql/global_id_compatibility.rb20
-rw-r--r--lib/gitlab/graphql/markdown_field.rb8
-rw-r--r--lib/gitlab/graphql/markdown_field/resolver.rb22
-rw-r--r--lib/gitlab/graphql/pagination/keyset/order_info.rb9
-rw-r--r--lib/gitlab/graphql/query_analyzers/logger_analyzer.rb7
-rw-r--r--lib/gitlab/group_search_results.rb2
-rw-r--r--lib/gitlab/health_checks/unicorn_check.rb2
-rw-r--r--lib/gitlab/import_export/base/relation_factory.rb26
-rw-r--r--lib/gitlab/import_export/lfs_saver.rb8
-rw-r--r--lib/gitlab/import_export/members_mapper.rb6
-rw-r--r--lib/gitlab/instrumentation/redis.rb2
-rw-r--r--lib/gitlab/job_waiter.rb17
-rw-r--r--lib/gitlab/lfs/client.rb42
-rw-r--r--lib/gitlab/manifest_import/metadata.rb49
-rw-r--r--lib/gitlab/metrics/dashboard/importers/prometheus_metrics.rb49
-rw-r--r--lib/gitlab/metrics/dashboard/stages/custom_dashboard_metrics_inserter.rb24
-rw-r--r--lib/gitlab/metrics/dashboard/stages/url_validator.rb2
-rw-r--r--lib/gitlab/metrics/dashboard/transformers/errors.rb6
-rw-r--r--lib/gitlab/metrics/requests_rack_middleware.rb23
-rw-r--r--lib/gitlab/metrics/samplers/unicorn_sampler.rb2
-rw-r--r--lib/gitlab/middleware/multipart.rb1
-rw-r--r--lib/gitlab/pagination/offset_pagination.rb10
-rw-r--r--lib/gitlab/project_template.rb1
-rw-r--r--lib/gitlab/quick_actions/merge_request_actions.rb14
-rw-r--r--lib/gitlab/redis/hll.rb4
-rw-r--r--lib/gitlab/regex.rb67
-rw-r--r--lib/gitlab/relative_positioning/item_context.rb4
-rw-r--r--lib/gitlab/search_results.rb62
-rw-r--r--lib/gitlab/sidekiq_daemon/memory_killer.rb2
-rw-r--r--lib/gitlab/static_site_editor/config/file_config.rb35
-rw-r--r--lib/gitlab/static_site_editor/config/file_config/entry/global.rb39
-rw-r--r--lib/gitlab/static_site_editor/config/file_config/entry/image_upload_path.rb26
-rw-r--r--lib/gitlab/static_site_editor/config/file_config/entry/mount.rb39
-rw-r--r--lib/gitlab/static_site_editor/config/file_config/entry/mounts.rb33
-rw-r--r--lib/gitlab/static_site_editor/config/file_config/entry/static_site_generator.rb26
-rw-r--r--lib/gitlab/static_site_editor/config/generated_config.rb12
-rw-r--r--lib/gitlab/themes.rb24
-rw-r--r--lib/gitlab/usage_data.rb66
-rw-r--r--lib/gitlab/usage_data_counters/hll_redis_counter.rb3
-rw-r--r--lib/gitlab/usage_data_counters/issue_activity_unique_counter.rb42
-rw-r--r--lib/gitlab/usage_data_counters/known_events.yml28
-rw-r--r--lib/gitlab/usage_data_queries.rb2
-rw-r--r--lib/gitlab/utils/usage_data.rb5
-rw-r--r--lib/gitlab/webpack/manifest.rb102
-rw-r--r--lib/gitlab_danger.rb2
-rw-r--r--lib/grafana/client.rb4
-rw-r--r--lib/pager_duty/validator/schemas/message.json47
-rw-r--r--lib/pager_duty/webhook_payload_parser.rb12
-rw-r--r--lib/peek/views/detailed_view.rb2
-rw-r--r--lib/safe_zip/extract.rb22
-rw-r--r--lib/sentry/client/issue.rb4
-rw-r--r--lib/tasks/gitlab/assets.rake2
-rw-r--r--lib/tasks/gitlab/backup.rake4
-rw-r--r--lib/tasks/gitlab/db.rake21
-rw-r--r--lib/uploaded_file.rb7
-rw-r--r--locale/gitlab.pot898
-rw-r--r--locale/unfound_translations.rb1
-rw-r--r--package.json19
-rw-r--r--qa/Gemfile2
-rw-r--r--qa/Gemfile.lock14
-rw-r--r--qa/qa.rb3
-rw-r--r--qa/qa/git/repository.rb35
-rw-r--r--qa/qa/page/component/design_management.rb29
-rw-r--r--qa/qa/page/component/issuable/sidebar.rb33
-rw-r--r--qa/qa/page/component/new_snippet.rb37
-rw-r--r--qa/qa/page/component/note.rb66
-rw-r--r--qa/qa/page/component/select2.rb2
-rw-r--r--qa/qa/page/component/snippet.rb42
-rw-r--r--qa/qa/page/dashboard/snippet/edit.rb4
-rw-r--r--qa/qa/page/merge_request/show.rb80
-rw-r--r--qa/qa/page/profile/ssh_keys.rb15
-rw-r--r--qa/qa/page/project/issue/show.rb12
-rw-r--r--qa/qa/page/project/operations/kubernetes/index.rb4
-rw-r--r--qa/qa/page/project/packages/index.rb2
-rw-r--r--qa/qa/page/project/pipeline/index.rb12
-rw-r--r--qa/qa/page/project/pipeline/new.rb19
-rw-r--r--qa/qa/page/project/show.rb2
-rw-r--r--qa/qa/page/project/snippet/index.rb29
-rw-r--r--qa/qa/resource/api_fabricator.rb35
-rw-r--r--qa/qa/resource/design.rb31
-rw-r--r--qa/qa/resource/events/base.rb7
-rw-r--r--qa/qa/resource/events/project.rb7
-rw-r--r--qa/qa/resource/merge_request.rb46
-rw-r--r--qa/qa/resource/project.rb16
-rw-r--r--qa/qa/resource/repository/push.rb5
-rw-r--r--qa/qa/resource/snippet.rb11
-rw-r--r--qa/qa/resource/ssh_key.rb9
-rw-r--r--qa/qa/runtime/api/request.rb6
-rw-r--r--qa/qa/runtime/feature.rb169
-rw-r--r--qa/qa/specs/features/api/3_create/gitaly/distributed_reads_spec.rb4
-rw-r--r--qa/qa/specs/features/api/3_create/merge_request/push_options_labels_spec.rb64
-rw-r--r--qa/qa/specs/features/api/3_create/merge_request/push_options_mwps_spec.rb124
-rw-r--r--qa/qa/specs/features/api/3_create/merge_request/push_options_remove_source_branch_spec.rb45
-rw-r--r--qa/qa/specs/features/api/3_create/merge_request/push_options_target_branch_spec.rb53
-rw-r--r--qa/qa/specs/features/api/3_create/merge_request/push_options_title_description_spec.rb49
-rw-r--r--qa/qa/specs/features/api/3_create/repository/default_branch_name_setting_spec.rb4
-rw-r--r--qa/qa/specs/features/api/3_create/repository/project_archive_compare_spec.rb7
-rw-r--r--qa/qa/specs/features/browser_ui/1_manage/login/log_in_with_2fa_spec.rb2
-rw-r--r--qa/qa/specs/features/browser_ui/2_plan/milestone/assign_milestone_spec.rb2
-rw-r--r--qa/qa/specs/features/browser_ui/2_plan/milestone/create_group_milestone_spec.rb2
-rw-r--r--qa/qa/specs/features/browser_ui/2_plan/milestone/create_project_milestone_spec.rb2
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/design_management/archive_design_content_spec.rb51
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/merge_request/view_merge_request_merge_ref_diff_spec.rb80
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/repository/push_mirroring_lfs_over_http_spec.rb4
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_personal_snippet_spec.rb4
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb4
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/snippet/create_personal_snippet_with_multiple_files_spec.rb48
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/snippet/create_project_snippet_spec.rb4
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/wiki/project_based_directory_management_spec.rb6
-rw-r--r--qa/qa/specs/features/browser_ui/4_verify/pipeline/create_and_process_pipeline_spec.rb2
-rw-r--r--qa/qa/specs/features/browser_ui/4_verify/pipeline/run_pipeline_via_web_only_spec.rb65
-rw-r--r--qa/qa/specs/features/browser_ui/4_verify/runner/register_runner_spec.rb2
-rw-r--r--qa/qa/specs/features/browser_ui/4_verify/testing/view_code_coverage_spec.rb2
-rw-r--r--qa/qa/specs/features/browser_ui/5_package/maven_repository_spec.rb2
-rw-r--r--qa/qa/specs/features/browser_ui/5_package/npm_registry_spec.rb2
-rw-r--r--qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb2
-rw-r--r--qa/qa/specs/features/browser_ui/6_release/pipeline/parent_child_pipelines_dependent_relationship_spec.rb2
-rw-r--r--qa/qa/specs/features/browser_ui/6_release/pipeline/parent_child_pipelines_independent_relationship_spec.rb2
-rw-r--r--qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb4
-rw-r--r--qa/qa/specs/features/sanity/framework_spec.rb2
-rw-r--r--qa/qa/support/api.rb2
-rw-r--r--qa/qa/support/wait_for_requests.rb12
-rw-r--r--qa/spec/factory/resource/user_spec.rb2
-rw-r--r--qa/spec/fixtures/tanuki.jpgbin0 -> 84616 bytes
-rw-r--r--qa/spec/fixtures/values.pngbin0 -> 122101 bytes
-rw-r--r--qa/spec/git/location_spec.rb2
-rw-r--r--qa/spec/git/repository_spec.rb62
-rw-r--r--qa/spec/page/base_spec.rb2
-rw-r--r--qa/spec/page/element_spec.rb2
-rw-r--r--qa/spec/page/logging_spec.rb2
-rw-r--r--qa/spec/page/validator_spec.rb2
-rw-r--r--qa/spec/page/view_spec.rb2
-rw-r--r--qa/spec/resource/api_fabricator_spec.rb2
-rw-r--r--qa/spec/resource/base_spec.rb2
-rw-r--r--qa/spec/resource/events/base_spec.rb2
-rw-r--r--qa/spec/resource/events/project_spec.rb2
-rw-r--r--qa/spec/resource/repository/push_spec.rb2
-rw-r--r--qa/spec/resource/ssh_key_spec.rb2
-rw-r--r--qa/spec/resource/user_spec.rb2
-rw-r--r--qa/spec/runtime/api/client_spec.rb2
-rw-r--r--qa/spec/runtime/api/request_spec.rb2
-rw-r--r--qa/spec/runtime/application_settings_spec.rb2
-rw-r--r--qa/spec/runtime/env_spec.rb2
-rw-r--r--qa/spec/runtime/feature_spec.rb252
-rw-r--r--qa/spec/runtime/key/ecdsa_spec.rb2
-rw-r--r--qa/spec/runtime/key/ed25519_spec.rb2
-rw-r--r--qa/spec/runtime/key/rsa_spec.rb2
-rw-r--r--qa/spec/runtime/logger_spec.rb2
-rw-r--r--qa/spec/runtime/namespace_spec.rb2
-rw-r--r--qa/spec/runtime/release_spec.rb2
-rw-r--r--qa/spec/runtime/scenario_spec.rb2
-rw-r--r--qa/spec/scenario/actable_spec.rb2
-rw-r--r--qa/spec/scenario/bootable_spec.rb2
-rw-r--r--qa/spec/scenario/template_spec.rb2
-rw-r--r--qa/spec/scenario/test/instance/airgapped_spec.rb2
-rw-r--r--qa/spec/scenario/test/instance/all_spec.rb2
-rw-r--r--qa/spec/scenario/test/instance/smoke_spec.rb2
-rw-r--r--qa/spec/scenario/test/integration/github_spec.rb2
-rw-r--r--qa/spec/scenario/test/integration/instance_saml_spec.rb2
-rw-r--r--qa/spec/scenario/test/integration/kubernetes_spec.rb2
-rw-r--r--qa/spec/scenario/test/integration/ldap_spec.rb6
-rw-r--r--qa/spec/scenario/test/integration/mattermost_spec.rb2
-rw-r--r--qa/spec/scenario/test/integration/object_storage_spec.rb2
-rw-r--r--qa/spec/scenario/test/sanity/framework_spec.rb2
-rw-r--r--qa/spec/scenario/test/sanity/selectors_spec.rb2
-rw-r--r--qa/spec/specs/helpers/quarantine_spec.rb2
-rw-r--r--qa/spec/specs/parallel_runner_spec.rb2
-rw-r--r--qa/spec/specs/runner_spec.rb2
-rw-r--r--qa/spec/support/repeater_spec.rb2
-rw-r--r--qa/spec/support/retrier_spec.rb2
-rw-r--r--qa/spec/support/wait_for_requests_spec.rb3
-rw-r--r--qa/spec/support/waiter_spec.rb2
-rw-r--r--rubocop/cop/code_reuse/active_record.rb35
-rw-r--r--scripts/prepare_build.sh6
-rwxr-xr-xscripts/review_apps/review-apps.sh8
-rw-r--r--spec/bin/feature_flag_spec.rb13
-rw-r--r--spec/channels/application_cable/connection_spec.rb52
-rw-r--r--spec/controllers/admin/application_settings_controller_spec.rb38
-rw-r--r--spec/controllers/admin/clusters_controller_spec.rb5
-rw-r--r--spec/controllers/admin/integrations_controller_spec.rb14
-rw-r--r--spec/controllers/admin/runners_controller_spec.rb17
-rw-r--r--spec/controllers/admin/sessions_controller_spec.rb2
-rw-r--r--spec/controllers/admin/users_controller_spec.rb6
-rw-r--r--spec/controllers/application_controller_spec.rb4
-rw-r--r--spec/controllers/boards/lists_controller_spec.rb11
-rw-r--r--spec/controllers/concerns/controller_with_feature_category/config_spec.rb53
-rw-r--r--spec/controllers/concerns/controller_with_feature_category_spec.rb38
-rw-r--r--spec/controllers/concerns/redis_tracking_spec.rb14
-rw-r--r--spec/controllers/dashboard_controller_spec.rb10
-rw-r--r--spec/controllers/every_controller_spec.rb25
-rw-r--r--spec/controllers/groups/clusters_controller_spec.rb5
-rw-r--r--spec/controllers/groups/group_links_controller_spec.rb33
-rw-r--r--spec/controllers/groups/labels_controller_spec.rb79
-rw-r--r--spec/controllers/groups/milestones_controller_spec.rb7
-rw-r--r--spec/controllers/groups/registry/repositories_controller_spec.rb2
-rw-r--r--spec/controllers/groups/settings/ci_cd_controller_spec.rb21
-rw-r--r--spec/controllers/groups_controller_spec.rb67
-rw-r--r--spec/controllers/import/bulk_imports_controller_spec.rb137
-rw-r--r--spec/controllers/import/manifest_controller_spec.rb99
-rw-r--r--spec/controllers/invites_controller_spec.rb89
-rw-r--r--spec/controllers/projects/clusters_controller_spec.rb6
-rw-r--r--spec/controllers/projects/group_links_controller_spec.rb51
-rw-r--r--spec/controllers/projects/import/jira_controller_spec.rb2
-rw-r--r--spec/controllers/projects/incidents_controller_spec.rb111
-rw-r--r--spec/controllers/projects/jobs_controller_spec.rb7
-rw-r--r--spec/controllers/projects/labels_controller_spec.rb95
-rw-r--r--spec/controllers/projects/milestones_controller_spec.rb4
-rw-r--r--spec/controllers/projects/registry/tags_controller_spec.rb2
-rw-r--r--spec/controllers/projects/releases/evidences_controller_spec.rb32
-rw-r--r--spec/controllers/projects/runners_controller_spec.rb41
-rw-r--r--spec/controllers/projects/serverless/functions_controller_spec.rb16
-rw-r--r--spec/controllers/projects/settings/access_tokens_controller_spec.rb26
-rw-r--r--spec/controllers/projects/settings/ci_cd_controller_spec.rb17
-rw-r--r--spec/controllers/projects/static_site_editor_controller_spec.rb87
-rw-r--r--spec/controllers/projects/tags_controller_spec.rb21
-rw-r--r--spec/controllers/runner_setup_controller_spec.rb21
-rw-r--r--spec/controllers/sessions_controller_spec.rb23
-rw-r--r--spec/factories/alert_management/alerts.rb14
-rw-r--r--spec/factories/alert_management/http_integrations.rb14
-rw-r--r--spec/factories/alerting/alert.rb25
-rw-r--r--spec/factories/authentication_event.rb11
-rw-r--r--spec/factories/ci/build_pending_states.rb2
-rw-r--r--spec/factories/ci/build_trace_chunks.rb13
-rw-r--r--spec/factories/ci/pipelines.rb2
-rw-r--r--spec/factories/design_management/designs.rb2
-rw-r--r--spec/factories/events.rb11
-rw-r--r--spec/factories/group_import_states.rb1
-rw-r--r--spec/factories/instance_statistics/measurement.rb8
-rw-r--r--spec/factories/issue_email_participants.rb8
-rw-r--r--spec/factories/merge_request_diffs.rb8
-rw-r--r--spec/factories/merge_requests.rb2
-rw-r--r--spec/factories/namespaces.rb8
-rw-r--r--spec/factories/packages.rb160
-rw-r--r--spec/factories/packages/package_file.rb165
-rw-r--r--spec/factories/pages_deployments.rb9
-rw-r--r--spec/factories/project_tracing_settings.rb8
-rw-r--r--spec/factories/projects.rb6
-rw-r--r--spec/factories/prometheus_metrics.rb1
-rw-r--r--spec/factories/services.rb6
-rw-r--r--spec/factories/terraform/state.rb10
-rw-r--r--spec/factories/terraform/state_version.rb8
-rw-r--r--spec/factories/todos.rb4
-rw-r--r--spec/factories/usage_data.rb14
-rw-r--r--spec/factories/wiki_pages.rb3
-rw-r--r--spec/factories/wikis.rb6
-rw-r--r--spec/features/admin/admin_settings_spec.rb32
-rw-r--r--spec/features/admin/admin_users_spec.rb22
-rw-r--r--spec/features/admin/admin_uses_repository_checks_spec.rb2
-rw-r--r--spec/features/admin/clusters/eks_spec.rb2
-rw-r--r--spec/features/admin/dashboard_spec.rb6
-rw-r--r--spec/features/boards/add_issues_modal_spec.rb4
-rw-r--r--spec/features/boards/boards_spec.rb22
-rw-r--r--spec/features/calendar_spec.rb2
-rw-r--r--spec/features/clusters/cluster_detail_page_spec.rb12
-rw-r--r--spec/features/commits_spec.rb1
-rw-r--r--spec/features/dashboard/todos/todos_filtering_spec.rb8
-rw-r--r--spec/features/dashboard/todos/todos_spec.rb15
-rw-r--r--spec/features/expand_collapse_diffs_spec.rb1
-rw-r--r--spec/features/groups/clusters/eks_spec.rb2
-rw-r--r--spec/features/groups/clusters/user_spec.rb8
-rw-r--r--spec/features/groups/members/manage_groups_spec.rb113
-rw-r--r--spec/features/groups/navbar_spec.rb8
-rw-r--r--spec/features/groups/packages_spec.rb2
-rw-r--r--spec/features/groups/show_spec.rb13
-rw-r--r--spec/features/incidents/incident_details_spec.rb32
-rw-r--r--spec/features/invites_spec.rb45
-rw-r--r--spec/features/issuables/close_reopen_report_toggle_spec.rb12
-rw-r--r--spec/features/issuables/markdown_references/internal_references_spec.rb8
-rw-r--r--spec/features/issuables/merge_request_discussion_lock_spec.rb79
-rw-r--r--spec/features/issues/gfm_autocomplete_spec.rb24
-rw-r--r--spec/features/issues/user_sees_live_update_spec.rb2
-rw-r--r--spec/features/issues/user_views_issue_spec.rb4
-rw-r--r--spec/features/merge_request/batch_comments_spec.rb15
-rw-r--r--spec/features/merge_request/maintainer_edits_fork_spec.rb7
-rw-r--r--spec/features/merge_request/user_comments_on_diff_spec.rb9
-rw-r--r--spec/features/merge_request/user_edits_mr_spec.rb18
-rw-r--r--spec/features/merge_request/user_expands_diff_spec.rb5
-rw-r--r--spec/features/merge_request/user_marks_merge_request_as_draft_spec.rb29
-rw-r--r--spec/features/merge_request/user_reopens_merge_request_spec.rb6
-rw-r--r--spec/features/merge_request/user_resolves_wip_mr_spec.rb4
-rw-r--r--spec/features/merge_request/user_sees_diff_spec.rb3
-rw-r--r--spec/features/merge_request/user_sees_page_metadata_spec.rb17
-rw-r--r--spec/features/merge_request/user_sees_pipelines_spec.rb4
-rw-r--r--spec/features/merge_request/user_sees_suggest_pipeline_spec.rb35
-rw-r--r--spec/features/merge_request/user_suggests_changes_on_diff_spec.rb5
-rw-r--r--spec/features/merge_request/user_views_open_merge_request_spec.rb19
-rw-r--r--spec/features/milestones/user_views_milestone_spec.rb76
-rw-r--r--spec/features/milestones/user_views_milestones_spec.rb3
-rw-r--r--spec/features/operations_sidebar_link_spec.rb81
-rw-r--r--spec/features/profiles/keys_spec.rb36
-rw-r--r--spec/features/projects/activity/user_sees_design_comment_spec.rb2
-rw-r--r--spec/features/projects/blobs/edit_spec.rb11
-rw-r--r--spec/features/projects/blobs/user_follows_pipeline_suggest_nudge_spec.rb2
-rw-r--r--spec/features/projects/branches_spec.rb4
-rw-r--r--spec/features/projects/clusters/eks_spec.rb2
-rw-r--r--spec/features/projects/clusters/gcp_spec.rb12
-rw-r--r--spec/features/projects/clusters/user_spec.rb8
-rw-r--r--spec/features/projects/clusters_spec.rb16
-rw-r--r--spec/features/projects/commit/user_comments_on_commit_spec.rb48
-rw-r--r--spec/features/projects/environments/environments_spec.rb4
-rw-r--r--spec/features/projects/issues/design_management/user_links_to_designs_in_issue_spec.rb29
-rw-r--r--spec/features/projects/issues/viewing_relocated_issues_spec.rb40
-rw-r--r--spec/features/projects/members/groups_with_access_list_spec.rb41
-rw-r--r--spec/features/projects/pipelines/pipeline_spec.rb17
-rw-r--r--spec/features/projects/releases/user_creates_release_spec.rb20
-rw-r--r--spec/features/projects/releases/user_views_edit_release_spec.rb2
-rw-r--r--spec/features/projects/releases/user_views_release_spec.rb14
-rw-r--r--spec/features/projects/releases/user_views_releases_spec.rb4
-rw-r--r--spec/features/projects/show/user_manages_notifications_spec.rb8
-rw-r--r--spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb2
-rw-r--r--spec/features/projects/tree/tree_show_spec.rb2
-rw-r--r--spec/features/projects/user_sees_sidebar_spec.rb2
-rw-r--r--spec/features/projects/wiki/markdown_preview_spec.rb168
-rw-r--r--spec/features/projects/wiki/shortcuts_spec.rb20
-rw-r--r--spec/features/projects/wiki/user_creates_wiki_page_spec.rb360
-rw-r--r--spec/features/projects/wiki/user_deletes_wiki_page_spec.rb22
-rw-r--r--spec/features/projects/wiki/user_updates_wiki_page_spec.rb263
-rw-r--r--spec/features/projects/wiki/user_views_wiki_empty_spec.rb138
-rw-r--r--spec/features/projects/wiki/user_views_wiki_page_spec.rb276
-rw-r--r--spec/features/projects/wiki/user_views_wiki_pages_spec.rb91
-rw-r--r--spec/features/projects/wiki/users_views_asciidoc_page_with_includes_spec.rb79
-rw-r--r--spec/features/projects/wikis_spec.rb20
-rw-r--r--spec/features/projects_spec.rb9
-rw-r--r--spec/features/protected_branches_spec.rb14
-rw-r--r--spec/features/search/user_searches_for_code_spec.rb1
-rw-r--r--spec/features/search/user_uses_header_search_field_spec.rb26
-rw-r--r--spec/features/static_site_editor_spec.rb67
-rw-r--r--spec/features/tags/developer_deletes_tag_spec.rb27
-rw-r--r--spec/features/task_lists_spec.rb115
-rw-r--r--spec/features/triggers_spec.rb186
-rw-r--r--spec/features/users/show_spec.rb10
-rw-r--r--spec/features/users/terms_spec.rb15
-rw-r--r--spec/finders/ci/pipelines_for_merge_request_finder_spec.rb2
-rw-r--r--spec/finders/group_labels_finder_spec.rb42
-rw-r--r--spec/finders/groups_finder_spec.rb56
-rw-r--r--spec/finders/merge_requests/by_approvals_finder_spec.rb82
-rw-r--r--spec/finders/packages/generic/package_finder_spec.rb31
-rw-r--r--spec/fixtures/api/schemas/entities/group_group_link.json4
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request_basic.json1
-rw-r--r--spec/fixtures/api/schemas/entities/test_case.json1
-rw-r--r--spec/fixtures/api/schemas/entities/trigger.json39
-rw-r--r--spec/fixtures/api/schemas/feature_flags_client_token.json10
-rw-r--r--spec/fixtures/api/schemas/group_member.json13
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/packages/package.json3
-rw-r--r--spec/fixtures/api/schemas/registry/repository.json3
-rw-r--r--spec/fixtures/api/schemas/unleash/unleash.json20
-rw-r--r--spec/fixtures/api/schemas/unleash/unleash_feature.json27
-rw-r--r--spec/fixtures/api/schemas/unleash/unleash_strategy.json24
-rw-r--r--spec/fixtures/invalid_manifest.xml4
-rw-r--r--spec/fixtures/lib/backup/design_repo.bundlebin0 -> 490 bytes
-rw-r--r--spec/fixtures/lib/backup/project_repo.bundlebin0 -> 387 bytes
-rw-r--r--spec/fixtures/lib/backup/wiki_repo.bundlebin0 -> 365 bytes
-rw-r--r--spec/fixtures/packages/debian/libsample0_1.2.3~alpha2-1_amd64.deb1
-rw-r--r--spec/fixtures/packages/generic/myfile.tar.gzbin0 -> 1149 bytes
-rw-r--r--spec/frontend/alert_handler_spec.js65
-rw-r--r--spec/frontend/alert_management/components/alert_details_spec.js98
-rw-r--r--spec/frontend/alert_management/components/alert_management_table_spec.js30
-rw-r--r--spec/frontend/alert_management/components/alert_summary_row_spec.js40
-rw-r--r--spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js4
-rw-r--r--spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js13
-rw-r--r--spec/frontend/alert_management/components/system_notes/alert_management_system_note_spec.js6
-rw-r--r--spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap14
-rw-r--r--spec/frontend/analytics/instance_statistics/components/app_spec.js24
-rw-r--r--spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js54
-rw-r--r--spec/frontend/analytics/instance_statistics/mock_data.js4
-rw-r--r--spec/frontend/analytics/shared/components/metric_card_spec.js129
-rw-r--r--spec/frontend/api_spec.js40
-rw-r--r--spec/frontend/awards_handler_spec.js24
-rw-r--r--spec/frontend/batch_comments/components/preview_item_spec.js16
-rw-r--r--spec/frontend/batch_comments/components/publish_button_spec.js11
-rw-r--r--spec/frontend/batch_comments/components/publish_dropdown_spec.js87
-rw-r--r--spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js90
-rw-r--r--spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js52
-rw-r--r--spec/frontend/behaviors/load_startup_css_spec.js44
-rw-r--r--spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap2
-rw-r--r--spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js67
-rw-r--r--spec/frontend/boards/board_blank_state_spec.js95
-rw-r--r--spec/frontend/boards/boards_store_spec.js19
-rw-r--r--spec/frontend/boards/components/board_configuration_options_spec.js59
-rw-r--r--spec/frontend/boards/components/board_content_spec.js3
-rw-r--r--spec/frontend/boards/components/sidebar/board_editable_item_spec.js14
-rw-r--r--spec/frontend/boards/stores/actions_spec.js74
-rw-r--r--spec/frontend/boards/stores/mutations_spec.js70
-rw-r--r--spec/frontend/ci_settings_pipeline_triggers/components/triggers_list_spec.js102
-rw-r--r--spec/frontend/ci_settings_pipeline_triggers/mock_data.js30
-rw-r--r--spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js24
-rw-r--r--spec/frontend/clusters/components/fluentd_output_settings_spec.js4
-rw-r--r--spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js10
-rw-r--r--spec/frontend/clusters/components/knative_domain_editor_spec.js4
-rw-r--r--spec/frontend/clusters/services/crossplane_provider_stack_spec.js4
-rw-r--r--spec/frontend/clusters_list/components/clusters_spec.js18
-rw-r--r--spec/frontend/clusters_list/components/node_error_help_text_spec.js33
-rw-r--r--spec/frontend/clusters_list/mock_data.js10
-rw-r--r--spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap1
-rw-r--r--spec/frontend/commit/pipelines/pipelines_spec.js82
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js8
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js1
-rw-r--r--spec/frontend/create_cluster/eks_cluster/store/actions_spec.js32
-rw-r--r--spec/frontend/design_management/components/design_todo_button_spec.js2
-rw-r--r--spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap2
-rw-r--r--spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap6
-rw-r--r--spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap9
-rw-r--r--spec/frontend/design_management/components/toolbar/design_navigation_spec.js4
-rw-r--r--spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap3
-rw-r--r--spec/frontend/design_management/mock_data/apollo_mock.js1
-rw-r--r--spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap11
-rw-r--r--spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap1
-rw-r--r--spec/frontend/design_management/pages/index_spec.js56
-rw-r--r--spec/frontend/design_management/router_spec.js2
-rw-r--r--spec/frontend/design_management/utils/design_management_utils_spec.js4
-rw-r--r--spec/frontend/diff_comments_store_spec.js136
-rw-r--r--spec/frontend/diffs/components/app_spec.js4
-rw-r--r--spec/frontend/diffs/components/collapsed_files_warning_spec.js2
-rw-r--r--spec/frontend/diffs/components/commit_item_spec.js6
-rw-r--r--spec/frontend/diffs/components/diff_file_header_spec.js53
-rw-r--r--spec/frontend/diffs/components/diff_file_spec.js26
-rw-r--r--spec/frontend/diffs/components/diff_row_utils_spec.js203
-rw-r--r--spec/frontend/diffs/components/diff_table_cell_spec.js279
-rw-r--r--spec/frontend/diffs/components/edit_button_spec.js75
-rw-r--r--spec/frontend/diffs/mock_data/diff_file.js2
-rw-r--r--spec/frontend/diffs/mock_data/diff_file_unreadable.js2
-rw-r--r--spec/frontend/diffs/store/actions_spec.js8
-rw-r--r--spec/frontend/diffs/store/getters_spec.js90
-rw-r--r--spec/frontend/diffs/store/mutations_spec.js8
-rw-r--r--spec/frontend/emoji/emoji_spec.js41
-rw-r--r--spec/frontend/environment.js10
-rw-r--r--spec/frontend/environments/environments_app_spec.js12
-rw-r--r--spec/frontend/environments/folder/environments_folder_view_spec.js5
-rw-r--r--spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js12
-rw-r--r--spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js159
-rw-r--r--spec/frontend/feature_flags/components/edit_feature_flag_spec.js197
-rw-r--r--spec/frontend/feature_flags/components/environments_dropdown_spec.js145
-rw-r--r--spec/frontend/feature_flags/components/feature_flags_spec.js343
-rw-r--r--spec/frontend/feature_flags/components/feature_flags_tab_spec.js168
-rw-r--r--spec/frontend/feature_flags/components/feature_flags_table_spec.js262
-rw-r--r--spec/frontend/feature_flags/components/form_spec.js485
-rw-r--r--spec/frontend/feature_flags/components/new_environments_dropdown_spec.js103
-rw-r--r--spec/frontend/feature_flags/components/new_feature_flag_spec.js145
-rw-r--r--spec/frontend/feature_flags/components/strategy_spec.js320
-rw-r--r--spec/frontend/feature_flags/components/user_lists_table_spec.js98
-rw-r--r--spec/frontend/feature_flags/mock_data.js109
-rw-r--r--spec/frontend/feature_flags/store/edit/actions_spec.js334
-rw-r--r--spec/frontend/feature_flags/store/edit/mutations_spec.js150
-rw-r--r--spec/frontend/feature_flags/store/helpers_spec.js514
-rw-r--r--spec/frontend/feature_flags/store/index/actions_spec.js605
-rw-r--r--spec/frontend/feature_flags/store/index/mutations_spec.js332
-rw-r--r--spec/frontend/feature_flags/store/new/actions_spec.js223
-rw-r--r--spec/frontend/feature_flags/store/new/mutations_spec.js65
-rw-r--r--spec/frontend/fixtures/releases.rb136
-rw-r--r--spec/frontend/gfm_auto_complete_spec.js219
-rw-r--r--spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap22
-rw-r--r--spec/frontend/groups/components/item_actions_spec.js125
-rw-r--r--spec/frontend/groups/components/item_caret_spec.js58
-rw-r--r--spec/frontend/groups/components/item_stats_spec.js131
-rw-r--r--spec/frontend/groups/components/item_stats_value_spec.js111
-rw-r--r--spec/frontend/groups/components/item_type_icon_spec.js80
-rw-r--r--spec/frontend/groups/members/index_spec.js10
-rw-r--r--spec/frontend/helpers/experimentation_helper.js14
-rw-r--r--spec/frontend/helpers/keep_alive_component_helper.js29
-rw-r--r--spec/frontend/helpers/keep_alive_component_helper_spec.js32
-rw-r--r--spec/frontend/helpers/local_storage_helper.js2
-rw-r--r--spec/frontend/helpers/local_storage_helper_spec.js4
-rw-r--r--spec/frontend/helpers/vue_test_utils_helper.js7
-rw-r--r--spec/frontend/helpers/wait_for_text.js3
-rw-r--r--spec/frontend/ide/components/commit_sidebar/actions_spec.js4
-rw-r--r--spec/frontend/ide/components/ide_review_spec.js78
-rw-r--r--spec/frontend/ide/components/ide_side_bar_spec.js81
-rw-r--r--spec/frontend/ide/components/ide_tree_list_spec.js8
-rw-r--r--spec/frontend/ide/components/ide_tree_spec.js41
-rw-r--r--spec/frontend/ide/components/repo_commit_section_spec.js21
-rw-r--r--spec/frontend/incidents/components/incidents_list_spec.js211
-rw-r--r--spec/frontend/incidents/mocks/incidents_filter.json14
-rw-r--r--spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap27
-rw-r--r--spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap1
-rw-r--r--spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap54
-rw-r--r--spec/frontend/integrations/edit/components/confirmation_modal_spec.js51
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_spec.js23
-rw-r--r--spec/frontend/integrations/edit/mock_data.js1
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js115
-rw-r--r--spec/frontend/invite_members/components/invite_members_trigger_spec.js58
-rw-r--r--spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js5
-rw-r--r--spec/frontend/issuable/related_issues/components/issue_token_spec.js257
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_block_spec.js5
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_list_spec.js5
-rw-r--r--spec/frontend/issuable_create/components/issuable_form_spec.js1
-rw-r--r--spec/frontend/issue_show/components/incidents/highlight_bar_spec.js4
-rw-r--r--spec/frontend/issue_show/components/incidents/incident_tabs_spec.js2
-rw-r--r--spec/frontend/issue_show/issue_spec.js8
-rw-r--r--spec/frontend/jobs/store/utils_spec.js8
-rw-r--r--spec/frontend/lib/dompurify_spec.js98
-rw-r--r--spec/frontend/lib/utils/axios_startup_calls_spec.js49
-rw-r--r--spec/frontend/lib/utils/datetime_utility_spec.js23
-rw-r--r--spec/frontend/lib/utils/experimentation_spec.js20
-rw-r--r--spec/frontend/lib/utils/url_utility_spec.js57
-rw-r--r--spec/frontend/merge_request_spec.js63
-rw-r--r--spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap181
-rw-r--r--spec/frontend/monitoring/components/group_empty_state_spec.js33
-rw-r--r--spec/frontend/notes/components/discussion_counter_spec.js28
-rw-r--r--spec/frontend/notes/components/discussion_filter_spec.js20
-rw-r--r--spec/frontend/notes/components/sort_discussion_spec.js12
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap214
-rw-r--r--spec/frontend/packages/details/store/getters_spec.js2
-rw-r--r--spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap756
-rw-r--r--spec/frontend/packages/list/components/packages_list_app_spec.js1
-rw-r--r--spec/frontend/packages/list/components/packages_title_spec.js71
-rw-r--r--spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap26
-rw-r--r--spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap3
-rw-r--r--spec/frontend/packages/shared/components/package_list_row_spec.js8
-rw-r--r--spec/frontend/packages/shared/components/package_path_spec.js86
-rw-r--r--spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap3
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js21
-rw-r--r--spec/frontend/pipeline_new/mock_data.js6
-rw-r--r--spec/frontend/pipelines/components/dag/dag_spec.js9
-rw-r--r--spec/frontend/pipelines/graph/graph_component_spec.js102
-rw-r--r--spec/frontend/pipelines/header_component_spec.js177
-rw-r--r--spec/frontend/pipelines/legacy_header_component_spec.js116
-rw-r--r--spec/frontend/pipelines/mock_data.js78
-rw-r--r--spec/frontend/pipelines/pipelines_spec.js383
-rw-r--r--spec/frontend/pipelines/test_reports/mock_data.js16
-rw-r--r--spec/frontend/pipelines/test_reports/test_suite_table_spec.js21
-rw-r--r--spec/frontend/project_find_file_spec.js5
-rw-r--r--spec/frontend/projects/commit_box/info/load_branches_spec.js68
-rw-r--r--spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap1
-rw-r--r--spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap3
-rw-r--r--spec/frontend/projects/settings/access_dropdown_spec.js21
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js4
-rw-r--r--spec/frontend/registry/explorer/components/details_page/partial_cleanup_alert_spec.js71
-rw-r--r--spec/frontend/registry/explorer/components/list_page/registry_header_spec.js90
-rw-r--r--spec/frontend/registry/explorer/pages/details_spec.js65
-rw-r--r--spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap7
-rw-r--r--spec/frontend/registry/settings/components/registry_settings_app_spec.js88
-rw-r--r--spec/frontend/registry/settings/components/settings_form_spec.js293
-rw-r--r--spec/frontend/registry/settings/graphql/cache_updated_spec.js56
-rw-r--r--spec/frontend/registry/settings/mock_data.js32
-rw-r--r--spec/frontend/registry/settings/store/actions_spec.js90
-rw-r--r--spec/frontend/registry/settings/store/getters_spec.js72
-rw-r--r--spec/frontend/registry/settings/store/mutations_spec.js80
-rw-r--r--spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap101
-rw-r--r--spec/frontend/registry/shared/components/expiration_policy_fields_spec.js20
-rw-r--r--spec/frontend/registry/shared/stubs.js11
-rw-r--r--spec/frontend/registry/shared/utils_spec.js37
-rw-r--r--spec/frontend/related_merge_requests/components/related_merge_requests_spec.js5
-rw-r--r--spec/frontend/releases/__snapshots__/util_spec.js.snap110
-rw-r--r--spec/frontend/releases/components/app_edit_new_spec.js5
-rw-r--r--spec/frontend/releases/components/app_index_spec.js63
-rw-r--r--spec/frontend/releases/components/app_show_spec.js8
-rw-r--r--spec/frontend/releases/components/asset_links_form_spec.js18
-rw-r--r--spec/frontend/releases/components/evidence_block_spec.js8
-rw-r--r--spec/frontend/releases/components/release_block_assets_spec.js10
-rw-r--r--spec/frontend/releases/components/release_block_footer_spec.js4
-rw-r--r--spec/frontend/releases/components/release_block_header_spec.js6
-rw-r--r--spec/frontend/releases/components/release_block_metadata_spec.js4
-rw-r--r--spec/frontend/releases/components/release_block_milestone_info_spec.js26
-rw-r--r--spec/frontend/releases/components/release_block_spec.js8
-rw-r--r--spec/frontend/releases/components/release_skeleton_loader_spec.js15
-rw-r--r--spec/frontend/releases/components/releases_pagination_graphql_spec.js10
-rw-r--r--spec/frontend/releases/components/releases_pagination_rest_spec.js8
-rw-r--r--spec/frontend/releases/mock_data.js335
-rw-r--r--spec/frontend/releases/stores/getters_spec.js22
-rw-r--r--spec/frontend/releases/stores/modules/detail/actions_spec.js13
-rw-r--r--spec/frontend/releases/stores/modules/detail/mutations_spec.js25
-rw-r--r--spec/frontend/releases/stores/modules/list/actions_spec.js334
-rw-r--r--spec/frontend/releases/stores/modules/list/mutations_spec.js44
-rw-r--r--spec/frontend/releases/util_spec.js6
-rw-r--r--spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap58
-rw-r--r--spec/frontend/repository/components/last_commit_spec.js2
-rw-r--r--spec/frontend/repository/log_tree_spec.js8
-rw-r--r--spec/frontend/right_sidebar_spec.js13
-rw-r--r--spec/frontend/search/components/dropdown_filter_spec.js108
-rw-r--r--spec/frontend/search/components/state_filter_spec.js104
-rw-r--r--spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap12
-rw-r--r--spec/frontend/self_monitor/components/self_monitor_form_spec.js4
-rw-r--r--spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap2
-rw-r--r--spec/frontend/serverless/components/missing_prometheus_spec.js4
-rw-r--r--spec/frontend/sidebar/confidential/edit_form_buttons_spec.js15
-rw-r--r--spec/frontend/sidebar/lock/edit_form_buttons_spec.js14
-rw-r--r--spec/frontend/sidebar/reviewer_title_spec.js116
-rw-r--r--spec/frontend/sidebar/reviewers_spec.js169
-rw-r--r--spec/frontend/sidebar/sidebar_labels_spec.js2
-rw-r--r--spec/frontend/snippet/snippet_edit_spec.js1
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap1
-rw-r--r--spec/frontend/snippets/components/edit_spec.js8
-rw-r--r--spec/frontend/snippets/components/snippet_blob_view_spec.js6
-rw-r--r--spec/frontend/static_site_editor/mock_data.js5
-rw-r--r--spec/frontend/static_site_editor/pages/home_spec.js51
-rw-r--r--spec/frontend/static_site_editor/pages/success_spec.js110
-rw-r--r--spec/frontend/static_site_editor/services/front_matterify_spec.js47
-rw-r--r--spec/frontend/static_site_editor/services/submit_content_changes_spec.js53
-rw-r--r--spec/frontend/static_site_editor/services/templater_spec.js8
-rw-r--r--spec/frontend/test_setup.js3
-rw-r--r--spec/frontend/user_lists/components/add_user_modal_spec.js50
-rw-r--r--spec/frontend/user_lists/components/edit_user_list_spec.js150
-rw-r--r--spec/frontend/user_lists/components/new_user_list_spec.js93
-rw-r--r--spec/frontend/user_lists/components/user_list_form_spec.js40
-rw-r--r--spec/frontend/user_lists/components/user_list_spec.js196
-rw-r--r--spec/frontend/user_lists/store/edit/actions_spec.js121
-rw-r--r--spec/frontend/user_lists/store/edit/mutations_spec.js61
-rw-r--r--spec/frontend/user_lists/store/new/actions_spec.js69
-rw-r--r--spec/frontend/user_lists/store/new/mutations_spec.js38
-rw-r--r--spec/frontend/user_lists/store/show/actions_spec.js117
-rw-r--r--spec/frontend/user_lists/store/show/mutations_spec.js86
-rw-r--r--spec/frontend/user_lists/store/utils_spec.js23
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js2
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js22
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js14
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js15
-rw-r--r--spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js5
-rw-r--r--spec/frontend/vue_mr_widget/deployment/deployment_spec.js5
-rw-r--r--spec/frontend/vue_mr_widget/mr_widget_options_spec.js59
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap2
-rw-r--r--spec/frontend/vue_shared/components/alert_detail_table_spec.js74
-rw-r--r--spec/frontend/vue_shared/components/alert_details_table_spec.js99
-rw-r--r--spec/frontend/vue_shared/components/clipboard_button_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/confirm_modal_spec.js16
-rw-r--r--spec/frontend/vue_shared/components/local_storage_sync_spec.js122
-rw-r--r--spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap1
-rw-r--r--spec/frontend/vue_shared/components/markdown/field_spec.js17
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js46
-rw-r--r--spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js38
-rw-r--r--spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js85
-rw-r--r--spec/frontend/vue_shared/components/members/mock_data.js61
-rw-r--r--spec/frontend/vue_shared/components/members/table/created_at_spec.js61
-rw-r--r--spec/frontend/vue_shared/components/members/table/expires_at_spec.js86
-rw-r--r--spec/frontend/vue_shared/components/members/table/member_avatar_spec.js39
-rw-r--r--spec/frontend/vue_shared/components/members/table/member_source_spec.js71
-rw-r--r--spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js130
-rw-r--r--spec/frontend/vue_shared/components/members/table/members_table_spec.js104
-rw-r--r--spec/frontend/vue_shared/components/members/utils_spec.js29
-rw-r--r--spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap5
-rw-r--r--spec/frontend/vue_shared/components/registry/title_area_spec.js33
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js11
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js27
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js11
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js23
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js7
-rw-r--r--spec/frontend/vue_shared/components/todo_button_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/web_ide_link_spec.js1
-rw-r--r--spec/frontend/vue_shared/droplab_dropdown_button_spec.js132
-rw-r--r--spec/frontend/whats_new/components/app_spec.js38
-rw-r--r--spec/frontend/whats_new/store/actions_spec.js7
-rw-r--r--spec/frontend_integration/.eslintrc.yml2
-rw-r--r--spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap1
-rw-r--r--spec/frontend_integration/ide/ide_helper.js102
-rw-r--r--spec/frontend_integration/ide/ide_integration_spec.js47
-rw-r--r--spec/frontend_integration/test_helpers/setup/setup_mock_server.js9
-rw-r--r--spec/graphql/features/feature_flag_spec.rb4
-rw-r--r--spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb35
-rw-r--r--spec/graphql/resolvers/board_resolver_spec.rb72
-rw-r--r--spec/graphql/resolvers/concerns/looks_ahead_spec.rb14
-rw-r--r--spec/graphql/resolvers/issues_resolver_spec.rb33
-rw-r--r--spec/graphql/resolvers/projects_resolver_spec.rb44
-rw-r--r--spec/graphql/resolvers/snippets/blobs_resolver_spec.rb50
-rw-r--r--spec/graphql/resolvers/terraform/states_resolver_spec.rb33
-rw-r--r--spec/graphql/types/base_field_spec.rb4
-rw-r--r--spec/graphql/types/design_management/design_collection_copy_state_enum_spec.rb11
-rw-r--r--spec/graphql/types/design_management/design_collection_type_spec.rb2
-rw-r--r--spec/graphql/types/global_id_type_spec.rb26
-rw-r--r--spec/graphql/types/issue_sort_enum_spec.rb2
-rw-r--r--spec/graphql/types/package_type_enum_spec.rb2
-rw-r--r--spec/graphql/types/project_type_spec.rb9
-rw-r--r--spec/graphql/types/snippet_type_spec.rb52
-rw-r--r--spec/graphql/types/terraform/state_type_spec.rb21
-rw-r--r--spec/haml_lint/linter/documentation_links_spec.rb98
-rw-r--r--spec/helpers/analytics/unique_visits_helper_spec.rb9
-rw-r--r--spec/helpers/boards_helper_spec.rb5
-rw-r--r--spec/helpers/clusters_helper_spec.rb44
-rw-r--r--spec/helpers/emails_helper_spec.rb112
-rw-r--r--spec/helpers/feature_flags_helper_spec.rb21
-rw-r--r--spec/helpers/gitlab_routing_helper_spec.rb4
-rw-r--r--spec/helpers/groups/group_members_helper_spec.rb15
-rw-r--r--spec/helpers/issuables_helper_spec.rb32
-rw-r--r--spec/helpers/issues_helper_spec.rb21
-rw-r--r--spec/helpers/projects/incidents_helper_spec.rb14
-rw-r--r--spec/helpers/projects_helper_spec.rb114
-rw-r--r--spec/helpers/search_helper_spec.rb29
-rw-r--r--spec/helpers/tree_helper_spec.rb10
-rw-r--r--spec/helpers/whats_new_helper_spec.rb46
-rw-r--r--spec/lib/api/github/entities_spec.rb31
-rw-r--r--spec/lib/api/helpers/packages/dependency_proxy_helpers_spec.rb2
-rw-r--r--spec/lib/api/helpers_spec.rb19
-rw-r--r--spec/lib/backup/files_spec.rb2
-rw-r--r--spec/lib/backup/repositories_spec.rb238
-rw-r--r--spec/lib/backup/repository_spec.rb232
-rw-r--r--spec/lib/banzai/filter/design_reference_filter_spec.rb20
-rw-r--r--spec/lib/banzai/filter/external_issue_reference_filter_spec.rb10
-rw-r--r--spec/lib/banzai/filter/inline_grafana_metrics_filter_spec.rb2
-rw-r--r--spec/lib/banzai/filter/issue_reference_filter_spec.rb6
-rw-r--r--spec/lib/banzai/filter/milestone_reference_filter_spec.rb44
-rw-r--r--spec/lib/feature/definition_spec.rb11
-rw-r--r--spec/lib/feature_spec.rb4
-rw-r--r--spec/lib/gitlab/alert_management/alert_params_spec.rb101
-rw-r--r--spec/lib/gitlab/alert_management/payload/generic_spec.rb32
-rw-r--r--spec/lib/gitlab/alerting/alert_spec.rb299
-rw-r--r--spec/lib/gitlab/alerting/notification_payload_parser_spec.rb204
-rw-r--r--spec/lib/gitlab/analytics/unique_visits_spec.rb2
-rw-r--r--spec/lib/gitlab/auth/auth_finders_spec.rb26
-rw-r--r--spec/lib/gitlab/auth/current_user_mode_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/merge_request_assignees_migration_progress_check_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/migrate_users_bio_to_user_details_spec.rb17
-rw-r--r--spec/lib/gitlab/background_migration/user_mentions/create_resource_user_mention_spec.rb8
-rw-r--r--spec/lib/gitlab/bitbucket_import/importer_spec.rb2
-rw-r--r--spec/lib/gitlab/checks/matching_merge_request_spec.rb31
-rw-r--r--spec/lib/gitlab/ci/ansi2json/line_spec.rb9
-rw-r--r--spec/lib/gitlab/ci/ansi2json_spec.rb38
-rw-r--r--spec/lib/gitlab/ci/config/entry/product/matrix_spec.rb147
-rw-r--r--spec/lib/gitlab/ci/config/entry/product/variables_spec.rb77
-rw-r--r--spec/lib/gitlab/ci/cron_parser_spec.rb14
-rw-r--r--spec/lib/gitlab/ci/parsers/test/junit_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/pipeline/seed/build_spec.rb10
-rw-r--r--spec/lib/gitlab/ci/runner/backoff_spec.rb126
-rw-r--r--spec/lib/gitlab/ci/trace/checksum_spec.rb121
-rw-r--r--spec/lib/gitlab/ci/trace/metrics_spec.rb18
-rw-r--r--spec/lib/gitlab/ci/trace_spec.rb9
-rw-r--r--spec/lib/gitlab/ci/yaml_processor/result_spec.rb45
-rw-r--r--spec/lib/gitlab/cleanup/orphan_lfs_file_references_spec.rb12
-rw-r--r--spec/lib/gitlab/code_navigation_path_spec.rb14
-rw-r--r--spec/lib/gitlab/cycle_analytics/events_spec.rb273
-rw-r--r--spec/lib/gitlab/danger/commit_linter_spec.rb14
-rw-r--r--spec/lib/gitlab/danger/helper_spec.rb22
-rw-r--r--spec/lib/gitlab/danger/roulette_spec.rb73
-rw-r--r--spec/lib/gitlab/danger/teammate_spec.rb2
-rw-r--r--spec/lib/gitlab/database/background_migration_job_spec.rb2
-rw-r--r--spec/lib/gitlab/database/batch_count_spec.rb36
-rw-r--r--spec/lib/gitlab/database/concurrent_reindex_spec.rb207
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb102
-rw-r--r--spec/lib/gitlab/database/obsolete_ignored_columns_spec.rb2
-rw-r--r--spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb2
-rw-r--r--spec/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table_spec.rb17
-rw-r--r--spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb6
-rw-r--r--spec/lib/gitlab/database/postgres_index_spec.rb116
-rw-r--r--spec/lib/gitlab/database/reindexing/concurrent_reindex_spec.rb255
-rw-r--r--spec/lib/gitlab/database/reindexing/reindex_action_spec.rb86
-rw-r--r--spec/lib/gitlab/database/reindexing_spec.rb66
-rw-r--r--spec/lib/gitlab/database/similarity_score_spec.rb11
-rw-r--r--spec/lib/gitlab/database/with_lock_retries_spec.rb64
-rw-r--r--spec/lib/gitlab/database_spec.rb81
-rw-r--r--spec/lib/gitlab/diff/file_collection/merge_request_diff_batch_spec.rb2
-rw-r--r--spec/lib/gitlab/diff/highlight_cache_spec.rb34
-rw-r--r--spec/lib/gitlab/email/handler/create_note_handler_spec.rb23
-rw-r--r--spec/lib/gitlab/exclusive_lease_helpers_spec.rb15
-rw-r--r--spec/lib/gitlab/experimentation_spec.rb58
-rw-r--r--spec/lib/gitlab/git/branch_spec.rb6
-rw-r--r--spec/lib/gitlab/git/diff_collection_spec.rb17
-rw-r--r--spec/lib/gitlab/git/diff_spec.rb12
-rw-r--r--spec/lib/gitlab/git/object_pool_spec.rb4
-rw-r--r--spec/lib/gitlab/git/remote_mirror_spec.rb4
-rw-r--r--spec/lib/gitlab/git/repository_spec.rb2
-rw-r--r--spec/lib/gitlab/git/rugged_impl/use_rugged_spec.rb4
-rw-r--r--spec/lib/gitlab/git_access_snippet_spec.rb23
-rw-r--r--spec/lib/gitlab/gitaly_client/commit_service_spec.rb4
-rw-r--r--spec/lib/gitlab/gon_helper_spec.rb4
-rw-r--r--spec/lib/gitlab/grape_logging/loggers/queue_duration_logger_spec.rb2
-rw-r--r--spec/lib/gitlab/graphql/markdown_field/resolver_spec.rb33
-rw-r--r--spec/lib/gitlab/graphql/markdown_field_spec.rb59
-rw-r--r--spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb11
-rw-r--r--spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb7
-rw-r--r--spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb2
-rw-r--r--spec/lib/gitlab/group_search_results_spec.rb7
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml4
-rw-r--r--spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb10
-rw-r--r--spec/lib/gitlab/import_export/group/relation_factory_spec.rb95
-rw-r--r--spec/lib/gitlab/import_export/lfs_saver_spec.rb8
-rw-r--r--spec/lib/gitlab/import_export/project/relation_factory_spec.rb73
-rw-r--r--spec/lib/gitlab/job_waiter_spec.rb17
-rw-r--r--spec/lib/gitlab/lfs/client_spec.rb103
-rw-r--r--spec/lib/gitlab/lfs_token_spec.rb2
-rw-r--r--spec/lib/gitlab/manifest_import/manifest_spec.rb14
-rw-r--r--spec/lib/gitlab/manifest_import/metadata_spec.rb62
-rw-r--r--spec/lib/gitlab/metrics/dashboard/importers/prometheus_metrics_spec.rb82
-rw-r--r--spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb88
-rw-r--r--spec/lib/gitlab/middleware/rails_queue_duration_spec.rb2
-rw-r--r--spec/lib/gitlab/pagination/offset_pagination_spec.rb30
-rw-r--r--spec/lib/gitlab/project_search_results_spec.rb6
-rw-r--r--spec/lib/gitlab/project_template_spec.rb6
-rw-r--r--spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb2
-rw-r--r--spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb2
-rw-r--r--spec/lib/gitlab/prometheus/query_variables_spec.rb4
-rw-r--r--spec/lib/gitlab/regex_spec.rb205
-rw-r--r--spec/lib/gitlab/search_results_spec.rb39
-rw-r--r--spec/lib/gitlab/sql/pattern_spec.rb37
-rw-r--r--spec/lib/gitlab/static_site_editor/config/file_config/entry/global_spec.rb245
-rw-r--r--spec/lib/gitlab/static_site_editor/config/file_config/entry/image_upload_path_spec.rb38
-rw-r--r--spec/lib/gitlab/static_site_editor/config/file_config/entry/mount_spec.rb101
-rw-r--r--spec/lib/gitlab/static_site_editor/config/file_config/entry/mounts_spec.rb53
-rw-r--r--spec/lib/gitlab/static_site_editor/config/file_config/entry/static_site_generator_spec.rb50
-rw-r--r--spec/lib/gitlab/static_site_editor/config/file_config_spec.rb82
-rw-r--r--spec/lib/gitlab/static_site_editor/config/generated_config_spec.rb22
-rw-r--r--spec/lib/gitlab/themes_spec.rb14
-rw-r--r--spec/lib/gitlab/tracking_spec.rb2
-rw-r--r--spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb12
-rw-r--r--spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb26
-rw-r--r--spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb50
-rw-r--r--spec/lib/gitlab/usage_data_counters/track_unique_events_spec.rb4
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb84
-rw-r--r--spec/lib/gitlab/utils/usage_data_spec.rb17
-rw-r--r--spec/lib/gitlab/webpack/manifest_spec.rb116
-rw-r--r--spec/lib/gitlab_danger_spec.rb2
-rw-r--r--spec/lib/google_api/auth_spec.rb8
-rw-r--r--spec/lib/grafana/time_window_spec.rb6
-rw-r--r--spec/lib/pager_duty/webhook_payload_parser_spec.rb84
-rw-r--r--spec/lib/safe_zip/extract_spec.rb36
-rw-r--r--spec/mailers/abuse_report_mailer_spec.rb10
-rw-r--r--spec/mailers/emails/projects_spec.rb119
-rw-r--r--spec/mailers/notify_spec.rb36
-rw-r--r--spec/migrations/add_partial_index_to_ci_builds_table_on_user_id_name_spec.rb22
-rw-r--r--spec/migrations/backfill_status_page_published_incidents_spec.rb2
-rw-r--r--spec/migrations/cleanup_group_import_states_with_null_user_id_spec.rb101
-rw-r--r--spec/migrations/ensure_filled_file_store_on_package_files_spec.rb40
-rw-r--r--spec/models/alert_management/http_integration_spec.rb92
-rw-r--r--spec/models/analytics/instance_statistics/measurement_spec.rb30
-rw-r--r--spec/models/application_setting/term_spec.rb5
-rw-r--r--spec/models/application_setting_spec.rb2
-rw-r--r--spec/models/audit_event_spec.rb7
-rw-r--r--spec/models/authentication_event_spec.rb36
-rw-r--r--spec/models/ci/build_pending_state_spec.rb27
-rw-r--r--spec/models/ci/build_spec.rb20
-rw-r--r--spec/models/ci/build_trace_chunk_spec.rb58
-rw-r--r--spec/models/ci/freeze_period_status_spec.rb4
-rw-r--r--spec/models/ci/job_artifact_spec.rb4
-rw-r--r--spec/models/ci/pipeline_schedule_spec.rb4
-rw-r--r--spec/models/ci/pipeline_spec.rb12
-rw-r--r--spec/models/ci_platform_metric_spec.rb6
-rw-r--r--spec/models/clusters/applications/runner_spec.rb4
-rw-r--r--spec/models/clusters/cluster_spec.rb1
-rw-r--r--spec/models/commit_status_spec.rb133
-rw-r--r--spec/models/concerns/avatarable_spec.rb4
-rw-r--r--spec/models/concerns/bulk_insertable_associations_spec.rb6
-rw-r--r--spec/models/concerns/cache_markdown_field_spec.rb6
-rw-r--r--spec/models/concerns/case_sensitivity_spec.rb4
-rw-r--r--spec/models/concerns/checksummable_spec.rb16
-rw-r--r--spec/models/concerns/counter_attribute_spec.rb30
-rw-r--r--spec/models/concerns/featurable_spec.rb2
-rw-r--r--spec/models/concerns/issuable_spec.rb76
-rw-r--r--spec/models/concerns/mentionable_spec.rb6
-rw-r--r--spec/models/concerns/milestoneable_spec.rb2
-rw-r--r--spec/models/concerns/milestoneish_spec.rb8
-rw-r--r--spec/models/concerns/reactive_caching_spec.rb11
-rw-r--r--spec/models/concerns/resolvable_discussion_spec.rb6
-rw-r--r--spec/models/concerns/routable_spec.rb2
-rw-r--r--spec/models/concerns/schedulable_spec.rb2
-rw-r--r--spec/models/concerns/subscribable_spec.rb22
-rw-r--r--spec/models/concerns/token_authenticatable_spec.rb2
-rw-r--r--spec/models/container_repository_spec.rb27
-rw-r--r--spec/models/design_management/design_collection_spec.rb12
-rw-r--r--spec/models/design_management/design_spec.rb9
-rw-r--r--spec/models/event_spec.rb50
-rw-r--r--spec/models/group_import_state_spec.rb1
-rw-r--r--spec/models/group_spec.rb319
-rw-r--r--spec/models/import_failure_spec.rb2
-rw-r--r--spec/models/integration_spec.rb10
-rw-r--r--spec/models/issue/metrics_spec.rb14
-rw-r--r--spec/models/issue_email_participant_spec.rb19
-rw-r--r--spec/models/issue_spec.rb158
-rw-r--r--spec/models/iteration_spec.rb45
-rw-r--r--spec/models/member_spec.rb76
-rw-r--r--spec/models/merge_request_diff_spec.rb11
-rw-r--r--spec/models/merge_request_spec.rb97
-rw-r--r--spec/models/namespace_spec.rb136
-rw-r--r--spec/models/notification_setting_spec.rb1
-rw-r--r--spec/models/operations/feature_flag_spec.rb18
-rw-r--r--spec/models/operations/feature_flags/strategy_spec.rb176
-rw-r--r--spec/models/packages/package_spec.rb14
-rw-r--r--spec/models/plan_limits_spec.rb1
-rw-r--r--spec/models/project_feature_usage_spec.rb2
-rw-r--r--spec/models/project_spec.rb35
-rw-r--r--spec/models/project_statistics_spec.rb52
-rw-r--r--spec/models/project_tracing_setting_spec.rb40
-rw-r--r--spec/models/resource_label_event_spec.rb36
-rw-r--r--spec/models/resource_milestone_event_spec.rb1
-rw-r--r--spec/models/resource_state_event_spec.rb16
-rw-r--r--spec/models/resource_weight_event_spec.rb10
-rw-r--r--spec/models/service_spec.rb36
-rw-r--r--spec/models/snippet_input_action_spec.rb2
-rw-r--r--spec/models/snippet_repository_spec.rb34
-rw-r--r--spec/models/snippet_statistics_spec.rb19
-rw-r--r--spec/models/terraform/state_spec.rb21
-rw-r--r--spec/models/terraform/state_version_spec.rb2
-rw-r--r--spec/models/todo_spec.rb46
-rw-r--r--spec/models/user_spec.rb28
-rw-r--r--spec/models/wiki_directory_spec.rb78
-rw-r--r--spec/models/wiki_page_spec.rb293
-rw-r--r--spec/policies/design_management/design_policy_spec.rb36
-rw-r--r--spec/policies/global_policy_spec.rb18
-rw-r--r--spec/policies/group_policy_spec.rb70
-rw-r--r--spec/policies/project_policy_spec.rb2
-rw-r--r--spec/policies/terraform/state_policy_spec.rb33
-rw-r--r--spec/presenters/ci/pipeline_presenter_spec.rb17
-rw-r--r--spec/presenters/event_presenter_spec.rb30
-rw-r--r--spec/presenters/merge_request_presenter_spec.rb21
-rw-r--r--spec/presenters/projects/prometheus/alert_presenter_spec.rb346
-rw-r--r--spec/presenters/sentry_error_presenter_spec.rb8
-rw-r--r--spec/presenters/snippet_blob_presenter_spec.rb92
-rw-r--r--spec/presenters/snippet_presenter_spec.rb21
-rw-r--r--spec/requests/api/admin/instance_clusters_spec.rb18
-rw-r--r--spec/requests/api/ci/runner/jobs_artifacts_spec.rb30
-rw-r--r--spec/requests/api/ci/runner/jobs_put_spec.rb43
-rw-r--r--spec/requests/api/commits_spec.rb59
-rw-r--r--spec/requests/api/debian_group_packages_spec.rb39
-rw-r--r--spec/requests/api/debian_project_packages_spec.rb46
-rw-r--r--spec/requests/api/features_spec.rb2
-rw-r--r--spec/requests/api/files_spec.rb6
-rw-r--r--spec/requests/api/generic_packages_spec.rb419
-rw-r--r--spec/requests/api/graphql/gitlab_schema_spec.rb4
-rw-r--r--spec/requests/api/graphql/mutations/boards/lists/destroy_spec.rb77
-rw-r--r--spec/requests/api/graphql/mutations/snippets/create_spec.rb12
-rw-r--r--spec/requests/api/graphql/mutations/snippets/update_spec.rb6
-rw-r--r--spec/requests/api/graphql/project/issues_spec.rb35
-rw-r--r--spec/requests/api/graphql_spec.rb10
-rw-r--r--spec/requests/api/group_clusters_spec.rb18
-rw-r--r--spec/requests/api/group_container_repositories_spec.rb2
-rw-r--r--spec/requests/api/group_packages_spec.rb6
-rw-r--r--spec/requests/api/groups_spec.rb133
-rw-r--r--spec/requests/api/helpers_spec.rb63
-rw-r--r--spec/requests/api/internal/base_spec.rb84
-rw-r--r--spec/requests/api/internal/lfs_spec.rb93
-rw-r--r--spec/requests/api/lint_spec.rb49
-rw-r--r--spec/requests/api/maven_packages_spec.rb4
-rw-r--r--spec/requests/api/merge_requests_spec.rb2
-rw-r--r--spec/requests/api/npm_packages_spec.rb10
-rw-r--r--spec/requests/api/project_clusters_spec.rb18
-rw-r--r--spec/requests/api/project_container_repositories_spec.rb8
-rw-r--r--spec/requests/api/project_packages_spec.rb13
-rw-r--r--spec/requests/api/releases_spec.rb4
-rw-r--r--spec/requests/api/repositories_spec.rb4
-rw-r--r--spec/requests/api/search_spec.rb36
-rw-r--r--spec/requests/api/services_spec.rb30
-rw-r--r--spec/requests/api/settings_spec.rb8
-rw-r--r--spec/requests/api/terraform/state_spec.rb2
-rw-r--r--spec/requests/api/terraform/state_version_spec.rb210
-rw-r--r--spec/requests/api/unleash_spec.rb608
-rw-r--r--spec/requests/api/usage_data_spec.rb4
-rw-r--r--spec/requests/api/users_spec.rb69
-rw-r--r--spec/requests/git_http_spec.rb26
-rw-r--r--spec/requests/projects/cycle_analytics_events_spec.rb2
-rw-r--r--spec/requests/request_profiler_spec.rb2
-rw-r--r--spec/routing/admin_routing_spec.rb6
-rw-r--r--spec/routing/group_routing_spec.rb4
-rw-r--r--spec/routing/instance_statistics_routing_spec.rb11
-rw-r--r--spec/routing/project_routing_spec.rb6
-rw-r--r--spec/routing/routing_spec.rb6
-rw-r--r--spec/rubocop/cop/code_reuse/active_record_spec.rb6
-rw-r--r--spec/serializers/ci/trigger_entity_spec.rb70
-rw-r--r--spec/serializers/ci/trigger_serializer_spec.rb15
-rw-r--r--spec/serializers/cluster_serializer_spec.rb1
-rw-r--r--spec/serializers/diff_file_base_entity_spec.rb55
-rw-r--r--spec/serializers/diffs_entity_spec.rb10
-rw-r--r--spec/serializers/feature_flag_entity_spec.rb22
-rw-r--r--spec/serializers/feature_flag_serializer_spec.rb23
-rw-r--r--spec/serializers/feature_flag_summary_entity_spec.rb21
-rw-r--r--spec/serializers/feature_flag_summary_serializer_spec.rb22
-rw-r--r--spec/serializers/feature_flags_client_serializer_spec.rb17
-rw-r--r--spec/serializers/group_group_link_entity_spec.rb22
-rw-r--r--spec/serializers/merge_request_poll_cached_widget_entity_spec.rb61
-rw-r--r--spec/serializers/merge_request_poll_widget_entity_spec.rb33
-rw-r--r--spec/serializers/merge_request_widget_entity_spec.rb4
-rw-r--r--spec/serializers/paginated_diff_entity_spec.rb10
-rw-r--r--spec/serializers/pipeline_serializer_spec.rb9
-rw-r--r--spec/serializers/test_case_entity_spec.rb2
-rw-r--r--spec/services/admin/propagate_integration_service_spec.rb82
-rw-r--r--spec/services/admin/propagate_service_template_spec.rb125
-rw-r--r--spec/services/alert_management/process_prometheus_alert_service_spec.rb36
-rw-r--r--spec/services/audit_event_service_spec.rb13
-rw-r--r--spec/services/bulk_create_integration_service_spec.rb107
-rw-r--r--spec/services/bulk_update_integration_service_spec.rb57
-rw-r--r--spec/services/ci/create_pipeline_service_spec.rb27
-rw-r--r--spec/services/ci/expire_pipeline_cache_service_spec.rb2
-rw-r--r--spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb4
-rw-r--r--spec/services/ci/pipelines/create_artifact_service_spec.rb10
-rw-r--r--spec/services/ci/retry_build_service_spec.rb4
-rw-r--r--spec/services/ci/update_build_queue_service_spec.rb16
-rw-r--r--spec/services/ci/update_build_state_service_spec.rb97
-rw-r--r--spec/services/clusters/gcp/finalize_creation_service_spec.rb16
-rw-r--r--spec/services/clusters/kubernetes/configure_istio_ingress_service_spec.rb20
-rw-r--r--spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb8
-rw-r--r--spec/services/clusters/kubernetes/fetch_kubernetes_token_service_spec.rb24
-rw-r--r--spec/services/deployments/after_create_service_spec.rb4
-rw-r--r--spec/services/design_management/copy_design_collection/copy_service_spec.rb259
-rw-r--r--spec/services/design_management/copy_design_collection/queue_service_spec.rb51
-rw-r--r--spec/services/design_management/delete_designs_service_spec.rb2
-rw-r--r--spec/services/design_management/generate_image_versions_service_spec.rb51
-rw-r--r--spec/services/design_management/save_designs_service_spec.rb33
-rw-r--r--spec/services/feature_flags/create_service_spec.rb79
-rw-r--r--spec/services/feature_flags/destroy_service_spec.rb61
-rw-r--r--spec/services/feature_flags/disable_service_spec.rb91
-rw-r--r--spec/services/feature_flags/enable_service_spec.rb153
-rw-r--r--spec/services/feature_flags/update_service_spec.rb250
-rw-r--r--spec/services/git/branch_hooks_service_spec.rb22
-rw-r--r--spec/services/git/wiki_push_service_spec.rb18
-rw-r--r--spec/services/groups/create_service_spec.rb87
-rw-r--r--spec/services/groups/import_export/import_service_spec.rb9
-rw-r--r--spec/services/groups/transfer_service_spec.rb38
-rw-r--r--spec/services/groups/update_service_spec.rb25
-rw-r--r--spec/services/groups/update_shared_runners_service_spec.rb194
-rw-r--r--spec/services/incident_management/incidents/update_severity_service_spec.rb86
-rw-r--r--spec/services/issuable/bulk_update_service_spec.rb2
-rw-r--r--spec/services/issuable/clone/attributes_rewriter_spec.rb10
-rw-r--r--spec/services/issuable/common_system_notes_service_spec.rb8
-rw-r--r--spec/services/issues/close_service_spec.rb23
-rw-r--r--spec/services/issues/move_service_spec.rb50
-rw-r--r--spec/services/issues/update_service_spec.rb2
-rw-r--r--spec/services/keys/last_used_service_spec.rb2
-rw-r--r--spec/services/lfs/push_service_spec.rb48
-rw-r--r--spec/services/members/destroy_service_spec.rb18
-rw-r--r--spec/services/members/invitation_reminder_email_service_spec.rb78
-rw-r--r--spec/services/merge_requests/close_service_spec.rb67
-rw-r--r--spec/services/merge_requests/export_csv_service_spec.rb115
-rw-r--r--spec/services/merge_requests/ff_merge_service_spec.rb104
-rw-r--r--spec/services/merge_requests/merge_service_spec.rb22
-rw-r--r--spec/services/merge_requests/refresh_service_spec.rb139
-rw-r--r--spec/services/merge_requests/reopen_service_spec.rb20
-rw-r--r--spec/services/merge_requests/update_service_spec.rb53
-rw-r--r--spec/services/metrics/dashboard/custom_dashboard_service_spec.rb17
-rw-r--r--spec/services/milestones/destroy_service_spec.rb2
-rw-r--r--spec/services/milestones/promote_service_spec.rb2
-rw-r--r--spec/services/milestones/transfer_service_spec.rb2
-rw-r--r--spec/services/namespace_settings/update_service_spec.rb35
-rw-r--r--spec/services/notes/create_service_spec.rb2
-rw-r--r--spec/services/notes/update_service_spec.rb2
-rw-r--r--spec/services/notification_service_spec.rb85
-rw-r--r--spec/services/packages/create_event_service_spec.rb54
-rw-r--r--spec/services/packages/generic/create_package_file_service_spec.rb54
-rw-r--r--spec/services/packages/generic/find_or_create_package_service_spec.rb88
-rw-r--r--spec/services/projects/alerting/notify_service_spec.rb45
-rw-r--r--spec/services/projects/container_repository/cleanup_tags_service_spec.rb6
-rw-r--r--spec/services/projects/container_repository/delete_tags_service_spec.rb106
-rw-r--r--spec/services/projects/create_service_spec.rb163
-rw-r--r--spec/services/projects/transfer_service_spec.rb31
-rw-r--r--spec/services/projects/update_pages_service_spec.rb6
-rw-r--r--spec/services/projects/update_remote_mirror_service_spec.rb17
-rw-r--r--spec/services/projects/update_service_spec.rb26
-rw-r--r--spec/services/quick_actions/interpret_service_spec.rb72
-rw-r--r--spec/services/resource_access_tokens/create_service_spec.rb12
-rw-r--r--spec/services/search_service_spec.rb6
-rw-r--r--spec/services/snippets/update_service_spec.rb95
-rw-r--r--spec/services/static_site_editor/config_service_spec.rb86
-rw-r--r--spec/services/system_note_service_spec.rb24
-rw-r--r--spec/services/system_notes/incident_service_spec.rb59
-rw-r--r--spec/services/system_notes/issuables_service_spec.rb142
-rw-r--r--spec/services/users/build_service_spec.rb14
-rw-r--r--spec/simplecov_env.rb3
-rw-r--r--spec/spec_helper.rb14
-rw-r--r--spec/support/capybara.rb4
-rw-r--r--spec/support/counter_attribute.rb6
-rw-r--r--spec/support/factory_bot.rb4
-rw-r--r--spec/support/google_api/cloud_platform_helpers.rb2
-rw-r--r--spec/support/helpers/api_internal_base_helpers.rb85
-rw-r--r--spec/support/helpers/features/canonical_link_helpers.rb28
-rw-r--r--spec/support/helpers/git_http_helpers.rb26
-rw-r--r--spec/support/helpers/graphql_helpers.rb3
-rw-r--r--spec/support/helpers/kubernetes_helpers.rb10
-rw-r--r--spec/support/helpers/search_helpers.rb10
-rw-r--r--spec/support/helpers/stub_experiments.rb4
-rw-r--r--spec/support/helpers/stub_feature_flags.rb4
-rw-r--r--spec/support/helpers/stub_object_storage.rb18
-rw-r--r--spec/support/helpers/usage_data_helpers.rb4
-rw-r--r--spec/support/helpers/wait_for_requests.rb11
-rw-r--r--spec/support/helpers/wiki_helpers.rb10
-rw-r--r--spec/support/matchers/be_sorted.rb21
-rw-r--r--spec/support/migrations_helpers/schema_version_finder.rb34
-rw-r--r--spec/support/models/merge_request_without_merge_request_diff.rb7
-rw-r--r--spec/support/shared_contexts/cache_allowed_users_in_namespace_shared_context.rb2
-rw-r--r--spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/controllers/known_sign_in_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/controllers/milestone_tabs_shared_examples.rb23
-rw-r--r--spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/features/editable_merge_request_shared_examples.rb26
-rw-r--r--spec/support/shared_examples/features/navbar_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/features/packages_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/features/page_description_shared_examples.rb9
-rw-r--r--spec/support/shared_examples/features/protected_branches_with_deploy_keys_examples.rb95
-rw-r--r--spec/support/shared_examples/features/wiki/file_attachments_shared_examples.rb86
-rw-r--r--spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb245
-rw-r--r--spec/support/shared_examples/features/wiki/user_deletes_wiki_page_shared_examples.rb24
-rw-r--r--spec/support/shared_examples/features/wiki/user_previews_wiki_changes_shared_examples.rb110
-rw-r--r--spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb231
-rw-r--r--spec/support/shared_examples/features/wiki/user_uses_wiki_shortcuts_shared_examples.rb20
-rw-r--r--spec/support/shared_examples/features/wiki/user_views_asciidoc_page_with_includes_shared_examples.rb77
-rw-r--r--spec/support/shared_examples/features/wiki/user_views_wiki_empty_shared_examples.rb62
-rw-r--r--spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb278
-rw-r--r--spec/support/shared_examples/features/wiki/user_views_wiki_pages_shared_examples.rb89
-rw-r--r--spec/support/shared_examples/features/wiki/user_views_wiki_sidebar_shared_examples.rb68
-rw-r--r--spec/support/shared_examples/features/wiki_file_attachments_shared_examples.rb88
-rw-r--r--spec/support/shared_examples/lib/gitlab/background_migration/mentions_migration_shared_examples.rb24
-rw-r--r--spec/support/shared_examples/lib/gitlab/diff_file_collections_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/lib/gitlab/import_export/relation_factory_shared_examples.rb107
-rw-r--r--spec/support/shared_examples/lib/gitlab/search_confidential_filter_shared_examples.rb91
-rw-r--r--spec/support/shared_examples/lib/gitlab/search_results_sorted_shared_examples.rb19
-rw-r--r--spec/support/shared_examples/lib/gitlab/search_state_filter_shared_examples.rb (renamed from spec/support/shared_examples/lib/gitlab/search_issue_state_filter_shared_examples.rb)0
-rw-r--r--spec/support/shared_examples/models/mentionable_shared_examples.rb23
-rw-r--r--spec/support/shared_examples/models/relative_positioning_shared_examples.rb163
-rw-r--r--spec/support/shared_examples/models/resource_timebox_event_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/models/throttled_touch_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/models/update_project_statistics_shared_examples.rb260
-rw-r--r--spec/support/shared_examples/models/wiki_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/policies/resource_access_token_shared_examples.rb32
-rw-r--r--spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb309
-rw-r--r--spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/requests/api/packages_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/requests/api/tracking_shared_examples.rb9
-rw-r--r--spec/support/shared_examples/services/boards/boards_create_service_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/services/merge_request_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/services/packages_shared_examples.rb1
-rw-r--r--spec/support/shared_examples/services/projects/urls_with_escaped_elements_shared_example.rb20
-rw-r--r--spec/support/shared_examples/validators/ip_address_validator_shared_examples.rb10
-rw-r--r--spec/support_specs/helpers/stub_feature_flags_spec.rb31
-rw-r--r--spec/tasks/gitlab/backup_rake_spec.rb34
-rw-r--r--spec/tasks/gitlab/db_rake_spec.rb74
-rw-r--r--spec/uploaders/pages/deployment_uploader_spec.rb39
-rw-r--r--spec/validators/ip_address_validator_spec.rb37
-rw-r--r--spec/views/admin/dashboard/index.html.haml_spec.rb2
-rw-r--r--spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb18
-rw-r--r--spec/views/profiles/preferences/show.html.haml_spec.rb8
-rw-r--r--spec/views/projects/merge_requests/diffs/_diffs.html.haml_spec.rb38
-rw-r--r--spec/views/search/_results.html.haml_spec.rb22
-rw-r--r--spec/views/shared/milestones/_issuables.html.haml_spec.rb3
-rw-r--r--spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb20
-rw-r--r--spec/workers/authorized_project_update/periodic_recalculate_worker_spec.rb12
-rw-r--r--spec/workers/authorized_project_update/user_refresh_over_user_range_worker_spec.rb12
-rw-r--r--spec/workers/cleanup_container_repository_worker_spec.rb23
-rw-r--r--spec/workers/concerns/limited_capacity/job_tracker_spec.rb100
-rw-r--r--spec/workers/concerns/limited_capacity/worker_spec.rb285
-rw-r--r--spec/workers/design_management/copy_design_collection_worker_spec.rb39
-rw-r--r--spec/workers/design_management/new_version_worker_spec.rb4
-rw-r--r--spec/workers/git_garbage_collect_worker_spec.rb50
-rw-r--r--spec/workers/group_import_worker_spec.rb53
-rw-r--r--spec/workers/incident_management/add_severity_system_note_worker_spec.rb60
-rw-r--r--spec/workers/incident_management/process_alert_worker_spec.rb2
-rw-r--r--spec/workers/incident_management/process_prometheus_alert_worker_spec.rb2
-rw-r--r--spec/workers/member_invitation_reminder_emails_worker_spec.rb29
-rw-r--r--spec/workers/metrics/dashboard/sync_dashboards_worker_spec.rb25
-rw-r--r--spec/workers/post_receive_spec.rb2
-rw-r--r--spec/workers/propagate_integration_group_worker_spec.rb37
-rw-r--r--spec/workers/propagate_integration_inherit_worker_spec.rb38
-rw-r--r--spec/workers/propagate_integration_project_worker_spec.rb37
-rw-r--r--vendor/Dockerfile/Golang-alpine.Dockerfile2
-rw-r--r--vendor/Dockerfile/Golang-scratch.Dockerfile2
-rw-r--r--vendor/Dockerfile/Golang.Dockerfile2
-rw-r--r--[-rwxr-xr-x]vendor/gitignore/C++.gitignore0
-rw-r--r--[-rwxr-xr-x]vendor/gitignore/Java.gitignore0
-rwxr-xr-xvendor/languages.yml1433
-rw-r--r--vendor/project_templates/gitpod_spring_petclinic.tar.gzbin0 -> 402980 bytes
-rw-r--r--yarn.lock393
3574 files changed, 74106 insertions, 30073 deletions
diff --git a/.gitignore b/.gitignore
index 2a07963eecc..25c42cdb56d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -96,4 +96,4 @@ apollo.config.js
/tmp/matching_foss_tests.txt
/tmp/matching_tests.txt
ee/changelogs/unreleased-ee
-
+/sitespeed-result
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 638613ab43e..f9c9c5888db 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -17,7 +17,7 @@ stages:
# in cases where jobs require Docker-in-Docker, the job
# definition must be extended with `.use-docker-in-docker`
default:
- image: "registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.6-golang-1.14-git-2.28-lfs-2.9-chrome-84-node-12.x-yarn-1.21-postgresql-11-graphicsmagick-1.3.34"
+ image: "registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.6-golang-1.14-git-2.28-lfs-2.9-chrome-85-node-12.18-yarn-1.22-postgresql-11-graphicsmagick-1.3.34"
tags:
- gitlab-org
# All jobs are interruptible by default
diff --git a/.gitlab/CODEOWNERS b/.gitlab/CODEOWNERS
index b4fd436cc58..f2df17b1ed8 100644
--- a/.gitlab/CODEOWNERS
+++ b/.gitlab/CODEOWNERS
@@ -184,7 +184,7 @@ Dangerfile @gl-quality/eng-prod
/lib/gitlab/auth/ldap/ @dblessing @mkozono
[Templates]
-/lib/gitlab/ci/templates/ @nolith @dosuken123
+/lib/gitlab/ci/templates/ @nolith @shinya.maeda
/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml @DylanGriffith @mayra-cabrera @tkuah
/lib/gitlab/ci/templates/Security/ @plafoucriere @gonzoyumo @twoodham @sethgitlab
diff --git a/.gitlab/ci/docs.gitlab-ci.yml b/.gitlab/ci/docs.gitlab-ci.yml
index 0e0e156a64f..0c6b5b31ab6 100644
--- a/.gitlab/ci/docs.gitlab-ci.yml
+++ b/.gitlab/ci/docs.gitlab-ci.yml
@@ -42,7 +42,7 @@ docs lint:
extends:
- .default-retry
- .docs:rules:docs-lint
- image: "registry.gitlab.com/gitlab-org/gitlab-docs/lint:vale-2.3.4-markdownlint-0.23.2"
+ image: "registry.gitlab.com/gitlab-org/gitlab-docs/lint:vale-2.4.0-markdownlint-0.23.2"
stage: test
needs: []
script:
diff --git a/.gitlab/ci/frontend.gitlab-ci.yml b/.gitlab/ci/frontend.gitlab-ci.yml
index 27f56cd8667..35555ed0a2e 100644
--- a/.gitlab/ci/frontend.gitlab-ci.yml
+++ b/.gitlab/ci/frontend.gitlab-ci.yml
@@ -11,7 +11,7 @@
extends:
- .frontend-base
- .assets-compile-cache
- image: registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.6-git-2.28-lfs-2.9-node-12.x-yarn-1.21-graphicsmagick-1.3.34
+ image: registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.6-git-2.28-lfs-2.9-node-12.18-yarn-1.22-graphicsmagick-1.3.34
variables:
WEBPACK_VENDOR_DLL: "true"
stage: prepare
@@ -19,7 +19,7 @@
- node --version
- run_timed_command "retry yarn install --frozen-lockfile"
- free -m
- - run_timed_command "bin/rake gitlab:assets:compile > assets-compile.log 2>&1"
+ - run_timed_command "bin/rake gitlab:assets:compile"
- run_timed_command "scripts/clean-old-cached-assets"
compile-production-assets:
@@ -34,7 +34,6 @@ compile-production-assets:
name: webpack-report
expire_in: 31d
paths:
- - assets-compile.log
# These assets are used in multiple locations:
# - in `build-assets-image` job to create assets image for packaging systems
# - GitLab UI for integration tests: https://gitlab.com/gitlab-org/gitlab-ui/-/blob/e88493b3c855aea30bf60baee692a64606b0eb1e/.storybook/preview-head.pug#L1
@@ -51,7 +50,6 @@ compile-test-assets:
artifacts:
expire_in: 7d
paths:
- - assets-compile.log
- public/assets/
- node_modules/@gitlab/svgs/dist/icons.json # app/helpers/icons_helper.rb uses this file
when: always
diff --git a/.gitlab/ci/global.gitlab-ci.yml b/.gitlab/ci/global.gitlab-ci.yml
index 238059bf972..181e8c8811c 100644
--- a/.gitlab/ci/global.gitlab-ci.yml
+++ b/.gitlab/ci/global.gitlab-ci.yml
@@ -34,6 +34,13 @@
- tmp/rubocop_cache/
policy: pull
+.coverage-cache:
+ cache:
+ key: "coverage-cache-v1"
+ paths:
+ - vendor/ruby/
+ policy: pull
+
.qa-cache:
cache:
key: "qa-v1"
@@ -64,7 +71,7 @@
policy: pull
.use-pg11:
- image: "registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.6-golang-1.14-git-2.28-lfs-2.9-chrome-84-node-12.x-yarn-1.21-postgresql-11-graphicsmagick-1.3.34"
+ image: "registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.6-golang-1.14-git-2.28-lfs-2.9-chrome-85-node-12.18-yarn-1.22-postgresql-11-graphicsmagick-1.3.34"
services:
- name: postgres:11.6
command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"]
@@ -73,7 +80,7 @@
POSTGRES_HOST_AUTH_METHOD: trust
.use-pg12:
- image: "registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.6-golang-1.14-git-2.28-lfs-2.9-chrome-84-node-12.x-yarn-1.21-postgresql-12-graphicsmagick-1.3.34"
+ image: "registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.6-golang-1.14-git-2.28-lfs-2.9-chrome-85-node-12.18-yarn-1.22-postgresql-12-graphicsmagick-1.3.34"
services:
- name: postgres:12
command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"]
@@ -82,7 +89,7 @@
POSTGRES_HOST_AUTH_METHOD: trust
.use-pg11-ee:
- image: "registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.6-golang-1.14-git-2.28-lfs-2.9-chrome-84-node-12.x-yarn-1.21-postgresql-11-graphicsmagick-1.3.34"
+ image: "registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.6-golang-1.14-git-2.28-lfs-2.9-chrome-85-node-12.18-yarn-1.22-postgresql-11-graphicsmagick-1.3.34"
services:
- name: postgres:11.6
command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"]
@@ -92,7 +99,7 @@
POSTGRES_HOST_AUTH_METHOD: trust
.use-pg12-ee:
- image: "registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.6-golang-1.14-git-2.28-lfs-2.9-chrome-84-node-12.x-yarn-1.21-postgresql-12-graphicsmagick-1.3.34"
+ image: "registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.6-golang-1.14-git-2.28-lfs-2.9-chrome-85-node-12.18-yarn-1.22-postgresql-12-graphicsmagick-1.3.34"
services:
- name: postgres:12
command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"]
diff --git a/.gitlab/ci/rails.gitlab-ci.yml b/.gitlab/ci/rails.gitlab-ci.yml
index 165476678bb..38bcea0df1f 100644
--- a/.gitlab/ci/rails.gitlab-ci.yml
+++ b/.gitlab/ci/rails.gitlab-ci.yml
@@ -11,6 +11,9 @@
stage: test
needs: ["setup-test-env", "retrieve-tests-metadata", "compile-test-assets"]
script:
+ # Only install knapsack after bundle install! Otherwise oddly some native
+ # gems could not be found under some circumstance. No idea why, hours wasted.
+ - run_timed_command "gem install knapsack --no-document"
- run_timed_command "scripts/gitaly-test-build"
- run_timed_command "scripts/gitaly-test-spawn"
- source scripts/rspec_helpers.sh
@@ -32,6 +35,9 @@
.rspec-base-migration:
extends: .rails:rules:ee-and-foss-migration
script:
+ # Only install knapsack after bundle install! Otherwise oddly some native
+ # gems could not be found under some circumstance. No idea why, hours wasted.
+ - run_timed_command "gem install knapsack --no-document"
- run_timed_command "scripts/gitaly-test-build"
- run_timed_command "scripts/gitaly-test-spawn"
- source scripts/rspec_helpers.sh
@@ -67,6 +73,9 @@
.rspec-ee-base-geo:
extends: .rspec-base
script:
+ # Only install knapsack after bundle install! Otherwise oddly some native
+ # gems could not be found under some circumstance. No idea why, hours wasted.
+ - run_timed_command "gem install knapsack --no-document"
- run_timed_command "scripts/gitaly-test-build"
- run_timed_command "scripts/gitaly-test-spawn"
- source scripts/rspec_helpers.sh
@@ -160,6 +169,25 @@ update-rails-cache:
cache:
policy: push # We want to rebuild the cache from scratch to ensure stale dependencies are cleaned up.
+.coverage-base:
+ extends:
+ - .default-retry
+ - .default-before_script
+ - .coverage-cache
+ variables:
+ SETUP_DB: "false"
+ USE_BUNDLE_INSTALL: "false"
+
+update-coverage-cache:
+ extends:
+ - .coverage-base
+ - .shared:rules:update-cache
+ stage: prepare
+ script:
+ - run_timed_command "bundle install --jobs=$(nproc) --path=vendor --retry=3 --quiet --without default development test production puma unicorn kerberos metrics omnibus ed25519"
+ cache:
+ policy: push # We want to rebuild the cache from scratch to ensure stale dependencies are cleaned up.
+
.static-analysis-base:
extends:
- .default-retry
@@ -178,7 +206,7 @@ update-static-analysis-cache:
script:
- rm -rf ./node_modules # We remove node_modules because there's no mechanism to remove stall entries.
- run_timed_command "retry yarn install --frozen-lockfile"
- - bundle exec rubocop --parallel # For the moment we only cache `vendor/ruby/`, `node_modules/`, and `tmp/rubocop_cache` so we don't need to run all the tasks,
+ - run_timed_command "bundle exec rubocop --parallel" # For the moment we only cache `vendor/ruby/`, `node_modules/`, and `tmp/rubocop_cache` so we don't need to run all the tasks,
cache:
# We want to rebuild the cache from scratch to ensure stale dependencies are cleaned up but RuboCop has a mechanism
# for keeping only the N latest cache files, so we take advantage of it with `pull-push` and removing `node_modules` at the start of the job.
@@ -313,7 +341,7 @@ db:backup_and_restore:
rspec:coverage:
extends:
- - .rails-job-base
+ - .coverage-base
- .rails:rules:rspec-coverage
stage: post-test
# We cannot use needs since it would mean needing 84 jobs (since most are parallelized)
@@ -333,11 +361,10 @@ rspec:coverage:
- rspec-ee system pg11 geo
- memory-static
- memory-on-boot
- variables:
- SETUP_DB: "false"
script:
- - bundle exec scripts/merge-simplecov
- - bundle exec scripts/gather-test-memory-data
+ - run_timed_command "bundle install --jobs=$(nproc) --path=vendor --retry=3 --quiet --without default development test production puma unicorn kerberos metrics omnibus ed25519"
+ - run_timed_command "bundle exec scripts/merge-simplecov"
+ - run_timed_command "bundle exec scripts/gather-test-memory-data"
coverage: '/LOC \((\d+\.\d+%)\) covered.$/'
artifacts:
name: coverage
diff --git a/.gitlab/ci/review.gitlab-ci.yml b/.gitlab/ci/review.gitlab-ci.yml
index d34687cfdad..fab33dd3c07 100644
--- a/.gitlab/ci/review.gitlab-ci.yml
+++ b/.gitlab/ci/review.gitlab-ci.yml
@@ -174,7 +174,7 @@ review-performance:
- .default-retry
- .review:rules:review-performance
image:
- name: sitespeedio/sitespeed.io:6.3.1
+ name: sitespeedio/sitespeed.io
entrypoint: [""]
stage: qa
# This is needed so that manual jobs with needs don't block the pipeline.
diff --git a/.gitlab/issue_templates/Feature Flag Roll Out.md b/.gitlab/issue_templates/Feature Flag Roll Out.md
index 69053b396a4..788b610a982 100644
--- a/.gitlab/issue_templates/Feature Flag Roll Out.md
+++ b/.gitlab/issue_templates/Feature Flag Roll Out.md
@@ -30,14 +30,14 @@ If applicable, any groups/projects that are happy to have this feature turned on
## Roll Out Steps
-- [ ] Enable on staging
+- [ ] Enable on staging (`/chatops run feature set feature_name true --staging`)
- [ ] Test on staging
- [ ] Ensure that documentation has been updated
-- [ ] Enable on GitLab.com for individual groups/projects listed above and verify behaviour
+- [ ] Enable on GitLab.com for individual groups/projects listed above and verify behaviour (`/chatops run feature set --project=gitlab-org/gitlab feature_name true`)
- [ ] Coordinate a time to enable the flag with `#production` and `#g_delivery` on slack.
- [ ] Announce on the issue an estimated time this will be enabled on GitLab.com
-- [ ] Enable on GitLab.com by running chatops command in `#production`
-- [ ] Cross post chatops slack command to `#support_gitlab-com` ([more guidance when this is necessary in the dev docs](https://docs.gitlab.com/ee/development/feature_flags/controls.html#where-to-run-commands)) and in your team channel
+- [ ] Enable on GitLab.com by running chatops command in `#production` (`/chatops run feature set feature_name true`)
+- [ ] Cross post chatops Slack command to `#support_gitlab-com` ([more guidance when this is necessary in the dev docs](https://docs.gitlab.com/ee/development/feature_flags/controls.html#where-to-run-commands)) and in your team channel
- [ ] Announce on the issue that the flag has been enabled
- [ ] Remove feature flag and add changelog entry
- [ ] After the flag removal is deployed, [clean up the feature flag](https://docs.gitlab.com/ee/development/feature_flags/controls.html#cleaning-up) by running chatops command in `#production` channel
diff --git a/.gitlab/issue_templates/Feature proposal.md b/.gitlab/issue_templates/Feature proposal.md
index 0f19b7c75f5..c80305312c3 100644
--- a/.gitlab/issue_templates/Feature proposal.md
+++ b/.gitlab/issue_templates/Feature proposal.md
@@ -1,6 +1,6 @@
<!-- The first section "Release notes" is required if you want to have your release post blog MR auto generated. Currently in BETA, details on the **release post item generator** can be found in the handbook: https://about.gitlab.com/handbook/marketing/blog/release-posts/#release-post-item-generator and this video: https://www.youtube.com/watch?v=rfn9ebgTwKg. The next four sections: "Problem to solve", "Intended users", "User experience goal", and "Proposal", are strongly recommended in your first draft, while the rest of the sections can be filled out during the problem validation or breakdown phase. However, keep in mind that providing complete and relevant information early helps our product team validate the problem and start working on a solution. -->
-### Release notes
+### Release notes
<!-- What is the problem and solution you're proposing? This content sets the overall vision for the feature and serves as the release notes that will populate in various places, including the [release post blog](https://about.gitlab.com/releases/categories/releases/) and [Gitlab project releases](https://gitlab.com/gitlab-org/gitlab/-/releases). " -->
@@ -22,19 +22,19 @@ Personas are described at https://about.gitlab.com/handbook/marketing/product-ma
* [Devon (DevOps Engineer)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#devon-devops-engineer)
* [Sidney (Systems Administrator)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#sidney-systems-administrator)
* [Sam (Security Analyst)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#sam-security-analyst)
-* [Rachel (Release Manager)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#rachel-release-manager)
+* [Rachel (Release Manager)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#rachel-release-manager)
* [Alex (Security Operations Engineer)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#alex-security-operations-engineer)
* [Simone (Software Engineer in Test)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#simone-software-engineer-in-test)
-* [Allison (Application Ops)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#allison-application-ops)
+* [Allison (Application Ops)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#allison-application-ops)
* [Priyanka (Platform Engineer)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#priyanka-platform-engineer)
* [Dana (Data Analyst)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#dana-data-analyst)
-->
### User experience goal
-<!-- What is the single user experience workflow this problem addresses?
+<!-- What is the single user experience workflow this problem addresses?
For example, "The user should be able to use the UI/API/.gitlab-ci.yml with GitLab to <perform a specific task>"
-https://about.gitlab.com/handbook/engineering/ux/ux-research-training/user-story-mapping/ -->
+https://about.gitlab.com/handbook/engineering/ux/ux-research-training/user-story-mapping/ -->
### Proposal
@@ -52,7 +52,7 @@ Consider adding checkboxes and expectations of users with certain levels of memb
* [ ] Add expected impact to members with no access (0)
* [ ] Add expected impact to Guest (10) members
* [ ] Add expected impact to Reporter (20) members
-* [ ] Add expected impact to Developer (30) members
+* [ ] Add expected impact to Developer (30) members
* [ ] Add expected impact to Maintainer (40) members
* [ ] Add expected impact to Owner (50) members -->
@@ -78,7 +78,11 @@ See the test engineering planning process and reach out to your counterpart Soft
### What does success look like, and how can we measure that?
-<!-- Define both the success metrics and acceptance criteria. Note that success metrics indicate the desired business outcomes, while acceptance criteria indicate when the solution is working correctly. If there is no way to measure success, link to an issue that will implement a way to measure this. -->
+<!--
+Define both the success metrics and acceptance criteria. Note that success metrics indicate the desired business outcomes, while acceptance criteria indicate when the solution is working correctly. If there is no way to measure success, link to an issue that will implement a way to measure this.
+
+Create tracking issue using the the Snowplow event tracking template. See https://gitlab.com/gitlab-org/gitlab/-/blob/master/.gitlab/issue_templates/Snowplow%20event%20tracking.md
+-->
### What is the type of buyer?
diff --git a/.gitlab/issue_templates/Implementation.md b/.gitlab/issue_templates/Implementation.md
new file mode 100644
index 00000000000..dc5eb18a25e
--- /dev/null
+++ b/.gitlab/issue_templates/Implementation.md
@@ -0,0 +1,62 @@
+<!--
+Implementation issues are used break-up a large piece of work into small, discrete tasks that can
+move independently through the build workflow steps. They're typically used to populate a Feature
+Epic. Once created, an implementation issue is usually refined in order to populate and review the
+implementation plan and weight.
+Example workflow: https://about.gitlab.com/handbook/engineering/development/threat-management/planning/diagram.html#plan
+-->
+
+## Why are we doing this work
+<!--
+A brief explanation of the why, not the what or how. Assume the reader doesn't know the
+background and won't have time to dig-up information from comment threads.
+-->
+
+
+## Relevant links
+<!--
+Information that the developer might need to refer to when implementing the issue.
+
+- [Design Issue](https://gitlab.com/gitlab-org/gitlab/-/issues/<id>)
+ - [Design 1](https://gitlab.com/gitlab-org/gitlab/-/issues/<id>/designs/<image>.png)
+ - [Design 2](https://gitlab.com/gitlab-org/gitlab/-/issues/<id>/designs/<image>.png)
+- [Similar implementation](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/<id>)
+-->
+
+
+## Non-functional requirements
+<!--
+Add details for required items and delete others.
+-->
+
+- [ ] Documentation:
+- [ ] Feature flag:
+- [ ] Performance:
+- [ ] Testing:
+
+
+## Implementation plan
+<!--
+Steps and the parts of the code that will need to get updated. The plan can also
+call-out responsibilities for other team members or teams.
+-->
+
+- [ ] ~frontend Step 1
+ - [ ] @person Step 1a
+- [ ] ~frontend Step 2
+
+
+<!--
+Workflow and other relevant labels
+
+~"group::" ~"Category:" ~"GitLab Ultimate"
+-->
+/label ~"workflow::refinement"
+
+<!--
+Other settings you might want to include when creating the issue.
+
+/milestone %"Next 1-3 releases"
+/assign @
+/epic &
+-->
diff --git a/.gitlab/issue_templates/Security Release Tracking Issue.md b/.gitlab/issue_templates/Security Release Tracking Issue.md
index d2de7462ecb..fce68d61204 100644
--- a/.gitlab/issue_templates/Security Release Tracking Issue.md
+++ b/.gitlab/issue_templates/Security Release Tracking Issue.md
@@ -31,7 +31,7 @@ Your Security Implementation Issue should have `4` merge requests associated:
## Blog post
-Dev: {https://dev.gitlab.org/gitlab/www-gitlab-com/merge_requests/ link}<br/>
+Security: {https://gitlab.com/gitlab-org/security/www-gitlab-com/merge_requests/ link}<br/>
GitLab.com: {https://gitlab.com/gitlab-com/www-gitlab-com/merge_requests/ link}
## Email notification
diff --git a/.gitlab/issue_templates/Snowplow event tracking.md b/.gitlab/issue_templates/Snowplow event tracking.md
new file mode 100644
index 00000000000..71fa880e087
--- /dev/null
+++ b/.gitlab/issue_templates/Snowplow event tracking.md
@@ -0,0 +1,42 @@
+<!--
+* Use this issue template for creating requests to track snowplow events
+* Snowplow events can be both Frontend (javascript) or Backend (Ruby)
+* Snowplow is currently not used for self-hosted instances of GitLab - Self-hosted still rely on usage ping for product analytics - Snowplow is used for GitLab SaaS
+* You do not need to create an issue to track generic front-end events, such as All page views, sessions, link clicks, some button clicks, etc.
+* What you should capture are specific events with defined business logic. For example, when a user creates an incident by escalating an existing alert, or when a user creates and pushes up a new Node package to the NPM registry.
+* For more details read https://about.gitlab.com/handbook/business-ops/data-team/programs/data-for-product-managers/
+ -->
+
+<!--
+We generally recommend events be tracked using a [structured event](https://docs.snowplowanalytics.com/docs/understanding-tracking-design/out-of-the-box-vs-custom-events-and-entities/#structured-events) which has 5 properties you can use. There may be instances where structured events are not sufficient. You may want to track an event where the property changes frequently or is general something very unique. In those cases, use a [self-decribing event](https://docs.snowplowanalytics.com/docs/understanding-tracking-design/out-of-the-box-vs-custom-events-and-entities/#self-describing-events)
+
+-->
+
+## Structured Snowplow events to track
+
+* Category: The page or backend area of the application. Unless infeasible, please use the Rails page attribute by default in the frontend, and namespace + classname on the backend. If you're not sure what it is, work with your engineering manager to figure it out.
+* Action: A string that is used to define the user action. The first word should always describe the action or aspect: clicks should be `click`, activations should be `activate`, creations should be `create`, etc. Use underscores to describe what was acted on; for example, activating a form field would be `activate_form_input`. An interface action like clicking on a dropdown would be `click_dropdown`, while a behavior like creating a project record from the backend would be `create_project`
+* Label: Optional. The specific element, or object that's being acted on. This is either the label of the element (e.g. a tab labeled 'Create from template' may be `create_from_template`) or a unique identifier if no text is available (e.g. closing the Groups dropdown in the top navbar might be `groups_dropdown_close`), or it could be the name or title attribute of a record being created.
+* Property: Optional. Any additional property of the element, or object being acted on.
+* Value: Optional, numeric. Describes a numeric value or something directly related to the event. This could be the value of an input (e.g. `10` when clicking `internal` visibility)
+
+| Category | Action | Label | Property | Feature Issue | Additional Information |
+| ------ | ------ | ------ | ------ | ------ | ------ |
+| cell | cell | cell | cell | cell | cell |
+| cell | cell | cell | cell | cell | cell |
+
+<!--
+ Snowplow event tracking starts with instrumentation and completed after a chart is created in Sisense.
+
+ Use this checklist to ensure all steps are completed
+-->
+
+## Snowplow event tracking checklist
+* [ ] Engineering complete work and deploy changes to GitLab SaaS
+* [ ] Verify the new Snowplow events are listed in the [Snowplow Event Exploration](https://app.periscopedata.com/app/gitlab/539181/Snowplow-Event-Exploration---last-30-days) dashboard
+* [ ] Create chart(s) to track your event(s) in the relevant dashboard
+ * [ ] Use the [Chart Snowplow Actions](https://app.periscopedata.com/app/gitlab/snippet/Chart-Snowplow-Actions/5546da87ae2c4a3fbc98415c88b3eedd/edit) SQL snippet to quickly visualize usage. See [example](https://app.periscopedata.com/app/gitlab/737489/Health-Group-Dashboard?widget=9797112&udv=0)
+
+<!-- Label reminders - you should have one of each of the following labels if you can figure out the correct ones -->
+/label ~devops:: ~group: ~Category:
+/label ~"snowplow tracking events"
diff --git a/.gitlab/issue_templates/actionable_insight.md b/.gitlab/issue_templates/actionable_insight.md
index 7c65388eff4..68b2b153831 100644
--- a/.gitlab/issue_templates/actionable_insight.md
+++ b/.gitlab/issue_templates/actionable_insight.md
@@ -1,5 +1,5 @@
## Actionable Insights
-Actionable insights always have a follow-up action that needs to take place as a result of the research observation or data, and a clear recommendation or action associated with it. An actionable insight both defines the insight and clearly calls out the next step. These insights are tracked over time.
+Actionable insights always have a follow-up action that needs to take place as a result of the research observation or data, and a clear recommendation or action associated with it. An actionable insight both defines the insight and clearly calls out the next step. These insights are tracked over time and at the group level.
#### Link
@@ -10,6 +10,10 @@ Actionable insights always have a follow-up action that needs to take place as a
- [ ] Assign this issue to the appropriate Product Manager, Product Designer, or UX Researcher
+#### Group label
+
+- [ ] Add the appropriate `Group` (such as `~"group::source code"`) label to the issue. This is done to identify and track actionable insights at the group level.
+
#### Description
- [ ] Provide some brief details on the actionable insight and the action to take
diff --git a/.gitlab/merge_request_templates/Change Documentation Location.md b/.gitlab/merge_request_templates/Change Documentation Location.md
index f18957fdaaa..155694c947d 100644
--- a/.gitlab/merge_request_templates/Change Documentation Location.md
+++ b/.gitlab/merge_request_templates/Change Documentation Location.md
@@ -15,7 +15,7 @@ Closes
## Moving docs to a new location?
Read the guidelines:
-https://docs.gitlab.com/ee/development/documentation/index.html#changing-document-location
+https://docs.gitlab.com/ee/development/documentation/index.html#move-or-rename-a-page
- [ ] Make sure the old link is not removed and has its contents replaced with
a link to the new location.
diff --git a/.gitlab/merge_request_templates/Documentation.md b/.gitlab/merge_request_templates/Documentation.md
index b17043fd3b9..8713405033b 100644
--- a/.gitlab/merge_request_templates/Documentation.md
+++ b/.gitlab/merge_request_templates/Documentation.md
@@ -48,10 +48,13 @@ All reviewers can help ensure accuracy, clarity, completeness, and adherence to
- [ ] Technical writer review. If not requested for this MR, must be scheduled post-merge. To request for this MR, assign the writer listed for the applicable [DevOps stage](https://about.gitlab.com/handbook/product/product-categories/#devops-stages).
- [ ] Ensure docs metadata are present and up-to-date.
- [ ] Ensure ~"Technical Writing" and ~"documentation" are added.
- - [ ] Add the corresponding `docs::` scoped label.
+ - [ ] Add the corresponding `docs::` [scoped label](https://gitlab.com/groups/gitlab-org/-/labels?utf8=%E2%9C%93&subscribed=&search=docs%3A%3A).
+ - [ ] If working on UI text, add the corresponding `UI Text` [scoped label](https://gitlab.com/groups/gitlab-org/-/labels?utf8=%E2%9C%93&subscribed=&search=ui+text).
- [ ] Add ~"tw::doing" when starting work on the MR.
- [ ] Add ~"tw::finished" if Technical Writing team work on the MR is complete but it remains open.
+For more information about labels, see [Technical Writing workflows - Labels](https://about.gitlab.com/handbook/engineering/ux/technical-writing/workflow/#labels).
+
**3. Maintainer**
1. [ ] Review by assigned maintainer, who can always request/require the above reviews. Maintainer's review can occur before or after a technical writer review.
diff --git a/.gitlab/merge_request_templates/Security Release.md b/.gitlab/merge_request_templates/Security Release.md
index eda16747c13..fccfad18ef0 100644
--- a/.gitlab/merge_request_templates/Security Release.md
+++ b/.gitlab/merge_request_templates/Security Release.md
@@ -21,7 +21,7 @@ See [the general developer security release guidelines](https://gitlab.com/gitla
- [ ] Assign to a reviewer and maintainer, per our [Code Review process].
- [ ] Ensure it's approved according to our [Approval Guidelines].
- [ ] Ensure it's approved by an AppSec engineer.
- - If you're unsure who should approve, find the AppSec engineer associated to the issue in the [Canonical repository], or ask #sec-appsec on Slack.
+ - Please see the security release [Code reviews and Approvals](https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md#code-reviews-and-approvals) documentation for details on which AppSec team member to ping for approval.
- Trigger the [`package-and-qa` build]. The docker image generated will be used by the AppSec engineer to validate the security vulnerability has been remediated.
- [ ] For a backport MR targeting a versioned stable branch (`X-Y-stable-ee`)
- [ ] Ensure it's approved by a maintainer.
diff --git a/.haml-lint_todo.yml b/.haml-lint_todo.yml
index 83aba188d2b..57c0e014a03 100644
--- a/.haml-lint_todo.yml
+++ b/.haml-lint_todo.yml
@@ -7,377 +7,374 @@
# versions of Haml-Lint, may require this file to be generated again.
linters:
-
# Offense count: 1552
NoPlainNodes:
enabled: true
exclude:
- - "app/views/admin/abuse_reports/_abuse_report.html.haml"
- - "app/views/admin/abuse_reports/index.html.haml"
- - "app/views/admin/appearances/_form.html.haml"
- - "app/views/admin/application_settings/_abuse.html.haml"
- - "app/views/admin/application_settings/_diff_limits.html.haml"
- - "app/views/admin/application_settings/_gitaly.html.haml"
- - "app/views/admin/application_settings/_ip_limits.html.haml"
- - "app/views/admin/application_settings/_performance.html.haml"
- - "app/views/admin/application_settings/_plantuml.html.haml"
- - "app/views/admin/application_settings/_prometheus.html.haml"
- - "app/views/admin/application_settings/_realtime.html.haml"
- - "app/views/admin/application_settings/_repository_check.html.haml"
- - "app/views/admin/application_settings/_signin.html.haml"
- - "app/views/admin/application_settings/_signup.html.haml"
- - "app/views/admin/application_settings/_spam.html.haml"
- - "app/views/admin/application_settings/_terminal.html.haml"
- - "app/views/admin/application_settings/_usage.html.haml"
- - "app/views/admin/application_settings/_visibility_and_access.html.haml"
- - "app/views/admin/applications/_delete_form.html.haml"
- - "app/views/admin/applications/_form.html.haml"
- - "app/views/admin/applications/edit.html.haml"
- - "app/views/admin/applications/index.html.haml"
- - "app/views/admin/applications/new.html.haml"
- - "app/views/admin/applications/show.html.haml"
- - "app/views/admin/background_jobs/show.html.haml"
- - "app/views/admin/broadcast_messages/index.html.haml"
- - "app/views/admin/dashboard/index.html.haml"
- - "app/views/admin/deploy_keys/new.html.haml"
- - "app/views/admin/health_check/show.html.haml"
- - "app/views/admin/hook_logs/_index.html.haml"
- - "app/views/admin/hook_logs/show.html.haml"
- - "app/views/admin/hooks/_form.html.haml"
- - "app/views/admin/hooks/edit.html.haml"
- - "app/views/admin/logs/show.html.haml"
- - "app/views/admin/projects/_projects.html.haml"
- - "app/views/admin/requests_profiles/index.html.haml"
- - "app/views/admin/runners/_runner.html.haml"
- - "app/views/admin/runners/index.html.haml"
- - "app/views/admin/runners/show.html.haml"
- - "app/views/admin/services/_form.html.haml"
- - "app/views/admin/services/index.html.haml"
- - "app/views/admin/spam_logs/_spam_log.html.haml"
- - "app/views/admin/spam_logs/index.html.haml"
- - "app/views/admin/system_info/show.html.haml"
- - "app/views/admin/users/_form.html.haml"
- - "app/views/admin/users/_head.html.haml"
- - "app/views/admin/users/_profile.html.haml"
- - "app/views/admin/users/_projects.html.haml"
- - "app/views/admin/users/new.html.haml"
- - "app/views/admin/users/projects.html.haml"
- - "app/views/admin/users/show.html.haml"
+ - 'app/views/admin/abuse_reports/_abuse_report.html.haml'
+ - 'app/views/admin/abuse_reports/index.html.haml'
+ - 'app/views/admin/appearances/_form.html.haml'
+ - 'app/views/admin/application_settings/_abuse.html.haml'
+ - 'app/views/admin/application_settings/_diff_limits.html.haml'
+ - 'app/views/admin/application_settings/_gitaly.html.haml'
+ - 'app/views/admin/application_settings/_ip_limits.html.haml'
+ - 'app/views/admin/application_settings/_performance.html.haml'
+ - 'app/views/admin/application_settings/_plantuml.html.haml'
+ - 'app/views/admin/application_settings/_prometheus.html.haml'
+ - 'app/views/admin/application_settings/_realtime.html.haml'
+ - 'app/views/admin/application_settings/_repository_check.html.haml'
+ - 'app/views/admin/application_settings/_signin.html.haml'
+ - 'app/views/admin/application_settings/_signup.html.haml'
+ - 'app/views/admin/application_settings/_spam.html.haml'
+ - 'app/views/admin/application_settings/_terminal.html.haml'
+ - 'app/views/admin/application_settings/_usage.html.haml'
+ - 'app/views/admin/application_settings/_visibility_and_access.html.haml'
+ - 'app/views/admin/applications/_delete_form.html.haml'
+ - 'app/views/admin/applications/_form.html.haml'
+ - 'app/views/admin/applications/edit.html.haml'
+ - 'app/views/admin/applications/index.html.haml'
+ - 'app/views/admin/applications/new.html.haml'
+ - 'app/views/admin/applications/show.html.haml'
+ - 'app/views/admin/background_jobs/show.html.haml'
+ - 'app/views/admin/broadcast_messages/index.html.haml'
+ - 'app/views/admin/dashboard/index.html.haml'
+ - 'app/views/admin/deploy_keys/new.html.haml'
+ - 'app/views/admin/health_check/show.html.haml'
+ - 'app/views/admin/hook_logs/_index.html.haml'
+ - 'app/views/admin/hook_logs/show.html.haml'
+ - 'app/views/admin/hooks/_form.html.haml'
+ - 'app/views/admin/hooks/edit.html.haml'
+ - 'app/views/admin/logs/show.html.haml'
+ - 'app/views/admin/projects/_projects.html.haml'
+ - 'app/views/admin/requests_profiles/index.html.haml'
+ - 'app/views/admin/runners/_runner.html.haml'
+ - 'app/views/admin/runners/index.html.haml'
+ - 'app/views/admin/runners/show.html.haml'
+ - 'app/views/admin/services/_form.html.haml'
+ - 'app/views/admin/services/index.html.haml'
+ - 'app/views/admin/spam_logs/_spam_log.html.haml'
+ - 'app/views/admin/spam_logs/index.html.haml'
+ - 'app/views/admin/system_info/show.html.haml'
+ - 'app/views/admin/users/_form.html.haml'
+ - 'app/views/admin/users/_head.html.haml'
+ - 'app/views/admin/users/_profile.html.haml'
+ - 'app/views/admin/users/_projects.html.haml'
+ - 'app/views/admin/users/new.html.haml'
+ - 'app/views/admin/users/projects.html.haml'
+ - 'app/views/admin/users/show.html.haml'
- 'app/views/authentication/_authenticate.html.haml'
- 'app/views/authentication/_register.html.haml'
- - "app/views/clusters/clusters/_cluster.html.haml"
- - "app/views/clusters/clusters/new.html.haml"
- - "app/views/dashboard/milestones/index.html.haml"
- - "app/views/dashboard/projects/_blank_state_admin_welcome.html.haml"
- - "app/views/dashboard/projects/_blank_state_welcome.html.haml"
- - "app/views/dashboard/todos/_todo.html.haml"
- - "app/views/dashboard/todos/index.html.haml"
- - "app/views/devise/confirmations/almost_there.haml"
- - "app/views/devise/mailer/_confirmation_instructions_account.html.haml"
- - "app/views/devise/mailer/_confirmation_instructions_secondary.html.haml"
- - "app/views/devise/mailer/email_changed.html.haml"
- - "app/views/devise/mailer/password_change.html.haml"
- - "app/views/devise/mailer/reset_password_instructions.html.haml"
- - "app/views/devise/mailer/unlock_instructions.html.haml"
- - "app/views/devise/passwords/edit.html.haml"
- - "app/views/devise/sessions/_new_base.html.haml"
- - "app/views/devise/sessions/_new_crowd.html.haml"
- - "app/views/devise/sessions/_new_ldap.html.haml"
- - "app/views/devise/sessions/new.html.haml"
- - "app/views/devise/sessions/two_factor.html.haml"
- - "app/views/devise/shared/_omniauth_box.html.haml"
- - "app/views/devise/shared/_sign_in_link.html.haml"
- - "app/views/devise/shared/_tabs_normal.html.haml"
- - "app/views/discussions/_discussion.html.haml"
- - "app/views/discussions/_headline.html.haml"
- - "app/views/discussions/_notes.html.haml"
- - "app/views/doorkeeper/applications/_delete_form.html.haml"
- - "app/views/doorkeeper/authorized_applications/_delete_form.html.haml"
- - "app/views/errors/encoding.html.haml"
- - "app/views/errors/git_not_found.html.haml"
- - "app/views/errors/omniauth_error.html.haml"
- - "app/views/errors/precondition_failed.html.haml"
- - "app/views/events/_event_push.atom.haml"
- - "app/views/events/event/_push.html.haml"
- - "app/views/groups/_create_chat_team.html.haml"
- - "app/views/groups/_group_admin_settings.html.haml"
- - "app/views/groups/labels/edit.html.haml"
- - "app/views/groups/labels/new.html.haml"
- - "app/views/groups/milestones/edit.html.haml"
- - "app/views/groups/milestones/index.html.haml"
- - "app/views/groups/milestones/new.html.haml"
- - "app/views/groups/projects.html.haml"
- - "app/views/groups/runners/edit.html.haml"
- - "app/views/groups/settings/_advanced.html.haml"
- - "app/views/groups/settings/_lfs.html.haml"
- - "app/views/help/_shortcuts.html.haml"
- - "app/views/help/index.html.haml"
- - "app/views/help/instance_configuration.html.haml"
- - "app/views/help/instance_configuration/_gitlab_ci.html.haml"
- - "app/views/help/instance_configuration/_gitlab_pages.html.haml"
- - "app/views/import/bitbucket/status.html.haml"
- - "app/views/import/bitbucket_server/status.html.haml"
- - "app/views/invites/show.html.haml"
- - "app/views/jira_connect/subscriptions/index.html.haml"
- - "app/views/layouts/_mailer.html.haml"
- - "app/views/layouts/experiment_mailer.html.haml"
- - "app/views/layouts/header/_default.html.haml"
- - "app/views/layouts/header/_new_dropdown.haml"
- - "app/views/layouts/jira_connect.html.haml"
- - "app/views/layouts/notify.html.haml"
- - "app/views/notify/_failed_builds.html.haml"
- - "app/views/notify/_reassigned_issuable_email.html.haml"
- - "app/views/notify/_removal_notification.html.haml"
- - "app/views/notify/_successful_pipeline.html.haml"
- - "app/views/notify/autodevops_disabled_email.html.haml"
- - "app/views/notify/changed_milestone_email.html.haml"
- - "app/views/notify/import_issues_csv_email.html.haml"
- - "app/views/notify/issue_moved_email.html.haml"
- - "app/views/notify/member_access_denied_email.html.haml"
- - "app/views/notify/member_invite_accepted_email.html.haml"
- - "app/views/notify/member_invited_email.html.haml"
- - "app/views/notify/new_gpg_key_email.html.haml"
- - "app/views/notify/new_mention_in_issue_email.html.haml"
- - "app/views/notify/new_ssh_key_email.html.haml"
- - "app/views/notify/new_user_email.html.haml"
- - "app/views/notify/pages_domain_disabled_email.html.haml"
- - "app/views/notify/pages_domain_enabled_email.html.haml"
- - "app/views/notify/pages_domain_verification_failed_email.html.haml"
- - "app/views/notify/pages_domain_verification_succeeded_email.html.haml"
- - "app/views/notify/pipeline_failed_email.html.haml"
- - "app/views/notify/project_was_exported_email.html.haml"
- - "app/views/notify/project_was_moved_email.html.haml"
- - "app/views/notify/project_was_not_exported_email.html.haml"
- - "app/views/notify/push_to_merge_request_email.html.haml"
- - "app/views/notify/remote_mirror_update_failed_email.html.haml"
- - "app/views/notify/removed_milestone_issue_email.html.haml"
- - "app/views/notify/removed_milestone_merge_request_email.html.haml"
- - "app/views/notify/repository_push_email.html.haml"
- - "app/views/profiles/chat_names/_chat_name.html.haml"
- - "app/views/profiles/chat_names/index.html.haml"
- - "app/views/profiles/chat_names/new.html.haml"
- - "app/views/projects/_bitbucket_import_modal.html.haml"
- - "app/views/projects/_customize_workflow.html.haml"
- - "app/views/projects/_deletion_failed.html.haml"
- - "app/views/projects/_fork_suggestion.html.haml"
- - "app/views/projects/_gitlab_import_modal.html.haml"
- - "app/views/projects/_home_panel.html.haml"
- - "app/views/projects/_import_project_pane.html.haml"
- - "app/views/projects/_issuable_by_email.html.haml"
- - "app/views/projects/_readme.html.haml"
- - "app/views/projects/artifacts/_artifact.html.haml"
- - "app/views/projects/artifacts/_tree_file.html.haml"
- - "app/views/projects/artifacts/browse.html.haml"
- - "app/views/projects/blame/_age_map_legend.html.haml"
- - "app/views/projects/blame/show.html.haml"
- - "app/views/projects/blob/_editor.html.haml"
- - "app/views/projects/blob/_header_content.html.haml"
- - "app/views/projects/blob/_remove.html.haml"
- - "app/views/projects/blob/_render_error.html.haml"
- - "app/views/projects/blob/edit.html.haml"
- - "app/views/projects/blob/new.html.haml"
- - "app/views/projects/blob/preview.html.haml"
- - "app/views/projects/blob/viewers/_empty.html.haml"
- - "app/views/projects/blob/viewers/_stl.html.haml"
- - "app/views/projects/branches/_branch.html.haml"
- - "app/views/projects/branches/_delete_protected_modal.html.haml"
- - "app/views/projects/branches/new.html.haml"
- - "app/views/projects/ci/builds/_build.html.haml"
- - "app/views/projects/ci/lints/_create.html.haml"
- - "app/views/projects/compare/_form.html.haml"
- - "app/views/projects/compare/index.html.haml"
- - "app/views/projects/cycle_analytics/_empty_stage.html.haml"
- - "app/views/projects/cycle_analytics/_no_access.html.haml"
- - "app/views/projects/cycle_analytics/_overview.html.haml"
- - "app/views/projects/cycle_analytics/show.html.haml"
- - "app/views/projects/deploy_keys/_form.html.haml"
- - "app/views/projects/deploy_keys/_index.html.haml"
- - "app/views/projects/deploy_keys/edit.html.haml"
- - "app/views/projects/deployments/_deployment.html.haml"
- - "app/views/projects/diffs/_file_header.html.haml"
- - "app/views/projects/diffs/_replaced_image_diff.html.haml"
- - "app/views/projects/diffs/_stats.html.haml"
- - "app/views/projects/empty.html.haml"
- - "app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml"
- - "app/views/projects/hook_logs/_index.html.haml"
- - "app/views/projects/hook_logs/show.html.haml"
- - "app/views/projects/hooks/edit.html.haml"
- - "app/views/projects/imports/new.html.haml"
- - "app/views/projects/imports/show.html.haml"
- - "app/views/projects/issues/_new_branch.html.haml"
- - "app/views/projects/issues/import_csv/_modal.html.haml"
- - "app/views/projects/issues/show.html.haml"
- - "app/views/projects/jobs/_header.html.haml"
- - "app/views/projects/jobs/_table.html.haml"
- - "app/views/projects/jobs/index.html.haml"
- - "app/views/projects/labels/edit.html.haml"
- - "app/views/projects/labels/new.html.haml"
- - "app/views/projects/mattermosts/_no_teams.html.haml"
- - "app/views/projects/mattermosts/_team_selection.html.haml"
- - "app/views/projects/mattermosts/new.html.haml"
- - "app/views/projects/merge_requests/_commits.html.haml"
- - "app/views/projects/merge_requests/_discussion.html.haml"
- - "app/views/projects/merge_requests/_how_to_merge.html.haml"
- - "app/views/projects/merge_requests/_mr_title.html.haml"
- - "app/views/projects/merge_requests/conflicts/_commit_stats.html.haml"
- - "app/views/projects/merge_requests/conflicts/_file_actions.html.haml"
- - "app/views/projects/merge_requests/conflicts/_submit_form.html.haml"
- - "app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml"
- - "app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml"
- - "app/views/projects/merge_requests/conflicts/show.html.haml"
- - "app/views/projects/merge_requests/creations/_diffs.html.haml"
- - "app/views/projects/merge_requests/creations/_new_compare.html.haml"
- - "app/views/projects/merge_requests/creations/_new_submit.html.haml"
- - "app/views/projects/merge_requests/diffs/_different_base.html.haml"
- - "app/views/projects/merge_requests/diffs/_diffs.html.haml"
- - "app/views/projects/merge_requests/diffs/_version_controls.html.haml"
- - "app/views/projects/merge_requests/invalid.html.haml"
- - "app/views/projects/merge_requests/widget/open/_error.html.haml"
- - "app/views/projects/mirrors/_regenerate_public_ssh_key_confirm_modal.html.haml"
- - "app/views/projects/mirrors/_ssh_host_keys.html.haml"
- - "app/views/projects/no_repo.html.haml"
- - "app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml"
- - "app/views/projects/pipelines/_info.html.haml"
- - "app/views/projects/protected_branches/shared/_dropdown.html.haml"
- - "app/views/projects/protected_branches/shared/_index.html.haml"
- - "app/views/projects/protected_branches/shared/_matching_branch.html.haml"
- - "app/views/projects/protected_branches/shared/_protected_branch.html.haml"
- - "app/views/projects/protected_branches/show.html.haml"
- - "app/views/projects/protected_tags/shared/_create_protected_tag.html.haml"
- - "app/views/projects/protected_tags/shared/_dropdown.html.haml"
- - "app/views/projects/protected_tags/shared/_index.html.haml"
- - "app/views/projects/protected_tags/shared/_matching_tag.html.haml"
- - "app/views/projects/protected_tags/shared/_protected_tag.html.haml"
- - "app/views/projects/protected_tags/shared/_tags_list.html.haml"
- - "app/views/projects/protected_tags/show.html.haml"
- - "app/views/projects/registry/repositories/_tag.html.haml"
- - "app/views/projects/repositories/_feed.html.haml"
- - "app/views/projects/runners/_shared_runners.html.haml"
- - "app/views/projects/runners/edit.html.haml"
- - "app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml"
- - "app/views/projects/services/mattermost_slash_commands/_help.html.haml"
- - "app/views/projects/services/prometheus/_metrics.html.haml"
- - "app/views/projects/services/slack_slash_commands/_help.html.haml"
- - "app/views/projects/settings/ci_cd/_badge.html.haml"
- - "app/views/projects/settings/ci_cd/_form.html.haml"
- - "app/views/projects/tags/index.html.haml"
- - "app/views/projects/tags/releases/edit.html.haml"
- - "app/views/projects/tree/_tree_row.html.haml"
- - "app/views/projects/tree/_truncated_notice_tree_row.html.haml"
- - "app/views/projects/triggers/_form.html.haml"
- - "app/views/projects/triggers/_index.html.haml"
- - "app/views/projects/triggers/_trigger.html.haml"
- - "app/views/projects/triggers/edit.html.haml"
- - "app/views/search/results/_issue.html.haml"
- - "app/views/search/results/_note.html.haml"
- - "app/views/search/results/_snippet_blob.html.haml"
- - "app/views/search/results/_snippet_title.html.haml"
- - "app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml"
- - "app/views/shared/_commit_message_container.html.haml"
- - "app/views/shared/_delete_label_modal.html.haml"
- - "app/views/shared/_group_form.html.haml"
- - "app/views/shared/_group_tips.html.haml"
- - "app/views/shared/_md_preview.html.haml"
- - "app/views/shared/_milestone_expired.html.haml"
- - "app/views/shared/_no_password.html.haml"
- - "app/views/shared/_ping_consent.html.haml"
- - "app/views/shared/_project_limit.html.haml"
- - "app/views/shared/boards/components/_sidebar.html.haml"
- - "app/views/shared/boards/components/sidebar/_due_date.html.haml"
- - "app/views/shared/boards/components/sidebar/_labels.html.haml"
- - "app/views/shared/boards/components/sidebar/_milestone.html.haml"
- - "app/views/shared/hook_logs/_content.html.haml"
- - "app/views/shared/issuable/_assignees.html.haml"
- - "app/views/shared/issuable/_board_create_list_dropdown.html.haml"
- - "app/views/shared/issuable/_close_reopen_report_toggle.html.haml"
- - "app/views/shared/issuable/_form.html.haml"
- - "app/views/shared/issuable/_search_bar.html.haml"
- - "app/views/shared/issuable/_sidebar.html.haml"
- - "app/views/shared/issuable/form/_default_templates.html.haml"
- - "app/views/shared/issuable/form/_template_selector.html.haml"
- - "app/views/shared/issuable/form/_title.html.haml"
- - "app/views/shared/labels/_form.html.haml"
- - "app/views/shared/members/_member.html.haml"
- - "app/views/shared/milestones/_form_dates.html.haml"
- - "app/views/shared/milestones/_issuable.html.haml"
- - "app/views/shared/milestones/_milestone.html.haml"
- - "app/views/shared/milestones/_sidebar.html.haml"
- - "app/views/shared/milestones/_top.html.haml"
- - "app/views/shared/notes/_hints.html.haml"
- - "app/views/shared/notifications/_button.html.haml"
- - "app/views/shared/notifications/_new_button.html.haml"
- - "app/views/shared/runners/_runner_description.html.haml"
- - "app/views/shared/runners/show.html.haml"
- - "app/views/shared/snippets/_header.html.haml"
- - "app/views/shared/snippets/_snippet.html.haml"
- - "app/views/shared/web_hooks/_form.html.haml"
- - "app/views/shared/web_hooks/_hook.html.haml"
- - "app/views/shared/wikis/_pages_wiki_page.html.haml"
- - "app/views/users/_deletion_guidance.html.haml"
- - "ee/app/views/admin/_namespace_plan_info.html.haml"
- - "ee/app/views/admin/application_settings/_templates.html.haml"
- - "ee/app/views/admin/audit_logs/index.html.haml"
- - "ee/app/views/admin/emails/show.html.haml"
- - "ee/app/views/admin/geo/projects/_registry_failed.html.haml"
- - "ee/app/views/admin/geo/projects/_registry_never.html.haml"
- - "ee/app/views/admin/licenses/_upload_trial_license.html.haml"
- - "ee/app/views/admin/licenses/new.html.haml"
- - "ee/app/views/admin/monitoring/ee/_nav.html.haml"
- - "ee/app/views/admin/projects/_shared_runner_status.html.haml"
- - "ee/app/views/admin/users/_auditor_access_level_radio.html.haml"
- - "ee/app/views/admin/users/_auditor_user_badge.html.haml"
- - "ee/app/views/admin/users/_limits.html.haml"
- - "ee/app/views/admin/users/_user_detail_note.html.haml"
- - "ee/app/views/dashboard/projects/_blank_state_ee_trial.html.haml"
- - "ee/app/views/errors/kerberos_denied.html.haml"
- - "ee/app/views/groups/ee/_settings_nav.html.haml"
- - "ee/app/views/groups/group_members/_ldap_sync.html.haml"
- - "ee/app/views/groups/group_members/_sync_button.html.haml"
- - "ee/app/views/groups/hooks/edit.html.haml"
- - "ee/app/views/groups/ldap_group_links/index.html.haml"
- - "ee/app/views/layouts/nav/ee/admin/_new_monitoring_sidebar.html.haml"
- - "ee/app/views/layouts/service_desk.html.haml"
- - "ee/app/views/ldap_group_links/_form.html.haml"
- - "ee/app/views/ldap_group_links/_ldap_group_link.html.haml"
- - "ee/app/views/ldap_group_links/_ldap_group_links.html.haml"
- - "ee/app/views/ldap_group_links/_ldap_group_links_show.html.haml"
- - "ee/app/views/ldap_group_links/_ldap_group_links_synchronizations.html.haml"
- - "ee/app/views/namespaces/_shared_runner_status.html.haml"
- - "ee/app/views/namespaces/_shared_runners_minutes_setting.html.haml"
- - "ee/app/views/namespaces/pipelines_quota/_extra_shared_runners_minutes_quota.html.haml"
- - "ee/app/views/namespaces/pipelines_quota/_list.haml"
- - "ee/app/views/notify/approved_merge_request_email.html.haml"
- - "ee/app/views/notify/epic_status_changed_email.html.haml"
- - "ee/app/views/notify/new_review_email.html.haml"
- - "ee/app/views/notify/send_admin_notification.html.haml"
- - "ee/app/views/notify/send_unsubscribed_notification.html.haml"
- - "ee/app/views/notify/unapproved_merge_request_email.html.haml"
- - "ee/app/views/oauth/geo_auth/error.html.haml"
- - "ee/app/views/projects/commits/_mirror_status.html.haml"
- - "ee/app/views/projects/merge_requests/_approvals_count.html.haml"
- - "ee/app/views/projects/merge_requests/widget/open/_geo.html.haml"
- - "ee/app/views/projects/mirrors/_mirrored_repositories_count.html.haml"
- - "ee/app/views/projects/protected_branches/_update_protected_branch.html.haml"
- - "ee/app/views/projects/protected_branches/ee/_create_protected_branch.html.haml"
- - "ee/app/views/projects/protected_branches/ee/_dropdown.html.haml"
- - "ee/app/views/projects/protected_tags/_protected_tag_extra_create_access_levels.haml"
- - "ee/app/views/projects/protected_tags/ee/_create_protected_tag.html.haml"
- - "ee/app/views/projects/push_rules/_index.html.haml"
- - "ee/app/views/projects/services/gitlab_slack_application/_help.html.haml"
- - "ee/app/views/projects/services/gitlab_slack_application/_slack_integration_form.html.haml"
- - "ee/app/views/projects/settings/slacks/edit.html.haml"
- - "ee/app/views/shared/_mirror_update_button.html.haml"
- - "ee/app/views/shared/epic/_search_bar.html.haml"
- - "ee/app/views/shared/issuable/_approvals.html.haml"
- - "ee/app/views/shared/issuable/_board_create_list_dropdown.html.haml"
- - "ee/app/views/shared/issuable/_filter_weight.html.haml"
- - "ee/app/views/shared/members/ee/_ldap_tag.html.haml"
- - "ee/app/views/shared/members/ee/_override_member_buttons.html.haml"
- - "ee/app/views/shared/members/ee/_sso_badge.html.haml"
- - "ee/app/views/shared/milestones/_burndown.html.haml"
- - "ee/app/views/shared/milestones/_weight.html.haml"
- - "ee/app/views/shared/promotions/_promote_issue_weights.html.haml"
- - "ee/app/views/shared/promotions/_promote_repository_features.html.haml"
- - "ee/app/views/shared/promotions/_promote_servicedesk.html.haml"
- - "ee/app/views/shared/push_rules/_form.html.haml"
- - "ee/app/views/unsubscribes/show.html.haml"
+ - 'app/views/clusters/clusters/_cluster.html.haml'
+ - 'app/views/clusters/clusters/new.html.haml'
+ - 'app/views/dashboard/milestones/index.html.haml'
+ - 'app/views/dashboard/projects/_blank_state_admin_welcome.html.haml'
+ - 'app/views/dashboard/projects/_blank_state_welcome.html.haml'
+ - 'app/views/dashboard/todos/_todo.html.haml'
+ - 'app/views/dashboard/todos/index.html.haml'
+ - 'app/views/devise/confirmations/almost_there.haml'
+ - 'app/views/devise/mailer/_confirmation_instructions_account.html.haml'
+ - 'app/views/devise/mailer/_confirmation_instructions_secondary.html.haml'
+ - 'app/views/devise/mailer/email_changed.html.haml'
+ - 'app/views/devise/mailer/password_change.html.haml'
+ - 'app/views/devise/mailer/reset_password_instructions.html.haml'
+ - 'app/views/devise/mailer/unlock_instructions.html.haml'
+ - 'app/views/devise/passwords/edit.html.haml'
+ - 'app/views/devise/sessions/_new_base.html.haml'
+ - 'app/views/devise/sessions/_new_crowd.html.haml'
+ - 'app/views/devise/sessions/_new_ldap.html.haml'
+ - 'app/views/devise/sessions/new.html.haml'
+ - 'app/views/devise/sessions/two_factor.html.haml'
+ - 'app/views/devise/shared/_omniauth_box.html.haml'
+ - 'app/views/devise/shared/_sign_in_link.html.haml'
+ - 'app/views/devise/shared/_tabs_normal.html.haml'
+ - 'app/views/discussions/_discussion.html.haml'
+ - 'app/views/discussions/_headline.html.haml'
+ - 'app/views/discussions/_notes.html.haml'
+ - 'app/views/doorkeeper/applications/_delete_form.html.haml'
+ - 'app/views/doorkeeper/authorized_applications/_delete_form.html.haml'
+ - 'app/views/errors/encoding.html.haml'
+ - 'app/views/errors/git_not_found.html.haml'
+ - 'app/views/errors/omniauth_error.html.haml'
+ - 'app/views/errors/precondition_failed.html.haml'
+ - 'app/views/events/_event_push.atom.haml'
+ - 'app/views/events/event/_push.html.haml'
+ - 'app/views/groups/_create_chat_team.html.haml'
+ - 'app/views/groups/_group_admin_settings.html.haml'
+ - 'app/views/groups/labels/edit.html.haml'
+ - 'app/views/groups/labels/new.html.haml'
+ - 'app/views/groups/milestones/edit.html.haml'
+ - 'app/views/groups/milestones/index.html.haml'
+ - 'app/views/groups/milestones/new.html.haml'
+ - 'app/views/groups/projects.html.haml'
+ - 'app/views/groups/runners/edit.html.haml'
+ - 'app/views/groups/settings/_advanced.html.haml'
+ - 'app/views/groups/settings/_lfs.html.haml'
+ - 'app/views/help/_shortcuts.html.haml'
+ - 'app/views/help/index.html.haml'
+ - 'app/views/help/instance_configuration.html.haml'
+ - 'app/views/help/instance_configuration/_gitlab_ci.html.haml'
+ - 'app/views/help/instance_configuration/_gitlab_pages.html.haml'
+ - 'app/views/import/bitbucket/status.html.haml'
+ - 'app/views/import/bitbucket_server/status.html.haml'
+ - 'app/views/invites/show.html.haml'
+ - 'app/views/jira_connect/subscriptions/index.html.haml'
+ - 'app/views/layouts/_mailer.html.haml'
+ - 'app/views/layouts/experiment_mailer.html.haml'
+ - 'app/views/layouts/header/_default.html.haml'
+ - 'app/views/layouts/header/_new_dropdown.haml'
+ - 'app/views/layouts/jira_connect.html.haml'
+ - 'app/views/layouts/notify.html.haml'
+ - 'app/views/notify/_failed_builds.html.haml'
+ - 'app/views/notify/_reassigned_issuable_email.html.haml'
+ - 'app/views/notify/_removal_notification.html.haml'
+ - 'app/views/notify/_successful_pipeline.html.haml'
+ - 'app/views/notify/autodevops_disabled_email.html.haml'
+ - 'app/views/notify/changed_milestone_email.html.haml'
+ - 'app/views/notify/import_issues_csv_email.html.haml'
+ - 'app/views/notify/issue_moved_email.html.haml'
+ - 'app/views/notify/member_access_denied_email.html.haml'
+ - 'app/views/notify/member_invite_accepted_email.html.haml'
+ - 'app/views/notify/member_invited_email.html.haml'
+ - 'app/views/notify/new_gpg_key_email.html.haml'
+ - 'app/views/notify/new_mention_in_issue_email.html.haml'
+ - 'app/views/notify/new_ssh_key_email.html.haml'
+ - 'app/views/notify/new_user_email.html.haml'
+ - 'app/views/notify/pages_domain_disabled_email.html.haml'
+ - 'app/views/notify/pages_domain_enabled_email.html.haml'
+ - 'app/views/notify/pages_domain_verification_failed_email.html.haml'
+ - 'app/views/notify/pages_domain_verification_succeeded_email.html.haml'
+ - 'app/views/notify/pipeline_failed_email.html.haml'
+ - 'app/views/notify/project_was_exported_email.html.haml'
+ - 'app/views/notify/project_was_moved_email.html.haml'
+ - 'app/views/notify/project_was_not_exported_email.html.haml'
+ - 'app/views/notify/push_to_merge_request_email.html.haml'
+ - 'app/views/notify/remote_mirror_update_failed_email.html.haml'
+ - 'app/views/notify/removed_milestone_issue_email.html.haml'
+ - 'app/views/notify/removed_milestone_merge_request_email.html.haml'
+ - 'app/views/notify/repository_push_email.html.haml'
+ - 'app/views/profiles/chat_names/_chat_name.html.haml'
+ - 'app/views/profiles/chat_names/index.html.haml'
+ - 'app/views/profiles/chat_names/new.html.haml'
+ - 'app/views/projects/_bitbucket_import_modal.html.haml'
+ - 'app/views/projects/_customize_workflow.html.haml'
+ - 'app/views/projects/_deletion_failed.html.haml'
+ - 'app/views/projects/_fork_suggestion.html.haml'
+ - 'app/views/projects/_gitlab_import_modal.html.haml'
+ - 'app/views/projects/_home_panel.html.haml'
+ - 'app/views/projects/_import_project_pane.html.haml'
+ - 'app/views/projects/_issuable_by_email.html.haml'
+ - 'app/views/projects/_readme.html.haml'
+ - 'app/views/projects/artifacts/_artifact.html.haml'
+ - 'app/views/projects/artifacts/_tree_file.html.haml'
+ - 'app/views/projects/artifacts/browse.html.haml'
+ - 'app/views/projects/blame/_age_map_legend.html.haml'
+ - 'app/views/projects/blame/show.html.haml'
+ - 'app/views/projects/blob/_editor.html.haml'
+ - 'app/views/projects/blob/_header_content.html.haml'
+ - 'app/views/projects/blob/_remove.html.haml'
+ - 'app/views/projects/blob/_render_error.html.haml'
+ - 'app/views/projects/blob/edit.html.haml'
+ - 'app/views/projects/blob/new.html.haml'
+ - 'app/views/projects/blob/preview.html.haml'
+ - 'app/views/projects/blob/viewers/_empty.html.haml'
+ - 'app/views/projects/blob/viewers/_stl.html.haml'
+ - 'app/views/projects/branches/_branch.html.haml'
+ - 'app/views/projects/branches/_delete_protected_modal.html.haml'
+ - 'app/views/projects/branches/new.html.haml'
+ - 'app/views/projects/ci/builds/_build.html.haml'
+ - 'app/views/projects/ci/lints/_create.html.haml'
+ - 'app/views/projects/compare/_form.html.haml'
+ - 'app/views/projects/compare/index.html.haml'
+ - 'app/views/projects/cycle_analytics/_empty_stage.html.haml'
+ - 'app/views/projects/cycle_analytics/_no_access.html.haml'
+ - 'app/views/projects/cycle_analytics/_overview.html.haml'
+ - 'app/views/projects/cycle_analytics/show.html.haml'
+ - 'app/views/projects/deploy_keys/_form.html.haml'
+ - 'app/views/projects/deploy_keys/_index.html.haml'
+ - 'app/views/projects/deploy_keys/edit.html.haml'
+ - 'app/views/projects/deployments/_deployment.html.haml'
+ - 'app/views/projects/diffs/_file_header.html.haml'
+ - 'app/views/projects/diffs/_replaced_image_diff.html.haml'
+ - 'app/views/projects/diffs/_stats.html.haml'
+ - 'app/views/projects/empty.html.haml'
+ - 'app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml'
+ - 'app/views/projects/hook_logs/_index.html.haml'
+ - 'app/views/projects/hook_logs/show.html.haml'
+ - 'app/views/projects/hooks/edit.html.haml'
+ - 'app/views/projects/imports/new.html.haml'
+ - 'app/views/projects/imports/show.html.haml'
+ - 'app/views/projects/issues/_new_branch.html.haml'
+ - 'app/views/projects/issues/import_csv/_modal.html.haml'
+ - 'app/views/projects/issues/show.html.haml'
+ - 'app/views/projects/jobs/_header.html.haml'
+ - 'app/views/projects/jobs/_table.html.haml'
+ - 'app/views/projects/jobs/index.html.haml'
+ - 'app/views/projects/labels/edit.html.haml'
+ - 'app/views/projects/labels/new.html.haml'
+ - 'app/views/projects/mattermosts/_no_teams.html.haml'
+ - 'app/views/projects/mattermosts/_team_selection.html.haml'
+ - 'app/views/projects/mattermosts/new.html.haml'
+ - 'app/views/projects/merge_requests/_commits.html.haml'
+ - 'app/views/projects/merge_requests/_how_to_merge.html.haml'
+ - 'app/views/projects/merge_requests/_mr_title.html.haml'
+ - 'app/views/projects/merge_requests/conflicts/_commit_stats.html.haml'
+ - 'app/views/projects/merge_requests/conflicts/_file_actions.html.haml'
+ - 'app/views/projects/merge_requests/conflicts/_submit_form.html.haml'
+ - 'app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml'
+ - 'app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml'
+ - 'app/views/projects/merge_requests/conflicts/show.html.haml'
+ - 'app/views/projects/merge_requests/creations/_diffs.html.haml'
+ - 'app/views/projects/merge_requests/creations/_new_compare.html.haml'
+ - 'app/views/projects/merge_requests/creations/_new_submit.html.haml'
+ - 'app/views/projects/merge_requests/diffs/_different_base.html.haml'
+ - 'app/views/projects/merge_requests/diffs/_diffs.html.haml'
+ - 'app/views/projects/merge_requests/diffs/_version_controls.html.haml'
+ - 'app/views/projects/merge_requests/invalid.html.haml'
+ - 'app/views/projects/merge_requests/widget/open/_error.html.haml'
+ - 'app/views/projects/mirrors/_regenerate_public_ssh_key_confirm_modal.html.haml'
+ - 'app/views/projects/mirrors/_ssh_host_keys.html.haml'
+ - 'app/views/projects/no_repo.html.haml'
+ - 'app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml'
+ - 'app/views/projects/pipelines/_info.html.haml'
+ - 'app/views/projects/protected_branches/shared/_dropdown.html.haml'
+ - 'app/views/projects/protected_branches/shared/_index.html.haml'
+ - 'app/views/projects/protected_branches/shared/_matching_branch.html.haml'
+ - 'app/views/projects/protected_branches/shared/_protected_branch.html.haml'
+ - 'app/views/projects/protected_branches/show.html.haml'
+ - 'app/views/projects/protected_tags/shared/_create_protected_tag.html.haml'
+ - 'app/views/projects/protected_tags/shared/_dropdown.html.haml'
+ - 'app/views/projects/protected_tags/shared/_index.html.haml'
+ - 'app/views/projects/protected_tags/shared/_matching_tag.html.haml'
+ - 'app/views/projects/protected_tags/shared/_protected_tag.html.haml'
+ - 'app/views/projects/protected_tags/shared/_tags_list.html.haml'
+ - 'app/views/projects/protected_tags/show.html.haml'
+ - 'app/views/projects/registry/repositories/_tag.html.haml'
+ - 'app/views/projects/repositories/_feed.html.haml'
+ - 'app/views/projects/runners/_shared_runners.html.haml'
+ - 'app/views/projects/runners/edit.html.haml'
+ - 'app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml'
+ - 'app/views/projects/services/mattermost_slash_commands/_help.html.haml'
+ - 'app/views/projects/services/prometheus/_metrics.html.haml'
+ - 'app/views/projects/services/slack_slash_commands/_help.html.haml'
+ - 'app/views/projects/settings/ci_cd/_badge.html.haml'
+ - 'app/views/projects/settings/ci_cd/_form.html.haml'
+ - 'app/views/projects/tags/index.html.haml'
+ - 'app/views/projects/tags/releases/edit.html.haml'
+ - 'app/views/projects/tree/_tree_row.html.haml'
+ - 'app/views/projects/tree/_truncated_notice_tree_row.html.haml'
+ - 'app/views/projects/triggers/_form.html.haml'
+ - 'app/views/projects/triggers/_index.html.haml'
+ - 'app/views/projects/triggers/_trigger.html.haml'
+ - 'app/views/projects/triggers/edit.html.haml'
+ - 'app/views/search/results/_issue.html.haml'
+ - 'app/views/search/results/_note.html.haml'
+ - 'app/views/search/results/_snippet_blob.html.haml'
+ - 'app/views/search/results/_snippet_title.html.haml'
+ - 'app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml'
+ - 'app/views/shared/_commit_message_container.html.haml'
+ - 'app/views/shared/_delete_label_modal.html.haml'
+ - 'app/views/shared/_group_form.html.haml'
+ - 'app/views/shared/_group_tips.html.haml'
+ - 'app/views/shared/_md_preview.html.haml'
+ - 'app/views/shared/_milestone_expired.html.haml'
+ - 'app/views/shared/_no_password.html.haml'
+ - 'app/views/shared/_ping_consent.html.haml'
+ - 'app/views/shared/_project_limit.html.haml'
+ - 'app/views/shared/boards/components/_sidebar.html.haml'
+ - 'app/views/shared/boards/components/sidebar/_due_date.html.haml'
+ - 'app/views/shared/boards/components/sidebar/_labels.html.haml'
+ - 'app/views/shared/boards/components/sidebar/_milestone.html.haml'
+ - 'app/views/shared/hook_logs/_content.html.haml'
+ - 'app/views/shared/issuable/_assignees.html.haml'
+ - 'app/views/shared/issuable/_board_create_list_dropdown.html.haml'
+ - 'app/views/shared/issuable/_close_reopen_report_toggle.html.haml'
+ - 'app/views/shared/issuable/_form.html.haml'
+ - 'app/views/shared/issuable/_search_bar.html.haml'
+ - 'app/views/shared/issuable/_sidebar.html.haml'
+ - 'app/views/shared/issuable/form/_default_templates.html.haml'
+ - 'app/views/shared/issuable/form/_template_selector.html.haml'
+ - 'app/views/shared/issuable/form/_title.html.haml'
+ - 'app/views/shared/labels/_form.html.haml'
+ - 'app/views/shared/members/_member.html.haml'
+ - 'app/views/shared/milestones/_form_dates.html.haml'
+ - 'app/views/shared/milestones/_issuable.html.haml'
+ - 'app/views/shared/milestones/_milestone.html.haml'
+ - 'app/views/shared/milestones/_sidebar.html.haml'
+ - 'app/views/shared/milestones/_top.html.haml'
+ - 'app/views/shared/notes/_hints.html.haml'
+ - 'app/views/shared/notifications/_button.html.haml'
+ - 'app/views/shared/notifications/_new_button.html.haml'
+ - 'app/views/shared/runners/_runner_description.html.haml'
+ - 'app/views/shared/runners/show.html.haml'
+ - 'app/views/shared/snippets/_header.html.haml'
+ - 'app/views/shared/snippets/_snippet.html.haml'
+ - 'app/views/shared/web_hooks/_form.html.haml'
+ - 'app/views/shared/web_hooks/_hook.html.haml'
+ - 'app/views/shared/wikis/_pages_wiki_page.html.haml'
+ - 'app/views/users/_deletion_guidance.html.haml'
+ - 'ee/app/views/admin/_namespace_plan_info.html.haml'
+ - 'ee/app/views/admin/application_settings/_templates.html.haml'
+ - 'ee/app/views/admin/audit_logs/index.html.haml'
+ - 'ee/app/views/admin/emails/show.html.haml'
+ - 'ee/app/views/admin/geo/projects/_registry_failed.html.haml'
+ - 'ee/app/views/admin/geo/projects/_registry_never.html.haml'
+ - 'ee/app/views/admin/licenses/_upload_trial_license.html.haml'
+ - 'ee/app/views/admin/licenses/new.html.haml'
+ - 'ee/app/views/admin/monitoring/ee/_nav.html.haml'
+ - 'ee/app/views/admin/projects/_shared_runner_status.html.haml'
+ - 'ee/app/views/admin/users/_auditor_access_level_radio.html.haml'
+ - 'ee/app/views/admin/users/_auditor_user_badge.html.haml'
+ - 'ee/app/views/admin/users/_limits.html.haml'
+ - 'ee/app/views/admin/users/_user_detail_note.html.haml'
+ - 'ee/app/views/dashboard/projects/_blank_state_ee_trial.html.haml'
+ - 'ee/app/views/errors/kerberos_denied.html.haml'
+ - 'ee/app/views/groups/ee/_settings_nav.html.haml'
+ - 'ee/app/views/groups/group_members/_ldap_sync.html.haml'
+ - 'ee/app/views/groups/group_members/_sync_button.html.haml'
+ - 'ee/app/views/groups/hooks/edit.html.haml'
+ - 'ee/app/views/groups/ldap_group_links/index.html.haml'
+ - 'ee/app/views/layouts/nav/ee/admin/_new_monitoring_sidebar.html.haml'
+ - 'ee/app/views/layouts/service_desk.html.haml'
+ - 'ee/app/views/ldap_group_links/_form.html.haml'
+ - 'ee/app/views/ldap_group_links/_ldap_group_link.html.haml'
+ - 'ee/app/views/ldap_group_links/_ldap_group_links.html.haml'
+ - 'ee/app/views/ldap_group_links/_ldap_group_links_show.html.haml'
+ - 'ee/app/views/namespaces/_shared_runner_status.html.haml'
+ - 'ee/app/views/namespaces/_shared_runners_minutes_setting.html.haml'
+ - 'ee/app/views/namespaces/pipelines_quota/_extra_shared_runners_minutes_quota.html.haml'
+ - 'ee/app/views/namespaces/pipelines_quota/_list.haml'
+ - 'ee/app/views/notify/approved_merge_request_email.html.haml'
+ - 'ee/app/views/notify/epic_status_changed_email.html.haml'
+ - 'ee/app/views/notify/new_review_email.html.haml'
+ - 'ee/app/views/notify/send_admin_notification.html.haml'
+ - 'ee/app/views/notify/send_unsubscribed_notification.html.haml'
+ - 'ee/app/views/notify/unapproved_merge_request_email.html.haml'
+ - 'ee/app/views/oauth/geo_auth/error.html.haml'
+ - 'ee/app/views/projects/commits/_mirror_status.html.haml'
+ - 'ee/app/views/projects/merge_requests/_approvals_count.html.haml'
+ - 'ee/app/views/projects/merge_requests/widget/open/_geo.html.haml'
+ - 'ee/app/views/projects/mirrors/_mirrored_repositories_count.html.haml'
+ - 'ee/app/views/projects/protected_branches/_update_protected_branch.html.haml'
+ - 'ee/app/views/projects/protected_branches/ee/_create_protected_branch.html.haml'
+ - 'ee/app/views/projects/protected_branches/ee/_dropdown.html.haml'
+ - 'ee/app/views/projects/protected_tags/_protected_tag_extra_create_access_levels.haml'
+ - 'ee/app/views/projects/protected_tags/ee/_create_protected_tag.html.haml'
+ - 'ee/app/views/projects/push_rules/_index.html.haml'
+ - 'ee/app/views/projects/services/gitlab_slack_application/_help.html.haml'
+ - 'ee/app/views/projects/services/gitlab_slack_application/_slack_integration_form.html.haml'
+ - 'ee/app/views/projects/settings/slacks/edit.html.haml'
+ - 'ee/app/views/shared/_mirror_update_button.html.haml'
+ - 'ee/app/views/shared/epic/_search_bar.html.haml'
+ - 'ee/app/views/shared/issuable/_approvals.html.haml'
+ - 'ee/app/views/shared/issuable/_board_create_list_dropdown.html.haml'
+ - 'ee/app/views/shared/issuable/_filter_weight.html.haml'
+ - 'ee/app/views/shared/members/ee/_ldap_tag.html.haml'
+ - 'ee/app/views/shared/members/ee/_override_member_buttons.html.haml'
+ - 'ee/app/views/shared/members/ee/_sso_badge.html.haml'
+ - 'ee/app/views/shared/milestones/_burndown.html.haml'
+ - 'ee/app/views/shared/milestones/_weight.html.haml'
+ - 'ee/app/views/shared/promotions/_promote_issue_weights.html.haml'
+ - 'ee/app/views/shared/promotions/_promote_repository_features.html.haml'
+ - 'ee/app/views/shared/promotions/_promote_servicedesk.html.haml'
+ - 'ee/app/views/shared/push_rules/_form.html.haml'
+ - 'ee/app/views/unsubscribes/show.html.haml'
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index 228fb7bd6ea..d40c01b4256 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -105,14 +105,6 @@ Lint/StructNewOverride:
- 'app/serializers/environment_serializer.rb'
- 'lib/gitlab/ci/pipeline/duration.rb'
-# Offense count: 7
-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'
-
# Offense count: 65
# Cop supports --auto-correct.
Migration/DepartmentName:
@@ -176,7 +168,6 @@ Performance/Detect:
Exclude:
- 'ee/spec/controllers/projects/dependencies_controller_spec.rb'
- 'ee/spec/controllers/projects/feature_flags_controller_spec.rb'
- - 'ee/spec/requests/api/unleash_spec.rb'
- 'spec/lib/gitlab/git/tree_spec.rb'
- 'spec/lib/gitlab/import_export/project/tree_restorer_spec.rb'
- 'spec/models/event_spec.rb'
@@ -700,13 +691,9 @@ Rails/SaveBang:
- 'ee/spec/models/application_setting_spec.rb'
- 'ee/spec/models/approval_merge_request_rule_spec.rb'
- 'ee/spec/models/approval_project_rule_spec.rb'
- - 'ee/spec/models/approval_state_spec.rb'
- 'ee/spec/models/burndown_spec.rb'
- 'ee/spec/models/ci/pipeline_spec.rb'
- 'ee/spec/models/ci/subscriptions/project_spec.rb'
- - 'ee/spec/models/concerns/approver_migrate_hook_spec.rb'
- - 'ee/spec/models/concerns/deprecated_approvals_before_merge_spec.rb'
- - 'ee/spec/models/concerns/elastic/note_spec.rb'
- 'ee/spec/models/ee/appearance_spec.rb'
- 'ee/spec/models/ee/ci/job_artifact_spec.rb'
- 'ee/spec/models/ee/protected_branch_spec.rb'
@@ -954,16 +941,12 @@ Rails/SaveBang:
- 'spec/lib/gitlab/ci/ansi2json/style_spec.rb'
- 'spec/lib/gitlab/ci/status/build/common_spec.rb'
- 'spec/lib/gitlab/cycle_analytics/base_event_fetcher_spec.rb'
- - 'spec/lib/gitlab/cycle_analytics/events_spec.rb'
- 'spec/lib/gitlab/database/custom_structure_spec.rb'
- 'spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb'
- 'spec/lib/gitlab/database_importers/self_monitoring/project/create_service_spec.rb'
- 'spec/lib/gitlab/email/handler/create_note_handler_spec.rb'
- 'spec/lib/gitlab/email/handler/unsubscribe_handler_spec.rb'
- 'spec/lib/gitlab/gfm/reference_rewriter_spec.rb'
- - 'spec/lib/gitlab/git/object_pool_spec.rb'
- - 'spec/lib/gitlab/git/remote_mirror_spec.rb'
- - 'spec/lib/gitlab/git/repository_spec.rb'
- 'spec/lib/gitlab/git_access_spec.rb'
- 'spec/lib/gitlab/gitaly_client/object_pool_service_spec.rb'
- 'spec/lib/gitlab/gitaly_client/repository_service_spec.rb'
@@ -1042,18 +1025,6 @@ Rails/SaveBang:
- 'spec/models/clusters/applications/helm_spec.rb'
- 'spec/models/commit_spec.rb'
- 'spec/models/commit_status_spec.rb'
- - 'spec/models/concerns/avatarable_spec.rb'
- - 'spec/models/concerns/bulk_insertable_associations_spec.rb'
- - 'spec/models/concerns/cache_markdown_field_spec.rb'
- - 'spec/models/concerns/case_sensitivity_spec.rb'
- - 'spec/models/concerns/featurable_spec.rb'
- - 'spec/models/concerns/issuable_spec.rb'
- - 'spec/models/concerns/mentionable_spec.rb'
- - 'spec/models/concerns/milestoneable_spec.rb'
- - 'spec/models/concerns/milestoneish_spec.rb'
- - 'spec/models/concerns/routable_spec.rb'
- - 'spec/models/concerns/subscribable_spec.rb'
- - 'spec/models/concerns/token_authenticatable_spec.rb'
- 'spec/models/container_repository_spec.rb'
- 'spec/models/deploy_keys_project_spec.rb'
- 'spec/models/deploy_token_spec.rb'
@@ -1085,7 +1056,6 @@ Rails/SaveBang:
- 'spec/models/note_spec.rb'
- 'spec/models/notification_setting_spec.rb'
- 'spec/models/operations/feature_flag_scope_spec.rb'
- - 'spec/models/operations/feature_flag_spec.rb'
- 'spec/models/operations/feature_flags/strategy_spec.rb'
- 'spec/models/operations/feature_flags/user_list_spec.rb'
- 'spec/models/pages_domain_spec.rb'
@@ -1140,13 +1110,7 @@ Rails/SaveBang:
- 'spec/services/emails/confirm_service_spec.rb'
- 'spec/services/groups/destroy_service_spec.rb'
- 'spec/services/groups/import_export/import_service_spec.rb'
- - 'spec/services/issuable/bulk_update_service_spec.rb'
- - 'spec/services/issuable/clone/attributes_rewriter_spec.rb'
- - 'spec/services/issuable/common_system_notes_service_spec.rb'
- 'spec/services/labels/promote_service_spec.rb'
- - 'spec/services/milestones/destroy_service_spec.rb'
- - 'spec/services/milestones/promote_service_spec.rb'
- - 'spec/services/milestones/transfer_service_spec.rb'
- 'spec/services/notes/create_service_spec.rb'
- 'spec/services/notification_recipients/build_service_spec.rb'
- 'spec/services/notification_service_spec.rb'
@@ -1164,7 +1128,6 @@ Rails/SaveBang:
- 'spec/services/projects/unlink_fork_service_spec.rb'
- 'spec/services/projects/update_pages_service_spec.rb'
- 'spec/services/projects/update_service_spec.rb'
- - 'spec/services/quick_actions/interpret_service_spec.rb'
- 'spec/services/reset_project_cache_service_spec.rb'
- 'spec/services/resource_events/change_milestone_service_spec.rb'
- 'spec/services/system_hooks_service_spec.rb'
diff --git a/.test_license_encryption_key.pub b/.test_license_encryption_key.pub
new file mode 100644
index 00000000000..d3936e5e07e
--- /dev/null
+++ b/.test_license_encryption_key.pub
@@ -0,0 +1,9 @@
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtgemxR8RUJXi3p7G/dkh
+Yuln1L4lA6GtQsT83X0yTVDbLVsI2C6bepsRjGiLV0R/9JGvTojORx+9F/ZQAiEC
+g6QXWasAOSmrzr4EjADG6cWcCnOju8hX9yib1HUIBxl+jHkmXP3NPuwyb8p2G149
+EG1o4apEqE5RtqV/Xyx/u57xTYYZShJ/c7o4iA8xvt6IAKFPFKpQwb5hv4KvUZBP
+h0xG2qvOjDu430fK8JclPlXHqPjXDkXOZyLd4FvRStdEQU3RVXvUQfuGt/tOMS7J
+nPQ94fr/xdaEbcEtIlr32+tcgsMWyhqtDCPUWJT1aRPviUgaJKLoVs8tRKwYMV9+
+1wIDAQAB
+-----END PUBLIC KEY-----
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 95191fd373d..fb07c3e86ff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,42 +2,6 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
-## 13.4.2 (2020-10-01)
-
-### Security (14 changes)
-
-- Do not store session id in Redis.
-- Fix permission checks when updating confidentiality and milestone on issues or merge requests.
-- Purge unaccepted member invitations older than 90 days.
-- Adds feature flags plan limits.
-- Prevent SVG XSS via Web IDE.
-- Ensure user has no solo owned groups before triggering account deletion.
-- Security fix safe params helper.
-- Do not bypass admin mode when authenticated with deploy token.
-- Fixes release asset link filepath ReDoS.
-- Ensure global ID is of Annotation type in GraphQL destroy mutation.
-- Validate that membership expiry dates are not in the past.
-- Rate limit adding new email and re-sending email confirmation.
-- Fix redaction of confidential Todos.
-- Update GitLab Runner Helm Chart to 0.20.2.
-
-
-## 13.4.1 (2020-09-24)
-
-### Fixed (2 changes)
-
-- Revert required encryption on CI runner tokens. !42623
-- Allow Unleash clients to request feature flags when repository is private. !43059
-
-### Added (1 change)
-
-- Add missing fontawesome file icon classes. !43091
-
-### Other (1 change)
-
-- Notifications icon: Render empty string for custom setting. !42848
-
-
## 13.4.0 (2020-09-22)
### Security (2 changes, 1 of them is from the community)
@@ -1262,6 +1226,26 @@ entry.
- Replace fa-pencil icon with GitLab SVG. !39648
+## 13.2.10 (2020-10-01)
+
+### Security (14 changes)
+
+- Do not store session id in Redis.
+- Fix permission checks when updating confidentiality and milestone on issues or merge requests.
+- Purge unaccepted member invitations older than 90 days.
+- Adds feature flags plan limits.
+- Prevent SVG XSS via Web IDE.
+- Ensure user has no solo owned groups before triggering account deletion.
+- Security fix safe params helper.
+- Do not bypass admin mode when authenticated with deploy token.
+- Fixes release asset link filepath ReDoS.
+- Ensure global ID is of Annotation type in GraphQL destroy mutation.
+- Validate that membership expiry dates are not in the past.
+- Rate limit adding new email and re-sending email confirmation.
+- Fix redaction of confidential Todos.
+- Update GitLab Runner Helm Chart to 0.19.4.
+
+
## 13.2.8 (2020-09-02)
### Security (1 change)
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index f1017eceacb..cee73821244 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-13.4.2
+1f6b6c2fb5febebbadded24719ef2ff000d904c7
diff --git a/GITLAB_KAS_VERSION b/GITLAB_KAS_VERSION
index bbdeab6222c..1750564f270 100644
--- a/GITLAB_KAS_VERSION
+++ b/GITLAB_KAS_VERSION
@@ -1 +1 @@
-0.0.5
+0.0.6
diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION
index ad2191947f7..5ff8c4f5d2a 100644
--- a/GITLAB_PAGES_VERSION
+++ b/GITLAB_PAGES_VERSION
@@ -1 +1 @@
-1.25.0
+1.26.0
diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION
index f850905cea7..20f1d546bd3 100644
--- a/GITLAB_WORKHORSE_VERSION
+++ b/GITLAB_WORKHORSE_VERSION
@@ -1 +1 @@
-8.46.0
+8.50.0
diff --git a/Gemfile b/Gemfile
index 90abfd30900..57853f9f3b5 100644
--- a/Gemfile
+++ b/Gemfile
@@ -244,7 +244,7 @@ gem 'atlassian-jwt', '~> 0.2.0'
gem 'flowdock', '~> 0.7'
# Slack integration
-gem 'slack-messenger', '~> 2.3.3'
+gem 'slack-messenger', '~> 2.3.4'
# Hangouts Chat integration
gem 'hangouts-chat', '~> 0.0.5'
@@ -272,7 +272,7 @@ gem 'licensee', '~> 8.9'
gem 'ace-rails-ap', '~> 4.1.0'
# Detect and convert string character encoding
-gem 'charlock_holmes', '~> 0.7.5'
+gem 'charlock_holmes', '~> 0.7.7'
# Detect mime content type from content
gem 'mimemagic', '~> 0.3.2'
@@ -284,7 +284,6 @@ gem 'fast_blank'
gem 'gitlab-chronic', '~> 0.10.5'
gem 'gitlab_chronic_duration', '~> 0.10.6.2'
-gem 'webpack-rails', '~> 0.9.10'
gem 'rack-proxy', '~> 0.6.0'
gem 'sassc-rails', '~> 2.1.0'
@@ -330,7 +329,7 @@ group :metrics do
gem 'method_source', '~> 1.0', require: false
# Prometheus
- gem 'prometheus-client-mmap', '~> 0.11.0'
+ gem 'prometheus-client-mmap', '~> 0.12.0'
gem 'raindrops', '~> 0.18'
end
@@ -375,8 +374,6 @@ group :development, :test do
gem 'scss_lint', '~> 0.56.0', require: false
gem 'haml_lint', '~> 0.34.0', require: false
- gem 'simplecov', '~> 0.18.5', require: false
- gem 'simplecov-cobertura', '~> 1.3.1', require: false
gem 'bundler-audit', '~> 0.6.1', require: false
gem 'benchmark-ips', '~> 2.3.0', require: false
@@ -394,6 +391,11 @@ group :development, :test do
gem 'rblineprof', '~> 0.3.6', platform: :mri, require: false
end
+group :development, :test, :coverage do
+ gem 'simplecov', '~> 0.18.5', require: false
+ gem 'simplecov-cobertura', '~> 1.3.1', require: false
+end
+
# Gems required in omnibus-gitlab pipeline
group :development, :test, :omnibus do
gem 'license_finder', '~> 5.4', require: false
@@ -411,7 +413,7 @@ group :test do
gem 'shoulda-matchers', '~> 4.0.1', require: false
gem 'email_spec', '~> 2.2.0'
- gem 'webmock', '~> 3.5.1'
+ gem 'webmock', '~> 3.9.1'
gem 'rails-controller-testing'
gem 'concurrent-ruby', '~> 1.1'
gem 'test-prof', '~> 0.12.0'
diff --git a/Gemfile.lock b/Gemfile.lock
index 7d743f2029e..142a606faa0 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -6,59 +6,59 @@ GEM
ace-rails-ap (4.1.2)
acme-client (2.0.6)
faraday (>= 0.17, < 2.0.0)
- actioncable (6.0.3.1)
- actionpack (= 6.0.3.1)
+ actioncable (6.0.3.3)
+ actionpack (= 6.0.3.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
- actionmailbox (6.0.3.1)
- actionpack (= 6.0.3.1)
- activejob (= 6.0.3.1)
- activerecord (= 6.0.3.1)
- activestorage (= 6.0.3.1)
- activesupport (= 6.0.3.1)
+ actionmailbox (6.0.3.3)
+ actionpack (= 6.0.3.3)
+ activejob (= 6.0.3.3)
+ activerecord (= 6.0.3.3)
+ activestorage (= 6.0.3.3)
+ activesupport (= 6.0.3.3)
mail (>= 2.7.1)
- actionmailer (6.0.3.1)
- actionpack (= 6.0.3.1)
- actionview (= 6.0.3.1)
- activejob (= 6.0.3.1)
+ actionmailer (6.0.3.3)
+ actionpack (= 6.0.3.3)
+ actionview (= 6.0.3.3)
+ activejob (= 6.0.3.3)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
- actionpack (6.0.3.1)
- actionview (= 6.0.3.1)
- activesupport (= 6.0.3.1)
+ actionpack (6.0.3.3)
+ actionview (= 6.0.3.3)
+ activesupport (= 6.0.3.3)
rack (~> 2.0, >= 2.0.8)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
- actiontext (6.0.3.1)
- actionpack (= 6.0.3.1)
- activerecord (= 6.0.3.1)
- activestorage (= 6.0.3.1)
- activesupport (= 6.0.3.1)
+ actiontext (6.0.3.3)
+ actionpack (= 6.0.3.3)
+ activerecord (= 6.0.3.3)
+ activestorage (= 6.0.3.3)
+ activesupport (= 6.0.3.3)
nokogiri (>= 1.8.5)
- actionview (6.0.3.1)
- activesupport (= 6.0.3.1)
+ actionview (6.0.3.3)
+ activesupport (= 6.0.3.3)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
- activejob (6.0.3.1)
- activesupport (= 6.0.3.1)
+ activejob (6.0.3.3)
+ activesupport (= 6.0.3.3)
globalid (>= 0.3.6)
- activemodel (6.0.3.1)
- activesupport (= 6.0.3.1)
- activerecord (6.0.3.1)
- activemodel (= 6.0.3.1)
- activesupport (= 6.0.3.1)
+ activemodel (6.0.3.3)
+ activesupport (= 6.0.3.3)
+ activerecord (6.0.3.3)
+ activemodel (= 6.0.3.3)
+ activesupport (= 6.0.3.3)
activerecord-explain-analyze (0.1.0)
activerecord (>= 4)
pg
- activestorage (6.0.3.1)
- actionpack (= 6.0.3.1)
- activejob (= 6.0.3.1)
- activerecord (= 6.0.3.1)
+ activestorage (6.0.3.3)
+ actionpack (= 6.0.3.3)
+ activejob (= 6.0.3.3)
+ activerecord (= 6.0.3.3)
marcel (~> 0.3.1)
- activesupport (6.0.3.1)
+ activesupport (6.0.3.3)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
minitest (~> 5.1)
@@ -167,7 +167,7 @@ GEM
mime-types (>= 1.16)
cbor (0.5.9.6)
character_set (1.4.0)
- charlock_holmes (0.7.6)
+ charlock_holmes (0.7.7)
childprocess (3.0.0)
chunky_png (1.3.5)
citrus (3.0.2)
@@ -183,7 +183,7 @@ GEM
concord (0.1.5)
adamantium (~> 0.2.0)
equalizer (~> 0.0.9)
- concurrent-ruby (1.1.6)
+ concurrent-ruby (1.1.7)
connection_pool (2.2.2)
contracts (0.11.0)
cork (0.3.0)
@@ -547,7 +547,7 @@ GEM
tilt
hana (1.3.6)
hangouts-chat (0.0.5)
- hashdiff (0.3.8)
+ hashdiff (1.0.1)
hashie (3.6.0)
hashie-forbidden_attributes (0.1.1)
hashie (>= 3.0)
@@ -577,7 +577,7 @@ GEM
mime-types (~> 3.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
- i18n (1.8.3)
+ i18n (1.8.5)
concurrent-ruby (~> 1.0)
i18n_data (0.8.0)
icalendar (2.4.1)
@@ -662,7 +662,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
- loofah (2.5.0)
+ loofah (2.7.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
lru_redux (1.1.0)
@@ -713,7 +713,7 @@ GEM
net-ntp (2.1.3)
net-ssh (6.0.0)
netrc (0.11.0)
- nio4r (2.5.2)
+ nio4r (2.5.4)
no_proxy_fix (0.1.2)
nokogiri (1.10.10)
mini_portile2 (~> 2.4.0)
@@ -838,7 +838,7 @@ GEM
parser
unparser
procto (0.0.3)
- prometheus-client-mmap (0.11.0)
+ prometheus-client-mmap (0.12.0)
pry (0.13.1)
coderay (~> 1.1)
method_source (~> 1.0)
@@ -870,25 +870,25 @@ GEM
rack-test (1.1.0)
rack (>= 1.0, < 3)
rack-timeout (0.5.2)
- rails (6.0.3.1)
- actioncable (= 6.0.3.1)
- actionmailbox (= 6.0.3.1)
- actionmailer (= 6.0.3.1)
- actionpack (= 6.0.3.1)
- actiontext (= 6.0.3.1)
- actionview (= 6.0.3.1)
- activejob (= 6.0.3.1)
- activemodel (= 6.0.3.1)
- activerecord (= 6.0.3.1)
- activestorage (= 6.0.3.1)
- activesupport (= 6.0.3.1)
+ rails (6.0.3.3)
+ actioncable (= 6.0.3.3)
+ actionmailbox (= 6.0.3.3)
+ actionmailer (= 6.0.3.3)
+ actionpack (= 6.0.3.3)
+ actiontext (= 6.0.3.3)
+ actionview (= 6.0.3.3)
+ activejob (= 6.0.3.3)
+ activemodel (= 6.0.3.3)
+ activerecord (= 6.0.3.3)
+ activestorage (= 6.0.3.3)
+ activesupport (= 6.0.3.3)
bundler (>= 1.3.0)
- railties (= 6.0.3.1)
+ railties (= 6.0.3.3)
sprockets-rails (>= 2.0.0)
- rails-controller-testing (1.0.4)
- actionpack (>= 5.0.1.x)
- actionview (>= 5.0.1.x)
- activesupport (>= 5.0.1.x)
+ rails-controller-testing (1.0.5)
+ actionpack (>= 5.0.1.rc1)
+ actionview (>= 5.0.1.rc1)
+ activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
@@ -897,9 +897,9 @@ GEM
rails-i18n (6.0.0)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 7)
- railties (6.0.3.1)
- actionpack (= 6.0.3.1)
- activesupport (= 6.0.3.1)
+ railties (6.0.3.3)
+ actionpack (= 6.0.3.3)
+ activesupport (= 6.0.3.3)
method_source
rake (>= 0.8.7)
thor (>= 0.20.3, < 2.0)
@@ -1065,7 +1065,7 @@ GEM
seed-fu (2.3.7)
activerecord (>= 3.1)
activesupport (>= 3.1)
- selenium-webdriver (3.142.6)
+ selenium-webdriver (3.142.7)
childprocess (>= 0.5, < 4.0)
rubyzip (>= 1.2.2)
sentry-raven (3.0.4)
@@ -1096,7 +1096,7 @@ GEM
simplecov (~> 0.8)
simplecov-html (0.12.2)
sixarm_ruby_unaccent (1.2.0)
- slack-messenger (2.3.3)
+ slack-messenger (2.3.4)
snowplow-tracker (0.6.1)
contracts (~> 0.7, <= 0.11)
spring (2.0.2)
@@ -1106,7 +1106,7 @@ GEM
sprockets (3.7.2)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
- sprockets-rails (3.2.1)
+ sprockets-rails (3.2.2)
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets (>= 3.0.0)
@@ -1214,13 +1214,11 @@ GEM
webfinger (1.1.0)
activesupport
httpclient (>= 2.4)
- webmock (3.5.1)
+ webmock (3.9.1)
addressable (>= 2.3.6)
crack (>= 0.3.2)
- hashdiff
- webpack-rails (0.9.11)
- railties (>= 3.2.0)
- websocket-driver (0.7.1)
+ hashdiff (>= 0.4.0, < 2.0.0)
+ websocket-driver (0.7.3)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
wikicloth (0.8.1)
@@ -1232,7 +1230,7 @@ GEM
xpath (3.2.0)
nokogiri (~> 1.8)
yajl-ruby (1.4.1)
- zeitwerk (2.3.0)
+ zeitwerk (2.4.0)
PLATFORMS
ruby
@@ -1272,7 +1270,7 @@ DEPENDENCIES
capybara (~> 3.33.0)
capybara-screenshot (~> 1.0.22)
carrierwave (~> 1.3)
- charlock_holmes (~> 0.7.5)
+ charlock_holmes (~> 0.7.7)
commonmarker (~> 0.20)
concurrent-ruby (~> 1.1)
connection_pool (~> 2.0)
@@ -1419,7 +1417,7 @@ DEPENDENCIES
pg (~> 1.1)
png_quantizator (~> 0.2.1)
premailer-rails (~> 1.10.3)
- prometheus-client-mmap (~> 0.11.0)
+ prometheus-client-mmap (~> 0.12.0)
pry-byebug (~> 3.9.0)
pry-rails (~> 0.3.9)
rack (~> 2.0.9)
@@ -1473,7 +1471,7 @@ DEPENDENCIES
simple_po_parser (~> 1.1.2)
simplecov (~> 0.18.5)
simplecov-cobertura (~> 1.3.1)
- slack-messenger (~> 2.3.3)
+ slack-messenger (~> 2.3.4)
snowplow-tracker (~> 0.6.1)
spring (~> 2.0.0)
spring-commands-rspec (~> 1.0.4)
@@ -1498,8 +1496,7 @@ DEPENDENCIES
version_sorter (~> 2.2.4)
vmstat (~> 2.3.0)
webauthn (~> 2.3)
- webmock (~> 3.5.1)
- webpack-rails (~> 0.9.10)
+ webmock (~> 3.9.1)
wikicloth (= 0.8.1)
yajl-ruby (~> 1.4.1)
diff --git a/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue b/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue
index 2ea55d44420..bc2d96832fa 100644
--- a/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue
+++ b/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue
@@ -9,13 +9,13 @@ export default {
},
inject: {
svgPath: {
- type: String,
+ default: '',
},
docsLink: {
- type: String,
+ default: '',
},
primaryButtonPath: {
- type: String,
+ default: '',
},
},
};
diff --git a/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue b/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue
index 5429ec403d3..316827e1b07 100644
--- a/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue
+++ b/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue
@@ -10,16 +10,16 @@ export default {
},
inject: {
isAdmin: {
- type: Boolean,
+ default: false,
},
svgPath: {
- type: String,
+ default: '',
},
docsLink: {
- type: String,
+ default: '',
},
primaryButtonPath: {
- type: String,
+ default: '',
},
},
};
diff --git a/app/assets/javascripts/alert_handler.js b/app/assets/javascripts/alert_handler.js
index 8fffb61d1dd..26b0142f6a2 100644
--- a/app/assets/javascripts/alert_handler.js
+++ b/app/assets/javascripts/alert_handler.js
@@ -1,13 +1,21 @@
-// This allows us to dismiss alerts that we've migrated from bootstrap
-// Note: This ONLY works on alerts that are created on page load
+// This allows us to dismiss alerts and banners that we've migrated from bootstrap
+// Note: This ONLY works on elements that are created on page load
// You can follow this effort in the following epic
// https://gitlab.com/groups/gitlab-org/-/epics/4070
export default function initAlertHandler() {
- const ALERT_SELECTOR = '.gl-alert';
- const CLOSE_SELECTOR = '.gl-alert-dismiss';
+ const DISMISSIBLE_SELECTORS = ['.gl-alert', '.gl-banner'];
+ const DISMISS_LABEL = '[aria-label="Dismiss"]';
+ const DISMISS_CLASS = '.gl-alert-dismiss';
- const dismissAlert = ({ target }) => target.closest(ALERT_SELECTOR).remove();
- const closeButtons = document.querySelectorAll(`${ALERT_SELECTOR} ${CLOSE_SELECTOR}`);
- closeButtons.forEach(alert => alert.addEventListener('click', dismissAlert));
+ DISMISSIBLE_SELECTORS.forEach(selector => {
+ const elements = document.querySelectorAll(selector);
+ elements.forEach(element => {
+ const button = element.querySelector(DISMISS_LABEL) || element.querySelector(DISMISS_CLASS);
+ if (!button) {
+ return;
+ }
+ button.addEventListener('click', () => element.remove());
+ });
+ });
}
diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue
index c6605452616..072ed2fa663 100644
--- a/app/assets/javascripts/alert_management/components/alert_details.vue
+++ b/app/assets/javascripts/alert_management/components/alert_details.vue
@@ -1,15 +1,16 @@
<script>
-/* eslint-disable vue/no-v-html */
import * as Sentry from '@sentry/browser';
import {
GlAlert,
GlBadge,
GlIcon,
+ GlLink,
GlLoadingIcon,
GlSprintf,
GlTabs,
GlTab,
GlButton,
+ GlSafeHtmlDirective,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import alertQuery from '../graphql/queries/details.query.graphql';
@@ -28,6 +29,7 @@ import SystemNote from './system_notes/system_note.vue';
import AlertSidebar from './alert_sidebar.vue';
import AlertMetrics from './alert_metrics.vue';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
+import AlertSummaryRow from './alert_summary_row.vue';
const containerEl = document.querySelector('.page-with-contextual-sidebar');
@@ -39,6 +41,9 @@ export default {
reportedAt: s__('AlertManagement|Reported %{when}'),
reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'),
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
severityLabels: ALERTS_SEVERITY_LABELS,
tabsConfig: [
{
@@ -56,9 +61,11 @@ export default {
],
components: {
AlertDetailsTable,
+ AlertSummaryRow,
GlBadge,
GlAlert,
GlIcon,
+ GlLink,
GlLoadingIcon,
GlSprintf,
GlTab,
@@ -74,15 +81,12 @@ export default {
default: '',
},
alertId: {
- type: String,
default: '',
},
projectId: {
- type: String,
default: '',
},
projectIssuesPath: {
- type: String,
default: '',
},
},
@@ -211,7 +215,7 @@ export default {
<template>
<div>
<gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError">
- <p v-html="sidebarErrorMessage || $options.i18n.errorMsg"></p>
+ <p v-safe-html="sidebarErrorMessage || $options.i18n.errorMsg"></p>
</gl-alert>
<gl-alert
v-if="createIncidentError"
@@ -283,54 +287,66 @@ export default {
</div>
<gl-tabs v-if="alert" v-model="currentTabIndex" data-testid="alertDetailsTabs">
<gl-tab :data-testid="$options.tabsConfig[0].id" :title="$options.tabsConfig[0].title">
- <div v-if="alert.severity" class="gl-mt-3 gl-mb-5 gl-display-flex">
- <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Severity') }}:
- </div>
- <div class="gl-pl-2" data-testid="severity">
- <span>
- <gl-icon
- class="gl-vertical-align-middle"
- :size="12"
- :name="`severity-${alert.severity.toLowerCase()}`"
- :class="`icon-${alert.severity.toLowerCase()}`"
- />
- </span>
+ <alert-summary-row v-if="alert.severity" :label="`${s__('AlertManagement|Severity')}:`">
+ <span data-testid="severity">
+ <gl-icon
+ class="gl-vertical-align-middle"
+ :size="12"
+ :name="`severity-${alert.severity.toLowerCase()}`"
+ :class="`icon-${alert.severity.toLowerCase()}`"
+ />
{{ $options.severityLabels[alert.severity] }}
- </div>
- </div>
- <div v-if="alert.startedAt" class="gl-my-5 gl-display-flex">
- <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Start time') }}:
- </div>
- <div class="gl-pl-2">
- <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" />
- </div>
- </div>
- <div v-if="alert.eventCount" class="gl-my-5 gl-display-flex">
- <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Events') }}:
- </div>
- <div class="gl-pl-2" data-testid="eventCount">{{ alert.eventCount }}</div>
- </div>
- <div v-if="alert.monitoringTool" class="gl-my-5 gl-display-flex">
- <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Tool') }}:
- </div>
- <div class="gl-pl-2" data-testid="monitoringTool">{{ alert.monitoringTool }}</div>
- </div>
- <div v-if="alert.service" class="gl-my-5 gl-display-flex">
- <div class="bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Service') }}:
- </div>
- <div class="gl-pl-2" data-testid="service">{{ alert.service }}</div>
- </div>
- <div v-if="alert.runbook" class="gl-my-5 gl-display-flex">
- <div class="bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Runbook') }}:
- </div>
- <div class="gl-pl-2" data-testid="runbook">{{ alert.runbook }}</div>
- </div>
+ </span>
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.environment"
+ :label="`${s__('AlertManagement|Environment')}:`"
+ >
+ <gl-link
+ v-if="alert.environmentUrl"
+ class="gl-display-inline-block"
+ data-testid="environmentUrl"
+ :href="alert.environmentUrl"
+ target="_blank"
+ >
+ {{ alert.environment }}
+ </gl-link>
+ <span v-else data-testid="environment">{{ alert.environment }}</span>
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.startedAt"
+ :label="`${s__('AlertManagement|Start time')}:`"
+ >
+ <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" />
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.eventCount"
+ :label="`${s__('AlertManagement|Events')}:`"
+ data-testid="eventCount"
+ >
+ {{ alert.eventCount }}
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.monitoringTool"
+ :label="`${s__('AlertManagement|Tool')}:`"
+ data-testid="monitoringTool"
+ >
+ {{ alert.monitoringTool }}
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.service"
+ :label="`${s__('AlertManagement|Service')}:`"
+ data-testid="service"
+ >
+ {{ alert.service }}
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.runbook"
+ :label="`${s__('AlertManagement|Runbook')}:`"
+ data-testid="runbook"
+ >
+ {{ alert.runbook }}
+ </alert-summary-row>
<alert-details-table :alert="alert" :loading="loading" />
</gl-tab>
<gl-tab :data-testid="$options.tabsConfig[1].id" :title="$options.tabsConfig[1].title">
diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue
index 0fd00fe90eb..fc87252f772 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_table.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue
@@ -267,8 +267,8 @@ export default {
this.searchTerm = trimmedInput;
}
}, 500),
- navigateToAlertDetails({ iid }) {
- return visitUrl(joinPaths(window.location.pathname, iid, 'details'));
+ navigateToAlertDetails({ iid }, index, { metaKey }) {
+ return visitUrl(joinPaths(window.location.pathname, iid, 'details'), metaKey);
},
trackPageViews() {
const { category, action } = trackAlertListViewsOptions;
diff --git a/app/assets/javascripts/alert_management/components/alert_sidebar.vue b/app/assets/javascripts/alert_management/components/alert_sidebar.vue
index 64e4089c85a..41d77716592 100644
--- a/app/assets/javascripts/alert_management/components/alert_sidebar.vue
+++ b/app/assets/javascripts/alert_management/components/alert_sidebar.vue
@@ -18,7 +18,6 @@ export default {
default: '',
},
projectId: {
- type: String,
default: '',
},
},
diff --git a/app/assets/javascripts/alert_management/components/alert_status.vue b/app/assets/javascripts/alert_management/components/alert_status.vue
index ff71b348cc9..c505ef6c15b 100644
--- a/app/assets/javascripts/alert_management/components/alert_status.vue
+++ b/app/assets/javascripts/alert_management/components/alert_status.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDeprecatedDropdown, GlDeprecatedDropdownItem, GlButton } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
import { trackAlertStatusUpdateOptions } from '../constants';
@@ -18,9 +18,8 @@ export default {
RESOLVED: s__('AlertManagement|Resolved'),
},
components: {
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
- GlButton,
+ GlDropdown,
+ GlDropdownItem,
},
props: {
projectPath: {
@@ -91,39 +90,30 @@ export default {
<template>
<div class="dropdown dropdown-menu-selectable" :class="dropdownClass">
- <gl-deprecated-dropdown
+ <gl-dropdown
ref="dropdown"
right
:text="$options.statuses[alert.status]"
class="w-100"
toggle-class="dropdown-menu-toggle"
- variant="outline-default"
@keydown.esc.native="$emit('hide-dropdown')"
@hide="$emit('hide-dropdown')"
>
- <div v-if="isSidebar" class="dropdown-title gl-display-flex">
- <span class="alert-title gl-ml-auto">{{ s__('AlertManagement|Assign status') }}</span>
- <gl-button
- :aria-label="__('Close')"
- variant="link"
- class="dropdown-title-button dropdown-menu-close gl-ml-auto gl-text-black-normal!"
- icon="close"
- @click="$emit('hide-dropdown')"
- />
- </div>
+ <p v-if="isSidebar" class="gl-new-dropdown-header-top" data-testid="dropdown-header">
+ {{ s__('AlertManagement|Assign status') }}
+ </p>
<div class="dropdown-content dropdown-body">
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
v-for="(label, field) in $options.statuses"
:key="field"
data-testid="statusDropdownItem"
- class="gl-vertical-align-middle"
:active="label.toUpperCase() === alert.status"
:active-class="'is-active'"
@click="updateAlertStatus(label)"
>
{{ label }}
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
</div>
- </gl-deprecated-dropdown>
+ </gl-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/alert_management/components/alert_summary_row.vue b/app/assets/javascripts/alert_management/components/alert_summary_row.vue
new file mode 100644
index 00000000000..13835b7e2fa
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/alert_summary_row.vue
@@ -0,0 +1,18 @@
+<script>
+export default {
+ props: {
+ label: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-my-5 gl-display-flex">
+ <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">{{ label }}</div>
+ <div class="gl-pl-2">
+ <slot></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue
index 0a1478ef5fe..df07038151e 100644
--- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue
+++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue
@@ -1,9 +1,9 @@
<script>
-import { GlDeprecatedDropdownItem } from '@gitlab/ui';
+import { GlDropdownItem } from '@gitlab/ui';
export default {
components: {
- GlDeprecatedDropdownItem,
+ GlDropdownItem,
},
props: {
user: {
@@ -24,7 +24,7 @@ export default {
</script>
<template>
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
:key="user.username"
data-testid="assigneeDropdownItem"
class="assignee-dropdown-item gl-vertical-align-middle"
@@ -47,5 +47,5 @@ export default {
</strong>
<span class="dropdown-menu-user-username"> {{ user.username }}</span>
</span>
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue
index 0f354e85e96..2e667bf99a8 100644
--- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue
+++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue
@@ -1,10 +1,11 @@
<script>
import {
GlIcon,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownDivider,
- GlDeprecatedDropdownHeader,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlDropdownItem,
+ GlSearchBoxByType,
GlLoadingIcon,
GlTooltip,
GlButton,
@@ -33,10 +34,11 @@ export default {
},
components: {
GlIcon,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
- GlDeprecatedDropdownDivider,
- GlDeprecatedDropdownHeader,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
GlLoadingIcon,
GlTooltip,
GlButton,
@@ -216,48 +218,36 @@ export default {
</p>
<div class="dropdown dropdown-menu-selectable" :class="dropdownClass">
- <gl-deprecated-dropdown
+ <gl-dropdown
ref="dropdown"
:text="userName"
class="w-100"
toggle-class="dropdown-menu-toggle"
- variant="outline-default"
@keydown.esc.native="hideDropdown"
@hide="hideDropdown"
>
- <div class="dropdown-title gl-display-flex">
- <span class="alert-title gl-ml-auto">{{ __('Assign To') }}</span>
- <gl-button
- :aria-label="__('Close')"
- variant="link"
- class="dropdown-title-button dropdown-menu-close gl-ml-auto gl-text-black-normal!"
- icon="close"
- @click="hideDropdown"
- />
- </div>
- <div class="dropdown-input">
- <input
- v-model.trim="search"
- class="dropdown-input-field"
- type="search"
- :placeholder="__('Search users')"
- />
- <gl-icon name="search" class="dropdown-input-search ic-search" data-hidden="true" />
- </div>
+ <p class="gl-new-dropdown-header-top">
+ {{ __('Assign To') }}
+ </p>
+ <gl-search-box-by-type
+ v-model.trim="search"
+ class="m-2"
+ :placeholder="__('Search users')"
+ />
<div class="dropdown-content dropdown-body">
<template v-if="userListValid">
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
:active="!userName"
active-class="is-active"
@click="updateAlertAssignees('')"
>
{{ __('Unassigned') }}
- </gl-deprecated-dropdown-item>
- <gl-deprecated-dropdown-divider />
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
- <gl-deprecated-dropdown-header class="mt-0">
+ <gl-dropdown-section-header>
{{ __('Assignee') }}
- </gl-deprecated-dropdown-header>
+ </gl-dropdown-section-header>
<sidebar-assignee
v-for="user in sortedUsers"
:key="user.username"
@@ -266,12 +256,12 @@ export default {
@update-alert-assignees="updateAlertAssignees"
/>
</template>
- <gl-deprecated-dropdown-item v-else-if="userListEmpty">
+ <p v-else-if="userListEmpty" class="mx-3 my-2">
{{ __('No Matching Results') }}
- </gl-deprecated-dropdown-item>
+ </p>
<gl-loading-icon v-else />
</div>
- </gl-deprecated-dropdown>
+ </gl-dropdown>
</div>
<gl-loading-icon v-if="isUpdating" :inline="true" />
diff --git a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue
index 0b206ce42f4..3705e36a579 100644
--- a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue
+++ b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue
@@ -1,11 +1,12 @@
<script>
/* eslint-disable vue/no-v-html */
+import { GlIcon } from '@gitlab/ui';
import NoteHeader from '~/notes/components/note_header.vue';
-import { spriteIcon } from '~/lib/utils/common_utils';
export default {
components: {
NoteHeader,
+ GlIcon,
},
props: {
note: {
@@ -24,23 +25,23 @@ export default {
} = this.note;
return { ...author, id: id?.split('/').pop() };
},
- iconHtml() {
- return spriteIcon(this.note?.systemNoteIconName);
- },
},
};
</script>
<template>
- <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-px-0!">
- <div class="timeline-entry-inner">
- <div class="timeline-icon" v-html="iconHtml"></div>
- <div class="timeline-content">
- <div class="note-header">
- <note-header :author="noteAuthor" :created-at="note.createdAt" :note-id="note.id">
- <span v-html="note.bodyHtml"></span>
- </note-header>
- </div>
+ <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-p-0!">
+ <div class="gl-display-inline-flex gl-align-items-center">
+ <div
+ class="gl-display-inline gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-box-sizing-content-box gl-p-3 gl-mt-n2 gl-mr-6"
+ >
+ <gl-icon :name="note.systemNoteIconName" />
+ </div>
+
+ <div class="note-header">
+ <note-header :author="noteAuthor" :created-at="note.createdAt" :note-id="note.id">
+ <span v-html="note.bodyHtml"></span>
+ </note-header>
</div>
</div>
</li>
diff --git a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue
index c5e213d7dc9..f2394ce385f 100644
--- a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue
+++ b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue
@@ -180,11 +180,9 @@ export default {
/>
</span>
</div>
- <span class="gl-display-flex gl-justify-content-end">
- <gl-button v-gl-modal.authKeyModal class="gl-mt-2" :disabled="isDisabled">{{
- $options.RESET_KEY
- }}</gl-button>
- </span>
+ <gl-button v-gl-modal.authKeyModal class="gl-mt-2" :disabled="isDisabled">{{
+ $options.RESET_KEY
+ }}</gl-button>
<gl-modal
modal-id="authKeyModal"
:title="$options.RESET_KEY"
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
index f0bb8b0a90f..225cdbcdab0 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
@@ -511,16 +511,11 @@ export default {
max-rows="10"
/>
</gl-form-group>
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{
- $options.i18n.testAlertInfo
- }}</gl-button>
- </div>
+ <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{
+ $options.i18n.testAlertInfo
+ }}</gl-button>
</template>
<div class="footer-block row-content-block gl-display-flex gl-justify-content-space-between">
- <gl-button category="primary" :disabled="!canSaveConfig" @click="onReset">
- {{ __('Cancel') }}
- </gl-button>
<gl-button
variant="success"
category="primary"
@@ -529,6 +524,9 @@ export default {
>
{{ __('Save changes') }}
</gl-button>
+ <gl-button category="primary" :disabled="!canSaveConfig" @click="onReset">
+ {{ __('Cancel') }}
+ </gl-button>
</div>
</gl-form>
</div>
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/app.vue b/app/assets/javascripts/analytics/instance_statistics/components/app.vue
new file mode 100644
index 00000000000..eb0b67a1629
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/components/app.vue
@@ -0,0 +1,14 @@
+<script>
+import InstanceCounts from './instance_counts.vue';
+
+export default {
+ name: 'InstanceStatisticsApp',
+ components: {
+ InstanceCounts,
+ },
+};
+</script>
+
+<template>
+ <instance-counts />
+</template>
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue b/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue
new file mode 100644
index 00000000000..1147ce9af73
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue
@@ -0,0 +1,64 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import { s__ } from '~/locale';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format';
+import MetricCard from '~/analytics/shared/components/metric_card.vue';
+import instanceStatisticsCountQuery from '../graphql/queries/instance_statistics_count.query.graphql';
+
+const defaultPrecision = 0;
+
+export default {
+ name: 'InstanceCounts',
+ components: {
+ MetricCard,
+ },
+ data() {
+ return {
+ counts: [],
+ };
+ },
+ apollo: {
+ counts: {
+ query: instanceStatisticsCountQuery,
+ update(data) {
+ return Object.entries(data).map(([key, obj]) => {
+ const label = this.$options.i18n.labels[key];
+ const formatter = getFormatter(SUPPORTED_FORMATS.number);
+ const value = obj.nodes?.length ? formatter(obj.nodes[0].count, defaultPrecision) : null;
+
+ return {
+ key,
+ value,
+ label,
+ };
+ });
+ },
+ error(error) {
+ createFlash(this.$options.i18n.loadCountsError);
+ Sentry.captureException(error);
+ },
+ },
+ },
+ i18n: {
+ labels: {
+ users: s__('InstanceStatistics|Users'),
+ projects: s__('InstanceStatistics|Projects'),
+ groups: s__('InstanceStatistics|Groups'),
+ issues: s__('InstanceStatistics|Issues'),
+ mergeRequests: s__('InstanceStatistics|Merge Requests'),
+ pipelines: s__('InstanceStatistics|Pipelines'),
+ },
+ loadCountsError: s__('Could not load instance counts. Please refresh the page to try again.'),
+ },
+};
+</script>
+
+<template>
+ <metric-card
+ :title="__('Instance Statistics')"
+ :metrics="counts"
+ :is-loading="$apollo.queries.counts.loading"
+ class="gl-mt-4"
+ />
+</template>
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql
new file mode 100644
index 00000000000..fd8282683d9
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql
@@ -0,0 +1,32 @@
+query getInstanceCounts {
+ projects: instanceStatisticsMeasurements(identifier: PROJECTS, first: 1) {
+ nodes {
+ count
+ }
+ }
+ groups: instanceStatisticsMeasurements(identifier: GROUPS, first: 1) {
+ nodes {
+ count
+ }
+ }
+ users: instanceStatisticsMeasurements(identifier: USERS, first: 1) {
+ nodes {
+ count
+ }
+ }
+ issues: instanceStatisticsMeasurements(identifier: ISSUES, first: 1) {
+ nodes {
+ count
+ }
+ }
+ mergeRequests: instanceStatisticsMeasurements(identifier: MERGE_REQUESTS, first: 1) {
+ nodes {
+ count
+ }
+ }
+ pipelines: instanceStatisticsMeasurements(identifier: PIPELINES, first: 1) {
+ nodes {
+ count
+ }
+ }
+}
diff --git a/app/assets/javascripts/analytics/instance_statistics/index.js b/app/assets/javascripts/analytics/instance_statistics/index.js
new file mode 100644
index 00000000000..0d7dcf6ace8
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/index.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import InstanceStatisticsApp from './components/app.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export default () => {
+ const el = document.getElementById('js-instance-statistics-app');
+
+ if (!el) return false;
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(InstanceStatisticsApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/analytics/shared/components/metric_card.vue b/app/assets/javascripts/analytics/shared/components/metric_card.vue
new file mode 100644
index 00000000000..cee186c057c
--- /dev/null
+++ b/app/assets/javascripts/analytics/shared/components/metric_card.vue
@@ -0,0 +1,80 @@
+<script>
+import {
+ GlCard,
+ GlDeprecatedSkeletonLoading as GlSkeletonLoading,
+ GlLink,
+ GlIcon,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+
+export default {
+ name: 'MetricCard',
+ components: {
+ GlCard,
+ GlSkeletonLoading,
+ GlLink,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ metrics: {
+ type: Array,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ methods: {
+ valueText(metric) {
+ const { value = null, unit = null } = metric;
+ if (!value || value === '-') return '-';
+ return unit && value ? `${value} ${unit}` : value;
+ },
+ },
+};
+</script>
+<template>
+ <gl-card>
+ <template #header>
+ <strong ref="title">{{ title }}</strong>
+ </template>
+ <template #default>
+ <gl-skeleton-loading v-if="isLoading" class="gl-h-auto gl-py-3" />
+ <div v-else ref="metricsWrapper" class="gl-display-flex">
+ <div
+ v-for="metric in metrics"
+ :key="metric.key"
+ ref="metricItem"
+ class="js-metric-card-item gl-flex-grow-1 gl-text-center"
+ >
+ <gl-link v-if="metric.link" :href="metric.link">
+ <h3 class="gl-my-2 gl-text-blue-700">{{ valueText(metric) }}</h3>
+ </gl-link>
+ <h3 v-else class="gl-my-2">{{ valueText(metric) }}</h3>
+ <p class="text-secondary gl-font-sm gl-mb-2">
+ {{ metric.label }}
+ <span v-if="metric.tooltipText">
+ &nbsp;
+ <gl-icon
+ v-gl-tooltip="{ title: metric.tooltipText }"
+ :size="14"
+ class="gl-vertical-align-middle"
+ name="question"
+ data-testid="tooltip"
+ />
+ </span>
+ </p>
+ </div>
+ </div>
+ </template>
+ </gl-card>
+</template>
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index dbc7ff67d9d..a87f89efd70 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -64,6 +64,9 @@ const Api = {
issuePath: '/api/:version/projects/:id/issues/:issue_iid',
tagsPath: '/api/:version/projects/:id/repository/tags',
freezePeriodsPath: '/api/:version/projects/:id/freeze_periods',
+ usageDataIncrementUniqueUsersPath: '/api/:version/usage_data/increment_unique_users',
+ featureFlagUserLists: '/api/:version/projects/:id/feature_flags_user_lists',
+ featureFlagUserList: '/api/:version/projects/:id/feature_flags_user_lists/:list_iid',
group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@@ -111,6 +114,12 @@ const Api = {
});
},
+ inviteGroupMember(id, data) {
+ const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id));
+
+ return axios.post(url, data);
+ },
+
groupMilestones(id, options) {
const url = Api.buildUrl(this.groupMilestonesPath).replace(':id', encodeURIComponent(id));
@@ -686,9 +695,58 @@ const Api = {
return axios.post(url, freezePeriod);
},
+ trackRedisHllUserEvent(event) {
+ if (!gon.features?.usageDataApi) {
+ return null;
+ }
+
+ const url = Api.buildUrl(this.usageDataIncrementUniqueUsersPath);
+ const headers = {
+ 'Content-Type': 'application/json',
+ };
+
+ return axios.post(url, { event }, { headers });
+ },
+
buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
},
+
+ fetchFeatureFlagUserLists(id, page) {
+ const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id);
+
+ return axios.get(url, { params: { page } });
+ },
+
+ createFeatureFlagUserList(id, list) {
+ const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id);
+
+ return axios.post(url, list);
+ },
+
+ fetchFeatureFlagUserList(id, listIid) {
+ const url = Api.buildUrl(this.featureFlagUserList)
+ .replace(':id', id)
+ .replace(':list_iid', listIid);
+
+ return axios.get(url);
+ },
+
+ updateFeatureFlagUserList(id, list) {
+ const url = Api.buildUrl(this.featureFlagUserList)
+ .replace(':id', id)
+ .replace(':list_iid', list.iid);
+
+ return axios.put(url, list);
+ },
+
+ deleteFeatureFlagUserList(id, listIid) {
+ const url = Api.buildUrl(this.featureFlagUserList)
+ .replace(':id', id)
+ .replace(':list_iid', listIid);
+
+ return axios.delete(url);
+ },
};
export default Api;
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index cb71047e00c..bc69c02e21e 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -572,7 +572,7 @@ export class AwardsHandler {
}
findMatchingEmojiElements(query) {
- const emojiMatches = this.emoji.filterEmojiNamesByAlias(query);
+ const emojiMatches = this.emoji.searchEmoji(query).map(({ name }) => name);
const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]');
const $matchingElements = $emojiElements.filter(
(i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0,
diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue
index 6afb10dd2ad..0a8479519f1 100644
--- a/app/assets/javascripts/badges/components/badge_form.vue
+++ b/app/assets/javascripts/badges/components/badge_form.vue
@@ -218,7 +218,7 @@ export default {
</p>
</div>
- <div v-if="isEditing" class="row-content-block gl-display-flex gl-justify-content-end">
+ <div v-if="isEditing" class="row-content-block">
<gl-button class="btn-cancel gl-mr-4" data-testid="cancelEditing" @click="onCancel">
{{ __('Cancel') }}
</gl-button>
@@ -232,7 +232,7 @@ export default {
{{ s__('Badges|Save changes') }}
</gl-button>
</div>
- <div v-else class="gl-display-flex gl-justify-content-end form-group">
+ <div v-else class="form-group">
<gl-button :loading="isSaving" type="submit" variant="success" category="primary">
{{ s__('Badges|Add badge') }}
</gl-button>
diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue
index 3343634ecad..bf950d525bd 100644
--- a/app/assets/javascripts/badges/components/badge_list_row.vue
+++ b/app/assets/javascripts/badges/components/badge_list_row.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions, mapState } from 'vuex';
-import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
import { PROJECT_BADGE } from '../constants';
import Badge from './badge.vue';
@@ -9,8 +9,8 @@ export default {
name: 'BadgeListRow',
components: {
Badge,
- GlIcon,
GlLoadingIcon,
+ GlButton,
},
props: {
badge: {
@@ -51,24 +51,25 @@ export default {
<span class="table-section section-30 str-truncated">{{ badge.linkUrl }}</span>
<div class="table-section section-10 table-button-footer">
<div v-if="canEditBadge" class="table-action-buttons">
- <button
+ <gl-button
:disabled="badge.isDeleting"
- class="btn btn-default gl-mr-3"
- type="button"
+ class="gl-mr-3"
+ variant="default"
+ icon="pencil"
+ size="medium"
+ :aria-label="__('Edit')"
@click="editBadge(badge)"
- >
- <gl-icon :size="16" :aria-label="__('Edit')" name="pencil" />
- </button>
- <button
+ />
+ <gl-button
:disabled="badge.isDeleting"
- class="btn btn-danger"
- type="button"
+ variant="danger"
data-toggle="modal"
data-target="#delete-badge-modal"
+ icon="remove"
+ size="medium"
+ :aria-label="__('Delete')"
@click="updateBadgeInModal(badge)"
- >
- <gl-icon :size="16" :aria-label="__('Delete')" name="remove" />
- </button>
+ />
<gl-loading-icon v-show="badge.isDeleting" :inline="true" />
</div>
</div>
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index a6cd36caede..74069b61f07 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -18,11 +18,6 @@ export default {
type: Object,
required: true,
},
- diffFile: {
- type: Object,
- required: false,
- default: () => ({}),
- },
line: {
type: Object,
required: false,
diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
index 2b37ed19176..e18dc344cd7 100644
--- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
@@ -1,114 +1,43 @@
<script>
-import { mapActions, mapGetters, mapState } from 'vuex';
-import { GlButton, GlLoadingIcon, GlIcon } from '@gitlab/ui';
-import { sprintf, n__ } from '~/locale';
-import DraftsCount from './drafts_count.vue';
-import PublishButton from './publish_button.vue';
+import { mapActions, mapGetters } from 'vuex';
+import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
import PreviewItem from './preview_item.vue';
export default {
components: {
- GlButton,
- GlLoadingIcon,
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
- DraftsCount,
- PublishButton,
PreviewItem,
},
computed: {
- ...mapGetters(['isNotesFetched']),
...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']),
- ...mapState('batchComments', ['showPreviewDropdown']),
- dropdownTitle() {
- return sprintf(
- n__('%{count} pending comment', '%{count} pending comments', this.draftsCount),
- { count: this.draftsCount },
- );
- },
- },
- watch: {
- showPreviewDropdown() {
- if (this.showPreviewDropdown && this.$refs.dropdown) {
- this.$nextTick(() => this.$refs.dropdown.$el.focus());
- }
- },
- },
- mounted() {
- document.addEventListener('click', this.onClickDocument);
- },
- beforeDestroy() {
- document.removeEventListener('click', this.onClickDocument);
},
methods: {
- ...mapActions('batchComments', ['toggleReviewDropdown']),
+ ...mapActions('batchComments', ['scrollToDraft']),
isLast(index) {
return index === this.sortedDrafts.length - 1;
},
- onClickDocument({ target }) {
- if (
- this.showPreviewDropdown &&
- !target.closest('.review-preview-dropdown, .js-publish-draft-button')
- ) {
- this.toggleReviewDropdown();
- }
- },
},
};
</script>
<template>
- <div
- class="dropdown float-right review-preview-dropdown"
- :class="{
- show: showPreviewDropdown,
- }"
+ <gl-dropdown
+ :header-text="n__('%d pending comment', '%d pending comments', draftsCount)"
+ dropup
+ toggle-class="qa-review-preview-toggle"
>
- <gl-button
- ref="dropdown"
- type="button"
- category="primary"
- variant="success"
- class="review-preview-dropdown-toggle qa-review-preview-toggle"
- @click="toggleReviewDropdown"
- >
- {{ __('Finish review') }}
- <drafts-count />
- <gl-icon name="angle-up" />
- </gl-button>
- <div
- class="dropdown-menu dropdown-menu-large dropdown-menu-right dropdown-open-top"
- :class="{
- show: showPreviewDropdown,
- }"
+ <template #button-content>
+ {{ __('Pending comments') }}
+ <gl-icon class="dropdown-chevron" name="chevron-up" />
+ </template>
+ <gl-dropdown-item
+ v-for="(draft, index) in sortedDrafts"
+ :key="draft.id"
+ @click="scrollToDraft(draft)"
>
- <div class="dropdown-title gl-display-flex gl-align-items-center">
- <span class="gl-ml-auto">{{ dropdownTitle }}</span>
- <gl-button
- :aria-label="__('Close')"
- type="button"
- category="tertiary"
- size="small"
- class="dropdown-title-button gl-ml-auto gl-p-0!"
- icon="close"
- @click="toggleReviewDropdown"
- />
- </div>
- <div class="dropdown-content">
- <ul v-if="isNotesFetched">
- <li v-for="(draft, index) in sortedDrafts" :key="draft.id">
- <preview-item :draft="draft" :is-last="isLast(index)" />
- </li>
- </ul>
- <gl-loading-icon v-else size="lg" class="gl-mt-3 gl-mb-3" />
- </div>
- <div class="dropdown-footer">
- <publish-button
- :show-count="false"
- :should-publish="true"
- :label="__('Submit review')"
- class="float-right gl-mr-3"
- />
- </div>
- </div>
- </div>
+ <preview-item :draft="draft" :is-last="isLast(index)" />
+ </gl-dropdown-item>
+ </gl-dropdown>
</template>
diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue
index c89a6b537ef..dca6d90fbcb 100644
--- a/app/assets/javascripts/batch_comments/components/preview_item.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_item.vue
@@ -1,5 +1,5 @@
<script>
-import { mapActions, mapGetters } from 'vuex';
+import { mapGetters } from 'vuex';
import { GlSprintf, GlIcon } from '@gitlab/ui';
import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants';
import { sprintf, __ } from '~/locale';
@@ -78,7 +78,6 @@ export default {
},
},
methods: {
- ...mapActions('batchComments', ['scrollToDraft']),
getLineClasses(lineNumber) {
return getLineClasses(lineNumber);
},
@@ -88,17 +87,7 @@ export default {
</script>
<template>
- <button
- type="button"
- class="review-preview-item menu-item"
- :class="[
- componentClasses,
- {
- 'is-last': isLast,
- },
- ]"
- @click="scrollToDraft(draft)"
- >
+ <span>
<span class="review-preview-item-header">
<gl-icon class="flex-shrink-0" :name="iconName" />
<span
@@ -139,5 +128,5 @@ export default {
>
<gl-icon class="gl-mr-3" name="status_success" /> {{ resolvedStatusMessage }}
</span>
- </button>
+ </span>
</template>
diff --git a/app/assets/javascripts/batch_comments/components/publish_button.vue b/app/assets/javascripts/batch_comments/components/publish_button.vue
index 0c79e185f06..ecced36771e 100644
--- a/app/assets/javascripts/batch_comments/components/publish_button.vue
+++ b/app/assets/javascripts/batch_comments/components/publish_button.vue
@@ -1,7 +1,6 @@
<script>
import { mapActions, mapState } from 'vuex';
import { GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
import DraftsCount from './drafts_count.vue';
export default {
@@ -15,11 +14,6 @@ export default {
required: false,
default: false,
},
- label: {
- type: String,
- required: false,
- default: __('Finish review'),
- },
category: {
type: String,
required: false,
@@ -30,22 +24,14 @@ export default {
required: false,
default: 'success',
},
- shouldPublish: {
- type: Boolean,
- required: true,
- },
},
computed: {
...mapState('batchComments', ['isPublishing']),
},
methods: {
- ...mapActions('batchComments', ['publishReview', 'toggleReviewDropdown']),
+ ...mapActions('batchComments', ['publishReview']),
onClick() {
- if (this.shouldPublish) {
- this.publishReview();
- } else {
- this.toggleReviewDropdown();
- }
+ this.publishReview();
},
},
};
@@ -59,7 +45,7 @@ export default {
:variant="variant"
@click="onClick"
>
- {{ label }}
+ {{ __('Submit review') }}
<drafts-count v-if="showCount" />
</gl-button>
</template>
diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue
index e51888eabc1..035d6f4e0ab 100644
--- a/app/assets/javascripts/batch_comments/components/review_bar.vue
+++ b/app/assets/javascripts/batch_comments/components/review_bar.vue
@@ -1,22 +1,15 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { mapActions, mapState, mapGetters } from 'vuex';
-import { GlModal, GlModalDirective, GlButton } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
+import { mapActions, mapGetters } from 'vuex';
import PreviewDropdown from './preview_dropdown.vue';
+import PublishButton from './publish_button.vue';
export default {
components: {
- GlButton,
- GlModal,
PreviewDropdown,
- },
- directives: {
- 'gl-modal': GlModalDirective,
+ PublishButton,
},
computed: {
...mapGetters(['isNotesFetched']),
- ...mapState('batchComments', ['isDiscarding']),
...mapGetters('batchComments', ['draftsCount']),
},
watch: {
@@ -27,45 +20,17 @@ export default {
},
},
methods: {
- ...mapActions('batchComments', ['discardReview', 'expandAllDiscussions']),
+ ...mapActions('batchComments', ['expandAllDiscussions']),
},
- modalId: 'discard-draft-review',
- text: sprintf(
- s__(
- `BatchComments|You're about to discard your review which will delete all of your pending comments.
- The deleted comments %{strong_start}cannot%{strong_end} be restored.`,
- ),
- {
- strong_start: '<strong>',
- strong_end: '</strong>',
- },
- false,
- ),
};
</script>
<template>
<div v-show="draftsCount > 0">
<nav class="review-bar-component">
- <div class="review-bar-content qa-review-bar">
+ <div class="review-bar-content qa-review-bar d-flex gl-justify-content-end">
<preview-dropdown />
- <gl-button
- v-gl-modal="$options.modalId"
- :loading="isDiscarding"
- class="qa-discard-review float-right"
- >
- {{ __('Discard review') }}
- </gl-button>
+ <publish-button class="gl-ml-3" show-count />
</div>
</nav>
- <gl-modal
- :title="s__('BatchComments|Discard review?')"
- :ok-title="s__('BatchComments|Delete all pending comments')"
- :modal-id="$options.modalId"
- title-tag="h4"
- ok-variant="danger qa-modal-delete-pending-comments"
- @ok="discardReview"
- >
- <p v-html="$options.text"></p>
- </gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
index d9b92113103..ebd821125fb 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
@@ -75,15 +75,6 @@ export const updateDiscussionsAfterPublish = ({ dispatch, getters, rootGetters }
}),
);
-export const discardReview = ({ commit, getters }) => {
- commit(types.REQUEST_DISCARD_REVIEW);
-
- return service
- .discard(getters.getNotesData.draftsDiscardPath)
- .then(() => commit(types.RECEIVE_DISCARD_REVIEW_SUCCESS))
- .catch(() => commit(types.RECEIVE_DISCARD_REVIEW_ERROR));
-};
-
export const updateDraft = (
{ commit, getters },
{ note, noteText, resolveDiscussion, position, callback },
@@ -108,8 +99,6 @@ export const scrollToDraft = ({ dispatch, rootGetters }, draft) => {
const draftID = `note_${draft.id}`;
const el = document.querySelector(`#${tabEl} #${draftID}`);
- dispatch('closeReviewDropdown');
-
window.location.hash = draftID;
if (window.mrTabs.currentAction !== tab) {
@@ -125,17 +114,6 @@ export const scrollToDraft = ({ dispatch, rootGetters }, draft) => {
}
};
-export const toggleReviewDropdown = ({ dispatch, state }) => {
- if (state.showPreviewDropdown) {
- dispatch('closeReviewDropdown');
- } else {
- dispatch('openReviewDropdown');
- }
-};
-
-export const openReviewDropdown = ({ commit }) => commit(types.OPEN_REVIEW_DROPDOWN);
-export const closeReviewDropdown = ({ commit }) => commit(types.CLOSE_REVIEW_DROPDOWN);
-
export const expandAllDiscussions = ({ dispatch, state }) =>
state.drafts
.filter(draft => draft.discussion_id)
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
index c8f0658c21c..df523a692d3 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
@@ -11,13 +11,6 @@ export const REQUEST_PUBLISH_REVIEW = 'REQUEST_PUBLISH_REVIEW';
export const RECEIVE_PUBLISH_REVIEW_SUCCESS = 'RECEIVE_PUBLISH_REVIEW_SUCCESS';
export const RECEIVE_PUBLISH_REVIEW_ERROR = 'RECEIVE_PUBLISH_REVIEW_ERROR';
-export const REQUEST_DISCARD_REVIEW = 'REQUEST_DISCARD_REVIEW';
-export const RECEIVE_DISCARD_REVIEW_SUCCESS = 'RECEIVE_DISCARD_REVIEW_SUCCESS';
-export const RECEIVE_DISCARD_REVIEW_ERROR = 'RECEIVE_DISCARD_REVIEW_ERROR';
-
export const RECEIVE_DRAFT_UPDATE_SUCCESS = 'RECEIVE_DRAFT_UPDATE_SUCCESS';
-export const OPEN_REVIEW_DROPDOWN = 'OPEN_REVIEW_DROPDOWN';
-export const CLOSE_REVIEW_DROPDOWN = 'CLOSE_REVIEW_DROPDOWN';
-
export const TOGGLE_RESOLVE_DISCUSSION = 'TOGGLE_RESOLVE_DISCUSSION';
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
index 81ceef7b160..731f4b6d12a 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
@@ -43,16 +43,6 @@ export default {
[types.RECEIVE_PUBLISH_REVIEW_ERROR](state) {
state.isPublishing = false;
},
- [types.REQUEST_DISCARD_REVIEW](state) {
- state.isDiscarding = true;
- },
- [types.RECEIVE_DISCARD_REVIEW_SUCCESS](state) {
- state.isDiscarding = false;
- state.drafts = [];
- },
- [types.RECEIVE_DISCARD_REVIEW_ERROR](state) {
- state.isDiscarding = false;
- },
[types.RECEIVE_DRAFT_UPDATE_SUCCESS](state, data) {
const index = state.drafts.findIndex(draft => draft.id === data.id);
@@ -60,12 +50,6 @@ export default {
state.drafts.splice(index, 1, processDraft(data));
}
},
- [types.OPEN_REVIEW_DROPDOWN](state) {
- state.showPreviewDropdown = true;
- },
- [types.CLOSE_REVIEW_DROPDOWN](state) {
- state.showPreviewDropdown = false;
- },
[types.TOGGLE_RESOLVE_DISCUSSION](state, draftId) {
state.drafts = state.drafts.map(draft => {
if (draft.id === draftId) {
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
index 80c710deab0..6b97fc242c8 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
@@ -4,6 +4,4 @@ export default () => ({
drafts: [],
isPublishing: false,
currentlyPublishingDrafts: [],
- isDiscarding: false,
- showPreviewDropdown: false,
});
diff --git a/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js b/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js
index d9164f6204a..719d76fef8f 100644
--- a/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js
+++ b/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js
@@ -8,7 +8,6 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
* @sentrify
*/
export default () => {
- const $sidebarGutterToggle = $('.js-sidebar-toggle');
let bootstrapBreakpoint = bp.getBreakpointSize();
$(window).on('resize.app', () => {
@@ -19,8 +18,13 @@ export default () => {
const breakpointSizes = ['md', 'sm', 'xs'];
if (breakpointSizes.includes(bootstrapBreakpoint)) {
- const $gutterIcon = $sidebarGutterToggle.find('i');
- if ($gutterIcon.hasClass('fa-angle-double-right')) {
+ const $toggleContainer = $('.js-sidebar-toggle-container');
+ const isExpanded = $toggleContainer.data('is-expanded');
+ const $expandIcon = $('.js-sidebar-expand');
+
+ if (isExpanded) {
+ const $sidebarGutterToggle = $expandIcon.closest('.js-sidebar-toggle');
+
$sidebarGutterToggle.trigger('click');
}
diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js
index fd12c282b62..613309a1c5a 100644
--- a/app/assets/javascripts/behaviors/index.js
+++ b/app/assets/javascripts/behaviors/index.js
@@ -13,6 +13,9 @@ import './toggler_behavior';
import './preview_markdown';
import initCollapseSidebarOnWindowResize from './collapse_sidebar_on_window_resize';
import initSelect2Dropdowns from './select2';
+import { loadStartupCSS } from './load_startup_css';
+
+loadStartupCSS();
installGlEmojiElement();
diff --git a/app/assets/javascripts/behaviors/load_startup_css.js b/app/assets/javascripts/behaviors/load_startup_css.js
new file mode 100644
index 00000000000..1d7bf716475
--- /dev/null
+++ b/app/assets/javascripts/behaviors/load_startup_css.js
@@ -0,0 +1,15 @@
+export const loadStartupCSS = () => {
+ // We need to fallback to dispatching `load` in case our event listener was added too late
+ // or the browser environment doesn't load media=print.
+ // Do this on `window.load` so that the default deferred behavior takes precedence.
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/239357
+ window.addEventListener(
+ 'load',
+ () => {
+ document
+ .querySelectorAll('link[media=print]')
+ .forEach(x => x.dispatchEvent(new Event('load')));
+ },
+ { once: true },
+ );
+};
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index 8a8b61a57cd..3cb2d6719c8 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -117,9 +117,9 @@ export default class Shortcuts {
e.preventDefault();
const performanceBarCookieName = 'perf_bar_enabled';
if (parseBoolean(Cookies.get(performanceBarCookieName))) {
- Cookies.set(performanceBarCookieName, 'false', { path: '/' });
+ Cookies.set(performanceBarCookieName, 'false', { expires: 365, path: '/' });
} else {
- Cookies.set(performanceBarCookieName, 'true', { path: '/' });
+ Cookies.set(performanceBarCookieName, 'true', { expires: 365, path: '/' });
}
refreshCurrentPage();
}
diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue
index 601b694db87..f99ecba2324 100644
--- a/app/assets/javascripts/blob/components/blob_header_filepath.vue
+++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue
@@ -43,6 +43,7 @@ export default {
:text="blob.path"
:gfm="gfmCopyText"
:title="__('Copy file path')"
+ category="tertiary"
css-class="btn-clipboard btn-transparent lh-100 position-static"
/>
</div>
diff --git a/app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue b/app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue
deleted file mode 100644
index 1308ca53e74..00000000000
--- a/app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue
+++ /dev/null
@@ -1,50 +0,0 @@
-<script>
-import { GlAlert, GlButton } from '@gitlab/ui';
-import axios from '~/lib/utils/axios_utils';
-
-export default {
- components: {
- GlAlert,
- GlButton,
- },
- props: {
- dismissEndpoint: {
- type: String,
- required: true,
- },
- featureId: {
- type: String,
- required: true,
- },
- editPath: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- showAlert: true,
- };
- },
- methods: {
- dismissAlert() {
- this.showAlert = false;
-
- return axios.post(this.dismissEndpoint, {
- feature_name: this.featureId,
- });
- },
- },
-};
-</script>
-
-<template>
- <gl-alert v-if="showAlert" class="gl-mt-5" @dismiss="dismissAlert">
- {{ __('The Web IDE offers advanced syntax highlighting capabilities and more.') }}
- <div class="gl-mt-5">
- <gl-button :href="editPath" category="primary" variant="info">{{
- __('Open Web IDE')
- }}</gl-button>
- </div>
- </gl-alert>
-</template>
diff --git a/app/assets/javascripts/blob/suggest_web_ide_ci/index.js b/app/assets/javascripts/blob/suggest_web_ide_ci/index.js
deleted file mode 100644
index eadf3cd6216..00000000000
--- a/app/assets/javascripts/blob/suggest_web_ide_ci/index.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Vue from 'vue';
-import WebIdeAlert from './components/web_ide_alert.vue';
-
-export default el => {
- const { dismissEndpoint, featureId, editPath } = el.dataset;
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- render(createElement) {
- return createElement(WebIdeAlert, {
- props: {
- dismissEndpoint,
- featureId,
- editPath,
- },
- });
- },
- });
-};
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index 05ee8e49eb1..0fb803cdfec 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -179,9 +179,7 @@ export default class BlobViewer {
viewer.innerHTML = data.html;
viewer.setAttribute('data-loaded', 'true');
- if (window.gon?.features?.codeNavigation) {
- eventHub.$emit('showBlobInteractionZones', viewer.dataset.path);
- }
+ eventHub.$emit('showBlobInteractionZones', viewer.dataset.path);
return viewer;
});
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index c9972f0b43c..d1e5dad7971 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -7,14 +7,12 @@ import BlobFileDropzone from '../blob/blob_file_dropzone';
import initPopover from '~/blob/suggest_gitlab_ci_yml';
import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils';
import Tracking from '~/tracking';
-import initWebIdeAlert from '~/blob/suggest_web_ide_ci';
export default () => {
const editBlobForm = $('.js-edit-blob-form');
const uploadBlobForm = $('.js-upload-blob-form');
const deleteBlobForm = $('.js-delete-blob-form');
const suggestEl = document.querySelector('.js-suggest-gitlab-ci-yml');
- const alertEl = document.getElementById('js-suggest-web-ide-ci');
if (editBlobForm.length) {
const urlRoot = editBlobForm.data('relativeUrlRoot');
@@ -85,8 +83,4 @@ export default () => {
});
}
}
-
- if (alertEl) {
- initWebIdeAlert(alertEl);
- }
};
diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue
deleted file mode 100644
index 55e3e4a6329..00000000000
--- a/app/assets/javascripts/boards/components/board_blank_state.vue
+++ /dev/null
@@ -1,104 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import Cookies from 'js-cookie';
-import { __ } from '~/locale';
-import ListLabel from '~/boards/models/label';
-import boardsStore from '../stores/boards_store';
-
-export default {
- components: {
- GlButton,
- },
- 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) => {
- boardsStore.addList({
- title: label.title,
- position: i,
- list_type: 'label',
- label: {
- title: label.title,
- color: label.color,
- },
- });
- });
-
- const loadListIssues = listObj => {
- const list = boardsStore.findList('title', listObj.title);
-
- if (!list) {
- return null;
- }
-
- list.id = listObj.id;
- list.label.id = listObj.label.id;
- return list.getIssues().catch(() => {
- // TODO: handle request error
- });
- };
-
- // Save the labels
- boardsStore
- .generateDefaultLists()
- .then(res => res.data)
- .then(data => Promise.all(data.map(loadListIssues)))
- .catch(() => {
- boardsStore.removeList(undefined, 'label');
- Cookies.remove('issue_board_welcome_hidden', {
- path: '',
- });
- boardsStore.addBlankState();
- });
- },
- clearBlankState: boardsStore.removeBlankState.bind(boardsStore),
- },
-};
-</script>
-
-<template>
- <div class="board-blank-state p-3">
- <p>
- {{
- s__('BoardBlankState|Add the following default lists to your Issue Board with one click:')
- }}
- </p>
- <ul class="list-unstyled board-blank-state-list">
- <li v-for="(label, index) in predefinedLabels" :key="index">
- <span
- :style="{ backgroundColor: label.color }"
- class="label-color position-relative d-inline-block rounded"
- ></span>
- {{ label.title }}
- </li>
- </ul>
- <p>
- {{
- s__(
- 'BoardBlankState|Starting out with the default set of lists will get you right on the way to making the most of your board.',
- )
- }}
- </p>
- <gl-button
- category="secondary"
- variant="success"
- block="block"
- class="gl-mb-0"
- @click.stop="addDefaultLists"
- >
- {{ s__('BoardBlankState|Add default lists') }}
- </gl-button>
- <gl-button category="secondary" variant="default" block="block" @click.stop="clearBlankState">
- {{ s__("BoardBlankState|Nevermind, I'll use my own") }}
- </gl-button>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index 6d216911798..6aff5f0c3c3 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -6,7 +6,6 @@ import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue'
import Tooltip from '~/vue_shared/directives/tooltip';
import EmptyComponent from '~/vue_shared/components/empty_component';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import BoardBlankState from './board_blank_state.vue';
import BoardList from './board_list.vue';
import boardsStore from '../stores/boards_store';
import eventHub from '../eventhub';
@@ -16,7 +15,6 @@ import { ListType } from '../constants';
export default {
components: {
BoardPromotionState: EmptyComponent,
- BoardBlankState,
BoardListHeader,
BoardList,
},
@@ -54,7 +52,7 @@ export default {
computed: {
...mapGetters(['getIssues']),
showBoardListAndBoardInfo() {
- return this.list.type !== ListType.blank && this.list.type !== ListType.promotion;
+ return this.list.type !== ListType.promotion;
},
uniqueKey() {
// eslint-disable-next-line @gitlab/require-i18n-strings
@@ -148,7 +146,6 @@ export default {
:list="list"
:loading="list.loading"
/>
- <board-blank-state v-if="canAdminList && list.id === 'blank'" />
<!-- Will be only available in EE -->
<board-promotion-state v-if="list.id === 'promotion'" />
diff --git a/app/assets/javascripts/boards/components/board_configuration_options.vue b/app/assets/javascripts/boards/components/board_configuration_options.vue
new file mode 100644
index 00000000000..ad3d653b905
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_configuration_options.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlFormCheckbox } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlFormCheckbox,
+ },
+ props: {
+ currentBoard: {
+ type: Object,
+ required: true,
+ },
+ board: {
+ type: Object,
+ required: true,
+ },
+ isNewForm: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ const { hide_backlog_list: hideBacklogList, hide_closed_list: hideClosedList } = this.isNewForm
+ ? this.board
+ : this.currentBoard;
+
+ return {
+ hideClosedList,
+ hideBacklogList,
+ };
+ },
+ methods: {
+ changeClosedList(checked) {
+ this.board.hideClosedList = !checked;
+ },
+ changeBacklogList(checked) {
+ this.board.hideBacklogList = !checked;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="append-bottom-20">
+ <label class="form-section-title label-bold" for="board-new-name">
+ {{ __('List options') }}
+ </label>
+ <p class="text-secondary gl-mb-3">
+ {{ __('Configure which lists are shown for anyone who visits this board') }}
+ </p>
+ <gl-form-checkbox
+ :checked="!hideBacklogList"
+ data-testid="backlog-list-checkbox"
+ @change="changeBacklogList"
+ >{{ __('Show the Open list') }}
+ </gl-form-checkbox>
+ <gl-form-checkbox
+ :checked="!hideClosedList"
+ data-testid="closed-list-checkbox"
+ @change="changeClosedList"
+ >{{ __('Show the Closed list') }}
+ </gl-form-checkbox>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index 385dd5fdc71..793c594cf16 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -5,6 +5,8 @@ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import { visitUrl } from '~/lib/utils/url_utility';
import boardsStore from '~/boards/stores/boards_store';
+import BoardConfigurationOptions from './board_configuration_options.vue';
+
const boardDefaults = {
id: false,
name: '',
@@ -13,12 +15,15 @@ const boardDefaults = {
assignee: {},
assignee_id: undefined,
weight: null,
+ hide_backlog_list: false,
+ hide_closed_list: false,
};
export default {
components: {
BoardScope: () => import('ee_component/boards/components/board_scope.vue'),
DeprecatedModal,
+ BoardConfigurationOptions,
},
props: {
canAdminBoard: {
@@ -140,7 +145,17 @@ export default {
} else {
boardsStore
.createBoard(this.board)
- .then(resp => resp.data)
+ .then(resp => {
+ // This handles 2 use cases
+ // - In create call we only get one parameter, the new board
+ // - In update call, due to Promise.all, we get REST response in
+ // array index 0
+
+ if (Array.isArray(resp)) {
+ return resp[0].data;
+ }
+ return resp.data ? resp.data : resp;
+ })
.then(data => {
visitUrl(data.board_path);
})
@@ -182,7 +197,7 @@ export default {
<form v-else class="js-board-config-modal" @submit.prevent>
<div v-if="!readonly" class="append-bottom-20">
<label class="form-section-title label-bold" for="board-new-name">{{
- __('Board name')
+ __('Title')
}}</label>
<input
id="board-new-name"
@@ -196,6 +211,12 @@ export default {
/>
</div>
+ <board-configuration-options
+ :is-new-form="isNewForm"
+ :board="board"
+ :current-board="currentBoard"
+ />
+
<board-scope
v-if="scopedIssueBoardFeatureEnabled"
:collapse-scope="isNewForm"
diff --git a/app/assets/javascripts/boards/components/modal/tabs.vue b/app/assets/javascripts/boards/components/modal/tabs.vue
index a71fda9d7c5..b066fb25360 100644
--- a/app/assets/javascripts/boards/components/modal/tabs.vue
+++ b/app/assets/javascripts/boards/components/modal/tabs.vue
@@ -1,9 +1,15 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
+import { GlTabs, GlTab, GlBadge } from '@gitlab/ui';
import ModalStore from '../../stores/modal_store';
import modalMixin from '../../mixins/modal_mixins';
export default {
+ components: {
+ GlTabs,
+ GlTab,
+ GlBadge,
+ },
mixins: [modalMixin],
data() {
return ModalStore.store;
@@ -19,18 +25,18 @@ export default {
};
</script>
<template>
- <div class="top-area gl-mt-3 gl-mb-3">
- <ul class="nav-links issues-state-filters">
- <li :class="{ active: activeTab == 'all' }">
- <a href="#" role="button" @click.prevent="changeTab('all')">
- Open issues <span class="badge badge-pill"> {{ issuesCount }} </span>
- </a>
- </li>
- <li :class="{ active: activeTab == 'selected' }">
- <a href="#" role="button" @click.prevent="changeTab('selected')">
- Selected issues <span class="badge badge-pill"> {{ selectedCount }} </span>
- </a>
- </li>
- </ul>
- </div>
+ <gl-tabs class="gl-mt-3">
+ <gl-tab @click.prevent="changeTab('all')">
+ <template slot="title">
+ <span>Open issues</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ issuesCount }}</gl-badge>
+ </template>
+ </gl-tab>
+ <gl-tab @click.prevent="changeTab('selected')">
+ <template slot="title">
+ <span>Selected issues</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ selectedCount }}</gl-badge>
+ </template>
+ </gl-tab>
+ </gl-tabs>
</template>
diff --git a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
index 8df03ea581f..ec3c4e309b6 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
@@ -36,7 +36,7 @@ export default {
}
this.edit = true;
- this.$emit('changed', this.edit);
+ this.$emit('open');
window.addEventListener('click', this.collapseWhenOffClick);
},
collapse() {
@@ -45,7 +45,7 @@ export default {
}
this.edit = false;
- this.$emit('changed', this.edit);
+ this.$emit('close');
window.removeEventListener('click', this.collapseWhenOffClick);
},
},
diff --git a/app/assets/javascripts/boards/ee_functions.js b/app/assets/javascripts/boards/ee_functions.js
index 583270fcae5..419a640d5c5 100644
--- a/app/assets/javascripts/boards/ee_functions.js
+++ b/app/assets/javascripts/boards/ee_functions.js
@@ -1,6 +1,6 @@
export const setPromotionState = () => {};
-export const setWeigthFetchingState = () => {};
+export const setWeightFetchingState = () => {};
export const setEpicFetchingState = () => {};
export const getMilestoneTitle = () => ({});
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 1173c6d0578..2af96e94d32 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -11,7 +11,7 @@ import toggleLabels from 'ee_else_ce/boards/toggle_labels';
import toggleEpicsSwimlanes from 'ee_else_ce/boards/toggle_epics_swimlanes';
import {
setPromotionState,
- setWeigthFetchingState,
+ setWeightFetchingState,
setEpicFetchingState,
getMilestoneTitle,
getBoardsModalData,
@@ -84,8 +84,9 @@ export default () => {
},
provide: {
boardId: $boardApp.dataset.boardId,
- groupId: Number($boardApp.dataset.groupId) || null,
+ groupId: Number($boardApp.dataset.groupId),
rootPath: $boardApp.dataset.rootPath,
+ canUpdate: $boardApp.dataset.canUpdate,
},
store,
apolloProvider,
@@ -131,6 +132,7 @@ export default () => {
eventHub.$on('clearDetailIssue', this.clearDetailIssue);
sidebarEventHub.$on('toggleSubscription', this.toggleSubscription);
eventHub.$on('performSearch', this.performSearch);
+ eventHub.$on('initialBoardLoad', this.initialBoardLoad);
},
beforeDestroy() {
eventHub.$off('updateTokens', this.updateTokens);
@@ -138,6 +140,7 @@ export default () => {
eventHub.$off('clearDetailIssue', this.clearDetailIssue);
sidebarEventHub.$off('toggleSubscription', this.toggleSubscription);
eventHub.$off('performSearch', this.performSearch);
+ eventHub.$off('initialBoardLoad', this.initialBoardLoad);
},
mounted() {
this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit);
@@ -148,6 +151,18 @@ export default () => {
boardsStore.disabled = this.disabled;
if (!gon.features.graphqlBoardLists) {
+ this.initialBoardLoad();
+ }
+ },
+ methods: {
+ ...mapActions([
+ 'setInitialBoardData',
+ 'setFilters',
+ 'fetchEpicsSwimlanes',
+ 'resetIssues',
+ 'resetEpics',
+ ]),
+ initialBoardLoad() {
boardsStore
.all()
.then(res => res.data)
@@ -160,30 +175,23 @@ export default () => {
.catch(() => {
Flash(__('An error occurred while fetching the board lists. Please try again.'));
});
- }
- },
- methods: {
- ...mapActions([
- 'setInitialBoardData',
- 'setFilters',
- 'fetchEpicsSwimlanes',
- 'fetchIssuesForAllLists',
- ]),
+ },
updateTokens() {
this.filterManager.updateTokens();
},
performSearch() {
this.setFilters(convertObjectPropsToCamelCase(urlParamsToObject(window.location.search)));
if (gon.features.boardsWithSwimlanes && this.isShowingEpicsSwimlanes) {
- this.fetchEpicsSwimlanes(false);
- this.fetchIssuesForAllLists();
+ this.resetEpics();
+ this.fetchEpicsSwimlanes({ withLists: false });
+ this.resetIssues();
}
},
updateDetailIssue(newIssue, multiSelect = false) {
const { sidebarInfoEndpoint } = newIssue;
if (sidebarInfoEndpoint && newIssue.subscribed === undefined) {
newIssue.setFetchingState('subscriptions', true);
- setWeigthFetchingState(newIssue, true);
+ setWeightFetchingState(newIssue, true);
setEpicFetchingState(newIssue, true);
boardsStore
.getIssueInfo(sidebarInfoEndpoint)
@@ -201,7 +209,7 @@ export default () => {
} = convertObjectPropsToCamelCase(data);
newIssue.setFetchingState('subscriptions', false);
- setWeigthFetchingState(newIssue, false);
+ setWeightFetchingState(newIssue, false);
setEpicFetchingState(newIssue, false);
newIssue.updateData({
humanTimeSpent: humanTotalTimeSpent,
@@ -216,7 +224,7 @@ export default () => {
})
.catch(() => {
newIssue.setFetchingState('subscriptions', false);
- setWeigthFetchingState(newIssue, false);
+ setWeightFetchingState(newIssue, false);
Flash(__('An error occurred while fetching sidebar data'));
});
}
diff --git a/app/assets/javascripts/boards/queries/board.mutation.graphql b/app/assets/javascripts/boards/queries/board.mutation.graphql
new file mode 100644
index 00000000000..ef2b81a7939
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/board.mutation.graphql
@@ -0,0 +1,11 @@
+mutation UpdateBoard($id: ID!, $hideClosedList: Boolean, $hideBacklogList: Boolean) {
+ updateBoard(
+ input: { id: $id, hideClosedList: $hideClosedList, hideBacklogList: $hideBacklogList }
+ ) {
+ board {
+ id
+ hideClosedList
+ hideBacklogList
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 4b81d9c73ef..a513e02e0ca 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -226,31 +226,8 @@ export default {
.catch(() => commit(types.RECEIVE_ISSUES_FOR_LIST_FAILURE, listId));
},
- fetchIssuesForAllLists: ({ state, commit }) => {
- commit(types.REQUEST_ISSUES_FOR_ALL_LISTS);
-
- const { endpoints, boardType, filterParams } = state;
- const { fullPath, boardId } = endpoints;
-
- const variables = {
- fullPath,
- boardId: fullBoardId(boardId),
- filters: filterParams,
- isGroup: boardType === BoardType.group,
- isProject: boardType === BoardType.project,
- };
-
- return gqlClient
- .query({
- query: listsIssuesQuery,
- variables,
- })
- .then(({ data }) => {
- const { lists } = data[boardType]?.board;
- const listIssues = formatListIssues(lists);
- commit(types.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS, listIssues);
- })
- .catch(() => commit(types.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE));
+ resetIssues: ({ commit }) => {
+ commit(types.RESET_ISSUES);
},
moveIssue: (
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index faf4f9ebfd3..d1a5db1bcc5 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -2,7 +2,7 @@
/* global List */
/* global ListIssue */
import $ from 'jquery';
-import { sortBy } from 'lodash';
+import { sortBy, pick } from 'lodash';
import Vue from 'vue';
import Cookies from 'js-cookie';
import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee';
@@ -12,7 +12,7 @@ import {
parseBoolean,
convertObjectPropsToCamelCase,
} from '~/lib/utils/common_utils';
-import { __ } from '~/locale';
+import createDefaultClient from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -23,7 +23,11 @@ import ListLabel from '../models/label';
import ListAssignee from '../models/assignee';
import ListMilestone from '../models/milestone';
+import createBoardMutation from '../queries/board.mutation.graphql';
+
const PER_PAGE = 20;
+export const gqlClient = createDefaultClient();
+
const boardsStore = {
disabled: false,
timeTracking: {
@@ -114,7 +118,6 @@ const boardsStore = {
.catch(() => {
// https://gitlab.com/gitlab-org/gitlab-foss/issues/30821
});
- this.removeBlankState();
},
updateNewListDropdown(listId) {
$(`.js-board-list-${listId}`).removeClass('is-active');
@@ -124,22 +127,14 @@ const boardsStore = {
return !this.state.lists.filter(list => list.type !== 'backlog' && list.type !== 'closed')[0];
},
addBlankState() {
- if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return;
-
- this.addList({
- id: 'blank',
- list_type: 'blank',
- title: __('Welcome to your Issue Board!'),
- position: 0,
- });
- },
- removeBlankState() {
- this.removeList('blank');
+ if (!this.shouldAddBlankState() || this.welcomeIsHidden()) return;
- Cookies.set('issue_board_welcome_hidden', 'true', {
- expires: 365 * 10,
- path: '',
- });
+ this.generateDefaultLists()
+ .then(res => res.data)
+ .then(data => Promise.all(data.map(list => this.addList(list))))
+ .catch(() => {
+ this.removeList(undefined, 'label');
+ });
},
findIssueLabel(issue, findLabel) {
@@ -542,6 +537,10 @@ const boardsStore = {
this.timeTracking.limitToHours = parseBoolean(limitToHours);
},
+ generateBoardGid(boardId) {
+ return `gid://gitlab/Board/${boardId}`;
+ },
+
generateBoardsPath(id) {
return `${this.state.endpoints.boardsEndpoint}${id ? `/${id}` : ''}.json`;
},
@@ -800,9 +799,33 @@ const boardsStore = {
}
if (boardPayload.id) {
- return axios.put(this.generateBoardsPath(boardPayload.id), { board: boardPayload });
+ const input = {
+ ...pick(boardPayload, ['hideClosedList', 'hideBacklogList']),
+ id: this.generateBoardGid(boardPayload.id),
+ };
+
+ return Promise.all([
+ axios.put(this.generateBoardsPath(boardPayload.id), { board: boardPayload }),
+ gqlClient.mutate({
+ mutation: createBoardMutation,
+ variables: input,
+ }),
+ ]);
}
- return axios.post(this.generateBoardsPath(), { board: boardPayload });
+
+ return axios
+ .post(this.generateBoardsPath(), { board: boardPayload })
+ .then(resp => resp.data)
+ .then(data => {
+ gqlClient.mutate({
+ mutation: createBoardMutation,
+ variables: {
+ ...pick(boardPayload, ['hideClosedList', 'hideBacklogList']),
+ id: this.generateBoardGid(data.id),
+ },
+ });
+ return data;
+ });
},
deleteBoard({ id }) {
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index f0a283f6161..7e0597f5332 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -12,11 +12,8 @@ export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE';
export const REQUEST_REMOVE_LIST = 'REQUEST_REMOVE_LIST';
export const RECEIVE_REMOVE_LIST_SUCCESS = 'RECEIVE_REMOVE_LIST_SUCCESS';
export const RECEIVE_REMOVE_LIST_ERROR = 'RECEIVE_REMOVE_LIST_ERROR';
-export const REQUEST_ISSUES_FOR_ALL_LISTS = 'REQUEST_ISSUES_FOR_ALL_LISTS';
export const RECEIVE_ISSUES_FOR_LIST_FAILURE = 'RECEIVE_ISSUES_FOR_LIST_FAILURE';
export const RECEIVE_ISSUES_FOR_LIST_SUCCESS = 'RECEIVE_ISSUES_FOR_LIST_SUCCESS';
-export const RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS = 'RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS';
-export const RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE = 'RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE';
export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE';
export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS';
export const RECEIVE_ADD_ISSUE_ERROR = 'RECEIVE_ADD_ISSUE_ERROR';
@@ -32,3 +29,4 @@ export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE';
export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE';
export const SET_ACTIVE_ID = 'SET_ACTIVE_ID';
export const UPDATE_ISSUE_BY_ID = 'UPDATE_ISSUE_BY_ID';
+export const RESET_ISSUES = 'RESET_ISSUES';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index faeb3e25a71..de18ec4b4f3 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import { sortBy, pull } from 'lodash';
import { formatIssue, moveIssueListHelper } from '../boards_util';
import * as mutationTypes from './mutation_types';
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
const notImplemented = () => {
@@ -49,7 +49,7 @@ export default {
},
[mutationTypes.CREATE_LIST_FAILURE]: state => {
- state.error = __('An error occurred while creating the list. Please try again.');
+ state.error = s__('Boards|An error occurred while creating the list. Please try again.');
},
[mutationTypes.REQUEST_ADD_LIST]: () => {
@@ -73,7 +73,7 @@ export default {
},
[mutationTypes.UPDATE_LIST_FAILURE]: (state, backupList) => {
- state.error = __('An error occurred while updating the list. Please try again.');
+ state.error = s__('Boards|An error occurred while updating the list. Please try again.');
Vue.set(state, 'boardLists', backupList);
},
@@ -98,19 +98,17 @@ export default {
},
[mutationTypes.RECEIVE_ISSUES_FOR_LIST_FAILURE]: (state, listId) => {
- state.error = __('An error occurred while fetching the board issues. Please reload the page.');
+ state.error = s__(
+ 'Boards|An error occurred while fetching the board issues. Please reload the page.',
+ );
const listIndex = state.boardLists.findIndex(l => l.id === listId);
Vue.set(state.boardLists[listIndex], 'loading', false);
},
- [mutationTypes.REQUEST_ISSUES_FOR_ALL_LISTS]: state => {
- state.isLoadingIssues = true;
- },
-
- [mutationTypes.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS]: (state, { listData, issues }) => {
- state.issuesByListId = listData;
- state.issues = issues;
- state.isLoadingIssues = false;
+ [mutationTypes.RESET_ISSUES]: state => {
+ Object.keys(state.issuesByListId).forEach(listId => {
+ Vue.set(state.issuesByListId, listId, []);
+ });
},
[mutationTypes.UPDATE_ISSUE_BY_ID]: (state, { issueId, prop, value }) => {
@@ -122,11 +120,6 @@ export default {
Vue.set(state.issues[issueId], prop, value);
},
- [mutationTypes.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE]: state => {
- state.error = __('An error occurred while fetching the board issues. Please reload the page.');
- state.isLoadingIssues = false;
- },
-
[mutationTypes.REQUEST_ADD_ISSUE]: () => {
notImplemented();
},
@@ -162,7 +155,7 @@ export default {
state,
{ originalIssue, fromListId, toListId, originalIndex },
) => {
- state.error = __('An error occurred while moving the issue. Please try again.');
+ state.error = s__('Boards|An error occurred while moving the issue. Please try again.');
Vue.set(state.issues, originalIssue.id, originalIssue);
removeIssueFromList(state, toListId, originalIssue.id);
addIssueToList({
@@ -193,7 +186,7 @@ export default {
},
[mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issue }) => {
- state.error = __('An error occurred while creating the issue. Please try again.');
+ state.error = s__('Boards|An error occurred while creating the issue. Please try again.');
removeIssueFromList(state, list.id, issue.id);
},
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index be937d68c6c..2d388739586 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -11,7 +11,6 @@ export default () => ({
boardLists: [],
issuesByListId: {},
issues: {},
- isLoadingIssues: false,
filterParams: {},
error: undefined,
// TODO: remove after ce/ee split of board_content.vue
diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js
index 2955f0f014b..8324c649538 100644
--- a/app/assets/javascripts/build_artifacts.js
+++ b/app/assets/javascripts/build_artifacts.js
@@ -3,6 +3,7 @@
import $ from 'jquery';
import { visitUrl } from './lib/utils/url_utility';
import { parseBoolean } from './lib/utils/common_utils';
+import { hide, initTooltips, show } from '~/tooltips';
export default class BuildArtifacts {
constructor() {
@@ -10,6 +11,7 @@ export default class BuildArtifacts {
this.setupEntryClick();
this.setupTooltips();
}
+
// eslint-disable-next-line class-methods-use-this
disablePropagation() {
$('.top-block').on('click', '.download', e => {
@@ -19,15 +21,17 @@ export default class BuildArtifacts {
e.stopImmediatePropagation();
});
}
+
// eslint-disable-next-line class-methods-use-this
setupEntryClick() {
return $('.tree-holder').on('click', 'tr[data-link]', function() {
visitUrl(this.dataset.link, parseBoolean(this.dataset.externalLink));
});
}
+
// eslint-disable-next-line class-methods-use-this
setupTooltips() {
- $('.js-artifact-tree-tooltip').tooltip({
+ initTooltips({
placement: 'bottom',
// Stop the tooltip from hiding when we stop hovering the element directly
// We handle all the showing/hiding below
@@ -38,14 +42,14 @@ export default class BuildArtifacts {
// But be placed below and in the middle of the file name
$('.js-artifact-tree-row')
.on('mouseenter', e => {
- $(e.currentTarget)
- .find('.js-artifact-tree-tooltip')
- .tooltip('show');
+ const $el = $(e.currentTarget).find('.js-artifact-tree-tooltip');
+
+ show($el);
})
.on('mouseleave', e => {
- $(e.currentTarget)
- .find('.js-artifact-tree-tooltip')
- .tooltip('hide');
+ const $el = $(e.currentTarget).find('.js-artifact-tree-tooltip');
+
+ hide($el);
});
}
}
diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
new file mode 100644
index 00000000000..ad07052a298
--- /dev/null
+++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
@@ -0,0 +1,143 @@
+<script>
+import { GlTable, GlButton, GlBadge, GlTooltipDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ components: {
+ GlTable,
+ GlButton,
+ GlBadge,
+ ClipboardButton,
+ TooltipOnTruncate,
+ UserAvatarLink,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ triggers: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ fields: [
+ {
+ key: 'token',
+ label: s__('Pipelines|Token'),
+ },
+ {
+ key: 'description',
+ label: s__('Pipelines|Description'),
+ },
+ {
+ key: 'owner',
+ label: s__('Pipelines|Owner'),
+ },
+ {
+ key: 'lastUsed',
+ label: s__('Pipelines|Last Used'),
+ },
+ {
+ key: 'actions',
+ label: '',
+ tdClass: 'gl-text-right gl-white-space-nowrap',
+ },
+ ],
+};
+</script>
+
+<template>
+ <div>
+ <gl-table
+ v-if="triggers.length"
+ :fields="$options.fields"
+ :items="triggers"
+ class="triggers-list"
+ responsive
+ >
+ <template #cell(token)="{item}">
+ {{ item.token }}
+ <clipboard-button
+ v-if="item.hasTokenExposed"
+ :text="item.token"
+ data-testid="clipboard-btn"
+ data-qa-selector="clipboard_button"
+ :title="s__('Pipelines|Copy trigger token')"
+ css-class="gl-border-none gl-py-0 gl-px-2"
+ />
+ <div class="label-container">
+ <gl-badge v-if="!item.canAccessProject" variant="danger">
+ <span
+ v-gl-tooltip.viewport
+ boundary="viewport"
+ :title="s__('Pipelines|Trigger user has insufficient permissions to project')"
+ >{{ s__('Pipelines|invalid') }}</span
+ >
+ </gl-badge>
+ </div>
+ </template>
+ <template #cell(description)="{item}">
+ <tooltip-on-truncate
+ :title="item.description"
+ truncate-target="child"
+ placement="top"
+ class="trigger-description gl-display-flex"
+ >
+ <div class="gl-flex-fill-1 gl-text-truncate">{{ item.description }}</div>
+ </tooltip-on-truncate>
+ </template>
+ <template #cell(owner)="{item}">
+ <span class="trigger-owner sr-only">{{ item.owner.name }}</span>
+ <user-avatar-link
+ v-if="item.owner"
+ :link-href="item.owner.path"
+ :img-src="item.owner.avatarUrl"
+ :tooltip-text="item.owner.name"
+ :img-alt="item.owner.name"
+ />
+ </template>
+ <template #cell(lastUsed)="{item}">
+ <time-ago-tooltip v-if="item.lastUsed" :time="item.lastUsed" />
+ <span v-else>{{ __('Never') }}</span>
+ </template>
+ <template #cell(actions)="{item}">
+ <gl-button
+ :title="s__('Pipelines|Edit')"
+ icon="pencil"
+ data-testid="edit-btn"
+ :href="item.editProjectTriggerPath"
+ />
+ <gl-button
+ :title="s__('Pipelines|Revoke')"
+ icon="remove"
+ variant="warning"
+ :data-confirm="
+ s__(
+ 'Pipelines|By revoking a trigger you will break any processes making use of it. Are you sure?',
+ )
+ "
+ data-method="delete"
+ rel="nofollow"
+ class="gl-ml-3"
+ data-testid="trigger_revoke_button"
+ data-qa-selector="trigger_revoke_button"
+ :href="item.projectTriggerPath"
+ />
+ </template>
+ </gl-table>
+ <div
+ v-else
+ data-testid="no_triggers_content"
+ data-qa-selector="no_triggers_content"
+ class="settings-message gl-text-center gl-mb-3"
+ >
+ {{ s__('Pipelines|No triggers have been created yet. Add one using the form above.') }}
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/index.js b/app/assets/javascripts/ci_settings_pipeline_triggers/index.js
new file mode 100644
index 00000000000..182d5ca5ffb
--- /dev/null
+++ b/app/assets/javascripts/ci_settings_pipeline_triggers/index.js
@@ -0,0 +1,36 @@
+import Vue from 'vue';
+import TriggersList from './components/triggers_list.vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+const parseJsonArray = triggers => {
+ try {
+ return convertObjectPropsToCamelCase(JSON.parse(triggers), { deep: true });
+ } catch {
+ return [];
+ }
+};
+
+export default (containerId = 'js-ci-pipeline-triggers-list') => {
+ const containerEl = document.getElementById(containerId);
+
+ // Note: Remove this check when FF `ci_pipeline_triggers_settings_vue_ui` is removed.
+ if (!containerEl) {
+ return null;
+ }
+
+ const triggers = parseJsonArray(containerEl.dataset.triggers);
+
+ return new Vue({
+ el: containerEl,
+ components: {
+ TriggersList,
+ },
+ render(h) {
+ return h(TriggersList, {
+ props: {
+ triggers,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
index fbf19847e9d..a2f4bea2f61 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
@@ -6,7 +6,6 @@ import {
GlFormCheckbox,
GlFormCombobox,
GlFormGroup,
- GlFormInput,
GlFormSelect,
GlFormTextarea,
GlIcon,
@@ -41,7 +40,6 @@ export default {
GlFormCheckbox,
GlFormCombobox,
GlFormGroup,
- GlFormInput,
GlFormSelect,
GlFormTextarea,
GlIcon,
@@ -122,11 +120,6 @@ export default {
return '';
},
tokenValidationState() {
- // If the feature flag is off, do not validate. Remove when flag is removed.
- if (!this.glFeatures.ciKeyAutocomplete) {
- return true;
- }
-
const validator = this.$options.tokens?.[this.variable.key]?.validation;
if (validator) {
@@ -204,21 +197,12 @@ export default {
>
<form>
<gl-form-combobox
- v-if="glFeatures.ciKeyAutocomplete"
v-model="key"
:token-list="$options.tokenList"
:label-text="__('Key')"
data-qa-selector="ci_variable_key_field"
/>
- <gl-form-group v-else :label="__('Key')" label-for="ci-variable-key">
- <gl-form-input
- id="ci-variable-key"
- v-model="key"
- data-qa-selector="ci_variable_key_field"
- />
- </gl-form-group>
-
<gl-form-group
:label="__('Value')"
label-for="ci-variable-value"
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
index 501c82b419e..07278bb442c 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
@@ -163,10 +163,7 @@ export default {
</p>
</template>
</gl-table>
- <div
- class="ci-variable-actions d-flex justify-content-end"
- :class="{ 'justify-content-center': !tableIsNotEmpty }"
- >
+ <div class="ci-variable-actions" :class="{ 'justify-content-center': !tableIsNotEmpty }">
<gl-button
v-if="tableIsNotEmpty"
ref="secret-value-reveal-button"
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index 039237042ea..7add8d16912 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -481,7 +481,7 @@ export default {
type="text"
class="form-control js-hostname"
/>
- <span class="input-group-btn">
+ <span class="input-group-append">
<clipboard-button
:text="jupyterHostname"
:title="s__('ClusterIntegration|Copy Jupyter Hostname')"
diff --git a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue
index c816fc56d7a..6b99bb09504 100644
--- a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue
+++ b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue
@@ -1,12 +1,12 @@
<script>
-import { GlDeprecatedDropdown, GlDeprecatedDropdownItem, GlIcon } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
import { s__ } from '../../locale';
export default {
name: 'CrossplaneProviderStack',
components: {
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
},
props: {
@@ -67,21 +67,17 @@ export default {
<label>
{{ s__('ClusterIntegration|Enabled stack') }}
</label>
- <gl-deprecated-dropdown
+ <gl-dropdown
:disabled="crossplane.installed"
:text="dropdownText"
toggle-class="dropdown-menu-toggle gl-field-error-outline"
class="w-100"
:class="{ 'gl-show-field-errors': validationError }"
>
- <gl-deprecated-dropdown-item
- v-for="stack in stacks"
- :key="stack.code"
- @click="selectStack(stack)"
- >
+ <gl-dropdown-item v-for="stack in stacks" :key="stack.code" @click="selectStack(stack)">
<span class="ml-1">{{ stack.name }}</span>
- </gl-deprecated-dropdown-item>
- </gl-deprecated-dropdown>
+ </gl-dropdown-item>
+ </gl-dropdown>
<span v-if="validationError" class="gl-field-error">{{ validationError }}</span>
<p class="form-text text-muted">
{{ s__(`You must select a stack for configuring your cloud provider. Learn more about`) }}
diff --git a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
index e6001b11296..b37fc3894f8 100644
--- a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
+++ b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
@@ -1,11 +1,5 @@
<script>
-import {
- GlAlert,
- GlDeprecatedButton,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
- GlFormCheckbox,
-} from '@gitlab/ui';
+import { GlAlert, GlButton, GlDropdown, GlDropdownItem, GlFormCheckbox } from '@gitlab/ui';
import { mapValues } from 'lodash';
import { __ } from '~/locale';
import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants';
@@ -16,9 +10,9 @@ const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_S
export default {
components: {
GlAlert,
- GlDeprecatedButton,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlFormCheckbox,
},
props: {
@@ -203,15 +197,15 @@ export default {
<label for="fluentd-protocol">
<strong>{{ s__('ClusterIntegration|SIEM Protocol') }}</strong>
</label>
- <gl-deprecated-dropdown :text="protocolName" class="w-100">
- <gl-deprecated-dropdown-item
+ <gl-dropdown :text="protocolName" class="w-100">
+ <gl-dropdown-item
v-for="(value, index) in protocols"
:key="index"
@click="selectProtocol(value.toLowerCase())"
>
{{ value }}
- </gl-deprecated-dropdown-item>
- </gl-deprecated-dropdown>
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
<div class="form-group flex flex-wrap">
<gl-form-checkbox :checked="wafLogEnabled" @input="wafLogChanged">
@@ -221,20 +215,21 @@ export default {
<strong>{{ s__('ClusterIntegration|Send Container Network Policies Logs') }}</strong>
</gl-form-checkbox>
</div>
- <div v-if="showButtons" class="mt-3">
- <gl-deprecated-button
+ <div v-if="showButtons" class="gl-mt-5 gl-display-flex">
+ <gl-button
ref="saveBtn"
- class="mr-1"
+ class="gl-mr-3"
variant="success"
+ category="primary"
:loading="isSaving"
:disabled="saveButtonDisabled"
@click="updateApplication"
>
{{ saveButtonLabel }}
- </gl-deprecated-button>
- <gl-deprecated-button ref="cancelBtn" :disabled="saveButtonDisabled" @click="resetStatus">
+ </gl-button>
+ <gl-button ref="cancelBtn" :disabled="saveButtonDisabled" @click="resetStatus">
{{ __('Cancel') }}
- </gl-deprecated-button>
+ </gl-button>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
index 5e8e1a76182..f05c8db5d56 100644
--- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
+++ b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
@@ -5,9 +5,9 @@ import {
GlSprintf,
GlLink,
GlToggle,
- GlDeprecatedButton,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
} from '@gitlab/ui';
import modSecurityLogo from 'images/cluster_app_logos/gitlab.png';
@@ -25,9 +25,9 @@ export default {
GlSprintf,
GlLink,
GlToggle,
- GlDeprecatedButton,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
},
props: {
@@ -221,29 +221,31 @@ export default {
</strong>
</p>
</div>
- <gl-deprecated-dropdown :text="modSecurityModeName" :disabled="saveButtonDisabled">
- <gl-deprecated-dropdown-item
- v-for="(mode, key) in modes"
- :key="key"
- @click="selectMode(key)"
- >
+ <gl-dropdown :text="modSecurityModeName" :disabled="saveButtonDisabled">
+ <gl-dropdown-item v-for="(mode, key) in modes" :key="key" @click="selectMode(key)">
{{ mode.name }}
- </gl-deprecated-dropdown-item>
- </gl-deprecated-dropdown>
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</div>
- <div v-if="showButtons" class="mt-3">
- <gl-deprecated-button
- class="btn-success inline mr-1"
+ <div v-if="showButtons" class="gl-mt-5 gl-display-flex">
+ <gl-button
+ variant="success"
+ category="primary"
+ data-qa-selector="save_ingress_modsecurity_settings"
:loading="saving"
:disabled="saveButtonDisabled"
@click="updateApplication"
>
{{ saveButtonLabel }}
- </gl-deprecated-button>
- <gl-deprecated-button :disabled="saveButtonDisabled" @click="resetStatus">
+ </gl-button>
+ <gl-button
+ data-qa-selector="cancel_ingress_modsecurity_settings"
+ :disabled="saveButtonDisabled"
+ @click="resetStatus"
+ >
{{ __('Cancel') }}
- </gl-deprecated-button>
+ </gl-button>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
index 2617ea0bdea..19ce3e36cd7 100644
--- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue
+++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
@@ -1,8 +1,8 @@
<script>
import {
- GlDeprecatedDropdown,
- GlDeprecatedDropdownDivider,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
GlLoadingIcon,
GlSearchBoxByType,
GlSprintf,
@@ -20,9 +20,9 @@ export default {
GlButton,
ClipboardButton,
GlLoadingIcon,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownDivider,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
GlSearchBoxByType,
GlSprintf,
},
@@ -121,7 +121,7 @@ export default {
<strong>{{ s__('ClusterIntegration|Knative Domain Name:') }}</strong>
</label>
- <gl-deprecated-dropdown
+ <gl-dropdown
v-if="showDomainsDropdown"
:text="domainDropdownText"
toggle-class="dropdown-menu-toggle"
@@ -132,16 +132,16 @@ export default {
:placeholder="s__('ClusterIntegration|Search domains')"
class="gl-m-3"
/>
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
v-for="domain in filteredDomains"
:key="domain.id"
@click="selectDomain(domain)"
>
<span class="ml-1">{{ domain.domain }}</span>
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
<template v-if="searchQuery">
- <gl-deprecated-dropdown-divider />
- <gl-deprecated-dropdown-item key="custom-domain" @click="selectCustomDomain(searchQuery)">
+ <gl-dropdown-divider />
+ <gl-dropdown-item key="custom-domain" @click="selectCustomDomain(searchQuery)">
<span class="ml-1">
<gl-sprintf :message="s__('ClusterIntegration|Use %{query}')">
<template #query>
@@ -149,9 +149,9 @@ export default {
</template>
</gl-sprintf>
</span>
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
</template>
- </gl-deprecated-dropdown>
+ </gl-dropdown>
<input
v-else
diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue
index 7b53020fc49..f8fb58cdca2 100644
--- a/app/assets/javascripts/clusters_list/components/clusters.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters.vue
@@ -10,6 +10,7 @@ import {
GlTable,
} from '@gitlab/ui';
import AncestorNotice from './ancestor_notice.vue';
+import NodeErrorHelpText from './node_error_help_text.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import { CLUSTER_TYPES, STATUSES } from '../constants';
import { __, sprintf } from '~/locale';
@@ -26,6 +27,7 @@ export default {
GlSkeletonLoading,
GlSprintf,
GlTable,
+ NodeErrorHelpText,
},
directives: {
tooltip,
@@ -199,7 +201,13 @@ export default {
<section v-else>
<ancestor-notice />
- <gl-table :items="clusters" :fields="fields" stacked="md" class="qa-clusters-table">
+ <gl-table
+ :items="clusters"
+ :fields="fields"
+ stacked="md"
+ class="qa-clusters-table"
+ data-testid="cluster_list_table"
+ >
<template #cell(name)="{ item }">
<div :class="[contentAlignClasses, 'js-status']">
<img
@@ -231,9 +239,12 @@ export default {
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
- <small v-else class="gl-font-sm gl-font-style-italic gl-text-gray-200">{{
- __('Unknown')
- }}</small>
+ <NodeErrorHelpText
+ v-else-if="item.kubernetes_errors"
+ :class="contentAlignClasses"
+ :error-type="item.kubernetes_errors.connection_error"
+ :popover-id="`nodeSizeError${item.id}`"
+ />
</template>
<template #cell(total_cpu)="{ item }">
@@ -250,6 +261,13 @@ export default {
</span>
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
+
+ <NodeErrorHelpText
+ v-else-if="item.kubernetes_errors"
+ :class="contentAlignClasses"
+ :error-type="item.kubernetes_errors.node_connection_error"
+ :popover-id="`nodeCpuError${item.id}`"
+ />
</template>
<template #cell(total_memory)="{ item }">
@@ -266,6 +284,13 @@ export default {
</span>
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
+
+ <NodeErrorHelpText
+ v-else-if="item.kubernetes_errors"
+ :class="contentAlignClasses"
+ :error-type="item.kubernetes_errors.metrics_connection_error"
+ :popover-id="`nodeMemoryError${item.id}`"
+ />
</template>
<template #cell(cluster_type)="{value}">
diff --git a/app/assets/javascripts/clusters_list/components/node_error_help_text.vue b/app/assets/javascripts/clusters_list/components/node_error_help_text.vue
new file mode 100644
index 00000000000..1a396694bc8
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/node_error_help_text.vue
@@ -0,0 +1,53 @@
+<script>
+import { GlIcon, GlPopover } from '@gitlab/ui';
+import { CLUSTER_ERRORS } from '../constants';
+
+export default {
+ components: {
+ GlIcon,
+ GlPopover,
+ },
+ props: {
+ errorType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ popoverId: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ errorContent() {
+ return CLUSTER_ERRORS[this.errorType] || CLUSTER_ERRORS.default;
+ },
+ },
+};
+</script>
+
+<template>
+ <div :id="popoverId">
+ <span class="gl-font-style-italic">
+ {{ errorContent.tableText }}
+ </span>
+
+ <gl-icon name="status_warning" :size="24" class="gl-p-2" />
+
+ <gl-popover :container="popoverId" :target="popoverId" placement="top" triggers="hover focus">
+ <template #title>
+ <span class="gl-display-block gl-text-left">{{ errorContent.title }}</span>
+ </template>
+
+ <p class="gl-text-left">{{ errorContent.description }}</p>
+
+ <p class="gl-text-left">{{ s__('ClusterIntegration|Troubleshooting tips:') }}</p>
+
+ <ul class="gl-text-left">
+ <li v-for="tip in errorContent.troubleshootingTips" :key="tip">
+ {{ tip }}
+ </li>
+ </ul>
+ </gl-popover>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index 3e8ef3151a6..f39678b73dc 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -1,4 +1,45 @@
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
+
+export const CLUSTER_ERRORS = {
+ default: {
+ tableText: s__('ClusterIntegration|Unknown Error'),
+ title: s__('ClusterIntegration|Unknown Error'),
+ description: s__(
+ 'ClusterIntegration|An unknown error occurred while attempting to connect to Kubernetes.',
+ ),
+ troubleshootingTips: [
+ s__('ClusterIntegration|Check your cluster status'),
+ s__('ClusterIntegration|Make sure your API endpoint is correct'),
+ s__(
+ 'ClusterIntegration|Node calculations use the Kubernetes Metrics API. Make sure your cluster has metrics installed',
+ ),
+ ],
+ },
+ authentication_error: {
+ tableText: s__('ClusterIntegration|Unable to Authenticate'),
+ title: s__('ClusterIntegration|Authentication Error'),
+ description: s__('ClusterIntegration|GitLab failed to authenticate.'),
+ troubleshootingTips: [
+ s__('ClusterIntegration|Check your token'),
+ s__('ClusterIntegration|Check your CA certificate'),
+ ],
+ },
+ connection_error: {
+ tableText: s__('ClusterIntegration|Unable to Connect'),
+ title: s__('ClusterIntegration|Connection Error'),
+ description: s__('ClusterIntegration|GitLab failed to connect to the cluster.'),
+ troubleshootingTips: [
+ s__('ClusterIntegration|Check your cluster status'),
+ s__('ClusterIntegration|Make sure your API endpoint is correct'),
+ ],
+ },
+ http_error: {
+ tableText: s__('ClusterIntegration|Unable to Connect'),
+ title: s__('ClusterIntegration|HTTP Error'),
+ description: s__('ClusterIntegration|There was an HTTP error when connecting to your cluster.'),
+ troubleshootingTips: [s__('ClusterIntegration|Check your cluster status')],
+ },
+};
export const CLUSTER_TYPES = {
project_type: __('Project'),
diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js
index 51ad8769250..daa82892773 100644
--- a/app/assets/javascripts/clusters_list/index.js
+++ b/app/assets/javascripts/clusters_list/index.js
@@ -1,20 +1,6 @@
import Vue from 'vue';
-import Clusters from './components/clusters.vue';
-import { createStore } from './store';
+import loadClusters from './load_clusters';
export default () => {
- const entryPoint = document.querySelector('#js-clusters-list-app');
-
- if (!entryPoint) {
- return;
- }
-
- // eslint-disable-next-line no-new
- new Vue({
- el: '#js-clusters-list-app',
- store: createStore(entryPoint.dataset),
- render(createElement) {
- return createElement(Clusters);
- },
- });
+ loadClusters(Vue);
};
diff --git a/app/assets/javascripts/clusters_list/load_clusters.js b/app/assets/javascripts/clusters_list/load_clusters.js
new file mode 100644
index 00000000000..98bc5880898
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/load_clusters.js
@@ -0,0 +1,18 @@
+import Clusters from './components/clusters.vue';
+import { createStore } from './store';
+
+export default Vue => {
+ const el = document.querySelector('#js-clusters-list-app');
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
+ store: createStore(el.dataset),
+ render(createElement) {
+ return createElement(Clusters);
+ },
+ });
+};
diff --git a/app/assets/javascripts/code_navigation/index.js b/app/assets/javascripts/code_navigation/index.js
index 362c26ae065..fa5835245bc 100644
--- a/app/assets/javascripts/code_navigation/index.js
+++ b/app/assets/javascripts/code_navigation/index.js
@@ -1,13 +1,17 @@
import Vue from 'vue';
import Vuex from 'vuex';
-import store from './store';
+import createStore from './store';
import App from './components/app.vue';
-Vue.use(Vuex);
-
export default initialData => {
const el = document.getElementById('js-code-navigation');
+ if (!el) return null;
+
+ Vue.use(Vuex);
+
+ const store = createStore();
+
store.dispatch('setInitialData', initialData);
return new Vue({
diff --git a/app/assets/javascripts/code_navigation/store/index.js b/app/assets/javascripts/code_navigation/store/index.js
index fe48f3ac7f5..9b60fc337fe 100644
--- a/app/assets/javascripts/code_navigation/store/index.js
+++ b/app/assets/javascripts/code_navigation/store/index.js
@@ -3,8 +3,9 @@ import createState from './state';
import actions from './actions';
import mutations from './mutations';
-export default new Vuex.Store({
- actions,
- mutations,
- state: createState(),
-});
+export default () =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state: createState(),
+ });
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index 2f4118c1717..188d958ba86 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -1,6 +1,5 @@
<script>
import { GlButton, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui';
-import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import PipelinesService from '~/pipelines/services/pipelines_service';
import PipelineStore from '~/pipelines/stores/pipelines_store';
import pipelinesMixin from '~/pipelines/mixins/pipelines';
@@ -126,16 +125,6 @@ export default {
(latest.flags.detached_merge_request_pipeline || latest.flags.merge_request_pipeline)
);
},
- /**
- * When we are on Desktop and the button is visible
- * we need to add a negative margin to the table
- * to make it inline with the button
- *
- * @returns {Boolean}
- */
- shouldAddNegativeMargin() {
- return this.canRenderPipelineButton && bp.isDesktop();
- },
},
created() {
this.service = new PipelinesService(this.endpoint);
@@ -205,65 +194,76 @@ export default {
/>
<div v-else-if="shouldRenderTable" class="table-holder">
- <div v-if="canRenderPipelineButton" class="nav justify-content-end">
- <gl-button
- variant="success"
- class="js-run-mr-pipeline gl-mt-3 btn-wide-on-xs"
- :disabled="state.isRunningMergeRequestPipeline"
- @click="tryRunPipeline"
- >
- <gl-loading-icon v-if="state.isRunningMergeRequestPipeline" inline />
- {{ s__('Pipelines|Run Pipeline') }}
- </gl-button>
-
- <gl-modal
- :id="modalId"
- ref="modal"
- :modal-id="modalId"
- :title="s__('Pipelines|Are you sure you want to run this pipeline?')"
- :ok-title="s__('Pipelines|Run Pipeline')"
- ok-variant="danger"
- @ok="onClickRunPipeline"
- >
- <p>
- {{
- s__(
- 'Pipelines|This pipeline will run code originating from a forked project merge request. This means that the code can potentially have security considerations like exposing CI variables.',
- )
- }}
- </p>
- <p>
- {{
- s__(
- "Pipelines|It is recommended the code is reviewed thoroughly before running this pipeline with the parent project's CI resource.",
- )
- }}
- </p>
- <p>
- {{
- s__(
- 'Pipelines|If you are unsure, please ask a project maintainer to review it for you.',
- )
- }}
- </p>
- <gl-link
- href="/help/ci/merge_request_pipelines/index.html#run-pipelines-in-the-parent-project-for-merge-requests-from-a-forked-project"
- target="_blank"
- >
- {{ s__('Pipelines|More Information') }}
- </gl-link>
- </gl-modal>
- </div>
+ <gl-button
+ v-if="canRenderPipelineButton"
+ block
+ class="gl-mt-3 gl-mb-0 gl-display-md-none"
+ variant="success"
+ data-testid="run_pipeline_button_mobile"
+ :loading="state.isRunningMergeRequestPipeline"
+ @click="tryRunPipeline"
+ >
+ {{ s__('Pipelines|Run Pipeline') }}
+ </gl-button>
<pipelines-table-component
:pipelines="state.pipelines"
:update-graph-dropdown="updateGraphDropdown"
:auto-devops-help-path="autoDevopsHelpPath"
:view-type="viewType"
- :class="{ 'negative-margin-top': shouldAddNegativeMargin }"
- />
+ >
+ <template #table-header-actions>
+ <div v-if="canRenderPipelineButton" class="gl-text-right">
+ <gl-button
+ variant="success"
+ data-testid="run_pipeline_button"
+ :loading="state.isRunningMergeRequestPipeline"
+ @click="tryRunPipeline"
+ >
+ {{ s__('Pipelines|Run Pipeline') }}
+ </gl-button>
+ </div>
+ </template>
+ </pipelines-table-component>
</div>
+ <gl-modal
+ v-if="canRenderPipelineButton"
+ :id="modalId"
+ ref="modal"
+ :modal-id="modalId"
+ :title="s__('Pipelines|Are you sure you want to run this pipeline?')"
+ :ok-title="s__('Pipelines|Run Pipeline')"
+ ok-variant="danger"
+ @ok="onClickRunPipeline"
+ >
+ <p>
+ {{
+ s__(
+ 'Pipelines|This pipeline will run code originating from a forked project merge request. This means that the code can potentially have security considerations like exposing CI variables.',
+ )
+ }}
+ </p>
+ <p>
+ {{
+ s__(
+ "Pipelines|It is recommended the code is reviewed thoroughly before running this pipeline with the parent project's CI resource.",
+ )
+ }}
+ </p>
+ <p>
+ {{
+ s__('Pipelines|If you are unsure, please ask a project maintainer to review it for you.')
+ }}
+ </p>
+ <gl-link
+ href="/help/ci/merge_request_pipelines/index.html#run-pipelines-in-the-parent-project-for-merge-requests-from-a-forked-project"
+ target="_blank"
+ >
+ {{ s__('Pipelines|More Information') }}
+ </gl-link>
+ </gl-modal>
+
<table-pagination
v-if="shouldRenderPagination"
:change="onChangePage"
diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js
index e0d012cef23..77c85d85e27 100644
--- a/app/assets/javascripts/commons/index.js
+++ b/app/assets/javascripts/commons/index.js
@@ -1,5 +1,4 @@
import './polyfills';
-import './jquery';
import './bootstrap';
import './vue';
import '../lib/utils/axios_utils';
diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js
deleted file mode 100644
index 334f95bb27f..00000000000
--- a/app/assets/javascripts/commons/jquery.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import 'jquery';
-
-// common jQuery plugins
-import 'jquery-ujs';
diff --git a/app/assets/javascripts/confirm_danger_modal.js b/app/assets/javascripts/confirm_danger_modal.js
index 262d501bfba..7321e4d18cc 100644
--- a/app/assets/javascripts/confirm_danger_modal.js
+++ b/app/assets/javascripts/confirm_danger_modal.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { Rails } from '~/lib/utils/rails_ujs';
import { rstrip } from './lib/utils/common_utils';
function openConfirmDangerModal($form, $modal, text) {
@@ -21,9 +22,16 @@ function openConfirmDangerModal($form, $modal, text) {
$submit.disable();
}
});
+
$('.js-confirm-danger-submit', $modal)
.off('click')
- .on('click', () => $form.submit());
+ .on('click', () => {
+ if ($form.data('remote')) {
+ Rails.fire($form[0], 'submit');
+ } else {
+ $form.submit();
+ }
+ });
}
function getModal($btn) {
diff --git a/app/assets/javascripts/confirm_modal.js b/app/assets/javascripts/confirm_modal.js
index 4b4fdf03873..bf2ea3ce38a 100644
--- a/app/assets/javascripts/confirm_modal.js
+++ b/app/assets/javascripts/confirm_modal.js
@@ -1,14 +1,16 @@
import Vue from 'vue';
import ConfirmModal from '~/vue_shared/components/confirm_modal.vue';
-const mountConfirmModal = () => {
- return new Vue({
+const mountConfirmModal = optionalProps =>
+ new Vue({
render(h) {
return h(ConfirmModal, {
- props: { selector: '.js-confirm-modal-button' },
+ props: {
+ selector: '.js-confirm-modal-button',
+ ...optionalProps,
+ },
});
},
}).$mount();
-};
-export default () => mountConfirmModal();
+export default (optionalProps = {}) => mountConfirmModal(optionalProps);
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue
index 3f7c2204b9f..eb195ad2b30 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue
@@ -13,6 +13,10 @@ export default {
type: String,
required: true,
},
+ namespacePerEnvironmentHelpPath: {
+ type: String,
+ required: true,
+ },
kubernetesIntegrationHelpPath: {
type: String,
required: true,
@@ -40,6 +44,7 @@ export default {
<eks-cluster-configuration-form
v-if="hasCredentials"
:gitlab-managed-cluster-help-path="gitlabManagedClusterHelpPath"
+ :namespace-per-environment-help-path="namespacePerEnvironmentHelpPath"
:kubernetes-integration-help-path="kubernetesIntegrationHelpPath"
:external-link-icon="externalLinkIcon"
/>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
index a653e228e3f..d403f370f9d 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
@@ -1,9 +1,7 @@
<script>
-/* eslint-disable vue/no-v-html */
import { createNamespacedHelpers, mapState, mapActions, mapGetters } from 'vuex';
-import { escape } from 'lodash';
-import { GlFormInput, GlFormCheckbox } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
+import { GlFormInput, GlFormCheckbox, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue';
import { KUBERNETES_VERSIONS } from '../constants';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
@@ -28,8 +26,11 @@ const { mapState: mapInstanceTypesState } = createNamespacedHelpers('instanceTyp
export default {
components: {
ClusterFormDropdown,
- GlFormInput,
GlFormCheckbox,
+ GlFormInput,
+ GlIcon,
+ GlLink,
+ GlSprintf,
LoadingButton,
},
props: {
@@ -37,6 +38,10 @@ export default {
type: String,
required: true,
},
+ namespacePerEnvironmentHelpPath: {
+ type: String,
+ required: true,
+ },
kubernetesIntegrationHelpPath: {
type: String,
required: true,
@@ -46,6 +51,49 @@ export default {
required: true,
},
},
+ i18n: {
+ kubernetesIntegrationHelpText: s__(
+ 'ClusterIntegration|Read our %{linkStart}help page%{linkEnd} on Kubernetes cluster integration.',
+ ),
+ roleDropdownHelpText: s__(
+ 'ClusterIntegration|Your service role is distinct from the provision role used when authenticating. It will allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role, first create one on %{linkStart}Amazon Web Services%{linkEnd}.',
+ ),
+ roleDropdownHelpPath:
+ 'https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html#create-service-role',
+ regionsDropdownHelpText: s__(
+ 'ClusterIntegration|Learn more about %{linkStart}Regions%{linkEnd}.',
+ ),
+ regionsDropdownHelpPath:
+ 'https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/',
+ keyPairDropdownHelpText: s__(
+ 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{linkStart}Amazon Web Services%{linkEnd}.',
+ ),
+ keyPairDropdownHelpPath:
+ 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair',
+ vpcDropdownHelpText: s__(
+ 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{linkStart}Amazon Web Services %{linkEnd}.',
+ ),
+ vpcDropdownHelpPath:
+ 'https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#vpc-create',
+ subnetDropdownHelpText: s__(
+ 'ClusterIntegration|Choose the %{linkStart}subnets %{linkEnd} in your VPC where your worker nodes will run.',
+ ),
+ subnetDropdownHelpPath: 'https://console.aws.amazon.com/vpc/home?#subnets',
+ securityGroupDropdownHelpText: s__(
+ 'ClusterIntegration|Choose the %{linkStart}security group %{linkEnd} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.',
+ ),
+ securityGroupDropdownHelpPath: 'https://console.aws.amazon.com/vpc/home?#securityGroups',
+ instanceTypesDropdownHelpText: s__(
+ 'ClusterIntegration|Choose the worker node %{linkStart}instance type%{linkEnd}.',
+ ),
+ instanceTypesDropdownHelpPath: 'https://aws.amazon.com/ec2/instance-types',
+ gitlabManagedClusterHelpText: s__(
+ 'ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster. %{linkStart}More information%{linkEnd}',
+ ),
+ namespacePerEnvironmentHelpText: s__(
+ 'ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared. %{linkStart}More information%{linkEnd}',
+ ),
+ },
computed: {
...mapState([
'clusterName',
@@ -60,6 +108,7 @@ export default {
'selectedInstanceType',
'nodeCount',
'gitlabManagedCluster',
+ 'namespacePerEnvironment',
'isCreatingCluster',
]),
...mapGetters(['subnetValid']),
@@ -137,90 +186,6 @@ export default {
? s__('ClusterIntegration|Creating Kubernetes cluster')
: s__('ClusterIntegration|Create Kubernetes cluster');
},
- kubernetesIntegrationHelpText() {
- const escapedUrl = escape(this.kubernetesIntegrationHelpPath);
-
- return sprintf(
- s__(
- 'ClusterIntegration|Read our %{link_start}help page%{link_end} on Kubernetes cluster integration.',
- ),
- {
- link_start: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`,
- link_end: '</a>',
- },
- false,
- );
- },
- roleDropdownHelpText() {
- return sprintf(
- s__(
- 'ClusterIntegration|Your service role is distinct from the provision role used when authenticating. It will allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.',
- ),
- {
- startLink:
- '<a href="https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html#create-service-role" target="_blank" rel="noopener noreferrer">',
- externalLinkIcon: this.externalLinkIcon,
- endLink: '</a>',
- },
- false,
- );
- },
- regionsDropdownHelpText() {
- return sprintf(
- s__(
- 'ClusterIntegration|Learn more about %{startLink}Regions %{externalLinkIcon}%{endLink}.',
- ),
- {
- startLink:
- '<a href="https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/" target="_blank" rel="noopener noreferrer">',
- externalLinkIcon: this.externalLinkIcon,
- endLink: '</a>',
- },
- false,
- );
- },
- keyPairDropdownHelpText() {
- return sprintf(
- s__(
- 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.',
- ),
- {
- startLink:
- '<a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair" target="_blank" rel="noopener noreferrer">',
- externalLinkIcon: this.externalLinkIcon,
- endLink: '</a>',
- },
- false,
- );
- },
- vpcDropdownHelpText() {
- return sprintf(
- s__(
- 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.',
- ),
- {
- startLink:
- '<a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#vpc-create" target="_blank" rel="noopener noreferrer">',
- externalLinkIcon: this.externalLinkIcon,
- endLink: '</a>',
- },
- false,
- );
- },
- subnetDropdownHelpText() {
- return sprintf(
- s__(
- 'ClusterIntegration|Choose the %{startLink}subnets %{externalLinkIcon} %{endLink} in your VPC where your worker nodes will run.',
- ),
- {
- startLink:
- '<a href="https://console.aws.amazon.com/vpc/home?#subnets" target="_blank" rel="noopener noreferrer">',
- externalLinkIcon: this.externalLinkIcon,
- endLink: '</a>',
- },
- false,
- );
- },
subnetValidationErrorText() {
if (this.loadingSubnetsError) {
return s__('ClusterIntegration|Could not load subnets for the selected VPC');
@@ -228,48 +193,6 @@ export default {
return s__('ClusterIntegration|You should select at least two subnets');
},
- securityGroupDropdownHelpText() {
- return sprintf(
- s__(
- 'ClusterIntegration|Choose the %{startLink}security group %{externalLinkIcon} %{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.',
- ),
- {
- startLink:
- '<a href="https://console.aws.amazon.com/vpc/home?#securityGroups" target="_blank" rel="noopener noreferrer">',
- externalLinkIcon: this.externalLinkIcon,
- endLink: '</a>',
- },
- false,
- );
- },
- instanceTypesDropdownHelpText() {
- return sprintf(
- s__(
- 'ClusterIntegration|Choose the worker node %{startLink}instance type %{externalLinkIcon} %{endLink}.',
- ),
- {
- startLink:
- '<a href="https://aws.amazon.com/ec2/instance-types" target="_blank" rel="noopener noreferrer">',
- externalLinkIcon: this.externalLinkIcon,
- endLink: '</a>',
- },
- false,
- );
- },
- gitlabManagedHelpText() {
- const escapedUrl = escape(this.gitlabManagedClusterHelpPath);
-
- return sprintf(
- s__(
- 'ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster. %{startLink}More information%{endLink}',
- ),
- {
- startLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`,
- endLink: '</a>',
- },
- false,
- );
- },
},
mounted() {
this.fetchRegions();
@@ -290,6 +213,7 @@ export default {
'setInstanceType',
'setNodeCount',
'setGitlabManagedCluster',
+ 'setNamespacePerEnvironment',
]),
...mapRegionsActions({ fetchRegions: 'fetchItems' }),
...mapVpcActions({ fetchVpcs: 'fetchItems' }),
@@ -321,7 +245,15 @@ export default {
<h4>
{{ s__('ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster') }}
</h4>
- <div class="mb-3" v-html="kubernetesIntegrationHelpText"></div>
+ <div class="mb-3">
+ <gl-sprintf :message="$options.i18n.kubernetesIntegrationHelpText">
+ <template #link="{ content }">
+ <gl-link :href="kubernetesIntegrationHelpPath">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
<div class="form-group">
<label class="label-bold" for="eks-cluster-name">{{
s__('ClusterIntegration|Kubernetes cluster name')
@@ -371,7 +303,16 @@ export default {
:error-message="s__('ClusterIntegration|Could not load IAM roles')"
@input="setRole({ role: $event })"
/>
- <p class="form-text text-muted" v-html="roleDropdownHelpText"></p>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.roleDropdownHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.i18n.roleDropdownHelpPath" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
<div class="form-group">
<label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Region') }}</label>
@@ -389,7 +330,16 @@ export default {
:error-message="s__('ClusterIntegration|Could not load regions from your AWS account')"
@input="setRegionAndFetchVpcsAndKeyPairs($event)"
/>
- <p class="form-text text-muted" v-html="regionsDropdownHelpText"></p>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.regionsDropdownHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.i18n.regionsDropdownHelpPath" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
<div class="form-group">
<label class="label-bold" for="eks-key-pair">{{
@@ -411,7 +361,16 @@ export default {
:error-message="s__('ClusterIntegration|Could not load Key Pairs')"
@input="setKeyPair({ keyPair: $event })"
/>
- <p class="form-text text-muted" v-html="keyPairDropdownHelpText"></p>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.keyPairDropdownHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.i18n.keyPairDropdownHelpPath" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
<div class="form-group">
<label class="label-bold" for="eks-vpc">{{ s__('ClusterIntegration|VPC') }}</label>
@@ -431,7 +390,16 @@ export default {
:error-message="s__('ClusterIntegration|Could not load VPCs for the selected region')"
@input="setVpcAndFetchSubnets($event)"
/>
- <p class="form-text text-muted" v-html="vpcDropdownHelpText"></p>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.vpcDropdownHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.i18n.vpcDropdownHelpPath" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
<div class="form-group">
<label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnets') }}</label>
@@ -452,7 +420,16 @@ export default {
:error-message="subnetValidationErrorText"
@input="setSubnet({ subnet: $event })"
/>
- <p class="form-text text-muted" v-html="subnetDropdownHelpText"></p>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.subnetDropdownHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.i18n.subnetDropdownHelpPath" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
<div class="form-group">
<label class="label-bold" for="eks-security-group">{{
@@ -476,7 +453,16 @@ export default {
"
@input="setSecurityGroup({ securityGroup: $event })"
/>
- <p class="form-text text-muted" v-html="securityGroupDropdownHelpText"></p>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.securityGroupDropdownHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.i18n.securityGroupDropdownHelpPath" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
<div class="form-group">
<label class="label-bold" for="eks-instance-type">{{
@@ -496,7 +482,16 @@ export default {
:error-message="s__('ClusterIntegration|Could not load instance types')"
@input="setInstanceType({ instanceType: $event })"
/>
- <p class="form-text text-muted" v-html="instanceTypesDropdownHelpText"></p>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.instanceTypesDropdownHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.i18n.instanceTypesDropdownHelpPath" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
<div class="form-group">
<label class="label-bold" for="eks-node-count">{{
@@ -517,7 +512,31 @@ export default {
@input="setGitlabManagedCluster({ gitlabManagedCluster: $event })"
>{{ s__('ClusterIntegration|GitLab-managed cluster') }}</gl-form-checkbox
>
- <p class="form-text text-muted" v-html="gitlabManagedHelpText"></p>
+ <p class="form text text-muted">
+ <gl-sprintf :message="$options.i18n.gitlabManagedClusterHelpText">
+ <template #link="{ content }">
+ <gl-link :href="gitlabManagedClusterHelpPath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ <div class="form-group">
+ <gl-form-checkbox
+ :checked="namespacePerEnvironment"
+ @input="setNamespacePerEnvironment({ namespacePerEnvironment: $event })"
+ >{{ s__('ClusterIntegration|Namespace per environment') }}</gl-form-checkbox
+ >
+ <p class="form text text-muted">
+ <gl-sprintf :message="$options.i18n.namespacePerEnvironmentHelpText">
+ <template #link="{ content }">
+ <gl-link :href="namespacePerEnvironmentHelpPath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
<div class="form-group">
<loading-button
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/index.js b/app/assets/javascripts/create_cluster/eks_cluster/index.js
index fb993a7aa59..6d1034b4a72 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/index.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/index.js
@@ -9,6 +9,7 @@ Vue.use(Vuex);
export default el => {
const {
gitlabManagedClusterHelpPath,
+ namespacePerEnvironmentHelpPath,
kubernetesIntegrationHelpPath,
accountAndExternalIdsHelpPath,
createRoleArnHelpPath,
@@ -42,6 +43,7 @@ export default el => {
return createElement('create-eks-cluster', {
props: {
gitlabManagedClusterHelpPath,
+ namespacePerEnvironmentHelpPath,
kubernetesIntegrationHelpPath,
accountAndExternalIdsHelpPath,
createRoleArnHelpPath,
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
index 5abff3c7831..48c85ff627f 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
@@ -55,6 +55,7 @@ export const createCluster = ({ dispatch, state }) => {
name: state.clusterName,
environment_scope: state.environmentScope,
managed: state.gitlabManagedCluster,
+ namespace_per_environment: state.namespacePerEnvironment,
provider_aws_attributes: {
kubernetes_version: state.kubernetesVersion,
region: state.selectedRegion,
@@ -114,6 +115,10 @@ export const setGitlabManagedCluster = ({ commit }, payload) => {
commit(types.SET_GITLAB_MANAGED_CLUSTER, payload);
};
+export const setNamespacePerEnvironment = ({ commit }, payload) => {
+ commit(types.SET_NAMESPACE_PER_ENVIRONMENT, payload);
+};
+
export const setInstanceType = ({ commit }, payload) => {
commit(types.SET_INSTANCE_TYPE, payload);
};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
index 9dee6abae5f..4a48195a27b 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
@@ -10,6 +10,7 @@ export const SET_SECURITY_GROUP = 'SET_SECURITY_GROUP';
export const SET_INSTANCE_TYPE = 'SET_INSTANCE_TYPE';
export const SET_NODE_COUNT = 'SET_NODE_COUNT';
export const SET_GITLAB_MANAGED_CLUSTER = 'SET_GITLAB_MANAGED_CLUSTER';
+export const SET_NAMESPACE_PER_ENVIRONMENT = 'SET_NAMESPACE_PER_ENVIRONMENT';
export const REQUEST_CREATE_ROLE = 'REQUEST_CREATE_ROLE';
export const CREATE_ROLE_SUCCESS = 'CREATE_ROLE_SUCCESS';
export const CREATE_ROLE_ERROR = 'CREATE_ROLE_ERROR';
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
index c331d27d255..f57236e0e31 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
@@ -37,6 +37,9 @@ export default {
[types.SET_GITLAB_MANAGED_CLUSTER](state, { gitlabManagedCluster }) {
state.gitlabManagedCluster = gitlabManagedCluster;
},
+ [types.SET_NAMESPACE_PER_ENVIRONMENT](state, { namespacePerEnvironment }) {
+ state.namespacePerEnvironment = namespacePerEnvironment;
+ },
[types.REQUEST_CREATE_ROLE](state) {
state.isCreatingRole = true;
state.createRoleError = null;
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
index ed51e95e434..c957eca1f7a 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
@@ -30,4 +30,5 @@ export default () => ({
createClusterError: false,
gitlabManagedCluster: true,
+ namespacePerEnvironment: true,
});
diff --git a/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue b/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue
index 34e4aeb290f..7c4117d7e8b 100644
--- a/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue
+++ b/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue
@@ -1,11 +1,11 @@
<script>
-import { GlModal, GlModalDirective, GlDeprecatedButton } from '@gitlab/ui';
+import { GlModal, GlModalDirective, GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
components: {
GlModal,
- GlDeprecatedButton,
+ GlButton,
},
directives: {
'gl-modal': GlModalDirective,
@@ -33,9 +33,9 @@ export default {
</script>
<template>
<div class="d-inline-block float-right mr-3">
- <gl-deprecated-button v-gl-modal="$options.modalId" variant="danger">
+ <gl-button v-gl-modal="$options.modalId" variant="danger" category="primary">
{{ __('Delete') }}
- </gl-deprecated-button>
+ </gl-button>
<gl-modal
:title="s__('Metrics|Delete metric?')"
:ok-title="s__('Metrics|Delete metric')"
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 ff0f352b333..b2c9cd4e597 100644
--- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue
+++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue
@@ -1,7 +1,10 @@
<script>
-import { GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
+ components: {
+ GlIcon,
+ },
directives: {
GlTooltip: GlTooltipDirective,
},
@@ -15,15 +18,17 @@ export default {
</script>
<template>
<span v-if="count === 50" class="events-info float-right">
- <i
- v-gl-tooltip
- :title="
- n__('Limited to showing %d event at most', 'Limited to showing %d events at most', 50)
- "
- class="fa fa-warning"
+ <gl-icon
+ v-gl-tooltip="{
+ title: n__(
+ 'Limited to showing %d event at most',
+ 'Limited to showing %d events at most',
+ 50,
+ ),
+ }"
+ name="warning"
aria-hidden="true"
- >
- </i>
+ />
{{ n__('Showing %d event', 'Showing %d events', 50) }}
</span>
</template>
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 ba2be2e5167..6530bfd72a9 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue
@@ -1,6 +1,5 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
import iconBranch from '../svg/icon_branch.svg';
import limitWarning from './limit_warning_component.vue';
@@ -13,6 +12,9 @@ export default {
limitWarning,
GlIcon,
},
+ directives: {
+ SafeHtml,
+ },
props: {
items: {
type: Array,
@@ -47,7 +49,7 @@ export default {
<a :href="build.url" class="pipeline-id"> #{{ build.id }} </a>
<gl-icon :size="16" name="fork" />
<a :href="build.branch.url" class="ref-name"> {{ build.branch.name }} </a>
- <span class="icon-branch" v-html="iconBranch"> </span>
+ <span v-safe-html="iconBranch" class="icon-branch"> </span>
<a :href="build.commitUrl" class="commit-sha"> {{ build.shortSha }} </a>
</h5>
<span>
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index f6bad5dce41..4cccabca28b 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -36,12 +36,6 @@ export default () => {
'stage-review-component': stageReviewComponent,
'stage-staging-component': stageStagingComponent,
'stage-production-component': stageComponent,
- GroupsDropdownFilter: () =>
- import('ee_component/analytics/shared/components/groups_dropdown_filter.vue'),
- ProjectsDropdownFilter: () =>
- import('ee_component/analytics/shared/components/projects_dropdown_filter.vue'),
- DateRangeDropdown: () =>
- import('ee_component/analytics/shared/components/date_range_dropdown.vue'),
'stage-nav-item': stageNavItem,
},
data() {
diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
index 159f5ddd755..0d6657973c3 100644
--- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
+++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
@@ -69,15 +69,13 @@ export default {
</p>
</template>
</gl-table>
- <div class="gl-display-flex gl-justify-content-center">
- <gl-button
- v-gl-modal.deploy-freeze-modal
- data-testid="add-deploy-freeze"
- category="primary"
- variant="success"
- >
- {{ $options.translations.addDeployFreeze }}
- </gl-button>
- </div>
+ <gl-button
+ v-gl-modal.deploy-freeze-modal
+ data-testid="add-deploy-freeze"
+ category="primary"
+ variant="success"
+ >
+ {{ $options.translations.addDeployFreeze }}
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/design_management/components/delete_button.vue b/app/assets/javascripts/design_management/components/delete_button.vue
index 970197ef41b..273fa3f6be2 100644
--- a/app/assets/javascripts/design_management/components/delete_button.vue
+++ b/app/assets/javascripts/design_management/components/delete_button.vue
@@ -63,7 +63,7 @@ export default {
title: s__('DesignManagement|Are you sure you want to archive the selected designs?'),
actionPrimary: {
text: s__('DesignManagement|Archive designs'),
- attributes: { variant: 'warning' },
+ attributes: { variant: 'warning', 'data-qa-selector': 'confirm_archiving_button' },
},
actionCancel: {
text: __('Cancel'),
diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue
index df425e3b96d..fecedceef32 100644
--- a/app/assets/javascripts/design_management/components/design_sidebar.vue
+++ b/app/assets/javascripts/design_management/components/design_sidebar.vue
@@ -126,7 +126,7 @@ export default {
v-if="showTodoButton"
class="gl-py-4 gl-mb-4 gl-display-flex gl-justify-content-space-between gl-align-items-center gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
>
- <span>{{ __('To-Do') }}</span>
+ <span>{{ __('To Do') }}</span>
<design-todo-button :design="design" @error="$emit('todoError', $event)" />
</div>
<h2 class="gl-font-weight-bold gl-mt-0">
diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue
index 36ea812d92e..b179b1b5e79 100644
--- a/app/assets/javascripts/design_management/components/list/item.vue
+++ b/app/assets/javascripts/design_management/components/list/item.vue
@@ -149,6 +149,7 @@ export default {
:alt="filename"
class="gl-display-block gl-mx-auto gl-max-w-full mh-100 design-img"
data-qa-selector="design_image"
+ :data-qa-filename="filename"
@load="onImageLoad"
@error="onImageError"
/>
diff --git a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
index afca8ed2c6f..2719d701c12 100644
--- a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
@@ -64,9 +64,9 @@ export default {
</script>
<template>
- <div v-if="designsCount" class="d-flex align-items-center">
+ <div v-if="designsCount" class="gl-display-flex gl-align-items-center">
{{ paginationText }}
- <gl-button-group class="ml-3 mr-3">
+ <gl-button-group class="gl-mx-5">
<gl-button
:disabled="!previousDesign"
:title="s__('DesignManagement|Go to previous design')"
diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue
index a1cb57123ab..8d25d467d59 100644
--- a/app/assets/javascripts/design_management/components/toolbar/index.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/index.vue
@@ -106,12 +106,12 @@ export default {
>
<gl-icon name="close" />
</router-link>
- <div class="overflow-hidden d-flex align-items-center">
- <h2 class="m-0 str-truncated-100 gl-font-base">{{ filename }}</h2>
- <small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small>
+ <div class="gl-overflow-hidden gl-display-flex gl-align-items-center">
+ <h2 class="gl-m-0 str-truncated-100 gl-font-base">{{ filename }}</h2>
+ <small v-if="updatedAt" class="gl-text-gray-500">{{ updatedText }}</small>
</div>
</div>
- <design-navigation :id="id" class="ml-auto flex-shrink-0" />
+ <design-navigation :id="id" class="gl-ml-auto gl-flex-shrink-0" />
<gl-button :href="image" icon="download" />
<delete-button
v-if="isLatestVersion && canDeleteDesign"
diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql
index 96efa8e8242..efa61edf51a 100644
--- a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql
+++ b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql
@@ -6,6 +6,7 @@ query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) {
id
issue(iid: $iid) {
designCollection {
+ copyState
designs(atVersion: $atVersion) {
nodes {
...DesignListItem
diff --git a/app/assets/javascripts/design_management/mixins/all_designs.js b/app/assets/javascripts/design_management/mixins/all_designs.js
index 0c2858bb14b..62bcf216add 100644
--- a/app/assets/javascripts/design_management/mixins/all_designs.js
+++ b/app/assets/javascripts/design_management/mixins/all_designs.js
@@ -8,7 +8,7 @@ import { DESIGNS_ROUTE_NAME } from '../router/constants';
export default {
mixins: [allVersionsMixin],
apollo: {
- designs: {
+ designCollection: {
query: getDesignListQuery,
variables() {
return {
@@ -25,10 +25,11 @@ export default {
'designs',
'nodes',
]);
- if (designNodes) {
- return designNodes;
- }
- return [];
+ const copyState = propertyOf(data)(['project', 'issue', 'designCollection', 'copyState']);
+ return {
+ designs: designNodes,
+ copyState,
+ };
},
error() {
this.error = true;
@@ -42,13 +43,26 @@ export default {
);
this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } });
}
+ if (this.designCollection.copyState === 'ERROR') {
+ createFlash(
+ s__(
+ 'DesignManagement|There was an error moving your designs. Please upload your designs below.',
+ ),
+ 'warning',
+ );
+ }
},
},
},
data() {
return {
- designs: [],
+ designCollection: null,
error: false,
};
},
+ computed: {
+ designs() {
+ return this.designCollection?.designs || [];
+ },
+ },
};
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index 6c4c8c75054..fb6a91abcdc 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -71,11 +71,14 @@ export default {
selectedDesigns: [],
isDraggingDesign: false,
reorderedDesigns: null,
+ isReorderingInProgress: false,
};
},
computed: {
isLoading() {
- return this.$apollo.queries.designs.loading || this.$apollo.queries.permissions.loading;
+ return (
+ this.$apollo.queries.designCollection.loading || this.$apollo.queries.permissions.loading
+ );
},
isSaving() {
return this.filesToBeSaved.length > 0;
@@ -109,6 +112,9 @@ export default {
isDesignListEmpty() {
return !this.isSaving && !this.hasDesigns;
},
+ isDesignCollectionCopying() {
+ return this.designCollection && this.designCollection.copyState === 'IN_PROGRESS';
+ },
designDropzoneWrapperClass() {
return this.isDesignListEmpty
? 'col-12'
@@ -277,6 +283,7 @@ export default {
return variables;
},
reorderDesigns({ moved: { newIndex, element } }) {
+ this.isReorderingInProgress = true;
this.$apollo
.mutate({
mutation: moveDesignMutation,
@@ -287,6 +294,9 @@ export default {
})
.catch(() => {
createFlash(MOVE_DESIGN_ERROR);
+ })
+ .finally(() => {
+ this.isReorderingInProgress = false;
});
},
onDesignMove(designs) {
@@ -339,6 +349,7 @@ export default {
button-category="secondary"
button-class="gl-mr-3"
button-size="small"
+ data-qa-selector="archive_button"
:loading="loading"
:has-selected-designs="hasSelectedDesigns"
@deleteSelectedDesigns="mutate()"
@@ -355,10 +366,25 @@ export default {
<gl-alert v-else-if="error" variant="danger" :dismissible="false">
{{ __('An error occurred while loading designs. Please try again.') }}
</gl-alert>
+ <header
+ v-else-if="isDesignCollectionCopying"
+ class="card"
+ data-testid="design-collection-is-copying"
+ >
+ <div class="card-header design-card-header border-bottom-0">
+ <div class="card-title gl-display-flex gl-align-items-center gl-my-0 gl-h-7">
+ {{
+ s__(
+ 'DesignManagement|Your designs are being copied and are on their way… Please refresh to update.',
+ )
+ }}
+ </div>
+ </div>
+ </header>
<vue-draggable
v-else
:value="designs"
- :disabled="!isLatestVersion"
+ :disabled="!isLatestVersion || isReorderingInProgress"
v-bind="$options.dragOptions"
tag="ol"
draggable=".js-design-tile"
@@ -390,6 +416,8 @@ export default {
:checked="isDesignSelected(design.filename)"
type="checkbox"
class="design-checkbox"
+ data-qa-selector="design_checkbox"
+ :data-qa-design="design.filename"
@change="changeSelectedDesigns(design.filename)"
/>
</li>
@@ -399,6 +427,7 @@ export default {
:is-dragging-design="isDraggingDesign"
:class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }"
:has-designs="hasDesigns"
+ data-qa-selector="design_dropzone_content"
@change="onUploadDesign"
/>
</li>
diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js
index ff41136fd54..6c64f05c973 100644
--- a/app/assets/javascripts/design_management/utils/cache_update.js
+++ b/app/assets/javascripts/design_management/utils/cache_update.js
@@ -155,6 +155,7 @@ const addNewDesignToStore = (store, designManagementUpload, query) => {
const updatedDesigns = {
__typename: 'DesignCollection',
+ copyState: 'READY',
designs: {
__typename: 'DesignConnection',
nodes: newDesigns,
diff --git a/app/assets/javascripts/design_management/utils/design_management_utils.js b/app/assets/javascripts/design_management/utils/design_management_utils.js
index 93e4d6060c3..687e793d3df 100644
--- a/app/assets/javascripts/design_management/utils/design_management_utils.js
+++ b/app/assets/javascripts/design_management/utils/design_management_utils.js
@@ -65,6 +65,10 @@ export const designUploadOptimisticResponse = files => {
fullPath: '',
notesCount: 0,
event: 'NONE',
+ currentUserTodos: {
+ __typename: 'TodoConnection',
+ nodes: [],
+ },
diffRefs: {
__typename: 'DiffRefs',
baseSha: '',
diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
deleted file mode 100644
index dd60e2c7684..00000000000
--- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
+++ /dev/null
@@ -1,65 +0,0 @@
-/* global CommentsStore */
-
-import $ from 'jquery';
-import Vue from 'vue';
-import { __ } from '~/locale';
-
-const CommentAndResolveBtn = Vue.extend({
- props: {
- discussionId: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- textareaIsEmpty: true,
- discussion: {},
- };
- },
- computed: {
- showButton() {
- if (this.discussion) {
- return this.discussion.isResolvable();
- }
- return false;
- },
- isDiscussionResolved() {
- return this.discussion.isResolved();
- },
- buttonText() {
- if (this.textareaIsEmpty) {
- return this.isDiscussionResolved ? __('Unresolve thread') : __('Resolve thread');
- }
- return this.isDiscussionResolved
- ? __('Comment & unresolve thread')
- : __('Comment & resolve thread');
- },
- },
- created() {
- if (this.discussionId) {
- this.discussion = CommentsStore.state[this.discussionId];
- }
- },
- mounted() {
- if (!this.discussionId) return;
-
- const $textarea = $(
- `.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`,
- );
- this.textareaIsEmpty = $textarea.val() === '';
-
- $textarea.on('input.comment-and-resolve-btn', () => {
- this.textareaIsEmpty = $textarea.val() === '';
- });
- },
- destroyed() {
- if (!this.discussionId) return;
-
- $(`.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`).off(
- 'input.comment-and-resolve-btn',
- );
- },
-});
-
-Vue.component('comment-and-resolve-btn', CommentAndResolveBtn);
diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
deleted file mode 100644
index b5a781cbc92..00000000000
--- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
+++ /dev/null
@@ -1,189 +0,0 @@
-/* global CommentsStore */
-
-import $ from 'jquery';
-import Vue from 'vue';
-import collapseIcon from '../icons/collapse_icon.svg';
-import Notes from '../../notes';
-import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
-import { n__ } from '~/locale';
-
-const DiffNoteAvatars = Vue.extend({
- components: {
- userAvatarImage,
- },
- props: {
- discussionId: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- isVisible: false,
- lineType: '',
- storeState: CommentsStore.state,
- shownAvatars: 3,
- collapseIcon,
- };
- },
- computed: {
- discussionClassName() {
- return `js-diff-avatars-${this.discussionId}`;
- },
- notesSubset() {
- let notes = [];
-
- if (this.discussion) {
- notes = Object.keys(this.discussion.notes)
- .slice(0, this.shownAvatars)
- .map(noteId => this.discussion.notes[noteId]);
- }
-
- return notes;
- },
- extraNotesTitle() {
- if (this.discussion) {
- const extra = this.discussion.notesCount() - this.shownAvatars;
-
- return n__('%d more comment', '%d more comments', extra);
- }
-
- return '';
- },
- discussion() {
- return this.storeState[this.discussionId];
- },
- notesCount() {
- if (this.discussion) {
- return this.discussion.notesCount();
- }
-
- return 0;
- },
- moreText() {
- const plusSign = this.notesCount < 100 ? '+' : '';
-
- return `${plusSign}${this.notesCount - this.shownAvatars}`;
- },
- },
- watch: {
- storeState: {
- handler() {
- this.$nextTick(() => {
- $('.has-tooltip', this.$el).tooltip('_fixTitle');
-
- // We need to add/remove a class to an element that is outside the Vue instance
- this.addNoCommentClass();
- });
- },
- deep: true,
- },
- },
- mounted() {
- this.$nextTick(() => {
- this.addNoCommentClass();
- this.setDiscussionVisible();
-
- this.lineType = $(this.$el)
- .closest('.diff-line-num')
- .hasClass('old_line')
- ? 'old'
- : 'new';
- });
-
- $(document).on('toggle.comments', () => {
- this.$nextTick(() => {
- this.setDiscussionVisible();
- });
- });
- },
- beforeDestroy() {
- this.addNoCommentClass();
- $(document).off('toggle.comments');
- },
- methods: {
- clickedAvatar(e) {
- Notes.instance.onAddDiffNote(e);
-
- // Toggle the active state of the toggle all button
- this.toggleDiscussionsToggleState();
-
- this.$nextTick(() => {
- this.setDiscussionVisible();
-
- $('.has-tooltip', this.$el).tooltip('_fixTitle');
- $('.has-tooltip', this.$el).tooltip('hide');
- });
- },
- addNoCommentClass() {
- const { notesCount } = this;
-
- $(this.$el)
- .closest('.js-avatar-container')
- .toggleClass('no-comment-btn', notesCount > 0)
- .nextUntil('.js-avatar-container')
- .toggleClass('no-comment-btn', notesCount > 0);
- },
- toggleDiscussionsToggleState() {
- const $notesHolders = $(this.$el)
- .closest('.code')
- .find('.notes_holder');
- const $visibleNotesHolders = $notesHolders.filter(':visible');
- const $toggleDiffCommentsBtn = $(this.$el)
- .closest('.diff-file')
- .find('.js-toggle-diff-comments');
-
- $toggleDiffCommentsBtn.toggleClass(
- 'active',
- $notesHolders.length === $visibleNotesHolders.length,
- );
- },
- setDiscussionVisible() {
- this.isVisible = $(`.diffs .notes[data-discussion-id="${this.discussion.id}"]`).is(
- ':visible',
- );
- },
- getTooltipText(note) {
- return `${note.authorName}: ${note.noteTruncated}`;
- },
- },
- template: `
- <div class="diff-comment-avatar-holders"
- :class="discussionClassName"
- v-show="notesCount !== 0">
- <div v-if="!isVisible">
- <!-- FIXME: Pass an alt attribute here for accessibility -->
- <user-avatar-image
- v-for="note in notesSubset"
- :key="note.id"
- class="diff-comment-avatar js-diff-comment-avatar"
- @click.native="clickedAvatar($event)"
- :img-src="note.authorAvatar"
- :tooltip-text="getTooltipText(note)"
- :data-line-type="lineType"
- :size="19"
- data-html="true"
- />
- <span v-if="notesCount > shownAvatars"
- class="diff-comments-more-count has-tooltip js-diff-comment-avatar"
- data-container="body"
- data-placement="top"
- ref="extraComments"
- role="button"
- :data-line-type="lineType"
- :title="extraNotesTitle"
- @click="clickedAvatar($event)">{{ moreText }}</span>
- </div>
- <button class="diff-notes-collapse js-diff-comment-avatar"
- type="button"
- aria-label="Show comments"
- :data-line-type="lineType"
- @click="clickedAvatar($event)"
- v-if="isVisible"
- v-html="collapseIcon">
- </button>
- </div>
- `,
-});
-
-Vue.component('diff-note-avatars', DiffNoteAvatars);
diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
deleted file mode 100644
index 1de00c9f08b..00000000000
--- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
+++ /dev/null
@@ -1,210 +0,0 @@
-/* eslint-disable func-names, no-continue */
-/* global CommentsStore */
-
-import $ from 'jquery';
-import 'vendor/jquery.scrollTo';
-import Vue from 'vue';
-import { __ } from '~/locale';
-
-import DiscussionMixins from '../mixins/discussion';
-
-const JumpToDiscussion = Vue.extend({
- mixins: [DiscussionMixins],
- props: {
- discussionId: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- discussions: CommentsStore.state,
- discussion: {},
- };
- },
- computed: {
- buttonText() {
- if (this.discussionId) {
- return __('Jump to next unresolved thread');
- }
- return __('Jump to first unresolved thread');
- },
- allResolved() {
- return this.unresolvedDiscussionCount === 0;
- },
- showButton() {
- if (this.discussionId) {
- if (this.unresolvedDiscussionCount > 1) {
- return true;
- }
- return this.discussionId !== this.lastResolvedId;
- }
- return this.unresolvedDiscussionCount >= 1;
- },
- lastResolvedId() {
- let lastId;
- Object.keys(this.discussions).forEach(discussionId => {
- const discussion = this.discussions[discussionId];
-
- if (!discussion.isResolved()) {
- lastId = discussion.id;
- }
- });
- return lastId;
- },
- },
- created() {
- this.discussion = this.discussions[this.discussionId];
- },
- methods: {
- jumpToNextUnresolvedDiscussion() {
- let discussionsSelector;
- let discussionIdsInScope;
- let firstUnresolvedDiscussionId;
- let nextUnresolvedDiscussionId;
- let activeTab = window.mrTabs.currentAction;
- let hasDiscussionsToJumpTo = true;
- let jumpToFirstDiscussion = !this.discussionId;
-
- const discussionIdsForElements = function(elements) {
- return elements
- .map(function() {
- return $(this).attr('data-discussion-id');
- })
- .toArray();
- };
-
- const { discussions } = this;
-
- if (activeTab === 'diffs') {
- discussionsSelector = '.diffs .notes[data-discussion-id]';
- discussionIdsInScope = discussionIdsForElements($(discussionsSelector));
-
- let unresolvedDiscussionCount = 0;
-
- for (let i = 0; i < discussionIdsInScope.length; i += 1) {
- const discussionId = discussionIdsInScope[i];
- const discussion = discussions[discussionId];
- if (discussion && !discussion.isResolved()) {
- unresolvedDiscussionCount += 1;
- }
- }
-
- if (this.discussionId && !this.discussion.isResolved()) {
- // If this is the last unresolved discussion on the diffs tab,
- // there are no discussions to jump to.
- if (unresolvedDiscussionCount === 1) {
- hasDiscussionsToJumpTo = false;
- }
- } else if (unresolvedDiscussionCount === 0) {
- // If there are no unresolved discussions on the diffs tab at all,
- // there are no discussions to jump to.
- hasDiscussionsToJumpTo = false;
- }
- } else if (activeTab !== 'show') {
- // If we are on the commits or builds tabs,
- // there are no discussions to jump to.
- hasDiscussionsToJumpTo = false;
- }
-
- if (!hasDiscussionsToJumpTo) {
- // If there are no discussions to jump to on the current page,
- // switch to the notes tab and jump to the first discussion there.
- window.mrTabs.activateTab('show');
- activeTab = 'show';
- jumpToFirstDiscussion = true;
- }
-
- if (activeTab === 'show') {
- discussionsSelector = '.discussion[data-discussion-id]';
- discussionIdsInScope = discussionIdsForElements($(discussionsSelector));
- }
-
- let currentDiscussionFound = false;
- for (let i = 0; i < discussionIdsInScope.length; i += 1) {
- const discussionId = discussionIdsInScope[i];
- const discussion = discussions[discussionId];
-
- if (!discussion) {
- // Discussions for comments on commits in this MR don't have a resolved status.
- continue;
- }
-
- if (!firstUnresolvedDiscussionId && !discussion.isResolved()) {
- firstUnresolvedDiscussionId = discussionId;
-
- if (jumpToFirstDiscussion) {
- break;
- }
- }
-
- if (!jumpToFirstDiscussion) {
- if (currentDiscussionFound) {
- if (!discussion.isResolved()) {
- nextUnresolvedDiscussionId = discussionId;
- break;
- } else {
- continue;
- }
- }
-
- if (discussionId === this.discussionId) {
- currentDiscussionFound = true;
- }
- }
- }
-
- nextUnresolvedDiscussionId = nextUnresolvedDiscussionId || firstUnresolvedDiscussionId;
-
- if (!nextUnresolvedDiscussionId) {
- return;
- }
-
- let $target = $(`${discussionsSelector}[data-discussion-id="${nextUnresolvedDiscussionId}"]`);
-
- if (activeTab === 'show') {
- $target = $target.closest('.note-discussion');
-
- // If the next discussion is closed, toggle it open.
- if ($target.find('.js-toggle-content').is(':hidden')) {
- $target.find('.js-toggle-button i').trigger('click');
- }
- } else if (activeTab === 'diffs') {
- // Resolved discussions are hidden in the diffs tab by default.
- // If they are marked unresolved on the notes tab, they will still be hidden on the diffs tab.
- // When jumping between unresolved discussions on the diffs tab, we show them.
- $target.closest('.content').show();
-
- const $notesHolder = $target.closest('tr.notes_holder');
-
- // Image diff discussions does not use notes_holder
- // so we should keep original $target value in those cases
- if ($notesHolder.length > 0) {
- $target = $notesHolder;
- }
-
- $target.show();
-
- // If we are on the diffs tab, we don't scroll to the discussion itself, but to
- // 4 diff lines above it: the line the discussion was in response to + 3 context
- let prevEl;
- for (let i = 0; i < 4; i += 1) {
- prevEl = $target.prev();
-
- // If the discussion doesn't have 4 lines above it, we'll have to do with fewer.
- if (!prevEl.hasClass('line_holder')) {
- break;
- }
-
- $target = prevEl;
- }
- }
-
- $.scrollTo($target, {
- offset: -150,
- });
- },
- },
-});
-
-Vue.component('jump-to-discussion', JumpToDiscussion);
diff --git a/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js b/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js
deleted file mode 100644
index e0c09aa0eee..00000000000
--- a/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/* global CommentsStore */
-
-import Vue from 'vue';
-
-const NewIssueForDiscussion = Vue.extend({
- props: {
- discussionId: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- discussions: CommentsStore.state,
- };
- },
- computed: {
- discussion() {
- return this.discussions[this.discussionId];
- },
- showButton() {
- if (this.discussion) return !this.discussion.isResolved();
- return false;
- },
- },
-});
-
-Vue.component('new-issue-for-discussion-btn', NewIssueForDiscussion);
diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js
deleted file mode 100644
index 0943712d0c5..00000000000
--- a/app/assets/javascripts/diff_notes/components/resolve_btn.js
+++ /dev/null
@@ -1,145 +0,0 @@
-/* global CommentsStore */
-/* global ResolveService */
-
-import $ from 'jquery';
-import Vue from 'vue';
-import { deprecatedCreateFlash as Flash } from '../../flash';
-import { sprintf, __ } from '~/locale';
-
-const ResolveBtn = Vue.extend({
- props: {
- noteId: {
- type: Number,
- required: true,
- },
- discussionId: {
- type: String,
- required: true,
- },
- resolved: {
- type: Boolean,
- required: true,
- },
- canResolve: {
- type: Boolean,
- required: true,
- },
- resolvedBy: {
- type: String,
- required: true,
- },
- authorName: {
- type: String,
- required: true,
- },
- authorAvatar: {
- type: String,
- required: true,
- },
- noteTruncated: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- discussions: CommentsStore.state,
- loading: false,
- };
- },
- computed: {
- discussion() {
- return this.discussions[this.discussionId];
- },
- note() {
- return this.discussion ? this.discussion.getNote(this.noteId) : {};
- },
- buttonText() {
- if (this.isResolved) {
- return sprintf(__('Resolved by %{resolvedByName}'), {
- resolvedByName: this.resolvedByName,
- });
- } else if (this.canResolve) {
- return __('Mark as resolved');
- }
-
- return __('Unable to resolve');
- },
- isResolved() {
- if (this.note) {
- return this.note.resolved;
- }
-
- return false;
- },
- resolvedByName() {
- return this.note.resolved_by;
- },
- },
- watch: {
- discussions: {
- handler: 'updateTooltip',
- deep: true,
- },
- },
- mounted() {
- $(this.$refs.button).tooltip({
- container: 'body',
- });
- },
- beforeDestroy() {
- CommentsStore.delete(this.discussionId, this.noteId);
- },
- created() {
- CommentsStore.create({
- discussionId: this.discussionId,
- noteId: this.noteId,
- canResolve: this.canResolve,
- resolved: this.resolved,
- resolvedBy: this.resolvedBy,
- authorName: this.authorName,
- authorAvatar: this.authorAvatar,
- noteTruncated: this.noteTruncated,
- });
- },
- methods: {
- updateTooltip() {
- this.$nextTick(() => {
- $(this.$refs.button)
- .tooltip('hide')
- .tooltip('_fixTitle');
- });
- },
- resolve() {
- if (!this.canResolve) return;
-
- let promise;
- this.loading = true;
-
- if (this.isResolved) {
- promise = ResolveService.unresolve(this.noteId);
- } else {
- promise = ResolveService.resolve(this.noteId);
- }
-
- promise
- .then(resp => resp.json())
- .then(data => {
- this.loading = false;
-
- const resolvedBy = data ? data.resolved_by : null;
-
- CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolvedBy);
- this.discussion.updateHeadline(data);
- gl.mrWidget.checkStatus();
- this.updateTooltip();
- })
- .catch(
- () =>
- new Flash(__('An error occurred when trying to resolve a comment. Please try again.')),
- );
- },
- },
-});
-
-Vue.component('resolve-btn', ResolveBtn);
diff --git a/app/assets/javascripts/diff_notes/components/resolve_count.js b/app/assets/javascripts/diff_notes/components/resolve_count.js
deleted file mode 100644
index f960853b25b..00000000000
--- a/app/assets/javascripts/diff_notes/components/resolve_count.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/* global CommentsStore */
-
-import Vue from 'vue';
-
-import DiscussionMixins from '../mixins/discussion';
-
-window.ResolveCount = Vue.extend({
- mixins: [DiscussionMixins],
- props: {
- loggedOut: {
- type: Boolean,
- required: true,
- },
- },
- data() {
- return {
- discussions: CommentsStore.state,
- };
- },
- computed: {
- allResolved() {
- return this.resolvedDiscussionCount === this.discussionCount;
- },
- resolvedCountText() {
- return this.discussionCount === 1 ? 'discussion' : 'discussions';
- },
- },
-});
diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
deleted file mode 100644
index 92862d4c933..00000000000
--- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js
+++ /dev/null
@@ -1,72 +0,0 @@
-/* eslint-disable func-names, new-cap */
-
-import $ from 'jquery';
-import Vue from 'vue';
-import './models/discussion';
-import './models/note';
-import './stores/comments';
-import './services/resolve';
-import './mixins/discussion';
-import './components/comment_resolve_btn';
-import './components/jump_to_discussion';
-import './components/resolve_btn';
-import './components/resolve_count';
-import './components/diff_note_avatars';
-import './components/new_issue_for_discussion';
-
-export default () => {
- const projectPathHolder =
- document.querySelector('.merge-request') || document.querySelector('.commit-box');
- const { projectPath } = projectPathHolder.dataset;
- const COMPONENT_SELECTOR =
- 'resolve-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn';
-
- window.gl = window.gl || {};
- window.gl.diffNoteApps = {};
-
- window.ResolveService = new gl.DiffNotesResolveServiceClass(projectPath);
-
- gl.diffNotesCompileComponents = () => {
- $('diff-note-avatars').each(function() {
- const tmp = Vue.extend({
- template: $(this).get(0).outerHTML,
- });
- const tmpApp = new tmp().$mount();
-
- $(this).replaceWith(tmpApp.$el);
- $(tmpApp.$el).one('remove.vue', () => {
- tmpApp.$destroy();
- tmpApp.$el.remove();
- });
- });
-
- const $components = $(COMPONENT_SELECTOR).filter(function() {
- return $(this).closest('resolve-count').length !== 1;
- });
-
- if ($components) {
- $components.each(function() {
- const $this = $(this);
- const noteId = $this.attr(':note-id');
- const discussionId = $this.attr(':discussion-id');
-
- if ($this.is('comment-and-resolve-btn') && !discussionId) return;
-
- const tmp = Vue.extend({
- template: $this.get(0).outerHTML,
- });
- const tmpApp = new tmp().$mount();
-
- if (noteId) {
- gl.diffNoteApps[`note_${noteId}`] = tmpApp;
- }
-
- $this.replaceWith(tmpApp.$el);
- });
- }
- };
-
- gl.diffNotesCompileComponents();
-
- $(window).trigger('resize.nav');
-};
diff --git a/app/assets/javascripts/diff_notes/icons/collapse_icon.svg b/app/assets/javascripts/diff_notes/icons/collapse_icon.svg
deleted file mode 100644
index bd4b393cfaa..00000000000
--- a/app/assets/javascripts/diff_notes/icons/collapse_icon.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="11" height="11" viewBox="0 0 9 13"><path d="M2.57568253,6.49866948 C2.50548852,6.57199715 2.44637866,6.59708255 2.39835118,6.57392645 C2.3503237,6.55077034 2.32631032,6.48902165 2.32631032,6.38867852 L2.32631032,-2.13272614 C2.32631032,-2.23306927 2.3503237,-2.29481796 2.39835118,-2.31797406 C2.44637866,-2.34113017 2.50548852,-2.31604477 2.57568253,-2.24271709 L6.51022184,1.86747129 C6.53977721,1.8983461 6.56379059,1.93500939 6.5822627,1.97746225 L6.5822627,2.27849013 C6.56379059,2.31708364 6.53977721,2.35374693 6.51022184,2.38848109 L2.57568253,6.49866948 Z" transform="translate(4.454287, 2.127976) rotate(90.000000) translate(-4.454287, -2.127976) "></path><path d="M3.74312342,2.09553332 C3.74312342,1.99519019 3.77821989,1.9083561 3.8484139,1.83502843 C3.91860791,1.76170075 4.00173115,1.72503747 4.09778611,1.72503747 L4.80711151,1.72503747 C4.90316647,1.72503747 4.98628971,1.76170075 5.05648372,1.83502843 C5.12667773,1.9083561 5.16177421,1.99519019 5.16177421,2.09553332 L5.16177421,10.2464421 C5.16177421,10.3467853 5.12667773,10.4336194 5.05648372,10.506947 C4.98628971,10.5802747 4.90316647,10.616938 4.80711151,10.616938 L4.09778611,10.616938 C4.00173115,10.616938 3.91860791,10.5802747 3.8484139,10.506947 C3.77821989,10.4336194 3.74312342,10.3467853 3.74312342,10.2464421 L3.74312342,2.09553332 Z" transform="translate(4.452449, 6.170988) rotate(-90.000000) translate(-4.452449, -6.170988) "></path><path d="M2.57568253,14.6236695 C2.50548852,14.6969971 2.44637866,14.7220826 2.39835118,14.6989264 C2.3503237,14.6757703 2.32631032,14.6140216 2.32631032,14.5136785 L2.32631032,5.99227386 C2.32631032,5.89193073 2.3503237,5.83018204 2.39835118,5.80702594 C2.44637866,5.78386983 2.50548852,5.80895523 2.57568253,5.88228291 L6.51022184,9.99247129 C6.53977721,10.0233461 6.56379059,10.0600094 6.5822627,10.1024622 L6.5822627,10.4034901 C6.56379059,10.4420836 6.53977721,10.4787469 6.51022184,10.5134811 L2.57568253,14.6236695 Z" transform="translate(4.454287, 10.252976) scale(1, -1) rotate(90.000000) translate(-4.454287, -10.252976) "></path></svg>
diff --git a/app/assets/javascripts/diff_notes/mixins/discussion.js b/app/assets/javascripts/diff_notes/mixins/discussion.js
deleted file mode 100644
index ef3001393cf..00000000000
--- a/app/assets/javascripts/diff_notes/mixins/discussion.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/* eslint-disable guard-for-in, no-restricted-syntax, */
-
-const DiscussionMixins = {
- computed: {
- discussionCount() {
- return Object.keys(this.discussions).length;
- },
- resolvedDiscussionCount() {
- let resolvedCount = 0;
-
- for (const discussionId in this.discussions) {
- const discussion = this.discussions[discussionId];
-
- if (discussion.isResolved()) {
- resolvedCount += 1;
- }
- }
-
- return resolvedCount;
- },
- unresolvedDiscussionCount() {
- let unresolvedCount = 0;
-
- for (const discussionId in this.discussions) {
- const discussion = this.discussions[discussionId];
-
- if (!discussion.isResolved()) {
- unresolvedCount += 1;
- }
- }
-
- return unresolvedCount;
- },
- },
-};
-
-export default DiscussionMixins;
diff --git a/app/assets/javascripts/diff_notes/models/discussion.js b/app/assets/javascripts/diff_notes/models/discussion.js
deleted file mode 100644
index 97296a40d6e..00000000000
--- a/app/assets/javascripts/diff_notes/models/discussion.js
+++ /dev/null
@@ -1,99 +0,0 @@
-/* eslint-disable guard-for-in, no-restricted-syntax */
-/* global NoteModel */
-
-import $ from 'jquery';
-import Vue from 'vue';
-import { localTimeAgo } from '../../lib/utils/datetime_utility';
-
-class DiscussionModel {
- constructor(discussionId) {
- this.id = discussionId;
- this.notes = {};
- this.loading = false;
- this.canResolve = false;
- }
-
- createNote(noteObj) {
- Vue.set(this.notes, noteObj.noteId, new NoteModel(this.id, noteObj));
- }
-
- deleteNote(noteId) {
- Vue.delete(this.notes, noteId);
- }
-
- getNote(noteId) {
- return this.notes[noteId];
- }
-
- notesCount() {
- return Object.keys(this.notes).length;
- }
-
- isResolved() {
- for (const noteId in this.notes) {
- const note = this.notes[noteId];
-
- if (!note.resolved) {
- return false;
- }
- }
- return true;
- }
-
- resolveAllNotes(resolvedBy) {
- for (const noteId in this.notes) {
- const note = this.notes[noteId];
-
- if (!note.resolved) {
- note.resolved = true;
- note.resolved_by = resolvedBy;
- }
- }
- }
-
- unResolveAllNotes() {
- for (const noteId in this.notes) {
- const note = this.notes[noteId];
-
- if (note.resolved) {
- note.resolved = false;
- note.resolved_by = null;
- }
- }
- }
-
- updateHeadline(data) {
- const discussionSelector = `.discussion[data-discussion-id="${this.id}"]`;
- const $discussionHeadline = $(`${discussionSelector} .js-discussion-headline`);
-
- if (data.discussion_headline_html) {
- if ($discussionHeadline.length) {
- $discussionHeadline.replaceWith(data.discussion_headline_html);
- } else {
- $(`${discussionSelector} .discussion-header`).append(data.discussion_headline_html);
- }
-
- localTimeAgo($('.js-timeago', `${discussionSelector}`));
- } else {
- $discussionHeadline.remove();
- }
- }
-
- isResolvable() {
- if (!this.canResolve) {
- return false;
- }
-
- for (const noteId in this.notes) {
- const note = this.notes[noteId];
-
- if (note.canResolve) {
- return true;
- }
- }
-
- return false;
- }
-}
-
-window.DiscussionModel = DiscussionModel;
diff --git a/app/assets/javascripts/diff_notes/models/note.js b/app/assets/javascripts/diff_notes/models/note.js
deleted file mode 100644
index 825a69deeec..00000000000
--- a/app/assets/javascripts/diff_notes/models/note.js
+++ /dev/null
@@ -1,14 +0,0 @@
-class NoteModel {
- constructor(discussionId, noteObj) {
- this.discussionId = discussionId;
- this.id = noteObj.noteId;
- this.canResolve = noteObj.canResolve;
- this.resolved = noteObj.resolved;
- this.resolved_by = noteObj.resolvedBy;
- this.authorName = noteObj.authorName;
- this.authorAvatar = noteObj.authorAvatar;
- this.noteTruncated = noteObj.noteTruncated;
- }
-}
-
-window.NoteModel = NoteModel;
diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js
deleted file mode 100644
index d6975963977..00000000000
--- a/app/assets/javascripts/diff_notes/services/resolve.js
+++ /dev/null
@@ -1,86 +0,0 @@
-/* global CommentsStore */
-
-import Vue from 'vue';
-import { deprecatedCreateFlash as Flash } from '../../flash';
-import { __ } from '~/locale';
-
-window.gl = window.gl || {};
-
-class ResolveServiceClass {
- constructor(root) {
- 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) {
- return this.noteResource.save({ noteId }, {});
- }
-
- unresolve(noteId) {
- return this.noteResource.delete({ noteId }, {});
- }
-
- toggleResolveForDiscussion(mergeRequestId, discussionId) {
- const discussion = CommentsStore.state[discussionId];
- const isResolved = discussion.isResolved();
- let promise;
-
- if (isResolved) {
- promise = this.unResolveAll(mergeRequestId, discussionId);
- } else {
- promise = this.resolveAll(mergeRequestId, discussionId);
- }
-
- promise
- .then(resp => resp.json())
- .then(data => {
- discussion.loading = false;
- const resolvedBy = data ? data.resolved_by : null;
-
- if (isResolved) {
- discussion.unResolveAllNotes();
- } else {
- discussion.resolveAllNotes(resolvedBy);
- }
-
- if (gl.mrWidget) gl.mrWidget.checkStatus();
- discussion.updateHeadline(data);
- })
- .catch(
- () =>
- new Flash(__('An error occurred when trying to resolve a discussion. Please try again.')),
- );
- }
-
- resolveAll(mergeRequestId, discussionId) {
- const discussion = CommentsStore.state[discussionId];
-
- discussion.loading = true;
-
- return this.discussionResource.save(
- {
- mergeRequestId,
- discussionId,
- },
- {},
- );
- }
-
- unResolveAll(mergeRequestId, discussionId) {
- const discussion = CommentsStore.state[discussionId];
-
- discussion.loading = true;
-
- return this.discussionResource.delete(
- {
- mergeRequestId,
- discussionId,
- },
- {},
- );
- }
-}
-
-gl.DiffNotesResolveServiceClass = ResolveServiceClass;
diff --git a/app/assets/javascripts/diff_notes/stores/comments.js b/app/assets/javascripts/diff_notes/stores/comments.js
deleted file mode 100644
index 9bde18c4edf..00000000000
--- a/app/assets/javascripts/diff_notes/stores/comments.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/* eslint-disable no-restricted-syntax, guard-for-in */
-/* global DiscussionModel */
-
-import Vue from 'vue';
-
-window.CommentsStore = {
- state: {},
- get(discussionId, noteId) {
- return this.state[discussionId].getNote(noteId);
- },
- createDiscussion(discussionId, canResolve) {
- let discussion = this.state[discussionId];
- if (!this.state[discussionId]) {
- discussion = new DiscussionModel(discussionId);
- Vue.set(this.state, discussionId, discussion);
- }
-
- if (canResolve !== undefined) {
- discussion.canResolve = canResolve;
- }
-
- return discussion;
- },
- create(noteObj) {
- const discussion = this.createDiscussion(noteObj.discussionId);
-
- discussion.createNote(noteObj);
- },
- update(discussionId, noteId, resolved, resolvedBy) {
- const discussion = this.state[discussionId];
- const note = discussion.getNote(noteId);
- note.resolved = resolved;
- note.resolved_by = resolvedBy;
- },
- delete(discussionId, noteId) {
- const discussion = this.state[discussionId];
- discussion.deleteNote(noteId);
-
- if (discussion.notesCount() === 0) {
- Vue.delete(this.state, discussionId);
- }
- },
- unresolvedDiscussionIds() {
- const ids = [];
-
- for (const discussionId in this.state) {
- const discussion = this.state[discussionId];
-
- if (!discussion.isResolved()) {
- ids.push(discussion.id);
- }
- }
-
- return ids;
- },
-};
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index dd5addbf1e3..085f951147f 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -474,7 +474,7 @@ export default {
<div
v-if="showTreeList"
:style="{ width: `${treeWidth}px` }"
- class="diff-tree-list js-diff-tree-list mr-3"
+ class="diff-tree-list js-diff-tree-list px-3 pr-md-0"
>
<panel-resizer
:size.sync="treeWidth"
@@ -487,7 +487,7 @@ export default {
<tree-list :hide-file-stats="hideFileStats" />
</div>
<div
- class="diff-files-holder"
+ class="col-12 col-md-auto diff-files-holder"
:class="{
[CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer,
}"
diff --git a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
index dded3643115..270bbfb99b7 100644
--- a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
+++ b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
@@ -50,7 +50,7 @@ export default {
</script>
<template>
- <div v-if="!isDismissed" data-testid="root" :class="containerClasses">
+ <div v-if="!isDismissed" data-testid="root" :class="containerClasses" class="col-12">
<gl-alert
:dismissible="true"
:title="__('Some changes are not shown')"
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 23669eecce2..09abdbe25d7 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -1,7 +1,7 @@
<script>
/* eslint-disable vue/no-v-html */
import { mapActions } from 'vuex';
-import { GlButtonGroup, GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlButtonGroup, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -182,14 +182,14 @@ export default {
:endpoint="commit.pipeline_status_path"
class="d-inline-flex"
/>
- <div class="commit-sha-group">
- <div class="label label-monospace monospace" v-text="commit.short_id"></div>
+ <gl-button-group class="gl-ml-4" data-testid="commit-sha-group">
+ <gl-button label class="gl-font-monospace" v-text="commit.short_id" />
<clipboard-button
:text="commit.id"
:title="__('Copy commit SHA')"
- class="btn btn-default"
+ class="input-group-text"
/>
- </div>
+ </gl-button-group>
<div
v-if="hasNeighborCommits && glFeatures.mrCommitNeighborNav"
class="commit-nav-buttons ml-3"
diff --git a/app/assets/javascripts/diffs/components/commit_widget.vue b/app/assets/javascripts/diffs/components/commit_widget.vue
index 5c7e84bd87c..b1a2b2a72ea 100644
--- a/app/assets/javascripts/diffs/components/commit_widget.vue
+++ b/app/assets/javascripts/diffs/components/commit_widget.vue
@@ -1,19 +1,6 @@
<script>
import CommitItem from './commit_item.vue';
-/**
- * CommitWidget
- *
- * -----------------------------------------------------------------
- * WARNING: Please keep changes up-to-date with the following files:
- * - `views/projects/merge_requests/diffs/_commit_widget.html.haml`
- * -----------------------------------------------------------------
- *
- * This Component was cloned from a HAML view. For the time being,
- * they coexist, but there is an issue to remove the duplication.
- * https://gitlab.com/gitlab-org/gitlab-foss/issues/51613
- *
- */
export default {
components: {
CommitItem,
diff --git a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
index 8263e938e69..adef5d94624 100644
--- a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
+++ b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
@@ -32,7 +32,7 @@ export default {
<gl-icon :size="12" name="angle-down" class="position-absolute" />
</a>
<div class="dropdown-menu dropdown-select dropdown-menu-selectable">
- <div class="dropdown-content">
+ <div class="dropdown-content" data-qa-selector="dropdown_content">
<ul>
<li v-for="version in versions" :key="version.id">
<a :class="{ 'is-active': version.selected }" :href="version.href">
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index b94874c5644..b1ebd8e6ebc 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -100,6 +100,7 @@ export default {
<compare-dropdown-layout
:versions="diffCompareDropdownTargetVersions"
class="mr-version-compare-dropdown"
+ data-qa-selector="target_version_dropdown"
/>
</template>
<template #source>
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index 9ecb9a44443..e68260b3e62 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -85,11 +85,9 @@ export default {
},
},
updated() {
- if (window.gon?.features?.codeNavigation) {
- this.$nextTick(() => {
- eventHub.$emit('showBlobInteractionZones', this.diffFile.new_path);
- });
- }
+ this.$nextTick(() => {
+ eventHub.$emit('showBlobInteractionZones', this.diffFile.new_path);
+ });
},
methods: {
...mapActions('diffs', ['saveDiffDiscussion', 'closeDiffFileCommentForm']),
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 9a7ed76bad3..529723a349d 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -1,32 +1,26 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { escape } from 'lodash';
-import { GlButton, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { sprintf } from '~/locale';
+import { __, sprintf } from '~/locale';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { hasDiff } from '~/helpers/diffs_helper';
import eventHub from '../../notes/event_hub';
import DiffFileHeader from './diff_file_header.vue';
import DiffContent from './diff_content.vue';
import { diffViewerErrors } from '~/ide/constants';
-import { GENERIC_ERROR, DIFF_FILE } from '../i18n';
export default {
components: {
DiffFileHeader,
DiffContent,
- GlButton,
GlLoadingIcon,
},
directives: {
SafeHtml,
},
mixins: [glFeatureFlagsMixin()],
- i18n: {
- genericError: GENERIC_ERROR,
- ...DIFF_FILE,
- },
props: {
file: {
type: Object,
@@ -50,7 +44,7 @@ export default {
return {
isLoadingCollapsedDiff: false,
forkMessageVisible: false,
- isCollapsed: this.file.viewer.collapsed || false,
+ isCollapsed: this.file.viewer.automaticallyCollapsed || false,
};
},
computed: {
@@ -59,7 +53,7 @@ export default {
...mapGetters('diffs', ['getDiffFileDiscussions']),
viewBlobLink() {
return sprintf(
- this.$options.i18n.blobView,
+ __('You can %{linkStart}view the blob%{linkEnd} instead.'),
{
linkStart: `<a href="${escape(this.file.view_path)}">`,
linkEnd: '</a>',
@@ -81,7 +75,9 @@ export default {
},
forkMessage() {
return sprintf(
- this.$options.i18n.editInFork,
+ __(
+ "You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.",
+ ),
{
tag_start: '<span class="js-file-fork-suggestion-section-action">',
tag_end: '</span>',
@@ -100,16 +96,16 @@ export default {
},
'file.file_hash': {
handler: function watchFileHash() {
- if (this.viewDiffsFileByFile && this.file.viewer.collapsed) {
+ if (this.viewDiffsFileByFile && this.file.viewer.automaticallyCollapsed) {
this.isCollapsed = false;
this.handleLoadCollapsedDiff();
} else {
- this.isCollapsed = this.file.viewer.collapsed || false;
+ this.isCollapsed = this.file.viewer.automaticallyCollapsed || false;
}
},
immediate: true,
},
- 'file.viewer.collapsed': function setIsCollapsed(newVal) {
+ 'file.viewer.automaticallyCollapsed': function setIsCollapsed(newVal) {
if (!this.viewDiffsFileByFile) {
this.isCollapsed = newVal;
}
@@ -152,7 +148,7 @@ export default {
})
.catch(() => {
this.isLoadingCollapsedDiff = false;
- createFlash(this.$options.i18n.genericError);
+ createFlash(__('Something went wrong on our end. Please try again!'));
});
},
showForkMessage() {
@@ -192,14 +188,14 @@ export default {
<a
:href="file.fork_path"
class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success"
- >{{ $options.i18n.fork }}</a
+ >{{ __('Fork') }}</a
>
<button
class="js-cancel-fork-suggestion-button btn btn-grouped"
type="button"
@click="hideForkMessage"
>
- {{ $options.i18n.cancel }}
+ {{ __('Cancel') }}
</button>
</div>
<gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" />
@@ -209,17 +205,11 @@ export default {
<div v-safe-html="errorMessage" class="nothing-here-block"></div>
</div>
<template v-else>
- <div v-show="isCollapsed" class="gl-p-7 gl-text-center collapsed-file-warning">
- <p class="gl-mb-8 gl-mt-5">
- {{ $options.i18n.collapsed }}
- </p>
- <gl-button
- class="gl-alert-action gl-mb-5"
- data-testid="expandButton"
- @click="handleToggle"
- >
- {{ $options.i18n.expand }}
- </gl-button>
+ <div v-show="isCollapsed" class="nothing-here-block diff-collapsed">
+ {{ __('This diff is collapsed.') }}
+ <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{
+ __('Click to expand it.')
+ }}</a>
</div>
<diff-content
v-show="!isCollapsed && !isFileTooLarge"
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index fded391cc84..ee8a8737f44 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -1,34 +1,37 @@
<script>
-/* eslint-disable vue/no-v-html */
import { escape } from 'lodash';
import { mapActions, mapGetters } from 'vuex';
import {
- GlDeprecatedButton,
GlTooltipDirective,
GlSafeHtmlDirective,
- GlLoadingIcon,
GlIcon,
GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
} from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import { truncateSha } from '~/lib/utils/text_utility';
import { __, s__, sprintf } from '~/locale';
import { diffViewerModes } from '~/ide/constants';
-import EditButton from './edit_button.vue';
import DiffStats from './diff_stats.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
export default {
components: {
- GlLoadingIcon,
- GlDeprecatedButton,
ClipboardButton,
- EditButton,
GlIcon,
FileIcon,
DiffStats,
GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -69,6 +72,11 @@ export default {
default: false,
},
},
+ data() {
+ return {
+ moreActionsShown: false,
+ };
+ },
computed: {
...mapGetters('diffs', ['diffHasExpandedDiscussions', 'diffHasDiscussions']),
diffContentIDSelector() {
@@ -151,6 +159,13 @@ export default {
}
return s__('MRDiff|Show full file');
},
+ showEditButton() {
+ return (
+ this.diffFile.blob?.readable_text &&
+ !this.diffFile.deleted_file &&
+ (this.diffFile.edit_path || this.diffFile.ide_edit_path)
+ );
+ },
},
methods: {
...mapActions('diffs', [
@@ -162,8 +177,11 @@ export default {
handleToggleFile() {
this.$emit('toggleFile');
},
- showForkMessage() {
- this.$emit('showForkMessage');
+ showForkMessage(e) {
+ if (this.canCurrentUserFork && !this.diffFile.can_modify_blob) {
+ e.preventDefault();
+ this.$emit('showForkMessage');
+ }
},
handleFileNameClick(e) {
const isLinkToOtherPage =
@@ -179,6 +197,9 @@ export default {
}
}
},
+ setMoreActionsShown(val) {
+ this.moreActionsShown = val;
+ },
},
};
</script>
@@ -186,10 +207,11 @@ export default {
<template>
<div
ref="header"
+ :class="{ 'gl-z-dropdown-menu!': moreActionsShown }"
class="js-file-title file-title file-title-flex-parent"
@click.self="handleToggleFile"
>
- <div class="file-header-content">
+ <div class="file-header-content gl-display-flex gl-align-items-center gl-pr-0!">
<gl-icon
v-if="collapsible"
ref="collapseIcon"
@@ -202,7 +224,7 @@ export default {
<a
ref="titleWrapper"
:v-once="!viewDiffsFileByFile"
- class="gl-mr-2"
+ class="gl-mr-2 gl-text-decoration-none!"
:href="titleLink"
@click="handleFileNameClick"
>
@@ -210,20 +232,27 @@ export default {
<span v-if="isFileRenamed">
<strong
v-gl-tooltip
+ v-safe-html="diffFile.old_path_html"
:title="diffFile.old_path"
class="file-title-name"
- v-html="diffFile.old_path_html"
></strong>
<strong
v-gl-tooltip
+ v-safe-html="diffFile.new_path_html"
:title="diffFile.new_path"
class="file-title-name"
- v-html="diffFile.new_path_html"
></strong>
</span>
- <strong v-else v-gl-tooltip :title="filePath" class="file-title-name" data-container="body">
+ <strong
+ v-else
+ v-gl-tooltip
+ :title="filePath"
+ class="file-title-name"
+ data-container="body"
+ data-qa-selector="file_name_content"
+ >
{{ filePath }}
</strong>
</a>
@@ -232,7 +261,8 @@ export default {
:title="__('Copy file path')"
:text="diffFile.file_path"
:gfm="gfmCopyText"
- css-class="btn-default btn-transparent btn-clipboard"
+ data-testid="diff-file-copy-clipboard"
+ category="tertiary"
data-track-event="click_copy_file_button"
data-track-label="diff_copy_file_path_button"
data-track-property="diff_copy_file"
@@ -247,93 +277,95 @@ export default {
<div
v-if="!diffFile.submodule && addMergeRequestButtons"
- class="file-actions d-none d-sm-flex align-items-center flex-wrap"
+ class="file-actions d-flex align-items-center flex-wrap"
>
<diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" />
- <div class="btn-group" role="group">
- <template v-if="diffFile.blob && diffFile.blob.readable_text">
- <span v-gl-tooltip.hover :title="s__('MergeRequests|Toggle comments for this file')">
- <gl-deprecated-button
- ref="toggleDiscussionsButton"
- :disabled="!diffHasDiscussions(diffFile)"
- :class="{ active: diffHasExpandedDiscussions(diffFile) }"
- class="js-btn-vue-toggle-comments btn"
- data-qa-selector="toggle_comments_button"
- data-track-event="click_toggle_comments_button"
- data-track-label="diff_toggle_comments_button"
- data-track-property="diff_toggle_comments"
- type="button"
- @click="toggleFileDiscussionWrappers(diffFile)"
- >
- <gl-icon name="comment" />
- </gl-deprecated-button>
- </span>
-
- <edit-button
- v-if="!diffFile.deleted_file"
- :can-current-user-fork="canCurrentUserFork"
- :edit-path="diffFile.edit_path"
- :can-modify-blob="diffFile.can_modify_blob"
- data-track-event="click_toggle_edit_button"
- data-track-label="diff_toggle_edit_button"
- data-track-property="diff_toggle_edit"
- @showForkMessage="showForkMessage"
- />
- </template>
-
- <a
- v-if="diffFile.replaced_view_path"
- ref="replacedFileButton"
- :href="diffFile.replaced_view_path"
- class="btn view-file"
- v-html="viewReplacedFileButtonText"
- >
- </a>
- <gl-deprecated-button
- v-if="!diffFile.is_fully_expanded"
- ref="expandDiffToFullFileButton"
- v-gl-tooltip.hover
- :title="expandDiffToFullFileTitle"
- class="expand-file"
- data-track-event="click_toggle_view_full_button"
- data-track-label="diff_toggle_view_full_button"
- data-track-property="diff_toggle_view_full"
- @click="toggleFullDiff(diffFile.file_path)"
- >
- <gl-loading-icon v-if="diffFile.isLoadingFullFile" color="dark" inline />
- <gl-icon v-else-if="diffFile.isShowingFullFile" name="doc-changes" />
- <gl-icon v-else name="doc-expand" />
- </gl-deprecated-button>
- <gl-deprecated-button
- ref="viewButton"
- v-gl-tooltip.hover
- :href="diffFile.view_path"
- target="_blank"
- class="view-file"
- data-track-event="click_toggle_view_sha_button"
- data-track-label="diff_toggle_view_sha_button"
- data-track-property="diff_toggle_view_sha"
- :title="viewFileButtonText"
- >
- <gl-icon name="doc-text" />
- </gl-deprecated-button>
-
- <a
+ <gl-button-group class="gl-pt-0!">
+ <gl-button
v-if="diffFile.external_url"
ref="externalLink"
v-gl-tooltip.hover
:href="diffFile.external_url"
:title="`View on ${diffFile.formatted_external_url}`"
target="_blank"
- rel="noopener noreferrer"
data-track-event="click_toggle_external_button"
data-track-label="diff_toggle_external_button"
data-track-property="diff_toggle_external"
- class="btn btn-file-option"
+ icon="external-link"
+ />
+ <gl-dropdown
+ v-gl-tooltip.hover.focus="__('More actions')"
+ right
+ toggle-class="btn-icon js-diff-more-actions"
+ class="gl-pt-0!"
+ @show="setMoreActionsShown(true)"
+ @hidden="setMoreActionsShown(false)"
>
- <gl-icon name="external-link" />
- </a>
- </div>
+ <template #button-content>
+ <gl-icon name="ellipsis_v" class="mr-0" />
+ <span class="sr-only">{{ __('More actions') }}</span>
+ </template>
+ <gl-dropdown-section-header>
+ {{ __('More actions') }}
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-if="diffFile.replaced_view_path"
+ ref="replacedFileButton"
+ v-safe-html="viewReplacedFileButtonText"
+ :href="diffFile.replaced_view_path"
+ target="_blank"
+ />
+ <gl-dropdown-item ref="viewButton" :href="diffFile.view_path" target="_blank">
+ {{ viewFileButtonText }}
+ </gl-dropdown-item>
+ <template v-if="showEditButton">
+ <gl-dropdown-item
+ v-if="diffFile.edit_path"
+ ref="editButton"
+ :href="diffFile.edit_path"
+ class="js-edit-blob"
+ @click="showForkMessage"
+ >
+ {{ __('Edit in single-file editor') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="diffFile.edit_path"
+ ref="ideEditButton"
+ :href="diffFile.ide_edit_path"
+ class="js-ide-edit-blob"
+ >
+ {{ __('Edit in Web IDE') }}
+ </gl-dropdown-item>
+ </template>
+
+ <template v-if="!diffFile.viewer.automaticallyCollapsed">
+ <gl-dropdown-divider
+ v-if="!diffFile.is_fully_expanded || diffHasDiscussions(diffFile)"
+ />
+
+ <gl-dropdown-item
+ v-if="diffHasDiscussions(diffFile)"
+ ref="toggleDiscussionsButton"
+ data-qa-selector="toggle_comments_button"
+ @click="toggleFileDiscussionWrappers(diffFile)"
+ >
+ <template v-if="diffHasExpandedDiscussions(diffFile)">
+ {{ __('Hide comments on this file') }}
+ </template>
+ <template v-else>
+ {{ __('Show comments on this file') }}
+ </template>
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="!diffFile.is_fully_expanded"
+ ref="expandDiffToFullFileButton"
+ @click="toggleFullDiff(diffFile.file_path)"
+ >
+ {{ expandDiffToFullFileTitle }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+ </gl-button-group>
</div>
<div
diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js
new file mode 100644
index 00000000000..998320c3245
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_row_utils.js
@@ -0,0 +1,99 @@
+import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import {
+ MATCH_LINE_TYPE,
+ CONTEXT_LINE_TYPE,
+ LINE_HOVER_CLASS_NAME,
+ OLD_NO_NEW_LINE_TYPE,
+ NEW_NO_NEW_LINE_TYPE,
+ EMPTY_CELL_TYPE,
+} from '../constants';
+
+export const isHighlighted = (state, line, isCommented) => {
+ if (isCommented) return true;
+
+ const lineCode = line?.line_code;
+ return lineCode ? lineCode === state.diffs.highlightedRow : false;
+};
+
+export const isContextLine = type => type === CONTEXT_LINE_TYPE;
+
+export const isMatchLine = type => type === MATCH_LINE_TYPE;
+
+export const isMetaLine = type =>
+ [OLD_NO_NEW_LINE_TYPE, NEW_NO_NEW_LINE_TYPE, EMPTY_CELL_TYPE].includes(type);
+
+export const shouldRenderCommentButton = (
+ isLoggedIn,
+ isCommentButtonRendered,
+ featureMergeRefHeadComments = false,
+) => {
+ if (!isCommentButtonRendered) {
+ return false;
+ }
+
+ if (isLoggedIn) {
+ const isDiffHead = parseBoolean(getParameterByName('diff_head'));
+ return !isDiffHead || featureMergeRefHeadComments;
+ }
+
+ return false;
+};
+
+export const hasDiscussions = line => line?.discussions?.length > 0;
+
+export const lineHref = line => `#${line?.line_code || ''}`;
+
+export const lineCode = line => {
+ if (!line) return undefined;
+ return line.line_code || line.left?.line_code || line.right?.line_code;
+};
+
+export const classNameMapCell = (line, hll, isLoggedIn, isHover) => {
+ if (!line) return [];
+ const { type } = line;
+
+ return [
+ type,
+ {
+ hll,
+ [LINE_HOVER_CLASS_NAME]: isLoggedIn && isHover && !isContextLine(type) && !isMetaLine(type),
+ },
+ ];
+};
+
+export const addCommentTooltip = line => {
+ let tooltip;
+ if (!line) return tooltip;
+
+ tooltip = __('Add a comment to this line');
+ const brokenSymlinks = line.commentsDisabled;
+
+ if (brokenSymlinks) {
+ if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) {
+ tooltip = __(
+ 'Commenting on symbolic links that replace or are replaced by files is currently not supported.',
+ );
+ } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) {
+ tooltip = __(
+ 'Commenting on files that replace or are replaced by symbolic links is currently not supported.',
+ );
+ }
+ }
+
+ return tooltip;
+};
+
+export const parallelViewLeftLineType = (line, hll) => {
+ if (line?.right?.type === NEW_NO_NEW_LINE_TYPE) {
+ return OLD_NO_NEW_LINE_TYPE;
+ }
+
+ const lineTypeClass = line?.left ? line.left.type : EMPTY_CELL_TYPE;
+
+ return [lineTypeClass, { hll }];
+};
+
+export const shouldShowCommentButton = (hover, context, meta, discussions) => {
+ return hover && !context && !meta && !discussions;
+};
diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue
index 05fbbd39fae..f229fc4cf60 100644
--- a/app/assets/javascripts/diffs/components/diff_stats.vue
+++ b/app/assets/javascripts/diffs/components/diff_stats.vue
@@ -42,7 +42,7 @@ export default {
class="diff-stats"
:class="{
'is-compare-versions-header d-none d-lg-inline-flex': isCompareVersionsHeader,
- 'd-inline-flex': !isCompareVersionsHeader,
+ 'd-none d-sm-inline-flex': !isCompareVersionsHeader,
}"
>
<div v-if="hasDiffFiles" class="diff-stats-group">
diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue
deleted file mode 100644
index 49982a81372..00000000000
--- a/app/assets/javascripts/diffs/components/diff_table_cell.vue
+++ /dev/null
@@ -1,206 +0,0 @@
-<script>
-import { mapGetters, mapActions } from 'vuex';
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
-import DiffGutterAvatars from './diff_gutter_avatars.vue';
-import { __ } from '~/locale';
-import {
- CONTEXT_LINE_TYPE,
- LINE_POSITION_RIGHT,
- EMPTY_CELL_TYPE,
- OLD_NO_NEW_LINE_TYPE,
- OLD_LINE_TYPE,
- NEW_NO_NEW_LINE_TYPE,
- LINE_HOVER_CLASS_NAME,
-} from '../constants';
-
-export default {
- components: {
- DiffGutterAvatars,
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- line: {
- type: Object,
- required: true,
- },
- fileHash: {
- type: String,
- required: true,
- },
- isHighlighted: {
- type: Boolean,
- required: true,
- },
- showCommentButton: {
- type: Boolean,
- required: false,
- default: false,
- },
- linePosition: {
- type: String,
- required: false,
- default: '',
- },
- lineType: {
- type: String,
- required: false,
- default: '',
- },
- isBottom: {
- type: Boolean,
- required: false,
- default: false,
- },
- isHover: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- isCommentButtonRendered: false,
- };
- },
- computed: {
- ...mapGetters(['isLoggedIn']),
- lineCode() {
- return (
- this.line.line_code ||
- (this.line.left && this.line.left.line_code) ||
- (this.line.right && this.line.right.line_code)
- );
- },
- lineHref() {
- return `#${this.line.line_code || ''}`;
- },
- shouldShowCommentButton() {
- return this.isHover && !this.isContextLine && !this.isMetaLine && !this.hasDiscussions;
- },
- hasDiscussions() {
- return this.line.discussions && this.line.discussions.length > 0;
- },
- shouldShowAvatarsOnGutter() {
- if (!this.line.type && this.linePosition === LINE_POSITION_RIGHT) {
- return false;
- }
- return this.showCommentButton && this.hasDiscussions;
- },
- shouldRenderCommentButton() {
- if (!this.isCommentButtonRendered) {
- return false;
- }
-
- if (this.isLoggedIn && this.showCommentButton) {
- const isDiffHead = parseBoolean(getParameterByName('diff_head'));
- return !isDiffHead || gon.features?.mergeRefHeadComments;
- }
-
- return false;
- },
- isContextLine() {
- return this.line.type === CONTEXT_LINE_TYPE;
- },
- isMetaLine() {
- const { type } = this.line;
-
- return (
- type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE
- );
- },
- classNameMap() {
- const { type } = this.line;
-
- return [
- type,
- {
- hll: this.isHighlighted,
- [LINE_HOVER_CLASS_NAME]:
- this.isLoggedIn && this.isHover && !this.isContextLine && !this.isMetaLine,
- },
- ];
- },
- lineNumber() {
- return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line;
- },
- addCommentTooltip() {
- const brokenSymlinks = this.line.commentsDisabled;
- let tooltip = __('Add a comment to this line');
-
- if (brokenSymlinks) {
- if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) {
- tooltip = __(
- 'Commenting on symbolic links that replace or are replaced by files is currently not supported.',
- );
- } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) {
- tooltip = __(
- 'Commenting on files that replace or are replaced by symbolic links is currently not supported.',
- );
- }
- }
-
- return tooltip;
- },
- },
- mounted() {
- this.unwatchShouldShowCommentButton = this.$watch('shouldShowCommentButton', newVal => {
- if (newVal) {
- this.isCommentButtonRendered = true;
- this.unwatchShouldShowCommentButton();
- }
- });
- },
- beforeDestroy() {
- this.unwatchShouldShowCommentButton();
- },
- methods: {
- ...mapActions('diffs', ['showCommentForm', 'setHighlightedRow', 'toggleLineDiscussions']),
- handleCommentButton() {
- this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash });
- },
- },
-};
-</script>
-
-<template>
- <td ref="td" :class="classNameMap">
- <span
- ref="addNoteTooltip"
- v-gl-tooltip
- class="add-diff-note tooltip-wrapper"
- :title="addCommentTooltip"
- >
- <button
- v-if="shouldRenderCommentButton"
- v-show="shouldShowCommentButton"
- ref="addDiffNoteButton"
- type="button"
- class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
- :disabled="line.commentsDisabled"
- @click="handleCommentButton"
- >
- <gl-icon :size="12" name="comment" />
- </button>
- </span>
- <a
- v-if="lineNumber"
- ref="lineNumberRef"
- :data-linenumber="lineNumber"
- :href="lineHref"
- @click="setHighlightedRow(lineCode)"
- >
- </a>
- <diff-gutter-avatars
- v-if="shouldShowAvatarsOnGutter"
- :discussions="line.discussions"
- :discussions-expanded="line.discussionsExpanded"
- @toggleLineDiscussions="
- toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded })
- "
- />
- </td>
-</template>
diff --git a/app/assets/javascripts/diffs/components/edit_button.vue b/app/assets/javascripts/diffs/components/edit_button.vue
deleted file mode 100644
index ff1af5569dc..00000000000
--- a/app/assets/javascripts/diffs/components/edit_button.vue
+++ /dev/null
@@ -1,64 +0,0 @@
-<script>
-import { GlTooltipDirective, GlDeprecatedButton, GlIcon } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default {
- components: {
- GlDeprecatedButton,
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- editPath: {
- type: String,
- required: false,
- default: '',
- },
- canCurrentUserFork: {
- type: Boolean,
- required: true,
- },
- canModifyBlob: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- tooltipTitle() {
- if (this.isDisabled) {
- return __("Can't edit as source branch was deleted");
- }
-
- return __('Edit file');
- },
- isDisabled() {
- return !this.editPath;
- },
- },
- methods: {
- handleEditClick(evt) {
- if (this.canCurrentUserFork && !this.canModifyBlob) {
- evt.preventDefault();
- this.$emit('showForkMessage');
- }
- },
- },
-};
-</script>
-
-<template>
- <span v-gl-tooltip.top :title="tooltipTitle">
- <gl-deprecated-button
- :href="editPath"
- :disabled="isDisabled"
- :class="{ 'cursor-not-allowed': isDisabled }"
- class="rounded-0 js-edit-blob"
- @click.native="handleEditClick"
- >
- <gl-icon name="pencil" />
- </gl-deprecated-button>
- </span>
-</template>
diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
index 7fab750089e..f9d491603cb 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
@@ -1,22 +1,9 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
-import {
- MATCH_LINE_TYPE,
- NEW_LINE_TYPE,
- OLD_LINE_TYPE,
- CONTEXT_LINE_TYPE,
- CONTEXT_LINE_CLASS_NAME,
- LINE_POSITION_LEFT,
- LINE_POSITION_RIGHT,
- LINE_HOVER_CLASS_NAME,
- OLD_NO_NEW_LINE_TYPE,
- NEW_NO_NEW_LINE_TYPE,
- EMPTY_CELL_TYPE,
-} from '../constants';
-import { __ } from '~/locale';
-import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
+import { CONTEXT_LINE_CLASS_NAME } from '../constants';
import DiffGutterAvatars from './diff_gutter_avatars.vue';
+import * as utils from './diff_row_utils';
export default {
components: {
@@ -61,14 +48,11 @@ export default {
...mapGetters('diffs', ['fileLineCoverage']),
...mapState({
isHighlighted(state) {
- if (this.isCommented) return true;
-
- const lineCode = this.line.line_code;
- return lineCode ? lineCode === state.diffs.highlightedRow : false;
+ return utils.isHighlighted(state, this.line, this.isCommented);
},
}),
isContextLine() {
- return this.line.type === CONTEXT_LINE_TYPE;
+ return utils.isContextLine(this.line.type);
},
classNameMap() {
return [
@@ -82,82 +66,48 @@ export default {
return this.line.line_code || `${this.fileHash}_${this.line.old_line}_${this.line.new_line}`;
},
isMatchLine() {
- return this.line.type === MATCH_LINE_TYPE;
+ return utils.isMatchLine(this.line.type);
},
coverageState() {
return this.fileLineCoverage(this.filePath, this.line.new_line);
},
isMetaLine() {
- const { type } = this.line;
-
- return (
- type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE
- );
+ return utils.isMetaLine(this.line.type);
},
classNameMapCell() {
- const { type } = this.line;
-
- return [
- type,
- {
- hll: this.isHighlighted,
- [LINE_HOVER_CLASS_NAME]:
- this.isLoggedIn && this.isHover && !this.isContextLine && !this.isMetaLine,
- },
- ];
+ return utils.classNameMapCell(this.line, this.isHighlighted, this.isLoggedIn, this.isHover);
},
addCommentTooltip() {
- const brokenSymlinks = this.line.commentsDisabled;
- let tooltip = __('Add a comment to this line');
-
- if (brokenSymlinks) {
- if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) {
- tooltip = __(
- 'Commenting on symbolic links that replace or are replaced by files is currently not supported.',
- );
- } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) {
- tooltip = __(
- 'Commenting on files that replace or are replaced by symbolic links is currently not supported.',
- );
- }
- }
-
- return tooltip;
+ return utils.addCommentTooltip(this.line);
},
shouldRenderCommentButton() {
- if (this.isLoggedIn) {
- const isDiffHead = parseBoolean(getParameterByName('diff_head'));
- return !isDiffHead || gon.features?.mergeRefHeadComments;
- }
-
- return false;
+ return utils.shouldRenderCommentButton(
+ this.isLoggedIn,
+ true,
+ gon.features?.mergeRefHeadComments,
+ );
},
shouldShowCommentButton() {
- return this.isHover && !this.isContextLine && !this.isMetaLine && !this.hasDiscussions;
+ return utils.shouldShowCommentButton(
+ this.isHover,
+ this.isContextLine,
+ this.isMetaLine,
+ this.hasDiscussions,
+ );
},
hasDiscussions() {
- return this.line.discussions && this.line.discussions.length > 0;
+ return utils.hasDiscussions(this.line);
},
lineHref() {
- return `#${this.line.line_code || ''}`;
+ return utils.lineHref(this.line);
},
lineCode() {
- return (
- this.line.line_code ||
- (this.line.left && this.line.left.line_code) ||
- (this.line.right && this.line.right.line_code)
- );
+ return utils.lineCode(this.line);
},
shouldShowAvatarsOnGutter() {
return this.hasDiscussions;
},
},
- created() {
- this.newLineType = NEW_LINE_TYPE;
- this.oldLineType = OLD_LINE_TYPE;
- this.linePositionLeft = LINE_POSITION_LEFT;
- this.linePositionRight = LINE_POSITION_RIGHT;
- },
mounted() {
this.scrollToLineIfNeededInline(this.line);
},
@@ -242,6 +192,7 @@ export default {
class="line-coverage"
></td>
<td
+ :key="line.line_code"
v-safe-html="line.rich_text"
:class="[
line.type,
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
index b525490f7cc..127e3f214cf 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
@@ -113,8 +113,8 @@ export default {
},
methods: {
...mapActions('diffs', ['showCommentForm']),
- showNewDiscussionForm() {
- this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.diffFileHash });
+ showNewDiscussionForm(lineCode) {
+ this.showCommentForm({ lineCode, fileHash: this.diffFileHash });
},
},
};
@@ -134,7 +134,7 @@ export default {
v-if="!hasDraftLeft"
:has-form="showLeftSideCommentForm"
:render-reply-placeholder="shouldRenderReplyPlaceholderOnLeft"
- @showNewDiscussionForm="showNewDiscussionForm"
+ @showNewDiscussionForm="showNewDiscussionForm(line.left.line_code)"
>
<template #form>
<diff-line-note-form
@@ -159,7 +159,7 @@ export default {
v-if="!hasDraftRight"
:has-form="showRightSideCommentForm"
:render-reply-placeholder="shouldRenderReplyPlaceholderOnRight"
- @showNewDiscussionForm="showNewDiscussionForm"
+ @showNewDiscussionForm="showNewDiscussionForm(line.right.line_code)"
>
<template #form>
<diff-line-note-form
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
index 0bf47dc77a6..06dcadb2dc1 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
@@ -2,21 +2,9 @@
import { mapActions, mapGetters, mapState } from 'vuex';
import $ from 'jquery';
import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
-import {
- MATCH_LINE_TYPE,
- NEW_LINE_TYPE,
- OLD_LINE_TYPE,
- CONTEXT_LINE_TYPE,
- CONTEXT_LINE_CLASS_NAME,
- OLD_NO_NEW_LINE_TYPE,
- PARALLEL_DIFF_VIEW_TYPE,
- NEW_NO_NEW_LINE_TYPE,
- EMPTY_CELL_TYPE,
- LINE_HOVER_CLASS_NAME,
-} from '../constants';
-import { __ } from '~/locale';
-import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
+import { CONTEXT_LINE_CLASS_NAME, PARALLEL_DIFF_VIEW_TYPE } from '../constants';
import DiffGutterAvatars from './diff_gutter_avatars.vue';
+import * as utils from './diff_row_utils';
export default {
components: {
@@ -63,20 +51,15 @@ export default {
...mapGetters(['isLoggedIn']),
...mapState({
isHighlighted(state) {
- if (this.isCommented) return true;
-
- const lineCode =
- (this.line.left && this.line.left.line_code) ||
- (this.line.right && this.line.right.line_code);
-
- return lineCode ? lineCode === state.diffs.highlightedRow : false;
+ const line = this.line.left?.line_code ? this.line.left : this.line.right;
+ return utils.isHighlighted(state, line, this.isCommented);
},
}),
isContextLineLeft() {
- return this.line.left && this.line.left.type === CONTEXT_LINE_TYPE;
+ return utils.isContextLine(this.line.left?.type);
},
isContextLineRight() {
- return this.line.right && this.line.right.type === CONTEXT_LINE_TYPE;
+ return utils.isContextLine(this.line.right?.type);
},
classNameMap() {
return {
@@ -85,157 +68,84 @@ export default {
};
},
parallelViewLeftLineType() {
- if (this.line.right && this.line.right.type === NEW_NO_NEW_LINE_TYPE) {
- return OLD_NO_NEW_LINE_TYPE;
- }
-
- const lineTypeClass = this.line.left ? this.line.left.type : EMPTY_CELL_TYPE;
-
- return [
- lineTypeClass,
- {
- hll: this.isHighlighted,
- },
- ];
+ return utils.parallelViewLeftLineType(this.line, this.isHighlighted);
},
isMatchLineLeft() {
- return this.line.left && this.line.left.type === MATCH_LINE_TYPE;
+ return utils.isMatchLine(this.line.left?.type);
},
isMatchLineRight() {
- return this.line.right && this.line.right.type === MATCH_LINE_TYPE;
+ return utils.isMatchLine(this.line.right?.type);
},
coverageState() {
return this.fileLineCoverage(this.filePath, this.line.right.new_line);
},
classNameMapCellLeft() {
- const { type } = this.line.left;
-
- return [
- type,
- {
- hll: this.isHighlighted,
- [LINE_HOVER_CLASS_NAME]:
- this.isLoggedIn && this.isLeftHover && !this.isContextLineLeft && !this.isMetaLineLeft,
- },
- ];
+ return utils.classNameMapCell(
+ this.line.left,
+ this.isHighlighted,
+ this.isLoggedIn,
+ this.isLeftHover,
+ );
},
classNameMapCellRight() {
- const { type } = this.line.right;
-
- return [
- type,
- {
- hll: this.isHighlighted,
- [LINE_HOVER_CLASS_NAME]:
- this.isLoggedIn &&
- this.isRightHover &&
- !this.isContextLineRight &&
- !this.isMetaLineRight,
- },
- ];
+ return utils.classNameMapCell(
+ this.line.right,
+ this.isHighlighted,
+ this.isLoggedIn,
+ this.isRightHover,
+ );
},
addCommentTooltipLeft() {
- const brokenSymlinks = this.line.left.commentsDisabled;
- let tooltip = __('Add a comment to this line');
-
- if (brokenSymlinks) {
- if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) {
- tooltip = __(
- 'Commenting on symbolic links that replace or are replaced by files is currently not supported.',
- );
- } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) {
- tooltip = __(
- 'Commenting on files that replace or are replaced by symbolic links is currently not supported.',
- );
- }
- }
-
- return tooltip;
+ return utils.addCommentTooltip(this.line.left);
},
addCommentTooltipRight() {
- const brokenSymlinks = this.line.right.commentsDisabled;
- let tooltip = __('Add a comment to this line');
-
- if (brokenSymlinks) {
- if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) {
- tooltip = __(
- 'Commenting on symbolic links that replace or are replaced by files is currently not supported.',
- );
- } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) {
- tooltip = __(
- 'Commenting on files that replace or are replaced by symbolic links is currently not supported.',
- );
- }
- }
-
- return tooltip;
+ return utils.addCommentTooltip(this.line.right);
},
shouldRenderCommentButton() {
- if (!this.isCommentButtonRendered) {
- return false;
- }
-
- if (this.isLoggedIn) {
- const isDiffHead = parseBoolean(getParameterByName('diff_head'));
- return !isDiffHead || gon.features?.mergeRefHeadComments;
- }
-
- return false;
+ return utils.shouldRenderCommentButton(
+ this.isLoggedIn,
+ this.isCommentButtonRendered,
+ gon.features?.mergeRefHeadComments,
+ );
},
shouldShowCommentButtonLeft() {
- return (
- this.isLeftHover &&
- !this.isContextLineLeft &&
- !this.isMetaLineLeft &&
- !this.hasDiscussionsLeft
+ return utils.shouldShowCommentButton(
+ this.isLeftHover,
+ this.isContextLineLeft,
+ this.isMetaLineLeft,
+ this.hasDiscussionsLeft,
);
},
shouldShowCommentButtonRight() {
- return (
- this.isRightHover &&
- !this.isContextLineRight &&
- !this.isMetaLineRight &&
- !this.hasDiscussionsRight
+ return utils.shouldShowCommentButton(
+ this.isRightHover,
+ this.isContextLineRight,
+ this.isMetaLineRight,
+ this.hasDiscussionsRight,
);
},
hasDiscussionsLeft() {
- return this.line.left?.discussions?.length > 0;
+ return utils.hasDiscussions(this.line.left);
},
hasDiscussionsRight() {
- return this.line.right?.discussions?.length > 0;
+ return utils.hasDiscussions(this.line.right);
},
lineHrefOld() {
- return `#${this.line.left.line_code || ''}`;
+ return utils.lineHref(this.line.left);
},
lineHrefNew() {
- return `#${this.line.right.line_code || ''}`;
+ return utils.lineHref(this.line.right);
},
lineCode() {
- return (
- (this.line.left && this.line.left.line_code) ||
- (this.line.right && this.line.right.line_code)
- );
+ return utils.lineCode(this.line);
},
isMetaLineLeft() {
- const type = this.line.left?.type;
-
- return (
- type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE
- );
+ return utils.isMetaLine(this.line.left?.type);
},
isMetaLineRight() {
- const type = this.line.right?.type;
-
- return (
- type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE
- );
+ return utils.isMetaLine(this.line.right?.type);
},
},
- created() {
- this.newLineType = NEW_LINE_TYPE;
- this.oldLineType = OLD_LINE_TYPE;
- this.parallelDiffViewType = PARALLEL_DIFF_VIEW_TYPE;
- },
mounted() {
this.scrollToLineIfNeededParallel(this.line);
this.unwatchShouldShowCommentButton = this.$watch(
@@ -341,6 +251,7 @@ export default {
<td :class="parallelViewLeftLineType" class="line-coverage left-side"></td>
<td
:id="line.left.line_code"
+ :key="line.left.line_code"
v-safe-html="line.left.rich_text"
:class="parallelViewLeftLineType"
class="line_content with-coverage parallel left-side"
@@ -401,6 +312,7 @@ export default {
></td>
<td
:id="line.right.line_code"
+ :key="line.right.rich_text"
v-safe-html="line.right.rich_text"
:class="[
line.right.type,
diff --git a/app/assets/javascripts/diffs/diff_file.js b/app/assets/javascripts/diffs/diff_file.js
index 610b71235d9..933197a2c7f 100644
--- a/app/assets/javascripts/diffs/diff_file.js
+++ b/app/assets/javascripts/diffs/diff_file.js
@@ -18,9 +18,21 @@ function fileSymlinkInformation(file, fileList) {
);
}
+function collapsed(file) {
+ const viewer = file.viewer || {};
+
+ return {
+ automaticallyCollapsed: viewer.automaticallyCollapsed || viewer.collapsed || false,
+ };
+}
+
export function prepareRawDiffFile({ file, allFiles }) {
Object.assign(file, {
brokenSymlink: fileSymlinkInformation(file, allFiles),
+ viewer: {
+ ...file.viewer,
+ ...collapsed(file),
+ },
});
return file;
diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js
deleted file mode 100644
index 8b91543587c..00000000000
--- a/app/assets/javascripts/diffs/i18n.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { __ } from '~/locale';
-
-export const GENERIC_ERROR = __('Something went wrong on our end. Please try again!');
-
-export const DIFF_FILE = {
- blobView: __('You can %{linkStart}view the blob%{linkEnd} instead.'),
- editInFork: __(
- "You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.",
- ),
- fork: __('Fork'),
- cancel: __('Cancel'),
- collapsed: __('This file is collapsed.'),
- expand: __('Expand file'),
-};
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 0f275f1cb3e..966b706fc31 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -103,7 +103,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
commit(types.VIEW_DIFF_FILE, state.diffFiles[0].file_hash);
}
- if (gon.features?.codeNavigation) {
+ if (state.diffFiles?.length) {
// eslint-disable-next-line promise/catch-or-return,promise/no-nesting
import('~/code_navigation').then(m =>
m.default({
@@ -236,7 +236,7 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi
commit(types.RENDER_FILE, file);
}
- if (file.viewer.collapsed) {
+ if (file.viewer.automaticallyCollapsed) {
eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`);
scrollToElement(document.getElementById(file.file_hash));
} else {
@@ -252,7 +252,8 @@ export const startRenderDiffsQueue = ({ state, commit }) => {
const nextFile = state.diffFiles.find(
file =>
!file.renderIt &&
- (file.viewer && (!file.viewer.collapsed || file.viewer.name !== diffViewerModes.text)),
+ (file.viewer &&
+ (!file.viewer.automaticallyCollapsed || file.viewer.name !== diffViewerModes.text)),
);
if (nextFile) {
@@ -631,7 +632,7 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { d
filePath: diffFile.file_path,
viewer: {
...diffFile.alternate_viewer,
- collapsed: false,
+ automaticallyCollapsed: false,
},
});
commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: diffFile.file_path, lines });
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index 42df5873a41..91425c7825b 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -9,7 +9,7 @@ export const isParallelView = state => state.diffViewType === PARALLEL_DIFF_VIEW
export const isInlineView = state => state.diffViewType === INLINE_DIFF_VIEW_TYPE;
export const hasCollapsedFile = state =>
- state.diffFiles.some(file => file.viewer && file.viewer.collapsed);
+ state.diffFiles.some(file => file.viewer && file.viewer.automaticallyCollapsed);
export const commitId = state => (state.commit && state.commit.id ? state.commit.id : null);
@@ -46,15 +46,24 @@ export const diffHasAllCollapsedDiscussions = (state, getters) => diff => {
* @param {Object} diff
* @returns {Boolean}
*/
-export const diffHasExpandedDiscussions = (state, getters) => diff => {
- const discussions = getters.getDiffFileDiscussions(diff);
-
- return (
- (discussions &&
- discussions.length &&
- discussions.find(discussion => discussion.expanded) !== undefined) ||
- false
- );
+export const diffHasExpandedDiscussions = state => diff => {
+ const lines = {
+ [INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [],
+ [PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => {
+ if (line.left) {
+ acc.push(line.left);
+ }
+
+ if (line.right) {
+ acc.push(line.right);
+ }
+
+ return acc;
+ }, []),
+ };
+ return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType]
+ .filter(l => l.discussions.length >= 1)
+ .some(l => l.discussionsExpanded);
};
/**
@@ -62,8 +71,25 @@ export const diffHasExpandedDiscussions = (state, getters) => diff => {
* @param {Boolean} diff
* @returns {Boolean}
*/
-export const diffHasDiscussions = (state, getters) => diff =>
- getters.getDiffFileDiscussions(diff).length > 0;
+export const diffHasDiscussions = state => diff => {
+ const lines = {
+ [INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [],
+ [PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => {
+ if (line.left) {
+ acc.push(line.left);
+ }
+
+ if (line.right) {
+ acc.push(line.right);
+ }
+
+ return acc;
+ }, []),
+ };
+ return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType].some(
+ l => l.discussions.length >= 1,
+ );
+};
/**
* Returns an array with the discussions of the given diff
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 7925c620c4e..13ecf6a997d 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -172,7 +172,7 @@ export default {
state.diffFiles.forEach(file => {
Object.assign(file, {
viewer: Object.assign(file.viewer, {
- collapsed: false,
+ automaticallyCollapsed: false,
}),
});
});
@@ -355,7 +355,7 @@ export default {
const file = state.diffFiles.find(f => f.file_path === filePath);
if (file && file.viewer) {
- file.viewer.collapsed = collapsed;
+ file.viewer.automaticallyCollapsed = collapsed;
}
},
[types.SET_HIDDEN_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) {
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js
index 4567c807c40..1bdc7b3a8b5 100644
--- a/app/assets/javascripts/emoji/index.js
+++ b/app/assets/javascripts/emoji/index.js
@@ -1,53 +1,58 @@
import { uniq } from 'lodash';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
import emojiAliases from 'emojis/aliases.json';
import axios from '../lib/utils/axios_utils';
-
import AccessorUtilities from '../lib/utils/accessor';
let emojiMap = null;
-let emojiPromise = null;
let validEmojiNames = null;
export const EMOJI_VERSION = '1';
const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
-export function initEmojiMap() {
- emojiPromise =
- emojiPromise ||
- new Promise((resolve, reject) => {
- if (emojiMap) {
- resolve(emojiMap);
- } else if (
- isLocalStorageAvailable &&
- window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION &&
- window.localStorage.getItem('gl-emoji-map')
- ) {
- emojiMap = JSON.parse(window.localStorage.getItem('gl-emoji-map'));
- validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
- resolve(emojiMap);
- } else {
- // We load the JSON file direct from the server
- // because it can't be loaded from a CDN due to
- // cross domain problems with JSON
- axios
- .get(`${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`)
- .then(({ data }) => {
- emojiMap = data;
- validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
- resolve(emojiMap);
- if (isLocalStorageAvailable) {
- window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION);
- window.localStorage.setItem('gl-emoji-map', JSON.stringify(emojiMap));
- }
- })
- .catch(err => {
- reject(err);
- });
- }
- });
+async function loadEmoji() {
+ if (
+ isLocalStorageAvailable &&
+ window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION &&
+ window.localStorage.getItem('gl-emoji-map')
+ ) {
+ return JSON.parse(window.localStorage.getItem('gl-emoji-map'));
+ }
- return emojiPromise;
+ // We load the JSON file direct from the server
+ // because it can't be loaded from a CDN due to
+ // cross domain problems with JSON
+ const { data } = await axios.get(
+ `${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`,
+ );
+ window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION);
+ window.localStorage.setItem('gl-emoji-map', JSON.stringify(data));
+ return data;
+}
+
+async function prepareEmojiMap() {
+ emojiMap = await loadEmoji();
+
+ validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
+
+ Object.keys(emojiMap).forEach(name => {
+ emojiMap[name].aliases = [];
+ emojiMap[name].name = name;
+ });
+ Object.entries(emojiAliases).forEach(([alias, name]) => {
+ // This check, `if (name in emojiMap)` is necessary during testing. In
+ // production, it shouldn't be necessary, because at no point should there
+ // be an entry in aliases.json with no corresponding entry in emojis.json.
+ // However, during testing, the endpoint for emojis.json is mocked with a
+ // small dataset, whereas aliases.json is always `import`ed directly.
+ if (name in emojiMap) emojiMap[name].aliases.push(alias);
+ });
+}
+
+export function initEmojiMap() {
+ initEmojiMap.promise = initEmojiMap.promise || prepareEmojiMap();
+ return initEmojiMap.promise;
}
export function normalizeEmojiName(name) {
@@ -62,13 +67,49 @@ export function isEmojiNameValid(name) {
return validEmojiNames.indexOf(name) >= 0;
}
-export function filterEmojiNames(filter) {
- const match = filter.toLowerCase();
- return validEmojiNames.filter(name => name.indexOf(match) >= 0);
+/**
+ * Search emoji by name or alias. Returns a normalized, deduplicated list of
+ * names.
+ *
+ * Calling with an empty filter returns an empty array.
+ *
+ * @param {String}
+ * @returns {Array}
+ */
+export function queryEmojiNames(filter) {
+ const matches = fuzzaldrinPlus.filter(validEmojiNames, filter);
+ return uniq(matches.map(name => normalizeEmojiName(name)));
}
-export function filterEmojiNamesByAlias(filter) {
- return uniq(filterEmojiNames(filter).map(name => normalizeEmojiName(name)));
+/**
+ * Searches emoji by name, alias, description, and unicode value and returns an
+ * array of matches.
+ *
+ * Note: `initEmojiMap` must have been called and completed before this method
+ * can safely be called.
+ *
+ * @param {String} query The search query
+ * @returns {Object[]} A list of emoji that match the query
+ */
+export function searchEmoji(query) {
+ if (!emojiMap)
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ throw new Error('The emoji map is uninitialized or initialization has not completed');
+
+ const matches = s => fuzzaldrinPlus.score(s, query) > 0;
+
+ // Search emoji
+ return Object.values(emojiMap).filter(
+ emoji =>
+ // by name
+ matches(emoji.name) ||
+ // by alias
+ emoji.aliases.some(matches) ||
+ // by description
+ matches(emoji.d) ||
+ // by unicode value
+ query === emoji.e,
+ );
}
let emojiCategoryMap;
diff --git a/app/assets/javascripts/environments/components/enable_review_app_button.vue b/app/assets/javascripts/environments/components/enable_review_app_button.vue
index 8fbbc5189bf..554875b7ce3 100644
--- a/app/assets/javascripts/environments/components/enable_review_app_button.vue
+++ b/app/assets/javascripts/environments/components/enable_review_app_button.vue
@@ -49,7 +49,7 @@ export default {
variant="info"
category="secondary"
type="button"
- class="js-enable-review-app-button"
+ class="gl-w-full js-enable-review-app-button"
>
{{ s__('Environments|Enable review app') }}
</gl-button>
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index f0e74d96f09..18f69855349 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDeprecatedButton } from '@gitlab/ui';
+import { GlBadge, GlButton, GlTab, GlTabs } from '@gitlab/ui';
import { deprecatedCreateFlash as Flash } from '~/flash';
import { s__ } from '~/locale';
import emptyState from './empty_state.vue';
@@ -16,7 +16,10 @@ export default {
ConfirmRollbackModal,
emptyState,
EnableReviewAppButton,
- GlDeprecatedButton,
+ GlBadge,
+ GlButton,
+ GlTab,
+ GlTabs,
StopEnvironmentModal,
DeleteEnvironmentModal,
},
@@ -124,43 +127,87 @@ export default {
};
</script>
<template>
- <div>
+ <div class="environments-section">
<stop-environment-modal :environment="environmentInStopModal" />
<delete-environment-modal :environment="environmentInDeleteModal" />
<confirm-rollback-modal :environment="environmentInRollbackModal" />
- <div class="top-area">
- <tabs :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" />
-
- <div class="nav-controls">
- <enable-review-app-button v-if="state.reviewAppDetails.can_setup_review_app" class="mr-2" />
- <gl-deprecated-button
+ <div class="gl-w-full">
+ <div
+ class="
+ gl-display-flex
+ gl-flex-direction-column
+ gl-mt-3
+ gl-display-md-none!"
+ >
+ <enable-review-app-button
+ v-if="state.reviewAppDetails.can_setup_review_app"
+ class="gl-mb-3 gl-flex-fill-1"
+ />
+ <gl-button
v-if="canCreateEnvironment && !isLoading"
:href="newEnvironmentPath"
category="primary"
variant="success"
>
{{ s__('Environments|New environment') }}
- </gl-deprecated-button>
+ </gl-button>
</div>
+ <gl-tabs content-class="gl-display-none">
+ <gl-tab
+ v-for="(tab, idx) in tabs"
+ :key="idx"
+ :title-item-class="`js-environments-tab-${tab.scope}`"
+ @click="onChangeTab(tab.scope)"
+ >
+ <template #title>
+ <span>{{ tab.name }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge>
+ </template>
+ </gl-tab>
+ <template #tabs-end>
+ <div
+ class="
+ gl-display-none
+ gl-display-md-flex
+ gl-lg-align-items-center
+ gl-lg-flex-direction-row
+ gl-lg-flex-fill-1
+ gl-lg-justify-content-end
+ gl-lg-mt-0"
+ >
+ <enable-review-app-button
+ v-if="state.reviewAppDetails.can_setup_review_app"
+ class="gl-mb-3 gl-lg-mr-3 gl-lg-mb-0"
+ />
+ <gl-button
+ v-if="canCreateEnvironment && !isLoading"
+ :href="newEnvironmentPath"
+ category="primary"
+ variant="success"
+ >
+ {{ s__('Environments|New environment') }}
+ </gl-button>
+ </div>
+ </template>
+ </gl-tabs>
+ <container
+ :is-loading="isLoading"
+ :environments="state.environments"
+ :pagination="state.paginationInformation"
+ :can-read-environment="canReadEnvironment"
+ :canary-deployment-feature-id="canaryDeploymentFeatureId"
+ :show-canary-deployment-callout="showCanaryDeploymentCallout"
+ :user-callouts-path="userCalloutsPath"
+ :lock-promotion-svg-path="lockPromotionSvgPath"
+ :help-canary-deployments-path="helpCanaryDeploymentsPath"
+ :deploy-boards-help-path="deployBoardsHelpPath"
+ @onChangePage="onChangePage"
+ >
+ <template v-if="!isLoading && state.environments.length === 0" #emptyState>
+ <empty-state :help-path="helpPagePath" />
+ </template>
+ </container>
</div>
-
- <container
- :is-loading="isLoading"
- :environments="state.environments"
- :pagination="state.paginationInformation"
- :can-read-environment="canReadEnvironment"
- :canary-deployment-feature-id="canaryDeploymentFeatureId"
- :show-canary-deployment-callout="showCanaryDeploymentCallout"
- :user-callouts-path="userCalloutsPath"
- :lock-promotion-svg-path="lockPromotionSvgPath"
- :help-canary-deployments-path="helpCanaryDeploymentsPath"
- :deploy-boards-help-path="deployBoardsHelpPath"
- @onChangePage="onChangePage"
- >
- <template v-if="!isLoading && state.environments.length === 0" #emptyState>
- <empty-state :help-path="helpPagePath" />
- </template>
- </container>
</div>
</template>
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index c06ab265915..c1b3eabec16 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -184,7 +184,6 @@ export default {
:deploy-boards-help-path="deployBoardsHelpPath"
:is-loading="model.isLoadingDeployBoard"
:is-empty="model.isEmptyDeployBoard"
- :has-legacy-app-label="model.hasLegacyAppLabel"
:logs-path="model.logs_path"
/>
</div>
diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue
index 88612376b6e..892d0b96da1 100644
--- a/app/assets/javascripts/environments/components/stop_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue
@@ -1,8 +1,7 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings, vue/no-v-html */
-import { GlTooltipDirective } from '@gitlab/ui';
+/* eslint-disable @gitlab/vue-require-i18n-strings */
+import { GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
-import { s__, sprintf } from '~/locale';
import eventHub from '../event_hub';
export default {
@@ -11,6 +10,7 @@ export default {
components: {
GlModal: DeprecatedModal2,
+ GlSprintf,
},
directives: {
@@ -24,27 +24,6 @@ export default {
},
},
- computed: {
- noStopActionMessage() {
- return sprintf(
- s__(
- `Environments|Note that this action will stop the environment,
- but it will %{emphasisStart}not%{emphasisEnd} have an effect on any existing deployment
- due to no “stop environment action” being defined
- in the %{ciConfigLinkStart}.gitlab-ci.yml%{ciConfigLinkEnd} file.`,
- ),
- {
- emphasisStart: '<strong>',
- emphasisEnd: '</strong>',
- ciConfigLinkStart:
- '<a href="https://docs.gitlab.com/ee/ci/yaml/" target="_blank" rel="noopener noreferrer">',
- ciConfigLinkEnd: '</a>',
- },
- false,
- );
- },
- },
-
methods: {
onSubmit() {
eventHub.$emit('stopEnvironment', this.environment);
@@ -72,7 +51,25 @@ export default {
<p>{{ s__('Environments|Are you sure you want to stop this environment?') }}</p>
<div v-if="!environment.has_stop_action" class="warning_message">
- <p v-html="noStopActionMessage"></p>
+ <p>
+ <gl-sprintf
+ :message="
+ s__(`Environments|Note that this action will stop the environment,
+ but it will %{emphasisStart}not%{emphasisEnd} have an effect on any existing deployment
+ due to no “stop environment action” being defined
+ in the %{ciConfigLinkStart}.gitlab-ci.yml%{ciConfigLinkEnd} file.`)
+ "
+ >
+ <template #emphasis="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #ciConfigLink="{ content }">
+ <a href="https://docs.gitlab.com/ee/ci/yaml/" target="_blank" rel="noopener noreferrer">
+ {{ content }}</a
+ >
+ </template>
+ </gl-sprintf>
+ </p>
<a
href="https://docs.gitlab.com/ee/ci/environments.html#stopping-an-environment"
target="_blank"
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue
index 16d25615779..061c9ffe8d4 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.vue
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue
@@ -1,4 +1,5 @@
<script>
+import { GlBadge, GlTab, GlTabs } from '@gitlab/ui';
import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import StopEnvironmentModal from '../components/stop_environment_modal.vue';
@@ -6,8 +7,11 @@ import DeleteEnvironmentModal from '../components/delete_environment_modal.vue';
export default {
components: {
- StopEnvironmentModal,
DeleteEnvironmentModal,
+ GlBadge,
+ GlTab,
+ GlTabs,
+ StopEnvironmentModal,
},
mixins: [environmentsMixin, CIPaginationMixin],
@@ -73,9 +77,21 @@ export default {
<b>{{ folderName }}</b>
</h4>
- <div class="top-area">
- <tabs v-if="!isLoading" :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" />
- </div>
+ <gl-tabs v-if="!isLoading" scope="environments" content-class="gl-display-none">
+ <gl-tab
+ v-for="(tab, i) in tabs"
+ :key="`${tab.name}-${i}`"
+ :active="tab.isActive"
+ :title-item-class="tab.isActive ? 'gl-outline-none' : ''"
+ :title-link-attributes="{ 'data-testid': `environments-tab-${tab.scope}` }"
+ @click="onChangeTab(tab.scope)"
+ >
+ <template #title>
+ <span>{{ tab.name }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge>
+ </template>
+ </gl-tab>
+ </gl-tabs>
<container
:is-loading="isLoading"
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
index a4938fe13ed..cd4bb476b6e 100644
--- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
+++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
@@ -94,7 +94,9 @@ export default {
<clipboard-button
:title="__('Copy file path')"
:text="filePath"
- css-class="btn-default btn-transparent btn-clipboard position-static"
+ category="tertiary"
+ size="small"
+ css-class="gl-mr-1"
/>
<gl-sprintf v-if="errorFn" :message="__('%{spanStart}in%{spanEnd} %{errorFn}')">
diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue
index db90ac1c740..786abc8ce49 100644
--- a/app/assets/javascripts/error_tracking_settings/components/app.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/app.vue
@@ -92,15 +92,13 @@ export default {
@select-project="updateSelectedProject"
/>
</div>
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button
- :disabled="settingsLoading"
- class="js-error-tracking-button"
- variant="success"
- @click="handleSubmit"
- >
- {{ __('Save changes') }}
- </gl-button>
- </div>
+ <gl-button
+ :disabled="settingsLoading"
+ class="js-error-tracking-button"
+ variant="success"
+ @click="handleSubmit"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
index f1fb1a44758..b1b699d2e2a 100644
--- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
@@ -1,10 +1,9 @@
<script>
import { mapActions, mapState } from 'vuex';
-import { GlFormInput, GlIcon } from '@gitlab/ui';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import { GlFormInput, GlIcon, GlButton } from '@gitlab/ui';
export default {
- components: { GlFormInput, GlIcon, LoadingButton },
+ components: { GlFormInput, GlIcon, GlButton },
computed: {
...mapState(['apiHost', 'connectError', 'connectSuccessful', 'isLoadingProjects', 'token']),
tokenInputState() {
@@ -57,12 +56,16 @@ export default {
/>
</div>
<div class="col-4 col-md-3 gl-pl-0">
- <loading-button
+ <gl-button
class="js-error-tracking-connect gl-ml-2 d-inline-flex"
- :label="isLoadingProjects ? __('Connecting') : __('Connect')"
+ category="secondary"
+ variant="default"
:loading="isLoadingProjects"
@click="fetchProjects"
- />
+ >
+ {{ isLoadingProjects ? __('Connecting') : __('Connect') }}
+ </gl-button>
+
<gl-icon
v-show="connectSuccessful"
class="js-error-tracking-connect-success gl-ml-2 text-success align-middle"
diff --git a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
new file mode 100644
index 00000000000..b652cb329d7
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
@@ -0,0 +1,254 @@
+<script>
+import {
+ GlFormGroup,
+ GlFormInput,
+ GlModal,
+ GlTooltipDirective,
+ GlLoadingIcon,
+ GlSprintf,
+ GlLink,
+ GlIcon,
+} from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+import Callout from '~/vue_shared/components/callout.vue';
+
+export default {
+ cancelActionLabel: __('Close'),
+ modalTitle: s__('FeatureFlags|Configure feature flags'),
+ apiUrlLabelText: s__('FeatureFlags|API URL'),
+ apiUrlCopyText: __('Copy URL'),
+ instanceIdLabelText: s__('FeatureFlags|Instance ID'),
+ instanceIdCopyText: __('Copy ID'),
+ instanceIdRegenerateError: __('Unable to generate new instance ID'),
+ instanceIdRegenerateText: __(
+ 'Regenerating the instance ID can break integration depending on the client you are using.',
+ ),
+ instanceIdRegenerateActionLabel: __('Regenerate instance ID'),
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ GlModal,
+ ModalCopyButton,
+ GlIcon,
+ Callout,
+ GlLoadingIcon,
+ GlSprintf,
+ GlLink,
+ },
+
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+
+ props: {
+ helpClientLibrariesPath: {
+ type: String,
+ required: true,
+ },
+ helpClientExamplePath: {
+ type: String,
+ required: true,
+ },
+ apiUrl: {
+ type: String,
+ required: true,
+ },
+ instanceId: {
+ type: String,
+ required: true,
+ },
+ modalId: {
+ type: String,
+ required: false,
+ default: 'configure-feature-flags',
+ },
+ isRotating: {
+ type: Boolean,
+ required: true,
+ },
+ hasRotateError: {
+ type: Boolean,
+ required: true,
+ },
+ canUserRotateToken: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ inject: ['projectName', 'featureFlagsHelpPagePath'],
+ data() {
+ return {
+ enteredProjectName: '',
+ };
+ },
+ computed: {
+ cancelActionProps() {
+ return {
+ text: this.$options.cancelActionLabel,
+ };
+ },
+ canRegenerateInstanceId() {
+ return this.canUserRotateToken && this.enteredProjectName === this.projectName;
+ },
+ regenerateInstanceIdActionProps() {
+ return this.canUserRotateToken
+ ? {
+ text: this.$options.instanceIdRegenerateActionLabel,
+ attributes: [
+ {
+ category: 'secondary',
+ disabled: !this.canRegenerateInstanceId,
+ loading: this.isRotating,
+ variant: 'danger',
+ },
+ ],
+ }
+ : null;
+ },
+ },
+
+ methods: {
+ clearState() {
+ this.enteredProjectName = '';
+ },
+ rotateToken() {
+ this.$emit('token');
+ this.clearState();
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ :modal-id="modalId"
+ :action-cancel="cancelActionProps"
+ :action-primary="regenerateInstanceIdActionProps"
+ @canceled="clearState"
+ @hide="clearState"
+ @primary.prevent="rotateToken"
+ >
+ <template #modal-title>
+ {{ $options.modalTitle }}
+ </template>
+ <p>
+ <gl-sprintf
+ :message="
+ s__(
+ 'FeatureFlags|Install a %{docsLinkAnchoredStart}compatible client library%{docsLinkAnchoredEnd} and specify the API URL, application name, and instance ID during the configuration setup. %{docsLinkStart}More Information%{docsLinkEnd}',
+ )
+ "
+ >
+ <template #docsLinkAnchored="{ content }">
+ <gl-link :href="helpClientLibrariesPath" target="_blank" data-testid="help-client-link">
+ {{ content }}
+ </gl-link>
+ </template>
+ <template #docsLink="{ content }">
+ <gl-link :href="featureFlagsHelpPagePath" target="_blank" data-testid="help-link">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <callout category="warning">
+ <gl-sprintf
+ :message="
+ s__(
+ 'FeatureFlags|Set the Unleash client application name to the name of the environment your application runs in. This value is used to match environment scopes. See the %{linkStart}example client configuration%{linkEnd}.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="helpClientExamplePath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </callout>
+ <div class="form-group">
+ <label for="api_url" class="label-bold">{{ $options.apiUrlLabelText }}</label>
+ <div class="input-group">
+ <input
+ id="api_url"
+ :value="apiUrl"
+ readonly
+ class="form-control"
+ type="text"
+ name="api_url"
+ />
+ <span class="input-group-append">
+ <modal-copy-button
+ :text="apiUrl"
+ :title="$options.apiUrlCopyText"
+ :modal-id="modalId"
+ class="input-group-text"
+ />
+ </span>
+ </div>
+ </div>
+ <div class="form-group">
+ <label for="instance_id" class="label-bold">{{ $options.instanceIdLabelText }}</label>
+ <div class="input-group">
+ <input
+ id="instance_id"
+ :value="instanceId"
+ class="form-control"
+ type="text"
+ name="instance_id"
+ readonly
+ :disabled="isRotating"
+ />
+
+ <gl-loading-icon
+ v-if="isRotating"
+ class="position-absolute align-self-center instance-id-loading-icon"
+ />
+
+ <div class="input-group-append">
+ <modal-copy-button
+ :text="instanceId"
+ :title="$options.instanceIdCopyText"
+ :modal-id="modalId"
+ :disabled="isRotating"
+ class="input-group-text"
+ />
+ </div>
+ </div>
+ </div>
+ <div
+ v-if="hasRotateError"
+ class="text-danger d-flex align-items-center font-weight-normal mb-2"
+ >
+ <gl-icon name="warning" class="mr-1" />
+ <span>{{ $options.instanceIdRegenerateError }}</span>
+ </div>
+ <callout
+ v-if="canUserRotateToken"
+ category="danger"
+ :message="$options.instanceIdRegenerateText"
+ />
+ <p v-if="canUserRotateToken" data-testid="prevent-accident-text">
+ <gl-sprintf
+ :message="
+ s__(
+ 'FeatureFlags|To prevent accidental actions we ask you to confirm your intention. Please type %{projectName} to proceed or close this modal to cancel.',
+ )
+ "
+ >
+ <template #projectName>
+ <span class="gl-font-weight-bold gl-text-red-500">{{ projectName }}</span>
+ </template>
+ </gl-sprintf>
+ </p>
+ <gl-form-group>
+ <gl-form-input
+ v-if="canUserRotateToken"
+ id="project_name_verification"
+ v-model="enteredProjectName"
+ name="project_name"
+ type="text"
+ :disabled="isRotating"
+ />
+ </gl-form-group>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
new file mode 100644
index 00000000000..7c9744da0e8
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
@@ -0,0 +1,184 @@
+<script>
+import { GlAlert, GlLoadingIcon, GlToggle } from '@gitlab/ui';
+import { createNamespacedHelpers } from 'vuex';
+import axios from '~/lib/utils/axios_utils';
+import { sprintf, s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { LEGACY_FLAG, NEW_FLAG_ALERT } from '../constants';
+import store from '../store/index';
+import FeatureFlagForm from './form.vue';
+
+const { mapState, mapActions } = createNamespacedHelpers('edit');
+
+export default {
+ store,
+ components: {
+ GlAlert,
+ GlLoadingIcon,
+ GlToggle,
+ FeatureFlagForm,
+ },
+ mixins: [glFeatureFlagMixin()],
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ environmentsEndpoint: {
+ type: String,
+ required: true,
+ },
+ projectId: {
+ type: String,
+ required: true,
+ },
+ featureFlagIssuesEndpoint: {
+ type: String,
+ required: true,
+ },
+ showUserCallout: {
+ type: Boolean,
+ required: true,
+ },
+ userCalloutId: {
+ default: '',
+ type: String,
+ required: false,
+ },
+ userCalloutsPath: {
+ default: '',
+ type: String,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ userShouldSeeNewFlagAlert: this.showUserCallout,
+ };
+ },
+ translations: {
+ legacyFlagAlert: s__(
+ 'FeatureFlags|GitLab is moving to a new way of managing feature flags, and in 13.4, this feature flag will become read-only. Please create a new feature flag.',
+ ),
+ legacyReadOnlyFlagAlert: s__(
+ 'FeatureFlags|GitLab is moving to a new way of managing feature flags. This feature flag is read-only, and it will be removed in 14.0. Please create a new feature flag.',
+ ),
+ newFlagAlert: NEW_FLAG_ALERT,
+ },
+ computed: {
+ ...mapState([
+ 'error',
+ 'name',
+ 'description',
+ 'scopes',
+ 'strategies',
+ 'isLoading',
+ 'hasError',
+ 'iid',
+ 'active',
+ 'version',
+ ]),
+ title() {
+ return this.iid
+ ? `^${this.iid} ${this.name}`
+ : sprintf(s__('Edit %{name}'), { name: this.name });
+ },
+ deprecated() {
+ return this.hasNewVersionFlags && this.version === LEGACY_FLAG;
+ },
+ deprecatedAndEditable() {
+ return this.deprecated && !this.hasLegacyReadOnlyFlags;
+ },
+ deprecatedAndReadOnly() {
+ return this.deprecated && this.hasLegacyReadOnlyFlags;
+ },
+ hasNewVersionFlags() {
+ return this.glFeatures.featureFlagsNewVersion;
+ },
+ hasLegacyReadOnlyFlags() {
+ return (
+ this.glFeatures.featureFlagsLegacyReadOnly &&
+ !this.glFeatures.featureFlagsLegacyReadOnlyOverride
+ );
+ },
+ shouldShowNewFlagAlert() {
+ return !this.hasNewVersionFlags && this.userShouldSeeNewFlagAlert;
+ },
+ },
+ created() {
+ this.setPath(this.path);
+ return this.setEndpoint(this.endpoint).then(() => this.fetchFeatureFlag());
+ },
+ methods: {
+ ...mapActions([
+ 'updateFeatureFlag',
+ 'setEndpoint',
+ 'setPath',
+ 'fetchFeatureFlag',
+ 'toggleActive',
+ ]),
+ dismissNewVersionFlagAlert() {
+ this.userShouldSeeNewFlagAlert = false;
+ axios.post(this.userCalloutsPath, {
+ feature_name: this.userCalloutId,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert
+ v-if="shouldShowNewFlagAlert"
+ variant="warning"
+ class="gl-my-5"
+ @dismiss="dismissNewVersionFlagAlert"
+ >
+ {{ $options.translations.newFlagAlert }}
+ </gl-alert>
+ <gl-loading-icon v-if="isLoading" />
+
+ <template v-else-if="!isLoading && !hasError">
+ <gl-alert v-if="deprecatedAndEditable" variant="warning" :dismissible="false" class="gl-my-5">
+ {{ $options.translations.legacyFlagAlert }}
+ </gl-alert>
+ <gl-alert v-if="deprecatedAndReadOnly" variant="warning" :dismissible="false" class="gl-my-5">
+ {{ $options.translations.legacyReadOnlyFlagAlert }}
+ </gl-alert>
+ <div class="gl-display-flex gl-align-items-center gl-mb-4 gl-mt-4">
+ <gl-toggle
+ :value="active"
+ data-testid="feature-flag-status-toggle"
+ data-track-event="click_button"
+ data-track-label="feature_flag_toggle"
+ class="gl-mr-4"
+ @change="toggleActive"
+ />
+ <h3 class="page-title gl-m-0">{{ title }}</h3>
+ </div>
+
+ <div v-if="error.length" class="alert alert-danger">
+ <p v-for="(message, index) in error" :key="index" class="gl-mb-0">{{ message }}</p>
+ </div>
+
+ <feature-flag-form
+ :name="name"
+ :description="description"
+ :project-id="projectId"
+ :scopes="scopes"
+ :strategies="strategies"
+ :cancel-path="path"
+ :submit-text="__('Save changes')"
+ :environments-endpoint="environmentsEndpoint"
+ :feature-flag-issues-endpoint="featureFlagIssuesEndpoint"
+ :active="active"
+ :version="version"
+ @handleSubmit="data => updateFeatureFlag(data)"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue
new file mode 100644
index 00000000000..3533771e3ad
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue
@@ -0,0 +1,184 @@
+<script>
+import { debounce } from 'lodash';
+import { GlDeprecatedButton, GlSearchBoxByType } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+
+/**
+ * Creates a searchable input for environments.
+ *
+ * When given a value, it will render it as selected value
+ * Otherwise it will render a placeholder for the search input.
+ * It will fetch the available environments on focus.
+ *
+ * When the user types, it will trigger an event to allow
+ * for API queries outside of the component.
+ *
+ * When results are returned, it renders a selectable
+ * list with the suggestions
+ *
+ * When no results are returned, it will render a
+ * button with a `Create` label. When clicked, it will
+ * emit an event to allow for the creation of a new
+ * record.
+ *
+ */
+
+export default {
+ name: 'EnvironmentsSearchableInput',
+ components: {
+ GlDeprecatedButton,
+ GlSearchBoxByType,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ placeholder: {
+ type: String,
+ required: false,
+ default: __('Search an environment spec'),
+ },
+ createButtonLabel: {
+ type: String,
+ required: false,
+ default: __('Create'),
+ },
+ disabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ environmentSearch: this.value,
+ results: [],
+ showSuggestions: false,
+ isLoading: false,
+ };
+ },
+ computed: {
+ /**
+ * Creates a label with the value of the filter
+ * @returns {String}
+ */
+ composedCreateButtonLabel() {
+ return `${this.createButtonLabel} ${this.environmentSearch}`;
+ },
+ shouldRenderCreateButton() {
+ return !this.isLoading && !this.results.length;
+ },
+ },
+ methods: {
+ fetchEnvironments: debounce(function debouncedFetchEnvironments() {
+ this.isLoading = true;
+ this.openSuggestions();
+ axios
+ .get(this.endpoint, { params: { query: this.environmentSearch } })
+ .then(({ data }) => {
+ this.results = data || [];
+ this.isLoading = false;
+ })
+ .catch(() => {
+ this.isLoading = false;
+ this.closeSuggestions();
+ createFlash(__('Something went wrong on our end. Please try again.'));
+ });
+ }, 250),
+ /**
+ * Opens the list of suggestions
+ */
+ openSuggestions() {
+ this.showSuggestions = true;
+ },
+ /**
+ * Closes the list of suggestions and cleans the results
+ */
+ closeSuggestions() {
+ this.showSuggestions = false;
+ this.environmentSearch = '';
+ },
+ /**
+ * On click, it will:
+ * 1. clear the input value
+ * 2. close the list of suggestions
+ * 3. emit an event
+ */
+ clearInput() {
+ this.closeSuggestions();
+ this.$emit('clearInput');
+ },
+ /**
+ * When the user selects a value from the list of suggestions
+ *
+ * It emits an event with the selected value
+ * Clears the filter
+ * and closes the list of suggestions
+ *
+ * @param {String} selected
+ */
+ selectEnvironment(selected) {
+ this.$emit('selectEnvironment', selected);
+ this.results = [];
+ this.closeSuggestions();
+ },
+
+ /**
+ * When the user clicks the create button
+ * it emits an event with the filter value
+ */
+ createClicked() {
+ this.$emit('createClicked', this.environmentSearch);
+ this.closeSuggestions();
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div class="dropdown position-relative">
+ <gl-search-box-by-type
+ v-model.trim="environmentSearch"
+ class="js-env-search"
+ :aria-label="placeholder"
+ :placeholder="placeholder"
+ :disabled="disabled"
+ :is-loading="isLoading"
+ @focus="fetchEnvironments"
+ @keyup="fetchEnvironments"
+ />
+ <div
+ v-if="showSuggestions"
+ class="dropdown-menu d-block dropdown-menu-selectable dropdown-menu-full-width"
+ >
+ <div class="dropdown-content">
+ <ul v-if="results.length">
+ <li v-for="(result, i) in results" :key="i">
+ <gl-deprecated-button class="btn-transparent" @click="selectEnvironment(result)">{{
+ result
+ }}</gl-deprecated-button>
+ </li>
+ </ul>
+ <div v-else-if="!results.length" class="text-secondary gl-p-3">
+ {{ __('No matching results') }}
+ </div>
+ <div v-if="shouldRenderCreateButton" class="dropdown-footer">
+ <gl-deprecated-button
+ class="js-create-button btn-blank dropdown-item"
+ @click="createClicked"
+ >{{ composedCreateButtonLabel }}</gl-deprecated-button
+ >
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue
new file mode 100644
index 00000000000..18008111a18
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue
@@ -0,0 +1,354 @@
+<script>
+import { createNamespacedHelpers } from 'vuex';
+import { isEmpty } from 'lodash';
+import { GlButton, GlModalDirective, GlTabs } from '@gitlab/ui';
+import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../constants';
+import FeatureFlagsTab from './feature_flags_tab.vue';
+import FeatureFlagsTable from './feature_flags_table.vue';
+import UserListsTable from './user_lists_table.vue';
+import store from '../store';
+import { s__ } from '~/locale';
+import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+import {
+ getParameterByName,
+ historyPushState,
+ buildUrlWithCurrentLocation,
+} from '~/lib/utils/common_utils';
+
+import ConfigureFeatureFlagsModal from './configure_feature_flags_modal.vue';
+
+const { mapState, mapActions } = createNamespacedHelpers('index');
+
+const SCOPES = { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE };
+
+export default {
+ store,
+ components: {
+ FeatureFlagsTable,
+ UserListsTable,
+ TablePagination,
+ GlButton,
+ GlTabs,
+ FeatureFlagsTab,
+ ConfigureFeatureFlagsModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ projectId: {
+ type: String,
+ required: true,
+ },
+ csrfToken: {
+ type: String,
+ required: true,
+ },
+ featureFlagsClientLibrariesHelpPagePath: {
+ type: String,
+ required: true,
+ },
+ featureFlagsClientExampleHelpPagePath: {
+ type: String,
+ required: true,
+ },
+ rotateInstanceIdPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ unleashApiUrl: {
+ type: String,
+ required: true,
+ },
+ unleashApiInstanceId: {
+ type: String,
+ required: true,
+ },
+ canUserConfigure: {
+ type: Boolean,
+ required: true,
+ },
+ newFeatureFlagPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ newUserListPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ const scope = getParameterByName('scope') || SCOPES.FEATURE_FLAG_SCOPE;
+ return {
+ scope,
+ page: getParameterByName('page') || '1',
+ isUserListAlertDismissed: false,
+ selectedTab: Object.values(SCOPES).indexOf(scope),
+ };
+ },
+ computed: {
+ ...mapState([
+ FEATURE_FLAG_SCOPE,
+ USER_LIST_SCOPE,
+ 'alerts',
+ 'count',
+ 'pageInfo',
+ 'isLoading',
+ 'hasError',
+ 'options',
+ 'instanceId',
+ 'isRotating',
+ 'hasRotateError',
+ ]),
+ topAreaBaseClasses() {
+ return ['gl-display-flex', 'gl-flex-direction-column'];
+ },
+ canUserRotateToken() {
+ return this.rotateInstanceIdPath !== '';
+ },
+ currentlyDisplayedData() {
+ return this.dataForScope(this.scope);
+ },
+ shouldRenderPagination() {
+ return (
+ !this.isLoading &&
+ !this.hasError &&
+ this.currentlyDisplayedData.length > 0 &&
+ this.pageInfo[this.scope].total > this.pageInfo[this.scope].perPage
+ );
+ },
+ shouldShowEmptyState() {
+ return !this.isLoading && !this.hasError && this.currentlyDisplayedData.length === 0;
+ },
+ shouldRenderErrorState() {
+ return this.hasError && !this.isLoading;
+ },
+ shouldRenderFeatureFlags() {
+ return this.shouldRenderTable(SCOPES.FEATURE_FLAG_SCOPE);
+ },
+ shouldRenderUserLists() {
+ return this.shouldRenderTable(SCOPES.USER_LIST_SCOPE);
+ },
+ hasNewPath() {
+ return !isEmpty(this.newFeatureFlagPath);
+ },
+ emptyStateTitle() {
+ return s__('FeatureFlags|Get started with feature flags');
+ },
+ },
+ created() {
+ this.setFeatureFlagsEndpoint(this.endpoint);
+ this.setFeatureFlagsOptions({ scope: this.scope, page: this.page });
+ this.setProjectId(this.projectId);
+ this.fetchFeatureFlags();
+ this.fetchUserLists();
+ this.setInstanceId(this.unleashApiInstanceId);
+ this.setInstanceIdEndpoint(this.rotateInstanceIdPath);
+ },
+ methods: {
+ ...mapActions([
+ 'setFeatureFlagsEndpoint',
+ 'setFeatureFlagsOptions',
+ 'fetchFeatureFlags',
+ 'fetchUserLists',
+ 'setInstanceIdEndpoint',
+ 'setInstanceId',
+ 'setProjectId',
+ 'rotateInstanceId',
+ 'toggleFeatureFlag',
+ 'deleteUserList',
+ 'clearAlert',
+ ]),
+ onChangeTab(scope) {
+ this.scope = scope;
+ this.updateFeatureFlagOptions({
+ scope,
+ page: '1',
+ });
+ },
+ onFeatureFlagsTab() {
+ this.onChangeTab(SCOPES.FEATURE_FLAG_SCOPE);
+ },
+ onUserListsTab() {
+ this.onChangeTab(SCOPES.USER_LIST_SCOPE);
+ },
+ onChangePage(page) {
+ this.updateFeatureFlagOptions({
+ scope: this.scope,
+ /* URLS parameters are strings, we need to parse to match types */
+ page: Number(page).toString(),
+ });
+ },
+ updateFeatureFlagOptions(parameters) {
+ const queryString = Object.keys(parameters)
+ .map(parameter => {
+ const value = parameters[parameter];
+ return `${parameter}=${encodeURIComponent(value)}`;
+ })
+ .join('&');
+
+ historyPushState(buildUrlWithCurrentLocation(`?${queryString}`));
+ this.setFeatureFlagsOptions(parameters);
+ if (this.scope === SCOPES.FEATURE_FLAG_SCOPE) {
+ this.fetchFeatureFlags();
+ } else {
+ this.fetchUserLists();
+ }
+ },
+ shouldRenderTable(scope) {
+ return (
+ !this.isLoading &&
+ this.dataForScope(scope).length > 0 &&
+ !this.hasError &&
+ this.scope === scope
+ );
+ },
+ dataForScope(scope) {
+ return this[scope];
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <configure-feature-flags-modal
+ v-if="canUserConfigure"
+ :help-client-libraries-path="featureFlagsClientLibrariesHelpPagePath"
+ :help-client-example-path="featureFlagsClientExampleHelpPagePath"
+ :api-url="unleashApiUrl"
+ :instance-id="instanceId"
+ :is-rotating="isRotating"
+ :has-rotate-error="hasRotateError"
+ :can-user-rotate-token="canUserRotateToken"
+ modal-id="configure-feature-flags"
+ @token="rotateInstanceId()"
+ />
+ <div :class="topAreaBaseClasses">
+ <div class="gl-display-flex gl-flex-direction-column gl-display-md-none!">
+ <gl-button
+ v-if="canUserConfigure"
+ v-gl-modal="'configure-feature-flags'"
+ variant="info"
+ category="secondary"
+ data-qa-selector="configure_feature_flags_button"
+ data-testid="ff-configure-button"
+ class="gl-mb-3"
+ >
+ {{ s__('FeatureFlags|Configure') }}
+ </gl-button>
+
+ <gl-button
+ v-if="newUserListPath"
+ :href="newUserListPath"
+ variant="success"
+ category="secondary"
+ class="gl-mb-3"
+ data-testid="ff-new-list-button"
+ >
+ {{ s__('FeatureFlags|New user list') }}
+ </gl-button>
+
+ <gl-button
+ v-if="hasNewPath"
+ :href="newFeatureFlagPath"
+ variant="success"
+ data-testid="ff-new-button"
+ >
+ {{ s__('FeatureFlags|New feature flag') }}
+ </gl-button>
+ </div>
+ <gl-tabs v-model="selectedTab" class="gl-align-items-center gl-w-full">
+ <feature-flags-tab
+ :title="s__('FeatureFlags|Feature Flags')"
+ :count="count.featureFlags"
+ :alerts="alerts"
+ :is-loading="isLoading"
+ :loading-label="s__('FeatureFlags|Loading feature flags')"
+ :error-state="shouldRenderErrorState"
+ :error-title="s__(`FeatureFlags|There was an error fetching the feature flags.`)"
+ :empty-state="shouldShowEmptyState"
+ :empty-title="emptyStateTitle"
+ data-testid="feature-flags-tab"
+ @dismissAlert="clearAlert"
+ @changeTab="onFeatureFlagsTab"
+ >
+ <feature-flags-table
+ v-if="shouldRenderFeatureFlags"
+ :csrf-token="csrfToken"
+ :feature-flags="featureFlags"
+ @toggle-flag="toggleFeatureFlag"
+ />
+ </feature-flags-tab>
+ <feature-flags-tab
+ :title="s__('FeatureFlags|User Lists')"
+ :count="count.userLists"
+ :alerts="alerts"
+ :is-loading="isLoading"
+ :loading-label="s__('FeatureFlags|Loading user lists')"
+ :error-state="shouldRenderErrorState"
+ :error-title="s__(`FeatureFlags|There was an error fetching the user lists.`)"
+ :empty-state="shouldShowEmptyState"
+ :empty-title="emptyStateTitle"
+ data-testid="user-lists-tab"
+ @dismissAlert="clearAlert"
+ @changeTab="onUserListsTab"
+ >
+ <user-lists-table
+ v-if="shouldRenderUserLists"
+ :user-lists="userLists"
+ @delete="deleteUserList"
+ />
+ </feature-flags-tab>
+ <template #tabs-end>
+ <div
+ class="gl-display-none gl-display-md-flex gl-align-items-center gl-flex-fill-1 gl-justify-content-end"
+ >
+ <gl-button
+ v-if="canUserConfigure"
+ v-gl-modal="'configure-feature-flags'"
+ variant="info"
+ category="secondary"
+ data-qa-selector="configure_feature_flags_button"
+ data-testid="ff-configure-button"
+ class="gl-mb-0 gl-mr-4"
+ >
+ {{ s__('FeatureFlags|Configure') }}
+ </gl-button>
+
+ <gl-button
+ v-if="newUserListPath"
+ :href="newUserListPath"
+ variant="success"
+ category="secondary"
+ class="gl-mb-0 gl-mr-4"
+ data-testid="ff-new-list-button"
+ >
+ {{ s__('FeatureFlags|New user list') }}
+ </gl-button>
+
+ <gl-button
+ v-if="hasNewPath"
+ :href="newFeatureFlagPath"
+ variant="success"
+ data-testid="ff-new-button"
+ >
+ {{ s__('FeatureFlags|New feature flag') }}
+ </gl-button>
+ </div>
+ </template>
+ </gl-tabs>
+ </div>
+ <table-pagination
+ v-if="shouldRenderPagination"
+ :change="onChangePage"
+ :page-info="pageInfo[scope]"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue b/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue
new file mode 100644
index 00000000000..5c35aa33e14
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue
@@ -0,0 +1,108 @@
+<script>
+import { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTab } from '@gitlab/ui';
+
+export default {
+ components: { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTab },
+ props: {
+ title: {
+ required: true,
+ type: String,
+ },
+ count: {
+ required: false,
+ type: Number,
+ default: null,
+ },
+ alerts: {
+ required: true,
+ type: Array,
+ },
+ isLoading: {
+ required: true,
+ type: Boolean,
+ },
+ loadingLabel: {
+ required: true,
+ type: String,
+ },
+ errorState: {
+ required: true,
+ type: Boolean,
+ },
+ errorTitle: {
+ required: true,
+ type: String,
+ },
+ emptyState: {
+ required: true,
+ type: Boolean,
+ },
+ emptyTitle: {
+ required: true,
+ type: String,
+ },
+ },
+ inject: ['errorStateSvgPath', 'featureFlagsHelpPagePath'],
+ computed: {
+ itemCount() {
+ return this.count ?? 0;
+ },
+ },
+ methods: {
+ clearAlert(index) {
+ this.$emit('dismissAlert', index);
+ },
+ onClick(event) {
+ return this.$emit('changeTab', event);
+ },
+ },
+};
+</script>
+<template>
+ <gl-tab @click="onClick">
+ <template #title>
+ <span data-testid="feature-flags-tab-title">{{ title }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ itemCount }}</gl-badge>
+ </template>
+ <template>
+ <gl-alert
+ v-for="(message, index) in alerts"
+ :key="index"
+ data-testid="serverErrors"
+ variant="danger"
+ @dismiss="clearAlert(index)"
+ >
+ {{ message }}
+ </gl-alert>
+
+ <gl-loading-icon v-if="isLoading" :label="loadingLabel" size="md" class="gl-mt-4" />
+
+ <gl-empty-state
+ v-else-if="errorState"
+ :title="errorTitle"
+ :description="s__(`FeatureFlags|Try again in a few moments or contact your support team.`)"
+ :svg-path="errorStateSvgPath"
+ data-testid="error-state"
+ />
+
+ <gl-empty-state
+ v-else-if="emptyState"
+ :title="emptyTitle"
+ :svg-path="errorStateSvgPath"
+ data-testid="empty-state"
+ >
+ <template #description>
+ {{
+ s__(
+ 'FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.',
+ )
+ }}
+ <gl-link :href="featureFlagsHelpPagePath" target="_blank">
+ {{ s__('FeatureFlags|More information') }}
+ </gl-link>
+ </template>
+ </gl-empty-state>
+ <slot> </slot>
+ </template>
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
new file mode 100644
index 00000000000..7881ae523fc
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
@@ -0,0 +1,274 @@
+<script>
+import { GlBadge, GlButton, GlTooltipDirective, GlModal, GlToggle, GlIcon } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { ROLLOUT_STRATEGY_PERCENT_ROLLOUT, NEW_VERSION_FLAG, LEGACY_FLAG } from '../constants';
+import labelForStrategy from '../utils';
+
+export default {
+ components: {
+ GlBadge,
+ GlButton,
+ GlIcon,
+ GlModal,
+ GlToggle,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [glFeatureFlagMixin()],
+ props: {
+ csrfToken: {
+ type: String,
+ required: true,
+ },
+ featureFlags: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ deleteFeatureFlagUrl: null,
+ deleteFeatureFlagName: null,
+ };
+ },
+ translations: {
+ legacyFlagAlert: s__('FeatureFlags|Flag becomes read only soon'),
+ legacyFlagReadOnlyAlert: s__('FeatureFlags|Flag is read-only'),
+ },
+ computed: {
+ permissions() {
+ return this.glFeatures.featureFlagPermissions;
+ },
+ isNewVersionFlagsEnabled() {
+ return this.glFeatures.featureFlagsNewVersion;
+ },
+ isLegacyReadOnlyFlagsEnabled() {
+ return (
+ this.glFeatures.featureFlagsLegacyReadOnly &&
+ !this.glFeatures.featureFlagsLegacyReadOnlyOverride
+ );
+ },
+ modalTitle() {
+ return sprintf(s__('FeatureFlags|Delete %{name}?'), {
+ name: this.deleteFeatureFlagName,
+ });
+ },
+ deleteModalMessage() {
+ return sprintf(s__('FeatureFlags|Feature flag %{name} will be removed. Are you sure?'), {
+ name: this.deleteFeatureFlagName,
+ });
+ },
+ modalId() {
+ return 'delete-feature-flag';
+ },
+ legacyFlagToolTipText() {
+ const { legacyFlagReadOnlyAlert, legacyFlagAlert } = this.$options.translations;
+
+ return this.isLegacyReadOnlyFlagsEnabled ? legacyFlagReadOnlyAlert : legacyFlagAlert;
+ },
+ },
+ methods: {
+ isLegacyFlag(flag) {
+ return !this.isNewVersionFlagsEnabled || flag.version !== NEW_VERSION_FLAG;
+ },
+ statusToggleDisabled(flag) {
+ return this.isLegacyReadOnlyFlagsEnabled && flag.version === LEGACY_FLAG;
+ },
+ scopeTooltipText(scope) {
+ return !scope.active
+ ? sprintf(s__('FeatureFlags|Inactive flag for %{scope}'), {
+ scope: scope.environmentScope,
+ })
+ : '';
+ },
+ badgeText(scope) {
+ const displayName =
+ scope.environmentScope === '*'
+ ? s__('FeatureFlags|* (All environments)')
+ : scope.environmentScope;
+
+ const displayPercentage =
+ scope.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT
+ ? `: ${scope.rolloutPercentage}%`
+ : '';
+
+ return `${displayName}${displayPercentage}`;
+ },
+ badgeVariant(scope) {
+ return scope.active ? 'info' : 'muted';
+ },
+ strategyBadgeText(strategy) {
+ return labelForStrategy(strategy);
+ },
+ featureFlagIidText(featureFlag) {
+ return featureFlag.iid ? `^${featureFlag.iid}` : '';
+ },
+ canDeleteFlag(flag) {
+ return !this.permissions || (flag.scopes || []).every(scope => scope.can_update);
+ },
+ setDeleteModalData(featureFlag) {
+ this.deleteFeatureFlagUrl = featureFlag.destroy_path;
+ this.deleteFeatureFlagName = featureFlag.name;
+
+ this.$refs[this.modalId].show();
+ },
+ onSubmit() {
+ this.$refs.form.submit();
+ },
+ toggleFeatureFlag(flag) {
+ this.$emit('toggle-flag', {
+ ...flag,
+ active: !flag.active,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div class="table-holder js-feature-flag-table">
+ <div class="gl-responsive-table-row table-row-header" role="row">
+ <div class="table-section section-10">
+ {{ s__('FeatureFlags|ID') }}
+ </div>
+ <div class="table-section section-10" role="columnheader">
+ {{ s__('FeatureFlags|Status') }}
+ </div>
+ <div class="table-section section-20" role="columnheader">
+ {{ s__('FeatureFlags|Feature Flag') }}
+ </div>
+ <div class="table-section section-40" role="columnheader">
+ {{ s__('FeatureFlags|Environment Specs') }}
+ </div>
+ </div>
+
+ <template v-for="featureFlag in featureFlags">
+ <div :key="featureFlag.id" class="gl-responsive-table-row" role="row">
+ <div class="table-section section-10" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|ID') }}</div>
+ <div class="table-mobile-content js-feature-flag-id">
+ {{ featureFlagIidText(featureFlag) }}
+ </div>
+ </div>
+ <div class="table-section section-10" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|Status') }}</div>
+ <div class="table-mobile-content">
+ <gl-toggle
+ v-if="featureFlag.update_path"
+ :value="featureFlag.active"
+ :disabled="statusToggleDisabled(featureFlag)"
+ data-testid="feature-flag-status-toggle"
+ data-track-event="click_button"
+ data-track-label="feature_flag_toggle"
+ @change="toggleFeatureFlag(featureFlag)"
+ />
+ <gl-badge
+ v-else-if="featureFlag.active"
+ variant="success"
+ data-testid="feature-flag-status-badge"
+ >
+ {{ s__('FeatureFlags|Active') }}
+ </gl-badge>
+ <gl-badge v-else variant="danger">{{ s__('FeatureFlags|Inactive') }}</gl-badge>
+ </div>
+ </div>
+
+ <div class="table-section section-20" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Feature Flag') }}
+ </div>
+ <div class="table-mobile-content d-flex flex-column js-feature-flag-title">
+ <div class="gl-display-flex gl-align-items-center">
+ <div class="feature-flag-name text-monospace text-truncate">
+ {{ featureFlag.name }}
+ </div>
+ <gl-icon
+ v-if="isLegacyFlag(featureFlag)"
+ v-gl-tooltip.hover="legacyFlagToolTipText"
+ class="gl-ml-3"
+ name="information-o"
+ />
+ </div>
+ <div class="feature-flag-description text-secondary text-truncate">
+ {{ featureFlag.description }}
+ </div>
+ </div>
+ </div>
+
+ <div class="table-section section-40" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Environment Specs') }}
+ </div>
+ <div
+ class="table-mobile-content d-flex flex-wrap justify-content-end justify-content-md-start js-feature-flag-environments"
+ >
+ <template v-if="isLegacyFlag(featureFlag)">
+ <gl-badge
+ v-for="scope in featureFlag.scopes"
+ :key="scope.id"
+ v-gl-tooltip.hover="scopeTooltipText(scope)"
+ :variant="badgeVariant(scope)"
+ :data-qa-selector="`feature-flag-scope-${badgeVariant(scope)}-badge`"
+ class="gl-mr-3 gl-mt-2"
+ >
+ {{ badgeText(scope) }}
+ </gl-badge>
+ </template>
+ <template v-else>
+ <gl-badge
+ v-for="strategy in featureFlag.strategies"
+ :key="strategy.id"
+ data-testid="strategy-badge"
+ variant="info"
+ class="gl-mr-3 gl-mt-2"
+ >
+ {{ strategyBadgeText(strategy) }}
+ </gl-badge>
+ </template>
+ </div>
+ </div>
+
+ <div class="table-section section-20 table-button-footer" role="gridcell">
+ <div class="table-action-buttons btn-group">
+ <template v-if="featureFlag.edit_path">
+ <gl-button
+ v-gl-tooltip.hover.bottom="__('Edit')"
+ class="js-feature-flag-edit-button"
+ icon="pencil"
+ :href="featureFlag.edit_path"
+ />
+ </template>
+ <template v-if="featureFlag.destroy_path">
+ <gl-button
+ v-gl-tooltip.hover.bottom="__('Delete')"
+ class="js-feature-flag-delete-button"
+ variant="danger"
+ icon="remove"
+ :disabled="!canDeleteFlag(featureFlag)"
+ @click="setDeleteModalData(featureFlag)"
+ />
+ </template>
+ </div>
+ </div>
+ </div>
+ </template>
+
+ <gl-modal
+ :ref="modalId"
+ :title="modalTitle"
+ :ok-title="s__('FeatureFlags|Delete feature flag')"
+ :modal-id="modalId"
+ title-tag="h4"
+ ok-variant="danger"
+ category="primary"
+ @ok="onSubmit"
+ >
+ {{ deleteModalMessage }}
+ <form ref="form" :action="deleteFeatureFlagUrl" method="post" class="js-requires-input">
+ <input ref="method" type="hidden" name="_method" value="delete" />
+ <input :value="csrfToken" type="hidden" name="authenticity_token" />
+ </form>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue
new file mode 100644
index 00000000000..04bea2d80d4
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/form.vue
@@ -0,0 +1,616 @@
+<script>
+import Vue from 'vue';
+import { memoize, isString, cloneDeep, isNumber, uniqueId } from 'lodash';
+import {
+ GlButton,
+ GlDeprecatedBadge as GlBadge,
+ GlTooltip,
+ GlTooltipDirective,
+ GlFormTextarea,
+ GlFormCheckbox,
+ GlSprintf,
+ GlIcon,
+} from '@gitlab/ui';
+import Api from '~/api';
+import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue';
+import { s__ } from '~/locale';
+import { deprecatedCreateFlash as flash, FLASH_TYPES } from '~/flash';
+import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import ToggleButton from '~/vue_shared/components/toggle_button.vue';
+import EnvironmentsDropdown from './environments_dropdown.vue';
+import Strategy from './strategy.vue';
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ ALL_ENVIRONMENTS_NAME,
+ INTERNAL_ID_PREFIX,
+ NEW_VERSION_FLAG,
+ LEGACY_FLAG,
+} from '../constants';
+import { createNewEnvironmentScope } from '../store/modules/helpers';
+
+export default {
+ components: {
+ GlButton,
+ GlBadge,
+ GlFormTextarea,
+ GlFormCheckbox,
+ GlTooltip,
+ GlSprintf,
+ GlIcon,
+ ToggleButton,
+ EnvironmentsDropdown,
+ Strategy,
+ RelatedIssuesRoot,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [featureFlagsMixin()],
+ props: {
+ active: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ name: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ description: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ projectId: {
+ type: String,
+ required: true,
+ },
+ scopes: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ cancelPath: {
+ type: String,
+ required: true,
+ },
+ submitText: {
+ type: String,
+ required: true,
+ },
+ environmentsEndpoint: {
+ type: String,
+ required: true,
+ },
+ featureFlagIssuesEndpoint: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ strategies: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ version: {
+ type: String,
+ required: false,
+ default: LEGACY_FLAG,
+ },
+ },
+ translations: {
+ allEnvironmentsText: s__('FeatureFlags|* (All Environments)'),
+
+ helpText: s__(
+ 'FeatureFlags|Feature Flag behavior is built up by creating a set of rules to define the status of target environments. A default wildcard rule %{codeStart}*%{codeEnd} for %{boldStart}All Environments%{boldEnd} is set, and you are able to add as many rules as you need by choosing environment specs below. You can toggle the behavior for each of your rules to set them %{boldStart}Active%{boldEnd} or %{boldStart}Inactive%{boldEnd}.',
+ ),
+
+ newHelpText: s__(
+ 'FeatureFlags|Enable features for specific users and environments by configuring feature flag strategies.',
+ ),
+ noStrategiesText: s__('FeatureFlags|Feature Flag has no strategies'),
+ },
+
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+
+ // Matches numbers 0 through 100
+ rolloutPercentageRegex: /^[0-9]$|^[1-9][0-9]$|^100$/,
+
+ data() {
+ return {
+ formName: this.name,
+ formDescription: this.description,
+
+ // operate on a clone to avoid mutating props
+ formScopes: this.scopes.map(s => ({ ...s })),
+ formStrategies: cloneDeep(this.strategies),
+
+ newScope: '',
+ userLists: [],
+ };
+ },
+ computed: {
+ filteredScopes() {
+ return this.formScopes.filter(scope => !scope.shouldBeDestroyed);
+ },
+ filteredStrategies() {
+ return this.formStrategies.filter(s => !s.shouldBeDestroyed);
+ },
+ canUpdateFlag() {
+ return !this.permissionsFlag || (this.formScopes || []).every(scope => scope.canUpdate);
+ },
+ permissionsFlag() {
+ return this.glFeatures.featureFlagPermissions;
+ },
+ supportsStrategies() {
+ return this.glFeatures.featureFlagsNewVersion && this.version === NEW_VERSION_FLAG;
+ },
+ showRelatedIssues() {
+ return this.featureFlagIssuesEndpoint.length > 0;
+ },
+ readOnly() {
+ return (
+ this.glFeatures.featureFlagsNewVersion &&
+ this.glFeatures.featureFlagsLegacyReadOnly &&
+ !this.glFeatures.featureFlagsLegacyReadOnlyOverride &&
+ this.version === LEGACY_FLAG
+ );
+ },
+ },
+ mounted() {
+ if (this.supportsStrategies) {
+ Api.fetchFeatureFlagUserLists(this.projectId)
+ .then(({ data }) => {
+ this.userLists = data;
+ })
+ .catch(() => {
+ flash(s__('FeatureFlags|There was an error retrieving user lists'), FLASH_TYPES.WARNING);
+ });
+ }
+ },
+ methods: {
+ keyFor(strategy) {
+ if (strategy.id) {
+ return strategy.id;
+ }
+
+ return uniqueId('strategy_');
+ },
+
+ addStrategy() {
+ this.formStrategies.push({ name: ROLLOUT_STRATEGY_ALL_USERS, parameters: {}, scopes: [] });
+ },
+
+ deleteStrategy(s) {
+ if (isNumber(s.id)) {
+ Vue.set(s, 'shouldBeDestroyed', true);
+ } else {
+ this.formStrategies = this.formStrategies.filter(strategy => strategy !== s);
+ }
+ },
+
+ isAllEnvironment(name) {
+ return name === ALL_ENVIRONMENTS_NAME;
+ },
+
+ /**
+ * When the user clicks the remove button we delete the scope
+ *
+ * If the scope has an ID, we need to add the `shouldBeDestroyed` flag.
+ * If the scope does *not* have an ID, we can just remove it.
+ *
+ * This flag will be used when submitting the data to the backend
+ * to determine which records to delete (via a "_destroy" property).
+ *
+ * @param {Object} scope
+ */
+ removeScope(scope) {
+ if (isString(scope.id) && scope.id.startsWith(INTERNAL_ID_PREFIX)) {
+ this.formScopes = this.formScopes.filter(s => s !== scope);
+ } else {
+ Vue.set(scope, 'shouldBeDestroyed', true);
+ }
+ },
+
+ /**
+ * Creates a new scope and adds it to the list of scopes
+ *
+ * @param overrides An object whose properties will
+ * be used override the default scope options
+ */
+ createNewScope(overrides) {
+ this.formScopes.push(createNewEnvironmentScope(overrides, this.permissionsFlag));
+ this.newScope = '';
+ },
+
+ /**
+ * When the user clicks the submit button
+ * it triggers an event with the form data
+ */
+ handleSubmit() {
+ const flag = {
+ name: this.formName,
+ description: this.formDescription,
+ active: this.active,
+ version: this.version,
+ };
+
+ if (this.version === LEGACY_FLAG) {
+ flag.scopes = this.formScopes;
+ } else {
+ flag.strategies = this.formStrategies;
+ }
+
+ this.$emit('handleSubmit', flag);
+ },
+
+ canUpdateScope(scope) {
+ return !this.permissionsFlag || scope.canUpdate;
+ },
+
+ isRolloutPercentageInvalid: memoize(function isRolloutPercentageInvalid(percentage) {
+ return !this.$options.rolloutPercentageRegex.test(percentage);
+ }),
+
+ /**
+ * Generates a unique ID for the strategy based on the v-for index
+ *
+ * @param index The index of the strategy
+ */
+ rolloutStrategyId(index) {
+ return `rollout-strategy-${index}`;
+ },
+
+ /**
+ * Generates a unique ID for the percentage based on the v-for index
+ *
+ * @param index The index of the percentage
+ */
+ rolloutPercentageId(index) {
+ return `rollout-percentage-${index}`;
+ },
+ rolloutUserId(index) {
+ return `rollout-user-id-${index}`;
+ },
+
+ shouldDisplayIncludeUserIds(scope) {
+ return ![ROLLOUT_STRATEGY_ALL_USERS, ROLLOUT_STRATEGY_USER_ID].includes(
+ scope.rolloutStrategy,
+ );
+ },
+ shouldDisplayUserIds(scope) {
+ return scope.rolloutStrategy === ROLLOUT_STRATEGY_USER_ID || scope.shouldIncludeUserIds;
+ },
+ onStrategyChange(index) {
+ const scope = this.filteredScopes[index];
+ scope.shouldIncludeUserIds =
+ scope.rolloutUserIds.length > 0 &&
+ scope.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT;
+ },
+ onFormStrategyChange(strategy, index) {
+ Object.assign(this.filteredStrategies[index], strategy);
+ },
+ },
+};
+</script>
+<template>
+ <form class="feature-flags-form">
+ <fieldset>
+ <div class="row">
+ <div class="form-group col-md-4">
+ <label for="feature-flag-name" class="label-bold">{{ s__('FeatureFlags|Name') }} *</label>
+ <input
+ id="feature-flag-name"
+ v-model="formName"
+ :disabled="!canUpdateFlag"
+ class="form-control"
+ />
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="form-group col-md-4">
+ <label for="feature-flag-description" class="label-bold">
+ {{ s__('FeatureFlags|Description') }}
+ </label>
+ <textarea
+ id="feature-flag-description"
+ v-model="formDescription"
+ :disabled="!canUpdateFlag"
+ class="form-control"
+ rows="4"
+ ></textarea>
+ </div>
+ </div>
+
+ <related-issues-root
+ v-if="showRelatedIssues"
+ :endpoint="featureFlagIssuesEndpoint"
+ :can-admin="true"
+ :show-categorized-issues="false"
+ />
+
+ <template v-if="supportsStrategies">
+ <div class="row">
+ <div class="col-md-12">
+ <h4>{{ s__('FeatureFlags|Strategies') }}</h4>
+ <div class="flex align-items-baseline justify-content-between">
+ <p class="mr-3">{{ $options.translations.newHelpText }}</p>
+ <gl-button variant="success" category="secondary" @click="addStrategy">
+ {{ s__('FeatureFlags|Add strategy') }}
+ </gl-button>
+ </div>
+ </div>
+ </div>
+ <div v-if="filteredStrategies.length > 0" data-testid="feature-flag-strategies">
+ <strategy
+ v-for="(strategy, index) in filteredStrategies"
+ :key="keyFor(strategy)"
+ :strategy="strategy"
+ :index="index"
+ :endpoint="environmentsEndpoint"
+ :user-lists="userLists"
+ @change="onFormStrategyChange($event, index)"
+ @delete="deleteStrategy(strategy)"
+ />
+ </div>
+ <div v-else class="flex justify-content-center border-top py-4 w-100">
+ <span>{{ $options.translations.noStrategiesText }}</span>
+ </div>
+ </template>
+
+ <div v-else class="row">
+ <div class="form-group col-md-12">
+ <h4>{{ s__('FeatureFlags|Target environments') }}</h4>
+ <gl-sprintf :message="$options.translations.helpText">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ <template #bold="{ content }">
+ <b>{{ content }}</b>
+ </template>
+ </gl-sprintf>
+
+ <div class="js-scopes-table gl-mt-3">
+ <div class="gl-responsive-table-row table-row-header" role="row">
+ <div class="table-section section-30" role="columnheader">
+ {{ s__('FeatureFlags|Environment Spec') }}
+ </div>
+ <div class="table-section section-20 text-center" role="columnheader">
+ {{ s__('FeatureFlags|Status') }}
+ </div>
+ <div class="table-section section-40" role="columnheader">
+ {{ s__('FeatureFlags|Rollout Strategy') }}
+ </div>
+ </div>
+
+ <div
+ v-for="(scope, index) in filteredScopes"
+ :key="scope.id"
+ ref="scopeRow"
+ class="gl-responsive-table-row"
+ role="row"
+ >
+ <div class="table-section section-30" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Environment Spec') }}
+ </div>
+ <div
+ class="table-mobile-content js-feature-flag-status d-flex align-items-center justify-content-start"
+ >
+ <p v-if="isAllEnvironment(scope.environmentScope)" class="js-scope-all pl-3">
+ {{ $options.translations.allEnvironmentsText }}
+ </p>
+
+ <environments-dropdown
+ v-else
+ class="col-12"
+ :value="scope.environmentScope"
+ :endpoint="environmentsEndpoint"
+ :disabled="!canUpdateScope(scope) || scope.environmentScope !== ''"
+ @selectEnvironment="env => (scope.environmentScope = env)"
+ @createClicked="env => (scope.environmentScope = env)"
+ @clearInput="env => (scope.environmentScope = '')"
+ />
+
+ <gl-badge v-if="permissionsFlag && scope.protected" variant="success">
+ {{ s__('FeatureFlags|Protected') }}
+ </gl-badge>
+ </div>
+ </div>
+
+ <div class="table-section section-20 text-center" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Status') }}
+ </div>
+ <div class="table-mobile-content js-feature-flag-status">
+ <toggle-button
+ :value="scope.active"
+ :disabled-input="!active || !canUpdateScope(scope)"
+ @change="status => (scope.active = status)"
+ />
+ </div>
+ </div>
+
+ <div class="table-section section-40" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Rollout Strategy') }}
+ </div>
+ <div class="table-mobile-content js-rollout-strategy form-inline">
+ <label class="sr-only" :for="rolloutStrategyId(index)">
+ {{ s__('FeatureFlags|Rollout Strategy') }}
+ </label>
+ <div class="select-wrapper col-12 col-md-8 p-0">
+ <select
+ :id="rolloutStrategyId(index)"
+ v-model="scope.rolloutStrategy"
+ :disabled="!scope.active"
+ class="form-control select-control w-100 js-rollout-strategy"
+ @change="onStrategyChange(index)"
+ >
+ <option :value="$options.ROLLOUT_STRATEGY_ALL_USERS">
+ {{ s__('FeatureFlags|All users') }}
+ </option>
+ <option :value="$options.ROLLOUT_STRATEGY_PERCENT_ROLLOUT">
+ {{ s__('FeatureFlags|Percent rollout (logged in users)') }}
+ </option>
+ <option :value="$options.ROLLOUT_STRATEGY_USER_ID">
+ {{ s__('FeatureFlags|User IDs') }}
+ </option>
+ </select>
+ <gl-icon
+ name="chevron-down"
+ class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
+ :size="16"
+ />
+ </div>
+
+ <div
+ v-if="scope.rolloutStrategy === $options.ROLLOUT_STRATEGY_PERCENT_ROLLOUT"
+ class="d-flex-center mt-2 mt-md-0 ml-md-2"
+ >
+ <label class="sr-only" :for="rolloutPercentageId(index)">
+ {{ s__('FeatureFlags|Rollout Percentage') }}
+ </label>
+ <div class="w-3rem">
+ <input
+ :id="rolloutPercentageId(index)"
+ v-model="scope.rolloutPercentage"
+ :disabled="!scope.active"
+ :class="{
+ 'is-invalid': isRolloutPercentageInvalid(scope.rolloutPercentage),
+ }"
+ type="number"
+ min="0"
+ max="100"
+ :pattern="$options.rolloutPercentageRegex.source"
+ class="rollout-percentage js-rollout-percentage form-control text-right w-100"
+ />
+ </div>
+ <gl-tooltip
+ v-if="isRolloutPercentageInvalid(scope.rolloutPercentage)"
+ :target="rolloutPercentageId(index)"
+ >
+ {{
+ s__('FeatureFlags|Percent rollout must be a whole number between 0 and 100')
+ }}
+ </gl-tooltip>
+ <span class="ml-1">%</span>
+ </div>
+ <div class="d-flex flex-column align-items-start mt-2 w-100">
+ <gl-form-checkbox
+ v-if="shouldDisplayIncludeUserIds(scope)"
+ v-model="scope.shouldIncludeUserIds"
+ >{{ s__('FeatureFlags|Include additional user IDs') }}</gl-form-checkbox
+ >
+ <template v-if="shouldDisplayUserIds(scope)">
+ <label :for="rolloutUserId(index)" class="mb-2">
+ {{ s__('FeatureFlags|User IDs') }}
+ </label>
+ <gl-form-textarea
+ :id="rolloutUserId(index)"
+ v-model="scope.rolloutUserIds"
+ class="w-100"
+ />
+ </template>
+ </div>
+ </div>
+ </div>
+
+ <div class="table-section section-10 text-right" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Remove') }}
+ </div>
+ <div class="table-mobile-content js-feature-flag-delete">
+ <gl-button
+ v-if="!isAllEnvironment(scope.environmentScope) && canUpdateScope(scope)"
+ v-gl-tooltip
+ :title="s__('FeatureFlags|Remove')"
+ class="js-delete-scope btn-transparent pr-3 pl-3"
+ icon="clear"
+ @click="removeScope(scope)"
+ />
+ </div>
+ </div>
+ </div>
+
+ <div class="js-add-new-scope gl-responsive-table-row" role="row">
+ <div class="table-section section-30" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Environment Spec') }}
+ </div>
+ <div class="table-mobile-content js-feature-flag-status">
+ <environments-dropdown
+ class="js-new-scope-name col-12"
+ :endpoint="environmentsEndpoint"
+ :value="newScope"
+ @selectEnvironment="env => createNewScope({ environmentScope: env })"
+ @createClicked="env => createNewScope({ environmentScope: env })"
+ />
+ </div>
+ </div>
+
+ <div class="table-section section-20 text-center" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Status') }}
+ </div>
+ <div class="table-mobile-content js-feature-flag-status">
+ <toggle-button
+ :disabled-input="!active"
+ :value="false"
+ @change="createNewScope({ active: true })"
+ />
+ </div>
+ </div>
+
+ <div class="table-section section-40" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Rollout Strategy') }}
+ </div>
+ <div class="table-mobile-content js-rollout-strategy form-inline">
+ <label class="sr-only" for="new-rollout-strategy-placeholder">{{
+ s__('FeatureFlags|Rollout Strategy')
+ }}</label>
+ <div class="select-wrapper col-12 col-md-8 p-0">
+ <select
+ id="new-rollout-strategy-placeholder"
+ disabled
+ class="form-control select-control w-100"
+ >
+ <option>{{ s__('FeatureFlags|All users') }}</option>
+ </select>
+ <gl-icon
+ name="chevron-down"
+ class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
+ :size="16"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </fieldset>
+
+ <div class="form-actions">
+ <gl-button
+ ref="submitButton"
+ :disabled="readOnly"
+ type="button"
+ variant="success"
+ class="js-ff-submit col-xs-12"
+ @click="handleSubmit"
+ >{{ submitText }}</gl-button
+ >
+ <gl-button :href="cancelPath" class="js-ff-cancel col-xs-12 float-right">
+ {{ __('Cancel') }}
+ </gl-button>
+ </div>
+ </form>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
new file mode 100644
index 00000000000..2888746005e
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
@@ -0,0 +1,106 @@
+<script>
+import { debounce } from 'lodash';
+import {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlIcon,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { __, sprintf } from '~/locale';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlIcon,
+ GlLoadingIcon,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ environmentSearch: '',
+ results: [],
+ isLoading: false,
+ };
+ },
+ translations: {
+ addEnvironmentsLabel: __('Add environment'),
+ noResultsLabel: __('No matching results'),
+ },
+ computed: {
+ createEnvironmentLabel() {
+ return sprintf(__('Create %{environment}'), { environment: this.environmentSearch });
+ },
+ },
+ methods: {
+ addEnvironment(newEnvironment) {
+ this.$emit('add', newEnvironment);
+ this.environmentSearch = '';
+ this.results = [];
+ },
+ fetchEnvironments: debounce(function debouncedFetchEnvironments() {
+ this.isLoading = true;
+ axios
+ .get(this.endpoint, { params: { query: this.environmentSearch } })
+ .then(({ data }) => {
+ this.results = data || [];
+ })
+ .catch(() => {
+ createFlash(__('Something went wrong on our end. Please try again.'));
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ }, 250),
+ setFocus() {
+ this.$refs.searchBox.focusInput();
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown class="js-new-environments-dropdown" @shown="setFocus">
+ <template #button-content>
+ <span class="d-md-none mr-1">
+ {{ $options.translations.addEnvironmentsLabel }}
+ </span>
+ <gl-icon class="d-none d-md-inline-flex" name="plus" />
+ </template>
+ <gl-search-box-by-type
+ ref="searchBox"
+ v-model.trim="environmentSearch"
+ class="gl-m-3"
+ @focus="fetchEnvironments"
+ @keyup="fetchEnvironments"
+ />
+ <gl-loading-icon v-if="isLoading" />
+ <gl-dropdown-item
+ v-for="environment in results"
+ v-else-if="results.length"
+ :key="environment"
+ @click="addEnvironment(environment)"
+ >
+ {{ environment }}
+ </gl-dropdown-item>
+ <template v-else-if="environmentSearch.length">
+ <span ref="noResults" class="text-secondary gl-p-3">
+ {{ $options.translations.noMatchingResults }}
+ </span>
+ <gl-dropdown-divider />
+ <gl-dropdown-item @click="addEnvironment(environmentSearch)">
+ {{ createEnvironmentLabel }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
new file mode 100644
index 00000000000..df19667a3ae
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
@@ -0,0 +1,134 @@
+<script>
+import { createNamespacedHelpers } from 'vuex';
+import { GlAlert } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import store from '../store/index';
+import FeatureFlagForm from './form.vue';
+import {
+ LEGACY_FLAG,
+ NEW_VERSION_FLAG,
+ NEW_FLAG_ALERT,
+ ROLLOUT_STRATEGY_ALL_USERS,
+} from '../constants';
+import { createNewEnvironmentScope } from '../store/modules/helpers';
+
+import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+const { mapState, mapActions } = createNamespacedHelpers('new');
+
+export default {
+ store,
+ components: {
+ GlAlert,
+ FeatureFlagForm,
+ },
+ mixins: [featureFlagsMixin()],
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ environmentsEndpoint: {
+ type: String,
+ required: true,
+ },
+ projectId: {
+ type: String,
+ required: true,
+ },
+ showUserCallout: {
+ type: Boolean,
+ required: true,
+ },
+ userCalloutId: {
+ default: '',
+ type: String,
+ required: false,
+ },
+ userCalloutsPath: {
+ default: '',
+ type: String,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ userShouldSeeNewFlagAlert: this.showUserCallout,
+ };
+ },
+ translations: {
+ newFlagAlert: NEW_FLAG_ALERT,
+ },
+ computed: {
+ ...mapState(['error']),
+ scopes() {
+ return [
+ createNewEnvironmentScope(
+ {
+ environmentScope: '*',
+ active: true,
+ },
+ this.glFeatures.featureFlagsPermissions,
+ ),
+ ];
+ },
+ version() {
+ return this.hasNewVersionFlags ? NEW_VERSION_FLAG : LEGACY_FLAG;
+ },
+ hasNewVersionFlags() {
+ return this.glFeatures.featureFlagsNewVersion;
+ },
+ shouldShowNewFlagAlert() {
+ return !this.hasNewVersionFlags && this.userShouldSeeNewFlagAlert;
+ },
+ strategies() {
+ return [{ name: ROLLOUT_STRATEGY_ALL_USERS, parameters: {}, scopes: [] }];
+ },
+ },
+ created() {
+ this.setEndpoint(this.endpoint);
+ this.setPath(this.path);
+ },
+ methods: {
+ ...mapActions(['createFeatureFlag', 'setEndpoint', 'setPath']),
+ dismissNewVersionFlagAlert() {
+ this.userShouldSeeNewFlagAlert = false;
+ axios.post(this.userCalloutsPath, {
+ feature_name: this.userCalloutId,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert
+ v-if="shouldShowNewFlagAlert"
+ variant="warning"
+ class="gl-my-5"
+ @dismiss="dismissNewVersionFlagAlert"
+ >
+ {{ $options.translations.newFlagAlert }}
+ </gl-alert>
+ <h3 class="page-title">{{ s__('FeatureFlags|New feature flag') }}</h3>
+
+ <div v-if="error.length" class="alert alert-danger">
+ <p v-for="(message, index) in error" :key="index" class="mb-0">{{ message }}</p>
+ </div>
+
+ <feature-flag-form
+ :project-id="projectId"
+ :cancel-path="path"
+ :submit-text="s__('FeatureFlags|Create feature flag')"
+ :scopes="scopes"
+ :strategies="strategies"
+ :environments-endpoint="environmentsEndpoint"
+ :version="version"
+ @handleSubmit="data => createFeatureFlag(data)"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue
new file mode 100644
index 00000000000..3f10ec00aa5
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/strategy.vue
@@ -0,0 +1,327 @@
+<script>
+import Vue from 'vue';
+import { isNumber } from 'lodash';
+import {
+ GlButton,
+ GlFormSelect,
+ GlFormInput,
+ GlFormTextarea,
+ GlFormGroup,
+ GlIcon,
+ GlLink,
+ GlToken,
+} from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import {
+ PERCENT_ROLLOUT_GROUP_ID,
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+} from '../constants';
+
+import NewEnvironmentsDropdown from './new_environments_dropdown.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlFormSelect,
+ GlIcon,
+ GlLink,
+ GlToken,
+ NewEnvironmentsDropdown,
+ },
+ model: {
+ prop: 'strategy',
+ event: 'change',
+ },
+ inject: {
+ strategyTypeDocsPagePath: {
+ type: String,
+ },
+ environmentsScopeDocsPath: {
+ type: String,
+ },
+ },
+ props: {
+ strategy: {
+ type: Object,
+ required: true,
+ },
+ index: {
+ type: Number,
+ required: true,
+ },
+ endpoint: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ userLists: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+
+ i18n: {
+ allEnvironments: __('All environments'),
+ environmentsLabel: __('Environments'),
+ environmentsSelectDescription: __('Select the environment scope for this feature flag.'),
+ rolloutPercentageDescription: __('Enter a whole number between 0 and 100'),
+ rolloutPercentageInvalid: s__(
+ 'FeatureFlags|Percent rollout must be a whole number between 0 and 100',
+ ),
+ rolloutPercentageLabel: s__('FeatureFlag|Percentage'),
+ rolloutUserIdsDescription: __('Enter one or more user ID separated by commas'),
+ rolloutUserIdsLabel: s__('FeatureFlag|User IDs'),
+ rolloutUserListLabel: s__('FeatureFlag|List'),
+ rolloutUserListDescription: s__('FeatureFlag|Select a user list'),
+ rolloutUserListNoListError: s__('FeatureFlag|There are no configured user lists'),
+ strategyTypeDescription: __('Select strategy activation method.'),
+ strategyTypeLabel: s__('FeatureFlag|Type'),
+ },
+
+ data() {
+ return {
+ environments: this.strategy.scopes || [],
+ formStrategy: { ...this.strategy },
+ formPercentage:
+ this.strategy.name === ROLLOUT_STRATEGY_PERCENT_ROLLOUT
+ ? this.strategy.parameters.percentage
+ : '',
+ formUserIds:
+ this.strategy.name === ROLLOUT_STRATEGY_USER_ID ? this.strategy.parameters.userIds : '',
+ formUserListId:
+ this.strategy.name === ROLLOUT_STRATEGY_GITLAB_USER_LIST ? this.strategy.userListId : '',
+ strategies: [
+ {
+ value: ROLLOUT_STRATEGY_ALL_USERS,
+ text: __('All users'),
+ },
+ {
+ value: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ text: __('Percent of users'),
+ },
+ {
+ value: ROLLOUT_STRATEGY_USER_ID,
+ text: __('User IDs'),
+ },
+ {
+ value: ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+ text: __('User List'),
+ },
+ ],
+ };
+ },
+ computed: {
+ strategyTypeId() {
+ return `strategy-type-${this.index}`;
+ },
+ strategyPercentageId() {
+ return `strategy-percentage-${this.index}`;
+ },
+ strategyUserIdsId() {
+ return `strategy-user-ids-${this.index}`;
+ },
+ strategyUserListId() {
+ return `strategy-user-list-${this.index}`;
+ },
+ environmentsDropdownId() {
+ return `environments-dropdown-${this.index}`;
+ },
+ isPercentRollout() {
+ return this.isStrategyType(ROLLOUT_STRATEGY_PERCENT_ROLLOUT);
+ },
+ isUserWithId() {
+ return this.isStrategyType(ROLLOUT_STRATEGY_USER_ID);
+ },
+ isUserList() {
+ return this.isStrategyType(ROLLOUT_STRATEGY_GITLAB_USER_LIST);
+ },
+ appliesToAllEnvironments() {
+ return (
+ this.filteredEnvironments.length === 1 &&
+ this.filteredEnvironments[0].environmentScope === '*'
+ );
+ },
+ filteredEnvironments() {
+ return this.environments.filter(e => !e.shouldBeDestroyed);
+ },
+ userListOptions() {
+ return this.userLists.map(({ name, id }) => ({ value: id, text: name }));
+ },
+ hasUserLists() {
+ return this.userListOptions.length > 0;
+ },
+ },
+ methods: {
+ addEnvironment(environment) {
+ const allEnvironmentsScope = this.environments.find(scope => scope.environmentScope === '*');
+ if (allEnvironmentsScope) {
+ allEnvironmentsScope.shouldBeDestroyed = true;
+ }
+ this.environments.push({ environmentScope: environment });
+ this.onStrategyChange();
+ },
+ onStrategyChange() {
+ const parameters = {};
+ const strategy = {
+ ...this.formStrategy,
+ scopes: this.environments,
+ };
+ switch (this.formStrategy.name) {
+ case ROLLOUT_STRATEGY_PERCENT_ROLLOUT:
+ parameters.percentage = this.formPercentage;
+ parameters.groupId = PERCENT_ROLLOUT_GROUP_ID;
+ break;
+ case ROLLOUT_STRATEGY_USER_ID:
+ parameters.userIds = this.formUserIds;
+ break;
+ case ROLLOUT_STRATEGY_GITLAB_USER_LIST:
+ strategy.userListId = this.formUserListId;
+ break;
+ default:
+ break;
+ }
+ this.$emit('change', {
+ ...strategy,
+ parameters,
+ });
+ },
+ removeScope(environment) {
+ if (isNumber(environment.id)) {
+ Vue.set(environment, 'shouldBeDestroyed', true);
+ } else {
+ this.environments = this.environments.filter(e => e !== environment);
+ }
+ if (this.filteredEnvironments.length === 0) {
+ this.environments.push({ environmentScope: '*' });
+ }
+ this.onStrategyChange();
+ },
+ isStrategyType(type) {
+ return this.formStrategy.name === type;
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-py-6">
+ <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row flex-md-wrap">
+ <div class="mr-5">
+ <gl-form-group :label="$options.i18n.strategyTypeLabel" :label-for="strategyTypeId">
+ <p class="gl-display-inline-block ">{{ $options.i18n.strategyTypeDescription }}</p>
+ <gl-link :href="strategyTypeDocsPagePath" target="_blank">
+ <gl-icon name="question" />
+ </gl-link>
+ <gl-form-select
+ :id="strategyTypeId"
+ v-model="formStrategy.name"
+ :options="strategies"
+ @change="onStrategyChange"
+ />
+ </gl-form-group>
+ </div>
+
+ <div data-testid="strategy">
+ <gl-form-group
+ v-if="isPercentRollout"
+ :label="$options.i18n.rolloutPercentageLabel"
+ :description="$options.i18n.rolloutPercentageDescription"
+ :label-for="strategyPercentageId"
+ :invalid-feedback="$options.i18n.rolloutPercentageInvalid"
+ >
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-form-input
+ :id="strategyPercentageId"
+ v-model="formPercentage"
+ class="rollout-percentage gl-text-right gl-w-9"
+ type="number"
+ @input="onStrategyChange"
+ />
+ <span class="gl-ml-2">%</span>
+ </div>
+ </gl-form-group>
+
+ <gl-form-group
+ v-if="isUserWithId"
+ :label="$options.i18n.rolloutUserIdsLabel"
+ :description="$options.i18n.rolloutUserIdsDescription"
+ :label-for="strategyUserIdsId"
+ >
+ <gl-form-textarea
+ :id="strategyUserIdsId"
+ v-model="formUserIds"
+ @input="onStrategyChange"
+ />
+ </gl-form-group>
+ <gl-form-group
+ v-if="isUserList"
+ :state="hasUserLists"
+ :invalid-feedback="$options.i18n.rolloutUserListNoListError"
+ :label="$options.i18n.rolloutUserListLabel"
+ :description="$options.i18n.rolloutUserListDescription"
+ :label-for="strategyUserListId"
+ >
+ <gl-form-select
+ :id="strategyUserListId"
+ v-model="formUserListId"
+ :options="userListOptions"
+ @change="onStrategyChange"
+ />
+ </gl-form-group>
+ </div>
+
+ <div
+ class="align-self-end align-self-md-stretch order-first offset-md-0 order-md-0 gl-ml-auto"
+ >
+ <gl-button
+ data-testid="delete-strategy-button"
+ variant="danger"
+ icon="remove"
+ @click="$emit('delete')"
+ />
+ </div>
+ </div>
+ <label class="gl-display-block" :for="environmentsDropdownId">{{
+ $options.i18n.environmentsLabel
+ }}</label>
+ <p class="gl-display-inline-block">{{ $options.i18n.environmentsSelectDescription }}</p>
+ <gl-link :href="environmentsScopeDocsPath" target="_blank">
+ <gl-icon name="question" />
+ </gl-link>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row align-items-start gl-md-align-items-center"
+ >
+ <new-environments-dropdown
+ :id="environmentsDropdownId"
+ :endpoint="endpoint"
+ class="gl-mr-3"
+ @add="addEnvironment"
+ />
+ <span v-if="appliesToAllEnvironments" class="text-secondary gl-mt-3 mt-md-0 ml-md-3">
+ {{ $options.i18n.allEnvironments }}
+ </span>
+ <div v-else class="gl-display-flex gl-align-items-center">
+ <gl-token
+ v-for="environment in filteredEnvironments"
+ :key="environment.id"
+ class="gl-mt-3 gl-mr-3 mt-md-0 mr-md-0 ml-md-2 rounded-pill"
+ @close="removeScope(environment)"
+ >
+ {{ environment.environmentScope }}
+ </gl-token>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/user_lists_table.vue b/app/assets/javascripts/feature_flags/components/user_lists_table.vue
new file mode 100644
index 00000000000..0bfd18f992c
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/user_lists_table.vue
@@ -0,0 +1,122 @@
+<script>
+import {
+ GlButton,
+ GlButtonGroup,
+ GlModal,
+ GlSprintf,
+ GlTooltipDirective,
+ GlModalDirective,
+} from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+
+export default {
+ components: { GlButton, GlButtonGroup, GlModal, GlSprintf },
+ directives: { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective },
+ mixins: [timeagoMixin],
+ props: {
+ userLists: {
+ type: Array,
+ required: true,
+ },
+ },
+ translations: {
+ createdTimeagoLabel: s__('UserList|created %{timeago}'),
+ deleteListTitle: s__('UserList|Delete %{name}?'),
+ deleteListMessage: s__('User list %{name} will be removed. Are you sure?'),
+ },
+ modal: {
+ id: 'deleteListModal',
+ actionPrimary: {
+ text: s__('Delete user list'),
+ attributes: { variant: 'danger', 'data-testid': 'modal-confirm' },
+ },
+ },
+ data() {
+ return {
+ deleteUserList: null,
+ };
+ },
+ computed: {
+ deleteListName() {
+ return this.deleteUserList?.name;
+ },
+ modalTitle() {
+ return sprintf(this.$options.translations.deleteListTitle, {
+ name: this.deleteListName,
+ });
+ },
+ },
+ methods: {
+ createdTimeago(list) {
+ return sprintf(this.$options.translations.createdTimeagoLabel, {
+ timeago: this.timeFormatted(list.created_at),
+ });
+ },
+ displayList(list) {
+ return list.user_xids.replace(/,/g, ', ');
+ },
+ onDelete() {
+ this.$emit('delete', this.deleteUserList);
+ },
+ confirmDeleteList(list) {
+ this.deleteUserList = list;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div
+ v-for="list in userLists"
+ :key="list.id"
+ data-testid="ffUserList"
+ class="gl-border-b-solid gl-border-gray-100 gl-border-b-1 gl-w-full gl-py-4 gl-display-flex gl-justify-content-space-between"
+ >
+ <div class="gl-display-flex gl-flex-direction-column gl-overflow-hidden gl-flex-grow-1">
+ <span data-testid="ffUserListName" class="gl-font-weight-bold gl-mb-2">
+ {{ list.name }}
+ </span>
+ <span
+ v-gl-tooltip
+ :title="tooltipTitle(list.created_at)"
+ data-testid="ffUserListTimestamp"
+ class="gl-text-gray-300 gl-mb-2"
+ >
+ {{ createdTimeago(list) }}
+ </span>
+ <span data-testid="ffUserListIds" class="gl-str-truncated">{{ displayList(list) }}</span>
+ </div>
+
+ <gl-button-group class="gl-align-self-start gl-mt-2">
+ <gl-button
+ :href="list.path"
+ category="secondary"
+ icon="pencil"
+ data-testid="edit-user-list"
+ />
+ <gl-button
+ v-gl-modal="$options.modal.id"
+ category="secondary"
+ variant="danger"
+ icon="remove"
+ data-testid="delete-user-list"
+ @click="confirmDeleteList(list)"
+ />
+ </gl-button-group>
+ </div>
+ <gl-modal
+ :title="modalTitle"
+ :modal-id="$options.modal.id"
+ :action-primary="$options.modal.actionPrimary"
+ static
+ @primary="onDelete"
+ >
+ <gl-sprintf :message="$options.translations.deleteListMessage">
+ <template #name>
+ <b>{{ deleteListName }}</b>
+ </template>
+ </gl-sprintf>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/constants.js b/app/assets/javascripts/feature_flags/constants.js
new file mode 100644
index 00000000000..f59414ab1a7
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/constants.js
@@ -0,0 +1,28 @@
+import { property } from 'lodash';
+import { s__ } from '~/locale';
+
+export const ROLLOUT_STRATEGY_ALL_USERS = 'default';
+export const ROLLOUT_STRATEGY_PERCENT_ROLLOUT = 'gradualRolloutUserId';
+export const ROLLOUT_STRATEGY_USER_ID = 'userWithId';
+export const ROLLOUT_STRATEGY_GITLAB_USER_LIST = 'gitlabUserList';
+
+export const PERCENT_ROLLOUT_GROUP_ID = 'default';
+
+export const DEFAULT_PERCENT_ROLLOUT = '100';
+
+export const ALL_ENVIRONMENTS_NAME = '*';
+
+export const INTERNAL_ID_PREFIX = 'internal_';
+
+export const fetchPercentageParams = property(['parameters', 'percentage']);
+export const fetchUserIdParams = property(['parameters', 'userIds']);
+
+export const NEW_VERSION_FLAG = 'new_version_flag';
+export const LEGACY_FLAG = 'legacy_flag';
+
+export const NEW_FLAG_ALERT = s__(
+ 'FeatureFlags|Feature Flags will look different in the next milestone. No action is needed, but you may notice the functionality was changed to improve the workflow.',
+);
+
+export const FEATURE_FLAG_SCOPE = 'featureFlags';
+export const USER_LIST_SCOPE = 'userLists';
diff --git a/app/assets/javascripts/feature_flags/edit.js b/app/assets/javascripts/feature_flags/edit.js
new file mode 100644
index 00000000000..390a1f7555d
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/edit.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import EditFeatureFlag from '~/feature_flags/components/edit_feature_flag.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export default () => {
+ const el = document.querySelector('#js-edit-feature-flag');
+ const { environmentsScopeDocsPath, strategyTypeDocsPagePath } = el.dataset;
+
+ return new Vue({
+ el,
+ components: {
+ EditFeatureFlag,
+ },
+ provide: {
+ environmentsScopeDocsPath,
+ strategyTypeDocsPagePath,
+ },
+ render(createElement) {
+ return createElement('edit-feature-flag', {
+ props: {
+ endpoint: el.dataset.endpoint,
+ path: el.dataset.featureFlagsPath,
+ environmentsEndpoint: el.dataset.environmentsEndpoint,
+ projectId: el.dataset.projectId,
+ featureFlagIssuesEndpoint: el.dataset.featureFlagIssuesEndpoint,
+ userCalloutsPath: el.dataset.userCalloutsPath,
+ userCalloutId: el.dataset.userCalloutId,
+ showUserCallout: parseBoolean(el.dataset.showUserCallout),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/feature_flags/index.js b/app/assets/javascripts/feature_flags/index.js
new file mode 100644
index 00000000000..90857c5f2da
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/index.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import FeatureFlagsComponent from '~/feature_flags/components/feature_flags.vue';
+import csrf from '~/lib/utils/csrf';
+
+export default () =>
+ new Vue({
+ el: '#feature-flags-vue',
+ components: {
+ FeatureFlagsComponent,
+ },
+ data() {
+ return {
+ dataset: document.querySelector(this.$options.el).dataset,
+ };
+ },
+ provide() {
+ return {
+ projectName: this.dataset.projectName,
+ featureFlagsHelpPagePath: this.dataset.featureFlagsHelpPagePath,
+ errorStateSvgPath: this.dataset.errorStateSvgPath,
+ };
+ },
+ render(createElement) {
+ return createElement('feature-flags-component', {
+ props: {
+ endpoint: this.dataset.endpoint,
+ projectId: this.dataset.projectId,
+ featureFlagsClientLibrariesHelpPagePath: this.dataset
+ .featureFlagsClientLibrariesHelpPagePath,
+ featureFlagsClientExampleHelpPagePath: this.dataset.featureFlagsClientExampleHelpPagePath,
+ unleashApiUrl: this.dataset.unleashApiUrl,
+ unleashApiInstanceId: this.dataset.unleashApiInstanceId || '',
+ csrfToken: csrf.token,
+ canUserConfigure: this.dataset.canUserAdminFeatureFlag,
+ newFeatureFlagPath: this.dataset.newFeatureFlagPath,
+ rotateInstanceIdPath: this.dataset.rotateInstanceIdPath,
+ newUserListPath: this.dataset.newUserListPath,
+ },
+ });
+ },
+ });
diff --git a/app/assets/javascripts/feature_flags/new.js b/app/assets/javascripts/feature_flags/new.js
new file mode 100644
index 00000000000..f14dd151910
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/new.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import NewFeatureFlag from '~/feature_flags/components/new_feature_flag.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export default () => {
+ const el = document.querySelector('#js-new-feature-flag');
+ const { environmentsScopeDocsPath, strategyTypeDocsPagePath } = el.dataset;
+
+ return new Vue({
+ el,
+ components: {
+ NewFeatureFlag,
+ },
+ provide: {
+ environmentsScopeDocsPath,
+ strategyTypeDocsPagePath,
+ },
+ render(createElement) {
+ return createElement('new-feature-flag', {
+ props: {
+ endpoint: el.dataset.endpoint,
+ path: el.dataset.featureFlagsPath,
+ environmentsEndpoint: el.dataset.environmentsEndpoint,
+ projectId: el.dataset.projectId,
+ userCalloutsPath: el.dataset.userCalloutsPath,
+ userCalloutId: el.dataset.userCalloutId,
+ showUserCallout: parseBoolean(el.dataset.showUserCallout),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/feature_flags/store/index.js b/app/assets/javascripts/feature_flags/store/index.js
new file mode 100644
index 00000000000..f4f49c20895
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/index.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import indexModule from './modules/index';
+import newModule from './modules/new';
+import editModule from './modules/edit';
+
+Vue.use(Vuex);
+
+export const createStore = () =>
+ new Vuex.Store({
+ modules: {
+ index: indexModule,
+ new: newModule,
+ edit: editModule,
+ },
+ });
+
+export default createStore();
diff --git a/app/assets/javascripts/feature_flags/store/modules/edit/actions.js b/app/assets/javascripts/feature_flags/store/modules/edit/actions.js
new file mode 100644
index 00000000000..351f36d8fa6
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/edit/actions.js
@@ -0,0 +1,75 @@
+import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import { __ } from '~/locale';
+import { NEW_VERSION_FLAG } from '../../../constants';
+import { mapFromScopesViewModel, mapStrategiesToRails } from '../helpers';
+
+/**
+ * Commits mutation to set the main endpoint
+ * @param {String} endpoint
+ */
+export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
+
+/**
+ * Commits mutation to set the feature flag path.
+ * Used to redirect the user after form submission
+ *
+ * @param {String} path
+ */
+export const setPath = ({ commit }, path) => commit(types.SET_PATH, path);
+
+/**
+ * Handles the edition of a feature flag.
+ *
+ * Will dispatch `requestUpdateFeatureFlag`
+ * Serializes the params and makes a put request
+ * Dispatches an action acording to the request status.
+ *
+ * @param {Object} params
+ */
+export const updateFeatureFlag = ({ state, dispatch }, params) => {
+ dispatch('requestUpdateFeatureFlag');
+
+ axios
+ .put(
+ state.endpoint,
+ params.version === NEW_VERSION_FLAG
+ ? mapStrategiesToRails(params)
+ : mapFromScopesViewModel(params),
+ )
+ .then(() => {
+ dispatch('receiveUpdateFeatureFlagSuccess');
+ visitUrl(state.path);
+ })
+ .catch(error => dispatch('receiveUpdateFeatureFlagError', error.response.data));
+};
+
+export const requestUpdateFeatureFlag = ({ commit }) => commit(types.REQUEST_UPDATE_FEATURE_FLAG);
+export const receiveUpdateFeatureFlagSuccess = ({ commit }) =>
+ commit(types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS);
+export const receiveUpdateFeatureFlagError = ({ commit }, error) =>
+ commit(types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, error);
+
+/**
+ * Fetches the feature flag data for the edit form
+ */
+export const fetchFeatureFlag = ({ state, dispatch }) => {
+ dispatch('requestFeatureFlag');
+
+ axios
+ .get(state.endpoint)
+ .then(({ data }) => dispatch('receiveFeatureFlagSuccess', data))
+ .catch(() => dispatch('receiveFeatureFlagError'));
+};
+
+export const requestFeatureFlag = ({ commit }) => commit(types.REQUEST_FEATURE_FLAG);
+export const receiveFeatureFlagSuccess = ({ commit }, response) =>
+ commit(types.RECEIVE_FEATURE_FLAG_SUCCESS, response);
+export const receiveFeatureFlagError = ({ commit }) => {
+ commit(types.RECEIVE_FEATURE_FLAG_ERROR);
+ createFlash(__('Something went wrong on our end. Please try again!'));
+};
+
+export const toggleActive = ({ commit }, active) => commit(types.TOGGLE_ACTIVE, active);
diff --git a/app/assets/javascripts/feature_flags/store/modules/edit/index.js b/app/assets/javascripts/feature_flags/store/modules/edit/index.js
new file mode 100644
index 00000000000..665bb29a17e
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/edit/index.js
@@ -0,0 +1,10 @@
+import state from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default {
+ namespaced: true,
+ actions,
+ mutations,
+ state: state(),
+};
diff --git a/app/assets/javascripts/feature_flags/store/modules/edit/mutation_types.js b/app/assets/javascripts/feature_flags/store/modules/edit/mutation_types.js
new file mode 100644
index 00000000000..b2715e501f4
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/edit/mutation_types.js
@@ -0,0 +1,12 @@
+export const SET_ENDPOINT = 'SET_ENDPOINT';
+export const SET_PATH = 'SET_PATH';
+
+export const REQUEST_UPDATE_FEATURE_FLAG = 'REQUEST_UPDATE_FEATURE_FLAG';
+export const RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS';
+export const RECEIVE_UPDATE_FEATURE_FLAG_ERROR = 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR';
+
+export const REQUEST_FEATURE_FLAG = 'REQUEST_FEATURE_FLAG';
+export const RECEIVE_FEATURE_FLAG_SUCCESS = 'RECEIVE_FEATURE_FLAG_SUCCESS';
+export const RECEIVE_FEATURE_FLAG_ERROR = 'RECEIVE_FEATURE_FLAG_ERROR';
+
+export const TOGGLE_ACTIVE = 'TOGGLE_ACTIVE';
diff --git a/app/assets/javascripts/feature_flags/store/modules/edit/mutations.js b/app/assets/javascripts/feature_flags/store/modules/edit/mutations.js
new file mode 100644
index 00000000000..1d2721e037d
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/edit/mutations.js
@@ -0,0 +1,45 @@
+import * as types from './mutation_types';
+import { mapToScopesViewModel, mapStrategiesToViewModel } from '../helpers';
+import { LEGACY_FLAG } from '../../../constants';
+
+export default {
+ [types.SET_ENDPOINT](state, endpoint) {
+ state.endpoint = endpoint;
+ },
+ [types.SET_PATH](state, path) {
+ state.path = path;
+ },
+ [types.REQUEST_FEATURE_FLAG](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_FEATURE_FLAG_SUCCESS](state, response) {
+ state.isLoading = false;
+ state.hasError = false;
+
+ state.name = response.name;
+ state.description = response.description;
+ state.iid = response.iid;
+ state.active = response.active;
+ state.scopes = mapToScopesViewModel(response.scopes);
+ state.strategies = mapStrategiesToViewModel(response.strategies);
+ state.version = response.version || LEGACY_FLAG;
+ },
+ [types.RECEIVE_FEATURE_FLAG_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ },
+ [types.REQUEST_UPDATE_FEATURE_FLAG](state) {
+ state.isSendingRequest = true;
+ state.error = [];
+ },
+ [types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](state) {
+ state.isSendingRequest = false;
+ },
+ [types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](state, error) {
+ state.isSendingRequest = false;
+ state.error = error.message || [];
+ },
+ [types.TOGGLE_ACTIVE](state, active) {
+ state.active = active;
+ },
+};
diff --git a/app/assets/javascripts/feature_flags/store/modules/edit/state.js b/app/assets/javascripts/feature_flags/store/modules/edit/state.js
new file mode 100644
index 00000000000..7de05b49482
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/edit/state.js
@@ -0,0 +1,18 @@
+import { LEGACY_FLAG } from '../../../constants';
+
+export default () => ({
+ endpoint: null,
+ path: null,
+ isSendingRequest: false,
+ error: [],
+
+ name: null,
+ description: null,
+ scopes: [],
+ isLoading: false,
+ hasError: false,
+ iid: null,
+ active: true,
+ strategies: [],
+ version: LEGACY_FLAG,
+});
diff --git a/app/assets/javascripts/feature_flags/store/modules/helpers.js b/app/assets/javascripts/feature_flags/store/modules/helpers.js
new file mode 100644
index 00000000000..5a8d7bc6af3
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/helpers.js
@@ -0,0 +1,213 @@
+import { isEmpty, uniqueId, isString } from 'lodash';
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+ INTERNAL_ID_PREFIX,
+ DEFAULT_PERCENT_ROLLOUT,
+ PERCENT_ROLLOUT_GROUP_ID,
+ fetchPercentageParams,
+ fetchUserIdParams,
+ LEGACY_FLAG,
+} from '../../constants';
+
+/**
+ * Converts raw scope objects fetched from the API into an array of scope
+ * objects that is easier/nicer to bind to in Vue.
+ * @param {Array} scopesFromRails An array of scope objects fetched from the API
+ */
+export const mapToScopesViewModel = scopesFromRails =>
+ (scopesFromRails || []).map(s => {
+ const percentStrategy = (s.strategies || []).find(
+ strat => strat.name === ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ );
+
+ const rolloutPercentage = fetchPercentageParams(percentStrategy) || DEFAULT_PERCENT_ROLLOUT;
+
+ const userStrategy = (s.strategies || []).find(
+ strat => strat.name === ROLLOUT_STRATEGY_USER_ID,
+ );
+
+ const rolloutStrategy =
+ (percentStrategy && percentStrategy.name) ||
+ (userStrategy && userStrategy.name) ||
+ ROLLOUT_STRATEGY_ALL_USERS;
+
+ const rolloutUserIds = (fetchUserIdParams(userStrategy) || '')
+ .split(',')
+ .filter(id => id)
+ .join(', ');
+
+ return {
+ id: s.id,
+ environmentScope: s.environment_scope,
+ active: Boolean(s.active),
+ canUpdate: Boolean(s.can_update),
+ protected: Boolean(s.protected),
+ rolloutStrategy,
+ rolloutPercentage,
+ rolloutUserIds,
+
+ // eslint-disable-next-line no-underscore-dangle
+ shouldBeDestroyed: Boolean(s._destroy),
+ shouldIncludeUserIds: rolloutUserIds.length > 0 && percentStrategy !== null,
+ };
+ });
+/**
+ * Converts the parameters emitted by the Vue component into
+ * the shape that the Rails API expects.
+ * @param {Array} scopesFromVue An array of scope objects from the Vue component
+ */
+export const mapFromScopesViewModel = params => {
+ const scopes = (params.scopes || []).map(s => {
+ const parameters = {};
+ if (s.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT) {
+ parameters.groupId = PERCENT_ROLLOUT_GROUP_ID;
+ parameters.percentage = s.rolloutPercentage;
+ } else if (s.rolloutStrategy === ROLLOUT_STRATEGY_USER_ID) {
+ parameters.userIds = (s.rolloutUserIds || '').replace(/, /g, ',');
+ }
+
+ const userIdParameters = {};
+
+ if (s.shouldIncludeUserIds && s.rolloutStrategy !== ROLLOUT_STRATEGY_USER_ID) {
+ userIdParameters.userIds = (s.rolloutUserIds || '').replace(/, /g, ',');
+ }
+
+ // Strip out any internal IDs
+ const id = isString(s.id) && s.id.startsWith(INTERNAL_ID_PREFIX) ? undefined : s.id;
+
+ const strategies = [
+ {
+ name: s.rolloutStrategy,
+ parameters,
+ },
+ ];
+
+ if (!isEmpty(userIdParameters)) {
+ strategies.push({ name: ROLLOUT_STRATEGY_USER_ID, parameters: userIdParameters });
+ }
+
+ return {
+ id,
+ environment_scope: s.environmentScope,
+ active: s.active,
+ can_update: s.canUpdate,
+ protected: s.protected,
+ _destroy: s.shouldBeDestroyed,
+ strategies,
+ };
+ });
+
+ const model = {
+ operations_feature_flag: {
+ name: params.name,
+ description: params.description,
+ active: params.active,
+ scopes_attributes: scopes,
+ version: LEGACY_FLAG,
+ },
+ };
+
+ return model;
+};
+
+/**
+ * Creates a new feature flag environment scope object for use
+ * in a Vue component. An optional parameter can be passed to
+ * override the property values that are created by default.
+ *
+ * @param {Object} overrides An optional object whose
+ * property values will be used to override the default values.
+ *
+ */
+export const createNewEnvironmentScope = (overrides = {}, featureFlagPermissions = false) => {
+ const defaultScope = {
+ environmentScope: '',
+ active: false,
+ id: uniqueId(INTERNAL_ID_PREFIX),
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ rolloutUserIds: '',
+ };
+
+ const newScope = {
+ ...defaultScope,
+ ...overrides,
+ };
+
+ if (featureFlagPermissions) {
+ newScope.canUpdate = true;
+ newScope.protected = false;
+ }
+
+ return newScope;
+};
+
+const mapStrategyScopesToRails = scopes =>
+ scopes.length === 0
+ ? [{ environment_scope: '*' }]
+ : scopes.map(s => ({
+ id: s.id,
+ _destroy: s.shouldBeDestroyed,
+ environment_scope: s.environmentScope,
+ }));
+
+const mapStrategyScopesToView = scopes =>
+ scopes.map(s => ({
+ id: s.id,
+ // eslint-disable-next-line no-underscore-dangle
+ shouldBeDestroyed: Boolean(s._destroy),
+ environmentScope: s.environment_scope,
+ }));
+
+const mapStrategiesParametersToViewModel = params => {
+ if (params.userIds) {
+ return { ...params, userIds: params.userIds.split(',').join(', ') };
+ }
+ return params;
+};
+
+export const mapStrategiesToViewModel = strategiesFromRails =>
+ (strategiesFromRails || []).map(s => ({
+ id: s.id,
+ name: s.name,
+ parameters: mapStrategiesParametersToViewModel(s.parameters),
+ userListId: s.user_list?.id,
+ // eslint-disable-next-line no-underscore-dangle
+ shouldBeDestroyed: Boolean(s._destroy),
+ scopes: mapStrategyScopesToView(s.scopes),
+ }));
+
+const mapStrategiesParametersToRails = params => {
+ if (params.userIds) {
+ return { ...params, userIds: params.userIds.split(', ').join(',') };
+ }
+ return params;
+};
+
+const mapStrategyToRails = strategy => {
+ const mappedStrategy = {
+ id: strategy.id,
+ name: strategy.name,
+ _destroy: strategy.shouldBeDestroyed,
+ scopes_attributes: mapStrategyScopesToRails(strategy.scopes || []),
+ parameters: mapStrategiesParametersToRails(strategy.parameters),
+ };
+
+ if (strategy.name === ROLLOUT_STRATEGY_GITLAB_USER_LIST) {
+ mappedStrategy.user_list_id = strategy.userListId;
+ }
+ return mappedStrategy;
+};
+
+export const mapStrategiesToRails = params => ({
+ operations_feature_flag: {
+ name: params.name,
+ description: params.description,
+ version: params.version,
+ active: params.active,
+ strategies_attributes: (params.strategies || []).map(mapStrategyToRails),
+ },
+});
diff --git a/app/assets/javascripts/feature_flags/store/modules/index/actions.js b/app/assets/javascripts/feature_flags/store/modules/index/actions.js
new file mode 100644
index 00000000000..ed41dd34e4d
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/index/actions.js
@@ -0,0 +1,107 @@
+import Api from '~/api';
+import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+
+export const setFeatureFlagsEndpoint = ({ commit }, endpoint) =>
+ commit(types.SET_FEATURE_FLAGS_ENDPOINT, endpoint);
+
+export const setFeatureFlagsOptions = ({ commit }, options) =>
+ commit(types.SET_FEATURE_FLAGS_OPTIONS, options);
+
+export const setInstanceIdEndpoint = ({ commit }, endpoint) =>
+ commit(types.SET_INSTANCE_ID_ENDPOINT, endpoint);
+
+export const setProjectId = ({ commit }, endpoint) => commit(types.SET_PROJECT_ID, endpoint);
+
+export const setInstanceId = ({ commit }, instanceId) => commit(types.SET_INSTANCE_ID, instanceId);
+
+export const fetchFeatureFlags = ({ state, dispatch }) => {
+ dispatch('requestFeatureFlags');
+
+ axios
+ .get(state.endpoint, {
+ params: state.options,
+ })
+ .then(response =>
+ dispatch('receiveFeatureFlagsSuccess', {
+ data: response.data || {},
+ headers: response.headers,
+ }),
+ )
+ .catch(() => dispatch('receiveFeatureFlagsError'));
+};
+
+export const requestFeatureFlags = ({ commit }) => commit(types.REQUEST_FEATURE_FLAGS);
+export const receiveFeatureFlagsSuccess = ({ commit }, response) =>
+ commit(types.RECEIVE_FEATURE_FLAGS_SUCCESS, response);
+export const receiveFeatureFlagsError = ({ commit }) => commit(types.RECEIVE_FEATURE_FLAGS_ERROR);
+
+export const fetchUserLists = ({ state, dispatch }) => {
+ dispatch('requestUserLists');
+
+ return Api.fetchFeatureFlagUserLists(state.projectId, state.options.page)
+ .then(({ data, headers }) => dispatch('receiveUserListsSuccess', { data, headers }))
+ .catch(() => dispatch('receiveUserListsError'));
+};
+
+export const requestUserLists = ({ commit }) => commit(types.REQUEST_USER_LISTS);
+export const receiveUserListsSuccess = ({ commit }, response) =>
+ commit(types.RECEIVE_USER_LISTS_SUCCESS, response);
+export const receiveUserListsError = ({ commit }) => commit(types.RECEIVE_USER_LISTS_ERROR);
+
+export const toggleFeatureFlag = ({ dispatch }, flag) => {
+ dispatch('updateFeatureFlag', flag);
+
+ axios
+ .put(flag.update_path, {
+ operations_feature_flag: flag,
+ })
+ .then(response => dispatch('receiveUpdateFeatureFlagSuccess', response.data))
+ .catch(() => dispatch('receiveUpdateFeatureFlagError', flag.id));
+};
+
+export const updateFeatureFlag = ({ commit }, flag) => commit(types.UPDATE_FEATURE_FLAG, flag);
+
+export const receiveUpdateFeatureFlagSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS, data);
+export const receiveUpdateFeatureFlagError = ({ commit }, id) =>
+ commit(types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, id);
+
+export const deleteUserList = ({ state, dispatch }, list) => {
+ dispatch('requestDeleteUserList', list);
+
+ return Api.deleteFeatureFlagUserList(state.projectId, list.iid)
+ .then(() => dispatch('fetchUserLists'))
+ .catch(error =>
+ dispatch('receiveDeleteUserListError', {
+ list,
+ error: error?.response?.data ?? error,
+ }),
+ );
+};
+
+export const requestDeleteUserList = ({ commit }, list) =>
+ commit(types.REQUEST_DELETE_USER_LIST, list);
+
+export const receiveDeleteUserListError = ({ commit }, { error, list }) => {
+ commit(types.RECEIVE_DELETE_USER_LIST_ERROR, { error, list });
+};
+
+export const rotateInstanceId = ({ state, dispatch }) => {
+ dispatch('requestRotateInstanceId');
+
+ axios
+ .post(state.rotateEndpoint)
+ .then(({ data = {}, headers }) => dispatch('receiveRotateInstanceIdSuccess', { data, headers }))
+ .catch(() => dispatch('receiveRotateInstanceIdError'));
+};
+
+export const requestRotateInstanceId = ({ commit }) => commit(types.REQUEST_ROTATE_INSTANCE_ID);
+export const receiveRotateInstanceIdSuccess = ({ commit }, response) =>
+ commit(types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS, response);
+export const receiveRotateInstanceIdError = ({ commit }) =>
+ commit(types.RECEIVE_ROTATE_INSTANCE_ID_ERROR);
+
+export const clearAlert = ({ commit }, index) => {
+ commit(types.RECEIVE_CLEAR_ALERT, index);
+};
diff --git a/app/assets/javascripts/feature_flags/store/modules/index/index.js b/app/assets/javascripts/feature_flags/store/modules/index/index.js
new file mode 100644
index 00000000000..665bb29a17e
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/index/index.js
@@ -0,0 +1,10 @@
+import state from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default {
+ namespaced: true,
+ actions,
+ mutations,
+ state: state(),
+};
diff --git a/app/assets/javascripts/feature_flags/store/modules/index/mutation_types.js b/app/assets/javascripts/feature_flags/store/modules/index/mutation_types.js
new file mode 100644
index 00000000000..4a4bd13c945
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/index/mutation_types.js
@@ -0,0 +1,26 @@
+export const SET_FEATURE_FLAGS_ENDPOINT = 'SET_FEATURE_FLAGS_ENDPOINT';
+export const SET_FEATURE_FLAGS_OPTIONS = 'SET_FEATURE_FLAGS_OPTIONS';
+export const SET_INSTANCE_ID_ENDPOINT = 'SET_INSTANCE_ID_ENDPOINT';
+export const SET_INSTANCE_ID = 'SET_INSTANCE_ID';
+export const SET_PROJECT_ID = 'SET_PROJECT_ID';
+
+export const REQUEST_FEATURE_FLAGS = 'REQUEST_FEATURE_FLAGS';
+export const RECEIVE_FEATURE_FLAGS_SUCCESS = 'RECEIVE_FEATURE_FLAGS_SUCCESS';
+export const RECEIVE_FEATURE_FLAGS_ERROR = 'RECEIVE_FEATURE_FLAGS_ERROR';
+
+export const REQUEST_USER_LISTS = 'REQUEST_USER_LISTS';
+export const RECEIVE_USER_LISTS_SUCCESS = 'RECEIVE_USER_LISTS_SUCCESS';
+export const RECEIVE_USER_LISTS_ERROR = 'RECEIVE_USER_LISTS_ERROR';
+
+export const REQUEST_DELETE_USER_LIST = 'REQUEST_DELETE_USER_LIST';
+export const RECEIVE_DELETE_USER_LIST_ERROR = 'RECEIVE_DELETE_USER_LIST_ERROR';
+
+export const UPDATE_FEATURE_FLAG = 'UPDATE_FEATURE_FLAG';
+export const RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS';
+export const RECEIVE_UPDATE_FEATURE_FLAG_ERROR = 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR';
+
+export const REQUEST_ROTATE_INSTANCE_ID = 'REQUEST_ROTATE_INSTANCE_ID';
+export const RECEIVE_ROTATE_INSTANCE_ID_SUCCESS = 'RECEIVE_ROTATE_INSTANCE_ID_SUCCESS';
+export const RECEIVE_ROTATE_INSTANCE_ID_ERROR = 'RECEIVE_ROTATE_INSTANCE_ID_ERROR';
+
+export const RECEIVE_CLEAR_ALERT = 'RECEIVE_CLEAR_ALERT';
diff --git a/app/assets/javascripts/feature_flags/store/modules/index/mutations.js b/app/assets/javascripts/feature_flags/store/modules/index/mutations.js
new file mode 100644
index 00000000000..948786a3533
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/index/mutations.js
@@ -0,0 +1,125 @@
+import Vue from 'vue';
+import * as types from './mutation_types';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../../../constants';
+import { mapToScopesViewModel } from '../helpers';
+
+const mapFlag = flag => ({ ...flag, scopes: mapToScopesViewModel(flag.scopes || []) });
+
+const updateFlag = (state, flag) => {
+ const index = state[FEATURE_FLAG_SCOPE].findIndex(({ id }) => id === flag.id);
+ Vue.set(state[FEATURE_FLAG_SCOPE], index, flag);
+};
+
+const createPaginationInfo = (state, headers) => {
+ let paginationInfo;
+ if (Object.keys(headers).length) {
+ const normalizedHeaders = normalizeHeaders(headers);
+ paginationInfo = parseIntPagination(normalizedHeaders);
+ } else {
+ paginationInfo = headers;
+ }
+ return paginationInfo;
+};
+
+export default {
+ [types.SET_FEATURE_FLAGS_ENDPOINT](state, endpoint) {
+ state.endpoint = endpoint;
+ },
+ [types.SET_FEATURE_FLAGS_OPTIONS](state, options = {}) {
+ state.options = options;
+ },
+ [types.SET_INSTANCE_ID_ENDPOINT](state, endpoint) {
+ state.rotateEndpoint = endpoint;
+ },
+ [types.SET_INSTANCE_ID](state, instance) {
+ state.instanceId = instance;
+ },
+ [types.SET_PROJECT_ID](state, project) {
+ state.projectId = project;
+ },
+ [types.REQUEST_FEATURE_FLAGS](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_FEATURE_FLAGS_SUCCESS](state, response) {
+ state.isLoading = false;
+ state.hasError = false;
+ state[FEATURE_FLAG_SCOPE] = (response.data.feature_flags || []).map(mapFlag);
+
+ const paginationInfo = createPaginationInfo(state, response.headers);
+ state.count = {
+ ...state.count,
+ [FEATURE_FLAG_SCOPE]: paginationInfo?.total ?? state[FEATURE_FLAG_SCOPE].length,
+ };
+ state.pageInfo = {
+ ...state.pageInfo,
+ [FEATURE_FLAG_SCOPE]: paginationInfo,
+ };
+ },
+ [types.RECEIVE_FEATURE_FLAGS_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ },
+ [types.REQUEST_USER_LISTS](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_USER_LISTS_SUCCESS](state, response) {
+ state.isLoading = false;
+ state.hasError = false;
+ state[USER_LIST_SCOPE] = response.data || [];
+
+ const paginationInfo = createPaginationInfo(state, response.headers);
+ state.count = {
+ ...state.count,
+ [USER_LIST_SCOPE]: paginationInfo?.total ?? state[USER_LIST_SCOPE].length,
+ };
+ state.pageInfo = {
+ ...state.pageInfo,
+ [USER_LIST_SCOPE]: paginationInfo,
+ };
+ },
+ [types.RECEIVE_USER_LISTS_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ },
+ [types.REQUEST_ROTATE_INSTANCE_ID](state) {
+ state.isRotating = true;
+ state.hasRotateError = false;
+ },
+ [types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS](
+ state,
+ {
+ data: { token },
+ },
+ ) {
+ state.isRotating = false;
+ state.instanceId = token;
+ state.hasRotateError = false;
+ },
+ [types.RECEIVE_ROTATE_INSTANCE_ID_ERROR](state) {
+ state.isRotating = false;
+ state.hasRotateError = true;
+ },
+ [types.UPDATE_FEATURE_FLAG](state, flag) {
+ updateFlag(state, flag);
+ },
+ [types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](state, data) {
+ updateFlag(state, mapFlag(data));
+ },
+ [types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](state, i) {
+ const flag = state[FEATURE_FLAG_SCOPE].find(({ id }) => i === id);
+ updateFlag(state, { ...flag, active: !flag.active });
+ },
+ [types.REQUEST_DELETE_USER_LIST](state, list) {
+ state.userLists = state.userLists.filter(l => l !== list);
+ },
+ [types.RECEIVE_DELETE_USER_LIST_ERROR](state, { error, list }) {
+ state.isLoading = false;
+ state.hasError = false;
+ state.alerts = [].concat(error.message);
+ state.userLists = state.userLists.concat(list).sort((l1, l2) => l1.iid - l2.iid);
+ },
+ [types.RECEIVE_CLEAR_ALERT](state, index) {
+ state.alerts.splice(index, 1);
+ },
+};
diff --git a/app/assets/javascripts/feature_flags/store/modules/index/state.js b/app/assets/javascripts/feature_flags/store/modules/index/state.js
new file mode 100644
index 00000000000..443a12d485d
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/index/state.js
@@ -0,0 +1,18 @@
+import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../../../constants';
+
+export default () => ({
+ [FEATURE_FLAG_SCOPE]: [],
+ [USER_LIST_SCOPE]: [],
+ alerts: [],
+ count: {},
+ pageInfo: { [FEATURE_FLAG_SCOPE]: {}, [USER_LIST_SCOPE]: {} },
+ isLoading: true,
+ hasError: false,
+ endpoint: null,
+ rotateEndpoint: null,
+ instanceId: '',
+ isRotating: false,
+ hasRotateError: false,
+ options: {},
+ projectId: '',
+});
diff --git a/app/assets/javascripts/feature_flags/store/modules/new/actions.js b/app/assets/javascripts/feature_flags/store/modules/new/actions.js
new file mode 100644
index 00000000000..d2159d55d53
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/new/actions.js
@@ -0,0 +1,51 @@
+import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { NEW_VERSION_FLAG } from '../../../constants';
+import { mapFromScopesViewModel, mapStrategiesToRails } from '../helpers';
+
+/**
+ * Commits mutation to set the main endpoint
+ * @param {String} endpoint
+ */
+export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
+
+/**
+ * Commits mutation to set the feature flag path.
+ * Used to redirect the user after form submission
+ *
+ * @param {String} path
+ */
+export const setPath = ({ commit }, path) => commit(types.SET_PATH, path);
+
+/**
+ * Handles the creation of a new feature flag.
+ *
+ * Will dispatch `requestCreateFeatureFlag`
+ * Serializes the params and makes a post request
+ * Dispatches an action acording to the request status.
+ *
+ * @param {Object} params
+ */
+export const createFeatureFlag = ({ state, dispatch }, params) => {
+ dispatch('requestCreateFeatureFlag');
+
+ return axios
+ .post(
+ state.endpoint,
+ params.version === NEW_VERSION_FLAG
+ ? mapStrategiesToRails(params)
+ : mapFromScopesViewModel(params),
+ )
+ .then(() => {
+ dispatch('receiveCreateFeatureFlagSuccess');
+ visitUrl(state.path);
+ })
+ .catch(error => dispatch('receiveCreateFeatureFlagError', error.response.data));
+};
+
+export const requestCreateFeatureFlag = ({ commit }) => commit(types.REQUEST_CREATE_FEATURE_FLAG);
+export const receiveCreateFeatureFlagSuccess = ({ commit }) =>
+ commit(types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS);
+export const receiveCreateFeatureFlagError = ({ commit }, error) =>
+ commit(types.RECEIVE_CREATE_FEATURE_FLAG_ERROR, error);
diff --git a/app/assets/javascripts/feature_flags/store/modules/new/index.js b/app/assets/javascripts/feature_flags/store/modules/new/index.js
new file mode 100644
index 00000000000..665bb29a17e
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/new/index.js
@@ -0,0 +1,10 @@
+import state from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default {
+ namespaced: true,
+ actions,
+ mutations,
+ state: state(),
+};
diff --git a/app/assets/javascripts/feature_flags/store/modules/new/mutation_types.js b/app/assets/javascripts/feature_flags/store/modules/new/mutation_types.js
new file mode 100644
index 00000000000..317f3689dfd
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/new/mutation_types.js
@@ -0,0 +1,6 @@
+export const SET_ENDPOINT = 'SET_ENDPOINT';
+export const SET_PATH = 'SET_PATH';
+
+export const REQUEST_CREATE_FEATURE_FLAG = 'REQUEST_CREATE_FEATURE_FLAG';
+export const RECEIVE_CREATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_CREATE_FEATURE_FLAG_SUCCESS';
+export const RECEIVE_CREATE_FEATURE_FLAG_ERROR = 'RECEIVE_CREATE_FEATURE_FLAG_ERROR';
diff --git a/app/assets/javascripts/feature_flags/store/modules/new/mutations.js b/app/assets/javascripts/feature_flags/store/modules/new/mutations.js
new file mode 100644
index 00000000000..06e467c04f1
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/new/mutations.js
@@ -0,0 +1,21 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_ENDPOINT](state, endpoint) {
+ state.endpoint = endpoint;
+ },
+ [types.SET_PATH](state, path) {
+ state.path = path;
+ },
+ [types.REQUEST_CREATE_FEATURE_FLAG](state) {
+ state.isSendingRequest = true;
+ state.error = [];
+ },
+ [types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS](state) {
+ state.isSendingRequest = false;
+ },
+ [types.RECEIVE_CREATE_FEATURE_FLAG_ERROR](state, error) {
+ state.isSendingRequest = false;
+ state.error = error.message || [];
+ },
+};
diff --git a/app/assets/javascripts/feature_flags/store/modules/new/state.js b/app/assets/javascripts/feature_flags/store/modules/new/state.js
new file mode 100644
index 00000000000..6f9263dbb2a
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/modules/new/state.js
@@ -0,0 +1,6 @@
+export default () => ({
+ endpoint: null,
+ path: null,
+ isSendingRequest: false,
+ error: [],
+});
diff --git a/app/assets/javascripts/feature_flags/utils.js b/app/assets/javascripts/feature_flags/utils.js
new file mode 100644
index 00000000000..1017a3d0c2a
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/utils.js
@@ -0,0 +1,48 @@
+import { s__, n__, sprintf } from '~/locale';
+import {
+ ALL_ENVIRONMENTS_NAME,
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+} from './constants';
+
+const badgeTextByType = {
+ [ROLLOUT_STRATEGY_ALL_USERS]: {
+ name: s__('FeatureFlags|All Users'),
+ parameters: null,
+ },
+ [ROLLOUT_STRATEGY_PERCENT_ROLLOUT]: {
+ name: s__('FeatureFlags|Percent of users'),
+ parameters: ({ parameters: { percentage } }) => `${percentage}%`,
+ },
+ [ROLLOUT_STRATEGY_USER_ID]: {
+ name: s__('FeatureFlags|User IDs'),
+ parameters: ({ parameters: { userIds } }) =>
+ sprintf(n__('FeatureFlags|%d user', 'FeatureFlags|%d users', userIds.split(',').length)),
+ },
+ [ROLLOUT_STRATEGY_GITLAB_USER_LIST]: {
+ name: s__('FeatureFlags|User List'),
+ parameters: ({ user_list: { name } }) => name,
+ },
+};
+
+const scopeName = ({ environment_scope: scope }) =>
+ scope === ALL_ENVIRONMENTS_NAME ? s__('FeatureFlags|All Environments') : scope;
+
+export default strategy => {
+ const { name, parameters } = badgeTextByType[strategy.name];
+
+ if (parameters) {
+ return sprintf('%{name} - %{parameters}: %{scopes}', {
+ name,
+ parameters: parameters(strategy),
+ scopes: strategy.scopes.map(scopeName).join(', '),
+ });
+ }
+
+ return sprintf('%{name}: %{scopes}', {
+ name,
+ scopes: strategy.scopes.map(scopeName).join(', '),
+ });
+};
diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
index 80f78c154ee..1bfbab9ef96 100644
--- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
+++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
@@ -63,4 +63,47 @@ export default IssuableTokenKeys => {
IssuableTokenKeys.tokenKeys.push(targetBranchToken);
IssuableTokenKeys.tokenKeysWithAlternative.push(targetBranchToken);
+
+ const approvedBy = {
+ token: {
+ formattedKey: __('Approved-By'),
+ key: 'approved-by',
+ type: 'array',
+ param: 'usernames[]',
+ symbol: '@',
+ icon: 'approval',
+ tag: '@approved-by',
+ },
+ condition: [
+ {
+ url: 'approved_by_usernames[]=None',
+ tokenKey: 'approved-by',
+ value: __('None'),
+ operator: '=',
+ },
+ {
+ url: 'not[approved_by_usernames][]=None',
+ tokenKey: 'approved-by',
+ value: __('None'),
+ operator: '!=',
+ },
+ {
+ url: 'approved_by_usernames[]=Any',
+ tokenKey: 'approved-by',
+ value: __('Any'),
+ operator: '=',
+ },
+ {
+ url: 'not[approved_by_usernames][]=Any',
+ tokenKey: 'approved-by',
+ value: __('Any'),
+ operator: '!=',
+ },
+ ],
+ };
+
+ const tokenPosition = 2;
+ IssuableTokenKeys.tokenKeys.splice(tokenPosition, 0, ...[approvedBy.token]);
+ IssuableTokenKeys.tokenKeysWithAlternative.splice(tokenPosition, 0, ...[approvedBy.token]);
+ IssuableTokenKeys.conditions.push(...approvedBy.condition);
};
diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
index 49bd3cda127..5b4af96c861 100644
--- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
+++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
@@ -69,6 +69,11 @@ export default class AvailableDropdownMappings {
gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-assignee'),
},
+ 'approved-by': {
+ reference: null,
+ gl: DropdownUser,
+ element: this.container.querySelector('#js-dropdown-approved-by'),
+ },
milestone: {
reference: null,
gl: DropdownNonUser,
diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js
index 0b9fe969da1..6cd6f9c9906 100644
--- a/app/assets/javascripts/filtered_search/constants.js
+++ b/app/assets/javascripts/filtered_search/constants.js
@@ -1,4 +1,4 @@
-export const USER_TOKEN_TYPES = ['author', 'assignee'];
+export const USER_TOKEN_TYPES = ['author', 'assignee', 'approved-by'];
export const DROPDOWN_TYPE = {
hint: 'hint',
diff --git a/app/assets/javascripts/frequent_items/utils.js b/app/assets/javascripts/frequent_items/utils.js
index 112e8eaaf17..954d426c86c 100644
--- a/app/assets/javascripts/frequent_items/utils.js
+++ b/app/assets/javascripts/frequent_items/utils.js
@@ -1,6 +1,6 @@
import { take } from 'lodash';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import { sanitize } from 'dompurify';
+import { sanitize } from '~/lib/dompurify';
import { FREQUENT_ITEMS, HOUR_IN_MS } from './constants';
export const isMobile = () => ['md', 'sm', 'xs'].includes(bp.getBreakpointSize());
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 409733c73b9..0329006c62a 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -4,6 +4,7 @@ import { escape, template } from 'lodash';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache';
+import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from './lib/utils/common_utils';
import * as Emoji from '~/emoji';
@@ -60,6 +61,7 @@ class GfmAutoComplete {
this.dataSources = dataSources;
this.cachedData = {};
this.isLoadingData = {};
+ this.previousQuery = '';
}
setup(input, enableMap = defaultAutocompleteConfig) {
@@ -523,7 +525,7 @@ class GfmAutoComplete {
}
getDefaultCallbacks() {
- const fetchData = this.fetchData.bind(this);
+ const self = this;
return {
sorter(query, items, searchKey) {
@@ -536,7 +538,15 @@ class GfmAutoComplete {
},
filter(query, data, searchKey) {
if (GfmAutoComplete.isLoading(data)) {
- fetchData(this.$inputor, this.at);
+ self.fetchData(this.$inputor, this.at);
+ return data;
+ }
+ if (
+ GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[this.at]) &&
+ self.previousQuery !== query
+ ) {
+ self.fetchData(this.$inputor, this.at, query);
+ self.previousQuery = query;
return data;
}
return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
@@ -584,13 +594,22 @@ class GfmAutoComplete {
};
}
- fetchData($input, at) {
+ fetchData($input, at, search) {
if (this.isLoadingData[at]) return;
this.isLoadingData[at] = true;
const dataSource = this.dataSources[GfmAutoComplete.atTypeMap[at]];
- if (this.cachedData[at]) {
+ if (GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[at])) {
+ axios
+ .get(dataSource, { params: { search } })
+ .then(({ data }) => {
+ this.loadData($input, at, data);
+ })
+ .catch(() => {
+ this.isLoadingData[at] = false;
+ });
+ } else if (this.cachedData[at]) {
this.loadData($input, at, this.cachedData[at]);
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
Emoji.initEmojiMap()
@@ -684,6 +703,8 @@ GfmAutoComplete.atTypeMap = {
$: 'snippets',
};
+GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
+
// Emoji
GfmAutoComplete.glEmojiTag = null;
GfmAutoComplete.Emoji = {
diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
index 79494cb173b..7a991ac2455 100644
--- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
+++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
@@ -92,11 +92,9 @@ export default {
</a>
</p>
</gl-form-group>
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button variant="success" category="primary" @click="updateGrafanaIntegration">
- {{ __('Save Changes') }}
- </gl-button>
- </div>
+ <gl-button variant="success" category="primary" @click="updateGrafanaIntegration">
+ {{ __('Save Changes') }}
+ </gl-button>
</form>
</div>
</section>
diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue
index 8c7192b49a0..d2a613bed4f 100644
--- a/app/assets/javascripts/groups/components/group_folder.vue
+++ b/app/assets/javascripts/groups/components/group_folder.vue
@@ -1,8 +1,12 @@
<script>
+import { GlIcon } from '@gitlab/ui';
import { n__ } from '../../locale';
import { MAX_CHILDREN_COUNT } from '../constants';
export default {
+ components: {
+ GlIcon,
+ },
props: {
parentGroup: {
type: Object,
@@ -45,7 +49,7 @@ export default {
/>
<li v-if="hasMoreChildren" class="group-row">
<a :href="parentGroup.relativePath" class="group-row-contents has-more-items py-2">
- <i class="fa fa-external-link" aria-hidden="true"> </i> {{ moreChildrenStats }}
+ <gl-icon name="external-link" aria-hidden="true" /> {{ moreChildrenStats }}
</a>
</li>
</ul>
diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue
index 5487e25066e..2e92a608f76 100644
--- a/app/assets/javascripts/groups/components/item_actions.vue
+++ b/app/assets/javascripts/groups/components/item_actions.vue
@@ -53,6 +53,7 @@ export default {
:aria-label="leaveBtnTitle"
data-container="body"
data-placement="bottom"
+ data-testid="leave-group-btn"
class="leave-group btn btn-xs no-expand gl-text-gray-500 gl-ml-5"
@click.prevent="onLeaveGroup"
>
@@ -66,6 +67,7 @@ export default {
:aria-label="editBtnTitle"
data-container="body"
data-placement="bottom"
+ data-testid="edit-group-btn"
class="edit-group btn btn-xs no-expand gl-text-gray-500 gl-ml-5"
>
<gl-icon name="settings" class="position-top-0 align-middle" />
diff --git a/app/assets/javascripts/groups/components/item_stats_value.vue b/app/assets/javascripts/groups/components/item_stats_value.vue
index 18efd8c6823..2185284c892 100644
--- a/app/assets/javascripts/groups/components/item_stats_value.vue
+++ b/app/assets/javascripts/groups/components/item_stats_value.vue
@@ -57,6 +57,7 @@ export default {
:title="title"
data-container="body"
>
- <gl-icon :name="iconName" /> <span v-if="isValuePresent" class="stat-value"> {{ value }} </span>
+ <gl-icon :name="iconName" />
+ <span v-if="isValuePresent" class="stat-value" data-testid="itemStatValue"> {{ value }} </span>
</span>
</template>
diff --git a/app/assets/javascripts/groups/members/components/app.vue b/app/assets/javascripts/groups/members/components/app.vue
index e94b28f5773..32b832644b9 100644
--- a/app/assets/javascripts/groups/members/components/app.vue
+++ b/app/assets/javascripts/groups/members/components/app.vue
@@ -1,11 +1,12 @@
<script>
+import MembersTable from '~/vue_shared/components/members/table/members_table.vue';
+
export default {
name: 'GroupMembersApp',
+ components: { MembersTable },
};
</script>
<template>
- <span>
- <!-- Temporary empty template -->
- </span>
+ <members-table />
</template>
diff --git a/app/assets/javascripts/groups/members/index.js b/app/assets/javascripts/groups/members/index.js
index 4ca1756f10c..0a032eacf05 100644
--- a/app/assets/javascripts/groups/members/index.js
+++ b/app/assets/javascripts/groups/members/index.js
@@ -4,7 +4,7 @@ import App from './components/app.vue';
import membersModule from '~/vuex_shared/modules/members';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-export default el => {
+export const initGroupMembersApp = (el, tableFields) => {
if (!el) {
return () => {};
}
@@ -18,6 +18,7 @@ export default el => {
members: convertObjectPropsToCamelCase(JSON.parse(members), { deep: true }),
sourceId: parseInt(groupId, 10),
currentUserId: gon.current_user_id || null,
+ tableFields,
}),
});
diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue
index 183816921c1..69e5cd839b4 100644
--- a/app/assets/javascripts/ide/components/activity_bar.vue
+++ b/app/assets/javascripts/ide/components/activity_bar.vue
@@ -32,7 +32,7 @@ export default {
</script>
<template>
- <nav class="ide-activity-bar">
+ <nav class="ide-activity-bar" data-testid="left-sidebar">
<ul class="list-unstyled">
<li>
<button
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
index de4b0a34002..b89329c92ec 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
@@ -1,8 +1,8 @@
<script>
-/* eslint-disable vue/no-v-html */
import { escape } from 'lodash';
import { mapState, mapGetters, createNamespacedHelpers } from 'vuex';
-import { sprintf, s__ } from '~/locale';
+import { GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
import consts from '../../stores/modules/commit/constants';
import RadioGroup from './radio_group.vue';
import NewMergeRequestOption from './new_merge_request_option.vue';
@@ -13,6 +13,7 @@ const { mapState: mapCommitState, mapActions: mapCommitActions } = createNamespa
export default {
components: {
+ GlSprintf,
RadioGroup,
NewMergeRequestOption,
},
@@ -20,12 +21,8 @@ export default {
...mapState(['currentBranchId', 'changedFiles', 'stagedFiles']),
...mapCommitState(['commitAction']),
...mapGetters(['currentBranch', 'emptyRepo', 'canPushToBranch']),
- commitToCurrentBranchText() {
- return sprintf(
- s__('IDE|Commit to %{branchName} branch'),
- { branchName: `<strong class="monospace">${escape(this.currentBranchId)}</strong>` },
- false,
- );
+ currentBranchText() {
+ return escape(this.currentBranchId);
},
containsStagedChanges() {
return this.changedFiles.length > 0 && this.stagedFiles.length > 0;
@@ -77,11 +74,13 @@ export default {
:disabled="!canPushToBranch"
:title="$options.currentBranchPermissionsTooltip"
>
- <span
- class="ide-option-label"
- data-qa-selector="commit_to_current_branch_radio"
- v-html="commitToCurrentBranchText"
- ></span>
+ <span class="ide-option-label" data-qa-selector="commit_to_current_branch_radio">
+ <gl-sprintf :message="s__('IDE|Commit to %{branchName} branch')">
+ <template #branchName>
+ <strong class="monospace">{{ currentBranchText }}</strong>
+ </template>
+ </gl-sprintf>
+ </span>
</radio-group>
<template v-if="!emptyRepo">
<radio-group
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
index 2787b10a48b..5b392470e41 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlPopover } from '@gitlab/ui';
import { __, sprintf } from '../../../locale';
import popover from '../../../vue_shared/directives/popover';
import { MAX_TITLE_LENGTH, MAX_BODY_LENGTH } from '../../constants';
@@ -10,6 +10,7 @@ export default {
},
components: {
GlIcon,
+ GlPopover,
},
props: {
text: {
@@ -58,7 +59,7 @@ export default {
},
},
popoverOptions: {
- trigger: 'hover',
+ triggers: 'hover',
placement: 'top',
content: sprintf(
__(`
@@ -83,9 +84,16 @@ export default {
<ul class="nav-links">
<li>
{{ __('Commit Message') }}
- <span v-popover="$options.popoverOptions" class="form-text text-muted gl-ml-3">
- <gl-icon name="question" />
- </span>
+ <div id="ide-commit-message-popover-container">
+ <span id="ide-commit-message-question" class="form-text text-muted gl-ml-3">
+ <gl-icon name="question" />
+ </span>
+ <gl-popover
+ target="ide-commit-message-question"
+ container="ide-commit-message-popover-container"
+ v-bind="$options.popoverOptions"
+ />
+ </div>
</li>
</ul>
</div>
diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
index 732fa0786b0..dec8aa61838 100644
--- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
+++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
@@ -1,8 +1,12 @@
<script>
+import { GlButton } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { viewerTypes } from '../constants';
export default {
+ components: {
+ GlButton,
+ },
props: {
viewer: {
type: String,
@@ -31,7 +35,7 @@ export default {
<template>
<div class="dropdown">
- <button type="button" class="btn btn-link" data-toggle="dropdown">{{ __('Edit') }}</button>
+ <gl-button variant="link" data-toggle="dropdown">{{ __('Edit') }}</gl-button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-open-left">
<ul>
<li>
diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue
index d80662f6ae1..cfd2555b769 100644
--- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue
+++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue
@@ -1,12 +1,13 @@
<script>
import $ from 'jquery';
import { mapActions, mapState } from 'vuex';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
export default {
components: {
DropdownButton,
+ GlIcon,
GlLoadingIcon,
},
props: {
@@ -85,7 +86,7 @@ export default {
type="search"
class="dropdown-input-field qa-dropdown-filter-input"
/>
- <i aria-hidden="true" class="fa fa-search dropdown-input-search"></i>
+ <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" />
</div>
<div class="dropdown-content">
<gl-loading-icon v-if="showLoading" size="lg" />
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 1b03d9eee8b..b08497f8f82 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -2,7 +2,18 @@
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
+import {
+ WEBIDE_MARK_APP_START,
+ WEBIDE_MARK_FILE_FINISH,
+ WEBIDE_MARK_FILE_CLICKED,
+ WEBIDE_MARK_TREE_FINISH,
+ WEBIDE_MEASURE_TREE_FROM_REQUEST,
+ WEBIDE_MEASURE_FILE_FROM_REQUEST,
+ WEBIDE_MEASURE_FILE_AFTER_INTERACTION,
+} from '~/performance_constants';
+import { performanceMarkAndMeasure } from '~/performance_utils';
import { modalTypes } from '../constants';
+import eventHub from '../eventhub';
import FindFile from '~/vue_shared/components/file_finder/index.vue';
import NewModal from './new_dropdown/modal.vue';
import IdeSidebar from './ide_side_bar.vue';
@@ -14,6 +25,50 @@ import ErrorMessage from './error_message.vue';
import CommitEditorHeader from './commit_sidebar/editor_header.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+const markPerformance = params => {
+ performanceMarkAndMeasure(params);
+};
+const markTreePerformance = () => {
+ markPerformance({
+ mark: WEBIDE_MARK_TREE_FINISH,
+ measures: [
+ {
+ name: WEBIDE_MEASURE_TREE_FROM_REQUEST,
+ start: undefined,
+ end: WEBIDE_MARK_TREE_FINISH,
+ },
+ ],
+ });
+};
+const markEditorLoadPerformance = () => {
+ markPerformance({
+ mark: WEBIDE_MARK_FILE_FINISH,
+ measures: [
+ {
+ name: WEBIDE_MEASURE_FILE_FROM_REQUEST,
+ start: undefined,
+ end: WEBIDE_MARK_FILE_FINISH,
+ },
+ ],
+ });
+};
+const markEditorInteractionPerformance = () => {
+ markPerformance({
+ mark: WEBIDE_MARK_FILE_FINISH,
+ measures: [
+ {
+ name: WEBIDE_MEASURE_FILE_AFTER_INTERACTION,
+ start: WEBIDE_MARK_FILE_CLICKED,
+ end: WEBIDE_MARK_FILE_FINISH,
+ },
+ ],
+ });
+};
+
+eventHub.$on(WEBIDE_MEASURE_TREE_FROM_REQUEST, markTreePerformance);
+eventHub.$on(WEBIDE_MEASURE_FILE_FROM_REQUEST, markEditorLoadPerformance);
+eventHub.$on(WEBIDE_MEASURE_FILE_AFTER_INTERACTION, markEditorInteractionPerformance);
+
export default {
components: {
NewModal,
@@ -59,6 +114,9 @@ export default {
if (this.themeName)
document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`);
},
+ beforeCreate() {
+ performance.mark(WEBIDE_MARK_APP_START);
+ },
methods: {
...mapActions(['toggleFileFinder']),
onBeforeUnload(e = {}) {
diff --git a/app/assets/javascripts/ide/components/ide_review.vue b/app/assets/javascripts/ide/components/ide_review.vue
index e36d0a5a5b1..7d2f0acb08c 100644
--- a/app/assets/javascripts/ide/components/ide_review.vue
+++ b/app/assets/javascripts/ide/components/ide_review.vue
@@ -23,26 +23,32 @@ export default {
},
},
mounted() {
- if (this.activeFile && this.activeFile.pending && !this.activeFile.deleted) {
- this.$router.push(this.getUrlForPath(this.activeFile.path), () => {
- this.updateViewer('editor');
- });
- } else if (this.activeFile && this.activeFile.deleted) {
- this.resetOpenFiles();
- }
-
- this.$nextTick(() => {
- this.updateViewer(this.currentMergeRequestId ? viewerTypes.mr : viewerTypes.diff);
- });
+ this.initialize();
+ },
+ activated() {
+ this.initialize();
},
methods: {
...mapActions(['updateViewer', 'resetOpenFiles']),
+ initialize() {
+ if (this.activeFile && this.activeFile.pending && !this.activeFile.deleted) {
+ this.$router.push(this.getUrlForPath(this.activeFile.path), () => {
+ this.updateViewer(viewerTypes.edit);
+ });
+ } else if (this.activeFile && this.activeFile.deleted) {
+ this.resetOpenFiles();
+ }
+
+ this.$nextTick(() => {
+ this.updateViewer(this.currentMergeRequestId ? viewerTypes.mr : viewerTypes.diff);
+ });
+ },
},
};
</script>
<template>
- <ide-tree-list :viewer-type="viewer" header-class="ide-review-header">
+ <ide-tree-list header-class="ide-review-header">
<template #header>
<div class="ide-review-button-holder">
{{ __('Review') }}
diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue
index ed68ca5cae9..53dfc133fc8 100644
--- a/app/assets/javascripts/ide/components/ide_side_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_side_bar.vue
@@ -7,9 +7,8 @@ import ActivityBar from './activity_bar.vue';
import RepoCommitSection from './repo_commit_section.vue';
import CommitForm from './commit_sidebar/form.vue';
import IdeReview from './ide_review.vue';
-import SuccessMessage from './commit_sidebar/success_message.vue';
import IdeProjectHeader from './ide_project_header.vue';
-import { leftSidebarViews, SIDEBAR_INIT_WIDTH } from '../constants';
+import { SIDEBAR_INIT_WIDTH } from '../constants';
export default {
components: {
@@ -20,18 +19,11 @@ export default {
IdeTree,
CommitForm,
IdeReview,
- SuccessMessage,
IdeProjectHeader,
},
computed: {
...mapState(['loading', 'currentActivityView', 'changedFiles', 'stagedFiles', 'lastCommitMsg']),
...mapGetters(['currentProject', 'someUncommittedChanges']),
- showSuccessMessage() {
- return (
- this.currentActivityView === leftSidebarViews.edit.name &&
- (this.lastCommitMsg && !this.someUncommittedChanges)
- );
- },
},
SIDEBAR_INIT_WIDTH,
};
@@ -44,7 +36,7 @@ export default {
class="multi-file-commit-panel flex-column"
>
<template v-if="loading">
- <div class="multi-file-commit-panel-inner">
+ <div class="multi-file-commit-panel-inner" data-testid="ide-side-bar-inner">
<div v-for="n in 3" :key="n" class="multi-file-loading-container">
<gl-skeleton-loading />
</div>
@@ -54,9 +46,11 @@ export default {
<ide-project-header :project="currentProject" />
<div class="ide-context-body d-flex flex-fill">
<activity-bar />
- <div class="multi-file-commit-panel-inner">
+ <div class="multi-file-commit-panel-inner" data-testid="ide-side-bar-inner">
<div class="multi-file-commit-panel-inner-content">
- <component :is="currentActivityView" />
+ <keep-alive>
+ <component :is="currentActivityView" />
+ </keep-alive>
</div>
<commit-form />
</div>
diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue
index 747d5044790..51d783df0ad 100644
--- a/app/assets/javascripts/ide/components/ide_tree.vue
+++ b/app/assets/javascripts/ide/components/ide_tree.vue
@@ -1,6 +1,6 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
-import { modalTypes } from '../constants';
+import { modalTypes, viewerTypes } from '../constants';
import IdeTreeList from './ide_tree_list.vue';
import Upload from './new_dropdown/upload.vue';
import NewEntryButton from './new_dropdown/button.vue';
@@ -18,15 +18,10 @@ export default {
...mapGetters(['currentProject', 'currentTree', 'activeFile', 'getUrlForPath']),
},
mounted() {
- if (!this.activeFile) return;
-
- if (this.activeFile.pending && !this.activeFile.deleted) {
- this.$router.push(this.getUrlForPath(this.activeFile.path), () => {
- this.updateViewer('editor');
- });
- } else if (this.activeFile.deleted) {
- this.resetOpenFiles();
- }
+ this.initialize();
+ },
+ activated() {
+ this.initialize();
},
methods: {
...mapActions(['updateViewer', 'createTempEntry', 'resetOpenFiles']),
@@ -36,12 +31,27 @@ export default {
createNewFolder() {
this.$refs.newModal.open(modalTypes.tree);
},
+ initialize() {
+ this.$nextTick(() => {
+ this.updateViewer(viewerTypes.edit);
+ });
+
+ if (!this.activeFile) return;
+
+ if (this.activeFile.pending && !this.activeFile.deleted) {
+ this.$router.push(this.getUrlForPath(this.activeFile.path), () => {
+ this.updateViewer(viewerTypes.edit);
+ });
+ } else if (this.activeFile.deleted) {
+ this.resetOpenFiles();
+ }
+ },
},
};
</script>
<template>
- <ide-tree-list viewer-type="editor">
+ <ide-tree-list>
<template #header>
{{ __('Edit') }}
<div class="ide-tree-actions ml-auto d-flex">
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index 776d8459515..bbec20776bf 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -2,6 +2,13 @@
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import FileTree from '~/vue_shared/components/file_tree.vue';
+import {
+ WEBIDE_MARK_TREE_START,
+ WEBIDE_MEASURE_TREE_FROM_REQUEST,
+ WEBIDE_MARK_FILE_CLICKED,
+} from '~/performance_constants';
+import { performanceMarkAndMeasure } from '~/performance_utils';
+import eventHub from '../eventhub';
import IdeFileRow from './ide_file_row.vue';
import NavDropdown from './nav_dropdown.vue';
@@ -12,10 +19,6 @@ export default {
FileTree,
},
props: {
- viewerType: {
- type: String,
- required: true,
- },
headerClass: {
type: String,
required: false,
@@ -29,11 +32,21 @@ export default {
return !this.currentTree || this.currentTree.loading;
},
},
- mounted() {
- this.updateViewer(this.viewerType);
+ beforeCreate() {
+ performanceMarkAndMeasure({ mark: WEBIDE_MARK_TREE_START });
+ },
+ updated() {
+ if (this.currentTree?.tree?.length) {
+ this.$nextTick(() => {
+ eventHub.$emit(WEBIDE_MEASURE_TREE_FROM_REQUEST);
+ });
+ }
},
methods: {
- ...mapActions(['updateViewer', 'toggleTreeOpen']),
+ ...mapActions(['toggleTreeOpen']),
+ clickedFile() {
+ performanceMarkAndMeasure({ mark: WEBIDE_MARK_FILE_CLICKED });
+ },
},
IdeFileRow,
};
@@ -51,7 +64,7 @@ export default {
<nav-dropdown />
<slot name="header"></slot>
</header>
- <div class="ide-tree-body h-100">
+ <div class="ide-tree-body h-100" data-testid="ide-tree-body">
<template v-if="currentTree.tree.length">
<file-tree
v-for="file in currentTree.tree"
@@ -60,6 +73,7 @@ export default {
:level="0"
:file-row-component="$options.IdeFileRow"
@toggleTreeOpen="toggleTreeOpen"
+ @clickFile="clickedFile"
/>
</template>
<div v-else class="file-row">{{ __('No files') }}</div>
diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue
index 11033a5cc88..394a512f5bd 100644
--- a/app/assets/javascripts/ide/components/jobs/detail.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail.vue
@@ -2,9 +2,8 @@
/* eslint-disable vue/no-v-html */
import { mapActions, mapState } from 'vuex';
import { throttle } from 'lodash';
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '../../../locale';
-import tooltip from '../../../vue_shared/directives/tooltip';
import ScrollButton from './detail/scroll_button.vue';
import JobDescription from './detail/description.vue';
@@ -15,7 +14,7 @@ const scrollPositions = {
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
@@ -84,7 +83,7 @@ export default {
<job-description :job="detailJob" />
<div class="controllers ml-auto">
<a
- v-tooltip
+ v-gl-tooltip
:title="__('Show complete raw log')"
:href="detailJob.rawPath"
data-placement="top"
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index 528475849de..5ad836f346a 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -152,6 +152,7 @@ export default {
v-model.trim="entryName"
type="text"
class="form-control"
+ data-testid="file-name-field"
data-qa-selector="file_name_field"
:placeholder="placeholder"
/>
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
index 5eed57bb6c5..92b99b5c731 100644
--- a/app/assets/javascripts/ide/components/repo_commit_section.vue
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -26,28 +26,34 @@ export default {
},
},
mounted() {
- const file =
- this.lastOpenedFile && this.lastOpenedFile.type !== 'tree'
- ? this.lastOpenedFile
- : this.activeFile;
-
- if (!file) return;
-
- this.openPendingTab({
- file,
- keyPrefix: file.staged ? stageKeys.staged : stageKeys.unstaged,
- })
- .then(changeViewer => {
- if (changeViewer) {
- this.updateViewer('diff');
- }
- })
- .catch(e => {
- throw e;
- });
+ this.initialize();
+ },
+ activated() {
+ this.initialize();
},
methods: {
...mapActions(['openPendingTab', 'updateViewer', 'updateActivityBarView']),
+ initialize() {
+ const file =
+ this.lastOpenedFile && this.lastOpenedFile.type !== 'tree'
+ ? this.lastOpenedFile
+ : this.activeFile;
+
+ if (!file) return;
+
+ this.openPendingTab({
+ file,
+ keyPrefix: file.staged ? stageKeys.staged : stageKeys.unstaged,
+ })
+ .then(changeViewer => {
+ if (changeViewer) {
+ this.updateViewer('diff');
+ }
+ })
+ .catch(e => {
+ throw e;
+ });
+ },
},
stageKeys,
};
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index f342ce1739c..7465772d86a 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -5,6 +5,14 @@ import { deprecatedCreateFlash as flash } from '~/flash';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import {
+ WEBIDE_MARK_FILE_CLICKED,
+ WEBIDE_MARK_FILE_START,
+ WEBIDE_MEASURE_FILE_AFTER_INTERACTION,
+ WEBIDE_MEASURE_FILE_FROM_REQUEST,
+} from '~/performance_constants';
+import { performanceMarkAndMeasure } from '~/performance_utils';
+import eventHub from '../eventhub';
+import {
leftSidebarViews,
viewerTypes,
FILE_VIEW_MODE_EDITOR,
@@ -164,6 +172,9 @@ export default {
}
},
},
+ beforeCreate() {
+ performanceMarkAndMeasure({ mark: WEBIDE_MARK_FILE_START });
+ },
beforeDestroy() {
this.editor.dispose();
},
@@ -289,6 +300,13 @@ export default {
});
this.$emit('editorSetup');
+ this.$nextTick(() => {
+ if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) {
+ eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION);
+ } else {
+ eventHub.$emit(WEBIDE_MEASURE_FILE_FROM_REQUEST);
+ }
+ });
},
refreshEditorDimensions() {
if (this.showEditor) {
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index 59b1969face..bdb11e6b004 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -47,9 +47,9 @@ export const diffViewerErrors = Object.freeze({
});
export const leftSidebarViews = {
- edit: { name: 'ide-tree', keepAlive: false },
- review: { name: 'ide-review', keepAlive: false },
- commit: { name: 'repo-commit-section', keepAlive: false },
+ edit: { name: 'ide-tree' },
+ review: { name: 'ide-review' },
+ commit: { name: 'repo-commit-section' },
};
export const rightSidebarViews = {
diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue
index 670c42cbdac..78eb828fd19 100644
--- a/app/assets/javascripts/incidents/components/incidents_list.vue
+++ b/app/assets/javascripts/incidents/components/incidents_list.vue
@@ -8,7 +8,6 @@ import {
GlAvatar,
GlTooltipDirective,
GlButton,
- GlSearchBoxByType,
GlIcon,
GlPagination,
GlTabs,
@@ -16,18 +15,35 @@ import {
GlBadge,
GlEmptyState,
} from '@gitlab/ui';
-import { debounce } from 'lodash';
+import Api from '~/api';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
-import { s__ } from '~/locale';
-import { mergeUrlParams, joinPaths, visitUrl } from '~/lib/utils/url_utility';
+import { s__, __ } from '~/locale';
+import { urlParamsToObject } from '~/lib/utils/common_utils';
+import {
+ visitUrl,
+ mergeUrlParams,
+ joinPaths,
+ updateHistory,
+ setUrlParams,
+} from '~/lib/utils/url_utility';
import getIncidents from '../graphql/queries/get_incidents.query.graphql';
import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql';
import SeverityToken from '~/sidebar/components/severity/severity.vue';
import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants';
-import { I18N, DEFAULT_PAGE_SIZE, INCIDENT_SEARCH_DELAY, INCIDENT_STATUS_TABS } from '../constants';
+import {
+ I18N,
+ DEFAULT_PAGE_SIZE,
+ INCIDENT_STATUS_TABS,
+ TH_CREATED_AT_TEST_ID,
+ TH_SEVERITY_TEST_ID,
+ TH_PUBLISHED_TEST_ID,
+ INCIDENT_DETAILS_PATH,
+} from '../constants';
-const TH_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' };
const tdClass =
'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap';
const thClass = 'gl-hover-bg-blue-50';
@@ -49,8 +65,10 @@ export default {
{
key: 'severity',
label: s__('IncidentManagement|Severity'),
- thClass: `gl-pointer-events-none`,
- tdClass,
+ thClass,
+ tdClass: `${tdClass} sortable-cell`,
+ sortable: true,
+ thAttr: TH_SEVERITY_TEST_ID,
},
{
key: 'title',
@@ -64,7 +82,7 @@ export default {
thClass,
tdClass: `${tdClass} sortable-cell`,
sortable: true,
- thAttr: TH_TEST_ID,
+ thAttr: TH_CREATED_AT_TEST_ID,
},
{
key: 'assignees',
@@ -82,7 +100,6 @@ export default {
GlAvatar,
GlButton,
TimeAgoTooltip,
- GlSearchBoxByType,
GlIcon,
GlPagination,
GlTabs,
@@ -91,10 +108,12 @@ export default {
GlBadge,
GlEmptyState,
SeverityToken,
+ FilteredSearchBar,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
inject: [
'projectPath',
'newIssuePath',
@@ -103,6 +122,9 @@ export default {
'issuePath',
'publishedAvailable',
'emptyListSvgPath',
+ 'textQuery',
+ 'authorUsernamesQuery',
+ 'assigneeUsernamesQuery',
],
apollo: {
incidents: {
@@ -118,6 +140,8 @@ export default {
lastPageSize: this.pagination.lastPageSize,
prevPageCursor: this.pagination.prevPageCursor,
nextPageCursor: this.pagination.nextPageCursor,
+ authorUsername: this.authorUsername,
+ assigneeUsernames: this.assigneeUsernames,
};
},
update({ project: { issues: { nodes = [], pageInfo = {} } = {} } = {} }) {
@@ -135,6 +159,8 @@ export default {
variables() {
return {
searchTerm: this.searchTerm,
+ authorUsername: this.authorUsername,
+ assigneeUsernames: this.assigneeUsernames,
projectPath: this.projectPath,
issueTypes: ['INCIDENT'],
};
@@ -149,7 +175,7 @@ export default {
errored: false,
isErrorAlertDismissed: false,
redirecting: false,
- searchTerm: '',
+ searchTerm: this.textQuery,
pagination: initialPaginationState,
incidents: {},
sort: 'created_desc',
@@ -157,6 +183,9 @@ export default {
sortDesc: true,
statusFilter: '',
filteredByStatus: '',
+ authorUsername: this.authorUsernamesQuery,
+ assigneeUsernames: this.assigneeUsernamesQuery,
+ filterParams: {},
};
},
computed: {
@@ -208,7 +237,10 @@ export default {
{
key: 'published',
label: s__('IncidentManagement|Published'),
- thClass: 'gl-pointer-events-none',
+ thClass,
+ tdClass: `${tdClass} sortable-cell`,
+ sortable: true,
+ thAttr: TH_PUBLISHED_TEST_ID,
},
],
]
@@ -242,15 +274,59 @@ export default {
btnText: createIncidentBtnLabel,
};
},
+ filteredSearchTokens() {
+ return [
+ {
+ type: 'author_username',
+ icon: 'user',
+ title: __('Author'),
+ unique: true,
+ symbol: '@',
+ token: AuthorToken,
+ operators: [{ value: '=', description: __('is'), default: 'true' }],
+ fetchPath: this.projectPath,
+ fetchAuthors: Api.projectUsers.bind(Api),
+ },
+ {
+ type: 'assignee_username',
+ icon: 'user',
+ title: __('Assignees'),
+ unique: true,
+ symbol: '@',
+ token: AuthorToken,
+ operators: [{ value: '=', description: __('is'), default: 'true' }],
+ fetchPath: this.projectPath,
+ fetchAuthors: Api.projectUsers.bind(Api),
+ },
+ ];
+ },
+ filteredSearchValue() {
+ const value = [];
+
+ if (this.authorUsername) {
+ value.push({
+ type: 'author_username',
+ value: { data: this.authorUsername },
+ });
+ }
+
+ if (this.assigneeUsernames) {
+ value.push({
+ type: 'assignee_username',
+ value: { data: this.assigneeUsernames },
+ });
+ }
+
+ if (this.searchTerm) {
+ value.push(this.searchTerm);
+ }
+
+ return value;
+ },
},
methods: {
- onInputChange: debounce(function debounceSearch(input) {
- const trimmedInput = input.trim();
- if (trimmedInput !== this.searchTerm) {
- this.searchTerm = trimmedInput;
- }
- }, INCIDENT_SEARCH_DELAY),
filterIncidentsByStatus(tabIndex) {
+ this.resetPagination();
const { filters, status } = this.$options.statusTabs[tabIndex];
this.statusFilter = filters;
this.filteredByStatus = status;
@@ -259,7 +335,10 @@ export default {
return Boolean(assignees.nodes?.length);
},
navigateToIncidentDetails({ iid }) {
- return visitUrl(joinPaths(this.issuePath, iid));
+ const path = this.glFeatures.issuesIncidentDetails
+ ? joinPaths(this.issuePath, INCIDENT_DETAILS_PATH)
+ : this.issuePath;
+ return visitUrl(joinPaths(path, iid));
},
handlePageChange(page) {
const { startCursor, endCursor } = this.incidents.pageInfo;
@@ -284,14 +363,73 @@ export default {
this.pagination = initialPaginationState;
},
fetchSortedData({ sortBy, sortDesc }) {
- const sortingDirection = sortDesc ? 'desc' : 'asc';
- const sortingColumn = convertToSnakeCase(sortBy).replace(/_.*/, '');
+ const sortingDirection = sortDesc ? 'DESC' : 'ASC';
+ const sortingColumn = convertToSnakeCase(sortBy)
+ .replace(/_.*/, '')
+ .toUpperCase();
+ this.resetPagination();
this.sort = `${sortingColumn}_${sortingDirection}`;
},
getSeverity(severity) {
return INCIDENT_SEVERITY[severity];
},
+ handleFilterIncidents(filters) {
+ this.resetPagination();
+ const filterParams = { authorUsername: '', assigneeUsername: '', search: '' };
+
+ filters.forEach(filter => {
+ if (typeof filter === 'object') {
+ switch (filter.type) {
+ case 'author_username':
+ filterParams.authorUsername = filter.value.data;
+ break;
+ case 'assignee_username':
+ filterParams.assigneeUsername = filter.value.data;
+ break;
+ case 'filtered-search-term':
+ if (filter.value.data !== '') filterParams.search = filter.value.data;
+ break;
+ default:
+ break;
+ }
+ }
+ });
+
+ this.filterParams = filterParams;
+ this.updateUrl();
+ this.searchTerm = filterParams?.search;
+ this.authorUsername = filterParams?.authorUsername;
+ this.assigneeUsernames = filterParams?.assigneeUsername;
+ },
+ updateUrl() {
+ const queryParams = urlParamsToObject(window.location.search);
+ const { authorUsername, assigneeUsername, search } = this.filterParams || {};
+
+ if (authorUsername) {
+ queryParams.author_username = authorUsername;
+ } else {
+ delete queryParams.author_username;
+ }
+
+ if (assigneeUsername) {
+ queryParams.assignee_username = assigneeUsername;
+ } else {
+ delete queryParams.assignee_username;
+ }
+
+ if (search) {
+ queryParams.search = search;
+ } else {
+ delete queryParams.search;
+ }
+
+ updateHistory({
+ url: setUrlParams(queryParams, window.location.href, true),
+ title: document.title,
+ replace: true,
+ });
+ },
},
};
</script>
@@ -331,12 +469,16 @@ export default {
</gl-button>
</div>
- <div class="gl-bg-gray-10 gl-p-5 gl-border-b-solid gl-border-b-1 gl-border-gray-100">
- <gl-search-box-by-type
- :value="searchTerm"
- class="gl-bg-white"
- :placeholder="$options.i18n.searchPlaceholder"
- @input="onInputChange"
+ <div class="filtered-search-wrapper">
+ <filtered-search-bar
+ :namespace="projectPath"
+ :search-input-placeholder="$options.i18n.searchPlaceholder"
+ :tokens="filteredSearchTokens"
+ :initial-filter-value="filteredSearchValue"
+ initial-sortby="created_desc"
+ recent-searches-storage-key="incidents"
+ class="row-content-block"
+ @onFilter="handleFilterIncidents"
/>
</div>
diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js
index 289b36d9848..797439495e3 100644
--- a/app/assets/javascripts/incidents/constants.js
+++ b/app/assets/javascripts/incidents/constants.js
@@ -6,7 +6,7 @@ export const I18N = {
unassigned: s__('IncidentManagement|Unassigned'),
createIncidentBtnLabel: s__('IncidentManagement|Create incident'),
unPublished: s__('IncidentManagement|Unpublished'),
- searchPlaceholder: __('Search results…'),
+ searchPlaceholder: __('Search or filter results…'),
emptyState: {
title: s__('IncidentManagement|Display your incidents in a dedicated view'),
emptyClosedTabTitle: s__('IncidentManagement|There are no closed incidents'),
@@ -34,5 +34,8 @@ export const INCIDENT_STATUS_TABS = [
},
];
-export const INCIDENT_SEARCH_DELAY = 300;
export const DEFAULT_PAGE_SIZE = 20;
+export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' };
+export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' };
+export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' };
+export const INCIDENT_DETAILS_PATH = 'incident';
diff --git a/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql
index 0b784b104a8..fd96825c0f7 100644
--- a/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql
+++ b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql
@@ -1,6 +1,17 @@
-query getIncidentsCountByStatus($searchTerm: String, $projectPath: ID!, $issueTypes: [IssueType!]) {
+query getIncidentsCountByStatus(
+ $searchTerm: String
+ $projectPath: ID!
+ $issueTypes: [IssueType!]
+ $authorUsername: String = ""
+ $assigneeUsernames: String = ""
+) {
project(fullPath: $projectPath) {
- issueStatusCounts(search: $searchTerm, types: $issueTypes) {
+ issueStatusCounts(
+ search: $searchTerm
+ types: $issueTypes
+ authorUsername: $authorUsername
+ assigneeUsername: $assigneeUsernames
+ ) {
all
opened
closed
diff --git a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql
index dab130835e2..dd2a42ba4e8 100644
--- a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql
+++ b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql
@@ -9,7 +9,9 @@ query getIncidents(
$lastPageSize: Int
$prevPageCursor: String = ""
$nextPageCursor: String = ""
- $searchTerm: String
+ $searchTerm: String = ""
+ $authorUsername: String = ""
+ $assigneeUsernames: String = ""
) {
project(fullPath: $projectPath) {
issues(
@@ -17,6 +19,8 @@ query getIncidents(
types: $issueTypes
sort: $sort
state: $status
+ authorUsername: $authorUsername
+ assigneeUsername: $assigneeUsernames
first: $firstPageSize
last: $lastPageSize
after: $nextPageCursor
diff --git a/app/assets/javascripts/incidents/list.js b/app/assets/javascripts/incidents/list.js
index 7505d07449c..aeec4a258b9 100644
--- a/app/assets/javascripts/incidents/list.js
+++ b/app/assets/javascripts/incidents/list.js
@@ -16,6 +16,9 @@ export default () => {
issuePath,
publishedAvailable,
emptyListSvgPath,
+ textQuery,
+ authorUsernamesQuery,
+ assigneeUsernamesQuery,
} = domEl.dataset;
const apolloProvider = new VueApollo({
@@ -32,6 +35,9 @@ export default () => {
issuePath,
publishedAvailable,
emptyListSvgPath,
+ textQuery,
+ authorUsernamesQuery,
+ assigneeUsernamesQuery,
},
apolloProvider,
components: {
diff --git a/app/assets/javascripts/incidents_settings/components/alerts_form.vue b/app/assets/javascripts/incidents_settings/components/alerts_form.vue
index 17a77f650e0..5fe0badc56e 100644
--- a/app/assets/javascripts/incidents_settings/components/alerts_form.vue
+++ b/app/assets/javascripts/incidents_settings/components/alerts_form.vue
@@ -130,18 +130,16 @@ export default {
<span>{{ $options.i18n.autoCloseIncidents.label }}</span>
</gl-form-checkbox>
</gl-form-group>
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button
- ref="submitBtn"
- data-qa-selector="save_changes_button"
- :disabled="loading"
- variant="success"
- type="submit"
- class="js-no-auto-disable"
- >
- {{ $options.i18n.saveBtnLabel }}
- </gl-button>
- </div>
+ <gl-button
+ ref="submitBtn"
+ data-qa-selector="save_changes_button"
+ :disabled="loading"
+ variant="success"
+ type="submit"
+ class="js-no-auto-disable"
+ >
+ {{ $options.i18n.saveBtnLabel }}
+ </gl-button>
</form>
</div>
</template>
diff --git a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue
index 8b608d9f391..ae6b72679e1 100644
--- a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue
+++ b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue
@@ -149,17 +149,15 @@ export default {
</template>
</gl-sprintf>
</div>
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button
- v-gl-modal.resetWebhookModal
- class="gl-mt-3"
- :disabled="loading"
- :loading="resettingWebhook"
- data-testid="webhook-reset-btn"
- >
- {{ $options.i18n.webhookUrl.resetWebhookUrl }}
- </gl-button>
- </div>
+ <gl-button
+ v-gl-modal.resetWebhookModal
+ class="gl-mt-3"
+ :disabled="loading"
+ :loading="resettingWebhook"
+ data-testid="webhook-reset-btn"
+ >
+ {{ $options.i18n.webhookUrl.resetWebhookUrl }}
+ </gl-button>
<gl-modal
modal-id="resetWebhookModal"
:title="$options.i18n.webhookUrl.resetWebhookUrl"
@@ -170,17 +168,15 @@ export default {
{{ $options.i18n.webhookUrl.restKeyInfo }}
</gl-modal>
</gl-form-group>
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button
- ref="submitBtn"
- :disabled="isSaveDisabled"
- variant="success"
- type="submit"
- class="js-no-auto-disable"
- >
- {{ $options.i18n.saveBtnLabel }}
- </gl-button>
- </div>
+ <gl-button
+ ref="submitBtn"
+ :disabled="isSaveDisabled"
+ variant="success"
+ type="submit"
+ class="js-no-auto-disable"
+ >
+ {{ $options.i18n.saveBtnLabel }}
+ </gl-button>
</form>
</div>
</template>
diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js
index 528d5d8072f..1e82ecb05b5 100644
--- a/app/assets/javascripts/init_issuable_sidebar.js
+++ b/app/assets/javascripts/init_issuable_sidebar.js
@@ -5,10 +5,14 @@ import LabelsSelect from './labels_select';
import IssuableContext from './issuable_context';
import Sidebar from './right_sidebar';
import DueDateSelectors from './due_date_select';
-import { mountSidebarLabels } from '~/sidebar/mount_sidebar';
+import { mountSidebarLabels, getSidebarOptions } from '~/sidebar/mount_sidebar';
export default () => {
- const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
+ const sidebarOptEl = document.querySelector('.js-sidebar-options');
+
+ if (!sidebarOptEl) return;
+
+ const sidebarOptions = getSidebarOptions(sidebarOptEl);
new MilestoneSelect({
full_path: sidebarOptions.fullPath,
diff --git a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
new file mode 100644
index 00000000000..890381a8f29
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
@@ -0,0 +1,60 @@
+<script>
+import { mapGetters } from 'vuex';
+import { GlModal } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlModal,
+ },
+ computed: {
+ ...mapGetters(['isSavingOrTesting']),
+ primaryProps() {
+ return {
+ text: __('Save'),
+ attributes: [
+ { variant: 'success' },
+ { category: 'primary' },
+ { disabled: this.isSavingOrTesting },
+ ],
+ };
+ },
+ cancelProps() {
+ return {
+ text: __('Cancel'),
+ };
+ },
+ },
+ methods: {
+ onSubmit() {
+ this.$emit('submit');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ modal-id="confirmSaveIntegration"
+ size="sm"
+ :title="s__('Integrations|Save settings?')"
+ :action-primary="primaryProps"
+ :action-cancel="cancelProps"
+ @primary="onSubmit"
+ >
+ <p>
+ {{
+ s__(
+ 'Integrations|Saving will update the default settings for all projects that are not using custom settings.',
+ )
+ }}
+ </p>
+ <p class="gl-mb-0">
+ {{
+ s__(
+ 'Integrations|Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults.',
+ )
+ }}
+ </p>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index 0460ed6791e..0fd39c5635d 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -1,8 +1,9 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlModalDirective } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
+import { integrationLevels } from '../constants';
import OverrideDropdown from './override_dropdown.vue';
import ActiveCheckbox from './active_checkbox.vue';
@@ -10,6 +11,7 @@ import JiraTriggerFields from './jira_trigger_fields.vue';
import JiraIssuesFields from './jira_issues_fields.vue';
import TriggerFields from './trigger_fields.vue';
import DynamicField from './dynamic_field.vue';
+import ConfirmationModal from './confirmation_modal.vue';
export default {
name: 'IntegrationForm',
@@ -20,8 +22,12 @@ export default {
JiraIssuesFields,
TriggerFields,
DynamicField,
+ ConfirmationModal,
GlButton,
},
+ directives: {
+ 'gl-modal': GlModalDirective,
+ },
mixins: [glFeatureFlagsMixin()],
computed: {
...mapGetters(['currentKey', 'propsSource', 'isSavingOrTesting']),
@@ -32,6 +38,9 @@ export default {
isJira() {
return this.propsSource.type === 'jira';
},
+ isInstanceLevel() {
+ return this.propsSource.integrationLevel === integrationLevels.INSTANCE;
+ },
showJiraIssuesFields() {
return this.isJira && this.glFeatures.jiraIssuesIntegration;
},
@@ -82,7 +91,21 @@ export default {
v-bind="propsSource.jiraIssuesProps"
/>
<div v-if="isEditable" class="footer-block row-content-block">
+ <template v-if="isInstanceLevel">
+ <gl-button
+ v-gl-modal.confirmSaveIntegration
+ category="primary"
+ variant="success"
+ :loading="isSaving"
+ :disabled="isSavingOrTesting"
+ data-qa-selector="save_changes_button"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
+ <confirmation-modal @submit="onSaveClick" />
+ </template>
<gl-button
+ v-else
category="primary"
variant="success"
type="submit"
@@ -93,6 +116,7 @@ export default {
>
{{ __('Save changes') }}
</gl-button>
+
<gl-button
v-if="propsSource.canTest"
:loading="isTesting"
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
new file mode 100644
index 00000000000..d2ea14a658b
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -0,0 +1,224 @@
+<script>
+import {
+ GlModal,
+ GlDropdown,
+ GlDropdownItem,
+ GlDatepicker,
+ GlLink,
+ GlSprintf,
+ GlSearchBoxByType,
+ GlButton,
+ GlFormInput,
+} from '@gitlab/ui';
+import eventHub from '../event_hub';
+import { s__, sprintf } from '~/locale';
+import Api from '~/api';
+
+export default {
+ name: 'InviteMembersModal',
+ components: {
+ GlDatepicker,
+ GlLink,
+ GlModal,
+ GlDropdown,
+ GlDropdownItem,
+ GlSprintf,
+ GlSearchBoxByType,
+ GlButton,
+ GlFormInput,
+ },
+ props: {
+ groupId: {
+ type: String,
+ required: true,
+ },
+ groupName: {
+ type: String,
+ required: true,
+ },
+ accessLevels: {
+ type: Object,
+ required: true,
+ },
+ defaultAccessLevel: {
+ type: String,
+ required: true,
+ },
+ helpLink: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ visible: true,
+ modalId: 'invite-members-modal',
+ selectedAccessLevel: this.defaultAccessLevel,
+ newUsersToInvite: '',
+ selectedDate: undefined,
+ };
+ },
+ computed: {
+ introText() {
+ return sprintf(s__("InviteMembersModal|You're inviting members to the %{group_name} group"), {
+ group_name: this.groupName,
+ });
+ },
+ toastOptions() {
+ return {
+ onComplete: () => {
+ this.selectedAccessLevel = this.defaultAccessLevel;
+ this.newUsersToInvite = '';
+ },
+ };
+ },
+ postData() {
+ return {
+ user_id: this.newUsersToInvite,
+ access_level: this.selectedAccessLevel,
+ expires_at: this.selectedDate,
+ format: 'json',
+ };
+ },
+ selectedRoleName() {
+ return Object.keys(this.accessLevels).find(
+ key => this.accessLevels[key] === Number(this.selectedAccessLevel),
+ );
+ },
+ },
+ mounted() {
+ eventHub.$on('openModal', this.openModal);
+ },
+ methods: {
+ openModal() {
+ this.$root.$emit('bv::show::modal', this.modalId);
+ },
+ closeModal() {
+ this.$root.$emit('bv::hide::modal', this.modalId);
+ },
+ sendInvite() {
+ this.submitForm(this.postData);
+ this.closeModal();
+ },
+ cancelInvite() {
+ this.selectedAccessLevel = this.defaultAccessLevel;
+ this.selectedDate = undefined;
+ this.newUsersToInvite = '';
+ this.closeModal();
+ },
+ changeSelectedItem(item) {
+ this.selectedAccessLevel = item;
+ },
+ submitForm(formData) {
+ return Api.inviteGroupMember(this.groupId, formData)
+ .then(() => {
+ this.showToastMessageSuccess();
+ })
+ .catch(error => {
+ this.showToastMessageError(error);
+ });
+ },
+ showToastMessageSuccess() {
+ this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
+ },
+ showToastMessageError(error) {
+ const message = error.response.data.message || this.$options.labels.toastMessageUnsuccessful;
+
+ this.$toast.show(message, this.toastOptions);
+ },
+ },
+ labels: {
+ modalTitle: s__('InviteMembersModal|Invite team members'),
+ userToInvite: s__('InviteMembersModal|GitLab member or Email address'),
+ userPlaceholder: s__('InviteMembersModal|Search for members to invite'),
+ accessLevel: s__('InviteMembersModal|Choose a role permission'),
+ accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'),
+ toastMessageSuccessful: s__('InviteMembersModal|Users were succesfully added'),
+ toastMessageUnsuccessful: s__('InviteMembersModal|User not invited. Feature coming soon!'),
+ readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`),
+ inviteButtonText: s__('InviteMembersModal|Invite'),
+ cancelButtonText: s__('InviteMembersModal|Cancel'),
+ },
+};
+</script>
+<template>
+ <gl-modal :modal-id="modalId" size="sm" :title="$options.labels.modalTitle">
+ <div class="gl-ml-5 gl-mr-5">
+ <div>{{ introText }}</div>
+
+ <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.userToInvite }}</label>
+ <div class="gl-mt-2">
+ <gl-search-box-by-type
+ v-model="newUsersToInvite"
+ :placeholder="$options.labels.userPlaceholder"
+ type="text"
+ autocomplete="off"
+ autocorrect="off"
+ autocapitalize="off"
+ spellcheck="false"
+ />
+ </div>
+
+ <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label>
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full">
+ <gl-dropdown
+ menu-class="dropdown-menu-selectable"
+ class="gl-shadow-none gl-w-full"
+ v-bind="$attrs"
+ :text="selectedRoleName"
+ >
+ <template v-for="(key, item) in accessLevels">
+ <gl-dropdown-item
+ :key="key"
+ active-class="is-active"
+ :is-checked="key === selectedAccessLevel"
+ @click="changeSelectedItem(key)"
+ >
+ <div>{{ item }}</div>
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+ </div>
+
+ <div class="gl-mt-2">
+ <gl-sprintf :message="$options.labels.readMoreText">
+ <template #link="{content}">
+ <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+
+ <label class="gl-font-weight-bold gl-mt-5" for="expires_at">{{
+ $options.labels.accessExpireDate
+ }}</label>
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block">
+ <gl-datepicker
+ v-model="selectedDate"
+ class="gl-display-inline!"
+ :min-date="new Date()"
+ :target="null"
+ >
+ <template #default="{ formattedDate }">
+ <gl-form-input
+ class="gl-w-full"
+ :value="formattedDate"
+ :placeholder="__(`YYYY-MM-DD`)"
+ />
+ </template>
+ </gl-datepicker>
+ </div>
+ </div>
+
+ <template #modal-footer>
+ <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-p-3">
+ <gl-button ref="cancelButton" @click="cancelInvite">
+ {{ $options.labels.cancelButtonText }}
+ </gl-button>
+ <div class="gl-mr-3"></div>
+ <gl-button ref="inviteButton" variant="success" @click="sendInvite">{{
+ $options.labels.inviteButtonText
+ }}</gl-button>
+ </div>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
new file mode 100644
index 00000000000..d133e3655e3
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlLink, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import eventHub from '../event_hub';
+
+export default {
+ components: {
+ GlLink,
+ GlIcon,
+ },
+ props: {
+ displayText: {
+ type: String,
+ required: false,
+ default: s__('InviteMembers|Invite team members'),
+ },
+ icon: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ methods: {
+ openModal() {
+ eventHub.$emit('openModal');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-link @click="openModal">
+ <div v-if="icon" class="nav-icon-container">
+ <gl-icon :size="16" :name="icon" />
+ </div>
+ <span class="nav-item-name"> {{ displayText }} </span>
+ </gl-link>
+</template>
diff --git a/app/assets/javascripts/invite_members/event_hub.js b/app/assets/javascripts/invite_members/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/invite_members/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js
new file mode 100644
index 00000000000..92aa3187fc3
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js
@@ -0,0 +1,25 @@
+import Vue from 'vue';
+import { GlToast } from '@gitlab/ui';
+import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
+
+Vue.use(GlToast);
+
+export default function initInviteMembersModal() {
+ const el = document.querySelector('.js-invite-members-modal');
+
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ render: createElement =>
+ createElement(InviteMembersModal, {
+ props: {
+ ...el.dataset,
+ accessLevels: JSON.parse(el.dataset.accessLevels),
+ groupName: el.dataset.groupName.toUpperCase(),
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/invite_members/init_invite_members_trigger.js b/app/assets/javascripts/invite_members/init_invite_members_trigger.js
new file mode 100644
index 00000000000..bee4f1c0f72
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_invite_members_trigger.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+
+export default function initInviteMembersTrigger() {
+ const el = document.querySelector('.js-invite-members-trigger');
+
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ render: createElement =>
+ createElement(InviteMembersTrigger, {
+ props: {
+ ...el.dataset,
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js
index 566efa0d7d6..6f2bd2da078 100644
--- a/app/assets/javascripts/issuable_context.js
+++ b/app/assets/javascripts/issuable_context.js
@@ -6,6 +6,7 @@ import UsersSelect from './users_select';
export default class IssuableContext {
constructor(currentUser) {
this.userSelect = new UsersSelect(currentUser);
+ this.reviewersSelect = new UsersSelect(currentUser, '.js-reviewer-search');
import(/* webpackChunkName: 'select2' */ 'select2/select2')
.then(() => {
diff --git a/app/assets/javascripts/issuable_create/components/issuable_form.vue b/app/assets/javascripts/issuable_create/components/issuable_form.vue
index 17e51b3dbac..d7b88cc7fc8 100644
--- a/app/assets/javascripts/issuable_create/components/issuable_form.vue
+++ b/app/assets/javascripts/issuable_create/components/issuable_form.vue
@@ -71,6 +71,7 @@ export default {
:markdown-docs-path="descriptionHelpPath"
:add-spacing-classes="false"
:show-suggest-popover="true"
+ :textarea-value="issuableDescription"
>
<textarea
id="issuable-description"
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 e1b308c6f57..8a1a8448bb8 100644
--- a/app/assets/javascripts/issue_show/components/fields/description_template.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
import $ from 'jquery';
import { GlIcon } from '@gitlab/ui';
import IssuableTemplateSelectors from '../../../templates/issuable_template_selectors';
@@ -62,11 +61,15 @@ export default {
data-toggle="dropdown"
>
<span class="dropdown-toggle-text">{{ __('Choose a template') }}</span>
- <i aria-hidden="true" class="fa fa-chevron-down"> </i>
+ <gl-icon
+ name="chevron-down"
+ class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
+ aria-hidden="true"
+ />
</button>
<div class="dropdown-menu dropdown-select">
<div class="dropdown-title gl-display-flex gl-justify-content-center">
- <span class="gl-ml-auto">Choose a template</span>
+ <span class="gl-ml-auto">{{ __('Choose a template') }}</span>
<button
class="dropdown-title-button dropdown-menu-close gl-ml-auto"
:aria-label="__('Close')"
@@ -82,7 +85,7 @@ export default {
:placeholder="__('Filter')"
autocomplete="off"
/>
- <i aria-hidden="true" class="fa fa-search dropdown-input-search"> </i>
+ <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" />
<gl-icon
name="close"
class="dropdown-input-clear js-dropdown-input-clear"
diff --git a/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue b/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue
index a47fe4c84cf..b2aa5265331 100644
--- a/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue
+++ b/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue
@@ -1,11 +1,14 @@
<script>
-import { GlLink } from '@gitlab/ui';
+import { GlLink, GlTooltipDirective } from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility';
export default {
components: {
GlLink,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
alert: {
type: Object,
@@ -22,19 +25,21 @@ export default {
<template>
<div
- class="gl-border-solid gl-border-1 gl-border-gray-100 gl-p-5 gl-mb-3 gl-rounded-base gl-display-flex gl-justify-content-space-between"
+ class="gl-border-solid gl-border-1 gl-border-gray-100 gl-p-5 gl-mb-3 gl-rounded-base gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column"
>
- <div class="text-truncate gl-pr-3">
+ <div class="gl-pr-3">
<span class="gl-font-weight-bold">{{ s__('HighlightBar|Original alert:') }}</span>
- <gl-link :href="alert.detailsUrl">{{ alert.title }}</gl-link>
+ <gl-link v-gl-tooltip :title="alert.title" :href="alert.detailsUrl">
+ #{{ alert.iid }}
+ </gl-link>
</div>
- <div class="gl-pr-3 gl-white-space-nowrap">
+ <div class="gl-pr-3">
<span class="gl-font-weight-bold">{{ s__('HighlightBar|Alert start time:') }}</span>
{{ startTime }}
</div>
- <div class="gl-white-space-nowrap">
+ <div>
<span class="gl-font-weight-bold">{{ s__('HighlightBar|Alert events:') }}</span>
<span>{{ alert.eventCount }}</span>
</div>
diff --git a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue
index 4104ddbf06f..5925c013e89 100644
--- a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue
+++ b/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue
@@ -45,13 +45,6 @@ export default {
loading() {
return this.$apollo.queries.alert.loading;
},
- alertTableFields() {
- if (this.alert) {
- const { detailsUrl, __typename, ...restDetails } = this.alert;
- return restDetails;
- }
- return null;
- },
},
};
</script>
@@ -64,7 +57,7 @@ export default {
<description-component v-bind="$attrs" />
</gl-tab>
<gl-tab v-if="alert" class="alert-management-details" :title="s__('Incident|Alert details')">
- <alert-details-table :alert="alertTableFields" :loading="loading" />
+ <alert-details-table :alert="alert" :loading="loading" />
</gl-tab>
</gl-tabs>
</div>
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js
index c6f7e892f9b..06bbd406e3a 100644
--- a/app/assets/javascripts/issue_show/stores/index.js
+++ b/app/assets/javascripts/issue_show/stores/index.js
@@ -1,4 +1,4 @@
-import { sanitize } from 'dompurify';
+import { sanitize } from '~/lib/dompurify';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import updateDescription from '../utils/update_description';
diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js
index a62a5167961..f2d1650fed1 100644
--- a/app/assets/javascripts/issue_show/utils/parse_data.js
+++ b/app/assets/javascripts/issue_show/utils/parse_data.js
@@ -1,4 +1,5 @@
-import { sanitize } from 'dompurify';
+import * as Sentry from '@sentry/browser';
+import { sanitize } from '~/lib/dompurify';
// We currently load + parse the data from the issue app and related merge request
let cachedParsedData;
@@ -7,10 +8,9 @@ export const parseIssuableData = () => {
try {
if (cachedParsedData) return cachedParsedData;
- const initialDataEl = document.getElementById('js-issuable-app-initial-data');
-
- const parsedData = JSON.parse(initialDataEl.textContent.replace(/&quot;/g, '"'));
+ const initialDataEl = document.getElementById('js-issuable-app');
+ const parsedData = JSON.parse(initialDataEl.dataset.initial);
parsedData.initialTitleHtml = sanitize(parsedData.initialTitleHtml);
parsedData.initialDescriptionHtml = sanitize(parsedData.initialDescriptionHtml);
@@ -18,7 +18,7 @@ export const parseIssuableData = () => {
return parsedData;
} catch (e) {
- console.error(e); // eslint-disable-line no-console
+ Sentry.captureException(e);
return {};
}
diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js
index 695a237bf50..003f3c7107e 100644
--- a/app/assets/javascripts/jira_import/index.js
+++ b/app/assets/javascripts/jira_import/index.js
@@ -6,7 +6,7 @@ import App from './components/jira_import_app.vue';
Vue.use(VueApollo);
-const defaultClient = createDefaultClient();
+const defaultClient = createDefaultClient({}, { assumeImmutableResults: true });
const apolloProvider = new VueApollo({
defaultClient,
diff --git a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql
index 8fda8287988..807374bf06c 100644
--- a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql
+++ b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql
@@ -2,7 +2,6 @@
mutation($input: JiraImportStartInput!) {
jiraImportStart(input: $input) {
- clientMutationId
jiraImport {
...JiraImport
}
diff --git a/app/assets/javascripts/jira_import/utils/cache_update.js b/app/assets/javascripts/jira_import/utils/cache_update.js
index 6aaf2010866..65b2e459f03 100644
--- a/app/assets/javascripts/jira_import/utils/cache_update.js
+++ b/app/assets/javascripts/jira_import/utils/cache_update.js
@@ -1,3 +1,4 @@
+import produce from 'immer';
import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql';
import { IMPORT_STATE } from './jira_import_utils';
@@ -13,22 +14,20 @@ export const addInProgressImportToStore = (store, jiraImportStart, fullPath) =>
},
};
- const cacheData = store.readQuery({
+ const sourceData = store.readQuery({
...queryDetails,
});
store.writeQuery({
...queryDetails,
- data: {
- project: {
- ...cacheData.project,
- jiraImportStatus: IMPORT_STATE.SCHEDULED,
- jiraImports: {
- ...cacheData.project.jiraImports,
- nodes: cacheData.project.jiraImports.nodes.concat(jiraImportStart.jiraImport),
- },
- },
- },
+ data: produce(sourceData, draftData => {
+ draftData.project.jiraImportStatus = IMPORT_STATE.SCHEDULED; // eslint-disable-line no-param-reassign
+ // eslint-disable-next-line no-param-reassign
+ draftData.project.jiraImports.nodes = [
+ ...sourceData.project.jiraImports.nodes,
+ jiraImportStart.jiraImport,
+ ];
+ }),
});
};
diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue
index c4f180f200c..222fae6d9a8 100644
--- a/app/assets/javascripts/jobs/components/commit_block.vue
+++ b/app/assets/javascripts/jobs/components/commit_block.vue
@@ -32,26 +32,25 @@ export default {
block: !isLastBlock,
}"
>
- <p class="gl-mb-2">
- <span class="font-weight-bold">{{ __('Commit') }}</span>
+ <span class="font-weight-bold">{{ __('Commit') }}</span>
- <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit">
- {{ commit.short_id }}
- </gl-link>
+ <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit">
+ {{ commit.short_id }}
+ </gl-link>
- <clipboard-button
- :text="commit.id"
- :title="__('Copy commit SHA')"
- css-class="btn btn-clipboard btn-transparent"
- />
+ <clipboard-button
+ :text="commit.id"
+ :title="__('Copy commit SHA')"
+ category="tertiary"
+ size="small"
+ />
- <span v-if="mergeRequest">
- in
- <gl-link :href="mergeRequest.path" class="js-link-commit link-commit"
- >!{{ mergeRequest.iid }}</gl-link
- >
- </span>
- </p>
+ <span v-if="mergeRequest">
+ in
+ <gl-link :href="mergeRequest.path" class="js-link-commit link-commit"
+ >!{{ mergeRequest.iid }}</gl-link
+ >
+ </span>
<p class="gl-mb-0">{{ commit.title }}</p>
</div>
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index aa589989e8a..8701e05a01f 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -1,7 +1,7 @@
<script>
import { isEmpty } from 'lodash';
import { mapActions, mapState } from 'vuex';
-import { GlLink, GlDeprecatedButton, GlIcon } from '@gitlab/ui';
+import { GlLink, GlButton, GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
@@ -24,7 +24,7 @@ export default {
StagesDropdown,
JobsContainer,
GlLink,
- GlDeprecatedButton,
+ GlButton,
TooltipOnTruncate,
},
mixins: [timeagoMixin],
@@ -143,14 +143,13 @@ export default {
>
</div>
- <gl-deprecated-button
+ <gl-button
:aria-label="__('Toggle Sidebar')"
- type="button"
- class="btn btn-blank gutter-toggle float-right d-block d-md-none js-sidebar-build-toggle"
+ class="d-md-none gl-ml-2 js-sidebar-build-toggle"
+ category="tertiary"
+ icon="chevron-double-lg-right"
@click="toggleSidebar"
- >
- <i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"></i>
- </gl-deprecated-button>
+ />
</div>
<div v-if="job.terminal_path || job.new_issue_path" class="block retry-link">
diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js
index 8d6e5aac566..ea9c214de32 100644
--- a/app/assets/javascripts/jobs/store/utils.js
+++ b/app/assets/javascripts/jobs/store/utils.js
@@ -1,3 +1,5 @@
+import { parseBoolean } from '../../lib/utils/common_utils';
+
/**
* Adds the line number property
* @param Object line
@@ -17,7 +19,7 @@ export const parseLine = (line = {}, lineNumber) => ({
* @param Number lineNumber
*/
export const parseHeaderLine = (line = {}, lineNumber) => ({
- isClosed: false,
+ isClosed: parseBoolean(line.section_options?.collapsed),
isHeader: true,
line: parseLine(line, lineNumber),
lines: [],
diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js
index d7f5e6f8a5e..4d2955a8d3d 100644
--- a/app/assets/javascripts/layout_nav.js
+++ b/app/assets/javascripts/layout_nav.js
@@ -16,6 +16,15 @@ function initDeferred() {
const whatsNewTriggerEl = document.querySelector('.js-whats-new-trigger');
if (whatsNewTriggerEl) {
+ const storageKey = whatsNewTriggerEl.getAttribute('data-storage-key');
+
+ $('.header-help').on('show.bs.dropdown', () => {
+ const displayNotification = JSON.parse(localStorage.getItem(storageKey));
+ if (displayNotification === false) {
+ $('.js-whats-new-notification-count').remove();
+ }
+ });
+
whatsNewTriggerEl.addEventListener('click', () => {
import(/* webpackChunkName: 'whatsNewApp' */ '~/whats_new')
.then(({ default: initWhatsNew }) => {
diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js
new file mode 100644
index 00000000000..d9ea57fbbce
--- /dev/null
+++ b/app/assets/javascripts/lib/dompurify.js
@@ -0,0 +1,53 @@
+import { sanitize as dompurifySanitize, addHook } from 'dompurify';
+import { getBaseURL, relativePathToAbsolute } from '~/lib/utils/url_utility';
+
+// Safely allow SVG <use> tags
+
+const defaultConfig = {
+ ADD_TAGS: ['use'],
+};
+
+// Only icons urls from `gon` are allowed
+const getAllowedIconUrls = (gon = window.gon) =>
+ [gon.sprite_file_icons, gon.sprite_icons].filter(Boolean);
+
+const isUrlAllowed = url => getAllowedIconUrls().some(allowedUrl => url.startsWith(allowedUrl));
+
+const isHrefSafe = url =>
+ isUrlAllowed(url) || isUrlAllowed(relativePathToAbsolute(url, getBaseURL()));
+
+const removeUnsafeHref = (node, attr) => {
+ if (!node.hasAttribute(attr)) {
+ return;
+ }
+
+ if (!isHrefSafe(node.getAttribute(attr))) {
+ node.removeAttribute(attr);
+ }
+};
+
+/**
+ * Sanitize icons' <use> tag attributes, to safely include
+ * svgs such as in:
+ *
+ * <svg viewBox="0 0 100 100">
+ * <use href="/assets/icons-xxx.svg#icon_name"></use>
+ * </svg>
+ *
+ * @param {Object} node - Node to sanitize
+ */
+const sanitizeSvgIcon = node => {
+ removeUnsafeHref(node, 'href');
+
+ // Note: `xlink:href` is deprecated, but still in use
+ // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href
+ removeUnsafeHref(node, 'xlink:href');
+};
+
+addHook('afterSanitizeAttributes', node => {
+ if (node.tagName.toLowerCase() === 'use') {
+ sanitizeSvgIcon(node);
+ }
+});
+
+export const sanitize = (val, config = defaultConfig) => dompurifySanitize(val, config);
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index d2907f401c0..0e07f7d8e44 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -31,6 +31,7 @@ export default (resolvers = {}, config = {}) => {
// We set to `same-origin` which is default value in modern browsers.
// See https://github.com/whatwg/fetch/pull/585 for more information.
credentials: 'same-origin',
+ batchMax: config.batchMax || 10,
};
const uploadsLink = ApolloLink.split(
diff --git a/app/assets/javascripts/lib/utils/axios_startup_calls.js b/app/assets/javascripts/lib/utils/axios_startup_calls.js
index 7e2665b910c..7bb1da5aed5 100644
--- a/app/assets/javascripts/lib/utils/axios_startup_calls.js
+++ b/app/assets/javascripts/lib/utils/axios_startup_calls.js
@@ -7,7 +7,7 @@ const removeGitLabUrl = url => url.replace(gon.gitlab_url, '');
const getFullUrl = req => {
const url = removeGitLabUrl(req.url);
- return mergeUrlParams(req.params || {}, url);
+ return mergeUrlParams(req.params || {}, url, { sort: true });
};
const handleStartupCall = async ({ fetchCall }, req) => {
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index bcf302cc262..28b624168d5 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -44,6 +44,7 @@ export const checkPageAndAction = (page, action) => {
return pagePath === page && actionPath === action;
};
+export const isInIncidentPage = () => checkPageAndAction('issues', 'incident');
export const isInIssuePage = () => checkPageAndAction('issues', 'show');
export const isInMRPage = () => checkPageAndAction('merge_requests', 'show');
export const isInEpicPage = () => checkPageAndAction('epics', 'show');
diff --git a/app/assets/javascripts/lib/utils/csrf.js b/app/assets/javascripts/lib/utils/csrf.js
index ca9828c4682..3114a2a0dfb 100644
--- a/app/assets/javascripts/lib/utils/csrf.js
+++ b/app/assets/javascripts/lib/utils/csrf.js
@@ -1,5 +1,3 @@
-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
@@ -20,7 +18,6 @@ If you need to compose a headers object, use the spread operator:
see also http://guides.rubyonrails.org/security.html#cross-site-request-forgery-csrf
and https://github.com/rails/jquery-rails/blob/v4.3.1/vendor/assets/javascripts/jquery_ujs.js#L59-L62
*/
-
const csrf = {
init() {
const tokenEl = document.querySelector('meta[name=csrf-token]');
@@ -52,9 +49,4 @@ const csrf = {
csrf.init();
-// use our cached token for any $.rails-generated AJAX requests
-if ($.rails) {
- $.rails.csrfToken = () => csrf.token;
-}
-
export default csrf;
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index b193a8b2c9a..261f76a0f2d 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -743,3 +743,22 @@ export const differenceInMilliseconds = (startDate, endDate = Date.now()) => {
const endDateInMS = endDate instanceof Date ? endDate.getTime() : endDate;
return endDateInMS - startDateInMS;
};
+
+/**
+ * A utility which returns a new date at the first day of the month for any given date.
+ *
+ * @param {Date} date
+ *
+ * @return {Date} the date at the first day of the month
+ */
+export const dateAtFirstDayOfMonth = date => new Date(newDate(date).setDate(1));
+
+/**
+ * A utility function which checks if two dates match.
+ *
+ * @param {Date|Int} date1 Can be either a date object or a unix timestamp.
+ * @param {Date|Int} date2 Can be either a date object or a unix timestamp.
+ *
+ * @return {Boolean} true if the dates match
+ */
+export const datesMatch = (date1, date2) => differenceInMilliseconds(date1, date2) === 0;
diff --git a/app/assets/javascripts/lib/utils/experimentation.js b/app/assets/javascripts/lib/utils/experimentation.js
new file mode 100644
index 00000000000..555e76055e0
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/experimentation.js
@@ -0,0 +1,3 @@
+export function isExperimentEnabled(experimentKey) {
+ return Boolean(window.gon?.experiments?.[experimentKey]);
+}
diff --git a/app/assets/javascripts/lib/utils/highlight.js b/app/assets/javascripts/lib/utils/highlight.js
index 32553af9af3..8fa8af670b3 100644
--- a/app/assets/javascripts/lib/utils/highlight.js
+++ b/app/assets/javascripts/lib/utils/highlight.js
@@ -1,5 +1,5 @@
import fuzzaldrinPlus from 'fuzzaldrin-plus';
-import { sanitize } from 'dompurify';
+import { sanitize } from '~/lib/dompurify';
/**
* Wraps substring matches with HTML `<span>` elements.
diff --git a/app/assets/javascripts/lib/utils/rails_ujs.js b/app/assets/javascripts/lib/utils/rails_ujs.js
new file mode 100644
index 00000000000..8b40cc7bd11
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/rails_ujs.js
@@ -0,0 +1,20 @@
+import Rails from '@rails/ujs';
+
+export const initRails = () => {
+ // eslint-disable-next-line no-underscore-dangle
+ if (!window._rails_loaded) {
+ Rails.start();
+
+ // Count XHR requests for tests. See spec/support/helpers/wait_for_requests.rb
+ window.pendingRailsUJSRequests = 0;
+ document.body.addEventListener('ajax:complete', () => {
+ window.pendingRailsUJSRequests -= 1;
+ });
+
+ document.body.addEventListener('ajax:beforeSend', () => {
+ window.pendingRailsUJSRequests += 1;
+ });
+ }
+};
+
+export { Rails };
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index e9c3fe0a406..7f6b212b5fc 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -16,7 +16,7 @@ function decodeUrlParameter(val) {
return decodeURIComponent(val.replace(/\+/g, '%20'));
}
-function cleanLeadingSeparator(path) {
+export function cleanLeadingSeparator(path) {
return path.replace(PATH_SEPARATOR_LEADING_REGEX, '');
}
@@ -73,6 +73,7 @@ export function getParameterValues(sParam, url = window.location) {
* @param {String} url
* @param {Object} options
* @param {Boolean} options.spreadArrays - split array values into separate key/value-pairs
+ * @param {Boolean} options.sort - alphabetically sort params in the returned url (in asc order, i.e., a-z)
*/
export function mergeUrlParams(params, url, options = {}) {
const { spreadArrays = false, sort = false } = options;
@@ -255,6 +256,15 @@ export function getBaseURL() {
}
/**
+ * Takes a URL and returns content from the start until the final '/'
+ *
+ * @param {String} url - full url, including protocol and host
+ */
+export function stripFinalUrlSegment(url) {
+ return new URL('.', url).href;
+}
+
+/**
* Returns true if url is an absolute URL
*
* @param {String} url
@@ -434,3 +444,24 @@ export function getHTTPProtocol(url) {
const protocol = url.split(':');
return protocol.length > 1 ? protocol[0] : undefined;
}
+
+/**
+ * Strips the filename from the given path by removing every non-slash character from the end of the
+ * passed parameter.
+ * @param {string} path
+ */
+export function stripPathTail(path = '') {
+ return path.replace(/[^/]+$/, '');
+}
+
+export function getURLOrigin(url) {
+ if (!url) {
+ return window.location.origin;
+ }
+
+ try {
+ return new URL(url).origin;
+ } catch (e) {
+ return null;
+ }
+}
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 9fcf881a1ac..d60f949c49d 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -11,6 +11,7 @@ import './behaviors';
// lib/utils
import applyGitLabUIConfig from '@gitlab/ui/dist/config';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+import { initRails } from '~/lib/utils/rails_ujs';
import {
handleLocationHash,
addSelectOnFocusBehaviour,
@@ -38,6 +39,8 @@ import initPersistentUserCallouts from './persistent_user_callouts';
import { initUserTracking, initDefaultTrackers } from './tracking';
import { __ } from './locale';
+import * as tooltips from '~/tooltips';
+
import 'ee_else_ce/main_ee';
applyGitLabUIConfig();
@@ -76,7 +79,7 @@ document.addEventListener('beforeunload', () => {
// Unbind scroll events
$(document).off('scroll');
// Close any open tooltips
- $('.has-tooltip, [data-toggle="tooltip"]').tooltip('dispose');
+ tooltips.dispose(document.querySelectorAll('.has-tooltip, [data-toggle="tooltip"]'));
// Close any open popover
$('[data-toggle="popover"]').popover('dispose');
});
@@ -96,6 +99,8 @@ gl.lazyLoader = new LazyLoader({
observerNode: '#content-body',
});
+initRails();
+
// Put all initialisations here that can also wait after everything is rendered and ready
function deferredInitialisation() {
const $body = $('body');
@@ -130,8 +135,10 @@ function deferredInitialisation() {
addSelectOnFocusBehaviour('.js-select-on-focus');
$('.remove-row').on('ajax:success', function removeRowAjaxSuccessCallback() {
+ tooltips.dispose(this);
+
+ // eslint-disable-next-line no-jquery/no-fade
$(this)
- .tooltip('dispose')
.closest('li')
.fadeOut();
});
@@ -151,7 +158,7 @@ function deferredInitialisation() {
const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0;
// Initialize tooltips
- $body.tooltip({
+ tooltips.initTooltips({
selector: '.has-tooltip, [data-toggle="tooltip"]',
trigger: 'hover',
boundary: 'viewport',
diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js
index c3fbb5d6acf..252706b3647 100644
--- a/app/assets/javascripts/members.js
+++ b/app/assets/javascripts/members.js
@@ -1,6 +1,8 @@
import $ from 'jquery';
+import { Rails } from '~/lib/utils/rails_ujs';
import { disableButtonIfEmptyField } from '~/lib/utils/common_utils';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
+import { __, sprintf } from '~/locale';
export default class Members {
constructor() {
@@ -54,15 +56,37 @@ export default class Members {
formSubmit(e, $el = null) {
const $this = e ? $(e.currentTarget) : $el;
const { $toggle, $dateInput } = this.getMemberListItems($this);
+ const formEl = $this.closest('form').get(0);
- $this.closest('form').trigger('submit.rails');
+ Rails.fire(formEl, 'submit');
$toggle.disable();
$dateInput.disable();
}
formSuccess(e) {
- const { $toggle, $dateInput } = this.getMemberListItems($(e.currentTarget).closest('.member'));
+ const { $toggle, $dateInput, $expiresIn, $expiresInText } = this.getMemberListItems(
+ $(e.currentTarget).closest('.js-member'),
+ );
+
+ const [data] = e.detail;
+ const expiresIn = data?.expires_in;
+
+ if (expiresIn) {
+ $expiresIn.removeClass('gl-display-none');
+
+ $expiresInText.text(sprintf(__('Expires in %{expires_at}'), { expires_at: expiresIn }));
+
+ const { expires_soon: expiresSoon } = data;
+
+ if (expiresSoon) {
+ $expiresInText.addClass('text-warning');
+ } else {
+ $expiresInText.removeClass('text-warning');
+ }
+ } else {
+ $expiresIn.addClass('gl-display-none');
+ }
$toggle.enable();
$dateInput.enable();
@@ -70,10 +94,12 @@ export default class Members {
// eslint-disable-next-line class-methods-use-this
getMemberListItems($el) {
- const $memberListItem = $el.is('.member') ? $el : $(`#${$el.data('elId')}`);
+ const $memberListItem = $el.is('.js-member') ? $el : $(`#${$el.data('elId')}`);
return {
$memberListItem,
+ $expiresIn: $memberListItem.find('.js-expires-in'),
+ $expiresInText: $memberListItem.find('.js-expires-in-text'),
$toggle: $memberListItem.find('.dropdown-menu-toggle'),
$dateInput: $memberListItem.find('.js-access-expiration-date'),
};
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 79a4c3700ef..fe4e2cee69f 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -3,11 +3,12 @@
import $ from 'jquery';
import axios from './lib/utils/axios_utils';
import { __ } from '~/locale';
+import eventHub from '~/vue_merge_request_widget/event_hub';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import TaskList from './task_list';
import MergeRequestTabs from './merge_request_tabs';
-import IssuablesHelper from './helpers/issuables_helper';
import { addDelimiter } from './lib/utils/text_utility';
+import { getParameterValues, setUrlParams } from './lib/utils/url_utility';
function MergeRequest(opts) {
// Initialize MergeRequest behavior
@@ -23,7 +24,6 @@ function MergeRequest(opts) {
this.initTabs();
this.initMRBtnListeners();
this.initCommitMessageListeners();
- this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport();
if ($('.description.js-task-list-container').length) {
this.taskList = new TaskList({
@@ -66,13 +66,38 @@ MergeRequest.prototype.showAllCommits = function() {
MergeRequest.prototype.initMRBtnListeners = function() {
const _this = this;
+ const draftToggles = document.querySelectorAll('.js-draft-toggle-button');
- $('.report-abuse-link').on('click', e => {
- // this is needed because of the implementation of
- // the dropdown toggle and Report Abuse needing to be
- // linked to another page.
- e.stopPropagation();
- });
+ if (draftToggles.length) {
+ draftToggles.forEach(draftToggle => {
+ draftToggle.addEventListener('click', e => {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+
+ const url = draftToggle.href;
+ const wipEvent = getParameterValues('merge_request[wip_event]', url)[0];
+ const mobileDropdown = draftToggle.closest('.dropdown.show');
+
+ if (mobileDropdown) {
+ $(mobileDropdown.firstElementChild).dropdown('toggle');
+ }
+
+ draftToggle.setAttribute('disabled', 'disabled');
+
+ axios
+ .put(draftToggle.href, null, { params: { format: 'json' } })
+ .then(({ data }) => {
+ draftToggle.removeAttribute('disabled');
+ eventHub.$emit('MRWidgetUpdateRequested');
+ MergeRequest.toggleDraftStatus(data.title, wipEvent === 'unwip');
+ })
+ .catch(() => {
+ draftToggle.removeAttribute('disabled');
+ createFlash(__('Something went wrong. Please try again.'));
+ });
+ });
+ });
+ }
return $('.btn-close, .btn-reopen').on('click', function(e) {
const $this = $(this);
@@ -89,8 +114,6 @@ MergeRequest.prototype.initMRBtnListeners = function() {
return;
}
- if (this.closeReopenReportToggle) this.closeReopenReportToggle.setDisable();
-
if (shouldSubmit) {
if ($this.hasClass('btn-comment-and-close') || $this.hasClass('btn-comment-and-reopen')) {
e.preventDefault();
@@ -151,14 +174,35 @@ MergeRequest.hideCloseButton = function() {
const closeDropdownItem = el.querySelector('li.close-item');
if (closeDropdownItem) {
closeDropdownItem.classList.add('hidden');
- // Selects the next dropdown item
- el.querySelector('li.report-item').click();
- } else {
- // No dropdown just hide the Close button
- el.querySelector('.btn-close').classList.add('hidden');
}
// Dropdown for mobile screen
el.querySelector('li.js-close-item').classList.add('hidden');
};
+MergeRequest.toggleDraftStatus = function(title, isReady) {
+ if (isReady) {
+ createFlash(__('The merge request can now be merged.'), 'notice');
+ }
+ const titleEl = document.querySelector('.merge-request .detail-page-description .title');
+
+ if (titleEl) {
+ titleEl.textContent = title;
+ }
+
+ const draftToggles = document.querySelectorAll('.js-draft-toggle-button');
+
+ if (draftToggles.length) {
+ draftToggles.forEach(el => {
+ const draftToggle = el;
+ const url = setUrlParams(
+ { 'merge_request[wip_event]': isReady ? 'wip' : 'unwip' },
+ draftToggle.href,
+ );
+
+ draftToggle.setAttribute('href', url);
+ draftToggle.textContent = isReady ? __('Mark as draft') : __('Mark as ready');
+ });
+ }
+};
+
export default MergeRequest;
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index b7cf39db00c..52fa0038fbb 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -396,10 +396,6 @@ export default class MergeRequestTabs {
initChangesDropdown(this.stickyTop);
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- gl.diffNotesCompileComponents();
- }
-
localTimeAgo($('.js-timeago', 'div#diffs'));
syntaxHighlight($('#diffs .js-syntax-highlight'));
diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js
index 20d9fb82554..52e9b67c77d 100644
--- a/app/assets/javascripts/milestone.js
+++ b/app/assets/javascripts/milestone.js
@@ -7,11 +7,6 @@ import { __ } from './locale';
export default class Milestone {
constructor() {
this.bindTabsSwitching();
-
- // Load merge request tab if it is active
- // merge request tab is active based on different conditions in the backend
- this.loadTab($('.js-milestone-tabs .active a'));
-
this.loadInitialTab();
}
@@ -23,12 +18,14 @@ export default class Milestone {
this.loadTab($target);
});
}
- // eslint-disable-next-line class-methods-use-this
+
loadInitialTab() {
- const $target = $(`.js-milestone-tabs a[href="${window.location.hash}"]`);
+ const $target = $(`.js-milestone-tabs a:not(.active)[href="${window.location.hash}"]`);
if ($target.length) {
$target.tab('show');
+ } else {
+ this.loadTab($('.js-milestone-tabs a.active'));
}
}
// eslint-disable-next-line class-methods-use-this
diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue
index 132df9c9516..6f29b34141d 100644
--- a/app/assets/javascripts/monitoring/components/alert_widget_form.vue
+++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue
@@ -3,7 +3,7 @@ import { isEmpty, findKey } from 'lodash';
import Vue from 'vue';
import {
GlLink,
- GlDeprecatedButton,
+ GlButton,
GlButtonGroup,
GlFormGroup,
GlFormInput,
@@ -36,7 +36,7 @@ const SUBMIT_BUTTON_CLASS = {
export default {
components: {
- GlDeprecatedButton,
+ GlButton,
GlButtonGroup,
GlFormGroup,
GlFormInput,
@@ -267,30 +267,27 @@ export default {
</gl-dropdown>
</gl-form-group>
<gl-button-group class="mb-3" :label="s__('PrometheusAlerts|Operator')">
- <gl-deprecated-button
+ <gl-button
:class="{ active: operator === operators.greaterThan }"
:disabled="formDisabled"
- type="button"
@click="operator = operators.greaterThan"
>
{{ operators.greaterThan }}
- </gl-deprecated-button>
- <gl-deprecated-button
+ </gl-button>
+ <gl-button
:class="{ active: operator === operators.equalTo }"
:disabled="formDisabled"
- type="button"
@click="operator = operators.equalTo"
>
{{ operators.equalTo }}
- </gl-deprecated-button>
- <gl-deprecated-button
+ </gl-button>
+ <gl-button
:class="{ active: operator === operators.lessThan }"
:disabled="formDisabled"
- type="button"
@click="operator = operators.lessThan"
>
{{ operators.lessThan }}
- </gl-deprecated-button>
+ </gl-button>
</gl-button-group>
<gl-form-group :label="s__('PrometheusAlerts|Threshold')" label-for="alerts-threshold">
<gl-form-input
diff --git a/app/assets/javascripts/monitoring/components/group_empty_state.vue b/app/assets/javascripts/monitoring/components/group_empty_state.vue
index 499823fae3f..0365fc66331 100644
--- a/app/assets/javascripts/monitoring/components/group_empty_state.vue
+++ b/app/assets/javascripts/monitoring/components/group_empty_state.vue
@@ -1,6 +1,5 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlEmptyState } from '@gitlab/ui';
+import { GlEmptyState, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { metricStates } from '../constants';
@@ -8,6 +7,9 @@ export default {
components: {
GlEmptyState,
},
+ directives: {
+ SafeHtml,
+ },
props: {
documentationPath: {
type: String,
@@ -100,7 +102,7 @@ export default {
:compact="true"
>
<template v-if="currentState.slottedDescription" #description>
- <div v-html="currentState.slottedDescription"></div>
+ <div v-safe-html="currentState.slottedDescription"></div>
</template>
</gl-empty-state>
</template>
diff --git a/app/assets/javascripts/namespaces/leave_by_url.js b/app/assets/javascripts/namespaces/leave_by_url.js
index bf77617d516..7b15253d872 100644
--- a/app/assets/javascripts/namespaces/leave_by_url.js
+++ b/app/assets/javascripts/namespaces/leave_by_url.js
@@ -1,3 +1,4 @@
+import { initRails } from '~/lib/utils/rails_ujs';
import { deprecatedCreateFlash as Flash } from '~/flash';
import { __, sprintf } from '~/locale';
import { getParameterByName } from '~/lib/utils/common_utils';
@@ -11,6 +12,8 @@ export default function leaveByUrl(namespaceType) {
const param = getParameterByName(PARAMETER_NAME);
if (!param) return;
+ initRails();
+
const leaveLink = document.querySelector(LEAVE_LINK_SELECTOR);
if (leaveLink) {
leaveLink.click();
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 3bbaa44ec42..c04f2a2d465 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -1,8 +1,8 @@
<script>
/* eslint-disable vue/no-v-html */
import marked from 'marked';
-import { sanitize } from 'dompurify';
import katex from 'katex';
+import { sanitize } from '~/lib/dompurify';
import Prompt from './prompt.vue';
const renderer = new marked.Renderer();
diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue
index 856c8f31796..4d527baf730 100644
--- a/app/assets/javascripts/notebook/cells/output/html.vue
+++ b/app/assets/javascripts/notebook/cells/output/html.vue
@@ -1,6 +1,6 @@
<script>
/* eslint-disable vue/no-v-html */
-import { sanitize } from 'dompurify';
+import { sanitize } from '~/lib/dompurify';
import Prompt from '../prompt.vue';
export default {
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 340fbe4d887..37bb79defd1 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -479,11 +479,6 @@ export default class Notes {
row = form;
}
- const lineType = this.isParallelView() ? form.find('#line_type').val() : 'old';
- const diffAvatarContainer = row
- .prevAll('.line_holder')
- .first()
- .find(`.js-avatar-container.${lineType}_line`);
// is this the first note of discussion?
discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`);
if (!discussionContainer.length) {
@@ -519,12 +514,6 @@ export default class Notes {
Notes.animateAppendNote(noteEntity.html, discussionContainer);
}
- if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) {
- gl.diffNotesCompileComponents();
-
- this.renderDiscussionAvatar(diffAvatarContainer, noteEntity);
- }
-
localTimeAgo($('.js-timeago'), false);
Notes.checkMergeRequestStatus();
return this.updateNotesCount(1);
@@ -538,19 +527,6 @@ export default class Notes {
.get(0);
}
- renderDiscussionAvatar(diffAvatarContainer, noteEntity) {
- let avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders');
-
- if (!avatarHolder.length) {
- avatarHolder = document.createElement('diff-note-avatars');
- avatarHolder.setAttribute('discussion-id', noteEntity.discussion_id);
-
- diffAvatarContainer.append(avatarHolder);
-
- gl.diffNotesCompileComponents();
- }
- }
-
/**
* Called in response the main target form has been successfully submitted.
*
@@ -605,10 +581,6 @@ 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();
this.parentTimeline = form.parents('.timeline');
if (form.length) {
@@ -714,10 +686,6 @@ export default class Notes {
$note_li.replaceWith($noteEntityEl);
this.setupNewNote($noteEntityEl);
-
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- gl.diffNotesCompileComponents();
- }
}
checkContentToAllowEditing($el) {
@@ -844,12 +812,6 @@ export default class Notes {
const $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
@@ -979,13 +941,6 @@ export default class Notes {
form.removeClass('js-main-target-form').addClass('discussion-form js-discussion-note-form');
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- const $commentBtn = form.find('comment-and-resolve-btn');
- $commentBtn.attr(':discussion-id', `'${discussionID}'`);
-
- gl.diffNotesCompileComponents();
- }
-
form.find('.js-note-text').focus();
form.find('.js-comment-resolve-button').attr('data-discussion-id', discussionID);
}
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 54fcf41ca50..cfdadbceaf6 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -371,6 +371,7 @@ export default {
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:add-spacing-classes="false"
+ :textarea-value="note"
>
<textarea
id="note-body"
@@ -380,7 +381,8 @@ export default {
dir="auto"
:disabled="isSubmitting"
name="note[note]"
- class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area qa-comment-input"
+ class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
+ data-qa-selector="comment_field"
data-supports-quick-actions="true"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@@ -425,7 +427,8 @@ export default {
>
<gl-button
:disabled="isSubmitButtonDisabled"
- class="js-comment-button js-comment-submit-button qa-comment-button"
+ class="js-comment-button js-comment-submit-button"
+ data-qa-selector="comment_button"
type="submit"
category="primary"
variant="success"
@@ -439,7 +442,8 @@ export default {
name="button"
category="primary"
variant="success"
- class="note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown"
+ class="note-type-toggle js-note-new-discussion dropdown-toggle"
+ data-qa-selector="note_dropdown"
data-display="static"
data-toggle="dropdown"
icon="chevron-down"
@@ -468,7 +472,10 @@ export default {
</li>
<li class="divider droplab-item-ignore"></li>
<li :class="{ 'droplab-item-selected': noteType === 'discussion' }">
- <button class="qa-discussion-option" @click.prevent="setNoteType('discussion')">
+ <button
+ data-qa-selector="discussion_menu_item"
+ @click.prevent="setNoteType('discussion')"
+ >
<i aria-hidden="true" class="fa fa-check icon"></i>
<div class="description">
<strong>{{ __('Start thread') }}</strong>
diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue
index 8e6c01ba63f..ee39a529345 100644
--- a/app/assets/javascripts/notes/components/diff_discussion_header.vue
+++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue
@@ -1,7 +1,7 @@
<script>
-/* eslint-disable vue/no-v-html */
import { mapActions } from 'vuex';
import { escape } from 'lodash';
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale';
import { truncateSha } from '~/lib/utils/text_utility';
@@ -17,6 +17,9 @@ export default {
noteEditedText,
noteHeader,
},
+ directives: {
+ SafeHtml,
+ },
props: {
discussion: {
type: Object,
@@ -113,7 +116,7 @@ export default {
:expanded="discussion.expanded"
@toggleHandler="toggleDiscussionHandler"
>
- <span v-html="headerText"></span>
+ <span v-safe-html="headerText"></span>
</note-header>
<note-edited-text
v-if="discussion.resolved"
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index c01cd8f8037..a4271852563 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -76,7 +76,7 @@ export default {
:discussion-path="discussion.discussion_path"
:diff-file="discussion.diff_file"
:can-current-user-fork="false"
- :expanded="!discussion.diff_file.viewer.collapsed"
+ :expanded="!discussion.diff_file.viewer.automaticallyCollapsed"
/>
<div v-if="isTextFile" class="diff-content">
<table class="code js-syntax-highlight" :class="$options.userColorSchemeClass">
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index c6fab271376..2427a3f98ad 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -1,6 +1,7 @@
<script>
import { mapGetters, mapActions } from 'vuex';
-import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlTooltipDirective, GlIcon, GlButton, GlButtonGroup } from '@gitlab/ui';
+import { __ } from '~/locale';
import discussionNavigation from '../mixins/discussion_navigation';
export default {
@@ -9,6 +10,8 @@ export default {
},
components: {
GlIcon,
+ GlButton,
+ GlButtonGroup,
},
mixins: [discussionNavigation],
computed: {
@@ -34,6 +37,12 @@ export default {
allExpanded() {
return this.toggeableDiscussions.every(discussion => discussion.expanded);
},
+ lineResolveClass() {
+ return this.allResolved ? 'line-resolve-btn is-active' : 'line-resolve-text';
+ },
+ toggleThreadsLabel() {
+ return this.allExpanded ? __('Collapse all threads') : __('Expand all threads');
+ },
},
methods: {
...mapActions(['setExpandDiscussions']),
@@ -51,59 +60,49 @@ export default {
<div
v-if="resolvableDiscussionsCount > 0"
ref="discussionCounter"
- class="line-resolve-all-container full-width-mobile"
+ class="line-resolve-all-container full-width-mobile gl-display-flex d-sm-flex"
>
- <div class="full-width-mobile d-flex d-sm-flex">
- <div class="line-resolve-all">
- <span
- :class="{ 'line-resolve-btn is-active': allResolved, 'line-resolve-text': !allResolved }"
- >
- <template v-if="allResolved">
- <gl-icon name="check-circle-filled" />
- {{ __('All threads resolved') }}
- </template>
- <template v-else>
- {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }}
- </template>
- </span>
- </div>
- <div
- v-if="resolveAllDiscussionsIssuePath && !allResolved"
- class="btn-group btn-group-sm"
- role="group"
- >
- <a
- v-gl-tooltip
- :href="resolveAllDiscussionsIssuePath"
- :title="s__('Resolve all threads in new issue')"
- class="new-issue-for-discussion btn btn-default discussion-create-issue-btn"
- >
- <gl-icon name="issue-new" />
- </a>
- </div>
- <div v-if="isLoggedIn && !allResolved" class="btn-group btn-group-sm" role="group">
- <button
- v-gl-tooltip
- :title="__('Jump to next unresolved thread')"
- class="btn btn-default discussion-next-btn"
- data-track-event="click_button"
- data-track-label="mr_next_unresolved_thread"
- data-track-property="click_next_unresolved_thread_top"
- @click="jumpToNextDiscussion"
- >
- <gl-icon name="comment-next" />
- </button>
- </div>
- <div class="btn-group btn-group-sm" role="group">
- <button
- v-gl-tooltip
- :title="__('Toggle all threads')"
- class="btn btn-default toggle-all-discussions-btn"
- @click="handleExpandDiscussions"
- >
- <gl-icon :name="allExpanded ? 'angle-up' : 'angle-down'" />
- </button>
- </div>
+ <div class="line-resolve-all">
+ <span :class="lineResolveClass">
+ <template v-if="allResolved">
+ <gl-icon name="check-circle-filled" />
+ {{ __('All threads resolved') }}
+ </template>
+ <template v-else>
+ {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }}
+ </template>
+ </span>
</div>
+ <gl-button-group>
+ <gl-button
+ v-if="resolveAllDiscussionsIssuePath && !allResolved"
+ v-gl-tooltip
+ :href="resolveAllDiscussionsIssuePath"
+ :title="s__('Resolve all threads in new issue')"
+ :aria-label="s__('Resolve all threads in new issue')"
+ class="new-issue-for-discussion discussion-create-issue-btn"
+ icon="issue-new"
+ />
+ <gl-button
+ v-if="isLoggedIn && !allResolved"
+ v-gl-tooltip
+ :title="__('Jump to next unresolved thread')"
+ :aria-label="__('Jump to next unresolved thread')"
+ class="discussion-next-btn"
+ data-track-event="click_button"
+ data-track-label="mr_next_unresolved_thread"
+ data-track-property="click_next_unresolved_thread_top"
+ icon="comment-next"
+ @click="jumpToNextDiscussion"
+ />
+ <gl-button
+ v-gl-tooltip
+ :title="toggleThreadsLabel"
+ :aria-label="toggleThreadsLabel"
+ class="toggle-all-discussions-btn"
+ :icon="allExpanded ? 'angle-up' : 'angle-down'"
+ @click="handleExpandDiscussions"
+ />
+ </gl-button-group>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index 989ce9ff144..0e7ed854032 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -1,7 +1,6 @@
<script>
-import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex';
-import { GlIcon } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility';
import {
DISCUSSION_FILTERS_DEFAULT_VALUE,
@@ -14,7 +13,9 @@ import notesEventHub from '../event_hub';
export default {
components: {
- GlIcon,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
},
props: {
filters: {
@@ -66,9 +67,6 @@ export default {
selectFilter(value, persistFilter = true) {
const filter = parseInt(value, 10);
- // close dropdown
- this.toggleDropdown();
-
if (filter === this.currentValue) return;
this.currentValue = filter;
this.filterDiscussion({
@@ -78,9 +76,6 @@ export default {
});
this.toggleCommentsForm();
},
- toggleDropdown() {
- $(this.$refs.dropdownToggle).dropdown('toggle');
- },
toggleCommentsForm() {
this.setCommentsDisabled(this.currentValue === HISTORY_ONLY_FILTER_VALUE);
},
@@ -92,7 +87,6 @@ export default {
if (/^note_/.test(hash) && this.currentValue !== DISCUSSION_FILTERS_DEFAULT_VALUE) {
this.selectFilter(this.defaultValue, false);
- this.toggleDropdown(); // close dropdown
this.setTargetNoteHash(hash);
}
},
@@ -109,43 +103,24 @@ export default {
</script>
<template>
- <div
+ <gl-dropdown
v-if="displayFilters"
- class="discussion-filter-container js-discussion-filter-container d-inline-block align-bottom full-width-mobile"
+ id="discussion-filter-dropdown"
+ class="gl-mr-3 full-width-mobile discussion-filter-container js-discussion-filter-container qa-discussion-filter"
+ :text="currentFilter.title"
>
- <button
- id="discussion-filter-dropdown"
- ref="dropdownToggle"
- class="btn btn-sm qa-discussion-filter"
- data-toggle="dropdown"
- aria-expanded="false"
- >
- {{ currentFilter.title }} <gl-icon name="chevron-down" />
- </button>
- <div
- ref="dropdownMenu"
- class="dropdown-menu dropdown-menu-selectable dropdown-menu-right"
- aria-labelledby="discussion-filter-dropdown"
- >
- <div class="dropdown-content">
- <ul>
- <li
- v-for="filter in filters"
- :key="filter.value"
- :data-filter-type="filterType(filter.value)"
- >
- <button
- :class="{ 'is-active': filter.value === currentValue }"
- class="qa-filter-options"
- type="button"
- @click="selectFilter(filter.value)"
- >
- {{ filter.title }}
- </button>
- <div v-if="filter.value === defaultValue" class="dropdown-divider"></div>
- </li>
- </ul>
- </div>
+ <div v-for="filter in filters" :key="filter.value" class="dropdown-item-wrapper">
+ <gl-dropdown-item
+ :is-check-item="true"
+ :is-checked="filter.value === currentValue"
+ :class="{ 'is-active': filter.value === currentValue }"
+ :data-filter-type="filterType(filter.value)"
+ class="qa-filter-options"
+ @click.prevent="selectFilter(filter.value)"
+ >
+ {{ filter.title }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider v-if="filter.value === defaultValue" />
</div>
- </div>
+ </gl-dropdown>
</template>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index a8057276f1a..c2f40b2d21a 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -160,7 +160,7 @@ export default {
});
},
displayMemberBadgeText() {
- return sprintf(__('This user is a %{access} of the %{name} project.'), {
+ return sprintf(__('This user has the %{access} role in the %{name} project.'), {
access: this.accessLevel.toLowerCase(),
name: this.projectName,
});
@@ -275,7 +275,8 @@ export default {
v-gl-tooltip
type="button"
title="Edit comment"
- class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
+ class="note-action-button js-note-edit btn btn-transparent"
+ data-qa-selector="note_edit_button"
@click="onEdit"
>
<gl-icon name="pencil" class="link-highlight" />
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 314fa762768..65b89b94eaa 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -45,7 +45,7 @@ export default {
},
},
computed: {
- ...mapGetters(['getDiscussion']),
+ ...mapGetters(['getDiscussion', 'suggestionsCount']),
discussion() {
if (!this.note.isDraft) return {};
@@ -125,6 +125,7 @@ export default {
<suggestions
v-if="hasSuggestion && !isEditing"
:suggestions="note.suggestions"
+ :suggestions-count="suggestionsCount"
:batch-suggestions-info="batchSuggestionsInfo"
:note-html="note.note_html"
:line-type="lineType"
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 88b4461cf38..4b3f23e742d 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -328,6 +328,7 @@ export default {
:add-spacing-classes="false"
:help-page-path="helpPagePath"
:show-suggest-popover="showSuggestPopover"
+ :textarea-value="updatedNoteBody"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
>
<textarea
@@ -337,7 +338,8 @@ export default {
v-model="updatedNoteBody"
:data-supports-quick-actions="!isEditing"
name="note[note]"
- class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form qa-reply-input"
+ class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
+ data-qa-selector="reply_field"
dir="auto"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@@ -376,7 +378,8 @@ export default {
<button
:disabled="isDisabled"
type="button"
- class="btn btn-success qa-start-review"
+ class="btn btn-success"
+ data-qa-selector="start_review_button"
@click="handleAddToReview"
>
<template v-if="hasDrafts">{{ __('Add to review') }}</template>
@@ -385,7 +388,8 @@ export default {
<button
:disabled="isDisabled"
type="button"
- class="btn qa-comment-now js-comment-button"
+ class="btn js-comment-button"
+ data-qa-selector="comment_now_button"
@click="handleUpdate()"
>
{{ __('Add comment now') }}
@@ -404,7 +408,8 @@ export default {
<button
:disabled="isDisabled"
type="button"
- class="js-vue-issue-save btn btn-success js-comment-button qa-reply-comment-button"
+ class="js-vue-issue-save btn btn-success js-comment-button"
+ data-qa-selector="reply_comment_button"
@click="handleUpdate()"
>
{{ saveButtonTitle }}
diff --git a/app/assets/javascripts/notes/components/sort_discussion.vue b/app/assets/javascripts/notes/components/sort_discussion.vue
index 60b531d7597..113c00ffe8e 100644
--- a/app/assets/javascripts/notes/components/sort_discussion.vue
+++ b/app/assets/javascripts/notes/components/sort_discussion.vue
@@ -1,6 +1,5 @@
-gs
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { mapActions, mapGetters } from 'vuex';
import { __ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
@@ -15,7 +14,8 @@ const SORT_OPTIONS = [
export default {
SORT_OPTIONS,
components: {
- GlIcon,
+ GlDropdown,
+ GlDropdownItem,
LocalStorageSync,
},
mixins: [Tracking.mixin()],
@@ -49,33 +49,27 @@ export default {
</script>
<template>
- <div
- data-testid="sort-discussion-filter"
- class="gl-mr-2 gl-display-inline-block gl-vertical-align-bottom full-width-mobile"
- >
+ <div class="gl-mr-3 gl-display-inline-block gl-vertical-align-bottom full-width-mobile">
<local-storage-sync
:value="sortDirection"
:storage-key="storageKey"
@input="setDiscussionSortDirection"
/>
- <button class="btn btn-sm js-dropdown-text" data-toggle="dropdown" aria-expanded="false">
- {{ dropdownText }}
- <gl-icon name="chevron-down" />
- </button>
- <div ref="dropdownMenu" class="dropdown-menu dropdown-menu-selectable dropdown-menu-right">
- <div class="dropdown-content">
- <ul>
- <li v-for="{ text, key, cls } in $options.SORT_OPTIONS" :key="key">
- <button
- :class="[cls, { 'is-active': isDropdownItemActive(key) }]"
- type="button"
- @click="fetchSortedDiscussions(key)"
- >
- {{ text }}
- </button>
- </li>
- </ul>
- </div>
- </div>
+ <gl-dropdown
+ :text="dropdownText"
+ data-testid="sort-discussion-filter"
+ class="js-dropdown-text full-width-mobile"
+ >
+ <gl-dropdown-item
+ v-for="{ text, key, cls } in $options.SORT_OPTIONS"
+ :key="key"
+ :class="cls"
+ :is-check-item="true"
+ :is-checked="isDropdownItemActive(key)"
+ @click="fetchSortedDiscussions(key)"
+ >
+ {{ text }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
index bddac60647d..f49fd2c3fa3 100644
--- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue
+++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
@@ -57,7 +57,12 @@ export default {
tooltip-placement="bottom"
/>
</div>
- <button class="btn btn-link js-replies-text qa-expand-replies" type="button" @click="toggle">
+ <button
+ class="btn btn-link js-replies-text"
+ data-qa-selector="expand_replies_button"
+ type="button"
+ @click="toggle"
+ >
{{ replies.length }} {{ n__('reply', 'replies', replies.length) }}
</button>
{{ __('Last reply by') }}
@@ -68,7 +73,8 @@ export default {
</template>
<span
v-else
- class="collapse-replies-btn js-collapse-replies qa-collapse-replies"
+ class="collapse-replies-btn js-collapse-replies"
+ data-qa-selector="collapse_replies_button"
@click="toggle"
>
<gl-icon name="chevron-down" /> {{ s__('Notes|Collapse replies') }}
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index 7bf465482b3..ca186123a83 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -5,15 +5,19 @@ import initSortDiscussions from './sort_discussions';
import { store } from './stores';
document.addEventListener('DOMContentLoaded', () => {
+ const el = document.getElementById('js-vue-notes');
+
+ if (!el) return;
+
// eslint-disable-next-line no-new
new Vue({
- el: '#js-vue-notes',
+ el,
components: {
notesApp,
},
store,
data() {
- const notesDataset = document.getElementById('js-vue-notes').dataset;
+ const notesDataset = el.dataset;
const parsedUserData = JSON.parse(notesDataset.currentUserData);
const noteableData = JSON.parse(notesDataset.noteableData);
let currentUserData = {};
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 7d60fbffb10..fbdb71925ea 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -231,3 +231,6 @@ export const getDiscussion = state => discussionId =>
state.discussions.find(discussion => discussion.id === discussionId);
export const commentsDisabled = state => state.commentsDisabled;
+
+export const suggestionsCount = (state, getters) =>
+ Object.values(getters.notesById).filter(n => n.suggestions.length).length;
diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js
index 47fb5b271d1..ae992dd5dc5 100644
--- a/app/assets/javascripts/notifications_dropdown.js
+++ b/app/assets/javascripts/notifications_dropdown.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { Rails } from '~/lib/utils/rails_ujs';
import { deprecatedCreateFlash as Flash } from './flash';
import { __ } from '~/locale';
@@ -21,10 +22,12 @@ export default function notificationsDropdown() {
form.find('.js-notifications-icon').toggleClass('hidden');
}
form.find('#notification_setting_level').val(notificationLevel);
- form.submit();
+ Rails.fire(form[0], 'submit');
});
- $(document).on('ajax:success', '.notification-form', (e, data) => {
+ $(document).on('ajax:success', '.notification-form', e => {
+ const data = e.detail[0];
+
if (data.saved) {
$(e.currentTarget)
.closest('.js-notification-dropdown')
diff --git a/app/assets/javascripts/operation_settings/components/metrics_settings.vue b/app/assets/javascripts/operation_settings/components/metrics_settings.vue
index 9df6a412930..2e972dd7154 100644
--- a/app/assets/javascripts/operation_settings/components/metrics_settings.vue
+++ b/app/assets/javascripts/operation_settings/components/metrics_settings.vue
@@ -44,11 +44,9 @@ export default {
<form>
<dashboard-timezone />
<external-dashboard />
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button variant="success" category="primary" @click="saveChanges">
- {{ __('Save Changes') }}
- </gl-button>
- </div>
+ <gl-button variant="success" category="primary" @click="saveChanges">
+ {{ __('Save Changes') }}
+ </gl-button>
</form>
</div>
</section>
diff --git a/app/assets/javascripts/packages/details/store/getters.js b/app/assets/javascripts/packages/details/store/getters.js
index ede6d39bde7..04f75fc8333 100644
--- a/app/assets/javascripts/packages/details/store/getters.js
+++ b/app/assets/javascripts/packages/details/store/getters.js
@@ -98,7 +98,7 @@ export const nugetSetupCommand = ({ nugetPath }) =>
export const pypiPipCommand = ({ pypiPath, packageEntity }) =>
// eslint-disable-next-line @gitlab/require-i18n-strings
- `pip install ${packageEntity.name} --index-url ${pypiPath}`;
+ `pip install ${packageEntity.name} --extra-index-url ${pypiPath}`;
export const pypiSetupCommand = ({ pypiSetupPath }) => `[gitlab]
repository = ${pypiSetupPath}
diff --git a/app/assets/javascripts/packages/list/components/package_title.vue b/app/assets/javascripts/packages/list/components/package_title.vue
new file mode 100644
index 00000000000..e5cab310bc8
--- /dev/null
+++ b/app/assets/javascripts/packages/list/components/package_title.vue
@@ -0,0 +1,47 @@
+<script>
+import { n__ } from '~/locale';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '../constants';
+
+export default {
+ name: 'PackageTitle',
+ components: {
+ TitleArea,
+ MetadataItem,
+ },
+ props: {
+ packagesCount: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ packageHelpUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ showPackageCount() {
+ return Number.isInteger(this.packagesCount);
+ },
+ packageAmountText() {
+ return n__(`%d Package`, `%d Packages`, this.packagesCount);
+ },
+ infoMessages() {
+ return [{ text: LIST_INTRO_TEXT, link: this.packageHelpUrl }];
+ },
+ },
+ i18n: {
+ LIST_TITLE_TEXT,
+ },
+};
+</script>
+
+<template>
+ <title-area :title="$options.i18n.LIST_TITLE_TEXT" :info-messages="infoMessages">
+ <template #metadata_amount>
+ <metadata-item v-if="showPackageCount" icon="package" :text="packageAmountText" />
+ </template>
+ </title-area>
+</template>
diff --git a/app/assets/javascripts/packages/list/components/packages_list_app.vue b/app/assets/javascripts/packages/list/components/packages_list_app.vue
index 6304f723f6a..ad60ee6f379 100644
--- a/app/assets/javascripts/packages/list/components/packages_list_app.vue
+++ b/app/assets/javascripts/packages/list/components/packages_list_app.vue
@@ -3,13 +3,14 @@ import { mapActions, mapState } from 'vuex';
import { GlEmptyState, GlTab, GlTabs, GlLink, GlSprintf } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import createFlash from '~/flash';
+import { historyReplaceState } from '~/lib/utils/common_utils';
+import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
import PackageFilter from './packages_filter.vue';
import PackageList from './packages_list.vue';
import PackageSort from './packages_sort.vue';
import { PACKAGE_REGISTRY_TABS, DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants';
import PackagesComingSoon from '../coming_soon/packages_coming_soon.vue';
-import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
-import { historyReplaceState } from '~/lib/utils/common_utils';
+import PackageTitle from './package_title.vue';
export default {
components: {
@@ -22,6 +23,7 @@ export default {
PackageList,
PackageSort,
PackagesComingSoon,
+ PackageTitle,
},
computed: {
...mapState({
@@ -30,6 +32,8 @@ export default {
comingSoon: state => state.config.comingSoon,
filterQuery: state => state.filterQuery,
selectedType: state => state.selectedType,
+ packageHelpUrl: state => state.config.packageHelpUrl,
+ packagesCount: state => state.pagination?.total,
}),
tabsToRender() {
return PACKAGE_REGISTRY_TABS;
@@ -89,39 +93,43 @@ export default {
</script>
<template>
- <gl-tabs @input="tabChanged">
- <template #tabs-end>
- <div
- class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end"
- >
- <package-filter class="mr-1" @filter="requestPackagesList" />
- <package-sort @sort:changed="requestPackagesList" />
- </div>
- </template>
+ <div>
+ <package-title :package-help-url="packageHelpUrl" :packages-count="packagesCount" />
+
+ <gl-tabs @input="tabChanged">
+ <template #tabs-end>
+ <div
+ class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end"
+ >
+ <package-filter class="gl-mr-2" @filter="requestPackagesList" />
+ <package-sort @sort:changed="requestPackagesList" />
+ </div>
+ </template>
- <gl-tab v-for="(tab, index) in tabsToRender" :key="index" :title="tab.title">
- <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
- <template #empty-state>
- <gl-empty-state :title="emptyStateTitle(tab)" :svg-path="emptyListIllustration">
- <template #description>
- <gl-sprintf v-if="filterQuery" :message="$options.i18n.widenFilters" />
- <gl-sprintf v-else :message="$options.i18n.noResults">
- <template #noPackagesLink="{content}">
- <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </template>
- </gl-empty-state>
- </template>
- </package-list>
- </gl-tab>
+ <gl-tab v-for="(tab, index) in tabsToRender" :key="index" :title="tab.title">
+ <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
+ <template #empty-state>
+ <gl-empty-state :title="emptyStateTitle(tab)" :svg-path="emptyListIllustration">
+ <template #description>
+ <gl-sprintf v-if="filterQuery" :message="$options.i18n.widenFilters" />
+ <gl-sprintf v-else :message="$options.i18n.noResults">
+ <template #noPackagesLink="{content}">
+ <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-empty-state>
+ </template>
+ </package-list>
+ </gl-tab>
- <gl-tab v-if="comingSoon" :title="__('Coming soon')" lazy>
- <packages-coming-soon
- :illustration="emptyListIllustration"
- :project-path="comingSoon.projectPath"
- :suggested-contributions-path="comingSoon.suggestedContributions"
- />
- </gl-tab>
- </gl-tabs>
+ <gl-tab v-if="comingSoon" :title="__('Coming soon')" lazy>
+ <packages-coming-soon
+ :illustration="emptyListIllustration"
+ :project-path="comingSoon.projectPath"
+ :suggested-contributions-path="comingSoon.suggestedContributions"
+ />
+ </gl-tab>
+ </gl-tabs>
+ </div>
</template>
diff --git a/app/assets/javascripts/packages/list/components/packages_sort.vue b/app/assets/javascripts/packages/list/components/packages_sort.vue
index fa8f4f39d54..47e51bbdca5 100644
--- a/app/assets/javascripts/packages/list/components/packages_sort.vue
+++ b/app/assets/javascripts/packages/list/components/packages_sort.vue
@@ -51,7 +51,7 @@ export default {
<gl-sorting-item
v-for="item in sortableFields"
ref="packageListSortItem"
- :key="item.key"
+ :key="item.orderBy"
@click="onSortItemClick(item.orderBy)"
>
{{ item.label }}
diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js
index 0ff8c86362d..37242822e35 100644
--- a/app/assets/javascripts/packages/list/constants.js
+++ b/app/assets/javascripts/packages/list/constants.js
@@ -15,7 +15,7 @@ export const GROUP_PAGE_TYPE = 'groups';
export const LIST_KEY_NAME = 'name';
export const LIST_KEY_PROJECT = 'project_path';
export const LIST_KEY_VERSION = 'version';
-export const LIST_KEY_PACKAGE_TYPE = 'package_type';
+export const LIST_KEY_PACKAGE_TYPE = 'type';
export const LIST_KEY_CREATED_AT = 'created_at';
export const LIST_KEY_ACTIONS = 'actions';
@@ -23,47 +23,35 @@ export const LIST_LABEL_NAME = __('Name');
export const LIST_LABEL_PROJECT = __('Project');
export const LIST_LABEL_VERSION = __('Version');
export const LIST_LABEL_PACKAGE_TYPE = __('Type');
-export const LIST_LABEL_CREATED_AT = __('Created');
+export const LIST_LABEL_CREATED_AT = __('Published');
export const LIST_LABEL_ACTIONS = '';
-export const LIST_ORDER_BY_PACKAGE_TYPE = 'type';
-
export const ASCENDING_ODER = 'asc';
export const DESCENDING_ORDER = 'desc';
// The following is not translated because it is used to build a JavaScript exception error message
export const MISSING_DELETE_PATH_ERROR = 'Missing delete_api_path link';
-export const TABLE_HEADER_FIELDS = [
+export const SORT_FIELDS = [
{
- key: LIST_KEY_NAME,
- label: LIST_LABEL_NAME,
orderBy: LIST_KEY_NAME,
- class: ['text-left'],
+ label: LIST_LABEL_NAME,
},
{
- key: LIST_KEY_PROJECT,
- label: LIST_LABEL_PROJECT,
orderBy: LIST_KEY_PROJECT,
- class: ['text-left'],
+ label: LIST_LABEL_PROJECT,
},
{
- key: LIST_KEY_VERSION,
- label: LIST_LABEL_VERSION,
orderBy: LIST_KEY_VERSION,
- class: ['text-center'],
+ label: LIST_LABEL_VERSION,
},
{
- key: LIST_KEY_PACKAGE_TYPE,
+ orderBy: LIST_KEY_PACKAGE_TYPE,
label: LIST_LABEL_PACKAGE_TYPE,
- orderBy: LIST_ORDER_BY_PACKAGE_TYPE,
- class: ['text-center'],
},
{
- key: LIST_KEY_CREATED_AT,
- label: LIST_LABEL_CREATED_AT,
orderBy: LIST_KEY_CREATED_AT,
- class: ['text-center'],
+ label: LIST_LABEL_CREATED_AT,
},
];
@@ -98,3 +86,9 @@ export const PACKAGE_REGISTRY_TABS = [
type: PackageType.PYPI,
},
];
+
+export const LIST_TITLE_TEXT = s__('PackageRegistry|Package Registry');
+
+export const LIST_INTRO_TEXT = s__(
+ 'PackageRegistry|Publish and share packages for a variety of common package managers. %{docLinkStart}More information%{docLinkEnd}',
+);
diff --git a/app/assets/javascripts/packages/list/utils.js b/app/assets/javascripts/packages/list/utils.js
index 98d78db8706..6a300d7bfe6 100644
--- a/app/assets/javascripts/packages/list/utils.js
+++ b/app/assets/javascripts/packages/list/utils.js
@@ -1,7 +1,6 @@
-import { LIST_KEY_PROJECT, TABLE_HEADER_FIELDS } from './constants';
+import { LIST_KEY_PROJECT, SORT_FIELDS } from './constants';
-export default isGroupPage =>
- TABLE_HEADER_FIELDS.filter(f => f.key !== LIST_KEY_PROJECT || isGroupPage);
+export default isGroupPage => SORT_FIELDS.filter(f => f.key !== LIST_KEY_PROJECT || isGroupPage);
/**
* A small util function that works out if the delete action has deleted the
diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages/shared/components/package_list_row.vue
index f93bc51d185..d55ca80a7fc 100644
--- a/app/assets/javascripts/packages/shared/components/package_list_row.vue
+++ b/app/assets/javascripts/packages/shared/components/package_list_row.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlIcon, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui';
import PackageTags from './package_tags.vue';
+import PackagePath from './package_path.vue';
import PublishMethod from './publish_method.vue';
import { getPackageTypeLabel } from '../utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -15,6 +16,7 @@ export default {
GlSprintf,
GlTruncate,
PackageTags,
+ PackagePath,
PublishMethod,
ListItem,
},
@@ -92,22 +94,12 @@ export default {
</gl-sprintf>
</div>
- <div v-if="hasProjectLink" class="gl-display-flex gl-align-items-center">
- <gl-icon name="review-list" class="gl-ml-3 gl-mr-2 gl-min-w-0" />
-
- <gl-link
- class="gl-text-body gl-min-w-0"
- data-testid="packages-row-project"
- :href="`/${packageEntity.project_path}`"
- >
- <gl-truncate :text="packageEntity.projectPathName" />
- </gl-link>
- </div>
-
<div v-if="showPackageType" class="d-flex align-items-center" data-testid="package-type">
<gl-icon name="package" class="gl-ml-3 gl-mr-2" />
<span>{{ packageType }}</span>
</div>
+
+ <package-path v-if="hasProjectLink" :path="packageEntity.project_path" />
</div>
</template>
diff --git a/app/assets/javascripts/packages/shared/components/package_path.vue b/app/assets/javascripts/packages/shared/components/package_path.vue
new file mode 100644
index 00000000000..9afe06ab497
--- /dev/null
+++ b/app/assets/javascripts/packages/shared/components/package_path.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui';
+import { joinPaths } from '~/lib/utils/url_utility';
+
+export default {
+ name: 'PackagePath',
+ components: {
+ GlIcon,
+ GlLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ pathPieces() {
+ return this.path.split('/');
+ },
+ root() {
+ // we skip the first part of the path since is the 'base' group
+ return this.pathPieces[1];
+ },
+ rootLink() {
+ return joinPaths(this.pathPieces[0], this.root);
+ },
+ leaf() {
+ return this.pathPieces[this.pathPieces.length - 1];
+ },
+ deeplyNested() {
+ return this.pathPieces.length > 3;
+ },
+ hasGroup() {
+ return this.root !== this.leaf;
+ },
+ },
+};
+</script>
+
+<template>
+ <div data-qa-selector="package-path" class="gl-display-flex gl-align-items-center">
+ <gl-icon data-testid="base-icon" name="project" class="gl-mx-3 gl-min-w-0" />
+
+ <gl-link data-testid="root-link" class="gl-text-gray-500 gl-min-w-0" :href="`/${rootLink}`">
+ {{ root }}
+ </gl-link>
+
+ <template v-if="hasGroup">
+ <gl-icon data-testid="root-chevron" name="chevron-right" class="gl-mx-2 gl-min-w-0" />
+
+ <template v-if="deeplyNested">
+ <span
+ v-gl-tooltip="{ title: path }"
+ data-testid="ellipsis-icon"
+ class="gl-inset-border-1-gray-200 gl-rounded-base gl-px-2 gl-min-w-0"
+ >
+ <gl-icon name="ellipsis_h" />
+ </span>
+ <gl-icon data-testid="ellipsis-chevron" name="chevron-right" class="gl-mx-2 gl-min-w-0" />
+ </template>
+
+ <gl-link data-testid="leaf-link" class="gl-text-gray-500 gl-min-w-0" :href="`/${path}`">
+ {{ leaf }}
+ </gl-link>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages/shared/components/publish_method.vue b/app/assets/javascripts/packages/shared/components/publish_method.vue
index d17e23c4032..8a66a33f2ab 100644
--- a/app/assets/javascripts/packages/shared/components/publish_method.vue
+++ b/app/assets/javascripts/packages/shared/components/publish_method.vue
@@ -49,7 +49,8 @@ export default {
<clipboard-button
:text="packageEntity.pipeline.sha"
:title="__('Copy commit SHA')"
- css-class="gl-border-0 gl-py-0 gl-px-2"
+ category="tertiary"
+ size="small"
/>
</template>
diff --git a/app/assets/javascripts/pages/admin/instance_statistics/index.js b/app/assets/javascripts/pages/admin/instance_statistics/index.js
new file mode 100644
index 00000000000..d6b0a834ce3
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/instance_statistics/index.js
@@ -0,0 +1,3 @@
+import initInstanceStatisticsApp from '~/analytics/instance_statistics';
+
+document.addEventListener('DOMContentLoaded', () => initInstanceStatisticsApp());
diff --git a/app/assets/javascripts/pages/admin/keys/index.js b/app/assets/javascripts/pages/admin/keys/index.js
new file mode 100644
index 00000000000..45b83ffcd67
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/keys/index.js
@@ -0,0 +1,5 @@
+import initConfirmModal from '~/confirm_modal';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initConfirmModal();
+});
diff --git a/app/assets/javascripts/pages/admin/users/keys/index.js b/app/assets/javascripts/pages/admin/users/keys/index.js
new file mode 100644
index 00000000000..45b83ffcd67
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/users/keys/index.js
@@ -0,0 +1,5 @@
+import initConfirmModal from '~/confirm_modal';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initConfirmModal();
+});
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 3fa3a132dfa..30731f0e09c 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -4,7 +4,7 @@ import memberExpirationDate from '~/member_expiration_date';
import UsersSelect from '~/users_select';
import groupsSelect from '~/groups_select';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
-import initGroupMembersApp from '~/groups/members';
+import { initGroupMembersApp } from '~/groups/members';
function mountRemoveMemberModal() {
const el = document.querySelector('.js-remove-member-modal');
@@ -26,10 +26,24 @@ document.addEventListener('DOMContentLoaded', () => {
memberExpirationDate('.js-access-expiration-date-groups');
mountRemoveMemberModal();
- initGroupMembersApp(document.querySelector('.js-group-members-list'));
- initGroupMembersApp(document.querySelector('.js-group-linked-list'));
- initGroupMembersApp(document.querySelector('.js-group-invited-members-list'));
- initGroupMembersApp(document.querySelector('.js-group-access-requests-list'));
+ const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
+
+ initGroupMembersApp(
+ document.querySelector('.js-group-members-list'),
+ SHARED_FIELDS.concat(['source', 'granted']),
+ );
+ initGroupMembersApp(
+ document.querySelector('.js-group-linked-list'),
+ SHARED_FIELDS.concat('granted'),
+ );
+ initGroupMembersApp(
+ document.querySelector('.js-group-invited-members-list'),
+ SHARED_FIELDS.concat('invited'),
+ );
+ initGroupMembersApp(
+ document.querySelector('.js-group-access-requests-list'),
+ SHARED_FIELDS.concat('requested'),
+ );
new Members(); // eslint-disable-line no-new
new UsersSelect(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/profiles/keys/index.js b/app/assets/javascripts/pages/profiles/keys/index.js
index d3dcd21f456..4214d5bffb2 100644
--- a/app/assets/javascripts/pages/profiles/keys/index.js
+++ b/app/assets/javascripts/pages/profiles/keys/index.js
@@ -1,6 +1,9 @@
+import initConfirmModal from '~/confirm_modal';
import AddSshKeyValidation from '~/profile/add_ssh_key_validation';
document.addEventListener('DOMContentLoaded', () => {
+ initConfirmModal();
+
const input = document.querySelector('.js-add-ssh-key-validation-input');
if (!input) return;
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 46e59cd6572..0a52ac67aca 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -5,32 +5,30 @@ import initBlob from '~/pages/projects/init_blob';
import GpgBadges from '~/gpg_badges';
import '~/sourcegraph/load';
import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { isExperimentEnabled } from '~/lib/utils/experimentation';
const createGitlabCiYmlVisualization = (containerId = '#js-blob-toggle-graph-preview') => {
const el = document.querySelector(containerId);
- const { filename, blobData } = el?.dataset;
+ const { isCiConfigFile, blobData } = el?.dataset;
- const nameRegexp = /\.gitlab-ci.yml/;
-
- if (!el || !nameRegexp.test(filename)) {
- return;
+ if (el && parseBoolean(isCiConfigFile)) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ GitlabCiYamlVisualization: () =>
+ import('~/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue'),
+ },
+ render(createElement) {
+ return createElement('gitlabCiYamlVisualization', {
+ props: {
+ blobData,
+ },
+ });
+ },
+ });
}
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- components: {
- GitlabCiYamlVisualization: () =>
- import('~/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue'),
- },
- render(createElement) {
- return createElement('gitlabCiYamlVisualization', {
- props: {
- blobData,
- },
- });
- },
- });
};
document.addEventListener('DOMContentLoaded', () => {
@@ -61,7 +59,7 @@ document.addEventListener('DOMContentLoaded', () => {
const codeNavEl = document.getElementById('js-code-navigation');
- if (gon.features?.codeNavigation && codeNavEl) {
+ if (codeNavEl) {
const { codeNavigationPath, blobPath, definitionPathPrefix } = codeNavEl.dataset;
// eslint-disable-next-line promise/catch-or-return
@@ -73,7 +71,7 @@ document.addEventListener('DOMContentLoaded', () => {
);
}
- if (gon.features?.suggestPipeline) {
+ if (isExperimentEnabled('suggestPipeline')) {
const successPipelineEl = document.querySelector('.js-success-pipeline-modal');
if (successPipelineEl) {
diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js
index 744be65bfbe..1124eb5d939 100644
--- a/app/assets/javascripts/pages/projects/clusters/index/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/index/index.js
@@ -1,5 +1,5 @@
+import initClustersListApp from 'ee_else_ce/clusters_list';
import PersistentUserCallout from '~/persistent_user_callout';
-import initClustersListApp from '~/clusters_list';
document.addEventListener('DOMContentLoaded', () => {
const callout = document.querySelector('.gcp-signup-offer');
diff --git a/app/assets/javascripts/pages/projects/commit/pipelines/index.js b/app/assets/javascripts/pages/projects/commit/pipelines/index.js
index 1415a6f60c8..26dea17ca8a 100644
--- a/app/assets/javascripts/pages/projects/commit/pipelines/index.js
+++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js
@@ -1,14 +1,8 @@
-import $ from 'jquery';
-import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
+import { initCommitBoxInfo } from '~/projects/commit_box/info';
import initPipelines from '~/commit/pipelines/pipelines_bundle';
-import { fetchCommitMergeRequests } from '~/commit_merge_requests';
document.addEventListener('DOMContentLoaded', () => {
- new MiniPipelineGraph({
- container: '.js-commit-pipeline-graph',
- }).bindEvents();
- // eslint-disable-next-line no-jquery/no-load
- $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
- fetchCommitMergeRequests();
+ initCommitBoxInfo();
+
initPipelines();
});
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index d5fb2a8be3c..32fb35f97e3 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -4,10 +4,8 @@ import $ from 'jquery';
import Diff from '~/diff';
import ZenMode from '~/zen_mode';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
-import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
import initNotes from '~/init_notes';
import initChangesDropdown from '~/init_changes_dropdown';
-import { fetchCommitMergeRequests } from '~/commit_merge_requests';
import '~/sourcegraph/load';
import { handleLocationHash } from '~/lib/utils/common_utils';
import axios from '~/lib/utils/axios_utils';
@@ -15,6 +13,7 @@ import syntaxHighlight from '~/syntax_highlight';
import flash from '~/flash';
import { __ } from '~/locale';
import loadAwardsHandler from '~/awards_handler';
+import { initCommitBoxInfo } from '~/projects/commit_box/info';
document.addEventListener('DOMContentLoaded', () => {
const hasPerfBar = document.querySelector('.with-performance-bar');
@@ -22,13 +21,10 @@ document.addEventListener('DOMContentLoaded', () => {
initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight);
new ZenMode();
new ShortcutsNavigation();
- new MiniPipelineGraph({
- container: '.js-commit-pipeline-graph',
- }).bindEvents();
+
+ initCommitBoxInfo();
+
initNotes();
- // eslint-disable-next-line no-jquery/no-load
- $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
- fetchCommitMergeRequests();
const filesContainer = $('.js-diffs-batch');
diff --git a/app/assets/javascripts/pages/projects/feature_flags/edit/index.js b/app/assets/javascripts/pages/projects/feature_flags/edit/index.js
new file mode 100644
index 00000000000..62c85ada63b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/feature_flags/edit/index.js
@@ -0,0 +1,3 @@
+import initEditFeatureFlags from '~/feature_flags/edit';
+
+document.addEventListener('DOMContentLoaded', initEditFeatureFlags);
diff --git a/app/assets/javascripts/pages/projects/feature_flags/index/index.js b/app/assets/javascripts/pages/projects/feature_flags/index/index.js
new file mode 100644
index 00000000000..54e8dd73553
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/feature_flags/index/index.js
@@ -0,0 +1,3 @@
+import initFeatureFlags from '~/feature_flags';
+
+document.addEventListener('DOMContentLoaded', initFeatureFlags);
diff --git a/app/assets/javascripts/pages/projects/feature_flags/new/index.js b/app/assets/javascripts/pages/projects/feature_flags/new/index.js
new file mode 100644
index 00000000000..c5f29ae08a8
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/feature_flags/new/index.js
@@ -0,0 +1,3 @@
+import initNewFeatureFlags from '~/feature_flags/new';
+
+document.addEventListener('DOMContentLoaded', initNewFeatureFlags);
diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js
new file mode 100644
index 00000000000..bbe84322462
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js
@@ -0,0 +1,19 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import EditUserList from '~/user_lists/components/edit_user_list.vue';
+import createStore from '~/user_lists/store/edit';
+
+Vue.use(Vuex);
+
+document.addEventListener('DOMContentLoaded', () => {
+ const el = document.getElementById('js-edit-user-list');
+ const { userListsDocsPath } = el.dataset;
+ return new Vue({
+ el,
+ store: createStore(el.dataset),
+ provide: { userListsDocsPath },
+ render(h) {
+ return h(EditUserList, {});
+ },
+ });
+});
diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js
new file mode 100644
index 00000000000..679f0af8efc
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js
@@ -0,0 +1,22 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import NewUserList from '~/user_lists/components/new_user_list.vue';
+import createStore from '~/user_lists/store/new';
+
+Vue.use(Vuex);
+
+document.addEventListener('DOMContentLoaded', () => {
+ const el = document.getElementById('js-new-user-list');
+ const { userListsDocsPath, featureFlagsPath } = el.dataset;
+ return new Vue({
+ el,
+ store: createStore(el.dataset),
+ provide: {
+ userListsDocsPath,
+ featureFlagsPath,
+ },
+ render(h) {
+ return h(NewUserList);
+ },
+ });
+});
diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js
new file mode 100644
index 00000000000..bccd9dce2ec
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import UserList from '~/user_lists/components/user_list.vue';
+import createStore from '~/user_lists/store/show';
+
+Vue.use(Vuex);
+
+document.addEventListener('DOMContentLoaded', () => {
+ const el = document.getElementById('js-edit-user-list');
+ return new Vue({
+ el,
+ store: createStore(el.dataset),
+ render(h) {
+ const { emptyStatePath } = el.dataset;
+ return h(UserList, { props: { emptyStatePath } });
+ },
+ });
+});
diff --git a/app/assets/javascripts/pages/projects/incidents/show/index.js b/app/assets/javascripts/pages/projects/incidents/show/index.js
new file mode 100644
index 00000000000..540b0dd1de8
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/incidents/show/index.js
@@ -0,0 +1,7 @@
+import initRelatedIssues from '~/related_issues';
+import initShow from '../../issues/show';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initShow();
+ initRelatedIssues();
+});
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 98ae4e26257..80710f48a9c 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -18,7 +18,7 @@ export default function() {
if (issueType === 'incident') {
initIncidentApp(issuableData);
- } else {
+ } else if (issueType === 'issue') {
initIssueApp(issuableData);
}
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
index 7a3923dfefd..a138a3a3425 100644
--- 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
@@ -1,11 +1,8 @@
<script>
-/* eslint-disable vue/no-v-html */
import Vue from 'vue';
import Cookies from 'js-cookie';
import { GlIcon } from '@gitlab/ui';
import Translate from '../../../../../vue_shared/translate';
-// Full path is needed for Jest to be able to correctly mock this file
-import illustrationSvg from '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg';
import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(Translate);
@@ -20,12 +17,10 @@ export default {
data() {
return {
docsUrl: document.getElementById('pipeline-schedules-callout').dataset.docsUrl,
+ imageUrl: document.getElementById('pipeline-schedules-callout').dataset.imageUrl,
calloutDismissed: parseBoolean(Cookies.get(cookieKey)),
};
},
- created() {
- this.illustrationSvg = illustrationSvg;
- },
methods: {
dismissCallout() {
this.calloutDismissed = true;
@@ -40,7 +35,9 @@ export default {
<button id="dismiss-callout-btn" class="btn btn-default close" @click="dismissCallout">
<gl-icon name="close" aria-hidden="true" />
</button>
- <div class="svg-container" v-html="illustrationSvg"></div>
+ <div class="svg-container">
+ <img :src="imageUrl" />
+ </div>
<div class="user-callout-copy">
<h4>{{ __('Scheduling Pipelines') }}</h4>
<p>
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg
deleted file mode 100644
index 26d1ff97b3e..00000000000
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="140" height="102" viewBox="0 0 140 102" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>illustration</title><defs><rect id="a" width="12.033" height="40.197" rx="3"/><rect id="b" width="12.033" height="40.197" rx="3"/></defs><g fill="none" fill-rule="evenodd"><g transform="translate(-.446)"><path d="M91.747 35.675v-6.039a2.996 2.996 0 0 0-2.999-3.005H54.635a2.997 2.997 0 0 0-2.999 3.005v6.039H40.092a3.007 3.007 0 0 0-2.996 3.005v34.187a2.995 2.995 0 0 0 2.996 3.005h11.544V79.9a2.996 2.996 0 0 0 2.999 3.005h34.113a2.997 2.997 0 0 0 2.999-3.005v-4.03h11.544a3.007 3.007 0 0 0 2.996-3.004V38.68a2.995 2.995 0 0 0-2.996-3.005H91.747z" stroke="#B5A7DD" stroke-width="2"/><rect stroke="#E5E5E5" stroke-width="2" fill="#FFF" x="21.556" y="38.69" width="98.27" height="34.167" rx="3"/><path d="M121.325 38.19c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zM121.325 71.854a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038z" fill="#E5E5E5"/><g transform="translate(110.3 35.675)"><use fill="#FFF" xlink:href="#a"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="9.547" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.099" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="30.65" rx="1.504" ry="1.507"/></g><path d="M6.008 38.19c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zM6.008 71.854a1.004 1.004 0 0 1 0 2.006H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039z" fill="#E5E5E5"/><g transform="translate(19.05 35.675)"><use fill="#FFF" xlink:href="#b"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="10.049" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.601" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="31.153" rx="1.504" ry="1.507"/></g><g transform="translate(47.096)"><g transform="translate(7.05)"><ellipse fill="#FC8A51" cx="17.548" cy="5.025" rx="4.512" ry="4.522"/><rect stroke="#B5A7DD" stroke-width="2" fill="#FFF" x="13.036" y="4.02" width="9.025" height="20.099" rx="1.5"/><rect stroke="#FDE5D8" stroke-width="2" fill="#FFF" y="4.02" width="35.096" height="4.02" rx="2.01"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.512" y="18.089" width="26.072" height="17.084" rx="1.5"/></g><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(-45 43.117 35.117)" x="38.168" y="31.416" width="9.899" height="7.403" rx="3.702"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="25" ry="25"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="21" ry="21"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="43.05" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.305" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 74.422)" x="23.677" y="73.653" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 35.51)" x="23.844" y="34.742" width="2.616" height="1.538" rx=".769"/><path d="M13.362 42.502c-.124-.543.198-.854.74-.69l2.321.704c.533.161.643.592.235.972l-.22.206 7.06 7.572a1.002 1.002 0 1 1-1.467 1.368l-7.06-7.573-.118.11c-.402.375-.826.248-.952-.304l-.54-2.365zM21.606 67.576c-.408.38-.84.255-.968-.295l-.551-2.363c-.127-.542.191-.852.725-.69l.288.089 3.027-9.901a1.002 1.002 0 1 1 1.918.586l-3.027 9.901.154.047c.525.16.627.592.213.977l-1.779 1.65z" fill="#FC8A51"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25.099" cy="54.768" rx="2.507" ry="2.512"/></g></g><path d="M52.697 96.966a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zM86.29 96.966c0-.55.444-.996 1.002-.996.554 0 1.003.454 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044c0-.55.444-.996 1.002-.996.554 0 1.003.453 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038z" fill="#E5E5E5"/></g></svg> \ No newline at end of file
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index ab2a7c099c4..40816420eef 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -4,6 +4,7 @@ import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
import registrySettingsApp from '~/registry/settings/registry_settings_bundle';
import initVariableList from '~/ci_variable_list';
import initDeployFreeze from '~/deploy_freeze';
+import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers';
document.addEventListener('DOMContentLoaded', () => {
// Initialize expandable settings panels
@@ -42,4 +43,6 @@ document.addEventListener('DOMContentLoaded', () => {
registrySettingsApp();
initDeployFreeze();
+
+ initSettingsPipelinesTriggers();
});
diff --git a/app/assets/javascripts/pages/projects/tags/index/index.js b/app/assets/javascripts/pages/projects/tags/index/index.js
new file mode 100644
index 00000000000..ec56fa3e075
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tags/index/index.js
@@ -0,0 +1,12 @@
+import { initRemoveTag } from '../remove_tag';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initRemoveTag({
+ onDelete: path => {
+ document
+ .querySelector(`[data-path="${path}"]`)
+ .closest('.js-tag-list')
+ .remove();
+ },
+ });
+});
diff --git a/app/assets/javascripts/pages/projects/tags/remove_tag.js b/app/assets/javascripts/pages/projects/tags/remove_tag.js
new file mode 100644
index 00000000000..7e83dbe0565
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tags/remove_tag.js
@@ -0,0 +1,16 @@
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import initConfirmModal from '~/confirm_modal';
+
+export const initRemoveTag = ({ onDelete = () => {} }) => {
+ return initConfirmModal({
+ handleSubmit: (path = '') =>
+ axios
+ .delete(path)
+ .then(() => onDelete(path))
+ .catch(({ response: { data } }) => {
+ const { message } = data;
+ createFlash({ message });
+ }),
+ });
+};
diff --git a/app/assets/javascripts/pages/projects/tags/show/index.js b/app/assets/javascripts/pages/projects/tags/show/index.js
new file mode 100644
index 00000000000..651cc05ca4f
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tags/show/index.js
@@ -0,0 +1,10 @@
+import { redirectTo, getBaseURL, stripFinalUrlSegment } from '~/lib/utils/url_utility';
+import { initRemoveTag } from '../remove_tag';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initRemoveTag({
+ onDelete: (path = '') => {
+ redirectTo(stripFinalUrlSegment([getBaseURL(), path].join('')));
+ },
+ });
+});
diff --git a/app/assets/javascripts/pages/search/show/index.js b/app/assets/javascripts/pages/search/show/index.js
index 92d01343bd5..8dcc5aee00e 100644
--- a/app/assets/javascripts/pages/search/show/index.js
+++ b/app/assets/javascripts/pages/search/show/index.js
@@ -1,7 +1,10 @@
import Search from './search';
import initStateFilter from '~/search/state_filter';
+import initConfidentialFilter from '~/search/confidential_filter';
document.addEventListener('DOMContentLoaded', () => {
initStateFilter();
+ initConfidentialFilter();
+
return new Search();
});
diff --git a/app/assets/javascripts/performance_bar/performance_bar_log.js b/app/assets/javascripts/performance_bar/performance_bar_log.js
index 638c544f2e1..55b4d626e56 100644
--- a/app/assets/javascripts/performance_bar/performance_bar_log.js
+++ b/app/assets/javascripts/performance_bar/performance_bar_log.js
@@ -1,5 +1,6 @@
/* eslint-disable no-console */
import { getCLS, getFID, getLCP } from 'web-vitals';
+import { PERFORMANCE_TYPE_MARK, PERFORMANCE_TYPE_MEASURE } from '~/performance_constants';
const initVitalsLog = () => {
const reportVital = data => {
@@ -16,6 +17,29 @@ const initVitalsLog = () => {
getLCP(reportVital);
};
+const logUserTimingMetrics = () => {
+ const metricsProcessor = list => {
+ const entries = list.getEntries();
+ entries.forEach(entry => {
+ const { name, entryType, startTime, duration } = entry;
+ const typeMapper = {
+ [PERFORMANCE_TYPE_MARK]: String.fromCodePoint(0x1f3af),
+ [PERFORMANCE_TYPE_MEASURE]: String.fromCodePoint(0x1f4d0),
+ };
+ console.group(`${typeMapper[entryType]} ${name}`);
+ if (entryType === PERFORMANCE_TYPE_MARK) {
+ console.log(`Start time: ${startTime}`);
+ } else if (entryType === PERFORMANCE_TYPE_MEASURE) {
+ console.log(`Duration: ${duration}`);
+ }
+ console.log(entry);
+ console.groupEnd();
+ });
+ };
+ const observer = new PerformanceObserver(metricsProcessor);
+ observer.observe({ entryTypes: [PERFORMANCE_TYPE_MEASURE, PERFORMANCE_TYPE_MARK] });
+};
+
const initPerformanceBarLog = () => {
console.log(
`%c ${String.fromCodePoint(0x1f98a)} GitLab performance bar`,
@@ -23,6 +47,7 @@ const initPerformanceBarLog = () => {
);
initVitalsLog();
+ logUserTimingMetrics();
};
export default initPerformanceBarLog;
diff --git a/app/assets/javascripts/performance_constants.js b/app/assets/javascripts/performance_constants.js
index 1a53b925aa4..66a8880281c 100644
--- a/app/assets/javascripts/performance_constants.js
+++ b/app/assets/javascripts/performance_constants.js
@@ -1,8 +1,11 @@
+export const PERFORMANCE_TYPE_MARK = 'mark';
+export const PERFORMANCE_TYPE_MEASURE = 'measure';
+
//
// SNIPPET namespace
//
-// marks
+// Marks
export const SNIPPET_MARK_VIEW_APP_START = 'snippet-view-app-start';
export const SNIPPET_MARK_EDIT_APP_START = 'snippet-edit-app-start';
export const SNIPPET_MARK_BLOBS_CONTENT = 'snippet-blobs-content-finished';
@@ -10,3 +13,20 @@ export const SNIPPET_MARK_BLOBS_CONTENT = 'snippet-blobs-content-finished';
// Measures
export const SNIPPET_MEASURE_BLOBS_CONTENT = 'snippet-blobs-content';
export const SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP = 'snippet-blobs-content-within-app';
+
+//
+// WebIDE namespace
+//
+
+// Marks
+export const WEBIDE_MARK_APP_START = 'webide-app-start';
+export const WEBIDE_MARK_TREE_START = 'webide-tree-start';
+export const WEBIDE_MARK_TREE_FINISH = 'webide-tree-finished';
+export const WEBIDE_MARK_FILE_START = 'webide-file-start';
+export const WEBIDE_MARK_FILE_CLICKED = 'webide-file-clicked';
+export const WEBIDE_MARK_FILE_FINISH = 'webide-file-finished';
+
+// Measures
+export const WEBIDE_MEASURE_TREE_FROM_REQUEST = 'webide-tree-loading-from-request';
+export const WEBIDE_MEASURE_FILE_FROM_REQUEST = 'webide-file-loading-from-request';
+export const WEBIDE_MEASURE_FILE_AFTER_INTERACTION = 'webide-file-loading-after-interaction';
diff --git a/app/assets/javascripts/performance_utils.js b/app/assets/javascripts/performance_utils.js
new file mode 100644
index 00000000000..48a2958f5cc
--- /dev/null
+++ b/app/assets/javascripts/performance_utils.js
@@ -0,0 +1,12 @@
+export const performanceMarkAndMeasure = ({ mark, measures = [] } = {}) => {
+ window.requestAnimationFrame(() => {
+ if (mark && !performance.getEntriesByName(mark).length) {
+ performance.mark(mark);
+ }
+ measures.forEach(measure => {
+ window.requestAnimationFrame(() =>
+ performance.measure(measure.name, measure.start, measure.end),
+ );
+ });
+ });
+};
diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
index be8ce832d20..1cec08b93bd 100644
--- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
@@ -1,8 +1,8 @@
<script>
-import Vue from 'vue';
import { uniqueId } from 'lodash';
import {
GlAlert,
+ GlIcon,
GlButton,
GlForm,
GlFormGroup,
@@ -27,12 +27,13 @@ export default {
variablesDescription: s__(
'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.',
),
- formElementClasses: 'gl-mr-3 gl-mb-3 table-section section-15',
+ formElementClasses: 'gl-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0',
errorTitle: __('The form contains the following error:'),
warningTitle: __('The form contains the following warning:'),
maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'),
components: {
GlAlert,
+ GlIcon,
GlButton,
GlForm,
GlFormGroup,
@@ -85,7 +86,7 @@ export default {
return {
searchTerm: '',
refValue: this.refParam,
- variables: {},
+ variables: [],
error: null,
warnings: [],
totalWarnings: 0,
@@ -97,9 +98,6 @@ export default {
const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
return this.refs.filter(ref => ref.toLowerCase().includes(lowerCasedSearchTerm));
},
- variablesLength() {
- return Object.keys(this.variables).length;
- },
overMaxWarningsLimit() {
return this.totalWarnings > this.maxWarnings;
},
@@ -114,6 +112,8 @@ export default {
},
},
created() {
+ this.addEmptyVariable();
+
if (this.variableParams) {
this.setVariableParams(VARIABLE_TYPE, this.variableParams);
}
@@ -121,24 +121,26 @@ export default {
if (this.fileParams) {
this.setVariableParams(FILE_TYPE, this.fileParams);
}
-
- this.addEmptyVariable();
},
methods: {
- addEmptyVariable() {
- this.variables[uniqueId('var')] = {
- variable_type: VARIABLE_TYPE,
- key: '',
- value: '',
- };
- },
- setVariableParams(type, paramsObj) {
- Object.entries(paramsObj).forEach(([key, value]) => {
- this.variables[uniqueId('var')] = {
+ setVariable(type, key, value) {
+ const variable = this.variables.find(v => v.key === key);
+ if (variable) {
+ variable.type = type;
+ variable.value = value;
+ } else {
+ // insert before the empty variable
+ this.variables.splice(this.variables.length - 1, 0, {
+ uniqueId: uniqueId('var'),
key,
value,
variable_type: type,
- };
+ });
+ }
+ },
+ setVariableParams(type, paramsObj) {
+ Object.entries(paramsObj).forEach(([key, value]) => {
+ this.setVariable(type, key, value);
});
},
setRefSelected(ref) {
@@ -147,29 +149,34 @@ export default {
isSelected(ref) {
return ref === this.refValue;
},
- insertNewVariable() {
- Vue.set(this.variables, uniqueId('var'), {
+ addEmptyVariable() {
+ this.variables.push({
+ uniqueId: uniqueId('var'),
variable_type: VARIABLE_TYPE,
key: '',
value: '',
});
},
- removeVariable(key) {
- Vue.delete(this.variables, key);
+ removeVariable(index) {
+ this.variables.splice(index, 1);
},
canRemove(index) {
- return index < this.variablesLength - 1;
+ return index < this.variables.length - 1;
},
createPipeline() {
- const filteredVariables = Object.values(this.variables).filter(
- ({ key, value }) => key !== '' && value !== '',
- );
+ const filteredVariables = this.variables
+ .filter(({ key, value }) => key !== '' && value !== '')
+ .map(({ variable_type, key, value }) => ({
+ variable_type,
+ key,
+ secret_value: value,
+ }));
return axios
.post(this.pipelinesPath, {
ref: this.refValue,
- variables: filteredVariables,
+ variables_attributes: filteredVariables,
})
.then(({ data }) => {
redirectTo(`${this.pipelinesPath}/${data.id}`);
@@ -253,35 +260,47 @@ export default {
<gl-form-group :label="s__('Pipeline|Variables')">
<div
- v-for="(value, key, index) in variables"
- :key="key"
- class="gl-display-flex gl-align-items-center gl-mb-4 gl-pb-2 gl-border-b-solid gl-border-gray-200 gl-border-b-1 gl-flex-direction-column gl-md-flex-direction-row"
+ v-for="(variable, index) in variables"
+ :key="variable.uniqueId"
+ class="gl-display-flex gl-align-items-stretch gl-align-items-center gl-mb-4 gl-ml-n3 gl-pb-2 gl-border-b-solid gl-border-gray-200 gl-border-b-1 gl-flex-direction-column gl-md-flex-direction-row"
data-testid="ci-variable-row"
>
<gl-form-select
- v-model="variables[key].variable_type"
+ v-model="variable.variable_type"
:class="$options.formElementClasses"
:options="$options.typeOptions"
/>
<gl-form-input
- v-model="variables[key].key"
+ v-model="variable.key"
:placeholder="s__('CiVariables|Input variable key')"
:class="$options.formElementClasses"
data-testid="pipeline-form-ci-variable-key"
- @change.once="insertNewVariable()"
+ @change.once="addEmptyVariable()"
/>
<gl-form-input
- v-model="variables[key].value"
+ v-model="variable.value"
:placeholder="s__('CiVariables|Input variable value')"
- class="gl-mr-5 gl-mb-3 table-section section-15"
- />
- <gl-button
- v-if="canRemove(index)"
- icon="issue-close"
class="gl-mb-3"
- data-testid="remove-ci-variable-row"
- @click="removeVariable(key)"
/>
+
+ <template v-if="variables.length > 1">
+ <gl-button
+ v-if="canRemove(index)"
+ class="gl-md-ml-3 gl-mb-3"
+ data-testid="remove-ci-variable-row"
+ variant="danger"
+ category="secondary"
+ @click="removeVariable(index)"
+ >
+ <gl-icon class="gl-mr-0! gl-display-none gl-display-md-block" name="clear" />
+ <span class="gl-display-md-none">{{ s__('CiVariables|Remove variable') }}</span>
+ </gl-button>
+ <gl-button
+ v-else
+ class="gl-md-ml-3 gl-mb-3 gl-display-none gl-display-md-block gl-visibility-hidden"
+ icon="clear"
+ />
+ </template>
</div>
<template #description
@@ -295,9 +314,13 @@ export default {
<div
class="gl-border-t-solid gl-border-gray-100 gl-border-t-1 gl-p-5 gl-bg-gray-10 gl-display-flex gl-justify-content-space-between"
>
- <gl-button type="submit" category="primary" variant="success">{{
- s__('Pipeline|Run Pipeline')
- }}</gl-button>
+ <gl-button
+ type="submit"
+ category="primary"
+ variant="success"
+ data-qa-selector="run_pipeline_button"
+ >{{ s__('Pipeline|Run Pipeline') }}</gl-button
+ >
<gl-button :href="pipelinesPath">{{ __('Cancel') }}</gl-button>
</div>
</gl-form>
diff --git a/app/assets/javascripts/pipelines/components/dag/constants.js b/app/assets/javascripts/pipelines/components/dag/constants.js
index b6a98fdc488..cd89055737f 100644
--- a/app/assets/javascripts/pipelines/components/dag/constants.js
+++ b/app/assets/javascripts/pipelines/components/dag/constants.js
@@ -1,9 +1,3 @@
-/* Error constants */
-export const PARSE_FAILURE = 'parse_failure';
-export const LOAD_FAILURE = 'load_failure';
-export const UNSUPPORTED_DATA = 'unsupported_data';
-export const DEFAULT = 'default';
-
/* Interaction handles */
export const IS_HIGHLIGHTED = 'dag-highlighted';
export const LINK_SELECTOR = 'dag-link';
diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue
index 8487da3d621..ab736061a2e 100644
--- a/app/assets/javascripts/pipelines/components/dag/dag.vue
+++ b/app/assets/javascripts/pipelines/components/dag/dag.vue
@@ -6,16 +6,9 @@ import { fetchPolicies } from '~/lib/graphql';
import getDagVisData from '../../graphql/queries/get_dag_vis_data.query.graphql';
import DagGraph from './dag_graph.vue';
import DagAnnotations from './dag_annotations.vue';
-import {
- DEFAULT,
- PARSE_FAILURE,
- LOAD_FAILURE,
- UNSUPPORTED_DATA,
- ADD_NOTE,
- REMOVE_NOTE,
- REPLACE_NOTES,
-} from './constants';
+import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants';
import { parseData } from './parsing_utils';
+import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from '../../constants';
export default {
// eslint-disable-next-line @gitlab/require-i18n-strings
diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
index d12baa9617e..34ff89a5e6f 100644
--- a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
+++ b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
@@ -1,14 +1,7 @@
<script>
import * as d3 from 'd3';
import { uniqueId } from 'lodash';
-import {
- LINK_SELECTOR,
- NODE_SELECTOR,
- PARSE_FAILURE,
- ADD_NOTE,
- REMOVE_NOTE,
- REPLACE_NOTES,
-} from './constants';
+import { LINK_SELECTOR, NODE_SELECTOR, ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants';
import {
currentIsLive,
getLiveLinksAsDict,
@@ -19,6 +12,7 @@ import {
} from './interactions';
import { getMaxNodes, removeOrphanNodes } from './parsing_utils';
import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils';
+import { PARSE_FAILURE } from '../../constants';
export default {
viewOptions: {
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue
index efa11580c41..a580ee11627 100644
--- a/app/assets/javascripts/pipelines/components/graph/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue
@@ -88,7 +88,7 @@ export default {
:class="cssClass"
:disabled="isDisabled"
class="js-ci-action ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center"
- @click="onClickAction"
+ @click.stop="onClickAction"
>
<gl-loading-icon v-if="isLoading" class="js-action-icon-loading" />
<gl-icon v-else :name="actionIcon" class="gl-mr-0!" />
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 924cdeebba1..0f5a8cb8fbf 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -1,7 +1,7 @@
<script>
+import { escape, capitalize } from 'lodash';
import { GlLoadingIcon } from '@gitlab/ui';
import StageColumnComponent from './stage_column_component.vue';
-import GraphMixin from '../../mixins/graph_component_mixin';
import GraphWidthMixin from '../../mixins/graph_width_mixin';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
@@ -13,7 +13,7 @@ export default {
GlLoadingIcon,
LinkedPipelinesColumn,
},
- mixins: [GraphMixin, GraphWidthMixin, GraphBundleMixin],
+ mixins: [GraphWidthMixin, GraphBundleMixin],
props: {
isLoading: {
type: Boolean,
@@ -51,6 +51,9 @@ export default {
};
},
computed: {
+ graph() {
+ return this.pipeline.details?.stages;
+ },
hasTriggeredBy() {
return (
this.type !== this.$options.downstream &&
@@ -92,6 +95,39 @@ export default {
},
},
methods: {
+ capitalizeStageName(name) {
+ const escapedName = escape(name);
+ return capitalize(escapedName);
+ },
+ isFirstColumn(index) {
+ return index === 0;
+ },
+ stageConnectorClass(index, stage) {
+ let className;
+
+ // If it's the first stage column and only has one job
+ if (this.isFirstColumn(index) && stage.groups.length === 1) {
+ className = 'no-margin';
+ } else if (index > 0) {
+ // If it is not the first column
+ className = 'left-margin';
+ }
+
+ return className;
+ },
+ refreshPipelineGraph() {
+ this.$emit('refreshPipelineGraph');
+ },
+ /**
+ * CSS class is applied:
+ * - if pipeline graph contains only one stage column component
+ *
+ * @param {number} index
+ * @returns {boolean}
+ */
+ shouldAddRightMargin(index) {
+ return !(index === this.graph.length - 1);
+ },
handleClickedDownstream(pipeline, clickedIndex, downstreamNode) {
/**
* Calculates the margin top of the clicked downstream pipeline by
diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
index 11fb2b18e9d..133965f0aca 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -1,5 +1,4 @@
<script>
-import $ from 'jquery';
import { GlTooltipDirective } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import JobItem from './job_item.vue';
@@ -30,27 +29,7 @@ export default {
return `${name} - ${status.label}`;
},
},
- mounted() {
- this.stopDropdownClickPropagation();
- },
methods: {
- /**
- * When the user right clicks or cmd/ctrl + click in the group name or the action icon
- * the dropdown should not be closed 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() {
- $(
- '.js-grouped-pipeline-dropdown button, .js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item',
- this.$el,
- ).on('click', e => {
- e.stopPropagation();
- });
- },
-
pipelineActionRequestComplete() {
this.$emit('pipelineActionRequestComplete');
},
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 0fe0b671273..9f7fe85fb0d 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -135,6 +135,7 @@ export default {
:class="jobClasses"
class="js-pipeline-graph-job-link qa-job-link menu-item"
data-testid="job-with-link"
+ @click.stop
>
<job-name-component :name="job.name" :status="job.status" />
</gl-link>
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 1453c349f44..a75ec585b95 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -71,7 +71,7 @@ export default {
:action-icon="action.icon"
:tooltip-text="action.title"
:link="action.path"
- class="js-stage-action stage-action position-absolute position-top-0 rounded"
+ class="js-stage-action stage-action rounded"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
</div>
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index c7b72be36ad..b26f28fa6af 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -1,8 +1,11 @@
<script>
-import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui';
-import ciHeader from '~/vue_shared/components/header_ci_component.vue';
-import eventHub from '../event_hub';
+import { GlAlert, GlButton, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
import { __ } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import ciHeader from '~/vue_shared/components/header_ci_component.vue';
+import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
+import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql';
+import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants';
const DELETE_MODAL_ID = 'pipeline-delete-modal';
@@ -10,57 +13,143 @@ export default {
name: 'PipelineHeaderSection',
components: {
ciHeader,
+ GlAlert,
+ GlButton,
GlLoadingIcon,
GlModal,
- GlButton,
},
directives: {
GlModal: GlModalDirective,
},
- props: {
- pipeline: {
- type: Object,
- required: true,
+ errorTexts: {
+ [LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'),
+ [POST_FAILURE]: __('An error occurred while making the request.'),
+ [DELETE_FAILURE]: __('An error occurred while deleting the pipeline.'),
+ [DEFAULT]: __('An unknown error occurred.'),
+ },
+ inject: {
+ // Receive `cancel`, `delete`, `fullProject` and `retry`
+ paths: {
+ default: {},
+ },
+ pipelineId: {
+ default: '',
},
- isLoading: {
- type: Boolean,
- required: true,
+ pipelineIid: {
+ default: '',
+ },
+ },
+ apollo: {
+ pipeline: {
+ query: getPipelineQuery,
+ variables() {
+ return {
+ fullPath: this.paths.fullProject,
+ iid: this.pipelineIid,
+ };
+ },
+ update: data => data.project.pipeline,
+ error() {
+ this.reportFailure(LOAD_FAILURE);
+ },
+ pollInterval: 10000,
+ watchLoading(isLoading) {
+ if (!isLoading) {
+ // To ensure apollo has updated the cache,
+ // we only remove the loading state in sync with GraphQL
+ this.isCanceling = false;
+ this.isRetrying = false;
+ }
+ },
},
},
data() {
return {
+ pipeline: null,
+ failureType: null,
isCanceling: false,
isRetrying: false,
isDeleting: false,
};
},
-
computed: {
- status() {
- return this.pipeline.details && this.pipeline.details.status;
- },
- shouldRenderContent() {
- return !this.isLoading && Object.keys(this.pipeline).length;
- },
deleteModalConfirmationText() {
return __(
'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.',
);
},
+ hasError() {
+ return this.failureType;
+ },
+ hasPipelineData() {
+ return Boolean(this.pipeline);
+ },
+ isLoadingInitialQuery() {
+ return this.$apollo.queries.pipeline.loading && !this.hasPipelineData;
+ },
+ status() {
+ return this.pipeline?.status;
+ },
+ shouldRenderContent() {
+ return !this.isLoadingInitialQuery && this.hasPipelineData;
+ },
+ failure() {
+ switch (this.failureType) {
+ case LOAD_FAILURE:
+ return {
+ text: this.$options.errorTexts[LOAD_FAILURE],
+ variant: 'danger',
+ };
+ case POST_FAILURE:
+ return {
+ text: this.$options.errorTexts[POST_FAILURE],
+ variant: 'danger',
+ };
+ case DELETE_FAILURE:
+ return {
+ text: this.$options.errorTexts[DELETE_FAILURE],
+ variant: 'danger',
+ };
+ default:
+ return {
+ text: this.$options.errorTexts[DEFAULT],
+ variant: 'danger',
+ };
+ }
+ },
},
-
methods: {
- cancelPipeline() {
+ reportFailure(errorType) {
+ this.failureType = errorType;
+ },
+ async postAction(path) {
+ try {
+ await axios.post(path);
+ this.$apollo.queries.pipeline.refetch();
+ } catch {
+ this.reportFailure(POST_FAILURE);
+ }
+ },
+ async cancelPipeline() {
this.isCanceling = true;
- eventHub.$emit('headerPostAction', this.pipeline.cancel_path);
+ this.postAction(this.paths.cancel);
},
- retryPipeline() {
+ async retryPipeline() {
this.isRetrying = true;
- eventHub.$emit('headerPostAction', this.pipeline.retry_path);
+ this.postAction(this.paths.retry);
},
- deletePipeline() {
+ async deletePipeline() {
this.isDeleting = true;
- eventHub.$emit('headerDeleteAction', this.pipeline.delete_path);
+ this.$apollo.queries.pipeline.stopPolling();
+
+ try {
+ const { request } = await axios.delete(this.paths.delete);
+ redirectTo(setUrlFragment(request.responseURL, 'delete_success'));
+ } catch {
+ this.$apollo.queries.pipeline.startPolling();
+ this.reportFailure(DELETE_FAILURE);
+ this.isDeleting = false;
+ }
},
},
DELETE_MODAL_ID,
@@ -68,54 +157,53 @@ export default {
</script>
<template>
<div class="pipeline-header-container">
+ <gl-alert v-if="hasError" :variant="failure.variant">{{ failure.text }}</gl-alert>
<ci-header
v-if="shouldRenderContent"
- :status="status"
- :item-id="pipeline.id"
- :time="pipeline.created_at"
+ :status="pipeline.detailedStatus"
+ :time="pipeline.createdAt"
:user="pipeline.user"
+ :item-id="Number(pipelineId)"
item-name="Pipeline"
>
<gl-button
- v-if="pipeline.retry_path"
+ v-if="pipeline.retryable"
:loading="isRetrying"
:disabled="isRetrying"
- data-testid="retryButton"
category="secondary"
variant="info"
+ data-testid="retryPipeline"
+ class="js-retry-button"
@click="retryPipeline()"
>
{{ __('Retry') }}
</gl-button>
<gl-button
- v-if="pipeline.cancel_path"
+ v-if="pipeline.cancelable"
:loading="isCanceling"
:disabled="isCanceling"
- data-testid="cancelPipeline"
- class="gl-ml-3"
- category="primary"
variant="danger"
+ data-testid="cancelPipeline"
@click="cancelPipeline()"
>
{{ __('Cancel running') }}
</gl-button>
<gl-button
- v-if="pipeline.delete_path"
+ v-if="pipeline.userPermissions.destroyPipeline"
v-gl-modal="$options.DELETE_MODAL_ID"
:loading="isDeleting"
:disabled="isDeleting"
- data-testid="deletePipeline"
class="gl-ml-3"
- category="secondary"
variant="danger"
+ category="secondary"
+ data-testid="deletePipeline"
>
{{ __('Delete') }}
</gl-button>
</ci-header>
-
- <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" />
+ <gl-loading-icon v-if="isLoadingInitialQuery" size="lg" class="gl-mt-3 gl-mb-3" />
<gl-modal
:modal-id="$options.DELETE_MODAL_ID"
diff --git a/app/assets/javascripts/pipelines/components/legacy_header_component.vue b/app/assets/javascripts/pipelines/components/legacy_header_component.vue
new file mode 100644
index 00000000000..c7b72be36ad
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/legacy_header_component.vue
@@ -0,0 +1,132 @@
+<script>
+import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui';
+import ciHeader from '~/vue_shared/components/header_ci_component.vue';
+import eventHub from '../event_hub';
+import { __ } from '~/locale';
+
+const DELETE_MODAL_ID = 'pipeline-delete-modal';
+
+export default {
+ name: 'PipelineHeaderSection',
+ components: {
+ ciHeader,
+ GlLoadingIcon,
+ GlModal,
+ GlButton,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isCanceling: false,
+ isRetrying: false,
+ isDeleting: false,
+ };
+ },
+
+ computed: {
+ status() {
+ return this.pipeline.details && this.pipeline.details.status;
+ },
+ shouldRenderContent() {
+ return !this.isLoading && Object.keys(this.pipeline).length;
+ },
+ deleteModalConfirmationText() {
+ return __(
+ 'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.',
+ );
+ },
+ },
+
+ methods: {
+ cancelPipeline() {
+ this.isCanceling = true;
+ eventHub.$emit('headerPostAction', this.pipeline.cancel_path);
+ },
+ retryPipeline() {
+ this.isRetrying = true;
+ eventHub.$emit('headerPostAction', this.pipeline.retry_path);
+ },
+ deletePipeline() {
+ this.isDeleting = true;
+ eventHub.$emit('headerDeleteAction', this.pipeline.delete_path);
+ },
+ },
+ DELETE_MODAL_ID,
+};
+</script>
+<template>
+ <div class="pipeline-header-container">
+ <ci-header
+ v-if="shouldRenderContent"
+ :status="status"
+ :item-id="pipeline.id"
+ :time="pipeline.created_at"
+ :user="pipeline.user"
+ item-name="Pipeline"
+ >
+ <gl-button
+ v-if="pipeline.retry_path"
+ :loading="isRetrying"
+ :disabled="isRetrying"
+ data-testid="retryButton"
+ category="secondary"
+ variant="info"
+ @click="retryPipeline()"
+ >
+ {{ __('Retry') }}
+ </gl-button>
+
+ <gl-button
+ v-if="pipeline.cancel_path"
+ :loading="isCanceling"
+ :disabled="isCanceling"
+ data-testid="cancelPipeline"
+ class="gl-ml-3"
+ category="primary"
+ variant="danger"
+ @click="cancelPipeline()"
+ >
+ {{ __('Cancel running') }}
+ </gl-button>
+
+ <gl-button
+ v-if="pipeline.delete_path"
+ v-gl-modal="$options.DELETE_MODAL_ID"
+ :loading="isDeleting"
+ :disabled="isDeleting"
+ data-testid="deletePipeline"
+ class="gl-ml-3"
+ category="secondary"
+ variant="danger"
+ >
+ {{ __('Delete') }}
+ </gl-button>
+ </ci-header>
+
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" />
+
+ <gl-modal
+ :modal-id="$options.DELETE_MODAL_ID"
+ :title="__('Delete pipeline')"
+ :ok-title="__('Delete pipeline')"
+ ok-variant="danger"
+ @ok="deletePipeline()"
+ >
+ <p>
+ {{ deleteModalConfirmationText }}
+ </p>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
index fe8e3bd2b78..c5f30c8aef0 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
@@ -48,6 +48,7 @@ export default {
variant="info"
category="primary"
class="js-get-started-pipelines"
+ data-testid="get-started-pipelines"
>
{{ s__('Pipelines|Get started with Pipelines') }}
</gl-button>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
index d7b6e033bd1..cf0849751df 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
@@ -46,6 +46,8 @@ export default {
variant="success"
category="primary"
class="js-run-pipeline"
+ data-testid="run-pipeline-button"
+ data-qa-selector="run_pipeline_button"
>
{{ s__('Pipelines|Run Pipeline') }}
</gl-button>
@@ -54,12 +56,13 @@ export default {
v-if="resetCachePath"
:loading="isResetCacheButtonLoading"
class="js-clear-cache"
+ data-testid="clear-cache-button"
@click="onClickResetCache"
>
{{ s__('Pipelines|Clear Runner Caches') }}
</gl-button>
- <gl-button v-if="ciLintPath" :href="ciLintPath" class="js-ci-lint">
+ <gl-button v-if="ciLintPath" :href="ciLintPath" class="js-ci-lint" data-testid="ci-lint-button">
{{ s__('Pipelines|CI Lint') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
index f0614298bd3..e0f65643d37 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -98,7 +98,7 @@ export default {
placement="top"
>
<template #title>
- <div class="autodevops-title">
+ <div class="gl-font-weight-normal gl-line-height-normal">
<gl-sprintf
:message="
__(
@@ -112,12 +112,7 @@ export default {
</gl-sprintf>
</div>
</template>
- <gl-link
- class="autodevops-link"
- :href="autoDevopsHelpPath"
- target="_blank"
- rel="noopener noreferrer nofollow"
- >
+ <gl-link :href="autoDevopsHelpPath" target="_blank" rel="noopener noreferrer nofollow">
{{ __('Learn more about Auto DevOps') }}
</gl-link>
</gl-popover>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
index b8112149778..6c60594efca 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -91,6 +91,10 @@ export default {
<div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader">
{{ s__('Pipeline|Stages') }}
</div>
+ <div class="table-section section-15" role="rowheader"></div>
+ <div class="table-section section-20" role="rowheader">
+ <slot name="table-header-actions"></slot>
+ </div>
</div>
<pipelines-table-row-component
v-for="model in pipelines"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
index 7d13ee582c6..8de18aef639 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -1,12 +1,11 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import '~/lib/utils/datetime_utility';
-import tooltip from '~/vue_shared/directives/tooltip';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: { GlIcon },
mixins: [timeagoMixin],
@@ -63,7 +62,7 @@ export default {
<gl-icon name="calendar" class="gl-vertical-align-baseline!" aria-hidden="true" />
<time
- v-tooltip
+ v-gl-tooltip
:title="tooltipTitle(finishedTime)"
data-placement="top"
data-container="body"
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index abe5e1060c8..d3acd1ef3d0 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -13,6 +13,8 @@ export const TestStatus = {
FAILED: 'failed',
SKIPPED: 'skipped',
SUCCESS: 'success',
+ ERROR: 'error',
+ UNKNOWN: 'unknown',
};
export const FETCH_AUTHOR_ERROR_MESSAGE = __('There was a problem fetching project users.');
@@ -21,3 +23,11 @@ export const FETCH_TAG_ERROR_MESSAGE = __('There was a problem fetching project
export const RAW_TEXT_WARNING = s__(
'Pipeline|Raw text search is not currently supported. Please use the available search tokens.',
);
+
+/* Error constants shared across graphs */
+export const DEFAULT = 'default';
+export const DELETE_FAILURE = 'delete_pipeline_failure';
+export const LOAD_FAILURE = 'load_failure';
+export const PARSE_FAILURE = 'parse_failure';
+export const POST_FAILURE = 'post_failure';
+export const UNSUPPORTED_DATA = 'unsupported_data';
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
new file mode 100644
index 00000000000..06083daeca0
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
@@ -0,0 +1,30 @@
+query getPipelineHeaderData($fullPath: ID!, $iid: ID!) {
+ project(fullPath: $fullPath) {
+ pipeline(iid: $iid) {
+ id
+ status
+ retryable
+ cancelable
+ userPermissions {
+ destroyPipeline
+ }
+ detailedStatus {
+ detailsPath
+ icon
+ group
+ text
+ }
+ createdAt
+ user {
+ name
+ webPath
+ email
+ avatarUrl
+ status {
+ message
+ emoji
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js
deleted file mode 100644
index 53b7a174517..00000000000
--- a/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import { escape } from 'lodash';
-
-export default {
- props: {
- isLoading: {
- type: Boolean,
- required: true,
- },
- pipeline: {
- type: Object,
- required: true,
- },
- },
- computed: {
- graph() {
- return this.pipeline.details && this.pipeline.details.stages;
- },
- },
- methods: {
- capitalizeStageName(name) {
- const escapedName = escape(name);
- return escapedName.charAt(0).toUpperCase() + escapedName.slice(1);
- },
- isFirstColumn(index) {
- return index === 0;
- },
- stageConnectorClass(index, stage) {
- let className;
-
- // If it's the first stage column and only has one job
- if (index === 0 && stage.groups.length === 1) {
- className = 'no-margin';
- } else if (index > 0) {
- // If it is not the first column
- className = 'left-margin';
- }
-
- return className;
- },
- refreshPipelineGraph() {
- this.$emit('refreshPipelineGraph');
- },
- /**
- * CSS class is applied:
- * - if pipeline graph contains only one stage column component
- *
- * @param {number} index
- * @returns {boolean}
- */
- shouldAddRightMargin(index) {
- return !(index === this.graph.length - 1);
- },
- },
-};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 745f5b886a5..67aec12655a 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -7,10 +7,11 @@ import pipelineGraph from './components/graph/graph_component.vue';
import createDagApp from './pipeline_details_dag';
import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator';
-import pipelineHeader from './components/header_component.vue';
+import legacyPipelineHeader from './components/legacy_header_component.vue';
import eventHub from './event_hub';
import TestReports from './components/test_reports/test_reports.vue';
import createTestReportsStore from './stores/test_reports';
+import { createPipelineHeaderApp } from './pipeline_details_header';
Vue.use(Translate);
@@ -56,7 +57,7 @@ const createPipelinesDetailApp = mediator => {
});
};
-const createPipelineHeaderApp = mediator => {
+const createLegacyPipelineHeaderApp = mediator => {
if (!document.querySelector(SELECTORS.PIPELINE_HEADER)) {
return;
}
@@ -64,7 +65,7 @@ const createPipelineHeaderApp = mediator => {
new Vue({
el: SELECTORS.PIPELINE_HEADER,
components: {
- pipelineHeader,
+ legacyPipelineHeader,
},
data() {
return {
@@ -95,7 +96,7 @@ const createPipelineHeaderApp = mediator => {
},
},
render(createElement) {
- return createElement('pipeline-header', {
+ return createElement('legacy-pipeline-header', {
props: {
isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline,
@@ -132,7 +133,12 @@ export default () => {
mediator.fetchPipeline();
createPipelinesDetailApp(mediator);
- createPipelineHeaderApp(mediator);
+
+ if (gon.features.graphqlPipelineHeader) {
+ createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER);
+ } else {
+ createLegacyPipelineHeaderApp(mediator);
+ }
createTestDetails();
createDagApp();
};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js
new file mode 100644
index 00000000000..27fe9ba3f19
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_header.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import pipelineHeader from './components/header_component.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export const createPipelineHeaderApp = elSelector => {
+ const el = document.querySelector(elSelector);
+
+ if (!el) {
+ return;
+ }
+
+ const { cancelPath, deletePath, fullPath, pipelineId, pipelineIid, retryPath } = el?.dataset;
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ pipelineHeader,
+ },
+ apolloProvider,
+ provide: {
+ paths: {
+ cancel: cancelPath,
+ delete: deletePath,
+ fullProject: fullPath,
+ retry: retryPath,
+ },
+ pipelineId,
+ pipelineIid,
+ },
+ render(createElement) {
+ return createElement('pipeline-header', {});
+ },
+ });
+};
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/pipelines/stores/test_reports/utils.js
index 8f1ac305cda..42406e5a67a 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/utils.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/utils.js
@@ -1,13 +1,19 @@
import { __, sprintf } from '../../../locale';
+import { TestStatus } from '../../constants';
export function iconForTestStatus(status) {
switch (status) {
- case 'success':
+ case TestStatus.SUCCESS:
return 'status_success_borderless';
- case 'failed':
+ case TestStatus.FAILED:
return 'status_failed_borderless';
- default:
+ case TestStatus.ERROR:
+ return 'status_warning_borderless';
+ case TestStatus.SKIPPED:
return 'status_skipped_borderless';
+ case TestStatus.UNKNOWN:
+ default:
+ return 'status_notfound_borderless';
}
}
diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue
index 4aaa2cff2ac..200e5ba255f 100644
--- a/app/assets/javascripts/profile/account/components/update_username.vue
+++ b/app/assets/javascripts/profile/account/components/update_username.vue
@@ -1,6 +1,7 @@
<script>
/* eslint-disable vue/no-v-html */
import { escape } from 'lodash';
+import { GlButton } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { s__, sprintf } from '~/locale';
@@ -9,6 +10,7 @@ import { deprecatedCreateFlash as Flash } from '~/flash';
export default {
components: {
GlModal: DeprecatedModal2,
+ GlButton,
},
props: {
actionUrl: {
@@ -100,15 +102,15 @@ Please update your Git repository remotes as soon as possible.`),
</div>
<p class="form-text text-muted">{{ path }}</p>
</div>
- <button
+ <gl-button
:data-target="`#${$options.modalId}`"
:disabled="isRequestPending || newUsername === username"
- class="btn btn-warning"
- type="button"
+ category="primary"
+ variant="warning"
data-toggle="modal"
>
{{ $options.buttonText }}
- </button>
+ </gl-button>
<gl-modal
:id="$options.modalId"
:header-title-text="s__('Profiles|Change username') + '?'"
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index 6822fa8f7c7..4755a4aa9ba 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import axios from '~/lib/utils/axios_utils';
+import { Rails } from '~/lib/utils/rails_ujs';
import { deprecatedCreateFlash as flash } from '../flash';
import { parseBoolean } from '~/lib/utils/common_utils';
import TimezoneDropdown, {
@@ -48,9 +49,13 @@ export default class Profile {
}
submitForm() {
- return $(this)
- .parents('form')
- .submit();
+ const $form = $(this).parents('form');
+
+ if ($form.data('remote')) {
+ Rails.fire($form[0], 'submit');
+ } else {
+ $form.submit();
+ }
}
onSubmitForm(e) {
diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js
index 70fce4a4d09..5403c67aa8e 100644
--- a/app/assets/javascripts/project_find_file.js
+++ b/app/assets/javascripts/project_find_file.js
@@ -2,7 +2,7 @@
import $ from 'jquery';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
-import { sanitize } from 'dompurify';
+import { sanitize } from '~/lib/dompurify';
import axios from '~/lib/utils/axios_utils';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
import { deprecatedCreateFlash as flash } from '~/flash';
diff --git a/app/assets/javascripts/projects/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js
new file mode 100644
index 00000000000..352ac39f3c4
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/index.js
@@ -0,0 +1,18 @@
+import { loadBranches } from './load_branches';
+import { fetchCommitMergeRequests } from '~/commit_merge_requests';
+import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
+
+export const initCommitBoxInfo = (containerSelector = '.js-commit-box-info') => {
+ const containerEl = document.querySelector(containerSelector);
+
+ // Display commit related branches
+ loadBranches(containerEl);
+
+ // Related merge requests to this commit
+ fetchCommitMergeRequests();
+
+ // Display pipeline info for this commit
+ new MiniPipelineGraph({
+ container: '.js-commit-pipeline-graph',
+ }).bindEvents();
+};
diff --git a/app/assets/javascripts/projects/commit_box/info/load_branches.js b/app/assets/javascripts/projects/commit_box/info/load_branches.js
new file mode 100644
index 00000000000..0efa1998507
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/load_branches.js
@@ -0,0 +1,20 @@
+import axios from 'axios';
+import { sanitize } from '~/lib/dompurify';
+import { __ } from '~/locale';
+
+export const loadBranches = containerEl => {
+ if (!containerEl) {
+ return;
+ }
+
+ const { commitPath } = containerEl.dataset;
+ const branchesEl = containerEl.querySelector('.commit-info.branches');
+ axios
+ .get(commitPath)
+ .then(({ data }) => {
+ branchesEl.innerHTML = sanitize(data);
+ })
+ .catch(() => {
+ branchesEl.textContent = __('Failed to load branches. Please try again.');
+ });
+};
diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js
index 2d321ead33e..a6019e9c01b 100644
--- a/app/assets/javascripts/projects/default_project_templates.js
+++ b/app/assets/javascripts/projects/default_project_templates.js
@@ -57,6 +57,10 @@ export default {
text: s__('ProjectTemplates|Static Site Editor/Middleman'),
icon: '.template-option .icon-sse_middleman',
},
+ gitpod_spring_petclinic: {
+ text: s__('ProjectTemplates|Gitpod/Spring Petclinic'),
+ icon: '.template-option .icon-gitpod_spring_petclinic',
+ },
nfhugo: {
text: s__('ProjectTemplates|Netlify/Hugo'),
icon: '.template-option .icon-nfhugo',
diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js
index 5d51b7ea57b..c189e617105 100644
--- a/app/assets/javascripts/projects/settings/access_dropdown.js
+++ b/app/assets/javascripts/projects/settings/access_dropdown.js
@@ -2,8 +2,8 @@
import { escape, find, countBy } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as Flash } from '~/flash';
-import { n__, s__, __ } from '~/locale';
-import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVEL_NONE } from './constants';
+import { n__, s__, __, sprintf } from '~/locale';
+import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
export default class AccessDropdown {
@@ -11,6 +11,7 @@ export default class AccessDropdown {
const { $dropdown, accessLevel, accessLevelsData, hasLicense = true } = options;
this.options = options;
this.hasLicense = hasLicense;
+ this.deployKeysOnProtectedBranchesEnabled = gon.features.deployKeysOnProtectedBranches;
this.groups = [];
this.accessLevel = accessLevel;
this.accessLevelsData = accessLevelsData.roles;
@@ -18,6 +19,7 @@ export default class AccessDropdown {
this.$wrap = this.$dropdown.closest(`.${this.accessLevel}-container`);
this.usersPath = '/-/autocomplete/users.json';
this.groupsPath = '/-/autocomplete/project_groups.json';
+ this.deployKeysPath = '/-/autocomplete/deploy_keys_with_owners.json';
this.defaultLabel = this.$dropdown.data('defaultLabel');
this.setSelectedItems([]);
@@ -146,6 +148,8 @@ export default class AccessDropdown {
obj.access_level = item.access_level;
} else if (item.type === LEVEL_TYPES.USER) {
obj.user_id = item.user_id;
+ } else if (item.type === LEVEL_TYPES.DEPLOY_KEY) {
+ obj.deploy_key_id = item.deploy_key_id;
} else if (item.type === LEVEL_TYPES.GROUP) {
obj.group_id = item.group_id;
}
@@ -177,6 +181,9 @@ export default class AccessDropdown {
case LEVEL_TYPES.GROUP:
comparator = LEVEL_ID_PROP.GROUP;
break;
+ case LEVEL_TYPES.DEPLOY_KEY:
+ comparator = LEVEL_ID_PROP.DEPLOY_KEY;
+ break;
case LEVEL_TYPES.USER:
comparator = LEVEL_ID_PROP.USER;
break;
@@ -218,6 +225,11 @@ export default class AccessDropdown {
group_id: selectedItem.id,
type: LEVEL_TYPES.GROUP,
};
+ } else if (selectedItem.type === LEVEL_TYPES.DEPLOY_KEY) {
+ itemToAdd = {
+ deploy_key_id: selectedItem.id,
+ type: LEVEL_TYPES.DEPLOY_KEY,
+ };
}
this.items.push(itemToAdd);
@@ -233,11 +245,12 @@ export default class AccessDropdown {
return true;
}
- if (item.type === LEVEL_TYPES.USER && item.user_id === itemToDelete.id) {
- index = i;
- } else if (item.type === LEVEL_TYPES.ROLE && item.access_level === itemToDelete.id) {
- index = i;
- } else if (item.type === LEVEL_TYPES.GROUP && item.group_id === itemToDelete.id) {
+ if (
+ (item.type === LEVEL_TYPES.USER && item.user_id === itemToDelete.id) ||
+ (item.type === LEVEL_TYPES.ROLE && item.access_level === itemToDelete.id) ||
+ (item.type === LEVEL_TYPES.DEPLOY_KEY && item.deploy_key_id === itemToDelete.id) ||
+ (item.type === LEVEL_TYPES.GROUP && item.group_id === itemToDelete.id)
+ ) {
index = i;
}
@@ -289,6 +302,10 @@ export default class AccessDropdown {
labelPieces.push(n__('1 user', '%d users', counts[LEVEL_TYPES.USER]));
}
+ if (counts[LEVEL_TYPES.DEPLOY_KEY] > 0) {
+ labelPieces.push(n__('1 deploy key', '%d deploy keys', counts[LEVEL_TYPES.DEPLOY_KEY]));
+ }
+
if (counts[LEVEL_TYPES.GROUP] > 0) {
labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP]));
}
@@ -299,20 +316,31 @@ export default class AccessDropdown {
getData(query, callback) {
if (this.hasLicense) {
Promise.all([
+ this.getDeployKeys(query),
this.getUsers(query),
this.groupsData ? Promise.resolve(this.groupsData) : this.getGroups(),
])
- .then(([usersResponse, groupsResponse]) => {
+ .then(([deployKeysResponse, usersResponse, groupsResponse]) => {
this.groupsData = groupsResponse;
- callback(this.consolidateData(usersResponse.data, groupsResponse.data));
+ callback(
+ this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data),
+ );
})
- .catch(() => Flash(__('Failed to load groups & users.')));
+ .catch(() => {
+ if (this.deployKeysOnProtectedBranchesEnabled) {
+ Flash(__('Failed to load groups, users and deploy keys.'));
+ } else {
+ Flash(__('Failed to load groups & users.'));
+ }
+ });
} else {
- callback(this.consolidateData());
+ this.getDeployKeys(query)
+ .then(deployKeysResponse => callback(this.consolidateData(deployKeysResponse.data)))
+ .catch(() => Flash(__('Failed to load deploy keys.')));
}
}
- consolidateData(usersResponse = [], groupsResponse = []) {
+ consolidateData(deployKeysResponse, usersResponse = [], groupsResponse = []) {
let consolidatedData = [];
// ID property is handled differently locally from the server
@@ -328,6 +356,10 @@ export default class AccessDropdown {
// For Users
// In dropdown: `id`
// For submit: `user_id`
+ //
+ // For Deploy Keys
+ // In dropdown: `id`
+ // For submit: `deploy_key_id`
/*
* Build roles
@@ -410,6 +442,38 @@ export default class AccessDropdown {
}
}
+ if (this.deployKeysOnProtectedBranchesEnabled) {
+ const deployKeys = deployKeysResponse.map(response => {
+ const {
+ id,
+ fingerprint,
+ title,
+ owner: { avatar_url, name, username },
+ } = response;
+
+ const shortFingerprint = `(${fingerprint.substring(0, 14)}...)`;
+
+ return {
+ id,
+ title: title.concat(' ', shortFingerprint),
+ avatar_url,
+ fullname: name,
+ username,
+ type: LEVEL_TYPES.DEPLOY_KEY,
+ };
+ });
+
+ if (this.accessLevel === ACCESS_LEVELS.PUSH) {
+ if (deployKeys.length) {
+ consolidatedData = consolidatedData.concat(
+ [{ type: 'divider' }],
+ [{ type: 'header', content: s__('AccessDropdown|Deploy Keys') }],
+ deployKeys,
+ );
+ }
+ }
+ }
+
return consolidatedData;
}
@@ -433,6 +497,22 @@ export default class AccessDropdown {
});
}
+ getDeployKeys(query) {
+ if (this.deployKeysOnProtectedBranchesEnabled) {
+ return axios.get(this.buildUrl(gon.relative_url_root, this.deployKeysPath), {
+ params: {
+ search: query,
+ per_page: 20,
+ active: true,
+ project_id: gon.current_project_id,
+ push_code: true,
+ },
+ });
+ }
+
+ return Promise.resolve({ data: [] });
+ }
+
buildUrl(urlRoot, url) {
let newUrl;
if (urlRoot != null) {
@@ -454,6 +534,9 @@ export default class AccessDropdown {
case LEVEL_TYPES.ROLE:
criteria = { access_level: item.id };
break;
+ case LEVEL_TYPES.DEPLOY_KEY:
+ criteria = { deploy_key_id: item.id };
+ break;
case LEVEL_TYPES.GROUP:
criteria = { group_id: item.id };
break;
@@ -470,6 +553,10 @@ export default class AccessDropdown {
case LEVEL_TYPES.ROLE:
groupRowEl = this.roleRowHtml(item, isActive);
break;
+ case LEVEL_TYPES.DEPLOY_KEY:
+ groupRowEl =
+ this.accessLevel === ACCESS_LEVELS.PUSH ? this.deployKeyRowHtml(item, isActive) : '';
+ break;
case LEVEL_TYPES.GROUP:
groupRowEl = this.groupRowHtml(item, isActive);
break;
@@ -495,6 +582,31 @@ export default class AccessDropdown {
`;
}
+ deployKeyRowHtml(key, isActive) {
+ const isActiveClass = isActive || '';
+
+ return `
+ <li>
+ <a href="#" class="${isActiveClass}">
+ <strong>${key.title}</strong>
+ <p>
+ ${sprintf(
+ __('Owned by %{image_tag}'),
+ {
+ image_tag: `<img src="${key.avatar_url}" class="avatar avatar-inline s26" width="30">`,
+ },
+ false,
+ )}
+ <strong class="dropdown-menu-user-full-name gl-display-inline">${escape(
+ key.fullname,
+ )}</strong>
+ <span class="dropdown-menu-user-username gl-display-inline">${key.username}</span>
+ </p>
+ </a>
+ </li>
+ `;
+ }
+
groupRowHtml(group, isActive) {
const isActiveClass = isActive || '';
const avatarEl = group.avatar_url
diff --git a/app/assets/javascripts/projects/settings/constants.js b/app/assets/javascripts/projects/settings/constants.js
index fadb1f4f178..f5591c43dc4 100644
--- a/app/assets/javascripts/projects/settings/constants.js
+++ b/app/assets/javascripts/projects/settings/constants.js
@@ -1,13 +1,20 @@
export const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
+ DEPLOY_KEY: 'deploy_key',
GROUP: 'group',
};
export const LEVEL_ID_PROP = {
ROLE: 'access_level',
USER: 'user_id',
+ DEPLOY_KEY: 'deploy_key_id',
GROUP: 'group_id',
};
+export const ACCESS_LEVELS = {
+ MERGE: 'merge_access_levels',
+ PUSH: 'push_access_levels',
+};
+
export const ACCESS_LEVEL_NONE = 0;
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
index 81367f7d6b4..4bfed6d489d 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
@@ -1,6 +1,6 @@
<script>
import { GlAlert } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
import ServiceDeskSetting from './service_desk_setting.vue';
import ServiceDeskService from '../services/service_desk_service';
import eventHub from '../event_hub';
@@ -122,11 +122,13 @@ export default {
this.incomingEmail = data?.service_desk_address;
this.showAlert(__('Changes were successfully made.'), 'success');
})
- .catch(() =>
+ .catch(err => {
this.showAlert(
- __('An error occurred while saving the template. Please check if the template exists.'),
- ),
- )
+ sprintf(__('An error occured while making the changes: %{error}'), {
+ error: err?.response?.data?.message,
+ }),
+ );
+ })
.finally(() => {
this.isTemplateSaving = false;
});
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
index 6a0810ad3a1..089cac9ee4c 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlFormSelect, GlToggle, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlFormSelect, GlToggle, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -17,6 +17,7 @@ export default {
GlFormSelect,
GlToggle,
GlLoadingIcon,
+ GlSprintf,
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -60,6 +61,7 @@ export default {
selectedTemplate: this.initialSelectedTemplate,
outgoingName: this.initialOutgoingName || __('GitLab Support Bot'),
projectKey: this.initialProjectKey,
+ baseEmail: this.incomingEmail.replace(this.initialProjectKey, ''),
};
},
computed: {
@@ -108,7 +110,7 @@ export default {
<input
ref="service-desk-incoming-email"
type="text"
- class="form-control incoming-email h-auto"
+ class="form-control incoming-email"
:placeholder="__('Incoming email')"
:aria-label="__('Incoming email')"
aria-describedby="incoming-email-describer"
@@ -119,16 +121,37 @@ export default {
<clipboard-button
:title="__('Copy')"
:text="incomingEmail"
- css-class="btn qa-clipboard-button"
+ css-class="input-group-text qa-clipboard-button"
/>
</div>
</div>
+ <span v-if="projectKey" class="form-text text-muted">
+ <gl-sprintf :message="__('Emails sent to %{email} will still be supported')">
+ <template #email>
+ <code>{{ baseEmail }}</code>
+ </template>
+ </gl-sprintf>
+ </span>
</template>
<template v-else>
<gl-loading-icon :inline="true" />
<span class="sr-only">{{ __('Fetching incoming email') }}</span>
</template>
+ <template v-if="hasProjectKeySupport">
+ <label for="service-desk-project-suffix" class="mt-3">
+ {{ __('Project name suffix') }}
+ </label>
+ <input id="service-desk-project-suffix" v-model.trim="projectKey" class="form-control" />
+ <span class="form-text text-muted">
+ {{
+ __(
+ 'Project name suffix is a user-defined string which will be appended to the project path, and will form the Service Desk email address.',
+ )
+ }}
+ </span>
+ </template>
+
<label for="service-desk-template-select" class="mt-3">
{{ __('Template to append to all Service Desk issues') }}
</label>
@@ -144,19 +167,6 @@ export default {
<span class="form-text text-muted">
{{ __('Emails sent from Service Desk will have this name') }}
</span>
- <template v-if="hasProjectKeySupport">
- <label for="service-desk-project-suffix" class="mt-3">
- {{ __('Project name suffix') }}
- </label>
- <input id="service-desk-project-suffix" v-model.trim="projectKey" class="form-control" />
- <span class="form-text text-muted mb-3">
- {{
- __(
- 'Project name suffix is a user-defined string which will be appended to the project path, and will form the Service Desk email address.',
- )
- }}
- </span>
- </template>
<div class="gl-display-flex gl-justify-content-end">
<gl-button
variant="success"
diff --git a/app/assets/javascripts/protected_branches/constants.js b/app/assets/javascripts/protected_branches/constants.js
index a17ae6811b7..ae5eaa8e622 100644
--- a/app/assets/javascripts/protected_branches/constants.js
+++ b/app/assets/javascripts/protected_branches/constants.js
@@ -7,12 +7,14 @@ export const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
GROUP: 'group',
+ DEPLOY_KEY: 'deploy_key',
};
export const LEVEL_ID_PROP = {
ROLE: 'access_level',
USER: 'user_id',
GROUP: 'group_id',
+ DEPLOY_KEY: 'deploy_key_id',
};
export const ACCESS_LEVEL_NONE = 0;
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index 5ccffe9700e..19f6666fd52 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -108,6 +108,10 @@ export default class ProtectedBranchCreate {
levelAttributes.push({
group_id: item.group_id,
});
+ } else if (item.type === LEVEL_TYPES.DEPLOY_KEY) {
+ levelAttributes.push({
+ deploy_key_id: item.deploy_key_id,
+ });
}
});
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue b/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue
new file mode 100644
index 00000000000..d13d815a59e
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui';
+
+import { DELETE_ALERT_TITLE, DELETE_ALERT_LINK_TEXT } from '../../constants/index';
+
+export default {
+ components: {
+ GlSprintf,
+ GlAlert,
+ GlLink,
+ },
+ props: {
+ runCleanupPoliciesHelpPagePath: { type: String, required: false, default: '' },
+ cleanupPoliciesHelpPagePath: { type: String, required: false, default: '' },
+ },
+ i18n: {
+ DELETE_ALERT_TITLE,
+ DELETE_ALERT_LINK_TEXT,
+ },
+};
+</script>
+
+<template>
+ <gl-alert variant="warning" :title="$options.i18n.DELETE_ALERT_TITLE" @dismiss="$emit('dismiss')">
+ <gl-sprintf :message="$options.i18n.DELETE_ALERT_LINK_TEXT">
+ <template #adminLink="{content}">
+ <gl-link data-testid="run-link" :href="runCleanupPoliciesHelpPagePath" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ <template #docLink="{content}">
+ <gl-link data-testid="help-link" :href="cleanupPoliciesHelpPagePath" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
index 661213733ac..8d48430560e 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
+++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
@@ -197,7 +197,8 @@ export default {
v-if="tag.digest"
:title="tag.digest"
:text="tag.digest"
- css-class="btn-default btn-transparent btn-clipboard gl-p-0"
+ category="tertiary"
+ size="small"
/>
</details-row>
</template>
@@ -212,7 +213,8 @@ export default {
v-if="formattedRevision"
:title="formattedRevision"
:text="formattedRevision"
- css-class="btn-default btn-transparent btn-clipboard gl-p-0"
+ category="tertiary"
+ size="small"
/>
</details-row>
</template>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
index 32bf27f1143..29ce9150c89 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
@@ -42,6 +42,7 @@ export default {
name: this.item.path,
tags_path: this.item.tags_path,
id: this.item.id,
+ cleanup_policy_started_at: this.item.cleanup_policy_started_at,
});
return window.btoa(params);
},
@@ -82,7 +83,7 @@ export default {
:disabled="item.deleting"
:text="item.location"
:title="item.location"
- css-class="btn-default btn-transparent btn-clipboard gl-text-gray-300"
+ category="tertiary"
/>
<gl-icon
v-if="item.failedDelete"
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue
index 7be68e77def..228a660c997 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue
@@ -1,5 +1,4 @@
<script>
-import { GlSprintf, GlLink } from '@gitlab/ui';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import { n__, sprintf } from '~/locale';
@@ -15,8 +14,6 @@ import {
export default {
components: {
- GlSprintf,
- GlLink,
TitleArea,
MetadataItem,
},
@@ -54,8 +51,6 @@ export default {
},
i18n: {
CONTAINER_REGISTRY_TITLE,
- LIST_INTRO_TEXT,
- EXPIRATION_POLICY_DISABLED_MESSAGE,
},
computed: {
imagesCountText() {
@@ -83,52 +78,40 @@ export default {
!this.expirationPolicyEnabled && this.imagesCount > 0 && !this.hideExpirationPolicyData
);
},
+ infoMessages() {
+ const base = [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }];
+ return this.showExpirationPolicyTip
+ ? [
+ ...base,
+ { text: EXPIRATION_POLICY_DISABLED_MESSAGE, link: this.expirationPolicyHelpPagePath },
+ ]
+ : base;
+ },
},
};
</script>
<template>
- <div>
- <title-area :title="$options.i18n.CONTAINER_REGISTRY_TITLE">
- <template #right-actions>
- <slot name="commands"></slot>
- </template>
- <template #metadata_count>
- <metadata-item
- v-if="imagesCount"
- data-testid="images-count"
- icon="container-image"
- :text="imagesCountText"
- />
- </template>
- <template #metadata_exp_policies>
- <metadata-item
- v-if="!hideExpirationPolicyData"
- data-testid="expiration-policy"
- icon="expire"
- :text="expirationPolicyText"
- size="xl"
- />
- </template>
- </title-area>
-
- <div data-testid="info-area">
- <p>
- <span data-testid="default-intro">
- <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT">
- <template #docLink="{content}">
- <gl-link :href="helpPagePath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </span>
- <span v-if="showExpirationPolicyTip" data-testid="expiration-disabled-message">
- <gl-sprintf :message="$options.i18n.EXPIRATION_POLICY_DISABLED_MESSAGE">
- <template #docLink="{content}">
- <gl-link :href="expirationPolicyHelpPagePath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </span>
- </p>
- </div>
- </div>
+ <title-area :title="$options.i18n.CONTAINER_REGISTRY_TITLE" :info-messages="infoMessages">
+ <template #right-actions>
+ <slot name="commands"></slot>
+ </template>
+ <template #metadata_count>
+ <metadata-item
+ v-if="imagesCount"
+ data-testid="images-count"
+ icon="container-image"
+ :text="imagesCountText"
+ />
+ </template>
+ <template #metadata_exp_policies>
+ <metadata-item
+ v-if="!hideExpirationPolicyData"
+ data-testid="expiration-policy"
+ icon="expire"
+ :text="expirationPolicyText"
+ size="xl"
+ />
+ </template>
+ </title-area>
</template>
diff --git a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js
index 8af25ca6ecc..5f73834d995 100644
--- a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js
+++ b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js
@@ -9,3 +9,7 @@ export const EXPIRATION_POLICY_DISABLED_TEXT = s__(
export const EXPIRATION_POLICY_DISABLED_MESSAGE = s__(
'ContainerRegistry|Expiration policies help manage the storage space used by the Container Registry, but the expiration policies for this registry are disabled. Contact your administrator to enable. %{docLinkStart}More information%{docLinkEnd}',
);
+export const DELETE_ALERT_TITLE = s__('ContainerRegistry|Some tags were not deleted');
+export const DELETE_ALERT_LINK_TEXT = s__(
+ 'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}',
+);
diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue
index b697bca6259..d2fb695dbfa 100644
--- a/app/assets/javascripts/registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/registry/explorer/pages/details.vue
@@ -4,6 +4,7 @@ import { GlPagination, GlResizeObserverDirective } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import Tracking from '~/tracking';
import DeleteAlert from '../components/details_page/delete_alert.vue';
+import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue';
import DeleteModal from '../components/details_page/delete_modal.vue';
import DetailsHeader from '../components/details_page/details_header.vue';
import TagsList from '../components/details_page/tags_list.vue';
@@ -21,6 +22,7 @@ import {
export default {
components: {
DeleteAlert,
+ PartialCleanupAlert,
DetailsHeader,
GlPagination,
DeleteModal,
@@ -37,13 +39,16 @@ export default {
itemsToBeDeleted: [],
isDesktop: true,
deleteAlertType: null,
+ dismissPartialCleanupWarning: false,
};
},
computed: {
...mapState(['tagsPagination', 'isLoading', 'config', 'tags']),
- imageName() {
- const { name } = decodeAndParse(this.$route.params.id);
- return name;
+ queryParameters() {
+ return decodeAndParse(this.$route.params.id);
+ },
+ showPartialCleanupWarning() {
+ return this.queryParameters.cleanup_policy_started_at && !this.dismissPartialCleanupWarning;
},
tracking() {
return {
@@ -120,7 +125,14 @@ export default {
class="gl-my-2"
/>
- <details-header :image-name="imageName" />
+ <partial-cleanup-alert
+ v-if="showPartialCleanupWarning"
+ :run-cleanup-policies-help-page-path="config.runCleanupPoliciesHelpPagePath"
+ :cleanup-policies-help-page-path="config.cleanupPoliciesHelpPagePath"
+ @dismiss="dismissPartialCleanupWarning = true"
+ />
+
+ <details-header :image-name="queryParameters.name" />
<tags-loader v-if="isLoading" />
<template v-else>
diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
index 2ee7bbef4c6..fcb86fd18f0 100644
--- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
+++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
@@ -1,7 +1,7 @@
<script>
-import { mapActions, mapGetters, mapState } from 'vuex';
import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
-
+import { isEqual } from 'lodash';
+import expirationPolicyQuery from '../graphql/queries/get_expiration_policy.graphql';
import { FETCH_SETTINGS_ERROR_MESSAGE } from '../../shared/constants';
import SettingsForm from './settings_form.vue';
@@ -19,21 +19,39 @@ export default {
GlSprintf,
GlLink,
},
+ inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries'],
i18n: {
UNAVAILABLE_FEATURE_TITLE,
UNAVAILABLE_FEATURE_INTRO_TEXT,
FETCH_SETTINGS_ERROR_MESSAGE,
},
+ apollo: {
+ containerExpirationPolicy: {
+ query: expirationPolicyQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update: data => data.project?.containerExpirationPolicy,
+ result({ data }) {
+ this.workingCopy = { ...data.project?.containerExpirationPolicy };
+ },
+ error(e) {
+ this.fetchSettingsError = e;
+ },
+ },
+ },
data() {
return {
fetchSettingsError: false,
+ containerExpirationPolicy: null,
+ workingCopy: {},
};
},
computed: {
- ...mapState(['isAdmin', 'adminSettingsPath']),
- ...mapGetters({ isDisabled: 'getIsDisabled' }),
- showSettingForm() {
- return !this.isDisabled && !this.fetchSettingsError;
+ isDisabled() {
+ return !(this.containerExpirationPolicy || this.enableHistoricEntries);
},
showDisabledFormMessage() {
return this.isDisabled && !this.fetchSettingsError;
@@ -41,21 +59,27 @@ export default {
unavailableFeatureMessage() {
return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT;
},
- },
- mounted() {
- this.fetchSettings().catch(() => {
- this.fetchSettingsError = true;
- });
+ isEdited() {
+ return !isEqual(this.containerExpirationPolicy, this.workingCopy);
+ },
},
methods: {
- ...mapActions(['fetchSettings']),
+ restoreOriginal() {
+ this.workingCopy = { ...this.containerExpirationPolicy };
+ },
},
};
</script>
<template>
<div>
- <settings-form v-if="showSettingForm" />
+ <settings-form
+ v-if="containerExpirationPolicy"
+ v-model="workingCopy"
+ :is-loading="$apollo.queries.containerExpirationPolicy.loading"
+ :is-edited="isEdited"
+ @reset="restoreOriginal"
+ />
<template v-else>
<gl-alert
v-if="showDisabledFormMessage"
diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue
index 7a26fb5cbee..7deb1f92686 100644
--- a/app/assets/javascripts/registry/settings/components/settings_form.vue
+++ b/app/assets/javascripts/registry/settings/components/settings_form.vue
@@ -1,28 +1,45 @@
<script>
-import { get } from 'lodash';
-import { mapActions, mapState, mapGetters } from 'vuex';
-import { GlCard, GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { GlCard, GlButton } from '@gitlab/ui';
import Tracking from '~/tracking';
-import { mapComputed } from '~/vuex_shared/bindings';
import {
UPDATE_SETTINGS_ERROR_MESSAGE,
UPDATE_SETTINGS_SUCCESS_MESSAGE,
} from '../../shared/constants';
import ExpirationPolicyFields from '../../shared/components/expiration_policy_fields.vue';
import { SET_CLEANUP_POLICY_BUTTON, CLEANUP_POLICY_CARD_HEADER } from '../constants';
+import { formOptionsGenerator } from '~/registry/shared/utils';
+import updateContainerExpirationPolicyMutation from '../graphql/mutations/update_container_expiration_policy.graphql';
+import { updateContainerExpirationPolicy } from '../graphql/utils/cache_update';
export default {
components: {
GlCard,
GlButton,
- GlLoadingIcon,
ExpirationPolicyFields,
},
mixins: [Tracking.mixin()],
+ inject: ['projectPath'],
+ props: {
+ value: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isEdited: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
labelsConfig: {
cols: 3,
align: 'right',
},
+ formOptions: formOptionsGenerator(),
i18n: {
CLEANUP_POLICY_CARD_HEADER,
SET_CLEANUP_POLICY_BUTTON,
@@ -34,49 +51,74 @@ export default {
},
fieldsAreValid: true,
apiErrors: null,
+ mutationLoading: false,
};
},
computed: {
- ...mapState(['formOptions', 'isLoading']),
- ...mapGetters({ isEdited: 'getIsEdited' }),
- ...mapComputed([{ key: 'settings', getter: 'getSettings' }], 'updateSettings'),
+ showLoadingIcon() {
+ return this.isLoading || this.mutationLoading;
+ },
isSubmitButtonDisabled() {
- return !this.fieldsAreValid || this.isLoading;
+ return !this.fieldsAreValid || this.showLoadingIcon;
},
isCancelButtonDisabled() {
- return !this.isEdited || this.isLoading;
+ return !this.isEdited || this.isLoading || this.mutationLoading;
+ },
+ mutationVariables() {
+ return {
+ projectPath: this.projectPath,
+ enabled: this.value.enabled,
+ cadence: this.value.cadence,
+ olderThan: this.value.olderThan,
+ keepN: this.value.keepN,
+ nameRegex: this.value.nameRegex,
+ nameRegexKeep: this.value.nameRegexKeep,
+ };
},
},
methods: {
- ...mapActions(['resetSettings', 'saveSettings']),
reset() {
this.track('reset_form');
this.apiErrors = null;
- this.resetSettings();
+ this.$emit('reset');
},
setApiErrors(response) {
- const messages = get(response, 'data.message', []);
-
- this.apiErrors = Object.keys(messages).reduce((acc, curr) => {
- if (curr.startsWith('container_expiration_policy.')) {
- const key = curr.replace('container_expiration_policy.', '');
- acc[key] = get(messages, [curr, 0], '');
- }
+ this.apiErrors = response.graphQLErrors.reduce((acc, curr) => {
+ curr.extensions.problems.forEach(item => {
+ acc[item.path[0]] = item.message;
+ });
return acc;
}, {});
},
submit() {
this.track('submit_form');
this.apiErrors = null;
- this.saveSettings()
- .then(() => this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' }))
- .catch(({ response }) => {
- this.setApiErrors(response);
+ this.mutationLoading = true;
+ return this.$apollo
+ .mutate({
+ mutation: updateContainerExpirationPolicyMutation,
+ variables: {
+ input: this.mutationVariables,
+ },
+ update: updateContainerExpirationPolicy(this.projectPath),
+ })
+ .then(({ data }) => {
+ const errorMessage = data?.updateContainerExpirationPolicy?.errors[0];
+ if (errorMessage) {
+ this.$toast.show(errorMessage, { type: 'error' });
+ }
+ this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' });
+ })
+ .catch(error => {
+ this.setApiErrors(error);
this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' });
+ })
+ .finally(() => {
+ this.mutationLoading = false;
});
},
onModelChange(changePayload) {
- this.settings = changePayload.newValue;
+ this.$emit('input', changePayload.newValue);
if (this.apiErrors) {
this.apiErrors[changePayload.modified] = undefined;
}
@@ -93,8 +135,8 @@ export default {
</template>
<template #default>
<expiration-policy-fields
- :value="settings"
- :form-options="formOptions"
+ :value="value"
+ :form-options="$options.formOptions"
:is-loading="isLoading"
:api-errors="apiErrors"
@validated="fieldsAreValid = true"
@@ -103,27 +145,25 @@ export default {
/>
</template>
<template #footer>
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button
- ref="cancel-button"
- type="reset"
- class="gl-mr-3 gl-display-block"
- :disabled="isCancelButtonDisabled"
- >
- {{ __('Cancel') }}
- </gl-button>
- <gl-button
- ref="save-button"
- type="submit"
- :disabled="isSubmitButtonDisabled"
- variant="success"
- category="primary"
- class="gl-display-flex gl-justify-content-center gl-align-items-center js-no-auto-disable"
- >
- {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }}
- <gl-loading-icon v-if="isLoading" class="gl-ml-3" />
- </gl-button>
- </div>
+ <gl-button
+ ref="cancel-button"
+ type="reset"
+ class="gl-mr-3 gl-display-block float-right"
+ :disabled="isCancelButtonDisabled"
+ >
+ {{ __('Cancel') }}
+ </gl-button>
+ <gl-button
+ ref="save-button"
+ type="submit"
+ :disabled="isSubmitButtonDisabled"
+ :loading="showLoadingIcon"
+ variant="success"
+ category="primary"
+ class="js-no-auto-disable"
+ >
+ {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }}
+ </gl-button>
</template>
</gl-card>
</form>
diff --git a/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql
new file mode 100644
index 00000000000..224e0ed9472
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql
@@ -0,0 +1,8 @@
+fragment ContainerExpirationPolicyFields on ContainerExpirationPolicy {
+ cadence
+ enabled
+ keepN
+ nameRegex
+ nameRegexKeep
+ olderThan
+}
diff --git a/app/assets/javascripts/registry/settings/graphql/index.js b/app/assets/javascripts/registry/settings/graphql/index.js
new file mode 100644
index 00000000000..16152eb81f6
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/graphql/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
+Vue.use(VueApollo);
+
+export const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ ),
+});
diff --git a/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql
new file mode 100644
index 00000000000..c40cd115ab0
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/container_expiration_policy.fragment.graphql"
+
+mutation updateContainerExpirationPolicy($input: UpdateContainerExpirationPolicyInput!) {
+ updateContainerExpirationPolicy(input: $input) {
+ containerExpirationPolicy {
+ ...ContainerExpirationPolicyFields
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql
new file mode 100644
index 00000000000..c171be0ad07
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql
@@ -0,0 +1,9 @@
+#import "../fragments/container_expiration_policy.fragment.graphql"
+
+query getProjectExpirationPolicy($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ containerExpirationPolicy {
+ ...ContainerExpirationPolicyFields
+ }
+ }
+}
diff --git a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js
new file mode 100644
index 00000000000..88067d52b51
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js
@@ -0,0 +1,22 @@
+import { produce } from 'immer';
+import expirationPolicyQuery from '../queries/get_expiration_policy.graphql';
+
+export const updateContainerExpirationPolicy = projectPath => (client, { data: updatedData }) => {
+ const queryAndParams = {
+ query: expirationPolicyQuery,
+ variables: { projectPath },
+ };
+ const sourceData = client.readQuery(queryAndParams);
+
+ const data = produce(sourceData, draftState => {
+ // eslint-disable-next-line no-param-reassign
+ draftState.project.containerExpirationPolicy = {
+ ...updatedData.updateContainerExpirationPolicy.containerExpirationPolicy,
+ };
+ });
+
+ client.writeQuery({
+ ...queryAndParams,
+ data,
+ });
+};
diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
index a318aa2a694..5f25d508e2f 100644
--- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js
+++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
@@ -1,8 +1,8 @@
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
import Translate from '~/vue_shared/translate';
-import store from './store';
import RegistrySettingsApp from './components/registry_settings_app.vue';
+import { apolloProvider } from './graphql/index';
Vue.use(GlToast);
Vue.use(Translate);
@@ -12,13 +12,19 @@ export default () => {
if (!el) {
return null;
}
- store.dispatch('setInitialState', el.dataset);
+ const { projectPath, isAdmin, adminSettingsPath, enableHistoricEntries } = el.dataset;
return new Vue({
el,
- store,
+ apolloProvider,
components: {
RegistrySettingsApp,
},
+ provide: {
+ projectPath,
+ isAdmin,
+ adminSettingsPath,
+ enableHistoricEntries,
+ },
render(createElement) {
return createElement('registry-settings-app', {});
},
diff --git a/app/assets/javascripts/registry/settings/store/actions.js b/app/assets/javascripts/registry/settings/store/actions.js
deleted file mode 100644
index 0530a870ecc..00000000000
--- a/app/assets/javascripts/registry/settings/store/actions.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import Api from '~/api';
-import * as types from './mutation_types';
-
-export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
-export const updateSettings = ({ commit }, data) => commit(types.UPDATE_SETTINGS, data);
-export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING);
-export const receiveSettingsSuccess = ({ commit }, data) => {
- commit(types.SET_SETTINGS, data);
-};
-export const resetSettings = ({ commit }) => commit(types.RESET_SETTINGS);
-
-export const fetchSettings = ({ dispatch, state }) => {
- dispatch('toggleLoading');
- return Api.project(state.projectId)
- .then(({ data: { container_expiration_policy } }) =>
- dispatch('receiveSettingsSuccess', container_expiration_policy),
- )
- .finally(() => dispatch('toggleLoading'));
-};
-
-export const saveSettings = ({ dispatch, state }) => {
- dispatch('toggleLoading');
- return Api.updateProject(state.projectId, {
- container_expiration_policy_attributes: state.settings,
- })
- .then(({ data: { container_expiration_policy } }) =>
- dispatch('receiveSettingsSuccess', container_expiration_policy),
- )
- .finally(() => dispatch('toggleLoading'));
-};
diff --git a/app/assets/javascripts/registry/settings/store/getters.js b/app/assets/javascripts/registry/settings/store/getters.js
deleted file mode 100644
index ac1a931d8e0..00000000000
--- a/app/assets/javascripts/registry/settings/store/getters.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { isEqual } from 'lodash';
-import { findDefaultOption } from '../../shared/utils';
-
-export const getCadence = state =>
- state.settings.cadence || findDefaultOption(state.formOptions.cadence);
-
-export const getKeepN = state =>
- state.settings.keep_n || findDefaultOption(state.formOptions.keepN);
-
-export const getOlderThan = state =>
- state.settings.older_than || findDefaultOption(state.formOptions.olderThan);
-
-export const getSettings = (state, getters) => ({
- enabled: state.settings.enabled,
- cadence: getters.getCadence,
- older_than: getters.getOlderThan,
- keep_n: getters.getKeepN,
- name_regex: state.settings.name_regex,
- name_regex_keep: state.settings.name_regex_keep,
-});
-
-export const getIsEdited = state => !isEqual(state.original, state.settings);
-
-export const getIsDisabled = state => {
- return !(state.original || state.enableHistoricEntries);
-};
diff --git a/app/assets/javascripts/registry/settings/store/index.js b/app/assets/javascripts/registry/settings/store/index.js
deleted file mode 100644
index c2500454d8e..00000000000
--- a/app/assets/javascripts/registry/settings/store/index.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import * as actions from './actions';
-import mutations from './mutations';
-import * as getters from './getters';
-import state from './state';
-
-Vue.use(Vuex);
-
-export const createStore = () =>
- new Vuex.Store({
- state,
- actions,
- mutations,
- getters,
- });
-
-export default createStore();
diff --git a/app/assets/javascripts/registry/settings/store/mutation_types.js b/app/assets/javascripts/registry/settings/store/mutation_types.js
deleted file mode 100644
index db499ffa761..00000000000
--- a/app/assets/javascripts/registry/settings/store/mutation_types.js
+++ /dev/null
@@ -1,5 +0,0 @@
-export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
-export const UPDATE_SETTINGS = 'UPDATE_SETTINGS';
-export const TOGGLE_LOADING = 'TOGGLE_LOADING';
-export const SET_SETTINGS = 'SET_SETTINGS';
-export const RESET_SETTINGS = 'RESET_SETTINGS';
diff --git a/app/assets/javascripts/registry/settings/store/mutations.js b/app/assets/javascripts/registry/settings/store/mutations.js
deleted file mode 100644
index 3ba13419b98..00000000000
--- a/app/assets/javascripts/registry/settings/store/mutations.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import { parseBoolean } from '~/lib/utils/common_utils';
-import * as types from './mutation_types';
-
-export default {
- [types.SET_INITIAL_STATE](state, initialState) {
- state.projectId = initialState.projectId;
- state.formOptions = {
- cadence: JSON.parse(initialState.cadenceOptions),
- keepN: JSON.parse(initialState.keepNOptions),
- olderThan: JSON.parse(initialState.olderThanOptions),
- };
- state.enableHistoricEntries = parseBoolean(initialState.enableHistoricEntries);
- state.isAdmin = parseBoolean(initialState.isAdmin);
- state.adminSettingsPath = initialState.adminSettingsPath;
- },
- [types.UPDATE_SETTINGS](state, data) {
- state.settings = { ...state.settings, ...data.settings };
- },
- [types.SET_SETTINGS](state, settings) {
- state.settings = settings ?? state.settings;
- state.original = Object.freeze(settings);
- },
- [types.RESET_SETTINGS](state) {
- state.settings = { ...state.original };
- },
- [types.TOGGLE_LOADING](state) {
- state.isLoading = !state.isLoading;
- },
-};
diff --git a/app/assets/javascripts/registry/settings/store/state.js b/app/assets/javascripts/registry/settings/store/state.js
deleted file mode 100644
index fccc0991c1c..00000000000
--- a/app/assets/javascripts/registry/settings/store/state.js
+++ /dev/null
@@ -1,42 +0,0 @@
-export default () => ({
- /*
- * Project Id used to build the API call
- */
- projectId: '',
- /*
- * Boolean to determine if the UI is loading data from the API
- */
- isLoading: false,
- /*
- * Boolean to determine if the user is an admin
- */
- isAdmin: false,
- /*
- * String containing the full path to the admin config page for CI/CD
- */
- adminSettingsPath: '',
- /*
- * Boolean to determine if project created before 12.8 can use this feature
- */
- enableHistoricEntries: false,
- /*
- * This contains the data shown and manipulated in the UI
- * Has the following structure:
- * {
- * enabled: Boolean
- * cadence: String,
- * older_than: String,
- * keep_n: String,
- * name_regex: String
- * }
- */
- settings: {},
- /*
- * Same structure as settings, above but Frozen object and used only in case the user clicks 'cancel', initialized to null
- */
- original: null,
- /*
- * Contains the options used to populate the form selects
- */
- formOptions: {},
-});
diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
index 1ff2f6f99e5..2b8e9f6ff64 100644
--- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
+++ b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
@@ -68,34 +68,31 @@ export default {
{
name: 'expiration-policy-interval',
label: EXPIRATION_INTERVAL_LABEL,
- model: 'older_than',
- optionKey: 'olderThan',
+ model: 'olderThan',
},
{
name: 'expiration-policy-schedule',
label: EXPIRATION_SCHEDULE_LABEL,
model: 'cadence',
- optionKey: 'cadence',
},
{
name: 'expiration-policy-latest',
label: KEEP_N_LABEL,
- model: 'keep_n',
- optionKey: 'keepN',
+ model: 'keepN',
},
],
textAreaList: [
{
name: 'expiration-policy-name-matching',
label: NAME_REGEX_LABEL,
- model: 'name_regex',
+ model: 'nameRegex',
placeholder: NAME_REGEX_PLACEHOLDER,
description: NAME_REGEX_DESCRIPTION,
},
{
name: 'expiration-policy-keep-name',
label: NAME_REGEX_KEEP_LABEL,
- model: 'name_regex_keep',
+ model: 'nameRegexKeep',
placeholder: NAME_REGEX_KEEP_PLACEHOLDER,
description: NAME_REGEX_KEEP_DESCRIPTION,
},
@@ -107,17 +104,16 @@ export default {
},
computed: {
...mapComputedToEvent(
- ['enabled', 'cadence', 'older_than', 'keep_n', 'name_regex', 'name_regex_keep'],
+ ['enabled', 'cadence', 'olderThan', 'keepN', 'nameRegex', 'nameRegexKeep'],
'value',
),
policyEnabledText() {
return this.enabled ? ENABLED_TEXT : DISABLED_TEXT;
},
textAreaValidation() {
- const nameRegexErrors =
- this.apiErrors?.name_regex || this.validateRegexLength(this.name_regex);
+ const nameRegexErrors = this.apiErrors?.nameRegex || this.validateRegexLength(this.nameRegex);
const nameKeepRegexErrors =
- this.apiErrors?.name_regex_keep || this.validateRegexLength(this.name_regex_keep);
+ this.apiErrors?.nameRegexKeep || this.validateRegexLength(this.nameRegexKeep);
return {
/*
@@ -127,11 +123,11 @@ export default {
* false: red border, error message
* So in this function we keep null if the are no message otherwise we 'invert' the error message
*/
- name_regex: {
+ nameRegex: {
state: nameRegexErrors === null ? null : !nameRegexErrors,
message: nameRegexErrors,
},
- name_regex_keep: {
+ nameRegexKeep: {
state: nameKeepRegexErrors === null ? null : !nameKeepRegexErrors,
message: nameKeepRegexErrors,
},
@@ -139,8 +135,8 @@ export default {
},
fieldsValidity() {
return (
- this.textAreaValidation.name_regex.state !== false &&
- this.textAreaValidation.name_regex_keep.state !== false
+ this.textAreaValidation.nameRegex.state !== false &&
+ this.textAreaValidation.nameRegexKeep.state !== false
);
},
isFormElementDisabled() {
@@ -216,11 +212,7 @@ export default {
:disabled="isFormElementDisabled"
@input="updateModel($event, select.model)"
>
- <option
- v-for="option in formOptions[select.optionKey]"
- :key="option.key"
- :value="option.key"
- >
+ <option v-for="option in formOptions[select.model]" :key="option.key" :value="option.key">
{{ option.label }}
</option>
</gl-form-select>
diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js
index 36d55c7610e..735d72972e6 100644
--- a/app/assets/javascripts/registry/shared/constants.js
+++ b/app/assets/javascripts/registry/shared/constants.js
@@ -43,3 +43,27 @@ export const NAME_REGEX_KEEP_PLACEHOLDER = '';
export const NAME_REGEX_KEEP_DESCRIPTION = s__(
'ContainerRegistry|Wildcards such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported',
);
+
+export const KEEP_N_OPTIONS = [
+ { variable: 1, key: 'ONE_TAG', default: false },
+ { variable: 5, key: 'FIVE_TAGS', default: false },
+ { variable: 10, key: 'TEN_TAGS', default: true },
+ { variable: 25, key: 'TWENTY_FIVE_TAGS', default: false },
+ { variable: 50, key: 'FIFTY_TAGS', default: false },
+ { variable: 100, key: 'ONE_HUNDRED_TAGS', default: false },
+];
+
+export const CADENCE_OPTIONS = [
+ { key: 'EVERY_DAY', label: __('Every day'), default: true },
+ { key: 'EVERY_WEEK', label: __('Every week'), default: false },
+ { key: 'EVERY_TWO_WEEKS', label: __('Every two weeks'), default: false },
+ { key: 'EVERY_MONTH', label: __('Every month'), default: false },
+ { key: 'EVERY_THREE_MONTHS', label: __('Every three months'), default: false },
+];
+
+export const OLDER_THAN_OPTIONS = [
+ { key: 'SEVEN_DAYS', variable: 7, default: false },
+ { key: 'FOURTEEN_DAYS', variable: 14, default: false },
+ { key: 'THIRTY_DAYS', variable: 30, default: false },
+ { key: 'NINETY_DAYS', variable: 90, default: true },
+];
diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/shared/utils.js
index a7377773842..bdf1ab9507d 100644
--- a/app/assets/javascripts/registry/shared/utils.js
+++ b/app/assets/javascripts/registry/shared/utils.js
@@ -1,3 +1,6 @@
+import { n__ } from '~/locale';
+import { KEEP_N_OPTIONS, CADENCE_OPTIONS, OLDER_THAN_OPTIONS } from './constants';
+
export const findDefaultOption = options => {
const item = options.find(o => o.default);
return item ? item.key : null;
@@ -17,3 +20,27 @@ export const mapComputedToEvent = (list, root) => {
});
return result;
};
+
+export const olderThanTranslationGenerator = variable =>
+ n__(
+ '%d day until tags are automatically removed',
+ '%d days until tags are automatically removed',
+ variable,
+ );
+
+export const keepNTranslationGenerator = variable =>
+ n__('%d tag per image name', '%d tags per image name', variable);
+
+export const optionLabelGenerator = (collection, translationFn) =>
+ collection.map(option => ({
+ ...option,
+ label: translationFn(option.variable),
+ }));
+
+export const formOptionsGenerator = () => {
+ return {
+ olderThan: optionLabelGenerator(OLDER_THAN_OPTIONS, olderThanTranslationGenerator),
+ cadence: CADENCE_OPTIONS,
+ keepN: optionLabelGenerator(KEEP_N_OPTIONS, keepNTranslationGenerator),
+ };
+};
diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
index 63d61989cba..6fbae95094a 100644
--- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue
+++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
@@ -195,7 +195,8 @@ export default {
:disabled="isSubmitButtonDisabled"
:loading="isSubmitting"
type="submit"
- class="js-add-issuable-form-add-button float-left qa-add-issue-button"
+ class="js-add-issuable-form-add-button float-left"
+ data-qa-selector="add_issue_button"
>
{{ __('Add') }}
</gl-button>
diff --git a/app/assets/javascripts/related_issues/components/issue_token.vue b/app/assets/javascripts/related_issues/components/issue_token.vue
index 31d0c7dbbb0..bbbdf2cdb49 100644
--- a/app/assets/javascripts/related_issues/components/issue_token.vue
+++ b/app/assets/javascripts/related_issues/components/issue_token.vue
@@ -90,6 +90,7 @@ export default {
:size="12"
:title="stateTitle"
:aria-label="state"
+ data-testid="referenceIcon"
/>
{{ displayReference }}
</component>
@@ -105,6 +106,7 @@ export default {
:title="removeButtonLabel"
:aria-label="removeButtonLabel"
:disabled="removeDisabled"
+ data-testid="removeBtn"
type="button"
class="js-issue-token-remove-button"
@click="onRemoveRequest"
diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
index 1931cfb2c00..9809b228308 100644
--- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue
+++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
@@ -219,7 +219,8 @@ export default {
:value="inputValue"
:placeholder="inputPlaceholder"
type="text"
- class="js-add-issuable-form-input add-issuable-form-input qa-add-issue-input"
+ class="js-add-issuable-form-input add-issuable-form-input"
+ data-qa-selector="add_issue_field"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index e1edf3d689d..3080dd0e424 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -1,8 +1,7 @@
<script>
-/* eslint-disable vue/no-v-html */
import { mapState, mapActions, mapGetters } from 'vuex';
-import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
+import { GlButton, GlFormInput, GlFormGroup, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { BACK_URL_PARAM } from '~/releases/constants';
import { getParameterByName } from '~/lib/utils/common_utils';
@@ -17,6 +16,7 @@ export default {
GlFormInput,
GlFormGroup,
GlButton,
+ GlSprintf,
MarkdownField,
AssetLinksForm,
MilestoneCombobox,
@@ -41,18 +41,6 @@ export default {
showForm() {
return Boolean(!this.isFetchingRelease && !this.fetchError && this.release);
},
- subtitleText() {
- return sprintf(
- __(
- 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}.',
- ),
- {
- codeStart: '<code>',
- codeEnd: '</code>',
- },
- false,
- );
- },
releaseTitle: {
get() {
return this.$store.state.detail.release.name;
@@ -127,7 +115,19 @@ export default {
</script>
<template>
<div class="d-flex flex-column">
- <p class="pt-3 js-subtitle-text" v-html="subtitleText"></p>
+ <p class="pt-3 js-subtitle-text">
+ <gl-sprintf
+ :message="
+ __(
+ 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}.',
+ )
+ "
+ >
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
<form v-if="showForm" class="js-quick-submit" @submit.prevent="submitForm">
<tag-field />
<gl-form-group>
@@ -150,7 +150,7 @@ export default {
/>
</div>
</gl-form-group>
- <gl-form-group>
+ <gl-form-group data-testid="release-notes">
<label for="release-notes">{{ __('Release notes') }}</label>
<div class="bordered-box pr-3 pl-3">
<markdown-field
@@ -158,6 +158,7 @@ export default {
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:add-spacing-classes="false"
+ :textarea-value="releaseNotes"
class="gl-mt-3 gl-mb-3"
>
<template #textarea>
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index b8cf6ce478f..422d8bf630d 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -1,29 +1,21 @@
<script>
import { mapState, mapActions } from 'vuex';
-import {
- GlDeprecatedSkeletonLoading as GlSkeletonLoading,
- GlEmptyState,
- GlLink,
- GlButton,
-} from '@gitlab/ui';
-import {
- getParameterByName,
- historyPushState,
- buildUrlWithCurrentLocation,
-} from '~/lib/utils/common_utils';
+import { GlEmptyState, GlLink, GlButton } from '@gitlab/ui';
+import { getParameterByName } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
-import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import ReleaseBlock from './release_block.vue';
+import ReleasesPagination from './releases_pagination.vue';
+import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
export default {
name: 'ReleasesApp',
components: {
- GlSkeletonLoading,
GlEmptyState,
- ReleaseBlock,
- TablePagination,
GlLink,
GlButton,
+ ReleaseBlock,
+ ReleasesPagination,
+ ReleaseSkeletonLoader,
},
computed: {
...mapState('list', [
@@ -33,7 +25,6 @@ export default {
'isLoading',
'releases',
'hasError',
- 'pageInfo',
]),
shouldRenderEmptyState() {
return !this.releases.length && !this.hasError && !this.isLoading;
@@ -48,15 +39,23 @@ export default {
},
},
created() {
- this.fetchReleases({
- page: getParameterByName('page'),
- });
+ this.fetchReleases();
+
+ window.addEventListener('popstate', this.fetchReleases);
},
methods: {
- ...mapActions('list', ['fetchReleases']),
- onChangePage(page) {
- historyPushState(buildUrlWithCurrentLocation(`?page=${page}`));
- this.fetchReleases({ page });
+ ...mapActions('list', {
+ fetchReleasesStoreAction: 'fetchReleases',
+ }),
+ fetchReleases() {
+ this.fetchReleasesStoreAction({
+ // these two parameters are only used in "GraphQL mode"
+ before: getParameterByName('before'),
+ after: getParameterByName('after'),
+
+ // this parameter is only used when in "REST mode"
+ page: getParameterByName('page'),
+ });
},
},
};
@@ -74,7 +73,7 @@ export default {
{{ __('New release') }}
</gl-button>
- <gl-skeleton-loading v-if="isLoading" class="js-loading" />
+ <release-skeleton-loader v-if="isLoading" class="js-loading" />
<gl-empty-state
v-else-if="shouldRenderEmptyState"
@@ -105,7 +104,7 @@ export default {
/>
</div>
- <table-pagination v-if="!isLoading" :change="onChangePage" :page-info="pageInfo" />
+ <releases-pagination v-if="!isLoading" />
</div>
</template>
<style>
diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue
index 8b89f0cf3fc..9ef38503c10 100644
--- a/app/assets/javascripts/releases/components/app_show.vue
+++ b/app/assets/javascripts/releases/components/app_show.vue
@@ -1,13 +1,13 @@
<script>
import { mapState, mapActions } from 'vuex';
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import ReleaseBlock from './release_block.vue';
+import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
export default {
name: 'ReleaseShowApp',
components: {
- GlSkeletonLoading,
ReleaseBlock,
+ ReleaseSkeletonLoader,
},
computed: {
...mapState('detail', ['isFetchingRelease', 'fetchError', 'release']),
@@ -22,7 +22,7 @@ export default {
</script>
<template>
<div class="gl-mt-3">
- <gl-skeleton-loading v-if="isFetchingRelease" />
+ <release-skeleton-loader v-if="isFetchingRelease" />
<release-block v-else-if="!fetchError" :release="release" />
</div>
diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue
index 3724162f6d5..6e6017637d4 100644
--- a/app/assets/javascripts/releases/components/evidence_block.vue
+++ b/app/assets/javascripts/releases/components/evidence_block.vue
@@ -83,11 +83,7 @@ export default {
<span class="js-expanded monospace gl-pl-2">{{ sha(index) }}</span>
</template>
</expand-button>
- <clipboard-button
- :title="__('Copy evidence SHA')"
- :text="sha(index)"
- css-class="btn-default btn-transparent btn-clipboard"
- />
+ <clipboard-button :title="__('Copy evidence SHA')" :text="sha(index)" category="tertiary" />
</div>
<div class="d-flex align-items-center text-muted">
diff --git a/app/assets/javascripts/releases/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue
index 8824cbefd7e..60d2b3adfc9 100644
--- a/app/assets/javascripts/releases/components/release_block_assets.vue
+++ b/app/assets/javascripts/releases/components/release_block_assets.vue
@@ -119,7 +119,7 @@ export default {
{{ section.title }}
</h5>
<ul :key="`section-body-${index}`" class="list-unstyled gl-m-0">
- <li v-for="link in section.links" :key="link.url">
+ <li v-for="link in section.links" :key="link.url" class="gl-display-flex">
<gl-link
:href="link.directAssetUrl || link.url"
class="gl-display-flex gl-align-items-center gl-line-height-24"
diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue
index 95292a26bce..87538244f1a 100644
--- a/app/assets/javascripts/releases/components/release_block_header.vue
+++ b/app/assets/javascripts/releases/components/release_block_header.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlLink, GlBadge, GlButton, GlIcon } from '@gitlab/ui';
+import { GlTooltipDirective, GlLink, GlBadge, GlButton } from '@gitlab/ui';
import { BACK_URL_PARAM } from '~/releases/constants';
import { setUrlParams } from '~/lib/utils/url_utility';
@@ -8,7 +8,6 @@ export default {
components: {
GlLink,
GlBadge,
- GlIcon,
GlButton,
},
directives: {
@@ -55,11 +54,10 @@ export default {
v-gl-tooltip
category="primary"
variant="default"
+ icon="pencil"
class="gl-mr-3 js-edit-button ml-2 pb-2"
:title="__('Edit this release')"
:href="editLink"
- >
- <gl-icon name="pencil" />
- </gl-button>
+ />
</div>
</template>
diff --git a/app/assets/javascripts/releases/components/release_skeleton_loader.vue b/app/assets/javascripts/releases/components/release_skeleton_loader.vue
new file mode 100644
index 00000000000..054620af636
--- /dev/null
+++ b/app/assets/javascripts/releases/components/release_skeleton_loader.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ name: 'ReleaseSkeletonLoader',
+ components: { GlSkeletonLoader },
+};
+</script>
+<template>
+ <gl-skeleton-loader :width="1248" :height="420">
+ <!-- Outside border -->
+ <path
+ d="M 4.5 0 C 2.0156486 0 0 2.0156486 0 4.5 L 0 415.5 C 0 417.98435 2.0156486 420 4.5 420 L 1243.5 420 C 1245.9844 420 1248 417.98435 1248 415.5 L 1248 4.5 C 1248 2.0156486 1245.9844 0 1243.5 0 L 4.5 0 z M 4.5 1 L 1243.5 1 C 1245.4476 1 1247 2.5523514 1247 4.5 L 1247 415.5 C 1247 417.44765 1245.4476 419 1243.5 419 L 4.5 419 C 2.5523514 419 1 417.44765 1 415.5 L 1 4.5 C 1 2.5523514 2.5523514 1 4.5 1 z "
+ />
+
+ <!-- Header bottom border -->
+ <rect x="0" y="63.5" width="1248" height="1" />
+
+ <!-- Release title -->
+ <rect x="16" y="20" width="293" height="24" />
+
+ <!-- Edit (pencil) button -->
+ <rect x="1207" y="16" rx="4" width="32" height="32" />
+
+ <!-- Asset link 1 -->
+ <rect x="40" y="121" rx="4" width="16" height="16" />
+ <rect x="60" y="125" width="116" height="8" />
+
+ <!-- Asset link 2 -->
+ <rect x="40" y="145" rx="4" width="16" height="16" />
+ <rect x="60" y="149" width="132" height="8" />
+
+ <!-- Asset link 3 -->
+ <rect x="40" y="169" rx="4" width="16" height="16" />
+ <rect x="60" y="173" width="140" height="8" />
+
+ <!-- Asset link 4 -->
+ <rect x="40" y="193" rx="4" width="16" height="16" />
+ <rect x="60" y="197" width="112" height="8" />
+
+ <!-- Release notes -->
+ <rect x="16" y="228" width="480" height="8" />
+ <rect x="16" y="252" width="560" height="8" />
+ <rect x="16" y="276" width="480" height="8" />
+ <rect x="16" y="300" width="560" height="8" />
+ <rect x="16" y="324" width="320" height="8" />
+
+ <!-- Footer top border -->
+ <rect x="0" y="373" width="1248" height="1" />
+ </gl-skeleton-loader>
+</template>
diff --git a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue b/app/assets/javascripts/releases/components/releases_pagination_graphql.vue
index a4fe407a5bd..cb6f1fa18a1 100644
--- a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue
+++ b/app/assets/javascripts/releases/components/releases_pagination_graphql.vue
@@ -13,14 +13,14 @@ export default {
},
},
methods: {
- ...mapActions('list', ['fetchReleasesGraphQl']),
+ ...mapActions('list', ['fetchReleases']),
onPrev(before) {
historyPushState(buildUrlWithCurrentLocation(`?before=${before}`));
- this.fetchReleasesGraphQl({ before });
+ this.fetchReleases({ before });
},
onNext(after) {
historyPushState(buildUrlWithCurrentLocation(`?after=${after}`));
- this.fetchReleasesGraphQl({ after });
+ this.fetchReleases({ after });
},
},
};
diff --git a/app/assets/javascripts/releases/components/releases_pagination_rest.vue b/app/assets/javascripts/releases/components/releases_pagination_rest.vue
index 992cc4cd469..334458a2302 100644
--- a/app/assets/javascripts/releases/components/releases_pagination_rest.vue
+++ b/app/assets/javascripts/releases/components/releases_pagination_rest.vue
@@ -7,18 +7,18 @@ export default {
name: 'ReleasesPaginationRest',
components: { TablePagination },
computed: {
- ...mapState('list', ['pageInfo']),
+ ...mapState('list', ['restPageInfo']),
},
methods: {
- ...mapActions('list', ['fetchReleasesRest']),
+ ...mapActions('list', ['fetchReleases']),
onChangePage(page) {
historyPushState(buildUrlWithCurrentLocation(`?page=${page}`));
- this.fetchReleasesRest({ page });
+ this.fetchReleases({ page });
},
},
};
</script>
<template>
- <table-pagination :change="onChangePage" :page-info="pageInfo" />
+ <table-pagination :change="onChangePage" :page-info="restPageInfo" />
</template>
diff --git a/app/assets/javascripts/releases/constants.js b/app/assets/javascripts/releases/constants.js
index 361cee70747..953e7b4189c 100644
--- a/app/assets/javascripts/releases/constants.js
+++ b/app/assets/javascripts/releases/constants.js
@@ -10,3 +10,5 @@ export const ASSET_LINK_TYPE = Object.freeze({
});
export const DEFAULT_ASSET_LINK_TYPE = ASSET_LINK_TYPE.OTHER;
+
+export const PAGE_SIZE = 20;
diff --git a/app/assets/javascripts/releases/queries/all_releases.query.graphql b/app/assets/javascripts/releases/queries/all_releases.query.graphql
index 7a99f32fdfa..e74b7769abe 100644
--- a/app/assets/javascripts/releases/queries/all_releases.query.graphql
+++ b/app/assets/javascripts/releases/queries/all_releases.query.graphql
@@ -1,7 +1,6 @@
-query allReleases($fullPath: ID!) {
+query allReleases($fullPath: ID!, $first: Int, $last: Int, $before: String, $after: String) {
project(fullPath: $fullPath) {
- releases(first: 20) {
- count
+ releases(first: $first, last: $last, before: $before, after: $after) {
nodes {
name
tagName
@@ -64,6 +63,12 @@ query allReleases($fullPath: ID!) {
}
}
}
+ pageInfo {
+ startCursor
+ hasPreviousPage
+ hasNextPage
+ endCursor
+ }
}
}
}
diff --git a/app/assets/javascripts/releases/stores/getters.js b/app/assets/javascripts/releases/stores/getters.js
new file mode 100644
index 00000000000..6a1da63289c
--- /dev/null
+++ b/app/assets/javascripts/releases/stores/getters.js
@@ -0,0 +1,11 @@
+/**
+ * @returns {Boolean} `true` if all the feature flags
+ * required to enable the GraphQL endpoint are enabled
+ */
+export const useGraphQLEndpoint = rootState => {
+ return Boolean(
+ rootState.featureFlags.graphqlReleaseData &&
+ rootState.featureFlags.graphqlReleasesPage &&
+ rootState.featureFlags.graphqlMilestoneStats,
+ );
+};
diff --git a/app/assets/javascripts/releases/stores/index.js b/app/assets/javascripts/releases/stores/index.js
index b2e93d789d7..cc8b586964f 100644
--- a/app/assets/javascripts/releases/stores/index.js
+++ b/app/assets/javascripts/releases/stores/index.js
@@ -1,7 +1,9 @@
import Vuex from 'vuex';
+import * as getters from './getters';
export default ({ modules, featureFlags }) =>
new Vuex.Store({
modules,
state: { featureFlags },
+ getters,
});
diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js
index 5b682a0ab0f..2f298faf37e 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js
@@ -45,6 +45,9 @@ export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_REL
export const updateReleaseMilestones = ({ commit }, milestones) =>
commit(types.UPDATE_RELEASE_MILESTONES, milestones);
+export const updateReleaseGroupMilestones = ({ commit }, groupMilestones) =>
+ commit(types.UPDATE_RELEASE_GROUP_MILESTONES, groupMilestones);
+
export const addEmptyAssetLink = ({ commit }) => {
commit(types.ADD_EMPTY_ASSET_LINK);
};
diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
index 7784e0cc741..1b2f5f33f02 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
@@ -9,6 +9,7 @@ export const UPDATE_CREATE_FROM = 'UPDATE_CREATE_FROM';
export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE';
export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES';
export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES';
+export const UPDATE_RELEASE_GROUP_MILESTONES = 'UPDATE_RELEASE_GROUP_MILESTONES';
export const REQUEST_SAVE_RELEASE = 'REQUEST_SAVE_RELEASE';
export const RECEIVE_SAVE_RELEASE_SUCCESS = 'RECEIVE_SAVE_RELEASE_SUCCESS';
diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutations.js b/app/assets/javascripts/releases/stores/modules/detail/mutations.js
index 750f496665d..58a1958c5e2 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/mutations.js
@@ -13,6 +13,7 @@ export default {
name: '',
description: '',
milestones: [],
+ groupMilestones: [],
assets: {
links: [],
},
@@ -51,6 +52,10 @@ export default {
state.release.milestones = milestones;
},
+ [types.UPDATE_RELEASE_GROUP_MILESTONES](state, groupMilestones) {
+ state.release.groupMilestones = groupMilestones;
+ },
+
[types.REQUEST_SAVE_RELEASE](state) {
state.isUpdatingRelease = true;
},
diff --git a/app/assets/javascripts/releases/stores/modules/list/actions.js b/app/assets/javascripts/releases/stores/modules/list/actions.js
index 945b093b983..a7bb6a3a1d0 100644
--- a/app/assets/javascripts/releases/stores/modules/list/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/list/actions.js
@@ -9,54 +9,89 @@ import {
} from '~/lib/utils/common_utils';
import allReleasesQuery from '~/releases/queries/all_releases.query.graphql';
import { gqClient, convertGraphQLResponse } from '../../../util';
+import { PAGE_SIZE } from '../../../constants';
/**
- * Commits a mutation to update the state while the main endpoint is being requested.
+ * Gets a paginated list of releases from the server
+ *
+ * @param {Object} vuexParams
+ * @param {Object} actionParams
+ * @param {Number} [actionParams.page] The page number of results to fetch
+ * (this parameter is only used when fetching results from the REST API)
+ * @param {String} [actionParams.before] A GraphQL cursor. If provided,
+ * the items returned will proceed the provided cursor (this parameter is only
+ * used when fetching results from the GraphQL API).
+ * @param {String} [actionParams.after] A GraphQL cursor. If provided,
+ * the items returned will follow the provided cursor (this parameter is only
+ * used when fetching results from the GraphQL API).
*/
-export const requestReleases = ({ commit }) => commit(types.REQUEST_RELEASES);
+export const fetchReleases = ({ dispatch, rootGetters }, { page = 1, before, after }) => {
+ if (rootGetters.useGraphQLEndpoint) {
+ dispatch('fetchReleasesGraphQl', { before, after });
+ } else {
+ dispatch('fetchReleasesRest', { page });
+ }
+};
/**
- * Fetches the main endpoint.
- * Will dispatch requestNamespace action before starting the request.
- * Will dispatch receiveNamespaceSuccess if the request is successful
- * Will dispatch receiveNamesapceError if the request returns an error
- *
- * @param {String} projectId
+ * Gets a paginated list of releases from the GraphQL endpoint
*/
-export const fetchReleases = ({ dispatch, rootState, state }, { page = '1' }) => {
- dispatch('requestReleases');
+export const fetchReleasesGraphQl = (
+ { dispatch, commit, state },
+ { before = null, after = null },
+) => {
+ commit(types.REQUEST_RELEASES);
- if (
- rootState.featureFlags.graphqlReleaseData &&
- rootState.featureFlags.graphqlReleasesPage &&
- rootState.featureFlags.graphqlMilestoneStats
- ) {
- gqClient
- .query({
- query: allReleasesQuery,
- variables: {
- fullPath: state.projectPath,
- },
- })
- .then(response => {
- dispatch('receiveReleasesSuccess', convertGraphQLResponse(response));
- })
- .catch(() => dispatch('receiveReleasesError'));
+ let paginationParams;
+ if (!before && !after) {
+ paginationParams = { first: PAGE_SIZE };
+ } else if (before && !after) {
+ paginationParams = { last: PAGE_SIZE, before };
+ } else if (!before && after) {
+ paginationParams = { first: PAGE_SIZE, after };
} else {
- api
- .releases(state.projectId, { page })
- .then(response => dispatch('receiveReleasesSuccess', response))
- .catch(() => dispatch('receiveReleasesError'));
+ throw new Error(
+ 'Both a `before` and an `after` parameter were provided to fetchReleasesGraphQl. These parameters cannot be used together.',
+ );
}
+
+ gqClient
+ .query({
+ query: allReleasesQuery,
+ variables: {
+ fullPath: state.projectPath,
+ ...paginationParams,
+ },
+ })
+ .then(response => {
+ const { data, paginationInfo: graphQlPageInfo } = convertGraphQLResponse(response);
+
+ commit(types.RECEIVE_RELEASES_SUCCESS, {
+ data,
+ graphQlPageInfo,
+ });
+ })
+ .catch(() => dispatch('receiveReleasesError'));
};
-export const receiveReleasesSuccess = ({ commit }, { data, headers }) => {
- const pageInfo = parseIntPagination(normalizeHeaders(headers));
- const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true });
- commit(types.RECEIVE_RELEASES_SUCCESS, {
- data: camelCasedReleases,
- pageInfo,
- });
+/**
+ * Gets a paginated list of releases from the REST endpoint
+ */
+export const fetchReleasesRest = ({ dispatch, commit, state }, { page }) => {
+ commit(types.REQUEST_RELEASES);
+
+ api
+ .releases(state.projectId, { page })
+ .then(({ data, headers }) => {
+ const restPageInfo = parseIntPagination(normalizeHeaders(headers));
+ const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true });
+
+ commit(types.RECEIVE_RELEASES_SUCCESS, {
+ data: camelCasedReleases,
+ restPageInfo,
+ });
+ })
+ .catch(() => dispatch('receiveReleasesError'));
};
export const receiveReleasesError = ({ commit }) => {
diff --git a/app/assets/javascripts/releases/stores/modules/list/mutations.js b/app/assets/javascripts/releases/stores/modules/list/mutations.js
index 99fc096264a..296487cfee2 100644
--- a/app/assets/javascripts/releases/stores/modules/list/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/list/mutations.js
@@ -17,11 +17,12 @@ export default {
* @param {Object} state
* @param {Object} resp
*/
- [types.RECEIVE_RELEASES_SUCCESS](state, { data, pageInfo }) {
+ [types.RECEIVE_RELEASES_SUCCESS](state, { data, restPageInfo, graphQlPageInfo }) {
state.hasError = false;
state.isLoading = false;
state.releases = data;
- state.pageInfo = pageInfo;
+ state.restPageInfo = restPageInfo;
+ state.graphQlPageInfo = graphQlPageInfo;
},
/**
@@ -35,5 +36,7 @@ export default {
state.isLoading = false;
state.releases = [];
state.hasError = true;
+ state.restPageInfo = {};
+ state.graphQlPageInfo = {};
},
};
diff --git a/app/assets/javascripts/releases/stores/modules/list/state.js b/app/assets/javascripts/releases/stores/modules/list/state.js
index 9fe313745fc..0bffaa0f9db 100644
--- a/app/assets/javascripts/releases/stores/modules/list/state.js
+++ b/app/assets/javascripts/releases/stores/modules/list/state.js
@@ -14,5 +14,6 @@ export default ({
isLoading: false,
hasError: false,
releases: [],
- pageInfo: {},
+ restPageInfo: {},
+ graphQlPageInfo: {},
});
diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js
index d7fac7a9b65..e890b4b008d 100644
--- a/app/assets/javascripts/releases/util.js
+++ b/app/assets/javascripts/releases/util.js
@@ -126,5 +126,9 @@ export const convertGraphQLResponse = response => {
...convertMilestones(r),
}));
- return { data: releases };
+ const paginationInfo = {
+ ...response.data.project.releases.pageInfo,
+ };
+
+ return { data: releases, paginationInfo };
};
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 59831890a4e..3e87833f7f5 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -1,6 +1,6 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlTooltipDirective, GlLink, GlDeprecatedButton, GlLoadingIcon, GlIcon } from '@gitlab/ui';
+import { GlTooltipDirective, GlLink, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui';
import defaultAvatarUrl from 'images/no_avatar.png';
import { sprintf, s__ } from '~/locale';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
@@ -13,13 +13,13 @@ import pathLastCommitQuery from '../queries/path_last_commit.query.graphql';
export default {
components: {
- GlIcon,
UserAvatarLink,
TimeagoTooltip,
ClipboardButton,
CiIcon,
+ GlButton,
+ GlButtonGroup,
GlLink,
- GlDeprecatedButton,
GlLoadingIcon,
},
directives: {
@@ -123,15 +123,14 @@ export default {
class="commit-row-message item-title"
v-html="commit.titleHtml"
/>
- <gl-deprecated-button
+ <gl-button
v-if="commit.descriptionHtml"
:class="{ open: showDescription }"
:aria-label="__('Show commit description')"
- class="text-expander"
+ class="text-expander gl-vertical-align-bottom!"
+ icon="ellipsis_h"
@click="toggleShowDescription"
- >
- <gl-icon name="ellipsis_h" :size="10" />
- </gl-deprecated-button>
+ />
<div class="committer">
<gl-link
v-if="commit.author"
@@ -169,16 +168,19 @@ export default {
/>
</gl-link>
</div>
- <div class="commit-sha-group d-flex">
- <div class="label label-monospace monospace">
- {{ showCommitId }}
- </div>
+ <gl-button-group class="gl-ml-4 js-commit-sha-group">
+ <gl-button
+ label
+ class="gl-font-monospace"
+ data-testid="last-commit-id-label"
+ v-text="showCommitId"
+ />
<clipboard-button
:text="commit.sha"
:title="__('Copy commit SHA')"
- tooltip-placement="bottom"
+ class="input-group-text"
/>
- </div>
+ </gl-button-group>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index 365b6cbb550..78b8baaa75e 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -75,6 +75,7 @@ export default {
},
methods: {
fetchFiles() {
+ const originalPath = this.path || '/';
this.isLoadingFiles = true;
return this.$apollo
@@ -83,14 +84,14 @@ export default {
variables: {
projectPath: this.projectPath,
ref: this.ref,
- path: this.path || '/',
+ path: originalPath,
nextPageCursor: this.nextPageCursor,
pageSize: this.pageSize,
},
})
.then(({ data }) => {
if (data.errors) throw data.errors;
- if (!data?.project?.repository) return;
+ if (!data?.project?.repository || originalPath !== (this.path || '/')) return;
const pageInfo = this.hasNextPage(data.project.repository.tree);
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 7f72524b6fe..65da8f70b40 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -113,9 +113,10 @@ export default function setupVueRepositoryList() {
const webIdeLinkEl = document.getElementById('js-tree-web-ide-link');
if (webIdeLinkEl) {
- const { ideBasePath, ...options } = convertObjectPropsToCamelCase(
- JSON.parse(webIdeLinkEl.dataset.options),
- );
+ const {
+ webIdeUrlData: { path: ideBasePath, isFork: webIdeIsFork },
+ ...options
+ } = convertObjectPropsToCamelCase(JSON.parse(webIdeLinkEl.dataset.options), { deep: true });
// eslint-disable-next-line no-new
new Vue({
@@ -127,6 +128,7 @@ export default function setupVueRepositoryList() {
webIdeUrl: webIDEUrl(
joinPaths('/', ideBasePath, 'edit', ref, '-', this.$route.params.path || '', '/'),
),
+ webIdeIsFork,
...options,
},
});
diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js
index 361e0b62bb7..fc8fa40a855 100644
--- a/app/assets/javascripts/repository/log_tree.js
+++ b/app/assets/javascripts/repository/log_tree.js
@@ -5,8 +5,8 @@ import commitsQuery from './queries/commits.query.graphql';
import projectPathQuery from './queries/project_path.query.graphql';
import refQuery from './queries/ref.query.graphql';
-let fetchpromise;
-let resolvers = [];
+const fetchpromises = {};
+const resolvers = {};
export function resolveCommit(commits, path, { resolve, entry }) {
const commit = commits.find(c => c.filePath === `${path}/${entry.name}` && c.type === entry.type);
@@ -18,15 +18,19 @@ export function resolveCommit(commits, path, { resolve, entry }) {
export function fetchLogsTree(client, path, offset, resolver = null) {
if (resolver) {
- resolvers.push(resolver);
+ if (!resolvers[path]) {
+ resolvers[path] = [resolver];
+ } else {
+ resolvers[path].push(resolver);
+ }
}
- if (fetchpromise) return fetchpromise;
+ if (fetchpromises[path]) return fetchpromises[path];
const { projectPath } = client.readQuery({ query: projectPathQuery });
const { escapedRef } = client.readQuery({ query: refQuery });
- fetchpromise = axios
+ fetchpromises[path] = axios
.get(
`${gon.relative_url_root}/${projectPath}/-/refs/${escapedRef}/logs_tree/${encodeURIComponent(
path.replace(/^\//, ''),
@@ -46,16 +50,16 @@ export function fetchLogsTree(client, path, offset, resolver = null) {
data,
});
- resolvers.forEach(r => resolveCommit(data.commits, path, r));
+ resolvers[path].forEach(r => resolveCommit(data.commits, path, r));
- fetchpromise = null;
+ delete fetchpromises[path];
if (headerLogsOffset) {
fetchLogsTree(client, path, headerLogsOffset);
} else {
- resolvers = [];
+ delete resolvers[path];
}
});
- return fetchpromise;
+ return fetchpromises[path];
}
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 8bebd16ace7..87c8aa541d8 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -5,6 +5,7 @@ import Cookies from 'js-cookie';
import { deprecatedCreateFlash as flash } from './flash';
import axios from './lib/utils/axios_utils';
import { sprintf, s__, __ } from './locale';
+import { fixTitle, hide } from '~/tooltips';
function Sidebar() {
this.toggleTodo = this.toggleTodo.bind(this);
@@ -42,13 +43,17 @@ Sidebar.prototype.addEventListeners = function() {
Sidebar.prototype.sidebarToggleClicked = function(e, triggered) {
const $this = $(this);
- const isExpanded = $this.find('i').hasClass('fa-angle-double-right');
+ const $collapseIcon = $('.js-sidebar-collapse');
+ const $expandIcon = $('.js-sidebar-expand');
+ const $toggleContainer = $('.js-sidebar-toggle-container');
+ const isExpanded = $toggleContainer.data('is-expanded');
const tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar');
- const $allGutterToggleIcons = $('.js-sidebar-toggle i');
e.preventDefault();
if (isExpanded) {
- $allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left');
+ $toggleContainer.data('is-expanded', false);
+ $collapseIcon.addClass('hidden');
+ $expandIcon.removeClass('hidden');
$('aside.right-sidebar')
.removeClass('right-sidebar-expanded')
.addClass('right-sidebar-collapsed');
@@ -56,7 +61,9 @@ Sidebar.prototype.sidebarToggleClicked = function(e, triggered) {
.removeClass('right-sidebar-expanded')
.addClass('right-sidebar-collapsed');
} else {
- $allGutterToggleIcons.removeClass('fa-angle-double-left').addClass('fa-angle-double-right');
+ $toggleContainer.data('is-expanded', true);
+ $expandIcon.addClass('hidden');
+ $collapseIcon.removeClass('hidden');
$('aside.right-sidebar')
.removeClass('right-sidebar-collapsed')
.addClass('right-sidebar-expanded');
@@ -77,7 +84,7 @@ Sidebar.prototype.toggleTodo = function(e) {
const ajaxType = $this.data('deletePath') ? 'delete' : 'post';
const url = String($this.data('deletePath') || $this.data('createPath'));
- $this.tooltip('hide');
+ hide($this);
$('.js-issuable-todo')
.disable()
@@ -119,7 +126,7 @@ Sidebar.prototype.todoUpdateDone = function(data) {
.data('deletePath', deletePath);
if ($el.hasClass('has-tooltip')) {
- $el.tooltip('_fixTitle');
+ fixTitle($el);
}
if (typeof $el.data('isCollapsed') !== 'undefined') {
diff --git a/app/assets/javascripts/search/components/dropdown_filter.vue b/app/assets/javascripts/search/components/dropdown_filter.vue
new file mode 100644
index 00000000000..cd9237026f2
--- /dev/null
+++ b/app/assets/javascripts/search/components/dropdown_filter.vue
@@ -0,0 +1,111 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
+import { setUrlParams, visitUrl } from '~/lib/utils/url_utility';
+import { sprintf, s__ } from '~/locale';
+
+export default {
+ name: 'DropdownFilter',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ },
+ props: {
+ initialFilter: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ filters: {
+ type: Object,
+ required: true,
+ },
+ filtersArray: {
+ type: Array,
+ required: true,
+ },
+ header: {
+ type: String,
+ required: true,
+ },
+ param: {
+ type: String,
+ required: true,
+ },
+ scope: {
+ type: String,
+ required: true,
+ },
+ supportedScopes: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ filter() {
+ return this.initialFilter || this.filters.ANY.value;
+ },
+ selectedFilterText() {
+ const f = this.filtersArray.find(({ value }) => value === this.selectedFilter);
+ if (!f || f === this.filters.ANY) {
+ return sprintf(s__('Any %{header}'), { header: this.header });
+ }
+
+ return f.label;
+ },
+ showDropdown() {
+ return this.supportedScopes.includes(this.scope);
+ },
+ selectedFilter: {
+ get() {
+ if (this.filtersArray.some(({ value }) => value === this.filter)) {
+ return this.filter;
+ }
+
+ return this.filters.ANY.value;
+ },
+ set(filter) {
+ visitUrl(setUrlParams({ [this.param]: filter }));
+ },
+ },
+ },
+ methods: {
+ dropDownItemClass(filter) {
+ return {
+ 'gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2':
+ filter === this.filters.ANY,
+ };
+ },
+ isFilterSelected(filter) {
+ return filter === this.selectedFilter;
+ },
+ handleFilterChange(filter) {
+ this.selectedFilter = filter;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ v-if="showDropdown"
+ :text="selectedFilterText"
+ class="col-3 gl-pt-4 gl-pl-0 gl-pr-0 gl-mr-4"
+ menu-class="gl-w-full! gl-pl-0"
+ >
+ <header class="gl-text-center gl-font-weight-bold gl-font-lg">
+ {{ header }}
+ </header>
+ <gl-dropdown-divider />
+ <gl-dropdown-item
+ v-for="f in filtersArray"
+ :key="f.value"
+ :is-check-item="true"
+ :is-checked="isFilterSelected(f.value)"
+ :class="dropDownItemClass(f)"
+ @click="handleFilterChange(f.value)"
+ >
+ {{ f.label }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/search/confidential_filter/constants.js b/app/assets/javascripts/search/confidential_filter/constants.js
new file mode 100644
index 00000000000..4665ce6a5d1
--- /dev/null
+++ b/app/assets/javascripts/search/confidential_filter/constants.js
@@ -0,0 +1,28 @@
+import { __ } from '~/locale';
+
+export const FILTER_HEADER = __('Confidentiality');
+
+export const FILTER_STATES = {
+ ANY: {
+ label: __('Any'),
+ value: null,
+ },
+ CONFIDENTIAL: {
+ label: __('Confidential'),
+ value: 'yes',
+ },
+ NOT_CONFIDENTIAL: {
+ label: __('Not confidential'),
+ value: 'no',
+ },
+};
+
+export const SCOPES = {
+ ISSUES: 'issues',
+};
+
+export const FILTER_STATES_BY_SCOPE = {
+ [SCOPES.ISSUES]: [FILTER_STATES.ANY, FILTER_STATES.CONFIDENTIAL, FILTER_STATES.NOT_CONFIDENTIAL],
+};
+
+export const FILTER_PARAM = 'confidential';
diff --git a/app/assets/javascripts/search/confidential_filter/index.js b/app/assets/javascripts/search/confidential_filter/index.js
new file mode 100644
index 00000000000..bec772be0dd
--- /dev/null
+++ b/app/assets/javascripts/search/confidential_filter/index.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import DropdownFilter from '../components/dropdown_filter.vue';
+import {
+ FILTER_HEADER,
+ FILTER_PARAM,
+ FILTER_STATES_BY_SCOPE,
+ FILTER_STATES,
+ SCOPES,
+} from './constants';
+
+Vue.use(Translate);
+
+export default () => {
+ const el = document.getElementById('js-search-filter-by-confidential');
+
+ if (!el) return false;
+
+ return new Vue({
+ el,
+ data() {
+ return { ...el.dataset };
+ },
+
+ render(createElement) {
+ return createElement(DropdownFilter, {
+ props: {
+ initialFilter: this.filter,
+ filtersArray: FILTER_STATES_BY_SCOPE[this.scope],
+ filters: FILTER_STATES,
+ header: FILTER_HEADER,
+ param: FILTER_PARAM,
+ scope: this.scope,
+ supportedScopes: Object.values(SCOPES),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/search/state_filter/components/state_filter.vue b/app/assets/javascripts/search/state_filter/components/state_filter.vue
deleted file mode 100644
index f08adaf8c83..00000000000
--- a/app/assets/javascripts/search/state_filter/components/state_filter.vue
+++ /dev/null
@@ -1,94 +0,0 @@
-<script>
-import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
-import {
- FILTER_STATES,
- SCOPES,
- FILTER_STATES_BY_SCOPE,
- FILTER_HEADER,
- FILTER_TEXT,
-} from '../constants';
-import { setUrlParams, visitUrl } from '~/lib/utils/url_utility';
-
-const FILTERS_ARRAY = Object.values(FILTER_STATES);
-
-export default {
- name: 'StateFilter',
- components: {
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
- },
- props: {
- scope: {
- type: String,
- required: true,
- },
- state: {
- type: String,
- required: false,
- default: FILTER_STATES.ANY.value,
- validator: v => FILTERS_ARRAY.some(({ value }) => value === v),
- },
- },
- computed: {
- selectedFilterText() {
- const filter = FILTERS_ARRAY.find(({ value }) => value === this.selectedFilter);
- if (!filter || filter === FILTER_STATES.ANY) {
- return FILTER_TEXT;
- }
-
- return filter.label;
- },
- showDropdown() {
- return Object.values(SCOPES).includes(this.scope);
- },
- selectedFilter: {
- get() {
- if (FILTERS_ARRAY.some(({ value }) => value === this.state)) {
- return this.state;
- }
-
- return FILTER_STATES.ANY.value;
- },
- set(state) {
- visitUrl(setUrlParams({ state }));
- },
- },
- },
- methods: {
- dropDownItemClass(filter) {
- return {
- 'gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2':
- filter === FILTER_STATES.ANY,
- };
- },
- isFilterSelected(filter) {
- return filter === this.selectedFilter;
- },
- handleFilterChange(state) {
- this.selectedFilter = state;
- },
- },
- filterStates: FILTER_STATES,
- filterHeader: FILTER_HEADER,
- filtersByScope: FILTER_STATES_BY_SCOPE,
-};
-</script>
-
-<template>
- <gl-dropdown v-if="showDropdown" :text="selectedFilterText" class="col-sm-3 gl-pt-4 gl-pl-0">
- <header class="gl-text-center gl-font-weight-bold gl-font-lg">
- {{ $options.filterHeader }}
- </header>
- <gl-dropdown-divider />
- <gl-dropdown-item
- v-for="filter in $options.filtersByScope[scope]"
- :key="filter.value"
- :is-check-item="true"
- :is-checked="isFilterSelected(filter.value)"
- :class="dropDownItemClass(filter)"
- @click="handleFilterChange(filter.value)"
- >{{ filter.label }}</gl-dropdown-item
- >
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/search/state_filter/constants.js b/app/assets/javascripts/search/state_filter/constants.js
index 2f11cab9044..00ae1bd9750 100644
--- a/app/assets/javascripts/search/state_filter/constants.js
+++ b/app/assets/javascripts/search/state_filter/constants.js
@@ -2,8 +2,6 @@ import { __ } from '~/locale';
export const FILTER_HEADER = __('Status');
-export const FILTER_TEXT = __('Any Status');
-
export const FILTER_STATES = {
ANY: {
label: __('Any'),
@@ -37,3 +35,5 @@ export const FILTER_STATES_BY_SCOPE = {
FILTER_STATES.CLOSED,
],
};
+
+export const FILTER_PARAM = 'state';
diff --git a/app/assets/javascripts/search/state_filter/index.js b/app/assets/javascripts/search/state_filter/index.js
index 13708574cfb..2c12885c40b 100644
--- a/app/assets/javascripts/search/state_filter/index.js
+++ b/app/assets/javascripts/search/state_filter/index.js
@@ -1,6 +1,13 @@
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
-import StateFilter from './components/state_filter.vue';
+import DropdownFilter from '../components/dropdown_filter.vue';
+import {
+ FILTER_HEADER,
+ FILTER_PARAM,
+ FILTER_STATES_BY_SCOPE,
+ FILTER_STATES,
+ SCOPES,
+} from './constants';
Vue.use(Translate);
@@ -11,22 +18,20 @@ export default () => {
return new Vue({
el,
- components: {
- StateFilter,
- },
data() {
- const { dataset } = this.$options.el;
- return {
- scope: dataset.scope,
- state: dataset.state,
- };
+ return { ...el.dataset };
},
render(createElement) {
- return createElement('state-filter', {
+ return createElement(DropdownFilter, {
props: {
+ initialFilter: this.filter,
+ filtersArray: FILTER_STATES_BY_SCOPE[this.scope],
+ filters: FILTER_STATES,
+ header: FILTER_HEADER,
+ param: FILTER_PARAM,
scope: this.scope,
- state: this.state,
+ supportedScopes: Object.values(SCOPES),
},
});
},
diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
index 1ccf5e9e032..6776a9ebb22 100644
--- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
+++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
@@ -1,7 +1,7 @@
<script>
/* eslint-disable vue/no-v-html */
import Vue from 'vue';
-import { GlFormGroup, GlDeprecatedButton, GlModal, GlToast, GlToggle } from '@gitlab/ui';
+import { GlFormGroup, GlButton, GlModal, GlToast, GlToggle } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { __, s__, sprintf } from '~/locale';
import { visitUrl, getBaseURL } from '~/lib/utils/url_utility';
@@ -11,7 +11,7 @@ Vue.use(GlToast);
export default {
components: {
GlFormGroup,
- GlDeprecatedButton,
+ GlButton,
GlModal,
GlToggle,
},
@@ -123,7 +123,7 @@ export default {
<h4 class="js-section-header">
{{ s__('SelfMonitoring|Self monitoring') }}
</h4>
- <gl-deprecated-button class="js-settings-toggle">{{ __('Expand') }}</gl-deprecated-button>
+ <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
<p class="js-section-sub-header">
{{ s__('SelfMonitoring|Enable or disable instance self monitoring') }}
</p>
@@ -146,6 +146,7 @@ export default {
:ok-title="__('Delete project')"
:cancel-title="__('Cancel')"
ok-variant="danger"
+ category="primary"
@ok="deleteProject"
@cancel="hideSelfMonitorModal"
>
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
index e15549f5864..d662cc7b802 100644
--- a/app/assets/javascripts/serverless/components/functions.vue
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -1,7 +1,6 @@
<script>
-/* eslint-disable vue/no-v-html */
import { mapState, mapActions, mapGetters } from 'vuex';
-import { GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { GlLink, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import EnvironmentRow from './environment_row.vue';
import EmptyState from './empty_state.vue';
@@ -14,6 +13,9 @@ export default {
GlLink,
GlLoadingIcon,
},
+ directives: {
+ SafeHtml,
+ },
computed: {
...mapState(['installed', 'isLoading', 'hasFunctionData', 'helpPath', 'statusPath']),
...mapGetters(['getFunctions']),
@@ -92,9 +94,9 @@ export default {
}}
</p>
<ul>
- <li v-html="noServerlessConfigFile"></li>
- <li v-html="noGitlabYamlConfigured"></li>
- <li v-html="mismatchedServerlessFunctions"></li>
+ <li v-safe-html="noServerlessConfigFile"></li>
+ <li v-safe-html="noGitlabYamlConfigured"></li>
+ <li v-safe-html="mismatchedServerlessFunctions"></li>
<li>{{ s__('Serverless|The deploy job has not finished.') }}</li>
</ul>
diff --git a/app/assets/javascripts/serverless/components/missing_prometheus.vue b/app/assets/javascripts/serverless/components/missing_prometheus.vue
index 0d2c9f5151c..0b83d4b36eb 100644
--- a/app/assets/javascripts/serverless/components/missing_prometheus.vue
+++ b/app/assets/javascripts/serverless/components/missing_prometheus.vue
@@ -1,11 +1,11 @@
<script>
-import { GlDeprecatedButton, GlLink } from '@gitlab/ui';
+import { GlButton, GlLink } from '@gitlab/ui';
import { mapState } from 'vuex';
import { s__ } from '../../locale';
export default {
components: {
- GlDeprecatedButton,
+ GlButton,
GlLink,
},
props: {
@@ -47,9 +47,9 @@ export default {
</p>
<div v-if="!missingData" class="text-left">
- <gl-deprecated-button :href="clustersPath" variant="success">
+ <gl-button :href="clustersPath" variant="success" category="primary">
{{ s__('ServerlessDetails|Install Prometheus') }}
- </gl-deprecated-button>
+ </gl-button>
</div>
</div>
</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 86bfacbfb9e..46d51138ccf 100644
--- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
@@ -1,6 +1,6 @@
<script>
import $ from 'jquery';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { mapActions } from 'vuex';
import { __ } from '~/locale';
import { deprecatedCreateFlash as Flash } from '~/flash';
@@ -8,7 +8,7 @@ import eventHub from '../../event_hub';
export default {
components: {
- GlLoadingIcon,
+ GlButton,
},
props: {
fullPath: {
@@ -64,18 +64,18 @@ export default {
<template>
<div class="sidebar-item-warning-message-actions">
- <button type="button" class="btn btn-default gl-mr-3" @click="closeForm">
+ <gl-button class="gl-mr-3" @click="closeForm">
{{ __('Cancel') }}
- </button>
- <button
- type="button"
- class="btn btn-close"
- data-testid="confidential-toggle"
+ </gl-button>
+ <gl-button
+ category="secondary"
+ variant="warning"
:disabled="isLoading"
+ :loading="isLoading"
+ data-testid="confidential-toggle"
@click.prevent="submitForm"
>
- <gl-loading-icon v-if="isLoading" inline />
{{ toggleButtonText }}
- </button>
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
index d7be8927c29..0851ee21289 100644
--- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
+++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
@@ -89,6 +89,7 @@ export default {
:labels-select-in-progress="labelsSelectInProgress"
:selected-labels="selectedLabels"
:variant="$options.sidebar"
+ data-qa-selector="labels_block"
@onDropdownClose="handleDropdownClose"
@updateSelectedLabels="handleUpdateSelectedLabels"
>
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 ea7230ae488..26a7c8e4a80 100644
--- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
@@ -1,6 +1,6 @@
<script>
import $ from 'jquery';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { mapActions } from 'vuex';
import { __, sprintf } from '../../../locale';
import { deprecatedCreateFlash as Flash } from '~/flash';
@@ -8,7 +8,7 @@ import eventHub from '../../event_hub';
export default {
components: {
- GlLoadingIcon,
+ GlButton,
},
inject: ['fullPath'],
props: {
@@ -65,19 +65,19 @@ export default {
<template>
<div class="sidebar-item-warning-message-actions">
- <button type="button" class="btn btn-default gl-mr-3" @click="closeForm">
+ <gl-button class="gl-mr-3" @click="closeForm">
{{ __('Cancel') }}
- </button>
+ </gl-button>
- <button
- type="button"
+ <gl-button
data-testid="lock-toggle"
- class="btn btn-close"
+ category="secondary"
+ variant="warning"
:disabled="isLoading"
+ :loading="isLoading"
@click.prevent="submitForm"
>
- <gl-loading-icon v-if="isLoading" inline />
{{ buttonText }}
- </button>
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue
new file mode 100644
index 00000000000..6de926e0ff9
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue
@@ -0,0 +1,24 @@
+<script>
+// NOTE! For the first iteration, we are simply copying the implementation of Assignees
+// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import ReviewerAvatar from './reviewer_avatar.vue';
+
+export default {
+ components: {
+ ReviewerAvatar,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <button type="button" class="btn-link">
+ <reviewer-avatar :user="user" :img-size="24" />
+ <span class="author"> {{ user.name }} </span>
+ </button>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
new file mode 100644
index 00000000000..45707c18f7b
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
@@ -0,0 +1,107 @@
+<script>
+// NOTE! For the first iteration, we are simply copying the implementation of Assignees
+// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import CollapsedReviewer from './collapsed_reviewer.vue';
+
+const DEFAULT_MAX_COUNTER = 99;
+const DEFAULT_RENDER_COUNT = 5;
+
+export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ CollapsedReviewer,
+ GlIcon,
+ },
+ props: {
+ users: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ hasNoUsers() {
+ return !this.users.length;
+ },
+ hasMoreThanOneReviewer() {
+ return this.users.length > 1;
+ },
+ hasMoreThanTwoReviewers() {
+ return this.users.length > 2;
+ },
+ allReviewersCanMerge() {
+ return this.users.every(user => user.can_merge);
+ },
+ sidebarAvatarCounter() {
+ if (this.users.length > DEFAULT_MAX_COUNTER) {
+ return `${DEFAULT_MAX_COUNTER}+`;
+ }
+
+ return `+${this.users.length - 1}`;
+ },
+ collapsedUsers() {
+ const collapsedLength = this.hasMoreThanTwoReviewers ? 1 : this.users.length;
+
+ return this.users.slice(0, collapsedLength);
+ },
+ tooltipTitleMergeStatus() {
+ const mergeLength = this.users.filter(u => u.can_merge).length;
+
+ if (mergeLength === this.users.length) {
+ return '';
+ } else if (mergeLength > 0) {
+ return sprintf(__('%{mergeLength}/%{usersLength} can merge'), {
+ mergeLength,
+ usersLength: this.users.length,
+ });
+ }
+
+ return this.users.length === 1 ? __('cannot merge') : __('no one can merge');
+ },
+ tooltipTitle() {
+ const maxRender = Math.min(DEFAULT_RENDER_COUNT, this.users.length);
+ const renderUsers = this.users.slice(0, maxRender);
+ const names = renderUsers.map(u => u.name);
+
+ if (!this.users.length) {
+ return __('Reviewer(s)');
+ }
+
+ if (this.users.length > names.length) {
+ names.push(sprintf(__('+ %{amount} more'), { amount: this.users.length - names.length }));
+ }
+
+ const text = names.join(', ');
+
+ return this.tooltipTitleMergeStatus ? `${text} (${this.tooltipTitleMergeStatus})` : text;
+ },
+
+ tooltipOptions() {
+ return { container: 'body', placement: 'left', boundary: 'viewport' };
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-gl-tooltip="tooltipOptions"
+ :class="{ 'multiple-users': hasMoreThanOneReviewer }"
+ :title="tooltipTitle"
+ class="sidebar-collapsed-icon sidebar-collapsed-user"
+ >
+ <gl-icon v-if="hasNoUsers" name="user" :aria-label="__('None')" />
+ <collapsed-reviewer v-for="user in collapsedUsers" :key="user.id" :user="user" />
+ <button v-if="hasMoreThanTwoReviewers" class="btn-link" type="button">
+ <span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span>
+ <i
+ v-if="!allReviewersCanMerge"
+ aria-hidden="true"
+ class="fa fa-exclamation-triangle merge-icon"
+ ></i>
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue
new file mode 100644
index 00000000000..9fa3fa38eac
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue
@@ -0,0 +1,43 @@
+<script>
+// NOTE! For the first iteration, we are simply copying the implementation of Assignees
+// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import { __, sprintf } from '~/locale';
+
+export default {
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ imgSize: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ reviewerAlt() {
+ return sprintf(__("%{userName}'s avatar"), { userName: this.user.name });
+ },
+ avatarUrl() {
+ return this.user.avatar || this.user.avatar_url || gon.default_avatar_url;
+ },
+ hasMergeIcon() {
+ return !this.user.can_merge;
+ },
+ },
+};
+</script>
+
+<template>
+ <span class="position-relative">
+ <img
+ :alt="reviewerAlt"
+ :src="avatarUrl"
+ :width="imgSize"
+ :class="`s${imgSize}`"
+ class="avatar avatar-inline m-0"
+ data-qa-selector="avatar_image"
+ />
+ <i v-if="hasMergeIcon" aria-hidden="true" class="fa fa-exclamation-triangle merge-icon"></i>
+ </span>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
new file mode 100644
index 00000000000..b1b04564a62
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
@@ -0,0 +1,84 @@
+<script>
+// NOTE! For the first iteration, we are simply copying the implementation of Assignees
+// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import ReviewerAvatar from './reviewer_avatar.vue';
+
+export default {
+ components: {
+ ReviewerAvatar,
+ GlLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ tooltipPlacement: {
+ type: String,
+ default: 'bottom',
+ required: false,
+ },
+ tooltipHasName: {
+ type: Boolean,
+ default: true,
+ required: false,
+ },
+ issuableType: {
+ type: String,
+ default: 'issue',
+ required: false,
+ },
+ },
+ computed: {
+ cannotMerge() {
+ return this.issuableType === 'merge_request' && !this.user.can_merge;
+ },
+ tooltipTitle() {
+ if (this.cannotMerge && this.tooltipHasName) {
+ return sprintf(__('%{userName} (cannot merge)'), { userName: this.user.name });
+ } else if (this.cannotMerge) {
+ return __('Cannot merge');
+ } else if (this.tooltipHasName) {
+ return this.user.name;
+ }
+
+ return '';
+ },
+ tooltipOption() {
+ return {
+ container: 'body',
+ placement: this.tooltipPlacement,
+ boundary: 'viewport',
+ };
+ },
+ reviewerUrl() {
+ return this.user.web_url;
+ },
+ },
+};
+</script>
+
+<template>
+ <!-- must be `d-inline-block` or parent flex-basis causes width issues -->
+ <gl-link
+ v-gl-tooltip="tooltipOption"
+ :href="reviewerUrl"
+ :title="tooltipTitle"
+ class="d-inline-block"
+ >
+ <!-- use d-flex so that slot can be appropriately styled -->
+ <span class="d-flex">
+ <reviewer-avatar :user="user" :img-size="32" :issuable-type="issuableType" />
+ <slot :user="user"></slot>
+ </span>
+ </gl-link>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue
new file mode 100644
index 00000000000..437f28907fd
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue
@@ -0,0 +1,64 @@
+<script>
+// NOTE! For the first iteration, we are simply copying the implementation of Assignees
+// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import { GlLoadingIcon } from '@gitlab/ui';
+import { n__ } from '~/locale';
+
+export default {
+ name: 'ReviewerTitle',
+ components: {
+ GlLoadingIcon,
+ },
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ numberOfReviewers: {
+ type: Number,
+ required: true,
+ },
+ editable: {
+ type: Boolean,
+ required: true,
+ },
+ showToggle: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ reviewerTitle() {
+ const reviewers = this.numberOfReviewers;
+ return n__('Reviewer', `%d Reviewers`, reviewers);
+ },
+ },
+};
+</script>
+<template>
+ <div class="title hide-collapsed">
+ {{ reviewerTitle }}
+ <gl-loading-icon v-if="loading" inline class="align-bottom" />
+ <a
+ v-if="editable"
+ class="js-sidebar-dropdown-toggle edit-link float-right"
+ href="#"
+ data-track-event="click_edit_button"
+ data-track-label="right_sidebar"
+ data-track-property="reviewer"
+ >
+ {{ __('Edit') }}
+ </a>
+ <a
+ v-if="showToggle"
+ :aria-label="__('Toggle sidebar')"
+ class="gutter-toggle float-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/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
new file mode 100644
index 00000000000..6a3d88f6385
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
@@ -0,0 +1,72 @@
+<script>
+// NOTE! For the first iteration, we are simply copying the implementation of Assignees
+// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import CollapsedReviewerList from './collapsed_reviewer_list.vue';
+import UncollapsedReviewerList from './uncollapsed_reviewer_list.vue';
+
+export default {
+ // name: 'Reviewers' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ name: 'Reviewers',
+ components: {
+ CollapsedReviewerList,
+ UncollapsedReviewerList,
+ },
+ props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ users: {
+ type: Array,
+ required: true,
+ },
+ editable: {
+ type: Boolean,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ },
+ computed: {
+ hasNoUsers() {
+ return !this.users.length;
+ },
+ sortedReviewers() {
+ const canMergeUsers = this.users.filter(user => user.can_merge);
+ const canNotMergeUsers = this.users.filter(user => !user.can_merge);
+
+ return [...canMergeUsers, ...canNotMergeUsers];
+ },
+ },
+ methods: {
+ assignSelf() {
+ this.$emit('assign-self');
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <collapsed-reviewer-list :users="sortedReviewers" :issuable-type="issuableType" />
+
+ <div class="value hide-collapsed">
+ <template v-if="hasNoUsers">
+ <span class="assign-yourself no-value qa-assign-yourself">
+ {{ __('None') }}
+ </span>
+ </template>
+
+ <uncollapsed-reviewer-list
+ v-else
+ :users="sortedReviewers"
+ :root-path="rootPath"
+ :issuable-type="issuableType"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
new file mode 100644
index 00000000000..5d8a2e6fa65
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
@@ -0,0 +1,107 @@
+<script>
+// NOTE! For the first iteration, we are simply copying the implementation of Assignees
+// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import { deprecatedCreateFlash as Flash } from '~/flash';
+import eventHub from '~/sidebar/event_hub';
+import Store from '~/sidebar/stores/sidebar_store';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import ReviewerTitle from './reviewer_title.vue';
+import Reviewers from './reviewers.vue';
+import { __ } from '~/locale';
+
+export default {
+ name: 'SidebarReviewers',
+ components: {
+ ReviewerTitle,
+ Reviewers,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ mediator: {
+ type: Object,
+ required: true,
+ },
+ field: {
+ type: String,
+ required: true,
+ },
+ signedIn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ issuableIid: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ store: new Store(),
+ loading: false,
+ };
+ },
+ created() {
+ this.removeReviewer = this.store.removeReviewer.bind(this.store);
+ this.addReviewer = this.store.addReviewer.bind(this.store);
+ this.removeAllReviewers = this.store.removeAllReviewers.bind(this.store);
+
+ // Get events from deprecatedJQueryDropdown
+ eventHub.$on('sidebar.removeReviewer', this.removeReviewer);
+ eventHub.$on('sidebar.addReviewer', this.addReviewer);
+ eventHub.$on('sidebar.removeAllReviewers', this.removeAllReviewers);
+ eventHub.$on('sidebar.saveReviewers', this.saveReviewers);
+ },
+ beforeDestroy() {
+ eventHub.$off('sidebar.removeReviewer', this.removeReviewer);
+ eventHub.$off('sidebar.addReviewer', this.addReviewer);
+ eventHub.$off('sidebar.removeAllReviewers', this.removeAllReviewers);
+ eventHub.$off('sidebar.saveReviewers', this.saveReviewers);
+ },
+ methods: {
+ saveReviewers() {
+ this.loading = true;
+
+ this.mediator
+ .saveReviewers(this.field)
+ .then(() => {
+ this.loading = false;
+ // Uncomment once this issue has been addressed > https://gitlab.com/gitlab-org/gitlab/-/issues/237922
+ // refreshUserMergeRequestCounts();
+ })
+ .catch(() => {
+ this.loading = false;
+ return new Flash(__('Error occurred when saving reviewers'));
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <reviewer-title
+ :number-of-reviewers="store.reviewers.length"
+ :loading="loading || store.isFetching.reviewers"
+ :editable="store.editable"
+ :show-toggle="!signedIn"
+ />
+ <reviewers
+ v-if="!store.isFetching.reviewers"
+ :root-path="store.rootPath"
+ :users="store.reviewers"
+ :editable="store.editable"
+ :issuable-type="issuableType"
+ class="value"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
new file mode 100644
index 00000000000..2ae4a114b36
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -0,0 +1,103 @@
+<script>
+// NOTE! For the first iteration, we are simply copying the implementation of Assignees
+// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import { __, sprintf } from '~/locale';
+import ReviewerAvatarLink from './reviewer_avatar_link.vue';
+
+const DEFAULT_RENDER_COUNT = 5;
+
+export default {
+ components: {
+ ReviewerAvatarLink,
+ },
+ props: {
+ users: {
+ type: Array,
+ required: true,
+ },
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ },
+ data() {
+ return {
+ showLess: true,
+ };
+ },
+ computed: {
+ firstUser() {
+ return this.users[0];
+ },
+ hasOneUser() {
+ return this.users.length === 1;
+ },
+ hiddenReviewersLabel() {
+ const { numberOfHiddenReviewers } = this;
+ return sprintf(__('+ %{numberOfHiddenReviewers} more'), { numberOfHiddenReviewers });
+ },
+ renderShowMoreSection() {
+ return this.users.length > DEFAULT_RENDER_COUNT;
+ },
+ numberOfHiddenReviewers() {
+ return this.users.length - DEFAULT_RENDER_COUNT;
+ },
+ uncollapsedUsers() {
+ const uncollapsedLength = this.showLess
+ ? Math.min(this.users.length, DEFAULT_RENDER_COUNT)
+ : this.users.length;
+ return this.showLess ? this.users.slice(0, uncollapsedLength) : this.users;
+ },
+ username() {
+ return `@${this.firstUser.username}`;
+ },
+ },
+ methods: {
+ toggleShowLess() {
+ this.showLess = !this.showLess;
+ },
+ },
+};
+</script>
+
+<template>
+ <reviewer-avatar-link
+ v-if="hasOneUser"
+ #default="{ user }"
+ tooltip-placement="left"
+ :tooltip-has-name="false"
+ :user="firstUser"
+ :root-path="rootPath"
+ :issuable-type="issuableType"
+ >
+ <div class="ml-2">
+ <span class="author"> {{ user.name }} </span>
+ <span class="username"> {{ username }} </span>
+ </div>
+ </reviewer-avatar-link>
+ <div v-else>
+ <div class="user-list">
+ <div v-for="user in uncollapsedUsers" :key="user.id" class="user-item">
+ <reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType" />
+ </div>
+ </div>
+ <div v-if="renderShowMoreSection" class="user-list-more">
+ <button
+ type="button"
+ class="btn-link"
+ data-qa-selector="more_reviewers_link"
+ @click="toggleShowLess"
+ >
+ <template v-if="showLess">
+ {{ hiddenReviewersLabel }}
+ </template>
+ <template v-else>{{ __('- show less') }}</template>
+ </button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue
index 05ad7b4ea3e..406677941b7 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue
@@ -26,11 +26,14 @@ export default {
methods: {
listenForQuickActions() {
$(document).on('ajax:success', '.gfm-form', this.quickActionListened);
+
eventHub.$on('timeTrackingUpdated', data => {
- this.quickActionListened(null, data);
+ this.quickActionListened({ detail: [data] });
});
},
- quickActionListened(e, data) {
+ quickActionListened(e) {
+ const data = e.detail[0];
+
const subscribedCommands = ['spend_time', 'time_estimate'];
let changedCommands;
if (data !== undefined) {
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index be559b16420..a25a7b0b2fe 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -5,6 +5,7 @@ import Vuex from 'vuex';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import SidebarLabels from './components/labels/sidebar_labels.vue';
+import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue';
import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
import SidebarMoveIssue from './lib/sidebar_move_issue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue';
@@ -13,17 +14,17 @@ import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptio
import SidebarSeverity from './components/severity/sidebar_severity.vue';
import Translate from '../vue_shared/translate';
import createDefaultClient from '~/lib/graphql';
-import { store } from '~/notes/stores';
-import { isInIssuePage, parseBoolean } from '~/lib/utils/common_utils';
-import mergeRequestStore from '~/mr_notes/stores';
+import { isInIssuePage, isInIncidentPage, parseBoolean } from '~/lib/utils/common_utils';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
Vue.use(Translate);
Vue.use(VueApollo);
Vue.use(Vuex);
-function getSidebarOptions() {
- return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
+function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-options')) {
+ return JSON.parse(sidebarOptEl.innerHTML);
}
function mountAssigneesComponent(mediator) {
@@ -50,6 +51,36 @@ function mountAssigneesComponent(mediator) {
projectPath: fullPath,
field: el.dataset.field,
signedIn: el.hasAttribute('data-signed-in'),
+ issuableType: isInIssuePage() || isInIncidentPage() ? 'issue' : 'merge_request',
+ },
+ }),
+ });
+}
+
+function mountReviewersComponent(mediator) {
+ const el = document.getElementById('js-vue-sidebar-reviewers');
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ if (!el) return;
+
+ const { iid, fullPath } = getSidebarOptions();
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ apolloProvider,
+ components: {
+ SidebarReviewers,
+ },
+ render: createElement =>
+ createElement('sidebar-reviewers', {
+ props: {
+ mediator,
+ issuableIid: String(iid),
+ projectPath: fullPath,
+ field: el.dataset.field,
+ signedIn: el.hasAttribute('data-signed-in'),
issuableType: isInIssuePage() ? 'issue' : 'merge_request',
},
}),
@@ -89,47 +120,74 @@ function mountConfidentialComponent(mediator) {
const dataNode = document.getElementById('js-confidential-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
- // eslint-disable-next-line no-new
- new Vue({
- el,
- store,
- components: {
- ConfidentialIssueSidebar,
- },
- render: createElement =>
- createElement('confidential-issue-sidebar', {
- props: {
- iid: String(iid),
- fullPath,
- isEditable: initialData.is_editable,
- service: mediator.service,
- },
- }),
- });
+ import(/* webpackChunkName: 'notesStore' */ '~/notes/stores')
+ .then(
+ ({ store }) =>
+ new Vue({
+ el,
+ store,
+ components: {
+ ConfidentialIssueSidebar,
+ },
+ render: createElement =>
+ createElement('confidential-issue-sidebar', {
+ props: {
+ iid: String(iid),
+ fullPath,
+ isEditable: initialData.is_editable,
+ service: mediator.service,
+ },
+ }),
+ }),
+ )
+ .catch(() => {
+ createFlash({ message: __('Failed to load sidebar confidential toggle') });
+ });
}
function mountLockComponent() {
const el = document.getElementById('js-lock-entry-point');
+
+ if (!el) {
+ return;
+ }
+
const { fullPath } = getSidebarOptions();
const dataNode = document.getElementById('js-lock-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
- return el
- ? new Vue({
- el,
- store: isInIssuePage() ? store : mergeRequestStore,
- provide: {
- fullPath,
- },
- render: createElement =>
- createElement(IssuableLockForm, {
- props: {
- isEditable: initialData.is_editable,
- },
- }),
- })
- : undefined;
+ let importStore;
+ if (isInIssuePage() || isInIncidentPage()) {
+ importStore = import(/* webpackChunkName: 'notesStore' */ '~/notes/stores').then(
+ ({ store }) => store,
+ );
+ } else {
+ importStore = import(/* webpackChunkName: 'mrNotesStore' */ '~/mr_notes/stores').then(
+ store => store.default,
+ );
+ }
+
+ importStore
+ .then(
+ store =>
+ new Vue({
+ el,
+ store,
+ provide: {
+ fullPath,
+ },
+ render: createElement =>
+ createElement(IssuableLockForm, {
+ props: {
+ isEditable: initialData.is_editable,
+ },
+ }),
+ }),
+ )
+ .catch(() => {
+ createFlash({ message: __('Failed to load sidebar lock status') });
+ });
}
function mountParticipantsComponent(mediator) {
@@ -218,8 +276,9 @@ function mountSeverityComponent() {
export function mountSidebar(mediator) {
mountAssigneesComponent(mediator);
+ mountReviewersComponent(mediator);
mountConfidentialComponent(mediator);
- mountLockComponent(mediator);
+ mountLockComponent();
mountParticipantsComponent(mediator);
mountSubscriptionsComponent(mediator);
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 8f1f76a2e02..2146fb83b13 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -40,6 +40,17 @@ export default class SidebarMediator {
return this.service.update(field, data);
}
+ saveReviewers(field) {
+ const selected = this.store.reviewers.map(u => u.id);
+
+ // If there are no ids, that means we have to unassign (which is id = 0)
+ // And it only accepts an array, hence [0]
+ const reviewers = selected.length === 0 ? [0] : selected;
+ const data = { reviewer_ids: reviewers };
+
+ return this.service.update(field, data);
+ }
+
setMoveToProjectId(projectId) {
this.store.setMoveToProjectId(projectId);
}
@@ -55,6 +66,7 @@ export default class SidebarMediator {
processFetchedData(data) {
this.store.setAssigneeData(data);
+ this.store.setReviewerData(data);
this.store.setTimeTrackingData(data);
this.store.setParticipantsData(data);
this.store.setSubscriptionsData(data);
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index 095f93b72a9..8d0d093e920 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -18,8 +18,10 @@ export default class SidebarStore {
this.humanTimeSpent = '';
this.timeTrackingLimitToHours = timeTrackingLimitToHours;
this.assignees = [];
+ this.reviewers = [];
this.isFetching = {
assignees: true,
+ reviewers: true,
participants: true,
subscriptions: true,
};
@@ -42,6 +44,13 @@ export default class SidebarStore {
}
}
+ setReviewerData(data) {
+ this.isFetching.reviewers = false;
+ if (data.reviewers) {
+ this.reviewers = data.reviewers;
+ }
+ }
+
setTimeTrackingData(data) {
this.timeEstimate = data.time_estimate;
this.totalTimeSpent = data.total_time_spent;
@@ -75,20 +84,40 @@ export default class SidebarStore {
}
}
+ addReviewer(reviewer) {
+ if (!this.findReviewer(reviewer)) {
+ this.reviewers.push(reviewer);
+ }
+ }
+
findAssignee(findAssignee) {
return this.assignees.find(assignee => assignee.id === findAssignee.id);
}
+ findReviewer(findReviewer) {
+ return this.reviewers.find(reviewer => reviewer.id === findReviewer.id);
+ }
+
removeAssignee(removeAssignee) {
if (removeAssignee) {
this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
}
}
+ removeReviewer(removeReviewer) {
+ if (removeReviewer) {
+ this.reviewers = this.reviewers.filter(reviewer => reviewer.id !== removeReviewer.id);
+ }
+ }
+
removeAllAssignees() {
this.assignees = [];
}
+ removeAllReviewers() {
+ this.reviewers = [];
+ }
+
setAssigneesFromRealtime(data) {
this.assignees = data;
}
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index 586d1e62c2f..5fa6cef7195 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -57,16 +57,10 @@ export default class SingleFileDiff {
this.content.hide();
this.$toggleIcon.addClass('fa-caret-right').removeClass('fa-caret-down');
this.collapsedContent.show();
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- gl.diffNotesCompileComponents();
- }
} else if (this.content) {
this.collapsedContent.hide();
this.content.show();
this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right');
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- gl.diffNotesCompileComponents();
- }
} else {
this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right');
return this.getContentHTML(cb);
@@ -90,10 +84,6 @@ export default class SingleFileDiff {
}
this.collapsedContent.after(this.content);
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- gl.diffNotesCompileComponents();
- }
-
const $file = $(this.file);
FilesCommentButton.init($file);
diff --git a/app/assets/javascripts/snippet/snippet_show.js b/app/assets/javascripts/snippet/snippet_show.js
index bbddfc579c5..1899ff91f87 100644
--- a/app/assets/javascripts/snippet/snippet_show.js
+++ b/app/assets/javascripts/snippet/snippet_show.js
@@ -1,21 +1,33 @@
-import LineHighlighter from '~/line_highlighter';
-import BlobViewer from '~/blob/viewer';
-import ZenMode from '~/zen_mode';
import initNotes from '~/init_notes';
-import snippetEmbed from '~/snippet/snippet_embed';
-import { SnippetShowInit } from '~/snippets';
import loadAwardsHandler from '~/awards_handler';
-document.addEventListener('DOMContentLoaded', () => {
- if (!gon.features.snippetsVue) {
- new LineHighlighter(); // eslint-disable-line no-new
- new BlobViewer(); // eslint-disable-line no-new
- initNotes();
- new ZenMode(); // eslint-disable-line no-new
- snippetEmbed();
- } else {
- SnippetShowInit();
- initNotes();
- }
- loadAwardsHandler();
-});
+if (!gon.features.snippetsVue) {
+ const LineHighlighterModule = import('~/line_highlighter');
+ const BlobViewerModule = import('~/blob/viewer');
+ const ZenModeModule = import('~/zen_mode');
+ const SnippetEmbedModule = import('~/snippet/snippet_embed');
+
+ Promise.all([LineHighlighterModule, BlobViewerModule, ZenModeModule, SnippetEmbedModule])
+ .then(
+ ([
+ { default: LineHighlighter },
+ { default: BlobViewer },
+ { default: ZenMode },
+ { default: SnippetEmbed },
+ ]) => {
+ new LineHighlighter(); // eslint-disable-line no-new
+ new BlobViewer(); // eslint-disable-line no-new
+ new ZenMode(); // eslint-disable-line no-new
+ SnippetEmbed();
+ },
+ )
+ .catch(() => {});
+} else {
+ import('~/snippets')
+ .then(({ SnippetShowInit }) => {
+ SnippetShowInit();
+ })
+ .catch(() => {});
+}
+initNotes();
+loadAwardsHandler();
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index 1a539aa0876..e15aa10bd81 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -151,7 +151,7 @@ export default {
this.newSnippet = false;
},
onSnippetFetch(snippetRes) {
- if (snippetRes.data.snippets.edges.length === 0) {
+ if (snippetRes.data.snippets.nodes.length === 0) {
this.onNewSnippetFetched();
} else {
this.onExistingSnippetFetched();
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue
index 55cd13a6930..23fb9979ba0 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue
@@ -149,6 +149,7 @@ export default {
data-testid="add_button"
class="gl-my-3"
variant="dashed"
+ data-qa-selector="add_file_button"
@click="addBlob"
>{{ addLabel }}</gl-button
>
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
index f3f894ed649..ab7ef0d50a5 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
@@ -69,7 +69,7 @@ export default {
};
</script>
<template>
- <div class="file-holder snippet">
+ <div class="file-holder snippet" data-qa-selector="file_holder_container">
<blob-header-edit
:id="inputId"
:value="blob.path"
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
index b38be5bb9a4..e88126ea56a 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
@@ -23,6 +23,7 @@ export default {
return {
ids: this.snippet.id,
rich: this.activeViewerType === RICH_BLOB_VIEWER,
+ paths: [this.blob.path],
};
},
update(data) {
@@ -79,8 +80,10 @@ export default {
},
onContentUpdate(data) {
const { path: blobPath } = this.blob;
- const { blobs } = data.snippets.edges[0].node;
- const updatedBlobData = blobs.find(blob => blob.path === blobPath);
+ const {
+ blobs: { nodes: dataBlobs },
+ } = data.snippets.nodes[0];
+ const updatedBlobData = dataBlobs.find(blob => blob.path === blobPath);
return updatedBlobData.richData || updatedBlobData.plainData;
},
},
diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
index 737845d09b8..5e6caf27bdd 100644
--- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
@@ -49,6 +49,7 @@ export default {
:add-spacing-classes="false"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
+ :textarea-value="value"
>
<template #textarea>
<textarea
diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue
index 0ca69f3161a..30de5a9d0e0 100644
--- a/app/assets/javascripts/snippets/components/snippet_header.vue
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -18,6 +18,7 @@ import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql';
import CanCreatePersonalSnippet from '../queries/userPermissions.query.graphql';
import CanCreateProjectSnippet from '../queries/projectPermissions.query.graphql';
import { joinPaths } from '~/lib/utils/url_utility';
+import { fetchPolicies } from '~/lib/graphql';
export default {
components: {
@@ -37,6 +38,7 @@ export default {
},
apollo: {
canCreateSnippet: {
+ fetchPolicy: fetchPolicies.NO_CACHE,
query() {
return this.snippet.project ? CanCreateProjectSnippet : CanCreatePersonalSnippet;
},
diff --git a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
index 2cca71708ca..d75b4011d1c 100644
--- a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
+++ b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
@@ -12,18 +12,20 @@ fragment SnippetBase on Snippet {
httpUrlToRepo
sshUrlToRepo
blobs {
- binary
- name
- path
- rawPath
- size
- externalStorage
- renderedAsText
- simpleViewer {
- ...BlobViewer
- }
- richViewer {
- ...BlobViewer
+ nodes {
+ binary
+ name
+ path
+ rawPath
+ size
+ externalStorage
+ renderedAsText
+ simpleViewer {
+ ...BlobViewer
+ }
+ richViewer {
+ ...BlobViewer
+ }
}
}
userPermissions {
diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js
index c70ad9b95f8..d3caec42ce7 100644
--- a/app/assets/javascripts/snippets/index.js
+++ b/app/assets/javascripts/snippets/index.js
@@ -3,8 +3,6 @@ import VueApollo from 'vue-apollo';
import Translate from '~/vue_shared/translate';
import createDefaultClient from '~/lib/graphql';
-import SnippetsShow from './components/show.vue';
-import SnippetsEdit from './components/edit.vue';
import { SNIPPET_LEVELS_MAP, SNIPPET_VISIBILITY_PRIVATE } from '~/snippets/constants';
Vue.use(VueApollo);
@@ -16,7 +14,7 @@ function appFactory(el, Component) {
}
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient({}, { batchMax: 1 }),
});
const {
@@ -48,9 +46,17 @@ function appFactory(el, Component) {
}
export const SnippetShowInit = () => {
- appFactory(document.getElementById('js-snippet-view'), SnippetsShow);
+ import('./components/show.vue')
+ .then(({ default: SnippetsShow }) => {
+ appFactory(document.getElementById('js-snippet-view'), SnippetsShow);
+ })
+ .catch(() => {});
};
export const SnippetEditInit = () => {
- appFactory(document.getElementById('js-snippet-edit'), SnippetsEdit);
+ import('./components/edit.vue')
+ .then(({ default: SnippetsEdit }) => {
+ appFactory(document.getElementById('js-snippet-edit'), SnippetsEdit);
+ })
+ .catch(() => {});
};
diff --git a/app/assets/javascripts/snippets/mixins/snippets.js b/app/assets/javascripts/snippets/mixins/snippets.js
index 15daaa8d84a..d5e69e2a889 100644
--- a/app/assets/javascripts/snippets/mixins/snippets.js
+++ b/app/assets/javascripts/snippets/mixins/snippets.js
@@ -11,9 +11,16 @@ export const getSnippetMixin = {
ids: this.snippetGid,
};
},
- update: data => data.snippets.edges[0]?.node,
+ update: data => {
+ const res = data.snippets.nodes[0];
+ if (res) {
+ res.blobs = res.blobs.nodes;
+ }
+
+ return res;
+ },
result(res) {
- this.blobs = res.data.snippets.edges[0]?.node?.blobs || blobsDefault;
+ this.blobs = res.data.snippets.nodes[0]?.blobs || blobsDefault;
if (this.onSnippetFetch) {
this.onSnippetFetch(res);
}
diff --git a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql b/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql
index 8f1f16b76c2..0e04ee9b7b8 100644
--- a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql
+++ b/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql
@@ -1,9 +1,9 @@
-query SnippetBlobContent($ids: [ID!], $rich: Boolean!) {
+query SnippetBlobContent($ids: [ID!], $rich: Boolean!, $paths: [String!]) {
snippets(ids: $ids) {
- edges {
- node {
- id
- blobs {
+ nodes {
+ id
+ blobs(paths: $paths) {
+ nodes {
path
richData @include(if: $rich)
plainData @skip(if: $rich)
diff --git a/app/assets/javascripts/snippets/queries/snippet.query.graphql b/app/assets/javascripts/snippets/queries/snippet.query.graphql
index b23ab862439..2f385050d89 100644
--- a/app/assets/javascripts/snippets/queries/snippet.query.graphql
+++ b/app/assets/javascripts/snippets/queries/snippet.query.graphql
@@ -4,13 +4,11 @@
query GetSnippetQuery($ids: [ID!]) {
snippets(ids: $ids) {
- edges {
- node {
- ...SnippetBase
- ...SnippetProject
- author {
- ...Author
- }
+ nodes {
+ ...SnippetBase
+ ...SnippetProject
+ author {
+ ...Author
}
}
}
diff --git a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue
index 2d62964cb3b..5f00f9f22f3 100644
--- a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue
+++ b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue
@@ -41,7 +41,7 @@ export default {
:disabled="savingChanges"
@click="$emit('editSettings')"
>
- {{ __('Settings') }}
+ {{ __('Page settings') }}
</gl-button>
<gl-button
ref="submit"
diff --git a/app/assets/javascripts/static_site_editor/graphql/index.js b/app/assets/javascripts/static_site_editor/graphql/index.js
index 0a5d8c07ad9..fbb3d7fbfcc 100644
--- a/app/assets/javascripts/static_site_editor/graphql/index.js
+++ b/app/assets/javascripts/static_site_editor/graphql/index.js
@@ -4,6 +4,7 @@ import createDefaultClient from '~/lib/graphql';
import typeDefs from './typedefs.graphql';
import fileResolver from './resolvers/file';
import submitContentChangesResolver from './resolvers/submit_content_changes';
+import hasSubmittedChangesResolver from './resolvers/has_submitted_changes';
Vue.use(VueApollo);
@@ -15,6 +16,7 @@ const createApolloProvider = appData => {
},
Mutation: {
submitContentChanges: submitContentChangesResolver,
+ hasSubmittedChanges: hasSubmittedChangesResolver,
},
},
{
diff --git a/app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql b/app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql
new file mode 100644
index 00000000000..1f47929556a
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql
@@ -0,0 +1,5 @@
+mutation hasSubmittedChanges($input: HasSubmittedChangesInput) {
+ hasSubmittedChanges(input: $input) @client {
+ hasSubmittedChanges
+ }
+}
diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
index 946d80efff0..9f4b0afe55f 100644
--- a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
+++ b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
@@ -1,6 +1,7 @@
query appData {
appData @client {
isSupportedContent
+ hasSubmittedChanges
project
sourcePath
username
diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js
new file mode 100644
index 00000000000..ce55db7f3e5
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js
@@ -0,0 +1,17 @@
+import query from '../queries/app_data.query.graphql';
+
+const hasSubmittedChangesResolver = (_, { input: { hasSubmittedChanges } }, { cache }) => {
+ const { appData } = cache.readQuery({ query });
+ cache.writeQuery({
+ query,
+ data: {
+ appData: {
+ __typename: 'AppData',
+ ...appData,
+ hasSubmittedChanges,
+ },
+ },
+ });
+};
+
+export default hasSubmittedChangesResolver;
diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
index 0cb26f88785..694cf762e51 100644
--- a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
+++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
@@ -3,22 +3,27 @@ import savedContentMetaQuery from '../queries/saved_content_meta.query.graphql';
const submitContentChangesResolver = (
_,
- { input: { project: projectId, username, sourcePath, content, images } },
+ { input: { project: projectId, username, sourcePath, content, images, mergeRequestMeta } },
{ cache },
) => {
- return submitContentChanges({ projectId, username, sourcePath, content, images }).then(
- savedContentMeta => {
- cache.writeQuery({
- query: savedContentMetaQuery,
- data: {
- savedContentMeta: {
- __typename: 'SavedContentMeta',
- ...savedContentMeta,
- },
+ return submitContentChanges({
+ projectId,
+ username,
+ sourcePath,
+ content,
+ images,
+ mergeRequestMeta,
+ }).then(savedContentMeta => {
+ cache.writeQuery({
+ query: savedContentMetaQuery,
+ data: {
+ savedContentMeta: {
+ __typename: 'SavedContentMeta',
+ ...savedContentMeta,
},
- });
- },
- );
+ },
+ });
+ });
};
export default submitContentChangesResolver;
diff --git a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
index 78cc1746cdb..0ded1722d26 100644
--- a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
+++ b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
@@ -16,12 +16,17 @@ type SavedContentMeta {
type AppData {
isSupportedContent: Boolean!
+ hasSubmittedChanges: Boolean!
project: String!
returnUrl: String
sourcePath: String!
username: String!
}
+input HasSubmittedChangesInput {
+ hasSubmittedChanges: Boolean!
+}
+
input SubmitContentChangesInput {
project: String!
sourcePath: String!
@@ -40,4 +45,5 @@ extend type Query {
extend type Mutation {
submitContentChanges(input: SubmitContentChangesInput!): SavedContentMeta
+ hasSubmittedChanges(input: HasSubmittedChangesInput!): AppData
}
diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js
index b7e5ea4eee3..fceef8f9084 100644
--- a/app/assets/javascripts/static_site_editor/index.js
+++ b/app/assets/javascripts/static_site_editor/index.js
@@ -12,13 +12,23 @@ const initStaticSiteEditor = el => {
namespace,
project,
mergeRequestsIllustrationPath,
+ // NOTE: The following variables are not yet used, but are supported by the config file,
+ // so we are adding them here as a convenience for future use.
+ // eslint-disable-next-line no-unused-vars
+ staticSiteGenerator,
+ // eslint-disable-next-line no-unused-vars
+ imageUploadPath,
+ mounts,
} = el.dataset;
+ // NOTE that the object in 'mounts' is a JSON string from the data attribute, so it must be parsed into an object.
+ // eslint-disable-next-line no-unused-vars
+ const mountsObject = JSON.parse(mounts);
const { current_username: username } = window.gon;
const returnUrl = el.dataset.returnUrl || null;
-
const router = createRouter(baseUrl);
const apolloProvider = createApolloProvider({
isSupportedContent: parseBoolean(isSupportedContent),
+ hasSubmittedChanges: false,
project: `${namespace}/${project}`,
returnUrl,
sourcePath,
diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue
index eef2bd88f0e..d48917e8f36 100644
--- a/app/assets/javascripts/static_site_editor/pages/home.vue
+++ b/app/assets/javascripts/static_site_editor/pages/home.vue
@@ -1,13 +1,16 @@
<script>
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import { s__, sprintf } from '~/locale';
+import Tracking from '~/tracking';
+
import SkeletonLoader from '../components/skeleton_loader.vue';
import EditArea from '../components/edit_area.vue';
import InvalidContentMessage from '../components/invalid_content_message.vue';
import SubmitChangesError from '../components/submit_changes_error.vue';
import appDataQuery from '../graphql/queries/app_data.query.graphql';
import sourceContentQuery from '../graphql/queries/source_content.query.graphql';
+import hasSubmittedChangesMutation from '../graphql/mutations/has_submitted_changes.mutation.graphql';
import submitContentChangesMutation from '../graphql/mutations/submit_content_changes.mutation.graphql';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
-import Tracking from '~/tracking';
import { LOAD_CONTENT_ERROR, TRACKING_ACTION_INITIALIZE_EDITOR } from '../constants';
import { SUCCESS_ROUTE } from '../router/constants';
@@ -74,6 +77,20 @@ export default {
submitChanges(images) {
this.isSavingChanges = true;
+ // eslint-disable-next-line promise/catch-or-return
+ this.$apollo
+ .mutate({
+ mutation: hasSubmittedChangesMutation,
+ variables: {
+ input: {
+ hasSubmittedChanges: true,
+ },
+ },
+ })
+ .finally(() => {
+ this.$router.push(SUCCESS_ROUTE);
+ });
+
this.$apollo
.mutate({
mutation: submitContentChangesMutation,
@@ -84,12 +101,15 @@ export default {
sourcePath: this.appData.sourcePath,
content: this.content,
images,
+ mergeRequestMeta: {
+ title: sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), {
+ sourcePath: this.appData.sourcePath,
+ }),
+ description: s__('StaticSiteEditor|Copy update'),
+ },
},
},
})
- .then(() => {
- this.$router.push(SUCCESS_ROUTE);
- })
.catch(e => {
this.submitChangesError = e.message;
})
diff --git a/app/assets/javascripts/static_site_editor/pages/success.vue b/app/assets/javascripts/static_site_editor/pages/success.vue
index f0d597d7c9b..5b013c27c35 100644
--- a/app/assets/javascripts/static_site_editor/pages/success.vue
+++ b/app/assets/javascripts/static_site_editor/pages/success.vue
@@ -1,5 +1,5 @@
<script>
-import { GlEmptyState, GlButton } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale';
import savedContentMetaQuery from '../graphql/queries/saved_content_meta.query.graphql';
@@ -8,8 +8,9 @@ import { HOME_ROUTE } from '../router/constants';
export default {
components: {
- GlEmptyState,
GlButton,
+ GlEmptyState,
+ GlLoadingIcon,
},
props: {
mergeRequestsIllustrationPath: {
@@ -33,7 +34,7 @@ export default {
},
},
created() {
- if (!this.savedContentMeta) {
+ if (!this.appData.hasSubmittedChanges) {
this.$router.push(HOME_ROUTE);
}
},
@@ -50,14 +51,21 @@ export default {
assignMergeRequestInstruction: s__(
'StaticSiteEditor|3. Assign a person to review and accept the merge request.',
),
+ submittingTitle: s__('StaticSiteEditor|Creating your merge request'),
+ submittingNotePrimary: s__(
+ 'StaticSiteEditor|You can set an assignee to get your changes reviewed and deployed once your merge request is created.',
+ ),
+ submittingNoteSecondary: s__(
+ 'StaticSiteEditor|A link to view the merge request will appear once ready.',
+ ),
};
</script>
<template>
- <div
- v-if="savedContentMeta"
- class="container gl-flex-grow-1 gl-display-flex gl-flex-direction-column"
- >
- <div class="gl-fixed gl-left-0 gl-right-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100">
+ <div class="container gl-flex-grow-1 gl-display-flex gl-flex-direction-column">
+ <div
+ v-if="savedContentMeta"
+ class="gl-fixed gl-left-0 gl-right-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
+ >
<div class="container gl-py-4">
<gl-button
v-if="appData.returnUrl"
@@ -73,16 +81,24 @@ export default {
</div>
<gl-empty-state
class="gl-my-9"
- :primary-button-text="$options.primaryButtonText"
- :title="$options.title"
- :primary-button-link="savedContentMeta.mergeRequest.url"
+ :title="savedContentMeta ? $options.title : $options.submittingTitle"
+ :primary-button-text="savedContentMeta && $options.primaryButtonText"
+ :primary-button-link="savedContentMeta && savedContentMeta.mergeRequest.url"
:svg-path="mergeRequestsIllustrationPath"
+ :svg-height="146"
>
<template #description>
- <p>{{ $options.mergeRequestInstructionsHeading }}</p>
- <p>{{ $options.addTitleInstruction }}</p>
- <p>{{ $options.addDescriptionInstruction }}</p>
- <p>{{ $options.assignMergeRequestInstruction }}</p>
+ <div v-if="savedContentMeta">
+ <p>{{ $options.mergeRequestInstructionsHeading }}</p>
+ <p>{{ $options.addTitleInstruction }}</p>
+ <p>{{ $options.addDescriptionInstruction }}</p>
+ <p>{{ $options.assignMergeRequestInstruction }}</p>
+ </div>
+ <div v-else>
+ <p>{{ $options.submittingNotePrimary }}</p>
+ <p>{{ $options.submittingNoteSecondary }}</p>
+ <gl-loading-icon size="xl" />
+ </div>
</template>
</gl-empty-state>
</div>
diff --git a/app/assets/javascripts/static_site_editor/services/front_matterify.js b/app/assets/javascripts/static_site_editor/services/front_matterify.js
new file mode 100644
index 00000000000..cbf0fffd515
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/services/front_matterify.js
@@ -0,0 +1,73 @@
+import jsYaml from 'js-yaml';
+
+const NEW_LINE = '\n';
+
+const hasMatter = (firstThreeChars, fourthChar) => {
+ const isYamlDelimiter = firstThreeChars === '---';
+ const isFourthCharNewline = fourthChar === NEW_LINE;
+ return isYamlDelimiter && isFourthCharNewline;
+};
+
+export const frontMatterify = source => {
+ let index = 3;
+ let offset;
+ const delimiter = source.slice(0, index);
+ const type = 'yaml';
+ const NO_FRONTMATTER = {
+ source,
+ matter: null,
+ spacing: null,
+ content: source,
+ delimiter: null,
+ type: null,
+ };
+
+ if (!hasMatter(delimiter, source.charAt(index))) {
+ return NO_FRONTMATTER;
+ }
+
+ offset = source.indexOf(delimiter, index);
+
+ // Finds the end delimiter that starts at a new line
+ while (offset !== -1 && source.charAt(offset - 1) !== NEW_LINE) {
+ index = offset + delimiter.length;
+ offset = source.indexOf(delimiter, index);
+ }
+
+ if (offset === -1) {
+ return NO_FRONTMATTER;
+ }
+
+ const matterStr = source.slice(index, offset);
+ const matter = jsYaml.safeLoad(matterStr);
+
+ let content = source.slice(offset + delimiter.length);
+ let spacing = '';
+ let idx = 0;
+ while (content.charAt(idx).match(/(\s|\n)/)) {
+ spacing += content.charAt(idx);
+ idx += 1;
+ }
+ content = content.replace(spacing, '');
+
+ return {
+ source,
+ matter,
+ spacing,
+ content,
+ delimiter,
+ type,
+ };
+};
+
+export const stringify = ({ matter, spacing, content, delimiter }, newMatter) => {
+ const matterObj = newMatter || matter;
+
+ if (!matterObj) {
+ return content;
+ }
+
+ const header = `${delimiter}${NEW_LINE}${jsYaml.safeDump(matterObj)}${delimiter}`;
+ const body = `${spacing}${content}`;
+ return `${header}${body}`;
+};
diff --git a/app/assets/javascripts/static_site_editor/services/parse_source_file.js b/app/assets/javascripts/static_site_editor/services/parse_source_file.js
index 640186ee1d0..d4fc8b2edb6 100644
--- a/app/assets/javascripts/static_site_editor/services/parse_source_file.js
+++ b/app/assets/javascripts/static_site_editor/services/parse_source_file.js
@@ -1,7 +1,7 @@
-import grayMatter from 'gray-matter';
+import { frontMatterify, stringify } from './front_matterify';
const parseSourceFile = raw => {
- const remake = source => grayMatter(source, {});
+ const remake = source => frontMatterify(source);
let editable = remake(raw);
@@ -13,20 +13,17 @@ const parseSourceFile = raw => {
}
};
- const trimmedEditable = () => grayMatter.stringify(editable).trim();
+ const content = (isBody = false) => (isBody ? editable.content : stringify(editable));
- const content = (isBody = false) => (isBody ? editable.content.trim() : trimmedEditable()); // gray-matter internally adds an eof newline so we trim to bypass, open issue: https://github.com/jonschlinkert/gray-matter/issues/96
-
- const matter = () => editable.data;
+ const matter = () => editable.matter;
const syncMatter = settings => {
- const source = grayMatter.stringify(editable.content, settings);
- syncContent(source);
+ editable.matter = settings;
};
- const isModified = () => trimmedEditable() !== raw;
+ const isModified = () => stringify(editable) !== raw;
- const hasMatter = () => editable.matter.length > 0;
+ const hasMatter = () => Boolean(editable.matter);
return {
matter,
diff --git a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
index da62d3fa4fc..8623a671a7d 100644
--- a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
+++ b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
@@ -1,6 +1,5 @@
import Api from '~/api';
import Tracking from '~/tracking';
-import { s__, sprintf } from '~/locale';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import generateBranchName from '~/static_site_editor/services/generate_branch_name';
@@ -71,6 +70,7 @@ const commitContent = (projectId, message, branch, sourcePath, content, images)
const createMergeRequest = (
projectId,
title,
+ description,
sourceBranch,
targetBranch = DEFAULT_TARGET_BRANCH,
) => {
@@ -80,6 +80,7 @@ const createMergeRequest = (
projectId,
convertObjectPropsToSnakeCase({
title,
+ description,
sourceBranch,
targetBranch,
}),
@@ -88,11 +89,16 @@ const createMergeRequest = (
});
};
-const submitContentChanges = ({ username, projectId, sourcePath, content, images }) => {
+const submitContentChanges = ({
+ username,
+ projectId,
+ sourcePath,
+ content,
+ images,
+ mergeRequestMeta,
+}) => {
const branch = generateBranchName(username);
- const mergeRequestTitle = sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), {
- sourcePath,
- });
+ const { title: mergeRequestTitle, description: mergeRequestDescription } = mergeRequestMeta;
const meta = {};
return createBranch(projectId, branch)
@@ -104,7 +110,7 @@ const submitContentChanges = ({ username, projectId, sourcePath, content, images
.then(({ data: { short_id: label, web_url: url } }) => {
Object.assign(meta, { commit: { label, url } });
- return createMergeRequest(projectId, mergeRequestTitle, branch);
+ return createMergeRequest(projectId, mergeRequestTitle, mergeRequestDescription, branch);
})
.then(({ data: { iid: label, web_url: url } }) => {
Object.assign(meta, { mergeRequest: { label: label.toString(), url } });
diff --git a/app/assets/javascripts/static_site_editor/services/templater.js b/app/assets/javascripts/static_site_editor/services/templater.js
index a1c1bb6b8d6..318f2099064 100644
--- a/app/assets/javascripts/static_site_editor/services/templater.js
+++ b/app/assets/javascripts/static_site_editor/services/templater.js
@@ -15,7 +15,7 @@ const markPrefix = `${marker}-${Date.now()}`;
const reHelpers = {
template: `.| |\\t|\\n(?!(\\n|${markPrefix}))`,
- openTag: '<[a-zA-Z]+.*?>',
+ openTag: '<(?!iframe)[a-zA-Z]+.*?>',
closeTag: '</.+>',
};
const reTemplated = new RegExp(`(^${wrapPrefix}(${reHelpers.template})+?${wrapPostfix}$)`, 'gm');
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index f2b05946a08..b51951674d5 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -31,6 +31,15 @@ export default class TaskList {
init() {
this.disable(); // Prevent duplicate event bindings
+ const taskListFields = document.querySelectorAll(
+ `${this.taskListContainerSelector} .js-task-list-field[data-value]`,
+ );
+
+ taskListFields.forEach(taskListField => {
+ // eslint-disable-next-line no-param-reassign
+ taskListField.value = taskListField.dataset.value;
+ });
+
$(this.taskListContainerSelector).taskList('enable');
$(document).on('tasklist:changed', this.taskListContainerSelector, this.updateHandler);
}
diff --git a/app/assets/javascripts/tooltips/index.js b/app/assets/javascripts/tooltips/index.js
index cfbd88d6c40..debb36dc53f 100644
--- a/app/assets/javascripts/tooltips/index.js
+++ b/app/assets/javascripts/tooltips/index.js
@@ -58,6 +58,8 @@ const applyToElements = (elements, handler) => toArray(elements).forEach(handler
const invokeBootstrapApi = (elements, method) => {
if (isFunction(elements.tooltip)) {
+ elements.tooltip(method);
+ } else {
jQuery(elements).tooltip(method);
}
};
diff --git a/app/assets/javascripts/user_lists/components/add_user_modal.vue b/app/assets/javascripts/user_lists/components/add_user_modal.vue
new file mode 100644
index 00000000000..a8dde1f681e
--- /dev/null
+++ b/app/assets/javascripts/user_lists/components/add_user_modal.vue
@@ -0,0 +1,72 @@
+<script>
+import { GlModal, GlFormGroup, GlFormTextarea } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { ADD_USER_MODAL_ID } from '../constants/show';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormTextarea,
+ GlModal,
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ modalOptions: {
+ actionPrimary: {
+ text: s__('UserLists|Add'),
+ attributes: [{ 'data-testid': 'confirm-add-user-ids' }],
+ },
+ actionCancel: {
+ text: s__('UserLists|Cancel'),
+ attributes: [{ 'data-testid': 'cancel-add-user-ids' }],
+ },
+ modalId: ADD_USER_MODAL_ID,
+ static: true,
+ },
+ translations: {
+ title: s__('UserLists|Add users'),
+ description: s__(
+ 'UserLists|Enter a comma separated list of user IDs. These IDs should be the users of the system in which the feature flag is set, not GitLab IDs',
+ ),
+ userIdsLabel: s__('UserLists|User IDs'),
+ },
+ data() {
+ return {
+ userIds: '',
+ };
+ },
+ methods: {
+ submitUsers() {
+ this.$emit('addUsers', this.userIds);
+ this.clearInput();
+ },
+ clearInput() {
+ this.userIds = '';
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ v-bind="$options.modalOptions"
+ :visible="visible"
+ data-testid="add-users-modal"
+ @primary="submitUsers"
+ @canceled="clearInput"
+ >
+ <template #modal-title>
+ {{ $options.translations.title }}
+ </template>
+ <template #default>
+ <p data-testid="add-userids-description">{{ $options.translations.description }}</p>
+ <gl-form-group label-for="add-user-ids" :label="$options.translations.userIdsLabel">
+ <gl-form-textarea id="add-user-ids" v-model="userIds" />
+ </gl-form-group>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/user_lists/components/edit_user_list.vue b/app/assets/javascripts/user_lists/components/edit_user_list.vue
new file mode 100644
index 00000000000..d56c3d61027
--- /dev/null
+++ b/app/assets/javascripts/user_lists/components/edit_user_list.vue
@@ -0,0 +1,74 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import statuses from '../constants/edit';
+import UserListForm from './user_list_form.vue';
+
+export default {
+ components: {
+ GlAlert,
+ GlLoadingIcon,
+ UserListForm,
+ },
+ inject: ['userListsDocsPath'],
+ translations: {
+ saveButtonLabel: s__('UserLists|Save'),
+ },
+ computed: {
+ ...mapState(['userList', 'status', 'errorMessage']),
+ title() {
+ return sprintf(s__('UserLists|Edit %{name}'), { name: this.userList?.name });
+ },
+ isLoading() {
+ return this.status === statuses.LOADING;
+ },
+ isError() {
+ return this.status === statuses.ERROR;
+ },
+ hasUserList() {
+ return Boolean(this.userList);
+ },
+ },
+ mounted() {
+ this.fetchUserList();
+ },
+ methods: {
+ ...mapActions(['fetchUserList', 'updateUserList', 'dismissErrorAlert']),
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert
+ v-if="isError"
+ :dismissible="hasUserList"
+ variant="danger"
+ @dismiss="dismissErrorAlert"
+ >
+ <ul class="gl-mb-0">
+ <li v-for="(message, index) in errorMessage" :key="index">
+ {{ message }}
+ </li>
+ </ul>
+ </gl-alert>
+
+ <gl-loading-icon v-if="isLoading" size="xl" />
+
+ <template v-else-if="hasUserList">
+ <h3
+ data-testid="user-list-title"
+ class="gl-font-weight-bold gl-pb-5 gl-border-b-solid gl-border-gray-100 gl-border-1"
+ >
+ {{ title }}
+ </h3>
+ <user-list-form
+ :cancel-path="userList.path"
+ :save-button-label="$options.translations.saveButtonLabel"
+ :user-lists-docs-path="userListsDocsPath"
+ :user-list="userList"
+ @submit="updateUserList"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/user_lists/components/new_user_list.vue b/app/assets/javascripts/user_lists/components/new_user_list.vue
new file mode 100644
index 00000000000..522e077fb25
--- /dev/null
+++ b/app/assets/javascripts/user_lists/components/new_user_list.vue
@@ -0,0 +1,50 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import { GlAlert } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import UserListForm from './user_list_form.vue';
+
+export default {
+ components: {
+ GlAlert,
+ UserListForm,
+ },
+ inject: ['userListsDocsPath', 'featureFlagsPath'],
+ translations: {
+ pageTitle: s__('UserLists|New list'),
+ createButtonLabel: s__('UserLists|Create'),
+ },
+ computed: {
+ ...mapState(['userList', 'errorMessage']),
+ isError() {
+ return Array.isArray(this.errorMessage) && this.errorMessage.length > 0;
+ },
+ },
+ methods: {
+ ...mapActions(['createUserList', 'dismissErrorAlert']),
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert v-if="isError" variant="danger" @dismiss="dismissErrorAlert">
+ <ul class="gl-mb-0">
+ <li v-for="(message, index) in errorMessage" :key="index">
+ {{ message }}
+ </li>
+ </ul>
+ </gl-alert>
+
+ <h3 class="gl-font-weight-bold gl-pb-5 gl-border-b-solid gl-border-gray-100 gl-border-1">
+ {{ $options.translations.pageTitle }}
+ </h3>
+
+ <user-list-form
+ :cancel-path="featureFlagsPath"
+ :save-button-label="$options.translations.createButtonLabel"
+ :user-lists-docs-path="userListsDocsPath"
+ :user-list="userList"
+ @submit="createUserList"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/user_lists/components/user_list.vue b/app/assets/javascripts/user_lists/components/user_list.vue
new file mode 100644
index 00000000000..0e2b72c1423
--- /dev/null
+++ b/app/assets/javascripts/user_lists/components/user_list.vue
@@ -0,0 +1,142 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import {
+ GlAlert,
+ GlButton,
+ GlEmptyState,
+ GlLoadingIcon,
+ GlModalDirective as GlModal,
+} from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import { states, ADD_USER_MODAL_ID } from '../constants/show';
+import AddUserModal from './add_user_modal.vue';
+
+const commonTableClasses = ['gl-py-5', 'gl-border-b-1', 'gl-border-b-solid', 'gl-border-gray-100'];
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ GlEmptyState,
+ GlLoadingIcon,
+ AddUserModal,
+ },
+ directives: {
+ GlModal,
+ },
+ props: {
+ emptyStatePath: {
+ required: true,
+ type: String,
+ },
+ },
+ translations: {
+ addUserButtonLabel: s__('UserLists|Add Users'),
+ emptyStateTitle: s__('UserLists|There are no users'),
+ emptyStateDescription: s__(
+ 'UserLists|Define a set of users to be used within feature flag strategies',
+ ),
+ userIdLabel: s__('UserLists|User IDs'),
+ userIdColumnHeader: s__('UserLists|User ID'),
+ errorMessage: __('Something went wrong on our end. Please try again!'),
+ editButtonLabel: s__('UserLists|Edit'),
+ },
+ classes: {
+ headerClasses: [
+ 'gl-display-flex',
+ 'gl-justify-content-space-between',
+ 'gl-pb-5',
+ 'gl-border-b-1',
+ 'gl-border-b-solid',
+ 'gl-border-gray-100',
+ ].join(' '),
+ tableHeaderClasses: commonTableClasses.join(' '),
+ tableRowClasses: [
+ ...commonTableClasses,
+ 'gl-display-flex',
+ 'gl-justify-content-space-between',
+ 'gl-align-items-center',
+ ].join(' '),
+ },
+ ADD_USER_MODAL_ID,
+ computed: {
+ ...mapState(['userList', 'userIds', 'state']),
+ name() {
+ return this.userList?.name ?? '';
+ },
+ hasUserIds() {
+ return this.userIds.length > 0;
+ },
+ isLoading() {
+ return this.state === states.LOADING;
+ },
+ hasError() {
+ return this.state === states.ERROR;
+ },
+ editPath() {
+ return this.userList?.edit_path;
+ },
+ },
+ mounted() {
+ this.fetchUserList();
+ },
+ methods: {
+ ...mapActions(['fetchUserList', 'dismissErrorAlert', 'removeUserId', 'addUserIds']),
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert v-if="hasError" variant="danger" @dismiss="dismissErrorAlert">
+ {{ $options.translations.errorMessage }}
+ </gl-alert>
+ <gl-loading-icon v-if="isLoading" size="xl" class="gl-mt-6" />
+ <div v-else>
+ <add-user-modal @addUsers="addUserIds" />
+ <div :class="$options.classes.headerClasses">
+ <div>
+ <h3>{{ name }}</h3>
+ <h4 class="gl-text-gray-500">{{ $options.translations.userIdLabel }}</h4>
+ </div>
+ <div class="gl-mt-6">
+ <gl-button v-if="editPath" :href="editPath" data-testid="edit-user-list" class="gl-mr-3">
+ {{ $options.translations.editButtonLabel }}
+ </gl-button>
+ <gl-button
+ v-gl-modal="$options.ADD_USER_MODAL_ID"
+ data-testid="add-users"
+ variant="success"
+ >
+ {{ $options.translations.addUserButtonLabel }}
+ </gl-button>
+ </div>
+ </div>
+ <div v-if="hasUserIds">
+ <div :class="$options.classes.tableHeaderClasses">
+ {{ $options.translations.userIdColumnHeader }}
+ </div>
+ <div
+ v-for="id in userIds"
+ :key="id"
+ data-testid="user-id-row"
+ :class="$options.classes.tableRowClasses"
+ >
+ <span data-testid="user-id">{{ id }}</span>
+ <gl-button
+ category="secondary"
+ variant="danger"
+ icon="remove"
+ data-testid="delete-user-id"
+ @click="removeUserId(id)"
+ />
+ </div>
+ </div>
+ <gl-empty-state
+ v-else
+ :title="$options.translations.emptyStateTitle"
+ :description="$options.translations.emptyStateDescription"
+ :svg-path="emptyStatePath"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/user_lists/components/user_list_form.vue b/app/assets/javascripts/user_lists/components/user_list_form.vue
new file mode 100644
index 00000000000..657acb51fee
--- /dev/null
+++ b/app/assets/javascripts/user_lists/components/user_list_form.vue
@@ -0,0 +1,97 @@
+<script>
+import { GlButton, GlFormGroup, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ cancelPath: {
+ type: String,
+ required: true,
+ },
+ saveButtonLabel: {
+ type: String,
+ required: true,
+ },
+ userListsDocsPath: {
+ type: String,
+ required: true,
+ },
+ userList: {
+ type: Object,
+ required: true,
+ },
+ },
+ classes: {
+ actionContainer: [
+ 'gl-py-5',
+ 'gl-display-flex',
+ 'gl-justify-content-space-between',
+ 'gl-px-4',
+ 'gl-border-t-solid',
+ 'gl-border-gray-100',
+ 'gl-border-1',
+ 'gl-bg-gray-10',
+ ],
+ },
+ translations: {
+ formLabel: s__('UserLists|Feature flag list'),
+ formSubtitle: s__(
+ 'UserLists|Lists allow you to define a set of users to be used with feature flags. %{linkStart}Read more about feature flag lists.%{linkEnd}',
+ ),
+ nameLabel: s__('UserLists|Name'),
+ cancelButtonLabel: s__('UserLists|Cancel'),
+ },
+ data() {
+ return {
+ name: this.userList.name,
+ };
+ },
+ methods: {
+ submit() {
+ this.$emit('submit', { ...this.userList, name: this.name });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div class="gl-display-flex gl-mt-7">
+ <div class="gl-flex-basis-0 gl-mr-7">
+ <h4 class="gl-min-width-fit-content gl-white-space-nowrap">
+ {{ $options.translations.formLabel }}
+ </h4>
+ <gl-sprintf :message="$options.translations.formSubtitle" class="gl-text-gray-500">
+ <template #link="{ content }">
+ <gl-link :href="userListsDocsPath" data-testid="user-list-docs-link">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ <div class="gl-flex-fill-1 gl-ml-7">
+ <gl-form-group
+ label-for="user-list-name"
+ :label="$options.translations.nameLabel"
+ class="gl-mb-7"
+ >
+ <gl-form-input id="user-list-name" v-model="name" data-testid="user-list-name" required />
+ </gl-form-group>
+ <div :class="$options.classes.actionContainer">
+ <gl-button variant="success" data-testid="save-user-list" @click="submit">
+ {{ saveButtonLabel }}
+ </gl-button>
+ <gl-button :href="cancelPath" data-testid="user-list-cancel">
+ {{ $options.translations.cancelButtonLabel }}
+ </gl-button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/user_lists/constants/edit.js b/app/assets/javascripts/user_lists/constants/edit.js
new file mode 100644
index 00000000000..33378f0d39f
--- /dev/null
+++ b/app/assets/javascripts/user_lists/constants/edit.js
@@ -0,0 +1,6 @@
+export default Object.freeze({
+ LOADING: 'LOADING',
+ SUCCESS: 'SUCCESS',
+ ERROR: 'ERROR',
+ UNSYNCED: 'UNSYNCED',
+});
diff --git a/app/assets/javascripts/user_lists/constants/show.js b/app/assets/javascripts/user_lists/constants/show.js
new file mode 100644
index 00000000000..045375d5900
--- /dev/null
+++ b/app/assets/javascripts/user_lists/constants/show.js
@@ -0,0 +1,8 @@
+export const states = Object.freeze({
+ LOADING: 'LOADING',
+ SUCCESS: 'SUCCESS',
+ ERROR: 'ERROR',
+ ERROR_DISMISSED: 'ERROR_DISMISSED',
+});
+
+export const ADD_USER_MODAL_ID = 'add-userids-modal';
diff --git a/app/assets/javascripts/user_lists/store/edit/actions.js b/app/assets/javascripts/user_lists/store/edit/actions.js
new file mode 100644
index 00000000000..8f0a2bafec7
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/edit/actions.js
@@ -0,0 +1,22 @@
+import Api from '~/api';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { getErrorMessages } from '../utils';
+import * as types from './mutation_types';
+
+export const fetchUserList = ({ commit, state }) => {
+ commit(types.REQUEST_USER_LIST);
+ return Api.fetchFeatureFlagUserList(state.projectId, state.userListIid)
+ .then(({ data }) => commit(types.RECEIVE_USER_LIST_SUCCESS, data))
+ .catch(response => commit(types.RECEIVE_USER_LIST_ERROR, getErrorMessages(response)));
+};
+
+export const dismissErrorAlert = ({ commit }) => commit(types.DISMISS_ERROR_ALERT);
+
+export const updateUserList = ({ commit, state }, userList) => {
+ return Api.updateFeatureFlagUserList(state.projectId, {
+ iid: userList.iid,
+ name: userList.name,
+ })
+ .then(({ data }) => redirectTo(data.path))
+ .catch(response => commit(types.RECEIVE_USER_LIST_ERROR, getErrorMessages(response)));
+};
diff --git a/app/assets/javascripts/user_lists/store/edit/index.js b/app/assets/javascripts/user_lists/store/edit/index.js
new file mode 100644
index 00000000000..b30b0b04b9e
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/edit/index.js
@@ -0,0 +1,11 @@
+import Vuex from 'vuex';
+import createState from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default initialState =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state: createState(initialState),
+ });
diff --git a/app/assets/javascripts/user_lists/store/edit/mutation_types.js b/app/assets/javascripts/user_lists/store/edit/mutation_types.js
new file mode 100644
index 00000000000..8b572e36839
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/edit/mutation_types.js
@@ -0,0 +1,5 @@
+export const REQUEST_USER_LIST = 'REQUEST_USER_LIST';
+export const RECEIVE_USER_LIST_SUCCESS = 'RECEIVE_USER_LIST_SUCCESS';
+export const RECEIVE_USER_LIST_ERROR = 'RECEIVE_USER_LIST_ERROR';
+
+export const DISMISS_ERROR_ALERT = 'DISMISS_ERROR_ALERT';
diff --git a/app/assets/javascripts/user_lists/store/edit/mutations.js b/app/assets/javascripts/user_lists/store/edit/mutations.js
new file mode 100644
index 00000000000..8a202885069
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/edit/mutations.js
@@ -0,0 +1,19 @@
+import statuses from '../../constants/edit';
+import * as types from './mutation_types';
+
+export default {
+ [types.REQUEST_USER_LIST](state) {
+ state.status = statuses.LOADING;
+ },
+ [types.RECEIVE_USER_LIST_SUCCESS](state, userList) {
+ state.status = statuses.SUCCESS;
+ state.userList = userList;
+ },
+ [types.RECEIVE_USER_LIST_ERROR](state, error) {
+ state.status = statuses.ERROR;
+ state.errorMessage = error;
+ },
+ [types.DISMISS_ERROR_ALERT](state) {
+ state.status = statuses.UNSYNCED;
+ },
+};
diff --git a/app/assets/javascripts/user_lists/store/edit/state.js b/app/assets/javascripts/user_lists/store/edit/state.js
new file mode 100644
index 00000000000..66fbe3c2ba9
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/edit/state.js
@@ -0,0 +1,9 @@
+import statuses from '../../constants/edit';
+
+export default ({ projectId = '', userListIid = '' }) => ({
+ status: statuses.LOADING,
+ projectId,
+ userListIid,
+ userList: null,
+ errorMessage: [],
+});
diff --git a/app/assets/javascripts/user_lists/store/new/actions.js b/app/assets/javascripts/user_lists/store/new/actions.js
new file mode 100644
index 00000000000..185508bcfbc
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/new/actions.js
@@ -0,0 +1,15 @@
+import Api from '~/api';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { getErrorMessages } from '../utils';
+import * as types from './mutation_types';
+
+export const dismissErrorAlert = ({ commit }) => commit(types.DISMISS_ERROR_ALERT);
+
+export const createUserList = ({ commit, state }, userList) => {
+ return Api.createFeatureFlagUserList(state.projectId, {
+ ...state.userList,
+ ...userList,
+ })
+ .then(({ data }) => redirectTo(data.path))
+ .catch(response => commit(types.RECEIVE_CREATE_USER_LIST_ERROR, getErrorMessages(response)));
+};
diff --git a/app/assets/javascripts/user_lists/store/new/index.js b/app/assets/javascripts/user_lists/store/new/index.js
new file mode 100644
index 00000000000..b30b0b04b9e
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/new/index.js
@@ -0,0 +1,11 @@
+import Vuex from 'vuex';
+import createState from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default initialState =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state: createState(initialState),
+ });
diff --git a/app/assets/javascripts/user_lists/store/new/mutation_types.js b/app/assets/javascripts/user_lists/store/new/mutation_types.js
new file mode 100644
index 00000000000..9a5ce6e99f5
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/new/mutation_types.js
@@ -0,0 +1,3 @@
+export const RECEIVE_CREATE_USER_LIST_ERROR = 'RECEIVE_CREATE_USER_LIST_ERROR';
+
+export const DISMISS_ERROR_ALERT = 'DISMISS_ERROR_ALERT';
diff --git a/app/assets/javascripts/user_lists/store/new/mutations.js b/app/assets/javascripts/user_lists/store/new/mutations.js
new file mode 100644
index 00000000000..d7c1276bd72
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/new/mutations.js
@@ -0,0 +1,10 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.RECEIVE_CREATE_USER_LIST_ERROR](state, error) {
+ state.errorMessage = error;
+ },
+ [types.DISMISS_ERROR_ALERT](state) {
+ state.errorMessage = '';
+ },
+};
diff --git a/app/assets/javascripts/user_lists/store/new/state.js b/app/assets/javascripts/user_lists/store/new/state.js
new file mode 100644
index 00000000000..0fa73b4ffc1
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/new/state.js
@@ -0,0 +1,5 @@
+export default ({ projectId = '' }) => ({
+ projectId,
+ userList: { name: '', user_xids: '' },
+ errorMessage: [],
+});
diff --git a/app/assets/javascripts/user_lists/store/show/actions.js b/app/assets/javascripts/user_lists/store/show/actions.js
new file mode 100644
index 00000000000..15b971aa5e8
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/show/actions.js
@@ -0,0 +1,32 @@
+import Api from '~/api';
+import { stringifyUserIds } from '../utils';
+import * as types from './mutation_types';
+
+export const fetchUserList = ({ commit, state }) => {
+ commit(types.REQUEST_USER_LIST);
+ return Api.fetchFeatureFlagUserList(state.projectId, state.userListIid)
+ .then(response => commit(types.RECEIVE_USER_LIST_SUCCESS, response.data))
+ .catch(() => commit(types.RECEIVE_USER_LIST_ERROR));
+};
+
+export const dismissErrorAlert = ({ commit }) => commit(types.DISMISS_ERROR_ALERT);
+export const addUserIds = ({ dispatch, commit }, userIds) => {
+ commit(types.ADD_USER_IDS, userIds);
+ return dispatch('updateUserList');
+};
+
+export const removeUserId = ({ commit, dispatch }, userId) => {
+ commit(types.REMOVE_USER_ID, userId);
+ return dispatch('updateUserList');
+};
+
+export const updateUserList = ({ commit, state }) => {
+ commit(types.REQUEST_USER_LIST);
+
+ return Api.updateFeatureFlagUserList(state.projectId, {
+ ...state.userList,
+ user_xids: stringifyUserIds(state.userIds),
+ })
+ .then(response => commit(types.RECEIVE_USER_LIST_SUCCESS, response.data))
+ .catch(() => commit(types.RECEIVE_USER_LIST_ERROR));
+};
diff --git a/app/assets/javascripts/user_lists/store/show/index.js b/app/assets/javascripts/user_lists/store/show/index.js
new file mode 100644
index 00000000000..b30b0b04b9e
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/show/index.js
@@ -0,0 +1,11 @@
+import Vuex from 'vuex';
+import createState from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default initialState =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state: createState(initialState),
+ });
diff --git a/app/assets/javascripts/user_lists/store/show/mutation_types.js b/app/assets/javascripts/user_lists/store/show/mutation_types.js
new file mode 100644
index 00000000000..fb967f06beb
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/show/mutation_types.js
@@ -0,0 +1,8 @@
+export const REQUEST_USER_LIST = 'REQUEST_USER_LIST';
+export const RECEIVE_USER_LIST_SUCCESS = 'RECEIVE_USER_LIST_SUCCESS';
+export const RECEIVE_USER_LIST_ERROR = 'RECEIVE_USER_LIST_ERROR';
+
+export const DISMISS_ERROR_ALERT = 'DISMISS_ERROR_ALERT';
+
+export const ADD_USER_IDS = 'ADD_USER_IDS';
+export const REMOVE_USER_ID = 'REMOVE_USER_ID';
diff --git a/app/assets/javascripts/user_lists/store/show/mutations.js b/app/assets/javascripts/user_lists/store/show/mutations.js
new file mode 100644
index 00000000000..c3e766465a7
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/show/mutations.js
@@ -0,0 +1,29 @@
+import { states } from '../../constants/show';
+import * as types from './mutation_types';
+import { parseUserIds } from '../utils';
+
+export default {
+ [types.REQUEST_USER_LIST](state) {
+ state.state = states.LOADING;
+ },
+ [types.RECEIVE_USER_LIST_SUCCESS](state, userList) {
+ state.state = states.SUCCESS;
+ state.userIds = userList.user_xids?.length > 0 ? parseUserIds(userList.user_xids) : [];
+ state.userList = userList;
+ },
+ [types.RECEIVE_USER_LIST_ERROR](state) {
+ state.state = states.ERROR;
+ },
+ [types.DISMISS_ERROR_ALERT](state) {
+ state.state = states.ERROR_DISMISSED;
+ },
+ [types.ADD_USER_IDS](state, ids) {
+ state.userIds = [
+ ...state.userIds,
+ ...parseUserIds(ids).filter(id => id && !state.userIds.includes(id)),
+ ];
+ },
+ [types.REMOVE_USER_ID](state, id) {
+ state.userIds = state.userIds.filter(uid => uid !== id);
+ },
+};
diff --git a/app/assets/javascripts/user_lists/store/show/state.js b/app/assets/javascripts/user_lists/store/show/state.js
new file mode 100644
index 00000000000..a5780893ccb
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/show/state.js
@@ -0,0 +1,9 @@
+import { states } from '../../constants/show';
+
+export default ({ projectId = '', userListIid = '' }) => ({
+ state: states.LOADING,
+ projectId,
+ userListIid,
+ userIds: [],
+ userList: null,
+});
diff --git a/app/assets/javascripts/user_lists/store/utils.js b/app/assets/javascripts/user_lists/store/utils.js
new file mode 100644
index 00000000000..f4e46947759
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/utils.js
@@ -0,0 +1,5 @@
+export const parseUserIds = userIds => userIds.split(/\s*,\s*/g);
+
+export const stringifyUserIds = userIds => userIds.join(',');
+
+export const getErrorMessages = error => [].concat(error?.response?.data?.message ?? error.message);
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index c8f95dac48e..0636d79e6f2 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
-import { sanitize } from 'dompurify';
+import { sanitize } from '~/lib/dompurify';
import UsersCache from './lib/utils/users_cache';
import UserPopover from './vue_shared/components/user_popover/user_popover.vue';
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index 5f4260f26ff..20d1a3c1fcd 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -19,6 +19,7 @@ import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
function UsersSelect(currentUser, els, options = {}) {
+ const elsClassName = els?.toString().match('.(.+$)')[1];
const $els = $(els || '.js-user-search');
this.users = this.users.bind(this);
this.user = this.user.bind(this);
@@ -127,9 +128,16 @@ function UsersSelect(currentUser, els, options = {}) {
.find(`input[name='${$dropdown.data('fieldName')}'][value=${firstSelectedId}]`);
firstSelected.remove();
- emitSidebarEvent('sidebar.removeAssignee', {
- id: firstSelectedId,
- });
+
+ if ($dropdown.hasClass(elsClassName)) {
+ emitSidebarEvent('sidebar.removeReviewer', {
+ id: firstSelectedId,
+ });
+ } else {
+ emitSidebarEvent('sidebar.removeAssignee', {
+ id: firstSelectedId,
+ });
+ }
}
}
};
@@ -392,7 +400,11 @@ function UsersSelect(currentUser, els, options = {}) {
defaultLabel,
hidden() {
if ($dropdown.hasClass('js-multiselect')) {
- emitSidebarEvent('sidebar.saveAssignees');
+ if ($dropdown.hasClass(elsClassName)) {
+ emitSidebarEvent('sidebar.saveReviewers');
+ } else {
+ emitSidebarEvent('sidebar.saveAssignees');
+ }
}
if (!$dropdown.data('alwaysShowSelectbox')) {
@@ -428,10 +440,18 @@ function UsersSelect(currentUser, els, options = {}) {
previouslySelected.each((index, element) => {
element.remove();
});
- emitSidebarEvent('sidebar.removeAllAssignees');
+ if ($dropdown.hasClass(elsClassName)) {
+ emitSidebarEvent('sidebar.removeAllReviewers');
+ } else {
+ emitSidebarEvent('sidebar.removeAllAssignees');
+ }
} else if (isActive) {
// user selected
- emitSidebarEvent('sidebar.addAssignee', user);
+ if ($dropdown.hasClass(elsClassName)) {
+ emitSidebarEvent('sidebar.addReviewer', user);
+ } else {
+ emitSidebarEvent('sidebar.addAssignee', user);
+ }
// Remove unassigned selection (if it was previously selected)
const unassignedSelected = $dropdown
@@ -448,7 +468,11 @@ function UsersSelect(currentUser, els, options = {}) {
}
// User unselected
- emitSidebarEvent('sidebar.removeAssignee', user);
+ if ($dropdown.hasClass(elsClassName)) {
+ emitSidebarEvent('sidebar.removeReviewer', user);
+ } else {
+ emitSidebarEvent('sidebar.removeAssignee', user);
+ }
}
if (getSelected().find(u => u === gon.current_user_id)) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
index 208df03b6a4..b90cbfd1a1a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
@@ -74,9 +74,6 @@ export default {
canBeManuallyRedeployed() {
return this.computedDeploymentStatus === FAILED && Boolean(this.redeployPath);
},
- shouldShowManualButtons() {
- return this.glFeatures.deployFromFooter;
- },
hasExternalUrls() {
return Boolean(this.deployment.external_url && this.deployment.external_url_formatted);
},
@@ -154,7 +151,7 @@ export default {
<template>
<div>
<deployment-action-button
- v-if="shouldShowManualButtons && canBeManuallyDeployed"
+ v-if="canBeManuallyDeployed"
:action-in-progress="actionInProgress"
:actions-configuration="$options.actionsConfiguration[constants.DEPLOYING]"
:computed-deployment-status="computedDeploymentStatus"
@@ -165,7 +162,7 @@ export default {
<span>{{ $options.actionsConfiguration[constants.DEPLOYING].buttonText }}</span>
</deployment-action-button>
<deployment-action-button
- v-if="shouldShowManualButtons && canBeManuallyRedeployed"
+ v-if="canBeManuallyRedeployed"
:action-in-progress="actionInProgress"
:actions-configuration="$options.actionsConfiguration[constants.REDEPLOYING]"
:computed-deployment-status="computedDeploymentStatus"
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
index 814d4e8341e..eb8989adb2a 100644
--- 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
@@ -111,9 +111,10 @@ export default {
v-html="mr.sourceBranchLink"
/><clipboard-button
ref="copyBranchNameButton"
+ data-testid="mr-widget-copy-clipboard"
:text="branchNameClipboardData"
:title="__('Copy branch name')"
- css-class="btn-default btn-transparent btn-clipboard"
+ category="tertiary"
/>
{{ s__('mrWidget|into') }}
<tooltip-on-truncate
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
index 83e7d6db9fa..30da9947859 100644
--- 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
@@ -1,5 +1,5 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import eventHub from '../../event_hub';
import statusIcon from '../mr_widget_status_icon.vue';
@@ -8,6 +8,7 @@ export default {
components: {
statusIcon,
GlLoadingIcon,
+ GlButton,
},
props: {
mr: {
@@ -33,20 +34,21 @@ export default {
<template>
<div class="mr-widget-body media">
<status-icon status="warning" />
- <div class="media-body space-children">
+ <div class="media-body space-children gl-display-flex gl-flex-wrap gl-align-items-center">
<span class="bold">
<template v-if="mr.mergeError">{{ mr.mergeError }}</template>
{{ s__('mrWidget|This merge request failed to be merged automatically') }}
</span>
- <button
+ <gl-button
:disabled="isRefreshing"
- type="button"
- class="btn btn-sm btn-default"
+ category="secondary"
+ variant="default"
+ size="small"
@click="refreshWidget"
>
<gl-loading-icon v-if="isRefreshing" :inline="true" />
{{ s__('mrWidget|Refresh') }}
- </button>
+ </gl-button>
</div>
</div>
</template>
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
index 58839251edc..543d70cbdbe 100644
--- 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
@@ -177,7 +177,9 @@ export default {
<clipboard-button
:title="__('Copy commit SHA')"
:text="mr.mergeCommitSha"
- css-class="btn-default btn-transparent btn-clipboard js-mr-merged-copy-sha"
+ css-class="js-mr-merged-copy-sha"
+ category="tertiary"
+ size="small"
/>
</template>
</p>
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
index 83783528cc1..6489569cf68 100644
--- 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
@@ -1,13 +1,12 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
-import tooltip from '~/vue_shared/directives/tooltip';
import statusIcon from '../mr_widget_status_icon.vue';
export default {
name: 'MRWidgetMissingBranch',
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
@@ -52,7 +51,7 @@ export default {
<span class="bold js-branch-text">
<span class="capitalize"> {{ missingBranchName }} </span>
{{ s__('mrWidget|branch does not exist.') }} {{ missingBranchNameMessage }}
- <gl-icon v-tooltip :title="message" :aria-label="message" name="question-o" />
+ <gl-icon v-gl-tooltip :title="message" :aria-label="message" name="question-o" />
</span>
</div>
</div>
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
index ec0934c5b4b..14c2e9fa828 100644
--- 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
@@ -1,6 +1,6 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { escape } from 'lodash';
import simplePoll from '../../../lib/utils/simple_poll';
import eventHub from '../../event_hub';
@@ -12,7 +12,7 @@ export default {
name: 'MRWidgetRebase',
components: {
statusIcon,
- GlLoadingIcon,
+ GlButton,
},
props: {
mr: {
@@ -109,29 +109,29 @@ export default {
<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>
+ <span class="bold" data-testid="rebase-message">{{ __('Rebase in progress') }}</span>
</template>
<template v-if="!mr.rebaseInProgress && !mr.canPushToSourceBranch">
- <span class="bold" v-html="fastForwardMergeText"></span>
+ <span class="bold" data-testid="rebase-message" v-html="fastForwardMergeText"></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
- :disabled="isMakingRequest"
- type="button"
- class="btn btn-sm btn-reopen btn-success qa-mr-rebase-button"
+ <gl-button
+ :loading="isMakingRequest"
+ variant="success"
+ class="qa-mr-rebase-button"
@click="rebase"
>
- <gl-loading-icon v-if="isMakingRequest" />{{ __('Rebase') }}
- </button>
- <span v-if="!rebasingError" class="bold">{{
+ {{ __('Rebase') }}
+ </gl-button>
+ <span v-if="!rebasingError" class="bold" data-testid="rebase-message">{{
__(
'Fast-forward merge is not possible. Rebase the source branch onto the target branch.',
)
}}</span>
- <span v-else class="bold danger">{{ rebasingError }}</span>
+ <span v-else class="bold danger" data-testid="rebase-message">{{ rebasingError }}</span>
</div>
</template>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index 240bab58297..835f7b9e9a9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -1,12 +1,9 @@
<script>
-/* eslint-disable vue/no-v-html */
import { isEmpty } from 'lodash';
import { GlIcon, GlButton, GlSprintf, GlLink } from '@gitlab/ui';
-import successSvg from 'icons/_icon_status_success.svg';
-import warningSvg from 'icons/_icon_status_warning.svg';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
import simplePoll from '~/lib/utils/simple_poll';
-import { __, sprintf } from '~/locale';
+import { __ } from '~/locale';
import MergeRequest from '../../../merge_request';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { deprecatedCreateFlash as Flash } from '../../../flash';
@@ -59,8 +56,6 @@ export default {
commitMessage: this.mr.commitMessage,
squashBeforeMerge: this.mr.squashIsSelected,
isSquashReadOnly: this.mr.squashIsReadonly,
- successSvg,
- warningSvg,
squashCommitMessage: this.mr.squashCommitMessage,
};
},
@@ -147,16 +142,7 @@ export default {
return !this.mr.ffOnlyEnabled;
},
shaMismatchLink() {
- const href = this.mr.mergeRequestDiffsPath;
-
- return sprintf(
- __('New changes were added. %{linkStart}Reload the page to review them%{linkEnd}'),
- {
- linkStart: `<a href="${href}">`,
- linkEnd: '</a>',
- },
- false,
- );
+ return this.mr.mergeRequestDiffsPath;
},
},
methods: {
@@ -331,7 +317,7 @@ export default {
@click.prevent="handleMergeButtonClick(true)"
>
<span class="media">
- <span class="merge-opt-icon" aria-hidden="true" v-html="successSvg"></span>
+ <gl-icon name="status_success" class="merge-opt-icon" aria-hidden="true" />
<span class="media-body merge-opt-title">{{ autoMergeText }}</span>
</span>
</a>
@@ -349,7 +335,7 @@ export default {
@click.prevent="handleMergeImmediatelyButtonClick"
>
<span class="media">
- <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span>
+ <gl-icon name="status_warning" class="merge-opt-icon" aria-hidden="true" />
<span class="media-body merge-opt-title">{{ __('Merge immediately') }}</span>
</span>
</a>
@@ -400,7 +386,17 @@ export default {
</div>
<div v-if="mr.isSHAMismatch" class="d-flex align-items-center mt-2 js-sha-mismatch">
<gl-icon name="warning-solid" class="text-warning mr-1" />
- <span class="text-warning" v-html="shaMismatchLink"></span>
+ <span class="text-warning">
+ <gl-sprintf
+ :message="
+ __('New changes were added. %{linkStart}Reload the page to review them%{linkEnd}')
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="mr.mergeRequestDiffsPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index 61cc950f058..be9d37e4531 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -3,6 +3,7 @@ import $ from 'jquery';
import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
import { deprecatedCreateFlash as createFlash } from '~/flash';
+import MergeRequest from '~/merge_request';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import getStateQuery from '../../queries/get_state.query.graphql';
@@ -128,8 +129,7 @@ export default {
.then(res => res.data)
.then(data => {
eventHub.$emit('UpdateWidgetData', data);
- createFlash(__('The merge request can now be merged.'), 'notice');
- $('.merge-request .detail-page-description .title').text(this.mr.title);
+ MergeRequest.toggleDraftStatus(this.mr.title, true);
})
.catch(() => {
this.isMakingRequest = false;
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index 43ce748b41d..78ac9b6ac76 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -45,6 +45,7 @@ import GroupedTestReportsApp from '../reports/components/grouped_test_reports_ap
import { setFaviconOverlay } from '../lib/utils/common_utils';
import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue';
import getStateQuery from './queries/get_state.query.graphql';
+import { isExperimentEnabled } from '~/lib/utils/experimentation';
export default {
el: '#js-vue-mr-widget',
@@ -148,7 +149,7 @@ export default {
},
shouldSuggestPipelines() {
return (
- gon.features?.suggestPipeline &&
+ isExperimentEnabled('suggestPipeline') &&
!this.mr.hasCI &&
this.mr.mergeRequestAddCiConfigPath &&
!this.mr.isDismissedSuggestPipeline
diff --git a/app/assets/javascripts/vue_shared/components/alert_details_table.vue b/app/assets/javascripts/vue_shared/components/alert_details_table.vue
index c94e784c01e..a70b8e11a83 100644
--- a/app/assets/javascripts/vue_shared/components/alert_details_table.vue
+++ b/app/assets/javascripts/vue_shared/components/alert_details_table.vue
@@ -1,5 +1,6 @@
<script>
import { GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { reduce } from 'lodash';
import { s__ } from '~/locale';
import {
capitalizeFirstCharacter,
@@ -9,6 +10,22 @@ import {
const thClass = 'gl-bg-transparent! gl-border-1! gl-border-b-solid! gl-border-gray-200!';
const tdClass = 'gl-border-gray-100! gl-p-5!';
+const allowedFields = [
+ 'iid',
+ 'title',
+ 'severity',
+ 'status',
+ 'startedAt',
+ 'eventCount',
+ 'monitoringTool',
+ 'service',
+ 'description',
+ 'endedAt',
+ 'details',
+ 'environment',
+];
+
+const isAllowed = fieldName => allowedFields.includes(fieldName);
export default {
components: {
@@ -46,10 +63,16 @@ export default {
if (!this.alert) {
return [];
}
- return Object.entries(this.alert).map(([fieldName, value]) => ({
- fieldName,
- value,
- }));
+ return reduce(
+ this.alert,
+ (allowedItems, value, fieldName) => {
+ if (isAllowed(fieldName)) {
+ return [...allowedItems, { fieldName, value }];
+ }
+ return allowedItems;
+ },
+ [],
+ );
},
},
};
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 d7af3b3298e..1b7e51b7d02 100644
--- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
@@ -7,7 +7,7 @@ import CiIcon from './ci_icon.vue';
*
* Receives status object containing:
* status: {
- * details_path: "/gitlab-org/gitlab-foss/pipelines/8150156" // url
+ * details_path or detailsPath: "/gitlab-org/gitlab-foss/pipelines/8150156" // url
* group:"running" // used for CSS class
* icon: "icon_status_running" // used to render the icon
* label:"running" // used for potential tooltip
@@ -46,6 +46,13 @@ export default {
},
},
computed: {
+ title() {
+ return !this.showText ? this.status?.text : '';
+ },
+ detailsPath() {
+ // For now, this can either come from graphQL with camelCase or REST API in snake_case
+ return this.status.detailsPath || this.status.details_path;
+ },
cssClass() {
const className = this.status.group;
return className ? `ci-status ci-${className} qa-status-badge` : 'ci-status qa-status-badge';
@@ -54,12 +61,7 @@ export default {
};
</script>
<template>
- <a
- v-gl-tooltip
- :href="status.details_path"
- :class="cssClass"
- :title="!showText ? status.text : ''"
- >
+ <a v-gl-tooltip :href="detailsPath" :class="cssClass" :title="title">
<ci-icon :status="status" :css-classes="iconClasses" />
<template v-if="showText">
diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
index 0234b6bf848..960551fae91 100644
--- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -12,7 +12,7 @@
* css-class="btn-transparent"
* />
*/
-import { GlDeprecatedButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
export default {
name: 'ClipboardButton',
@@ -20,8 +20,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: {
- GlDeprecatedButton,
- GlIcon,
+ GlButton,
},
props: {
text: {
@@ -50,7 +49,17 @@ export default {
cssClass: {
type: String,
required: false,
- default: 'btn-default',
+ default: null,
+ },
+ category: {
+ type: String,
+ required: false,
+ default: 'secondary',
+ },
+ size: {
+ type: String,
+ required: false,
+ default: 'medium',
},
},
computed: {
@@ -65,13 +74,15 @@ export default {
</script>
<template>
- <gl-deprecated-button
+ <gl-button
v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }"
v-gl-tooltip.hover.blur
:class="cssClass"
:title="title"
:data-clipboard-text="clipboardText"
- >
- <gl-icon name="copy-to-clipboard" />
- </gl-deprecated-button>
+ :category="category"
+ :size="size"
+ icon="copy-to-clipboard"
+ :aria-label="__('Copy this value')"
+ />
</template>
diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue
index c1c8fb3a6e2..e01a651806d 100644
--- a/app/assets/javascripts/vue_shared/components/commit.vue
+++ b/app/assets/javascripts/vue_shared/components/commit.vue
@@ -139,7 +139,7 @@ export default {
<template>
<div class="branch-commit cgray">
<template v-if="shouldShowRefInfo">
- <div class="icon-container">
+ <div class="icon-container gl-display-inline-block">
<gl-icon v-if="tag" name="tag" />
<gl-icon v-else-if="mergeRequestRef" name="git-merge" />
<gl-icon v-else name="branch" />
diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_modal.vue
index e7f6cc1abc0..a42a606d446 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_modal.vue
@@ -12,6 +12,11 @@ export default {
type: String,
required: true,
},
+ handleSubmit: {
+ type: Function,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -41,7 +46,11 @@ export default {
this.$refs.modal.hide();
},
submitModal() {
- this.$refs.form.submit();
+ if (this.handleSubmit) {
+ this.handleSubmit(this.path);
+ } else {
+ this.$refs.form.submit();
+ }
},
},
csrf,
diff --git a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
index c7d7c3a1d24..2a28b13e7bf 100644
--- a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
+++ b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
@@ -22,7 +22,7 @@ export default {
},
data() {
return {
- isDismissed: 'false',
+ isDismissed: false,
};
},
computed: {
@@ -30,12 +30,12 @@ export default {
return `${slugifyWithUnderscore(this.featureName)}_feedback_dismissed`;
},
showAlert() {
- return this.isDismissed === 'false';
+ return !this.isDismissed;
},
},
methods: {
dismissFeedbackAlert() {
- this.isDismissed = 'true';
+ this.isDismissed = true;
},
},
};
@@ -43,16 +43,12 @@ export default {
<template>
<div v-show="showAlert">
- <local-storage-sync
- :value="isDismissed"
- :storage-key="storageKey"
- @input="dismissFeedbackAlert"
- />
+ <local-storage-sync v-model="isDismissed" :storage-key="storageKey" as-json />
<gl-alert v-if="showAlert" class="gl-mt-5" @dismiss="dismissFeedbackAlert">
<gl-sprintf
:message="
__(
- 'We’ve been making changes to %{featureName} and we’d love your feedback %{linkStart}in this issue%{linkEnd} to help us improve the experience.',
+ 'Please share your feedback about %{featureName} %{linkStart}in this issue%{linkEnd} to help us improve the experience.',
)
"
>
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue
index 7157337f8f3..300046dbb85 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue
@@ -1,7 +1,11 @@
<script>
+import { GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
+ components: {
+ GlIcon,
+ },
props: {
placeholderText: {
type: String,
@@ -41,5 +45,6 @@ export default {
autocomplete="off"
/>
<i class="fa fa-search dropdown-input-search" aria-hidden="true" data-hidden="true"> </i>
+ <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" data-hidden="true" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue
deleted file mode 100644
index 4d85726065b..00000000000
--- a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue
+++ /dev/null
@@ -1,92 +0,0 @@
-<script>
-import { GlDeprecatedButton, GlIcon } from '@gitlab/ui';
-
-export default {
- components: {
- GlIcon,
- GlDeprecatedButton,
- },
- props: {
- size: {
- type: String,
- required: false,
- default: '',
- },
- primaryButtonClass: {
- type: String,
- required: false,
- default: '',
- },
- dropdownClass: {
- type: String,
- required: false,
- default: '',
- },
- actions: {
- type: Array,
- required: true,
- },
- defaultAction: {
- type: Number,
- required: true,
- },
- },
- data() {
- return {
- selectedAction: this.defaultAction,
- };
- },
- computed: {
- selectedActionTitle() {
- return this.actions[this.selectedAction].title;
- },
- buttonSizeClass() {
- return `btn-${this.size}`;
- },
- },
- methods: {
- handlePrimaryActionClick() {
- this.$emit('onActionClick', this.actions[this.selectedAction]);
- },
- handleActionClick(selectedAction) {
- this.selectedAction = selectedAction;
- this.$emit('onActionSelect', selectedAction);
- },
- },
-};
-</script>
-
-<template>
- <div class="btn-group droplab-dropdown comment-type-dropdown">
- <gl-deprecated-button
- :class="primaryButtonClass"
- :size="size"
- @click.prevent="handlePrimaryActionClick"
- >
- {{ selectedActionTitle }}
- </gl-deprecated-button>
- <button
- :class="buttonSizeClass"
- type="button"
- class="btn dropdown-toggle pl-2 pr-2"
- data-display="static"
- data-toggle="dropdown"
- >
- <gl-icon name="chevron-down" :aria-label="__('toggle dropdown')" />
- </button>
- <ul :class="dropdownClass" class="dropdown-menu dropdown-open-top">
- <template v-for="(action, index) in actions">
- <li :key="index" :class="{ 'droplab-item-selected': selectedAction === index }">
- <gl-deprecated-button class="btn-transparent" @click.prevent="handleActionClick(index)">
- <i aria-hidden="true" class="fa fa-check icon"> </i>
- <div class="description">
- <strong>{{ action.title }}</strong>
- <p>{{ action.description }}</p>
- </div>
- </gl-deprecated-button>
- </li>
- <li v-if="index === 0" :key="`${index}-separator`" class="divider droplab-item-ignore"></li>
- </template>
- </ul>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
index 012aca8105a..386df617d47 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -230,13 +230,12 @@ export default {
@keydown="onKeydown($event)"
@keyup="onKeyup($event)"
/>
- <i
- :class="{
- hidden: showClearInputButton,
- }"
+ <gl-icon
+ name="search"
+ class="dropdown-input-search"
+ :class="{ hidden: showClearInputButton }"
aria-hidden="true"
- class="fa fa-search dropdown-input-search"
- ></i>
+ />
<gl-icon
name="close"
class="dropdown-input-clear"
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
index b70f093e930..91a0ac3aa92 100644
--- 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
@@ -9,6 +9,12 @@ const fileExtensionIcons = {
'md.rendered': 'markdown',
markdown: 'markdown',
'markdown.rendered': 'markdown',
+ mdown: 'markdown',
+ 'mdown.rendered': 'markdown',
+ mkd: 'markdown',
+ 'mkd.rendered': 'markdown',
+ mkdn: 'markdown',
+ 'mkdn.rendered': 'markdown',
rst: 'markdown',
blink: 'blink',
css: 'css',
diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
index da4b0aedef5..e895a7a52ab 100644
--- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
@@ -1,5 +1,5 @@
<script>
-import { escape } from 'lodash';
+import { escape, last } from 'lodash';
import Tribute from 'tributejs';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
@@ -12,6 +12,8 @@ const AutoComplete = {
MergeRequests: 'mergeRequests',
};
+const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings
+
function doesCurrentLineStartWith(searchString, fullText, selectionStart) {
const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length;
const currentLine = fullText.split('\n')[currentLineNumber - 1];
@@ -74,30 +76,40 @@ const autoCompleteMap = {
return this.members;
},
menuItemTemplate({ original }) {
- const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : '';
-
- const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass}
- gl-display-inline-flex! gl-align-items-center gl-justify-content-center`;
-
- const avatarTag = original.avatar_url
- ? `<img
- src="${original.avatar_url}"
- alt="${original.username}'s avatar"
- class="${avatarClasses}"/>`
- : `<div class="${avatarClasses}">${original.username.charAt(0).toUpperCase()}</div>`;
-
- const name = escape(original.name);
+ const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0';
+ const noAvatarClasses = `${commonClasses} gl-rounded-small
+ gl-display-flex gl-align-items-center gl-justify-content-center`;
+
+ const avatar = original.avatar_url
+ ? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />`
+ : `<div class="${noAvatarClasses}" aria-hidden="true">
+ ${original.username.charAt(0).toUpperCase()}</div>`;
+
+ let displayName = original.name;
+ let parentGroupOrUsername = `@${original.username}`;
+
+ if (original.type === groupType) {
+ const splitName = original.name.split(' / ');
+ displayName = splitName.pop();
+ parentGroupOrUsername = splitName.pop();
+ }
const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : '';
- const icon = original.mentionsDisabled
- ? spriteIcon('notifications-off', 's16 gl-vertical-align-middle gl-ml-3')
+ const disabledMentionsIcon = original.mentionsDisabled
+ ? spriteIcon('notifications-off', 's16 gl-ml-3')
: '';
- return `${avatarTag}
- ${original.username}
- <small class="gl-text-small gl-font-weight-normal gl-reset-color">${name}${count}</small>
- ${icon}`;
+ return `
+ <div class="gl-display-flex gl-align-items-center">
+ ${avatar}
+ <div class="gl-font-sm gl-line-height-normal gl-ml-3">
+ <div>${escape(displayName)}${count}</div>
+ <div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div>
+ </div>
+ ${disabledMentionsIcon}
+ </div>
+ `;
},
},
[AutoComplete.MergeRequests]: {
@@ -134,7 +146,8 @@ export default {
{
trigger: '@',
fillAttr: 'username',
- lookup: value => value.name + value.username,
+ lookup: value =>
+ value.type === groupType ? last(value.name.split(' / ')) : value.name + value.username,
menuItemTemplate: autoCompleteMap[AutoComplete.Members].menuItemTemplate,
values: this.getValues(AutoComplete.Members),
},
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 6ff6f10f786..4679d922861 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -1,10 +1,11 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlTooltipDirective, GlLink, GlDeprecatedButton } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
+import { GlTooltipDirective, GlLink, GlDeprecatedButton, GlTooltip } from '@gitlab/ui';
import CiIconBadge from './ci_badge_link.vue';
import TimeagoTooltip from './time_ago_tooltip.vue';
import UserAvatarImage from './user_avatar/user_avatar_image.vue';
+import { glEmojiTag } from '../../emoji';
+import { __, sprintf } from '../../locale';
/**
* Renders header component for job and pipeline page based on UI mockups
@@ -20,10 +21,12 @@ export default {
UserAvatarImage,
GlLink,
GlDeprecatedButton,
+ GlTooltip,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ EMOJI_REF: 'EMOJI_REF',
props: {
status: {
type: Object,
@@ -62,6 +65,27 @@ export default {
userAvatarAltText() {
return sprintf(__(`%{username}'s avatar`), { username: this.user.name });
},
+ userPath() {
+ // GraphQL returns `webPath` and Rest `path`
+ return this.user?.webPath || this.user?.path;
+ },
+ avatarUrl() {
+ // GraphQL returns `avatarUrl` and Rest `avatar_url`
+ return this.user?.avatarUrl || this.user?.avatar_url;
+ },
+ statusTooltipHTML() {
+ // Rest `status_tooltip_html` which is a ready to work
+ // html for the emoji and the status text inside a tooltip.
+ // GraphQL returns `status.emoji` and `status.message` which
+ // needs to be combined to make the html we want.
+ const { emoji } = this.user?.status || {};
+ const emojiHtml = emoji ? glEmojiTag(emoji) : '';
+
+ return emojiHtml || this.user?.status_tooltip_html;
+ },
+ message() {
+ return this.user?.status?.message;
+ },
},
methods: {
@@ -73,7 +97,7 @@ export default {
</script>
<template>
- <header class="page-content-header ci-header-container">
+ <header class="page-content-header ci-header-container" data-testid="pipeline-header-content">
<section class="header-main-content">
<ci-icon-badge :status="status" />
@@ -89,12 +113,12 @@ export default {
<template v-if="user">
<gl-link
v-gl-tooltip
- :href="user.path"
+ :href="userPath"
:title="user.email"
class="js-user-link commit-committer-link"
>
<user-avatar-image
- :img-src="user.avatar_url"
+ :img-src="avatarUrl"
:img-alt="userAvatarAltText"
:tooltip-text="user.name"
:img-size="24"
@@ -102,7 +126,15 @@ export default {
{{ user.name }}
</gl-link>
- <span v-if="user.status_tooltip_html" v-html="user.status_tooltip_html"></span>
+ <gl-tooltip v-if="message" :target="() => $refs[$options.EMOJI_REF]">
+ {{ message }}
+ </gl-tooltip>
+ <span
+ v-if="statusTooltipHTML"
+ :ref="$options.EMOJI_REF"
+ :data-testid="message"
+ v-html="statusTooltipHTML"
+ ></span>
</template>
</section>
diff --git a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
index b5d6b872547..59155bd4ddc 100644
--- a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
+++ b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
@@ -1,4 +1,6 @@
<script>
+import { isEqual } from 'lodash';
+
export default {
props: {
storageKey: {
@@ -6,31 +8,58 @@ export default {
required: true,
},
value: {
- type: String,
+ type: [String, Number, Boolean, Array, Object],
required: false,
default: '',
},
+ asJson: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
watch: {
value(newVal) {
- this.saveValue(newVal);
+ this.saveValue(this.serialize(newVal));
},
},
mounted() {
// On mount, trigger update if we actually have a localStorageValue
- const value = this.getValue();
+ const { exists, value } = this.getStorageValue();
- if (value && this.value !== value) {
+ if (exists && !isEqual(value, this.value)) {
this.$emit('input', value);
}
},
methods: {
- getValue() {
- return localStorage.getItem(this.storageKey);
+ getStorageValue() {
+ const value = localStorage.getItem(this.storageKey);
+
+ if (value === null) {
+ return { exists: false };
+ }
+
+ try {
+ return { exists: true, value: this.deserialize(value) };
+ } catch {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[gitlab] Failed to deserialize value from localStorage (key=${this.storageKey})`,
+ value,
+ );
+ // default to "don't use localStorage value"
+ return { exists: false };
+ }
},
saveValue(val) {
localStorage.setItem(this.storageKey, val);
},
+ serialize(val) {
+ return this.asJson ? JSON.stringify(val) : val;
+ },
+ deserialize(val) {
+ return this.asJson ? JSON.parse(val) : val;
+ },
},
render() {
return this.$slots.default;
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index a48c279d0e3..9dd2d5402c3 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -25,6 +25,18 @@ export default {
},
mixins: [glFeatureFlagsMixin()],
props: {
+ /**
+ * This prop should be bound to the value of the `<textarea>` element
+ * that is rendered as a child of this component (in the `textarea` slot)
+ */
+ textareaValue: {
+ type: String,
+ required: true,
+ },
+ markdownDocsPath: {
+ type: String,
+ required: true,
+ },
isSubmitting: {
type: Boolean,
required: false,
@@ -35,10 +47,6 @@ export default {
required: false,
default: '',
},
- markdownDocsPath: {
- type: String,
- required: true,
- },
addSpacingClasses: {
type: Boolean,
required: false,
@@ -84,12 +92,6 @@ export default {
required: false,
default: false,
},
- // This prop is used as a fallback in case if textarea.elm is undefined
- textareaValue: {
- type: String,
- required: false,
- default: '',
- },
},
data() {
return {
@@ -189,17 +191,11 @@ export default {
this.previewMarkdown = true;
- /*
- Can't use `$refs` as the component is technically in the parent component
- so we access the VNode & then get the element
- */
- const text = this.$slots.textarea[0]?.elm?.value || this.textareaValue;
-
- if (text) {
+ if (this.textareaValue) {
this.markdownPreviewLoading = true;
this.markdownPreview = __('Loading…');
axios
- .post(this.markdownPreviewPath, { text })
+ .post(this.markdownPreviewPath, { text: this.textareaValue })
.then(response => this.renderMarkdown(response.data))
.catch(() => new Flash(__('Error loading markdown preview')));
} else {
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
index 13c42d35b04..13ec7a6ada9 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -27,6 +27,11 @@ export default {
type: String,
required: true,
},
+ suggestionsCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
computed: {
batchSuggestionsCount() {
@@ -62,6 +67,7 @@ export default {
<div class="md-suggestion">
<suggestion-diff-header
class="qa-suggestion-diff-header js-suggestion-diff-header"
+ :suggestions-count="suggestionsCount"
:can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled"
:is-applied="suggestion.applied"
:is-batched="isBatched"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index 1fc54d2f52e..fb9636ba734 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -42,6 +42,11 @@ export default {
required: false,
default: null,
},
+ suggestionsCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
return {
@@ -127,7 +132,7 @@ export default {
</div>
<div v-else class="d-flex align-items-center">
<gl-button
- v-if="canBeBatched && !isDisableButton"
+ v-if="suggestionsCount > 1 && canBeBatched && !isDisableButton"
class="btn-inverted js-add-to-batch-btn btn-grouped"
:disabled="isDisableButton"
@click="addSuggestionToBatch"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 083f581af05..927a93487e6 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -38,6 +38,11 @@ export default {
type: String,
required: true,
},
+ suggestionsCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
return {
@@ -77,12 +82,12 @@ export default {
this.isRendered = true;
},
generateDiff(suggestionIndex) {
- const { suggestions, disabled, batchSuggestionsInfo, helpPagePath } = this;
+ const { suggestions, disabled, batchSuggestionsInfo, helpPagePath, suggestionsCount } = this;
const suggestion =
suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {};
const SuggestionDiffComponent = Vue.extend(SuggestionDiff);
const suggestionDiff = new SuggestionDiffComponent({
- propsData: { disabled, suggestion, batchSuggestionsInfo, helpPagePath },
+ propsData: { disabled, suggestion, batchSuggestionsInfo, helpPagePath, suggestionsCount },
});
suggestionDiff.$on('apply', ({ suggestionId, callback }) => {
diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue b/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue
new file mode 100644
index 00000000000..12b748f9ab6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui';
+import { AVATAR_SIZE } from '../constants';
+
+export default {
+ name: 'GroupAvatar',
+ avatarSize: AVATAR_SIZE,
+ components: { GlAvatarLink, GlAvatarLabeled },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ group() {
+ return this.member.sharedWithGroup;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-avatar-link :href="group.webUrl">
+ <gl-avatar-labeled
+ :label="group.fullName"
+ :src="group.avatarUrl"
+ :alt="group.fullName"
+ :size="$options.avatarSize"
+ :entity-name="group.name"
+ :entity-id="group.id"
+ />
+ </gl-avatar-link>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue b/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue
new file mode 100644
index 00000000000..28654a60860
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue
@@ -0,0 +1,32 @@
+<script>
+import { GlAvatarLabeled } from '@gitlab/ui';
+import { AVATAR_SIZE } from '../constants';
+
+export default {
+ name: 'InviteAvatar',
+ avatarSize: AVATAR_SIZE,
+ components: { GlAvatarLabeled },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ invite() {
+ return this.member.invite;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-avatar-labeled
+ :label="invite.email"
+ :src="invite.avatarUrl"
+ :alt="invite.email"
+ :size="$options.avatarSize"
+ :entity-name="invite.email"
+ :entity-id="member.id"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue b/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue
new file mode 100644
index 00000000000..4cd74305450
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue
@@ -0,0 +1,80 @@
+<script>
+import {
+ GlAvatarLink,
+ GlAvatarLabeled,
+ GlBadge,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
+import { generateBadges } from 'ee_else_ce/vue_shared/components/members/utils';
+import { __ } from '~/locale';
+import { AVATAR_SIZE } from '../constants';
+
+export default {
+ name: 'UserAvatar',
+ avatarSize: AVATAR_SIZE,
+ orphanedUserLabel: __('Orphaned member'),
+ components: {
+ GlAvatarLink,
+ GlAvatarLabeled,
+ GlBadge,
+ },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ user() {
+ return this.member.user;
+ },
+ badges() {
+ return generateBadges(this.member, this.isCurrentUser).filter(badge => badge.show);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-avatar-link
+ v-if="user"
+ class="js-user-link"
+ :href="user.webUrl"
+ :data-user-id="user.id"
+ :data-username="user.username"
+ >
+ <gl-avatar-labeled
+ :label="user.name"
+ :sub-label="`@${user.username}`"
+ :src="user.avatarUrl"
+ :alt="user.name"
+ :size="$options.avatarSize"
+ :entity-name="user.name"
+ :entity-id="user.id"
+ >
+ <template #meta>
+ <div v-for="badge in badges" :key="badge.text" class="gl-p-1">
+ <gl-badge size="sm" :variant="badge.variant">
+ {{ badge.text }}
+ </gl-badge>
+ </div>
+ </template>
+ </gl-avatar-labeled>
+ </gl-avatar-link>
+
+ <gl-avatar-labeled
+ v-else
+ :label="$options.orphanedUserLabel"
+ :alt="$options.orphanedUserLabel"
+ :size="$options.avatarSize"
+ :entity-name="$options.orphanedUserLabel"
+ :entity-id="member.id"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/constants.js b/app/assets/javascripts/vue_shared/components/members/constants.js
new file mode 100644
index 00000000000..9dc0ec97ce6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/constants.js
@@ -0,0 +1,66 @@
+import { __ } from '~/locale';
+
+export const FIELDS = [
+ {
+ key: 'account',
+ label: __('Account'),
+ },
+ {
+ key: 'source',
+ label: __('Source'),
+ thClass: 'col-meta',
+ tdClass: 'col-meta',
+ },
+ {
+ key: 'granted',
+ label: __('Access granted'),
+ thClass: 'col-meta',
+ tdClass: 'col-meta',
+ },
+ {
+ key: 'invited',
+ label: __('Invited'),
+ thClass: 'col-meta',
+ tdClass: 'col-meta',
+ },
+ {
+ key: 'requested',
+ label: __('Requested'),
+ thClass: 'col-meta',
+ tdClass: 'col-meta',
+ },
+ {
+ key: 'expires',
+ label: __('Access expires'),
+ thClass: 'col-meta',
+ tdClass: 'col-meta',
+ },
+ {
+ key: 'maxRole',
+ label: __('Max role'),
+ thClass: 'col-meta',
+ tdClass: 'col-meta',
+ },
+ {
+ key: 'expiration',
+ label: __('Expiration'),
+ thClass: 'col-expiration',
+ tdClass: 'col-expiration',
+ },
+ {
+ key: 'actions',
+ thClass: 'col-actions',
+ tdClass: 'col-actions',
+ },
+];
+
+export const AVATAR_SIZE = 48;
+
+export const MEMBER_TYPES = {
+ user: 'user',
+ group: 'group',
+ invite: 'invite',
+ accessRequest: 'accessRequest',
+};
+
+export const DAYS_TO_EXPIRE_SOON = 7;
diff --git a/app/assets/javascripts/vue_shared/components/members/table/created_at.vue b/app/assets/javascripts/vue_shared/components/members/table/created_at.vue
new file mode 100644
index 00000000000..0bad70894f9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/created_at.vue
@@ -0,0 +1,40 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ name: 'CreatedAt',
+ components: { GlSprintf, TimeAgoTooltip },
+ props: {
+ date: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ createdBy: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ showCreatedBy() {
+ return this.createdBy?.name && this.createdBy?.webUrl;
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <gl-sprintf v-if="showCreatedBy" :message="s__('Members|%{time} by %{user}')">
+ <template #time>
+ <time-ago-tooltip :time="date" />
+ </template>
+ <template #user>
+ <a :href="createdBy.webUrl">{{ createdBy.name }}</a>
+ </template>
+ </gl-sprintf>
+ <time-ago-tooltip v-else :time="date" />
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue b/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue
new file mode 100644
index 00000000000..de65e3fb10f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+import {
+ approximateDuration,
+ differenceInSeconds,
+ formatDate,
+ getDayDifference,
+} from '~/lib/utils/datetime_utility';
+import { DAYS_TO_EXPIRE_SOON } from '../constants';
+
+export default {
+ name: 'ExpiresAt',
+ components: { GlSprintf },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ date: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ noExpirationSet() {
+ return this.date === null;
+ },
+ parsed() {
+ return new Date(this.date);
+ },
+ differenceInSeconds() {
+ return differenceInSeconds(new Date(), this.parsed);
+ },
+ isExpired() {
+ return this.differenceInSeconds <= 0;
+ },
+ inWords() {
+ return approximateDuration(this.differenceInSeconds);
+ },
+ formatted() {
+ return formatDate(this.parsed);
+ },
+ expiresSoon() {
+ return getDayDifference(new Date(), this.parsed) < DAYS_TO_EXPIRE_SOON;
+ },
+ cssClass() {
+ return {
+ 'gl-text-red-500': this.isExpired,
+ 'gl-text-orange-500': this.expiresSoon,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <span v-if="noExpirationSet">{{ s__('Members|No expiration set') }}</span>
+ <span v-else v-gl-tooltip.hover :title="formatted" :class="cssClass">
+ <template v-if="isExpired">{{ s__('Members|Expired') }}</template>
+ <gl-sprintf v-else :message="s__('Members|in %{time}')">
+ <template #time>
+ {{ inWords }}
+ </template>
+ </gl-sprintf>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue b/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue
new file mode 100644
index 00000000000..a1f98d4008a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue
@@ -0,0 +1,35 @@
+<script>
+import { kebabCase } from 'lodash';
+import UserAvatar from '../avatars/user_avatar.vue';
+import InviteAvatar from '../avatars/invite_avatar.vue';
+import GroupAvatar from '../avatars/group_avatar.vue';
+
+export default {
+ name: 'MemberAvatar',
+ components: { UserAvatar, InviteAvatar, GroupAvatar, AccessRequestAvatar: UserAvatar },
+ props: {
+ memberType: {
+ type: String,
+ required: true,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ required: true,
+ },
+ member: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ avatarComponent() {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `${kebabCase(this.memberType)}-avatar`;
+ },
+ },
+};
+</script>
+
+<template>
+ <component :is="avatarComponent" :member="member" :is-current-user="isCurrentUser" />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_source.vue b/app/assets/javascripts/vue_shared/components/members/table/member_source.vue
new file mode 100644
index 00000000000..030d72c3420
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/member_source.vue
@@ -0,0 +1,27 @@
+<script>
+import { GlTooltipDirective } from '@gitlab/ui';
+
+export default {
+ name: 'MemberSource',
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ memberSource: {
+ type: Object,
+ required: true,
+ },
+ isDirectMember: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <span v-if="isDirectMember">{{ __('Direct member') }}</span>
+ <a v-else v-gl-tooltip.hover :title="__('Inherited')" :href="memberSource.webUrl">{{
+ memberSource.name
+ }}</a>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue
new file mode 100644
index 00000000000..b72633f0cee
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue
@@ -0,0 +1,82 @@
+<script>
+import { mapState } from 'vuex';
+import { GlTable } from '@gitlab/ui';
+import { FIELDS } from '../constants';
+import initUserPopovers from '~/user_popovers';
+import MemberAvatar from './member_avatar.vue';
+import MemberSource from './member_source.vue';
+import CreatedAt from './created_at.vue';
+import ExpiresAt from './expires_at.vue';
+import MembersTableCell from './members_table_cell.vue';
+
+export default {
+ name: 'MembersTable',
+ components: {
+ GlTable,
+ MemberAvatar,
+ CreatedAt,
+ ExpiresAt,
+ MembersTableCell,
+ MemberSource,
+ },
+ computed: {
+ ...mapState(['members', 'tableFields']),
+ filteredFields() {
+ return FIELDS.filter(field => this.tableFields.includes(field.key));
+ },
+ },
+ mounted() {
+ initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
+ },
+};
+</script>
+
+<template>
+ <gl-table
+ class="members-table"
+ head-variant="white"
+ stacked="lg"
+ :fields="filteredFields"
+ :items="members"
+ primary-key="id"
+ thead-class="border-bottom"
+ :empty-text="__('No members found')"
+ show-empty
+ >
+ <template #cell(account)="{ item: member }">
+ <members-table-cell #default="{ memberType, isCurrentUser }" :member="member">
+ <member-avatar
+ :member-type="memberType"
+ :is-current-user="isCurrentUser"
+ :member="member"
+ />
+ </members-table-cell>
+ </template>
+
+ <template #cell(source)="{ item: member }">
+ <members-table-cell #default="{ isDirectMember }" :member="member">
+ <member-source :is-direct-member="isDirectMember" :member-source="member.source" />
+ </members-table-cell>
+ </template>
+
+ <template #cell(granted)="{ item: { createdAt, createdBy } }">
+ <created-at :date="createdAt" :created-by="createdBy" />
+ </template>
+
+ <template #cell(invited)="{ item: { createdAt, createdBy } }">
+ <created-at :date="createdAt" :created-by="createdBy" />
+ </template>
+
+ <template #cell(requested)="{ item: { createdAt } }">
+ <created-at :date="createdAt" />
+ </template>
+
+ <template #cell(expires)="{ item: { expiresAt } }">
+ <expires-at :date="expiresAt" />
+ </template>
+
+ <template #head(actions)="{ label }">
+ <span data-testid="col-actions" class="gl-sr-only">{{ label }}</span>
+ </template>
+ </gl-table>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue
new file mode 100644
index 00000000000..0688c5d3c9d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue
@@ -0,0 +1,50 @@
+<script>
+import { mapState } from 'vuex';
+import { MEMBER_TYPES } from '../constants';
+
+export default {
+ name: 'MembersTableCell',
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['sourceId', 'currentUserId']),
+ isGroup() {
+ return Boolean(this.member.sharedWithGroup);
+ },
+ isInvite() {
+ return Boolean(this.member.invite);
+ },
+ isAccessRequest() {
+ return Boolean(this.member.requestedAt);
+ },
+ memberType() {
+ if (this.isGroup) {
+ return MEMBER_TYPES.group;
+ } else if (this.isInvite) {
+ return MEMBER_TYPES.invite;
+ } else if (this.isAccessRequest) {
+ return MEMBER_TYPES.accessRequest;
+ }
+
+ return MEMBER_TYPES.user;
+ },
+ isDirectMember() {
+ return this.member.source?.id === this.sourceId;
+ },
+ isCurrentUser() {
+ return this.member.user?.id === this.currentUserId;
+ },
+ },
+ render() {
+ return this.$scopedSlots.default({
+ memberType: this.memberType,
+ isDirectMember: this.isDirectMember,
+ isCurrentUser: this.isCurrentUser,
+ });
+ },
+};
+</script>
diff --git a/app/assets/javascripts/vue_shared/components/members/utils.js b/app/assets/javascripts/vue_shared/components/members/utils.js
new file mode 100644
index 00000000000..782a0b7f96b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/utils.js
@@ -0,0 +1,19 @@
+import { __ } from '~/locale';
+
+export const generateBadges = (member, isCurrentUser) => [
+ {
+ show: isCurrentUser,
+ text: __("It's you"),
+ variant: 'success',
+ },
+ {
+ show: member.user?.blocked,
+ text: __('Blocked'),
+ variant: 'danger',
+ },
+ {
+ show: member.user?.twoFactorEnabled,
+ text: __('2FA'),
+ variant: 'info',
+ },
+];
diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
index 35ba7c665d5..cad4439ecea 100644
--- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
+++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
@@ -1,19 +1,16 @@
<script>
import $ from 'jquery';
-import { GlDeprecatedButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import Clipboard from 'clipboard';
import { __ } from '~/locale';
export default {
components: {
- GlDeprecatedButton,
- GlIcon,
+ GlButton,
},
-
directives: {
GlTooltip: GlTooltipDirective,
},
-
props: {
text: {
type: String,
@@ -55,15 +52,12 @@ export default {
default: null,
},
},
-
copySuccessText: __('Copied'),
-
computed: {
modalDomId() {
return this.modalId ? `#${this.modalId}` : '';
},
},
-
mounted() {
this.$nextTick(() => {
this.clipboard = new Clipboard(this.$el, {
@@ -83,13 +77,11 @@ export default {
.on('error', e => this.$emit('error', e));
});
},
-
destroyed() {
if (this.clipboard) {
this.clipboard.destroy();
}
},
-
methods: {
updateTooltip(target) {
const $target = $(target);
@@ -112,15 +104,12 @@ export default {
};
</script>
<template>
- <gl-deprecated-button
+ <gl-button
v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }"
:class="cssClasses"
:data-clipboard-target="target"
:data-clipboard-text="text"
:title="title"
- >
- <slot>
- <gl-icon name="copy-to-clipboard" />
- </slot>
- </gl-deprecated-button>
+ icon="copy-to-clipboard"
+ />
</template>
diff --git a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
index f8983a3d29a..3749888ee36 100644
--- a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
+++ b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
@@ -58,7 +58,12 @@ export default {
active: tab.isActive,
}"
>
- <a :class="`js-${scope}-tab-${tab.scope}`" role="button" @click="onTabClick(tab)">
+ <a
+ :class="`js-${scope}-tab-${tab.scope}`"
+ :data-testid="`${scope}-tab-${tab.scope}`"
+ role="button"
+ @click="onTabClick(tab)"
+ >
{{ tab.name }}
<span v-if="shouldRenderBadge(tab.count)" class="badge badge-pill"> {{ tab.count }} </span>
diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
index 53dbae39608..3aca068c074 100644
--- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
@@ -12,7 +12,7 @@ export default {
</script>
<template>
- <timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note">
+ <timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note_placeholder">
<div class="timeline-icon"></div>
<div class="timeline-content">
<div class="note-header"></div>
diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
index cc33b8f85cd..197671b47d6 100644
--- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
@@ -1,10 +1,12 @@
<script>
-import { GlAvatar } from '@gitlab/ui';
+import { GlAvatar, GlSprintf, GlLink } from '@gitlab/ui';
export default {
name: 'TitleArea',
components: {
GlAvatar,
+ GlSprintf,
+ GlLink,
},
props: {
avatar: {
@@ -17,6 +19,11 @@ export default {
default: null,
required: false,
},
+ infoMessages: {
+ type: Array,
+ default: () => [],
+ required: false,
+ },
},
data() {
return {
@@ -30,37 +37,58 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-justify-content-space-between gl-py-3">
- <div class="gl-flex-direction-column">
- <div class="gl-display-flex">
- <gl-avatar v-if="avatar" :src="avatar" shape="rect" class="gl-align-self-center gl-mr-4" />
+ <div class="gl-display-flex gl-flex-direction-column">
+ <div class="gl-display-flex gl-justify-content-space-between gl-py-3">
+ <div class="gl-flex-direction-column">
+ <div class="gl-display-flex">
+ <gl-avatar
+ v-if="avatar"
+ :src="avatar"
+ shape="rect"
+ class="gl-align-self-center gl-mr-4"
+ />
- <div class="gl-display-flex gl-flex-direction-column">
- <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-2" data-testid="title">
- <slot name="title">{{ title }}</slot>
- </h1>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-2" data-testid="title">
+ <slot name="title">{{ title }}</slot>
+ </h1>
+
+ <div
+ v-if="$slots['sub-header']"
+ class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1"
+ >
+ <slot name="sub-header"></slot>
+ </div>
+ </div>
+ </div>
+ <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3">
<div
- v-if="$slots['sub-header']"
- class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1"
+ v-for="(row, metadataIndex) in metadataSlots"
+ :key="metadataIndex"
+ class="gl-display-flex gl-align-items-center gl-mr-5"
>
- <slot name="sub-header"></slot>
+ <slot :name="row"></slot>
</div>
</div>
</div>
-
- <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3">
- <div
- v-for="(row, metadataIndex) in metadataSlots"
- :key="metadataIndex"
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <slot :name="row"></slot>
- </div>
+ <div v-if="$slots['right-actions']" class="gl-mt-3">
+ <slot name="right-actions"></slot>
</div>
</div>
- <div v-if="$slots['right-actions']" class="gl-mt-3">
- <slot name="right-actions"></slot>
- </div>
+ <p>
+ <span
+ v-for="(message, index) in infoMessages"
+ :key="index"
+ class="gl-mr-2"
+ data-testid="info-message"
+ >
+ <gl-sprintf :message="message.text">
+ <template #docLink="{content}">
+ <gl-link :href="message.link" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </p>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
index c08659919fa..44d43ca8f69 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
@@ -4,6 +4,8 @@ export const CUSTOM_EVENTS = {
openAddImageModal: 'gl_openAddImageModal',
};
+export const ALLOWED_VIDEO_ORIGINS = ['https://www.youtube.com'];
+
/* eslint-disable @gitlab/require-i18n-strings */
export const TOOLBAR_ITEM_CONFIGS = [
{ icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') },
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
index 51ba033dff0..bbe3825138c 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
@@ -4,6 +4,7 @@ import ToolbarItem from '../toolbar_item.vue';
import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer';
import buildCustomHTMLRenderer from './build_custom_renderer';
import { TOOLBAR_ITEM_CONFIGS } from '../constants';
+import sanitizeHTML from './sanitize_html';
const buildWrapper = propsData => {
const instance = new Vue({
@@ -62,5 +63,6 @@ export const getEditorOptions = externalOptions => {
return defaults({
customHTMLRenderer: buildCustomHTMLRenderer(externalOptions?.customRenderers),
toolbarItems: TOOLBAR_ITEM_CONFIGS.map(toolbarItem => generateToolbarItem(toolbarItem)),
+ customHTMLSanitizer: html => sanitizeHTML(html),
});
};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js
index b179ca61dba..18bd17d43d9 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js
@@ -1,7 +1,21 @@
import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token';
+import { ALLOWED_VIDEO_ORIGINS } from '../../constants';
+import { getURLOrigin } from '~/lib/utils/url_utility';
-const canRender = ({ type }) => {
- return type === 'htmlBlock';
+const isVideoFrame = html => {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, 'text/html');
+ const {
+ children: { length },
+ } = doc;
+ const iframe = doc.querySelector('iframe');
+ const origin = iframe && getURLOrigin(iframe.getAttribute('src'));
+
+ return length === 1 && ALLOWED_VIDEO_ORIGINS.includes(origin);
+};
+
+const canRender = ({ type, literal }) => {
+ return type === 'htmlBlock' && !isVideoFrame(literal);
};
const render = node => buildUneditableHtmlAsTextTokens(node);
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js
new file mode 100644
index 00000000000..eae2e0335c1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js
@@ -0,0 +1,22 @@
+import createSanitizer from 'dompurify';
+import { ALLOWED_VIDEO_ORIGINS } from '../constants';
+import { getURLOrigin } from '~/lib/utils/url_utility';
+
+const sanitizer = createSanitizer(window);
+const ADD_TAGS = ['iframe'];
+
+sanitizer.addHook('uponSanitizeElement', node => {
+ if (node.tagName !== 'IFRAME') {
+ return;
+ }
+
+ const origin = getURLOrigin(node.getAttribute('src'));
+
+ if (!ALLOWED_VIDEO_ORIGINS.includes(origin)) {
+ node.remove();
+ }
+});
+
+const sanitize = content => sanitizer.sanitize(content, { ADD_TAGS });
+
+export default sanitize;
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
index 6839354fb3a..267c3be5f50 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
@@ -38,6 +38,7 @@ export default {
<template>
<div
class="labels-select-dropdown-contents w-100 mt-1 mb-3 py-2 rounded-top rounded-bottom position-absolute"
+ data-qa-selector="labels_dropdown_content"
:style="directionStyle"
>
<component :is="dropdownContentsView" />
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
index 0b763aa4b72..c8dee81d746 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
@@ -1,6 +1,7 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon, GlButton, GlSearchBoxByType, GlLink } from '@gitlab/ui';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
@@ -39,9 +40,9 @@ export default {
...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']),
visibleLabels() {
if (this.searchKey) {
- return this.labels.filter(label =>
- label.title.toLowerCase().includes(this.searchKey.toLowerCase()),
- );
+ return fuzzaldrinPlus.filter(this.labels, this.searchKey, {
+ key: ['title'],
+ });
}
return this.labels;
},
@@ -112,6 +113,7 @@ export default {
this.currentHighlightItem += 1;
} else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) {
this.updateSelectedLabels([this.visibleLabels[this.currentHighlightItem]]);
+ this.searchKey = '';
} else if (e.keyCode === ESC_KEY_CODE) {
this.toggleDropdownContents();
}
@@ -155,7 +157,11 @@ export default {
/>
</div>
<div class="dropdown-input" @click.stop="() => {}">
- <gl-search-box-by-type v-model="searchKey" :autofocus="true" />
+ <gl-search-box-by-type
+ v-model="searchKey"
+ :autofocus="true"
+ data-qa-selector="dropdown_input_field"
+ />
</div>
<div
v-show="showListContainer"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
index 12ad2acf308..286067a0d0f 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
@@ -35,6 +35,8 @@ export default {
<template v-for="label in selectedLabels" v-else>
<gl-label
:key="label.id"
+ data-qa-selector="selected_label_content"
+ :data-qa-label-name="label.title"
:title="label.title"
:description="label.description"
:background-color="label.color"
diff --git a/app/assets/javascripts/vue_shared/components/todo_button.vue b/app/assets/javascripts/vue_shared/components/todo_button.vue
index debf19ccca6..a9d4f8403fa 100644
--- a/app/assets/javascripts/vue_shared/components/todo_button.vue
+++ b/app/assets/javascripts/vue_shared/components/todo_button.vue
@@ -15,7 +15,7 @@ export default {
},
computed: {
buttonLabel() {
- return this.isTodo ? __('Mark as done') : __('Add a To-Do');
+ return this.isTodo ? __('Mark as done') : __('Add a To Do');
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index 8307c6d3b55..b9c25bdc2e8 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -15,7 +15,13 @@ export default {
props: {
webIdeUrl: {
type: String,
- required: true,
+ required: false,
+ default: '',
+ },
+ webIdeIsFork: {
+ type: Boolean,
+ required: false,
+ default: false,
},
needsToFork: {
type: Boolean,
@@ -61,9 +67,11 @@ export default {
? { href: '#modal-confirm-fork', handle: () => this.showModal('#modal-confirm-fork') }
: { href: this.webIdeUrl };
+ const text = this.webIdeIsFork ? __('Edit fork in Web IDE') : __('Web IDE');
+
return {
key: KEY_WEB_IDE,
- text: __('Web IDE'),
+ text,
secondaryText: __('Quickly and easily edit multiple files in your project.'),
tooltip: '',
attrs: {
diff --git a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js
index a740a3fa6b9..cdbde55901d 100644
--- a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js
+++ b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js
@@ -10,6 +10,10 @@ import { validateParams } from '~/pipelines/utils';
export default {
methods: {
onChangeTab(scope) {
+ if (this.scope === scope) {
+ return;
+ }
+
let params = {
scope,
page: '1',
diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
index be5f55a5220..c0fc055a01b 100644
--- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
+++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
@@ -111,7 +111,7 @@ const mixins = {
return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length;
},
isOpen() {
- return this.state === 'opened';
+ return this.state === 'opened' || this.state === 'reopened';
},
isClosed() {
return this.state === 'closed';
diff --git a/app/assets/javascripts/vuex_shared/modules/members/state.js b/app/assets/javascripts/vuex_shared/modules/members/state.js
index 1511961245c..244a54d74d5 100644
--- a/app/assets/javascripts/vuex_shared/modules/members/state.js
+++ b/app/assets/javascripts/vuex_shared/modules/members/state.js
@@ -1,5 +1,6 @@
-export default ({ members, sourceId, currentUserId }) => ({
+export default ({ members, sourceId, currentUserId, tableFields }) => ({
members,
sourceId,
currentUserId,
+ tableFields,
});
diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue
index a00661c214d..ed17927c5b2 100644
--- a/app/assets/javascripts/whats_new/components/app.vue
+++ b/app/assets/javascripts/whats_new/components/app.vue
@@ -1,6 +1,9 @@
<script>
import { mapState, mapActions } from 'vuex';
import { GlDrawer, GlBadge, GlIcon, GlLink } from '@gitlab/ui';
+import Tracking from '~/tracking';
+
+const trackingMixin = Tracking.mixin();
export default {
components: {
@@ -9,12 +12,18 @@ export default {
GlIcon,
GlLink,
},
+ mixins: [trackingMixin],
props: {
features: {
type: String,
required: false,
default: null,
},
+ storageKey: {
+ type: String,
+ required: true,
+ default: null,
+ },
},
computed: {
...mapState(['open']),
@@ -31,7 +40,12 @@ export default {
},
},
mounted() {
- this.openDrawer();
+ this.openDrawer(this.storageKey);
+
+ const body = document.querySelector('body');
+ const namespaceId = body.getAttribute('data-namespace-id');
+
+ this.track('click_whats_new_drawer', { label: 'namespace_id', value: namespaceId });
},
methods: {
...mapActions(['openDrawer', 'closeDrawer']),
@@ -41,13 +55,20 @@ export default {
<template>
<div>
- <gl-drawer class="mt-6" :open="open" @close="closeDrawer">
+ <gl-drawer class="whats-new-drawer" :open="open" @close="closeDrawer">
<template #header>
<h4 class="page-title my-2">{{ __("What's new at GitLab") }}</h4>
</template>
<div class="pb-6">
<div v-for="feature in parsedFeatures" :key="feature.title" class="mb-6">
- <gl-link :href="feature.url" target="_blank">
+ <gl-link
+ :href="feature.url"
+ target="_blank"
+ data-testid="whats-new-title-link"
+ data-track-event="click_whats_new_item"
+ :data-track-label="feature.title"
+ :data-track-property="feature.url"
+ >
<h5 class="gl-font-base">{{ feature.title }}</h5>
</gl-link>
<div class="mb-2">
@@ -57,7 +78,13 @@ export default {
</gl-badge>
</template>
</div>
- <gl-link :href="feature.url" target="_blank">
+ <gl-link
+ :href="feature.url"
+ target="_blank"
+ data-track-event="click_whats_new_item"
+ :data-track-label="feature.title"
+ :data-track-property="feature.url"
+ >
<img
:alt="feature.title"
:src="feature.image_url"
@@ -65,9 +92,17 @@ export default {
/>
</gl-link>
<p class="pt-2">{{ feature.body }}</p>
- <gl-link :href="feature.url" target="_blank">{{ __('Learn more') }}</gl-link>
+ <gl-link
+ :href="feature.url"
+ target="_blank"
+ data-track-event="click_whats_new_item"
+ :data-track-label="feature.title"
+ :data-track-property="feature.url"
+ >{{ __('Learn more') }}</gl-link
+ >
</div>
</div>
</gl-drawer>
+ <div v-if="open" class="whats-new-modal-backdrop modal-backdrop"></div>
</div>
</template>
diff --git a/app/assets/javascripts/whats_new/index.js b/app/assets/javascripts/whats_new/index.js
index 19cdb590ae2..dc2e9eb7ea3 100644
--- a/app/assets/javascripts/whats_new/index.js
+++ b/app/assets/javascripts/whats_new/index.js
@@ -20,6 +20,7 @@ export default () => {
return createElement('app', {
props: {
features: whatsNewElm.getAttribute('data-features'),
+ storageKey: whatsNewElm.getAttribute('data-storage-key'),
},
});
},
diff --git a/app/assets/javascripts/whats_new/store/actions.js b/app/assets/javascripts/whats_new/store/actions.js
index 53488413d9e..f4229598cb3 100644
--- a/app/assets/javascripts/whats_new/store/actions.js
+++ b/app/assets/javascripts/whats_new/store/actions.js
@@ -4,7 +4,11 @@ export default {
closeDrawer({ commit }) {
commit(types.CLOSE_DRAWER);
},
- openDrawer({ commit }) {
+ openDrawer({ commit }, storageKey) {
commit(types.OPEN_DRAWER);
+
+ if (storageKey) {
+ localStorage.setItem(storageKey, JSON.stringify(false));
+ }
},
};
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index f706b615e7e..e8899b0a430 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -1,13 +1,11 @@
@import './pages/admin';
@import './pages/alert_management/details';
@import './pages/alert_management/severity-icons';
-@import './pages/boards';
@import './pages/branches';
@import './pages/builds';
@import './pages/ci_projects';
@import './pages/clusters';
@import './pages/commits';
-@import './pages/cycle_analytics';
@import './pages/deploy_keys';
@import './pages/detail_page';
@import './pages/dev_ops_report';
@@ -35,7 +33,6 @@
@import './pages/members';
@import './pages/merge_conflicts';
@import './pages/merge_requests';
-@import './pages/milestone';
@import './pages/monitor';
@import './pages/note_form';
@import './pages/notes';
@@ -57,9 +54,7 @@
@import './pages/sherlock';
@import './pages/status';
@import './pages/storage_quota';
-@import './pages/tags';
@import './pages/tree';
@import './pages/trials';
-@import './pages/ui_dev_kit';
@import './pages/users';
@import './pages/wiki';
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 8acd338fff8..cae886bf846 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -1,11 +1,3 @@
-/*
- * This is a manifest file that'll automatically include all the stylesheets available in this directory
- * and any sub-directories. You're free to add application-wide styles to this file and they'll appear at
- * the top of the compiled file, but it's generally better to create a new file per style scope.
- *= require_self
- *= require cropper.css
-*/
-
// Welcome to GitLab css!
// If you need to add or modify UI component that is common for many pages
// like a table or typography then make changes in the framework/ directory.
@@ -14,6 +6,7 @@
@import '@gitlab/at.js/dist/css/jquery.atwho';
@import 'dropzone/dist/basic';
@import 'select2';
+@import 'cropper';
// GitLab UI framework
@import 'framework';
@@ -36,17 +29,6 @@
// EE-only stylesheets
@import 'application_ee';
-// CSS util classes
-/**
- These are deprecated in favor of the Gitlab UI utilities imported below.
- Please check https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss
- to see the available utility classes.
-**/
-@import 'utilities';
-
-// Gitlab UI util classes
-@import '@gitlab/ui/src/scss/utilities';
-
/* print styles */
@media print {
@import 'print';
diff --git a/app/assets/stylesheets/application_utilities.scss b/app/assets/stylesheets/application_utilities.scss
new file mode 100644
index 00000000000..817e983a0ec
--- /dev/null
+++ b/app/assets/stylesheets/application_utilities.scss
@@ -0,0 +1,12 @@
+@import 'page_bundles/mixins_and_variables_and_functions';
+
+// CSS util classes
+/**
+ These are deprecated in favor of the Gitlab UI utilities imported below.
+ Please check https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss
+ to see the available utility classes.
+**/
+@import 'utilities';
+
+// Gitlab UI util classes
+@import '@gitlab/ui/src/scss/utilities';
diff --git a/app/assets/stylesheets/application_utilities_dark.scss b/app/assets/stylesheets/application_utilities_dark.scss
new file mode 100644
index 00000000000..eb32cdfc444
--- /dev/null
+++ b/app/assets/stylesheets/application_utilities_dark.scss
@@ -0,0 +1,3 @@
+@import './themes/dark';
+
+@import 'application_utilities';
diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss
index 21133316291..f198c06c2df 100644
--- a/app/assets/stylesheets/components/design_management/design.scss
+++ b/app/assets/stylesheets/components/design_management/design.scss
@@ -152,6 +152,10 @@
}
}
+.design-card-header {
+ background: transparent;
+}
+
.design-dropzone-border {
border: 2px dashed $gray-100;
}
diff --git a/app/assets/stylesheets/components/whats_new.scss b/app/assets/stylesheets/components/whats_new.scss
index 4fff900f5a5..6c58346b750 100644
--- a/app/assets/stylesheets/components/whats_new.scss
+++ b/app/assets/stylesheets/components/whats_new.scss
@@ -1,9 +1,32 @@
+.whats-new-drawer {
+ margin-top: $header-height;
+ @include gl-shadow-none;
+}
+
+.with-performance-bar .whats-new-drawer {
+ margin-top: calc(#{$performance-bar-height} + #{$header-height});
+}
+
.gl-badge.whats-new-item-badge {
background-color: $purple-light;
color: $purple;
- font-weight: bold;
+ @include gl-font-weight-bold;
}
.whats-new-item-image {
border-color: $gray-50;
}
+
+.whats-new-modal-backdrop {
+ z-index: 9;
+}
+
+.whats-new-notification-count {
+ @include gl-bg-gray-900;
+ @include gl-font-sm;
+ @include gl-line-height-normal;
+ @include gl-text-white;
+ @include gl-vertical-align-top;
+ border-radius: 20px;
+ padding: 3px 10px;
+}
diff --git a/app/assets/stylesheets/fontawesome_custom.scss b/app/assets/stylesheets/fontawesome_custom.scss
index 46e5e5a28ea..7ea03c4127b 100644
--- a/app/assets/stylesheets/fontawesome_custom.scss
+++ b/app/assets/stylesheets/fontawesome_custom.scss
@@ -88,11 +88,6 @@
content: '\f078';
}
-.fa-remove::before,
-.fa-times::before {
- content: '\f00d';
-}
-
.fa-caret-down::before {
content: '\f0d7';
}
@@ -258,10 +253,6 @@
content: '\f081';
}
-.fa-unlink::before {
- content: '\f127';
-}
-
.fa-file-pdf-o::before {
content: '\f1c1';
}
diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss
index 136ff82e0f8..196fb3a7088 100644
--- a/app/assets/stylesheets/framework/animations.scss
+++ b/app/assets/stylesheets/framework/animations.scss
@@ -112,8 +112,7 @@ a {
}
.dropdown-menu a,
-.dropdown-menu button,
-.dropdown-menu-nav a {
+.dropdown-menu button {
transition: none;
}
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index a9c1652d00d..a8cc685d880 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -417,12 +417,6 @@
}
}
-@include media-breakpoint-down(xs) {
- .btn-wide-on-xs {
- width: 100%;
- }
-}
-
.btn-blank {
padding: 0;
background: transparent;
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index ad5864ef6d9..e8d37fcf40b 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -33,8 +33,7 @@
}
.show.dropdown {
- .dropdown-menu,
- .dropdown-menu-nav {
+ .dropdown-menu {
@include set-visible;
min-height: $dropdown-min-height;
max-height: $dropdown-max-height;
@@ -190,15 +189,6 @@
background-color: $gray-darker;
color: $gl-text-color;
outline: 0;
-
- // make sure the text color is not overridden
- &.text-danger {
- color: $brand-danger;
- }
-
- .avatar {
- border-color: $white;
- }
}
@mixin dropdown-link {
@@ -217,11 +207,6 @@
text-align: left;
width: 100%;
- // make sure the text color is not overridden
- &.text-danger {
- color: $brand-danger;
- }
-
&.disable-hover {
text-decoration: none;
}
@@ -233,10 +218,6 @@
@include dropdown-item-hover;
text-decoration: none;
-
- .badge.badge-pill {
- background-color: darken($blue-50, 5%);
- }
}
&.dropdown-menu-user-link {
@@ -258,8 +239,7 @@
}
}
-.dropdown-menu,
-.dropdown-menu-nav {
+.dropdown-menu {
display: none;
position: absolute;
width: auto;
@@ -393,49 +373,56 @@
pointer-events: none;
}
- .dropdown-menu li {
- cursor: pointer;
+ .dropdown-menu {
+ display: none;
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
- &.droplab-item-active button {
- @include dropdown-item-hover;
- }
+ li {
+ cursor: pointer;
- > a,
- > button {
- display: flex;
- margin: 0;
- text-overflow: inherit;
- text-align: left;
+ &.droplab-item-active button {
+ @include dropdown-item-hover;
+ }
- &.btn .fa:not(:last-child) {
- margin-left: 5px;
+ > a,
+ > button {
+ display: flex;
+ margin: 0;
+ text-overflow: inherit;
+ text-align: left;
+
+ &.btn .fa:not(:last-child) {
+ margin-left: 5px;
+ }
}
- }
- > button.dropdown-epic-button {
- flex-direction: column;
+ > button.dropdown-epic-button {
+ flex-direction: column;
- .reference {
- color: $gray-300;
- margin-top: $gl-padding-4;
+ .reference {
+ color: $gray-300;
+ margin-top: $gl-padding-4;
+ }
}
- }
- &.droplab-item-selected i {
- visibility: visible;
- }
+ &.droplab-item-selected i {
+ visibility: visible;
+ }
- .icon {
- visibility: hidden;
- }
+ .icon {
+ visibility: hidden;
+ }
- .description {
- display: inline-block;
- white-space: normal;
- margin-left: 5px;
+ .description {
+ display: inline-block;
+ white-space: normal;
+ margin-left: 5px;
- p {
- margin-bottom: 0;
+ p {
+ margin-bottom: 0;
+ }
}
}
}
@@ -447,21 +434,12 @@
}
}
-.droplab-dropdown .dropdown-menu,
-.droplab-dropdown .dropdown-menu-nav {
- display: none;
- opacity: 1;
- visibility: visible;
- transform: translateY(0);
-}
-
.comment-type-dropdown.show .dropdown-menu {
display: block;
}
.filtered-search-box-input-container {
- .dropdown-menu,
- .dropdown-menu-nav {
+ .dropdown-menu {
max-width: 280px;
}
}
@@ -850,8 +828,7 @@
}
header.navbar-gitlab .dropdown {
- .dropdown-menu,
- .dropdown-menu-nav {
+ .dropdown-menu {
width: 100%;
min-width: 100%;
}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 76c6e03377c..f8710cc1346 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -50,7 +50,7 @@
right: 15px;
margin-left: auto;
- .btn {
+ .btn:not(.btn-icon) {
padding: 0 10px;
font-size: 13px;
line-height: 28px;
@@ -372,7 +372,7 @@ span.idiff {
color: $gl-text-color;
}
- .file-actions .btn {
+ .file-actions .btn:not(.btn-icon) {
padding: 0 10px;
font-size: 13px;
line-height: 28px;
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index cf21c23cb17..52319d9658b 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -203,18 +203,6 @@
margin-right: 0;
}
}
-
- &:hover,
- &:focus {
- text-decoration: none;
- outline: 0;
- opacity: 1;
- color: $white;
-
- &.header-user-dropdown-toggle .header-user-avatar {
- border-color: $white;
- }
- }
}
.header-new-dropdown-toggle {
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index ec0755b1614..5623d38d66e 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -9,6 +9,7 @@
}
}
+.ci-status-icon-error,
.ci-status-icon-failed {
svg {
fill: $red-500;
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 292d57f132c..bbfe65e6eda 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -28,10 +28,6 @@
text-decoration: none;
color: $black;
border-bottom: 2px solid $gray-darkest;
-
- .badge.badge-pill {
- color: $black;
- }
}
}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 8b5fa6c1b6c..c15d46d43b2 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -439,10 +439,6 @@
content: '\f0c6';
}
- &:hover::before {
- text-decoration: none;
- }
-
&.no-attachment-icon {
&::before {
display: none;
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 8cebfc430e0..66267d8a8bc 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -819,7 +819,6 @@ $pipeline-dropdown-line-height: 20px;
$pipeline-dropdown-status-icon-size: 18px;
$ci-action-dropdown-button-size: 24px;
$ci-action-dropdown-svg-size: 12px;
-$pipelines-table-header-height: 40px;
/*
CI variable lists
@@ -868,9 +867,6 @@ $add-to-slack-popup-max-width: 400px;
$add-to-slack-gif-max-width: 850px;
$add-to-slack-well-max-width: 750px;
$add-to-slack-logo-size: 100px;
-$double-headed-arrow-width: 100px;
-$double-headed-arrow-height: 25px;
-$right-arrow-size: 16px;
/*
Popup
diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss
index 55996a074c6..d550a1faa18 100644
--- a/app/assets/stylesheets/framework/wells.scss
+++ b/app/assets/stylesheets/framework/wells.scss
@@ -3,7 +3,6 @@
color: $gl-text-color;
border: 1px solid $border-color;
border-radius: $border-radius-default;
- margin-bottom: $gl-padding-8;
.card.card-body-segment {
padding: $gl-padding;
@@ -29,11 +28,6 @@
.ref-name {
font-size: 12px;
-
- &:hover {
- text-decoration: underline;
- color: $gl-text-color;
- }
}
}
diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
index 1e239877428..93cb9be4a8f 100644
--- a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
+++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
@@ -6,9 +6,10 @@
$bs-input-focus-box-shadow: rgba(0, 123, 255, 0.25);
a:not(.btn),
- .btn-link:hover,
- .btn-link:focus,
- .btn-link:active {
+ .gl-button.btn-link,
+ .gl-button.btn-link:hover,
+ .gl-button.btn-link:focus,
+ .gl-button.btn-link:active {
color: var(--ide-link-color, $blue-600);
}
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
new file mode 100644
index 00000000000..ffa034a2495
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -0,0 +1,613 @@
+@import 'mixins_and_variables_and_functions';
+
+.user-can-drag {
+ cursor: grab;
+}
+
+.is-ghost {
+ opacity: 0.3;
+ pointer-events: none;
+}
+
+.dropdown-projects {
+ .dropdown-content {
+ max-height: 200px;
+ }
+}
+
+.dropdown-menu-issues-board-new {
+ width: 320px;
+
+ .dropdown-content {
+ max-height: 140px;
+ }
+}
+
+.issue-board-dropdown-content {
+ margin: 0;
+ padding: $gl-padding-4 $gl-padding $gl-padding;
+ border-bottom: 0;
+ color: $gl-text-color-secondary;
+}
+
+.issue-boards-page {
+ .content-wrapper {
+ padding-bottom: 0;
+ }
+}
+
+.boards-app {
+ @include media-breakpoint-up(sm) {
+ transition: width $sidebar-transition-duration;
+ width: 100%;
+
+ &.is-compact {
+ width: calc(100% - #{$gutter-width});
+ }
+ }
+}
+
+.boards-list,
+.board-swimlanes {
+ height: calc(100vh - #{$issue-board-list-difference-xs});
+ overflow-x: scroll;
+ min-height: 200px;
+
+ @include media-breakpoint-only(sm) {
+ height: calc(100vh - #{$issue-board-list-difference-sm});
+ }
+
+ @include media-breakpoint-up(md) {
+ height: calc(100vh - #{$issue-board-list-difference-md});
+ }
+
+ @include media-breakpoint-up(lg) {
+ height: calc(100vh - #{$issue-board-list-difference-lg});
+ }
+
+ .with-performance-bar & {
+ height: calc(100vh - #{$issue-board-list-difference-xs} - #{$performance-bar-height});
+
+ @include media-breakpoint-only(sm) {
+ height: calc(100vh - #{$issue-board-list-difference-sm} - #{$performance-bar-height});
+ }
+
+ @include media-breakpoint-up(md) {
+ height: calc(100vh - #{$issue-board-list-difference-md} - #{$performance-bar-height});
+ }
+
+ @include media-breakpoint-up(lg) {
+ height: calc(100vh - #{$issue-board-list-difference-lg} - #{$performance-bar-height});
+ }
+ }
+}
+
+.board {
+ // the next line cannot be replaced with .d-inline-block because it breaks display: none of SortableJS
+ // see https://gitlab.com/gitlab-org/gitlab-foss/issues/64828
+ display: inline-block;
+ width: calc(85vw - 15px);
+
+ @include media-breakpoint-up(sm) {
+ width: 400px;
+ }
+
+ .board-title-caret {
+ border-radius: $border-radius-default;
+ line-height: $gl-spacing-scale-5;
+ height: $gl-spacing-scale-5;
+
+ &.btn svg {
+ top: 0;
+ }
+
+ &:hover {
+ background-color: $gray-50;
+ transition: background-color 0.1s linear;
+ }
+ }
+
+ &:not(.is-collapsed) {
+ .board-title-caret {
+ margin-right: $gl-padding-4;
+ }
+ }
+
+ &.is-collapsed {
+ width: 50px;
+
+ .board-title {
+ flex-direction: column;
+ }
+
+ .board-title-caret {
+ margin-top: 1px;
+ }
+
+ .user-avatar-link,
+ .milestone-icon {
+ margin-top: $gl-padding-8;
+ transform: rotate(90deg);
+ }
+
+ .board-title-text {
+ flex-grow: 0;
+ margin: $gl-padding-8 0;
+
+ .board-title-main-text {
+ display: block;
+ }
+
+ .board-title-sub-text {
+ display: none;
+ }
+ }
+
+ .issue-count-badge {
+ border: 0;
+ white-space: nowrap;
+ padding: 0;
+ }
+
+ .board-title-text > span,
+ .issue-count-badge > span {
+ height: 16px;
+
+ // Force the height to be equal to the parent's width while centering the contents.
+ // The contents *should* be about 16 px.
+ // We do this because the flow of elements isn't affected by the rotate transform, so we must ensure that a
+ // rotated element has square dimensions so it won't overlap with its siblings.
+ margin: calc(50% - 8px) 0;
+
+ transform: rotate(90deg);
+ transform-origin: center;
+ }
+ }
+}
+
+.board-inner {
+ font-size: $issue-boards-font-size;
+ background: $gray-light;
+ border: 1px solid $gray-100;
+}
+
+.board-header {
+ &.has-border::before {
+ border-top: 3px solid;
+ border-color: inherit;
+ border-top-left-radius: $border-radius-default;
+ border-top-right-radius: $border-radius-default;
+ content: '';
+ position: absolute;
+ width: calc(100% + 2px);
+ top: 0;
+ left: 0;
+ margin-top: -1px;
+ margin-right: -1px;
+ margin-left: -1px;
+ padding-top: 1px;
+ padding-right: 1px;
+ padding-left: 1px;
+
+ .board-title {
+ padding-top: ($gl-padding - 3px);
+ padding-bottom: $gl-padding;
+ }
+ }
+}
+
+.board-title {
+ align-items: center;
+ font-size: 1em;
+ border-bottom: 1px solid $gray-100;
+ padding: 0 $gl-spacing-scale-3;
+ height: 3rem;
+
+ .js-max-issue-size::before {
+ content: '/';
+ }
+}
+
+.board-title-text {
+ flex-grow: 1;
+}
+
+.board-delete.gl-button {
+ background-color: transparent;
+ outline: 0;
+
+ &:hover {
+ color: $blue-600;
+ box-shadow: none;
+ }
+}
+
+.board-blank-state,
+.board-promotion-state {
+ background-color: $white;
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.board-blank-state-list {
+ > li:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ .label-color {
+ top: 2px;
+ width: 16px;
+ height: 16px;
+ margin-right: 3px;
+ }
+}
+
+.board-list-component {
+ min-height: 0; // firefox fix
+}
+
+.board-list {
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.board-list-loading {
+ margin-top: 10px;
+ font-size: (26px / $issue-boards-font-size) * 1em;
+}
+
+.board-card {
+ background: $white;
+ border: 1px solid $gray-100;
+ box-shadow: 0 1px 2px $issue-boards-card-shadow;
+ line-height: $gl-padding;
+ list-style: none;
+ position: relative;
+
+ &:not(:last-child) {
+ margin-bottom: $gl-padding-8;
+ }
+
+ &.is-active,
+ &.is-active .board-card-assignee:hover a {
+ background-color: $blue-50;
+ }
+
+ &.multi-select {
+ border-color: $blue-200;
+ background-color: $blue-50;
+ }
+
+ .gl-label {
+ margin-top: 4px;
+ margin-right: 4px;
+ }
+
+ .confidential-icon {
+ color: $orange-500;
+ cursor: help;
+ }
+
+ .issue-blocked-icon {
+ color: $red-500;
+ }
+
+ @include media-breakpoint-down(md) {
+ padding: $gl-padding-8;
+ }
+}
+
+.board-card-title {
+ @include overflow-break-word();
+ font-size: 1em;
+
+ a {
+ color: $gl-text-color;
+ }
+
+ @include media-breakpoint-down(md) {
+ font-size: $label-font-size;
+ }
+}
+
+.board-card-header {
+ text-align: initial;
+}
+
+.board-card-assignee {
+ margin-top: -$gl-padding-4;
+ margin-bottom: -$gl-padding-4;
+
+ .avatar-counter {
+ vertical-align: middle;
+ line-height: $gl-padding-24;
+ min-width: $gl-padding-24;
+ height: $gl-padding-24;
+ border-radius: $gl-padding-24;
+ background-color: $gl-text-color-tertiary;
+ font-size: $gl-font-size-xs;
+ cursor: help;
+ font-weight: $gl-font-weight-bold;
+ margin-left: -$gl-padding-4;
+ border: 0;
+ padding: 0 $gl-padding-4;
+
+ @include media-breakpoint-down(md) {
+ min-width: auto;
+ height: $gl-padding;
+ border-radius: $gl-padding;
+ line-height: $gl-padding;
+ }
+ }
+
+ img {
+ vertical-align: top;
+ }
+
+ .user-avatar-link:not(:only-child) {
+ margin-left: -$gl-padding-4;
+
+ &:nth-of-type(1) {
+ z-index: 2;
+ }
+
+ &:nth-of-type(2) {
+ z-index: 1;
+ }
+ }
+
+ .avatar {
+ @include media-breakpoint-down(md) {
+ width: $gl-padding;
+ height: $gl-padding;
+ }
+ }
+
+ @include media-breakpoint-down(md) {
+ margin-top: 0;
+ margin-bottom: 0;
+ }
+}
+
+.board-card-number {
+ font-size: $gl-font-size-xs;
+ color: $gl-text-color-secondary;
+
+ @include media-breakpoint-up(md) {
+ font-size: $label-font-size;
+ }
+}
+
+.board-list-count {
+ padding: 10px 0;
+ color: $gl-text-color-secondary;
+ font-size: 13px;
+}
+
+.board-new-issue-form {
+ z-index: 4;
+ margin: 5px;
+}
+
+.right-sidebar.issue-boards-sidebar {
+ .gutter-toggle {
+ bottom: 15px;
+ width: 22px;
+ padding-left: $gl-padding-32;
+
+ svg {
+ position: absolute;
+ top: 50%;
+ right: 0;
+ margin-top: (-11px / 2);
+ height: $gl-font-size-12;
+ width: $gl-font-size-12;
+ }
+ }
+
+ .issuable-header-text {
+ @include overflow-break-word();
+ padding-right: 35px;
+ }
+}
+
+.right-sidebar.right-sidebar-expanded {
+ &.boards-sidebar-slide-enter-active,
+ &.boards-sidebar-slide-leave-active {
+ transition: width $sidebar-transition-duration, padding $sidebar-transition-duration;
+ }
+
+ &.boards-sidebar-slide-enter,
+ &.boards-sidebar-slide-leave-active {
+ width: 0;
+ padding-left: 0;
+ padding-right: 0;
+ }
+}
+
+.add-issues-modal {
+ background-color: rgba($black, 0.3);
+ z-index: 9999;
+}
+
+.add-issues-container {
+ width: 90vw;
+ height: 85vh;
+ max-width: 1100px;
+ min-height: 500px;
+ padding: 25px 15px 0;
+ background-color: $white;
+ box-shadow: 0 2px 12px rgba($black, 0.5);
+
+ .empty-state {
+ &.add-issues-empty-state-filter {
+ flex-direction: column;
+ justify-content: center;
+ }
+
+ .svg-content {
+ margin-top: -40px;
+ }
+ }
+}
+
+.add-issues-header {
+ margin: -25px -15px -5px;
+ border-bottom: 1px solid $border-color;
+ border-top-right-radius: $border-radius-default;
+ border-top-left-radius: $border-radius-default;
+
+ > h2 {
+ font-size: 18px;
+ }
+}
+
+.add-issues-list-column {
+ width: 100%;
+
+ @include media-breakpoint-up(sm) {
+ width: 50%;
+ }
+
+ @include media-breakpoint-up(md) {
+ width: (100% / 3);
+ }
+}
+
+.add-issues-list {
+ padding-top: 3px;
+ margin-left: -$gl-vert-padding;
+ margin-right: -$gl-vert-padding;
+ overflow-y: scroll;
+
+ .board-card-parent {
+ padding: 0 5px 5px;
+ }
+
+ .board-card {
+ border: 1px solid $border-white-normal;
+ box-shadow: 0 1px 2px rgba($issue-boards-card-shadow, 0.3);
+ cursor: pointer;
+ }
+}
+
+.add-issues-footer {
+ margin: auto -15px 0;
+ padding-left: 15px;
+ padding-right: 15px;
+ border-bottom-right-radius: $border-radius-default;
+ border-bottom-left-radius: $border-radius-default;
+}
+
+.add-issues-footer-to-list {
+ padding-left: $gl-vert-padding;
+ padding-right: $gl-vert-padding;
+ line-height: $input-height;
+}
+
+.issue-card-selected {
+ position: absolute;
+ right: -3px;
+ top: -3px;
+ width: 17px;
+ background-color: $blue-500;
+ color: $white;
+ border: 1px solid $blue-600;
+ font-size: 9px;
+ line-height: 15px;
+ border-radius: 50%;
+}
+
+.board-card-info {
+ color: $gl-text-color-secondary;
+ white-space: nowrap;
+ margin-right: $gl-padding-8;
+
+ &:not(.board-card-weight) {
+ cursor: help;
+ }
+
+ &.board-card-weight {
+ color: $gl-text-color-secondary;
+ cursor: pointer;
+
+ &:hover {
+ color: initial;
+ text-decoration: underline;
+ }
+ }
+
+ .board-card-info-icon {
+ color: $gray-500;
+ margin-right: $gl-padding-4;
+ vertical-align: text-top;
+ }
+
+ @include media-breakpoint-down(md) {
+ font-size: $label-font-size;
+ }
+}
+
+.board-issue-path.js-show-tooltip {
+ cursor: help;
+}
+
+.board-labels-toggle-wrapper,
+.board-swimlanes-toggle-wrapper {
+ /**
+ * Make the wrapper the same height as a button so it aligns properly when the
+ * filtered-search-box input element increases in size on Linux smaller breakpoints
+ */
+ height: $input-height;
+}
+
+.issue-boards-content.is-focused {
+ position: fixed;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ background: $white;
+ z-index: 9000;
+
+ @include media-breakpoint-down(sm) {
+ padding-top: 10px;
+ }
+
+ .boards-list {
+ height: calc(100vh - #{$issue-boards-filter-height});
+ overflow-x: scroll;
+ }
+
+ .issue-boards-sidebar {
+ height: 100%;
+ top: 0;
+ }
+}
+
+.board-swimlanes {
+ overflow-x: auto;
+}
+
+.board-header-collapsed-info-icon:hover {
+ color: $gray-900;
+}
+
+$epic-icons-spacing: 40px;
+
+.board-epic-lane {
+ max-width: calc(100vw - #{$contextual-sidebar-width} - #{$epic-icons-spacing});
+
+ .page-with-icon-sidebar & {
+ max-width: calc(100vw - #{$contextual-sidebar-collapsed-width} - #{$epic-icons-spacing});
+ }
+
+ .page-with-icon-sidebar .is-compact & {
+ max-width: calc(100vw - #{$contextual-sidebar-collapsed-width} - #{$gutter-width} - #{$epic-icons-spacing});
+ }
+
+ .is-compact & {
+ max-width: calc(100vw - #{$contextual-sidebar-width} - #{$gutter-width} - #{$epic-icons-spacing});
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/cycle_analytics.scss b/app/assets/stylesheets/page_bundles/cycle_analytics.scss
new file mode 100644
index 00000000000..3a5e2e4159d
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/cycle_analytics.scss
@@ -0,0 +1,355 @@
+@import 'mixins_and_variables_and_functions';
+
+#cycle-analytics,
+.cycle-analytics {
+ margin: 24px auto 0;
+ position: relative;
+
+ .landing {
+ margin-top: 0;
+
+ .inner-content {
+ white-space: normal;
+
+ h4,
+ p {
+ margin: 7px 0 0;
+ max-width: 480px;
+ padding: 0 $gl-padding;
+
+ @include media-breakpoint-down(sm) {
+ margin: 0 auto;
+ }
+ }
+ }
+
+ .svg-container svg {
+ width: 136px;
+ height: 136px;
+ }
+ }
+
+ .col-headers {
+ ul {
+ @include clearfix;
+ margin: 0;
+ padding: 0;
+ }
+
+ li {
+ display: inline-block;
+ float: left;
+ line-height: 50px;
+ width: 20%;
+ }
+
+ .stage-header {
+ width: 20.5%;
+ }
+
+ .median-header {
+ width: 19.5%;
+ }
+
+ .event-header {
+ width: 45%;
+ }
+
+ .total-time-header {
+ width: 15%;
+ }
+ }
+
+ .card {
+ .content-block {
+ padding: 24px 0;
+ border-bottom: 0;
+ position: relative;
+
+ @include media-breakpoint-down(xs) {
+ padding: 6px 0 24px;
+ }
+ }
+
+ .column {
+ text-align: center;
+
+ @include media-breakpoint-down(xs) {
+ padding: 15px 0;
+ }
+
+ .header {
+ font-size: 30px;
+ line-height: 38px;
+ font-weight: $gl-font-weight-normal;
+ margin: 0;
+ }
+
+ .text {
+ color: var(--gray-500, $gray-500);
+ margin: 0;
+ }
+
+ &:last-child {
+ @include media-breakpoint-down(xs) {
+ text-align: center;
+ }
+ }
+ }
+ }
+
+ .stage-panel-body {
+ display: flex;
+ flex-wrap: wrap;
+ }
+
+ .stage-nav,
+ .stage-entries {
+ display: flex;
+ vertical-align: top;
+ font-size: $gl-font-size;
+ }
+
+ .stage-nav {
+ width: 40%;
+ margin-bottom: 0;
+
+ ul {
+ padding: 0;
+ margin: 0;
+ width: 100%;
+ }
+
+ li {
+ @include clearfix;
+ list-style-type: none;
+ }
+
+ .stage-nav-item {
+ line-height: 65px;
+
+ &.active {
+ background: var(--blue-50, $blue-50);
+ border-color: var(--blue-300, $blue-300);
+ box-shadow: inset 4px 0 0 0 var(--blue-500, $blue-500);
+ }
+
+ &:hover:not(.active) {
+ background-color: var(--gray-10, $gray-10);
+ box-shadow: inset 2px 0 0 0 var(--border-color, $border-color);
+ cursor: pointer;
+ }
+
+ .stage-nav-item-cell.stage-name {
+ width: 44.5%;
+ }
+
+ .stage-nav-item-cell.stage-median {
+ min-width: 43%;
+ }
+
+ .stage-empty,
+ .not-available {
+ color: var(--gray-500, $gray-500);
+ }
+ }
+ }
+
+ .stage-panel-container {
+ width: 100%;
+ overflow: auto;
+ }
+
+ .stage-panel {
+ min-width: 968px;
+
+ .card-header {
+ padding: 0;
+ background-color: transparent;
+ }
+
+ .events-description {
+ line-height: 65px;
+ padding: 0 $gl-padding;
+ }
+
+ .events-info {
+ color: var(--gray-500, $gray-500);
+ }
+ }
+
+ .stage-events {
+ width: 60%;
+ min-height: 467px;
+ }
+
+ .stage-event-list {
+ margin: 0;
+ padding: 0;
+ }
+
+ .stage-event-item {
+ @include clearfix;
+ list-style-type: none;
+ padding: 0 0 $gl-padding;
+ margin: 0 $gl-padding $gl-padding;
+ border-bottom: 1px solid var(--gray-50, $gray-50);
+
+ &:last-child {
+ border-bottom: 0;
+ margin-bottom: 0;
+ }
+
+ .item-details,
+ .item-time {
+ float: left;
+ }
+
+ .item-details {
+ width: 75%;
+ }
+
+ .item-title {
+ margin: 0 0 2px;
+
+ &.issue-title,
+ &.commit-title,
+ &.merge-request-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 100%;
+ display: block;
+
+ a {
+ color: var(--gl-text-color, $gl-text-color);
+ }
+ }
+ }
+
+ .item-time {
+ width: 25%;
+ text-align: right;
+ }
+
+ .total-time {
+ font-size: $cycle-analytics-big-font;
+ color: var(--gl-text-color, $gl-text-color);
+
+ span {
+ color: var(--gl-text-color, $gl-text-color);
+ font-size: $gl-font-size;
+ }
+ }
+
+ .issue-date,
+ .build-date {
+ color: var(--gl-text-color, $gl-text-color);
+ }
+
+ .mr-link,
+ .issue-link,
+ .commit-author-link,
+ .issue-author-link {
+ color: var(--gl-text-color, $gl-text-color);
+ }
+
+ // Custom CSS for components
+ .item-conmmit-component {
+ .commit-icon {
+ svg {
+ display: inline-block;
+ width: 20px;
+ height: 20px;
+ vertical-align: bottom;
+ }
+ }
+ }
+
+ .merge-request-branch {
+ a {
+ max-width: 180px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ display: inline-block;
+ vertical-align: bottom;
+ }
+ }
+ }
+
+ // Custom Styles for stage items
+ .item-build-component {
+ .item-title {
+ .icon-build-status {
+ float: left;
+ margin-right: 5px;
+ position: relative;
+ top: 2px;
+ }
+
+ .item-build-name {
+ color: var(--gl-text-color, $gl-text-color);
+ }
+
+ .pipeline-id {
+ color: var(--gl-text-color, $gl-text-color);
+ padding: 0 3px 0 0;
+ }
+
+ .ref-name {
+ color: var(--gray-900, $gray-900);
+ display: inline-block;
+ max-width: 180px;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ line-height: 1.3;
+ vertical-align: top;
+ }
+
+ .commit-sha {
+ color: var(--blue-600, $blue-600);
+ line-height: 1.3;
+ vertical-align: top;
+ font-weight: $gl-font-weight-normal;
+ }
+
+ .fa {
+ color: var(--gray-500, $gray-500);
+ font-size: $code-font-size;
+ }
+ }
+ }
+
+ .empty-stage,
+ .no-access-stage {
+ text-align: center;
+ width: 75%;
+ margin: 0 auto;
+ padding-top: 130px;
+ color: var(--gray-500, $gray-500);
+
+ h4 {
+ color: var(--gl-text-color, $gl-text-color);
+ }
+ }
+
+ .empty-stage {
+ .icon-no-data {
+ height: 36px;
+ width: 78px;
+ display: inline-block;
+ margin-bottom: 20px;
+ }
+ }
+
+ .no-access-stage {
+ .icon-lock {
+ height: 36px;
+ width: 78px;
+ display: inline-block;
+ margin-bottom: 20px;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/issues.scss b/app/assets/stylesheets/page_bundles/issues.scss
new file mode 100644
index 00000000000..705583c74ae
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/issues.scss
@@ -0,0 +1,8 @@
+.user-can-drag {
+ cursor: grab;
+}
+
+.is-ghost {
+ opacity: 0.3;
+ pointer-events: none;
+}
diff --git a/app/assets/stylesheets/page_bundles/milestone.scss b/app/assets/stylesheets/page_bundles/milestone.scss
new file mode 100644
index 00000000000..c1d7d86e3f9
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/milestone.scss
@@ -0,0 +1,254 @@
+@import 'mixins_and_variables_and_functions';
+
+$status-box-line-height: 26px;
+
+.issues-sortable-list .str-truncated {
+ max-width: 90%;
+}
+
+.milestones {
+ padding: $gl-padding-8;
+ margin-top: $gl-padding-8;
+ border-radius: $border-radius-default;
+ background-color: $gray-100;
+
+ .milestone {
+ border: 0;
+ padding: $gl-padding-top $gl-padding;
+ border-radius: $border-radius-default;
+ background-color: $white;
+
+ &:not(:last-child) {
+ margin-bottom: $gl-padding-4;
+ }
+
+ h4 {
+ font-weight: $gl-font-weight-bold;
+ }
+
+ .progress {
+ width: 100%;
+ height: 6px;
+ margin-bottom: $gl-padding-4;
+ }
+
+ .milestone-progress,
+ .milestone-release-links {
+ a {
+ color: $blue-600;
+ }
+ }
+
+ .status-box {
+ font-size: $tooltip-font-size;
+ margin-top: 0;
+ margin-right: $gl-padding-4;
+ line-height: $status-box-line-height;
+
+ @include media-breakpoint-down(xs) {
+ line-height: unset;
+ padding: $gl-padding-4 $gl-input-padding;
+ }
+ }
+ }
+}
+
+.milestone-content {
+ .issues-count {
+ margin-right: 17px;
+ float: right;
+ width: 105px;
+ }
+
+ .issuable-row {
+ span {
+ a {
+ color: $gl-text-color;
+ word-wrap: break-word;
+ }
+
+ .gl-label-link {
+ color: inherit;
+ }
+ }
+ }
+
+ .card-header {
+ line-height: $line-height-base;
+ padding: 14px 16px;
+ display: flex;
+
+ .title {
+ flex: 1;
+ flex-grow: 2;
+ }
+
+ .counter {
+ flex: 0;
+ padding-left: 16px;
+ }
+ }
+}
+
+.milestone-sidebar {
+ .milestone-progress {
+ .title {
+ padding-top: 5px;
+ }
+
+ .progress {
+ height: 6px;
+ margin: 0;
+ }
+
+ .sidebar-collapsed-icon {
+ clear: both;
+ padding: 15px 5px 5px;
+
+ .progress {
+ margin: 5px 0;
+ }
+ }
+ }
+
+ .collapsed-milestone-date {
+ font-size: 12px;
+ }
+
+ .milestone-date {
+ display: block;
+ }
+
+ .date-separator {
+ line-height: 5px;
+ }
+
+ .remaining-days strong {
+ font-weight: $gl-font-weight-normal;
+ }
+
+ .milestone-stat {
+ float: left;
+ margin-right: 14px;
+ }
+
+ .milestone-stat:last-child {
+ margin-right: 0;
+ }
+
+ .right-sidebar-expanded & {
+ .gutter-toggle {
+ margin-bottom: $sidebar-milestone-toggle-bottom-margin;
+ }
+ }
+
+ .right-sidebar-collapsed & {
+ .milestone-progress {
+ padding-top: 0;
+ }
+
+ .reference {
+ border-top: 1px solid $border-gray-normal;
+ }
+ }
+}
+
+.milestone-issues-list,
+.milestone-merge_requests-list {
+ .issuable-detail {
+ display: block;
+ margin-top: 7px;
+
+ .issue-link {
+ display: inline-block;
+ }
+
+ .issuable-number {
+ color: $gl-text-color-secondary;
+ margin-right: 5px;
+ }
+
+ .avatar {
+ float: none;
+ }
+
+ > a:not(:last-of-type) {
+ margin-right: 5px;
+ }
+ }
+}
+
+.milestone-detail {
+ border-bottom: 1px solid $border-color;
+}
+
+@include media-breakpoint-down(xs) {
+ .milestone-actions {
+ @include clearfix();
+ padding-top: $gl-vert-padding;
+
+ .btn:first-child {
+ margin-left: 0;
+ }
+ }
+}
+
+.milestone-page-header {
+ display: flex;
+ flex-flow: row;
+ align-items: center;
+ flex-wrap: wrap;
+
+ .status-box {
+ margin-top: 0;
+ order: 1;
+ }
+
+ .milestone-buttons {
+ margin-left: auto;
+ order: 2;
+
+ .verbose {
+ display: none;
+ }
+ }
+
+ .header-text-content {
+ order: 3;
+ width: 100%;
+ }
+
+ @include media-breakpoint-up(xs) {
+ .milestone-buttons .verbose {
+ display: inline;
+ }
+
+ .header-text-content {
+ order: 2;
+ width: auto;
+ }
+
+ .milestone-buttons {
+ order: 3;
+ }
+ }
+}
+
+.issuable-row {
+ background-color: $white;
+}
+
+.milestone-popover-instructions-list {
+ padding-left: 2em;
+
+ > li {
+ padding-left: 1em;
+ }
+}
+
+@include media-breakpoint-down(xs) {
+ .milestone-banner-text,
+ .milestone-banner-link {
+ display: inline;
+ }
+}
diff --git a/app/assets/stylesheets/pages/alert_management/details.scss b/app/assets/stylesheets/pages/alert_management/details.scss
index a104c06c853..514f228e223 100644
--- a/app/assets/stylesheets/pages/alert_management/details.scss
+++ b/app/assets/stylesheets/pages/alert_management/details.scss
@@ -33,7 +33,7 @@
}
.main-notes-list::before {
- left: 15px !important;
+ left: $gl-spacing-scale-5 !important;
}
.note-header-info {
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
deleted file mode 100644
index c4852974a4d..00000000000
--- a/app/assets/stylesheets/pages/boards.scss
+++ /dev/null
@@ -1,613 +0,0 @@
-.user-can-drag {
- cursor: grab;
-}
-
-.is-ghost {
- opacity: 0.3;
- pointer-events: none;
-}
-
-.dropdown-projects {
- .dropdown-content {
- max-height: 200px;
- }
-}
-
-.dropdown-menu-issues-board-new {
- width: 320px;
-
- .dropdown-content {
- max-height: 140px;
- }
-}
-
-.issue-board-dropdown-content {
- margin: 0;
- padding: $gl-padding-4 $gl-padding $gl-padding;
- border-bottom: 0;
- color: $gl-text-color-secondary;
-}
-
-.issue-boards-page {
- .content-wrapper {
- padding-bottom: 0;
- }
-}
-
-.boards-app {
- @include media-breakpoint-up(sm) {
- transition: width $sidebar-transition-duration;
- width: 100%;
-
- &.is-compact {
- width: calc(100% - #{$gutter-width});
- }
- }
-}
-
-.boards-list,
-.board-swimlanes {
- height: calc(100vh - #{$issue-board-list-difference-xs});
- overflow-x: scroll;
- min-height: 200px;
-
- @include media-breakpoint-only(sm) {
- height: calc(100vh - #{$issue-board-list-difference-sm});
- }
-
- @include media-breakpoint-up(md) {
- height: calc(100vh - #{$issue-board-list-difference-md});
- }
-
- @include media-breakpoint-up(lg) {
- height: calc(100vh - #{$issue-board-list-difference-lg});
- }
-
- .with-performance-bar & {
- height: calc(100vh - #{$issue-board-list-difference-xs} - #{$performance-bar-height});
-
- @include media-breakpoint-only(sm) {
- height: calc(100vh - #{$issue-board-list-difference-sm} - #{$performance-bar-height});
- }
-
- @include media-breakpoint-up(md) {
- height: calc(100vh - #{$issue-board-list-difference-md} - #{$performance-bar-height});
- }
-
- @include media-breakpoint-up(lg) {
- height: calc(100vh - #{$issue-board-list-difference-lg} - #{$performance-bar-height});
- }
- }
-}
-
-.board {
- // the next line cannot be replaced with .d-inline-block because it breaks display: none of SortableJS
- // see https://gitlab.com/gitlab-org/gitlab-foss/issues/64828
- display: inline-block;
- width: calc(85vw - 15px);
-
- @include media-breakpoint-up(sm) {
- width: 400px;
- }
-
- .board-title-caret {
- border-radius: $border-radius-default;
- line-height: $gl-spacing-scale-5;
- height: $gl-spacing-scale-5;
-
- &.btn svg {
- top: 0;
- }
-
- &:hover {
- background-color: $gray-50;
- transition: background-color 0.1s linear;
- }
- }
-
- &:not(.is-collapsed) {
- .board-title-caret {
- margin-right: $gl-padding-4;
- }
- }
-
- &.is-collapsed {
- width: 50px;
-
- .board-title {
- flex-direction: column;
- }
-
- .board-title-caret {
- margin-top: 1px;
- }
-
- .user-avatar-link,
- .milestone-icon {
- margin-top: $gl-padding-8;
- transform: rotate(90deg);
- }
-
- .board-title-text {
- flex-grow: 0;
- margin: $gl-padding-8 0;
-
- .board-title-main-text {
- display: block;
- }
-
- .board-title-sub-text {
- display: none;
- }
- }
-
- .issue-count-badge {
- border: 0;
- white-space: nowrap;
- padding: 0;
- }
-
- .board-title-text > span,
- .issue-count-badge > span {
- height: 16px;
-
- // Force the height to be equal to the parent's width while centering the contents.
- // The contents *should* be about 16 px.
- // We do this because the flow of elements isn't affected by the rotate transform, so we must ensure that a
- // rotated element has square dimensions so it won't overlap with its siblings.
- margin: calc(50% - 8px) 0;
-
- transform: rotate(90deg);
- transform-origin: center;
- }
- }
-}
-
-.board-inner {
- font-size: $issue-boards-font-size;
- background: $gray-light;
- border: 1px solid $gray-100;
-}
-
-.board-header {
- &.has-border::before {
- border-top: 3px solid;
- border-color: inherit;
- border-top-left-radius: $border-radius-default;
- border-top-right-radius: $border-radius-default;
- content: '';
- position: absolute;
- width: calc(100% + 2px);
- top: 0;
- left: 0;
- margin-top: -1px;
- margin-right: -1px;
- margin-left: -1px;
- padding-top: 1px;
- padding-right: 1px;
- padding-left: 1px;
-
- .board-title {
- padding-top: ($gl-padding - 3px);
- padding-bottom: $gl-padding;
- }
- }
-}
-
-.board-title {
- align-items: center;
- font-size: 1em;
- border-bottom: 1px solid $gray-100;
- padding: 0 $gl-spacing-scale-3;
- height: 3rem;
-
- .js-max-issue-size::before {
- content: '/';
- }
-}
-
-.board-title-text {
- flex-grow: 1;
-}
-
-.board-delete.gl-button {
- background-color: transparent;
- outline: 0;
-
- &:hover {
- color: $blue-600;
- box-shadow: none;
- }
-}
-
-.board-blank-state,
-.board-promotion-state {
- background-color: $white;
- flex: 1;
- overflow-y: auto;
- overflow-x: hidden;
-}
-
-.board-blank-state-list {
- > li:not(:last-child) {
- margin-bottom: 8px;
- }
-
- .label-color {
- top: 2px;
- width: 16px;
- height: 16px;
- margin-right: 3px;
- }
-}
-
-.board-list-component {
- min-height: 0; // firefox fix
-}
-
-.board-list {
- overflow-y: auto;
- overflow-x: hidden;
-}
-
-.board-list-loading {
- margin-top: 10px;
- font-size: (26px / $issue-boards-font-size) * 1em;
-}
-
-.board-card {
- background: $white;
- border: 1px solid $gray-100;
- box-shadow: 0 1px 2px $issue-boards-card-shadow;
- line-height: $gl-padding;
- list-style: none;
- position: relative;
-
- &:not(:last-child) {
- margin-bottom: $gl-padding-8;
- }
-
- &.is-active,
- &.is-active .board-card-assignee:hover a {
- background-color: $blue-50;
- }
-
- &.multi-select {
- border-color: $blue-200;
- background-color: $blue-50;
- }
-
- .gl-label {
- margin-top: 4px;
- margin-right: 4px;
- }
-
- .confidential-icon {
- color: $orange-500;
- cursor: help;
- }
-
- .issue-blocked-icon {
- color: $red-500;
- }
-
- @include media-breakpoint-down(md) {
- padding: $gl-padding-8;
- }
-}
-
-.board-card-title {
- @include overflow-break-word();
- font-size: 1em;
-
- a {
- color: $gl-text-color;
- }
-
- @include media-breakpoint-down(md) {
- font-size: $label-font-size;
- }
-}
-
-.board-card-header {
- text-align: initial;
-}
-
-.board-card-assignee {
- margin-top: -$gl-padding-4;
- margin-bottom: -$gl-padding-4;
-
- .avatar-counter {
- vertical-align: middle;
- line-height: $gl-padding-24;
- min-width: $gl-padding-24;
- height: $gl-padding-24;
- border-radius: $gl-padding-24;
- background-color: $gl-text-color-tertiary;
- font-size: $gl-font-size-xs;
- cursor: help;
- font-weight: $gl-font-weight-bold;
- margin-left: -$gl-padding-4;
- border: 0;
- padding: 0 $gl-padding-4;
-
- @include media-breakpoint-down(md) {
- min-width: auto;
- height: $gl-padding;
- border-radius: $gl-padding;
- line-height: $gl-padding;
- }
- }
-
- img {
- vertical-align: top;
- }
-
- .user-avatar-link:not(:only-child) {
- margin-left: -$gl-padding-4;
-
- &:nth-of-type(1) {
- z-index: 2;
- }
-
- &:nth-of-type(2) {
- z-index: 1;
- }
- }
-
- .avatar {
- margin: 0;
-
- @include media-breakpoint-down(md) {
- width: $gl-padding;
- height: $gl-padding;
- }
- }
-
- @include media-breakpoint-down(md) {
- margin-top: 0;
- margin-bottom: 0;
- }
-}
-
-.board-card-number {
- font-size: $gl-font-size-xs;
- color: $gl-text-color-secondary;
-
- @include media-breakpoint-up(md) {
- font-size: $label-font-size;
- }
-}
-
-.board-list-count {
- padding: 10px 0;
- color: $gl-text-color-secondary;
- font-size: 13px;
-}
-
-.board-new-issue-form {
- z-index: 4;
- margin: 5px;
-}
-
-.right-sidebar.issue-boards-sidebar {
- .gutter-toggle {
- bottom: 15px;
- width: 22px;
- padding-left: $gl-padding-32;
-
- svg {
- position: absolute;
- top: 50%;
- right: 0;
- margin-top: (-11px / 2);
- height: $gl-font-size-12;
- width: $gl-font-size-12;
- }
- }
-
- .issuable-header-text {
- @include overflow-break-word();
- padding-right: 35px;
- }
-}
-
-.right-sidebar.right-sidebar-expanded {
- &.boards-sidebar-slide-enter-active,
- &.boards-sidebar-slide-leave-active {
- transition: width $sidebar-transition-duration, padding $sidebar-transition-duration;
- }
-
- &.boards-sidebar-slide-enter,
- &.boards-sidebar-slide-leave-active {
- width: 0;
- padding-left: 0;
- padding-right: 0;
- }
-}
-
-.add-issues-modal {
- background-color: rgba($black, 0.3);
- z-index: 9999;
-}
-
-.add-issues-container {
- width: 90vw;
- height: 85vh;
- max-width: 1100px;
- min-height: 500px;
- padding: 25px 15px 0;
- background-color: $white;
- box-shadow: 0 2px 12px rgba($black, 0.5);
-
- .empty-state {
- &.add-issues-empty-state-filter {
- flex-direction: column;
- justify-content: center;
- }
-
- .svg-content {
- margin-top: -40px;
- }
- }
-}
-
-.add-issues-header {
- margin: -25px -15px -5px;
- border-bottom: 1px solid $border-color;
- border-top-right-radius: $border-radius-default;
- border-top-left-radius: $border-radius-default;
-
- > h2 {
- font-size: 18px;
- }
-}
-
-.add-issues-list-column {
- width: 100%;
-
- @include media-breakpoint-up(sm) {
- width: 50%;
- }
-
- @include media-breakpoint-up(md) {
- width: (100% / 3);
- }
-}
-
-.add-issues-list {
- padding-top: 3px;
- margin-left: -$gl-vert-padding;
- margin-right: -$gl-vert-padding;
- overflow-y: scroll;
-
- .board-card-parent {
- padding: 0 5px 5px;
- }
-
- .board-card {
- border: 1px solid $border-white-normal;
- box-shadow: 0 1px 2px rgba($issue-boards-card-shadow, 0.3);
- cursor: pointer;
- }
-}
-
-.add-issues-footer {
- margin: auto -15px 0;
- padding-left: 15px;
- padding-right: 15px;
- border-bottom-right-radius: $border-radius-default;
- border-bottom-left-radius: $border-radius-default;
-}
-
-.add-issues-footer-to-list {
- padding-left: $gl-vert-padding;
- padding-right: $gl-vert-padding;
- line-height: $input-height;
-}
-
-.issue-card-selected {
- position: absolute;
- right: -3px;
- top: -3px;
- width: 17px;
- background-color: $blue-500;
- color: $white;
- border: 1px solid $blue-600;
- font-size: 9px;
- line-height: 15px;
- border-radius: 50%;
-}
-
-.board-card-info {
- color: $gl-text-color-secondary;
- white-space: nowrap;
- margin-right: $gl-padding-8;
-
- &:not(.board-card-weight) {
- cursor: help;
- }
-
- &.board-card-weight {
- color: $gl-text-color-secondary;
- cursor: pointer;
-
- &:hover {
- color: initial;
- text-decoration: underline;
- }
- }
-
- .board-card-info-icon {
- color: $gray-500;
- margin-right: $gl-padding-4;
- vertical-align: text-top;
- }
-
- @include media-breakpoint-down(md) {
- font-size: $label-font-size;
- }
-}
-
-.board-issue-path.js-show-tooltip {
- cursor: help;
-}
-
-.board-labels-toggle-wrapper,
-.board-swimlanes-toggle-wrapper {
- /**
- * Make the wrapper the same height as a button so it aligns properly when the
- * filtered-search-box input element increases in size on Linux smaller breakpoints
- */
- height: $input-height;
-}
-
-.issue-boards-content.is-focused {
- position: fixed;
- width: 100%;
- height: 100%;
- top: 0;
- left: 0;
- background: $white;
- z-index: 9000;
-
- @include media-breakpoint-down(sm) {
- padding-top: 10px;
- }
-
- .boards-list {
- height: calc(100vh - #{$issue-boards-filter-height});
- overflow-x: scroll;
- }
-
- .issue-boards-sidebar {
- height: 100%;
- top: 0;
- }
-}
-
-.board-swimlanes {
- overflow-x: auto;
-}
-
-.board-header-collapsed-info-icon:hover {
- color: $gray-900;
-}
-
-$epic-icons-spacing: 40px;
-
-.board-epic-lane {
- max-width: calc(100vw - #{$contextual-sidebar-width} - #{$epic-icons-spacing});
-
- .page-with-icon-sidebar & {
- max-width: calc(100vw - #{$contextual-sidebar-collapsed-width} - #{$epic-icons-spacing});
- }
-
- .page-with-icon-sidebar .is-compact & {
- max-width: calc(100vw - #{$contextual-sidebar-collapsed-width} - #{$gutter-width} - #{$epic-icons-spacing});
- }
-
- .is-compact & {
- max-width: calc(100vw - #{$contextual-sidebar-width} - #{$gutter-width} - #{$epic-icons-spacing});
- }
-}
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 04167cbee1b..d7b4db3840e 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -123,20 +123,13 @@
}
.build-header {
- .ci-header-container,
- .header-action-buttons {
- display: flex;
- }
-
- .ci-header-container {
- min-height: 54px;
- }
-
.page-content-header {
padding: 10px 0 9px;
}
.header-action-buttons {
+ display: flex;
+
@include media-breakpoint-down(xs) {
.sidebar-toggle-btn {
margin-top: 0;
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index e6378fd9168..c55bfeb7b15 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -306,7 +306,6 @@
.commit,
.generic-commit-status,
.branch-commit {
- .autodevops-link,
.commit-sha {
color: $blue-600;
}
diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss
deleted file mode 100644
index c509bf121bc..00000000000
--- a/app/assets/stylesheets/pages/cycle_analytics.scss
+++ /dev/null
@@ -1,353 +0,0 @@
-#cycle-analytics,
-.cycle-analytics {
- margin: 24px auto 0;
- position: relative;
-
- .landing {
- margin-top: 0;
-
- .inner-content {
- white-space: normal;
-
- h4,
- p {
- margin: 7px 0 0;
- max-width: 480px;
- padding: 0 $gl-padding;
-
- @include media-breakpoint-down(sm) {
- margin: 0 auto;
- }
- }
- }
-
- .svg-container svg {
- width: 136px;
- height: 136px;
- }
- }
-
- .col-headers {
- ul {
- @include clearfix;
- margin: 0;
- padding: 0;
- }
-
- li {
- display: inline-block;
- float: left;
- line-height: 50px;
- width: 20%;
- }
-
- .stage-header {
- width: 20.5%;
- }
-
- .median-header {
- width: 19.5%;
- }
-
- .event-header {
- width: 45%;
- }
-
- .total-time-header {
- width: 15%;
- }
- }
-
- .card {
- .content-block {
- padding: 24px 0;
- border-bottom: 0;
- position: relative;
-
- @include media-breakpoint-down(xs) {
- padding: 6px 0 24px;
- }
- }
-
- .column {
- text-align: center;
-
- @include media-breakpoint-down(xs) {
- padding: 15px 0;
- }
-
- .header {
- font-size: 30px;
- line-height: 38px;
- font-weight: $gl-font-weight-normal;
- margin: 0;
- }
-
- .text {
- color: $layout-link-gray;
- margin: 0;
- }
-
- &:last-child {
- @include media-breakpoint-down(xs) {
- text-align: center;
- }
- }
- }
- }
-
- .stage-panel-body {
- display: flex;
- flex-wrap: wrap;
- }
-
- .stage-nav,
- .stage-entries {
- display: flex;
- vertical-align: top;
- font-size: $gl-font-size;
- }
-
- .stage-nav {
- width: 40%;
- margin-bottom: 0;
-
- ul {
- padding: 0;
- margin: 0;
- width: 100%;
- }
-
- li {
- @include clearfix;
- list-style-type: none;
- }
-
- .stage-nav-item {
- line-height: 65px;
-
- &.active {
- background: $blue-50;
- border-color: $blue-300;
- box-shadow: inset 4px 0 0 0 $blue-500;
- }
-
- &:hover:not(.active) {
- background-color: $gray-lightest;
- box-shadow: inset 2px 0 0 0 $border-color;
- cursor: pointer;
- }
-
- .stage-nav-item-cell.stage-name {
- width: 44.5%;
- }
-
- .stage-nav-item-cell.stage-median {
- min-width: 43%;
- }
-
- .stage-empty,
- .not-available {
- color: $gl-text-color-secondary;
- }
- }
- }
-
- .stage-panel-container {
- width: 100%;
- overflow: auto;
- }
-
- .stage-panel {
- min-width: 968px;
-
- .card-header {
- padding: 0;
- background-color: transparent;
- }
-
- .events-description {
- line-height: 65px;
- padding: 0 $gl-padding;
- }
-
- .events-info {
- color: $gl-text-color-secondary;
- }
- }
-
- .stage-events {
- width: 60%;
- min-height: 467px;
- }
-
- .stage-event-list {
- margin: 0;
- padding: 0;
- }
-
- .stage-event-item {
- @include clearfix;
- list-style-type: none;
- padding: 0 0 $gl-padding;
- margin: 0 $gl-padding $gl-padding;
- border-bottom: 1px solid $gray-darker;
-
- &:last-child {
- border-bottom: 0;
- margin-bottom: 0;
- }
-
- .item-details,
- .item-time {
- float: left;
- }
-
- .item-details {
- width: 75%;
- }
-
- .item-title {
- margin: 0 0 2px;
-
- &.issue-title,
- &.commit-title,
- &.merge-request-title {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 100%;
- display: block;
-
- a {
- color: $gl-text-color;
- }
- }
- }
-
- .item-time {
- width: 25%;
- text-align: right;
- }
-
- .total-time {
- font-size: $cycle-analytics-big-font;
- color: $gl-text-color;
-
- span {
- color: $gl-text-color;
- font-size: $gl-font-size;
- }
- }
-
- .issue-date,
- .build-date {
- color: $gl-text-color;
- }
-
- .mr-link,
- .issue-link,
- .commit-author-link,
- .issue-author-link {
- color: $gl-text-color;
- }
-
- // Custom CSS for components
- .item-conmmit-component {
- .commit-icon {
- svg {
- display: inline-block;
- width: 20px;
- height: 20px;
- vertical-align: bottom;
- }
- }
- }
-
- .merge-request-branch {
- a {
- max-width: 180px;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- display: inline-block;
- vertical-align: bottom;
- }
- }
- }
-
- // Custom Styles for stage items
- .item-build-component {
- .item-title {
- .icon-build-status {
- float: left;
- margin-right: 5px;
- position: relative;
- top: 2px;
- }
-
- .item-build-name {
- color: $gl-text-color;
- }
-
- .pipeline-id {
- color: $gl-text-color;
- padding: 0 3px 0 0;
- }
-
- .ref-name {
- color: $black;
- display: inline-block;
- max-width: 180px;
- text-overflow: ellipsis;
- overflow: hidden;
- white-space: nowrap;
- line-height: 1.3;
- vertical-align: top;
- }
-
- .commit-sha {
- color: $blue-600;
- line-height: 1.3;
- vertical-align: top;
- font-weight: $gl-font-weight-normal;
- }
-
- .fa {
- color: $gl-text-color-secondary;
- font-size: $code-font-size;
- }
- }
- }
-
- .empty-stage,
- .no-access-stage {
- text-align: center;
- width: 75%;
- margin: 0 auto;
- padding-top: 130px;
- color: $gl-text-color-secondary;
-
- h4 {
- color: $gl-text-color;
- }
- }
-
- .empty-stage {
- .icon-no-data {
- height: 36px;
- width: 78px;
- display: inline-block;
- margin-bottom: 20px;
- }
- }
-
- .no-access-stage {
- .icon-lock {
- height: 36px;
- width: 78px;
- display: inline-block;
- margin-bottom: 20px;
- }
- }
-}
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index 62af7103b39..3c432fe09c0 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -13,6 +13,21 @@
box-shadow: 0 -2px 0 0 var(--white);
cursor: pointer;
+ .dropdown-menu {
+ cursor: auto;
+ }
+
+ @media (max-width: map-get($grid-breakpoints, sm)-1) {
+ .file-header-content {
+ width: 0;
+ flex: 1;
+ }
+
+ .file-actions {
+ margin-left: $gl-spacing-scale-2;
+ }
+ }
+
@media (min-width: map-get($grid-breakpoints, md)) {
// The `+11` is to ensure the file header border shows when scrolled -
// the bottom of the compare-versions header and the top of the file header
@@ -55,10 +70,6 @@
}
}
- a:hover {
- text-decoration: none;
- }
-
&:hover {
background-color: $gray-normal;
}
diff --git a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss
index dfc56654229..415ff01bc33 100644
--- a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss
+++ b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss
@@ -57,4 +57,8 @@
height: $default-icon-size;
}
}
+
+ .decline-page {
+ width: 350px;
+ }
}
diff --git a/app/assets/stylesheets/pages/incident_management_list.scss b/app/assets/stylesheets/pages/incident_management_list.scss
index 316066694a8..4aa6b2492a2 100644
--- a/app/assets/stylesheets/pages/incident_management_list.scss
+++ b/app/assets/stylesheets/pages/incident_management_list.scss
@@ -127,9 +127,4 @@
@include gl-w-full;
}
}
-
- // TODO: Abstract to `@gitlab/ui` utility set: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/921
- .gl-fill-green-500 {
- fill: $green-500;
- }
}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 53525a4d877..7097c2b10c4 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -117,7 +117,8 @@
}
}
-.assignee {
+.assignee,
+.reviewer {
.merge-icon {
color: $orange-400;
position: absolute;
@@ -240,16 +241,6 @@
.avatar {
margin-left: 0;
}
-
- a.edit-link:not([href]):hover {
- color: rgba($gray-normal, 0.2);
- }
-
- .confidential-edit,
- .lock-edit,
- .edit-link {
- @extend .btn-link;
- }
}
.cross-project-reference,
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index e37b26187e7..80cb6ec89ce 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -134,11 +134,6 @@
}
}
-.label-description-wrapper {
- margin-right: 8px;
- margin-left: 8px;
-}
-
.prioritized-labels {
margin-bottom: 30px;
@@ -201,10 +196,6 @@
}
}
-.label-options-toggle {
- width: 100%;
-}
-
.label-subscription {
vertical-align: middle;
@@ -276,35 +267,6 @@
font-size: $label-font-size;
}
-.label-badge-blue {
- background-color: $theme-blue-100;
-}
-
-.label-badge-gray {
- background-color: $gray-50;
-}
-
-.label-links {
- list-style: none;
- margin: 0;
- padding: 0;
- white-space: nowrap;
-}
-
-.label-link-item {
- padding: 0;
-}
-
-.label-description {
- .description-text {
- margin-bottom: 10px;
-
- .admin-labels & {
- margin-bottom: 0;
- }
- }
-}
-
.label-list-item {
.content-list &::before,
.content-list &::after {
@@ -313,21 +275,12 @@
.label-name {
width: 200px;
- flex-shrink: 0;
.gl-label {
line-height: $gl-line-height;
}
}
- .label-description {
- flex-grow: 1;
-
- a {
- color: $blue-600;
- }
- }
-
.label {
padding: 4px $grid-size;
font-size: $label-font-size;
@@ -382,31 +335,8 @@
text-align: left;
}
- .label-links {
- white-space: normal;
- }
-
.label-description {
order: 3;
- width: 100%;
-
- > .label-description-wrapper {
- margin-left: 0;
- margin-right: 0;
- }
- }
- }
-}
-
-@media (max-width: 910px) {
- .priority-badge {
- display: block;
- width: 100%;
- margin-left: 0;
- margin-top: $gl-padding;
-
- .label-badge {
- display: inline-block;
}
}
}
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss
index 2d9a9f3029f..11d5104f64d 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/pages/members.scss
@@ -209,6 +209,23 @@
}
}
+
+.members-table {
+ @include media-breakpoint-up(lg) {
+ .col-meta {
+ width: px-to-rem(150px);
+ }
+
+ .col-expiration {
+ width: px-to-rem(200px);
+ }
+
+ .col-actions {
+ width: px-to-rem(50px);
+ }
+ }
+}
+
.card-mobile {
.content-list.members-list li {
display: block;
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 8aaeb92eb7a..ddec04b1b0c 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -770,8 +770,6 @@ $mr-widget-min-height: 69px;
position: -webkit-sticky;
position: sticky;
top: $header-height + $mr-tabs-height;
- margin-left: -16px;
- width: calc(100% + 32px);
.mr-version-menus-container {
flex-wrap: nowrap;
@@ -868,6 +866,13 @@ $mr-widget-min-height: 69px;
}
}
+.container-fluid {
+ // Negative margins for mobile/tablet screen
+ .diffs.tab-pane {
+ margin: 0 (-$gl-padding);
+ }
+}
+
// Wrap MR tabs/buttons so you don't have to scroll on desktop
@include media-breakpoint-down(md) {
.merge-request-tabs-container,
diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss
deleted file mode 100644
index e9eb79b071c..00000000000
--- a/app/assets/stylesheets/pages/milestone.scss
+++ /dev/null
@@ -1,252 +0,0 @@
-$status-box-line-height: 26px;
-
-.issues-sortable-list .str-truncated {
- max-width: 90%;
-}
-
-.milestones {
- padding: $gl-padding-8;
- margin-top: $gl-padding-8;
- border-radius: $border-radius-default;
- background-color: $gray-100;
-
- .milestone {
- border: 0;
- padding: $gl-padding-top $gl-padding;
- border-radius: $border-radius-default;
- background-color: $white;
-
- &:not(:last-child) {
- margin-bottom: $gl-padding-4;
- }
-
- h4 {
- font-weight: $gl-font-weight-bold;
- }
-
- .progress {
- width: 100%;
- height: 6px;
- margin-bottom: $gl-padding-4;
- }
-
- .milestone-progress,
- .milestone-release-links {
- a {
- color: $blue-600;
- }
- }
-
- .status-box {
- font-size: $tooltip-font-size;
- margin-top: 0;
- margin-right: $gl-padding-4;
- line-height: $status-box-line-height;
-
- @include media-breakpoint-down(xs) {
- line-height: unset;
- padding: $gl-padding-4 $gl-input-padding;
- }
- }
- }
-}
-
-.milestone-content {
- .issues-count {
- margin-right: 17px;
- float: right;
- width: 105px;
- }
-
- .issuable-row {
- span {
- a {
- color: $gl-text-color;
- word-wrap: break-word;
- }
-
- .gl-label-link {
- color: inherit;
- }
- }
- }
-
- .card-header {
- line-height: $line-height-base;
- padding: 14px 16px;
- display: flex;
-
- .title {
- flex: 1;
- flex-grow: 2;
- }
-
- .counter {
- flex: 0;
- padding-left: 16px;
- }
- }
-}
-
-.milestone-sidebar {
- .milestone-progress {
- .title {
- padding-top: 5px;
- }
-
- .progress {
- height: 6px;
- margin: 0;
- }
-
- .sidebar-collapsed-icon {
- clear: both;
- padding: 15px 5px 5px;
-
- .progress {
- margin: 5px 0;
- }
- }
- }
-
- .collapsed-milestone-date {
- font-size: 12px;
- }
-
- .milestone-date {
- display: block;
- }
-
- .date-separator {
- line-height: 5px;
- }
-
- .remaining-days strong {
- font-weight: $gl-font-weight-normal;
- }
-
- .milestone-stat {
- float: left;
- margin-right: 14px;
- }
-
- .milestone-stat:last-child {
- margin-right: 0;
- }
-
- .right-sidebar-expanded & {
- .gutter-toggle {
- margin-bottom: $sidebar-milestone-toggle-bottom-margin;
- }
- }
-
- .right-sidebar-collapsed & {
- .milestone-progress {
- padding-top: 0;
- }
-
- .reference {
- border-top: 1px solid $border-gray-normal;
- }
- }
-}
-
-.milestone-issues-list,
-.milestone-merge_requests-list {
- .issuable-detail {
- display: block;
- margin-top: 7px;
-
- .issue-link {
- display: inline-block;
- }
-
- .issuable-number {
- color: $gl-text-color-secondary;
- margin-right: 5px;
- }
-
- .avatar {
- float: none;
- }
-
- > a:not(:last-of-type) {
- margin-right: 5px;
- }
- }
-}
-
-.milestone-detail {
- border-bottom: 1px solid $border-color;
-}
-
-@include media-breakpoint-down(xs) {
- .milestone-actions {
- @include clearfix();
- padding-top: $gl-vert-padding;
-
- .btn:first-child {
- margin-left: 0;
- }
- }
-}
-
-.milestone-page-header {
- display: flex;
- flex-flow: row;
- align-items: center;
- flex-wrap: wrap;
-
- .status-box {
- margin-top: 0;
- order: 1;
- }
-
- .milestone-buttons {
- margin-left: auto;
- order: 2;
-
- .verbose {
- display: none;
- }
- }
-
- .header-text-content {
- order: 3;
- width: 100%;
- }
-
- @include media-breakpoint-up(xs) {
- .milestone-buttons .verbose {
- display: inline;
- }
-
- .header-text-content {
- order: 2;
- width: auto;
- }
-
- .milestone-buttons {
- order: 3;
- }
- }
-}
-
-.issuable-row {
- background-color: $white;
-}
-
-.milestone-popover-instructions-list {
- padding-left: 2em;
-
- > li {
- padding-left: 1em;
- }
-}
-
-@include media-breakpoint-down(xs) {
- .milestone-banner-text,
- .milestone-banner-link {
- display: inline;
- }
-}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index c144fb13322..b510822a20a 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -858,68 +858,28 @@ $note-form-margin-left: 72px;
}
.line-resolve-all-container {
- margin: $gl-padding-4;
-
> div {
white-space: nowrap;
}
- .discussion-next-btn {
- border-radius: 0;
- }
-
- .toggle-all-discussions-btn {
+ .btn-group .btn:first-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
-
- .btn {
- line-height: $gl-line-height;
-
- svg {
- fill: $gray-500;
- }
-
- &.discussion-create-issue-btn {
- border-radius: 0;
- border-right: 0;
-
- a {
- padding: 0;
- line-height: 0;
-
- &:hover {
- text-decoration: none;
- border: 0;
- }
- }
- }
-
- &.discussion-next-btn {
- border-right: 0;
- }
- }
}
.line-resolve-all {
vertical-align: middle;
display: inline-block;
- padding: $gl-padding-4 10px;
+ padding: $gl-padding-8 $gl-padding-12;
background-color: $gray-light;
border: 1px solid $border-color;
+ border-right: 0;
border-radius: $border-radius-default;
- font-size: $gl-btn-small-font-size;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
- border-right: 0;
-
- .line-resolve-btn {
- color: $gray-500;
-
- svg {
- vertical-align: text-top;
- }
- }
+ font-size: $gl-font-size;
+ line-height: 1rem;
@include media-breakpoint-down(xs) {
flex: 1;
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 8b104ce9017..d382fc6241f 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -26,10 +26,6 @@
}
.pipelines {
- .negative-margin-top {
- margin-top: -$pipelines-table-header-height;
- }
-
.stage {
max-width: 90px;
width: 90px;
@@ -111,6 +107,10 @@
white-space: nowrap;
}
}
+
+ .pipeline-tags .label-container {
+ white-space: normal;
+ }
}
}
@@ -124,22 +124,6 @@
}
.ci-table {
- .build.retried {
- background-color: $gray-lightest;
- }
-
- .commit-link {
- a {
- &:focus {
- text-decoration: none;
- }
- }
-
- a:hover {
- text-decoration: none;
- }
- }
-
.avatar {
margin-left: 0;
float: none;
@@ -191,45 +175,12 @@
}
}
- .icon-container {
- display: inline-block;
-
- &.commit-icon {
- width: 15px;
- text-align: center;
- }
- }
-
- /**
- * Play button with icon in dropdowns
- */
- .no-btn {
- border: 0;
- background: none;
- outline: none;
- width: 100%;
- text-align: left;
-
- .icon-play {
- position: relative;
- top: 2px;
- margin-right: 5px;
- height: 13px;
- width: 12px;
- }
- }
-
.duration,
.finished-at {
color: $gl-text-color-secondary;
margin: 0;
white-space: nowrap;
- .fa {
- font-size: 12px;
- margin-right: 4px;
- }
-
svg {
width: 12px;
height: 12px;
@@ -241,14 +192,6 @@
.build-link a {
color: $gl-text-color;
}
-
- .btn-group.open .dropdown-toggle {
- box-shadow: none;
- }
-
- .pipeline-tags .label-container {
- white-space: normal;
- }
}
.stage-cell {
@@ -322,9 +265,11 @@
}
}
-.admin-builds-table {
- .ci-table td:last-child {
- min-width: 120px;
+[data-page='admin:jobs:index'] {
+ .admin-builds-table {
+ td:last-child {
+ min-width: 120px;
+ }
}
}
@@ -333,377 +278,376 @@
border-bottom: 0;
}
-.tab-pane {
- &.builds .ci-table tr {
- height: 71px;
- }
-
- .ci-table {
- thead th {
- border-top: 0;
+[data-page='projects:pipelines:show'] {
+ .tab-pane {
+ .ci-table {
+ thead th {
+ border-top: 0;
+ }
}
}
-}
-.build-failures {
- .build-state {
- padding: 20px 2px;
+ .build-failures {
+ .build-state {
+ padding: 20px 2px;
- .build-name {
- font-weight: $gl-font-weight-normal;
- }
+ .build-name {
+ font-weight: $gl-font-weight-normal;
+ }
- .stage {
- color: $gl-text-color-secondary;
- font-weight: $gl-font-weight-normal;
- vertical-align: middle;
+ .stage {
+ color: $gl-text-color-secondary;
+ font-weight: $gl-font-weight-normal;
+ vertical-align: middle;
+ }
}
- }
- .build-log {
- border: 0;
- line-height: initial;
- }
+ .build-log {
+ border: 0;
+ line-height: initial;
+ }
- .build-trace-row td {
- border-top: 0;
- border-bottom-width: 1px;
- border-bottom-style: solid;
- padding-top: 0;
- }
+ .build-trace-row td {
+ border-top: 0;
+ border-bottom-width: 1px;
+ border-bottom-style: solid;
+ padding-top: 0;
+ }
- .build-trace {
- width: 100%;
- text-align: left;
- margin-top: $gl-padding;
- }
+ .build-trace {
+ width: 100%;
+ text-align: left;
+ margin-top: $gl-padding;
+ }
- .build-name {
- width: 196px;
+ .build-name {
+ width: 196px;
- a {
- font-weight: $gl-font-weight-bold;
- color: $gl-text-color;
- text-decoration: none;
+ a {
+ font-weight: $gl-font-weight-bold;
+ color: $gl-text-color;
+ text-decoration: none;
- &:focus,
- &:hover {
- text-decoration: underline;
+ &:focus,
+ &:hover {
+ text-decoration: underline;
+ }
}
}
- }
-
- .build-actions {
- width: 70px;
- text-align: right;
- }
-
- .build-stage {
- width: 140px;
- }
-
- .ci-status-icon-failed {
- padding: 10px 0 10px 12px;
- width: 12px + 24px; // padding-left + svg width
- }
- .build-icon svg {
- width: 24px;
- height: 24px;
- vertical-align: middle;
- }
-
- .build-state,
- .build-trace-row {
- > td:last-child {
- padding-right: 0;
+ .build-actions {
+ width: 70px;
+ text-align: right;
}
- }
- @include media-breakpoint-down(sm) {
- td:empty {
- display: none;
+ .build-stage {
+ width: 140px;
}
- .ci-table {
- margin-top: 2 * $gl-padding;
+ .ci-status-icon-failed {
+ padding: 10px 0 10px 12px;
+ width: 12px + 24px; // padding-left + svg width
}
- .build-trace-container {
- padding-top: $gl-padding;
- padding-bottom: $gl-padding;
+ .build-icon svg {
+ width: 24px;
+ height: 24px;
+ vertical-align: middle;
}
- .build-trace {
- margin-bottom: 0;
- margin-top: 0;
+ .build-state,
+ .build-trace-row {
+ > td:last-child {
+ padding-right: 0;
+ }
}
- }
-}
-.pipeline-tab-content {
- display: flex;
- width: 100%;
- min-height: $dropdown-max-height-lg;
- background-color: $gray-light;
- padding: $gl-padding 0;
- overflow: auto;
-}
+ @include media-breakpoint-down(sm) {
+ td:empty {
+ display: none;
+ }
-// Pipeline graph
-.pipeline-graph {
- white-space: nowrap;
- transition: max-height 0.3s, padding 0.3s;
+ .ci-table {
+ margin-top: 2 * $gl-padding;
+ }
- .stage-column-list,
- .builds-container > ul {
- padding: 0;
- }
+ .build-trace-container {
+ padding-top: $gl-padding;
+ padding-bottom: $gl-padding;
+ }
- a {
- text-decoration: none;
- color: $gl-text-color;
+ .build-trace {
+ margin-bottom: 0;
+ margin-top: 0;
+ }
+ }
}
- svg {
- vertical-align: middle;
+ .pipeline-tab-content {
+ display: flex;
+ width: 100%;
+ min-height: $dropdown-max-height-lg;
+ background-color: $gray-light;
+ padding: $gl-padding 0;
+ overflow: auto;
}
- .stage-column {
- display: inline-block;
- vertical-align: top;
-
- &.left-margin {
- &:not(:first-child) {
- margin-left: 44px;
+ // Pipeline graph, used at
+ // app/assets/javascripts/pipelines/components/graph/graph_component.vue
+ .pipeline-graph {
+ white-space: nowrap;
+ transition: max-height 0.3s, padding 0.3s;
- .left-connector {
- @include flat-connector-before;
- }
- }
+ .stage-column-list,
+ .builds-container > ul {
+ padding: 0;
}
- &.no-margin {
- margin: 0;
+ a {
+ text-decoration: none;
+ color: $gl-text-color;
}
- li {
- list-style: none;
+ svg {
+ vertical-align: middle;
}
- // when downstream pipelines are present, the last stage isn't the last column
- &:last-child:not(.has-downstream) {
- .build {
- // Remove right connecting horizontal line from first build in last stage
- &:first-child::after {
- border: 0;
- }
- // Remove right curved connectors from all builds in last stage
- &:not(:first-child)::after {
- border: 0;
- }
- // Remove opposite curve
- .curve::before {
- display: none;
- }
- }
- }
+ .stage-column {
+ display: inline-block;
+ vertical-align: top;
- // when upstream pipelines are present, the first stage isn't the first column
- &:first-child:not(.has-upstream) {
- .build {
- // Remove left curved connectors from all builds in first stage
- &:not(:first-child)::before {
- border: 0;
- }
- // Remove opposite curve
- .curve::after {
- display: none;
+ &.left-margin {
+ &:not(:first-child) {
+ margin-left: 44px;
+
+ .left-connector {
+ @include flat-connector-before;
+ }
}
}
- }
-
- // Curve first child connecting lines in opposite direction
- .curve {
- display: none;
- &::before,
- &::after {
- content: '';
- width: 21px;
- height: 25px;
- position: absolute;
- top: -31px;
- border-top: 2px solid $border-color;
+ &.no-margin {
+ margin: 0;
}
- &::after {
- left: -44px;
- border-right: 2px solid $border-color;
- border-radius: 0 20px;
+ li {
+ list-style: none;
}
- &::before {
- right: -44px;
- border-left: 2px solid $border-color;
- border-radius: 20px 0 0;
+ // when downstream pipelines are present, the last stage isn't the last column
+ &:last-child:not(.has-downstream) {
+ .build {
+ // Remove right connecting horizontal line from first build in last stage
+ &:first-child::after {
+ border: 0;
+ }
+ // Remove right curved connectors from all builds in last stage
+ &:not(:first-child)::after {
+ border: 0;
+ }
+ // Remove opposite curve
+ .curve::before {
+ display: none;
+ }
+ }
}
- }
- }
- .stage-name {
- margin: 0 0 15px 10px;
- font-weight: $gl-font-weight-bold;
- width: 176px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- line-height: 2.2em;
- }
-
- .build {
- position: relative;
- width: 186px;
- margin-bottom: 10px;
- white-space: normal;
-
- .ci-job-dropdown-container {
- // override dropdown.scss
- .dropdown-menu li button {
- padding: 0;
- text-align: center;
+ // when upstream pipelines are present, the first stage isn't the first column
+ &:first-child:not(.has-upstream) {
+ .build {
+ // Remove left curved connectors from all builds in first stage
+ &:not(:first-child)::before {
+ border: 0;
+ }
+ // Remove opposite curve
+ .curve::after {
+ display: none;
+ }
+ }
}
- }
- // ensure .build-content has hover style when action-icon is hovered
- .ci-job-dropdown-container:hover .build-content {
- @extend .build-content:hover;
- }
+ // Curve first child connecting lines in opposite direction
+ .curve {
+ display: none;
- .ci-status-icon svg {
- height: 24px;
- width: 24px;
- }
+ &::before,
+ &::after {
+ content: '';
+ width: 21px;
+ height: 25px;
+ position: absolute;
+ top: -31px;
+ border-top: 2px solid $border-color;
+ }
- .dropdown-menu-toggle {
- background-color: transparent;
- border: 0;
- padding: 0;
+ &::after {
+ left: -44px;
+ border-right: 2px solid $border-color;
+ border-radius: 0 20px;
+ }
- &:focus {
- outline: none;
+ &::before {
+ right: -44px;
+ border-left: 2px solid $border-color;
+ border-radius: 20px 0 0;
+ }
}
}
- .build-content {
- @include build-content();
+ .stage-name {
+ margin: 0 0 15px 10px;
+ font-weight: $gl-font-weight-bold;
+ width: 176px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 2.2em;
}
- a.build-content:hover,
- button.build-content:hover {
- background-color: $gray-darker;
- border: 1px solid $dropdown-toggle-active-border-color;
- }
+ .build {
+ position: relative;
+ width: 186px;
+ margin-bottom: 10px;
+ white-space: normal;
+
+ .ci-job-dropdown-container {
+ // override dropdown.scss
+ .dropdown-menu li button {
+ padding: 0;
+ text-align: center;
+ }
+ }
- // Connect first build in each stage with right horizontal line
- &:first-child {
- &::after {
- content: '';
- position: absolute;
- top: 48%;
- right: -48px;
- border-top: 2px solid $border-color;
- width: 48px;
- height: 1px;
+ // ensure .build-content has hover style when action-icon is hovered
+ .ci-job-dropdown-container:hover .build-content {
+ @extend .build-content:hover;
}
- }
- // Connect each build (except for first) with curved lines
- &:not(:first-child) {
- &::after,
- &::before {
- content: '';
- top: -49px;
- position: absolute;
- border-bottom: 2px solid $border-color;
- width: 25px;
- height: 69px;
+ .ci-status-icon svg {
+ height: 24px;
+ width: 24px;
}
- // Right connecting curves
- &::after {
- right: -25px;
- border-right: 2px solid $border-color;
- border-radius: 0 0 20px;
+ .dropdown-menu-toggle {
+ background-color: transparent;
+ border: 0;
+ padding: 0;
+
+ &:focus {
+ outline: none;
+ }
}
- // Left connecting curves
- &::before {
- left: -25px;
- border-left: 2px solid $border-color;
- border-radius: 0 0 0 20px;
+ .build-content {
+ @include build-content();
}
- }
- // Connect second build to first build with smaller curved line
- &:nth-child(2) {
- &::after,
- &::before {
- height: 29px;
- top: -9px;
+ a.build-content:hover,
+ button.build-content:hover {
+ background-color: $gray-darker;
+ border: 1px solid $dropdown-toggle-active-border-color;
}
- .curve {
- display: block;
+ // Connect first build in each stage with right horizontal line
+ &:first-child {
+ &::after {
+ content: '';
+ position: absolute;
+ top: 48%;
+ right: -48px;
+ border-top: 2px solid $border-color;
+ width: 48px;
+ height: 1px;
+ }
}
- }
- }
- .ci-action-icon-container {
- position: absolute;
- right: 5px;
- top: 50%;
- transform: translateY(-50%);
+ // Connect each build (except for first) with curved lines
+ &:not(:first-child) {
+ &::after,
+ &::before {
+ content: '';
+ top: -49px;
+ position: absolute;
+ border-bottom: 2px solid $border-color;
+ width: 25px;
+ height: 69px;
+ }
- // Action Icons in big pipeline-graph nodes
- &.ci-action-icon-wrapper {
- height: 30px;
- width: 30px;
- border-radius: 100%;
- display: block;
- padding: 0;
- line-height: 0;
+ // Right connecting curves
+ &::after {
+ right: -25px;
+ border-right: 2px solid $border-color;
+ border-radius: 0 0 20px;
+ }
- svg {
- fill: $gl-text-color-secondary;
+ // Left connecting curves
+ &::before {
+ left: -25px;
+ border-left: 2px solid $border-color;
+ border-radius: 0 0 0 20px;
+ }
}
- .spinner {
- top: 2px;
+ // Connect second build to first build with smaller curved line
+ &:nth-child(2) {
+ &::after,
+ &::before {
+ height: 29px;
+ top: -9px;
+ }
+
+ .curve {
+ display: block;
+ }
}
+ }
+
+ .ci-action-icon-container {
+ position: absolute;
+ right: 5px;
+ top: 50%;
+ transform: translateY(-50%);
+
+ // Action Icons in big pipeline-graph nodes
+ &.ci-action-icon-wrapper {
+ height: 30px;
+ width: 30px;
+ border-radius: 100%;
+ display: block;
+ padding: 0;
+ line-height: 0;
- &.play {
svg {
- left: 1px;
- top: 1px;
+ fill: $gl-text-color-secondary;
+ }
+
+ .spinner {
+ top: 2px;
+ }
+
+ &.play {
+ svg {
+ left: 1px;
+ top: 1px;
+ }
}
}
}
- }
- .stage-action svg {
- left: 1px;
- top: -2px;
+ .stage-action svg {
+ left: 1px;
+ top: -2px;
+ }
}
-}
-// Triggers the dropdown in the big pipeline graph
-.dropdown-counter-badge {
- font-weight: 100;
- font-size: 15px;
- position: absolute;
- right: 13px;
- top: 8px;
+ // Triggers the dropdown in the big pipeline graph
+ .dropdown-counter-badge {
+ font-weight: 100;
+ font-size: 15px;
+ position: absolute;
+ right: 13px;
+ top: 8px;
+ }
}
.ci-build-text,
@@ -1013,31 +957,35 @@ button.mini-pipeline-graph-dropdown-toggle {
/**
* Terminal
*/
-.terminal-icon {
- margin-left: 3px;
-}
-
-.terminal-container {
- .content-block {
- border-bottom: 0;
- }
+[data-page='projects:jobs:terminal'],
+[data-page='projects:environments:terminal'] {
+ .terminal-container {
+ .content-block {
+ border-bottom: 0;
+ }
- #terminal {
- margin-top: 10px;
- min-height: 450px;
- box-sizing: border-box;
+ #terminal {
+ margin-top: 10px;
- > div {
- min-height: 450px;
+ > div {
+ min-height: 450px;
+ }
}
}
}
-.ci-header-container {
- min-height: 55px;
-
- .text-center {
- padding-top: 12px;
+/**
+ * Pipelines / Jobs header
+ */
+[data-page='projects:pipelines:show'],
+[data-page='projects:jobs:show'] {
+ .ci-header-container {
+ min-height: $gl-spacing-scale-7;
+ display: flex;
+
+ .text-center {
+ padding-top: 12px;
+ }
}
}
@@ -1045,19 +993,6 @@ button.mini-pipeline-graph-dropdown-toggle {
float: none;
}
-.autodevops-title {
- font-weight: $gl-font-weight-normal;
- line-height: 1.5;
-}
-
-.legend-all {
- color: $gl-text-color-secondary;
-}
-
-.legend-success {
- color: $green-500;
-}
-
.test-reports-table {
.build-trace {
@include build-trace();
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index 4dc1f2034f3..3605283245f 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -227,6 +227,10 @@
padding-left: 40px;
}
+ .gl-label-scoped {
+ --label-inset-border: inset 0 0 0 1px currentColor;
+ }
+
@include media-breakpoint-up(lg) {
margin-right: 5px;
}
@@ -443,20 +447,3 @@ table.u2f-registrations,
width: 100%;
max-width: $add-to-slack-popup-max-width;
}
-
-.gitlab-slack-right-arrow svg {
- fill: $white-dark;
- width: $right-arrow-size;
- height: $right-arrow-size;
- vertical-align: text-bottom;
-}
-
-.gitlab-slack-double-headed-arrow {
- vertical-align: text-top;
-
- svg {
- fill: $gray-darker;
- width: $double-headed-arrow-width;
- height: $double-headed-arrow-height;
- }
-}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index a2f8447c0b6..05ade210153 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -778,7 +778,7 @@
}
.btn {
- margin-top: $gl-padding-8;
+ margin-bottom: $gl-padding-8;
padding: $gl-btn-vert-padding $gl-btn-padding;
line-height: $gl-btn-line-height;
@@ -794,11 +794,6 @@
}
.project-buttons {
- .stat-text {
- @extend .btn;
- @extend .btn-default;
- }
-
.nav > li:not(:last-child) {
margin-right: $gl-padding-8;
}
diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss
index 239123fc3ab..ebf21f58208 100644
--- a/app/assets/stylesheets/pages/settings_ci_cd.scss
+++ b/app/assets/stylesheets/pages/settings_ci_cd.scss
@@ -5,6 +5,10 @@
}
}
+.trigger-description {
+ max-width: 100px;
+}
+
.trigger-actions {
white-space: nowrap;
diff --git a/app/assets/stylesheets/pages/tags.scss b/app/assets/stylesheets/pages/tags.scss
deleted file mode 100644
index a6d30522ff7..00000000000
--- a/app/assets/stylesheets/pages/tags.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-.tag-release-link {
- color: $blue-600 !important;
-}
diff --git a/app/assets/stylesheets/pages/ui_dev_kit.scss b/app/assets/stylesheets/pages/ui_dev_kit.scss
deleted file mode 100644
index 288da4da5c3..00000000000
--- a/app/assets/stylesheets/pages/ui_dev_kit.scss
+++ /dev/null
@@ -1,17 +0,0 @@
-.gitlab-ui-dev-kit {
- > h2 {
- margin: 35px 0 20px;
- font-weight: $gl-font-weight-bold;
- }
-
- .example {
- padding: 15px;
- border: 1px dashed $gray-100;
- margin-bottom: 15px;
-
- &::before {
- content: 'Example';
- color: $ui-dev-kit-example-color;
- }
- }
-}
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index bfbcb8c13c6..cd607e9b247 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -163,6 +163,8 @@ body.gl-dark {
--gl-text-color: #{$gray-900};
--border-color: #{$border-color};
+
+ --white: #{$white};
}
$border-white-light: $gray-900;
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 9c666331c4f..0e57fc325c2 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -156,3 +156,18 @@
display: none;
}
}
+
+// This utility is used to force the z-index to match that of dropdown menu's
+.gl-z-dropdown-menu\! {
+ z-index: 300 !important;
+}
+
+.gl-flex-basis-quarter {
+ flex-basis: 25%;
+}
+
+.gl-md-ml-3 {
+ @media (min-width: $breakpoint-md) {
+ margin-left: $gl-spacing-scale-3;
+ }
+}
diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb
index b2d98d243f9..bdd9d00ca7f 100644
--- a/app/channels/application_cable/connection.rb
+++ b/app/channels/application_cable/connection.rb
@@ -15,12 +15,14 @@ module ApplicationCable
private
def find_user_from_session_store
- session = ActiveSession.sessions_from_ids([session_id.private_id]).first
+ session = ActiveSession.sessions_from_ids(Array.wrap(session_id)).first
Warden::SessionSerializer.new('rack.session' => session).fetch(:user)
end
def session_id
- Rack::Session::SessionId.new(cookies[Gitlab::Application.config.session_options[:key]])
+ session_cookie = cookies[Gitlab::Application.config.session_options[:key]]
+
+ Rack::Session::SessionId.new(session_cookie).private_id if session_cookie.present?
end
def notification_payload(_)
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 73f71f7ad55..c05153921fe 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -2,6 +2,7 @@
class Admin::ApplicationSettingsController < Admin::ApplicationController
include InternalRedirect
+ include ServicesHelper
# NOTE: Use @application_setting in this controller when you need to access
# application_settings after it has been modified. This is because the
@@ -32,6 +33,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
end
def integrations
+ return not_found unless instance_level_integrations?
+
@integrations = Service.find_or_initialize_all(Service.for_instance).sort_by(&:title)
end
diff --git a/app/controllers/admin/concerns/authenticates_2fa_for_admin_mode.rb b/app/controllers/admin/concerns/authenticates_2fa_for_admin_mode.rb
deleted file mode 100644
index 03783cd75a3..00000000000
--- a/app/controllers/admin/concerns/authenticates_2fa_for_admin_mode.rb
+++ /dev/null
@@ -1,110 +0,0 @@
-# frozen_string_literal: true
-
-module Authenticates2FAForAdminMode
- extend ActiveSupport::Concern
-
- included do
- include AuthenticatesWithTwoFactor
- end
-
- def admin_mode_prompt_for_two_factor(user)
- return handle_locked_user(user) unless user.can?(:log_in)
-
- session[:otp_user_id] = user.id
- push_frontend_feature_flag(:webauthn)
-
- if user.two_factor_webauthn_enabled?
- setup_webauthn_authentication(user)
- else
- setup_u2f_authentication(user)
- end
-
- render 'admin/sessions/two_factor', layout: 'application'
- end
-
- def admin_mode_authenticate_with_two_factor
- user = current_user
-
- return handle_locked_user(user) unless user.can?(:log_in)
-
- if user_params[:otp_attempt].present? && session[:otp_user_id]
- admin_mode_authenticate_with_two_factor_via_otp(user)
- elsif user_params[:device_response].present? && session[:otp_user_id]
- if user.two_factor_webauthn_enabled?
- admin_mode_authenticate_with_two_factor_via_webauthn(user)
- else
- admin_mode_authenticate_with_two_factor_via_u2f(user)
- end
- elsif user && user.valid_password?(user_params[:password])
- admin_mode_prompt_for_two_factor(user)
- else
- invalid_login_redirect
- end
- end
-
- def admin_mode_authenticate_with_two_factor_via_otp(user)
- if valid_otp_attempt?(user)
- # Remove any lingering user data from login
- session.delete(:otp_user_id)
-
- user.save! unless Gitlab::Database.read_only?
-
- # The admin user has successfully passed 2fa, enable admin mode ignoring password
- enable_admin_mode
- else
- user.increment_failed_attempts!
- Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=OTP")
- flash.now[:alert] = _('Invalid two-factor code.')
-
- admin_mode_prompt_for_two_factor(user)
- end
- end
-
- def admin_mode_authenticate_with_two_factor_via_u2f(user)
- if U2fRegistration.authenticate(user, u2f_app_id, user_params[:device_response], session[:challenge])
- admin_handle_two_factor_success
- else
- admin_handle_two_factor_failure(user, 'U2F')
- end
- end
-
- def admin_mode_authenticate_with_two_factor_via_webauthn(user)
- if Webauthn::AuthenticateService.new(user, user_params[:device_response], session[:challenge]).execute
- admin_handle_two_factor_success
- else
- admin_handle_two_factor_failure(user, 'WebAuthn')
- end
- end
-
- private
-
- def enable_admin_mode
- if current_user_mode.enable_admin_mode!(skip_password_validation: true)
- redirect_to redirect_path, notice: _('Admin mode enabled')
- else
- invalid_login_redirect
- end
- end
-
- def invalid_login_redirect
- flash.now[:alert] = _('Invalid login or password')
- render :new
- end
-
- def admin_handle_two_factor_success
- # Remove any lingering user data from login
- session.delete(:otp_user_id)
- session.delete(:challenge)
-
- # The admin user has successfully passed 2fa, enable admin mode ignoring password
- enable_admin_mode
- end
-
- def admin_handle_two_factor_failure(user, method)
- user.increment_failed_attempts!
- Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=#{method}")
- flash.now[:alert] = _('Authentication via %{method} device failed.') % { method: method }
-
- admin_mode_prompt_for_two_factor(user)
- end
-end
diff --git a/app/controllers/admin/integrations_controller.rb b/app/controllers/admin/integrations_controller.rb
index 1e2a99f7078..003a5d427f5 100644
--- a/app/controllers/admin/integrations_controller.rb
+++ b/app/controllers/admin/integrations_controller.rb
@@ -2,6 +2,7 @@
class Admin::IntegrationsController < Admin::ApplicationController
include IntegrationsActions
+ include ServicesHelper
private
@@ -10,7 +11,7 @@ class Admin::IntegrationsController < Admin::ApplicationController
end
def integrations_enabled?
- true
+ instance_level_integrations?
end
def scoped_edit_integration_path(integration)
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 7a377a33d41..ba7e7d57b91 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -1,7 +1,9 @@
# frozen_string_literal: true
class Admin::RunnersController < Admin::ApplicationController
- before_action :runner, except: [:index, :tag_list]
+ include RunnerSetupScripts
+
+ before_action :runner, except: [:index, :tag_list, :runner_setup_scripts]
def index
finder = Ci::RunnersFinder.new(current_user: current_user, params: params)
@@ -53,6 +55,10 @@ class Admin::RunnersController < Admin::ApplicationController
render json: ActsAsTaggableOn::TagSerializer.new.represent(tags)
end
+ def runner_setup_scripts
+ private_runner_setup_scripts
+ end
+
private
def runner
diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb
index 0c0bbaf4d93..e0bee6f48ea 100644
--- a/app/controllers/admin/sessions_controller.rb
+++ b/app/controllers/admin/sessions_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Admin::SessionsController < ApplicationController
- include Authenticates2FAForAdminMode
+ include AuthenticatesWithTwoFactorForAdminMode
include InternalRedirect
include RendersLdapServers
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index e19b09e1324..7f7a82a3032 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -10,6 +10,7 @@ class Admin::UsersController < Admin::ApplicationController
def index
@users = User.filter_items(params[:filter]).order_name_asc
@users = @users.search_with_secondary_emails(params[:search_query]) if params[:search_query].present?
+ @users = @users.includes(:authorized_projects) # rubocop: disable CodeReuse/ActiveRecord
@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 5f05337e59e..e71652faa27 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -484,7 +484,7 @@ class ApplicationController < ActionController::Base
def set_page_title_header
# Per https://tools.ietf.org/html/rfc5987, headers need to be ISO-8859-1, not UTF-8
- response.headers['Page-Title'] = URI.escape(page_title('GitLab'))
+ response.headers['Page-Title'] = Addressable::URI.encode_component(page_title('GitLab'))
end
def set_current_admin(&block)
diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb
index a18c80b996e..f5a9b9b61db 100644
--- a/app/controllers/boards/issues_controller.rb
+++ b/app/controllers/boards/issues_controller.rb
@@ -21,6 +21,8 @@ module Boards
before_action :validate_id_list, only: [:bulk_move]
before_action :can_move_issues?, only: [:bulk_move]
+ feature_category :boards
+
def index
list_service = Boards::Issues::ListService.new(board_parent, current_user, filter_params)
issues = issues_from(list_service)
diff --git a/app/controllers/boards/lists_controller.rb b/app/controllers/boards/lists_controller.rb
index 0b8469e8290..aecd287370f 100644
--- a/app/controllers/boards/lists_controller.rb
+++ b/app/controllers/boards/lists_controller.rb
@@ -8,6 +8,8 @@ module Boards
before_action :authorize_read_list, only: [:index]
skip_before_action :authenticate_user!, only: [:index]
+ feature_category :boards
+
def index
lists = Boards::Lists::ListService.new(board.resource_parent, current_user).execute(board)
@@ -42,7 +44,7 @@ module Boards
list = board.lists.destroyable.find(params[:id])
service = Boards::Lists::DestroyService.new(board_parent, current_user)
- if service.execute(list)
+ if service.execute(list).success?
head :ok
else
head :unprocessable_entity
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index 7006c23321c..52719e90e04 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -180,13 +180,20 @@ class Clusters::ClustersController < Clusters::BaseController
params.permit(:cleanup)
end
+ def base_permitted_cluster_params
+ [
+ :enabled,
+ :environment_scope,
+ :managed,
+ :namespace_per_environment
+ ]
+ end
+
def update_params
if cluster.provided_by_user?
params.require(:cluster).permit(
- :enabled,
+ *base_permitted_cluster_params,
:name,
- :environment_scope,
- :managed,
:base_domain,
:management_project_id,
platform_kubernetes_attributes: [
@@ -198,9 +205,7 @@ class Clusters::ClustersController < Clusters::BaseController
)
else
params.require(:cluster).permit(
- :enabled,
- :environment_scope,
- :managed,
+ *base_permitted_cluster_params,
:base_domain,
:management_project_id,
platform_kubernetes_attributes: [
@@ -212,10 +217,8 @@ class Clusters::ClustersController < Clusters::BaseController
def create_gcp_cluster_params
params.require(:cluster).permit(
- :enabled,
+ *base_permitted_cluster_params,
:name,
- :environment_scope,
- :managed,
provider_gcp_attributes: [
:gcp_project_id,
:zone,
@@ -232,10 +235,8 @@ class Clusters::ClustersController < Clusters::BaseController
def create_aws_cluster_params
params.require(:cluster).permit(
- :enabled,
+ *base_permitted_cluster_params,
:name,
- :environment_scope,
- :managed,
provider_aws_attributes: [
:kubernetes_version,
:key_name,
@@ -255,10 +256,8 @@ class Clusters::ClustersController < Clusters::BaseController
def create_user_cluster_params
params.require(:cluster).permit(
- :enabled,
+ *base_permitted_cluster_params,
:name,
- :environment_scope,
- :managed,
platform_kubernetes_attributes: [
:namespace,
:api_url,
diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb
index 9ff97f398f5..5c74d79951f 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -89,10 +89,7 @@ module AuthenticatesWithTwoFactor
user.save!
sign_in(user, message: :two_factor_authenticated, event: :authentication)
else
- user.increment_failed_attempts!
- Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=OTP")
- flash.now[:alert] = _('Invalid two-factor code.')
- prompt_for_two_factor(user)
+ handle_two_factor_failure(user, 'OTP', _('Invalid two-factor code.'))
end
end
@@ -101,7 +98,7 @@ module AuthenticatesWithTwoFactor
if U2fRegistration.authenticate(user, u2f_app_id, user_params[:device_response], session[:challenge])
handle_two_factor_success(user)
else
- handle_two_factor_failure(user, 'U2F')
+ handle_two_factor_failure(user, 'U2F', _('Authentication via U2F device failed.'))
end
end
@@ -109,7 +106,7 @@ module AuthenticatesWithTwoFactor
if Webauthn::AuthenticateService.new(user, user_params[:device_response], session[:challenge]).execute
handle_two_factor_success(user)
else
- handle_two_factor_failure(user, 'WebAuthn')
+ handle_two_factor_failure(user, 'WebAuthn', _('Authentication via WebAuthn device failed.'))
end
end
@@ -152,13 +149,19 @@ module AuthenticatesWithTwoFactor
sign_in(user, message: :two_factor_authenticated, event: :authentication)
end
- def handle_two_factor_failure(user, method)
+ def handle_two_factor_failure(user, method, message)
user.increment_failed_attempts!
+ log_failed_two_factor(user, method, request.remote_ip)
+
Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=#{method}")
- flash.now[:alert] = _('Authentication via %{method} device failed.') % { method: method }
+ flash.now[:alert] = message
prompt_for_two_factor(user)
end
+ def log_failed_two_factor(user, method, ip_address)
+ # overridden in EE
+ end
+
def handle_changed_user(user)
clear_two_factor_attempt!
@@ -173,3 +176,5 @@ module AuthenticatesWithTwoFactor
Digest::SHA256.hexdigest(user.encrypted_password) != session[:user_password_hash]
end
end
+
+AuthenticatesWithTwoFactor.prepend_if_ee('EE::AuthenticatesWithTwoFactor')
diff --git a/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb b/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb
new file mode 100644
index 00000000000..a8155f1e639
--- /dev/null
+++ b/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+
+module AuthenticatesWithTwoFactorForAdminMode
+ extend ActiveSupport::Concern
+
+ included do
+ include AuthenticatesWithTwoFactor
+ end
+
+ def admin_mode_prompt_for_two_factor(user)
+ return handle_locked_user(user) unless user.can?(:log_in)
+
+ session[:otp_user_id] = user.id
+ push_frontend_feature_flag(:webauthn)
+
+ if user.two_factor_webauthn_enabled?
+ setup_webauthn_authentication(user)
+ else
+ setup_u2f_authentication(user)
+ end
+
+ render 'admin/sessions/two_factor', layout: 'application'
+ end
+
+ def admin_mode_authenticate_with_two_factor
+ user = current_user
+
+ return handle_locked_user(user) unless user.can?(:log_in)
+
+ if user_params[:otp_attempt].present? && session[:otp_user_id]
+ admin_mode_authenticate_with_two_factor_via_otp(user)
+ elsif user_params[:device_response].present? && session[:otp_user_id]
+ if user.two_factor_webauthn_enabled?
+ admin_mode_authenticate_with_two_factor_via_webauthn(user)
+ else
+ admin_mode_authenticate_with_two_factor_via_u2f(user)
+ end
+ elsif user && user.valid_password?(user_params[:password])
+ admin_mode_prompt_for_two_factor(user)
+ else
+ invalid_login_redirect
+ end
+ end
+
+ def admin_mode_authenticate_with_two_factor_via_otp(user)
+ if valid_otp_attempt?(user)
+ # Remove any lingering user data from login
+ session.delete(:otp_user_id)
+
+ user.save! unless Gitlab::Database.read_only?
+
+ # The admin user has successfully passed 2fa, enable admin mode ignoring password
+ enable_admin_mode
+ else
+ admin_handle_two_factor_failure(user, 'OTP', _('Invalid two-factor code.'))
+ end
+ end
+
+ def admin_mode_authenticate_with_two_factor_via_u2f(user)
+ if U2fRegistration.authenticate(user, u2f_app_id, user_params[:device_response], session[:challenge])
+ admin_handle_two_factor_success
+ else
+ admin_handle_two_factor_failure(user, 'U2F', _('Authentication via U2F device failed.'))
+ end
+ end
+
+ def admin_mode_authenticate_with_two_factor_via_webauthn(user)
+ if Webauthn::AuthenticateService.new(user, user_params[:device_response], session[:challenge]).execute
+ admin_handle_two_factor_success
+ else
+ admin_handle_two_factor_failure(user, 'WebAuthn', _('Authentication via WebAuthn device failed.'))
+ end
+ end
+
+ private
+
+ def enable_admin_mode
+ if current_user_mode.enable_admin_mode!(skip_password_validation: true)
+ redirect_to redirect_path, notice: _('Admin mode enabled')
+ else
+ invalid_login_redirect
+ end
+ end
+
+ def invalid_login_redirect
+ flash.now[:alert] = _('Invalid login or password')
+ render :new
+ end
+
+ def admin_handle_two_factor_success
+ # Remove any lingering user data from login
+ session.delete(:otp_user_id)
+ session.delete(:challenge)
+
+ # The admin user has successfully passed 2fa, enable admin mode ignoring password
+ enable_admin_mode
+ end
+
+ def admin_handle_two_factor_failure(user, method, message)
+ user.increment_failed_attempts!
+ log_failed_two_factor(user, method, request.remote_ip)
+
+ Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=#{method}")
+ flash.now[:alert] = message
+ admin_mode_prompt_for_two_factor(user)
+ end
+end
diff --git a/app/controllers/concerns/boards_actions.rb b/app/controllers/concerns/boards_actions.rb
index 9d40b9e8c88..b382e338a78 100644
--- a/app/controllers/concerns/boards_actions.rb
+++ b/app/controllers/concerns/boards_actions.rb
@@ -9,7 +9,7 @@ module BoardsActions
before_action :boards, only: :index
before_action :board, only: :show
- before_action :push_wip_limits, only: [:index, :show]
+ before_action :push_licensed_features, only: [:index, :show]
before_action do
push_frontend_feature_flag(:not_issuable_queries, parent, default_enabled: true)
end
@@ -29,7 +29,7 @@ module BoardsActions
private
# Noop on FOSS
- def push_wip_limits
+ def push_licensed_features
end
def boards
diff --git a/app/controllers/concerns/controller_with_feature_category.rb b/app/controllers/concerns/controller_with_feature_category.rb
index f8985cf0950..c1ff9ef2e69 100644
--- a/app/controllers/concerns/controller_with_feature_category.rb
+++ b/app/controllers/concerns/controller_with_feature_category.rb
@@ -5,35 +5,38 @@ module ControllerWithFeatureCategory
include Gitlab::ClassAttributes
class_methods do
- def feature_category(category, config = {})
- validate_config!(config)
+ def feature_category(category, actions = [])
+ feature_category_configuration[category] ||= []
+ feature_category_configuration[category] += actions.map(&:to_s)
- category_config = Config.new(category, config[:only], config[:except], config[:if], config[:unless])
- # Add the config to the beginning. That way, the last defined one takes precedence.
- feature_category_configuration.unshift(category_config)
+ validate_config!(feature_category_configuration)
end
def feature_category_for_action(action)
- category_config = feature_category_configuration.find { |config| config.matches?(action) }
+ category_config = feature_category_configuration.find do |_, actions|
+ actions.empty? || actions.include?(action)
+ end
- category_config&.category || superclass_feature_category_for_action(action)
+ category_config&.first || superclass_feature_category_for_action(action)
end
private
def validate_config!(config)
- invalid_keys = config.keys - [:only, :except, :if, :unless]
- if invalid_keys.any?
- raise ArgumentError, "unknown arguments: #{invalid_keys} "
+ empty = config.find { |_, actions| actions.empty? }
+ duplicate_actions = config.values.flatten.group_by(&:itself).select { |_, v| v.count > 1 }.keys
+
+ if config.length > 1 && empty
+ raise ArgumentError, "#{empty.first} is defined for all actions, but other categories are set"
end
- if config.key?(:only) && config.key?(:except)
- raise ArgumentError, "cannot configure both `only` and `except`"
+ if duplicate_actions.any?
+ raise ArgumentError, "Actions have multiple feature categories: #{duplicate_actions.join(', ')}"
end
end
def feature_category_configuration
- class_attributes[:feature_category_config] ||= []
+ class_attributes[:feature_category_config] ||= {}
end
def superclass_feature_category_for_action(action)
diff --git a/app/controllers/concerns/controller_with_feature_category/config.rb b/app/controllers/concerns/controller_with_feature_category/config.rb
deleted file mode 100644
index 624691ee4f6..00000000000
--- a/app/controllers/concerns/controller_with_feature_category/config.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-module ControllerWithFeatureCategory
- class Config
- attr_reader :category
-
- def initialize(category, only, except, if_proc, unless_proc)
- @category = category.to_sym
- @only, @except = only&.map(&:to_s), except&.map(&:to_s)
- @if_proc, @unless_proc = if_proc, unless_proc
- end
-
- def matches?(action)
- included?(action) && !excluded?(action) &&
- if_proc?(action) && !unless_proc?(action)
- end
-
- private
-
- attr_reader :only, :except, :if_proc, :unless_proc
-
- def if_proc?(action)
- if_proc.nil? || if_proc.call(action)
- end
-
- def unless_proc?(action)
- unless_proc.present? && unless_proc.call(action)
- end
-
- def included?(action)
- only.nil? || only.include?(action)
- end
-
- def excluded?(action)
- except.present? && except.include?(action)
- end
- end
-end
diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb
index 02082f81598..bf38e4ad117 100644
--- a/app/controllers/concerns/enforces_two_factor_authentication.rb
+++ b/app/controllers/concerns/enforces_two_factor_authentication.rb
@@ -11,7 +11,7 @@ module EnforcesTwoFactorAuthentication
extend ActiveSupport::Concern
included do
- before_action :check_two_factor_requirement
+ before_action :check_two_factor_requirement, except: [:route_not_found]
# to include this in controllers inheriting from `ActionController::Metal`
# we need to add this block
diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb
index 6060dc729af..39f63bbaaec 100644
--- a/app/controllers/concerns/integrations_actions.rb
+++ b/app/controllers/concerns/integrations_actions.rb
@@ -20,7 +20,7 @@ module IntegrationsActions
respond_to do |format|
format.html do
if saved
- PropagateIntegrationWorker.perform_async(integration.id, false)
+ PropagateIntegrationWorker.perform_async(integration.id)
redirect_to scoped_edit_integration_path(integration), notice: success_message
else
render 'shared/integrations/edit'
diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb
index e3ac117660b..7ed66027da3 100644
--- a/app/controllers/concerns/issuable_collections_action.rb
+++ b/app/controllers/concerns/issuable_collections_action.rb
@@ -59,6 +59,9 @@ module IssuableCollectionsAction
end
def finder_options
- super.merge(non_archived: true)
+ super.merge(
+ non_archived: true,
+ issue_types: Issue::TYPES_FOR_LIST
+ )
end
end
diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb
index 29138e7b014..6470c75dfbd 100644
--- a/app/controllers/concerns/milestone_actions.rb
+++ b/app/controllers/concerns/milestone_actions.rb
@@ -3,13 +3,25 @@
module MilestoneActions
extend ActiveSupport::Concern
+ def issues
+ respond_to do |format|
+ format.html { redirect_to milestone_redirect_path }
+ format.json do
+ render json: tabs_json("shared/milestones/_issues_tab", {
+ issues: @milestone.sorted_issues(current_user), # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ show_project_name: Gitlab::Utils.to_boolean(params[:show_project_name])
+ })
+ end
+ end
+ end
+
def merge_requests
respond_to do |format|
format.html { redirect_to milestone_redirect_path }
format.json do
render json: tabs_json("shared/milestones/_merge_requests_tab", {
merge_requests: @milestone.sorted_merge_requests(current_user), # rubocop:disable Gitlab/ModuleWithInstanceVariables
- show_project_name: true
+ show_project_name: Gitlab::Utils.to_boolean(params[:show_project_name])
})
end
end
diff --git a/app/controllers/concerns/multiple_boards_actions.rb b/app/controllers/concerns/multiple_boards_actions.rb
index 95a6800f55c..370b8c72bfe 100644
--- a/app/controllers/concerns/multiple_boards_actions.rb
+++ b/app/controllers/concerns/multiple_boards_actions.rb
@@ -21,11 +21,13 @@ module MultipleBoardsActions
end
def create
- board = Boards::CreateService.new(parent, current_user, board_params).execute
+ response = Boards::CreateService.new(parent, current_user, board_params).execute
respond_to do |format|
format.json do
- if board.persisted?
+ board = response.payload
+
+ if response.success?
extra_json = { board_path: board_path(board) }
render json: serialize_as_json(board).merge(extra_json)
else
diff --git a/app/controllers/concerns/redis_tracking.rb b/app/controllers/concerns/redis_tracking.rb
index fa5eef981d1..fdd22cc0da0 100644
--- a/app/controllers/concerns/redis_tracking.rb
+++ b/app/controllers/concerns/redis_tracking.rb
@@ -26,7 +26,6 @@ module RedisTracking
def track_unique_redis_hll_event(event_name, feature, feature_default_enabled)
return unless metric_feature_enabled?(feature, feature_default_enabled)
- return unless Gitlab::CurrentSettings.usage_ping_enabled?
return unless visitor_id
Gitlab::UsageDataCounters::HLLRedisCounter.track_event(visitor_id, event_name)
diff --git a/app/controllers/concerns/runner_setup_scripts.rb b/app/controllers/concerns/runner_setup_scripts.rb
new file mode 100644
index 00000000000..c0e657a32d1
--- /dev/null
+++ b/app/controllers/concerns/runner_setup_scripts.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module RunnerSetupScripts
+ extend ActiveSupport::Concern
+
+ private
+
+ def private_runner_setup_scripts(**kwargs)
+ instructions = Gitlab::Ci::RunnerInstructions.new(current_user: current_user, os: script_params[:os], arch: script_params[:arch], **kwargs)
+ output = {
+ install: instructions.install_script,
+ register: instructions.register_command
+ }
+
+ if instructions.errors.any?
+ render json: { errors: instructions.errors }, status: :bad_request
+ else
+ render json: output
+ end
+ end
+
+ def script_params
+ params.permit(:os, :arch)
+ end
+end
diff --git a/app/controllers/concerns/show_inherited_labels_checker.rb b/app/controllers/concerns/show_inherited_labels_checker.rb
new file mode 100644
index 00000000000..acbea37a62e
--- /dev/null
+++ b/app/controllers/concerns/show_inherited_labels_checker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module ShowInheritedLabelsChecker
+ extend ActiveSupport::Concern
+
+ private
+
+ def show_inherited_labels?(include_ancestor_groups)
+ Feature.enabled?(:show_inherited_labels, @project || @group) || include_ancestor_groups # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+end
diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb
index 5a5b634da40..4d1684ec3a2 100644
--- a/app/controllers/concerns/wiki_actions.rb
+++ b/app/controllers/concerns/wiki_actions.rb
@@ -44,7 +44,7 @@ module WikiActions
wiki.list_pages(sort: params[:sort], direction: params[:direction])
).page(params[:page])
- @wiki_entries = WikiPage.group_by_directory(@wiki_pages)
+ @wiki_entries = WikiDirectory.group_pages(@wiki_pages)
render 'shared/wikis/pages'
end
diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb
index a27c4027380..c42c9827eaf 100644
--- a/app/controllers/confirmations_controller.rb
+++ b/app/controllers/confirmations_controller.rb
@@ -3,6 +3,8 @@
class ConfirmationsController < Devise::ConfirmationsController
include AcceptsPendingInvitations
+ feature_category :users
+
def almost_there
flash[:notice] = nil
render layout: "devise_empty"
diff --git a/app/controllers/dashboard/groups_controller.rb b/app/controllers/dashboard/groups_controller.rb
index f82cde8e10a..23ffcd50369 100644
--- a/app/controllers/dashboard/groups_controller.rb
+++ b/app/controllers/dashboard/groups_controller.rb
@@ -5,6 +5,8 @@ class Dashboard::GroupsController < Dashboard::ApplicationController
skip_cross_project_access_check :index
+ feature_category :subgroups
+
def index
groups = GroupsFinder.new(current_user, all_available: false).execute
render_group_tree(groups)
diff --git a/app/controllers/dashboard/labels_controller.rb b/app/controllers/dashboard/labels_controller.rb
index 89d87c2d5c8..e3773f65744 100644
--- a/app/controllers/dashboard/labels_controller.rb
+++ b/app/controllers/dashboard/labels_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Dashboard::LabelsController < Dashboard::ApplicationController
+ feature_category :issue_tracking
+
def index
respond_to do |format|
format.json { render json: LabelSerializer.new.represent_appearance(labels) }
diff --git a/app/controllers/dashboard/milestones_controller.rb b/app/controllers/dashboard/milestones_controller.rb
index 14f9a026688..e17b16c26a2 100644
--- a/app/controllers/dashboard/milestones_controller.rb
+++ b/app/controllers/dashboard/milestones_controller.rb
@@ -4,6 +4,8 @@ class Dashboard::MilestonesController < Dashboard::ApplicationController
before_action :projects
before_action :groups, only: :index
+ feature_category :issue_tracking
+
def index
respond_to do |format|
format.html do
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index 2bd6fd85381..f7a74f40e4b 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -14,6 +14,8 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
before_action :projects, only: [:index]
skip_cross_project_access_check :index, :starred
+ feature_category :projects
+
def index
respond_to do |format|
format.html do
diff --git a/app/controllers/dashboard/snippets_controller.rb b/app/controllers/dashboard/snippets_controller.rb
index a8ca3dbd0e7..6fe3d878639 100644
--- a/app/controllers/dashboard/snippets_controller.rb
+++ b/app/controllers/dashboard/snippets_controller.rb
@@ -7,6 +7,8 @@ class Dashboard::SnippetsController < Dashboard::ApplicationController
skip_cross_project_access_check :index
+ feature_category :snippets
+
def index
@snippet_counts = Snippets::CountService
.new(current_user, author: current_user)
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 4fc2f7b0571..0ae326b5d94 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -9,6 +9,8 @@ class Dashboard::TodosController < Dashboard::ApplicationController
before_action :authorize_read_group!, only: :index
before_action :find_todos, only: [:index, :destroy_all]
+ feature_category :issue_tracking
+
def index
@sort = params[:sort]
@todos = @todos.page(params[:page])
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 07cc31fb7d3..a88cf64d842 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -15,6 +15,10 @@ class DashboardController < Dashboard::ApplicationController
respond_to :html
+ feature_category :audit_events, [:activity]
+ feature_category :issue_tracking, [:issues, :issues_calendar]
+ feature_category :code_review, [:merge_requests]
+
def activity
respond_to do |format|
format.html
diff --git a/app/controllers/explore/groups_controller.rb b/app/controllers/explore/groups_controller.rb
index 67db797b80a..aa4196b1c18 100644
--- a/app/controllers/explore/groups_controller.rb
+++ b/app/controllers/explore/groups_controller.rb
@@ -3,6 +3,8 @@
class Explore::GroupsController < Explore::ApplicationController
include GroupTree
+ feature_category :subgroups
+
def index
render_group_tree GroupsFinder.new(current_user).execute
end
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index b3fa089a712..42795e418a4 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -18,6 +18,8 @@ class Explore::ProjectsController < Explore::ApplicationController
rescue_from PageOutOfBoundsError, with: :page_out_of_bounds
+ feature_category :projects
+
def index
@projects = load_projects
diff --git a/app/controllers/explore/snippets_controller.rb b/app/controllers/explore/snippets_controller.rb
index 3a56a48e578..91ab18f2f55 100644
--- a/app/controllers/explore/snippets_controller.rb
+++ b/app/controllers/explore/snippets_controller.rb
@@ -3,6 +3,8 @@
class Explore::SnippetsController < Explore::ApplicationController
include Gitlab::NoteableMetadata
+ feature_category :snippets
+
def index
@snippets = SnippetsFinder.new(current_user, explore: true)
.execute
diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb
index c395b93f4e7..06c793b5c4c 100644
--- a/app/controllers/groups/group_links_controller.rb
+++ b/app/controllers/groups/group_links_controller.rb
@@ -24,6 +24,15 @@ class Groups::GroupLinksController < Groups::ApplicationController
def update
Groups::GroupLinks::UpdateService.new(@group_link).execute(group_link_params)
+
+ if @group_link.expires?
+ render json: {
+ expires_in: helpers.distance_of_time_in_words_to_now(@group_link.expires_at),
+ expires_soon: @group_link.expires_soon?
+ }
+ else
+ render json: {}
+ end
end
def destroy
diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb
index 1034ca6cd7b..97d9f8fcecd 100644
--- a/app/controllers/groups/labels_controller.rb
+++ b/app/controllers/groups/labels_controller.rb
@@ -2,6 +2,7 @@
class Groups::LabelsController < Groups::ApplicationController
include ToggleSubscriptionAction
+ include ShowInheritedLabelsChecker
before_action :label, only: [:edit, :update, :destroy]
before_action :authorize_admin_labels!, only: [:new, :create, :edit, :update, :destroy]
@@ -12,8 +13,9 @@ class Groups::LabelsController < Groups::ApplicationController
def index
respond_to do |format|
format.html do
- @labels = GroupLabelsFinder
- .new(current_user, @group, params.merge(sort: sort)).execute
+ # at group level we do not want to list project labels,
+ # we only want `only_group_labels = false` when pulling labels for label filter dropdowns, fetched through json
+ @labels = available_labels(params.merge(only_group_labels: true)).page(params[:page])
end
format.json do
render json: LabelSerializer.new.represent_appearance(available_labels)
@@ -60,13 +62,7 @@ class Groups::LabelsController < Groups::ApplicationController
def destroy
@label.destroy
-
- respond_to do |format|
- format.html do
- redirect_to group_labels_path(@group), status: :found, notice: "#{@label.name} deleted permanently"
- end
- format.js
- end
+ redirect_to group_labels_path(@group), status: :found, notice: "#{@label.name} deleted permanently"
end
protected
@@ -80,7 +76,7 @@ class Groups::LabelsController < Groups::ApplicationController
end
def label
- @label ||= @group.labels.find(params[:id])
+ @label ||= available_labels(params.merge(only_group_labels: true)).find(params[:id])
end
alias_method :subscribable_resource, :label
@@ -108,15 +104,17 @@ class Groups::LabelsController < Groups::ApplicationController
session[:previous_labels_path] = URI(request.referer || '').path
end
- def available_labels
+ def available_labels(options = params)
@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],
- search: params[:search]).execute
+ only_group_labels: options[:only_group_labels],
+ include_ancestor_groups: show_inherited_labels?(params[:include_ancestor_groups]),
+ sort: sort,
+ subscribed: options[:subscribed],
+ include_descendant_groups: options[:include_descendant_groups],
+ search: options[:search]).execute
end
def sort
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index df3fb6b67c2..3f2894d378b 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -3,7 +3,7 @@
class Groups::MilestonesController < Groups::ApplicationController
include MilestoneActions
- before_action :milestone, only: [:edit, :show, :update, :merge_requests, :participants, :labels, :destroy]
+ before_action :milestone, only: [:edit, :show, :update, :issues, :merge_requests, :participants, :labels, :destroy]
before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update, :destroy]
before_action do
push_frontend_feature_flag(:burnup_charts, @group)
diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb
index 14651e0794a..87a62a8f9b0 100644
--- a/app/controllers/groups/registry/repositories_controller.rb
+++ b/app/controllers/groups/registry/repositories_controller.rb
@@ -2,6 +2,8 @@
module Groups
module Registry
class RepositoriesController < Groups::ApplicationController
+ include PackagesHelper
+
before_action :verify_container_registry_enabled!
before_action :authorize_read_container_image!
@@ -13,7 +15,7 @@ module Groups
.execute
.with_api_entity_associations
- track_event(:list_repositories)
+ track_package_event(:list_repositories, :container)
serializer = ContainerRepositoriesSerializer
.new(current_user: current_user)
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index bf3a38ce57b..ceee049d824 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -3,6 +3,8 @@
module Groups
module Settings
class CiCdController < Groups::ApplicationController
+ include RunnerSetupScripts
+
skip_cross_project_access_check :show
before_action :authorize_admin_group!
before_action :authorize_update_max_artifacts_size!, only: [:update]
@@ -49,6 +51,10 @@ module Groups
redirect_to group_settings_ci_cd_path
end
+ def runner_setup_scripts
+ private_runner_setup_scripts(group: group)
+ end
+
private
def define_variables
diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb
new file mode 100644
index 00000000000..58b9f8c0fbb
--- /dev/null
+++ b/app/controllers/import/bulk_imports_controller.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+class Import::BulkImportsController < ApplicationController
+ before_action :ensure_group_import_enabled
+ before_action :verify_blocked_uri, only: :status
+
+ def configure
+ session[access_token_key] = params[access_token_key]&.strip
+ session[url_key] = params[url_key]
+
+ redirect_to status_import_bulk_import_url
+ end
+
+ private
+
+ def import_params
+ params.permit(access_token_key, url_key)
+ end
+
+ def ensure_group_import_enabled
+ render_404 unless Feature.enabled?(:bulk_import)
+ end
+
+ def access_token_key
+ :bulk_import_gitlab_access_token
+ end
+
+ def url_key
+ :bulk_import_gitlab_url
+ end
+
+ def verify_blocked_uri
+ Gitlab::UrlBlocker.validate!(
+ session[url_key],
+ **{
+ allow_localhost: allow_local_requests?,
+ allow_local_network: allow_local_requests?,
+ schemes: %w(http https)
+ }
+ )
+ rescue Gitlab::UrlBlocker::BlockedUrlError => e
+ session[access_token_key] = nil
+ session[url_key] = nil
+
+ redirect_to new_group_path, alert: _('Specified URL cannot be used: "%{reason}"') % { reason: e.message }
+ end
+
+ def allow_local_requests?
+ Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services?
+ end
+end
diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb
index a34bc9c953f..bcbf5938e11 100644
--- a/app/controllers/import/fogbugz_controller.rb
+++ b/app/controllers/import/fogbugz_controller.rb
@@ -136,7 +136,7 @@ class Import::FogbugzController < Import::BaseController
def verify_blocked_uri
Gitlab::UrlBlocker.validate!(
params[:uri],
- {
+ **{
allow_localhost: allow_local_requests?,
allow_local_network: allow_local_requests?,
schemes: %w(http https)
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 29fe34f0734..a1adc6e062a 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -108,7 +108,7 @@ class Import::GithubController < Import::BaseController
@client ||= if Feature.enabled?(:remove_legacy_github_client)
Gitlab::GithubImport::Client.new(session[access_token_key])
else
- Gitlab::LegacyGithubImport::Client.new(session[access_token_key], client_options)
+ Gitlab::LegacyGithubImport::Client.new(session[access_token_key], **client_options)
end
end
diff --git a/app/controllers/import/manifest_controller.rb b/app/controllers/import/manifest_controller.rb
index 9c47e6d4b0b..fdca6da95c5 100644
--- a/app/controllers/import/manifest_controller.rb
+++ b/app/controllers/import/manifest_controller.rb
@@ -26,8 +26,7 @@ class Import::ManifestController < Import::BaseController
manifest = Gitlab::ManifestImport::Manifest.new(params[:manifest].tempfile)
if manifest.valid?
- session[:manifest_import_repositories] = manifest.projects
- session[:manifest_import_group_id] = group.id
+ manifest_import_metadata.save(manifest.projects, group.id)
redirect_to status_import_manifest_path
else
@@ -96,12 +95,16 @@ class Import::ManifestController < Import::BaseController
# rubocop: disable CodeReuse/ActiveRecord
def group
- @group ||= Group.find_by(id: session[:manifest_import_group_id])
+ @group ||= Group.find_by(id: manifest_import_metadata.group_id)
end
# rubocop: enable CodeReuse/ActiveRecord
+ def manifest_import_metadata
+ @manifest_import_status ||= Gitlab::ManifestImport::Metadata.new(current_user, fallback: session)
+ end
+
def repositories
- @repositories ||= session[:manifest_import_repositories]
+ @repositories ||= manifest_import_metadata.repositories
end
def find_jobs
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index aa9c7d01ba3..591ded7630c 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -4,6 +4,7 @@ class InvitesController < ApplicationController
include Gitlab::Utils::StrongMemoize
before_action :member
+ before_action :ensure_member_exists
before_action :invite_details
skip_before_action :authenticate_user!, only: :decline
@@ -12,13 +13,14 @@ class InvitesController < ApplicationController
respond_to :html
def show
- track_experiment('opened')
+ track_new_user_invite_experiment('opened')
accept if skip_invitation_prompt?
end
def accept
if member.accept_invite!(current_user)
- track_experiment('accepted')
+ track_new_user_invite_experiment('accepted')
+ track_invitation_reminders_experiment('accepted')
redirect_to invite_details[:path], notice: _("You have been granted %{member_human_access} access to %{title} %{name}.") %
{ member_human_access: member.human_access, title: invite_details[:title], name: invite_details[:name] }
else
@@ -28,6 +30,8 @@ class InvitesController < ApplicationController
def decline
if member.decline_invite!
+ return render layout: 'devise_experimental_onboarding_issues' if !current_user && member.invite_to_unknown_user? && member.created_by
+
path =
if current_user
dashboard_projects_path
@@ -59,14 +63,16 @@ class InvitesController < ApplicationController
end
def member
- return @member if defined?(@member)
-
- @token = params[:id]
- @member = Member.find_by_invite_token(@token)
+ strong_memoize(:member) do
+ @token = params[:id]
+ Member.find_by_invite_token(@token)
+ end
+ end
- return render_404 unless @member
+ def ensure_member_exists
+ return if member
- @member
+ render_404
end
def authenticate_user!
@@ -76,10 +82,7 @@ class InvitesController < ApplicationController
notice << "or create an account" if Gitlab::CurrentSettings.allow_signup?
notice = notice.join(' ') + "."
- # this is temporary finder instead of using member method due to render_404 possibility
- # will be resolved via https://gitlab.com/gitlab-org/gitlab/-/issues/245325
- initial_member = Member.find_by_invite_token(params[:id])
- redirect_params = initial_member ? { invite_email: initial_member.invite_email } : {}
+ redirect_params = member ? { invite_email: member.invite_email } : {}
store_location_for :user, request.fullpath
@@ -87,31 +90,43 @@ class InvitesController < ApplicationController
end
def invite_details
- @invite_details ||= case @member.source
+ @invite_details ||= case member.source
when Project
{
- name: @member.source.full_name,
- url: project_url(@member.source),
+ name: member.source.full_name,
+ url: project_url(member.source),
title: _("project"),
- path: project_path(@member.source)
+ path: project_path(member.source)
}
when Group
{
- name: @member.source.name,
- url: group_url(@member.source),
+ name: member.source.name,
+ url: group_url(member.source),
title: _("group"),
- path: group_path(@member.source)
+ path: group_path(member.source)
}
end
end
- def track_experiment(action)
+ def track_new_user_invite_experiment(action)
return unless params[:new_user_invite]
property = params[:new_user_invite] == 'experiment' ? 'experiment_group' : 'control_group'
+ track_experiment(:invite_email, action, property)
+ end
+
+ def track_invitation_reminders_experiment(action)
+ return unless Gitlab::Experimentation.enabled?(:invitation_reminders)
+
+ property = Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, member.invite_email) ? 'experimental_group' : 'control_group'
+
+ track_experiment(:invitation_reminders, action, property)
+ end
+
+ def track_experiment(experiment_key, action, property)
Gitlab::Tracking.event(
- Gitlab::Experimentation::EXPERIMENTS[:invite_email][:tracking_category],
+ Gitlab::Experimentation.experiment(experiment_key).tracking_category,
action,
property: property,
label: Digest::MD5.hexdigest(member.to_global_id.to_s)
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index b798d6680bc..2708e6669e7 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -1,8 +1,7 @@
# frozen_string_literal: true
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
- include AuthenticatesWithTwoFactor
- include Authenticates2FAForAdminMode
+ include AuthenticatesWithTwoFactorForAdminMode
include Devise::Controllers::Rememberable
include AuthHelper
include InitializesCurrentUserMode
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 1568d9966dd..f0ac86d1581 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -33,8 +33,7 @@ class Projects::BlobController < Projects::ApplicationController
before_action :set_last_commit_sha, only: [:edit, :update]
before_action only: :show do
- push_frontend_feature_flag(:code_navigation, @project, default_enabled: true)
- push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline)
+ push_frontend_experiment(:suggest_pipeline)
push_frontend_feature_flag(:gitlab_ci_yml_preview, @project, default_enabled: false)
end
diff --git a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
index b36c5f1aea6..3d3b62fa797 100644
--- a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
+++ b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
@@ -6,7 +6,6 @@ class Projects::Ci::DailyBuildGroupReportResultsController < Projects::Applicati
MAX_ITEMS = 1000
REPORT_WINDOW = 90.days
- before_action :validate_feature_flag!
before_action :authorize_read_build_report_results!
before_action :validate_param_type!
@@ -19,10 +18,6 @@ class Projects::Ci::DailyBuildGroupReportResultsController < Projects::Applicati
private
- def validate_feature_flag!
- render_404 unless Feature.enabled?(:ci_download_daily_code_coverage, project, default_enabled: true)
- end
-
def validate_param_type!
respond_422 unless allowed_param_types.include?(param_type)
end
@@ -43,7 +38,7 @@ class Projects::Ci::DailyBuildGroupReportResultsController < Projects::Applicati
end
def report_results
- Ci::DailyBuildGroupReportResultsFinder.new(finder_params).execute
+ Ci::DailyBuildGroupReportResultsFinder.new(**finder_params).execute
end
def finder_params
diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb
index 9b889f9e837..91c07c37d2a 100644
--- a/app/controllers/projects/graphs_controller.rb
+++ b/app/controllers/projects/graphs_controller.rb
@@ -57,7 +57,6 @@ class Projects::GraphsController < Projects::ApplicationController
end
def get_daily_coverage_options
- return unless Feature.enabled?(:ci_download_daily_code_coverage, @project, default_enabled: true)
return unless can?(current_user, :read_build_report_results, project)
date_today = Date.current
diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index a30c455a7e4..f8ea6f834a3 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -21,8 +21,17 @@ class Projects::GroupLinksController < Projects::ApplicationController
end
def update
- @group_link = @project.project_group_links.find(params[:id])
- Projects::GroupLinks::UpdateService.new(@group_link).execute(group_link_params)
+ group_link = @project.project_group_links.find(params[:id])
+ Projects::GroupLinks::UpdateService.new(group_link).execute(group_link_params)
+
+ if group_link.expires?
+ render json: {
+ expires_in: helpers.distance_of_time_in_words_to_now(group_link.expires_at),
+ expires_soon: group_link.expires_soon?
+ }
+ else
+ render json: {}
+ end
end
def destroy
diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb
index 12cc4dde1f4..1c915842e61 100644
--- a/app/controllers/projects/incidents_controller.rb
+++ b/app/controllers/projects/incidents_controller.rb
@@ -1,8 +1,52 @@
# frozen_string_literal: true
class Projects::IncidentsController < Projects::ApplicationController
- before_action :authorize_read_incidents!
+ include IssuableActions
+ include Gitlab::Utils::StrongMemoize
+
+ before_action :authorize_read_issue!
+ before_action :check_feature_flag, only: [:show]
+ before_action :load_incident, only: [:show]
+
+ before_action do
+ push_frontend_feature_flag(:issues_incident_details, @project)
+ end
def index
end
+
+ private
+
+ def incident
+ strong_memoize(:incident) do
+ incident_finder
+ .execute
+ .inc_relations_for_view
+ .iid_in(params[:id])
+ .without_order
+ .first
+ end
+ end
+
+ def load_incident
+ @issue = incident # needed by rendered view
+ return render_404 unless can?(current_user, :read_issue, incident)
+
+ @noteable = incident
+ @note = incident.project.notes.new(noteable: issuable)
+ end
+
+ alias_method :issuable, :incident
+
+ def incident_finder
+ IssuesFinder.new(current_user, project_id: @project.id, issue_types: :incident)
+ end
+
+ def serializer
+ IssueSerializer.new(current_user: current_user, project: incident.project)
+ end
+
+ def check_feature_flag
+ render_404 unless Feature.enabled?(:issues_incident_details, @project)
+ end
end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 7f0d23b79ce..319a5183429 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -56,7 +56,7 @@ class Projects::IssuesController < Projects::ApplicationController
end
before_action only: :index do
- push_frontend_feature_flag(:scoped_labels, @project)
+ push_frontend_feature_flag(:scoped_labels, @project, type: :licensed)
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
@@ -239,7 +239,7 @@ class Projects::IssuesController < Projects::ApplicationController
return @issue if defined?(@issue)
# The Sortable default scope causes performance issues when used with find_by
- @issuable = @noteable = @issue ||= @project.issues.includes(author: :status).where(iid: params[:id]).reorder(nil).take!
+ @issuable = @noteable = @issue ||= @project.issues.inc_relations_for_view.iid_in(params[:id]).without_order.take!
@note = @project.notes.new(noteable: @issuable)
return render_404 unless can?(current_user, :read_issue, @issue)
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 3f7f8da3478..a8ac20cf96b 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -28,11 +28,6 @@ class Projects::JobsController < Projects::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def show
- @pipeline = @build.pipeline
- @builds = @pipeline.builds
- .order('id DESC')
- .present(current_user: current_user)
-
respond_to do |format|
format.html
format.json do
diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb
index b7aeab8f5ff..ca2fad35451 100644
--- a/app/controllers/projects/labels_controller.rb
+++ b/app/controllers/projects/labels_controller.rb
@@ -2,6 +2,7 @@
class Projects::LabelsController < Projects::ApplicationController
include ToggleSubscriptionAction
+ include ShowInheritedLabelsChecker
before_action :check_issuables_available!
before_action :label, only: [:edit, :update, :destroy, :promote]
@@ -161,7 +162,7 @@ class Projects::LabelsController < Projects::ApplicationController
@available_labels ||=
LabelsFinder.new(current_user,
project_id: @project.id,
- include_ancestor_groups: params[:include_ancestor_groups],
+ include_ancestor_groups: show_inherited_labels?(params[:include_ancestor_groups]),
search: params[:search],
subscribed: params[:subscribed],
sort: sort).execute
diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb
index 921da788ad2..7b4024ed79c 100644
--- a/app/controllers/projects/merge_requests/application_controller.rb
+++ b/app/controllers/projects/merge_requests/application_controller.rb
@@ -35,6 +35,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
:source_branch,
:source_project_id,
:state_event,
+ :wip_event,
:squash,
:target_branch,
:target_project_id,
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 8aacfdce094..28aef6f4328 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -64,7 +64,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
render: ->(partial, locals) { view_to_html_string(partial, locals) }
}
- options = additional_attributes.merge(diff_view: Feature.enabled?(:unified_diff_lines, @merge_request.project) ? "inline" : diff_view)
+ options = additional_attributes.merge(diff_view: Feature.enabled?(:unified_diff_lines, @merge_request.project, default_enabled: true) ? "inline" : diff_view)
if @merge_request.project.context_commits_enabled?
options[:context_commits] = @merge_request.recent_context_commits
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 92785540172..8ca70602c89 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -27,9 +27,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :authenticate_user!, only: [:assign_related_issues]
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
before_action only: [:show] do
- push_frontend_feature_flag(:deploy_from_footer, @project, default_enabled: true)
- push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline)
- push_frontend_feature_flag(:code_navigation, @project, default_enabled: true)
+ push_frontend_experiment(:suggest_pipeline)
push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true)
push_frontend_feature_flag(:merge_ref_head_comments, @project, default_enabled: true)
push_frontend_feature_flag(:mr_commit_neighbor_nav, @project, default_enabled: true)
@@ -39,7 +37,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:approvals_commented_by, @project, default_enabled: true)
push_frontend_feature_flag(:hide_jump_to_next_unresolved_in_threads, default_enabled: true)
push_frontend_feature_flag(:merge_request_widget_graphql, @project)
- push_frontend_feature_flag(:unified_diff_lines, @project)
+ push_frontend_feature_flag(:unified_diff_lines, @project, default_enabled: true)
push_frontend_feature_flag(:highlight_current_diff_row, @project)
push_frontend_feature_flag(:default_merge_ref_for_diffs, @project)
end
@@ -52,12 +50,17 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
after_action :log_merge_request_show, only: [:show]
- feature_category :source_code_management,
- unless: -> (action) { action.ends_with?("_reports") }
- feature_category :code_testing,
- only: [:test_reports, :coverage_reports, :terraform_reports]
- feature_category :accessibility_testing,
- only: [:accessibility_reports]
+ feature_category :code_review, [
+ :assign_related_issues, :bulk_update, :cancel_auto_merge,
+ :ci_environments_status, :commit_change_content, :commits,
+ :context_commits, :destroy, :diff_for_path, :discussions,
+ :edit, :exposed_artifacts, :index, :merge,
+ :pipeline_status, :pipelines, :rebase, :remove_wip, :show,
+ :toggle_award_emoji, :toggle_subscription, :update
+ ]
+
+ feature_category :code_testing, [:test_reports, :coverage_reports, :terraform_reports]
+ feature_category :accessibility_testing, [:accessibility_reports]
def index
@merge_requests = @issuables
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index 16d63cc184f..8049a17068b 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -5,7 +5,7 @@ class Projects::MilestonesController < Projects::ApplicationController
include MilestoneActions
before_action :check_issuables_available!
- before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels, :promote]
+ before_action :milestone, only: [:edit, :update, :destroy, :show, :issues, :merge_requests, :participants, :labels, :promote]
before_action do
push_frontend_feature_flag(:burnup_charts, @project)
end
@@ -14,7 +14,7 @@ class Projects::MilestonesController < Projects::ApplicationController
before_action :authorize_read_milestone!
# Allow admin milestone
- before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels]
+ before_action :authorize_admin_milestone!, except: [:index, :show, :issues, :merge_requests, :participants, :labels]
# Allow to promote milestone
before_action :authorize_promote_milestone!, only: :promote
diff --git a/app/controllers/projects/packages/packages_controller.rb b/app/controllers/projects/packages/packages_controller.rb
index fc4ef7a01dc..302847eeaf5 100644
--- a/app/controllers/projects/packages/packages_controller.rb
+++ b/app/controllers/projects/packages/packages_controller.rb
@@ -5,20 +5,11 @@ module Projects
class PackagesController < Projects::ApplicationController
include PackagesAccess
- before_action :authorize_destroy_package!, only: [:destroy]
-
def show
@package = project.packages.find(params[:id])
@package_files = @package.package_files.recent
@maven_metadatum = @package.maven_metadatum
end
-
- def destroy
- @package = project.packages.find(params[:id])
- @package.destroy
-
- redirect_to project_packages_path(@project), status: :found, notice: _('Package was removed')
- end
end
end
end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index c1734d2cd8a..8676c90ca86 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -15,7 +15,8 @@ class Projects::PipelinesController < Projects::ApplicationController
push_frontend_feature_flag(:filter_pipelines_search, project, default_enabled: true)
push_frontend_feature_flag(:dag_pipeline_tab, project, default_enabled: true)
push_frontend_feature_flag(:pipelines_security_report_summary, project)
- push_frontend_feature_flag(:new_pipeline_form)
+ push_frontend_feature_flag(:new_pipeline_form, project)
+ push_frontend_feature_flag(:graphql_pipeline_header, project, type: :development, default_enabled: false)
end
before_action :ensure_pipeline, only: [:show]
diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb
index 060403a9cd9..f1a7ac36138 100644
--- a/app/controllers/projects/protected_refs_controller.rb
+++ b/app/controllers/projects/protected_refs_controller.rb
@@ -62,7 +62,7 @@ class Projects::ProtectedRefsController < Projects::ApplicationController
end
def access_level_attributes
- %i[access_level id _destroy]
+ %i[access_level id _destroy deploy_key_id]
end
end
diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb
index 19d0cb9acdc..28a86ecc9f0 100644
--- a/app/controllers/projects/registry/repositories_controller.rb
+++ b/app/controllers/projects/registry/repositories_controller.rb
@@ -3,6 +3,8 @@
module Projects
module Registry
class RepositoriesController < ::Projects::Registry::ApplicationController
+ include PackagesHelper
+
before_action :authorize_update_container_image!, only: [:destroy]
before_action :ensure_root_container_repository!, only: [:index]
@@ -13,7 +15,7 @@ module Projects
@images = ContainerRepositoriesFinder.new(user: current_user, subject: project, params: params.slice(:name))
.execute
- track_event(:list_repositories)
+ track_package_event(:list_repositories, :container)
serializer = ContainerRepositoriesSerializer
.new(project: project, current_user: current_user)
@@ -31,7 +33,7 @@ module Projects
def destroy
image.delete_scheduled!
DeleteContainerRepositoryWorker.perform_async(current_user.id, image.id) # rubocop:disable CodeReuse/Worker
- track_event(:delete_repository)
+ track_package_event(:delete_repository, :container)
respond_to do |format|
format.json { head :no_content }
diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb
index c42e3f6bdba..ebdb668207f 100644
--- a/app/controllers/projects/registry/tags_controller.rb
+++ b/app/controllers/projects/registry/tags_controller.rb
@@ -3,12 +3,15 @@
module Projects
module Registry
class TagsController < ::Projects::Registry::ApplicationController
+ include PackagesHelper
+
before_action :authorize_destroy_container_image!, only: [:destroy]
LIMIT = 15
def index
- track_event(:list_tags)
+ track_package_event(:list_tags, :tag)
+
respond_to do |format|
format.json do
render json: ContainerTagsSerializer
@@ -23,7 +26,7 @@ module Projects
result = Projects::ContainerRepository::DeleteTagsService
.new(image.project, current_user, tags: [params[:id]])
.execute(image)
- track_event(:delete_tag)
+ track_package_event(:delete_tag, :tag)
respond_to do |format|
format.json { head(result[:status] == :success ? :ok : bad_request) }
@@ -40,7 +43,7 @@ module Projects
result = Projects::ContainerRepository::DeleteTagsService
.new(image.project, current_user, tags: tag_names)
.execute(image)
- track_event(:delete_tag_bulk)
+ track_package_event(:delete_tag_bulk, :tag)
respond_to do |format|
format.json { head(result[:status] == :success ? :no_content : :bad_request) }
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index bd24aae980c..808e1c755ae 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -13,7 +13,7 @@ class Projects::ReleasesController < Projects::ApplicationController
push_frontend_feature_flag(:release_asset_link_type, project, default_enabled: true)
push_frontend_feature_flag(:graphql_release_data, project, default_enabled: true)
push_frontend_feature_flag(:graphql_milestone_stats, project, default_enabled: true)
- push_frontend_feature_flag(:graphql_releases_page, project, default_enabled: false)
+ push_frontend_feature_flag(:graphql_releases_page, project, default_enabled: true)
end
before_action :authorize_update_release!, only: %i[edit update]
before_action :authorize_create_release!, only: :new
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index ca62f54813b..fb00156d320 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -50,6 +50,10 @@ class Projects::RunnersController < Projects::ApplicationController
end
def toggle_shared_runners
+ if Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true) && !project.shared_runners_enabled && project.group && project.group.shared_runners_setting == 'disabled_and_unoverridable'
+ return redirect_to project_runners_path(@project), alert: _("Cannot enable shared runners because parent group does not allow it")
+ end
+
project.toggle!(:shared_runners_enabled)
redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings')
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index 9a69ef991dd..41ce834c658 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -12,7 +12,7 @@ class Projects::ServicesController < Projects::ApplicationController
before_action :set_deprecation_notice_for_prometheus_service, only: [:edit, :update]
before_action :redirect_deprecated_prometheus_service, only: [:update]
before_action only: :edit do
- push_frontend_feature_flag(:jira_issues_integration, @project, { default_enabled: true })
+ push_frontend_feature_flag(:jira_issues_integration, @project, type: :licensed, default_enabled: true)
end
respond_to :html
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index d0d100fd88c..b30f54acf90 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -3,15 +3,21 @@
module Projects
module Settings
class CiCdController < Projects::ApplicationController
+ include RunnerSetupScripts
+
before_action :authorize_admin_pipeline!
before_action :define_variables
before_action do
push_frontend_feature_flag(:new_variables_ui, @project, default_enabled: true)
push_frontend_feature_flag(:ajax_new_deploy_token, @project)
- push_frontend_feature_flag(:ci_key_autocomplete, default_enabled: true)
end
def show
+ if Feature.enabled?(:ci_pipeline_triggers_settings_vue_ui, @project)
+ @triggers_json = ::Ci::TriggerSerializer.new.represent(
+ @project.triggers, current_user: current_user, project: @project
+ ).to_json
+ end
end
def update
@@ -48,6 +54,10 @@ module Projects
redirect_to namespace_project_settings_ci_cd_path
end
+ def runner_setup_scripts
+ private_runner_setup_scripts(project: @project)
+ end
+
private
def update_params
@@ -116,6 +126,7 @@ module Projects
def define_triggers_variables
@triggers = @project.triggers
.present(current_user: current_user)
+
@trigger = ::Ci::Trigger.new
.present(current_user: current_user)
end
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index 35ca9336613..182968b9a41 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -7,6 +7,7 @@ module Projects
before_action :define_variables, only: [:create_deploy_token]
before_action do
push_frontend_feature_flag(:ajax_new_deploy_token, @project)
+ push_frontend_feature_flag(:deploy_keys_on_protected_branches, @project)
end
def show
@@ -125,6 +126,7 @@ module Projects
gon.push(protectable_tags_for_dropdown)
gon.push(protectable_branches_for_dropdown)
gon.push(access_levels_options)
+ gon.push(current_project_id: project.id) if project
end
end
end
diff --git a/app/controllers/projects/static_site_editor_controller.rb b/app/controllers/projects/static_site_editor_controller.rb
index e97a8db0b79..f99ffe170b0 100644
--- a/app/controllers/projects/static_site_editor_controller.rb
+++ b/app/controllers/projects/static_site_editor_controller.rb
@@ -25,14 +25,30 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController
).execute
if service_response.success?
- @data = service_response.payload
+ @data = serialize_necessary_payload_values_to_json(service_response.payload)
else
- respond_422
+ # TODO: For now, if the service returns any error, the user is redirected
+ # to the root project page with the error message displayed as an alert.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/213285#note_414808004
+ # for discussion of plans to handle this via a page owned by the Static Site Editor.
+ flash[:alert] = service_response.message
+ redirect_to project_path(project)
end
end
private
+ def serialize_necessary_payload_values_to_json(payload)
+ # This will convert booleans, Array-like and Hash-like objects to JSON
+ payload.transform_values do |value|
+ if value.is_a?(String) || value.is_a?(Integer)
+ value
+ else
+ value.to_json
+ end
+ end
+ end
+
def assign_ref_and_path
@ref, @path = extract_ref(params.fetch(:id))
diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb
index 475ca8894e4..71effebf1c0 100644
--- a/app/controllers/projects/tags_controller.rb
+++ b/app/controllers/projects/tags_controller.rb
@@ -76,25 +76,10 @@ class Projects::TagsController < Projects::ApplicationController
def destroy
result = ::Tags::DestroyService.new(project, current_user).execute(params[:id])
- respond_to do |format|
- if result[:status] == :success
- format.html do
- redirect_to project_tags_path(@project), status: :see_other
- end
-
- format.js
- else
- @error = result[:message]
-
- format.html do
- redirect_to project_tags_path(@project),
- alert: @error, status: :see_other
- end
-
- format.js do
- render status: :ok
- end
- end
+ if result[:status] == :success
+ render json: result
+ else
+ render json: { message: result[:message] }, status: result[:return_code]
end
end
diff --git a/app/controllers/runner_setup_controller.rb b/app/controllers/runner_setup_controller.rb
new file mode 100644
index 00000000000..2cb204b729c
--- /dev/null
+++ b/app/controllers/runner_setup_controller.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class RunnerSetupController < ApplicationController
+ def platforms
+ render json: Gitlab::Ci::RunnerInstructions::OS.merge(Gitlab::Ci::RunnerInstructions::OTHER_ENVIRONMENTS)
+ end
+end
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index dedaf0c903a..b5e221a8894 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -8,7 +8,8 @@ class SearchController < ApplicationController
SCOPE_PRELOAD_METHOD = {
projects: :with_web_entity_associations,
- issues: :with_web_entity_associations
+ issues: :with_web_entity_associations,
+ epics: :with_web_entity_associations
}.freeze
track_redis_hll_event :show, name: 'i_search_total', feature: :search_track_unique_users, feature_default_enabled: true
@@ -96,8 +97,6 @@ class SearchController < ApplicationController
end
def eager_load_user_status
- return if Feature.disabled?(:users_search, default_enabled: true)
-
@search_objects = @search_objects.eager_load(:status) # rubocop:disable CodeReuse/ActiveRecord
end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index 0b092d2622b..9510734bc9b 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -19,6 +19,7 @@ class UploadsController < ApplicationController
rescue_from UnknownUploadModelError, with: :render_404
skip_before_action :authenticate_user!
+ skip_before_action :check_two_factor_requirement, only: [:show]
before_action :upload_mount_satisfied?
before_action :authorize_access!, only: [:show]
before_action :authorize_create_access!, only: [:create, :authorize]
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 75a861423ed..e7af60beade 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -106,7 +106,7 @@ class UsersController < ApplicationController
def calendar_activities
@calendar_date = Date.parse(params[:date]) rescue Date.today
- @events = contributions_calendar.events_by_date(@calendar_date)
+ @events = contributions_calendar.events_by_date(@calendar_date).map(&:present)
render 'calendar_activities', layout: false
end
diff --git a/app/finders/group_labels_finder.rb b/app/finders/group_labels_finder.rb
deleted file mode 100644
index a668a0f0fae..00000000000
--- a/app/finders/group_labels_finder.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-class GroupLabelsFinder
- attr_reader :current_user, :group, :params
-
- def initialize(current_user, group, params = {})
- @current_user = current_user
- @group = group
- @params = params
- end
-
- def execute
- group.labels
- .optionally_subscribed_by(subscriber_id)
- .optionally_search(params[:search])
- .order_by(params[:sort])
- .page(params[:page])
- end
-
- private
-
- def subscriber_id
- current_user&.id if subscribed?
- end
-
- def subscribed?
- params[:subscribed] == 'true'
- end
-end
diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb
index ce0d52ad97a..09283f061c0 100644
--- a/app/finders/group_members_finder.rb
+++ b/app/finders/group_members_finder.rb
@@ -17,9 +17,8 @@ class GroupMembersFinder < UnionFinder
@params = params
end
- # rubocop: disable CodeReuse/ActiveRecord
def execute(include_relations: [:inherited, :direct])
- group_members = group.members
+ group_members = group_members_list
relations = []
return group_members if include_relations == [:direct]
@@ -27,17 +26,13 @@ class GroupMembersFinder < UnionFinder
relations << group_members if include_relations.include?(:direct)
if include_relations.include?(:inherited) && group.parent
- parents_members = GroupMember.non_request.non_minimal_access
- .where(source_id: group.ancestors.select(:id))
- .where.not(user_id: group.users.select(:id))
+ parents_members = relation_group_members(group.ancestors)
relations << parents_members
end
if include_relations.include?(:descendants)
- descendant_members = GroupMember.non_request.non_minimal_access
- .where(source_id: group.descendants.select(:id))
- .where.not(user_id: group.users.select(:id))
+ descendant_members = relation_group_members(group.descendants)
relations << descendant_members
end
@@ -47,7 +42,6 @@ class GroupMembersFinder < UnionFinder
members = find_union(relations, GroupMember)
filter_members(members)
end
- # rubocop: enable CodeReuse/ActiveRecord
private
@@ -67,6 +61,22 @@ class GroupMembersFinder < UnionFinder
def can_manage_members
Ability.allowed?(user, :admin_group_member, group)
end
+
+ def group_members_list
+ group.members
+ end
+
+ def relation_group_members(relation)
+ all_group_members(relation).non_minimal_access
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def all_group_members(relation)
+ GroupMember.non_request
+ .where(source_id: relation.select(:id))
+ .where.not(user_id: group.users.select(:id))
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
end
GroupMembersFinder.prepend_if_ee('EE::GroupMembersFinder')
diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb
index 54715557399..4b6b2716c64 100644
--- a/app/finders/groups_finder.rb
+++ b/app/finders/groups_finder.rb
@@ -12,6 +12,8 @@
# all_available: boolean (defaults to true)
# min_access_level: integer
# exclude_group_ids: array of integers
+# include_parent_descendants: boolean (defaults to false) - includes descendant groups when
+# filtering by parent. The parent param must be present.
#
# Users with full private access can see all groups. The `owned` and `parent`
# params can be used to restrict the groups that are returned.
@@ -84,7 +86,11 @@ class GroupsFinder < UnionFinder
def by_parent(groups)
return groups unless params[:parent]
- groups.where(parent: params[:parent])
+ if include_parent_descendants?
+ groups.id_in(params[:parent].descendants)
+ else
+ groups.where(parent: params[:parent])
+ end
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -100,6 +106,10 @@ class GroupsFinder < UnionFinder
params.fetch(:all_available, true)
end
+ def include_parent_descendants?
+ params.fetch(:include_parent_descendants, false)
+ end
+
def min_access_level?
current_user && params[:min_access_level].present?
end
diff --git a/app/finders/merge_requests/by_approvals_finder.rb b/app/finders/merge_requests/by_approvals_finder.rb
new file mode 100644
index 00000000000..e6ab1467f06
--- /dev/null
+++ b/app/finders/merge_requests/by_approvals_finder.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ # Used to filter MergeRequest collections by approvers
+ class ByApprovalsFinder
+ attr_reader :usernames, :ids
+
+ # We apply a limitation to the amount of elements that can be part of the filter condition
+ MAX_FILTER_ELEMENTS = 5
+
+ # Initialize the finder
+ #
+ # @param [Array<String>] usernames
+ # @param [Array<Integers>] ids
+ def initialize(usernames, ids)
+ # rubocop:disable CodeReuse/ActiveRecord
+ @usernames = Array(usernames).map(&:to_s).uniq.take(MAX_FILTER_ELEMENTS)
+ @ids = Array(ids).uniq.take(MAX_FILTER_ELEMENTS)
+ # rubocop:enable CodeReuse/ActiveRecord
+ end
+
+ # Filter MergeRequest collections by approvers
+ #
+ # @param [ActiveRecord::Relation] items the activerecord relation
+ def execute(items)
+ if by_no_approvals?
+ without_approvals(items)
+ elsif by_any_approvals?
+ with_any_approvals(items)
+ elsif ids.present?
+ find_approved_by_ids(items)
+ elsif usernames.present?
+ find_approved_by_names(items)
+ else
+ items
+ end
+ end
+
+ private
+
+ # Is param using special condition: "None" ?
+ #
+ # @return [Boolean] whether special condition "None" is being used
+ def by_no_approvals?
+ includes_special_label?(IssuableFinder::Params::FILTER_NONE)
+ end
+
+ # Is param using special condition: "Any" ?
+ #
+ # @return [Boolean] whether special condition "Any" is being used
+ def by_any_approvals?
+ includes_special_label?(IssuableFinder::Params::FILTER_ANY)
+ end
+
+ # Check if we have the special label in ids or usernames field
+ #
+ # @param [String] label the special label
+ # @return [Boolean] whether ids or usernames includes the special label
+ def includes_special_label?(label)
+ ids.first.to_s.downcase == label || usernames.map(&:downcase).include?(label)
+ end
+
+ # Merge Requests without any approval
+ #
+ # @param [ActiveRecord::Relation] items
+ def without_approvals(items)
+ items.without_approvals
+ end
+
+ # Merge Requests with any number of approvals
+ #
+ # @param [ActiveRecord::Relation] items the activerecord relation
+ def with_any_approvals(items)
+ items.select_from_union([
+ items.with_approvals
+ ])
+ end
+
+ # Merge Requests approved by given usernames
+ #
+ # @param [ActiveRecord::Relation] items the activerecord relation
+ def find_approved_by_names(items)
+ items.approved_by_users_with_usernames(*usernames)
+ end
+
+ # Merge Requests approved by given user IDs
+ #
+ # @param [ActiveRecord::Relation] items the activerecord relation
+ def find_approved_by_ids(items)
+ items.approved_by_users_with_ids(*ids)
+ end
+ end
+end
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index 37da29b32ff..7bdc98d2e3d 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -33,7 +33,11 @@ class MergeRequestsFinder < IssuableFinder
include MergedAtFilter
def self.scalar_params
- @scalar_params ||= super + [:wip, :draft, :target_branch, :merged_after, :merged_before]
+ @scalar_params ||= super + [:wip, :draft, :target_branch, :merged_after, :merged_before, :approved_by_ids]
+ end
+
+ def self.array_params
+ @array_params ||= super.merge(approved_by_usernames: [])
end
def klass
@@ -47,6 +51,7 @@ class MergeRequestsFinder < IssuableFinder
items = by_draft(items)
items = by_target_branch(items)
items = by_merged_at(items)
+ items = by_approvals(items)
by_source_project_id(items)
end
@@ -131,6 +136,15 @@ class MergeRequestsFinder < IssuableFinder
def deployment_id
@deployment_id ||= params[:deployment_id].presence
end
+
+ # Filter by merge requests that had been approved by specific users
+ # rubocop: disable CodeReuse/Finder
+ def by_approvals(items)
+ MergeRequests::ByApprovalsFinder
+ .new(params[:approved_by_usernames], params[:approved_by_ids])
+ .execute(items)
+ end
+ # rubocop: enable CodeReuse/Finder
end
MergeRequestsFinder.prepend_if_ee('EE::MergeRequestsFinder')
diff --git a/app/finders/packages/generic/package_finder.rb b/app/finders/packages/generic/package_finder.rb
new file mode 100644
index 00000000000..3a260e11fa3
--- /dev/null
+++ b/app/finders/packages/generic/package_finder.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Packages
+ module Generic
+ class PackageFinder
+ def initialize(project)
+ @project = project
+ end
+
+ def execute!(package_name, package_version)
+ project
+ .packages
+ .generic
+ .by_name_and_version!(package_name, package_version)
+ end
+
+ private
+
+ attr_reader :project
+ end
+ end
+end
diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb
index 577f10545b3..ac5ddc5bd4c 100644
--- a/app/graphql/mutations/base_mutation.rb
+++ b/app/graphql/mutations/base_mutation.rb
@@ -4,6 +4,7 @@ module Mutations
class BaseMutation < GraphQL::Schema::RelayClassicMutation
prepend Gitlab::Graphql::Authorize::AuthorizeResource
prepend Gitlab::Graphql::CopyFieldDescription
+ prepend ::Gitlab::Graphql::GlobalIDCompatibility
ERROR_MESSAGE = 'You cannot perform write operations on a read-only instance'
diff --git a/app/graphql/mutations/boards/lists/destroy.rb b/app/graphql/mutations/boards/lists/destroy.rb
new file mode 100644
index 00000000000..61ffae7c047
--- /dev/null
+++ b/app/graphql/mutations/boards/lists/destroy.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Boards
+ module Lists
+ class Destroy < ::Mutations::BaseMutation
+ graphql_name 'DestroyBoardList'
+
+ field :list,
+ Types::BoardListType,
+ null: true,
+ description: 'The list after mutation.'
+
+ argument :list_id, ::Types::GlobalIDType[::List],
+ required: true,
+ loads: Types::BoardListType,
+ description: 'Global ID of the list to destroy. Only label lists are accepted.'
+
+ def resolve(list:)
+ raise_resource_not_available_error! unless can_admin_list?(list)
+
+ response = ::Boards::Lists::DestroyService.new(list.board.resource_parent, current_user)
+ .execute(list)
+
+ {
+ list: response.success? ? nil : list,
+ errors: response.errors
+ }
+ end
+
+ private
+
+ def can_admin_list?(list)
+ return false unless list.present?
+
+ Ability.allowed?(current_user, :admin_list, list.board)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/ci/base.rb b/app/graphql/mutations/ci/base.rb
index 09df4487a50..aaece2a3021 100644
--- a/app/graphql/mutations/ci/base.rb
+++ b/app/graphql/mutations/ci/base.rb
@@ -3,13 +3,18 @@
module Mutations
module Ci
class Base < BaseMutation
- argument :id, ::Types::GlobalIDType[::Ci::Pipeline],
+ PipelineID = ::Types::GlobalIDType[::Ci::Pipeline]
+
+ argument :id, PipelineID,
required: true,
description: 'The id of the pipeline to mutate'
private
def find_object(id:)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = PipelineID.coerce_isolated_input(id)
GlobalID::Locator.locate(id)
end
end
diff --git a/app/graphql/mutations/design_management/move.rb b/app/graphql/mutations/design_management/move.rb
index 6126af8b68b..43e2e542408 100644
--- a/app/graphql/mutations/design_management/move.rb
+++ b/app/graphql/mutations/design_management/move.rb
@@ -21,7 +21,7 @@ module Mutations
description: "The current state of the collection"
def resolve(**args)
- service = ::DesignManagement::MoveDesignsService.new(current_user, parameters(args))
+ service = ::DesignManagement::MoveDesignsService.new(current_user, parameters(**args))
{ design_collection: service.collection, errors: service.execute.errors }
end
@@ -29,11 +29,18 @@ module Mutations
private
def parameters(**args)
- args.transform_values { |id| GitlabSchema.find_by_gid(id) }.transform_values(&:sync).tap do |hash|
+ args.transform_values { |id| find_design(id) }.transform_values(&:sync).tap do |hash|
hash.each { |k, design| not_found(args[k]) unless current_user.can?(:read_design, design) }
end
end
+ def find_design(id)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = DesignID.coerce_isolated_input(id)
+ GitlabSchema.object_from_id(id)
+ end
+
def not_found(gid)
raise Gitlab::Graphql::Errors::ResourceNotAvailable, "Resource not available: #{gid}"
end
diff --git a/app/graphql/mutations/metrics/dashboard/annotations/create.rb b/app/graphql/mutations/metrics/dashboard/annotations/create.rb
index f99688aeac6..6f316e76e2a 100644
--- a/app/graphql/mutations/metrics/dashboard/annotations/create.rb
+++ b/app/graphql/mutations/metrics/dashboard/annotations/create.rb
@@ -80,7 +80,7 @@ module Mutations
raise Gitlab::Graphql::Errors::ArgumentError, ANNOTATION_SOURCE_ARGUMENT_ERROR
end
- super(args)
+ super(**args)
end
def find_object(id:)
diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb
index 791c6eab42f..5d29d0bd437 100644
--- a/app/graphql/resolvers/base_resolver.rb
+++ b/app/graphql/resolvers/base_resolver.rb
@@ -4,6 +4,7 @@ module Resolvers
class BaseResolver < GraphQL::Schema::Resolver
extend ::Gitlab::Utils::Override
include ::Gitlab::Utils::StrongMemoize
+ include ::Gitlab::Graphql::GlobalIDCompatibility
def self.single
@single ||= Class.new(self) do
diff --git a/app/graphql/resolvers/board_list_issues_resolver.rb b/app/graphql/resolvers/board_list_issues_resolver.rb
index dba9f99edeb..3421e1024c0 100644
--- a/app/graphql/resolvers/board_list_issues_resolver.rb
+++ b/app/graphql/resolvers/board_list_issues_resolver.rb
@@ -14,7 +14,7 @@ module Resolvers
def resolve(**args)
filter_params = issue_filters(args[:filters]).merge(board_id: list.board.id, id: list.id)
- service = Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params)
+ service = ::Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params)
Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(service.execute)
end
diff --git a/app/graphql/resolvers/board_lists_resolver.rb b/app/graphql/resolvers/board_lists_resolver.rb
index b1d43934f24..ccb09e29f07 100644
--- a/app/graphql/resolvers/board_lists_resolver.rb
+++ b/app/graphql/resolvers/board_lists_resolver.rb
@@ -27,7 +27,7 @@ module Resolvers
private
def board_lists(id)
- service = Boards::Lists::ListService.new(
+ service = ::Boards::Lists::ListService.new(
board.resource_parent,
context[:current_user],
list_id: extract_list_id(id)
diff --git a/app/graphql/resolvers/board_resolver.rb b/app/graphql/resolvers/board_resolver.rb
new file mode 100644
index 00000000000..e42cb427c0c
--- /dev/null
+++ b/app/graphql/resolvers/board_resolver.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class BoardResolver < BaseResolver.single
+ alias_method :parent, :synchronized_object
+
+ type Types::BoardType, null: true
+
+ argument :id, GraphQL::ID_TYPE,
+ required: true,
+ description: 'The board\'s ID'
+
+ def resolve(id: nil)
+ return unless parent
+
+ ::Boards::ListService.new(parent, context[:current_user], board_id: extract_board_id(id)).execute(create_default_board: false).first
+ rescue ActiveRecord::RecordNotFound
+ nil
+ end
+
+ private
+
+ def extract_board_id(gid)
+ GitlabSchema.parse_gid(gid, expected_type: ::Board).model_id
+ end
+ end
+end
diff --git a/app/graphql/resolvers/boards_resolver.rb b/app/graphql/resolvers/boards_resolver.rb
index eceb5b38031..82efd92d33f 100644
--- a/app/graphql/resolvers/boards_resolver.rb
+++ b/app/graphql/resolvers/boards_resolver.rb
@@ -16,7 +16,7 @@ module Resolvers
return Board.none unless parent
- Boards::ListService.new(parent, context[:current_user], board_id: extract_board_id(id)).execute(create_default_board: false)
+ ::Boards::ListService.new(parent, context[:current_user], board_id: extract_board_id(id)).execute(create_default_board: false)
rescue ActiveRecord::RecordNotFound
Board.none
end
diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
index 2b14d8275d1..fe6fa0bb262 100644
--- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
+++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
@@ -18,9 +18,15 @@ module IssueResolverArguments
argument :milestone_title, GraphQL::STRING_TYPE.to_list_type,
required: false,
description: 'Milestone applied to this issue'
+ argument :author_username, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Username of the author of the issue'
argument :assignee_username, GraphQL::STRING_TYPE,
required: false,
description: 'Username of a user assigned to the issue'
+ argument :assignee_usernames, [GraphQL::STRING_TYPE],
+ required: false,
+ description: 'Usernames of users assigned to the issue'
argument :assignee_id, GraphQL::STRING_TYPE,
required: false,
description: 'ID of a user assigned to the issues, "none" and "any" values supported'
diff --git a/app/graphql/resolvers/concerns/looks_ahead.rb b/app/graphql/resolvers/concerns/looks_ahead.rb
index e7230287e13..61f23920ebb 100644
--- a/app/graphql/resolvers/concerns/looks_ahead.rb
+++ b/app/graphql/resolvers/concerns/looks_ahead.rb
@@ -3,8 +3,6 @@
module LooksAhead
extend ActiveSupport::Concern
- FEATURE_FLAG = :graphql_lookahead_support
-
included do
attr_accessor :lookahead
end
@@ -16,8 +14,6 @@ module LooksAhead
end
def apply_lookahead(query)
- return query unless Feature.enabled?(FEATURE_FLAG)
-
selection = node_selection
includes = preloads.each.flat_map do |name, requirements|
diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
index 0c01efd4f9a..3d845c8e9df 100644
--- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb
+++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
@@ -40,7 +40,7 @@ module ResolvesMergeRequests
author: [:author],
merged_at: [:metrics],
commit_count: [:metrics],
- approved_by: [:approver_users],
+ approved_by: [:approved_by_users],
milestone: [:milestone],
head_pipeline: [:merge_request_diff, { head_pipeline: [:merge_request] }]
}
diff --git a/app/graphql/resolvers/projects_resolver.rb b/app/graphql/resolvers/projects_resolver.rb
index 3bbadf87a71..f6afe945fe8 100644
--- a/app/graphql/resolvers/projects_resolver.rb
+++ b/app/graphql/resolvers/projects_resolver.rb
@@ -16,6 +16,10 @@ module Resolvers
required: false,
description: 'Filter projects by IDs'
+ argument :search_namespaces, GraphQL::BOOLEAN_TYPE,
+ required: false,
+ description: 'Include namespace in project search'
+
def resolve(**args)
ProjectsFinder
.new(current_user: current_user, params: project_finder_params(args), project_ids_relation: parse_gids(args[:ids]))
@@ -28,7 +32,8 @@ module Resolvers
{
without_deleted: true,
non_public: params[:membership],
- search: params[:search]
+ search: params[:search],
+ search_namespaces: params[:search_namespaces]
}.compact
end
diff --git a/app/graphql/resolvers/snippets/blobs_resolver.rb b/app/graphql/resolvers/snippets/blobs_resolver.rb
new file mode 100644
index 00000000000..dc28358cab6
--- /dev/null
+++ b/app/graphql/resolvers/snippets/blobs_resolver.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Snippets
+ class BlobsResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ alias_method :snippet, :object
+
+ argument :paths, [GraphQL::STRING_TYPE],
+ required: false,
+ description: 'Paths of the blobs'
+
+ def resolve(**args)
+ authorize!(snippet)
+
+ return [snippet.blob] if snippet.empty_repo?
+
+ paths = Array(args.fetch(:paths, []))
+
+ if paths.empty?
+ snippet.blobs
+ else
+ snippet.repository.blobs_at(transformed_blob_paths(paths))
+ end
+ end
+
+ def authorized_resource?(snippet)
+ Ability.allowed?(context[:current_user], :read_snippet, snippet)
+ end
+
+ private
+
+ def transformed_blob_paths(paths)
+ ref = snippet.default_branch
+ paths.map { |path| [ref, path] }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/terraform/states_resolver.rb b/app/graphql/resolvers/terraform/states_resolver.rb
new file mode 100644
index 00000000000..38b26a948b1
--- /dev/null
+++ b/app/graphql/resolvers/terraform/states_resolver.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Terraform
+ class StatesResolver < BaseResolver
+ type Types::Terraform::StateType, null: true
+
+ alias_method :project, :object
+
+ def resolve(**args)
+ return ::Terraform::State.none unless can_read_terraform_states?
+
+ project.terraform_states.ordered_by_name
+ end
+
+ private
+
+ def can_read_terraform_states?
+ current_user.can?(:read_terraform_state, project)
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb b/app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb
index 13c67442c2e..b9f7f616e13 100644
--- a/app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb
+++ b/app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb
@@ -14,6 +14,10 @@ module Types
value 'MERGE_REQUESTS', 'Merge request count', value: :merge_requests
value 'GROUPS', 'Group count', value: :groups
value 'PIPELINES', 'Pipeline count', value: :pipelines
+ value 'PIPELINES_SUCCEEDED', 'Pipeline count with success status', value: :pipelines_succeeded
+ value 'PIPELINES_FAILED', 'Pipeline count with failed status', value: :pipelines_failed
+ value 'PIPELINES_CANCELED', 'Pipeline count with canceled status', value: :pipelines_canceled
+ value 'PIPELINES_SKIPPED', 'Pipeline count with skipped status', value: :pipelines_skipped
end
end
end
diff --git a/app/graphql/types/design_management/design_collection_copy_state_enum.rb b/app/graphql/types/design_management/design_collection_copy_state_enum.rb
new file mode 100644
index 00000000000..7e7303c50ef
--- /dev/null
+++ b/app/graphql/types/design_management/design_collection_copy_state_enum.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Types
+ module DesignManagement
+ class DesignCollectionCopyStateEnum < BaseEnum
+ graphql_name 'DesignCollectionCopyState'
+ description 'Copy state of a DesignCollection'
+
+ DESCRIPTION_VARIANTS = {
+ in_progress: 'is being copied',
+ error: 'encountered an error during a copy',
+ ready: 'has no copy in progress'
+ }.freeze
+
+ def self.description_variant(copy_state)
+ DESCRIPTION_VARIANTS[copy_state.to_sym] ||
+ (raise ArgumentError, "Unknown copy state: #{copy_state}")
+ end
+
+ ::DesignManagement::DesignCollection.state_machines[:copy_state].states.keys.each do |copy_state|
+ value copy_state.upcase,
+ value: copy_state.to_s,
+ description: "The DesignCollection #{description_variant(copy_state)}"
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/design_management/design_collection_type.rb b/app/graphql/types/design_management/design_collection_type.rb
index 904fb270e11..9af1f4db425 100644
--- a/app/graphql/types/design_management/design_collection_type.rb
+++ b/app/graphql/types/design_management/design_collection_type.rb
@@ -39,6 +39,10 @@ module Types
null: true,
resolver: ::Resolvers::DesignManagement::DesignResolver,
description: 'Find a specific design'
+
+ field :copy_state, ::Types::DesignManagement::DesignCollectionCopyStateEnum,
+ null: true,
+ description: 'Copy state of the design collection'
end
end
end
diff --git a/app/graphql/types/global_id_type.rb b/app/graphql/types/global_id_type.rb
index a3964ba83e1..9ae9ba32c13 100644
--- a/app/graphql/types/global_id_type.rb
+++ b/app/graphql/types/global_id_type.rb
@@ -1,5 +1,21 @@
# frozen_string_literal: true
+module GraphQLExtensions
+ module ScalarExtensions
+ # Allow ID to unify with GlobalID Types
+ def ==(other)
+ if name == 'ID' && other.is_a?(self.class) &&
+ other.type_class.ancestors.include?(::Types::GlobalIDType)
+ return true
+ end
+
+ super
+ end
+ end
+end
+
+::GraphQL::ScalarType.prepend(GraphQLExtensions::ScalarExtensions)
+
module Types
class GlobalIDType < BaseScalar
graphql_name 'GlobalID'
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 60b2e3c7b6e..9b5fb778f6c 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -64,7 +64,7 @@ module Types
Types::BoardType,
null: true,
description: 'A single board of the group',
- resolver: Resolvers::BoardsResolver.single
+ resolver: Resolvers::BoardResolver
field :label,
Types::LabelType,
diff --git a/app/graphql/types/issue_sort_enum.rb b/app/graphql/types/issue_sort_enum.rb
index e458d6e02c5..08762264b1b 100644
--- a/app/graphql/types/issue_sort_enum.rb
+++ b/app/graphql/types/issue_sort_enum.rb
@@ -8,6 +8,8 @@ module Types
value 'DUE_DATE_ASC', 'Due date by ascending order', value: :due_date_asc
value 'DUE_DATE_DESC', 'Due date by descending order', value: :due_date_desc
value 'RELATIVE_POSITION_ASC', 'Relative position by ascending order', value: :relative_position_asc
+ value 'SEVERITY_ASC', 'Severity from less critical to more critical', value: :severity_asc
+ value 'SEVERITY_DESC', 'Severity from more critical to less critical', value: :severity_desc
end
end
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index d6253f74ce5..487508f448f 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -36,8 +36,7 @@ module Types
end
field :author, Types::UserType, null: false,
- description: 'User that created the issue',
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find }
+ description: 'User that created the issue'
field :assignees, Types::UserType.connection_type, null: true,
description: 'Assignees of the issue'
@@ -45,16 +44,14 @@ module Types
field :labels, Types::LabelType.connection_type, null: true,
description: 'Labels of the issue'
field :milestone, Types::MilestoneType, null: true,
- description: 'Milestone of the issue',
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find }
+ description: 'Milestone of the issue'
field :due_date, Types::TimeType, null: true,
description: 'Due date of the issue'
field :confidential, GraphQL::BOOLEAN_TYPE, null: false,
description: 'Indicates the issue is confidential'
field :discussion_locked, GraphQL::BOOLEAN_TYPE, null: false,
- description: 'Indicates discussion is locked on the issue',
- resolve: -> (obj, _args, _ctx) { !!obj.discussion_locked }
+ description: 'Indicates discussion is locked on the issue'
field :upvotes, GraphQL::INT_TYPE, null: false,
description: 'Number of upvotes the issue has received'
@@ -108,6 +105,18 @@ module Types
field :severity, Types::IssuableSeverityEnum, null: true,
description: 'Severity level of the incident'
+
+ def author
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find
+ end
+
+ def milestone
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, object.milestone_id).find
+ end
+
+ def discussion_locked
+ !!object.discussion_locked
+ end
end
end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 56c88491684..573818b1b7a 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -174,10 +174,6 @@ module Types
def commit_count
object&.metrics&.commits_count
end
-
- def approvers
- object.approver_users
- end
end
end
Types::MergeRequestType.prepend_if_ee('::EE::Types::MergeRequestType')
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index b2732d83aac..3a9d5529266 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -18,6 +18,7 @@ module Types
mount_mutation Mutations::Boards::Issues::IssueMoveList
mount_mutation Mutations::Boards::Lists::Create
mount_mutation Mutations::Boards::Lists::Update
+ mount_mutation Mutations::Boards::Lists::Destroy
mount_mutation Mutations::Branches::Create, calls_gitaly: true
mount_mutation Mutations::Commits::Create, calls_gitaly: true
mount_mutation Mutations::Discussions::ToggleResolve
@@ -71,4 +72,5 @@ module Types
end
::Types::MutationType.prepend(::Types::DeprecatedMutations)
+::Types::MutationType.prepend_if_ee('EE::Types::DeprecatedMutations')
::Types::MutationType.prepend_if_ee('::EE::Types::MutationType')
diff --git a/app/graphql/types/notes/noteable_type.rb b/app/graphql/types/notes/noteable_type.rb
index 3a16d54f9cd..602634d9292 100644
--- a/app/graphql/types/notes/noteable_type.rb
+++ b/app/graphql/types/notes/noteable_type.rb
@@ -8,24 +8,24 @@ module Types
field :notes, Types::Notes::NoteType.connection_type, null: false, description: "All notes on this noteable"
field :discussions, Types::Notes::DiscussionType.connection_type, null: false, description: "All discussions on this noteable"
- definition_methods do
- def resolve_type(object, context)
- case object
- when Issue
- Types::IssueType
- when MergeRequest
- Types::MergeRequestType
- when Snippet
- Types::SnippetType
- when ::DesignManagement::Design
- Types::DesignManagement::DesignType
- when ::AlertManagement::Alert
- Types::AlertManagement::AlertType
- else
- raise "Unknown GraphQL type for #{object}"
- end
+ def self.resolve_type(object, context)
+ case object
+ when Issue
+ Types::IssueType
+ when MergeRequest
+ Types::MergeRequestType
+ when Snippet
+ Types::SnippetType
+ when ::DesignManagement::Design
+ Types::DesignManagement::DesignType
+ when ::AlertManagement::Alert
+ Types::AlertManagement::AlertType
+ else
+ raise "Unknown GraphQL type for #{object}"
end
end
end
end
end
+
+Types::Notes::NoteableType.prepend_if_ee('::EE::Types::Notes::NoteableType')
diff --git a/app/graphql/types/project_member_type.rb b/app/graphql/types/project_member_type.rb
index f08781238d0..01731531ae2 100644
--- a/app/graphql/types/project_member_type.rb
+++ b/app/graphql/types/project_member_type.rb
@@ -12,7 +12,10 @@ module Types
authorize :read_project
field :project, Types::ProjectType, null: true,
- description: 'Project that User is a member of',
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, obj.source_id).find }
+ description: 'Project that User is a member of'
+
+ def project
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.source_id).find
+ end
end
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 0fd54af1538..c7fc193abe8 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -234,7 +234,7 @@ module Types
Types::BoardType,
null: true,
description: 'A single board of the project',
- resolver: Resolvers::BoardsResolver.single
+ resolver: Resolvers::BoardResolver
field :jira_imports,
Types::JiraImportType.connection_type,
@@ -294,6 +294,12 @@ module Types
description: 'Title of the label'
end
+ field :terraform_states,
+ Types::Terraform::StateType.connection_type,
+ null: true,
+ description: 'Terraform states associated with the project',
+ resolver: Resolvers::Terraform::StatesResolver
+
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args|
LabelsFinder
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 447ac63a294..73dd7c57223 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -49,8 +49,7 @@ module Types
field :milestone, ::Types::MilestoneType,
null: true,
- description: 'Find a milestone',
- resolve: -> (_obj, args, _ctx) { GitlabSchema.find_by_gid(args[:id]) } do
+ description: 'Find a milestone' do
argument :id, ::Types::GlobalIDType[Milestone],
required: true,
description: 'Find a milestone by its ID'
@@ -86,7 +85,17 @@ module Types
end
def issue(id:)
- GitlabSchema.object_from_id(id, expected_type: ::Issue)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::Issue].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
+ end
+
+ def milestone(id:)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[Milestone].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
end
end
end
diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb
index db98e62c10a..495c25c1776 100644
--- a/app/graphql/types/snippet_type.rb
+++ b/app/graphql/types/snippet_type.rb
@@ -24,16 +24,14 @@ module Types
field :project, Types::ProjectType,
description: 'The project the snippet is associated with',
null: true,
- authorize: :read_project,
- resolve: -> (snippet, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, snippet.project_id).find }
+ authorize: :read_project
# Author can be nil in some scenarios. For example,
# when the admin setting restricted visibility
# level is set to public
field :author, Types::UserType,
description: 'The owner of the snippet',
- null: true,
- resolve: -> (snippet, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, snippet.author_id).find }
+ null: true
field :file_name, GraphQL::STRING_TYPE,
description: 'File Name of the snippet',
@@ -69,10 +67,11 @@ module Types
null: false,
deprecated: { reason: 'Use `blobs`', milestone: '13.3' }
- field :blobs, type: [Types::Snippets::BlobType],
+ field :blobs, type: Types::Snippets::BlobType.connection_type,
description: 'Snippet blobs',
calls_gitaly: true,
- null: false
+ null: true,
+ resolver: Resolvers::Snippets::BlobsResolver
field :ssh_url_to_repo, type: GraphQL::STRING_TYPE,
description: 'SSH URL to the snippet repository',
@@ -85,5 +84,13 @@ module Types
null: true
markdown_field :description_html, null: true, method: :description
+
+ def author
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find
+ end
+
+ def project
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find
+ end
end
end
diff --git a/app/graphql/types/sort_enum.rb b/app/graphql/types/sort_enum.rb
index 3245cb33e0d..d0a6eecb672 100644
--- a/app/graphql/types/sort_enum.rb
+++ b/app/graphql/types/sort_enum.rb
@@ -5,9 +5,16 @@ module Types
graphql_name 'Sort'
description 'Common sort values'
- value 'updated_desc', 'Updated at descending order'
- value 'updated_asc', 'Updated at ascending order'
- value 'created_desc', 'Created at descending order'
- value 'created_asc', 'Created at ascending order'
+ # Deprecated, as we prefer uppercase enums
+ # https://gitlab.com/groups/gitlab-org/-/epics/1838
+ value 'updated_desc', 'Updated at descending order', deprecated: { reason: 'Use UPDATED_DESC', milestone: '13.5' }
+ value 'updated_asc', 'Updated at ascending order', deprecated: { reason: 'Use UPDATED_ASC', milestone: '13.5' }
+ value 'created_desc', 'Created at descending order', deprecated: { reason: 'Use CREATED_DESC', milestone: '13.5' }
+ value 'created_asc', 'Created at ascending order', deprecated: { reason: 'Use CREATED_ASC', milestone: '13.5' }
+
+ value 'UPDATED_DESC', 'Updated at descending order', value: :updated_desc
+ value 'UPDATED_ASC', 'Updated at ascending order', value: :updated_asc
+ value 'CREATED_DESC', 'Created at descending order', value: :created_desc
+ value 'CREATED_ASC', 'Created at ascending order', value: :created_asc
end
end
diff --git a/app/graphql/types/terraform/state_type.rb b/app/graphql/types/terraform/state_type.rb
new file mode 100644
index 00000000000..f25f3a7789b
--- /dev/null
+++ b/app/graphql/types/terraform/state_type.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Types
+ module Terraform
+ class StateType < BaseObject
+ graphql_name 'TerraformState'
+
+ authorize :read_terraform_state
+
+ field :id, GraphQL::ID_TYPE,
+ null: false,
+ description: 'ID of the Terraform state'
+
+ field :name, GraphQL::STRING_TYPE,
+ null: false,
+ description: 'Name of the Terraform state'
+
+ field :locked_by_user, Types::UserType,
+ null: true,
+ authorize: :read_user,
+ description: 'The user currently holding a lock on the Terraform state',
+ resolve: -> (state, _, _) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, state.locked_by_user_id).find }
+
+ field :locked_at, Types::TimeType,
+ null: true,
+ description: 'Timestamp the Terraform state was locked'
+
+ field :created_at, Types::TimeType,
+ null: false,
+ description: 'Timestamp the Terraform state was created'
+
+ field :updated_at, Types::TimeType,
+ null: false,
+ description: 'Timestamp the Terraform state was updated'
+ end
+ end
+end
diff --git a/app/helpers/analytics/navbar_helper.rb b/app/helpers/analytics/navbar_helper.rb
index ddf2655c887..bc0b5e7c74f 100644
--- a/app/helpers/analytics/navbar_helper.rb
+++ b/app/helpers/analytics/navbar_helper.rb
@@ -28,7 +28,7 @@ module Analytics
private
def navbar_sub_item(args)
- NavbarSubItem.new(args)
+ NavbarSubItem.new(**args)
end
def cycle_analytics_navbar_link(project, current_user)
diff --git a/app/helpers/analytics/unique_visits_helper.rb b/app/helpers/analytics/unique_visits_helper.rb
index ded7f54e44e..4c709b2ed23 100644
--- a/app/helpers/analytics/unique_visits_helper.rb
+++ b/app/helpers/analytics/unique_visits_helper.rb
@@ -14,8 +14,7 @@ module Analytics
end
def track_visit(target_id)
- return unless Feature.enabled?(:track_unique_visits)
- return unless Gitlab::CurrentSettings.usage_ping_enabled?
+ return unless Feature.enabled?(:track_unique_visits, default_enabled: true)
return unless visitor_id
Gitlab::Analytics::UniqueVisits.new.track_visit(visitor_id, target_id)
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index a81225c8954..665184f268c 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -8,8 +8,16 @@ module ApplicationHelper
# See https://docs.gitlab.com/ee/development/ee_features.html#code-in-app-views
# rubocop: disable CodeReuse/ActiveRecord
- def render_if_exists(partial, locals = {})
- render(partial, locals) if partial_exists?(partial)
+ # We allow partial to be nil so that collection views can be passed in
+ # `render partial: 'some/view', collection: @some_collection`
+ def render_if_exists(partial = nil, **options)
+ return unless partial_exists?(partial || options[:partial])
+
+ if partial.nil?
+ render(**options)
+ else
+ render(partial, options)
+ end
end
def partial_exists?(partial)
@@ -349,6 +357,12 @@ module ApplicationHelper
}
end
+ def add_page_specific_style(path)
+ content_for :page_specific_styles do
+ stylesheet_link_tag_defer path
+ end
+ end
+
def page_startup_api_calls
@api_startup_calls
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 9245cc1cb1c..3da4113497f 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -168,7 +168,7 @@ module ApplicationSettingsHelper
def visible_attributes
[
- :admin_notification_email,
+ :abuse_notification_email,
:after_sign_out_path,
:after_sign_up_text,
:akismet_api_key,
@@ -265,6 +265,7 @@ module ApplicationSettingsHelper
:receive_max_input_size,
:repository_checks_enabled,
:repository_storages_weighted,
+ :require_admin_approval_after_user_signup,
:require_two_factor_authentication,
:restricted_visibility_levels,
:rsa_key_restriction,
@@ -345,6 +346,12 @@ module ApplicationSettingsHelper
]
end
+ def deprecated_attributes
+ [
+ :admin_notification_email # ok to remove in REST API v5
+ ]
+ end
+
def expanded_by_default?
Rails.env.test?
end
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index 68dbc5b65d1..5457f96d506 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -60,7 +60,7 @@ module AvatarsHelper
avatar_size = options[:size] || 16
user_name = options[:user].try(:name) || options[:user_name]
- avatar_url = user_avatar_url_for(options.merge(size: 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] || {}
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index 6a4a7a8dfb2..4750580e20d 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -14,6 +14,7 @@ module BoardsHelper
root_path: root_path,
full_path: full_path,
bulk_update_path: @bulk_issues_path,
+ can_update: (!!can?(current_user, :admin_issue, board)).to_s,
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s,
recent_boards_endpoint: recent_boards_path,
parent: current_board_parent.model_name.param_key,
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index caad215e996..cc633df77f9 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -12,6 +12,18 @@ module ClustersHelper
end
end
+ def display_cluster_agents?(_clusterable)
+ false
+ end
+
+ def js_cluster_agents_list_data(clusterable_project)
+ {
+ default_branch_name: clusterable_project.default_branch,
+ empty_state_image: image_path('illustrations/clusters_empty.svg'),
+ project_path: clusterable_project.full_path
+ }
+ end
+
def js_clusters_list_data(path = nil)
{
ancestor_help_path: help_page_path('user/group/clusters/index', anchor: 'cluster-precedence'),
@@ -42,14 +54,6 @@ module ClustersHelper
}
end
- # This method is depreciated and will be removed when associated HAML files are moved to JavaScript
- def provider_icon(provider = nil)
- img_data = js_clusters_list_data.dig(:img_tags, provider&.to_sym) ||
- js_clusters_list_data.dig(:img_tags, :default)
-
- image_tag img_data[:path], alt: img_data[:text], class: 'gl-h-full'
- end
-
def render_gcp_signup_offer
return if Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers?
return unless show_gcp_signup_offer?
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index d5c22927991..0a0dc77e5e2 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -218,8 +218,28 @@ module EmailsHelper
_('Please contact your administrator with any questions.')
end
+ def change_reviewer_notification_text(new_reviewers, previous_reviewers, html_tag = nil)
+ new = new_reviewers.any? ? users_to_sentence(new_reviewers) : s_('ChangeReviewer|Unassigned')
+ old = previous_reviewers.any? ? users_to_sentence(previous_reviewers) : nil
+
+ if html_tag.present?
+ new = content_tag(html_tag, new)
+ old = content_tag(html_tag, old) if old.present?
+ end
+
+ if old.present?
+ s_('ChangeReviewer|Reviewer changed from %{old} to %{new}').html_safe % { old: old, new: new }
+ else
+ s_('ChangeReviewer|Reviewer changed to %{new}').html_safe % { new: new }
+ end
+ end
+
private
+ def users_to_sentence(users)
+ sanitize_name(users.map(&:name).to_sentence)
+ end
+
def generate_link(text, url)
link_to(text, url, target: :_blank, rel: 'noopener noreferrer')
end
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index 0167f2ef698..f40755b9439 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -28,19 +28,7 @@ module EventsHelper
end
def event_action_name(event)
- target = if event.target_type
- if event.design? || event.design_note?
- 'design'
- elsif event.wiki_page?
- 'wiki page'
- elsif event.note?
- event.note_target_type
- else
- event.target_type.titleize.downcase
- end
- else
- 'project'
- end
+ target = event.note_target_type_name || event.target_type_name
[event.action_name, target].join(" ")
end
@@ -229,7 +217,7 @@ module EventsHelper
def event_note_title_html(event)
if event.note_target
capture do
- concat content_tag(:span, event.note_target_type, class: "event-target-type gl-mr-2")
+ concat content_tag(:span, event.note_target_type_name, class: "event-target-type gl-mr-2")
concat link_to(event.note_target_reference, event_note_target_url(event), title: event.target_title, class: 'has-tooltip event-target-link gl-mr-2')
end
else
diff --git a/app/helpers/feature_flags_helper.rb b/app/helpers/feature_flags_helper.rb
new file mode 100644
index 00000000000..e50191a471f
--- /dev/null
+++ b/app/helpers/feature_flags_helper.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module FeatureFlagsHelper
+ include ::API::Helpers::RelatedResourcesHelpers
+
+ def unleash_api_url(project)
+ expose_url(api_v4_feature_flags_unleash_path(project_id: project.id))
+ end
+
+ def unleash_api_instance_id(project)
+ project.feature_flags_client_token
+ end
+
+ def feature_flag_issues_links_endpoint(_project, _feature_flag, _user)
+ ''
+ end
+end
+
+FeatureFlagsHelper.prepend_if_ee('::EE::FeatureFlagsHelper')
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index d71e6b4c004..7df6bef7914 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -343,6 +343,18 @@ module GitlabRoutingHelper
Gitlab::UrlBuilder.wiki_page_url(wiki, page, only_path: true, **options)
end
+ def gitlab_ide_merge_request_path(merge_request)
+ target_project = merge_request.target_project
+ source_project = merge_request.source_project
+ params = {}
+
+ if target_project != source_project
+ params = { target_project: target_project.full_path }
+ end
+
+ ide_merge_request_path(source_project.namespace, source_project, merge_request, params)
+ end
+
private
def snippet_query_params(snippet, *args)
diff --git a/app/helpers/gitpod_helper.rb b/app/helpers/gitpod_helper.rb
new file mode 100644
index 00000000000..7edf7dc218d
--- /dev/null
+++ b/app/helpers/gitpod_helper.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module GitpodHelper
+ def gitpod_enable_description
+ link_start = '<a href="https://gitpod.io/" target="_blank" rel="noopener noreferrer">'.html_safe
+ link_end = "#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}</a>".html_safe
+
+ s_('Enable %{link_start}Gitpod%{link_end} integration to launch a development environment in your browser directly from GitLab.').html_safe % { link_start: link_start, link_end: link_end }
+ end
+end
diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb
index dcff2be34da..0782961f541 100644
--- a/app/helpers/groups/group_members_helper.rb
+++ b/app/helpers/groups/group_members_helper.rb
@@ -10,11 +10,11 @@ module Groups::GroupMembersHelper
end
def render_invite_member_for_group(group, default_access_level)
- render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: GroupMember.access_level_roles, default_access_level: default_access_level
+ render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: group.access_level_roles, default_access_level: default_access_level
end
def linked_groups_data_json(group_links)
- GroupGroupLinkSerializer.new.represent(group_links).to_json
+ GroupGroupLinkSerializer.new.represent(group_links, { current_user: current_user }).to_json
end
def members_data_json(group, members)
@@ -47,10 +47,10 @@ module Groups::GroupMembersHelper
}
}.merge(member_created_by_data(member.created_by))
- if user.present?
- data[:user] = member_user_data(user)
- else
+ if member.invite?
data[:invite] = member_invite_data(member)
+ elsif user.present?
+ data[:user] = member_user_data(user)
end
data
@@ -77,6 +77,17 @@ module Groups::GroupMembersHelper
avatar_url: avatar_icon_for_user(user, AVATAR_SIZE),
blocked: user.blocked?,
two_factor_enabled: user.two_factor_enabled?
+ }.merge(member_user_status_data(user.status))
+ end
+
+ def member_user_status_data(status)
+ return {} unless status.present?
+
+ {
+ status: {
+ emoji: status.emoji,
+ message_html: status.message_html
+ }
}
end
diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb
new file mode 100644
index 00000000000..cbd08cb82ed
--- /dev/null
+++ b/app/helpers/invite_members_helper.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module InviteMembersHelper
+ def invite_members_allowed?(group)
+ Feature.enabled?(:invite_members_group_modal, group) && can?(current_user, :admin_group_member, group)
+ end
+end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index b255597b18d..5b5902b1fa2 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -4,7 +4,10 @@ module IssuablesHelper
include GitlabRoutingHelper
def sidebar_gutter_toggle_icon
- sidebar_gutter_collapsed? ? icon('angle-double-left', { 'aria-hidden': 'true' }) : icon('angle-double-right', { 'aria-hidden': 'true' })
+ content_tag(:span, class: 'js-sidebar-toggle-container', data: { is_expanded: !sidebar_gutter_collapsed? }) do
+ sprite_icon('chevron-double-lg-left', css_class: "js-sidebar-expand #{'hidden' unless sidebar_gutter_collapsed?}") +
+ sprite_icon('chevron-double-lg-right', css_class: "js-sidebar-collapse #{'hidden' if sidebar_gutter_collapsed?}")
+ end
end
def sidebar_gutter_collapsed_class
@@ -206,7 +209,7 @@ module IssuablesHelper
end
if access = project.team.human_max_access(issuable.author_id)
- output << content_tag(:span, access, class: "user-access-role has-tooltip d-none d-xl-inline-block gl-ml-3 ", title: _("This user is a %{access} of the %{name} project.") % { access: access.downcase, name: project.name })
+ output << content_tag(:span, access, class: "user-access-role has-tooltip d-none d-xl-inline-block gl-ml-3 ", title: _("This user has the %{access} role in the %{name} project.") % { access: access.downcase, name: project.name })
elsif project.team.contributor?(issuable.author_id)
output << content_tag(:span, _("Contributor"), class: "user-access-role has-tooltip d-none d-xl-inline-block gl-ml-3", title: _("This user has previously committed to the %{name} project.") % { name: project.name })
end
@@ -342,6 +345,12 @@ module IssuablesHelper
issuable.closed? ^ should_inverse ? reopen_issuable_path(issuable) : close_issuable_path(issuable)
end
+ def toggle_draft_issuable_path(issuable)
+ wip_event = issuable.work_in_progress? ? 'unwip' : 'wip'
+
+ issuable_path(issuable, { merge_request: { wip_event: wip_event } })
+ end
+
def issuable_path(issuable, *options)
polymorphic_path(issuable, *options)
end
@@ -386,6 +395,12 @@ module IssuablesHelper
end
end
+ def reviewer_sidebar_data(reviewer, merge_request: nil)
+ { avatar_url: reviewer.avatar_url, name: reviewer.name, username: reviewer.username }.tap do |data|
+ data[:can_merge] = merge_request.can_be_merged_by?(reviewer) if merge_request
+ end
+ end
+
def issuable_squash_option?(issuable, project)
if issuable.persisted?
issuable.squash
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index e8ea39d7ffc..dbf284e70e4 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -137,6 +137,21 @@ module IssuesHelper
issue.moved_from.project.service_desk_enabled? && !issue.project.service_desk_enabled?
end
+
+ def use_startup_call?
+ request.query_parameters.empty? && @sort == 'created_date'
+ end
+
+ def startup_call_params
+ {
+ state: 'opened',
+ with_labels_details: 'true',
+ page: 1,
+ per_page: 20,
+ order_by: 'created_at',
+ sort: 'desc'
+ }
+ end
end
IssuesHelper.prepend_if_ee('EE::IssuesHelper')
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index 3142d7d7782..bfe1728adad 100644
--- a/app/helpers/labels_helper.rb
+++ b/app/helpers/labels_helper.rb
@@ -36,11 +36,11 @@ module LabelsHelper
# link_to_label(label) { "My Custom Label Text" }
#
# Returns a String
- def link_to_label(label, type: :issue, tooltip: true, small: false, &block)
+ def link_to_label(label, type: :issue, tooltip: true, small: false, css_class: nil, &block)
link = label.filter_path(type: type)
if block_given?
- link_to link, &block
+ link_to link, class: css_class, &block
else
render_label(label, link: link, tooltip: tooltip, small: small)
end
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 1125ecb9b41..9cb7edbaeb6 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -109,10 +109,6 @@ module MergeRequestsHelper
@merge_request_diffs.size - @merge_request_diffs.index(merge_request_diff)
end
- def different_base?(version1, version2)
- version1 && version2 && version1.base_commit_sha != version2.base_commit_sha
- end
-
def merge_params(merge_request)
{
auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS,
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index 81451e398f2..8cf5cd49322 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -53,7 +53,7 @@ module NamespacesHelper
selected = options.delete(:selected) || :current_user
options[:groups] = current_user.manageable_groups_with_routes(include_groups_with_developer_maintainer_access: true)
- namespaces_options(selected, options)
+ namespaces_options(selected, **options)
end
private
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 578c7ae7923..3c757a4ef26 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -55,7 +55,8 @@ module NavHelper
current_path?('projects/merge_requests/conflicts#show') ||
current_path?('issues#show') ||
current_path?('milestones#show') ||
- current_path?('issues#designs')
+ current_path?('issues#designs') ||
+ current_path?('incidents#show')
end
def admin_monitoring_nav_links
diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb
index e6ecc403a88..0a296b4e6ba 100644
--- a/app/helpers/packages_helper.rb
+++ b/app/helpers/packages_helper.rb
@@ -51,9 +51,15 @@ module PackagesHelper
{
resource_id: resource.id,
page_type: type,
- empty_list_help_url: help_page_path('administration/packages/index'),
+ empty_list_help_url: help_page_path('user/packages/package_registry/index'),
empty_list_illustration: image_path('illustrations/no-packages.svg'),
- coming_soon_json: packages_coming_soon_data(resource).to_json
+ coming_soon_json: packages_coming_soon_data(resource).to_json,
+ package_help_url: help_page_path('user/packages/index')
}
end
+
+ def track_package_event(event_name, scope, **args)
+ ::Packages::CreateEventService.new(nil, current_user, event_name: event_name, scope: scope).execute
+ track_event(event_name, **args)
+ end
end
diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb
index a44760e85ca..6808ffc3e27 100644
--- a/app/helpers/page_layout_helper.rb
+++ b/app/helpers/page_layout_helper.rb
@@ -40,6 +40,14 @@ module PageLayoutHelper
end
end
+ def page_canonical_link(link = nil)
+ if link
+ @page_canonical_link = link
+ else
+ @page_canonical_link
+ end
+ end
+
def favicon
Gitlab::Favicon.main
end
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 2c406641882..9bf819febb0 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -61,8 +61,8 @@ module PreferencesHelper
@user_application_theme ||= Gitlab::Themes.for_user(current_user).css_class
end
- def user_application_theme_name
- @user_application_theme_name ||= Gitlab::Themes.for_user(current_user).name.downcase.tr(' ', '_')
+ def user_application_theme_css_filename
+ @user_application_theme_css_filename ||= Gitlab::Themes.for_user(current_user).css_filename
end
def user_color_scheme
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index 5a42e581867..44d869fbd8f 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -29,4 +29,19 @@ module ProfilesHelper
def user_profile?
params[:controller] == 'users'
end
+
+ def ssh_key_delete_modal_data(key, is_admin)
+ {
+ path: path_to_key(key, is_admin),
+ method: 'delete',
+ qa_selector: 'delete_ssh_key_button',
+ modal_attributes: {
+ 'data-qa-selector': 'ssh_key_delete_modal',
+ title: _('Are you sure you want to delete this SSH key?'),
+ message: _('This action cannot be undone, and will permanently delete the %{key} SSH key') % { key: key.title },
+ okVariant: 'danger',
+ okTitle: _('Delete')
+ }
+ }
+ end
end
diff --git a/app/helpers/projects/incidents_helper.rb b/app/helpers/projects/incidents_helper.rb
index e96f0f5a384..0cac142f2dc 100644
--- a/app/helpers/projects/incidents_helper.rb
+++ b/app/helpers/projects/incidents_helper.rb
@@ -1,14 +1,17 @@
# frozen_string_literal: true
module Projects::IncidentsHelper
- def incidents_data(project)
+ def incidents_data(project, params)
{
'project-path' => project.full_path,
'new-issue-path' => new_project_issue_path(project),
'incident-template-name' => 'incident',
'incident-type' => 'incident',
'issue-path' => project_issues_path(project),
- 'empty-list-svg-path' => image_path('illustrations/incident-empty-state.svg')
+ 'empty-list-svg-path' => image_path('illustrations/incident-empty-state.svg'),
+ 'text-query': params[:search],
+ 'author-usernames-query': params[:author_username],
+ 'assignee-usernames-query': params[:assignee_username]
}
end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 72cc07b13a5..6e317a63e47 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -468,7 +468,7 @@ module ProjectsHelper
serverless: :read_cluster,
error_tracking: :read_sentry_issue,
alert_management: :read_alert_management_alert,
- incidents: :read_incidents,
+ incidents: :read_issue,
labels: :read_label,
issues: :read_issue,
project_members: :read_project_member,
@@ -477,7 +477,14 @@ module ProjectsHelper
end
def can_view_operations_tab?(current_user, project)
- [:read_environment, :read_cluster, :metrics_dashboard].any? do |ability|
+ [
+ :metrics_dashboard,
+ :read_alert_management_alert,
+ :read_environment,
+ :read_issue,
+ :read_sentry_issue,
+ :read_cluster
+ ].any? do |ability|
can?(current_user, ability, project)
end
end
@@ -758,10 +765,6 @@ module ProjectsHelper
!project.repository.gitlab_ci_yml
end
- def native_code_navigation_enabled?(project)
- Feature.enabled?(:code_navigation, project, default_enabled: true)
- end
-
def show_visibility_confirm_modal?(project)
project.unlink_forks_upon_visibility_decrease_enabled? && project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && project.forks_count > 0
end
@@ -774,7 +777,7 @@ module ProjectsHelper
def project_access_token_available?(project)
return false if ::Gitlab.com?
- ::Feature.enabled?(:resource_access_token, project, default_enabled: true)
+ can?(current_user, :admin_resource_access_tokens, project)
end
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index d55ad878b92..36b58be60fc 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module SearchHelper
- SEARCH_PERMITTED_PARAMS = [:search, :scope, :project_id, :group_id, :repository_ref, :snippets, :state].freeze
+ SEARCH_PERMITTED_PARAMS = [:search, :scope, :project_id, :group_id, :repository_ref, :snippets, :sort, :state, :confidential].freeze
def search_autocomplete_opts(term)
return unless current_user
@@ -86,6 +86,11 @@ module SearchHelper
}).html_safe
end
+ def repository_ref(project)
+ # Always #to_s the repository_ref param in case the value is also a number
+ params[:repository_ref].to_s.presence || project.default_branch
+ end
+
# Overridden in EE
def search_blob_title(project, path)
path
@@ -294,9 +299,12 @@ module SearchHelper
sanitize(html, tags: %w(a p ol ul li pre code))
end
- def show_user_search_tab?
- return false if Feature.disabled?(:users_search, default_enabled: true)
+ # _search_highlight is used in EE override
+ def highlight_and_truncate_issue(issue, search_term, _search_highlight)
+ simple_search_highlight_and_truncate(issue.description, search_term, highlighter: '<span class="gl-text-black-normal gl-font-weight-bold">\1</span>')
+ end
+ def show_user_search_tab?
if @project
project_search_tabs?(:members)
else
diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb
index 6b5de73a831..ae59f84e7da 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/services_helper.rb
@@ -124,6 +124,10 @@ module ServicesHelper
@group.present? && Feature.enabled?(:group_level_integrations, @group)
end
+ def instance_level_integrations?
+ !Gitlab.com?
+ end
+
extend self
private
diff --git a/app/helpers/suggest_pipeline_helper.rb b/app/helpers/suggest_pipeline_helper.rb
index aa67f0ea770..d64e8d6f2cd 100644
--- a/app/helpers/suggest_pipeline_helper.rb
+++ b/app/helpers/suggest_pipeline_helper.rb
@@ -2,7 +2,7 @@
module SuggestPipelineHelper
def should_suggest_gitlab_ci_yml?
- Feature.enabled?(:suggest_pipeline) &&
+ experiment_enabled?(:suggest_pipeline) &&
current_user &&
params[:suggest_gitlab_ci_yml] == 'true'
end
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 0227ad1092d..79f4810e13a 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -2,6 +2,8 @@
module SystemNoteHelper
ICON_NAMES_BY_ACTION = {
+ 'approved' => 'approval',
+ 'unapproved' => 'unapproval',
'cherry_pick' => 'cherry-pick-commit',
'commit' => 'commit',
'description' => 'pencil-square',
@@ -11,6 +13,7 @@ module SystemNoteHelper
'closed' => 'issue-close',
'time_tracking' => 'timer',
'assignee' => 'user',
+ 'reviewer' => 'user',
'title' => 'pencil-square',
'task' => 'task-done',
'label' => 'label',
@@ -34,7 +37,8 @@ module SystemNoteHelper
'designs_discussion_added' => 'doc-image',
'status' => 'status',
'alert_issue_added' => 'issues',
- 'new_alert_added' => 'warning'
+ 'new_alert_added' => 'warning',
+ 'severity' => 'information-o'
}.freeze
def system_note_icon_name(note)
diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb
index 4984b51555d..bfc8803f514 100644
--- a/app/helpers/tags_helper.rb
+++ b/app/helpers/tags_helper.rb
@@ -38,4 +38,13 @@ module TagsHelper
text.html_safe
end
+
+ def delete_tag_modal_attributes(tag_name)
+ {
+ title: s_('TagsPage|Delete tag'),
+ message: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag_name },
+ okVariant: 'danger',
+ okTitle: s_('TagsPage|Delete tag')
+ }.to_json
+ end
end
diff --git a/app/helpers/timeboxes_helper.rb b/app/helpers/timeboxes_helper.rb
index 34919f994ee..bbf8cf7dac3 100644
--- a/app/helpers/timeboxes_helper.rb
+++ b/app/helpers/timeboxes_helper.rb
@@ -228,8 +228,8 @@ module TimeboxesHelper
end
alias_method :milestone_date_range, :timebox_date_range
- def milestone_tab_path(milestone, tab)
- url_for(action: tab, format: :json)
+ def milestone_tab_path(milestone, tab, params = {})
+ url_for(params.merge(action: tab, format: :json))
end
def update_milestone_path(milestone, params = {})
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 9865f7dfbef..7b0e0df8998 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -16,6 +16,7 @@ module TodosHelper
def todo_action_name(todo)
case todo.action
when Todo::ASSIGNED then todo.self_added? ? 'assigned' : 'assigned you'
+ when Todo::REVIEW_REQUESTED then 'requested a review of'
when Todo::MENTIONED then "mentioned #{todo_action_subject(todo)} on"
when Todo::BUILD_FAILED then 'The build failed for'
when Todo::MARKED then 'added a todo for'
@@ -26,6 +27,13 @@ module TodosHelper
end
end
+ def todo_self_addressing(todo)
+ case todo.action
+ when Todo::ASSIGNED then 'to yourself'
+ when Todo::REVIEW_REQUESTED then 'from yourself'
+ end
+ end
+
def todo_target_link(todo)
text = raw(todo_target_type_name(todo) + ' ') +
if todo.for_commit?
@@ -141,6 +149,7 @@ module TodosHelper
[
{ id: '', text: 'Any Action' },
{ id: Todo::ASSIGNED, text: 'Assigned' },
+ { id: Todo::REVIEW_REQUESTED, text: 'Review requested' },
{ id: Todo::MENTIONED, text: 'Mentioned' },
{ id: Todo::MARKED, text: 'Added' },
{ id: Todo::BUILD_FAILED, text: 'Pipelines' },
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index 7644ed783eb..1d8d9ddc1ec 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -199,14 +199,14 @@ module TreeHelper
}
end
- def ide_base_path(project)
+ def web_ide_url_data(project)
can_push_code = current_user&.can?(:push_code, project)
fork_path = current_user&.fork_of(project)&.full_path
- if can_push_code
- project.full_path
+ if fork_path && !can_push_code
+ { path: fork_path, is_fork: true }
else
- fork_path || project.full_path
+ { path: project.full_path, is_fork: false }
end
end
@@ -216,7 +216,7 @@ module TreeHelper
show_web_ide_button = (can_collaborate || current_user&.already_forked?(project) || can_create_mr_from_fork)
{
- ide_base_path: ide_base_path(project),
+ web_ide_url_data: web_ide_url_data(project),
needs_to_fork: !can_collaborate && !current_user&.already_forked?(project),
show_web_ide_button: show_web_ide_button,
show_gitpod_button: show_web_ide_button && Gitlab::Gitpod.feature_and_settings_enabled?(project),
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
index 967271a8431..b0cfda67ad4 100644
--- a/app/helpers/user_callouts_helper.rb
+++ b/app/helpers/user_callouts_helper.rb
@@ -9,7 +9,6 @@ module UserCalloutsHelper
TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight'
WEBHOOKS_MOVED = 'webhooks_moved'
CUSTOMIZE_HOMEPAGE = 'customize_homepage'
- WEB_IDE_ALERT_DISMISSED = 'web_ide_alert_dismissed'
def show_admin_integrations_moved?
!user_dismissed?(ADMIN_INTEGRATIONS_MOVED)
@@ -51,10 +50,6 @@ module UserCalloutsHelper
customize_homepage && !user_dismissed?(CUSTOMIZE_HOMEPAGE)
end
- def show_web_ide_alert?
- !user_dismissed?(WEB_IDE_ALERT_DISMISSED)
- end
-
private
def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil)
diff --git a/app/helpers/whats_new_helper.rb b/app/helpers/whats_new_helper.rb
index f0044daa645..edb3544d2d4 100644
--- a/app/helpers/whats_new_helper.rb
+++ b/app/helpers/whats_new_helper.rb
@@ -3,6 +3,24 @@
module WhatsNewHelper
EMPTY_JSON = ''.to_json
+ def whats_new_most_recent_release_items_count
+ items = parsed_most_recent_release_items
+
+ return unless items.is_a?(Array)
+
+ items.count
+ end
+
+ def whats_new_storage_key
+ items = parsed_most_recent_release_items
+
+ return unless items.is_a?(Array)
+
+ release = items.first.try(:[], 'release')
+
+ ['display-whats-new-notification', release].compact.join('-')
+ end
+
def whats_new_most_recent_release_items
YAML.load_file(most_recent_release_file_path).to_json
@@ -14,6 +32,10 @@ module WhatsNewHelper
private
+ def parsed_most_recent_release_items
+ Gitlab::Json.parse(whats_new_most_recent_release_items)
+ end
+
def most_recent_release_file_path
Dir.glob(files_path).max
end
diff --git a/app/mailers/abuse_report_mailer.rb b/app/mailers/abuse_report_mailer.rb
index 0f2f63b43f5..20aabb6fe58 100644
--- a/app/mailers/abuse_report_mailer.rb
+++ b/app/mailers/abuse_report_mailer.rb
@@ -11,7 +11,7 @@ class AbuseReportMailer < ApplicationMailer
@abuse_report = AbuseReport.find(abuse_report_id)
mail(
- to: Gitlab::CurrentSettings.admin_notification_email,
+ to: Gitlab::CurrentSettings.abuse_notification_email,
subject: "#{@abuse_report.user.name} (#{@abuse_report.user.username}) was reported for abuse"
)
end
@@ -19,6 +19,6 @@ class AbuseReportMailer < ApplicationMailer
private
def deliverable?
- Gitlab::CurrentSettings.admin_notification_email.present?
+ Gitlab::CurrentSettings.abuse_notification_email.present?
end
end
diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb
index 3a13c5949bd..376b1a723c3 100644
--- a/app/mailers/emails/members.rb
+++ b/app/mailers/emails/members.rb
@@ -77,6 +77,15 @@ module Emails
Gitlab::Tracking.event(Gitlab::Experimentation::EXPERIMENTS[:invite_email][:tracking_category], 'sent', property: 'control_group')
end
end
+
+ if member.invite_to_unknown_user? && Gitlab::Experimentation.enabled?(:invitation_reminders)
+ Gitlab::Tracking.event(
+ Gitlab::Experimentation.experiment(:invitation_reminders).tracking_category,
+ 'sent',
+ property: Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, member.invite_email) ? 'experimental_group' : 'control_group',
+ label: Digest::MD5.hexdigest(member.to_global_id.to_s)
+ )
+ end
end
def member_invite_accepted_email(member_source_type, member_id)
diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb
index c709c2950d6..2d1d271882d 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -34,6 +34,17 @@ module Emails
end
# rubocop: enable CodeReuse/ActiveRecord
+ # rubocop: disable CodeReuse/ActiveRecord
+ def changed_reviewer_of_merge_request_email(recipient_id, merge_request_id, previous_reviewer_ids, updated_by_user_id, reason = nil)
+ setup_merge_request_mail(merge_request_id, recipient_id)
+
+ @previous_reviewers = []
+ @previous_reviewers = User.where(id: previous_reviewer_ids) if previous_reviewer_ids.any?
+
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
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)
diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb
index fdf40a77ca4..17ef8b41e79 100644
--- a/app/mailers/emails/projects.rb
+++ b/app/mailers/emails/projects.rb
@@ -56,16 +56,14 @@ module Emails
subject: @message.subject)
end
- def prometheus_alert_fired_email(project_id, user_id, alert_payload)
+ def prometheus_alert_fired_email(project_id, user_id, alert_attributes)
@project = ::Project.find(project_id)
user = ::User.find(user_id)
- @alert = ::Gitlab::Alerting::Alert
- .new(project: @project, payload: alert_payload)
- .present
- return unless @alert.valid?
+ @alert = AlertManagement::Alert.new(alert_attributes.with_indifferent_access).present
+ return unless @alert.parsed_payload.has_required_attributes?
- subject_text = "Alert: #{@alert.full_title}"
+ subject_text = "Alert: #{@alert.email_title}"
mail(to: user.notification_email_for(@project.group), subject: subject(subject_text))
end
end
diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb
index e9b89af45c6..f5e56cb50c9 100644
--- a/app/models/alert_management/alert.rb
+++ b/app/models/alert_management/alert.rb
@@ -191,7 +191,7 @@ module AlertManagement
end
def prometheus?
- monitoring_tool == Gitlab::AlertManagement::AlertParams::MONITORING_TOOLS[:prometheus]
+ monitoring_tool == Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus]
end
def register_new_event!
diff --git a/app/models/alert_management/http_integration.rb b/app/models/alert_management/http_integration.rb
new file mode 100644
index 00000000000..7f954e1d384
--- /dev/null
+++ b/app/models/alert_management/http_integration.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class HttpIntegration < ApplicationRecord
+ belongs_to :project, inverse_of: :alert_management_http_integrations
+
+ attr_encrypted :token,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-gcm'
+
+ validates :project, presence: true
+ validates :active, inclusion: { in: [true, false] }
+
+ validates :token, presence: true
+ validates :name, presence: true, length: { maximum: 255 }
+ validates :endpoint_identifier, presence: true, length: { maximum: 255 }
+ validates :endpoint_identifier, uniqueness: { scope: [:project_id, :active] }, if: :active?
+
+ before_validation :prevent_token_assignment
+ before_validation :ensure_token
+
+ private
+
+ def prevent_token_assignment
+ if token.present? && token_changed?
+ self.token = nil
+ self.encrypted_token = encrypted_token_was
+ self.encrypted_token_iv = encrypted_token_iv_was
+ end
+ end
+
+ def ensure_token
+ self.token = generate_token if token.blank?
+ end
+
+ def generate_token
+ SecureRandom.hex
+ end
+ end
+end
diff --git a/app/models/analytics/instance_statistics/measurement.rb b/app/models/analytics/instance_statistics/measurement.rb
index eaaf9e999b3..76cc1111e90 100644
--- a/app/models/analytics/instance_statistics/measurement.rb
+++ b/app/models/analytics/instance_statistics/measurement.rb
@@ -3,13 +3,19 @@
module Analytics
module InstanceStatistics
class Measurement < ApplicationRecord
+ EXPERIMENTAL_IDENTIFIERS = %i[pipelines_succeeded pipelines_failed pipelines_canceled pipelines_skipped].freeze
+
enum identifier: {
projects: 1,
users: 2,
issues: 3,
merge_requests: 4,
groups: 5,
- pipelines: 6
+ pipelines: 6,
+ pipelines_succeeded: 7,
+ pipelines_failed: 8,
+ pipelines_canceled: 9,
+ pipelines_skipped: 10
}
IDENTIFIER_QUERY_MAPPING = {
@@ -18,7 +24,11 @@ module Analytics
identifiers[:issues] => -> { Issue },
identifiers[:merge_requests] => -> { MergeRequest },
identifiers[:groups] => -> { Group },
- identifiers[:pipelines] => -> { Ci::Pipeline }
+ identifiers[:pipelines] => -> { Ci::Pipeline },
+ identifiers[:pipelines_succeeded] => -> { Ci::Pipeline.success },
+ identifiers[:pipelines_failed] => -> { Ci::Pipeline.failed },
+ identifiers[:pipelines_canceled] => -> { Ci::Pipeline.canceled },
+ identifiers[:pipelines_skipped] => -> { Ci::Pipeline.skipped }
}.freeze
validates :recorded_at, :identifier, :count, presence: true
@@ -26,6 +36,14 @@ module Analytics
scope :order_by_latest, -> { order(recorded_at: :desc) }
scope :with_identifier, -> (identifier) { where(identifier: identifier) }
+
+ def self.measurement_identifier_values
+ if Feature.enabled?(:store_ci_pipeline_counts_by_status, default_enabled: true)
+ identifiers.values
+ else
+ identifiers.values - EXPERIMENTAL_IDENTIFIERS.map { |identifier| identifiers[identifier] }
+ end
+ end
end
end
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index e9a3dcf39df..2d26d5655ca 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -95,7 +95,7 @@ class ApplicationSetting < ApplicationRecord
allow_blank: true,
addressable_url: true
- validates :admin_notification_email,
+ validates :abuse_notification_email,
devise_email: true,
allow_blank: true
diff --git a/app/models/application_setting/term.rb b/app/models/application_setting/term.rb
index 723540c9b91..bab036f5697 100644
--- a/app/models/application_setting/term.rb
+++ b/app/models/application_setting/term.rb
@@ -14,6 +14,8 @@ class ApplicationSetting
end
def accepted_by_user?(user)
+ return true if user.project_bot?
+
user.accepted_term_id == id ||
term_agreements.accepted.where(user: user).exists?
end
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index f46803be057..929e7ecbf48 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -5,7 +5,13 @@ class AuditEvent < ApplicationRecord
include IgnorableColumns
include BulkInsertSafe
- PARALLEL_PERSISTENCE_COLUMNS = [:author_name, :entity_path, :target_details, :target_type].freeze
+ PARALLEL_PERSISTENCE_COLUMNS = [
+ :author_name,
+ :entity_path,
+ :target_details,
+ :target_type,
+ :target_id
+ ].freeze
ignore_column :type, remove_with: '13.6', remove_after: '2020-11-22'
@@ -16,6 +22,7 @@ class AuditEvent < ApplicationRecord
validates :author_id, presence: true
validates :entity_id, presence: true
validates :entity_type, presence: true
+ validates :ip_address, ip_address: true
scope :by_entity_type, -> (entity_type) { where(entity_type: entity_type) }
scope :by_entity_id, -> (entity_id) { where(entity_id: entity_id) }
@@ -59,8 +66,8 @@ class AuditEvent < ApplicationRecord
end
def lazy_author
- BatchLoader.for(author_id).batch(default_value: default_author_value) do |author_ids, loader|
- User.where(id: author_ids).find_each do |user|
+ BatchLoader.for(author_id).batch(default_value: default_author_value, replace_methods: false) do |author_ids, loader|
+ User.select(:id, :name, :username).where(id: author_ids).find_each do |user|
loader.call(user.id, user)
end
end
diff --git a/app/models/authentication_event.rb b/app/models/authentication_event.rb
index 1ac3c5fbd9c..ac6e08caf50 100644
--- a/app/models/authentication_event.rb
+++ b/app/models/authentication_event.rb
@@ -1,12 +1,22 @@
# frozen_string_literal: true
class AuthenticationEvent < ApplicationRecord
+ include UsageStatistics
+
belongs_to :user, optional: true
validates :provider, :user_name, :result, presence: true
+ validates :ip_address, ip_address: true
enum result: {
failed: 0,
success: 1
}
+
+ scope :for_provider, ->(provider) { where(provider: provider) }
+ scope :ldap, -> { where('provider LIKE ?', 'ldap%')}
+
+ def self.providers
+ distinct.pluck(:provider)
+ end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 99580a52e96..6b4a71d4e28 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -327,6 +327,8 @@ module Ci
after_transition any => [:success, :failed, :canceled] do |build|
build.run_after_commit do
+ build.run_status_commit_hooks!
+
BuildFinishedWorker.perform_async(id)
end
end
@@ -963,8 +965,24 @@ module Ci
pending_state.try(:delete)
end
+ def run_on_status_commit(&block)
+ status_commit_hooks.push(block)
+ end
+
+ protected
+
+ def run_status_commit_hooks!
+ status_commit_hooks.reverse_each do |hook|
+ instance_eval(&hook)
+ end
+ end
+
private
+ def status_commit_hooks
+ @status_commit_hooks ||= []
+ end
+
def auto_retry
strong_memoize(:auto_retry) do
Gitlab::Ci::Build::AutoRetry.new(self)
diff --git a/app/models/ci/build_pending_state.rb b/app/models/ci/build_pending_state.rb
index 45f323adec2..299c67f441d 100644
--- a/app/models/ci/build_pending_state.rb
+++ b/app/models/ci/build_pending_state.rb
@@ -9,4 +9,10 @@ class Ci::BuildPendingState < ApplicationRecord
enum failure_reason: CommitStatus.failure_reasons
validates :build, presence: true
+
+ def crc32
+ trace_checksum.try do |checksum|
+ checksum.to_s.split('crc32:').last.to_i(16)
+ end
+ end
end
diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb
index 444742062d9..55bcb8adaca 100644
--- a/app/models/ci/build_trace_chunk.rb
+++ b/app/models/ci/build_trace_chunk.rb
@@ -3,6 +3,7 @@
module Ci
class BuildTraceChunk < ApplicationRecord
extend ::Gitlab::Ci::Model
+ include ::Comparable
include ::FastDestroyAll
include ::Checksummable
include ::Gitlab::ExclusiveLeaseHelpers
@@ -29,6 +30,7 @@ module Ci
}
scope :live, -> { redis }
+ scope :persisted, -> { not_redis.order(:chunk_index) }
class << self
def all_stores
@@ -63,12 +65,24 @@ module Ci
get_store_class(store).delete_keys(value)
end
end
+
+ ##
+ # Sometimes we do not want to read raw data. This method makes it easier
+ # to find attributes that are just metadata excluding raw data.
+ #
+ def metadata_attributes
+ attribute_names - %w[raw_data]
+ end
end
def data
@data ||= get_data.to_s
end
+ def crc32
+ checksum.to_i
+ end
+
def truncate(offset = 0)
raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0
return if offset == size # Skip the following process as it doesn't affect anything
@@ -126,8 +140,13 @@ module Ci
# no chunk with higher index in the database.
#
def final?
- build.pending_state.present? &&
- build.trace_chunks.maximum(:chunk_index).to_i == chunk_index
+ build.pending_state.present? && chunks_max_index == chunk_index
+ end
+
+ def <=>(other)
+ return unless self.build_id == other.build_id
+
+ self.chunk_index <=> other.chunk_index
end
private
@@ -145,12 +164,19 @@ module Ci
current_size = current_data&.bytesize.to_i
unless current_size == CHUNK_SIZE || final?
- raise FailedToPersistDataError, 'Data is not fulfilled in a bucket'
+ raise FailedToPersistDataError, <<~MSG
+ data is not fulfilled in a bucket
+
+ size: #{current_size}
+ state: #{build.pending_state.present?}
+ max: #{chunks_max_index}
+ index: #{chunk_index}
+ MSG
end
self.raw_data = nil
self.data_store = new_store
- self.checksum = crc32(current_data)
+ self.checksum = self.class.crc32(current_data)
##
# We need to so persist data then save a new store identifier before we
@@ -203,6 +229,10 @@ module Ci
self.class.get_store_class(data_store)
end
+ def chunks_max_index
+ build.trace_chunks.maximum(:chunk_index).to_i
+ end
+
def lock_params
["trace_write:#{build_id}:chunks:#{chunk_index}",
{ ttl: WRITE_LOCK_TTL,
diff --git a/app/models/ci/build_trace_chunks/database.rb b/app/models/ci/build_trace_chunks/database.rb
index ea8072099c6..7448afba4c2 100644
--- a/app/models/ci/build_trace_chunks/database.rb
+++ b/app/models/ci/build_trace_chunks/database.rb
@@ -17,6 +17,8 @@ module Ci
def data(model)
model.raw_data
+ rescue ActiveModel::MissingAttributeError
+ model.reset.raw_data
end
def set_data(model, new_data)
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 47eba685afe..f9a55fa9157 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -42,6 +42,7 @@ module Ci
has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :latest_statuses_ordered_by_stage, -> { latest.order(:stage_idx, :stage) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
+ has_many :latest_statuses, -> { latest }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :processables, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline
has_many :bridges, class_name: 'Ci::Bridge', foreign_key: :commit_id, inverse_of: :pipeline
has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline
@@ -577,11 +578,11 @@ module Ci
end
def retried
- @retried ||= (statuses.order(id: :desc) - statuses.latest)
+ @retried ||= (statuses.order(id: :desc) - latest_statuses)
end
def coverage
- coverage_array = statuses.latest.map(&:coverage).compact
+ coverage_array = latest_statuses.map(&:coverage).compact
if coverage_array.size >= 1
'%.2f' % (coverage_array.reduce(:+) / coverage_array.size)
end
@@ -875,7 +876,7 @@ module Ci
end
def builds_with_coverage
- builds.with_coverage
+ builds.latest.with_coverage
end
def has_reports?(reports_scope)
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index f0b3c11ba1d..d07ea7b71dc 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class Runner < ApplicationRecord
- VERSION = '0.20.2'
+ VERSION = '0.21.1'
self.table_name = 'clusters_applications_runners'
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index 7af78960e35..b85a902d58b 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -11,6 +11,7 @@ module Clusters
RESERVED_NAMESPACES = %w(gitlab-managed-apps).freeze
self.table_name = 'cluster_platforms_kubernetes'
+ self.reactive_cache_work_type = :external_dependency
belongs_to :cluster, inverse_of: :platform_kubernetes, class_name: 'Clusters::Cluster'
@@ -101,7 +102,7 @@ module Clusters
def terminals(environment, data)
pods = filter_by_project_environment(data[:pods], environment.project.full_path_slug, environment.slug)
terminals = pods.flat_map { |pod| terminals_for_pod(api_url, environment.deployment_namespace, pod) }.compact
- terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) }
+ terminals.each { |terminal| add_terminal_auth(terminal, **terminal_auth) }
end
def kubeclient
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 5e0fceb23a4..5fe1b451ccd 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -29,12 +29,6 @@ class Commit
delegate :repository, to: :container
delegate :project, to: :repository, allow_nil: true
- DIFF_SAFE_LINES = Gitlab::Git::DiffCollection::DEFAULT_LIMITS[:max_lines]
-
- # Commits above this size will not be rendered in HTML
- DIFF_HARD_LIMIT_FILES = 1000
- DIFF_HARD_LIMIT_LINES = 50000
-
MIN_SHA_LENGTH = Gitlab::Git::Commit::MIN_SHA_LENGTH
COMMIT_SHA_PATTERN = /\h{#{MIN_SHA_LENGTH},40}/.freeze
EXACT_COMMIT_SHA_PATTERN = /\A#{COMMIT_SHA_PATTERN}\z/.freeze
@@ -80,10 +74,30 @@ class Commit
sha[0..MIN_SHA_LENGTH]
end
- def max_diff_options
+ def diff_safe_lines
+ Gitlab::Git::DiffCollection.default_limits[:max_lines]
+ end
+
+ def diff_hard_limit_files(project: nil)
+ if Feature.enabled?(:increased_diff_limits, project)
+ 2000
+ else
+ 1000
+ end
+ end
+
+ def diff_hard_limit_lines(project: nil)
+ if Feature.enabled?(:increased_diff_limits, project)
+ 75000
+ else
+ 50000
+ end
+ end
+
+ def max_diff_options(project: nil)
{
- max_files: DIFF_HARD_LIMIT_FILES,
- max_lines: DIFF_HARD_LIMIT_LINES
+ max_files: diff_hard_limit_files(project: project),
+ max_lines: diff_hard_limit_lines(project: project)
}
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 2f0596c93cc..b0169d6290a 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -204,8 +204,13 @@ class CommitStatus < ApplicationRecord
# 'rspec:linux: 1/10' => 'rspec:linux'
common_name = name.to_s.gsub(%r{\d+[\s:\/\\]+\d+\s*}, '')
- # 'rspec:linux: [aws, max memory]' => 'rspec:linux'
- common_name.gsub!(%r{: \[.*, .*\]\s*\z}, '')
+ if ::Gitlab::Ci::Features.one_dimensional_matrix_enabled?
+ # 'rspec:linux: [aws, max memory]' => 'rspec:linux', 'rspec:linux: [aws]' => 'rspec:linux'
+ common_name.gsub!(%r{: \[.*\]\s*\z}, '')
+ else
+ # 'rspec:linux: [aws, max memory]' => 'rspec:linux', 'rspec:linux: [aws]' => 'rspec:linux: [aws]'
+ common_name.gsub!(%r{: \[.*, .*\]\s*\z}, '')
+ end
common_name.strip!
common_name
diff --git a/app/models/concerns/approvable_base.rb b/app/models/concerns/approvable_base.rb
index d07c4ec43ac..c2d94b50f8d 100644
--- a/app/models/concerns/approvable_base.rb
+++ b/app/models/concerns/approvable_base.rb
@@ -2,10 +2,34 @@
module ApprovableBase
extend ActiveSupport::Concern
+ include FromUnion
included do
has_many :approvals, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :approved_by_users, through: :approvals, source: :user
+
+ scope :without_approvals, -> { left_outer_joins(:approvals).where(approvals: { id: nil }) }
+ scope :with_approvals, -> { joins(:approvals) }
+ scope :approved_by_users_with_ids, -> (*user_ids) do
+ with_approvals
+ .merge(Approval.with_user)
+ .where(users: { id: user_ids })
+ .group(:id)
+ .having("COUNT(users.id) = ?", user_ids.size)
+ end
+ scope :approved_by_users_with_usernames, -> (*usernames) do
+ with_approvals
+ .merge(Approval.with_user)
+ .where(users: { username: usernames })
+ .group(:id)
+ .having("COUNT(users.id) = ?", usernames.size)
+ end
+ end
+
+ class_methods do
+ def select_from_union(relations)
+ where(id: from_union(relations))
+ end
end
def approved_by?(user)
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index 0dd55ab67b5..92926620f8c 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -3,16 +3,11 @@
module Avatarable
extend ActiveSupport::Concern
- ALLOWED_IMAGE_SCALER_WIDTHS = [
- 400,
- 200,
- 64,
- 48,
- 40,
- 26,
- 20,
- 16
- ].freeze
+ USER_AVATAR_SIZES = [16, 20, 23, 24, 26, 32, 36, 38, 40, 48, 60, 64, 96, 120, 160].freeze
+ PROJECT_AVATAR_SIZES = [15, 40, 48, 64, 88].freeze
+ GROUP_AVATAR_SIZES = [15, 37, 38, 39, 40, 64, 96].freeze
+
+ ALLOWED_IMAGE_SCALER_WIDTHS = (USER_AVATAR_SIZES | PROJECT_AVATAR_SIZES | GROUP_AVATAR_SIZES).freeze
included do
prepend ShadowMethods
diff --git a/app/models/concerns/checksummable.rb b/app/models/concerns/checksummable.rb
index d6d17bfc604..056abafd0ce 100644
--- a/app/models/concerns/checksummable.rb
+++ b/app/models/concerns/checksummable.rb
@@ -3,11 +3,11 @@
module Checksummable
extend ActiveSupport::Concern
- def crc32(data)
- Zlib.crc32(data)
- end
-
class_methods do
+ def crc32(data)
+ Zlib.crc32(data)
+ end
+
def hexdigest(path)
::Digest::SHA256.file(path).hexdigest
end
diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb
index a5c7393e8f7..b468415c4c7 100644
--- a/app/models/concerns/counter_attribute.rb
+++ b/app/models/concerns/counter_attribute.rb
@@ -20,6 +20,14 @@
# To increment the counter we can use the method:
# delayed_increment_counter(:commit_count, 3)
#
+# It is possible to register callbacks to be executed after increments have
+# been flushed to the database. Callbacks are not executed if there are no increments
+# to flush.
+#
+# counter_attribute_after_flush do |statistic|
+# Namespaces::ScheduleAggregationWorker.perform_async(statistic.namespace_id)
+# end
+#
module CounterAttribute
extend ActiveSupport::Concern
extend AfterCommitQueue
@@ -48,6 +56,15 @@ module CounterAttribute
def counter_attributes
@counter_attributes ||= Set.new
end
+
+ def after_flush_callbacks
+ @after_flush_callbacks ||= []
+ end
+
+ # perform registered callbacks after increments have been flushed to the database
+ def counter_attribute_after_flush(&callback)
+ after_flush_callbacks << callback
+ end
end
# This method must only be called by FlushCounterIncrementsWorker
@@ -75,6 +92,8 @@ module CounterAttribute
unsafe_update_counters(id, attribute => increment_value)
redis_state { |redis| redis.del(flushed_key) }
end
+
+ execute_after_flush_callbacks
end
end
@@ -108,13 +127,13 @@ module CounterAttribute
counter_key(attribute) + ':lock'
end
- private
-
def counter_attribute_enabled?(attribute)
Feature.enabled?(:efficient_counter_attribute, project) &&
self.class.counter_attributes.include?(attribute)
end
+ private
+
def steal_increments(increment_key, flushed_key)
redis_state do |redis|
redis.eval(LUA_STEAL_INCREMENT_SCRIPT, keys: [increment_key, flushed_key])
@@ -129,6 +148,12 @@ module CounterAttribute
self.class.update_counters(id, increments)
end
+ def execute_after_flush_callbacks
+ self.class.after_flush_callbacks.each do |callback|
+ callback.call(self)
+ end
+ end
+
def redis_state(&block)
Gitlab::Redis::SharedState.with(&block)
end
diff --git a/app/models/concerns/integration.rb b/app/models/concerns/integration.rb
index 34ff5bb1195..9d446841a9f 100644
--- a/app/models/concerns/integration.rb
+++ b/app/models/concerns/integration.rb
@@ -16,7 +16,7 @@ module Integration
Project.where(id: custom_integration_project_ids)
end
- def ids_without_integration(integration, limit)
+ def without_integration(integration)
services = Service
.select('1')
.where('services.project_id = projects.id')
@@ -26,8 +26,6 @@ module Integration
.where('NOT EXISTS (?)', services)
.where(pending_delete: false)
.where(archived: false)
- .limit(limit)
- .pluck(:id)
end
end
end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 888e1b384a2..7624a1a4e80 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -182,7 +182,7 @@ module Issuable
end
def supports_time_tracking?
- is_a?(TimeTrackable) && !incident?
+ is_a?(TimeTrackable)
end
def supports_severity?
@@ -203,15 +203,6 @@ module Issuable
issuable_severity&.severity || IssuableSeverity::DEFAULT
end
- def update_severity(severity)
- return unless incident?
-
- severity = severity.to_s.downcase
- severity = IssuableSeverity::DEFAULT unless IssuableSeverity.severities.key?(severity)
-
- (issuable_severity || build_issuable_severity(issue_id: id)).update(severity: severity)
- end
-
private
def description_max_length_for_new_records_is_valid
diff --git a/app/models/concerns/issue_available_features.rb b/app/models/concerns/issue_available_features.rb
new file mode 100644
index 00000000000..6efb8103b7b
--- /dev/null
+++ b/app/models/concerns/issue_available_features.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+# Verifies features availability based on issue type.
+# This can be used, for example, for hiding UI elements or blocking specific
+# quick actions for particular issue types;
+module IssueAvailableFeatures
+ extend ActiveSupport::Concern
+
+ # EE only features are listed on EE::IssueAvailableFeatures
+ def available_features_for_issue_types
+ {}.with_indifferent_access
+ end
+
+ def issue_type_supports?(feature)
+ unless available_features_for_issue_types.has_key?(feature)
+ raise ArgumentError, 'invalid feature'
+ end
+
+ available_features_for_issue_types[feature].include?(issue_type)
+ end
+end
+
+IssueAvailableFeatures.prepend_if_ee('EE::IssueAvailableFeatures')
diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb
index 7b4485376d4..b10e8547e86 100644
--- a/app/models/concerns/mentionable.rb
+++ b/app/models/concerns/mentionable.rb
@@ -81,13 +81,6 @@ module Mentionable
end
def store_mentions!
- # if store_mentioned_users_to_db feature flag is not enabled then consider storing operation as succeeded
- # because we wrap this method in transaction with with_transaction_returning_status, and we need the status to be
- # successful if mentionable.save is successful.
- #
- # This line will get removed when we remove the feature flag.
- return true unless store_mentioned_users_to_db_enabled?
-
refs = all_references(self.author)
references = {}
@@ -253,15 +246,6 @@ module Mentionable
def model_user_mention
user_mentions.where(note_id: nil).first_or_initialize
end
-
- # We need this method to be checking that store_mentioned_users_to_db feature flag is enabled at the group level
- # and not the project level as epics are defined at group level and we want to have epics store user mentions as well
- # for the test period.
- # During the test period the flag should be enabled at the group level.
- def store_mentioned_users_to_db_enabled?
- return Feature.enabled?(:store_mentioned_users_to_db, self.project&.group, default_enabled: true) if self.respond_to?(:project)
- return Feature.enabled?(:store_mentioned_users_to_db, self.group, default_enabled: true) if self.respond_to?(:group)
- end
end
Mentionable.prepend_if_ee('EE::Mentionable')
diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb
index 5f30fc0c36c..26f1544103c 100644
--- a/app/models/concerns/reactive_caching.rb
+++ b/app/models/concerns/reactive_caching.rb
@@ -9,7 +9,7 @@ module ReactiveCaching
ExceededReactiveCacheLimit = Class.new(StandardError)
WORK_TYPE = {
- default: ReactiveCachingWorker,
+ no_dependency: ReactiveCachingWorker,
external_dependency: ExternalServiceReactiveCachingWorker
}.freeze
@@ -30,7 +30,6 @@ module ReactiveCaching
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 10.minutes
self.reactive_cache_hard_limit = nil # this value should be set in megabytes. E.g: 1.megabyte
- self.reactive_cache_work_type = :default
self.reactive_cache_worker_finder = ->(id, *_args) do
find_by(primary_key => id)
end
diff --git a/app/models/concerns/reactive_service.rb b/app/models/concerns/reactive_service.rb
index af69da24994..c444f238944 100644
--- a/app/models/concerns/reactive_service.rb
+++ b/app/models/concerns/reactive_service.rb
@@ -8,5 +8,6 @@ module ReactiveService
# Default cache key: class name + project_id
self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] }
+ self.reactive_cache_work_type = :external_dependency
end
end
diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb
index 40edd3b3ead..9a17131c91c 100644
--- a/app/models/concerns/referable.rb
+++ b/app/models/concerns/referable.rb
@@ -85,7 +85,7 @@ module Referable
\/#{route.is_a?(Regexp) ? route : Regexp.escape(route)}
\/#{pattern}
(?<path>
- (\/[a-z0-9_=-]+)*
+ (\/[a-z0-9_=-]+)*\/*
)?
(?<query>
\?[a-z0-9_=-]+
diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb
index 3cbc174536c..b62eb50840d 100644
--- a/app/models/concerns/relative_positioning.rb
+++ b/app/models/concerns/relative_positioning.rb
@@ -200,4 +200,10 @@ module RelativePositioning
# Override if you want to be notified of failures to move
def could_not_move(exception)
end
+
+ # Override if the implementing class is not a simple application record, for
+ # example if the record is loaded from a union.
+ def reset_relative_position
+ reset.relative_position
+ end
end
diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb
index a7028e18451..586f1dbb65c 100644
--- a/app/models/concerns/update_project_statistics.rb
+++ b/app/models/concerns/update_project_statistics.rb
@@ -80,10 +80,7 @@ module UpdateProjectStatistics
run_after_commit do
ProjectStatistics.increment_statistic(
- project_id, self.class.project_statistics_name, delta)
-
- Namespaces::ScheduleAggregationWorker.perform_async(
- project.namespace_id)
+ project, self.class.project_statistics_name, delta)
end
end
end
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index b0f7edac2f3..d97b8776085 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -107,6 +107,14 @@ class ContainerRepository < ApplicationRecord
client.delete_repository_tag_by_name(self.path, name)
end
+ def reset_expiration_policy_started_at!
+ update!(expiration_policy_started_at: nil)
+ end
+
+ def start_expiration_policy!
+ update!(expiration_policy_started_at: Time.zone.now)
+ end
+
def self.build_from_path(path)
self.new(project: path.repository_project,
name: path.repository_name)
diff --git a/app/models/data_list.rb b/app/models/data_list.rb
index 2cee3447886..adad8e3013e 100644
--- a/app/models/data_list.rb
+++ b/app/models/data_list.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
class DataList
- def initialize(batch_ids, data_fields_hash, klass)
- @batch_ids = batch_ids
+ def initialize(batch, data_fields_hash, klass)
+ @batch = batch
@data_fields_hash = data_fields_hash
@klass = klass
end
@@ -13,15 +13,15 @@ class DataList
private
- attr_reader :batch_ids, :data_fields_hash, :klass
+ attr_reader :batch, :data_fields_hash, :klass
def columns
data_fields_hash.keys << 'service_id'
end
def values
- batch_ids.map do |row|
- data_fields_hash.values << row['id']
+ batch.map do |record|
+ data_fields_hash.values << record['id']
end
end
end
diff --git a/app/models/design_management/design.rb b/app/models/design_management/design.rb
index 57bb250829d..62e4bd6cebc 100644
--- a/app/models/design_management/design.rb
+++ b/app/models/design_management/design.rb
@@ -167,6 +167,10 @@ module DesignManagement
end
end
+ def self.build_full_path(issue, design)
+ File.join(DesignManagement.designs_directory, "issue-#{issue.iid}", design.filename)
+ end
+
def to_ability_name
'design'
end
@@ -180,7 +184,7 @@ module DesignManagement
end
def full_path
- @full_path ||= File.join(DesignManagement.designs_directory, "issue-#{issue.iid}", filename)
+ @full_path ||= self.class.build_full_path(issue, self)
end
def diff_refs
@@ -224,6 +228,10 @@ module DesignManagement
!interloper.exists?
end
+ def notes_with_associations
+ notes.includes(:author)
+ end
+
private
def head_version
diff --git a/app/models/design_management/design_collection.rb b/app/models/design_management/design_collection.rb
index c48b36588c9..6deba14a6ba 100644
--- a/app/models/design_management/design_collection.rb
+++ b/app/models/design_management/design_collection.rb
@@ -5,6 +5,7 @@ module DesignManagement
attr_reader :issue
delegate :designs, :project, to: :issue
+ delegate :empty?, to: :designs
state_machine :copy_state, initial: :ready, namespace: :copy do
after_transition any => any, do: :update_stored_copy_state!
diff --git a/app/models/environment.rb b/app/models/environment.rb
index cfdcb0499e6..f64776a6991 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -86,6 +86,7 @@ class Environment < ApplicationRecord
scope :with_rank, -> do
select('environments.*, rank() OVER (PARTITION BY project_id ORDER BY id DESC)')
end
+ scope :for_id, -> (id) { where(id: id) }
state_machine :state, initial: :available do
event :start do
diff --git a/app/models/event.rb b/app/models/event.rb
index 92609144576..671def16151 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -242,6 +242,8 @@ class Event < ApplicationRecord
target if note?
end
+ # rubocop: disable Metrics/CyclomaticComplexity
+ # rubocop: disable Metrics/PerceivedComplexity
def action_name
if push_action?
push_action_name
@@ -267,10 +269,14 @@ class Event < ApplicationRecord
'updated'
elsif created_project_action?
created_project_action_name
+ elsif approved_action?
+ 'approved'
else
"opened"
end
end
+ # rubocop: enable Metrics/CyclomaticComplexity
+ # rubocop: enable Metrics/PerceivedComplexity
def target_iid
target.respond_to?(:iid) ? target.iid : target_id
@@ -323,14 +329,6 @@ class Event < ApplicationRecord
end
end
- def note_target_type
- if target.noteable_type.present?
- target.noteable_type.titleize
- else
- "Wall"
- end.downcase
- end
-
def body?
if push_action?
push_with_commits?
diff --git a/app/models/group.rb b/app/models/group.rb
index c0f145997cc..1dec831606b 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -15,11 +15,10 @@ class Group < Namespace
include WithUploads
include Gitlab::Utils::StrongMemoize
include GroupAPICompatibility
+ include EachBatch
ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10
- UpdateSharedRunnersError = Class.new(StandardError)
-
has_many :all_group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent
has_many :group_members, -> { where(requested_at: nil).where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
alias_method :members, :group_members
@@ -140,6 +139,15 @@ class Group < Namespace
end
end
+ def without_integration(integration)
+ services = Service
+ .select('1')
+ .where('services.group_id = namespaces.id')
+ .where(type: integration.type)
+
+ where('NOT EXISTS (?)', services)
+ end
+
private
def public_to_user_arel(user)
@@ -348,6 +356,7 @@ class Group < Namespace
end
group_hierarchy_members = GroupMember.active_without_invites_and_requests
+ .non_minimal_access
.where(source_id: source_ids)
GroupMember.from_union([group_hierarchy_members,
@@ -528,57 +537,26 @@ class Group < Namespace
preloader.preload(self, shared_with_group_links: [shared_with_group: :route])
end
- def shared_runners_allowed?
- shared_runners_enabled? || allow_descendants_override_disabled_shared_runners?
- end
-
- def parent_allows_shared_runners?
- return true unless has_parent?
+ def update_shared_runners_setting!(state)
+ raise ArgumentError unless SHARED_RUNNERS_SETTINGS.include?(state)
- parent.shared_runners_allowed?
- end
-
- def parent_enabled_shared_runners?
- return true unless has_parent?
-
- parent.shared_runners_enabled?
- end
-
- def enable_shared_runners!
- raise UpdateSharedRunnersError, 'Shared Runners disabled for the parent group' unless parent_enabled_shared_runners?
-
- update_column(:shared_runners_enabled, true)
- end
-
- def disable_shared_runners!
- group_ids = self_and_descendants
- return if group_ids.empty?
-
- Group.by_id(group_ids).update_all(shared_runners_enabled: false)
-
- all_projects.update_all(shared_runners_enabled: false)
+ case state
+ when 'disabled_and_unoverridable' then disable_shared_runners! # also disallows override
+ when 'disabled_with_override' then disable_shared_runners_and_allow_override!
+ when 'enabled' then enable_shared_runners! # set both to true
+ end
end
- def allow_descendants_override_disabled_shared_runners!
- raise UpdateSharedRunnersError, 'Shared Runners enabled' if shared_runners_enabled?
- raise UpdateSharedRunnersError, 'Group level shared Runners not allowed' unless parent_allows_shared_runners?
-
- update_column(:allow_descendants_override_disabled_shared_runners, true)
+ def default_owner
+ owners.first || parent&.default_owner || owner
end
- def disallow_descendants_override_disabled_shared_runners!
- raise UpdateSharedRunnersError, 'Shared Runners enabled' if shared_runners_enabled?
-
- group_ids = self_and_descendants
- return if group_ids.empty?
-
- Group.by_id(group_ids).update_all(allow_descendants_override_disabled_shared_runners: false)
-
- all_projects.update_all(shared_runners_enabled: false)
+ def access_level_roles
+ GroupMember.access_level_roles
end
- def default_owner
- owners.first || parent&.default_owner || owner
+ def access_level_values
+ access_level_roles.values
end
private
@@ -658,6 +636,45 @@ class Group < Namespace
.new(Group.where(id: group_ids))
.base_and_descendants
end
+
+ def disable_shared_runners!
+ update!(
+ shared_runners_enabled: false,
+ allow_descendants_override_disabled_shared_runners: false)
+
+ group_ids = descendants
+ unless group_ids.empty?
+ Group.by_id(group_ids).update_all(
+ shared_runners_enabled: false,
+ allow_descendants_override_disabled_shared_runners: false)
+ end
+
+ all_projects.update_all(shared_runners_enabled: false)
+ end
+
+ def disable_shared_runners_and_allow_override!
+ # enabled -> disabled_with_override
+ if shared_runners_enabled?
+ update!(
+ shared_runners_enabled: false,
+ allow_descendants_override_disabled_shared_runners: true)
+
+ group_ids = descendants
+ unless group_ids.empty?
+ Group.by_id(group_ids).update_all(shared_runners_enabled: false)
+ end
+
+ all_projects.update_all(shared_runners_enabled: false)
+
+ # disabled_and_unoverridable -> disabled_with_override
+ else
+ update!(allow_descendants_override_disabled_shared_runners: true)
+ end
+ end
+
+ def enable_shared_runners!
+ update!(shared_runners_enabled: true)
+ end
end
Group.prepend_if_ee('EE::Group')
diff --git a/app/models/group_import_state.rb b/app/models/group_import_state.rb
index d22c1ac5550..89602e40357 100644
--- a/app/models/group_import_state.rb
+++ b/app/models/group_import_state.rb
@@ -4,8 +4,9 @@ class GroupImportState < ApplicationRecord
self.primary_key = :group_id
belongs_to :group, inverse_of: :import_state
+ belongs_to :user, optional: false
- validates :group, :status, presence: true
+ validates :group, :status, :user, presence: true
validates :jid, presence: true, if: -> { started? || finished? }
state_machine :status, initial: :created do
diff --git a/app/models/issuable_severity.rb b/app/models/issuable_severity.rb
index d68b3dc48ee..35d03a544bd 100644
--- a/app/models/issuable_severity.rb
+++ b/app/models/issuable_severity.rb
@@ -2,6 +2,13 @@
class IssuableSeverity < ApplicationRecord
DEFAULT = 'unknown'
+ SEVERITY_LABELS = {
+ unknown: 'Unknown',
+ low: 'Low - S4',
+ medium: 'Medium - S3',
+ high: 'High - S2',
+ critical: 'Critical - S1'
+ }.freeze
belongs_to :issue
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 5a5de371301..621b1a83b82 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -19,6 +19,8 @@ class Issue < ApplicationRecord
include WhereComposite
include StateEventable
include IdInOrdered
+ include Presentable
+ include IssueAvailableFeatures
DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
@@ -54,6 +56,7 @@ class Issue < ApplicationRecord
dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :issue_assignees
+ has_many :issue_email_participants
has_many :assignees, class_name: "User", through: :issue_assignees
has_many :zoom_meetings
has_many :user_mentions, class_name: "IssueUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
@@ -101,6 +104,8 @@ class Issue < ApplicationRecord
scope :order_relative_position_asc, -> { reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')) }
scope :order_closed_date_desc, -> { reorder(closed_at: :desc) }
scope :order_created_at_desc, -> { reorder(created_at: :desc) }
+ scope :order_severity_asc, -> { includes(:issuable_severity).order('issuable_severities.severity ASC NULLS FIRST') }
+ scope :order_severity_desc, -> { includes(:issuable_severity).order('issuable_severities.severity DESC NULLS LAST') }
scope :preload_associated_models, -> { preload(:assignees, :labels, project: :namespace) }
scope :with_web_entity_associations, -> { preload(:author, :project) }
@@ -122,6 +127,7 @@ class Issue < ApplicationRecord
scope :counts_by_state, -> { reorder(nil).group(:state_id).count }
scope :service_desk, -> { where(author: ::User.support_bot) }
+ scope :inc_relations_for_view, -> { includes(author: :status) }
# An issue can be uniquely identified by project_id and iid
# Takes one or more sets of composite IDs, expressed as hash-like records of
@@ -145,6 +151,7 @@ class Issue < ApplicationRecord
after_commit :expire_etag_cache, unless: :importing?
after_save :ensure_metrics, unless: :importing?
+ after_create_commit :record_create_action, unless: :importing?
attr_spammable :title, spam_title: true
attr_spammable :description, spam_description: true
@@ -232,6 +239,8 @@ class Issue < ApplicationRecord
when 'due_date', 'due_date_asc' then order_due_date_asc.with_order_id_desc
when 'due_date_desc' then order_due_date_desc.with_order_id_desc
when 'relative_position', 'relative_position_asc' then order_relative_position_asc.with_order_id_desc
+ when 'severity_asc' then order_severity_asc.with_order_id_desc
+ when 'severity_desc' then order_severity_desc.with_order_id_desc
else
super
end
@@ -413,6 +422,10 @@ class Issue < ApplicationRecord
IssueLink.inverse_link_type(type)
end
+ def relocation_target
+ moved_to || duplicated_to
+ end
+
private
def ensure_metrics
@@ -420,6 +433,10 @@ class Issue < ApplicationRecord
metrics.record!
end
+ def record_create_action
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_created_action(author: author)
+ 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_email_participant.rb b/app/models/issue_email_participant.rb
new file mode 100644
index 00000000000..8eb9b6a8152
--- /dev/null
+++ b/app/models/issue_email_participant.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class IssueEmailParticipant < ApplicationRecord
+ belongs_to :issue
+
+ validates :email, presence: true, uniqueness: { scope: [:issue_id] }
+ validates :issue, presence: true
+ validate :validate_email_format
+
+ def validate_email_format
+ self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email)
+ end
+end
diff --git a/app/models/iteration.rb b/app/models/iteration.rb
index d223c80fca0..bd245de411c 100644
--- a/app/models/iteration.rb
+++ b/app/models/iteration.rb
@@ -94,13 +94,25 @@ class Iteration < ApplicationRecord
private
+ def parent_group
+ group || project.group
+ end
+
def start_or_due_dates_changed?
start_date_changed? || due_date_changed?
end
- # ensure dates do not overlap with other Iterations in the same group/project
+ # ensure dates do not overlap with other Iterations in the same group/project tree
def dates_do_not_overlap
- return unless resource_parent.iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists?
+ iterations = if parent_group.present? && resource_parent.is_a?(Project)
+ Iteration.where(group: parent_group.self_and_ancestors).or(project.iterations)
+ elsif parent_group.present?
+ Iteration.where(group: parent_group.self_and_ancestors)
+ else
+ project.iterations
+ end
+
+ return unless iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists?
errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations"))
end
diff --git a/app/models/member.rb b/app/models/member.rb
index 7ea9caa45d3..498e03b2c1a 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -80,7 +80,10 @@ class Member < ApplicationRecord
scope :request, -> { where.not(requested_at: nil) }
scope :non_request, -> { where(requested_at: nil) }
- scope :not_accepted_invitations_by_user, -> (user) { invite.where(invite_accepted_at: nil, created_by: user) }
+ scope :not_accepted_invitations, -> { invite.where(invite_accepted_at: nil) }
+ scope :not_accepted_invitations_by_user, -> (user) { not_accepted_invitations.where(created_by: user) }
+ scope :not_expired, -> (today = Date.current) { where(arel_table[:expires_at].gt(today).or(arel_table[:expires_at].eq(nil))) }
+ scope :last_ten_days_excluding_today, -> (today = Date.current) { where(created_at: (today - 10).beginning_of_day..(today - 1).end_of_day) }
scope :has_access, -> { active.where('access_level > 0') }
@@ -372,6 +375,14 @@ class Member < ApplicationRecord
send_invite
end
+ def send_invitation_reminder(reminder_index)
+ return unless invite?
+
+ generate_invite_token! unless @raw_invite_token
+
+ run_after_commit_or_now { notification_service.invite_member_reminder(self, @raw_invite_token, reminder_index) }
+ end
+
def create_notification_setting
user.notification_settings.find_or_create_for(source)
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 3fdc501644d..22c6777fca3 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -31,6 +31,7 @@ class MergeRequest < ApplicationRecord
self.reactive_cache_key = ->(model) { [model.project.id, model.iid] }
self.reactive_cache_refresh_interval = 10.minutes
self.reactive_cache_lifetime = 10.minutes
+ self.reactive_cache_work_type = :no_dependency
SORTING_PREFERENCE_FIELD = :merge_requests_sort
@@ -121,6 +122,8 @@ class MergeRequest < ApplicationRecord
# when creating new merge request
attr_accessor :can_be_created, :compare_commits, :diff_options, :compare
+ participant :reviewers
+
# Keep states definition to be evaluated before the state_machine block to avoid spec failures.
# If this gets evaluated after, the `merged` and `locked` states which are overrided can be nil.
def self.available_state_names
@@ -255,11 +258,7 @@ class MergeRequest < ApplicationRecord
scope :join_project, -> { joins(:target_project) }
scope :join_metrics, -> do
query = joins(:metrics)
-
- if Feature.enabled?(:improved_mr_merged_at_queries, default_enabled: true)
- query = query.where(MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id]))
- end
-
+ query = query.where(MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id]))
query
end
scope :references_project, -> { references(:target_project) }
@@ -271,6 +270,8 @@ class MergeRequest < ApplicationRecord
metrics: [:latest_closed_by, :merged_by])
}
+ scope :with_csv_entity_associations, -> { preload(:assignees, :approved_by_users, :author, :milestone, metrics: [:merged_by]) }
+
scope :by_target_branch_wildcard, ->(wildcard_branch_name) do
where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%'))
end
@@ -629,7 +630,7 @@ class MergeRequest < ApplicationRecord
def diff_size
# Calling `merge_request_diff.diffs.real_size` will also perform
# highlighting, which we don't need here.
- merge_request_diff&.real_size || diff_stats&.real_size || diffs.real_size
+ merge_request_diff&.real_size || diff_stats&.real_size(project: project) || diffs.real_size
end
def modified_paths(past_merge_request_diff: nil, fallback_on_overflow: false)
@@ -1301,6 +1302,14 @@ class MergeRequest < ApplicationRecord
unlock_mr
end
+ def update_and_mark_in_progress_merge_commit_sha(commit_id)
+ self.update(in_progress_merge_commit_sha: commit_id)
+ # Since another process checks for matching merge request, we need
+ # to make it possible to detect whether the query should go to the
+ # primary.
+ target_project.mark_primary_write_location
+ end
+
def diverged_commits_count
cache = Rails.cache.read(:"merge_request_#{id}_diverged_commits")
@@ -1375,8 +1384,6 @@ class MergeRequest < ApplicationRecord
end
def has_coverage_reports?
- return false unless Feature.enabled?(:coverage_report_view, project, default_enabled: true)
-
actual_head_pipeline&.has_coverage_reports?
end
@@ -1511,6 +1518,7 @@ class MergeRequest < ApplicationRecord
metrics&.merged_at ||
merge_event&.created_at ||
+ resource_state_events.find_by(state: :merged)&.created_at ||
notes.system.reorder(nil).find_by(note: 'merged')&.created_at
end
end
@@ -1680,6 +1688,10 @@ class MergeRequest < ApplicationRecord
Feature.enabled?(:merge_request_reviewers, project)
end
+ def allows_multiple_reviewers?
+ false
+ end
+
private
def with_rebase_lock
diff --git a/app/models/merge_request_context_commit.rb b/app/models/merge_request_context_commit.rb
index a2982a5dd73..59cc82cfaf5 100644
--- a/app/models/merge_request_context_commit.rb
+++ b/app/models/merge_request_context_commit.rb
@@ -22,8 +22,8 @@ class MergeRequestContextCommit < ApplicationRecord
end
# create MergeRequestContextCommit by given commit sha and it's diff file record
- def self.bulk_insert(*args)
- Gitlab::Database.bulk_insert('merge_request_context_commits', *args) # rubocop:disable Gitlab/BulkInsert
+ def self.bulk_insert(rows, **args)
+ Gitlab::Database.bulk_insert('merge_request_context_commits', rows, **args) # rubocop:disable Gitlab/BulkInsert
end
def to_commit
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 880e3cc1ba5..3cf0db9403d 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -509,6 +509,8 @@ class MergeRequestDiff < ApplicationRecord
end
def encode_in_base64?(diff_text)
+ return false if diff_text.nil?
+
(diff_text.encoding == Encoding::BINARY && !diff_text.ascii_only?) ||
diff_text.include?("\0")
end
@@ -536,7 +538,7 @@ class MergeRequestDiff < ApplicationRecord
rows.each do |row|
data = row.delete(:diff)
row[:external_diff_offset] = file.pos
- row[:external_diff_size] = data.bytesize
+ row[:external_diff_size] = data&.bytesize || 0
file.write(data)
end
@@ -651,7 +653,7 @@ class MergeRequestDiff < ApplicationRecord
if compare.commits.empty?
new_attributes[:state] = :empty
else
- diff_collection = compare.diffs(Commit.max_diff_options)
+ diff_collection = compare.diffs(Commit.max_diff_options(project: merge_request.project))
new_attributes[:real_size] = diff_collection.real_size
if diff_collection.any?
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 527fa9d52d0..f0550713d01 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -18,6 +18,8 @@ class Namespace < ApplicationRecord
# Android repo (15) + some extra backup.
NUMBER_OF_ANCESTORS_ALLOWED = 20
+ SHARED_RUNNERS_SETTINGS = %w[disabled_and_unoverridable disabled_with_override enabled].freeze
+
cache_markdown_field :description, pipeline: :description
has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -59,6 +61,8 @@ class Namespace < ApplicationRecord
validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
validate :nesting_level_allowed
+ validate :changing_shared_runners_enabled_is_allowed
+ validate :changing_allow_descendants_override_disabled_shared_runners_is_allowed
validates_associated :runners
@@ -378,6 +382,52 @@ class Namespace < ApplicationRecord
actual_plan.name
end
+ def changing_shared_runners_enabled_is_allowed
+ return unless Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true)
+ return unless new_record? || changes.has_key?(:shared_runners_enabled)
+
+ if shared_runners_enabled && has_parent? && parent.shared_runners_setting == 'disabled_and_unoverridable'
+ errors.add(:shared_runners_enabled, _('cannot be enabled because parent group has shared Runners disabled'))
+ end
+ end
+
+ def changing_allow_descendants_override_disabled_shared_runners_is_allowed
+ return unless Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true)
+ return unless new_record? || changes.has_key?(:allow_descendants_override_disabled_shared_runners)
+
+ if shared_runners_enabled && !new_record?
+ errors.add(:allow_descendants_override_disabled_shared_runners, _('cannot be changed if shared runners are enabled'))
+ end
+
+ if allow_descendants_override_disabled_shared_runners && has_parent? && parent.shared_runners_setting == 'disabled_and_unoverridable'
+ errors.add(:allow_descendants_override_disabled_shared_runners, _('cannot be enabled because parent group does not allow it'))
+ end
+ end
+
+ def shared_runners_setting
+ if shared_runners_enabled
+ 'enabled'
+ else
+ if allow_descendants_override_disabled_shared_runners
+ 'disabled_with_override'
+ else
+ 'disabled_and_unoverridable'
+ end
+ end
+ end
+
+ def shared_runners_setting_higher_than?(other_setting)
+ if other_setting == 'enabled'
+ false
+ elsif other_setting == 'disabled_with_override'
+ shared_runners_setting == 'enabled'
+ elsif other_setting == 'disabled_and_unoverridable'
+ shared_runners_setting == 'enabled' || shared_runners_setting == 'disabled_with_override'
+ else
+ raise ArgumentError
+ end
+ end
+
private
def all_projects_with_pages
diff --git a/app/models/note.rb b/app/models/note.rb
index 812d77d5f86..954843505d4 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -322,8 +322,6 @@ class Note < ApplicationRecord
end
def contributor?
- return false unless ::Feature.enabled?(:show_contributor_on_note, project)
-
project&.team&.contributor?(self.author_id)
end
diff --git a/app/models/notification_reason.rb b/app/models/notification_reason.rb
index a7967239417..c227626af9e 100644
--- a/app/models/notification_reason.rb
+++ b/app/models/notification_reason.rb
@@ -5,6 +5,7 @@
class NotificationReason
OWN_ACTIVITY = 'own_activity'
ASSIGNED = 'assigned'
+ REVIEW_REQUESTED = 'review_requested'
MENTIONED = 'mentioned'
SUBSCRIBED = 'subscribed'
@@ -12,6 +13,7 @@ class NotificationReason
REASON_PRIORITY = [
OWN_ACTIVITY,
ASSIGNED,
+ REVIEW_REQUESTED,
MENTIONED,
SUBSCRIBED
].freeze
diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb
index 6a6b2bb1b58..79a84231083 100644
--- a/app/models/notification_recipient.rb
+++ b/app/models/notification_recipient.rb
@@ -5,7 +5,7 @@ class NotificationRecipient
attr_reader :user, :type, :reason
- def initialize(user, type, **opts)
+ def initialize(user, type, opts = {})
unless NotificationSetting.levels.key?(type) || type == :subscription
raise ArgumentError, "invalid type: #{type.inspect}"
end
diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb
index c003a20f0fc..6066046a722 100644
--- a/app/models/notification_setting.rb
+++ b/app/models/notification_setting.rb
@@ -43,6 +43,7 @@ class NotificationSetting < ApplicationRecord
:reopen_merge_request,
:close_merge_request,
:reassign_merge_request,
+ :change_reviewer_merge_request,
:merge_merge_request,
:failed_pipeline,
:fixed_pipeline,
diff --git a/app/models/operations/feature_flags/strategy.rb b/app/models/operations/feature_flags/strategy.rb
index ff68af9741e..c70e10c72d5 100644
--- a/app/models/operations/feature_flags/strategy.rb
+++ b/app/models/operations/feature_flags/strategy.rb
@@ -6,14 +6,17 @@ module Operations
STRATEGY_DEFAULT = 'default'
STRATEGY_GITLABUSERLIST = 'gitlabUserList'
STRATEGY_GRADUALROLLOUTUSERID = 'gradualRolloutUserId'
+ STRATEGY_FLEXIBLEROLLOUT = 'flexibleRollout'
STRATEGY_USERWITHID = 'userWithId'
STRATEGIES = {
STRATEGY_DEFAULT => [].freeze,
STRATEGY_GITLABUSERLIST => [].freeze,
STRATEGY_GRADUALROLLOUTUSERID => %w[groupId percentage].freeze,
+ STRATEGY_FLEXIBLEROLLOUT => %w[groupId rollout stickiness].freeze,
STRATEGY_USERWITHID => ['userIds'].freeze
}.freeze
USERID_MAX_LENGTH = 256
+ STICKINESS_SETTINGS = %w[DEFAULT USERID SESSIONID RANDOM].freeze
self.table_name = 'operations_strategies'
@@ -67,16 +70,25 @@ module Operations
case name
when STRATEGY_GRADUALROLLOUTUSERID
gradual_rollout_user_id_parameters_validation
+ when STRATEGY_FLEXIBLEROLLOUT
+ flexible_rollout_parameters_validation
when STRATEGY_USERWITHID
FeatureFlagUserXidsValidator.validate_user_xids(self, :parameters, parameters['userIds'], 'userIds')
end
end
+ def within_range?(value, min, max)
+ return false unless value.is_a?(String)
+ return false unless value.match?(/\A\d+\z/)
+
+ value.to_i.between?(min, max)
+ end
+
def gradual_rollout_user_id_parameters_validation
percentage = parameters['percentage']
group_id = parameters['groupId']
- unless percentage.is_a?(String) && percentage.match(/\A[1-9]?[0-9]\z|\A100\z/)
+ unless within_range?(percentage, 0, 100)
parameters_error('percentage must be a string between 0 and 100 inclusive')
end
@@ -85,6 +97,25 @@ module Operations
end
end
+ def flexible_rollout_parameters_validation
+ stickiness = parameters['stickiness']
+ group_id = parameters['groupId']
+ rollout = parameters['rollout']
+
+ unless STICKINESS_SETTINGS.include?(stickiness)
+ options = STICKINESS_SETTINGS.to_sentence(last_word_connector: ', or ')
+ parameters_error("stickiness parameter must be #{options}")
+ end
+
+ unless group_id.is_a?(String) && group_id.match(/\A[a-z]{1,32}\z/)
+ parameters_error('groupId parameter is invalid')
+ end
+
+ unless within_range?(rollout, 0, 100)
+ parameters_error('rollout must be a string between 0 and 100 inclusive')
+ end
+ end
+
def parameters_error(message)
errors.add(:parameters, message)
false
diff --git a/app/models/packages/event.rb b/app/models/packages/event.rb
new file mode 100644
index 00000000000..730ce267273
--- /dev/null
+++ b/app/models/packages/event.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class Packages::Event < ApplicationRecord
+ belongs_to :package, optional: true
+
+ # FIXME: Remove debian: 9 from here when it's added to the types in package.rb model
+ EVENT_SCOPES = ::Packages::Package.package_types.merge(debian: 9, container: 1000, tag: 1001).freeze
+
+ enum event_scope: EVENT_SCOPES
+
+ enum event_type: {
+ push_package: 0,
+ delete_package: 1,
+ pull_package: 2,
+ search_package: 3,
+ list_package: 4,
+ list_repositories: 5,
+ delete_repository: 6,
+ delete_tag: 7,
+ delete_tag_bulk: 8,
+ list_tags: 9,
+ cli_metadata: 10
+ }
+
+ enum originator_type: { user: 0, deploy_token: 1, guest: 2 }
+end
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index bda11160957..caf2522e3dd 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -26,7 +26,7 @@ class Packages::Package < ApplicationRecord
validates :project, presence: true
validates :name, presence: true
- validates :name, format: { with: Gitlab::Regex.package_name_regex }, unless: :conan?
+ validates :name, format: { with: Gitlab::Regex.package_name_regex }, unless: -> { conan? || generic? }
validates :name,
uniqueness: { scope: %i[project_id version package_type] }, unless: :conan?
@@ -35,17 +35,19 @@ class Packages::Package < ApplicationRecord
validate :valid_npm_package_name, if: :npm?
validate :valid_composer_global_name, if: :composer?
validate :package_already_taken, if: :npm?
- validates :version, format: { with: Gitlab::Regex.semver_regex }, if: -> { npm? || nuget? }
validates :name, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan?
+ validates :name, format: { with: Gitlab::Regex.generic_package_name_regex }, if: :generic?
+ validates :version, format: { with: Gitlab::Regex.semver_regex }, if: -> { npm? || nuget? }
validates :version, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan?
validates :version, format: { with: Gitlab::Regex.maven_version_regex }, if: -> { version? && maven? }
validates :version, format: { with: Gitlab::Regex.pypi_version_regex }, if: :pypi?
+ validates :version, format: { with: Gitlab::Regex.prefixed_semver_regex }, if: :golang?
validates :version,
presence: true,
format: { with: Gitlab::Regex.generic_package_version_regex },
if: :generic?
- enum package_type: { maven: 1, npm: 2, conan: 3, nuget: 4, pypi: 5, composer: 6, generic: 7 }
+ enum package_type: { maven: 1, npm: 2, conan: 3, nuget: 4, pypi: 5, composer: 6, generic: 7, golang: 8 }
scope :with_name, ->(name) { where(name: name) }
scope :with_name_like, ->(name) { where(arel_table[:name].matches(name)) }
@@ -119,6 +121,10 @@ class Packages::Package < ApplicationRecord
.where(packages_package_files: { file_name: file_name, file_sha256: sha256 }).last!
end
+ def self.by_name_and_version!(name, version)
+ find_by!(name: name, version: version)
+ end
+
def self.pluck_names
pluck(:name)
end
diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb
index 78e0f185a11..d8f122cfb23 100644
--- a/app/models/pages_deployment.rb
+++ b/app/models/pages_deployment.rb
@@ -2,10 +2,14 @@
# PagesDeployment stores a zip archive containing GitLab Pages web-site
class PagesDeployment < ApplicationRecord
+ include FileStoreMounter
+
belongs_to :project, optional: false
belongs_to :ci_build, class_name: 'Ci::Build', optional: true
validates :file, presence: true
validates :file_store, presence: true, inclusion: { in: ObjectStorage::SUPPORTED_STORES }
validates :size, presence: true, numericality: { greater_than: 0, only_integer: true }
+
+ mount_file_store_uploader ::Pages::DeploymentUploader
end
diff --git a/app/models/postgresql/replication_slot.rb b/app/models/postgresql/replication_slot.rb
index a4370eda5ba..c96786423e5 100644
--- a/app/models/postgresql/replication_slot.rb
+++ b/app/models/postgresql/replication_slot.rb
@@ -22,8 +22,8 @@ module Postgresql
def self.lag_too_great?(max = 100.megabytes)
return false unless in_use?
- lag_function = "#{Gitlab::Database.pg_wal_lsn_diff}" \
- "(#{Gitlab::Database.pg_current_wal_insert_lsn}(), restart_lsn)::bigint"
+ lag_function = "pg_wal_lsn_diff" \
+ "(pg_current_wal_insert_lsn(), restart_lsn)::bigint"
# We force the use of a transaction here so the query always goes to the
# primary, even when using the EE DB load balancer.
diff --git a/app/models/project.rb b/app/models/project.rb
index 4db0eaa0442..52cad50809f 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -33,6 +33,7 @@ class Project < ApplicationRecord
include FromUnion
include IgnorableColumns
include Integration
+ include EachBatch
extend Gitlab::Cache::RequestCache
extend Gitlab::ConfigHelper
@@ -198,6 +199,7 @@ class Project < ApplicationRecord
has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :export_jobs, class_name: 'ProjectExportJob'
has_one :project_repository, inverse_of: :project
+ has_one :tracing_setting, class_name: 'ProjectTracingSetting'
has_one :incident_management_setting, inverse_of: :project, class_name: 'IncidentManagement::ProjectIncidentManagementSetting'
has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting'
has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting'
@@ -268,6 +270,7 @@ class Project < ApplicationRecord
has_many :metrics_users_starred_dashboards, class_name: 'Metrics::UsersStarredDashboard', inverse_of: :project
has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :project
+ has_many :alert_management_http_integrations, class_name: 'AlertManagement::HttpIntegration', inverse_of: :project
# Container repositories need to remove data from the container registry,
# which is not managed by the DB. Hence we're still using dependent: :destroy
@@ -336,6 +339,8 @@ class Project < ApplicationRecord
has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project
has_many :reviews, inverse_of: :project
+ has_many :terraform_states, class_name: 'Terraform::State', inverse_of: :project
+
# GitLab Pages
has_many :pages_domains
has_one :pages_metadatum, class_name: 'ProjectPagesMetadatum', inverse_of: :project
@@ -432,6 +437,7 @@ class Project < ApplicationRecord
validate :visibility_level_allowed_by_group, if: :should_validate_visibility_level?
validate :visibility_level_allowed_as_fork, if: :should_validate_visibility_level?
validate :validate_pages_https_only, if: -> { changes.has_key?(:pages_https_only) }
+ validate :changing_shared_runners_enabled_is_allowed
validates :repository_storage,
presence: true,
inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } }
@@ -560,6 +566,7 @@ class Project < ApplicationRecord
}
scope :imported_from, -> (type) { where(import_type: type) }
+ scope :with_tracing_enabled, -> { joins(:tracing_setting) }
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
@@ -1186,6 +1193,15 @@ class Project < ApplicationRecord
end
end
+ def changing_shared_runners_enabled_is_allowed
+ return unless Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true)
+ return unless new_record? || changes.has_key?(:shared_runners_enabled)
+
+ if shared_runners_enabled && group && group.shared_runners_setting == 'disabled_and_unoverridable'
+ errors.add(:shared_runners_enabled, _('cannot be enabled because parent group does not allow it'))
+ end
+ end
+
def to_param
if persisted? && errors.include?(:path)
path_was
@@ -2292,6 +2308,10 @@ class Project < ApplicationRecord
[]
end
+ def mark_primary_write_location
+ # Overriden in EE
+ end
+
def toggle_ci_cd_settings!(settings_attribute)
ci_cd_settings.toggle!(settings_attribute)
end
@@ -2501,6 +2521,15 @@ class Project < ApplicationRecord
GroupDeployKey.for_groups(group.self_and_ancestors_ids)
end
+ def feature_flags_client_token
+ instance = operations_feature_flags_client || create_operations_feature_flags_client!
+ instance.token
+ end
+
+ def tracing_external_url
+ tracing_setting&.external_url
+ end
+
private
def find_service(services, name)
@@ -2509,10 +2538,10 @@ class Project < ApplicationRecord
def build_from_instance_or_template(name)
instance = find_service(services_instances, name)
- return Service.build_from_integration(id, instance) if instance
+ return Service.build_from_integration(instance, project_id: id) if instance
template = find_service(services_templates, name)
- return Service.build_from_integration(id, template) if template
+ return Service.build_from_integration(template, project_id: id) if template
end
def services_templates
diff --git a/app/models/project_services/drone_ci_service.rb b/app/models/project_services/drone_ci_service.rb
index 4e4955b45d8..5a49f780d46 100644
--- a/app/models/project_services/drone_ci_service.rb
+++ b/app/models/project_services/drone_ci_service.rb
@@ -42,7 +42,7 @@ class DroneCiService < CiService
def commit_status_path(sha, ref)
Gitlab::Utils.append_path(
drone_url,
- "gitlab/#{project.full_path}/commits/#{sha}?branch=#{URI.encode(ref.to_s)}&access_token=#{token}")
+ "gitlab/#{project.full_path}/commits/#{sha}?branch=#{Addressable::URI.encode_component(ref.to_s)}&access_token=#{token}")
end
def commit_status(sha, ref)
@@ -75,7 +75,7 @@ class DroneCiService < CiService
def build_page(sha, ref)
Gitlab::Utils.append_path(
drone_url,
- "gitlab/#{project.full_path}/redirect/commits/#{sha}?branch=#{URI.encode(ref.to_s)}")
+ "gitlab/#{project.full_path}/redirect/commits/#{sha}?branch=#{Addressable::URI.encode_component(ref.to_s)}")
end
def title
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index 67ab2c0ce8a..0d2f89fb18d 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -2,6 +2,7 @@
class ProjectStatistics < ApplicationRecord
include AfterCommitQueue
+ include CounterAttribute
belongs_to :project
belongs_to :namespace
@@ -9,6 +10,13 @@ class ProjectStatistics < ApplicationRecord
default_value_for :wiki_size, 0
default_value_for :snippets_size, 0
+ counter_attribute :build_artifacts_size
+ counter_attribute :storage_size
+
+ counter_attribute_after_flush do |project_statistic|
+ Namespaces::ScheduleAggregationWorker.perform_async(project_statistic.namespace_id)
+ end
+
before_save :update_storage_size
COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count, :snippets_size].freeze
@@ -29,6 +37,8 @@ class ProjectStatistics < ApplicationRecord
end
def refresh!(only: [])
+ return if Gitlab::Database.read_only?
+
COLUMNS_TO_REFRESH.each do |column, generator|
if only.empty? || only.include?(column)
public_send("update_#{column}") # rubocop:disable GitlabSecurity/PublicSend
@@ -96,12 +106,27 @@ class ProjectStatistics < ApplicationRecord
# Additional columns are updated depending on key => [columns], which allows
# to update statistics which are and also those which aren't included in storage_size
# or any other additional summary column in the future.
- def self.increment_statistic(project_id, key, amount)
+ def self.increment_statistic(project, key, amount)
raise ArgumentError, "Cannot increment attribute: #{key}" unless INCREMENTABLE_COLUMNS.key?(key)
return if amount == 0
- where(project_id: project_id)
- .columns_to_increment(key, amount)
+ project.statistics.try do |project_statistics|
+ if project_statistics.counter_attribute_enabled?(key)
+ statistics_to_increment = [key] + INCREMENTABLE_COLUMNS[key].to_a
+ statistics_to_increment.each do |statistic|
+ project_statistics.delayed_increment_counter(statistic, amount)
+ end
+ else
+ legacy_increment_statistic(project, key, amount)
+ end
+ end
+ end
+
+ def self.legacy_increment_statistic(project, key, amount)
+ where(project_id: project.id).columns_to_increment(key, amount)
+
+ Namespaces::ScheduleAggregationWorker.perform_async( # rubocop: disable CodeReuse/Worker
+ project.namespace_id)
end
def self.columns_to_increment(key, amount)
diff --git a/app/models/project_tracing_setting.rb b/app/models/project_tracing_setting.rb
new file mode 100644
index 00000000000..93fa80aed67
--- /dev/null
+++ b/app/models/project_tracing_setting.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class ProjectTracingSetting < ApplicationRecord
+ belongs_to :project
+
+ validates :external_url, length: { maximum: 255 }, public_url: true
+
+ before_validation :sanitize_external_url
+
+ private
+
+ def sanitize_external_url
+ self.external_url = Rails::Html::FullSanitizer.new.sanitize(self.external_url)
+ end
+end
diff --git a/app/models/prometheus_alert.rb b/app/models/prometheus_alert.rb
index f0441d4a3cb..684f50d5f58 100644
--- a/app/models/prometheus_alert.rb
+++ b/app/models/prometheus_alert.rb
@@ -4,6 +4,7 @@ class PrometheusAlert < ApplicationRecord
include Sortable
include UsageStatistics
include Presentable
+ include EachBatch
OPERATORS_MAP = {
lt: "<",
@@ -35,6 +36,7 @@ class PrometheusAlert < ApplicationRecord
scope :for_metric, -> (metric) { where(prometheus_metric: metric) }
scope :for_project, -> (project) { where(project_id: project) }
scope :for_environment, -> (environment) { where(environment_id: environment) }
+ scope :get_environment_id, -> { select(:environment_id).pluck(:environment_id) }
def self.distinct_projects
sub_query = self.group(:project_id).select(1)
diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb
index 9ddf66cd388..590eda62c11 100644
--- a/app/models/prometheus_metric.rb
+++ b/app/models/prometheus_metric.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class PrometheusMetric < ApplicationRecord
+ include EachBatch
+
belongs_to :project, validate: true, inverse_of: :prometheus_metrics
has_many :prometheus_alerts, inverse_of: :prometheus_metric
diff --git a/app/models/repository.rb b/app/models/repository.rb
index ef17e010ba8..6bc2ec24811 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -853,7 +853,7 @@ class Repository
def merge(user, source_sha, merge_request, message)
with_cache_hooks do
raw_repository.merge(user, source_sha, merge_request.target_branch, message) do |commit_id|
- merge_request.update(in_progress_merge_commit_sha: commit_id)
+ merge_request.update_and_mark_in_progress_merge_commit_sha(commit_id)
nil # Return value does not matter.
end
end
@@ -873,7 +873,7 @@ class Repository
their_commit_id = commit(source)&.id
raise 'Invalid merge source' if their_commit_id.nil?
- merge_request&.update(in_progress_merge_commit_sha: their_commit_id)
+ merge_request&.update_and_mark_in_progress_merge_commit_sha(their_commit_id)
with_cache_hooks { raw.ff_merge(user, their_commit_id, target_branch) }
end
diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb
index cc96698be09..18e2944a9ca 100644
--- a/app/models/resource_label_event.rb
+++ b/app/models/resource_label_event.rb
@@ -15,6 +15,7 @@ class ResourceLabelEvent < ResourceEvent
validate :exactly_one_issuable
after_save :expire_etag_cache
+ after_save :usage_metrics
after_destroy :expire_etag_cache
enum action: {
@@ -113,6 +114,16 @@ class ResourceLabelEvent < ResourceEvent
def discussion_id_key
[self.class.name, created_at, user_id]
end
+
+ def for_issue?
+ issue_id.present?
+ end
+
+ def usage_metrics
+ return unless for_issue?
+
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_label_changed_action(author: user)
+ end
end
ResourceLabelEvent.prepend_if_ee('EE::ResourceLabelEvent')
diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb
index 1ce4e14d289..6475633868a 100644
--- a/app/models/resource_state_event.rb
+++ b/app/models/resource_state_event.rb
@@ -11,6 +11,8 @@ class ResourceStateEvent < ResourceEvent
# state is used for issue and merge request states.
enum state: Issue.available_states.merge(MergeRequest.available_states).merge(reopened: 5)
+ after_save :usage_metrics
+
def self.issuable_attrs
%i(issue merge_request).freeze
end
@@ -18,6 +20,29 @@ class ResourceStateEvent < ResourceEvent
def issuable
issue || merge_request
end
+
+ def for_issue?
+ issue_id.present?
+ end
+
+ private
+
+ def usage_metrics
+ return unless for_issue?
+
+ case state
+ when 'closed'
+ issue_usage_counter.track_issue_closed_action(author: user)
+ when 'reopened'
+ issue_usage_counter.track_issue_reopened_action(author: user)
+ else
+ # no-op, nothing to do, not a state we're tracking
+ end
+ end
+
+ def issue_usage_counter
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter
+ end
end
ResourceStateEvent.prepend_if_ee('EE::ResourceStateEvent')
diff --git a/app/models/resource_timebox_event.rb b/app/models/resource_timebox_event.rb
index 44f48915425..dbb2b428c7b 100644
--- a/app/models/resource_timebox_event.rb
+++ b/app/models/resource_timebox_event.rb
@@ -13,6 +13,8 @@ class ResourceTimeboxEvent < ResourceEvent
remove: 2
}
+ after_save :usage_metrics
+
def self.issuable_attrs
%i(issue merge_request).freeze
end
@@ -20,4 +22,17 @@ class ResourceTimeboxEvent < ResourceEvent
def issuable
issue || merge_request
end
+
+ private
+
+ def usage_metrics
+ case self
+ when ResourceMilestoneEvent
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_milestone_changed_action(author: user)
+ when ResourceIterationEvent
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_iteration_changed_action(author: user)
+ else
+ # no-op
+ end
+ end
end
diff --git a/app/models/resource_weight_event.rb b/app/models/resource_weight_event.rb
index bbabd54325e..a261f3a1bd7 100644
--- a/app/models/resource_weight_event.rb
+++ b/app/models/resource_weight_event.rb
@@ -1,7 +1,15 @@
# frozen_string_literal: true
class ResourceWeightEvent < ResourceEvent
+ include IssueResourceEvent
+
validates :issue, presence: true
- include IssueResourceEvent
+ after_save :usage_metrics
+
+ private
+
+ def usage_metrics
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_weight_changed_action(author: user)
+ end
end
diff --git a/app/models/service.rb b/app/models/service.rb
index e63e06bf46f..48fa62d3d46 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -7,9 +7,7 @@ class Service < ApplicationRecord
include Importable
include ProjectServicesLoggable
include DataFields
- include IgnorableColumns
-
- ignore_columns %i[default], remove_with: '13.5', remove_after: '2020-10-22'
+ include FromUnion
SERVICE_NAMES = %w[
alerts asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker discord
@@ -65,6 +63,7 @@ class Service < ApplicationRecord
scope :active, -> { where(active: true) }
scope :by_type, -> (type) { where(type: type) }
scope :by_active_flag, -> (flag) { where(active: flag) }
+ scope :inherit_from_id, -> (id) { where(inherit_from_id: id) }
scope :for_group, -> (group) { where(group_id: group, type: available_services_types) }
scope :for_template, -> { where(template: true, type: available_services_types) }
scope :for_instance, -> { where(instance: true, type: available_services_types) }
@@ -217,7 +216,7 @@ class Service < ApplicationRecord
services_names.map { |service_name| "#{service_name}_service".camelize }
end
- def self.build_from_integration(project_id, integration)
+ def self.build_from_integration(integration, project_id: nil, group_id: nil)
service = integration.dup
if integration.supports_data_fields?
@@ -227,8 +226,9 @@ class Service < ApplicationRecord
service.template = false
service.instance = false
- service.inherit_from_id = integration.id if integration.instance?
service.project_id = project_id
+ service.group_id = group_id
+ service.inherit_from_id = integration.id if integration.instance? || integration.group
service.active = false if service.invalid?
service
end
@@ -256,6 +256,19 @@ class Service < ApplicationRecord
end
private_class_method :instance_level_integration
+ def self.create_from_active_default_integrations(scope, association, with_templates: false)
+ group_ids = scope.ancestors.select(:id)
+ array = group_ids.to_sql.present? ? "array(#{group_ids.to_sql})" : 'ARRAY[]'
+
+ from_union([
+ with_templates ? active.where(template: true) : none,
+ active.where(instance: true),
+ active.where(group_id: group_ids)
+ ]).order(Arel.sql("type ASC, array_position(#{array}::bigint[], services.group_id), instance DESC")).group_by(&:type).each do |type, records|
+ build_from_integration(records.first, association => scope.id).save!
+ end
+ end
+
def activated?
active
end
diff --git a/app/models/service_list.rb b/app/models/service_list.rb
index 9cbc5e68059..5eca5f2bda1 100644
--- a/app/models/service_list.rb
+++ b/app/models/service_list.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
class ServiceList
- def initialize(batch_ids, service_hash, association)
- @batch_ids = batch_ids
+ def initialize(batch, service_hash, association)
+ @batch = batch
@service_hash = service_hash
@association = association
end
@@ -13,15 +13,15 @@ class ServiceList
private
- attr_reader :batch_ids, :service_hash, :association
+ attr_reader :batch, :service_hash, :association
def columns
- (service_hash.keys << "#{association}_id")
+ service_hash.keys << "#{association}_id"
end
def values
- batch_ids.map do |id|
- (service_hash.values << id)
+ batch.select(:id).map do |record|
+ service_hash.values << record.id
end
end
end
diff --git a/app/models/snippet_input_action_collection.rb b/app/models/snippet_input_action_collection.rb
index 38313e3a980..1e886e98083 100644
--- a/app/models/snippet_input_action_collection.rb
+++ b/app/models/snippet_input_action_collection.rb
@@ -8,7 +8,11 @@ class SnippetInputActionCollection
delegate :empty?, :any?, :[], to: :actions
def initialize(actions = [], allowed_actions: nil)
- @actions = actions.map { |action| SnippetInputAction.new(action.merge(allowed_actions: allowed_actions)) }
+ @actions = actions.map do |action|
+ params = action.merge(allowed_actions: allowed_actions)
+
+ SnippetInputAction.new(**params)
+ end
end
def to_commit_actions
diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb
index 2cfb201191d..fa25a6f8441 100644
--- a/app/models/snippet_repository.rb
+++ b/app/models/snippet_repository.rb
@@ -12,7 +12,7 @@ class SnippetRepository < ApplicationRecord
belongs_to :snippet, inverse_of: :snippet_repository
- delegate :repository, to: :snippet
+ delegate :repository, :repository_storage, to: :snippet
class << self
def find_snippet(disk_path)
diff --git a/app/models/snippet_statistics.rb b/app/models/snippet_statistics.rb
index 8545296d076..6fb6f0ef713 100644
--- a/app/models/snippet_statistics.rb
+++ b/app/models/snippet_statistics.rb
@@ -34,6 +34,8 @@ class SnippetStatistics < ApplicationRecord
end
def refresh!
+ return if Gitlab::Database.read_only?
+
update_commit_count
update_repository_size
update_file_count
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index 961212d0295..0ddf2c5fbcd 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -18,9 +18,9 @@ class SystemNoteMetadata < ApplicationRecord
commit description merge confidential visible label assignee cross_reference
designs_added designs_modified designs_removed designs_discussion_added
title time_tracking branch milestone discussion task moved
- opened closed merged duplicate locked unlocked outdated
+ opened closed merged duplicate locked unlocked outdated reviewer
tag due_date pinned_embed cherry_pick health_status approved unapproved
- status alert_issue_added relate unrelate new_alert_added
+ status alert_issue_added relate unrelate new_alert_added severity
].freeze
validates :note, presence: true
diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb
index 419fffcb666..2ff2e3d66c0 100644
--- a/app/models/terraform/state.rb
+++ b/app/models/terraform/state.rb
@@ -4,6 +4,12 @@ module Terraform
class State < ApplicationRecord
include UsageStatistics
include FileStoreMounter
+ include IgnorableColumns
+ # These columns are being removed since geo replication falls to the versioned state
+ # Tracking in https://gitlab.com/gitlab-org/gitlab/-/issues/258262
+ ignore_columns %i[verification_failure verification_retry_at verified_at verification_retry_count verification_checksum],
+ remove_with: '13.7',
+ remove_after: '2020-12-22'
HEX_REGEXP = %r{\A\h+\z}.freeze
UUID_LENGTH = 32
@@ -15,6 +21,7 @@ module Terraform
has_one :latest_version, -> { ordered_by_version_desc }, class_name: 'Terraform::StateVersion', foreign_key: :terraform_state_id
scope :versioning_not_enabled, -> { where(versioning_enabled: false) }
+ scope :ordered_by_name, -> { order(:name) }
validates :project_id, presence: true
validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH },
@@ -33,10 +40,6 @@ module Terraform
versioning_enabled ? latest_version&.file : file
end
- def local?
- file_store == ObjectStorage::Store::LOCAL
- end
-
def locked?
self.lock_xid.present?
end
@@ -53,5 +56,3 @@ module Terraform
end
end
end
-
-Terraform::State.prepend_if_ee('EE::Terraform::State')
diff --git a/app/models/terraform/state_version.rb b/app/models/terraform/state_version.rb
index d5e315d18a1..eff44485401 100644
--- a/app/models/terraform/state_version.rb
+++ b/app/models/terraform/state_version.rb
@@ -14,5 +14,11 @@ module Terraform
mount_file_store_uploader VersionedStateUploader
delegate :project_id, :uuid, to: :terraform_state, allow_nil: true
+
+ def local?
+ file_store == ObjectStorage::Store::LOCAL
+ end
end
end
+
+Terraform::StateVersion.prepend_if_ee('EE::Terraform::StateVersion')
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 6c8e085762d..0d893b25253 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -227,7 +227,7 @@ class Todo < ApplicationRecord
end
def self_assigned?
- assigned? && self_added?
+ self_added? && (assigned? || review_requested?)
end
private
diff --git a/app/models/user.rb b/app/models/user.rb
index 0a784b30d8f..5bbdb2b2e9a 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -64,14 +64,7 @@ class User < ApplicationRecord
# and should be added after Devise modules are initialized.
include AsyncDeviseEmail
- BLOCKED_MESSAGE = "Your account has been blocked. Please contact your GitLab " \
- "administrator if you think this is an error."
- LOGIN_FORBIDDEN = "Your account does not have the required permission to login. Please contact your GitLab " \
- "administrator if you think this is an error."
-
- MINIMUM_INACTIVE_DAYS = 180
-
- ignore_column :bio, remove_with: '13.4', remove_after: '2020-09-22'
+ MINIMUM_INACTIVE_DAYS = 90
# Override Devise::Models::Trackable#update_tracked_fields!
# to limit database writes to at most once every hour
@@ -134,6 +127,8 @@ class User < ApplicationRecord
-> { where(members: { access_level: [Gitlab::Access::REPORTER, Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) },
through: :group_members,
source: :group
+ has_many :minimal_access_group_members, -> { where(access_level: [Gitlab::Access::MINIMAL_ACCESS]) }, source: 'GroupMember', class_name: 'GroupMember'
+ has_many :minimal_access_groups, through: :minimal_access_group_members, source: :group
# Projects
has_many :groups_projects, through: :groups, source: :projects
@@ -381,11 +376,12 @@ class User < ApplicationRecord
super && can?(:log_in)
end
+ # The messages for these keys are defined in `devise.en.yml`
def inactive_message
if blocked?
- BLOCKED_MESSAGE
+ :blocked
elsif internal?
- LOGIN_FORBIDDEN
+ :forbidden
else
super
end
@@ -1676,6 +1672,8 @@ class User < ApplicationRecord
end
def terms_accepted?
+ return true if project_bot?
+
accepted_term_id.present?
end
diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb
index 0ba319aa444..e39ff8712fc 100644
--- a/app/models/user_callout.rb
+++ b/app/models/user_callout.rb
@@ -19,7 +19,7 @@ class UserCallout < ApplicationRecord
webhooks_moved: 13,
service_templates_deprecated: 14,
admin_integrations_moved: 15,
- web_ide_alert_dismissed: 16,
+ web_ide_alert_dismissed: 16, # no longer in use
active_user_count_threshold: 18, # EE-only
buy_pipeline_minutes_notification_dot: 19, # EE-only
personal_access_token_expiry: 21, # EE-only
diff --git a/app/models/user_interacted_project.rb b/app/models/user_interacted_project.rb
index 1c615777018..7e7a387d3d4 100644
--- a/app/models/user_interacted_project.rb
+++ b/app/models/user_interacted_project.rb
@@ -21,7 +21,7 @@ class UserInteractedProject < ApplicationRecord
user_id: event.author_id
}
- cached_exists?(attributes) do
+ cached_exists?(**attributes) do
transaction(requires_new: true) do
where(attributes).select(1).first || create!(attributes)
true # not caching the whole record here for now
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index d3b3a46bf74..c05bc80415a 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -8,6 +8,9 @@ class UserPreference < ApplicationRecord
belongs_to :user
+ scope :with_user, -> { joins(:user) }
+ scope :gitpod_enabled, -> { where(gitpod_enabled: true) }
+
validates :issue_notes_filter, :merge_request_notes_filter, inclusion: { in: NOTES_FILTERS.values }, presence: true
validates :tab_width, numericality: {
only_integer: true,
diff --git a/app/models/wiki.rb b/app/models/wiki.rb
index 9462f7401c4..9114de0e965 100644
--- a/app/models/wiki.rb
+++ b/app/models/wiki.rb
@@ -103,10 +103,10 @@ class Wiki
limited = pages.size > limit
pages = pages.first(limit) if limited
- [WikiPage.group_by_directory(pages), limited]
+ [WikiDirectory.group_pages(pages), limited]
end
- # Finds a page within the repository based on a tile
+ # Finds a page within the repository based on a title
# or slug.
#
# title - The human readable or parameterized title of
diff --git a/app/models/wiki_directory.rb b/app/models/wiki_directory.rb
index df2fe25b08b..3a2613e15d9 100644
--- a/app/models/wiki_directory.rb
+++ b/app/models/wiki_directory.rb
@@ -3,13 +3,46 @@
class WikiDirectory
include ActiveModel::Validations
- attr_accessor :slug, :pages
+ attr_accessor :slug, :entries
validates :slug, presence: true
- def initialize(slug, pages = [])
+ # Groups a list of wiki pages into a nested collection of WikiPage and WikiDirectory objects,
+ # preserving the order of the passed pages.
+ #
+ # Returns an array with all entries for the toplevel directory.
+ #
+ # @param [Array<WikiPage>] pages
+ # @return [Array<WikiPage, WikiDirectory>]
+ #
+ def self.group_pages(pages)
+ # Build a hash to map paths to created WikiDirectory objects,
+ # and recursively create them for each level of the path.
+ # For the toplevel directory we use '' as path, as that's what WikiPage#directory returns.
+ directories = Hash.new do |_, path|
+ directories[path] = new(path).tap do |directory|
+ if path.present?
+ parent = File.dirname(path)
+ parent = '' if parent == '.'
+ directories[parent].entries << directory
+ end
+ end
+ end
+
+ pages.each do |page|
+ directories[page.directory].entries << page
+ end
+
+ directories[''].entries
+ end
+
+ def initialize(slug, entries = [])
@slug = slug
- @pages = pages
+ @entries = entries
+ end
+
+ def title
+ WikiPage.unhyphenize(File.basename(slug))
end
# Relative path to the partial to be used when rendering collections
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index faf3d19d936..989128987d5 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -31,29 +31,6 @@ class WikiPage
alias_method :==, :eql?
- # Sorts and groups pages by directory.
- #
- # pages - an array of WikiPage objects.
- #
- # Returns an array of WikiPage and WikiDirectory objects. The entries are
- # sorted by alphabetical order (directories and pages inside each directory).
- # Pages at the root level come before everything.
- def self.group_by_directory(pages)
- return [] if pages.blank?
-
- pages.each_with_object([]) do |page, grouped_pages|
- next grouped_pages << page unless page.directory.present?
-
- directory = grouped_pages.find do |obj|
- obj.is_a?(WikiDirectory) && obj.slug == page.directory
- end
-
- next directory.pages << page if directory
-
- grouped_pages << WikiDirectory.new(page.directory, [page])
- end
- end
-
def self.unhyphenize(name)
name.gsub(/-+/, ' ')
end
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 13d732e4edd..1c93073025d 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -27,10 +27,7 @@ class BasePolicy < DeclarativePolicy::Base
desc "User email is unconfirmed or user account is locked"
with_options scope: :user, score: 0
- condition(:inactive) do
- Feature.enabled?(:inactive_policy_condition, default_enabled: true) &&
- @user&.confirmation_required_on_sign_in? || @user&.access_locked?
- end
+ condition(:inactive) { @user&.confirmation_required_on_sign_in? || @user&.access_locked? }
with_options scope: :user, score: 0
condition(:external_user) { @user.nil? || @user.external? }
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index c98e82efef7..f9ec026a6d2 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -46,6 +46,19 @@ class GroupPolicy < BasePolicy
group_projects_for(user: @user, group: @subject, only_owned: false).any? { |p| p.design_management_enabled? }
end
+ desc "Deploy token with read_package_registry scope"
+ condition(:read_package_registry_deploy_token) do
+ @user.is_a?(DeployToken) && @user.groups.include?(@subject) && @user.read_package_registry
+ end
+
+ desc "Deploy token with write_package_registry scope"
+ condition(:write_package_registry_deploy_token) do
+ @user.is_a?(DeployToken) && @user.groups.include?(@subject) && @user.write_package_registry
+ end
+
+ with_scope :subject
+ condition(:resource_access_token_available) { resource_access_token_available? }
+
rule { design_management_enabled }.policy do
enable :read_design_activity
end
@@ -91,7 +104,6 @@ class GroupPolicy < BasePolicy
rule { developer }.policy do
enable :admin_milestone
- enable :read_package
enable :create_metrics_dashboard_annotation
enable :delete_metrics_dashboard_annotation
enable :update_metrics_dashboard_annotation
@@ -105,6 +117,7 @@ class GroupPolicy < BasePolicy
enable :admin_issue
enable :read_metrics_dashboard_annotation
enable :read_prometheus
+ enable :read_package
end
rule { maintainer }.policy do
@@ -167,6 +180,20 @@ class GroupPolicy < BasePolicy
rule { maintainer & can?(:create_projects) }.enable :transfer_projects
+ rule { read_package_registry_deploy_token }.policy do
+ enable :read_package
+ enable :read_group
+ end
+
+ rule { write_package_registry_deploy_token }.policy do
+ enable :create_package
+ enable :read_group
+ end
+
+ rule { resource_access_token_available & can?(:admin_group) }.policy do
+ enable :admin_resource_access_tokens
+ end
+
def access_level
return GroupMember::NO_ACCESS if @user.nil?
return GroupMember::NO_ACCESS unless user_is_user?
@@ -183,6 +210,14 @@ class GroupPolicy < BasePolicy
def user_is_user?
user.is_a?(User)
end
+
+ def group
+ @subject
+ end
+
+ def resource_access_token_available?
+ true
+ end
end
GroupPolicy.prepend_if_ee('EE::GroupPolicy')
diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb
index b02bb8621ed..44c448eb601 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -15,9 +15,6 @@ class IssuePolicy < IssuablePolicy
desc "Issue is confidential"
condition(:confidential, scope: :subject) { @subject.confidential? }
- desc "Issue has moved"
- condition(:moved) { @subject.moved? }
-
rule { confidential & ~can_read_confidential }.policy do
prevent(*create_read_update_admin_destroy(:issue))
prevent :read_issue_iid
@@ -38,12 +35,6 @@ class IssuePolicy < IssuablePolicy
rule { ~can?(:read_design) }.policy do
prevent :move_design
end
-
- rule { locked | moved }.policy do
- prevent :create_design
- prevent :move_design
- prevent :destroy_design
- end
end
IssuePolicy.prepend_if_ee('EE::IssuePolicy')
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 87ee7d201e4..59e2d617bf7 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -104,6 +104,9 @@ class ProjectPolicy < BasePolicy
with_scope :subject
condition(:service_desk_enabled) { @subject.service_desk_enabled? }
+ with_scope :subject
+ condition(:resource_access_token_available) { resource_access_token_available? }
+
# 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
@@ -237,7 +240,6 @@ class ProjectPolicy < BasePolicy
enable :read_merge_request
enable :read_sentry_issue
enable :update_sentry_issue
- enable :read_incidents
enable :read_prometheus
enable :read_metrics_dashboard_annotation
enable :metrics_dashboard
@@ -589,6 +591,10 @@ class ProjectPolicy < BasePolicy
prevent :read_project
end
+ rule { resource_access_token_available & can?(:admin_project) }.policy do
+ enable :admin_resource_access_tokens
+ end
+
private
def user_is_user?
@@ -663,6 +669,10 @@ class ProjectPolicy < BasePolicy
end
end
+ def resource_access_token_available?
+ true
+ end
+
def project
@subject
end
diff --git a/app/policies/releases/evidence_policy.rb b/app/policies/releases/evidence_policy.rb
index 701913e6fe4..3e35f2f5e87 100644
--- a/app/policies/releases/evidence_policy.rb
+++ b/app/policies/releases/evidence_policy.rb
@@ -15,6 +15,7 @@ module Releases
# - Project
# - Milestones
# - Issues
+ # TODO: remove issues from this check: https://gitlab.com/gitlab-org/gitlab/-/issues/259674
condition(:allowed_to_read_evidence) do
can?(:read_release) &&
can?(:download_code) &&
diff --git a/app/policies/terraform/state_policy.rb b/app/policies/terraform/state_policy.rb
new file mode 100644
index 00000000000..ba6109e5975
--- /dev/null
+++ b/app/policies/terraform/state_policy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Terraform
+ class StatePolicy < BasePolicy
+ alias_method :terraform_state, :subject
+
+ delegate { terraform_state.project }
+ end
+end
diff --git a/app/presenters/alert_management/alert_presenter.rb b/app/presenters/alert_management/alert_presenter.rb
index 5debe6d5dbd..4bfa3dc9a13 100644
--- a/app/presenters/alert_management/alert_presenter.rb
+++ b/app/presenters/alert_management/alert_presenter.rb
@@ -8,10 +8,11 @@ module AlertManagement
MARKDOWN_LINE_BREAK = " \n"
HORIZONTAL_LINE = "\n\n---\n\n"
+ INCIDENT_LABEL_NAME = ::IncidentManagement::CreateIncidentLabelService::LABEL_PROPERTIES[:title]
delegate :metrics_dashboard_url, :runbook, to: :parsed_payload
- def initialize(alert, _attributes = {})
+ def initialize(alert, **attributes)
super
@alert = alert
@@ -38,6 +39,30 @@ module AlertManagement
Gitlab::Utils::InlineHash.merge_keys(payload)
end
+ def show_incident_issues_link?
+ project.incident_management_setting&.create_issue?
+ end
+
+ def show_performance_dashboard_link?
+ prometheus_alert.present?
+ end
+
+ def incident_issues_link
+ project_issues_url(project, label_name: INCIDENT_LABEL_NAME)
+ end
+
+ def performance_dashboard_link
+ if environment
+ metrics_project_environment_url(project, environment)
+ else
+ metrics_project_environments_url(project)
+ end
+ end
+
+ def email_title
+ [environment&.name, query_title].compact.join(': ')
+ end
+
private
attr_reader :alert, :project
@@ -80,5 +105,11 @@ module AlertManagement
def host_links
hosts.join(' ')
end
+
+ def query_title
+ return title unless prometheus_alert
+
+ "#{prometheus_alert.title} #{prometheus_alert.computed_operator} #{prometheus_alert.threshold} for 5 minutes"
+ end
end
end
diff --git a/app/presenters/event_presenter.rb b/app/presenters/event_presenter.rb
index 8f2388c2c31..c37721f7213 100644
--- a/app/presenters/event_presenter.rb
+++ b/app/presenters/event_presenter.rb
@@ -29,4 +29,26 @@ class EventPresenter < Gitlab::View::Presenter::Delegated
''
end
end
+
+ def target_type_name
+ if design?
+ 'Design'
+ elsif wiki_page?
+ 'Wiki Page'
+ elsif target_type.present?
+ target_type.titleize
+ else
+ "Project"
+ end.downcase
+ end
+
+ def note_target_type_name
+ return unless note?
+
+ if design_note?
+ 'Design'
+ else
+ target.noteable_type.titleize
+ end.downcase
+ end
end
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index 1ff02412994..a22138011ae 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -37,7 +37,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
def remove_wip_path
- if work_in_progress? && can?(current_user, :update_merge_request, merge_request.project)
+ if can?(current_user, :update_merge_request, merge_request.project)
remove_wip_project_merge_request_path(project, merge_request)
end
end
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index ef75c160b2d..0be8a4d5472 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -118,10 +118,6 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
add_special_file_path(file_name: ci_config_path_or_default)
end
- def add_ci_yml_ide_path
- ide_edit_path(project, default_branch_or_master, ci_config_path_or_default)
- end
-
def add_readme_path
add_special_file_path(file_name: 'README.md')
end
@@ -330,7 +326,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
if cicd_missing?
AnchorData.new(false,
statistic_icon + _('Set up CI/CD'),
- add_ci_yml_ide_path)
+ add_ci_yml_path)
elsif repository.gitlab_ci_yml.present?
AnchorData.new(false,
statistic_icon('doc-text') + _('CI/CD configuration'),
diff --git a/app/presenters/projects/prometheus/alert_presenter.rb b/app/presenters/projects/prometheus/alert_presenter.rb
deleted file mode 100644
index 783b2b2b1e0..00000000000
--- a/app/presenters/projects/prometheus/alert_presenter.rb
+++ /dev/null
@@ -1,179 +0,0 @@
-# frozen_string_literal: true
-
-module Projects
- module Prometheus
- class AlertPresenter < Gitlab::View::Presenter::Delegated
- GENERIC_ALERT_SUMMARY_ANNOTATIONS = %w(monitoring_tool service hosts).freeze
- MARKDOWN_LINE_BREAK = " \n".freeze
- INCIDENT_LABEL_NAME = ::IncidentManagement::CreateIncidentLabelService::LABEL_PROPERTIES[:title].freeze
- METRIC_TIME_WINDOW = 30.minutes
-
- def full_title
- [environment_name, alert_title].compact.join(': ')
- end
-
- def project_full_path
- project.full_path
- end
-
- def metric_query
- gitlab_alert&.full_query
- end
-
- def environment_name
- environment&.name
- end
-
- def performance_dashboard_link
- if environment
- metrics_project_environment_url(project, environment)
- else
- metrics_project_environments_url(project)
- end
- end
-
- def show_performance_dashboard_link?
- gitlab_alert.present?
- end
-
- def show_incident_issues_link?
- project.incident_management_setting&.create_issue?
- end
-
- def incident_issues_link
- project_issues_url(project, label_name: INCIDENT_LABEL_NAME)
- end
-
- def start_time
- starts_at&.strftime('%d %B %Y, %-l:%M%p (%Z)')
- end
-
- def issue_summary_markdown
- <<~MARKDOWN.chomp
- #{metadata_list}
- #{metric_embed_for_alert}
- MARKDOWN
- end
-
- def metric_embed_for_alert
- "\n[](#{metrics_dashboard_url})" if metrics_dashboard_url
- end
-
- def metrics_dashboard_url
- strong_memoize(:metrics_dashboard_url) do
- embed_url_for_gitlab_alert || embed_url_for_self_managed_alert
- end
- end
-
- def details_url
- return unless am_alert
-
- ::Gitlab::Routing.url_helpers.details_project_alert_management_url(
- project,
- am_alert.iid
- )
- end
-
- private
-
- def alert_title
- query_title || title
- end
-
- def query_title
- return unless gitlab_alert
-
- "#{gitlab_alert.title} #{gitlab_alert.computed_operator} #{gitlab_alert.threshold} for 5 minutes"
- end
-
- def metadata_list
- metadata = []
-
- metadata << list_item('Start time', start_time) if start_time
- metadata << list_item('full_query', backtick(full_query)) if full_query
- metadata << list_item(service.label.humanize, service.value) if service
- metadata << list_item(monitoring_tool.label.humanize, monitoring_tool.value) if monitoring_tool
- metadata << list_item(hosts.label.humanize, host_links) if hosts
- metadata << list_item('GitLab alert', details_url) if details_url
-
- metadata.join(MARKDOWN_LINE_BREAK)
- end
-
- def details
- Gitlab::Utils::InlineHash.merge_keys(payload)
- end
-
- def list_item(key, value)
- "**#{key}:** #{value}".strip
- end
-
- def backtick(value)
- "`#{value}`"
- end
-
- GENERIC_ALERT_SUMMARY_ANNOTATIONS.each do |annotation_name|
- define_method(annotation_name) do
- annotations.find { |a| a.label == annotation_name }
- end
- end
-
- def host_links
- Array(hosts.value).join(' ')
- end
-
- def embed_url_for_gitlab_alert
- return unless gitlab_alert
-
- metrics_dashboard_project_prometheus_alert_url(
- project,
- gitlab_alert.prometheus_metric_id,
- environment_id: environment.id,
- embedded: true,
- **alert_embed_window_params(embed_time)
- )
- end
-
- def embed_url_for_self_managed_alert
- return unless environment && full_query && title
-
- metrics_dashboard_project_environment_url(
- project,
- environment,
- embed_json: dashboard_for_self_managed_alert.to_json,
- embedded: true,
- **alert_embed_window_params(embed_time)
- )
- end
-
- def embed_time
- starts_at || Time.current
- end
-
- def alert_embed_window_params(time)
- {
- start: format_embed_timestamp(time - METRIC_TIME_WINDOW),
- end: format_embed_timestamp(time + METRIC_TIME_WINDOW)
- }
- end
-
- def format_embed_timestamp(time)
- time.utc.strftime('%FT%TZ')
- end
-
- def dashboard_for_self_managed_alert
- {
- panel_groups: [{
- panels: [{
- type: 'area-chart',
- title: title,
- y_label: y_label,
- metrics: [{
- query_range: full_query
- }]
- }]
- }]
- }
- end
- end
- end
-end
diff --git a/app/presenters/sentry_error_presenter.rb b/app/presenters/sentry_error_presenter.rb
index ba724b0f8be..669bcb68b7c 100644
--- a/app/presenters/sentry_error_presenter.rb
+++ b/app/presenters/sentry_error_presenter.rb
@@ -14,7 +14,7 @@ class SentryErrorPresenter < Gitlab::View::Presenter::Delegated
end
def project_id
- Gitlab::GlobalId.build(model_name: 'Project', id: error.project_id).to_s
+ Gitlab::GlobalId.build(model_name: 'SentryProject', id: error.project_id).to_s
end
def frequency
diff --git a/app/presenters/snippet_presenter.rb b/app/presenters/snippet_presenter.rb
index d814c4404b6..695aa266e2c 100644
--- a/app/presenters/snippet_presenter.rb
+++ b/app/presenters/snippet_presenter.rb
@@ -32,15 +32,9 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated
end
def blob
- blobs.first
- end
+ return snippet.blob if snippet.empty_repo?
- def blobs
- if snippet.empty_repo?
- [snippet.blob]
- else
- snippet.blobs
- end
+ blobs.first
end
private
diff --git a/app/serializers/ci/trigger_entity.rb b/app/serializers/ci/trigger_entity.rb
new file mode 100644
index 00000000000..005a9b752ed
--- /dev/null
+++ b/app/serializers/ci/trigger_entity.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Ci
+ class TriggerEntity < Grape::Entity
+ include Gitlab::Routing
+ include Gitlab::Allowable
+
+ expose :description
+ expose :owner, using: UserEntity
+ expose :last_used
+
+ expose :token do |trigger|
+ can_admin_trigger?(trigger) ? trigger.token : trigger.short_token
+ end
+
+ expose :has_token_exposed do |trigger|
+ can_admin_trigger?(trigger)
+ end
+
+ expose :can_access_project do |trigger|
+ trigger.can_access_project?
+ end
+
+ expose :project_trigger_path, if: -> (trigger) { can_manage_trigger?(trigger) } do |trigger|
+ project_trigger_path(options[:project], trigger)
+ end
+
+ expose :edit_project_trigger_path, if: -> (trigger) { can_admin_trigger?(trigger) } do |trigger|
+ edit_project_trigger_path(options[:project], trigger)
+ end
+
+ private
+
+ def can_manage_trigger?(trigger)
+ can?(options[:current_user], :manage_trigger, trigger)
+ end
+
+ def can_admin_trigger?(trigger)
+ can?(options[:current_user], :admin_trigger, trigger)
+ end
+ end
+end
diff --git a/app/serializers/ci/trigger_serializer.rb b/app/serializers/ci/trigger_serializer.rb
new file mode 100644
index 00000000000..8e42ec12c3f
--- /dev/null
+++ b/app/serializers/ci/trigger_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Ci
+ class TriggerSerializer < BaseSerializer
+ entity ::Ci::TriggerEntity
+ end
+end
diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb
index eea0acdc11b..b904666971e 100644
--- a/app/serializers/cluster_entity.rb
+++ b/app/serializers/cluster_entity.rb
@@ -6,6 +6,8 @@ class ClusterEntity < Grape::Entity
expose :cluster_type
expose :enabled
expose :environment_scope
+ expose :id
+ expose :namespace_per_environment
expose :name
expose :nodes
expose :provider_type
diff --git a/app/serializers/cluster_serializer.rb b/app/serializers/cluster_serializer.rb
index 700a46040e3..f71591612a6 100644
--- a/app/serializers/cluster_serializer.rb
+++ b/app/serializers/cluster_serializer.rb
@@ -12,6 +12,7 @@ class ClusterSerializer < BaseSerializer
:environment_scope,
:gitlab_managed_apps_logs_path,
:enable_advanced_logs_querying,
+ :id,
:kubernetes_errors,
:name,
:nodes,
diff --git a/app/serializers/container_repository_entity.rb b/app/serializers/container_repository_entity.rb
index 4c87d1438b0..9a002971bef 100644
--- a/app/serializers/container_repository_entity.rb
+++ b/app/serializers/container_repository_entity.rb
@@ -4,6 +4,7 @@ class ContainerRepositoryEntity < Grape::Entity
include RequestAwareEntity
expose :id, :name, :path, :location, :created_at, :status, :tags_count
+ expose :expiration_policy_started_at, as: :cleanup_policy_started_at
expose :tags_path do |repository|
project_registry_repository_tags_path(project, repository, format: :json)
diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb
index 9f27191c3c8..596f5d686da 100644
--- a/app/serializers/diff_file_base_entity.rb
+++ b/app/serializers/diff_file_base_entity.rb
@@ -34,7 +34,7 @@ class DiffFileBaseEntity < Grape::Entity
expose :edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file|
merge_request = options[:merge_request]
- next unless merge_request.merged? || merge_request.source_branch_exists?
+ next unless has_edit_path?(merge_request)
target_project, target_branch = edit_project_branch_options(merge_request)
@@ -43,6 +43,14 @@ class DiffFileBaseEntity < Grape::Entity
project_edit_blob_path(target_project, tree_join(target_branch, diff_file.new_path), options)
end
+ expose :ide_edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file|
+ merge_request = options[:merge_request]
+
+ next unless has_edit_path?(merge_request)
+
+ gitlab_ide_merge_request_path(merge_request)
+ end
+
expose :old_path_html do |diff_file|
old_path, _ = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
old_path
@@ -125,4 +133,8 @@ class DiffFileBaseEntity < Grape::Entity
[merge_request.target_project, merge_request.target_branch]
end
end
+
+ def has_edit_path?(merge_request)
+ merge_request.merged? || merge_request.source_branch_exists?
+ end
end
diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb
index 6ef524b5bec..0b4f21c55f4 100644
--- a/app/serializers/diffs_entity.rb
+++ b/app/serializers/diffs_entity.rb
@@ -78,7 +78,7 @@ class DiffsEntity < Grape::Entity
options[:merge_request_diffs]
end
- expose :definition_path_prefix, if: -> (diff_file) { Feature.enabled?(:code_navigation, merge_request.project, default_enabled: true) } do |diffs|
+ expose :definition_path_prefix do |diffs|
project_blob_path(merge_request.project, diffs.diff_refs&.head_sha)
end
@@ -89,8 +89,6 @@ class DiffsEntity < Grape::Entity
private
def code_navigation_path(diffs)
- return unless Feature.enabled?(:code_navigation, merge_request.project, default_enabled: true)
-
Gitlab::CodeNavigationPath.new(merge_request.project, diffs.diff_refs&.head_sha)
end
diff --git a/app/serializers/feature_flag_entity.rb b/app/serializers/feature_flag_entity.rb
new file mode 100644
index 00000000000..80cf869a389
--- /dev/null
+++ b/app/serializers/feature_flag_entity.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+class FeatureFlagEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id
+ expose :iid
+ expose :active
+ expose :created_at
+ expose :updated_at
+ expose :name
+ expose :description
+ expose :version
+
+ expose :edit_path, if: -> (feature_flag, _) { can_update?(feature_flag) } do |feature_flag|
+ edit_project_feature_flag_path(feature_flag.project, feature_flag)
+ end
+
+ expose :update_path, if: -> (feature_flag, _) { can_update?(feature_flag) } do |feature_flag|
+ project_feature_flag_path(feature_flag.project, feature_flag)
+ end
+
+ expose :destroy_path, if: -> (feature_flag, _) { can_destroy?(feature_flag) } do |feature_flag|
+ project_feature_flag_path(feature_flag.project, feature_flag)
+ end
+
+ expose :scopes, with: FeatureFlagScopeEntity do |feature_flag|
+ feature_flag.scopes.sort_by(&:id)
+ end
+
+ expose :strategies, with: FeatureFlags::StrategyEntity do |feature_flag|
+ feature_flag.strategies.sort_by(&:id)
+ end
+
+ private
+
+ def can_update?(feature_flag)
+ can?(current_user, :update_feature_flag, feature_flag)
+ end
+
+ def can_destroy?(feature_flag)
+ can?(current_user, :destroy_feature_flag, feature_flag)
+ end
+
+ def current_user
+ request.current_user
+ end
+end
diff --git a/app/serializers/feature_flag_scope_entity.rb b/app/serializers/feature_flag_scope_entity.rb
new file mode 100644
index 00000000000..0450797a545
--- /dev/null
+++ b/app/serializers/feature_flag_scope_entity.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class FeatureFlagScopeEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id
+ expose :active
+ expose :environment_scope
+ expose :created_at
+ expose :updated_at
+ expose :strategies
+end
diff --git a/app/serializers/feature_flag_serializer.rb b/app/serializers/feature_flag_serializer.rb
new file mode 100644
index 00000000000..e0ff33cc61a
--- /dev/null
+++ b/app/serializers/feature_flag_serializer.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class FeatureFlagSerializer < BaseSerializer
+ include WithPagination
+ entity FeatureFlagEntity
+
+ def represent(resource, opts = {})
+ super(resource, opts)
+ end
+end
diff --git a/app/serializers/feature_flag_summary_entity.rb b/app/serializers/feature_flag_summary_entity.rb
new file mode 100644
index 00000000000..be4f02dabca
--- /dev/null
+++ b/app/serializers/feature_flag_summary_entity.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class FeatureFlagSummaryEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :count do
+ expose :all do |project|
+ project.operations_feature_flags.count
+ end
+
+ expose :enabled do |project|
+ project.operations_feature_flags.enabled.count
+ end
+
+ expose :disabled do |project|
+ project.operations_feature_flags.disabled.count
+ end
+ end
+end
diff --git a/app/serializers/feature_flag_summary_serializer.rb b/app/serializers/feature_flag_summary_serializer.rb
new file mode 100644
index 00000000000..46f70666e40
--- /dev/null
+++ b/app/serializers/feature_flag_summary_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class FeatureFlagSummarySerializer < BaseSerializer
+ entity FeatureFlagSummaryEntity
+end
diff --git a/app/serializers/feature_flags/scope_entity.rb b/app/serializers/feature_flags/scope_entity.rb
new file mode 100644
index 00000000000..1c9dd491652
--- /dev/null
+++ b/app/serializers/feature_flags/scope_entity.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class ScopeEntity < Grape::Entity
+ expose :id
+ expose :environment_scope
+ end
+end
diff --git a/app/serializers/feature_flags/strategy_entity.rb b/app/serializers/feature_flags/strategy_entity.rb
new file mode 100644
index 00000000000..73450476869
--- /dev/null
+++ b/app/serializers/feature_flags/strategy_entity.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class StrategyEntity < Grape::Entity
+ expose :id
+ expose :name
+ expose :parameters
+ expose :scopes, with: FeatureFlags::ScopeEntity
+ expose :user_list, with: FeatureFlags::UserListEntity, expose_nil: false
+ end
+end
diff --git a/app/serializers/feature_flags/user_list_entity.rb b/app/serializers/feature_flags/user_list_entity.rb
new file mode 100644
index 00000000000..d3fddb4fa7a
--- /dev/null
+++ b/app/serializers/feature_flags/user_list_entity.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class UserListEntity < Grape::Entity
+ expose :id
+ expose :iid
+ expose :name
+ expose :user_xids
+ end
+end
diff --git a/app/serializers/feature_flags_client_entity.rb b/app/serializers/feature_flags_client_entity.rb
new file mode 100644
index 00000000000..4a195c7d759
--- /dev/null
+++ b/app/serializers/feature_flags_client_entity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class FeatureFlagsClientEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :token
+end
diff --git a/app/serializers/feature_flags_client_serializer.rb b/app/serializers/feature_flags_client_serializer.rb
new file mode 100644
index 00000000000..104729b6668
--- /dev/null
+++ b/app/serializers/feature_flags_client_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class FeatureFlagsClientSerializer < BaseSerializer
+ entity FeatureFlagsClientEntity
+
+ def represent_token(resource, opts = {})
+ represent(resource, only: [:token])
+ end
+end
diff --git a/app/serializers/group_group_link_entity.rb b/app/serializers/group_group_link_entity.rb
index 7a51e1a9316..491c672233d 100644
--- a/app/serializers/group_group_link_entity.rb
+++ b/app/serializers/group_group_link_entity.rb
@@ -1,12 +1,22 @@
# frozen_string_literal: true
class GroupGroupLinkEntity < Grape::Entity
+ include RequestAwareEntity
+
expose :id
expose :created_at
expose :expires_at do |group_link|
group_link.expires_at&.to_time
end
+ expose :can_update do |group_link|
+ can_manage?(group_link)
+ end
+
+ expose :can_remove do |group_link|
+ can_manage?(group_link)
+ end
+
expose :access_level do
expose :human_access, as: :string_value
expose :group_access, as: :integer_value
@@ -23,4 +33,14 @@ class GroupGroupLinkEntity < Grape::Entity
expose :shared_with_group, merge: true, using: GroupBasicEntity
end
+
+ private
+
+ def current_user
+ options[:current_user]
+ end
+
+ def can_manage?(group_link)
+ can?(current_user, :admin_group_member, group_link.shared_group)
+ end
end
diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb
index ef1177e9967..9e2bce53c8a 100644
--- a/app/serializers/merge_request_basic_entity.rb
+++ b/app/serializers/merge_request_basic_entity.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class MergeRequestBasicEntity < Grape::Entity
+ expose :title
expose :public_merge_status, as: :merge_status
expose :merge_error
expose :state
diff --git a/app/serializers/merge_request_poll_cached_widget_entity.rb b/app/serializers/merge_request_poll_cached_widget_entity.rb
index 002be8be729..080b6554de1 100644
--- a/app/serializers/merge_request_poll_cached_widget_entity.rb
+++ b/app/serializers/merge_request_poll_cached_widget_entity.rb
@@ -15,7 +15,7 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
expose :target_project_id
expose :squash
expose :rebase_in_progress?, as: :rebase_in_progress
- expose :default_squash_commit_message, if: -> (merge_request, _) { merge_request.mergeable? }
+ expose :default_squash_commit_message
expose :commits_count
expose :merge_ongoing?, as: :merge_ongoing
expose :work_in_progress?, as: :work_in_progress
@@ -25,10 +25,10 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
expose :source_branch_exists?, as: :source_branch_exists
expose :branch_missing?, as: :branch_missing
- expose :commits_without_merge_commits, using: MergeRequestWidgetCommitEntity,
- if: -> (merge_request, _) { merge_request.mergeable? } do |merge_request|
+ expose :commits_without_merge_commits, using: MergeRequestWidgetCommitEntity do |merge_request|
merge_request.recent_commits.without_merge_commits
end
+
expose :diff_head_sha do |merge_request|
merge_request.diff_head_sha.presence
end
@@ -46,6 +46,12 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
end
end
+ expose :actual_head_pipeline, as: :pipeline, if: -> (mr, _) {
+ Feature.enabled?(:merge_request_cached_pipeline_serializer, mr.project) && presenter(mr).can_read_pipeline?
+ } do |merge_request, options|
+ MergeRequests::PipelineEntity.represent(merge_request.actual_head_pipeline, options)
+ end
+
# Paths
#
expose :target_branch_commits_path do |merge_request|
diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb
index 41ab5005091..9609a894e6d 100644
--- a/app/serializers/merge_request_poll_widget_entity.rb
+++ b/app/serializers/merge_request_poll_widget_entity.rb
@@ -19,20 +19,14 @@ class MergeRequestPollWidgetEntity < Grape::Entity
# User entities
expose :merge_user, using: UserEntity
- expose :actual_head_pipeline, as: :pipeline, if: -> (mr, _) { presenter(mr).can_read_pipeline? } do |merge_request, options|
- if Feature.enabled?(:merge_request_short_pipeline_serializer, merge_request.project, default_enabled: true)
- MergeRequests::PipelineEntity.represent(merge_request.actual_head_pipeline, options)
- else
- PipelineDetailsEntity.represent(merge_request.actual_head_pipeline, options)
- end
+ expose :actual_head_pipeline, as: :pipeline, if: -> (mr, _) {
+ Feature.disabled?(:merge_request_cached_pipeline_serializer, mr.project) && presenter(mr).can_read_pipeline?
+ } do |merge_request, options|
+ MergeRequests::PipelineEntity.represent(merge_request.actual_head_pipeline, options)
end
expose :merge_pipeline, if: ->(mr, _) { mr.merged? && can?(request.current_user, :read_pipeline, mr.target_project)} do |merge_request, options|
- if Feature.enabled?(:merge_request_short_pipeline_serializer, merge_request.project, default_enabled: true)
- MergeRequests::PipelineEntity.represent(merge_request.merge_pipeline, options)
- else
- PipelineDetailsEntity.represent(merge_request.merge_pipeline, options)
- end
+ MergeRequests::PipelineEntity.represent(merge_request.merge_pipeline, options)
end
expose :default_merge_commit_message
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index 494192c8dbb..3fd2edfd425 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -67,15 +67,15 @@ class MergeRequestWidgetEntity < Grape::Entity
)
end
- expose :user_callouts_path, if: -> (*) { Feature.enabled?(:suggest_pipeline) } do |merge_request|
+ expose :user_callouts_path, if: -> (*) { Gitlab::Experimentation.enabled?(:suggest_pipeline) } do |_merge_request|
user_callouts_path
end
- expose :suggest_pipeline_feature_id, if: -> (*) { Feature.enabled?(:suggest_pipeline) } do |merge_request|
+ expose :suggest_pipeline_feature_id, if: -> (*) { Gitlab::Experimentation.enabled?(:suggest_pipeline) } do |_merge_request|
SUGGEST_PIPELINE
end
- expose :is_dismissed_suggest_pipeline, if: -> (*) { Feature.enabled?(:suggest_pipeline) } do |merge_request|
+ expose :is_dismissed_suggest_pipeline, if: -> (*) { Gitlab::Experimentation.enabled?(:suggest_pipeline) } do |_merge_request|
current_user && current_user.dismissed_callout?(feature_name: SUGGEST_PIPELINE)
end
diff --git a/app/serializers/paginated_diff_entity.rb b/app/serializers/paginated_diff_entity.rb
index 37c48338e55..f24571f7d7d 100644
--- a/app/serializers/paginated_diff_entity.rb
+++ b/app/serializers/paginated_diff_entity.rb
@@ -37,8 +37,6 @@ class PaginatedDiffEntity < Grape::Entity
private
def code_navigation_path(diffs)
- return unless Feature.enabled?(:code_navigation, merge_request.project, default_enabled: true)
-
Gitlab::CodeNavigationPath.new(merge_request.project, diffs.diff_refs&.head_sha)
end
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index 45c5a1d3e1c..a45214670fa 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -47,6 +47,7 @@ class PipelineSerializer < BaseSerializer
:retryable_builds,
:scheduled_actions,
:stages,
+ :latest_statuses,
:trigger_requests,
:user,
{
@@ -62,7 +63,14 @@ class PipelineSerializer < BaseSerializer
pending_builds: :project,
project: [:route, { namespace: :route }],
triggered_by_pipeline: [{ project: [:route, { namespace: :route }] }, :user],
- triggered_pipelines: [{ project: [:route, { namespace: :route }] }, :user, :source_job]
+ triggered_pipelines: [
+ {
+ project: [:route, { namespace: :route }]
+ },
+ :source_job,
+ :latest_statuses,
+ :user
+ ]
}
]
end
diff --git a/app/serializers/test_case_entity.rb b/app/serializers/test_case_entity.rb
index d2e08590ef0..b44aa62ad73 100644
--- a/app/serializers/test_case_entity.rb
+++ b/app/serializers/test_case_entity.rb
@@ -6,6 +6,7 @@ class TestCaseEntity < Grape::Entity
expose :status
expose :name
expose :classname
+ expose :file
expose :execution_time
expose :system_output
expose :stack_trace
diff --git a/app/services/admin/propagate_integration_service.rb b/app/services/admin/propagate_integration_service.rb
index 34d6008cb6a..80e27c21d5b 100644
--- a/app/services/admin/propagate_integration_service.rb
+++ b/app/services/admin/propagate_integration_service.rb
@@ -14,59 +14,19 @@ module Admin
private
# rubocop: disable Cop/InBatches
- # rubocop: disable CodeReuse/ActiveRecord
def update_inherited_integrations
- Service.where(type: integration.type, inherit_from_id: integration.id).in_batches(of: BATCH_SIZE) do |batch|
- bulk_update_from_integration(batch)
+ Service.by_type(integration.type).inherit_from_id(integration.id).in_batches(of: BATCH_SIZE) do |services|
+ min_id, max_id = services.pick("MIN(services.id), MAX(services.id)")
+ PropagateIntegrationInheritWorker.perform_async(integration.id, min_id, max_id)
end
end
# rubocop: enable Cop/InBatches
- # rubocop: enable CodeReuse/ActiveRecord
-
- # rubocop: disable CodeReuse/ActiveRecord
- def bulk_update_from_integration(batch)
- # Retrieving the IDs instantiates the ActiveRecord relation (batch)
- # into concrete models, otherwise update_all will clear the relation.
- # https://stackoverflow.com/q/34811646/462015
- batch_ids = batch.pluck(:id)
-
- Service.transaction do
- batch.update_all(service_hash)
-
- if data_fields_present?
- integration.data_fields.class.where(service_id: batch_ids).update_all(data_fields_hash)
- end
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
def create_integration_for_groups_without_integration
- loop do
- batch = Group.uncached { group_ids_without_integration(integration, BATCH_SIZE) }
-
- bulk_create_from_integration(batch, 'group') unless batch.empty?
-
- break if batch.size < BATCH_SIZE
+ Group.without_integration(integration).each_batch(of: BATCH_SIZE) do |groups|
+ min_id, max_id = groups.pick("MIN(namespaces.id), MAX(namespaces.id)")
+ PropagateIntegrationGroupWorker.perform_async(integration.id, min_id, max_id)
end
end
-
- def service_hash
- @service_hash ||= integration.to_service_hash
- .tap { |json| json['inherit_from_id'] = integration.id }
- end
-
- # rubocop:disable CodeReuse/ActiveRecord
- def group_ids_without_integration(integration, limit)
- services = Service
- .select('1')
- .where('services.group_id = namespaces.id')
- .where(type: integration.type)
-
- Group
- .where('NOT EXISTS (?)', services)
- .limit(limit)
- .pluck(:id)
- end
- # rubocop:enable CodeReuse/ActiveRecord
end
end
diff --git a/app/services/admin/propagate_service_template.rb b/app/services/admin/propagate_service_template.rb
index cd0d2d5d03f..07be3c1027d 100644
--- a/app/services/admin/propagate_service_template.rb
+++ b/app/services/admin/propagate_service_template.rb
@@ -9,11 +9,5 @@ module Admin
create_integration_for_projects_without_integration
end
-
- private
-
- def service_hash
- @service_hash ||= integration.to_service_hash
- end
end
end
diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb
index 95ae84a85a4..5c7698f724a 100644
--- a/app/services/alert_management/process_prometheus_alert_service.rb
+++ b/app/services/alert_management/process_prometheus_alert_service.rb
@@ -47,7 +47,7 @@ module AlertManagement
def create_alert_management_alert
if alert.save
alert.execute_services
- SystemNoteService.create_new_alert(alert, Gitlab::AlertManagement::AlertParams::MONITORING_TOOLS[:prometheus])
+ SystemNoteService.create_new_alert(alert, Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus])
return
end
diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb
index d7630dbdac9..3c21844ec62 100644
--- a/app/services/audit_event_service.rb
+++ b/app/services/audit_event_service.rb
@@ -53,7 +53,6 @@ class AuditEventService
private
- attr_accessor :authentication_event
attr_reader :ip_address
def build_author(author)
@@ -99,23 +98,35 @@ class AuditEventService
end
def mark_as_authentication_event!
- self.authentication_event = true
+ @authentication_event = true
end
def authentication_event?
- authentication_event
+ @authentication_event
end
def log_security_event_to_database
return if Gitlab::Database.read_only?
- AuditEvent.create(base_payload.merge(details: @details))
+ event = AuditEvent.new(base_payload.merge(details: @details))
+ save_or_track event
+
+ event
end
def log_authentication_event_to_database
return unless Gitlab::Database.read_write? && authentication_event?
- AuthenticationEvent.create(authentication_event_payload)
+ event = AuthenticationEvent.new(authentication_event_payload)
+ save_or_track event
+
+ event
+ end
+
+ def save_or_track(event)
+ event.save!
+ rescue => e
+ Gitlab::ErrorTracking.track_exception(e, audit_event_type: event.class.to_s)
end
end
diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb
index 1a5dc790c41..2ccaea64d14 100644
--- a/app/services/boards/create_service.rb
+++ b/app/services/boards/create_service.rb
@@ -3,7 +3,11 @@
module Boards
class CreateService < Boards::BaseService
def execute
- create_board! if can_create_board?
+ unless can_create_board?
+ return ServiceResponse.error(message: "You don't have the permission to create a board for this resource.")
+ end
+
+ create_board!
end
private
@@ -15,12 +19,16 @@ module Boards
def create_board!
board = parent.boards.create(params)
- if board.persisted?
- board.lists.create(list_type: :backlog)
- board.lists.create(list_type: :closed)
+ unless board.persisted?
+ return ServiceResponse.error(message: "There was an error when creating a board.", payload: board)
+ end
+
+ board.tap do |created_board|
+ created_board.lists.create(list_type: :backlog)
+ created_board.lists.create(list_type: :closed)
end
- board
+ ServiceResponse.success(payload: board)
end
end
end
diff --git a/app/services/boards/lists/destroy_service.rb b/app/services/boards/lists/destroy_service.rb
index e20805d0405..ebac0f07fe1 100644
--- a/app/services/boards/lists/destroy_service.rb
+++ b/app/services/boards/lists/destroy_service.rb
@@ -4,7 +4,9 @@ module Boards
module Lists
class DestroyService < Boards::BaseService
def execute(list)
- return false unless list.destroyable?
+ unless list.destroyable?
+ return ServiceResponse.error(message: "The list cannot be destroyed. Only label lists can be destroyed.")
+ end
@board = list.board
@@ -12,6 +14,8 @@ module Boards
decrement_higher_lists(list)
remove_list(list)
end
+
+ ServiceResponse.success
end
private
@@ -26,7 +30,7 @@ module Boards
# rubocop: enable CodeReuse/ActiveRecord
def remove_list(list)
- list.destroy
+ list.destroy!
end
end
end
diff --git a/app/services/bulk_create_integration_service.rb b/app/services/bulk_create_integration_service.rb
new file mode 100644
index 00000000000..23b89b0d8a9
--- /dev/null
+++ b/app/services/bulk_create_integration_service.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+class BulkCreateIntegrationService
+ def initialize(integration, batch, association)
+ @integration = integration
+ @batch = batch
+ @association = association
+ end
+
+ def execute
+ service_list = ServiceList.new(batch, service_hash, association).to_array
+
+ Service.transaction do
+ results = bulk_insert(*service_list)
+
+ if integration.data_fields_present?
+ data_list = DataList.new(results, data_fields_hash, integration.data_fields.class).to_array
+
+ bulk_insert(*data_list)
+ end
+
+ run_callbacks(batch) if association == 'project'
+ end
+ end
+
+ private
+
+ attr_reader :integration, :batch, :association
+
+ def bulk_insert(klass, columns, values_array)
+ items_to_insert = values_array.map { |array| Hash[columns.zip(array)] }
+
+ klass.insert_all(items_to_insert, returning: [:id])
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def run_callbacks(batch)
+ if integration.issue_tracker?
+ Project.where(id: batch.select(:id)).update_all(has_external_issue_tracker: true)
+ end
+
+ if integration.type == 'ExternalWikiService'
+ Project.where(id: batch.select(:id)).update_all(has_external_wiki: true)
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def service_hash
+ if integration.template?
+ integration.to_service_hash
+ else
+ integration.to_service_hash.tap { |json| json['inherit_from_id'] = integration.id }
+ end
+ end
+
+ def data_fields_hash
+ integration.to_data_fields_hash
+ end
+end
diff --git a/app/services/bulk_update_integration_service.rb b/app/services/bulk_update_integration_service.rb
new file mode 100644
index 00000000000..74d77618f2c
--- /dev/null
+++ b/app/services/bulk_update_integration_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class BulkUpdateIntegrationService
+ def initialize(integration, batch)
+ @integration = integration
+ @batch = batch
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def execute
+ Service.transaction do
+ batch.update_all(service_hash)
+
+ if integration.data_fields_present?
+ integration.data_fields.class.where(service_id: batch.select(:id)).update_all(data_fields_hash)
+ end
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ attr_reader :integration, :batch
+
+ def service_hash
+ integration.to_service_hash.tap { |json| json['inherit_from_id'] = integration.id }
+ end
+
+ def data_fields_hash
+ integration.to_data_fields_hash
+ end
+end
diff --git a/app/services/ci/create_job_artifacts_service.rb b/app/services/ci/create_job_artifacts_service.rb
index 1fe65898d55..5efb3805bf7 100644
--- a/app/services/ci/create_job_artifacts_service.rb
+++ b/app/services/ci/create_job_artifacts_service.rb
@@ -52,24 +52,15 @@ module Ci
attr_reader :job, :project
def validate_requirements(artifact_type:, filesize:)
- return forbidden_type_error(artifact_type) if forbidden_type?(artifact_type)
return too_large_error if too_large?(artifact_type, filesize)
success
end
- def forbidden_type?(type)
- lsif?(type) && !code_navigation_enabled?
- end
-
def too_large?(type, size)
size > max_size(type) if size
end
- def code_navigation_enabled?
- Feature.enabled?(:code_navigation, project, default_enabled: true)
- end
-
def lsif?(type)
type == LSIF_ARTIFACT_TYPE
end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 70ad18e80eb..3f1a2d1350d 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -82,8 +82,7 @@ module Ci
schedule_head_pipeline_update if pipeline.persisted?
# If pipeline is not persisted, try to recover IID
- pipeline.reset_project_iid unless pipeline.persisted? ||
- Feature.disabled?(:ci_pipeline_rewind_iid, project, default_enabled: true)
+ pipeline.reset_project_iid unless pipeline.persisted?
pipeline
end
diff --git a/app/services/ci/daily_build_group_report_result_service.rb b/app/services/ci/daily_build_group_report_result_service.rb
index 6cdf3c88f8c..c32fc27c274 100644
--- a/app/services/ci/daily_build_group_report_result_service.rb
+++ b/app/services/ci/daily_build_group_report_result_service.rb
@@ -3,8 +3,6 @@
module Ci
class DailyBuildGroupReportResultService
def execute(pipeline)
- return unless Feature.enabled?(:ci_daily_code_coverage, pipeline.project, default_enabled: true)
-
DailyBuildGroupReportResult.upsert_reports(coverage_reports(pipeline))
end
diff --git a/app/services/ci/expire_pipeline_cache_service.rb b/app/services/ci/expire_pipeline_cache_service.rb
index 32abd1a7626..8343e0f8cd0 100644
--- a/app/services/ci/expire_pipeline_cache_service.rb
+++ b/app/services/ci/expire_pipeline_cache_service.rb
@@ -32,11 +32,18 @@ module Ci
Gitlab::Routing.url_helpers.project_new_merge_request_path(project, format: :json)
end
+ def pipelines_project_merge_request_path(merge_request)
+ Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(merge_request.target_project, merge_request, format: :json)
+ end
+
+ def merge_request_widget_path(merge_request)
+ Gitlab::Routing.url_helpers.cached_widget_project_json_merge_request_path(merge_request.project, merge_request, format: :json)
+ end
+
def each_pipelines_merge_request_path(pipeline)
pipeline.all_merge_requests.each do |merge_request|
- path = Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(merge_request.target_project, merge_request, format: :json)
-
- yield(path)
+ yield(pipelines_project_merge_request_path(merge_request))
+ yield(merge_request_widget_path(merge_request))
end
end
diff --git a/app/services/ci/pipelines/create_artifact_service.rb b/app/services/ci/pipelines/create_artifact_service.rb
index b7d334e436d..bfaf317241a 100644
--- a/app/services/ci/pipelines/create_artifact_service.rb
+++ b/app/services/ci/pipelines/create_artifact_service.rb
@@ -3,7 +3,6 @@ module Ci
module Pipelines
class CreateArtifactService
def execute(pipeline)
- return unless ::Gitlab::Ci::Features.coverage_report_view?(pipeline.project)
return unless pipeline.can_generate_coverage_reports?
return if pipeline.has_coverage_reports?
diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb
index 18bae26613f..e511e26adfe 100644
--- a/app/services/ci/process_pipeline_service.rb
+++ b/app/services/ci/process_pipeline_service.rb
@@ -31,14 +31,14 @@ module Ci
# rubocop: disable CodeReuse/ActiveRecord
def update_retried
# find the latest builds for each name
- latest_statuses = pipeline.statuses.latest
+ latest_statuses = pipeline.latest_statuses
.group(:name)
.having('count(*) > 1')
.pluck(Arel.sql('MAX(id)'), 'name')
# mark builds that are retried
if latest_statuses.any?
- pipeline.statuses.latest
+ pipeline.latest_statuses
.where(name: latest_statuses.map(&:second))
.where.not(id: latest_statuses.map(&:first))
.update_all(retried: true)
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index 6b2e6c245f3..f397ada0696 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -58,7 +58,7 @@ module Ci
build = project.builds.new(attributes)
build.assign_attributes(::Gitlab::Ci::Pipeline::Seed::Build.environment_attributes_for(build))
build.retried = false
- BulkInsertableAssociations.with_bulk_insert(enabled: ::Gitlab::Ci::Features.bulk_insert_on_create?(project)) do
+ BulkInsertableAssociations.with_bulk_insert do
build.save!
end
build
diff --git a/app/services/ci/update_build_queue_service.rb b/app/services/ci/update_build_queue_service.rb
index 31c7178c9e7..241eba733ea 100644
--- a/app/services/ci/update_build_queue_service.rb
+++ b/app/services/ci/update_build_queue_service.rb
@@ -9,9 +9,7 @@ module Ci
private
def tick_for(build, runners)
- if Feature.enabled?(:ci_update_queues_for_online_runners, build.project, default_enabled: true)
- runners = runners.with_recent_runner_queue
- end
+ runners = runners.with_recent_runner_queue
runners.each do |runner|
runner.pick_build!(build)
diff --git a/app/services/ci/update_build_state_service.rb b/app/services/ci/update_build_state_service.rb
index 61e4c77c1e5..cc8e2060888 100644
--- a/app/services/ci/update_build_state_service.rb
+++ b/app/services/ci/update_build_state_service.rb
@@ -2,7 +2,10 @@
module Ci
class UpdateBuildStateService
- Result = Struct.new(:status, keyword_init: true)
+ include ::Gitlab::Utils::StrongMemoize
+ include ::Gitlab::ExclusiveLeaseHelpers
+
+ Result = Struct.new(:status, :backoff, keyword_init: true)
ACCEPT_TIMEOUT = 5.minutes.freeze
@@ -17,44 +20,65 @@ module Ci
def execute
overwrite_trace! if has_trace?
- if accept_request?
- accept_build_state!
- else
- check_migration_state
- update_build_state!
+ unless accept_available?
+ return update_build_state!
+ end
+
+ ensure_pending_state!
+
+ in_build_trace_lock do
+ process_build_state!
end
end
private
- def accept_build_state!
- if Time.current - ensure_pending_state.created_at > ACCEPT_TIMEOUT
- metrics.increment_trace_operation(operation: :discarded)
+ def overwrite_trace!
+ metrics.increment_trace_operation(operation: :overwrite)
- return update_build_state!
+ build.trace.set(params[:trace]) if Gitlab::Ci::Features.trace_overwrite?
+ end
+
+ def ensure_pending_state!
+ pending_state.created_at
+ end
+
+ def process_build_state!
+ if live_chunks_pending?
+ if pending_state_outdated?
+ discard_build_trace!
+ update_build_state!
+ else
+ accept_build_state!
+ end
+ else
+ validate_build_trace!
+ update_build_state!
end
+ end
+ def accept_build_state!
build.trace_chunks.live.find_each do |chunk|
chunk.schedule_to_persist!
end
metrics.increment_trace_operation(operation: :accepted)
- Result.new(status: 202)
- end
-
- def overwrite_trace!
- metrics.increment_trace_operation(operation: :overwrite)
-
- build.trace.set(params[:trace]) if Gitlab::Ci::Features.trace_overwrite?
+ ::Gitlab::Ci::Runner::Backoff.new(pending_state.created_at).then do |backoff|
+ Result.new(status: 202, backoff: backoff.to_seconds)
+ end
end
- def check_migration_state
- return unless accept_available?
+ def validate_build_trace!
+ return unless has_chunks?
- if has_chunks? && !live_chunks_pending?
+ unless live_chunks_pending?
metrics.increment_trace_operation(operation: :finalized)
end
+
+ unless ::Gitlab::Ci::Trace::Checksum.new(build).valid?
+ metrics.increment_trace_operation(operation: :invalid)
+ end
end
def update_build_state!
@@ -76,12 +100,24 @@ module Ci
end
end
+ def discard_build_trace!
+ metrics.increment_trace_operation(operation: :discarded)
+ end
+
def accept_available?
!build_running? && has_checksum? && chunks_migration_enabled?
end
- def accept_request?
- accept_available? && live_chunks_pending?
+ def live_chunks_pending?
+ build.trace_chunks.live.any?
+ end
+
+ def has_chunks?
+ build.trace_chunks.any?
+ end
+
+ def pending_state_outdated?
+ Time.current - pending_state.created_at > ACCEPT_TIMEOUT
end
def build_state
@@ -96,18 +132,14 @@ module Ci
params.dig(:checksum).present?
end
- def has_chunks?
- build.trace_chunks.any?
- end
-
- def live_chunks_pending?
- build.trace_chunks.live.any?
- end
-
def build_running?
build_state == 'running'
end
+ def pending_state
+ strong_memoize(:pending_state) { ensure_pending_state }
+ end
+
def ensure_pending_state
Ci::BuildPendingState.create_or_find_by!(
build_id: build.id,
@@ -121,6 +153,32 @@ module Ci
build.pending_state
end
+ ##
+ # This method is releasing an exclusive lock on a build trace the moment we
+ # conclude that build status has been written and the build state update
+ # has been committed to the database.
+ #
+ # Because a build state machine schedules a bunch of workers to run after
+ # build status transition to complete, we do not want to keep the lease
+ # until all the workers are scheduled because it opens a possibility of
+ # race conditions happening.
+ #
+ # Instead of keeping the lease until the transition is fully done and
+ # workers are scheduled, we immediately release the lock after the database
+ # commit happens.
+ #
+ def in_build_trace_lock(&block)
+ build.trace.lock do |_, lease| # rubocop:disable CodeReuse/ActiveRecord
+ build.run_on_status_commit { lease.cancel }
+
+ yield
+ end
+ rescue ::Gitlab::Ci::Trace::LockedError
+ metrics.increment_trace_operation(operation: :locked)
+
+ accept_build_state!
+ end
+
def chunks_migration_enabled?
::Gitlab::Ci::Features.accept_trace?(build.project)
end
diff --git a/app/services/concerns/admin/propagate_service.rb b/app/services/concerns/admin/propagate_service.rb
index 974408f678c..065ab6f7ff9 100644
--- a/app/services/concerns/admin/propagate_service.rb
+++ b/app/services/concerns/admin/propagate_service.rb
@@ -4,9 +4,7 @@ module Admin
module PropagateService
extend ActiveSupport::Concern
- BATCH_SIZE = 100
-
- delegate :data_fields_present?, to: :integration
+ BATCH_SIZE = 10_000
class_methods do
def propagate(integration)
@@ -23,51 +21,10 @@ module Admin
attr_reader :integration
def create_integration_for_projects_without_integration
- loop do
- batch_ids = Project.uncached { Project.ids_without_integration(integration, BATCH_SIZE) }
-
- bulk_create_from_integration(batch_ids, 'project') unless batch_ids.empty?
-
- break if batch_ids.size < BATCH_SIZE
- end
- end
-
- def bulk_create_from_integration(batch_ids, association)
- service_list = ServiceList.new(batch_ids, service_hash, association).to_array
-
- Service.transaction do
- results = bulk_insert(*service_list)
-
- if data_fields_present?
- data_list = DataList.new(results, data_fields_hash, integration.data_fields.class).to_array
-
- bulk_insert(*data_list)
- end
-
- run_callbacks(batch_ids) if association == 'project'
+ Project.without_integration(integration).each_batch(of: BATCH_SIZE) do |projects|
+ min_id, max_id = projects.pick("MIN(projects.id), MAX(projects.id)")
+ PropagateIntegrationProjectWorker.perform_async(integration.id, min_id, max_id)
end
end
-
- def bulk_insert(klass, columns, values_array)
- items_to_insert = values_array.map { |array| Hash[columns.zip(array)] }
-
- klass.insert_all(items_to_insert, returning: [:id])
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def run_callbacks(batch_ids)
- if integration.issue_tracker?
- Project.where(id: batch_ids).update_all(has_external_issue_tracker: true)
- end
-
- if integration.type == 'ExternalWikiService'
- Project.where(id: batch_ids).update_all(has_external_wiki: true)
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def data_fields_hash
- @data_fields_hash ||= integration.to_data_fields_hash
- end
end
end
diff --git a/app/services/design_management/copy_design_collection.rb b/app/services/design_management/copy_design_collection.rb
new file mode 100644
index 00000000000..66cf6112062
--- /dev/null
+++ b/app/services/design_management/copy_design_collection.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ module CopyDesignCollection
+ end
+end
diff --git a/app/services/design_management/copy_design_collection/copy_service.rb b/app/services/design_management/copy_design_collection/copy_service.rb
new file mode 100644
index 00000000000..68f69a9c8db
--- /dev/null
+++ b/app/services/design_management/copy_design_collection/copy_service.rb
@@ -0,0 +1,306 @@
+# frozen_string_literal: true
+
+# Service to copy a DesignCollection from one Issue to another.
+# Copies the DesignCollection's Designs, Versions, and Notes on Designs.
+module DesignManagement
+ module CopyDesignCollection
+ class CopyService < DesignService
+ # rubocop: disable CodeReuse/ActiveRecord
+ def initialize(project, user, params = {})
+ super
+
+ @target_issue = params.fetch(:target_issue)
+ @target_project = @target_issue.project
+ @target_repository = @target_project.design_repository
+ @target_design_collection = @target_issue.design_collection
+ @temporary_branch = "CopyDesignCollectionService_#{SecureRandom.hex}"
+
+ @designs = DesignManagement::Design.unscoped.where(issue: issue).order(:id).load
+ @versions = DesignManagement::Version.unscoped.where(issue: issue).order(:id).includes(:designs).load
+
+ @sha_attribute = Gitlab::Database::ShaAttribute.new
+ @shas = []
+ @event_enum_map = DesignManagement::DesignAction::EVENT_FOR_GITALY_ACTION.invert
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def execute
+ return error('User cannot copy design collection to issue') unless user_can_copy?
+ return error('Target design collection must first be queued') unless target_design_collection.copy_in_progress?
+ return error('Design collection has no designs') if designs.empty?
+ return error('Target design collection already has designs') unless target_design_collection.empty?
+
+ with_temporary_branch do
+ copy_commits!
+
+ ActiveRecord::Base.transaction do
+ design_ids = copy_designs!
+ version_ids = copy_versions!
+ copy_actions!(design_ids, version_ids)
+ link_lfs_files!
+ copy_notes!(design_ids)
+ finalize!
+ end
+ end
+
+ ServiceResponse.success
+ rescue => error
+ log_exception(error)
+
+ target_design_collection.error_copy!
+
+ error('Designs were unable to be copied successfully')
+ end
+
+ private
+
+ attr_reader :designs, :event_enum_map, :sha_attribute, :shas, :temporary_branch,
+ :target_design_collection, :target_issue, :target_repository,
+ :target_project, :versions
+
+ alias_method :merge_branch, :target_branch
+
+ def log_exception(exception)
+ payload = {
+ issue_id: issue.id,
+ project_id: project.id,
+ target_issue_id: target_issue.id,
+ target_project: target_project.id
+ }
+
+ Gitlab::ErrorTracking.track_exception(exception, payload)
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def user_can_copy?
+ current_user.can?(:read_design, design_collection) &&
+ current_user.can?(:admin_issue, target_issue)
+ end
+
+ def with_temporary_branch(&block)
+ target_repository.create_if_not_exists
+
+ create_master_branch! if target_repository.empty?
+ create_temporary_branch!
+
+ yield
+ ensure
+ remove_temporary_branch!
+ end
+
+ # A project that does not have any designs will have a blank design
+ # repository. To create a temporary branch from `master` we need
+ # create `master` first by adding a file to it.
+ def create_master_branch!
+ target_repository.create_file(
+ current_user,
+ ".CopyDesignCollectionService_#{Time.now.to_i}",
+ '.gitlab',
+ message: "Commit to create #{merge_branch} branch in CopyDesignCollectionService",
+ branch_name: merge_branch
+ )
+ end
+
+ def create_temporary_branch!
+ target_repository.add_branch(
+ current_user,
+ temporary_branch,
+ target_repository.root_ref
+ )
+ end
+
+ def remove_temporary_branch!
+ return unless target_repository.branch_exists?(temporary_branch)
+
+ target_repository.rm_branch(current_user, temporary_branch)
+ end
+
+ # Merge the temporary branch containing the commits to `master`
+ # and update the state of the target_design_collection.
+ def finalize!
+ source_sha = shas.last
+
+ target_repository.raw.merge(
+ current_user,
+ source_sha,
+ merge_branch,
+ 'CopyDesignCollectionService finalize merge'
+ ) { nil }
+
+ target_design_collection.end_copy!
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def copy_commits!
+ # Execute another query to include actions and their designs
+ DesignManagement::Version.unscoped.where(id: versions).order(:id).includes(actions: :design).find_each(batch_size: 100) do |version|
+ gitaly_actions = version.actions.map do |action|
+ design = action.design
+ # Map the raw Action#event enum value to a Gitaly "action" for the
+ # `Repository#multi_action` call.
+ gitaly_action_name = @event_enum_map[action.event_before_type_cast]
+ # `content` will be the LfsPointer file and not the design file,
+ # and can be nil for deletions.
+ content = blobs.dig(version.sha, design.filename)&.data
+ file_path = DesignManagement::Design.build_full_path(target_issue, design)
+
+ {
+ action: gitaly_action_name,
+ file_path: file_path,
+ content: content
+ }.compact
+ end
+
+ sha = target_repository.multi_action(
+ current_user,
+ branch_name: temporary_branch,
+ message: commit_message(version),
+ actions: gitaly_actions
+ )
+
+ shas << sha
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def copy_designs!
+ design_attributes = attributes_config[:design_attributes]
+
+ new_rows = designs.map do |design|
+ design.attributes.slice(*design_attributes).merge(
+ issue_id: target_issue.id,
+ project_id: target_project.id
+ )
+ end
+
+ # TODO Replace `Gitlab::Database.bulk_insert` with `BulkInsertSafe`
+ # once https://gitlab.com/gitlab-org/gitlab/-/issues/247718 is fixed.
+ ::Gitlab::Database.bulk_insert( # rubocop:disable Gitlab/BulkInsert
+ DesignManagement::Design.table_name,
+ new_rows,
+ return_ids: true
+ )
+ end
+
+ def copy_versions!
+ version_attributes = attributes_config[:version_attributes]
+ # `shas` are the list of Git commits made during the Git copy phase,
+ # and will be ordered 1:1 with old versions
+ shas_enum = shas.to_enum
+
+ new_rows = versions.map do |version|
+ version.attributes.slice(*version_attributes).merge(
+ issue_id: target_issue.id,
+ sha: sha_attribute.serialize(shas_enum.next)
+ )
+ end
+
+ # TODO Replace `Gitlab::Database.bulk_insert` with `BulkInsertSafe`
+ # once https://gitlab.com/gitlab-org/gitlab/-/issues/247718 is fixed.
+ ::Gitlab::Database.bulk_insert( # rubocop:disable Gitlab/BulkInsert
+ DesignManagement::Version.table_name,
+ new_rows,
+ return_ids: true
+ )
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def copy_actions!(new_design_ids, new_version_ids)
+ # Create a map of <Old design id> => <New design id>
+ design_id_map = new_design_ids.each_with_index.to_h do |design_id, i|
+ [designs[i].id, design_id]
+ end
+
+ # Create a map of <Old version id> => <New version id>
+ version_id_map = new_version_ids.each_with_index.to_h do |version_id, i|
+ [versions[i].id, version_id]
+ end
+
+ actions = DesignManagement::Action.unscoped.select(:design_id, :version_id, :event).where(design: designs, version: versions)
+
+ new_rows = actions.map do |action|
+ {
+ design_id: design_id_map[action.design_id],
+ version_id: version_id_map[action.version_id],
+ event: action.event_before_type_cast
+ }
+ end
+
+ # We cannot use `BulkInsertSafe` because of the uploader mounted in `Action`.
+ ::Gitlab::Database.bulk_insert( # rubocop:disable Gitlab/BulkInsert
+ DesignManagement::Action.table_name,
+ new_rows
+ )
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def commit_message(version)
+ "Copy commit #{version.sha} from issue #{issue.to_reference(full: true)}"
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def copy_notes!(design_ids)
+ new_designs = DesignManagement::Design.unscoped.find(design_ids)
+
+ # Execute another query to filter only designs with notes
+ DesignManagement::Design.unscoped.where(id: designs).joins(:notes).distinct.find_each(batch_size: 100) do |old_design|
+ new_design = new_designs.find { |d| d.filename == old_design.filename }
+
+ Notes::CopyService.new(current_user, old_design, new_design).execute
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def link_lfs_files!
+ oids = blobs.values.flat_map(&:values).map(&:lfs_oid)
+ repository_type = LfsObjectsProject.repository_types[:design]
+
+ new_rows = LfsObject.where(oid: oids).find_each(batch_size: 1000).map do |lfs_object|
+ {
+ project_id: target_project.id,
+ lfs_object_id: lfs_object.id,
+ repository_type: repository_type
+ }
+ end
+
+ # We cannot use `BulkInsertSafe` due to the LfsObjectsProject#update_project_statistics
+ # callback that fires after_commit.
+ ::Gitlab::Database.bulk_insert( # rubocop:disable Gitlab/BulkInsert
+ LfsObjectsProject.table_name,
+ new_rows,
+ on_conflict: :do_nothing # Upsert
+ )
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # Blob data is used to find the oids for LfsObjects and to copy to Git.
+ # Blobs are reasonably small in memory, as their data are LFS Pointer files.
+ #
+ # Returns all blobs for the designs as a Hash of `{ Blob#commit_id => { Design#filename => Blob } }`
+ def blobs
+ @blobs ||= begin
+ items = versions.flat_map { |v| v.designs.map { |d| [v.sha, DesignManagement::Design.build_full_path(issue, d)] } }
+
+ repository.blobs_at(items).each_with_object({}) do |blob, h|
+ design = designs.find { |d| DesignManagement::Design.build_full_path(issue, d) == blob.path }
+
+ h[blob.commit_id] ||= {}
+ h[blob.commit_id][design.filename] = blob
+ end
+ end
+ end
+
+ def attributes_config
+ @attributes_config ||= YAML.load_file(attributes_config_file).symbolize_keys
+ end
+
+ def attributes_config_file
+ Rails.root.join('lib/gitlab/design_management/copy_design_collection_model_attributes.yml')
+ end
+ end
+ end
+end
diff --git a/app/services/design_management/copy_design_collection/queue_service.rb b/app/services/design_management/copy_design_collection/queue_service.rb
new file mode 100644
index 00000000000..f76917dbe47
--- /dev/null
+++ b/app/services/design_management/copy_design_collection/queue_service.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+# Service for setting the initial copy_state on the target DesignCollection
+# and queuing a CopyDesignCollectionWorker.
+module DesignManagement
+ module CopyDesignCollection
+ class QueueService
+ def initialize(current_user, issue, target_issue)
+ @current_user = current_user
+ @issue = issue
+ @target_issue = target_issue
+ @target_design_collection = target_issue.design_collection
+ end
+
+ def execute
+ return error('User cannot copy designs to issue') unless user_can_copy?
+ return error('Target design collection copy state must be `ready`') unless target_design_collection.can_start_copy?
+
+ target_design_collection.start_copy!
+
+ DesignManagement::CopyDesignCollectionWorker.perform_async(current_user.id, issue.id, target_issue.id)
+
+ ServiceResponse.success
+ end
+
+ private
+
+ delegate :design_collection, to: :issue
+
+ attr_reader :current_user, :issue, :target_design_collection, :target_issue
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def user_can_copy?
+ current_user.can?(:read_design, issue) &&
+ current_user.can?(:admin_issue, target_issue)
+ end
+ end
+ end
+end
diff --git a/app/services/design_management/design_service.rb b/app/services/design_management/design_service.rb
index 54e53609646..5aa2a2f73bc 100644
--- a/app/services/design_management/design_service.rb
+++ b/app/services/design_management/design_service.rb
@@ -19,6 +19,7 @@ module DesignManagement
def collection
issue.design_collection
end
+ alias_method :design_collection, :collection
def repository
collection.repository
diff --git a/app/services/design_management/generate_image_versions_service.rb b/app/services/design_management/generate_image_versions_service.rb
index 213aac164ff..e56d163c461 100644
--- a/app/services/design_management/generate_image_versions_service.rb
+++ b/app/services/design_management/generate_image_versions_service.rb
@@ -48,6 +48,9 @@ module DesignManagement
# Store and process the file
action.image_v432x230.store!(raw_file)
action.save!
+ rescue CarrierWave::IntegrityError => e
+ Gitlab::ErrorTracking.log_exception(e, project_id: project.id, design_id: action.design_id, version_id: action.version_id)
+ log_error(e.message)
rescue CarrierWave::UploadError => e
Gitlab::ErrorTracking.track_exception(e, project_id: project.id, design_id: action.design_id, version_id: action.version_id)
log_error(e.message)
diff --git a/app/services/design_management/runs_design_actions.rb b/app/services/design_management/runs_design_actions.rb
index 4bd6bb45658..ee6aa9286d3 100644
--- a/app/services/design_management/runs_design_actions.rb
+++ b/app/services/design_management/runs_design_actions.rb
@@ -4,14 +4,15 @@ module DesignManagement
module RunsDesignActions
NoActions = Class.new(StandardError)
- # this concern requires the following methods to be implemented:
+ # This concern requires the following methods to be implemented:
# current_user, target_branch, repository, commit_message
#
# Before calling `run_actions`, you should ensure the repository exists, by
# calling `repository.create_if_not_exists`.
#
# @raise [NoActions] if actions are empty
- def run_actions(actions)
+ # @return [DesignManagement::Version]
+ def run_actions(actions, skip_system_notes: false)
raise NoActions if actions.empty?
sha = repository.multi_action(current_user,
@@ -21,14 +22,14 @@ module DesignManagement
::DesignManagement::Version
.create_for_designs(actions, sha, current_user)
- .tap { |version| post_process(version) }
+ .tap { |version| post_process(version, skip_system_notes) }
end
private
- def post_process(version)
+ def post_process(version, skip_system_notes)
version.run_after_commit_or_now do
- ::DesignManagement::NewVersionWorker.perform_async(id)
+ ::DesignManagement::NewVersionWorker.perform_async(id, skip_system_notes)
end
end
end
diff --git a/app/services/design_management/save_designs_service.rb b/app/services/design_management/save_designs_service.rb
index 0446d2f1ee8..8dcc678e87e 100644
--- a/app/services/design_management/save_designs_service.rb
+++ b/app/services/design_management/save_designs_service.rb
@@ -16,11 +16,15 @@ module DesignManagement
def execute
return error("Not allowed!") unless can_create_designs?
return error("Only #{MAX_FILES} files are allowed simultaneously") if files.size > MAX_FILES
+ return error("Duplicate filenames are not allowed!") if files.map(&:original_filename).uniq.length != files.length
+ return error("Design copy is in progress") if design_collection.copy_in_progress?
uploaded_designs, version = upload_designs!
skipped_designs = designs - uploaded_designs
create_events
+ design_collection.reset_copy!
+
success({ designs: uploaded_designs, version: version, skipped_designs: skipped_designs })
rescue ::ActiveRecord::RecordInvalid => e
error(e.message)
@@ -34,7 +38,10 @@ module DesignManagement
::DesignManagement::Version.with_lock(project.id, repository) do
actions = build_actions
- [actions.map(&:design), actions.presence && run_actions(actions)]
+ [
+ actions.map(&:design),
+ actions.presence && run_actions(actions)
+ ]
end
end
diff --git a/app/services/feature_flags/base_service.rb b/app/services/feature_flags/base_service.rb
new file mode 100644
index 00000000000..9b27df90992
--- /dev/null
+++ b/app/services/feature_flags/base_service.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class BaseService < ::BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ AUDITABLE_ATTRIBUTES = %w(name description active).freeze
+
+ protected
+
+ def audit_event(feature_flag)
+ message = audit_message(feature_flag)
+
+ return if message.blank?
+
+ details =
+ {
+ custom_message: message,
+ target_id: feature_flag.id,
+ target_type: feature_flag.class.name,
+ target_details: feature_flag.name
+ }
+
+ ::AuditEventService.new(
+ current_user,
+ feature_flag.project,
+ details
+ )
+ end
+
+ def save_audit_event(audit_event)
+ return unless audit_event
+
+ audit_event.security_event
+ end
+
+ def created_scope_message(scope)
+ "Created rule <strong>#{scope.environment_scope}</strong> "\
+ "and set it as <strong>#{scope.active ? "active" : "inactive"}</strong> "\
+ "with strategies <strong>#{scope.strategies}</strong>."
+ end
+
+ def feature_flag_by_name
+ strong_memoize(:feature_flag_by_name) do
+ project.operations_feature_flags.find_by_name(params[:name])
+ end
+ end
+
+ def feature_flag_scope_by_environment_scope
+ strong_memoize(:feature_flag_scope_by_environment_scope) do
+ feature_flag_by_name.scopes.find_by_environment_scope(params[:environment_scope])
+ end
+ end
+ end
+end
diff --git a/app/services/feature_flags/create_service.rb b/app/services/feature_flags/create_service.rb
new file mode 100644
index 00000000000..b4ca90f7aae
--- /dev/null
+++ b/app/services/feature_flags/create_service.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class CreateService < FeatureFlags::BaseService
+ def execute
+ return error('Access Denied', 403) unless can_create?
+ return error('Version is invalid', :bad_request) unless valid_version?
+ return error('New version feature flags are not enabled for this project', :bad_request) unless flag_version_enabled?
+
+ ActiveRecord::Base.transaction do
+ feature_flag = project.operations_feature_flags.new(params)
+
+ if feature_flag.save
+ save_audit_event(audit_event(feature_flag))
+
+ success(feature_flag: feature_flag)
+ else
+ error(feature_flag.errors.full_messages, 400)
+ end
+ end
+ end
+
+ private
+
+ def audit_message(feature_flag)
+ message_parts = ["Created feature flag <strong>#{feature_flag.name}</strong>",
+ "with description <strong>\"#{feature_flag.description}\"</strong>."]
+
+ message_parts += feature_flag.scopes.map do |scope|
+ created_scope_message(scope)
+ end
+
+ message_parts.join(" ")
+ end
+
+ def can_create?
+ Ability.allowed?(current_user, :create_feature_flag, project)
+ end
+
+ def valid_version?
+ !params.key?(:version) || Operations::FeatureFlag.versions.key?(params[:version])
+ end
+
+ def flag_version_enabled?
+ params[:version] != 'new_version_flag' || new_version_feature_flags_enabled?
+ end
+
+ def new_version_feature_flags_enabled?
+ ::Feature.enabled?(:feature_flags_new_version, project, default_enabled: true)
+ end
+ end
+end
diff --git a/app/services/feature_flags/destroy_service.rb b/app/services/feature_flags/destroy_service.rb
new file mode 100644
index 00000000000..c77e3e03ec3
--- /dev/null
+++ b/app/services/feature_flags/destroy_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class DestroyService < FeatureFlags::BaseService
+ def execute(feature_flag)
+ destroy_feature_flag(feature_flag)
+ end
+
+ private
+
+ def destroy_feature_flag(feature_flag)
+ return error('Access Denied', 403) unless can_destroy?(feature_flag)
+
+ ActiveRecord::Base.transaction do
+ if feature_flag.destroy
+ save_audit_event(audit_event(feature_flag))
+
+ success(feature_flag: feature_flag)
+ else
+ error(feature_flag.errors.full_messages)
+ end
+ end
+ end
+
+ def audit_message(feature_flag)
+ "Deleted feature flag <strong>#{feature_flag.name}</strong>."
+ end
+
+ def can_destroy?(feature_flag)
+ Ability.allowed?(current_user, :destroy_feature_flag, feature_flag)
+ end
+ end
+end
diff --git a/app/services/feature_flags/disable_service.rb b/app/services/feature_flags/disable_service.rb
new file mode 100644
index 00000000000..8a443ac1795
--- /dev/null
+++ b/app/services/feature_flags/disable_service.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class DisableService < BaseService
+ def execute
+ return error('Feature Flag not found', 404) unless feature_flag_by_name
+ return error('Feature Flag Scope not found', 404) unless feature_flag_scope_by_environment_scope
+ return error('Strategy not found', 404) unless strategy_exist_in_persisted_data?
+
+ ::FeatureFlags::UpdateService
+ .new(project, current_user, update_params)
+ .execute(feature_flag_by_name)
+ end
+
+ private
+
+ def update_params
+ if remaining_strategies.empty?
+ params_to_destroy_scope
+ else
+ params_to_update_scope
+ end
+ end
+
+ def remaining_strategies
+ strong_memoize(:remaining_strategies) do
+ feature_flag_scope_by_environment_scope.strategies.reject do |strategy|
+ strategy['name'] == params[:strategy]['name'] &&
+ strategy['parameters'] == params[:strategy]['parameters']
+ end
+ end
+ end
+
+ def strategy_exist_in_persisted_data?
+ feature_flag_scope_by_environment_scope.strategies != remaining_strategies
+ end
+
+ def params_to_destroy_scope
+ { scopes_attributes: [{ id: feature_flag_scope_by_environment_scope.id, _destroy: true }] }
+ end
+
+ def params_to_update_scope
+ { scopes_attributes: [{ id: feature_flag_scope_by_environment_scope.id, strategies: remaining_strategies }] }
+ end
+ end
+end
diff --git a/app/services/feature_flags/enable_service.rb b/app/services/feature_flags/enable_service.rb
new file mode 100644
index 00000000000..b4cbb32e003
--- /dev/null
+++ b/app/services/feature_flags/enable_service.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class EnableService < BaseService
+ def execute
+ if feature_flag_by_name
+ update_feature_flag
+ else
+ create_feature_flag
+ end
+ end
+
+ private
+
+ def create_feature_flag
+ ::FeatureFlags::CreateService
+ .new(project, current_user, create_params)
+ .execute
+ end
+
+ def update_feature_flag
+ ::FeatureFlags::UpdateService
+ .new(project, current_user, update_params)
+ .execute(feature_flag_by_name)
+ end
+
+ def create_params
+ if params[:environment_scope] == '*'
+ params_to_create_flag_with_default_scope
+ else
+ params_to_create_flag_with_additional_scope
+ end
+ end
+
+ def update_params
+ if feature_flag_scope_by_environment_scope
+ params_to_update_scope
+ else
+ params_to_create_scope
+ end
+ end
+
+ def params_to_create_flag_with_default_scope
+ {
+ name: params[:name],
+ scopes_attributes: [
+ {
+ active: true,
+ environment_scope: '*',
+ strategies: [params[:strategy]]
+ }
+ ]
+ }
+ end
+
+ def params_to_create_flag_with_additional_scope
+ {
+ name: params[:name],
+ scopes_attributes: [
+ {
+ active: false,
+ environment_scope: '*'
+ },
+ {
+ active: true,
+ environment_scope: params[:environment_scope],
+ strategies: [params[:strategy]]
+ }
+ ]
+ }
+ end
+
+ def params_to_create_scope
+ {
+ scopes_attributes: [{
+ active: true,
+ environment_scope: params[:environment_scope],
+ strategies: [params[:strategy]]
+ }]
+ }
+ end
+
+ def params_to_update_scope
+ {
+ scopes_attributes: [{
+ id: feature_flag_scope_by_environment_scope.id,
+ active: true,
+ strategies: feature_flag_scope_by_environment_scope.strategies | [params[:strategy]]
+ }]
+ }
+ end
+ end
+end
diff --git a/app/services/feature_flags/update_service.rb b/app/services/feature_flags/update_service.rb
new file mode 100644
index 00000000000..c837e50b104
--- /dev/null
+++ b/app/services/feature_flags/update_service.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class UpdateService < FeatureFlags::BaseService
+ AUDITABLE_SCOPE_ATTRIBUTES_HUMAN_NAMES = {
+ 'active' => 'active state',
+ 'environment_scope' => 'environment scope',
+ 'strategies' => 'strategies'
+ }.freeze
+
+ def execute(feature_flag)
+ return error('Access Denied', 403) unless can_update?(feature_flag)
+
+ ActiveRecord::Base.transaction do
+ feature_flag.assign_attributes(params)
+
+ feature_flag.strategies.each do |strategy|
+ if strategy.name_changed? && strategy.name_was == ::Operations::FeatureFlags::Strategy::STRATEGY_GITLABUSERLIST
+ strategy.user_list = nil
+ end
+ end
+
+ audit_event = audit_event(feature_flag)
+
+ if feature_flag.save
+ save_audit_event(audit_event)
+
+ success(feature_flag: feature_flag)
+ else
+ error(feature_flag.errors.full_messages, :bad_request)
+ end
+ end
+ end
+
+ private
+
+ def audit_message(feature_flag)
+ changes = changed_attributes_messages(feature_flag)
+ changes += changed_scopes_messages(feature_flag)
+
+ return if changes.empty?
+
+ "Updated feature flag <strong>#{feature_flag.name}</strong>. " + changes.join(" ")
+ end
+
+ def changed_attributes_messages(feature_flag)
+ feature_flag.changes.slice(*AUDITABLE_ATTRIBUTES).map do |attribute_name, changes|
+ "Updated #{attribute_name} "\
+ "from <strong>\"#{changes.first}\"</strong> to "\
+ "<strong>\"#{changes.second}\"</strong>."
+ end
+ end
+
+ def changed_scopes_messages(feature_flag)
+ feature_flag.scopes.map do |scope|
+ if scope.new_record?
+ created_scope_message(scope)
+ elsif scope.marked_for_destruction?
+ deleted_scope_message(scope)
+ else
+ updated_scope_message(scope)
+ end
+ end.compact # updated_scope_message can return nil if nothing has been changed
+ end
+
+ def deleted_scope_message(scope)
+ "Deleted rule <strong>#{scope.environment_scope}</strong>."
+ end
+
+ def updated_scope_message(scope)
+ changes = scope.changes.slice(*AUDITABLE_SCOPE_ATTRIBUTES_HUMAN_NAMES.keys)
+ return if changes.empty?
+
+ message = "Updated rule <strong>#{scope.environment_scope}</strong> "
+ message += changes.map do |attribute_name, change|
+ name = AUDITABLE_SCOPE_ATTRIBUTES_HUMAN_NAMES[attribute_name]
+ "#{name} from <strong>#{change.first}</strong> to <strong>#{change.second}</strong>"
+ end.join(' ')
+
+ message + '.'
+ end
+
+ def can_update?(feature_flag)
+ Ability.allowed?(current_user, :update_feature_flag, feature_flag)
+ end
+ end
+end
diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb
index dcb32b4c84b..93a0d139001 100644
--- a/app/services/git/branch_hooks_service.rb
+++ b/app/services/git/branch_hooks_service.rb
@@ -76,12 +76,20 @@ module Git
def branch_change_hooks
enqueue_process_commit_messages
enqueue_jira_connect_sync_messages
+ enqueue_metrics_dashboard_sync
end
def branch_remove_hooks
project.repository.after_remove_branch(expire_cache: false)
end
+ def enqueue_metrics_dashboard_sync
+ return unless Feature.enabled?(:sync_metrics_dashboards, project)
+ return unless default_branch?
+
+ ::Metrics::Dashboard::SyncDashboardsWorker.perform_async(project.id)
+ end
+
# Schedules processing of commit messages
def enqueue_process_commit_messages
referencing_commits = limited_commits.select(&:matches_cross_reference_regex?)
diff --git a/app/services/git/wiki_push_service.rb b/app/services/git/wiki_push_service.rb
index fa3019ee9d6..87e2be858c0 100644
--- a/app/services/git/wiki_push_service.rb
+++ b/app/services/git/wiki_push_service.rb
@@ -34,9 +34,7 @@ module Git
def can_process_wiki_events?
# TODO: Support activity events for group wikis
# https://gitlab.com/gitlab-org/gitlab/-/issues/209306
- return false unless wiki.is_a?(ProjectWiki)
-
- Feature.enabled?(:wiki_events_on_git_push, wiki.container)
+ wiki.is_a?(ProjectWiki)
end
def push_changes
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index ce583095168..4747e1d5ac5 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -15,6 +15,8 @@ module Groups
after_build_hook(@group, params)
+ inherit_group_shared_runners_settings
+
unless can_use_visibility_level? && can_create_group?
return @group
end
@@ -28,9 +30,12 @@ module Groups
@group.build_chat_team(name: response['name'], team_id: response['id'])
end
- if @group.save
- @group.add_owner(current_user)
- add_settings_record
+ Group.transaction do
+ if @group.save
+ @group.add_owner(current_user)
+ @group.create_namespace_settings
+ Service.create_from_active_default_integrations(@group, :group_id) if Feature.enabled?(:group_level_integrations)
+ end
end
@group
@@ -84,8 +89,11 @@ module Groups
params[:visibility_level] = Gitlab::CurrentSettings.current_application_settings.default_group_visibility
end
- def add_settings_record
- @group.create_namespace_settings
+ def inherit_group_shared_runners_settings
+ return unless @group.parent
+
+ @group.shared_runners_enabled = @group.parent.shared_runners_enabled
+ @group.allow_descendants_override_disabled_shared_runners = @group.parent.allow_descendants_override_disabled_shared_runners
end
end
end
diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb
index a5c776f8fc2..a0ddc50e5e0 100644
--- a/app/services/groups/import_export/import_service.rb
+++ b/app/services/groups/import_export/import_service.rb
@@ -13,7 +13,7 @@ module Groups
end
def async_execute
- group_import_state = GroupImportState.safe_find_or_create_by!(group: group)
+ group_import_state = GroupImportState.safe_find_or_create_by!(group: group, user: current_user)
jid = GroupImportWorker.perform_async(current_user.id, group.id)
if jid.present?
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index 2bd571f60af..70f5c7e2ea7 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -103,6 +103,9 @@ module Groups
@group.parent = @new_parent_group
@group.clear_memoization(:self_and_ancestors_ids)
+
+ inherit_group_shared_runners_settings
+
@group.save!
end
@@ -161,6 +164,17 @@ module Groups
group_contains_npm_packages: s_('TransferGroup|Group contains projects with NPM packages.')
}.freeze
end
+
+ def inherit_group_shared_runners_settings
+ parent_setting = @group.parent&.shared_runners_setting
+ return unless parent_setting
+
+ if @group.shared_runners_setting_higher_than?(parent_setting)
+ result = Groups::UpdateSharedRunnersService.new(@group, current_user, shared_runners_setting: parent_setting).execute
+
+ raise TransferError, result[:message] unless result[:status] == :success
+ end
+ end
end
end
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index 81393681dc0..382a3dbf0f7 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -19,6 +19,8 @@ module Groups
return false unless valid_path_change_with_npm_packages?
+ return false unless update_shared_runners
+
before_assignment_hook(group, params)
group.assign_attributes(params)
@@ -98,6 +100,17 @@ module Groups
params[:share_with_group_lock] != group.share_with_group_lock
end
+
+ def update_shared_runners
+ return true if params[:shared_runners_setting].nil?
+
+ result = Groups::UpdateSharedRunnersService.new(group, current_user, shared_runners_setting: params.delete(:shared_runners_setting)).execute
+
+ return true if result[:status] == :success
+
+ group.errors.add(:update_shared_runners, result[:message])
+ false
+ end
end
end
diff --git a/app/services/groups/update_shared_runners_service.rb b/app/services/groups/update_shared_runners_service.rb
index 63f57104510..639c5bf6ae0 100644
--- a/app/services/groups/update_shared_runners_service.rb
+++ b/app/services/groups/update_shared_runners_service.rb
@@ -7,44 +7,24 @@ module Groups
validate_params
- enable_or_disable_shared_runners!
- allow_or_disallow_descendants_override_disabled_shared_runners!
+ update_shared_runners
success
- rescue Group::UpdateSharedRunnersError => error
+ rescue ActiveRecord::RecordInvalid, ArgumentError => error
error(error.message)
end
private
def validate_params
- if Gitlab::Utils.to_boolean(params[:shared_runners_enabled]) && !params[:allow_descendants_override_disabled_shared_runners].nil?
- raise Group::UpdateSharedRunnersError, 'Cannot set shared_runners_enabled to true and allow_descendants_override_disabled_shared_runners'
+ unless Namespace::SHARED_RUNNERS_SETTINGS.include?(params[:shared_runners_setting])
+ raise ArgumentError, "state must be one of: #{Namespace::SHARED_RUNNERS_SETTINGS.join(', ')}"
end
end
- def enable_or_disable_shared_runners!
- return if params[:shared_runners_enabled].nil?
-
- if Gitlab::Utils.to_boolean(params[:shared_runners_enabled])
- group.enable_shared_runners!
- else
- group.disable_shared_runners!
- end
- end
-
- def allow_or_disallow_descendants_override_disabled_shared_runners!
- return if params[:allow_descendants_override_disabled_shared_runners].nil?
-
- # Needs to reset group because if both params are present could result in error
- group.reset
-
- if Gitlab::Utils.to_boolean(params[:allow_descendants_override_disabled_shared_runners])
- group.allow_descendants_override_disabled_shared_runners!
- else
- group.disallow_descendants_override_disabled_shared_runners!
- end
+ def update_shared_runners
+ group.update_shared_runners_setting!(params[:shared_runners_setting])
end
end
end
diff --git a/app/services/incident_management/incidents/create_service.rb b/app/services/incident_management/incidents/create_service.rb
index 5b925e0f440..cff288d602b 100644
--- a/app/services/incident_management/incidents/create_service.rb
+++ b/app/services/incident_management/incidents/create_service.rb
@@ -24,7 +24,7 @@ module IncidentManagement
return error(issue.errors.full_messages.to_sentence, issue) unless issue.valid?
- issue.update_severity(severity)
+ update_severity_for(issue)
success(issue)
end
@@ -40,6 +40,10 @@ module IncidentManagement
def error(message, issue = nil)
ServiceResponse.error(payload: { issue: issue }, message: message)
end
+
+ def update_severity_for(issue)
+ ::IncidentManagement::Incidents::UpdateSeverityService.new(issue, current_user, severity).execute
+ end
end
end
end
diff --git a/app/services/incident_management/incidents/update_severity_service.rb b/app/services/incident_management/incidents/update_severity_service.rb
new file mode 100644
index 00000000000..5b150f3f02e
--- /dev/null
+++ b/app/services/incident_management/incidents/update_severity_service.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ module Incidents
+ class UpdateSeverityService < BaseService
+ def initialize(issuable, current_user, severity)
+ super(issuable.project, current_user)
+
+ @issuable = issuable
+ @severity = severity.to_s.downcase
+ @severity = IssuableSeverity::DEFAULT unless IssuableSeverity.severities.key?(@severity)
+ end
+
+ def execute
+ return unless issuable.incident?
+
+ update_severity!
+ add_system_note
+ end
+
+ private
+
+ attr_reader :issuable, :severity
+
+ def issuable_severity
+ issuable.issuable_severity || issuable.build_issuable_severity(issue_id: issuable.id)
+ end
+
+ def update_severity!
+ issuable_severity.update!(severity: severity)
+ end
+
+ def add_system_note
+ ::IncidentManagement::AddSeveritySystemNoteWorker.perform_async(issuable.id, current_user.id)
+ end
+ end
+ end
+end
diff --git a/app/services/incident_management/pager_duty/process_webhook_service.rb b/app/services/incident_management/pager_duty/process_webhook_service.rb
index fd8252f75fb..027425e4aaa 100644
--- a/app/services/incident_management/pager_duty/process_webhook_service.rb
+++ b/app/services/incident_management/pager_duty/process_webhook_service.rb
@@ -34,7 +34,7 @@ module IncidentManagement
strong_memoize(:pager_duty_processable_events) do
::PagerDuty::WebhookPayloadParser
.call(params.to_h)
- .filter { |msg| msg['event'].in?(PAGER_DUTY_PROCESSABLE_EVENT_TYPES) }
+ .filter { |msg| msg['event'].to_s.in?(PAGER_DUTY_PROCESSABLE_EVENT_TYPES) }
end
end
diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb
index b185ab592ff..c84074039ea 100644
--- a/app/services/issuable/clone/attributes_rewriter.rb
+++ b/app/services/issuable/clone/attributes_rewriter.rb
@@ -56,7 +56,7 @@ module Issuable
end
def copy_resource_weight_events
- return unless original_entity.respond_to?(:resource_weight_events)
+ return unless both_respond_to?(:resource_weight_events)
copy_events(ResourceWeightEvent.table_name, original_entity.resource_weight_events) do |event|
event.attributes
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 1672ba2830a..60e5293e218 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -322,7 +322,7 @@ class IssuableBaseService < BaseService
def change_severity(issuable)
if severity = params.delete(:severity)
- issuable.update_severity(severity)
+ ::IncidentManagement::Incidents::UpdateSeverityService.new(issuable, current_user, severity).execute
end
end
@@ -366,6 +366,7 @@ class IssuableBaseService < BaseService
}
associations[:total_time_spent] = issuable.total_time_spent if issuable.respond_to?(:total_time_spent)
associations[:description] = issuable.description
+ associations[:reviewers] = issuable.reviewers.to_a if issuable.allows_reviewers?
associations
end
diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb
index 60e0d1eec3d..e8747b9d6d8 100644
--- a/app/services/issues/move_service.rb
+++ b/app/services/issues/move_service.rb
@@ -23,11 +23,15 @@ module Issues
# to receive service desk emails on the new moved issue.
update_service_desk_sent_notifications
+ queue_copy_designs
+
new_entity
end
private
+ attr_reader :target_project
+
def update_service_desk_sent_notifications
return unless original_entity.from_service_desk?
@@ -46,7 +50,7 @@ module Issues
new_params = {
id: nil,
iid: nil,
- project: @target_project,
+ project: target_project,
author: original_entity.author,
assignee_ids: original_entity.assignee_ids
}
@@ -58,6 +62,23 @@ module Issues
CreateService.new(@target_project, @current_user, new_params).execute(skip_system_notes: true)
end
+ def queue_copy_designs
+ return unless copy_designs_enabled? && original_entity.designs.present?
+
+ response = DesignManagement::CopyDesignCollection::QueueService.new(
+ current_user,
+ original_entity,
+ new_entity
+ ).execute
+
+ log_error(response.message) if response.error?
+ end
+
+ def copy_designs_enabled?
+ Feature.enabled?(:design_management_copy_designs, old_project) &&
+ Feature.enabled?(:design_management_copy_designs, target_project)
+ end
+
def mark_as_moved
original_entity.update(moved_to: new_entity)
end
@@ -75,7 +96,7 @@ module Issues
end
def add_note_from
- SystemNoteService.noteable_moved(new_entity, @target_project,
+ SystemNoteService.noteable_moved(new_entity, target_project,
original_entity, current_user,
direction: :from)
end
diff --git a/app/services/lfs/push_service.rb b/app/services/lfs/push_service.rb
index 6e1a11ebff8..9b947fbed07 100644
--- a/app/services/lfs/push_service.rb
+++ b/app/services/lfs/push_service.rb
@@ -12,7 +12,7 @@ module Lfs
def execute
lfs_objects_relation.each_batch(of: BATCH_SIZE) do |objects|
- push_objects(objects)
+ push_objects!(objects)
end
success
@@ -30,8 +30,8 @@ module Lfs
project.lfs_objects_for_repository_types(nil, :project)
end
- def push_objects(objects)
- rsp = lfs_client.batch('upload', objects)
+ def push_objects!(objects)
+ rsp = lfs_client.batch!('upload', objects)
objects = objects.index_by(&:oid)
rsp.fetch('objects', []).each do |spec|
@@ -53,14 +53,14 @@ module Lfs
return
end
- lfs_client.upload(object, upload, authenticated: authenticated)
+ lfs_client.upload!(object, upload, authenticated: authenticated)
end
def verify_object!(object, spec)
- # TODO: the remote has requested that we make another call to verify that
- # the object has been sent correctly.
- # https://gitlab.com/gitlab-org/gitlab/-/issues/250654
- log_error("LFS upload verification requested, but not supported for #{object.oid}")
+ authenticated = spec['authenticated']
+ verify = spec.dig('actions', 'verify')
+
+ lfs_client.verify!(object, verify, authenticated: authenticated)
end
def url
diff --git a/app/services/members/invitation_reminder_email_service.rb b/app/services/members/invitation_reminder_email_service.rb
new file mode 100644
index 00000000000..e589cdc2fa3
--- /dev/null
+++ b/app/services/members/invitation_reminder_email_service.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Members
+ class InvitationReminderEmailService
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :invitation
+
+ MAX_INVITATION_LIFESPAN = 14.0
+ REMINDER_RATIO = [2, 5, 10].freeze
+
+ def initialize(invitation)
+ @invitation = invitation
+ end
+
+ def execute
+ return unless experiment_enabled?
+
+ reminder_index = days_on_which_to_send_reminders.index(days_after_invitation_sent)
+ return unless reminder_index
+
+ invitation.send_invitation_reminder(reminder_index)
+ end
+
+ private
+
+ def experiment_enabled?
+ Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, invitation.invite_email)
+ end
+
+ def days_after_invitation_sent
+ (Date.today - invitation.created_at.to_date).to_i
+ end
+
+ def days_on_which_to_send_reminders
+ # Don't send any reminders if the invitation has expired or expires today
+ return [] if invitation.expires_at && invitation.expires_at <= Date.today
+
+ # Calculate the number of days on which to send reminders based on the MAX_INVITATION_LIFESPAN and the REMINDER_RATIO
+ REMINDER_RATIO.map { |number_of_days| ((number_of_days * invitation_lifespan_in_days) / MAX_INVITATION_LIFESPAN).ceil }.uniq
+ end
+
+ def invitation_lifespan_in_days
+ # When the invitation lifespan is more than 14 days or does not expire, send the reminders within 14 days
+ strong_memoize(:invitation_lifespan_in_days) do
+ if invitation.expires_at
+ [(invitation.expires_at - invitation.created_at.to_date).to_i, MAX_INVITATION_LIFESPAN].min
+ else
+ MAX_INVITATION_LIFESPAN
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index abc3f99797d..aa591312c6a 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -110,6 +110,10 @@ module MergeRequests
return
end
+ unless merge_request.allows_multiple_reviewers?
+ params[:reviewer_ids] = params[:reviewer_ids].first(1)
+ end
+
reviewer_ids = params[:reviewer_ids].select { |reviewer_id| user_can_read?(merge_request, reviewer_id) }
if params[:reviewer_ids].map(&:to_s) == [IssuableFinder::Params::NONE]
@@ -130,6 +134,11 @@ module MergeRequests
merge_request, merge_request.project, current_user, old_assignees)
end
+ def create_reviewer_note(merge_request, old_reviewers)
+ SystemNoteService.change_issuable_reviewers(
+ merge_request, merge_request.project, current_user, old_reviewers)
+ end
+
def create_pipeline_for(merge_request, user)
MergeRequests::CreatePipelineService.new(project, user).execute(merge_request)
end
diff --git a/app/services/merge_requests/export_csv_service.rb b/app/services/merge_requests/export_csv_service.rb
new file mode 100644
index 00000000000..2755fc6687c
--- /dev/null
+++ b/app/services/merge_requests/export_csv_service.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class ExportCsvService
+ include Gitlab::Routing.url_helpers
+ include GitlabRoutingHelper
+
+ # Target attachment size before base64 encoding
+ TARGET_FILESIZE = 15.megabytes
+
+ def initialize(merge_requests)
+ @merge_requests = merge_requests
+ end
+
+ def csv_data
+ csv_builder.render(TARGET_FILESIZE)
+ end
+
+ private
+
+ def csv_builder
+ @csv_builder ||= CsvBuilder.new(@merge_requests.with_csv_entity_associations, header_to_value_hash)
+ end
+
+ def header_to_value_hash
+ {
+ 'MR IID' => 'iid',
+ 'URL' => -> (merge_request) { merge_request_url(merge_request) },
+ 'Title' => 'title',
+ 'State' => 'state',
+ 'Description' => 'description',
+ 'Source Branch' => 'source_branch',
+ 'Target Branch' => 'target_branch',
+ 'Source Project ID' => 'source_project_id',
+ 'Target Project ID' => 'target_project_id',
+ 'Author' => -> (merge_request) { merge_request.author.name },
+ 'Author Username' => -> (merge_request) { merge_request.author.username },
+ 'Assignees' => -> (merge_request) { merge_request.assignees.map(&:name).join(', ') },
+ 'Assignee Usernames' => -> (merge_request) { merge_request.assignees.map(&:username).join(', ') },
+ 'Approvers' => -> (merge_request) { merge_request.approved_by_users.map(&:name).join(', ') },
+ 'Approver Usernames' => -> (merge_request) { merge_request.approved_by_users.map(&:username).join(', ') },
+ 'Merged User' => -> (merge_request) { merge_request.metrics&.merged_by&.name.to_s },
+ 'Merged Username' => -> (merge_request) { merge_request.metrics&.merged_by&.username.to_s },
+ 'Milestone ID' => -> (merge_request) { merge_request&.milestone&.id || '' },
+ 'Created At (UTC)' => -> (merge_request) { merge_request.created_at.utc },
+ 'Updated At (UTC)' => -> (merge_request) { merge_request.updated_at.utc }
+ }
+ end
+ end
+end
diff --git a/app/services/merge_requests/ff_merge_service.rb b/app/services/merge_requests/ff_merge_service.rb
index 79011094e88..c5640047899 100644
--- a/app/services/merge_requests/ff_merge_service.rb
+++ b/app/services/merge_requests/ff_merge_service.rb
@@ -27,7 +27,7 @@ module MergeRequests
rescue StandardError => e
raise MergeError, "Something went wrong during merge: #{e.message}"
ensure
- merge_request.update(in_progress_merge_commit_sha: nil)
+ merge_request.update_and_mark_in_progress_merge_commit_sha(nil)
end
end
end
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index 437e87dadf7..ba22b458777 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -84,7 +84,7 @@ module MergeRequests
merge_request.update!(merge_commit_sha: commit_id)
ensure
- merge_request.update_column(:in_progress_merge_commit_sha, nil)
+ merge_request.update_and_mark_in_progress_merge_commit_sha(nil)
end
def try_merge
diff --git a/app/services/merge_requests/mergeability_check_service.rb b/app/services/merge_requests/mergeability_check_service.rb
index a3c39fa2e32..12c04772ef4 100644
--- a/app/services/merge_requests/mergeability_check_service.rb
+++ b/app/services/merge_requests/mergeability_check_service.rb
@@ -88,7 +88,7 @@ module MergeRequests
sleep_sec: retry_lease ? 1.second : 0
}
- in_lock(lease_key, lease_opts, &block)
+ in_lock(lease_key, **lease_opts, &block)
end
def payload
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index 405b8fe9c9e..0873c20b99c 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -184,7 +184,7 @@ module MergeRequests
def abort_auto_merge_with_todo(merge_request, reason)
response = abort_auto_merge(merge_request, reason)
- response = ServiceResponse.new(response)
+ response = ServiceResponse.new(**response)
return unless response.success?
todo_service.merge_request_became_unmergeable(merge_request)
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 1468bfd6bb6..8c069ea5bb0 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -112,6 +112,8 @@ module MergeRequests
end
def handle_reviewers_change(merge_request, old_reviewers)
+ create_reviewer_note(merge_request, old_reviewers)
+ notification_service.async.changed_reviewer_of_merge_request(merge_request, current_user, old_reviewers)
todo_service.reassigned_reviewable(merge_request, current_user, old_reviewers)
end
diff --git a/app/services/metrics/dashboard/custom_dashboard_service.rb b/app/services/metrics/dashboard/custom_dashboard_service.rb
index f0f19bf2ba3..bde8e86851a 100644
--- a/app/services/metrics/dashboard/custom_dashboard_service.rb
+++ b/app/services/metrics/dashboard/custom_dashboard_service.rb
@@ -42,6 +42,12 @@ module Metrics
def cache_key
"project_#{project.id}_metrics_dashboard_#{dashboard_path}"
end
+
+ def sequence
+ [
+ ::Gitlab::Metrics::Dashboard::Stages::CustomDashboardMetricsInserter
+ ] + super
+ end
end
end
end
diff --git a/app/services/namespace_settings/update_service.rb b/app/services/namespace_settings/update_service.rb
new file mode 100644
index 00000000000..3c9b7b637ac
--- /dev/null
+++ b/app/services/namespace_settings/update_service.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module NamespaceSettings
+ class UpdateService
+ include ::Gitlab::Allowable
+
+ attr_reader :current_user, :group, :settings_params
+
+ def initialize(current_user, group, settings)
+ @current_user = current_user
+ @group = group
+ @settings_params = settings
+ end
+
+ def execute
+ if group.namespace_settings
+ group.namespace_settings.attributes = settings_params
+ else
+ group.build_namespace_settings(settings_params)
+ end
+ end
+ end
+end
+
+NamespaceSettings::UpdateService.prepend_if_ee('EE::NamespaceSettings::UpdateService')
diff --git a/app/services/notification_recipients/builder/default.rb b/app/services/notification_recipients/builder/default.rb
index 790ce57452c..19527ba84e6 100644
--- a/app/services/notification_recipients/builder/default.rb
+++ b/app/services/notification_recipients/builder/default.rb
@@ -34,6 +34,9 @@ module NotificationRecipients
when :reassign_merge_request, :reassign_issue
add_recipients(previous_assignees, :mention, nil)
add_recipients(target.assignees, :mention, NotificationReason::ASSIGNED)
+ when :change_reviewer_merge_request
+ add_recipients(previous_assignees, :mention, nil)
+ add_recipients(target.reviewers, :mention, NotificationReason::REVIEW_REQUESTED)
end
add_subscribed_users
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 731d72c41d4..f343433360e 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -238,6 +238,33 @@ class NotificationService
end
end
+ # When we change reviewer in a merge_request we should send an email to:
+ #
+ # * merge_request old reviewers if their notification level is not Disabled
+ # * merge_request new reviewers if their notification level is not Disabled
+ # * users with custom level checked with "change reviewer merge request"
+ #
+ def changed_reviewer_of_merge_request(merge_request, current_user, previous_reviewers = [])
+ recipients = NotificationRecipients::BuildService.build_recipients(
+ merge_request,
+ current_user,
+ action: "change_reviewer",
+ previous_assignees: previous_reviewers
+ )
+
+ previous_reviewer_ids = previous_reviewers.map(&:id)
+
+ recipients.each do |recipient|
+ mailer.changed_reviewer_of_merge_request_email(
+ recipient.user.id,
+ merge_request.id,
+ previous_reviewer_ids,
+ current_user.id,
+ recipient.reason
+ ).deliver_later
+ end
+ end
+
# When we add labels to a merge request we should send an email to:
#
# * watchers of the mr's labels
diff --git a/app/services/packages/create_event_service.rb b/app/services/packages/create_event_service.rb
new file mode 100644
index 00000000000..d009cba2812
--- /dev/null
+++ b/app/services/packages/create_event_service.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Packages
+ class CreateEventService < BaseService
+ def execute
+ event_scope = scope.is_a?(::Packages::Package) ? scope.package_type : scope
+
+ ::Packages::Event.create!(
+ event_type: event_name,
+ originator: current_user&.id,
+ originator_type: originator_type,
+ event_scope: event_scope
+ )
+ end
+
+ private
+
+ def scope
+ params[:scope]
+ end
+
+ def event_name
+ params[:event_name]
+ end
+
+ def originator_type
+ case current_user
+ when User
+ :user
+ when DeployToken
+ :deploy_token
+ else
+ :guest
+ end
+ end
+ end
+end
diff --git a/app/services/packages/create_package_service.rb b/app/services/packages/create_package_service.rb
index 397a5f74e0a..e3b0ad218e2 100644
--- a/app/services/packages/create_package_service.rb
+++ b/app/services/packages/create_package_service.rb
@@ -10,6 +10,7 @@ module Packages
.with_package_type(package_type)
.safe_find_or_create_by!(name: name, version: version) do |pkg|
pkg.creator = package_creator
+ yield pkg if block_given?
end
end
diff --git a/app/services/packages/generic/create_package_file_service.rb b/app/services/packages/generic/create_package_file_service.rb
new file mode 100644
index 00000000000..4d49c63799f
--- /dev/null
+++ b/app/services/packages/generic/create_package_file_service.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Packages
+ module Generic
+ class CreatePackageFileService < BaseService
+ def execute
+ ::Packages::Package.transaction do
+ create_package_file(find_or_create_package)
+ end
+ end
+
+ private
+
+ def find_or_create_package
+ package_params = {
+ name: params[:package_name],
+ version: params[:package_version],
+ build: params[:build]
+ }
+
+ ::Packages::Generic::FindOrCreatePackageService
+ .new(project, current_user, package_params)
+ .execute
+ end
+
+ def create_package_file(package)
+ file_params = {
+ file: params[:file],
+ size: params[:file].size,
+ file_sha256: params[:file].sha256,
+ file_name: params[:file_name]
+ }
+
+ ::Packages::CreatePackageFileService.new(package, file_params).execute
+ end
+ end
+ end
+end
diff --git a/app/services/packages/generic/find_or_create_package_service.rb b/app/services/packages/generic/find_or_create_package_service.rb
new file mode 100644
index 00000000000..8a8459d167e
--- /dev/null
+++ b/app/services/packages/generic/find_or_create_package_service.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Packages
+ module Generic
+ class FindOrCreatePackageService < ::Packages::CreatePackageService
+ def execute
+ find_or_create_package!(::Packages::Package.package_types['generic']) do |package|
+ if params[:build].present?
+ package.build_info = Packages::BuildInfo.new(pipeline: params[:build].pipeline)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/pod_logs/base_service.rb b/app/services/pod_logs/base_service.rb
index 8936f9b67a5..e4b6ad31e33 100644
--- a/app/services/pod_logs/base_service.rb
+++ b/app/services/pod_logs/base_service.rb
@@ -10,6 +10,8 @@ module PodLogs
CACHE_KEY_GET_POD_LOG = 'get_pod_log'
K8S_NAME_MAX_LENGTH = 253
+ self.reactive_cache_work_type = :external_dependency
+
def id
cluster.id
end
diff --git a/app/services/pod_logs/elasticsearch_service.rb b/app/services/pod_logs/elasticsearch_service.rb
index f79562c8ab3..58d1bfbf835 100644
--- a/app/services/pod_logs/elasticsearch_service.rb
+++ b/app/services/pod_logs/elasticsearch_service.rb
@@ -11,7 +11,6 @@ module PodLogs
:pod_logs,
:filter_return_keys
- self.reactive_cache_work_type = :external_dependency
self.reactive_cache_worker_finder = ->(id, _cache_key, namespace, params) { new(::Clusters::Cluster.find(id), namespace, params: params) }
private
diff --git a/app/services/pod_logs/kubernetes_service.rb b/app/services/pod_logs/kubernetes_service.rb
index b573ceae1aa..03b84f98973 100644
--- a/app/services/pod_logs/kubernetes_service.rb
+++ b/app/services/pod_logs/kubernetes_service.rb
@@ -17,7 +17,6 @@ module PodLogs
:split_logs,
:filter_return_keys
- self.reactive_cache_work_type = :external_dependency
self.reactive_cache_worker_finder = ->(id, _cache_key, namespace, params) { new(::Clusters::Cluster.find(id), namespace, params: params) }
private
diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb
index bfce5f1ad63..affac45fc3d 100644
--- a/app/services/projects/alerting/notify_service.rb
+++ b/app/services/projects/alerting/notify_service.rb
@@ -7,43 +7,34 @@ module Projects
include ::IncidentManagement::Settings
def execute(token)
+ return bad_request unless valid_payload_size?
return forbidden unless alerts_service_activated?
return unauthorized unless valid_token?(token)
- alert = process_alert
+ process_alert
return bad_request unless alert.persisted?
- process_incident_issues(alert) if process_issues?
+ process_incident_issues if process_issues?
send_alert_email if send_email?
ServiceResponse.success
- rescue Gitlab::Alerting::NotificationPayloadParser::BadPayloadError
- bad_request
end
private
delegate :alerts_service, :alerts_service_activated?, to: :project
- def am_alert_params
- strong_memoize(:am_alert_params) do
- Gitlab::AlertManagement::AlertParams.from_generic_alert(project: project, payload: params.to_h)
- end
- end
-
def process_alert
- existing_alert = find_alert_by_fingerprint(am_alert_params[:fingerprint])
-
- if existing_alert
- process_existing_alert(existing_alert)
+ if alert.persisted?
+ process_existing_alert
else
create_alert
end
end
- def process_existing_alert(alert)
- if am_alert_params[:ended_at].present?
- process_resolved_alert(alert)
+ def process_existing_alert
+ if incoming_payload.ends_at.present?
+ process_resolved_alert
else
alert.register_new_event!
end
@@ -51,10 +42,10 @@ module Projects
alert
end
- def process_resolved_alert(alert)
+ def process_resolved_alert
return unless auto_close_incident?
- if alert.resolve(am_alert_params[:ended_at])
+ if alert.resolve(incoming_payload.ends_at)
close_issue(alert.issue)
end
@@ -72,20 +63,16 @@ module Projects
end
def create_alert
- alert = AlertManagement::Alert.create(am_alert_params.except(:ended_at))
- alert.execute_services if alert.persisted?
- SystemNoteService.create_new_alert(alert, 'Generic Alert Endpoint')
-
- alert
- end
-
- def find_alert_by_fingerprint(fingerprint)
- return unless fingerprint
+ return unless alert.save
- AlertManagement::Alert.not_resolved.for_fingerprint(project, fingerprint).first
+ alert.execute_services
+ SystemNoteService.create_new_alert(
+ alert,
+ alert.monitoring_tool || 'Generic Alert Endpoint'
+ )
end
- def process_incident_issues(alert)
+ def process_incident_issues
return if alert.issue
::IncidentManagement::ProcessAlertWorker.perform_async(nil, nil, alert.id)
@@ -94,11 +81,33 @@ module Projects
def send_alert_email
notification_service
.async
- .prometheus_alerts_fired(project, [parsed_payload])
+ .prometheus_alerts_fired(project, [alert.attributes])
+ end
+
+ def alert
+ strong_memoize(:alert) do
+ existing_alert || new_alert
+ end
+ end
+
+ def existing_alert
+ return unless incoming_payload.gitlab_fingerprint
+
+ AlertManagement::Alert.not_resolved.for_fingerprint(project, incoming_payload.gitlab_fingerprint).first
+ end
+
+ def new_alert
+ AlertManagement::Alert.new(**incoming_payload.alert_params, ended_at: nil)
+ end
+
+ def incoming_payload
+ strong_memoize(:incoming_payload) do
+ Gitlab::AlertManagement::Payload.parse(project, params.to_h)
+ end
end
- def parsed_payload
- Gitlab::Alerting::NotificationPayloadParser.call(params.to_h, project)
+ def valid_payload_size?
+ Gitlab::Utils::DeepSize.new(params).valid?
end
def valid_token?(token)
diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb
index 204a54ff23a..31500043544 100644
--- a/app/services/projects/container_repository/cleanup_tags_service.rb
+++ b/app/services/projects/container_repository/cleanup_tags_service.rb
@@ -25,7 +25,10 @@ module Projects
tag_names = tags.map(&:name)
Projects::ContainerRepository::DeleteTagsService
- .new(container_repository.project, current_user, tags: tag_names)
+ .new(container_repository.project,
+ current_user,
+ tags: tag_names,
+ container_expiration_policy: params['container_expiration_policy'])
.execute(container_repository)
end
diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb
index a23a6a369b2..9fc3ec0aafb 100644
--- a/app/services/projects/container_repository/delete_tags_service.rb
+++ b/app/services/projects/container_repository/delete_tags_service.rb
@@ -7,7 +7,10 @@ module Projects
def execute(container_repository)
@container_repository = container_repository
- return error('access denied') unless can?(current_user, :destroy_container_image, project)
+
+ unless params[:container_expiration_policy]
+ return error('access denied') unless can?(current_user, :destroy_container_image, project)
+ end
@tag_names = params[:tags]
return error('not tags specified') if @tag_names.blank?
@@ -23,9 +26,7 @@ module Projects
end
def delete_service
- fast_delete_enabled = Feature.enabled?(:container_registry_fast_tag_delete, default_enabled: true)
-
- if fast_delete_enabled && @container_repository.client.supports_tag_delete?
+ if @container_repository.client.supports_tag_delete?
::Projects::ContainerRepository::Gitlab::DeleteTagsService.new(@container_repository, @tag_names)
else
::Projects::ContainerRepository::ThirdParty::DeleteTagsService.new(@container_repository, @tag_names)
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 68b40fdd8f1..6fc8e8f8935 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -19,6 +19,10 @@ module Projects
@project = Project.new(params)
+ # If a project is newly created it should have shared runners settings
+ # based on its group having it enabled. This is like the "default value"
+ @project.shared_runners_enabled = false if !params.key?(:shared_runners_enabled) && @project.group && @project.group.shared_runners_setting != 'enabled'
+
# Make sure that the user is allowed to use the specified visibility level
if project_visibility.restricted?
deny_visibility_level(@project, project_visibility.visibility_level)
@@ -162,7 +166,7 @@ module Projects
if @project.save
unless @project.gitlab_project_import?
- create_services_from_active_instances_or_templates(@project)
+ Service.create_from_active_default_integrations(@project, :project_id, with_templates: true)
@project.create_labels
end
@@ -228,15 +232,6 @@ module Projects
private
- # rubocop: disable CodeReuse/ActiveRecord
- def create_services_from_active_instances_or_templates(project)
- Service.active.where(instance: true).or(Service.active.where(template: true)).group_by(&:type).each do |type, records|
- service = records.find(&:instance?) || records.find(&:template?)
- Service.build_from_integration(project.id, service).save!
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def project_namespace
@project_namespace ||= Namespace.find_by_id(@params[:namespace_id]) || current_user.namespace
end
diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb
index d32ead76d00..c002aca32db 100644
--- a/app/services/projects/prometheus/alerts/notify_service.rb
+++ b/app/services/projects/prometheus/alerts/notify_service.rb
@@ -125,7 +125,7 @@ module Projects
notification_service
.async
- .prometheus_alerts_fired(project, firings)
+ .prometheus_alerts_fired(project, alerts_attributes)
end
def process_prometheus_alerts
@@ -136,6 +136,18 @@ module Projects
end
end
+ def alerts_attributes
+ firings.map do |payload|
+ alert_params = Gitlab::AlertManagement::Payload.parse(
+ project,
+ payload,
+ monitoring_tool: Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus]
+ ).alert_params
+
+ AlertManagement::Alert.new(alert_params).attributes
+ end
+ end
+
def bad_request
ServiceResponse.error(message: 'Bad Request', http_status: :bad_request)
end
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index dba5177718d..013861631a1 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -88,6 +88,10 @@ module Projects
# Move uploads
move_project_uploads(project)
+ # If a project is being transferred to another group it means it can already
+ # have shared runners enabled but we need to check whether the new group allows that.
+ project.shared_runners_enabled = false if project.group && project.group.shared_runners_setting == 'disabled_and_unoverridable'
+
project.old_path_with_namespace = @old_path
update_repository_configuration(@new_path)
diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb
index 5c41f00aac2..40cf916e2f5 100644
--- a/app/services/projects/update_remote_mirror_service.rb
+++ b/app/services/projects/update_remote_mirror_service.rb
@@ -2,12 +2,14 @@
module Projects
class UpdateRemoteMirrorService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
MAX_TRIES = 3
def execute(remote_mirror, tries)
return success unless remote_mirror.enabled?
- if Gitlab::UrlBlocker.blocked_url?(CGI.unescape(Gitlab::UrlSanitizer.sanitize(remote_mirror.url)))
+ if Gitlab::UrlBlocker.blocked_url?(normalized_url(remote_mirror.url))
return error("The remote mirror URL is invalid.")
end
@@ -27,6 +29,12 @@ module Projects
private
+ def normalized_url(url)
+ strong_memoize(:normalized_url) do
+ CGI.unescape(Gitlab::UrlSanitizer.sanitize(url))
+ end
+ end
+
def update_mirror(remote_mirror)
remote_mirror.update_start!
remote_mirror.ensure_remote!
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index bb430811497..d44f5e637f1 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -135,8 +135,8 @@ module Projects
end
def ensure_wiki_exists
- ProjectWiki.new(project, project.owner).wiki
- rescue Wiki::CouldNotCreateWikiError
+ return if project.create_wiki
+
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').increment
end
diff --git a/app/services/quick_actions/target_service.rb b/app/services/quick_actions/target_service.rb
index 4273acfbf8b..a465632ccfb 100644
--- a/app/services/quick_actions/target_service.rb
+++ b/app/services/quick_actions/target_service.rb
@@ -17,12 +17,16 @@ module QuickActions
# rubocop: disable CodeReuse/ActiveRecord
def issue(type_id)
+ return project.issues.build if type_id.nil?
+
IssuesFinder.new(current_user, project_id: project.id).find_by(iid: type_id) || project.issues.build
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def merge_request(type_id)
+ return project.merge_requests.build if type_id.nil?
+
MergeRequestsFinder.new(current_user, project_id: project.id).find_by(iid: type_id) || project.merge_requests.build
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb
index c253154c1b7..4ff8973773d 100644
--- a/app/services/resource_access_tokens/create_service.rb
+++ b/app/services/resource_access_tokens/create_service.rb
@@ -32,20 +32,11 @@ module ResourceAccessTokens
attr_reader :resource_type, :resource
def feature_enabled?
- return false if ::Gitlab.com?
-
- ::Feature.enabled?(:resource_access_token, resource, default_enabled: true)
+ return true unless ::Gitlab.com?
end
def has_permission_to_create?
- case resource_type
- when 'project'
- can?(current_user, :admin_project, resource)
- when 'group'
- can?(current_user, :admin_group, resource)
- else
- false
- end
+ %w(project group).include?(resource_type) && can?(current_user, :admin_resource_access_tokens, resource)
end
def create_user
diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb
index fab02697cf0..5f80b07aa59 100644
--- a/app/services/search/global_service.rb
+++ b/app/services/search/global_service.rb
@@ -4,6 +4,8 @@ module Search
class GlobalService
include Gitlab::Utils::StrongMemoize
+ ALLOWED_SCOPES = %w(issues merge_requests milestones users).freeze
+
attr_accessor :current_user, :params
def initialize(user, params)
@@ -14,7 +16,8 @@ module Search
Gitlab::SearchResults.new(current_user,
params[:search],
projects,
- filters: { state: params[:state] })
+ sort: params[:sort],
+ filters: { state: params[:state], confidential: params[:confidential] })
end
def projects
@@ -22,10 +25,7 @@ module Search
end
def allowed_scopes
- strong_memoize(:allowed_scopes) do
- allowed_scopes = %w[issues merge_requests milestones]
- allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true)
- end
+ ALLOWED_SCOPES
end
def scope
diff --git a/app/services/search/group_service.rb b/app/services/search/group_service.rb
index 68778aa2768..24409a04e74 100644
--- a/app/services/search/group_service.rb
+++ b/app/services/search/group_service.rb
@@ -16,7 +16,7 @@ module Search
params[:search],
projects,
group: group,
- filters: { state: params[:state] }
+ filters: { state: params[:state], confidential: params[:confidential] }
)
end
diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb
index 5eba909c23b..b1142b816d0 100644
--- a/app/services/search/project_service.rb
+++ b/app/services/search/project_service.rb
@@ -2,6 +2,10 @@
module Search
class ProjectService
+ include Gitlab::Utils::StrongMemoize
+
+ ALLOWED_SCOPES = %w(notes issues merge_requests milestones wiki_blobs commits users).freeze
+
attr_accessor :project, :current_user, :params
def initialize(project, user, params)
@@ -13,15 +17,17 @@ module Search
params[:search],
project: project,
repository_ref: params[:repository_ref],
- filters: { state: params[:state] })
+ filters: { confidential: params[:confidential], state: params[:state] }
+ )
end
- def scope
- @scope ||= begin
- allowed_scopes = %w[notes issues merge_requests milestones wiki_blobs commits]
- allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true)
+ def allowed_scopes
+ ALLOWED_SCOPES
+ end
- allowed_scopes.delete(params[:scope]) { 'blobs' }
+ def scope
+ strong_memoize(:scope) do
+ allowed_scopes.include?(params[:scope]) ? params[:scope] : 'blobs'
end
end
end
diff --git a/app/services/snippets/base_service.rb b/app/services/snippets/base_service.rb
index 53a04e5a398..278857b7933 100644
--- a/app/services/snippets/base_service.rb
+++ b/app/services/snippets/base_service.rb
@@ -4,6 +4,9 @@ module Snippets
class BaseService < ::BaseService
include SpamCheckMethods
+ UPDATE_COMMIT_MSG = 'Update snippet'
+ INITIAL_COMMIT_MSG = 'Initial commit'
+
CreateRepositoryError = Class.new(StandardError)
attr_reader :uploaded_assets, :snippet_actions
@@ -85,5 +88,20 @@ module Snippets
def restricted_files_actions
nil
end
+
+ def commit_attrs(snippet, msg)
+ {
+ branch_name: snippet.default_branch,
+ message: msg
+ }
+ end
+
+ def delete_repository(snippet)
+ snippet.repository.remove
+ snippet.snippet_repository&.delete
+
+ # Purge any existing value for repository_exists?
+ snippet.repository.expire_exists_cache
+ end
end
end
diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb
index 5c9b2eb1aea..d7181883c39 100644
--- a/app/services/snippets/create_service.rb
+++ b/app/services/snippets/create_service.rb
@@ -59,7 +59,7 @@ module Snippets
log_error(e.message)
# If the commit action failed we need to remove the repository if exists
- @snippet.repository.remove if @snippet.repository_exists?
+ delete_repository(@snippet) if @snippet.repository_exists?
# If the snippet was created, we need to remove it as we
# would do like if it had had any validation error
@@ -81,12 +81,9 @@ module Snippets
end
def create_commit
- commit_attrs = {
- branch_name: @snippet.default_branch,
- message: 'Initial commit'
- }
+ attrs = commit_attrs(@snippet, INITIAL_COMMIT_MSG)
- @snippet.snippet_repository.multi_files_action(current_user, files_to_commit(@snippet), commit_attrs)
+ @snippet.snippet_repository.multi_files_action(current_user, files_to_commit(@snippet), **attrs)
end
def move_temporary_files
diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb
index a0e9ab6ffda..0115cd19287 100644
--- a/app/services/snippets/update_service.rb
+++ b/app/services/snippets/update_service.rb
@@ -37,7 +37,10 @@ module Snippets
# is implemented.
# Once we can perform different operations through this service
# we won't need to keep track of the `content` and `file_name` fields
- if snippet_actions.any?
+ #
+ # If the repository does not exist we don't need to update `params`
+ # because we need to commit the information from the database
+ if snippet_actions.any? && snippet.repository_exists?
params[:content] = snippet_actions[0].content if snippet_actions[0].content
params[:file_name] = snippet_actions[0].file_path
end
@@ -52,7 +55,11 @@ module Snippets
# the repository we can just return
return true unless committable_attributes?
- create_repository_for(snippet)
+ unless snippet.repository_exists?
+ create_repository_for(snippet)
+ create_first_commit_using_db_data(snippet)
+ end
+
create_commit(snippet)
true
@@ -72,13 +79,7 @@ module Snippets
# If the commit action failed we remove it because
# we don't want to leave empty repositories
# around, to allow cloning them.
- if repository_empty?(snippet)
- snippet.repository.remove
- snippet.snippet_repository&.delete
- end
-
- # Purge any existing value for repository_exists?
- snippet.repository.expire_exists_cache
+ delete_repository(snippet) if repository_empty?(snippet)
false
end
@@ -89,15 +90,25 @@ module Snippets
raise CreateRepositoryError, 'Repository could not be created' unless snippet.repository_exists?
end
+ # If the user provides `snippet_actions` and the repository
+ # does not exist, we need to commit first the snippet info stored
+ # in the database. Mostly because the content inside `snippet_actions`
+ # would assume that the file is already in the repository.
+ def create_first_commit_using_db_data(snippet)
+ return if snippet_actions.empty?
+
+ attrs = commit_attrs(snippet, INITIAL_COMMIT_MSG)
+ actions = [{ file_path: snippet.file_name, content: snippet.content }]
+
+ snippet.snippet_repository.multi_files_action(current_user, actions, **attrs)
+ end
+
def create_commit(snippet)
raise UpdateError unless snippet.snippet_repository
- commit_attrs = {
- branch_name: snippet.default_branch,
- message: 'Update snippet'
- }
+ attrs = commit_attrs(snippet, UPDATE_COMMIT_MSG)
- snippet.snippet_repository.multi_files_action(current_user, files_to_commit(snippet), commit_attrs)
+ snippet.snippet_repository.multi_files_action(current_user, files_to_commit(snippet), **attrs)
end
# Because we are removing repositories we don't want to remove
diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb
index b745b67f566..ab83fc401e9 100644
--- a/app/services/spam/spam_action_service.rb
+++ b/app/services/spam/spam_action_service.rb
@@ -45,7 +45,7 @@ module Spam
attr_reader :user, :context
def allowlisted?(user)
- user.respond_to?(:gitlab_employee) && user.gitlab_employee?
+ user.try(:gitlab_employee?) || user.try(:gitlab_bot?)
end
def perform_spam_service_check(api)
diff --git a/app/services/static_site_editor/config_service.rb b/app/services/static_site_editor/config_service.rb
index 987ee071976..7b3115468a5 100644
--- a/app/services/static_site_editor/config_service.rb
+++ b/app/services/static_site_editor/config_service.rb
@@ -4,18 +4,38 @@ module StaticSiteEditor
class ConfigService < ::BaseContainerService
ValidationError = Class.new(StandardError)
- def execute
+ def initialize(container:, current_user: nil, params: {})
+ super
+
@project = container
+ @repository = project.repository
+ @ref = params.fetch(:ref)
+ end
+
+ def execute
check_access!
+ file_config = load_file_config!
+ file_data = file_config.to_hash_with_defaults
+ generated_data = load_generated_config.data
+
+ check_for_duplicate_keys!(generated_data, file_data)
+ data = merged_data(generated_data, file_data)
+
ServiceResponse.success(payload: data)
rescue ValidationError => e
ServiceResponse.error(message: e.message)
+ rescue => e
+ Gitlab::ErrorTracking.track_and_raise_exception(e)
end
private
- attr_reader :project
+ attr_reader :project, :repository, :ref
+
+ def static_site_editor_config_file
+ '.gitlab/static-site-editor.yml'
+ end
def check_access!
unless can?(current_user, :download_code, project)
@@ -23,27 +43,43 @@ module StaticSiteEditor
end
end
- def data
- check_for_duplicate_keys!
- generated_data.merge(file_data)
+ def load_file_config!
+ yaml = yaml_from_repo.presence || '{}'
+ file_config = Gitlab::StaticSiteEditor::Config::FileConfig.new(yaml)
+
+ unless file_config.valid?
+ raise ValidationError, file_config.errors.first
+ end
+
+ file_config
+ rescue Gitlab::StaticSiteEditor::Config::FileConfig::ConfigError => e
+ raise ValidationError, e.message
end
- def generated_data
- @generated_data ||= Gitlab::StaticSiteEditor::Config::GeneratedConfig.new(
- project.repository,
- params.fetch(:ref),
+ def load_generated_config
+ Gitlab::StaticSiteEditor::Config::GeneratedConfig.new(
+ repository,
+ ref,
params.fetch(:path),
params[:return_url]
- ).data
- end
-
- def file_data
- @file_data ||= Gitlab::StaticSiteEditor::Config::FileConfig.new.data
+ )
end
- def check_for_duplicate_keys!
+ def check_for_duplicate_keys!(generated_data, file_data)
duplicate_keys = generated_data.keys & file_data.keys
raise ValidationError.new("Duplicate key(s) '#{duplicate_keys}' found.") if duplicate_keys.present?
end
+
+ def merged_data(generated_data, file_data)
+ generated_data.merge(file_data)
+ end
+
+ def yaml_from_repo
+ repository.blob_data_at(ref, static_site_editor_config_file)
+ rescue GRPC::NotFound
+ # Return nil in the case of a GRPC::NotFound exception, so the default config will be used.
+ # Allow any other unexpected exception will be tracked and re-raised.
+ nil
+ end
end
end
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index df042fdc393..1a4374f2e94 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -41,6 +41,10 @@ module SystemNoteService
::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).change_issuable_assignees(old_assignees)
end
+ def change_issuable_reviewers(issuable, project, author, old_reviewers)
+ ::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).change_issuable_reviewers(old_reviewers)
+ end
+
def relate_issue(noteable, noteable_ref, user)
::SystemNotes::IssuablesService.new(noteable: noteable, project: noteable.project, author: user).relate_issue(noteable_ref)
end
@@ -308,6 +312,10 @@ module SystemNoteService
::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project).create_new_alert(monitoring_tool)
end
+ def change_incident_severity(incident, author)
+ ::SystemNotes::IncidentService.new(noteable: incident, project: incident.project, author: author).change_incident_severity
+ end
+
private
def merge_requests_service(noteable, project, author)
diff --git a/app/services/system_notes/incident_service.rb b/app/services/system_notes/incident_service.rb
new file mode 100644
index 00000000000..4628662f0e9
--- /dev/null
+++ b/app/services/system_notes/incident_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module SystemNotes
+ class IncidentService < ::SystemNotes::BaseService
+ # Called when the severity of an Incident has changed
+ #
+ # Example Note text:
+ #
+ # "changed the severity to Medium - S3"
+ #
+ # Returns the created Note object
+ def change_incident_severity
+ severity = noteable.severity
+
+ if severity_label = IssuableSeverity::SEVERITY_LABELS[severity.to_sym]
+ body = "changed the severity to **#{severity_label}**"
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'severity'))
+ else
+ Gitlab::AppLogger.error(
+ message: 'Cannot create a system note for severity change',
+ noteable_class: noteable.class.to_s,
+ noteable_id: noteable.id,
+ severity: severity
+ )
+ end
+ end
+ end
+end
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
index 2252503d97e..784bd6b9699 100644
--- a/app/services/system_notes/issuables_service.rb
+++ b/app/services/system_notes/issuables_service.rb
@@ -81,6 +81,32 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee'))
end
+ # Called when the reviewers of an issuable is changed or removed
+ #
+ # reviewers - Users being requested to review, or nil
+ #
+ # Example Note text:
+ #
+ # "requested review from @user1 and @user2"
+ #
+ # "requested review from @user1, @user2 and @user3 and removed review request for @user4 and @user5"
+ #
+ # Returns the created Note object
+ def change_issuable_reviewers(old_reviewers)
+ unassigned_users = old_reviewers - noteable.reviewers
+ added_users = noteable.reviewers - old_reviewers
+ text_parts = []
+
+ Gitlab::I18n.with_default_locale do
+ text_parts << "requested review from #{added_users.map(&:to_reference).to_sentence}" if added_users.any?
+ text_parts << "removed review request for #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any?
+ end
+
+ body = text_parts.join(' and ')
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'reviewer'))
+ end
+
# Called when the title of a Noteable is changed
#
# old_title - Previous String title
@@ -242,19 +268,7 @@ module SystemNotes
#
# Returns the created Note object
def change_status(status, source = nil)
- body = status.dup
- body << " via #{source.gfm_reference(project)}" if source
-
- action = status == 'reopened' ? 'opened' : status
-
- # A state event which results in a synthetic note will be
- # created by EventCreateService if change event tracking
- # is enabled.
- if state_change_tracking_enabled?
- create_resource_state_event(status: status, mentionable_source: source)
- else
- create_note(NoteSummary.new(noteable, project, author, body, action: action))
- end
+ create_resource_state_event(status: status, mentionable_source: source)
end
# Check if a cross reference to a noteable from a mentioner already exists
@@ -312,23 +326,11 @@ module SystemNotes
end
def close_after_error_tracking_resolve
- if state_change_tracking_enabled?
- create_resource_state_event(status: 'closed', close_after_error_tracking_resolve: true)
- else
- body = 'resolved the corresponding error and closed the issue.'
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'closed'))
- end
+ create_resource_state_event(status: 'closed', close_after_error_tracking_resolve: true)
end
def auto_resolve_prometheus_alert
- if state_change_tracking_enabled?
- create_resource_state_event(status: 'closed', close_auto_resolve_prometheus_alert: true)
- else
- body = 'automatically closed this issue because the alert resolved.'
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'closed'))
- end
+ create_resource_state_event(status: 'closed', close_auto_resolve_prometheus_alert: true)
end
private
@@ -361,11 +363,6 @@ module SystemNotes
.execute(params)
end
- def state_change_tracking_enabled?
- noteable.respond_to?(:resource_state_events) &&
- ::Feature.enabled?(:track_resource_state_change_events, noteable.project, default_enabled: true)
- end
-
def issue_activity_counter
Gitlab::UsageDataCounters::IssueActivityUniqueCounter
end
diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb
index 2fc46f033dd..e3f02bf85f0 100644
--- a/app/services/users/build_service.rb
+++ b/app/services/users/build_service.rb
@@ -104,7 +104,6 @@ module Users
def build_user_params(skip_authorization:)
if current_user&.admin?
user_params = params.slice(*admin_create_params)
- user_params[:created_by_id] = current_user&.id
if params[:reset_password]
user_params.merge!(force_random_password: true, password_expires_at: nil)
@@ -125,6 +124,8 @@ module Users
end
end
+ user_params[:created_by_id] = current_user&.id
+
if user_default_internal_regex_enabled? && !user_params.key?(:external)
user_params[:external] = user_external?
end
diff --git a/app/uploaders/pages/deployment_uploader.rb b/app/uploaders/pages/deployment_uploader.rb
new file mode 100644
index 00000000000..4fe1a548a05
--- /dev/null
+++ b/app/uploaders/pages/deployment_uploader.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Pages
+ class DeploymentUploader < GitlabUploader
+ include ObjectStorage::Concern
+
+ storage_options Gitlab.config.pages
+
+ alias_method :upload, :model
+
+ private
+
+ def dynamic_segment
+ Gitlab::HashedPath.new('pages_deployments', model.id, root_hash: model.project_id)
+ end
+
+ # @hashed is chosen to avoid conflict with namespace name because we use the same directory for storage
+ # @ is not valid character for namespace
+ def base_dir
+ "@hashed"
+ end
+ end
+end
diff --git a/app/validators/addressable_url_validator.rb b/app/validators/addressable_url_validator.rb
index 9fa99903e36..c6d9bd73566 100644
--- a/app/validators/addressable_url_validator.rb
+++ b/app/validators/addressable_url_validator.rb
@@ -80,7 +80,7 @@ class AddressableUrlValidator < ActiveModel::EachValidator
value = strip_value!(record, attribute, value)
- Gitlab::UrlBlocker.validate!(value, blocker_args)
+ Gitlab::UrlBlocker.validate!(value, **blocker_args)
rescue Gitlab::UrlBlocker::BlockedUrlError => e
record.errors.add(attribute, options.fetch(:blocked_message) % { exception_message: e.message })
end
diff --git a/app/validators/ip_address_validator.rb b/app/validators/ip_address_validator.rb
new file mode 100644
index 00000000000..0acf2bdf4fc
--- /dev/null
+++ b/app/validators/ip_address_validator.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+# IpAddressValidator
+#
+# Validates that an IP address is a valid IPv4 or IPv6 address.
+# This should be coupled with a database column of type `inet`
+#
+# When using column type `inet` Rails will silently return the value
+# as `nil` when the value is not valid according to its type cast
+# using `IpAddr`. It's not very user friendly to return an error
+# "IP Address can't be blank" when a value was clearly given but
+# was not the right format. This validator will look at the value
+# before Rails type casts it when the value itself is `nil`.
+# This enables the validator to return a specific and useful error message.
+#
+# This validator allows `nil` values by default since the database
+# allows null values by default. To disallow `nil` values, use in conjunction
+# with `presence: true`.
+#
+# Do not use this validator with `allow_nil: true` or `allow_blank: true`.
+# Because of Rails type casting, when an invalid value is set the attribute
+# will return `nil` and Rails won't run this validator.
+#
+# Example:
+#
+# class Group < ActiveRecord::Base
+# validates :ip_address, presence: true, ip_address: true
+# end
+#
+class IpAddressValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, _)
+ value = record.public_send("#{attribute}_before_type_cast") # rubocop:disable GitlabSecurity/PublicSend
+ return if value.blank?
+
+ IPAddress.parse(value.to_s)
+ rescue ArgumentError
+ record.errors.add(attribute, _('must be a valid IPv4 or IPv6 address'))
+ end
+end
diff --git a/app/views/admin/application_settings/_abuse.html.haml b/app/views/admin/application_settings/_abuse.html.haml
index ddffec32c41..6ffeea81f28 100644
--- a/app/views/admin/application_settings/_abuse.html.haml
+++ b/app/views/admin/application_settings/_abuse.html.haml
@@ -3,8 +3,8 @@
%fieldset
.form-group
- = f.label :admin_notification_email, 'Abuse reports notification email', class: 'label-bold'
- = f.text_field :admin_notification_email, class: 'form-control'
+ = f.label :abuse_notification_email, 'Abuse reports notification email', class: 'label-bold'
+ = f.text_field :abuse_notification_email, class: 'form-control'
.form-text.text-muted
Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area.
diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml
index 184249bcaba..1701eb5b6e4 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -60,5 +60,4 @@
= render_if_exists 'admin/application_settings/updating_name_disabled_for_users', form: f
= render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: 'btn btn-success qa-save-changes-button'
+ = f.submit _('Save changes'), class: 'btn btn-success qa-save-changes-button'
diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml
index 1bf25b6a558..734640c16a1 100644
--- a/app/views/admin/application_settings/_diff_limits.html.haml
+++ b/app/views/admin/application_settings/_diff_limits.html.haml
@@ -12,5 +12,4 @@
= link_to sprite_icon('question-o'),
help_page_path('user/admin_area/diff_limits',
anchor: 'maximum-diff-patch-size')
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: 'btn btn-success'
+ = f.submit _('Save changes'), class: 'btn btn-success'
diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
index 179eb2d5f2e..08620bdde35 100644
--- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml
+++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
@@ -47,5 +47,4 @@
.form-group
= f.label :external_authorization_service_default_label, _('Default classification label'), class: 'label-bold'
= f.text_field :external_authorization_service_default_label, class: 'form-control'
- .gl-display-flex.gl-justify-content-end
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml
index bbad5155ada..cf40eb7b108 100644
--- a/app/views/admin/application_settings/_gitpod.html.haml
+++ b/app/views/admin/application_settings/_gitpod.html.haml
@@ -1,6 +1,5 @@
- return unless Gitlab::Gitpod.feature_available?
- expanded = integration_expanded?('gitpod_')
-- gitpod_link = link_to("Gitpod#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}".html_safe, 'https://gitpod.io/', target: '_blank', rel: 'noopener noreferrer')
%section.settings.no-animate#js-gitpod-settings{ class: ('expanded' if expanded) }
.settings-header
@@ -9,7 +8,7 @@
%button.btn.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
- = s_('Enable %{gitpod_link} integration to launch a development environment in your browser directly from GitLab.').html_safe % { gitpod_link: gitpod_link }
+ = gitpod_enable_description
= link_to sprite_icon('question-o'), help_page_path('integration/gitpod.md'), target: '_blank', class: 'has-tooltip', title: _('More information')
diff --git a/app/views/admin/application_settings/_initial_branch_name.html.haml b/app/views/admin/application_settings/_initial_branch_name.html.haml
index acbf971e4b9..bab841fcade 100644
--- a/app/views/admin/application_settings/_initial_branch_name.html.haml
+++ b/app/views/admin/application_settings/_initial_branch_name.html.haml
@@ -10,5 +10,4 @@
%span.form-text.text-muted
= (_("Changes affect new repositories only. If not specified, Git's default name %{branch_name_default} will be used.") % { branch_name_default: fallback_branch_name } ).html_safe
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: 'gl-button btn-success'
+ = f.submit _('Save changes'), class: 'gl-button btn-success'
diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml
index 6f9d3a889cd..e6874cc3f78 100644
--- a/app/views/admin/application_settings/_repository_check.html.haml
+++ b/app/views/admin/application_settings/_repository_check.html.haml
@@ -18,8 +18,7 @@
If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database.
- clear_repository_checks_link = _('Clear all repository checks')
- clear_repository_checks_message = _('This will clear repository check states for ALL projects in the database. This cannot be undone. Are you sure?')
- .gl-display-flex.gl-justify-content-end
- = link_to clear_repository_checks_link, clear_repository_check_states_admin_application_settings_path, data: { confirm: clear_repository_checks_message }, method: :put, class: "btn btn-sm btn-remove"
+ = link_to clear_repository_checks_link, clear_repository_check_states_admin_application_settings_path, data: { confirm: clear_repository_checks_message }, method: :put, class: "btn btn-sm btn-remove"
.sub-section
%h4 Housekeeping
@@ -56,5 +55,4 @@
.form-text.text-muted
Number of Git pushes after which 'git gc' is run.
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
index d598f173ff3..86f8ea8821e 100644
--- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml
+++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
@@ -14,5 +14,4 @@
= render_if_exists 'admin/application_settings/mirror_settings', form: f
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_repository_static_objects.html.haml b/app/views/admin/application_settings/_repository_static_objects.html.haml
index 9bc751adc8b..03aa48b2282 100644
--- a/app/views/admin/application_settings/_repository_static_objects.html.haml
+++ b/app/views/admin/application_settings/_repository_static_objects.html.haml
@@ -15,5 +15,4 @@
%span.form-text.text-muted#static_objects_external_storage_auth_token_help_block
= _('A secure token that identifies an external storage request.')
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success"
+ = 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
index 0dc8dc0740e..71c957b0bea 100644
--- a/app/views/admin/application_settings/_repository_storage.html.haml
+++ b/app/views/admin/application_settings/_repository_storage.html.haml
@@ -22,5 +22,4 @@
= f.text_field attribute[:name], class: 'form-text-input', value: attribute[:value]
= f.label attribute[:label], attribute[:label], class: 'label-bold form-check-label'
%br
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success qa-save-changes-button"
+ = f.submit _('Save changes'), class: "btn btn-success qa-save-changes-button"
diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml
index 2a26a0909fd..f2ff3891ace 100644
--- a/app/views/admin/application_settings/_signin.html.haml
+++ b/app/views/admin/application_settings/_signin.html.haml
@@ -57,5 +57,4 @@
= f.label :sign_in_text, class: 'label-bold'
= f.text_area :sign_in_text, class: 'form-control', rows: 4
.form-text.text-muted Markdown enabled
- .gl-display-flex.gl-justify-content-end
- = f.submit 'Save changes', class: "btn btn-success"
+ = 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
index 3b88696dc51..adc585125a6 100644
--- a/app/views/admin/application_settings/_signup.html.haml
+++ b/app/views/admin/application_settings/_signup.html.haml
@@ -9,6 +9,14 @@
Sign-up enabled
.form-text.text-muted
= _("When enabled, any user visiting %{host} will be able to create an account.") % { host: "#{new_user_session_url(host: Gitlab.config.gitlab.host)}" }
+ - if Feature.enabled?(:admin_approval_for_new_user_signups)
+ .form-group
+ .form-check
+ = f.check_box :require_admin_approval_after_user_signup, class: 'form-check-input'
+ = f.label :require_admin_approval_after_user_signup, class: 'form-check-label' do
+ = _('Require admin approval for new sign-ups')
+ .form-text.text-muted
+ = _("When enabled, any user visiting %{host} and creating an account will have to be explicitly approved by the admin before they can login. This setting is effective only if sign-ups are enabled.") % { host: "#{new_user_session_url(host: Gitlab.config.gitlab.host)}" }
.form-group
.form-check
= f.check_box :send_user_confirmation_email, class: 'form-check-input'
@@ -67,5 +75,4 @@
= f.label :after_sign_up_text, class: 'label-bold'
= f.text_area :after_sign_up_text, class: 'form-control', rows: 4
.form-text.text-muted Markdown enabled
- .gl-display-flex.gl-justify-content-end
- = f.submit 'Save changes', class: "btn btn-success"
+ = 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
index 25d23ea7a84..60d5ca1ee0f 100644
--- a/app/views/admin/application_settings/_terminal.html.haml
+++ b/app/views/admin/application_settings/_terminal.html.haml
@@ -8,5 +8,4 @@
.form-text.text-muted
Maximum time for web terminal websocket connection (in seconds).
0 for unlimited.
- .gl-display-flex.gl-justify-content-end
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_terms.html.haml b/app/views/admin/application_settings/_terms.html.haml
index a6d03ac1dde..07d372882b9 100644
--- a/app/views/admin/application_settings/_terms.html.haml
+++ b/app/views/admin/application_settings/_terms.html.haml
@@ -15,5 +15,4 @@
= f.text_area :terms, class: 'form-control', rows: 8
.form-text.text-muted
= _("Markdown enabled")
- .gl-display-flex.gl-justify-content-end
- = f.submit _("Save changes"), class: "btn btn-success"
+ = 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
index 28208d923db..d44a1524f2e 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -66,5 +66,4 @@
.form-group
= f.label field_name, "#{type.upcase} SSH keys", class: 'label-bold'
= f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control'
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index 823cee09d4b..493c995dd91 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -101,8 +101,7 @@
= s_('IDE|Live Preview')
%span.form-text.text-muted
= s_('IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox Live Preview.')
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "btn btn-success"
- if Feature.enabled?(:maintenance_mode)
%section.settings.no-animate#js-maintenance-mode-toggle{ class: ('expanded' if expanded_by_default?) }
diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml
index 8a937bd66cf..9693a97367f 100644
--- a/app/views/admin/broadcast_messages/_form.html.haml
+++ b/app/views/admin/broadcast_messages/_form.html.haml
@@ -1,4 +1,4 @@
-.broadcast-message.broadcast-banner-message.alert-warning.js-broadcast-banner-message-preview.mt-2{ style: broadcast_message_style(@broadcast_message), class: ('hidden' unless @broadcast_message.banner? ) }
+.broadcast-message.broadcast-banner-message.gl-alert-warning.js-broadcast-banner-message-preview.gl-mt-3{ style: broadcast_message_style(@broadcast_message), class: ('gl-display-none' unless @broadcast_message.banner? ) }
= sprite_icon('bullhorn', css_class:'vertical-align-text-top')
.js-broadcast-message-preview
- if @broadcast_message.message.present?
@@ -77,6 +77,6 @@
= f.datetime_select :ends_at, {}, class: 'form-control form-control-inline'
.form-actions
- if @broadcast_message.persisted?
- = f.submit "Update broadcast message", class: "btn btn-success"
+ = f.submit "Update broadcast message", class: "btn gl-button btn-success"
- else
- = f.submit "Add broadcast message", class: "btn btn-success"
+ = f.submit "Add broadcast message", class: "btn gl-button btn-success"
diff --git a/app/views/admin/dashboard/_billable_users_text.html.haml b/app/views/admin/dashboard/_billable_users_text.html.haml
new file mode 100644
index 00000000000..e9485d23228
--- /dev/null
+++ b/app/views/admin/dashboard/_billable_users_text.html.haml
@@ -0,0 +1 @@
+= s_('AdminArea|Active users')
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 4acfc96caf2..b0d4a3fd8f5 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -19,7 +19,7 @@
%h3.text-center
= s_('AdminArea|Projects: %{number_of_projects}') % { number_of_projects: approximate_count_with_delimiters(@counts, Project) }
%hr
- = link_to(s_('AdminArea|New project'), new_project_path, class: "btn btn-success gl-w-full")
+ = link_to(s_('AdminArea|New project'), new_project_path, class: "btn gl-button btn-success gl-w-full")
.col-sm-4
.info-well.dark-well
.well-segment.well-centered
@@ -28,8 +28,8 @@
= s_('AdminArea|Users: %{number_of_users}') % { number_of_users: approximate_count_with_delimiters(@counts, User) }
%hr
.btn-group.d-flex{ role: 'group' }
- = link_to s_('AdminArea|New user'), new_admin_user_path, class: "btn btn-success gl-w-full"
- = link_to s_('AdminArea|Users statistics'), admin_dashboard_stats_path, class: 'btn btn-primary gl-w-full'
+ = link_to s_('AdminArea|New user'), new_admin_user_path, class: "btn gl-button btn-success gl-w-full"
+ = link_to s_('AdminArea|Users statistics'), admin_dashboard_stats_path, class: 'btn gl-button btn-info gl-w-full'
.col-sm-4
.info-well.dark-well
.well-segment.well-centered
@@ -37,7 +37,7 @@
%h3.text-center
= s_('AdminArea|Groups: %{number_of_groups}') % { number_of_groups: approximate_count_with_delimiters(@counts, Group) }
%hr
- = link_to s_('AdminArea|New group'), new_admin_group_path, class: "btn btn-success gl-w-full"
+ = link_to s_('AdminArea|New group'), new_admin_group_path, class: "btn gl-button btn-success gl-w-full"
.row
.col-md-4
#js-admin-statistics-container
@@ -51,7 +51,7 @@
= feature_entry(_('LDAP'),
enabled: Gitlab.config.ldap.enabled,
- doc_href: help_page_path('administration/auth/ldap'))
+ doc_href: help_page_path('administration/auth/ldap/index.md'))
= feature_entry(_('Gravatar'),
href: general_admin_application_settings_path(anchor: 'js-account-settings'),
diff --git a/app/views/admin/dashboard/stats.html.haml b/app/views/admin/dashboard/stats.html.haml
index 78707235cb5..9a89bf12365 100644
--- a/app/views/admin/dashboard/stats.html.haml
+++ b/app/views/admin/dashboard/stats.html.haml
@@ -50,11 +50,9 @@
= s_('AdminArea|Bots')
%td.p-3.text-right
= @users_statistics&.bots.to_i
-
%tr.bg-gray-light.gl-text-gray-900
%td.p-3
%strong
- = s_('AdminArea|Active users')
= render_if_exists 'admin/dashboard/billable_users_text'
%td.p-3.text-right
%strong
diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml
index 041b0661d37..1feb2ad16ad 100644
--- a/app/views/admin/groups/_form.html.haml
+++ b/app/views/admin/groups/_form.html.haml
@@ -36,5 +36,3 @@
.form-actions
= f.submit _('Save changes'), class: "btn btn-success", data: { qa_selector: 'save_changes_button' }
= link_to _('Cancel'), admin_group_path(@group), class: "btn btn-cancel"
-
-= render_if_exists 'ldap_group_links/ldap_syncrhonizations', group: @group
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index 6c2c0b3a488..dc43b45195e 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -113,7 +113,7 @@
%div
= users_select_tag(:user_ids, multiple: true, email_user: true, skip_ldap: @group.ldap_synced?, scope: :all)
.gl-mt-3
- = select_tag :access_level, options_for_select(GroupMember.access_level_roles), class: "project-access-select select2"
+ = select_tag :access_level, options_for_select(@group.access_level_roles), class: "project-access-select select2"
%hr
= button_tag _('Add users to group'), class: "btn btn-success"
= render 'shared/members/requests', membership_source: @group, requesters: @requesters, force_mobile_view: true
diff --git a/app/views/admin/hook_logs/show.html.haml b/app/views/admin/hook_logs/show.html.haml
index a8ef19dcf46..ca2737ca56f 100644
--- a/app/views/admin/hook_logs/show.html.haml
+++ b/app/views/admin/hook_logs/show.html.haml
@@ -4,6 +4,6 @@
%hr
-= link_to _("Resend Request"), retry_admin_hook_hook_log_path(@hook, @hook_log), method: :post, class: "btn btn-default float-right gl-ml-3"
+= link_to _("Resend Request"), retry_admin_hook_hook_log_path(@hook, @hook_log), method: :post, class: "btn gl-button btn-default float-right gl-ml-3"
= render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log }
diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
index f9faf5b11fa..924cc0fe54f 100644
--- a/app/views/admin/hooks/edit.html.haml
+++ b/app/views/admin/hooks/edit.html.haml
@@ -9,9 +9,9 @@
= form_for @hook, as: :hook, url: admin_hook_path do |f|
= render partial: 'form', locals: { form: f, hook: @hook }
.form-actions
- %span>= f.submit _('Save changes'), class: 'btn btn-success gl-mr-3'
+ %span>= f.submit _('Save changes'), class: 'btn gl-button btn-success gl-mr-3'
= render 'shared/web_hooks/test_button', hook: @hook
- = link_to _('Delete'), admin_hook_path(@hook), method: :delete, class: 'btn btn-remove float-right', data: { confirm: _('Are you sure?') }
+ = link_to _('Delete'), admin_hook_path(@hook), method: :delete, class: 'btn gl-button btn-remove float-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 d70baa592ea..c0bad6a0a63 100644
--- a/app/views/admin/hooks/index.html.haml
+++ b/app/views/admin/hooks/index.html.haml
@@ -7,7 +7,7 @@
.col-lg-8.gl-mb-3
= 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-success'
+ = f.submit _('Add system hook'), class: 'btn gl-button btn-success'
= render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class
diff --git a/app/views/admin/labels/_form.html.haml b/app/views/admin/labels/_form.html.haml
index 299d0a12e6c..664081339f3 100644
--- a/app/views/admin/labels/_form.html.haml
+++ b/app/views/admin/labels/_form.html.haml
@@ -27,5 +27,5 @@
= render_suggested_colors
.form-actions
- = f.submit _('Save'), class: 'btn btn-success js-save-button'
- = link_to _("Cancel"), admin_labels_path, class: 'btn btn-cancel'
+ = f.submit _('Save'), class: 'btn gl-button btn-success js-save-button'
+ = link_to _("Cancel"), admin_labels_path, class: 'btn gl-button btn-cancel'
diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml
index 6d934654c5d..b31b9bdab0a 100644
--- a/app/views/admin/labels/_label.html.haml
+++ b/app/views/admin/labels/_label.html.haml
@@ -1,7 +1,7 @@
%li.label-list-item{ id: dom_id(label) }
= render "shared/label_row", label: label.present(issuable_subject: nil)
.label-actions-list
- = link_to edit_admin_label_path(label), class: 'btn btn-transparent label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
+ = link_to edit_admin_label_path(label), class: 'btn gl-button btn-transparent label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
= sprite_icon('pencil')
- = link_to admin_label_path(label), class: 'btn btn-transparent remove-row label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: "Delete this label? Are you sure?" }, aria_label: _('Delete'), method: :delete, remote: true do
+ = link_to admin_label_path(label), class: 'btn gl-button btn-transparent remove-row label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: "Delete this label? Are you sure?" }, aria_label: _('Delete'), method: :delete, remote: true do
= sprite_icon('remove')
diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml
index 38137f360fd..76d37626fff 100644
--- a/app/views/admin/labels/index.html.haml
+++ b/app/views/admin/labels/index.html.haml
@@ -1,7 +1,7 @@
- page_title _("Labels")
%div
- = link_to new_admin_label_path, class: "float-right btn btn-nr btn-success" do
+ = link_to new_admin_label_path, class: "float-right btn gl-button btn-nr btn-success" do
= _('New label')
%h3.page-title
= _('Labels')
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index d5af12fcd09..01a0b4d295d 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -13,8 +13,9 @@
- if @project.last_repository_check_failed?
.row
.col-md-12
- .card
- .card-header.alert.alert-danger
+ .gl-alert.gl-alert-danger.gl-mb-5
+ = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-body
- last_check_message = _("Last repository check (%{last_check_timestamp}) failed. See the 'repocheck.log' file for error messages.")
- last_check_message = last_check_message % { last_check_timestamp: time_ago_with_tooltip(@project.last_repository_check_at) }
= last_check_message.html_safe
diff --git a/app/views/admin/users/_modals.html.haml b/app/views/admin/users/_modals.html.haml
index 6cf6dc116e3..a8e5d962e5b 100644
--- a/app/views/admin/users/_modals.html.haml
+++ b/app/views/admin/users/_modals.html.haml
@@ -25,6 +25,6 @@
'secondary-action': s_('AdminUsers|Block user') } }
= s_('AdminUsers|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},
+ consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd},
it cannot be undone or recovered.')
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index 160303890f5..284307d1d54 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -4,7 +4,12 @@
= _('Name')
.table-mobile-content
= render 'user_detail', user: user
- .table-section.section-25
+ .table-section.section-10
+ .table-mobile-header{ role: 'rowheader' }
+ = _('Projects')
+ .table-mobile-content.gl-str-truncated{ data: { testid: "user-project-count-#{user.id}" } }
+ = user.authorized_projects.length
+ .table-section.section-15
.table-mobile-header{ role: 'rowheader' }
= _('Created on')
.table-mobile-content
diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml
index 118bdf7bb17..78c3e41278d 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -72,7 +72,8 @@
.table-holder
.thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-40{ role: 'rowheader' }= _('Name')
- .table-section.section-25{ role: 'rowheader' }= _('Created on')
+ .table-section.section-10{ role: 'rowheader' }= _('Projects')
+ .table-section.section-15{ role: 'rowheader' }= _('Created on')
.table-section.section-15{ role: 'rowheader' }= _('Last activity')
= render partial: 'admin/users/user', collection: @users
diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml
index 117bdbc06a1..047f77ba94b 100644
--- a/app/views/clusters/clusters/_advanced_settings.html.haml
+++ b/app/views/clusters/clusters/_advanced_settings.html.haml
@@ -24,17 +24,18 @@
.text-muted
= html_escape(s_('ClusterIntegration|A cluster management project can be used to run deployment jobs with Kubernetes %{code_open}cluster-admin%{code_close} privileges.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
= link_to _('More information'), help_page_path('user/clusters/management_project.md'), target: '_blank'
- .gl-display-flex.gl-justify-content-end
- = field.submit _('Save changes'), class: 'btn btn-success'
+ = field.submit _('Save changes'), class: 'btn btn-success'
- - if @cluster.managed?
- .sub-section.form-group
- %h4
- = s_('ClusterIntegration|Clear cluster cache')
- %p
- = s_("ClusterIntegration|Clear the local cache of namespace and service accounts. This is necessary if your integration has become out of sync. The cache is repopulated during the next CI job that requires namespace and service accounts.")
- .gl-display-flex.gl-justify-content-end
- = link_to(s_('ClusterIntegration|Clear cluster cache'), clusterable.clear_cluster_cache_path(@cluster), method: :delete, class: 'btn btn-primary')
+ .sub-section.form-group
+ %h4
+ = s_('ClusterIntegration|Clear cluster cache')
+ %p
+ = s_("ClusterIntegration|Clear the local cache of namespace and service accounts.")
+ - if @cluster.managed?
+ = s_("ClusterIntegration|This is necessary if your integration has become out of sync. The cache is repopulated during the next CI job that requires namespace and service accounts.")
+ - else
+ = s_("ClusterIntegration|This is necessary to clear existing environment-namespace associations from clusters previously managed by GitLab.")
+ = link_to(s_('ClusterIntegration|Clear cluster cache'), clusterable.clear_cluster_cache_path(@cluster), method: :delete, class: 'btn btn-primary')
.sub-section.form-group
%h4.text-danger
diff --git a/app/views/clusters/clusters/_buttons.html.haml b/app/views/clusters/clusters/_buttons.html.haml
deleted file mode 100644
index c81d1d5b05a..00000000000
--- a/app/views/clusters/clusters/_buttons.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-.nav-controls
- - if clusterable.can_add_cluster?
- = link_to s_('ClusterIntegration|Add Kubernetes cluster'), clusterable.new_path, class: 'btn btn-success js-add-cluster'
- - else
- %span.btn.btn-add-cluster.disabled.js-add-cluster
- = s_("ClusterIntegration|Add Kubernetes cluster")
diff --git a/app/views/clusters/clusters/_cluster.html.haml b/app/views/clusters/clusters/_cluster.html.haml
deleted file mode 100644
index f11117ea5c4..00000000000
--- a/app/views/clusters/clusters/_cluster.html.haml
+++ /dev/null
@@ -1,19 +0,0 @@
-.card
- .card-body.gl-responsive-table-row
- .table-section.section-60
- .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Kubernetes cluster")
- .table-mobile-content.gl-display-flex.gl-align-items-center.gl-justify-content-end.gl-justify-content-md-start
- .gl-w-6.gl-h-6.gl-mr-3.gl-display-flex.gl-align-items-center= provider_icon(cluster.provider_type)
- = cluster.item_link(clusterable, html_options: { data: { qa_selector: 'cluster', qa_cluster_name: cluster.name } })
- - if cluster.status_name == :creating
- .spinner.ml-2.align-middle.has-tooltip{ title: s_("ClusterIntegration|Cluster being created") }
- - unless cluster.enabled?
- %span.badge.badge-danger Connection disabled
- .table-section.section-25
- .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Environment scope")
- .table-mobile-content= cluster.environment_scope
- .table-section.section-15.text-right
- .table-mobile-header{ role: "rowheader" }
- .table-mobile-content
- %span.badge.badge-light
- = cluster.cluster_type_description
diff --git a/app/views/clusters/clusters/_cluster_list.html.haml b/app/views/clusters/clusters/_cluster_list.html.haml
new file mode 100644
index 00000000000..9627d940126
--- /dev/null
+++ b/app/views/clusters/clusters/_cluster_list.html.haml
@@ -0,0 +1,12 @@
+- if clusters.empty?
+ = render 'empty_state'
+- else
+ .top-area.adjust
+ .gl-display-block.gl-text-right.gl-my-4.gl-w-full
+ - if clusterable.can_add_cluster?
+ = link_to s_('ClusterIntegration|Connect cluster with certificate'), clusterable.new_path, class: 'btn gl-button btn-success js-add-cluster gl-py-2', qa_selector: :integrate_kubernetes_cluster_button
+ - else
+ %span.btn.gl-button.btn-success.js-add-cluster.disabled.gl-py-2
+ = s_("ClusterIntegration|Connect cluster with certificate")
+
+ #js-clusters-list-app{ data: js_clusters_list_data(clusterable.index_path(format: :json)) }
diff --git a/app/views/clusters/clusters/_empty_state.html.haml b/app/views/clusters/clusters/_empty_state.html.haml
index cfdbfe2dea1..1798ba81075 100644
--- a/app/views/clusters/clusters/_empty_state.html.haml
+++ b/app/views/clusters/clusters/_empty_state.html.haml
@@ -3,12 +3,12 @@
.svg-content= image_tag 'illustrations/clusters_empty.svg'
.col-12
.text-content
- %h4.text-center= s_('ClusterIntegration|Integrate Kubernetes cluster automation')
- %p
+ %h4.gl-text-center= s_('ClusterIntegration|Integrate Kubernetes with a cluster certificate')
+ %p.gl-text-center
= s_('ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way.')
= clusterable.empty_state_help_text
= clusterable.learn_more_link
- if clusterable.can_add_cluster?
- .text-center
- = link_to s_('ClusterIntegration|Add Kubernetes cluster'), clusterable.new_path, class: 'btn btn-success'
+ .gl-text-center
+ = link_to s_('ClusterIntegration|Integrate with a cluster certificate'), clusterable.new_path, class: 'btn btn-success'
diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
index 54f6fa91cf1..8c23fc7c590 100644
--- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
+++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
@@ -1,11 +1,9 @@
- link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
-.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.gl-mt-3.gl-mb-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } }
- %button.close.js-close{ type: "button" } &times;
- .gcp-signup-offer--content
- .gcp-signup-offer--icon.gl-mr-3
- = sprite_icon("information")
- .gcp-signup-offer--copy
- %h4= s_('ClusterIntegration|Did you know?')
- %p= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link }
- %a.btn.btn-default{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' }
- = s_("ClusterIntegration|Apply for credit")
+.gcp-signup-offer.gl-alert.gl-alert-info.gl-my-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } }
+ %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
+ = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ %h4.gl-alert-title= s_('ClusterIntegration|Did you know?')
+ %p.gl-alert-body= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link }
+ %a.gl-button.btn-info{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' }
+ = s_("ClusterIntegration|Apply for credit")
diff --git a/app/views/clusters/clusters/_provider_details_form.html.haml b/app/views/clusters/clusters/_provider_details_form.html.haml
index e211851b939..16891c7fc21 100644
--- a/app/views/clusters/clusters/_provider_details_form.html.haml
+++ b/app/views/clusters/clusters/_provider_details_form.html.haml
@@ -42,11 +42,17 @@
class: 'js-gl-managed',
label_class: 'label-bold' }
.form-text.text-muted
- = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.')
+ = s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.')
= link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank'
+ .form-group
+ = field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' }
+ .form-text.text-muted
+ = s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.')
+ = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'custom-namespace'), target: '_blank'
+
- if cluster.allow_user_defined_namespace?
- = render('clusters/clusters/namespace', platform_field: platform_field)
+ = render('clusters/clusters/namespace', platform_field: platform_field, field: field)
- .form-group.gl-display-flex.gl-justify-content-end
+ .form-group
= field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success'
diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml
index 3eab9b46fb3..b1a277faae9 100644
--- a/app/views/clusters/clusters/aws/_new.html.haml
+++ b/app/views/clusters/clusters/aws/_new.html.haml
@@ -4,6 +4,7 @@
= s_('Amazon authentication is not %{link_start}correctly configured%{link_end}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_start: documentation_link_start, link_end: '<a/>'.html_safe }
- else
.js-create-eks-cluster-form-container{ data: { 'gitlab-managed-cluster-help-path' => help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'),
+ 'namespace-per-environment-help-path' => help_page_path('user/project/clusters/index.md', anchor: 'custom-namespace'),
'create-role-path' => clusterable.authorize_aws_role_path,
'create-cluster-path' => clusterable.create_aws_clusters_path,
'account-id' => Gitlab::CurrentSettings.eks_account_id,
diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml
index 434c02a5c41..ceb6e1d46b0 100644
--- a/app/views/clusters/clusters/gcp/_form.html.haml
+++ b/app/views/clusters/clusters/gcp/_form.html.haml
@@ -75,9 +75,15 @@
= field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'),
label_class: 'label-bold' }
.form-text.text-muted
- = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.')
+ = s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.')
= link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank'
+ .form-group
+ = field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' }
+ .form-text.text-muted
+ = s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.')
+ = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'custom-namespace'), target: '_blank'
+
.form-group.js-gke-cluster-creation-submit-container
= field.submit s_('ClusterIntegration|Create Kubernetes cluster'),
class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true
diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml
index 557ad1bf280..45287a01cc9 100644
--- a/app/views/clusters/clusters/index.html.haml
+++ b/app/views/clusters/clusters/index.html.haml
@@ -3,30 +3,24 @@
= render_gcp_signup_offer
-.clusters-container
- - if @clusters.empty?
- = render "empty_state"
- - else
- .top-area.adjust
- .nav-text
- = s_('ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project')
- = render 'clusters/clusters/buttons'
+.clusters-container.gl-my-2
+ - if display_cluster_agents?(clusterable)
+ .js-toggle-container
+ %ul.nav-links.nav-tabs.nav{ role: 'tablist' }
+ %li.nav-item{ role: 'presentation' }
+ %a.nav-link.active{ href: "#certificate-clusters-pane", id: "certificate-clusters-tab", data: { toggle: 'tab' }, role: 'tab' }
+ %span= s_('ClusterIntegration|Clusters connected with a certificate')
+
+ %li.nav-item{ role: 'presentation' }
+ %a.nav-link{ href: "#agent-clusters-pane", id: "agent-clusters-tab", data: { toggle: 'tab' }, role: 'tab' }
+ %span= s_('ClusterIntegration|GitLab Agent managed clusters')
+
+ .tab-content
+ .tab-pane.active{ id: 'certificate-clusters-pane', role: 'tabpanel' }
+ = render 'cluster_list', clusters: @clusters
- - if Feature.enabled?(:clusters_list_redesign)
- #js-clusters-list-app{ data: js_clusters_list_data(clusterable.index_path(format: :json)) }
- - else
- - if @has_ancestor_clusters
- .bs-callout.bs-callout-info
- = s_('ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters.')
- %strong
- = link_to _('More information'), help_page_path('user/group/clusters/index', anchor: 'cluster-precedence')
- .clusters-table.js-clusters-list
- .gl-responsive-table-row.table-row-header{ role: "row" }
- .table-section.section-60{ role: "rowheader" }
- = s_("ClusterIntegration|Kubernetes cluster")
- .table-section.section-30{ role: "rowheader" }
- = s_("ClusterIntegration|Environment scope")
- .table-section.section-10{ role: "rowheader" }
- - @clusters.each do |cluster|
- = render "cluster", cluster: cluster.present(current_user: current_user)
- = paginate @clusters, theme: "gitlab"
+ .tab-pane{ id: 'agent-clusters-pane', role: 'tabpanel' }
+ #js-cluster-agents-list{ data: js_cluster_agents_list_data(clusterable) }
+
+ - else
+ = render 'cluster_list', clusters: @clusters
diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml
index 11772107135..a6097038b2e 100644
--- a/app/views/clusters/clusters/user/_form.html.haml
+++ b/app/views/clusters/clusters/user/_form.html.haml
@@ -46,9 +46,15 @@
class: 'js-gl-managed',
label_class: 'label-bold' }
.form-text.text-muted
- = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.')
+ = s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.')
= link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank'
+ .form-group
+ = field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' }
+ .form-text.text-muted
+ = s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.')
+ = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'custom-namespace'), target: '_blank'
+
= field.fields_for :platform_kubernetes, @user_cluster.platform_kubernetes do |platform_kubernetes_field|
- if @user_cluster.allow_user_defined_namespace?
= render('clusters/clusters/namespace', platform_field: platform_kubernetes_field)
diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml
index a0c1c314a85..923e78ad360 100644
--- a/app/views/dashboard/milestones/index.html.haml
+++ b/app/views/dashboard/milestones/index.html.haml
@@ -1,6 +1,7 @@
- @hide_top_links = true
- page_title _('Milestones')
- header_title _('Milestones'), dashboard_milestones_path
+- add_page_specific_style 'page_bundles/milestone'
.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Milestones')
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index 82abb9b3b8a..96714ebf922 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -31,7 +31,7 @@
- if todo.self_assigned?
%span.title-item.action-name
- to yourself
+ = todo_self_addressing(todo)
%span.title-item
&middot;
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index 9c6a6be1bc3..44d968ae26d 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -3,7 +3,7 @@
- header_title _("To-Do List"), dashboard_todos_path
= render_dashboard_gold_trial(current_user)
-= stylesheet_link_tag 'page_bundles/todos'
+- add_page_specific_style 'page_bundles/todos'
.page-title-holder.d-flex.align-items-center
%h1.page-title= _('To-Do List')
diff --git a/app/views/devise/mailer/confirmation_instructions.text.erb b/app/views/devise/mailer/confirmation_instructions.text.erb
index 05fddddf415..925ad9bd22e 100644
--- a/app/views/devise/mailer/confirmation_instructions.text.erb
+++ b/app/views/devise/mailer/confirmation_instructions.text.erb
@@ -1 +1 @@
-<%= render partial: "confirmation_instructions_#{@resource.is_a?(User) ? 'account' : 'secondary'}" %> \ No newline at end of file
+<%= render partial: "confirmation_instructions_#{@resource.is_a?(User) ? 'account' : 'secondary'}" %>
diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml
index 8a3c841de0b..b34b6f09662 100644
--- a/app/views/discussions/_diff_with_notes.html.haml
+++ b/app/views/discussions/_diff_with_notes.html.haml
@@ -29,7 +29,7 @@
%td.line_content.js-success-lazy-load
.js-code-placeholder
%td.js-error-lazy-load-diff.hidden.diff-loading-error-block
- - button = button_tag(_("Try again"), class: "btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button")
+ - button = button_tag(_("Try again"), class: "btn-link gl-button btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button")
= _("Unable to load the diff. %{button_try_again}").html_safe % { button_try_again: button}
= render "discussions/diff_discussion", discussions: [discussion], expanded: true
- else
diff --git a/app/views/discussions/_jump_to_next.html.haml b/app/views/discussions/_jump_to_next.html.haml
deleted file mode 100644
index 3db509f24a5..00000000000
--- a/app/views/discussions/_jump_to_next.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-- discussion = local_assigns.fetch(:discussion, nil)
-- if current_user
- %jump-to-discussion{ "inline-template" => true, ":discussion-id" => "'#{discussion.try(:id)}'" }
- .btn-group{ role: "group", "v-show" => "!allResolved", "v-if" => "showButton" }
- %button.btn.btn-default.discussion-next-btn.has-tooltip{ "@click" => "jumpToNextUnresolvedDiscussion",
- ":title" => "buttonText",
- ":aria-label" => "buttonText",
- data: { container: "body" } }
- = custom_icon("next_discussion")
diff --git a/app/views/discussions/_new_issue_for_all_discussions.html.haml b/app/views/discussions/_new_issue_for_all_discussions.html.haml
deleted file mode 100644
index 50dd5864195..00000000000
--- a/app/views/discussions/_new_issue_for_all_discussions.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-- if merge_request.discussions_can_be_resolved_by?(current_user) && can?(current_user, :create_issue, @project)
- .btn-group{ role: "group", "v-if" => "unresolvedDiscussionCount > 0" }
- = link_to custom_icon('icon_mr_issue'),
- new_project_issue_path(@project, merge_request_to_resolve_discussions_of: merge_request.iid),
- title: 'Resolve all discussions in new issue',
- aria: { label: 'Resolve all discussions in new issue' },
- data: { container: 'body' },
- class: 'new-issue-for-discussion btn btn-default discussion-create-issue-btn has-tooltip'
diff --git a/app/views/discussions/_new_issue_for_discussion.html.haml b/app/views/discussions/_new_issue_for_discussion.html.haml
deleted file mode 100644
index 49d5378d62e..00000000000
--- a/app/views/discussions/_new_issue_for_discussion.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-- if discussion.can_resolve?(current_user) && can?(current_user, :create_issue, @project)
- %new-issue-for-discussion-btn{ ":discussion-id" => "'#{discussion.id}'",
- "inline-template" => true }
- .btn-group{ role: "group", "v-if" => "showButton" }
- = link_to custom_icon('icon_mr_issue'),
- new_project_issue_path(@project, merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id),
- title: 'Resolve this thread in a new issue',
- aria: { label: 'Resolve this thread in a new issue' },
- data: { container: 'body' },
- class: 'new-issue-for-discussion btn btn-default discussion-create-issue-btn has-tooltip'
diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml
index 0a5541c3e82..7db318f83b1 100644
--- a/app/views/discussions/_notes.html.haml
+++ b/app/views/discussions/_notes.html.haml
@@ -9,9 +9,9 @@
-# to the first note position when we click on a badge diff discussion
%ul.notes{ id: "discussion_#{discussion.id}", data: { discussion_id: discussion.id, position: discussion.notes[0].position.to_json } }
- if discussion.try(:on_image?) && show_toggle
- %button.diff-notes-collapse.js-diff-notes-toggle{ type: 'button' }
+ %button.gl-button.diff-notes-collapse.js-diff-notes-toggle{ type: 'button' }
= sprite_icon('collapse', css_class: 'collapse-icon')
- %button.btn-transparent.badge.badge-pill.js-diff-notes-toggle{ type: 'button' }
+ %button.gl-button.btn-transparent.badge.badge-pill.js-diff-notes-toggle{ type: 'button' }
= badge_counter
= render partial: "shared/notes/note", collection: discussion.notes, as: :note, locals: { badge_counter: badge_counter, show_image_comment_badge: show_image_comment_badge }
@@ -21,22 +21,8 @@
- if can_create_note?
%a.user-avatar-link.d-none.d-sm-block{ href: user_path(current_user) }
= image_tag avatar_icon_for_user(current_user), alt: current_user.to_reference, class: 'avatar s40'
- - if discussion.potentially_resolvable?
- - line_type = local_assigns.fetch(:line_type, nil)
-
- .discussion-with-resolve-btn
- .btn-group.discussion-with-resolve-btn{ role: "group" }
- .btn-group{ role: "group" }
- = link_to_reply_discussion(discussion, line_type)
-
- = render "discussions/resolve_all", discussion: discussion
-
- .btn-group.discussion-actions
- = render "discussions/new_issue_for_discussion", discussion: discussion, merge_request: discussion.noteable
- = render "discussions/jump_to_next", discussion: discussion
- - else
- .discussion-with-resolve-btn
- = link_to_reply_discussion(discussion)
+ .discussion-with-resolve-btn
+ = link_to_reply_discussion(discussion)
- elsif !current_user
.disabled-comment.text-center
Please
diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml
index a38d6dd3836..93c6efc9083 100644
--- a/app/views/events/event/_common.html.haml
+++ b/app/views/events/event/_common.html.haml
@@ -7,7 +7,7 @@
- if event.target
%span.event-type.d-inline-block.gl-mr-2{ class: event.action_name }
= event.action_name
- %span.event-target-type.gl-mr-2= event.target_type.titleize.downcase
+ %span.event-target-type.gl-mr-2= event.target_type_name
= link_to event.target_link_options, class: 'has-tooltip event-target-link gl-mr-2', title: event.target_title do
= event.target.reference_link_text
- unless event.milestone?
diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml
new file mode 100644
index 00000000000..51f41d58029
--- /dev/null
+++ b/app/views/groups/_invite_members_modal.html.haml
@@ -0,0 +1,6 @@
+- if invite_members_allowed?(group)
+ .js-invite-members-modal{ data: { group_id: group.id,
+ group_name: group.name,
+ access_levels: GroupMember.access_level_roles.to_json,
+ default_access_level: Gitlab::Access::GUEST,
+ help_link: help_page_url('user/permissions') } }
diff --git a/app/views/groups/_invite_members_side_nav_link.html.haml b/app/views/groups/_invite_members_side_nav_link.html.haml
new file mode 100644
index 00000000000..1c90eaee992
--- /dev/null
+++ b/app/views/groups/_invite_members_side_nav_link.html.haml
@@ -0,0 +1,3 @@
+- if invite_members_allowed?(group) && body_data_page == 'groups:show'
+ %li
+ .js-invite-members-trigger{ data: { icon: 'plus', display_text: 'Invite team members' } }
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index 1358e848154..ecf9141307a 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -23,9 +23,12 @@
= render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
- if Feature.enabled?(:vue_issuables_list, @group)
+ - if use_startup_call?
+ - add_page_startup_api_call(api_v4_groups_issues_path(id: @group.id, params: startup_call_params))
.js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)),
'can-bulk-edit': @can_bulk_update.to_json,
'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') },
- 'sort-key': @sort } }
+ 'sort-key': @sort,
+ type: 'issues' } }
- else
= render 'shared/issues'
diff --git a/app/views/groups/labels/destroy.js.haml b/app/views/groups/labels/destroy.js.haml
deleted file mode 100644
index 3dfbfc77c0d..00000000000
--- a/app/views/groups/labels/destroy.js.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-- if @group.labels.empty?
- $('.labels').load(document.URL + ' .nothing-here-block').hide().fadeIn(1000)
diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml
index 3299d127222..debbe95d2aa 100644
--- a/app/views/groups/labels/index.html.haml
+++ b/app/views/groups/labels/index.html.haml
@@ -27,5 +27,5 @@
= render 'shared/empty_states/labels'
%template#js-badge-item-template
- %li.label-link-item.js-priority-badge.inline.gl-ml-3
- .label-badge.label-badge-blue= _('Prioritized label')
+ %li.js-priority-badge.inline.gl-ml-3
+ .label-badge.gl-bg-blue-50= _('Prioritized label')
diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml
index 1685707d457..d20fa938a68 100644
--- a/app/views/groups/milestones/index.html.haml
+++ b/app/views/groups/milestones/index.html.haml
@@ -1,4 +1,5 @@
- page_title _("Milestones")
+- add_page_specific_style 'page_bundles/milestone'
.top-area
= render 'shared/milestones_filter', counts: @milestone_states
diff --git a/app/views/groups/milestones/show.html.haml b/app/views/groups/milestones/show.html.haml
index 33e68bc766e..5bbdd3a3b19 100644
--- a/app/views/groups/milestones/show.html.haml
+++ b/app/views/groups/milestones/show.html.haml
@@ -1,3 +1,4 @@
+- add_page_specific_style 'page_bundles/milestone'
= render "header_title"
= render 'shared/milestones/top', milestone: @milestone, group: @group
= render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true
diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml
index 2cac8e653e5..21882c3e3ce 100644
--- a/app/views/groups/registry/repositories/index.html.haml
+++ b/app/views/groups/registry/repositories/index.html.haml
@@ -12,6 +12,8 @@
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"registry_host_url_with_port" => escape_once(registry_config.host_port),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
+ "run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'),
+ "cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
"is_admin": current_user&.admin.to_s,
is_group_page: "true",
character_error: @character_error.to_s } }
diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml
index 98f4acaa5e3..f415ca79bd4 100644
--- a/app/views/groups/settings/_advanced.html.haml
+++ b/app/views/groups/settings/_advanced.html.haml
@@ -22,8 +22,7 @@
pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS,
title: s_('GroupSettings|Please choose a group URL with no special characters.'),
"data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
- .gl-display-flex.gl-justify-content-end
- = f.submit s_('GroupSettings|Change group URL'), class: 'btn btn-warning'
+ = f.submit s_('GroupSettings|Change group URL'), class: 'btn btn-warning'
.sub-section
%h4.warning-title= s_('GroupSettings|Transfer group')
@@ -39,8 +38,7 @@
%li= s_('GroupSettings|You can only transfer the group to a group you manage.')
%li= s_('GroupSettings|You will need to update your local repositories to point to the new location.')
%li= s_("GroupSettings|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.")
- .gl-display-flex.gl-justify-content-end
- = f.submit s_('GroupSettings|Transfer group'), class: 'btn btn-warning'
+ = f.submit s_('GroupSettings|Transfer group'), class: 'btn btn-warning'
= render 'groups/settings/remove', group: @group
= render_if_exists 'groups/settings/restore', group: @group
diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml
index af06cfff397..94466b76ac8 100644
--- a/app/views/groups/settings/_export.html.haml
+++ b/app/views/groups/settings/_export.html.haml
@@ -24,6 +24,5 @@
= link_to _('Download export'), download_export_group_path(group),
rel: 'nofollow', method: :get, class: 'btn btn-default', data: { qa_selector: 'download_export_link' }
- else
- .gl-display-flex.gl-justify-content-end
- = link_to _('Export group'), export_group_path(group),
- method: :post, class: 'btn btn-default', data: { qa_selector: 'export_group_link' }
+ = link_to _('Export group'), export_group_path(group),
+ method: :post, class: 'btn btn-default', data: { qa_selector: 'export_group_link' }
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index e43d49b229e..35d82084263 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -29,5 +29,4 @@
= link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link'
= render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: 'btn btn-success mt-4 js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' }
+ = f.submit _('Save changes'), class: 'btn btn-success mt-4 js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' }
diff --git a/app/views/groups/settings/_permanent_deletion.html.haml b/app/views/groups/settings/_permanent_deletion.html.haml
index 063ff6dd132..5cdcdefae8c 100644
--- a/app/views/groups/settings/_permanent_deletion.html.haml
+++ b/app/views/groups/settings/_permanent_deletion.html.haml
@@ -5,5 +5,4 @@
= _('Removing this group also removes all child projects, including archived projects, and their resources.')
%br
%strong= _('Removed group can not be restored!')
- .gl-display-flex.gl-justify-content-end
- = button_to _('Remove group'), '#', class: 'btn btn-remove js-confirm-danger', data: { 'confirm-danger-message' => remove_group_message(group) }
+ = button_to _('Remove group'), '#', class: 'btn btn-remove js-confirm-danger', data: { 'confirm-danger-message' => remove_group_message(group) }
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index 86f49672d66..5c50ae6bb13 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -41,5 +41,4 @@
= render 'groups/settings/two_factor_auth', f: f
= render_if_exists 'groups/personal_access_token_expiration_policy', f: f, group: @group
= render_if_exists 'groups/member_lock_setting', f: f, group: @group
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: 'btn btn-success gl-mt-3 js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' }
+ = f.submit _('Save changes'), class: 'btn btn-success gl-mt-3 js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' }
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index ec4ab603d22..fa560942c5d 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -23,6 +23,8 @@
= render_if_exists 'groups/group_activity_analytics', group: @group
+= render_if_exists 'groups/invite_members_modal', group: @group
+
.groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } }
.top-area.group-nav-container.justify-content-between
.scrolling-tabs-container.inner-page-scroll-tabs
diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml
index d0384fd50bc..79cba2a54b0 100644
--- a/app/views/ide/_show.html.haml
+++ b/app/views/ide/_show.html.haml
@@ -1,8 +1,7 @@
- @body_class = 'ide-layout'
- page_title _('IDE')
-- content_for :page_specific_javascripts do
- = stylesheet_link_tag 'page_bundles/ide'
+- add_page_specific_style 'page_bundles/ide'
#ide.ide-loading{ data: ide_data }
.text-center
diff --git a/app/views/import/bulk_imports/status.html.haml b/app/views/import/bulk_imports/status.html.haml
new file mode 100644
index 00000000000..d909f6a13f0
--- /dev/null
+++ b/app/views/import/bulk_imports/status.html.haml
@@ -0,0 +1 @@
+- page_title 'Bulk Import'
diff --git a/app/views/import/shared/_errors.html.haml b/app/views/import/shared/_errors.html.haml
index de60c15351f..32b4a39924b 100644
--- a/app/views/import/shared/_errors.html.haml
+++ b/app/views/import/shared/_errors.html.haml
@@ -1,4 +1,6 @@
- if @errors.present?
- .alert.alert-danger
- - @errors.each do |error|
- = error
+ .gl-alert.gl-alert-danger.gl-mb-5
+ = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-body
+ - @errors.each do |error|
+ = error
diff --git a/app/views/invites/decline.html.haml b/app/views/invites/decline.html.haml
new file mode 100644
index 00000000000..4a57d70cb6e
--- /dev/null
+++ b/app/views/invites/decline.html.haml
@@ -0,0 +1,8 @@
+- page_title _('Invitation declined')
+.decline-page.gl-display-flex.gl-flex-direction-column.gl-mx-auto{ class: 'gl-xs-w-full!' }
+ .gl-align-self-center.gl-mb-4.gl-mt-7.gl-sm-mt-0= sprite_icon('check-circle', size: 48, css_class: 'gl-text-green-400')
+ %h2.gl-font-size-h2= _('You successfully declined the invitation')
+ %p
+ = html_escape(_('We will notify %{inviter} that you declined their invitation to join GitLab. You will stop receiving reminders.')) % { inviter: sanitize_name(@member.created_by.name) }
+ %p
+ = _('You can now close this window.')
diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml
index f7ecfd09209..32dd9a7c275 100644
--- a/app/views/jira_connect/subscriptions/index.html.haml
+++ b/app/views/jira_connect/subscriptions/index.html.haml
@@ -25,4 +25,4 @@
%td= link_to 'Remove', jira_connect_subscription_path(subscription), class: 'remove-subscription'
= page_specific_javascript_tag('jira_connect.js')
-= stylesheet_link_tag 'page_bundles/jira_connect'
+- add_page_specific_style 'page_bundles/jira_connect'
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 1c87452f0a3..6cc736e7056 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -42,35 +42,39 @@
%title= page_title(site_name)
%meta{ name: "description", content: page_description }
+ - if page_canonical_link
+ %link{ rel: 'canonical', href: page_canonical_link }
+
= favicon_link_tag favicon, id: 'favicon', data: { original_href: favicon }, type: 'image/png'
= render 'layouts/startup_css'
- if user_application_theme == 'gl-dark'
= stylesheet_link_tag_defer "application_dark"
+ = yield :page_specific_styles
+ = stylesheet_link_tag_defer "application_utilities_dark"
- else
= stylesheet_link_tag_defer "application"
+ = yield :page_specific_styles
+ = stylesheet_link_tag_defer "application_utilities"
- unless use_startup_css?
- = stylesheet_link_tag_defer "themes/theme_#{user_application_theme_name}"
+ = stylesheet_link_tag_defer "themes/#{user_application_theme_css_filename}" if user_application_theme_css_filename
= stylesheet_link_tag "disable_animations", media: "all" if Rails.env.test? || Gitlab.config.gitlab['disable_animations']
- = stylesheet_link_tag_defer 'performance_bar' if performance_bar_enabled?
= stylesheet_link_tag_defer "highlight/themes/#{user_color_scheme}"
= render 'layouts/startup_css_activation'
- = Gon::Base.render_data(nonce: content_security_policy_nonce)
+ = stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
- - if content_for?(:library_javascripts)
- = yield :library_javascripts
+ = Gon::Base.render_data(nonce: content_security_policy_nonce)
= javascript_include_tag locale_path unless I18n.locale == :en
= webpack_bundle_tag "sentry" if Gitlab.config.sentry.enabled
+ = webpack_bundle_tag 'performance_bar' if performance_bar_enabled?
- - if content_for?(:page_specific_javascripts)
- = yield :page_specific_javascripts
+ = yield :page_specific_javascripts
= webpack_controller_bundle_tags
- = webpack_bundle_tag 'performance_bar' if performance_bar_enabled?
= webpack_bundle_tag "chrome_84_icon_fix" if browser.chrome?([">=84", "<84.0.4147.125"]) || browser.edge?([">=84", "<84.0.522.59"])
= yield :project_javascripts
@@ -79,8 +83,6 @@
= csp_meta_tag
= action_cable_meta_tag
- - unless browser.safari?
- %meta{ name: 'referrer', content: 'origin-when-cross-origin' }
%meta{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1' }
%meta{ name: 'theme-color', content: '#474D57' }
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 5184bc93a81..9b925369660 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -19,7 +19,6 @@
= yield :customize_homepage_banner
- unless @hide_breadcrumbs
= render "layouts/nav/breadcrumbs"
- .d-flex
%div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
.content{ id: "content-body" }
= render "layouts/flash", extra_flash_class: 'limit-container-width'
diff --git a/app/views/layouts/_startup_css.haml b/app/views/layouts/_startup_css.haml
index ea05157ed19..2f674f79b2f 100644
--- a/app/views/layouts/_startup_css.haml
+++ b/app/views/layouts/_startup_css.haml
@@ -3,5 +3,5 @@
- startup_filename = current_path?("sessions#new") ? 'signin' : user_application_theme == 'gl-dark' ? 'dark' : 'general'
%style{ type: "text/css" }
- = Rails.application.assets_manifest.find_sources("themes/theme_#{user_application_theme_name}.css").first.to_s.html_safe
+ = Rails.application.assets_manifest.find_sources("themes/#{user_application_theme_css_filename}.css").first.to_s.html_safe if user_application_theme_css_filename
= Rails.application.assets_manifest.find_sources("startup/startup-#{startup_filename}.css").first.to_s.html_safe
diff --git a/app/views/layouts/_startup_css_activation.haml b/app/views/layouts/_startup_css_activation.haml
index 022b9a695bc..a426d686c34 100644
--- a/app/views/layouts/_startup_css_activation.haml
+++ b/app/views/layouts/_startup_css_activation.haml
@@ -7,4 +7,3 @@
const startupLinkLoadedEvent = new CustomEvent('CSSStartupLinkLoaded');
linkTag.addEventListener('load',function(){this.media='all';this.setAttribute('data-startupcss', 'loaded');document.dispatchEvent(startupLinkLoadedEvent);},{once: true});
})
-- return unless use_startup_css?
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index 8f4c89a9e77..6d2c5870e43 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -1,6 +1,6 @@
- page_title @group.name
-- page_description @group.description unless page_description
-- header_title group_title(@group) unless header_title
+- page_description @group.description_html unless page_description
+- header_title group_title(@group) unless header_title
- nav "group"
- display_subscription_banner!
- display_namespace_storage_limit_alert!
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 845231238f6..328c6031b24 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -73,7 +73,7 @@
%span.gl-sr-only
= s_('Nav|Help')
= sprite_icon('question')
- = sprite_icon('angle-down', css_class: 'caret-down')
+ = sprite_icon('chevron-down', css_class: 'caret-down')
.dropdown-menu.dropdown-menu-right
= render 'layouts/header/help_dropdown'
- if header_link?(:user_dropdown)
@@ -81,7 +81,7 @@
= link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do
= image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar", alt: current_user.name
= render_if_exists 'layouts/header/user_notification_dot', project: project, namespace: group
- = sprite_icon('angle-down', css_class: 'caret-down')
+ = sprite_icon('chevron-down', css_class: 'caret-down')
.dropdown-menu.dropdown-menu-right
= render 'layouts/header/current_user_dropdown'
- if has_impersonation_link
@@ -100,7 +100,7 @@
= sprite_icon('close', size: 12, css_class: 'close-icon js-navbar-toggle-left')
- if ::Feature.enabled?(:whats_new_drawer)
- #whats-new-app{ data: { features: whats_new_most_recent_release_items } }
+ #whats-new-app{ data: { features: whats_new_most_recent_release_items, storage_key: whats_new_storage_key } }
- if can?(current_user, :update_user_status, current_user)
.js-set-status-modal-wrapper{ data: { current_emoji: current_user.status.present? ? current_user.status.emoji : '', current_message: current_user.status.present? ? current_user.status.message : '' } }
diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml
index 0c989242194..2c5cd7e96c7 100644
--- a/app/views/layouts/header/_new_dropdown.haml
+++ b/app/views/layouts/header/_new_dropdown.haml
@@ -1,7 +1,7 @@
%li.header-new.dropdown{ data: { track_label: "new_dropdown", track_event: "click_dropdown", track_value: "" } }
= link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip qa-new-menu-toggle", id: "js-onboarding-new-project-link", title: _("New..."), ref: 'tooltip', aria: { label: _("New...") }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static' } do
= sprite_icon('plus-square')
- = sprite_icon('angle-down', css_class: 'caret-down')
+ = sprite_icon('chevron-down', css_class: 'caret-down')
.dropdown-menu.dropdown-menu-right
%ul
- if @group&.persisted?
diff --git a/app/views/layouts/jira_connect.html.haml b/app/views/layouts/jira_connect.html.haml
index fdeb3d3c9ac..17f6e9af61a 100644
--- a/app/views/layouts/jira_connect.html.haml
+++ b/app/views/layouts/jira_connect.html.haml
@@ -7,6 +7,7 @@
= stylesheet_link_tag 'https://unpkg.com/@atlaskit/reduced-ui-pack@10.5.5/dist/bundle.css'
= javascript_include_tag 'https://connect-cdn.atl-paas.net/all.js'
= javascript_include_tag 'https://unpkg.com/jquery@3.3.1/dist/jquery.min.js'
+ = yield :page_specific_styles
= yield :head
%body
.ac-content
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index 40ea42091bd..abaadc89a9e 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -3,17 +3,17 @@
%ul.list-unstyled.navbar-sub-nav
- 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", data: { track_label: "projects_dropdown", track_event: "click_dropdown", track_value: "" } }) do
- %button.btn{ type: 'button', data: { toggle: "dropdown" } }
+ %button{ type: 'button', data: { toggle: "dropdown" } }
= _('Projects')
- = sprite_icon('angle-down', css_class: 'caret-down')
+ = sprite_icon('chevron-down', css_class: 'caret-down')
.dropdown-menu.frequent-items-dropdown-menu
= render "layouts/nav/projects_dropdown/show"
- if dashboard_nav_link?(:groups)
= nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "d-none d-md-block home dropdown header-groups qa-groups-dropdown", data: { track_label: "groups_dropdown", track_event: "click_dropdown", track_value: "" } }) do
- %button.btn{ type: 'button', data: { toggle: "dropdown" } }
+ %button{ type: 'button', data: { toggle: "dropdown" } }
= _('Groups')
- = sprite_icon('angle-down', css_class: 'caret-down')
+ = sprite_icon('chevron-down', css_class: 'caret-down')
.dropdown-menu.frequent-items-dropdown-menu
= render "layouts/nav/groups_dropdown/show"
@@ -21,7 +21,7 @@
%li.header-more.dropdown{ **tracking_attrs('main_navigation', 'click_more_link', 'navigation') }
%a{ href: "#", data: { toggle: "dropdown", qa_selector: 'more_dropdown' } }
= _('More')
- = sprite_icon('angle-down', css_class: 'caret-down')
+ = sprite_icon('chevron-down', css_class: 'caret-down')
.dropdown-menu
%ul
- if dashboard_nav_link?(:groups)
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index cb5277c02f0..0da4d4f7ddd 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -260,10 +260,11 @@
= link_to general_admin_application_settings_path, title: _('General'), class: 'qa-admin-settings-general-item' do
%span
= _('General')
- = nav_link(path: ['application_settings#integrations', 'integrations#edit']) do
- = link_to integrations_admin_application_settings_path, title: _('Integrations'), data: { qa_selector: 'integration_settings_link' } do
- %span
- = _('Integrations')
+ - if instance_level_integrations?
+ = nav_link(path: ['application_settings#integrations', 'integrations#edit']) do
+ = link_to integrations_admin_application_settings_path, title: _('Integrations'), data: { qa_selector: 'integration_settings_link' } do
+ %span
+ = _('Integrations')
= nav_link(path: 'application_settings#repository') do
= link_to repository_admin_application_settings_path, title: _('Repository'), class: 'qa-admin-settings-repository-item' do
%span
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 9e9e6493e5b..5f4b1f8ad45 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -139,6 +139,8 @@
%strong.fly-out-top-item-name
= _('Members')
+ = render_if_exists 'groups/invite_members_side_nav_link', group: @group
+
- if group_sidebar_link?(:settings)
= nav_link(path: group_settings_nav_link_paths) do
= link_to edit_group_path(@group) do
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 0eef587d7c7..c11afc8e4ca 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -235,11 +235,11 @@
%span
= _('Alerts')
- - if project_nav_tab?(:incidents)
- = nav_link(controller: :incidents) do
- = link_to project_incidents_path(@project), title: _('Incidents'), data: { qa_selector: 'operations_incidents_link' } do
- %span
- = _('Incidents')
+ - if project_nav_tab?(:incidents)
+ = nav_link(controller: :incidents) do
+ = link_to project_incidents_path(@project), title: _('Incidents'), data: { qa_selector: 'operations_incidents_link' } do
+ %span
+ = _('Incidents')
- if project_nav_tab? :environments
= render_if_exists "layouts/nav/sidebar/tracing_link"
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index 222ca02b1df..a0c82380023 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -1,6 +1,6 @@
- page_title @project.full_name
-- page_description @project.description unless page_description
-- header_title project_title(@project) unless header_title
+- page_description @project.description_html unless page_description
+- header_title project_title(@project) unless header_title
- nav "project"
- display_subscription_banner!
- display_namespace_storage_limit_alert!
diff --git a/app/views/notify/_failed_builds.html.haml b/app/views/notify/_failed_builds.html.haml
index cde0ac21d6d..11cbd700258 100644
--- a/app/views/notify/_failed_builds.html.haml
+++ b/app/views/notify/_failed_builds.html.haml
@@ -6,7 +6,7 @@
#{'build'.pluralize(failed.size)}.
%tr.table-warning
%td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; border: 1px solid #ededed; border-bottom: 0; border-radius: 4px 4px 0 0; overflow: hidden; background-color: #fdf4f6; color: #d22852; font-size: 14px; line-height: 1.4; text-align: center; padding: 8px 16px;" }
- Logs may contain sensitive data. Please consider before forwarding this email.
+ Failed builds
%tr.section
%td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; padding: 0 16px; border: 1px solid #ededed; border-radius: 4px; overflow: hidden; border-top: 0; border-radius: 0 0 4px 4px;" }
%table.builds{ border: "0", cellpadding: "0", cellspacing: "0", style: "width: 100%; border-collapse: collapse;" }
diff --git a/app/views/notify/autodevops_disabled_email.html.haml b/app/views/notify/autodevops_disabled_email.html.haml
index 65a2f75a3e2..72bcfbdf3af 100644
--- a/app/views/notify/autodevops_disabled_email.html.haml
+++ b/app/views/notify/autodevops_disabled_email.html.haml
@@ -46,4 +46,4 @@
%td{ style: "font-family: 'Menlo','Liberation Mono','Consolas','DejaVu Sans Mono','Ubuntu Mono','Courier New','andale mono','lucida console',monospace; font-size: 14px; line-height: 1.4; vertical-align: baseline; padding:0 8px;" }
API
-= render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.statuses.latest.failed
+= render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.latest_statuses.failed
diff --git a/app/views/notify/autodevops_disabled_email.text.erb b/app/views/notify/autodevops_disabled_email.text.erb
index f849c017265..c75857e96d7 100644
--- a/app/views/notify/autodevops_disabled_email.text.erb
+++ b/app/views/notify/autodevops_disabled_email.text.erb
@@ -7,7 +7,7 @@ The Auto DevOps pipeline failed for pipeline <%= @pipeline.iid %> (<%= pipeline_
<% else -%>
Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API
<% end -%>
-<% failed = @pipeline.statuses.latest.failed -%>
+<% failed = @pipeline.latest_statuses.failed -%>
had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>.
<% failed.each do |build| -%>
diff --git a/app/views/notify/changed_reviewer_of_merge_request_email.html.haml b/app/views/notify/changed_reviewer_of_merge_request_email.html.haml
new file mode 100644
index 00000000000..ed7a3285f45
--- /dev/null
+++ b/app/views/notify/changed_reviewer_of_merge_request_email.html.haml
@@ -0,0 +1,2 @@
+%p
+ = change_reviewer_notification_text(@merge_request.reviewers, @previous_reviewers, :strong)
diff --git a/app/views/notify/changed_reviewer_of_merge_request_email.text.erb b/app/views/notify/changed_reviewer_of_merge_request_email.text.erb
new file mode 100644
index 00000000000..b6824966bb9
--- /dev/null
+++ b/app/views/notify/changed_reviewer_of_merge_request_email.text.erb
@@ -0,0 +1 @@
+<%= change_reviewer_notification_text(@merge_request.reviewers, @previous_reviewers) %>
diff --git a/app/views/notify/issue_status_changed_email.text.erb b/app/views/notify/issue_status_changed_email.text.erb
index f38b09e9820..f963e9b5c3d 100644
--- a/app/views/notify/issue_status_changed_email.text.erb
+++ b/app/views/notify/issue_status_changed_email.text.erb
@@ -1,4 +1,3 @@
Issue was <%= @issue_status %> by <%= sanitize_name(@updated_by.name) %>
Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
-
diff --git a/app/views/notify/pipeline_failed_email.html.haml b/app/views/notify/pipeline_failed_email.html.haml
index f01181857ce..575ec8c488e 100644
--- a/app/views/notify/pipeline_failed_email.html.haml
+++ b/app/views/notify/pipeline_failed_email.html.haml
@@ -108,4 +108,4 @@
%td{ style: "font-family:'Menlo','Liberation Mono','Consolas','DejaVu Sans Mono','Ubuntu Mono','Courier New','andale mono','lucida console',monospace;font-size:14px;line-height:1.4;vertical-align:baseline;padding:0 5px;" }
API
-= render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.statuses.latest.failed
+= render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.latest_statuses.failed
diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb
index b388aad7048..a30e331d892 100644
--- a/app/views/notify/pipeline_failed_email.text.erb
+++ b/app/views/notify/pipeline_failed_email.text.erb
@@ -27,7 +27,7 @@ Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%
<% else -%>
Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API
<% end -%>
-<% failed = @pipeline.statuses.latest.failed -%>
+<% failed = @pipeline.latest_statuses.failed -%>
had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>.
<% failed.each do |build| -%>
diff --git a/app/views/notify/prometheus_alert_fired_email.html.haml b/app/views/notify/prometheus_alert_fired_email.html.haml
index 17f9481d353..75ba66b44f9 100644
--- a/app/views/notify/prometheus_alert_fired_email.html.haml
+++ b/app/views/notify/prometheus_alert_fired_email.html.haml
@@ -1,17 +1,17 @@
%p
- = _('An alert has been triggered in %{project_path}.') % { project_path: @alert.project_full_path }
+ = _('An alert has been triggered in %{project_path}.') % { project_path: @alert.project.full_path }
- if description = @alert.description
%p
= _('Description:')
= description
-- if env_name = @alert.environment_name
+- if env_name = @alert.environment&.name
%p
= _('Environment:')
= env_name
-- if metric_query = @alert.metric_query
+- if metric_query = @alert.prometheus_alert&.full_query
%p
= _('Metric:')
@@ -25,4 +25,3 @@
- if @alert.show_performance_dashboard_link?
%p
= link_to(_('View performance dashboard.'), @alert.performance_dashboard_link)
-
diff --git a/app/views/notify/prometheus_alert_fired_email.text.erb b/app/views/notify/prometheus_alert_fired_email.text.erb
index c3f005cfb7e..8853f2a317b 100644
--- a/app/views/notify/prometheus_alert_fired_email.text.erb
+++ b/app/views/notify/prometheus_alert_fired_email.text.erb
@@ -1,14 +1,14 @@
-<%= _('An alert has been triggered in %{project_path}.') % { project_path: @alert.project_full_path } %>.
+<%= _('An alert has been triggered in %{project_path}.') % { project_path: @alert.project.full_path } %>.
<% if description = @alert.description %>
<%= _('Description:') %> <%= description %>
<% end %>
-<% if env_name = @alert.environment_name %>
+<% if env_name = @alert.environment&.name %>
<%= _('Environment:') %> <%= env_name %>
<% end %>
-<% if metric_query = @alert.metric_query %>
+<% if metric_query = @alert.prometheus_alert&.full_query %>
<%= _('Metric:') %> <%= metric_query %>
<% end %>
diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml
index 078b5907623..0a9ea5c4cb3 100644
--- a/app/views/profiles/keys/_form.html.haml
+++ b/app/views/profiles/keys/_form.html.haml
@@ -4,13 +4,13 @@
.form-group
= f.label :key, s_('Profiles|Key'), class: 'label-bold'
- %p= _("Paste your public SSH key, which is usually contained in the file '~/.ssh/id_ed25519.pub' or '~/.ssh/id_rsa.pub' and begins with 'ssh-ed25519' or 'ssh-rsa'. Don't use your private SSH key.")
+ %p= _("Paste your public SSH key, which is usually contained in the file '~/.ssh/id_ed25519.pub' or '~/.ssh/id_rsa.pub' and begins with 'ssh-ed25519' or 'ssh-rsa'. Do not paste your private SSH key, as that can compromise your identity.")
= f.text_area :key, class: "form-control js-add-ssh-key-validation-input qa-key-public-key-field", rows: 8, required: true, placeholder: s_('Profiles|Typically starts with "ssh-ed25519 …" or "ssh-rsa …"')
.form-row
.col.form-group
= f.label :title, _('Title'), class: 'label-bold'
= f.text_field :title, class: "form-control input-lg qa-key-title-field", required: true, placeholder: s_('Profiles|e.g. My MacBook key')
- %p.form-text.text-muted= s_('Profiles|Give your individual key a title. This will be publically visible.')
+ %p.form-text.text-muted= s_('Profiles|Give your individual key a title.')
.col.form-group
= f.label :expires_at, s_('Profiles|Expires at'), class: 'label-bold'
@@ -19,7 +19,7 @@
.js-add-ssh-key-validation-warning.hide
.bs-callout.bs-callout-warning{ role: 'alert', aria_live: 'assertive' }
%strong= _('Oops, are you sure?')
- %p= s_("Profiles|This doesn't look like a public SSH key, are you sure you want to add it?")
+ %p= s_("Profiles|This doesn't look like a public SSH key, are you sure you want to add it? It will be publicly visible.")
%button.btn.btn-success.js-add-ssh-key-validation-confirm-submit= _("Yes, add it")
diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml
index 02b45853aa0..3f0c1596396 100644
--- a/app/views/profiles/keys/_key.html.haml
+++ b/app/views/profiles/keys/_key.html.haml
@@ -23,9 +23,10 @@
%span.expires.gl-mr-3
= s_('Profiles|Expires:')
= key.expires_at ? key.expires_at.to_date : _('Never')
- %span.key-created-at
- = s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)}
+ %span.key-created-at.gl-display-flex.gl-align-items-center
+ = s_('Profiles|Created%{time_ago}'.html_safe) % { time_ago: time_ago_with_tooltip(key.created_at, html_class: 'gl-ml-2')}
- if key.can_delete?
- = link_to path_to_key(key, is_admin), data: { confirm: _('Are you sure?')}, method: :delete, class: "btn btn-transparent gl-ml-3 align-baseline" do
- %span.sr-only= _('Remove')
- = sprite_icon('remove')
+ .gl-ml-3
+ = button_to '#', class: "btn btn-default gl-button btn-default-tertiary js-confirm-modal-button", data: ssh_key_delete_modal_data(key, is_admin) do
+ %span.sr-only= _('Delete')
+ = sprite_icon('remove')
diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml
index 59d953678e7..2bc7e9eccb8 100644
--- a/app/views/profiles/keys/_key_details.html.haml
+++ b/app/views/profiles/keys/_key_details.html.haml
@@ -38,4 +38,4 @@
.col-md-12
.float-right
- if @key.can_delete?
- = link_to _('Remove'), path_to_key(@key, is_admin), data: {confirm: _('Are you sure?')}, method: :delete, class: "btn btn-remove delete-key qa-delete-key-button"
+ = button_to _('Delete'), '#', class: "btn btn-danger gl-button delete-key js-confirm-modal-button", data: ssh_key_delete_modal_data(@key, is_admin)
diff --git a/app/views/profiles/preferences/_gitpod.html.haml b/app/views/profiles/preferences/_gitpod.html.haml
index 69c9443ebbb..589c3a27c18 100644
--- a/app/views/profiles/preferences/_gitpod.html.haml
+++ b/app/views/profiles/preferences/_gitpod.html.haml
@@ -1,5 +1,3 @@
-- gitpod_link = link_to("Gitpod#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}".html_safe, 'https://gitpod.io/', target: '_blank', rel: 'noopener noreferrer')
-
%label.label-bold#gitpod
= s_('Gitpod')
= link_to sprite_icon('question-o'), help_page_path('integration/gitpod.md'), target: '_blank', class: 'has-tooltip', title: _('More information')
@@ -8,4 +6,4 @@
= f.label :gitpod_enabled, class: 'form-check-label' do
= s_('Gitpod|Enable Gitpod integration').html_safe
.form-text.text-muted
- = s_('Enable %{gitpod_link} integration to launch a development environment in your browser directly from GitLab.').html_safe % { gitpod_link: gitpod_link }
+ = gitpod_enable_description
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 2c705886f47..ea1126fb30f 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -2,7 +2,7 @@
- @content_class = "limit-container-width" unless fluid_layout
- Gitlab::Themes.each do |theme|
- = stylesheet_link_tag "themes/theme_#{theme.css_class.gsub('ui-', '')}"
+ = stylesheet_link_tag "themes/#{theme.css_filename}" if theme.css_filename
= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row gl-mt-3 js-preferences-form' } do |f|
.col-lg-4.application-theme#navigation-theme
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 82265938180..3e21928e306 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -64,12 +64,12 @@
- else
= _('Register Universal Two-Factor (U2F) Device')
%p
- = _('Use a hardware device to add the second factor of authentication.')
+ = _('Set up a hardware device as a second factor to sign in.')
%p
- if webauthn_enabled
- = _("As WebAuthn devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a WebAuthn device. That way you'll always be able to log in - even when you're using an unsupported browser.")
+ = _("Not all browsers support WebAuthn. Therefore, we require that you set up a two-factor authentication app first. That way you'll always be able to sign in - even from an unsupported browser.")
- else
- = _("As U2F devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser.")
+ = _("Not all browsers support U2F devices. Therefore, we require that you set up a two-factor authentication app first. That way you'll always be able to sign in - even when you're using an unsupported browser.")
.col-lg-8
- registration = webauthn_enabled ? @webauthn_registration : @u2f_registration
- if registration.errors.present?
@@ -102,7 +102,12 @@
%tbody
- @registrations.each do |registration|
%tr
- %td= registration[:name].presence || html_escape_once(_("&lt;no name set&gt;")).html_safe
+ %td
+ - if registration[:name].present?
+ = registration[:name]
+ - else
+ %span.gl-text-gray-500
+ = _("no name set")
%td= registration[:created_at].to_date.to_s(:medium)
%td= link_to _('Delete'), registration[:delete_path], method: :delete, class: "btn btn-danger float-right", data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.') }
diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml
index 41e13464b1e..5ec2dc57f96 100644
--- a/app/views/projects/_export.html.haml
+++ b/app/views/projects/_export.html.haml
@@ -26,6 +26,5 @@
= link_to _('Generate new export'), generate_new_export_project_path(project),
method: :post, class: "btn btn-default"
- else
- .gl-display-flex.gl-justify-content-end
- = link_to _('Export project'), export_project_path(project),
- method: :post, class: "btn btn-default", data: { qa_selector: 'export_project_link' }
+ = link_to _('Export project'), export_project_path(project),
+ method: :post, class: "btn btn-default", data: { qa_selector: 'export_project_link' }
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 1562cc065f1..81c42de13f0 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -14,7 +14,7 @@
- if is_project_overview
.project-buttons.gl-mb-3.js-show-on-project-root
- = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout)
+ = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout), project_buttons: true
#js-tree-list{ data: vue_file_list_data(project, ref) }
- if can_edit_tree?
diff --git a/app/views/projects/_stat_anchor_list.html.haml b/app/views/projects/_stat_anchor_list.html.haml
index 8e3d759b683..516790fb6d9 100644
--- a/app/views/projects/_stat_anchor_list.html.haml
+++ b/app/views/projects/_stat_anchor_list.html.haml
@@ -1,8 +1,9 @@
- anchors = local_assigns.fetch(:anchors, [])
+- project_buttons = local_assigns.fetch(:project_buttons, false)
- return unless anchors.any?
%ul.nav
- anchors.each do |anchor|
%li.nav-item
= link_to_if anchor.link, anchor.label, anchor.link, class: anchor.is_link ? 'nav-link stat-link d-flex align-items-center' : "nav-link btn btn-#{anchor.class_modifier || 'missing'} d-flex align-items-center" do
- .stat-text.d-flex.align-items-center= anchor.label
+ .stat-text.d-flex.align-items-center{ class: ('btn btn-default disabled' if project_buttons) }= anchor.label
diff --git a/app/views/projects/_visibility_modal.html.haml b/app/views/projects/_visibility_modal.html.haml
index 144f726572b..314211057f9 100644
--- a/app/views/projects/_visibility_modal.html.haml
+++ b/app/views/projects/_visibility_modal.html.haml
@@ -23,7 +23,7 @@
= ("To confirm, type %{phrase_code}").html_safe % { phrase_code: '<code class="js-confirm-danger-match">%{phrase_name}</code>'.html_safe % { phrase_name: @project.full_path } }
.form-group
= text_field_tag 'confirm_path_input', '', class: 'form-control js-confirm-danger-input qa-confirm-input'
- .form-actions.gl-display-flex.gl-justify-content-end
+ .form-actions
%button.btn.btn-default.gl-mr-4{ type: "button", "data-dismiss": "modal" }
= _('Cancel')
= submit_tag _('Reduce project visibility'), class: "btn btn-danger js-confirm-danger-submit qa-confirm-button", disabled: true
diff --git a/app/views/projects/artifacts/_artifact.html.haml b/app/views/projects/artifacts/_artifact.html.haml
index 36e149556e0..81c5a634616 100644
--- a/app/views/projects/artifacts/_artifact.html.haml
+++ b/app/views/projects/artifacts/_artifact.html.haml
@@ -10,7 +10,7 @@
%span.build-link ##{artifact.job_id}
- if artifact.job.ref
- .icon-container{ "aria-label" => artifact.job.tag? ? _('Tag') : _('Branch') }
+ .icon-container.gl-display-inline-block{ "aria-label" => artifact.job.tag? ? _('Tag') : _('Branch') }
= artifact.job.tag? ? sprite_icon('tag', css_class: 'sprite') : sprite_icon('branch', css_class: 'sprite')
= link_to artifact.job.ref, project_ref_path(@project, artifact.job.ref), class: 'ref-name'
- else
diff --git a/app/views/projects/blob/_content.html.haml b/app/views/projects/blob/_content.html.haml
index 11946f22811..5b77e31eb00 100644
--- a/app/views/projects/blob/_content.html.haml
+++ b/app/views/projects/blob/_content.html.haml
@@ -2,9 +2,9 @@
- rich_viewer = blob.rich_viewer
- rich_viewer_active = rich_viewer && params[:viewer] != 'simple'
- blob_data = defined?(@blob) ? @blob.data : {}
-- filename = defined?(@blob) ? @blob.name : ''
+- is_ci_config_file = defined?(@blob) && defined?(@project) ? editing_ci_config?.to_s : 'false'
-#js-blob-toggle-graph-preview{ data: { blob_data: blob_data, filename: filename } }
+#js-blob-toggle-graph-preview{ data: { blob_data: blob_data, is_ci_config_file: is_ci_config_file } }
= render 'projects/blob/viewer', viewer: simple_viewer, hidden: rich_viewer_active
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index cea65bf9b4e..4ec5bb1be30 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -30,7 +30,7 @@
.file-buttons
- if is_markdown
= render 'shared/blob/markdown_buttons', show_fullscreen_button: false
- = button_tag class: 'soft-wrap-toggle btn', type: 'button', tabindex: '-1' do
+ = button_tag class: 'soft-wrap-toggle btn gl-button', type: 'button', tabindex: '-1' do
%span.no-wrap
= custom_icon('icon_no_wrap')
No wrap
diff --git a/app/views/projects/blob/_new_dir.html.haml b/app/views/projects/blob/_new_dir.html.haml
index e9010dc63fc..ca60827863a 100644
--- a/app/views/projects/blob/_new_dir.html.haml
+++ b/app/views/projects/blob/_new_dir.html.haml
@@ -15,7 +15,7 @@
= render 'shared/new_commit_form', placeholder: _("Add new directory")
.form-actions
- = submit_tag _("Create directory"), class: 'btn btn-success'
- = link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal"
+ = submit_tag _("Create directory"), class: 'btn gl-button btn-success'
+ = link_to "Cancel", '#', class: "btn gl-button btn-cancel", "data-dismiss" => "modal"
= render 'shared/projects/edit_information'
diff --git a/app/views/projects/blob/_remove.html.haml b/app/views/projects/blob/_remove.html.haml
index f80bae5c88c..d3440ee41b5 100644
--- a/app/views/projects/blob/_remove.html.haml
+++ b/app/views/projects/blob/_remove.html.haml
@@ -12,5 +12,5 @@
.form-group.row
.offset-sm-2.col-sm-10
- = button_tag 'Delete file', class: 'btn btn-remove btn-remove-file'
- = link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal"
+ = button_tag 'Delete file', class: 'btn gl-button btn-danger btn-remove-file'
+ = link_to "Cancel", '#', class: "btn gl-button btn-cancel", "data-dismiss" => "modal"
diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml
index d2b3c8ef96b..4dbfa2b1e3c 100644
--- a/app/views/projects/blob/_upload.html.haml
+++ b/app/views/projects/blob/_upload.html.haml
@@ -15,14 +15,14 @@
#{ dropzone_text.html_safe }
%br
- .dropzone-alerts.alert.alert-danger.data{ style: "display:none" }
+ .dropzone-alerts.gl-alert.gl-alert-danger.gl-mb-5.data{ style: "display:none" }
= render 'shared/new_commit_form', placeholder: placeholder
.form-actions
- = button_tag class: 'btn btn-success btn-upload-file', id: 'submit-all', type: 'button' do
+ = button_tag class: 'btn gl-button btn-success btn-upload-file', id: 'submit-all', type: 'button' do
= icon('spin spinner', class: 'js-loading-icon hidden' )
= button_title
- = link_to _("Cancel"), '#', class: "btn btn-cancel", "data-dismiss" => "modal"
+ = link_to _("Cancel"), '#', class: "btn gl-button btn-cancel", "data-dismiss" => "modal"
= render 'shared/projects/edit_information'
diff --git a/app/views/projects/blob/_viewer_switcher.html.haml b/app/views/projects/blob/_viewer_switcher.html.haml
index df81e509c85..8e3cf607bbf 100644
--- a/app/views/projects/blob/_viewer_switcher.html.haml
+++ b/app/views/projects/blob/_viewer_switcher.html.haml
@@ -4,9 +4,9 @@
.btn-group.js-blob-viewer-switcher.ml-2{ role: "group" }>
- simple_label = "Display #{simple_viewer.switcher_title}"
- %button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => simple_label, title: simple_label, data: { viewer: 'simple', container: 'body' } }>
+ %button.btn.gl-button.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => simple_label, title: simple_label, data: { viewer: 'simple', container: 'body' } }>
= sprite_icon(simple_viewer.switcher_icon)
- rich_label = "Display #{rich_viewer.switcher_title}"
- %button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => rich_label, title: rich_label, data: { viewer: 'rich', container: 'body' } }>
+ %button.btn.gl-button.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => rich_label, title: rich_label, data: { viewer: 'rich', container: 'body' } }>
= sprite_icon(rich_viewer.switcher_icon)
diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml
index 9bb4342ffb4..54c47e7af38 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -9,9 +9,6 @@
= link_to "the file", project_blob_path(@project, tree_join(@branch_name, @file_path)), target: "_blank", rel: 'noopener noreferrer', class: 'gl-link'
and make sure your changes will not unintentionally remove theirs.
-- if editing_ci_config? && show_web_ide_alert?
- #js-suggest-web-ide-ci{ data: { dismiss_endpoint: user_callouts_path, feature_id: UserCalloutsHelper::WEB_IDE_ALERT_DISMISSED, edit_path: ide_edit_path } }
-
.editor-title-row
%h3.page-title.blob-edit-page-title
Edit file
diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml
index 2d9c7f9848f..dbe0bf35b98 100644
--- a/app/views/projects/buttons/_fork.html.haml
+++ b/app/views/projects/buttons/_fork.html.haml
@@ -3,14 +3,14 @@
.count-badge.d-inline-flex.align-item-stretch.gl-mr-3
- 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: s_('ProjectOverview|Go to your fork'), class: 'btn btn-default has-tooltip count-badge-button d-flex align-items-center fork-btn' do
- = sprite_icon('fork', { css_class: 'icon' })
+ = sprite_icon('fork', css_class: 'icon')
%span= s_('ProjectOverview|Fork')
- else
- can_create_fork = current_user.can?(:create_fork)
= link_to new_project_fork_path(@project),
class: "btn btn-default btn-xs has-tooltip count-badge-button d-flex align-items-center fork-btn #{'has-tooltip disabled' unless can_create_fork}",
title: (s_('ProjectOverview|You have reached your project limit') unless can_create_fork) do
- = sprite_icon('fork', { css_class: 'icon' })
+ = sprite_icon('fork', css_class: 'icon')
%span= s_('ProjectOverview|Fork')
%span.fork-count.count-badge-count.d-flex.align-items-center
= link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Fork'), s_('ProjectOverview|Forks'), @project.forks_count), class: 'count' do
diff --git a/app/views/projects/buttons/_remove_tag.html.haml b/app/views/projects/buttons/_remove_tag.html.haml
new file mode 100644
index 00000000000..bc378b847be
--- /dev/null
+++ b/app/views/projects/buttons/_remove_tag.html.haml
@@ -0,0 +1,6 @@
+- project = local_assigns.fetch(:project, nil)
+- tag = local_assigns.fetch(:tag, nil)
+- return unless project && tag
+
+%button{ type: "button", class: "js-remove-tag js-confirm-modal-button gl-button btn btn-remove remove-row has-tooltip gl-ml-3 #{protected_tag?(project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), data: { container: 'body', path: project_tag_path(@project, tag.name), modal_attributes: delete_tag_modal_attributes(tag.name) } }
+ = sprite_icon("remove")
diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml
index 3dac38d1356..690f0fe10f7 100644
--- a/app/views/projects/buttons/_star.html.haml
+++ b/app/views/projects/buttons/_star.html.haml
@@ -2,10 +2,10 @@
.count-badge.d-inline-flex.align-item-stretch.gl-mr-3
%button.count-badge-button.btn.btn-default.btn-xs.d-flex.align-items-center.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(@project, :json) } }
- if current_user.starred?(@project)
- = sprite_icon('star', { css_class: 'icon' })
+ = sprite_icon('star', css_class: 'icon')
%span.starred= s_('ProjectOverview|Unstar')
- else
- = sprite_icon('star-o', { css_class: 'icon' })
+ = sprite_icon('star-o', css_class: 'icon')
%span= s_('ProjectOverview|Star')
%span.star-count.count-badge-count.d-flex.align-items-center
= link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'count' do
@@ -14,7 +14,7 @@
- else
.count-badge.d-inline-flex.align-item-stretch.gl-mr-3
= link_to new_user_session_path, class: 'btn btn-default btn-xs has-tooltip count-badge-button d-flex align-items-center star-btn', title: s_('ProjectOverview|You must sign in to star a project') do
- = sprite_icon('star-o', { css_class: 'icon' })
+ = sprite_icon('star-o', css_class: 'icon')
%span= s_('ProjectOverview|Star')
%span.star-count.count-badge-count.d-flex.align-items-center
= link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'count' do
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index c7ab01a4ef7..b01665daff4 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -21,7 +21,7 @@
- if ref
- if job.ref
- .icon-container
+ .icon-container.gl-display-inline-block
= 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
@@ -101,11 +101,11 @@
= sprite_icon('download')
- if can?(current_user, :update_build, job)
- if job.active?
- = link_to cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), class: 'btn btn-build' do
+ = link_to cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), class: 'btn gl-button btn-build' do
= sprite_icon('close')
- elsif job.scheduled?
.btn-group
- .btn.btn-default{ disabled: true }
+ .btn.gl-button.btn-default{ disabled: true }
= sprite_icon('planning')
%time.js-remaining-time{ datetime: job.scheduled_at.utc.iso8601 }
= duration_in_numbers(job.execute_in)
@@ -113,17 +113,17 @@
= link_to play_project_job_path(job.project, job, return_to: request.original_url),
method: :post,
title: s_('DelayedJobs|Start now'),
- class: 'btn btn-default btn-build has-tooltip',
+ class: 'btn gl-button btn-default btn-build has-tooltip',
data: { confirm: confirmation_message } do
= sprite_icon('play')
= link_to unschedule_project_job_path(job.project, job, return_to: request.original_url),
method: :post,
title: s_('DelayedJobs|Unschedule'),
- class: 'btn btn-default btn-build has-tooltip' do
+ class: 'btn gl-button btn-default btn-build has-tooltip' do
= sprite_icon('time-out')
- elsif allow_retry
- if job.playable? && !admin && can?(current_user, :update_build, job)
- = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), class: 'btn btn-build' do
+ = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), class: 'btn gl-button btn-build' do
= custom_icon('icon_play')
- elsif job.retryable?
= link_to retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Retry'), class: 'btn btn-build gl-button btn-icon btn-default' do
diff --git a/app/views/projects/ci/lints/show.html.haml b/app/views/projects/ci/lints/show.html.haml
index 2e79852f4c9..55aa1c3ae56 100644
--- a/app/views/projects/ci/lints/show.html.haml
+++ b/app/views/projects/ci/lints/show.html.haml
@@ -1,7 +1,7 @@
- page_title _("CI Lint")
- page_description _("Validate your GitLab CI configuration file")
-- unless Feature.enabled?(:monaco_ci)
- - content_for :library_javascripts do
+- unless Feature.enabled?(:monaco_ci, default_enabled: true)
+ - content_for :page_specific_javascripts do
= page_specific_javascript_tag('lib/ace.js')
%h2.pt-3.pb-3= _("Validate your GitLab CI configuration")
@@ -17,7 +17,7 @@
.file-holder
.js-file-title.file-title.clearfix
= _("Contents of .gitlab-ci.yml")
- - if Feature.enabled?(:monaco_ci)
+ - if Feature.enabled?(:monaco_ci, default_enabled: true)
.file-editor.code
.js-edit-mode-pane.qa-editor#editor{ data: { 'editor-loading': true } }<
%pre.editor-loading-content= params[:content]
diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml
index 019894ddbb4..02d35e690ca 100644
--- a/app/views/projects/cleanup/_show.html.haml
+++ b/app/views/projects/cleanup/_show.html.haml
@@ -26,5 +26,4 @@
.form-text.text-muted
= _("The maximum file size allowed is %{size}.") % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) }
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Start cleanup'), class: 'btn btn-success'
+ = f.submit _('Start cleanup'), class: 'btn btn-success'
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 29ee4a69e83..d9174843301 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -1,6 +1,6 @@
- can_collaborate = can_collaborate_with_project?(@project)
-.page-content-header.js-commit-box{ 'data-commit-path' => branches_project_commit_path(@project, @commit.id) }
+.page-content-header
.header-main-content
= render partial: 'signature', object: @commit.signature
%strong
@@ -58,7 +58,7 @@
%pre.commit-description<
= preserve(markdown_field(@commit, :description))
-.info-well
+.info-well.js-commit-box-info{ 'data-commit-path' => branches_project_commit_path(@project, @commit.id) }
.well-segment.branch-info
.icon-container.commit-icon
= custom_icon("icon_commit")
diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml
index 293500a6c31..63cc96c2c05 100644
--- a/app/views/projects/commits/_commits.html.haml
+++ b/app/views/projects/commits/_commits.html.haml
@@ -28,7 +28,8 @@
= render partial: 'projects/commits/commit', collection: context_commits, locals: { project: project, ref: ref, merge_request: merge_request }
- if hidden > 0
- %li.alert.alert-warning
+ %li.gl-alert.gl-alert-warning
+ = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
= n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden)
- if project.context_commits_enabled? && can_update_merge_request && context_commits&.empty?
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index d7e10efc3b1..d99579c25c0 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -1,4 +1,5 @@
- page_title _("Value Stream Analytics")
+- add_page_specific_style 'page_bundles/cycle_analytics'
#cycle-analytics{ "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } }
- if @cycle_analytics_no_data
diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml
index 46ee60949db..2ba12601c79 100644
--- a/app/views/projects/default_branch/_show.html.haml
+++ b/app/views/projects/default_branch/_show.html.haml
@@ -28,5 +28,4 @@
= _("Issues referenced by merge requests and commits within the default branch will be closed automatically")
= link_to sprite_icon('question-o'), help_page_path('user/project/issues/managing_issues.md', anchor: 'disabling-automatic-issue-closing'), target: '_blank'
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml
index 743aa60b3ba..52e3e0fd997 100644
--- a/app/views/projects/deployments/_commit.html.haml
+++ b/app/views/projects/deployments/_commit.html.haml
@@ -1,7 +1,7 @@
.table-mobile-content
.branch-commit.cgray
- if deployment.ref
- %span.icon-container
+ %span.icon-container.gl-display-inline-block
= 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
diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml
index f954b09abee..e9dfda4e927 100644
--- a/app/views/projects/diffs/_file_header.html.haml
+++ b/app/views/projects/diffs/_file_header.html.haml
@@ -6,7 +6,7 @@
- if diff_file.submodule?
- blob = diff_file.blob
%span
- = icon('archive fw')
+ = sprite_icon('archive')
%strong.file-title-name
= submodule_link(blob, diff_file.content_sha, diff_file.repository)
diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml
index d35443cca1e..4d40071e07c 100644
--- a/app/views/projects/diffs/_line.html.haml
+++ b/app/views/projects/diffs/_line.html.haml
@@ -21,9 +21,6 @@
- else
= add_diff_note_button(line_code, diff_file.position(line), type)
%a{ href: "##{line_code}", data: { linenumber: link_text } }
- - discussion = line_discussions.try(:first)
- - if discussion && discussion.resolvable? && !plain
- %diff-note-avatars{ "discussion-id" => discussion.id }
%td.new_line.diff-line-num{ class: type, data: { linenumber: line.new_pos } }
- link_text = type == "old" ? " " : line.new_pos
- if plain
diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml
index 9587ea4696b..ebe3aad064a 100644
--- a/app/views/projects/diffs/_parallel_view.html.haml
+++ b/app/views/projects/diffs/_parallel_view.html.haml
@@ -20,9 +20,6 @@
%td.old_line.diff-line-num.js-avatar-container{ class: left.type, data: { linenumber: left.old_pos } }
= add_diff_note_button(left_line_code, left_position, 'old')
%a{ href: "##{left_line_code}", data: { linenumber: left.old_pos } }
- - discussion_left = discussions_left.try(:first)
- - if discussion_left && discussion_left.resolvable?
- %diff-note-avatars{ "discussion-id" => discussion_left.id }
%td.line_content.parallel.left-side{ id: left_line_code, class: left.type }= diff_line_content(left.rich_text)
- else
%td.old_line.diff-line-num.empty-cell
@@ -41,9 +38,6 @@
%td.new_line.diff-line-num.js-avatar-container{ class: right.type, data: { linenumber: right.new_pos } }
= add_diff_note_button(right_line_code, right_position, 'new')
%a{ href: "##{right_line_code}", data: { linenumber: right.new_pos } }
- - discussion_right = discussions_right.try(:first)
- - if discussion_right && discussion_right.resolvable?
- %diff-note-avatars{ "discussion-id" => discussion_right.id }
%td.line_content.parallel.right-side{ id: right_line_code, class: right.type }= diff_line_content(right.rich_text)
- else
%td.old_line.diff-line-num.empty-cell
diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml
index b438fbbf446..cee479aab0a 100644
--- a/app/views/projects/diffs/_stats.html.haml
+++ b/app/views/projects/diffs/_stats.html.haml
@@ -1,5 +1,5 @@
-- sum_added_lines = diff_files.sum(&:added_lines) # rubocop: disable CodeReuse/ActiveRecord
-- sum_removed_lines = diff_files.sum(&:removed_lines) # rubocop: disable CodeReuse/ActiveRecord
+- sum_added_lines = diff_files.sum(&:added_lines)
+- sum_removed_lines = diff_files.sum(&:removed_lines)
.commit-stat-summary.dropdown
Showing
%button.diff-stats-summary-toggler.js-diff-stats-dropdown{ type: "button", data: { toggle: "dropdown", display: "static" } }<
diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml
index 641a0689c26..a945ff5aedf 100644
--- a/app/views/projects/diffs/_text_file.html.haml
+++ b/app/views/projects/diffs/_text_file.html.haml
@@ -1,4 +1,4 @@
-- too_big = diff_file.diff_lines.count > Commit::DIFF_SAFE_LINES
+- too_big = diff_file.diff_lines.count > Commit.diff_safe_lines
- if too_big
.suppressed-container
%a.show-suppressed-diff.cursor-pointer.js-show-suppressed-diff= _("Changes suppressed. Click to show.")
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index e5c4cfcbd72..a9693f985db 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -21,10 +21,9 @@
%input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' }
%template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data_json(@project)
.js-project-permissions-form
- .gl-display-flex.gl-justify-content-end
- - if show_visibility_confirm_modal?(@project)
- = render "visibility_modal"
- = f.submit _('Save changes'), class: "btn btn-success #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level }
+ - if show_visibility_confirm_modal?(@project)
+ = render "visibility_modal"
+ = f.submit _('Save changes'), class: "btn btn-success #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level }
%section.qa-merge-request-settings.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] }
.settings-header
@@ -38,8 +37,7 @@
= form_for @project, remote: true, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f|
%input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' }
= render 'projects/merge_request_settings', form: f
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success qa-save-merge-request-changes rspec-save-merge-request-changes"
+ = f.submit _('Save changes'), class: "btn btn-succes qa-save-merge-request-changes rspec-save-merge-request-changes"
= render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded
@@ -70,9 +68,8 @@
.sub-section
%h4= _('Housekeeping')
%p= _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.')
- .gl-display-flex.gl-justify-content-end
- = link_to _('Run housekeeping'), housekeeping_project_path(@project),
- method: :post, class: "btn btn-default"
+ = link_to _('Run housekeeping'), housekeeping_project_path(@project),
+ method: :post, class: "btn btn-default"
= render 'export', project: @project
@@ -94,8 +91,7 @@
%li= _('You will need to update your local repositories to point to the new location.')
- if @project.deployment_platform.present?
%li= _('Your deployment services will be broken, you will need to manually fix the services after renaming.')
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Change path'), class: "btn btn-warning qa-change-path-button"
+ = f.submit _('Change path'), class: "btn btn-warning qa-change-path-button"
- if can?(current_user, :change_namespace, @project)
.sub-section
@@ -111,8 +107,7 @@
%li= _('You can only transfer the project to namespaces you manage.')
%li= _('You will need to update your local repositories to point to the new location.')
%li= _('Project visibility level will be changed to match namespace rules when transferring to a group.')
- .gl-display-flex.gl-justify-content-end
- = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger qa-transfer-button", data: { "confirm-danger-message" => transfer_project_message(@project) }
+ = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger qa-transfer-button", data: { "confirm-danger-message" => transfer_project_message(@project) }
- if @project.forked? && can?(current_user, :remove_fork_project, @project)
.sub-section
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index c9edc3c12ec..3f19d4db1cc 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -20,7 +20,7 @@
.project-clone-holder.d-none.d-md-inline-block.mt-2.mr-2.float-left
= render "projects/buttons/clone"
- = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons
+ = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons, project_buttons: true
- if can?(current_user, :push_code, @project)
.empty-wrapper.gl-mt-7
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index 929015023d2..a774e3b61cc 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -1,9 +1,7 @@
- add_to_breadcrumbs _("Environments"), project_environments_path(@project)
- breadcrumb_title @environment.name
- page_title _("Environments")
-
-- content_for :page_specific_javascripts do
- = stylesheet_link_tag 'page_bundles/xterm'
+- add_page_specific_style 'page_bundles/xterm'
#environments-detail-view{ data: { name: @environment.name, id: @environment.id, delete_path: environment_delete_path(@environment)} }
- if @environment.available? && can?(current_user, :stop_environment, @environment)
@@ -67,7 +65,7 @@
%p.blank-state-text
= html_escape(_("Define environments in the deploy stage(s) in %{code_open}.gitlab-ci.yml%{code_close} to track deployments here.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
.text-center
- = link_to _("Read more"), help_page_path("ci/environments"), class: "btn btn-success"
+ = link_to _("Read more"), help_page_path("ci/environments/index.md"), class: "btn btn-success"
- else
.table-holder.gl-overflow-visible
.ci-table.environments{ role: 'grid' }
diff --git a/app/views/projects/feature_flags/_errors.html.haml b/app/views/projects/feature_flags/_errors.html.haml
deleted file mode 100644
index a32245640be..00000000000
--- a/app/views/projects/feature_flags/_errors.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-#error_explanation
- .alert.alert-danger
- - @feature_flag.errors.full_messages.each do |message|
- %p= message
diff --git a/app/views/projects/feature_flags/edit.html.haml b/app/views/projects/feature_flags/edit.html.haml
index 4de41ca4080..67b1a8398d3 100644
--- a/app/views/projects/feature_flags/edit.html.haml
+++ b/app/views/projects/feature_flags/edit.html.haml
@@ -1,4 +1,4 @@
-- @gfm_form = Feature.enabled?(:feature_flags_issue_links, @project, default_enabled: true)
+- @gfm_form = true
- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project)
- breadcrumb_title @feature_flag.name
diff --git a/app/views/projects/forks/_fork_button.html.haml b/app/views/projects/forks/_fork_button.html.haml
index dd49e8bdb4b..cfef2a19420 100644
--- a/app/views/projects/forks/_fork_button.html.haml
+++ b/app/views/projects/forks/_fork_button.html.haml
@@ -10,11 +10,11 @@
%h5.gl-mt-3
= namespace.human_name
- if forked_project = namespace.find_fork_of(@project)
- = link_to _("Go to project"), project_path(forked_project), class: "btn"
+ = link_to _("Go to project"), project_path(forked_project), class: "btn gl-button btn-default"
- else
%div{ class: ('has-tooltip' unless can_create_project),
title: (_('You have reached your project limit') unless can_create_project) }
= link_to _("Select"), project_forks_path(@project, namespace_key: namespace.id),
data: { qa_selector: 'fork_namespace_button', qa_name: namespace.human_name },
method: "POST",
- class: ["btn btn-success", ("disabled" unless can_create_project)]
+ class: ["btn gl-button btn-success", ("disabled" unless can_create_project)]
diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml
index 8384561891a..67dc07fb785 100644
--- a/app/views/projects/forks/index.html.haml
+++ b/app/views/projects/forks/index.html.haml
@@ -30,11 +30,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-success' do
+ = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn gl-button btn-success' do
= sprite_icon('fork', size: 12)
%span= _('Fork')
- else
- = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn btn-success' do
+ = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn gl-button btn-success' do
= sprite_icon('fork', size: 12)
%span= _('Fork')
diff --git a/app/views/projects/group_links/update.js.haml b/app/views/projects/group_links/update.js.haml
deleted file mode 100644
index 55520fda494..00000000000
--- a/app/views/projects/group_links/update.js.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-:plain
- var $listItem = $('#{escape_javascript(render('shared/members/group', group_link: @group_link))}');
- $("#group_member_#{@group_link.id} .list-item-name").replaceWith($listItem.find('.list-item-name'));
- gl.utils.localTimeAgo($('.js-timeago'), $("#group_member_#{@group_link.id}"));
diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml
index 8a8c396a9e4..ebe179c3454 100644
--- a/app/views/projects/hook_logs/show.html.haml
+++ b/app/views/projects/hook_logs/show.html.haml
@@ -7,6 +7,6 @@
%h4.gl-mt-0
Request details
.col-lg-9
- = link_to 'Resend Request', @hook_log.present.retry_path, method: :post, class: "btn btn-default float-right gl-ml-3"
+ = link_to 'Resend Request', @hook_log.present.retry_path, method: :post, class: "btn gl-button btn-default float-right gl-ml-3"
= render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log }
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
index f728ef5ac1a..fb19b251d41 100644
--- a/app/views/projects/hooks/edit.html.haml
+++ b/app/views/projects/hooks/edit.html.haml
@@ -10,9 +10,9 @@
= form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
- %span>= f.submit 'Save changes', class: 'btn btn-success gl-mr-3'
+ = f.submit 'Save changes', class: 'btn gl-button btn-success gl-mr-3'
= render 'shared/web_hooks/test_button', hook: @hook
- = link_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'btn btn-remove float-right', data: { confirm: _('Are you sure?') }
+ = link_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'btn gl-button btn-danger float-right', data: { confirm: _('Are you sure?') }
%hr
diff --git a/app/views/projects/hooks/index.html.haml b/app/views/projects/hooks/index.html.haml
index 5c6a87ddb26..e40c36da29d 100644
--- a/app/views/projects/hooks/index.html.haml
+++ b/app/views/projects/hooks/index.html.haml
@@ -9,7 +9,6 @@
.col-lg-8.gl-mb-3
= form_for @hook, as: :hook, url: polymorphic_path([@project, :hooks]) do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
- .gl-display-flex.gl-justify-content-end
- = f.submit 'Add webhook', class: 'btn btn-success'
+ = f.submit 'Add webhook', class: 'btn btn-success'
= render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class
diff --git a/app/views/projects/incidents/_new_branch.html.haml b/app/views/projects/incidents/_new_branch.html.haml
new file mode 100644
index 00000000000..f250fbc4b8b
--- /dev/null
+++ b/app/views/projects/incidents/_new_branch.html.haml
@@ -0,0 +1 @@
+= render 'projects/issues/new_branch'
diff --git a/app/views/projects/incidents/index.html.haml b/app/views/projects/incidents/index.html.haml
index 3d66c254601..a89e93618bc 100644
--- a/app/views/projects/incidents/index.html.haml
+++ b/app/views/projects/incidents/index.html.haml
@@ -1,3 +1,3 @@
- page_title _('Incidents')
-#js-incidents{ data: incidents_data(@project) }
+#js-incidents{ data: incidents_data(@project, params) }
diff --git a/app/views/projects/incidents/show.html.haml b/app/views/projects/incidents/show.html.haml
new file mode 100644
index 00000000000..b0ddc85df5d
--- /dev/null
+++ b/app/views/projects/incidents/show.html.haml
@@ -0,0 +1 @@
+= render template: 'projects/issues/show'
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index 4273130bbc2..e1f1d8bb8f7 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -1,4 +1,5 @@
- add_page_startup_api_call discussions_path(@issue)
+- add_page_startup_api_call notes_url
- @gfm_form = true
diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml
index 1a557cce33c..f3c431de1ea 100644
--- a/app/views/projects/issues/_issues.html.haml
+++ b/app/views/projects/issues/_issues.html.haml
@@ -4,12 +4,14 @@
- data_endpoint = local_assigns.fetch(:data_endpoint, expose_path(api_v4_projects_issues_path(id: @project.id)))
- default_empty_state_meta = { create_issue_path: new_project_issue_path(@project), svg_path: image_path('illustrations/issues.svg') }
- data_empty_state_meta = local_assigns.fetch(:data_empty_state_meta, default_empty_state_meta)
- - type = local_assigns.fetch(:type, '')
+ - type = local_assigns.fetch(:type, 'issues')
+ - if type == 'issues' && use_startup_call?
+ - add_page_startup_api_call(api_v4_projects_issues_path(id: @project.id, params: startup_call_params))
.js-issuables-list{ data: { endpoint: data_endpoint,
'empty-state-meta': data_empty_state_meta.to_json,
'can-bulk-edit': @can_bulk_update.to_json,
'sort-key': @sort,
- 'type': type } }
+ type: type } }
- else
- empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues')
%ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position') }
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index 9bbab925f6a..aa95cecb5fe 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -43,7 +43,7 @@
%li.droplab-item-ignore.gl-ml-3.gl-mr-3.gl-mt-5
- if can_create_confidential_merge_request?
- #js-forked-project{ data: { namespace_path: @project.namespace.full_path, project_path: @project.full_path, new_fork_path: new_project_fork_path(@project), help_page_path: help_page_path('user/project/merge_requests') } }
+ #js-forked-project{ data: { namespace_path: @project.namespace.full_path, project_path: @project.full_path, new_fork_path: new_project_fork_path(@project), help_page_path: help_page_path('user/project/merge_requests/index.md') } }
.form-group
%label{ for: 'new-branch-name' }
= _('Branch name')
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index cfc423da57a..7fa158e0024 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -2,6 +2,7 @@
- page_title _("Issues")
- new_issue_email = @project.new_issuable_address(current_user, 'issue')
+- add_page_specific_style 'page_bundles/issues'
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} issues")
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index c762b044c3e..7ee6c2b137a 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -2,14 +2,17 @@
- add_to_breadcrumbs _("Issues"), project_issues_path(@project)
- breadcrumb_title @issue.to_reference
- page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues")
-- page_description @issue.description
+- page_description @issue.description_html
- page_card_attributes @issue.card_attributes
+- if @issue.relocation_target
+ - page_canonical_link @issue.relocation_target.present(current_user: current_user).web_url
- can_update_issue = can?(current_user, :update_issue, @issue)
- can_reopen_issue = can?(current_user, :reopen_issue, @issue)
- can_report_spam = @issue.submittable_as_spam_by?(current_user)
- can_create_issue = show_new_issue_link?(@project)
- related_branches_path = related_branches_project_issue_path(@project, @issue)
+- add_page_specific_style 'page_bundles/issues'
= render_if_exists "projects/issues/alert_blocked", issue: @issue, current_user: current_user
= render "projects/issues/alert_moved_from_service_desk", issue: @issue
@@ -61,15 +64,12 @@
.issue-details.issuable-details
.detail-page-description.content-block
- -# haml-lint:disable InlineJavaScript
- %script#js-issuable-app-initial-data{ type: "application/json" }= issuable_initial_data(@issue).to_json
- #js-issuable-app
+ #js-issuable-app{ data: { initial: issuable_initial_data(@issue).to_json} }
.title-container
%h2.title= markdown_field(@issue, :title)
- if @issue.description.present?
- .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' }
+ .description
.md= markdown_field(@issue, :description)
- %textarea.hidden.js-task-list-field= @issue.description
= edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago')
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
index df98a1c7cce..d7a778088ee 100644
--- a/app/views/projects/jobs/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -1,9 +1,7 @@
- add_to_breadcrumbs _("Jobs"), project_jobs_path(@project)
- breadcrumb_title "##{@build.id}"
- page_title "#{@build.name} (##{@build.id})", _("Jobs")
-
-- content_for :page_specific_javascripts do
- = stylesheet_link_tag 'page_bundles/xterm'
+- add_page_specific_style 'page_bundles/xterm'
= render_if_exists "shared/shared_runners_minutes_limit_flash_message"
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index 8d8270847a3..2699192adc9 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -52,5 +52,5 @@
= render 'shared/empty_states/labels'
%template#js-badge-item-template
- %li.label-link-item.js-priority-badge.inline.gl-ml-3
- .label-badge.label-badge-blue= _('Prioritized label')
+ %li.js-priority-badge.inline.gl-ml-3
+ .label-badge.gl-bg-blue-50= _('Prioritized label')
diff --git a/app/views/projects/merge_requests/_description.html.haml b/app/views/projects/merge_requests/_description.html.haml
index 354a384b647..c20479662dd 100644
--- a/app/views/projects/merge_requests/_description.html.haml
+++ b/app/views/projects/merge_requests/_description.html.haml
@@ -3,7 +3,6 @@
.description.qa-description{ class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : '' }
.md
= markdown_field(@merge_request, :description)
- %textarea.hidden.js-task-list-field
- = @merge_request.description
+ %textarea.hidden.js-task-list-field{ data: { value: @merge_request.description } }
= edited_time_ago_with_tooltip(@merge_request, placement: 'bottom')
diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml
deleted file mode 100644
index ecb51aca847..00000000000
--- a/app/views/projects/merge_requests/_discussion.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-- content_for :note_actions do
- - if can?(current_user, :update_merge_request, @merge_request)
- - if @merge_request.open?
- = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: { original_text: "Close merge request", alternative_text: "Comment & close merge request"}
- - if @merge_request.reopenable?
- = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-close js-note-target-reopen", title: "Reopen merge request", data: { original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"}
- %comment-and-resolve-btn{ "inline-template" => true }
- %button.btn.btn-nr.btn-default.gl-mr-3.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } }
- {{ buttonText }}
-
-#notes= render "shared/notes/notes_with_form", :autocomplete => true
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index 454a0694355..b56e2c3f985 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -32,16 +32,19 @@
%ul
- if can_update_merge_request
%li= link_to 'Edit', edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
- - unless current_user == @merge_request.author
- %li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request))
- if can_update_merge_request
+ - unless @merge_request.closed?
+ %li
+ = link_to @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft'), toggle_draft_issuable_path(@merge_request), method: :put, class: "js-draft-toggle-button"
%li{ class: [merge_request_button_visibility(@merge_request, true), 'js-close-item'] }
= link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request'
- if can_reopen_merge_request
%li{ class: merge_request_button_visibility(@merge_request, false) }
= link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request'
+ - unless current_user == @merge_request.author
+ %li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request))
- if can_update_merge_request
- = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-sm-none d-md-block btn btn-grouped js-issuable-edit qa-edit-button"
+ = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-sm-none d-md-block btn gl-button btn-grouped js-issuable-edit qa-edit-button"
= render 'shared/issuable/close_reopen_button', issuable: @merge_request, can_update: can_update_merge_request, can_reopen: can_reopen_merge_request
diff --git a/app/views/projects/merge_requests/diffs/_commit_widget.html.haml b/app/views/projects/merge_requests/diffs/_commit_widget.html.haml
deleted file mode 100644
index c022d2c70d8..00000000000
--- a/app/views/projects/merge_requests/diffs/_commit_widget.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
--#-----------------------------------------------------------------
- WARNING: Please keep changes up-to-date with the following files:
- - `assets/javascripts/diffs/components/commit_widget.vue`
--#-----------------------------------------------------------------
-- collapsible = local_assigns.fetch(:collapsible, true)
-
-- if @commit
- .info-well.mw-100.mx-0
- .well-segment
- %ul.blob-commit-info
- = render 'projects/commits/commit', commit: @commit, merge_request: @merge_request, view_details: true, collapsible: collapsible
diff --git a/app/views/projects/merge_requests/diffs/_different_base.html.haml b/app/views/projects/merge_requests/diffs/_different_base.html.haml
deleted file mode 100644
index 06a15b96653..00000000000
--- a/app/views/projects/merge_requests/diffs/_different_base.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-- if @merge_request_diff && different_base?(@start_version, @merge_request_diff)
- .mr-version-controls
- .content-block
- = sprite_icon('information-o')
- Selected versions have different base commits.
- Changes will include
- = link_to project_compare_path(@project, from: @start_version.base_commit_sha, to: @merge_request_diff.base_commit_sha) do
- new commits
- from
- = succeed '.' do
- %code.ref-name= @merge_request.target_branch
diff --git a/app/views/projects/merge_requests/diffs/_diffs.html.haml b/app/views/projects/merge_requests/diffs/_diffs.html.haml
deleted file mode 100644
index 9ebd91dea0b..00000000000
--- a/app/views/projects/merge_requests/diffs/_diffs.html.haml
+++ /dev/null
@@ -1,21 +0,0 @@
-= render 'projects/merge_requests/diffs/version_controls'
-= render 'projects/merge_requests/diffs/different_base'
-= render 'projects/merge_requests/diffs/not_all_comments_displayed'
-= render 'projects/merge_requests/diffs/commit_widget'
-
-- if @merge_request_diff&.empty?
- .row.empty-state.nothing-here-block
- .col-12
- .svg-content= image_tag 'illustrations/merge_request_changes_empty.svg'
- .col-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-success'
-- else
- - diff_viewable = @merge_request_diff ? @merge_request_diff.viewable? : true
- - if diff_viewable
- = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, merge_request: true
diff --git a/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml b/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml
deleted file mode 100644
index b9dc37c9b54..00000000000
--- a/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml
+++ /dev/null
@@ -1,17 +0,0 @@
-- if @commit || @start_version || (@merge_request_diff && !@merge_request_diff.latest?)
- .mr-version-controls
- .content-block.comments-disabled-notif.clearfix
- = sprite_icon('information-o')
- = succeed '.' do
- - if @commit
- Only comments from the following commit are shown below
- - else
- Not all comments are displayed because you're
- - if @start_version
- comparing two versions of the diff
- - else
- viewing an old version of the diff
- .float-right
- = link_to diffs_project_merge_request_path(@merge_request.project, @merge_request), class: 'btn btn-sm' do
- Show latest version
- = "of the diff" if @commit
diff --git a/app/views/projects/merge_requests/diffs/_version_controls.html.haml b/app/views/projects/merge_requests/diffs/_version_controls.html.haml
deleted file mode 100644
index 52bf584d550..00000000000
--- a/app/views/projects/merge_requests/diffs/_version_controls.html.haml
+++ /dev/null
@@ -1,73 +0,0 @@
-- if @merge_request_diff && @merge_request_diffs.size > 1
- .mr-version-controls
- .mr-version-menus-container.content-block
- Changes between
- %span.dropdown.inline.mr-version-dropdown
- %a.dropdown-toggle.btn.btn-default{ data: { toggle: :dropdown, display: 'static' } }
- %span
- - if @merge_request_diff.latest?
- latest version
- - else
- version #{version_index(@merge_request_diff)}
- = icon('caret-down')
- .dropdown-menu.dropdown-select.dropdown-menu-selectable
- .dropdown-title
- %span Version:
- %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } }
- = icon('times', class: 'dropdown-menu-close-icon')
- .dropdown-content
- %ul
- - @merge_request_diffs.each do |merge_request_diff|
- %li
- = link_to merge_request_version_path(@project, @merge_request, merge_request_diff, @start_sha), class: ('is-active' if merge_request_diff == @merge_request_diff) do
- %div
- %strong
- - if merge_request_diff.latest?
- latest version
- - else
- version #{version_index(merge_request_diff)}
- %div
- %small.commit-sha= short_sha(merge_request_diff.head_commit_sha)
- %div
- %small
- #{number_with_delimiter(merge_request_diff.commits_count)} #{'commit'.pluralize(merge_request_diff.commits_count)},
- = time_ago_with_tooltip(merge_request_diff.created_at)
-
- - if @merge_request_diff.base_commit_sha
- and
- %span.dropdown.inline.mr-version-compare-dropdown
- %a.btn.btn-default.dropdown-toggle{ data: { toggle: :dropdown, display: 'static' } }
- - if @start_version
- version #{version_index(@start_version)}
- - else
- %span.ref-name= @merge_request.target_branch
- = icon('caret-down')
- .dropdown-menu.dropdown-select.dropdown-menu-selectable
- .dropdown-title
- %span Compared with:
- %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } }
- = icon('times', class: 'dropdown-menu-close-icon')
- .dropdown-content
- %ul
- - @comparable_diffs.each do |merge_request_diff|
- %li
- = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff, merge_request_diff.head_commit_sha), class: ('is-active' if merge_request_diff == @start_version) do
- %div
- %strong
- - if merge_request_diff.latest?
- latest version
- - else
- version #{version_index(merge_request_diff)}
- %div
- %small.commit-sha= short_sha(merge_request_diff.head_commit_sha)
- %div
- %small
- = time_ago_with_tooltip(merge_request_diff.created_at)
- %li
- = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff), class: ('is-active' unless @start_version) do
- %div
- %strong
- %span.ref-name= @merge_request.target_branch
- (base)
- %div
- %strong.commit-sha= short_sha(@merge_request_diff.base_commit_sha)
diff --git a/app/views/projects/merge_requests/invalid.html.haml b/app/views/projects/merge_requests/invalid.html.haml
index 7b831aa2d01..df942c11883 100644
--- a/app/views/projects/merge_requests/invalid.html.haml
+++ b/app/views/projects/merge_requests/invalid.html.haml
@@ -1,25 +1,28 @@
- page_title "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge Requests")
+- badge_css_classes = "badge gl-text-white"
+- badge_info_css_classes = "#{badge_css_classes} badge-info"
+- badge_inverse_css_classes = "#{badge_css_classes} badge-inverse"
.merge-request
= render "projects/merge_requests/mr_title"
= render "projects/merge_requests/mr_box"
- .alert.alert-danger
+ .gl-alert.gl-alert-danger
+ = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
%p
We cannot render this merge request properly because
- if @merge_request.for_fork? && !@merge_request.source_project
fork project was removed
- elsif !@merge_request.source_branch_exists?
- %span.badge.badge-inverse= @merge_request.source_branch
+ %span{ class: badge_inverse_css_classes }= @merge_request.source_branch
does not exist in
- %span.badge.badge-info= @merge_request.source_project_path
+ %span{ class: badge_info_css_classes }= @merge_request.source_project_path
- elsif !@merge_request.target_branch_exists?
- %span.badge.badge-inverse= @merge_request.target_branch
+ %span{ class: badge_inverse_css_classes }= @merge_request.target_branch
does not exist in
- %span.badge.badge-info= @merge_request.target_project_path
+ %span{ class: badge_info_css_classes }= @merge_request.target_project_path
- else
of internal error
%strong
Please close Merge Request or change branches with existing one
-
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index b579f7510f9..84b108d69ad 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -3,7 +3,7 @@
- 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_description @merge_request.description_html
- page_card_attributes @merge_request.card_attributes
- suggest_changes_help_path = help_page_path('user/discussions/index.md', anchor: 'suggest-changes')
- number_of_pipelines = @pipelines.size
@@ -43,7 +43,6 @@
.tab-content#diff-notes-app
#js-diff-file-finder
- - if native_code_navigation_enabled?(@project)
#js-code-navigation
= render "projects/merge_requests/tabs/pane", id: "notes", class: "notes voting_notes" do
.row
@@ -92,7 +91,7 @@
.loading.hide
.spinner.spinner-md
-= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, source_branch: @merge_request.source_branch
+= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch
- if @merge_request.can_be_reverted?(current_user)
= render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title
diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml
index 2bab2a0fb03..6e81058df2a 100644
--- a/app/views/projects/milestones/index.html.haml
+++ b/app/views/projects/milestones/index.html.haml
@@ -1,4 +1,5 @@
- page_title _('Milestones')
+- add_page_specific_style 'page_bundles/milestone'
.top-area
= render 'shared/milestones_filter', counts: milestone_counts(@project.milestones)
diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml
index 99e626161c4..2514d2cce32 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -1,7 +1,8 @@
- add_to_breadcrumbs _('Milestones'), project_milestones_path(@project)
- breadcrumb_title @milestone.title
- page_title @milestone.title, _('Milestones')
-- page_description @milestone.description
+- page_description @milestone.description_html
+- add_page_specific_style 'page_bundles/milestone'
= render 'shared/milestones/header', milestone: @milestone
= render 'shared/milestones/description', milestone: @milestone
diff --git a/app/views/projects/milestones/update.js.haml b/app/views/projects/milestones/update.js.haml
deleted file mode 100644
index 3ff84915e97..00000000000
--- a/app/views/projects/milestones/update.js.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-:plain
- $('##{dom_id(@milestone)}').fadeOut();
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index d7098bbb69d..9c6b803210f 100644
--- a/app/views/projects/mirrors/_mirror_repos.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos.html.haml
@@ -32,7 +32,7 @@
= label_tag :only_protected_branches, _('Only mirror protected branches'), class: 'form-check-label'
= link_to sprite_icon('question-o'), help_page_path('user/project/protected_branches'), target: '_blank'
- .panel-footer.gl-display-flex.gl-justify-content-end
+ .panel-footer
= f.submit _('Mirror repository'), class: 'btn btn-success js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror
- else
.gl-alert.gl-alert-info{ role: 'alert' }
diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml
index 058366eb75d..a785e36fad5 100644
--- a/app/views/projects/notes/_actions.html.haml
+++ b/app/views/projects/notes/_actions.html.haml
@@ -2,7 +2,7 @@
- if note.noteable_author?(@noteable)
%span{ class: 'note-role user-access-role has-tooltip d-none d-md-inline-block', title: _("This user is the author of this %{noteable}.") % { noteable: @noteable.human_class_name } }= _("Author")
- if access
- %span{ class: 'note-role user-access-role has-tooltip', title: _("This user is a %{access} of the %{name} project.") % { access: access.downcase, name: note.project_name } }= access
+ %span{ class: 'note-role user-access-role has-tooltip', title: _("This user has the %{access} role in the %{name} project.") % { access: access.downcase, name: note.project_name } }= access
- elsif note.contributor?
%span{ class: 'note-role user-access-role has-tooltip', title: _("This user has previously committed to the %{name} project.") % { name: note.project_name } }= _("Contributor")
diff --git a/app/views/projects/pages/_destroy.haml b/app/views/projects/pages/_destroy.haml
index 58dbbb5bcfc..0833eba2c27 100644
--- a/app/views/projects/pages/_destroy.haml
+++ b/app/views/projects/pages/_destroy.haml
@@ -8,7 +8,7 @@
%p
= s_('GitLabPages|Removing pages will prevent them from being exposed to the outside world.')
.form-actions
- = link_to s_('GitLabPages|Remove pages'), project_pages_path(@project), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn btn-remove"
+ = link_to s_('GitLabPages|Remove pages'), project_pages_path(@project), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn gl-button btn-remove"
- else
.nothing-here-block
= s_('GitLabPages|Only project maintainers can remove pages')
diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml
index af6de10b2a0..05e9e569a04 100644
--- a/app/views/projects/pages/_list.html.haml
+++ b/app/views/projects/pages/_list.html.haml
@@ -21,8 +21,8 @@
%span.badge.badge-danger
= s_('GitLabPages|Expired')
%div
- = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped btn-success btn-inverted"
- = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn btn-remove btn-sm btn-grouped"
+ = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn gl-button btn-sm btn-grouped btn-success btn-inverted"
+ = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn gl-button btn-remove btn-sm btn-grouped"
- if domain.needs_verification?
%li.list-group-item.bs-callout-warning
- details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe
diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml
index 8a01945ffac..4347cbdbd9b 100644
--- a/app/views/projects/pages/show.html.haml
+++ b/app/views/projects/pages/show.html.haml
@@ -5,7 +5,7 @@
= s_('GitLabPages|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-success float-right', title: s_('GitLabPages|New Domain') do
+ = link_to new_project_pages_domain_path(@project), class: 'btn gl-button btn-success float-right', title: s_('GitLabPages|New Domain') do
= s_('GitLabPages|New Domain')
%p.light
diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml
index 9e9f60a6f09..453134ce5ab 100644
--- a/app/views/projects/pages_domains/_form.html.haml
+++ b/app/views/projects/pages_domains/_form.html.haml
@@ -1,5 +1,6 @@
- if domain_presenter.errors.any?
- .alert.alert-danger
+ .gl-alert.gl-alert-danger
+ = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- domain_presenter.errors.full_messages.each do |msg|
= msg
diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml
index 2b2b79d886b..91083cc0768 100644
--- a/app/views/projects/pipeline_schedules/index.html.haml
+++ b/app/views/projects/pipeline_schedules/index.html.haml
@@ -2,7 +2,7 @@
- page_title _("Pipeline Schedules")
-#pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipelines/schedules') } }
+#pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipelines/schedules'), image_url: image_path('illustrations/pipeline_schedule_callout.svg') } }
.top-area
- schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) }
= render "tabs", schedule_path_proc: schedule_path_proc, all_schedules: @all_schedules, scope: @scope
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index c54a19b8f61..a1dc721b900 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -43,8 +43,8 @@
placement: "top",
html: "true",
trigger: "focus",
- title: "<div class='autodevops-title'>#{popover_title_text}</div>",
- content: "<a class='autodevops-link' href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>",
+ title: "<div class='gl-font-weight-normal gl-line-height-normal'>#{popover_title_text}</div>",
+ content: "<a href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>",
} }
Auto DevOps
- if @pipeline.detached_merge_request_pipeline?
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index f1ed67f8f82..40a52f76641 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -68,7 +68,7 @@
%td.responsive-table-cell.build-failure{ data: { column: _('Failure')} }
= build.present.callout_failure_message
%td.responsive-table-cell.build-actions
- - if can?(current_user, :update_build, job)
+ - if can?(current_user, :update_build, job) && job.retryable?
= link_to retry_project_job_path(build.project, build, return_to: request.original_url), method: :post, title: _('Retry'), class: 'btn btn-build gl-button btn-icon btn-default' do
= sprite_icon('repeat', css_class: 'gl-icon')
- if can?(current_user, :read_build, job)
diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml
index 2be75106000..29e5f2cf5b4 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -6,7 +6,7 @@
= s_('Pipeline|Run Pipeline')
%hr
-- if Feature.enabled?(:new_pipeline_form)
+- if Feature.enabled?(:new_pipeline_form, @project)
#js-new-pipeline{ data: { project_id: @project.id, pipelines_path: project_pipelines_path(@project), ref_param: params[:ref] || @project.default_branch, var_param: params[:var].to_json, file_param: params[:file_var].to_json, ref_names: @project.repository.ref_names.to_json.html_safe, settings_link: project_settings_ci_cd_path(@project), max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT } }
- else
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index a9c140aee5f..5b8ab455ec8 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -4,8 +4,7 @@
- pipeline_has_errors = @pipeline.builds.empty? && @pipeline.yaml_errors.present?
.js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } }
- #js-pipeline-header-vue.pipeline-header-container
-
+ #js-pipeline-header-vue.pipeline-header-container{ data: {full_path: @project.full_path, retry_path: retry_project_pipeline_path(@pipeline.project, @pipeline), cancel_path: cancel_project_pipeline_path(@pipeline.project, @pipeline), delete_path: project_pipeline_path(@pipeline.project, @pipeline), pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id} }
- if @pipeline.commit.present?
= render "projects/pipelines/info", commit: @pipeline.commit
diff --git a/app/views/projects/project_members/import.html.haml b/app/views/projects/project_members/import.html.haml
index bcca943de6a..2f953db0d65 100644
--- a/app/views/projects/project_members/import.html.haml
+++ b/app/views/projects/project_members/import.html.haml
@@ -11,5 +11,5 @@
.col-sm-10= select_tag(:source_project_id, options_from_collection_for_select(@projects, :id, :name_with_namespace), prompt: "Select project", class: "select2 lg", required: true)
.form-actions
- = button_tag _('Import project members'), class: "btn btn-success"
- = link_to _("Cancel"), project_project_members_path(@project), class: "btn btn-cancel"
+ = button_tag _('Import project members'), class: "btn gl-button btn-success"
+ = link_to _("Cancel"), project_project_members_path(@project), class: "btn gl-button btn-cancel"
diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml
index ee359a01e74..33be875d9a6 100644
--- a/app/views/projects/protected_branches/_create_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml
@@ -1,3 +1,5 @@
+- select_mode_for_dropdown = Feature.enabled?(:deploy_keys_on_protected_branches, @project) ? 'js-multiselect' : ''
+
- content_for :merge_access_levels do
.merge_access_levels-container
= dropdown_tag('Select',
@@ -7,7 +9,7 @@
- content_for :push_access_levels do
.push_access_levels-container
= dropdown_tag('Select',
- options: { toggle_class: 'js-allowed-to-push qa-allowed-to-push-select wide',
+ options: { toggle_class: "js-allowed-to-push qa-allowed-to-push-select #{select_mode_for_dropdown} wide",
dropdown_class: 'dropdown-menu-selectable qa-allowed-to-push-dropdown rspec-allowed-to-push-dropdown capitalize-header',
data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }})
diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
index dc7514badb6..c4bf2d20ecf 100644
--- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
@@ -24,5 +24,5 @@
.create_access_levels-container
= yield :create_access_levels
- .card-footer.gl-display-flex.gl-justify-content-end
+ .card-footer
= f.submit _('Protect'), class: 'btn-success btn', disabled: true, data: { qa_selector: 'protect_tag_button' }
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index 8540ce30060..9ac1fda169f 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -15,5 +15,8 @@
"registry_host_url_with_port" => escape_once(registry_config.host_port),
"expiration_policy_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy'),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
+ "run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'),
+ "cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
+
"is_admin": current_user&.admin.to_s,
character_error: @character_error.to_s } }
diff --git a/app/views/projects/registry/settings/_index.haml b/app/views/projects/registry/settings/_index.haml
index c0cef8503e0..b53fac83830 100644
--- a/app/views/projects/registry/settings/_index.haml
+++ b/app/views/projects/registry/settings/_index.haml
@@ -1,4 +1,5 @@
#js-registry-settings{ data: { project_id: @project.id,
+ project_path: @project.full_path,
cadence_options: cadence_options.to_json,
keep_n_options: keep_n_options.to_json,
older_than_options: older_than_options.to_json,
diff --git a/app/views/projects/releases/show.html.haml b/app/views/projects/releases/show.html.haml
index 188262fb34c..550a37dabcb 100644
--- a/app/views/projects/releases/show.html.haml
+++ b/app/views/projects/releases/show.html.haml
@@ -1,4 +1,5 @@
- add_to_breadcrumbs _("Releases"), project_releases_path(@project)
- page_title @release.name
+- page_description @release.description_html
#js-show-release-page{ data: { project_id: @project.id, tag_name: @release.tag } }
diff --git a/app/views/projects/services/prometheus/_configuration_banner.html.haml b/app/views/projects/services/prometheus/_configuration_banner.html.haml
index b4e8458d8b9..717df405fa7 100644
--- a/app/views/projects/services/prometheus/_configuration_banner.html.haml
+++ b/app/views/projects/services/prometheus/_configuration_banner.html.haml
@@ -14,13 +14,13 @@
.col-sm-10
%p.text-success.gl-mt-3
= s_('PrometheusService|Prometheus is being automatically managed on your clusters')
- = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn'
+ = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn gl-button'
- else
.col-sm-2
= image_tag 'illustrations/monitoring/loading.svg'
.col-sm-10
%p.gl-mt-3
= 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'
+ = link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'btn gl-button btn-success'
%hr
diff --git a/app/views/projects/services/prometheus/_custom_metrics.html.haml b/app/views/projects/services/prometheus/_custom_metrics.html.haml
index 57100282c34..70685a8a9eb 100644
--- a/app/views/projects/services/prometheus/_custom_metrics.html.haml
+++ b/app/views/projects/services/prometheus/_custom_metrics.html.haml
@@ -13,7 +13,7 @@
-# haml-lint:disable NoPlainNodes
%span.badge.badge-pill.js-custom-monitored-count 0
-# haml-lint:enable NoPlainNodes
- = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn btn-success js-new-metric-button hidden', data: { qa_selector: 'new_metric_button' }
+ = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn gl-button btn-success js-new-metric-button hidden', data: { qa_selector: 'new_metric_button' }
.card-body
.flash-container.hidden
.flash-warning
diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml
index cbeedbd080c..4133129fde2 100644
--- a/app/views/projects/settings/_archive.html.haml
+++ b/app/views/projects/settings/_archive.html.haml
@@ -13,7 +13,6 @@
method: :post, class: "btn btn-success"
- else
%p= _("Archiving the project will make it entirely read only. It is hidden from the dashboard and doesn't show up in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
- .gl-display-flex.gl-justify-content-end
- = link_to _('Archive project'), archive_project_path(@project),
- data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link' },
- method: :post, class: "btn btn-warning"
+ = link_to _('Archive project'), archive_project_path(@project),
+ data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link' },
+ method: :post, class: "btn btn-warning"
diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml
index 50f80fd1e2f..5d5f1d54439 100644
--- a/app/views/projects/settings/_general.html.haml
+++ b/app/views/projects/settings/_general.html.haml
@@ -40,5 +40,4 @@
%hr
= link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link'
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success mt-4 qa-save-naming-topics-avatar-button"
+ = f.submit _('Save changes'), class: "btn btn-success mt-4 qa-save-naming-topics-avatar-button"
diff --git a/app/views/projects/settings/operations/_alert_management.html.haml b/app/views/projects/settings/operations/_alert_management.html.haml
index f8f3ecb6273..5c16a5e2758 100644
--- a/app/views/projects/settings/operations/_alert_management.html.haml
+++ b/app/views/projects/settings/operations/_alert_management.html.haml
@@ -9,6 +9,6 @@
= _('Expand')
%p
= _('Display alerts from all your monitoring tools directly within GitLab.')
- = link_to _('More information'), help_page_path('user/project/operations/alert_management'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('More information'), help_page_path('operations/incident_management/index.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
.js-alerts-settings{ data: alerts_settings_data }
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index dba9b20fcff..d7231e758c7 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -1,6 +1,7 @@
- commit = @repository.commit(tag.dereferenced_target)
- release = @releases.find { |release| release.tag == tag.name }
-%li.flex-row.allow-wrap
+
+%li.flex-row.allow-wrap.js-tag-list
.row-main-content
= sprite_icon('tag')
= link_to tag.name, project_tag_path(@project, tag.name), class: 'item-title ref-name'
@@ -24,7 +25,7 @@
.text-secondary
= sprite_icon("rocket", size: 12)
= _("Release")
- = link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'tag-release-link'
+ = link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'gl-text-blue-600!'
- if release.description.present?
.md.gl-mt-3
= markdown_field(release, :description)
@@ -38,5 +39,4 @@
- if can?(current_user, :admin_tag, @project)
= link_to edit_project_tag_release_path(@project, tag.name), class: 'btn btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do
= sprite_icon("pencil")
- = link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip gl-ml-3 #{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
- = sprite_icon("remove")
+ = render 'projects/buttons/remove_tag', project: @project, tag: tag
diff --git a/app/views/projects/tags/destroy.js.haml b/app/views/projects/tags/destroy.js.haml
deleted file mode 100644
index 59d359bbf10..00000000000
--- a/app/views/projects/tags/destroy.js.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- if @error.present?
- new Flash({ message: '#{escape_javascript(@error)}', type: 'alert' });
-- elsif @repository.tags.empty?
- $('.tags').load(document.URL + ' .nothing-here-block').hide().fadeIn(1000)
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index 25a560da5c6..d726d2ab233 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -52,8 +52,7 @@
= render 'projects/buttons/download', project: @project, ref: @tag.name
- if can?(current_user, :admin_tag, @project)
.btn-container.controls-item-full
- = link_to project_tag_path(@project, @tag.name), class: "btn btn-icon btn-danger gl-button 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 } } do
- = sprite_icon('remove', css_class: 'gl-icon')
+ = render 'projects/buttons/remove_tag', project: @project, tag: @tag
- if @tag.message.present?
%pre.wrap{ data: { qa_selector: 'tag_message_content' } }
diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml
index 4e097f345c2..4f39c839630 100644
--- a/app/views/projects/triggers/_index.html.haml
+++ b/app/views/projects/triggers/_index.html.haml
@@ -6,23 +6,26 @@
.card-body
= render "projects/triggers/form", btn_text: "Add trigger"
%hr
- - if @triggers.any?
- .table-responsive.triggers-list
- %table.table
- %thead
- %th
- %strong Token
- %th
- %strong Description
- %th
- %strong Owner
- %th
- %strong Last used
- %th
- = render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger
+ - if Feature.enabled?(:ci_pipeline_triggers_settings_vue_ui, @project)
+ #js-ci-pipeline-triggers-list.triggers-list{ data: { triggers: @triggers_json } }
- else
- %p.settings-message.text-center.gl-mb-3
- No triggers have been created yet. Add one using the form above.
+ - if @triggers.any?
+ .table-responsive.triggers-list
+ %table.table
+ %thead
+ %th
+ %strong Token
+ %th
+ %strong Description
+ %th
+ %strong Owner
+ %th
+ %strong Last used
+ %th
+ = render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger
+ - else
+ %p.settings-message.text-center.gl-mb-3{ data: { testid: 'no_triggers_content' } }
+ No triggers have been created yet. Add one using the form above.
.card-footer
diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml
index 579b8ba2766..b25199b405a 100644
--- a/app/views/projects/triggers/_trigger.html.haml
+++ b/app/views/projects/triggers/_trigger.html.haml
@@ -2,7 +2,7 @@
%td
- if trigger.has_token_exposed?
%span= trigger.token
- = clipboard_button(text: trigger.token, title: _("Copy trigger token"))
+ = clipboard_button(text: trigger.token, title: _("Copy trigger token"), testid: 'clipboard-btn')
- else
%span= trigger.short_token
@@ -33,5 +33,5 @@
= link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "btn btn-default btn-sm" do
= sprite_icon('pencil')
- if can?(current_user, :manage_trigger, trigger)
- = link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation }, method: :delete, title: "Revoke", class: "btn btn-default btn-warning btn-sm btn-trigger-revoke" do
+ = link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation, testid: 'trigger_revoke_button' }, method: :delete, title: "Revoke", class: "btn btn-default btn-warning btn-sm btn-trigger-revoke" do
= sprite_icon('remove')
diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml
index d6e38ddd5c6..f094a6f5e3b 100644
--- a/app/views/search/_category.html.haml
+++ b/app/views/search/_category.html.haml
@@ -30,5 +30,6 @@
= search_filter_link 'issues', _("Issues")
= search_filter_link 'merge_requests', _("Merge requests")
= search_filter_link 'milestones', _("Milestones")
+ = render_if_exists 'search/epics_filter_link'
= render_if_exists 'search/category_elasticsearch'
= users
diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml
index e0dbb5135e9..95c378bff7c 100644
--- a/app/views/search/_results.html.haml
+++ b/app/views/search/_results.html.haml
@@ -1,7 +1,7 @@
- if @search_objects.to_a.empty?
+ = render partial: "search/results/filters"
= render partial: "search/results/empty"
= render_if_exists 'shared/promotions/promote_advanced_search'
- = render_if_exists 'search/form_revert_to_basic'
- else
.row-content-block.d-md-flex.text-left.align-items-center
- unless @search_objects.is_a?(Kaminari::PaginatableWithoutCount)
@@ -10,10 +10,9 @@
- if @project
- link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1')
- if @scope == 'blobs'
- - repository_ref = params[:repository_ref].to_s.presence || @project.default_branch
= s_("SearchCodeResults|in")
.mx-md-1
- = render partial: "shared/ref_switcher", locals: { ref: repository_ref, form_path: request.fullpath, field_name: 'repository_ref' }
+ = render partial: "shared/ref_switcher", locals: { ref: repository_ref(@project), form_path: request.fullpath, field_name: 'repository_ref' }
= s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project }
- else
= _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project }
@@ -21,8 +20,7 @@
- link_to_group = link_to(@group.name, @group, class: 'ml-md-1')
= _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group }
= render_if_exists 'shared/promotions/promote_advanced_search'
-
- #js-search-filter-by-state{ 'v-cloak': true, data: { scope: @scope, state: params[:state] } }
+ = render partial: "search/results/filters"
.results.gl-mt-3
- if @scope == 'commits'
@@ -34,7 +32,7 @@
.term
= render 'shared/projects/list', projects: @search_objects, pipeline_status: false
- else
- = render partial: "search/results/#{@scope.singularize}", collection: @search_objects
+ = render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects
- if @scope != 'projects'
= paginate_collection(@search_objects)
diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml
index 6e17a25c713..aeb37022f99 100644
--- a/app/views/search/results/_blob.html.haml
+++ b/app/views/search/results/_blob.html.haml
@@ -1,5 +1,5 @@
- project = blob.project
- return unless project
-- blob_link = project_blob_path(project, tree_join(blob.ref, blob.path))
+- blob_link = project_blob_path(project, tree_join(repository_ref(project), blob.path))
= render partial: 'search/results/blob_data', locals: { blob: blob, project: project, path: blob.path, blob_link: blob_link }
diff --git a/app/views/search/results/_filters.html.haml b/app/views/search/results/_filters.html.haml
new file mode 100644
index 00000000000..8c402ddb3d1
--- /dev/null
+++ b/app/views/search/results/_filters.html.haml
@@ -0,0 +1,7 @@
+.d-lg-flex.align-items-end
+ #js-search-filter-by-state{ 'v-cloak': true, data: { scope: @scope, filter: params[:state]} }
+ - if Feature.enabled?(:search_filter_by_confidential, @group)
+ #js-search-filter-by-confidential{ 'v-cloak': true, data: { scope: @scope, filter: params[:confidential] } }
+
+ - if %w(issues merge_requests).include?(@scope)
+ %hr.gl-mt-4.gl-mb-4
diff --git a/app/views/shared/_confirm_modal.html.haml b/app/views/shared/_confirm_modal.html.haml
index dc95bcdc756..ecb462205b0 100644
--- a/app/views/shared/_confirm_modal.html.haml
+++ b/app/views/shared/_confirm_modal.html.haml
@@ -17,5 +17,5 @@
.form-group
= text_field_tag 'confirm_name_input', '', class: 'form-control js-confirm-danger-input qa-confirm-input'
- .form-actions.gl-display-flex.gl-justify-content-end
+ .form-actions
= submit_tag _('Confirm'), class: "btn btn-danger js-confirm-danger-submit qa-confirm-button"
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index c4b7ef481fd..9fcb71ec2b9 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -11,7 +11,7 @@
%ul.label-actions-list
- if @project
%li.inline
- .label-badge.label-badge-gray= label.model_name.human.capitalize
+ .label-badge.gl-bg-gray-50= label.model_name.human.capitalize
- if can?(current_user, :admin_label, @project)
%li.inline.js-toggle-priority{ data: { url: remove_priority_project_label_path(@project, label),
dom_id: dom_id(label), type: label.type } }
diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml
index 3d2ae772135..f2b257f9776 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -3,23 +3,22 @@
- show_label_issues_link = subject_or_group_defined && show_label_issuables_link?(label, :issues)
- show_label_merge_requests_link = subject_or_group_defined && show_label_issuables_link?(label, :merge_requests)
-.label-name
+.label-name.gl-flex-shrink-0.gl-mr-3
= render_label(label, tooltip: false)
-.label-description
- .label-description-wrapper
- - if label.description.present?
- .description-text
- = markdown_field(label, :description)
- %ul.label-links
- - if show_label_issues_link
- %li.label-link-item.inline
- = link_to_label(label) { _('Issues') }
- - if show_label_merge_requests_link
- &middot;
- %li.label-link-item.inline
- = link_to_label(label, type: :merge_request) { _('Merge requests') }
- - if force_priority
- &middot;
- %li.label-link-item.priority-badge.js-priority-badge.inline.gl-ml-3
- .label-badge.label-badge-blue= _('Prioritized label')
- = render_if_exists 'shared/label_row_epics_link', label: label
+.label-description.gl-flex-grow-1.gl-mr-3.gl-w-full
+ - if label.description.present?
+ .description-text.gl-mb-3
+ = markdown_field(label, :description)
+ %ul.label-links.gl-m-0.gl-p-0.gl-white-space-nowrap
+ - if show_label_issues_link
+ %li.inline.gl-text-blue-600
+ = link_to_label(label, css_class: 'gl-text-blue-600!') { _('Issues') }
+ - if show_label_merge_requests_link
+ &middot;
+ %li.inline.gl-text-blue-600
+ = link_to_label(label, type: :merge_request, css_class: 'gl-text-blue-600!') { _('Merge requests') }
+ = render_if_exists 'shared/label_row_epics_link', label: label
+ - if force_priority
+ &middot;
+ %li.js-priority-badge.inline.gl-ml-3
+ .label-badge.gl-bg-blue-50= _('Prioritized label')
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index e5808bfe878..879afff0474 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -8,6 +8,7 @@
- @content_class = "issue-boards-content js-focus-mode-board"
- breadcrumb_title _("Issue Boards")
- page_title("#{board.name}", _("Boards"))
+- add_page_specific_style 'page_bundles/boards'
- content_for :page_specific_javascripts do
diff --git a/app/views/shared/deploy_keys/_project_group_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml
index 815967b0372..179ec33ee65 100644
--- a/app/views/shared/deploy_keys/_project_group_form.html.haml
+++ b/app/views/shared/deploy_keys/_project_group_form.html.haml
@@ -20,5 +20,5 @@
%p.light.gl-mb-0
= _('Allow this key to push to repository as well? (Default only allows pull access.)')
- .form-group.row.gl-display-flex.gl-justify-content-end
+ .form-group.row
= f.submit _("Add key"), class: "btn-success btn"
diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml
index cc5addaa3a0..da634d37c55 100644
--- a/app/views/shared/deploy_tokens/_form.html.haml
+++ b/app/views/shared/deploy_tokens/_form.html.haml
@@ -46,5 +46,5 @@
= label_tag ("deploy_token_write_package_registry"), 'write_package_registry', class: 'label-bold form-check-label'
.text-secondary= s_('DeployTokens|Allows write access to the package registry')
- .gl-mt-3.gl-display-flex.gl-justify-content-end
+ .gl-mt-3
= f.submit s_('DeployTokens|Create deploy token'), class: 'btn btn-success qa-create-deploy-token'
diff --git a/app/views/shared/icons/_next_discussion.svg b/app/views/shared/icons/_next_discussion.svg
deleted file mode 100644
index 43559a60cb0..00000000000
--- a/app/views/shared/icons/_next_discussion.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg viewBox="0 0 20 19" ><path d="M15.21 7.783h-3.317c-.268 0-.472.218-.472.486v.953c0 .28.212.486.473.486h3.318v1.575c0 .36.233.452.52.23l3.06-2.37c.274-.213.286-.582 0-.804l-3.06-2.37c-.275-.213-.52-.12-.52.23v1.583zm.57-3.66c-1.558-1.22-3.783-1.98-6.254-1.98C4.816 2.143 1 4.91 1 8.333c0 1.964 1.256 3.715 3.216 4.846-.447 1.615-1.132 2.195-1.732 2.882-.142.174-.304.32-.256.56v.01c.047.213.218.368.41.368h.046c.37-.048.743-.116 1.085-.213 1.645-.425 3.13-1.22 4.377-2.34.447.048.913.077 1.38.077 2.092 0 4.01-.546 5.492-1.454-.416-.208-.798-.475-1.134-.792-1.227.63-2.743 1.008-4.36 1.008-.41 0-.828-.03-1.237-.078l-.543-.058-.41.368c-.78.696-1.655 1.248-2.616 1.654.248-.445.486-.977.667-1.664l.257-.928-.828-.484c-1.646-.948-2.598-2.32-2.598-3.763 0-2.69 3.35-4.952 7.308-4.952 1.893 0 3.647.518 4.962 1.353.393-.266.827-.473 1.29-.61z" /></svg>
diff --git a/app/views/shared/issuable/_approved_by_dropdown.html.haml b/app/views/shared/issuable/_approved_by_dropdown.html.haml
new file mode 100644
index 00000000000..8014545ab85
--- /dev/null
+++ b/app/views/shared/issuable/_approved_by_dropdown.html.haml
@@ -0,0 +1,16 @@
+#js-dropdown-approved-by.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('None')
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('Any')
+ %li.divider.droplab-item-ignore
+ - if current_user
+ = render 'shared/issuable/user_dropdown_item',
+ user: current_user
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ = render 'shared/issuable/user_dropdown_item',
+ user: User.new(username: '{{username}}', name: '{{name}}'),
+ avatar: { lazy: true, url: '{{avatar_url}}' }
diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml
index 59d0c46b92f..9fb64ff19a9 100644
--- a/app/views/shared/issuable/_close_reopen_button.html.haml
+++ b/app/views/shared/issuable/_close_reopen_button.html.haml
@@ -5,7 +5,7 @@
- if defined? warn_before_close
- add_blocked_class = warn_before_close
-- if is_current_user
+- if is_current_user && !issuable.is_a?(MergeRequest)
- if can_update
%button{ class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)} #{(add_blocked_class ? 'btn-issue-blocked' : '')}",
data: { remote: 'true', endpoint: close_issuable_path(issuable), qa_selector: 'close_issue_button' } }
@@ -16,7 +16,10 @@
= _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }
- else
- if can_update && !are_close_and_open_buttons_hidden
- = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable, warn_before_close: add_blocked_class
+ - if issuable.is_a?(MergeRequest)
+ = render 'shared/issuable/close_reopen_draft_report_toggle', issuable: issuable
+ - else
+ = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable, warn_before_close: add_blocked_class
- else
= link_to _('Report abuse'), new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)),
class: 'd-none d-sm-none d-md-block btn btn-grouped btn-close-color', title: _('Report abuse')
diff --git a/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml
new file mode 100644
index 00000000000..bdb53dfe323
--- /dev/null
+++ b/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml
@@ -0,0 +1,37 @@
+- display_issuable_type = issuable_display_type(issuable)
+- button_action_class = issuable.closed? ? 'btn-default' : 'btn-warning btn-warning-secondary'
+- button_class = "btn gl-button #{!issuable.closed? && 'js-draft-toggle-button'}"
+- toggle_class = "btn gl-button dropdown-toggle"
+
+.float-left.btn-group.gl-ml-3.issuable-close-dropdown.d-none.d-md-inline-flex.js-issuable-close-dropdown
+ = link_to issuable.closed? ? reopen_issuable_path(issuable) : toggle_draft_issuable_path(issuable), method: :put, class: "#{button_class} #{button_action_class}" do
+ - if issuable.closed?
+ = _('Reopen')
+ = display_issuable_type
+ - else
+ = issuable.work_in_progress? ? _('Mark as ready') : _('Mark as draft')
+
+ - if !issuable.closed? || !issuable_author_is_current_user(issuable)
+ = button_tag type: 'button', class: "#{toggle_class} #{button_action_class}", data: { 'toggle' => 'dropdown' } do
+ %span.sr-only= _('Toggle dropdown')
+ = sprite_icon "angle-down", size: 12
+
+ %ul.js-issuable-close-menu.dropdown-menu.dropdown-menu-right
+ - if issuable.open?
+ %li
+ = link_to close_issuable_path(issuable), method: :put do
+ .description
+ %strong.title
+ = _('Close')
+ = display_issuable_type
+
+ - unless issuable_author_is_current_user(issuable)
+ - unless issuable.closed?
+ %li.divider.droplab-item-ignore
+
+ %li.report-item
+ %a.report-abuse-link{ href: new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)) }
+ .description
+ %strong.title= _('Report abuse')
+ %p.text
+ = _('Report %{display_issuable_type} that are abusive, inappropriate or spam.') % { display_issuable_type: display_issuable_type.pluralize }
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 620e9b5ea31..014ada03686 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -5,6 +5,7 @@
- signed_in = !!issuable_sidebar.dig(:current_user, :id)
- can_edit_issuable = issuable_sidebar.dig(:current_user, :can_edit)
- add_page_startup_api_call "#{issuable_sidebar[:issuable_json_path]}?serializer=sidebar_extras"
+- reviewers = local_assigns.fetch(:reviewers, nil)
- if Feature.enabled?(:vue_issuable_sidebar, @project.group)
%aside#js-vue-issuable-sidebar{ data: { signed_in: signed_in,
@@ -28,6 +29,10 @@
.block.assignee.qa-assignee-block
= render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees
+ - if Feature.enabled?(:merge_request_reviewers, @project) && reviewers
+ .block.reviewer.qa-reviewer-block
+ = render "shared/issuable/sidebar_reviewers", issuable_sidebar: issuable_sidebar, reviewers: reviewers
+
= render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar
- if issuable_sidebar[:supports_milestone]
@@ -116,7 +121,7 @@
selected_labels: issuable_sidebar[:labels].to_json } }
- else
- selected_labels = issuable_sidebar[:labels]
- .block.labels
+ .block.labels{ data: { qa_selector: 'labels_block' } }
.sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(selected_labels), data: { placement: "left", container: "body", boundary: 'viewport' } }
= sprite_icon('labels')
%span
@@ -125,11 +130,11 @@
= _('Labels')
= loading_icon(css_class: 'gl-vertical-align-text-bottom hidden block-loading')
- if can_edit_issuable
- = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { qa_selector: "edit_labels_link", track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" }
- .value.issuable-show-labels.dont-hide.hide-collapsed{ class: ("has-labels" if selected_labels.any?), data: { qa_selector: 'labels_block' } }
+ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { qa_selector: "labels_edit_button", track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" }
+ .value.issuable-show-labels.dont-hide.hide-collapsed{ class: ("has-labels" if selected_labels.any?) }
- if selected_labels.any?
- selected_labels.each do |label_hash|
- = render_label(label_from_hash(label_hash).present(issuable_subject: nil), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title]), dataset: { qa_selector: 'label', qa_label_name: label_hash[:title] })
+ = render_label(label_from_hash(label_hash).present(issuable_subject: nil), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title]), dataset: { qa_selector: 'selected_label_content', qa_label_name: label_hash[:title] })
- else
%span.no-value
= _('None')
@@ -141,7 +146,7 @@
%span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) }
= multi_label_name(selected_labels, "Labels")
= icon('chevron-down', 'aria-hidden': 'true')
- .dropdown-menu.dropdown-select.dropdown-menu-paging.qa-dropdown-menu-labels.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height
+ .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height{ data: { qa_selector: "labels_dropdown_content"} }
= render partial: "shared/issuable/label_page_default"
- if issuable_sidebar.dig(:current_user, :can_admin_label)
= render partial: "shared/issuable/label_page_create"
diff --git a/app/views/shared/issuable/_sidebar_reviewers.html.haml b/app/views/shared/issuable/_sidebar_reviewers.html.haml
new file mode 100644
index 00000000000..8b546d5e344
--- /dev/null
+++ b/app/views/shared/issuable/_sidebar_reviewers.html.haml
@@ -0,0 +1,56 @@
+- issuable_type = issuable_sidebar[:type]
+- signed_in = !!issuable_sidebar.dig(:current_user, :id)
+
+#js-vue-sidebar-reviewers{ data: { field: issuable_type, signed_in: signed_in } }
+ .title.hide-collapsed
+ = _('Reviewer')
+ = loading_icon(css_class: 'gl-vertical-align-text-bottom')
+
+.selectbox.hide-collapsed
+ - if reviewers.none?
+ = hidden_field_tag "#{issuable_type}[reviewer_ids][]", 0, id: nil
+ - else
+ - reviewers.each do |reviewer|
+ = hidden_field_tag "#{issuable_type}[reviewer_ids][]", reviewer.id, id: nil, data: reviewer_sidebar_data(reviewer, merge_request: @merge_request)
+
+ - options = { toggle_class: 'js-reviewer-search js-author-search',
+ title: _('Request review from'),
+ filter: true,
+ dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author',
+ placeholder: _('Search users'),
+ data: { first_user: issuable_sidebar.dig(:current_user, :username),
+ current_user: true,
+ iid: issuable_sidebar[:iid],
+ issuable_type: issuable_type,
+ project_id: issuable_sidebar[:project_id],
+ author_id: issuable_sidebar[:author_id],
+ field_name: "#{issuable_type}[reviewer_ids][]",
+ issue_update: issuable_sidebar[:issuable_json_path],
+ ability_name: issuable_type,
+ null_user: true,
+ display: 'static' } }
+
+ - dropdown_options = reviewers_dropdown_options(issuable_type)
+ - title = dropdown_options[:title]
+ - options[:toggle_class] += ' js-multiselect js-save-user-data'
+ - data = { field_name: "#{issuable_type}[reviewer_ids][]" }
+ - data[:multi_select] = true
+ - data['dropdown-title'] = title
+ - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header']
+ - data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select']
+ - options[:data].merge!(data)
+
+ - if experiment_enabled?(:invite_members_version_a) && can_import_members?
+ - options[:dropdown_class] += ' dropdown-extended-height'
+ - options[:footer_content] = true
+ - options[:wrapper_class] = 'js-sidebar-reviewer-dropdown'
+
+ = dropdown_tag(title, options: options) do
+ %ul.dropdown-footer-list
+ %li
+ = link_to _('Invite Members'),
+ project_project_members_path(@project),
+ title: _('Invite Members'),
+ data: { 'is-link': true, 'track-event': 'click_invite_members', 'track-label': 'edit_reviewer' }
+ - else
+ = dropdown_tag(title, options: options)
diff --git a/app/views/shared/issuable/form/_type_selector.html.haml b/app/views/shared/issuable/form/_type_selector.html.haml
index 7a8120d2d02..9f818787848 100644
--- a/app/views/shared/issuable/form/_type_selector.html.haml
+++ b/app/views/shared/issuable/form/_type_selector.html.haml
@@ -20,7 +20,7 @@
%li.js-filter-issuable-type
= link_to new_project_issue_path(@project), class: ("is-active" if issuable.issue?) do
= _("Issue")
- %li.js-filter-issuable-type
+ %li.js-filter-issuable-type{ data: { track: { event: "select_issue_type_incident", label: "select_issue_type_incident_dropdown_option" } } }
= link_to new_project_issue_path(@project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }), class: ("is-active" if issuable.incident?) do
= _("Incident")
- if issuable.incident?
diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml
index 78ff225daad..2df6c3a6afd 100644
--- a/app/views/shared/labels/_form.html.haml
+++ b/app/views/shared/labels/_form.html.haml
@@ -28,7 +28,7 @@
= render_suggested_colors
.form-actions
- if @label.persisted?
- = f.submit 'Save changes', class: 'btn btn-success js-save-button'
+ = f.submit 'Save changes', class: 'btn gl-button btn-success js-save-button'
- else
- = f.submit 'Create label', class: 'btn btn-success js-save-button qa-label-create-button'
- = link_to 'Cancel', back_path, class: 'btn btn-cancel'
+ = f.submit 'Create label', class: 'btn gl-button btn-success js-save-button qa-label-create-button'
+ = link_to 'Cancel', back_path, class: 'btn gl-button btn-cancel'
diff --git a/app/views/shared/labels/_nav.html.haml b/app/views/shared/labels/_nav.html.haml
index d613ea466fa..cc43174dc19 100644
--- a/app/views/shared/labels/_nav.html.haml
+++ b/app/views/shared/labels/_nav.html.haml
@@ -15,10 +15,10 @@
.input-group
= search_field_tag :search, params[:search], { placeholder: _('Filter'), id: 'label-search', class: 'form-control search-text-input input-short', spellcheck: false, autofocus: true }
%span.input-group-append
- %button.btn.btn-default{ type: "submit", "aria-label" => _('Submit search') }
+ %button.btn.gl-button.btn-default{ type: "submit", "aria-label" => _('Submit search') }
= icon("search")
= render 'shared/labels/sort_dropdown'
- if labels_or_filters && can_admin_label && @project
- = link_to _('New label'), new_project_label_path(@project), class: "btn btn-success qa-label-create-new"
+ = link_to _('New label'), new_project_label_path(@project), class: "btn gl-button btn-success qa-label-create-new"
- if labels_or_filters && can_admin_label && @group
- = link_to _('New label'), new_group_label_path(@group), class: "btn btn-success qa-label-create-new"
+ = link_to _('New label'), new_group_label_path(@group), class: "btn gl-button btn-success qa-label-create-new"
diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml
index 8e5763842d9..cd24942616c 100644
--- a/app/views/shared/members/_group.html.haml
+++ b/app/views/shared/members/_group.html.haml
@@ -6,17 +6,18 @@
-# Note this is just for groups. For individual members please see shared/members/_member
-%li.member.group_member.py-2.px-3.d-flex.flex-column.flex-md-row{ id: dom_id, data: { qa_selector: 'group_row' } }
+%li.member.js-member.group_member.py-2.px-3.d-flex.flex-column.flex-md-row{ id: dom_id, data: { qa_selector: 'group_row' } }
%span.list-item-name.mb-2.m-md-0
= group_icon(group, class: "avatar s40 flex-shrink-0 flex-grow-0", alt: '')
.user-info
= link_to group.full_name, group_path(group), class: 'member'
.cgray
Given access #{time_ago_with_tooltip(group_link.created_at)}
- - if group_link.expires?
- ·
- %span{ class: ('text-warning' if group_link.expires_soon?) }
- = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(group_link.expires_at) }
+ %span.js-expires-in{ class: ('gl-display-none' unless group_link.expires?) }
+ &middot;
+ %span.js-expires-in-text{ class: ('text-warning' if group_link.expires_soon?) }
+ - if group_link.expires?
+ = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(group_link.expires_at) }
.controls.member-controls.align-items-center
= form_tag group_link_path, method: :put, remote: true, class: 'js-edit-member-form form-group d-sm-flex' do
= hidden_field_tag "group_link[group_access]", group_link.group_access
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index 7573c2f6d56..679a460eeb3 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -8,7 +8,7 @@
-# Note this is just for individual members. For groups please see shared/members/_group
-%li.member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("is-overridden" if override), ("flex-md-row" unless force_mobile_view)], id: dom_id(member), data: { qa_selector: 'member_row' } }
+%li.member.js-member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("is-overridden" if override), ("flex-md-row" unless force_mobile_view)], id: dom_id(member), data: { qa_selector: 'member_row' } }
%span.list-item-name.mb-2.m-md-0
- if user
= image_tag avatar_icon_for_user(user, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: ''
diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml
index f8bf3e7ad6a..a62ed009552 100644
--- a/app/views/shared/milestones/_issuable.html.haml
+++ b/app/views/shared/milestones/_issuable.html.haml
@@ -10,8 +10,6 @@
%span
- if show_project_name
%strong #{project.name} &middot;
- - elsif show_full_project_name
- %strong #{project.full_name} &middot;
- if issuable.is_a?(Issue)
= confidential_icon(issuable)
= link_to issuable.title, issuable_url_args, title: issuable.title
diff --git a/app/views/shared/milestones/_issuables.html.haml b/app/views/shared/milestones/_issuables.html.haml
index ee97f0172da..9147e1c50e3 100644
--- a/app/views/shared/milestones/_issuables.html.haml
+++ b/app/views/shared/milestones/_issuables.html.haml
@@ -15,4 +15,4 @@
= render partial: 'shared/milestones/issuable',
collection: issuables,
as: :issuable,
- locals: { show_project_name: show_project_name, show_full_project_name: show_full_project_name }
+ locals: { show_project_name: show_project_name }
diff --git a/app/views/shared/milestones/_issues_tab.html.haml b/app/views/shared/milestones/_issues_tab.html.haml
index dc54eefbaa9..76ef636ec96 100644
--- a/app/views/shared/milestones/_issues_tab.html.haml
+++ b/app/views/shared/milestones/_issues_tab.html.haml
@@ -1,5 +1,4 @@
-- args = { show_project_name: local_assigns.fetch(:show_project_name, false),
- show_full_project_name: local_assigns.fetch(:show_full_project_name, false) }
+- args = { show_project_name: local_assigns.fetch(:show_project_name, false) }
- if display_issues_count_warning?(@milestone)
.flash-container
diff --git a/app/views/shared/milestones/_merge_requests_tab.haml b/app/views/shared/milestones/_merge_requests_tab.haml
index 0dbf2b27c8d..a78440600ad 100644
--- a/app/views/shared/milestones/_merge_requests_tab.haml
+++ b/app/views/shared/milestones/_merge_requests_tab.haml
@@ -1,5 +1,4 @@
-- args = { show_project_name: local_assigns.fetch(:show_project_name, false),
- show_full_project_name: local_assigns.fetch(:show_full_project_name, false) }
+- args = { show_project_name: local_assigns.fetch(:show_project_name, false) }
.row.gl-mt-3
.col-md-3
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index 27b771b281b..f28aa406784 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -29,10 +29,10 @@
%div
= render('shared/milestone_expired', milestone: milestone)
- if milestone.group_milestone?
- .label-badge.label-badge-blue.d-inline-block
+ .label-badge.gl-bg-blue-50.d-inline-block
= milestone.group.full_name
- if milestone.project_milestone?
- .label-badge.label-badge-gray.d-inline-block
+ .label-badge.gl-bg-gray-50.d-inline-block
= milestone.project.full_name
.col-sm-4.milestone-progress
diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml
index 34f476241c6..33e634c3e7b 100644
--- a/app/views/shared/milestones/_tabs.html.haml
+++ b/app/views/shared/milestones/_tabs.html.haml
@@ -1,14 +1,16 @@
+- show_project_name = local_assigns.fetch(:show_project_name, false)
+
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
.fade-left= sprite_icon('chevron-lg-left', size: 12)
.fade-right= sprite_icon('chevron-lg-right', size: 12)
%ul.nav-links.scrolling-tabs.js-milestone-tabs.nav.nav-tabs
%li.nav-item
- = link_to '#tab-issues', class: 'nav-link active', data: { toggle: 'tab', show: '.tab-issues-buttons' } do
+ = link_to '#tab-issues', class: 'nav-link active', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'issues', show_project_name: show_project_name) } do
= _('Issues')
%span.badge.badge-pill= milestone.issues_visible_to_user(current_user).size
- if milestone.merge_requests_enabled?
%li.nav-item
- = link_to '#tab-merge-requests', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'merge_requests') } do
+ = link_to '#tab-merge-requests', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'merge_requests', show_project_name: show_project_name) } do
= _('Merge Requests')
%span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size
%li.nav-item
@@ -20,20 +22,13 @@
= _('Labels')
%span.badge.badge-pill= milestone.issue_labels_visible_by_user(current_user).count
-- issues = milestone.sorted_issues(current_user)
-- show_project_name = local_assigns.fetch(:show_project_name, false)
-- show_full_project_name = local_assigns.fetch(:show_full_project_name, false)
-
.tab-content.milestone-content
- .tab-pane.active#tab-issues{ data: { sort_endpoint: (sort_issues_project_milestone_path(@project, @milestone) if @project && current_user) } }
- = render 'shared/milestones/issues_tab', issues: issues, show_project_name: show_project_name, show_full_project_name: show_full_project_name
+ .tab-pane.active#tab-issues
+ = render "shared/milestones/tab_loading"
- if milestone.merge_requests_enabled?
.tab-pane#tab-merge-requests
- -# loaded async
= render "shared/milestones/tab_loading"
.tab-pane#tab-participants
- -# loaded async
= render "shared/milestones/tab_loading"
.tab-pane#tab-labels
- -# loaded async
= render "shared/milestones/tab_loading"
diff --git a/app/views/shared/notes/_edit.html.haml b/app/views/shared/notes/_edit.html.haml
index 84a3ef9d8fe..9cfb3f3b576 100644
--- a/app/views/shared/notes/_edit.html.haml
+++ b/app/views/shared/notes/_edit.html.haml
@@ -1 +1 @@
-%textarea.hidden.js-task-list-field.original-task-list{ data: { update_url: note_url(note) } }= note.note
+%textarea.hidden.js-task-list-field.original-task-list{ data: { update_url: note_url(note), value: note.note } }
diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml
index f2c7ab648c0..d7b53810f76 100644
--- a/app/views/shared/notifications/_button.html.haml
+++ b/app/views/shared/notifications/_button.html.haml
@@ -17,7 +17,7 @@
.js-notification-toggle-btns
%div{ class: ("btn-group" if notification_setting.custom?) }
- if notification_setting.custom?
- %button.dropdown-new.btn.btn-defaul.btn-icon.gl-button.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } }
+ %button.dropdown-new.btn.btn-default.btn-icon.gl-button.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } }
= sprite_icon("notifications", css_class: "js-notification-loading")
= notification_title(notification_setting.level)
%button.btn.dropdown-toggle.d-flex{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 198735df5ee..c325e8d4a16 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -10,7 +10,7 @@
.form-group
= f.label :title, class: 'label-bold'
- = f.text_field :title, class: 'form-control', required: true, autofocus: true, data: { qa_selector: 'snippet_title_field' }
+ = f.text_field :title, class: 'form-control', required: true, autofocus: true
.form-group.js-description-input
- description_placeholder = s_('Snippets|Optionally add a description about what your snippet does or how to use it...')
@@ -18,17 +18,17 @@
= f.label :description, s_("Snippets|Description (optional)"), class: 'label-bold'
.js-collapsible-input
.js-collapsed{ class: ('d-none' if is_expanded) }
- = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder, data: { qa_selector: 'description_placeholder' }
+ = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder
.js-expanded{ class: ('d-none' if !is_expanded) }
= render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
- = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'snippet_description_field'
+ = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder
= render 'shared/notes/hints'
.form-group.file-editor
= f.label :file_name, s_('Snippets|File')
.file-holder.snippet
.js-file-title.file-title-flex-parent
- = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name', data: { qa_selector: 'file_name_field' }
+ = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name'
.file-content.code
#editor{ data: { 'editor-loading': true } }<
%pre.editor-loading-content= @snippet.content
@@ -46,11 +46,11 @@
.form-actions
- if @snippet.new_record?
- = f.submit 'Create snippet', class: "btn-success btn", data: { qa_selector: 'submit_button' }
+ = f.submit 'Create snippet', class: "btn-success btn gl-button"
- else
- = f.submit 'Save changes', class: "btn-success btn", data: { qa_selector: 'submit_button' }
+ = f.submit 'Save changes', class: "btn-success btn gl-button"
- if @snippet.project_id
- = link_to "Cancel", project_snippets_path(@project), class: "btn btn-cancel"
+ = link_to "Cancel", project_snippets_path(@project), class: "btn gl-button btn-default"
- else
- = link_to "Cancel", snippets_path(@project), class: "btn btn-cancel"
+ = link_to "Cancel", snippets_path(@project), class: "btn gl-button btn-default"
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index a9226117727..e2680fac019 100644
--- a/app/views/shared/snippets/_header.html.haml
+++ b/app/views/shared/snippets/_header.html.haml
@@ -21,8 +21,6 @@
.description
.md
= markdown_field(@snippet, :description)
- %textarea.hidden.js-task-list-field
- = @snippet.description
- if @snippet.updated_at != @snippet.created_at
= edited_time_ago_with_tooltip(@snippet, placement: 'bottom', exclude_author: true)
@@ -31,15 +29,15 @@
.embed-snippet
.input-group
.input-group-prepend
- %button.btn.btn-svg.embed-toggle.input-group-text{ 'data-toggle': 'dropdown', type: 'button' }
+ %button.btn.gl-button.btn-svg.embed-toggle.input-group-text{ 'data-toggle': 'dropdown', type: 'button' }
%span.js-embed-action= _("Embed")
= sprite_icon('angle-down', size: 12, css_class: 'caret-down')
%ul.dropdown-menu.dropdown-menu-selectable.embed-toggle-list
%li
- %button.js-embed-btn.btn.btn-transparent.is-active{ type: 'button' }
+ %button.js-embed-btn.btn.gl-button.btn-default-tertiary.is-active{ type: 'button' }
%strong.embed-toggle-list-item= _("Embed")
%li
- %button.js-share-btn.btn.btn-transparent{ type: 'button' }
+ %button.js-share-btn.btn.gl-button.btn-default-tertiary{ type: 'button' }
%strong.embed-toggle-list-item= _("Share")
= snippet_embed_input(@snippet)
.input-group-append
diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml
index 25e31fd519b..5f0ecb2ee79 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)
- notes_count = @noteable_meta_data[snippet.id].user_notes_count
-%li.snippet-row.py-3
+%li.snippet-row.py-3{ data: { qa_selector: 'snippet_link', qa_snippet_title: snippet.title } }
= image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 d-none d-sm-block", alt: ''
.title
diff --git a/app/views/shared/wikis/_pages_wiki_page.html.haml b/app/views/shared/wikis/_pages_wiki_page.html.haml
index b56ae2bf9b1..fb6f58d044d 100644
--- a/app/views/shared/wikis/_pages_wiki_page.html.haml
+++ b/app/views/shared/wikis/_pages_wiki_page.html.haml
@@ -1,5 +1,5 @@
%li
- = link_to wiki_page.title, wiki_page_path(@wiki, wiki_page), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.slug }
+ = link_to wiki_page.human_title, wiki_page_path(@wiki, wiki_page), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.slug }
%small (#{wiki_page.format})
.float-right
- if wiki_page.last_version
diff --git a/app/views/shared/wikis/_wiki_directory.html.haml b/app/views/shared/wikis/_wiki_directory.html.haml
index 21e829d86a6..a492d1e5aa0 100644
--- a/app/views/shared/wikis/_wiki_directory.html.haml
+++ b/app/views/shared/wikis/_wiki_directory.html.haml
@@ -1,4 +1,4 @@
%li{ data: { qa_selector: 'wiki_directory_content' } }
- = wiki_directory.slug
+ = wiki_directory.title
%ul
- = render wiki_directory.pages, context: context
+ = render wiki_directory.entries, context: context
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index fbda9b79e82..f1733ce2b51 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -2,7 +2,7 @@
- @hide_breadcrumbs = true
- @no_container = true
- page_title @user.blocked? ? s_('UserProfile|Blocked user') : @user.name
-- page_description @user.bio
+- page_description @user.bio_html
- header_title @user.name, user_path(@user)
- link_classes = "flex-grow-1 mx-1 "
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 11bf797fb90..bdcb31b8d46 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -211,6 +211,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: cronjob:member_invitation_reminder_emails
+ :feature_category: :subgroups
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
- :name: cronjob:metrics_dashboard_schedule_annotations_prune
:feature_category: :metrics
:has_external_dependencies:
@@ -723,6 +731,14 @@
:weight: 2
:idempotent: true
:tags: []
+- :name: incident_management:incident_management_add_severity_system_note
+ :feature_category: :incident_management
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 2
+ :idempotent:
+ :tags: []
- :name: incident_management:incident_management_pager_duty_process_incident
:feature_category: :incident_management
:has_external_dependencies:
@@ -1324,6 +1340,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: design_management_copy_design_collection
+ :feature_category: :design_management
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: design_management_new_version
:feature_category: :design_management
:has_external_dependencies:
@@ -1532,6 +1556,14 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: metrics_dashboard_sync_dashboards
+ :feature_category: :metrics
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: migrate_external_diffs
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1708,6 +1740,30 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: propagate_integration_group
+ :feature_category: :integrations
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: propagate_integration_inherit
+ :feature_category: :integrations
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: propagate_integration_project
+ :feature_category: :integrations
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: propagate_service_template
:feature_category: :integrations
:has_external_dependencies:
diff --git a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb b/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb
index a9976c6e5cb..01bddfea7de 100644
--- a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb
+++ b/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb
@@ -17,10 +17,9 @@ module Analytics
return if Feature.disabled?(:store_instance_statistics_measurements, default_enabled: true)
recorded_at = Time.zone.now
- measurement_identifiers = Analytics::InstanceStatistics::Measurement.identifiers
worker_arguments = Gitlab::Analytics::InstanceStatistics::WorkersArgumentBuilder.new(
- measurement_identifiers: measurement_identifiers.values,
+ measurement_identifiers: ::Analytics::InstanceStatistics::Measurement.measurement_identifier_values,
recorded_at: recorded_at
).execute
diff --git a/app/workers/authorized_project_update/periodic_recalculate_worker.rb b/app/workers/authorized_project_update/periodic_recalculate_worker.rb
index 0d1ad67d7bb..78ffdbca4d6 100644
--- a/app/workers/authorized_project_update/periodic_recalculate_worker.rb
+++ b/app/workers/authorized_project_update/periodic_recalculate_worker.rb
@@ -12,9 +12,7 @@ module AuthorizedProjectUpdate
idempotent!
def perform
- if ::Feature.enabled?(:periodic_project_authorization_recalculation, default_enabled: true)
- AuthorizedProjectUpdate::PeriodicRecalculateService.new.execute
- end
+ AuthorizedProjectUpdate::PeriodicRecalculateService.new.execute
end
end
end
diff --git a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
index 336b1c5443e..9bd1ad2ed30 100644
--- a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
+++ b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
@@ -12,9 +12,7 @@ module AuthorizedProjectUpdate
idempotent!
def perform(start_user_id, end_user_id)
- if ::Feature.enabled?(:periodic_project_authorization_recalculation, default_enabled: true)
- AuthorizedProjectUpdate::RecalculateForUserRangeService.new(start_user_id, end_user_id).execute
- end
+ AuthorizedProjectUpdate::RecalculateForUserRangeService.new(start_user_id, end_user_id).execute
end
end
end
diff --git a/app/workers/cleanup_container_repository_worker.rb b/app/workers/cleanup_container_repository_worker.rb
index 4469ea8cff9..80cc296fff5 100644
--- a/app/workers/cleanup_container_repository_worker.rb
+++ b/app/workers/cleanup_container_repository_worker.rb
@@ -16,9 +16,17 @@ class CleanupContainerRepositoryWorker # rubocop:disable Scalability/IdempotentW
return unless valid?
- Projects::ContainerRepository::CleanupTagsService
+ if run_by_container_expiration_policy?
+ container_repository.start_expiration_policy!
+ end
+
+ result = Projects::ContainerRepository::CleanupTagsService
.new(project, current_user, params)
.execute(container_repository)
+
+ if run_by_container_expiration_policy? && result[:status] == :success
+ container_repository.reset_expiration_policy_started_at!
+ end
end
private
@@ -30,7 +38,7 @@ class CleanupContainerRepositoryWorker # rubocop:disable Scalability/IdempotentW
end
def run_by_container_expiration_policy?
- @params['container_expiration_policy'] && container_repository && project
+ @params['container_expiration_policy'] && container_repository.present? && project.present?
end
def project
diff --git a/app/workers/concerns/limited_capacity/job_tracker.rb b/app/workers/concerns/limited_capacity/job_tracker.rb
new file mode 100644
index 00000000000..96b6e1a2024
--- /dev/null
+++ b/app/workers/concerns/limited_capacity/job_tracker.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+module LimitedCapacity
+ class JobTracker # rubocop:disable Scalability/IdempotentWorker
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(namespace)
+ @namespace = namespace
+ end
+
+ def register(jid)
+ _added, @count = with_redis_pipeline do |redis|
+ register_job_keys(redis, jid)
+ get_job_count(redis)
+ end
+ end
+
+ def remove(jid)
+ _removed, @count = with_redis_pipeline do |redis|
+ remove_job_keys(redis, jid)
+ get_job_count(redis)
+ end
+ end
+
+ def clean_up
+ completed_jids = Gitlab::SidekiqStatus.completed_jids(running_jids)
+ return unless completed_jids.any?
+
+ _removed, @count = with_redis_pipeline do |redis|
+ remove_job_keys(redis, completed_jids)
+ get_job_count(redis)
+ end
+ end
+
+ def count
+ @count ||= with_redis { |redis| get_job_count(redis) }
+ end
+
+ def running_jids
+ with_redis do |redis|
+ redis.smembers(counter_key)
+ end
+ end
+
+ private
+
+ attr_reader :namespace
+
+ def counter_key
+ "worker:#{namespace.to_s.underscore}:running"
+ end
+
+ def get_job_count(redis)
+ redis.scard(counter_key)
+ end
+
+ def register_job_keys(redis, keys)
+ redis.sadd(counter_key, keys)
+ end
+
+ def remove_job_keys(redis, keys)
+ redis.srem(counter_key, keys)
+ end
+
+ def with_redis(&block)
+ Gitlab::Redis::Queues.with(&block) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def with_redis_pipeline(&block)
+ with_redis do |redis|
+ redis.pipelined(&block)
+ end
+ end
+ end
+end
diff --git a/app/workers/concerns/limited_capacity/worker.rb b/app/workers/concerns/limited_capacity/worker.rb
new file mode 100644
index 00000000000..c0d6bfff2f5
--- /dev/null
+++ b/app/workers/concerns/limited_capacity/worker.rb
@@ -0,0 +1,164 @@
+# frozen_string_literal: true
+
+# Usage:
+#
+# Worker that performs the tasks:
+#
+# class DummyWorker
+# include ApplicationWorker
+# include LimitedCapacity::Worker
+#
+# # For each job that raises any error, a worker instance will be disabled
+# # until the next schedule-run.
+# # If you wish to get around this, exceptions must by handled by the implementer.
+# #
+# def perform_work(*args)
+# end
+#
+# def remaining_work_count(*args)
+# 5
+# end
+#
+# def max_running_jobs
+# 25
+# end
+# end
+#
+# Cron worker to fill the pool of regular workers:
+#
+# class ScheduleDummyCronWorker
+# include ApplicationWorker
+# include CronjobQueue
+#
+# def perform(*args)
+# DummyWorker.perform_with_capacity(*args)
+# end
+# end
+#
+
+module LimitedCapacity
+ module Worker
+ extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
+
+ included do
+ # Disable Sidekiq retries, log the error, and send the job to the dead queue.
+ # This is done to have only one source that produces jobs and because the slot
+ # would be occupied by a job that will be performed in the distant future.
+ # We let the cron worker enqueue new jobs, this could be seen as our retry and
+ # back off mechanism because the job might fail again if executed immediately.
+ sidekiq_options retry: 0
+ deduplicate :none
+ end
+
+ class_methods do
+ def perform_with_capacity(*args)
+ worker = self.new
+ worker.remove_failed_jobs
+ worker.report_prometheus_metrics(*args)
+ required_jobs_count = worker.required_jobs_count(*args)
+
+ arguments = Array.new(required_jobs_count) { args }
+ self.bulk_perform_async(arguments) # rubocop:disable Scalability/BulkPerformWithContext
+ end
+ end
+
+ def perform(*args)
+ return unless has_capacity?
+
+ job_tracker.register(jid)
+ perform_work(*args)
+ rescue => exception
+ raise
+ ensure
+ job_tracker.remove(jid)
+ report_prometheus_metrics
+ re_enqueue(*args) unless exception
+ end
+
+ def perform_work(*args)
+ raise NotImplementedError
+ end
+
+ def remaining_work_count(*args)
+ raise NotImplementedError
+ end
+
+ def max_running_jobs
+ raise NotImplementedError
+ end
+
+ def has_capacity?
+ remaining_capacity > 0
+ end
+
+ def remaining_capacity
+ [
+ max_running_jobs - running_jobs_count - self.class.queue_size,
+ 0
+ ].max
+ end
+
+ def has_work?(*args)
+ remaining_work_count(*args) > 0
+ end
+
+ def remove_failed_jobs
+ job_tracker.clean_up
+ end
+
+ def report_prometheus_metrics(*args)
+ running_jobs_gauge.set(prometheus_labels, running_jobs_count)
+ remaining_work_gauge.set(prometheus_labels, remaining_work_count(*args))
+ max_running_jobs_gauge.set(prometheus_labels, max_running_jobs)
+ end
+
+ def required_jobs_count(*args)
+ [
+ remaining_work_count(*args),
+ remaining_capacity
+ ].min
+ end
+
+ private
+
+ def running_jobs_count
+ job_tracker.count
+ end
+
+ def job_tracker
+ strong_memoize(:job_tracker) do
+ JobTracker.new(self.class.name)
+ end
+ end
+
+ def re_enqueue(*args)
+ return unless has_capacity?
+ return unless has_work?(*args)
+
+ self.class.perform_async(*args)
+ end
+
+ def running_jobs_gauge
+ strong_memoize(:running_jobs_gauge) do
+ Gitlab::Metrics.gauge(:limited_capacity_worker_running_jobs, 'Number of running jobs')
+ end
+ end
+
+ def max_running_jobs_gauge
+ strong_memoize(:max_running_jobs_gauge) do
+ Gitlab::Metrics.gauge(:limited_capacity_worker_max_running_jobs, 'Maximum number of running jobs')
+ end
+ end
+
+ def remaining_work_gauge
+ strong_memoize(:remaining_work_gauge) do
+ Gitlab::Metrics.gauge(:limited_capacity_worker_remaining_work_count, 'Number of jobs waiting to be enqueued')
+ end
+ end
+
+ def prometheus_labels
+ { worker: self.class.name }
+ end
+ end
+end
diff --git a/app/workers/design_management/copy_design_collection_worker.rb b/app/workers/design_management/copy_design_collection_worker.rb
new file mode 100644
index 00000000000..0a6e23fe9da
--- /dev/null
+++ b/app/workers/design_management/copy_design_collection_worker.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class CopyDesignCollectionWorker
+ include ApplicationWorker
+
+ feature_category :design_management
+ idempotent!
+ urgency :low
+
+ def perform(user_id, issue_id, target_issue_id)
+ user = User.find(user_id)
+ issue = Issue.find(issue_id)
+ target_issue = Issue.find(target_issue_id)
+
+ response = DesignManagement::CopyDesignCollection::CopyService.new(
+ target_issue.project,
+ user,
+ issue: issue,
+ target_issue: target_issue
+ ).execute
+
+ Gitlab::AppLogger.warn(response.message) if response.error?
+ end
+ end
+end
diff --git a/app/workers/design_management/new_version_worker.rb b/app/workers/design_management/new_version_worker.rb
index 3634dcbcebd..4fbf2067be4 100644
--- a/app/workers/design_management/new_version_worker.rb
+++ b/app/workers/design_management/new_version_worker.rb
@@ -9,10 +9,10 @@ module DesignManagement
# `GenerateImageVersionsService` resizing designs
worker_resource_boundary :memory
- def perform(version_id)
+ def perform(version_id, skip_system_notes = false)
version = DesignManagement::Version.find(version_id)
- add_system_note(version)
+ add_system_note(version) unless skip_system_notes
generate_image_versions(version)
rescue ActiveRecord::RecordNotFound => e
Sidekiq.logger.warn(e)
diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb
index b0307571448..e66bad3962f 100644
--- a/app/workers/git_garbage_collect_worker.rb
+++ b/app/workers/git_garbage_collect_worker.rb
@@ -91,10 +91,12 @@ class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker
end
def cleanup_orphan_lfs_file_references(project)
- return unless Feature.enabled?(:cleanup_lfs_during_gc, project)
return if Gitlab::Database.read_only? # GitGarbageCollectWorker may be run on a Geo secondary
::Gitlab::Cleanup::OrphanLfsFileReferences.new(project, dry_run: false, logger: logger).run!
+ rescue => err
+ Gitlab::GitLogger.warn(message: "Cleaning up orphan LFS objects files failed", error: err.message)
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(err)
end
def flush_ref_caches(project)
diff --git a/app/workers/group_import_worker.rb b/app/workers/group_import_worker.rb
index 36d81468d55..494d9a3e46f 100644
--- a/app/workers/group_import_worker.rb
+++ b/app/workers/group_import_worker.rb
@@ -9,7 +9,7 @@ class GroupImportWorker # rubocop:disable Scalability/IdempotentWorker
def perform(user_id, group_id)
current_user = User.find(user_id)
group = Group.find(group_id)
- group_import_state = group.import_state || group.build_import_state
+ group_import_state = group.import_state
group_import_state.jid = self.jid
group_import_state.start!
diff --git a/app/workers/incident_management/add_severity_system_note_worker.rb b/app/workers/incident_management/add_severity_system_note_worker.rb
new file mode 100644
index 00000000000..9f132531562
--- /dev/null
+++ b/app/workers/incident_management/add_severity_system_note_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ class AddSeveritySystemNoteWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ queue_namespace :incident_management
+ feature_category :incident_management
+
+ def perform(incident_id, user_id)
+ return if incident_id.blank? || user_id.blank?
+
+ incident = Issue.with_issue_type(:incident).find_by_id(incident_id)
+ return unless incident
+
+ user = User.find_by_id(user_id)
+ return unless user
+
+ SystemNoteService.change_incident_severity(incident, user)
+ end
+ end
+end
diff --git a/app/workers/issue_placement_worker.rb b/app/workers/issue_placement_worker.rb
index a8d59e9125c..5b547ab0c8d 100644
--- a/app/workers/issue_placement_worker.rb
+++ b/app/workers/issue_placement_worker.rb
@@ -36,14 +36,14 @@ class IssuePlacementWorker
Gitlab::ErrorTracking.log_exception(e, issue_id: issue_id, project_id: project_id)
IssueRebalancingWorker.perform_async(nil, project_id.presence || issue.project_id)
end
- # rubocop: enable CodeReuse/ActiveRecord
def find_issue(issue_id, project_id)
- return Issue.id_in(issue_id).first if issue_id
+ return Issue.id_in(issue_id).take if issue_id
- project = Project.id_in(project_id).first
+ project = Project.id_in(project_id).take
return unless project
- project.issues.first
+ project.issues.take
end
+ # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/workers/issue_rebalancing_worker.rb b/app/workers/issue_rebalancing_worker.rb
index 032ba5534e6..a9ad66198f3 100644
--- a/app/workers/issue_rebalancing_worker.rb
+++ b/app/workers/issue_rebalancing_worker.rb
@@ -11,7 +11,8 @@ class IssueRebalancingWorker
return if project_id.nil?
project = Project.find(project_id)
- issue = project.issues.first # All issues are equivalent as far as we are concerned
+ # All issues are equivalent as far as we are concerned
+ issue = project.issues.take # rubocop: disable CodeReuse/ActiveRecord
IssueRebalancingService.new(issue).execute
rescue ActiveRecord::RecordNotFound, IssueRebalancingService::TooManyIssues => e
diff --git a/app/workers/member_invitation_reminder_emails_worker.rb b/app/workers/member_invitation_reminder_emails_worker.rb
new file mode 100644
index 00000000000..69d7f8ac8f6
--- /dev/null
+++ b/app/workers/member_invitation_reminder_emails_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class MemberInvitationReminderEmailsWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ feature_category :subgroups
+ urgency :low
+
+ def perform
+ return unless Gitlab::Experimentation.enabled?(:invitation_reminders)
+
+ # To keep this MR small, implementation will be done in another MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42981/diffs?commit_id=8063606e0f83957b2dd38d660ee986f24dee6138
+ end
+end
diff --git a/app/workers/metrics/dashboard/sync_dashboards_worker.rb b/app/workers/metrics/dashboard/sync_dashboards_worker.rb
new file mode 100644
index 00000000000..7a124a33f9e
--- /dev/null
+++ b/app/workers/metrics/dashboard/sync_dashboards_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Metrics
+ module Dashboard
+ class SyncDashboardsWorker
+ include ApplicationWorker
+
+ feature_category :metrics
+
+ idempotent!
+
+ def perform(project_id)
+ project = Project.find(project_id)
+ dashboard_paths = ::Gitlab::Metrics::Dashboard::RepoDashboardFinder.list_dashboards(project)
+
+ dashboard_paths.each do |dashboard_path|
+ ::Gitlab::Metrics::Dashboard::Importer.new(dashboard_path, project).execute!
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/propagate_integration_group_worker.rb b/app/workers/propagate_integration_group_worker.rb
new file mode 100644
index 00000000000..e539c6d4719
--- /dev/null
+++ b/app/workers/propagate_integration_group_worker.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class PropagateIntegrationGroupWorker
+ include ApplicationWorker
+
+ feature_category :integrations
+ idempotent!
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform(integration_id, min_id, max_id)
+ integration = Service.find_by_id(integration_id)
+ return unless integration
+
+ batch = Group.where(id: min_id..max_id).without_integration(integration)
+
+ BulkCreateIntegrationService.new(integration, batch, 'group').execute
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+end
diff --git a/app/workers/propagate_integration_inherit_worker.rb b/app/workers/propagate_integration_inherit_worker.rb
new file mode 100644
index 00000000000..ef3132202f6
--- /dev/null
+++ b/app/workers/propagate_integration_inherit_worker.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class PropagateIntegrationInheritWorker
+ include ApplicationWorker
+
+ feature_category :integrations
+ idempotent!
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform(integration_id, min_id, max_id)
+ integration = Service.find_by_id(integration_id)
+ return unless integration
+
+ services = Service.where(id: min_id..max_id).by_type(integration.type).inherit_from_id(integration.id)
+
+ BulkUpdateIntegrationService.new(integration, services).execute
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+end
diff --git a/app/workers/propagate_integration_project_worker.rb b/app/workers/propagate_integration_project_worker.rb
new file mode 100644
index 00000000000..c1e286b24fc
--- /dev/null
+++ b/app/workers/propagate_integration_project_worker.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class PropagateIntegrationProjectWorker
+ include ApplicationWorker
+
+ feature_category :integrations
+ idempotent!
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform(integration_id, min_id, max_id)
+ integration = Service.find_by_id(integration_id)
+ return unless integration
+
+ batch = Project.where(id: min_id..max_id).without_integration(integration)
+
+ BulkCreateIntegrationService.new(integration, batch, 'project').execute
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+end
diff --git a/app/workers/propagate_integration_worker.rb b/app/workers/propagate_integration_worker.rb
index 68e38386372..bb954b12a25 100644
--- a/app/workers/propagate_integration_worker.rb
+++ b/app/workers/propagate_integration_worker.rb
@@ -7,7 +7,8 @@ class PropagateIntegrationWorker
idempotent!
loggable_arguments 1
- # Keep overwrite parameter for backwards compatibility.
+ # TODO: Keep overwrite parameter for backwards compatibility. Remove after >= 14.0
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/255382
def perform(integration_id, overwrite = nil)
Admin::PropagateIntegrationService.propagate(Service.find(integration_id))
end
diff --git a/bin/feature-flag b/bin/feature-flag
index 9a550dc8884..b5e7889be1d 100755
--- a/bin/feature-flag
+++ b/bin/feature-flag
@@ -153,6 +153,10 @@ class FeatureFlagOptionParser
end
end
+ def read_ee_only(options)
+ TYPES.dig(options.type, :ee_only)
+ end
+
def read_rollout_issue_url(options)
return unless TYPES.dig(options.type, :rollout_issue)
@@ -204,6 +208,7 @@ class FeatureFlagCreator
# Read type from $stdin unless is already set
options.type ||= FeatureFlagOptionParser.read_type
+ options.ee ||= FeatureFlagOptionParser.read_ee_only(options)
options.group ||= FeatureFlagOptionParser.read_group
options.introduced_by_url ||= FeatureFlagOptionParser.read_introduced_by_url
options.rollout_issue_url ||= FeatureFlagOptionParser.read_rollout_issue_url(options)
@@ -224,14 +229,22 @@ class FeatureFlagCreator
private
def contents
- YAML.dump(
+ # Slice is used to ensure that YAML keys
+ # are always ordered in a predictable way
+ config_hash.slice(
+ *::Feature::Shared::PARAMS.map(&:to_s)
+ ).to_yaml
+ end
+
+ def config_hash
+ {
'name' => options.name,
'introduced_by_url' => options.introduced_by_url,
'rollout_issue_url' => options.rollout_issue_url,
- 'group' => options.group.to_s,
+ 'group' => options.group,
'type' => options.type.to_s,
'default_enabled' => FeatureFlagOptionParser.read_default_enabled(options)
- ).strip
+ }
end
def write
diff --git a/changelogs/unreleased/13426-bugfix-copyState-and-padding.yml b/changelogs/unreleased/13426-bugfix-copyState-and-padding.yml
new file mode 100644
index 00000000000..9be2b78ed70
--- /dev/null
+++ b/changelogs/unreleased/13426-bugfix-copyState-and-padding.yml
@@ -0,0 +1,5 @@
+---
+title: Fix displaying a message when design copying is in progress
+merge_request: 43749
+author:
+type: fixed
diff --git a/changelogs/unreleased/13426-copy-designs.yml b/changelogs/unreleased/13426-copy-designs.yml
new file mode 100644
index 00000000000..aabdd709bd7
--- /dev/null
+++ b/changelogs/unreleased/13426-copy-designs.yml
@@ -0,0 +1,5 @@
+---
+title: Copy designs to new issue when issue is moved
+merge_request: 41714
+author:
+type: added
diff --git a/changelogs/unreleased/13426-graphql-fe.yml b/changelogs/unreleased/13426-graphql-fe.yml
new file mode 100644
index 00000000000..8058a6925df
--- /dev/null
+++ b/changelogs/unreleased/13426-graphql-fe.yml
@@ -0,0 +1,5 @@
+---
+title: Copy designs to issue when an issue with designs is moved
+merge_request: 42548
+author:
+type: fixed
diff --git a/changelogs/unreleased/13426-graphql.yml b/changelogs/unreleased/13426-graphql.yml
new file mode 100644
index 00000000000..b3a86f8bd5c
--- /dev/null
+++ b/changelogs/unreleased/13426-graphql.yml
@@ -0,0 +1,5 @@
+---
+title: Add DesignCollection copyState GraphQL field
+merge_request: 42919
+author:
+type: added
diff --git a/changelogs/unreleased/17673-wiki-pages-tree-structure.yml b/changelogs/unreleased/17673-wiki-pages-tree-structure.yml
new file mode 100644
index 00000000000..e946a267dd9
--- /dev/null
+++ b/changelogs/unreleased/17673-wiki-pages-tree-structure.yml
@@ -0,0 +1,5 @@
+---
+title: Show wiki tree structure in sidebar and pages overview
+merge_request: 42867
+author:
+type: changed
diff --git a/changelogs/unreleased/197227-load-milestone-issues-tab-async.yml b/changelogs/unreleased/197227-load-milestone-issues-tab-async.yml
new file mode 100644
index 00000000000..b3f427ddbdd
--- /dev/null
+++ b/changelogs/unreleased/197227-load-milestone-issues-tab-async.yml
@@ -0,0 +1,5 @@
+---
+title: Load issues tab in the milestone page asynchronously
+merge_request: 42473
+author:
+type: performance
diff --git a/changelogs/unreleased/198413-CI-Pre-Collapsed-Sections.yml b/changelogs/unreleased/198413-CI-Pre-Collapsed-Sections.yml
new file mode 100644
index 00000000000..d31052a11a1
--- /dev/null
+++ b/changelogs/unreleased/198413-CI-Pre-Collapsed-Sections.yml
@@ -0,0 +1,5 @@
+---
+title: Pre-Collapsed Sections in CI Job Logs
+merge_request: 42231
+author: Kev @KevSlashNull
+type: added
diff --git a/changelogs/unreleased/198612-monaco-ci-default.yml b/changelogs/unreleased/198612-monaco-ci-default.yml
new file mode 100644
index 00000000000..3fbef70f629
--- /dev/null
+++ b/changelogs/unreleased/198612-monaco-ci-default.yml
@@ -0,0 +1,5 @@
+---
+title: Replaced ACE with Editor Lite for CI linting
+merge_request: 42814
+author:
+type: added
diff --git a/changelogs/unreleased/207347-terraform-versions-api.yml b/changelogs/unreleased/207347-terraform-versions-api.yml
new file mode 100644
index 00000000000..e20b754ceac
--- /dev/null
+++ b/changelogs/unreleased/207347-terraform-versions-api.yml
@@ -0,0 +1,5 @@
+---
+title: Add API endpoints to manage individual Terraform state versions
+merge_request: 42415
+author:
+type: added
diff --git a/changelogs/unreleased/208193-add-expiration-started-at-to-container-repositories.yml b/changelogs/unreleased/208193-add-expiration-started-at-to-container-repositories.yml
new file mode 100644
index 00000000000..27f46240a5c
--- /dev/null
+++ b/changelogs/unreleased/208193-add-expiration-started-at-to-container-repositories.yml
@@ -0,0 +1,5 @@
+---
+title: Add expiration policy started at support in container repositories
+merge_request: 42598
+author:
+type: added
diff --git a/changelogs/unreleased/208260-fix-mr-task-lists-starting-with-new-line.yml b/changelogs/unreleased/208260-fix-mr-task-lists-starting-with-new-line.yml
new file mode 100644
index 00000000000..3f9efc17abc
--- /dev/null
+++ b/changelogs/unreleased/208260-fix-mr-task-lists-starting-with-new-line.yml
@@ -0,0 +1,5 @@
+---
+title: Fix checking of task lists when MR description starts with a blank line
+merge_request: 43125
+author:
+type: fixed
diff --git a/changelogs/unreleased/209831-propagate-integrations-using-batching-and-queues.yml b/changelogs/unreleased/209831-propagate-integrations-using-batching-and-queues.yml
new file mode 100644
index 00000000000..d46b05c5e92
--- /dev/null
+++ b/changelogs/unreleased/209831-propagate-integrations-using-batching-and-queues.yml
@@ -0,0 +1,5 @@
+---
+title: Update database index on namespaces for type and id
+merge_request: 42128
+author:
+type: other
diff --git a/changelogs/unreleased/212373-249588-enable-ci_new_artifact_file_reader.yml b/changelogs/unreleased/212373-249588-enable-ci_new_artifact_file_reader.yml
new file mode 100644
index 00000000000..c5b485e9951
--- /dev/null
+++ b/changelogs/unreleased/212373-249588-enable-ci_new_artifact_file_reader.yml
@@ -0,0 +1,5 @@
+---
+title: Fix triggering multiple children pipeline with the same artifact
+merge_request: 42595
+author:
+type: fixed
diff --git a/changelogs/unreleased/215697-allow-groups-to-disable-2fa-requirement-for-subgroups-migration.yml b/changelogs/unreleased/215697-allow-groups-to-disable-2fa-requirement-for-subgroups-migration.yml
new file mode 100644
index 00000000000..e6a5e68ffc6
--- /dev/null
+++ b/changelogs/unreleased/215697-allow-groups-to-disable-2fa-requirement-for-subgroups-migration.yml
@@ -0,0 +1,6 @@
+---
+title: Add namespace setting to allow to mark if parent group allow subgroups to require
+ 2FA
+merge_request: 41760
+author:
+type: added
diff --git a/changelogs/unreleased/21654-ide-button-in-mr-diff-files.yml b/changelogs/unreleased/21654-ide-button-in-mr-diff-files.yml
new file mode 100644
index 00000000000..6c56b39bc0f
--- /dev/null
+++ b/changelogs/unreleased/21654-ide-button-in-mr-diff-files.yml
@@ -0,0 +1,5 @@
+---
+title: Add Web IDE as dropdown item to diff file edit
+merge_request: 42275
+author:
+type: changed
diff --git a/changelogs/unreleased/216571-terraform-states-graphql-endpoint.yml b/changelogs/unreleased/216571-terraform-states-graphql-endpoint.yml
new file mode 100644
index 00000000000..6b3c0dd0875
--- /dev/null
+++ b/changelogs/unreleased/216571-terraform-states-graphql-endpoint.yml
@@ -0,0 +1,5 @@
+---
+title: Add GraphQL endpoint for Terraform state metadata
+merge_request: 43375
+author:
+type: added
diff --git a/changelogs/unreleased/216642-display-youtube-iframes.yml b/changelogs/unreleased/216642-display-youtube-iframes.yml
new file mode 100644
index 00000000000..7dbb1d3ff35
--- /dev/null
+++ b/changelogs/unreleased/216642-display-youtube-iframes.yml
@@ -0,0 +1,5 @@
+---
+title: Display youtube videos on the Static Site Editor
+merge_request: 39756
+author:
+type: added
diff --git a/changelogs/unreleased/217115-assign-epic-to-a-group-when-creating-within-another-epic.yml b/changelogs/unreleased/217115-assign-epic-to-a-group-when-creating-within-another-epic.yml
new file mode 100644
index 00000000000..71e7cf38426
--- /dev/null
+++ b/changelogs/unreleased/217115-assign-epic-to-a-group-when-creating-within-another-epic.yml
@@ -0,0 +1,5 @@
+---
+title: Add a REST API endpoint to list group's descendants
+merge_request: 42620
+author:
+type: added
diff --git a/changelogs/unreleased/217882-allow-designs-to-be-added-to-moved-issues.yml b/changelogs/unreleased/217882-allow-designs-to-be-added-to-moved-issues.yml
new file mode 100644
index 00000000000..0237ad2d3e2
--- /dev/null
+++ b/changelogs/unreleased/217882-allow-designs-to-be-added-to-moved-issues.yml
@@ -0,0 +1,5 @@
+---
+title: Allow designs to be added, changed, or destroyed on locked and moved issues
+merge_request: 42935
+author:
+type: changed
diff --git a/changelogs/unreleased/219915-cleanup-policy-not-working.yml b/changelogs/unreleased/219915-cleanup-policy-not-working.yml
new file mode 100644
index 00000000000..7dfe0edeb7e
--- /dev/null
+++ b/changelogs/unreleased/219915-cleanup-policy-not-working.yml
@@ -0,0 +1,5 @@
+---
+title: Fix bug to allow container cleanup policies to properly run
+merge_request: 43359
+author:
+type: fixed
diff --git a/changelogs/unreleased/220182-remove-feature-flag.yml b/changelogs/unreleased/220182-remove-feature-flag.yml
new file mode 100644
index 00000000000..90eeff08826
--- /dev/null
+++ b/changelogs/unreleased/220182-remove-feature-flag.yml
@@ -0,0 +1,5 @@
+---
+title: Display cluster list node information
+merge_request: 42396
+author:
+type: added
diff --git a/changelogs/unreleased/220203-gitlab-com-sso-create-no-access-role-fe-changes.yml b/changelogs/unreleased/220203-gitlab-com-sso-create-no-access-role-fe-changes.yml
new file mode 100644
index 00000000000..d8b42d0bebc
--- /dev/null
+++ b/changelogs/unreleased/220203-gitlab-com-sso-create-no-access-role-fe-changes.yml
@@ -0,0 +1,5 @@
+---
+title: Add No Access Role for top group members
+merge_request: 40942
+author:
+type: added
diff --git a/changelogs/unreleased/220420-migration-validate-designs-filename-text-limit.yml b/changelogs/unreleased/220420-migration-validate-designs-filename-text-limit.yml
new file mode 100644
index 00000000000..c3b2abfcc0a
--- /dev/null
+++ b/changelogs/unreleased/220420-migration-validate-designs-filename-text-limit.yml
@@ -0,0 +1,5 @@
+---
+title: Add migration to validate design_management_designs.filename text limit constraint
+merge_request: 43952
+author:
+type: other
diff --git a/changelogs/unreleased/220503_bug_add_branches_to_be_notified_to_hangouts_service_api.yml b/changelogs/unreleased/220503_bug_add_branches_to_be_notified_to_hangouts_service_api.yml
new file mode 100644
index 00000000000..22f81687cef
--- /dev/null
+++ b/changelogs/unreleased/220503_bug_add_branches_to_be_notified_to_hangouts_service_api.yml
@@ -0,0 +1,5 @@
+---
+title: Fix branches_to_be_notified API param for hangouts chat service
+merge_request: 35599
+author:
+type: fixed
diff --git a/changelogs/unreleased/223101-follow-up-from-replace-double-angle-icons-with-chevron.yml b/changelogs/unreleased/223101-follow-up-from-replace-double-angle-icons-with-chevron.yml
new file mode 100644
index 00000000000..743e4621767
--- /dev/null
+++ b/changelogs/unreleased/223101-follow-up-from-replace-double-angle-icons-with-chevron.yml
@@ -0,0 +1,5 @@
+---
+title: Replace double angle icons with GitLab SVG in issuables sidebar
+merge_request: 43655
+author:
+type: changed
diff --git a/changelogs/unreleased/225164-add-a-title-section-to-the-package-registry-ui.yml b/changelogs/unreleased/225164-add-a-title-section-to-the-package-registry-ui.yml
new file mode 100644
index 00000000000..866b99a4e61
--- /dev/null
+++ b/changelogs/unreleased/225164-add-a-title-section-to-the-package-registry-ui.yml
@@ -0,0 +1,5 @@
+---
+title: Add a title section to the Package Registry UI
+merge_request: 42963
+author:
+type: changed
diff --git a/changelogs/unreleased/225175-change-the-default-order-for-packages-in-the-package-registry-list.yml b/changelogs/unreleased/225175-change-the-default-order-for-packages-in-the-package-registry-list.yml
new file mode 100644
index 00000000000..63c800ee990
--- /dev/null
+++ b/changelogs/unreleased/225175-change-the-default-order-for-packages-in-the-package-registry-list.yml
@@ -0,0 +1,5 @@
+---
+title: Rename Created to Published in package sort dropdown
+merge_request: 42677
+author:
+type: changed
diff --git a/changelogs/unreleased/225293-emaill-participants.yml b/changelogs/unreleased/225293-emaill-participants.yml
new file mode 100644
index 00000000000..78a4853dc6f
--- /dev/null
+++ b/changelogs/unreleased/225293-emaill-participants.yml
@@ -0,0 +1,5 @@
+---
+title: Add issue_email_participants table and related model
+merge_request: 42943
+author:
+type: other
diff --git a/changelogs/unreleased/225644-package-registry-in-the-group-registry-view-make-the-project-ui-mo.yml b/changelogs/unreleased/225644-package-registry-in-the-group-registry-view-make-the-project-ui-mo.yml
new file mode 100644
index 00000000000..cce1d360787
--- /dev/null
+++ b/changelogs/unreleased/225644-package-registry-in-the-group-registry-view-make-the-project-ui-mo.yml
@@ -0,0 +1,5 @@
+---
+title: Breadcrumb like UI for project path in packages list
+merge_request: 42684
+author:
+type: changed
diff --git a/changelogs/unreleased/225655-alert-new-page.yml b/changelogs/unreleased/225655-alert-new-page.yml
new file mode 100644
index 00000000000..baae40eccfb
--- /dev/null
+++ b/changelogs/unreleased/225655-alert-new-page.yml
@@ -0,0 +1,5 @@
+---
+title: Allow alerts to open on new tab
+merge_request: 42691
+author:
+type: changed
diff --git a/changelogs/unreleased/227668-boards_resolver_id_required.yml b/changelogs/unreleased/227668-boards_resolver_id_required.yml
new file mode 100644
index 00000000000..4253258055b
--- /dev/null
+++ b/changelogs/unreleased/227668-boards_resolver_id_required.yml
@@ -0,0 +1,5 @@
+---
+title: 'GraphQL: No longer allows to omit ID when querying for a single board.'
+merge_request: 43627
+author:
+type: fixed
diff --git a/changelogs/unreleased/228674-no-dependencies-on-deploy-ecs.yml b/changelogs/unreleased/228674-no-dependencies-on-deploy-ecs.yml
new file mode 100644
index 00000000000..6a308ae5de4
--- /dev/null
+++ b/changelogs/unreleased/228674-no-dependencies-on-deploy-ecs.yml
@@ -0,0 +1,5 @@
+---
+title: Add empty dependencies value to ECS Deploy job
+merge_request: 36862
+author:
+type: fixed
diff --git a/changelogs/unreleased/229284-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml b/changelogs/unreleased/229284-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml
new file mode 100644
index 00000000000..0e05ddd0a8a
--- /dev/null
+++ b/changelogs/unreleased/229284-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml
@@ -0,0 +1,5 @@
+---
+title: Update IDE compare changes view button to link style
+merge_request: 43403
+author:
+type: other
diff --git a/changelogs/unreleased/229343-update-lock-form-buttons.yml b/changelogs/unreleased/229343-update-lock-form-buttons.yml
new file mode 100644
index 00000000000..d19c738b5c3
--- /dev/null
+++ b/changelogs/unreleased/229343-update-lock-form-buttons.yml
@@ -0,0 +1,5 @@
+---
+title: Update lock form buttons to gl-button
+merge_request: 41454
+author:
+type: changed
diff --git a/changelogs/unreleased/229344-update-confidential-form-buttons.yml b/changelogs/unreleased/229344-update-confidential-form-buttons.yml
new file mode 100644
index 00000000000..0b6f687530d
--- /dev/null
+++ b/changelogs/unreleased/229344-update-confidential-form-buttons.yml
@@ -0,0 +1,5 @@
+---
+title: Update confidential form buttons to gl-button
+merge_request: 40893
+author:
+type: changed
diff --git a/changelogs/unreleased/229404-filter-capabilities.yml b/changelogs/unreleased/229404-filter-capabilities.yml
new file mode 100644
index 00000000000..ae415d6446a
--- /dev/null
+++ b/changelogs/unreleased/229404-filter-capabilities.yml
@@ -0,0 +1,5 @@
+---
+title: Resolve Add filter capabilities to Incident list
+merge_request: 42377
+author:
+type: changed
diff --git a/changelogs/unreleased/229534-incident-sorting-backend.yml b/changelogs/unreleased/229534-incident-sorting-backend.yml
new file mode 100644
index 00000000000..0732f2868f9
--- /dev/null
+++ b/changelogs/unreleased/229534-incident-sorting-backend.yml
@@ -0,0 +1,5 @@
+---
+title: Add severity and published sorting for incident issues
+merge_request: 42800
+author:
+type: added
diff --git a/changelogs/unreleased/229534-incidents-sorting.yml b/changelogs/unreleased/229534-incidents-sorting.yml
new file mode 100644
index 00000000000..1a33227c3d0
--- /dev/null
+++ b/changelogs/unreleased/229534-incidents-sorting.yml
@@ -0,0 +1,5 @@
+---
+title: Sort incidents list by severity and published columns
+merge_request: 43121
+author:
+type: added
diff --git a/changelogs/unreleased/229834-destroy-board-list-graphql.yml b/changelogs/unreleased/229834-destroy-board-list-graphql.yml
new file mode 100644
index 00000000000..79f63b07eb7
--- /dev/null
+++ b/changelogs/unreleased/229834-destroy-board-list-graphql.yml
@@ -0,0 +1,5 @@
+---
+title: Destroy issue board list via GraphQL
+merge_request: 43081
+author:
+type: added
diff --git a/changelogs/unreleased/230438-usage-data-optimize-usage_activity_by_stage-counters.yml b/changelogs/unreleased/230438-usage-data-optimize-usage_activity_by_stage-counters.yml
new file mode 100644
index 00000000000..c7a834d605f
--- /dev/null
+++ b/changelogs/unreleased/230438-usage-data-optimize-usage_activity_by_stage-counters.yml
@@ -0,0 +1,5 @@
+---
+title: Add index on ci_builds relation to improve Usage Ping metrics collection performance.
+merge_request: 37581
+author:
+type: added
diff --git a/changelogs/unreleased/230719-tabs-vue-migrate-app-assets-javascripts-environments-components-en.yml b/changelogs/unreleased/230719-tabs-vue-migrate-app-assets-javascripts-environments-components-en.yml
new file mode 100644
index 00000000000..8cdd77718f8
--- /dev/null
+++ b/changelogs/unreleased/230719-tabs-vue-migrate-app-assets-javascripts-environments-components-en.yml
@@ -0,0 +1,5 @@
+---
+title: Migrate custom Tabs to GlTabs
+merge_request: 42236
+author:
+type: changed
diff --git a/changelogs/unreleased/230720-tabs-vue-migrate-app-assets-javascripts-environments-folder-enviro.yml b/changelogs/unreleased/230720-tabs-vue-migrate-app-assets-javascripts-environments-folder-enviro.yml
new file mode 100644
index 00000000000..f27930f26e5
--- /dev/null
+++ b/changelogs/unreleased/230720-tabs-vue-migrate-app-assets-javascripts-environments-folder-enviro.yml
@@ -0,0 +1,5 @@
+---
+title: Migrate environments folder tabs to GlTabs
+merge_request: 42894
+author:
+type: changed
diff --git a/changelogs/unreleased/230725-update-boards-modal-tabs.yml b/changelogs/unreleased/230725-update-boards-modal-tabs.yml
new file mode 100644
index 00000000000..87e1e0b4f54
--- /dev/null
+++ b/changelogs/unreleased/230725-update-boards-modal-tabs.yml
@@ -0,0 +1,5 @@
+---
+title: Update issue boards modal to gl-tabs
+merge_request: 43740
+author:
+type: changed
diff --git a/changelogs/unreleased/230827-fix-epics-filter.yml b/changelogs/unreleased/230827-fix-epics-filter.yml
new file mode 100644
index 00000000000..898b5ce913e
--- /dev/null
+++ b/changelogs/unreleased/230827-fix-epics-filter.yml
@@ -0,0 +1,5 @@
+---
+title: Fix filtering epics when sorting by dates
+merge_request: 42827
+author:
+type: fixed
diff --git a/changelogs/unreleased/230841-update-alert-gfm-like-reference.yml b/changelogs/unreleased/230841-update-alert-gfm-like-reference.yml
new file mode 100644
index 00000000000..9f6a0e7b2dc
--- /dev/null
+++ b/changelogs/unreleased/230841-update-alert-gfm-like-reference.yml
@@ -0,0 +1,5 @@
+---
+title: Update alert GFM reference in highlight bar
+merge_request: 43104
+author:
+type: changed
diff --git a/changelogs/unreleased/231072-project-members-import-button.yml b/changelogs/unreleased/231072-project-members-import-button.yml
new file mode 100644
index 00000000000..26bdd048726
--- /dev/null
+++ b/changelogs/unreleased/231072-project-members-import-button.yml
@@ -0,0 +1,5 @@
+---
+title: Add gl-button class to import and cancel buttons for project member import page
+merge_request: 43620
+author: Gary Bell @garybell
+type: other
diff --git a/changelogs/unreleased/231180-ui-button-proj-hook_logs.yml b/changelogs/unreleased/231180-ui-button-proj-hook_logs.yml
new file mode 100644
index 00000000000..5e26bbc3f97
--- /dev/null
+++ b/changelogs/unreleased/231180-ui-button-proj-hook_logs.yml
@@ -0,0 +1,5 @@
+---
+title: Set hook_log css to gl-button
+merge_request: 42730
+author: Mike Terhar @mterhar
+type: other
diff --git a/changelogs/unreleased/231209-labels-buttons.yml b/changelogs/unreleased/231209-labels-buttons.yml
new file mode 100644
index 00000000000..51de12ff2e8
--- /dev/null
+++ b/changelogs/unreleased/231209-labels-buttons.yml
@@ -0,0 +1,5 @@
+---
+title: Apply GitLab UI styles to buttons in app/views/shared/labels directory
+merge_request: 43346
+author: Gary Bell @garybell
+type: other
diff --git a/changelogs/unreleased/231306-approve-activity-in-core.yml b/changelogs/unreleased/231306-approve-activity-in-core.yml
new file mode 100644
index 00000000000..41e921821b5
--- /dev/null
+++ b/changelogs/unreleased/231306-approve-activity-in-core.yml
@@ -0,0 +1,5 @@
+---
+title: Fix issues with optional merge requests approval in CE
+author: Pavel Kuznetsov
+merge_request: 42119
+type: fixed
diff --git a/changelogs/unreleased/233016_fix_jira_dvcs_user_avatars.yml b/changelogs/unreleased/233016_fix_jira_dvcs_user_avatars.yml
new file mode 100644
index 00000000000..695de5d40b0
--- /dev/null
+++ b/changelogs/unreleased/233016_fix_jira_dvcs_user_avatars.yml
@@ -0,0 +1,5 @@
+---
+title: Fix broken user avatars in Jira Development Panel
+merge_request: 43563
+author:
+type: fixed
diff --git a/changelogs/unreleased/233430-design-repo-in-backup.yml b/changelogs/unreleased/233430-design-repo-in-backup.yml
new file mode 100644
index 00000000000..e8bbd3cfb94
--- /dev/null
+++ b/changelogs/unreleased/233430-design-repo-in-backup.yml
@@ -0,0 +1,5 @@
+---
+title: Include Design Management git repositories in GitLab Backup
+merge_request: 43947
+author:
+type: fixed
diff --git a/changelogs/unreleased/233647-replace-bootstrap-alerts-to-gitlab-ui.yml b/changelogs/unreleased/233647-replace-bootstrap-alerts-to-gitlab-ui.yml
new file mode 100644
index 00000000000..c8316653765
--- /dev/null
+++ b/changelogs/unreleased/233647-replace-bootstrap-alerts-to-gitlab-ui.yml
@@ -0,0 +1,5 @@
+---
+title: Replace bootstrap alerts in app/views/admin/broadcast_messages/_form.html.haml
+merge_request: 41271
+author: Gilang Gumilar
+type: changed
diff --git a/changelogs/unreleased/233660-remove-bootstrap-alert-gcp.yml b/changelogs/unreleased/233660-remove-bootstrap-alert-gcp.yml
new file mode 100644
index 00000000000..a0cf820cb4e
--- /dev/null
+++ b/changelogs/unreleased/233660-remove-bootstrap-alert-gcp.yml
@@ -0,0 +1,5 @@
+---
+title: Remove bootrap alert from gcp offer
+merge_request: 41814
+author:
+type: other
diff --git a/changelogs/unreleased/233674-remove-bootstrap-usage-invalid.yml b/changelogs/unreleased/233674-remove-bootstrap-usage-invalid.yml
new file mode 100644
index 00000000000..ec86067a9b1
--- /dev/null
+++ b/changelogs/unreleased/233674-remove-bootstrap-usage-invalid.yml
@@ -0,0 +1,5 @@
+---
+title: Remove bootstrap usage from merge_requests/invalid
+merge_request: 43439
+author:
+type: changed
diff --git a/changelogs/unreleased/233686-replace-bootstrap-alerts-in-app-views-admin-projects-show-html-ham.yml b/changelogs/unreleased/233686-replace-bootstrap-alerts-in-app-views-admin-projects-show-html-ham.yml
new file mode 100644
index 00000000000..6ded0ab3b3d
--- /dev/null
+++ b/changelogs/unreleased/233686-replace-bootstrap-alerts-in-app-views-admin-projects-show-html-ham.yml
@@ -0,0 +1,5 @@
+---
+title: Replace bootstrap alerts in app/views/admin/projects/show.html.haml
+merge_request: 41389
+author: Gilang Gumilar
+type: changed
diff --git a/changelogs/unreleased/233693-remove-bootstrap-pages.yml b/changelogs/unreleased/233693-remove-bootstrap-pages.yml
new file mode 100644
index 00000000000..dce56a28d86
--- /dev/null
+++ b/changelogs/unreleased/233693-remove-bootstrap-pages.yml
@@ -0,0 +1,5 @@
+---
+title: Remove bootstrap from pages/form
+merge_request: 43442
+author:
+type: other
diff --git a/changelogs/unreleased/233703-replace-bootstrap-alerts-in-app-views-import-shared-_errors-html-h.yml b/changelogs/unreleased/233703-replace-bootstrap-alerts-in-app-views-import-shared-_errors-html-h.yml
new file mode 100644
index 00000000000..37ce9481f51
--- /dev/null
+++ b/changelogs/unreleased/233703-replace-bootstrap-alerts-in-app-views-import-shared-_errors-html-h.yml
@@ -0,0 +1,5 @@
+---
+title: Replace bootstrap alerts in app/views/import/shared/_errors.html.haml
+merge_request: 41288
+author: Gilang Gumilar
+type: changed
diff --git a/changelogs/unreleased/233719-project-creating-incidents-usage-ping.yml b/changelogs/unreleased/233719-project-creating-incidents-usage-ping.yml
new file mode 100644
index 00000000000..d6e5dadb688
--- /dev/null
+++ b/changelogs/unreleased/233719-project-creating-incidents-usage-ping.yml
@@ -0,0 +1,5 @@
+---
+title: Add projects_creating_incidents to usage ping counts
+merge_request: 42934
+author:
+type: added
diff --git a/changelogs/unreleased/235822-group-package-permissions.yml b/changelogs/unreleased/235822-group-package-permissions.yml
new file mode 100644
index 00000000000..4faa4c40747
--- /dev/null
+++ b/changelogs/unreleased/235822-group-package-permissions.yml
@@ -0,0 +1,5 @@
+---
+title: Fix group deploy tokens permissions for package access
+merge_request: 43007
+author:
+type: fixed
diff --git a/changelogs/unreleased/235857-sentryerrortype-project_id-returns-a-project-global-id.yml b/changelogs/unreleased/235857-sentryerrortype-project_id-returns-a-project-global-id.yml
new file mode 100644
index 00000000000..574d83e8a85
--- /dev/null
+++ b/changelogs/unreleased/235857-sentryerrortype-project_id-returns-a-project-global-id.yml
@@ -0,0 +1,5 @@
+---
+title: Fix type of SentryErrorType global ID
+merge_request: 42185
+author:
+type: fixed
diff --git a/changelogs/unreleased/235945-replace-the-delete-ssh-confirmation-dialog-with-a-pajamas-modal.yml b/changelogs/unreleased/235945-replace-the-delete-ssh-confirmation-dialog-with-a-pajamas-modal.yml
new file mode 100644
index 00000000000..2fddc52c957
--- /dev/null
+++ b/changelogs/unreleased/235945-replace-the-delete-ssh-confirmation-dialog-with-a-pajamas-modal.yml
@@ -0,0 +1,5 @@
+---
+title: Updated the admin and user SSH key delete confirmation to use GlModal
+merge_request: 42824
+author:
+type: changed
diff --git a/changelogs/unreleased/237923-error-when-quickly-reordering-designs.yml b/changelogs/unreleased/237923-error-when-quickly-reordering-designs.yml
new file mode 100644
index 00000000000..63ffd426228
--- /dev/null
+++ b/changelogs/unreleased/237923-error-when-quickly-reordering-designs.yml
@@ -0,0 +1,5 @@
+---
+title: Resolve Error when quickly reordering designs
+merge_request: 42818
+author:
+type: fixed
diff --git a/changelogs/unreleased/238570-hide-instance-level-integrations-on-gitlab-com.yml b/changelogs/unreleased/238570-hide-instance-level-integrations-on-gitlab-com.yml
new file mode 100644
index 00000000000..3a68c265a86
--- /dev/null
+++ b/changelogs/unreleased/238570-hide-instance-level-integrations-on-gitlab-com.yml
@@ -0,0 +1,5 @@
+---
+title: Hide instance-level integrations on GitLab.com
+merge_request: 42808
+author:
+type: changed
diff --git a/changelogs/unreleased/238605-optimise-lfs-cleanup.yml b/changelogs/unreleased/238605-optimise-lfs-cleanup.yml
new file mode 100644
index 00000000000..e1533b8fba2
--- /dev/null
+++ b/changelogs/unreleased/238605-optimise-lfs-cleanup.yml
@@ -0,0 +1,5 @@
+---
+title: Optimise cleaning up LFS objects
+merge_request: 42830
+author:
+type: performance
diff --git a/changelogs/unreleased/238605-remove-cleanup-lfs-during-gc-feature-flag.yml b/changelogs/unreleased/238605-remove-cleanup-lfs-during-gc-feature-flag.yml
new file mode 100644
index 00000000000..107417c01e3
--- /dev/null
+++ b/changelogs/unreleased/238605-remove-cleanup-lfs-during-gc-feature-flag.yml
@@ -0,0 +1,5 @@
+---
+title: Clean up unused LFS objects during repository housekeeping
+merge_request: 40979
+author:
+type: added
diff --git a/changelogs/unreleased/238628-design-specs-for-confirmation-modal.yml b/changelogs/unreleased/238628-design-specs-for-confirmation-modal.yml
new file mode 100644
index 00000000000..03eb155e53c
--- /dev/null
+++ b/changelogs/unreleased/238628-design-specs-for-confirmation-modal.yml
@@ -0,0 +1,5 @@
+---
+title: Add confirmation modal on instance-level integration form
+merge_request: 42840
+author:
+type: changed
diff --git a/changelogs/unreleased/239130-conan-recipe-display-be.yml b/changelogs/unreleased/239130-conan-recipe-display-be.yml
new file mode 100644
index 00000000000..81077644bd8
--- /dev/null
+++ b/changelogs/unreleased/239130-conan-recipe-display-be.yml
@@ -0,0 +1,5 @@
+---
+title: Use Conan recipe as package name in package API
+merge_request: 42860
+author:
+type: changed
diff --git a/changelogs/unreleased/239327-fj-fix-snippet-update-edge-case.yml b/changelogs/unreleased/239327-fj-fix-snippet-update-edge-case.yml
new file mode 100644
index 00000000000..ed4721cd1a6
--- /dev/null
+++ b/changelogs/unreleased/239327-fj-fix-snippet-update-edge-case.yml
@@ -0,0 +1,5 @@
+---
+title: Fix edge case when updating snippet with no repo
+merge_request: 42964
+author:
+type: fixed
diff --git a/changelogs/unreleased/241165-sse-config-image-upload-path.yml b/changelogs/unreleased/241165-sse-config-image-upload-path.yml
new file mode 100644
index 00000000000..a42520031b3
--- /dev/null
+++ b/changelogs/unreleased/241165-sse-config-image-upload-path.yml
@@ -0,0 +1,5 @@
+---
+title: Introduce 'image_upload_path' entry support for '.gitlab/static-site-editor.yml' config file.
+merge_request: 43481
+author:
+type: added
diff --git a/changelogs/unreleased/241166-sse-config-mounts.yml b/changelogs/unreleased/241166-sse-config-mounts.yml
new file mode 100644
index 00000000000..272cfec3ae0
--- /dev/null
+++ b/changelogs/unreleased/241166-sse-config-mounts.yml
@@ -0,0 +1,5 @@
+---
+title: Introduce 'mounts' entry support for '.gitlab/static-site-editor.yml' config file.
+merge_request: 43485
+author:
+type: added
diff --git a/changelogs/unreleased/242016-fj-add-instance-gitpod-enabled-to-usage-data.yml b/changelogs/unreleased/242016-fj-add-instance-gitpod-enabled-to-usage-data.yml
new file mode 100644
index 00000000000..7c109a090e5
--- /dev/null
+++ b/changelogs/unreleased/242016-fj-add-instance-gitpod-enabled-to-usage-data.yml
@@ -0,0 +1,5 @@
+---
+title: Add Gitpod enabled instance setting to Usage Data
+merge_request: 42563
+author:
+type: changed
diff --git a/changelogs/unreleased/242016-fj-add-user-preferences-gitpod-enabled-to-usage-data.yml b/changelogs/unreleased/242016-fj-add-user-preferences-gitpod-enabled-to-usage-data.yml
new file mode 100644
index 00000000000..780c98d46f6
--- /dev/null
+++ b/changelogs/unreleased/242016-fj-add-user-preferences-gitpod-enabled-to-usage-data.yml
@@ -0,0 +1,5 @@
+---
+title: Add Gitpod enabled user setting to Usage Data
+merge_request: 42570
+author:
+type: changed
diff --git a/changelogs/unreleased/243563-move-approval-code-to-ce.yml b/changelogs/unreleased/243563-move-approval-code-to-ce.yml
new file mode 100644
index 00000000000..1e96465e3c2
--- /dev/null
+++ b/changelogs/unreleased/243563-move-approval-code-to-ce.yml
@@ -0,0 +1,5 @@
+---
+title: Move approval MR filter and quick actions to CE
+author: Pavel Kuznetsov
+merge_request: 43326
+type: changed
diff --git a/changelogs/unreleased/243750-display-messages-and-warning-for-partially-executed-cleanup-polici.yml b/changelogs/unreleased/243750-display-messages-and-warning-for-partially-executed-cleanup-polici.yml
new file mode 100644
index 00000000000..e3f090109cf
--- /dev/null
+++ b/changelogs/unreleased/243750-display-messages-and-warning-for-partially-executed-cleanup-polici.yml
@@ -0,0 +1,5 @@
+---
+title: Display alert for partially executed cleanup policies
+merge_request: 43831
+author:
+type: changed
diff --git a/changelogs/unreleased/244355-add-javascript-example-on-how-we-could-track-ui-events-using-usage.yml b/changelogs/unreleased/244355-add-javascript-example-on-how-we-could-track-ui-events-using-usage.yml
new file mode 100644
index 00000000000..8320e4159ae
--- /dev/null
+++ b/changelogs/unreleased/244355-add-javascript-example-on-how-we-could-track-ui-events-using-usage.yml
@@ -0,0 +1,5 @@
+---
+title: JS client for increment_unique_users API
+merge_request: 43084
+author:
+type: added
diff --git a/changelogs/unreleased/244484-remove-redundant-cluster-agents-index.yml b/changelogs/unreleased/244484-remove-redundant-cluster-agents-index.yml
new file mode 100644
index 00000000000..5286361142c
--- /dev/null
+++ b/changelogs/unreleased/244484-remove-redundant-cluster-agents-index.yml
@@ -0,0 +1,5 @@
+---
+title: Remove duplicate index on cluster_agents
+merge_request: 42902
+author:
+type: other
diff --git a/changelogs/unreleased/244828-drop-iglu-registry-url-column.yml b/changelogs/unreleased/244828-drop-iglu-registry-url-column.yml
new file mode 100644
index 00000000000..ee87d496276
--- /dev/null
+++ b/changelogs/unreleased/244828-drop-iglu-registry-url-column.yml
@@ -0,0 +1,5 @@
+---
+title: Drop Iglu registry URL column
+merge_request: 42939
+author:
+type: removed
diff --git a/changelogs/unreleased/244873-perceived-ux-success-screen.yml b/changelogs/unreleased/244873-perceived-ux-success-screen.yml
new file mode 100644
index 00000000000..13722416404
--- /dev/null
+++ b/changelogs/unreleased/244873-perceived-ux-success-screen.yml
@@ -0,0 +1,5 @@
+---
+title: Update user feedback to a dedicated page as opposed to solely a button with a loader
+merge_request: 43189
+author:
+type: changed
diff --git a/changelogs/unreleased/245304-reference-pages_deployments-in-pages_metadata.yml b/changelogs/unreleased/245304-reference-pages_deployments-in-pages_metadata.yml
new file mode 100644
index 00000000000..948e9e9bd5b
--- /dev/null
+++ b/changelogs/unreleased/245304-reference-pages_deployments-in-pages_metadata.yml
@@ -0,0 +1,5 @@
+---
+title: Reference pages_deployments in pages_metadata
+merge_request: 42834
+author:
+type: added
diff --git a/changelogs/unreleased/245325-refactor-the-invites-controller-member-method.yml b/changelogs/unreleased/245325-refactor-the-invites-controller-member-method.yml
new file mode 100644
index 00000000000..2998a787691
--- /dev/null
+++ b/changelogs/unreleased/245325-refactor-the-invites-controller-member-method.yml
@@ -0,0 +1,5 @@
+---
+title: Refactor the invites controller member method
+merge_request: 42727
+author:
+type: other
diff --git a/changelogs/unreleased/246619-add-package-events.yml b/changelogs/unreleased/246619-add-package-events.yml
new file mode 100644
index 00000000000..26465bc4aaa
--- /dev/null
+++ b/changelogs/unreleased/246619-add-package-events.yml
@@ -0,0 +1,5 @@
+---
+title: Adds package event tracking
+merge_request: 41846
+author:
+type: added
diff --git a/changelogs/unreleased/246783-setting-button-position.yml b/changelogs/unreleased/246783-setting-button-position.yml
new file mode 100644
index 00000000000..2f703479857
--- /dev/null
+++ b/changelogs/unreleased/246783-setting-button-position.yml
@@ -0,0 +1,5 @@
+---
+title: Revert justified-content-end settings buttons
+merge_request: 42273
+author:
+type: changed
diff --git a/changelogs/unreleased/246793-mr-coverage-only-latest-build.yml b/changelogs/unreleased/246793-mr-coverage-only-latest-build.yml
new file mode 100644
index 00000000000..5bd6ef4739a
--- /dev/null
+++ b/changelogs/unreleased/246793-mr-coverage-only-latest-build.yml
@@ -0,0 +1,5 @@
+---
+title: Do not show retried builds in the MR code coverage
+merge_request: 42402
+author: Simon Lenz @koala7
+type: fixed
diff --git a/changelogs/unreleased/246847-store-pipeline-counts-by-status.yml b/changelogs/unreleased/246847-store-pipeline-counts-by-status.yml
new file mode 100644
index 00000000000..8cd1de665dd
--- /dev/null
+++ b/changelogs/unreleased/246847-store-pipeline-counts-by-status.yml
@@ -0,0 +1,5 @@
+---
+title: Store pipeline counts by status for instance statistics
+merge_request: 43027
+author:
+type: changed
diff --git a/changelogs/unreleased/247476-standardise-usage-of-angle-brackets.yml b/changelogs/unreleased/247476-standardise-usage-of-angle-brackets.yml
new file mode 100644
index 00000000000..eec8ad098a1
--- /dev/null
+++ b/changelogs/unreleased/247476-standardise-usage-of-angle-brackets.yml
@@ -0,0 +1,5 @@
+---
+title: Remove angle brackets from empty name in U2F device settings
+merge_request: 42440
+author:
+type: changed
diff --git a/changelogs/unreleased/248564-remove-web-ide-suggestion-banner-on-blob-edit.yml b/changelogs/unreleased/248564-remove-web-ide-suggestion-banner-on-blob-edit.yml
new file mode 100644
index 00000000000..7977c4f6c46
--- /dev/null
+++ b/changelogs/unreleased/248564-remove-web-ide-suggestion-banner-on-blob-edit.yml
@@ -0,0 +1,5 @@
+---
+title: Remove banner that suggests Web IDE for editing gitlab-ci.yml
+merge_request: 42815
+author:
+type: changed
diff --git a/changelogs/unreleased/249368-unit-test-report-test-cases-with-errored-status-show-skipped-icon.yml b/changelogs/unreleased/249368-unit-test-report-test-cases-with-errored-status-show-skipped-icon.yml
new file mode 100644
index 00000000000..5767b241881
--- /dev/null
+++ b/changelogs/unreleased/249368-unit-test-report-test-cases-with-errored-status-show-skipped-icon.yml
@@ -0,0 +1,5 @@
+---
+title: 'Unit Test Report: Fix icon for errored status'
+merge_request: 42540
+author:
+type: fixed
diff --git a/changelogs/unreleased/249561-update-workhorse.yml b/changelogs/unreleased/249561-update-workhorse.yml
new file mode 100644
index 00000000000..21bd979f4a2
--- /dev/null
+++ b/changelogs/unreleased/249561-update-workhorse.yml
@@ -0,0 +1,5 @@
+---
+title: Update GitLab Workhorse to v8.47.0
+merge_request: 42855
+author:
+type: other
diff --git a/changelogs/unreleased/249590-project-count-admin-view.yml b/changelogs/unreleased/249590-project-count-admin-view.yml
new file mode 100644
index 00000000000..5e9d9473f43
--- /dev/null
+++ b/changelogs/unreleased/249590-project-count-admin-view.yml
@@ -0,0 +1,5 @@
+---
+title: Display user project count on Admin Dashboard
+merge_request: 42871
+author:
+type: added
diff --git a/changelogs/unreleased/249811-remove-coverage-report-view-feature-flag.yml b/changelogs/unreleased/249811-remove-coverage-report-view-feature-flag.yml
new file mode 100644
index 00000000000..5c6933ebe02
--- /dev/null
+++ b/changelogs/unreleased/249811-remove-coverage-report-view-feature-flag.yml
@@ -0,0 +1,5 @@
+---
+title: Remove coverage_report_view feature flag
+merge_request: 43711
+author: David Barr @davebarr
+type: removed
diff --git a/changelogs/unreleased/250041-does-not-refresh-project-snippet-statistics-on-a-read-only-instanc.yml b/changelogs/unreleased/250041-does-not-refresh-project-snippet-statistics-on-a-read-only-instanc.yml
new file mode 100644
index 00000000000..f85da860bcd
--- /dev/null
+++ b/changelogs/unreleased/250041-does-not-refresh-project-snippet-statistics-on-a-read-only-instanc.yml
@@ -0,0 +1,5 @@
+---
+title: Does not refresh project/snippet statistics on a read-only instance
+merge_request: 42417
+author:
+type: fixed
diff --git a/changelogs/unreleased/250355-iterations-overlap-validation-bug.yml b/changelogs/unreleased/250355-iterations-overlap-validation-bug.yml
new file mode 100644
index 00000000000..a2c23138144
--- /dev/null
+++ b/changelogs/unreleased/250355-iterations-overlap-validation-bug.yml
@@ -0,0 +1,5 @@
+---
+title: Fix iteration validation not checking parent groups
+merge_request: 43234
+author:
+type: fixed
diff --git a/changelogs/unreleased/250580-fix-graphql-api-token-authentication.yml b/changelogs/unreleased/250580-fix-graphql-api-token-authentication.yml
new file mode 100644
index 00000000000..8beebf53a02
--- /dev/null
+++ b/changelogs/unreleased/250580-fix-graphql-api-token-authentication.yml
@@ -0,0 +1,5 @@
+---
+title: Fix GraphQL token authentication when installed under a relative URL
+merge_request: 42706
+author:
+type: fixed
diff --git a/changelogs/unreleased/250654-get-lfs-push-mirror-working-for-github.yml b/changelogs/unreleased/250654-get-lfs-push-mirror-working-for-github.yml
new file mode 100644
index 00000000000..1bc0f738692
--- /dev/null
+++ b/changelogs/unreleased/250654-get-lfs-push-mirror-working-for-github.yml
@@ -0,0 +1,5 @@
+---
+title: Make git lfs for push mirrors work to GitHub.com
+merge_request: 43321
+author:
+type: fixed
diff --git a/changelogs/unreleased/250671-don-t-init-http-metrics-in-sidekiq.yml b/changelogs/unreleased/250671-don-t-init-http-metrics-in-sidekiq.yml
new file mode 100644
index 00000000000..4f1ce2a10ca
--- /dev/null
+++ b/changelogs/unreleased/250671-don-t-init-http-metrics-in-sidekiq.yml
@@ -0,0 +1,5 @@
+---
+title: Don't expose http_request_duration_seconds metrics in sidekiq exporter
+merge_request: 43941
+author:
+type: performance
diff --git a/changelogs/unreleased/250685-fix-carets.yml b/changelogs/unreleased/250685-fix-carets.yml
new file mode 100644
index 00000000000..52885668f88
--- /dev/null
+++ b/changelogs/unreleased/250685-fix-carets.yml
@@ -0,0 +1,5 @@
+---
+title: Fix caret sizes in navigation
+merge_request: 42605
+author:
+type: fixed
diff --git a/changelogs/unreleased/250727-use-fuzzaldrinplus-search-for-labels.yml b/changelogs/unreleased/250727-use-fuzzaldrinplus-search-for-labels.yml
new file mode 100644
index 00000000000..2513161075c
--- /dev/null
+++ b/changelogs/unreleased/250727-use-fuzzaldrinplus-search-for-labels.yml
@@ -0,0 +1,5 @@
+---
+title: Add fuzzy search support to labels dropdown
+merge_request: 43969
+author:
+type: fixed
diff --git a/changelogs/unreleased/251048-modify-time-period-for-batch-counting-performance-of-the-last-port.yml b/changelogs/unreleased/251048-modify-time-period-for-batch-counting-performance-of-the-last-port.yml
new file mode 100644
index 00000000000..00f5c9b0f04
--- /dev/null
+++ b/changelogs/unreleased/251048-modify-time-period-for-batch-counting-performance-of-the-last-port.yml
@@ -0,0 +1,5 @@
+---
+title: Modify time_period for last 28 days to improve batch counting performance
+merge_request: 42972
+author:
+type: performance
diff --git a/changelogs/unreleased/254197-merge-request-diff-has-an-extra-spacing-on-mobile.yml b/changelogs/unreleased/254197-merge-request-diff-has-an-extra-spacing-on-mobile.yml
new file mode 100644
index 00000000000..02cf7dbea49
--- /dev/null
+++ b/changelogs/unreleased/254197-merge-request-diff-has-an-extra-spacing-on-mobile.yml
@@ -0,0 +1,5 @@
+---
+title: Eliminate extra spacing on MR diffs from mobile/tablet screen
+merge_request: 42821
+author: Takuya Noguchi
+type: fixed
diff --git a/changelogs/unreleased/254224-notification-icons-missing-icon-for-custom.yml b/changelogs/unreleased/254224-notification-icons-missing-icon-for-custom.yml
new file mode 100644
index 00000000000..1250aa860dc
--- /dev/null
+++ b/changelogs/unreleased/254224-notification-icons-missing-icon-for-custom.yml
@@ -0,0 +1,5 @@
+---
+title: 'Notifications icon: Render empty string for custom setting'
+merge_request: 42848
+author:
+type: other
diff --git a/changelogs/unreleased/254250-add-time-tracking-to-incidents.yml b/changelogs/unreleased/254250-add-time-tracking-to-incidents.yml
new file mode 100644
index 00000000000..0f183f66e82
--- /dev/null
+++ b/changelogs/unreleased/254250-add-time-tracking-to-incidents.yml
@@ -0,0 +1,5 @@
+---
+title: Allow time tracking in incidents
+merge_request: 42965
+author:
+type: changed
diff --git a/changelogs/unreleased/254281-add-user-agent-to-git-lfs-client.yml b/changelogs/unreleased/254281-add-user-agent-to-git-lfs-client.yml
new file mode 100644
index 00000000000..7f9f2d58fc2
--- /dev/null
+++ b/changelogs/unreleased/254281-add-user-agent-to-git-lfs-client.yml
@@ -0,0 +1,5 @@
+---
+title: Fix verifying LFS uploads with GitHub
+merge_request: 43852
+author:
+type: fixed
diff --git a/changelogs/unreleased/254721-enable-store_ci_pipeline_counts_by_status-ff-by-default.yml b/changelogs/unreleased/254721-enable-store_ci_pipeline_counts_by_status-ff-by-default.yml
new file mode 100644
index 00000000000..e0491b10620
--- /dev/null
+++ b/changelogs/unreleased/254721-enable-store_ci_pipeline_counts_by_status-ff-by-default.yml
@@ -0,0 +1,5 @@
+---
+title: Store pipeline counts by status for instance statistics
+merge_request: 43857
+author:
+type: added
diff --git a/changelogs/unreleased/254903-add-multiple-assignee-filter-graphql.yml b/changelogs/unreleased/254903-add-multiple-assignee-filter-graphql.yml
new file mode 100644
index 00000000000..54d482d0c9f
--- /dev/null
+++ b/changelogs/unreleased/254903-add-multiple-assignee-filter-graphql.yml
@@ -0,0 +1,5 @@
+---
+title: Add assignee usernames to issue resolver
+merge_request: 43294
+author:
+type: changed
diff --git a/changelogs/unreleased/254932-fix-search-results-blob-link-to-branch.yml b/changelogs/unreleased/254932-fix-search-results-blob-link-to-branch.yml
new file mode 100644
index 00000000000..7e3e2d05ac1
--- /dev/null
+++ b/changelogs/unreleased/254932-fix-search-results-blob-link-to-branch.yml
@@ -0,0 +1,5 @@
+---
+title: Ensure code search results link to searched ref
+merge_request: 43510
+author:
+type: fixed
diff --git a/changelogs/unreleased/254946-add-missing-fa-icons-for-file_type_icon_class.yml b/changelogs/unreleased/254946-add-missing-fa-icons-for-file_type_icon_class.yml
new file mode 100644
index 00000000000..8ad7010c0ee
--- /dev/null
+++ b/changelogs/unreleased/254946-add-missing-fa-icons-for-file_type_icon_class.yml
@@ -0,0 +1,5 @@
+---
+title: Add missing fontawesome file icon classes
+merge_request: 43091
+author:
+type: added
diff --git a/changelogs/unreleased/255322-search-sort-issues-and-mrs.yml b/changelogs/unreleased/255322-search-sort-issues-and-mrs.yml
new file mode 100644
index 00000000000..6808844dd9b
--- /dev/null
+++ b/changelogs/unreleased/255322-search-sort-issues-and-mrs.yml
@@ -0,0 +1,5 @@
+---
+title: Add sort parameter to Issue and Merge Request scopes
+merge_request: 43295
+author:
+type: added
diff --git a/changelogs/unreleased/255347-add-a-project-configured-with-gitpod-to-project-templates.yml b/changelogs/unreleased/255347-add-a-project-configured-with-gitpod-to-project-templates.yml
new file mode 100644
index 00000000000..21ea90a7be7
--- /dev/null
+++ b/changelogs/unreleased/255347-add-a-project-configured-with-gitpod-to-project-templates.yml
@@ -0,0 +1,5 @@
+---
+title: "Add Gitpod Spring Petclinic to Project Templates"
+merge_request: 43319
+author:
+type: added
diff --git a/changelogs/unreleased/255938-delete-user-dialog-text-contains-formatting-strings.yml b/changelogs/unreleased/255938-delete-user-dialog-text-contains-formatting-strings.yml
new file mode 100644
index 00000000000..7842acf3d85
--- /dev/null
+++ b/changelogs/unreleased/255938-delete-user-dialog-text-contains-formatting-strings.yml
@@ -0,0 +1,5 @@
+---
+title: Fix Delete User dialog formatted strings
+merge_request: 43871
+author:
+type: fixed
diff --git a/changelogs/unreleased/256094-fix-copy-indexes-migration-helper-opclass-replication.yml b/changelogs/unreleased/256094-fix-copy-indexes-migration-helper-opclass-replication.yml
new file mode 100644
index 00000000000..45b182503e8
--- /dev/null
+++ b/changelogs/unreleased/256094-fix-copy-indexes-migration-helper-opclass-replication.yml
@@ -0,0 +1,6 @@
+---
+title: Fix copy_indexes migration helper skipping the opclass for indexes
+ with operator classes defined for them
+merge_request: 43471
+author:
+type: fixed
diff --git a/changelogs/unreleased/256121-add-schema-to-migration-helpers-that-query-the-pg-catalog.yml b/changelogs/unreleased/256121-add-schema-to-migration-helpers-that-query-the-pg-catalog.yml
new file mode 100644
index 00000000000..7fa4750c445
--- /dev/null
+++ b/changelogs/unreleased/256121-add-schema-to-migration-helpers-that-query-the-pg-catalog.yml
@@ -0,0 +1,5 @@
+---
+title: Update database helpers to set the current_schema
+merge_request: 43568
+author:
+type: fixed
diff --git a/changelogs/unreleased/257198-align-recognized-markdown-extensions-with-file-icon.yml b/changelogs/unreleased/257198-align-recognized-markdown-extensions-with-file-icon.yml
new file mode 100644
index 00000000000..5ba79e464aa
--- /dev/null
+++ b/changelogs/unreleased/257198-align-recognized-markdown-extensions-with-file-icon.yml
@@ -0,0 +1,5 @@
+---
+title: Add markdown icon to more file extensions
+merge_request: 43479
+author:
+type: fixed
diff --git a/changelogs/unreleased/257881-migration-user-admin-approval-toggle.yml b/changelogs/unreleased/257881-migration-user-admin-approval-toggle.yml
new file mode 100644
index 00000000000..a995bfe7a7f
--- /dev/null
+++ b/changelogs/unreleased/257881-migration-user-admin-approval-toggle.yml
@@ -0,0 +1,6 @@
+---
+title: Add a database column to enable or disable the setting that puts newly registered
+ users in a pending state, requiring admin approval for their activation
+merge_request: 43661
+author:
+type: added
diff --git a/changelogs/unreleased/258203-add-api-fuzzing-plan-limits-db.yml b/changelogs/unreleased/258203-add-api-fuzzing-plan-limits-db.yml
new file mode 100644
index 00000000000..19695b5c384
--- /dev/null
+++ b/changelogs/unreleased/258203-add-api-fuzzing-plan-limits-db.yml
@@ -0,0 +1,5 @@
+---
+title: Add API Fuzzing plan limits db column
+merge_request: 43934
+author:
+type: added
diff --git a/changelogs/unreleased/258668-update-icons-in-nav.yml b/changelogs/unreleased/258668-update-icons-in-nav.yml
new file mode 100644
index 00000000000..ec630bb34a3
--- /dev/null
+++ b/changelogs/unreleased/258668-update-icons-in-nav.yml
@@ -0,0 +1,5 @@
+---
+title: Update nav icons to chevron-down
+merge_request: 43767
+author:
+type: changed
diff --git a/changelogs/unreleased/258964-update-node-sass-from-4-12-to-4-14.yml b/changelogs/unreleased/258964-update-node-sass-from-4-12-to-4-14.yml
new file mode 100644
index 00000000000..1e4b6f97122
--- /dev/null
+++ b/changelogs/unreleased/258964-update-node-sass-from-4-12-to-4-14.yml
@@ -0,0 +1,5 @@
+---
+title: Update node-sass from 4.12.0 to 4.14.1
+merge_request: 43808
+author: Takuya Noguchi
+type: other
diff --git a/changelogs/unreleased/35223-drop-unused-user-id-column-on-cluster-providers-aws.yml b/changelogs/unreleased/35223-drop-unused-user-id-column-on-cluster-providers-aws.yml
new file mode 100644
index 00000000000..dd27d6778fc
--- /dev/null
+++ b/changelogs/unreleased/35223-drop-unused-user-id-column-on-cluster-providers-aws.yml
@@ -0,0 +1,5 @@
+---
+title: Remove unused cluster_providers_aws.created_by_user_id column
+merge_request: 43064
+author:
+type: other
diff --git a/changelogs/unreleased/40174-snowflake-npm-forward.yml b/changelogs/unreleased/40174-snowflake-npm-forward.yml
new file mode 100644
index 00000000000..d0ed22870db
--- /dev/null
+++ b/changelogs/unreleased/40174-snowflake-npm-forward.yml
@@ -0,0 +1,5 @@
+---
+title: Measure npm request forwarding usage
+merge_request: 40174
+author:
+type: added
diff --git a/changelogs/unreleased/42426-migrate-slack-button.yml b/changelogs/unreleased/42426-migrate-slack-button.yml
new file mode 100644
index 00000000000..6599b8bb591
--- /dev/null
+++ b/changelogs/unreleased/42426-migrate-slack-button.yml
@@ -0,0 +1,5 @@
+---
+title: Update button to gl-button on GitLab for Slack page
+merge_request: 42426
+author:
+type: other
diff --git a/changelogs/unreleased/42910-use-create-wiki-method-on-ensure-wiki-exists-in-update-service.yml b/changelogs/unreleased/42910-use-create-wiki-method-on-ensure-wiki-exists-in-update-service.yml
new file mode 100644
index 00000000000..02400808f54
--- /dev/null
+++ b/changelogs/unreleased/42910-use-create-wiki-method-on-ensure-wiki-exists-in-update-service.yml
@@ -0,0 +1,5 @@
+---
+title: use create_wiki method on ensure_wiki_exists in update_service
+merge_request: 42910
+author:
+type: fixed
diff --git a/changelogs/unreleased/42916-pypi-install-command.yml b/changelogs/unreleased/42916-pypi-install-command.yml
new file mode 100644
index 00000000000..cad4d355c8c
--- /dev/null
+++ b/changelogs/unreleased/42916-pypi-install-command.yml
@@ -0,0 +1,5 @@
+---
+title: Update pypi install command to work with external dependencies
+merge_request: 42916
+author:
+type: changed
diff --git a/changelogs/unreleased/42917_golang_vendor_templates.yml b/changelogs/unreleased/42917_golang_vendor_templates.yml
new file mode 100644
index 00000000000..928bb628e1c
--- /dev/null
+++ b/changelogs/unreleased/42917_golang_vendor_templates.yml
@@ -0,0 +1,5 @@
+---
+title: Update golang version in vendored Dockerfile template
+merge_request: 42917
+author:
+type: added
diff --git a/changelogs/unreleased/42989-update-deactivation-threshold-to-90-days.yml b/changelogs/unreleased/42989-update-deactivation-threshold-to-90-days.yml
new file mode 100644
index 00000000000..3cf2bac918b
--- /dev/null
+++ b/changelogs/unreleased/42989-update-deactivation-threshold-to-90-days.yml
@@ -0,0 +1,5 @@
+---
+title: Adjusted deactivation threshold from 180 to 90 days
+merge_request: 42989
+author:
+type: changed
diff --git a/changelogs/unreleased/43009-update-docs-link-packages.yml b/changelogs/unreleased/43009-update-docs-link-packages.yml
new file mode 100644
index 00000000000..1722e866e4d
--- /dev/null
+++ b/changelogs/unreleased/43009-update-docs-link-packages.yml
@@ -0,0 +1,5 @@
+---
+title: Empty state Packages UI links to user docs
+merge_request: 43009
+author:
+type: fixed
diff --git a/changelogs/unreleased/43499-update-commit-message-popover.yml b/changelogs/unreleased/43499-update-commit-message-popover.yml
new file mode 100644
index 00000000000..3f4b4a222b7
--- /dev/null
+++ b/changelogs/unreleased/43499-update-commit-message-popover.yml
@@ -0,0 +1,5 @@
+---
+title: Update popover to gl-popover on WebIDE commit message
+merge_request: 43499
+author:
+type: other
diff --git a/changelogs/unreleased/Replace-GIDeprecatedDropdown-in-app-assets-javascripts-alert_management.yml b/changelogs/unreleased/Replace-GIDeprecatedDropdown-in-app-assets-javascripts-alert_management.yml
new file mode 100644
index 00000000000..983bfb84325
--- /dev/null
+++ b/changelogs/unreleased/Replace-GIDeprecatedDropdown-in-app-assets-javascripts-alert_management.yml
@@ -0,0 +1,5 @@
+---
+title: Replace-GIDeprecatedDropdown-in-app/assets/javascripts/alert_management
+merge_request: 41409
+author: nuwe1
+type: other
diff --git a/changelogs/unreleased/Replace-GlDeprecatedDropdown-with-GlDropdown-in-app-assets-javascripts-cl.yml b/changelogs/unreleased/Replace-GlDeprecatedDropdown-with-GlDropdown-in-app-assets-javascripts-cl.yml
new file mode 100644
index 00000000000..f3f2cb3ae58
--- /dev/null
+++ b/changelogs/unreleased/Replace-GlDeprecatedDropdown-with-GlDropdown-in-app-assets-javascripts-cl.yml
@@ -0,0 +1,5 @@
+---
+title: Replace deprecated cluster dropdowns with updated dropdowns
+merge_request: 41414
+author: nuwe1
+type: other
diff --git a/changelogs/unreleased/ab-reindexing-actiontracking.yml b/changelogs/unreleased/ab-reindexing-actiontracking.yml
new file mode 100644
index 00000000000..210e6d984ed
--- /dev/null
+++ b/changelogs/unreleased/ab-reindexing-actiontracking.yml
@@ -0,0 +1,5 @@
+---
+title: Track statistics for index rebuilds
+merge_request: 43156
+author:
+type: other
diff --git a/changelogs/unreleased/ab-reindexing-index-model.yml b/changelogs/unreleased/ab-reindexing-index-model.yml
new file mode 100644
index 00000000000..abff32e0004
--- /dev/null
+++ b/changelogs/unreleased/ab-reindexing-index-model.yml
@@ -0,0 +1,5 @@
+---
+title: 'Add database view for postgres indexes'
+merge_request: 42967
+author:
+type: other
diff --git a/changelogs/unreleased/ab-reindexing-limit-schema.yml b/changelogs/unreleased/ab-reindexing-limit-schema.yml
new file mode 100644
index 00000000000..c7ac33f6dc7
--- /dev/null
+++ b/changelogs/unreleased/ab-reindexing-limit-schema.yml
@@ -0,0 +1,5 @@
+---
+title: Limit postgres_indexes to owned schemas
+merge_request: 43834
+author:
+type: other
diff --git a/changelogs/unreleased/add-abuse-notification-email.yml b/changelogs/unreleased/add-abuse-notification-email.yml
new file mode 100644
index 00000000000..1d8312d9cf7
--- /dev/null
+++ b/changelogs/unreleased/add-abuse-notification-email.yml
@@ -0,0 +1,5 @@
+---
+title: Set abuse_notification_email instead of admin_notification_email.
+merge_request: 41319
+author: Hiromi Nozawa
+type: deprecated
diff --git a/changelogs/unreleased/add-dast-site-validation-worker-245209.yml b/changelogs/unreleased/add-dast-site-validation-worker-245209.yml
new file mode 100644
index 00000000000..ba38313f47f
--- /dev/null
+++ b/changelogs/unreleased/add-dast-site-validation-worker-245209.yml
@@ -0,0 +1,5 @@
+---
+title: Add state field to DastSiteValidation
+merge_request: 42198
+author:
+type: added
diff --git a/changelogs/unreleased/add-golang-to-packages.yml b/changelogs/unreleased/add-golang-to-packages.yml
new file mode 100644
index 00000000000..601c7996206
--- /dev/null
+++ b/changelogs/unreleased/add-golang-to-packages.yml
@@ -0,0 +1,5 @@
+---
+title: Add Go(lang) to Packages
+merge_request: 41712
+author: Ethan Reesor (@firelizzard)
+type: added
diff --git a/changelogs/unreleased/add-notification-setting-for-merge-request-reviewers.yml b/changelogs/unreleased/add-notification-setting-for-merge-request-reviewers.yml
new file mode 100644
index 00000000000..6163f5e5f03
--- /dev/null
+++ b/changelogs/unreleased/add-notification-setting-for-merge-request-reviewers.yml
@@ -0,0 +1,5 @@
+---
+title: Add notification setting for merge request reviewers
+merge_request: 41851
+author:
+type: added
diff --git a/changelogs/unreleased/add-sse-config-file-3.yml b/changelogs/unreleased/add-sse-config-file-3.yml
new file mode 100644
index 00000000000..b942b1224f2
--- /dev/null
+++ b/changelogs/unreleased/add-sse-config-file-3.yml
@@ -0,0 +1,6 @@
+---
+title: Introduce '.gitlab/static-site-editor.yml' config file, with support for 'static_site_generator'
+ entry.
+merge_request: 41957
+author:
+type: added
diff --git a/changelogs/unreleased/add_index_for_sha_to_deployments.yml b/changelogs/unreleased/add_index_for_sha_to_deployments.yml
new file mode 100644
index 00000000000..612665427fd
--- /dev/null
+++ b/changelogs/unreleased/add_index_for_sha_to_deployments.yml
@@ -0,0 +1,5 @@
+---
+title: Add index for project_id and sha to deployments table
+merge_request: 43836
+author:
+type: performance
diff --git a/changelogs/unreleased/ajk-219380-fix-design-type-formatting-in-activity-streams.yml b/changelogs/unreleased/ajk-219380-fix-design-type-formatting-in-activity-streams.yml
new file mode 100644
index 00000000000..d12834e93b1
--- /dev/null
+++ b/changelogs/unreleased/ajk-219380-fix-design-type-formatting-in-activity-streams.yml
@@ -0,0 +1,5 @@
+---
+title: Customize value of note_target_type for designs
+merge_request: 43727
+author:
+type: fixed
diff --git a/changelogs/unreleased/ajk-issue-reposition-take.yml b/changelogs/unreleased/ajk-issue-reposition-take.yml
new file mode 100644
index 00000000000..d3e00184cfb
--- /dev/null
+++ b/changelogs/unreleased/ajk-issue-reposition-take.yml
@@ -0,0 +1,5 @@
+---
+title: Performance fix for issue placement
+merge_request: 43315
+author:
+type: performance
diff --git a/changelogs/unreleased/ajk-remove-design_management_reference_filter_gfm_pipeline-ff.yml b/changelogs/unreleased/ajk-remove-design_management_reference_filter_gfm_pipeline-ff.yml
new file mode 100644
index 00000000000..af5900d538c
--- /dev/null
+++ b/changelogs/unreleased/ajk-remove-design_management_reference_filter_gfm_pipeline-ff.yml
@@ -0,0 +1,5 @@
+---
+title: Enable design management reference filter
+merge_request: 43731
+author:
+type: added
diff --git a/changelogs/unreleased/ajk-remove-graphql_lookahead_support.yml b/changelogs/unreleased/ajk-remove-graphql_lookahead_support.yml
new file mode 100644
index 00000000000..f4140b1d9a3
--- /dev/null
+++ b/changelogs/unreleased/ajk-remove-graphql_lookahead_support.yml
@@ -0,0 +1,5 @@
+---
+title: Remove graphql_lookahead_support feature flag
+merge_request: 43438
+author:
+type: added
diff --git a/changelogs/unreleased/ajk-remove-wiki_events_on_git_push-ff.yml b/changelogs/unreleased/ajk-remove-wiki_events_on_git_push-ff.yml
new file mode 100644
index 00000000000..37342ac2146
--- /dev/null
+++ b/changelogs/unreleased/ajk-remove-wiki_events_on_git_push-ff.yml
@@ -0,0 +1,5 @@
+---
+title: Enable wiki events on git push
+merge_request: 43738
+author:
+type: added
diff --git a/changelogs/unreleased/alipniagov-update-wh-to-8-49.yml b/changelogs/unreleased/alipniagov-update-wh-to-8-49.yml
new file mode 100644
index 00000000000..8eff6983c67
--- /dev/null
+++ b/changelogs/unreleased/alipniagov-update-wh-to-8-49.yml
@@ -0,0 +1,5 @@
+---
+title: Update GitLab Workhorse to v8.49.0
+merge_request: 43999
+author:
+type: other
diff --git a/changelogs/unreleased/alipniagov-update-wh-version.yml b/changelogs/unreleased/alipniagov-update-wh-version.yml
new file mode 100644
index 00000000000..a5e7d55af7a
--- /dev/null
+++ b/changelogs/unreleased/alipniagov-update-wh-version.yml
@@ -0,0 +1,5 @@
+---
+title: Update GitLab Workhorse to v8.48.0
+merge_request: 43586
+author:
+type: other
diff --git a/changelogs/unreleased/always-show-clear-cluster-cache-button.yml b/changelogs/unreleased/always-show-clear-cluster-cache-button.yml
new file mode 100644
index 00000000000..744e226e50b
--- /dev/null
+++ b/changelogs/unreleased/always-show-clear-cluster-cache-button.yml
@@ -0,0 +1,5 @@
+---
+title: Always show the "Clear cluster cache" button among the advanced Kubernetes cluster configuration options
+merge_request: 43619
+author:
+type: changed
diff --git a/changelogs/unreleased/api-expose-gpg.yml b/changelogs/unreleased/api-expose-gpg.yml
new file mode 100644
index 00000000000..0508b95a524
--- /dev/null
+++ b/changelogs/unreleased/api-expose-gpg.yml
@@ -0,0 +1,5 @@
+---
+title: Allow a users public GPG Keys to be API accessible
+merge_request: 43332
+author:
+type: added
diff --git a/changelogs/unreleased/bjk-rack_metric_methods.yml b/changelogs/unreleased/bjk-rack_metric_methods.yml
new file mode 100644
index 00000000000..cd86dfd5c69
--- /dev/null
+++ b/changelogs/unreleased/bjk-rack_metric_methods.yml
@@ -0,0 +1,5 @@
+---
+title: Cleanup request http method/code metrics
+merge_request: 42618
+author:
+type: performance
diff --git a/changelogs/unreleased/cat-2fa-404-redirect.yml b/changelogs/unreleased/cat-2fa-404-redirect.yml
new file mode 100644
index 00000000000..c5d88827295
--- /dev/null
+++ b/changelogs/unreleased/cat-2fa-404-redirect.yml
@@ -0,0 +1,5 @@
+---
+title: Exclude 2FA from upload#show routes and 404s
+merge_request: 42784
+author:
+type: fixed
diff --git a/changelogs/unreleased/change-transfer-update-create-services.yml b/changelogs/unreleased/change-transfer-update-create-services.yml
new file mode 100644
index 00000000000..a35016d229c
--- /dev/null
+++ b/changelogs/unreleased/change-transfer-update-create-services.yml
@@ -0,0 +1,5 @@
+---
+title: Change transfer, update and create services for groups and projects to take in consideration shared runners settings
+merge_request: 36080
+author: Arthur de Lapertosa Lisboa
+type: added
diff --git a/changelogs/unreleased/cngo-clear-label-select-on-enter.yml b/changelogs/unreleased/cngo-clear-label-select-on-enter.yml
new file mode 100644
index 00000000000..afcaca6a876
--- /dev/null
+++ b/changelogs/unreleased/cngo-clear-label-select-on-enter.yml
@@ -0,0 +1,5 @@
+---
+title: Reset labels select search text on Enter
+merge_request: 43285
+author:
+type: fixed
diff --git a/changelogs/unreleased/dblessing-auth-events-usage-ping.yml b/changelogs/unreleased/dblessing-auth-events-usage-ping.yml
new file mode 100644
index 00000000000..26117006f1c
--- /dev/null
+++ b/changelogs/unreleased/dblessing-auth-events-usage-ping.yml
@@ -0,0 +1,5 @@
+---
+title: Report auth events in manage stage usage ping
+merge_request: 39747
+author:
+type: added
diff --git a/changelogs/unreleased/dblessing-ip-address-validator.yml b/changelogs/unreleased/dblessing-ip-address-validator.yml
new file mode 100644
index 00000000000..cd20210d950
--- /dev/null
+++ b/changelogs/unreleased/dblessing-ip-address-validator.yml
@@ -0,0 +1,5 @@
+---
+title: Add validator for IP address/inet columns
+merge_request: 42893
+author:
+type: added
diff --git a/changelogs/unreleased/dblessing_user_created_by_id.yml b/changelogs/unreleased/dblessing_user_created_by_id.yml
new file mode 100644
index 00000000000..686b1834136
--- /dev/null
+++ b/changelogs/unreleased/dblessing_user_created_by_id.yml
@@ -0,0 +1,5 @@
+---
+title: Always set created_by_id when creating a user
+merge_request: 43342
+author:
+type: changed
diff --git a/changelogs/unreleased/debian_packages.yml b/changelogs/unreleased/debian_packages.yml
new file mode 100644
index 00000000000..2504018a6f9
--- /dev/null
+++ b/changelogs/unreleased/debian_packages.yml
@@ -0,0 +1,5 @@
+---
+title: Add Debian API skeleton
+merge_request: 42670
+author: Mathieu Parent
+type: added
diff --git a/changelogs/unreleased/debian_regexp.yml b/changelogs/unreleased/debian_regexp.yml
new file mode 100644
index 00000000000..fe8e06c9291
--- /dev/null
+++ b/changelogs/unreleased/debian_regexp.yml
@@ -0,0 +1,5 @@
+---
+title: Add Debian regexps
+merge_request: 43259
+author: Mathieu Parent
+type: added
diff --git a/changelogs/unreleased/display-contributor-and-author-badges-on-notes.yml b/changelogs/unreleased/display-contributor-and-author-badges-on-notes.yml
new file mode 100644
index 00000000000..163053c1b70
--- /dev/null
+++ b/changelogs/unreleased/display-contributor-and-author-badges-on-notes.yml
@@ -0,0 +1,5 @@
+---
+title: Display Contributor badges on notes
+merge_request: 42576
+author: Mycroft Kang @TaehyeokKang
+type: added
diff --git a/changelogs/unreleased/drop-an-element.yml b/changelogs/unreleased/drop-an-element.yml
new file mode 100644
index 00000000000..958d7a47678
--- /dev/null
+++ b/changelogs/unreleased/drop-an-element.yml
@@ -0,0 +1,5 @@
+---
+title: Remove an unnecessary element from every page
+merge_request: 42769
+author: Takuya Noguchi
+type: other
diff --git a/changelogs/unreleased/drop-instance-statistics-visibility-private-column.yml b/changelogs/unreleased/drop-instance-statistics-visibility-private-column.yml
new file mode 100644
index 00000000000..10885375093
--- /dev/null
+++ b/changelogs/unreleased/drop-instance-statistics-visibility-private-column.yml
@@ -0,0 +1,5 @@
+---
+title: Drop column instance_statistics_visibility_private
+merge_request: 42969
+author:
+type: deprecated
diff --git a/changelogs/unreleased/enable-track-unique-visits-ff-by-default.yml b/changelogs/unreleased/enable-track-unique-visits-ff-by-default.yml
new file mode 100644
index 00000000000..0c321e6b58b
--- /dev/null
+++ b/changelogs/unreleased/enable-track-unique-visits-ff-by-default.yml
@@ -0,0 +1,5 @@
+---
+title: Enable track_unique_visits feature flag by default
+merge_request: 43989
+author:
+type: other
diff --git a/changelogs/unreleased/eread-migrate-badge-list-row-buttons-to-new-buttons.yml b/changelogs/unreleased/eread-migrate-badge-list-row-buttons-to-new-buttons.yml
new file mode 100644
index 00000000000..82b5eff2ec5
--- /dev/null
+++ b/changelogs/unreleased/eread-migrate-badge-list-row-buttons-to-new-buttons.yml
@@ -0,0 +1,5 @@
+---
+title: Migrate badge list row buttons to new buttons
+merge_request: 43072
+author:
+type: other
diff --git a/changelogs/unreleased/expose-clusters-namespace-per-environment-flag.yml b/changelogs/unreleased/expose-clusters-namespace-per-environment-flag.yml
new file mode 100644
index 00000000000..99c630b5904
--- /dev/null
+++ b/changelogs/unreleased/expose-clusters-namespace-per-environment-flag.yml
@@ -0,0 +1,6 @@
+---
+title: Expose the option to use namespace-per-project instead of namespace-per-environment
+ for Kubernetes clusters
+merge_request: 42309
+author:
+type: changed
diff --git a/changelogs/unreleased/expose-junit-spec-file-path.yml b/changelogs/unreleased/expose-junit-spec-file-path.yml
new file mode 100644
index 00000000000..de15bae28b9
--- /dev/null
+++ b/changelogs/unreleased/expose-junit-spec-file-path.yml
@@ -0,0 +1,5 @@
+---
+title: Expose file path from XML Test Report artifact
+merge_request: 43594
+author:
+type: changed
diff --git a/changelogs/unreleased/feature-gb-validate-build-traces.yml b/changelogs/unreleased/feature-gb-validate-build-traces.yml
new file mode 100644
index 00000000000..7d7e49d20d4
--- /dev/null
+++ b/changelogs/unreleased/feature-gb-validate-build-traces.yml
@@ -0,0 +1,5 @@
+---
+title: Validate build traces using CRC32 checksums
+merge_request: 42829
+author:
+type: added
diff --git a/changelogs/unreleased/fix-encoding-for-nil-diff-files.yml b/changelogs/unreleased/fix-encoding-for-nil-diff-files.yml
new file mode 100644
index 00000000000..470cb8ff324
--- /dev/null
+++ b/changelogs/unreleased/fix-encoding-for-nil-diff-files.yml
@@ -0,0 +1,5 @@
+---
+title: Fix migrating some empty diffs
+merge_request: 42825
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix-pipeline-stage-action-alignment.yml b/changelogs/unreleased/fix-pipeline-stage-action-alignment.yml
new file mode 100644
index 00000000000..ad0293c00aa
--- /dev/null
+++ b/changelogs/unreleased/fix-pipeline-stage-action-alignment.yml
@@ -0,0 +1,5 @@
+---
+title: Fix button placement on pipeline graph
+merge_request: 43419
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix-retry-job-button-422.yml b/changelogs/unreleased/fix-retry-job-button-422.yml
new file mode 100644
index 00000000000..7415d881da3
--- /dev/null
+++ b/changelogs/unreleased/fix-retry-job-button-422.yml
@@ -0,0 +1,5 @@
+---
+title: Remove retry icon on failed job if merge pipeline
+merge_request: 42495
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix-theme-switcher.yml b/changelogs/unreleased/fix-theme-switcher.yml
new file mode 100644
index 00000000000..39c8dff2df9
--- /dev/null
+++ b/changelogs/unreleased/fix-theme-switcher.yml
@@ -0,0 +1,5 @@
+---
+title: Fix theme selector not working immediately for some themes
+merge_request: 43239
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix-wrong-scope-in-approved-by.yml b/changelogs/unreleased/fix-wrong-scope-in-approved-by.yml
new file mode 100644
index 00000000000..b2d9106737c
--- /dev/null
+++ b/changelogs/unreleased/fix-wrong-scope-in-approved-by.yml
@@ -0,0 +1,5 @@
+---
+title: Fix approvedBy filed in MR GraphQL API
+merge_request: 43705
+author:
+type: fixed
diff --git a/changelogs/unreleased/gaga5lala-227175-carrierwave-error-handle.yml b/changelogs/unreleased/gaga5lala-227175-carrierwave-error-handle.yml
new file mode 100644
index 00000000000..8e134642cb0
--- /dev/null
+++ b/changelogs/unreleased/gaga5lala-227175-carrierwave-error-handle.yml
@@ -0,0 +1,5 @@
+---
+title: Log CarrierWave::IntegrityError without sending exception
+merge_request: 43750
+author: gaga5lala
+type: other
diff --git a/changelogs/unreleased/georgekoltsov-add-group-import-usage-ping.yml b/changelogs/unreleased/georgekoltsov-add-group-import-usage-ping.yml
new file mode 100644
index 00000000000..5aa5a587275
--- /dev/null
+++ b/changelogs/unreleased/georgekoltsov-add-group-import-usage-ping.yml
@@ -0,0 +1,5 @@
+---
+title: Add Group Import usage ping
+merge_request: 41663
+author:
+type: added
diff --git a/changelogs/unreleased/gitlab_buttons_ci_builds.yml b/changelogs/unreleased/gitlab_buttons_ci_builds.yml
new file mode 100644
index 00000000000..ce5f78e1e46
--- /dev/null
+++ b/changelogs/unreleased/gitlab_buttons_ci_builds.yml
@@ -0,0 +1,5 @@
+---
+title: Apply GitLab UI button styles to HAML buttons app/views/projects/ci/builds
+merge_request: 43728
+author: Andrei Kyrnich @kyrnich
+type: other
diff --git a/changelogs/unreleased/gitlab_buttons_projects_blob.yml b/changelogs/unreleased/gitlab_buttons_projects_blob.yml
new file mode 100644
index 00000000000..eef9af0a46b
--- /dev/null
+++ b/changelogs/unreleased/gitlab_buttons_projects_blob.yml
@@ -0,0 +1,5 @@
+---
+title: Apply GitLab UI button styles to HAML buttons app/views/projects/blob
+merge_request: 42991
+author: Andrei Kyrnich @kyrnich
+type: other
diff --git a/changelogs/unreleased/gitlab_buttons_projects_forks.yml b/changelogs/unreleased/gitlab_buttons_projects_forks.yml
new file mode 100644
index 00000000000..0cb67451146
--- /dev/null
+++ b/changelogs/unreleased/gitlab_buttons_projects_forks.yml
@@ -0,0 +1,5 @@
+---
+title: Apply GitLab UI button styles to HAML buttons app/views/projects/forks
+merge_request: 43101
+author: Andrei Kyrnich @kyrnich
+type: other
diff --git a/changelogs/unreleased/id-add-approval-rules-approvers-usage-ping.yml b/changelogs/unreleased/id-add-approval-rules-approvers-usage-ping.yml
new file mode 100644
index 00000000000..3977e5763d2
--- /dev/null
+++ b/changelogs/unreleased/id-add-approval-rules-approvers-usage-ping.yml
@@ -0,0 +1,5 @@
+---
+title: Add approval rules with approvers to usage ping
+merge_request: 36737
+author:
+type: added
diff --git a/changelogs/unreleased/id-fix-squash-messages.yml b/changelogs/unreleased/id-fix-squash-messages.yml
new file mode 100644
index 00000000000..6acbbbee9d7
--- /dev/null
+++ b/changelogs/unreleased/id-fix-squash-messages.yml
@@ -0,0 +1,5 @@
+---
+title: Fix suggested squashed messages for MR
+merge_request: 43508
+author:
+type: fixed
diff --git a/changelogs/unreleased/improve-emoji-support.yml b/changelogs/unreleased/improve-emoji-support.yml
new file mode 100644
index 00000000000..9660c02c5f7
--- /dev/null
+++ b/changelogs/unreleased/improve-emoji-support.yml
@@ -0,0 +1,5 @@
+---
+title: Improve issuable reaction search
+merge_request: 42321
+author: Ethan Reesor (@firelizzard)
+type: added
diff --git a/changelogs/unreleased/isolate-mentions-migration.yml b/changelogs/unreleased/isolate-mentions-migration.yml
new file mode 100644
index 00000000000..ae12901ce90
--- /dev/null
+++ b/changelogs/unreleased/isolate-mentions-migration.yml
@@ -0,0 +1,5 @@
+---
+title: Store user mentions to DB
+merge_request: 43393
+author:
+type: added
diff --git a/changelogs/unreleased/issuable-award-fuzzy-search.yml b/changelogs/unreleased/issuable-award-fuzzy-search.yml
new file mode 100644
index 00000000000..b3671d861e2
--- /dev/null
+++ b/changelogs/unreleased/issuable-award-fuzzy-search.yml
@@ -0,0 +1,5 @@
+---
+title: Use fuzzy matching for issuable awards
+merge_request: 42674
+author: Ethan Reesor (@firelizzard)
+type: added
diff --git a/changelogs/unreleased/issue_205151.yml b/changelogs/unreleased/issue_205151.yml
new file mode 100644
index 00000000000..c687a736d20
--- /dev/null
+++ b/changelogs/unreleased/issue_205151.yml
@@ -0,0 +1,4 @@
+title: 'Rendering trailing slash in reference links (issue 205151)'
+merge_request: 42484
+author:
+type: fixed
diff --git a/changelogs/unreleased/jdb-refactor-diff-rows-utils.yml b/changelogs/unreleased/jdb-refactor-diff-rows-utils.yml
new file mode 100644
index 00000000000..eadc3ef0e94
--- /dev/null
+++ b/changelogs/unreleased/jdb-refactor-diff-rows-utils.yml
@@ -0,0 +1,5 @@
+---
+title: Move shared logic into utils
+merge_request: 42407
+author:
+type: other
diff --git a/changelogs/unreleased/jivanvl-runner-guided-install-backend.yml b/changelogs/unreleased/jivanvl-runner-guided-install-backend.yml
new file mode 100644
index 00000000000..9c68363867d
--- /dev/null
+++ b/changelogs/unreleased/jivanvl-runner-guided-install-backend.yml
@@ -0,0 +1,5 @@
+---
+title: Add runner setup methods
+merge_request: 42878
+author:
+type: added
diff --git a/changelogs/unreleased/jv-job-waiter-key-leak.yml b/changelogs/unreleased/jv-job-waiter-key-leak.yml
new file mode 100644
index 00000000000..8e1bde87fdb
--- /dev/null
+++ b/changelogs/unreleased/jv-job-waiter-key-leak.yml
@@ -0,0 +1,5 @@
+---
+title: Ensure JobWaiter keys always expire
+merge_request: 43320
+author:
+type: fixed
diff --git a/changelogs/unreleased/jyavorska-master-patch-96769.yml b/changelogs/unreleased/jyavorska-master-patch-96769.yml
new file mode 100644
index 00000000000..6cf4808e368
--- /dev/null
+++ b/changelogs/unreleased/jyavorska-master-patch-96769.yml
@@ -0,0 +1,5 @@
+---
+title: Add canonical links for moved/duplicated issues
+merge_request: 34604
+author:
+type: added
diff --git a/changelogs/unreleased/kassio-do-not-add-missing-not-if-importer-user-is-mapped.yml b/changelogs/unreleased/kassio-do-not-add-missing-not-if-importer-user-is-mapped.yml
new file mode 100644
index 00000000000..b878cc1311b
--- /dev/null
+++ b/changelogs/unreleased/kassio-do-not-add-missing-not-if-importer-user-is-mapped.yml
@@ -0,0 +1,5 @@
+---
+title: Allow member mapping to map importer user on Group/Project Import
+merge_request: 42882
+author:
+type: changed
diff --git a/changelogs/unreleased/kassio-fix-missing-author-notes-on-importers.yml b/changelogs/unreleased/kassio-fix-missing-author-notes-on-importers.yml
new file mode 100644
index 00000000000..7f7e2322f10
--- /dev/null
+++ b/changelogs/unreleased/kassio-fix-missing-author-notes-on-importers.yml
@@ -0,0 +1,5 @@
+---
+title: Fixed a bug causing 'Missing author note' to be added to notes for mapped users when importing project using GitLab Import.
+merge_request: 42648
+author:
+type: fixed
diff --git a/changelogs/unreleased/khanchi-designs-patch.yml b/changelogs/unreleased/khanchi-designs-patch.yml
new file mode 100644
index 00000000000..9f675685c30
--- /dev/null
+++ b/changelogs/unreleased/khanchi-designs-patch.yml
@@ -0,0 +1,5 @@
+---
+title: 'Designs: return an error if uploading designs with duplicate names'
+merge_request: 42514
+author: Sushil Khanchi
+type: fixed
diff --git a/changelogs/unreleased/lm-improve-n-1-pl-serializer.yml b/changelogs/unreleased/lm-improve-n-1-pl-serializer.yml
new file mode 100644
index 00000000000..d919ab72ef4
--- /dev/null
+++ b/changelogs/unreleased/lm-improve-n-1-pl-serializer.yml
@@ -0,0 +1,5 @@
+---
+title: Improve n+1 in pipeline serializer for triggered pipelines
+merge_request: 42421
+author:
+type: performance
diff --git a/changelogs/unreleased/mc-feature-ci-config-render-api-endpoint.yml b/changelogs/unreleased/mc-feature-ci-config-render-api-endpoint.yml
new file mode 100644
index 00000000000..5c8bc732f73
--- /dev/null
+++ b/changelogs/unreleased/mc-feature-ci-config-render-api-endpoint.yml
@@ -0,0 +1,5 @@
+---
+title: Show expanded CI config in CI lint API endpoint.
+merge_request: 42380
+author:
+type: added
diff --git a/changelogs/unreleased/migrate-recover-hidden-stage.yml b/changelogs/unreleased/migrate-recover-hidden-stage.yml
new file mode 100644
index 00000000000..e4cf4992a94
--- /dev/null
+++ b/changelogs/unreleased/migrate-recover-hidden-stage.yml
@@ -0,0 +1,5 @@
+---
+title: Migrate Recover hidden stage dropdown
+merge_request: 43032
+author:
+type: other
diff --git a/changelogs/unreleased/mk-enforce-not-null-file-store-on-package-files.yml b/changelogs/unreleased/mk-enforce-not-null-file-store-on-package-files.yml
new file mode 100644
index 00000000000..34d2c443232
--- /dev/null
+++ b/changelogs/unreleased/mk-enforce-not-null-file-store-on-package-files.yml
@@ -0,0 +1,6 @@
+---
+title: Validate not null file_store field on packages_package_files to maintain data
+ integrity
+merge_request: 42400
+author:
+type: added
diff --git a/changelogs/unreleased/move-referrer-policy-to-header.yml b/changelogs/unreleased/move-referrer-policy-to-header.yml
new file mode 100644
index 00000000000..e80bcee4a23
--- /dev/null
+++ b/changelogs/unreleased/move-referrer-policy-to-header.yml
@@ -0,0 +1,5 @@
+---
+title: Set default Referrer-Policy to strict-origin-when-cross-origin and set it in a header rather than HTML
+merge_request: 26065
+author: nhirokinet
+type: changed
diff --git a/changelogs/unreleased/mr-state-index.yml b/changelogs/unreleased/mr-state-index.yml
new file mode 100644
index 00000000000..80344515863
--- /dev/null
+++ b/changelogs/unreleased/mr-state-index.yml
@@ -0,0 +1,5 @@
+---
+title: Add state_id index for merge_requests list
+merge_request: 42481
+author:
+type: performance
diff --git a/changelogs/unreleased/mw-description-template-replace-fa-icons.yml b/changelogs/unreleased/mw-description-template-replace-fa-icons.yml
new file mode 100644
index 00000000000..99a0f095d15
--- /dev/null
+++ b/changelogs/unreleased/mw-description-template-replace-fa-icons.yml
@@ -0,0 +1,5 @@
+---
+title: "Description Templates: Replace fontawesome icons with GitLab SVGs"
+merge_request: 43379
+author:
+type: changed
diff --git a/changelogs/unreleased/mw-feature-flags-replace-chevron-fa-icon.yml b/changelogs/unreleased/mw-feature-flags-replace-chevron-fa-icon.yml
new file mode 100644
index 00000000000..72ec90332f4
--- /dev/null
+++ b/changelogs/unreleased/mw-feature-flags-replace-chevron-fa-icon.yml
@@ -0,0 +1,5 @@
+---
+title: 'Feature flags form: Replace fa-chevron-down with GitLab SVG'
+merge_request: 42968
+author:
+type: changed
diff --git a/changelogs/unreleased/mw-replace-external-link-icon-group-folder.yml b/changelogs/unreleased/mw-replace-external-link-icon-group-folder.yml
new file mode 100644
index 00000000000..d7cae88883e
--- /dev/null
+++ b/changelogs/unreleased/mw-replace-external-link-icon-group-folder.yml
@@ -0,0 +1,5 @@
+---
+title: Replace fa-external-link with GitLab SVG in group folder
+merge_request: 43128
+author:
+type: changed
diff --git a/changelogs/unreleased/mw-replace-fa-search-icons.yml b/changelogs/unreleased/mw-replace-fa-search-icons.yml
new file mode 100644
index 00000000000..777098ef490
--- /dev/null
+++ b/changelogs/unreleased/mw-replace-fa-search-icons.yml
@@ -0,0 +1,5 @@
+---
+title: Replace fa-search fontawesome icons with GitLab SVG in Vue components
+merge_request: 43879
+author:
+type: changed
diff --git a/changelogs/unreleased/mw-replace-fa-warning-in-limit-warning-component.yml b/changelogs/unreleased/mw-replace-fa-warning-in-limit-warning-component.yml
new file mode 100644
index 00000000000..03c226d7673
--- /dev/null
+++ b/changelogs/unreleased/mw-replace-fa-warning-in-limit-warning-component.yml
@@ -0,0 +1,5 @@
+---
+title: 'VSA: Replace fa-warning with GitLab SVG icon'
+merge_request: 43994
+author:
+type: changed
diff --git a/changelogs/unreleased/mw-vsa-limit-warnings-icon-refactor.yml b/changelogs/unreleased/mw-vsa-limit-warnings-icon-refactor.yml
new file mode 100644
index 00000000000..0d4051dae79
--- /dev/null
+++ b/changelogs/unreleased/mw-vsa-limit-warnings-icon-refactor.yml
@@ -0,0 +1,5 @@
+---
+title: 'VSA: Replace fa-warning with GitLab SVG'
+merge_request: 43262
+author:
+type: changed
diff --git a/changelogs/unreleased/nfriend-add-release-og-description.yml b/changelogs/unreleased/nfriend-add-release-og-description.yml
new file mode 100644
index 00000000000..0a8d6372b6f
--- /dev/null
+++ b/changelogs/unreleased/nfriend-add-release-og-description.yml
@@ -0,0 +1,5 @@
+---
+title: Add og:description meta tag to individual "Release" page
+merge_request: 42889
+author:
+type: added
diff --git a/changelogs/unreleased/nfriend-fix-markdown-preview-on-new-release-page.yml b/changelogs/unreleased/nfriend-fix-markdown-preview-on-new-release-page.yml
new file mode 100644
index 00000000000..b6854e68776
--- /dev/null
+++ b/changelogs/unreleased/nfriend-fix-markdown-preview-on-new-release-page.yml
@@ -0,0 +1,5 @@
+---
+title: Fix Markdown "Preview" tab on New/Edit Release and New Snippet pages
+merge_request: 42640
+author:
+type: fixed
diff --git a/changelogs/unreleased/nfriend-fix-release-asset-link-length.yml b/changelogs/unreleased/nfriend-fix-release-asset-link-length.yml
new file mode 100644
index 00000000000..435929e247c
--- /dev/null
+++ b/changelogs/unreleased/nfriend-fix-release-asset-link-length.yml
@@ -0,0 +1,5 @@
+---
+title: Fix clickable width of release asset links
+merge_request: 42757
+author:
+type: fixed
diff --git a/changelogs/unreleased/nfriend-fix-release-edit-button-size.yml b/changelogs/unreleased/nfriend-fix-release-edit-button-size.yml
new file mode 100644
index 00000000000..9ccb16bd852
--- /dev/null
+++ b/changelogs/unreleased/nfriend-fix-release-edit-button-size.yml
@@ -0,0 +1,5 @@
+---
+title: Fix size of edit button on releases page
+merge_request: 42779
+author:
+type: fixed
diff --git a/changelogs/unreleased/nfriend-strip-markdown-from-og-description.yml b/changelogs/unreleased/nfriend-strip-markdown-from-og-description.yml
new file mode 100644
index 00000000000..75d53bf26ed
--- /dev/null
+++ b/changelogs/unreleased/nfriend-strip-markdown-from-og-description.yml
@@ -0,0 +1,5 @@
+---
+title: Strip markdown from og:description meta tags
+merge_request: 42918
+author:
+type: added
diff --git a/changelogs/unreleased/nfriend-update-releases-page-skeleton-loader-shape.yml b/changelogs/unreleased/nfriend-update-releases-page-skeleton-loader-shape.yml
new file mode 100644
index 00000000000..2e21a11a233
--- /dev/null
+++ b/changelogs/unreleased/nfriend-update-releases-page-skeleton-loader-shape.yml
@@ -0,0 +1,5 @@
+---
+title: Update skeleton loader shape on releases pages
+merge_request: 43138
+author:
+type: added
diff --git a/changelogs/unreleased/notificaion-button-class.yml b/changelogs/unreleased/notificaion-button-class.yml
new file mode 100644
index 00000000000..367edc1d3a8
--- /dev/null
+++ b/changelogs/unreleased/notificaion-button-class.yml
@@ -0,0 +1,5 @@
+---
+title: Fix broken button default class
+merge_request: 43977
+author:
+type: fixed
diff --git a/changelogs/unreleased/ntepluhina-refactor-toolbar-to-gitlab-ui.yml b/changelogs/unreleased/ntepluhina-refactor-toolbar-to-gitlab-ui.yml
new file mode 100644
index 00000000000..7f3fc367d6e
--- /dev/null
+++ b/changelogs/unreleased/ntepluhina-refactor-toolbar-to-gitlab-ui.yml
@@ -0,0 +1,5 @@
+---
+title: Update Design Management toolbar to use GitLab UI classes
+merge_request: 43682
+author:
+type: other
diff --git a/changelogs/unreleased/okr-alert-widget-form.yml b/changelogs/unreleased/okr-alert-widget-form.yml
new file mode 100644
index 00000000000..138c428403a
--- /dev/null
+++ b/changelogs/unreleased/okr-alert-widget-form.yml
@@ -0,0 +1,5 @@
+---
+title: Migrate button in alert_widget_form.vue
+merge_request: 43720
+author:
+type: other
diff --git a/changelogs/unreleased/okr-fluentd-output-settings.yml b/changelogs/unreleased/okr-fluentd-output-settings.yml
new file mode 100644
index 00000000000..b966d1dca42
--- /dev/null
+++ b/changelogs/unreleased/okr-fluentd-output-settings.yml
@@ -0,0 +1,5 @@
+---
+title: Migrate button in fluentd_output_settings.vue
+merge_request: 43724
+author:
+type: other
diff --git a/changelogs/unreleased/okr-ingress-modsecurity-settings.yml b/changelogs/unreleased/okr-ingress-modsecurity-settings.yml
new file mode 100644
index 00000000000..b5b22d25a0a
--- /dev/null
+++ b/changelogs/unreleased/okr-ingress-modsecurity-settings.yml
@@ -0,0 +1,5 @@
+---
+title: Migrate deprecated button to GlButton in ingress_modsecurity_settings.vue
+merge_request: 43717
+author:
+type: other
diff --git a/changelogs/unreleased/okr-modal-copy-button.yml b/changelogs/unreleased/okr-modal-copy-button.yml
new file mode 100644
index 00000000000..390da8d4b73
--- /dev/null
+++ b/changelogs/unreleased/okr-modal-copy-button.yml
@@ -0,0 +1,5 @@
+---
+title: Update button in modal_copy_button.vue to use GlButton from GitLab UI
+merge_request: 43714
+author:
+type: other
diff --git a/changelogs/unreleased/pages-1-26-0.yml b/changelogs/unreleased/pages-1-26-0.yml
new file mode 100644
index 00000000000..704059c7049
--- /dev/null
+++ b/changelogs/unreleased/pages-1-26-0.yml
@@ -0,0 +1,5 @@
+---
+title: Upgrade GitLab Pages to 1.26.0
+merge_request: 43416
+author:
+type: added
diff --git a/changelogs/unreleased/pat-bot-terms.yml b/changelogs/unreleased/pat-bot-terms.yml
new file mode 100644
index 00000000000..dceb285e5e0
--- /dev/null
+++ b/changelogs/unreleased/pat-bot-terms.yml
@@ -0,0 +1,5 @@
+---
+title: Auto-accept TOS if project bot
+merge_request: 43067
+author:
+type: fixed
diff --git a/changelogs/unreleased/ph-199725-diffHeaderActionDropdown.yml b/changelogs/unreleased/ph-199725-diffHeaderActionDropdown.yml
new file mode 100644
index 00000000000..89ed558ea8f
--- /dev/null
+++ b/changelogs/unreleased/ph-199725-diffHeaderActionDropdown.yml
@@ -0,0 +1,5 @@
+---
+title: Move diff header actions into dropdown menu
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/ph-212334-fixTwoButtonReviewSubmit.yml b/changelogs/unreleased/ph-212334-fixTwoButtonReviewSubmit.yml
new file mode 100644
index 00000000000..90c20a6b97b
--- /dev/null
+++ b/changelogs/unreleased/ph-212334-fixTwoButtonReviewSubmit.yml
@@ -0,0 +1,5 @@
+---
+title: Improve two button review submit in merge requests
+merge_request: 43149
+author:
+type: changed
diff --git a/changelogs/unreleased/ph-224141-hideBatchSuggestionsWithOnlyOneSuggestion.yml b/changelogs/unreleased/ph-224141-hideBatchSuggestionsWithOnlyOneSuggestion.yml
new file mode 100644
index 00000000000..bdd30e0e946
--- /dev/null
+++ b/changelogs/unreleased/ph-224141-hideBatchSuggestionsWithOnlyOneSuggestion.yml
@@ -0,0 +1,5 @@
+---
+title: Hides batch suggestions button if there is only 1 suggestion
+merge_request: 42681
+author:
+type: fixed
diff --git a/changelogs/unreleased/ph-227421-changeMROptionsDropdownToIncludeDraft.yml b/changelogs/unreleased/ph-227421-changeMROptionsDropdownToIncludeDraft.yml
new file mode 100644
index 00000000000..9fdd604b19b
--- /dev/null
+++ b/changelogs/unreleased/ph-227421-changeMROptionsDropdownToIncludeDraft.yml
@@ -0,0 +1,5 @@
+---
+title: Adds button to update merge request draft status on merge request show page
+merge_request: 43098
+author:
+type: added
diff --git a/changelogs/unreleased/pl-tracing-core-2-usage-data.yml b/changelogs/unreleased/pl-tracing-core-2-usage-data.yml
new file mode 100644
index 00000000000..0dab5f6bb68
--- /dev/null
+++ b/changelogs/unreleased/pl-tracing-core-2-usage-data.yml
@@ -0,0 +1,5 @@
+---
+title: Move Tracing usage data ping to Core
+merge_request: 44006
+author:
+type: added
diff --git a/changelogs/unreleased/psi-do-not-extend-2.yml b/changelogs/unreleased/psi-do-not-extend-2.yml
new file mode 100644
index 00000000000..45cd1038392
--- /dev/null
+++ b/changelogs/unreleased/psi-do-not-extend-2.yml
@@ -0,0 +1,5 @@
+---
+title: Less inconsistent Edit links in sidebar
+merge_request: 43106
+author:
+type: performance
diff --git a/changelogs/unreleased/put_extra_graphql_logs.yml b/changelogs/unreleased/put_extra_graphql_logs.yml
new file mode 100644
index 00000000000..1ffbbea181f
--- /dev/null
+++ b/changelogs/unreleased/put_extra_graphql_logs.yml
@@ -0,0 +1,5 @@
+---
+title: Include `used_fields` and `used_deprecated_fields` in GraphQL logs
+merge_request: 42820
+author:
+type: added
diff --git a/changelogs/unreleased/rails-save-bang-33.yml b/changelogs/unreleased/rails-save-bang-33.yml
new file mode 100644
index 00000000000..ed001e647ba
--- /dev/null
+++ b/changelogs/unreleased/rails-save-bang-33.yml
@@ -0,0 +1,5 @@
+---
+title: Fix Rails/SaveBang offenses for spec files in spec/services/milestones/*
+merge_request: 42775
+author: Rajendra Kadam
+type: other
diff --git a/changelogs/unreleased/rails-save-bang-34.yml b/changelogs/unreleased/rails-save-bang-34.yml
new file mode 100644
index 00000000000..db0fba9c15e
--- /dev/null
+++ b/changelogs/unreleased/rails-save-bang-34.yml
@@ -0,0 +1,5 @@
+---
+title: Fix Rails/SaveBang offenses for spec files in spec/services/issuable/*
+merge_request: 42780
+author: Rajendra Kadam
+type: other
diff --git a/changelogs/unreleased/rails-save-bang-35.yml b/changelogs/unreleased/rails-save-bang-35.yml
new file mode 100644
index 00000000000..580b0cb1bf8
--- /dev/null
+++ b/changelogs/unreleased/rails-save-bang-35.yml
@@ -0,0 +1,5 @@
+---
+title: Fixes Rails/SaveBang cop for spec files in ee/spec/models/concerns/*
+merge_request: 42839
+author: Rajendra Kadam
+type: other
diff --git a/changelogs/unreleased/rails-save-bang-36.yml b/changelogs/unreleased/rails-save-bang-36.yml
new file mode 100644
index 00000000000..e3262aa413e
--- /dev/null
+++ b/changelogs/unreleased/rails-save-bang-36.yml
@@ -0,0 +1,5 @@
+---
+title: Fixes Rails/SaveBang cop for spec files in spec/models/concerns/*
+merge_request: 42942
+author: Rajendra Kadam
+type: other
diff --git a/changelogs/unreleased/rails-save-bang-37.yml b/changelogs/unreleased/rails-save-bang-37.yml
new file mode 100644
index 00000000000..167162dbac6
--- /dev/null
+++ b/changelogs/unreleased/rails-save-bang-37.yml
@@ -0,0 +1,5 @@
+---
+title: Fixes Rails/SaveBang cop for spec files in spec/lib/gitlab/git/*
+merge_request: 43013
+author: Rajendra Kadam
+type: other
diff --git a/changelogs/unreleased/reminder-emails-invitation-declined-screen.yml b/changelogs/unreleased/reminder-emails-invitation-declined-screen.yml
new file mode 100644
index 00000000000..35d3013f3c1
--- /dev/null
+++ b/changelogs/unreleased/reminder-emails-invitation-declined-screen.yml
@@ -0,0 +1,5 @@
+---
+title: Add invitation declined page
+merge_request: 43305
+author:
+type: changed
diff --git a/changelogs/unreleased/remove-add_severity_system_note-feature-flag.yml b/changelogs/unreleased/remove-add_severity_system_note-feature-flag.yml
new file mode 100644
index 00000000000..117d7511d8b
--- /dev/null
+++ b/changelogs/unreleased/remove-add_severity_system_note-feature-flag.yml
@@ -0,0 +1,5 @@
+---
+title: Add system note on incident severity change
+merge_request: 43998
+author:
+type: added
diff --git a/changelogs/unreleased/remove-tmp-index.yml b/changelogs/unreleased/remove-tmp-index.yml
new file mode 100644
index 00000000000..8c2820cc7cd
--- /dev/null
+++ b/changelogs/unreleased/remove-tmp-index.yml
@@ -0,0 +1,5 @@
+---
+title: Remove temporary index for fixing broken CS fingerprints
+merge_request: 43126
+author:
+type: other
diff --git a/changelogs/unreleased/revert-42465-and-42343.yml b/changelogs/unreleased/revert-42465-and-42343.yml
new file mode 100644
index 00000000000..4c7342c9d0d
--- /dev/null
+++ b/changelogs/unreleased/revert-42465-and-42343.yml
@@ -0,0 +1,5 @@
+---
+title: 'Revert 42465 and 42343: Expanded collapsed diff files'
+merge_request: 43361
+author:
+type: other
diff --git a/changelogs/unreleased/revert-a6875e5f.yml b/changelogs/unreleased/revert-a6875e5f.yml
new file mode 100644
index 00000000000..ea8135346f2
--- /dev/null
+++ b/changelogs/unreleased/revert-a6875e5f.yml
@@ -0,0 +1,5 @@
+---
+title: Revert required encryption on CI runner tokens
+merge_request: 42623
+author:
+type: fixed
diff --git a/changelogs/unreleased/revise-tooltip-text-of-note-role-badge.yml b/changelogs/unreleased/revise-tooltip-text-of-note-role-badge.yml
new file mode 100644
index 00000000000..4362c2fcc9e
--- /dev/null
+++ b/changelogs/unreleased/revise-tooltip-text-of-note-role-badge.yml
@@ -0,0 +1,5 @@
+---
+title: Revise tooltip text of note role badge
+merge_request: 42771
+author: Mycroft Kang @TaehyeokKang
+type: Bug fix
diff --git a/changelogs/unreleased/sarnold-deprecate-lowercase-enums.yml b/changelogs/unreleased/sarnold-deprecate-lowercase-enums.yml
new file mode 100644
index 00000000000..aee4f1137e3
--- /dev/null
+++ b/changelogs/unreleased/sarnold-deprecate-lowercase-enums.yml
@@ -0,0 +1,5 @@
+---
+title: Deprecate lowercase values for sort enums in GraphQL
+merge_request: 43650
+author:
+type: changed
diff --git a/changelogs/unreleased/scoped-label-dashboard.yml b/changelogs/unreleased/scoped-label-dashboard.yml
new file mode 100644
index 00000000000..b564fd4cd8e
--- /dev/null
+++ b/changelogs/unreleased/scoped-label-dashboard.yml
@@ -0,0 +1,5 @@
+---
+title: Fix profile scoped label CSS
+merge_request: 43005
+author:
+type: changed
diff --git a/changelogs/unreleased/security-update-runner-version.yml b/changelogs/unreleased/security-update-runner-version.yml
new file mode 100644
index 00000000000..46dd2163a14
--- /dev/null
+++ b/changelogs/unreleased/security-update-runner-version.yml
@@ -0,0 +1,5 @@
+---
+title: Update GitLab Runner Helm Chart to 0.21.1
+merge_request:
+author:
+type: security
diff --git a/changelogs/unreleased/set-performance-bar-cookie-expiry.yml b/changelogs/unreleased/set-performance-bar-cookie-expiry.yml
new file mode 100644
index 00000000000..bfeccc075d2
--- /dev/null
+++ b/changelogs/unreleased/set-performance-bar-cookie-expiry.yml
@@ -0,0 +1,5 @@
+---
+title: Set performance cookie to last for a year
+merge_request: 43692
+author:
+type: changed
diff --git a/changelogs/unreleased/sh-fix-pipeline-notification-email-warning.yml b/changelogs/unreleased/sh-fix-pipeline-notification-email-warning.yml
new file mode 100644
index 00000000000..a9f5ddcef33
--- /dev/null
+++ b/changelogs/unreleased/sh-fix-pipeline-notification-email-warning.yml
@@ -0,0 +1,5 @@
+---
+title: Update pipeline failed notification e-mail warning
+merge_request: 42736
+author:
+type: fixed
diff --git a/changelogs/unreleased/sh-lfs-smudge-api.yml b/changelogs/unreleased/sh-lfs-smudge-api.yml
new file mode 100644
index 00000000000..cab857aabbc
--- /dev/null
+++ b/changelogs/unreleased/sh-lfs-smudge-api.yml
@@ -0,0 +1,5 @@
+---
+title: Add internal API to download LFS objects
+merge_request: 42161
+author:
+type: added
diff --git a/changelogs/unreleased/spec_job_token.yml b/changelogs/unreleased/spec_job_token.yml
new file mode 100644
index 00000000000..bf7f32eaab5
--- /dev/null
+++ b/changelogs/unreleased/spec_job_token.yml
@@ -0,0 +1,5 @@
+---
+title: Move job token specs to core
+merge_request: 42374
+author: Mathieu Parent
+type: changed
diff --git a/changelogs/unreleased/sy-alert-integrations-table.yml b/changelogs/unreleased/sy-alert-integrations-table.yml
new file mode 100644
index 00000000000..70fe1b19159
--- /dev/null
+++ b/changelogs/unreleased/sy-alert-integrations-table.yml
@@ -0,0 +1,5 @@
+---
+title: Add table for alert http integrations for project
+merge_request: 43634
+author:
+type: added
diff --git a/changelogs/unreleased/sy-allow-guests-to-view-incidents.yml b/changelogs/unreleased/sy-allow-guests-to-view-incidents.yml
new file mode 100644
index 00000000000..5b7fdc80389
--- /dev/null
+++ b/changelogs/unreleased/sy-allow-guests-to-view-incidents.yml
@@ -0,0 +1,5 @@
+---
+title: Show incident list for users who can read issues
+merge_request: 43060
+author:
+type: fixed
diff --git a/changelogs/unreleased/sy-improve-generic-alert-system-note.yml b/changelogs/unreleased/sy-improve-generic-alert-system-note.yml
new file mode 100644
index 00000000000..e12f408753f
--- /dev/null
+++ b/changelogs/unreleased/sy-improve-generic-alert-system-note.yml
@@ -0,0 +1,5 @@
+---
+title: Include monitoring tool from payload in system note for alert creation
+merge_request: 42631
+author:
+type: changed
diff --git a/changelogs/unreleased/tr-filter-internal-strings.yml b/changelogs/unreleased/tr-filter-internal-strings.yml
new file mode 100644
index 00000000000..e761c5d54cd
--- /dev/null
+++ b/changelogs/unreleased/tr-filter-internal-strings.yml
@@ -0,0 +1,5 @@
+---
+title: Remove internal fields from alert details table
+merge_request: 43076
+author:
+type: changed
diff --git a/changelogs/unreleased/unleash-api-with-private-repo.yml b/changelogs/unreleased/unleash-api-with-private-repo.yml
new file mode 100644
index 00000000000..3dcdfcdd899
--- /dev/null
+++ b/changelogs/unreleased/unleash-api-with-private-repo.yml
@@ -0,0 +1,5 @@
+---
+title: Allow Unleash clients to request feature flags when repository is private
+merge_request: 43059
+author:
+type: fixed
diff --git a/changelogs/unreleased/update-clipboard-button.yml b/changelogs/unreleased/update-clipboard-button.yml
new file mode 100644
index 00000000000..03eff85ab32
--- /dev/null
+++ b/changelogs/unreleased/update-clipboard-button.yml
@@ -0,0 +1,5 @@
+---
+title: Update clipboard button to use Pajamas
+merge_request: 38421
+author:
+type: changed
diff --git a/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-21-0.yml b/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-21-0.yml
new file mode 100644
index 00000000000..0b4b92717e7
--- /dev/null
+++ b/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-21-0.yml
@@ -0,0 +1,5 @@
+---
+title: Update GitLab Runner Helm Chart to 0.21.0
+merge_request: 42844
+author:
+type: other
diff --git a/changelogs/unreleased/zj-update-lang-colors.yml b/changelogs/unreleased/zj-update-lang-colors.yml
new file mode 100644
index 00000000000..72b2bc01b08
--- /dev/null
+++ b/changelogs/unreleased/zj-update-lang-colors.yml
@@ -0,0 +1,5 @@
+---
+title: Update programming language colors and metadata
+merge_request: 43111
+author:
+type: changed
diff --git a/config/application.rb b/config/application.rb
index 4d2f3745b52..c39061a9824 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -152,12 +152,18 @@ module Gitlab
config.active_record.schema_format = :sql
# Configure webpack
+ config.webpack = ActiveSupport::OrderedOptions.new
config.webpack.config_file = "config/webpack.config.js"
config.webpack.output_dir = "public/assets/webpack"
config.webpack.public_path = "assets/webpack"
+ config.webpack.manifest_filename = "manifest.json"
# Webpack dev server configuration is handled in initializers/static_files.rb
+ config.webpack.dev_server = ActiveSupport::OrderedOptions.new
config.webpack.dev_server.enabled = false
+ config.webpack.dev_server.host = 'localhost'
+ config.webpack.dev_server.port = 3808
+ config.webpack.dev_server.https = false
config.action_mailer.delivery_job = "ActionMailer::MailDeliveryJob"
@@ -168,6 +174,8 @@ module Gitlab
config.assets.paths << Gemojione.images_path
config.assets.paths << "#{config.root}/vendor/assets/fonts"
+ config.assets.precompile << "application_utilities.css"
+ config.assets.precompile << "application_utilities_dark.css"
config.assets.precompile << "application_dark.css"
config.assets.precompile << "startup/*.css"
@@ -178,8 +186,12 @@ module Gitlab
config.assets.precompile << "notify.css"
config.assets.precompile << "mailers/*.css"
config.assets.precompile << "page_bundles/_mixins_and_variables_and_functions.css"
+ config.assets.precompile << "page_bundles/boards.css"
+ config.assets.precompile << "page_bundles/cycle_analytics.css"
config.assets.precompile << "page_bundles/ide.css"
+ config.assets.precompile << "page_bundles/issues.css"
config.assets.precompile << "page_bundles/jira_connect.css"
+ config.assets.precompile << "page_bundles/milestone.css"
config.assets.precompile << "page_bundles/todos.css"
config.assets.precompile << "page_bundles/xterm.css"
config.assets.precompile << "performance_bar.css"
@@ -210,13 +222,6 @@ module Gitlab
config.assets.paths << "#{config.root}/node_modules/xterm/src/"
config.assets.precompile << "xterm.css"
- # Add EE assets
- if Gitlab.ee?
- %w[images javascripts stylesheets].each do |path|
- config.assets.paths << "#{config.root}/ee/app/assets/#{path}"
- end
- end
-
# Import path for EE specific SCSS entry point
# In CE it will import a noop file, in EE a functioning file
# Order is important, so that the ee file takes precedence:
@@ -230,16 +235,6 @@ module Gitlab
# See https://gitlab.com/gitlab-org/gitlab-foss/issues/64091#note_194512508
config.assets.paths << "#{config.root}/node_modules"
- if Gitlab.ee?
- # Compile non-JS/CSS assets in the ee/app/assets folder by default
- # Mimic sprockets-rails default: https://github.com/rails/sprockets-rails/blob/v3.2.1/lib/sprockets/railtie.rb#L84-L87
- LOOSE_EE_APP_ASSETS = lambda do |logical_path, filename|
- filename.start_with?(config.root.join("ee/app/assets").to_s) &&
- !['.js', '.css', ''].include?(File.extname(logical_path))
- end
- config.assets.precompile << LOOSE_EE_APP_ASSETS
- end
-
# Version of your assets, change this if you want to expire all your assets
config.assets.version = '1.0'
@@ -304,6 +299,34 @@ module Gitlab
g.factory_bot false
end
+ # sprocket-rails adds some precompile assets we actually do not need.
+ #
+ # It copies all _non_ js and CSS files from the app/assets/ older.
+ #
+ # In our case this copies for example: Vue, Markdown and Graphql, which we do not need
+ # for production.
+ #
+ # We remove this default behavior and then reimplement it in order to consider ee/ as well
+ # and remove those other files we do not need.
+ #
+ # For reference: https://github.com/rails/sprockets-rails/blob/v3.2.1/lib/sprockets/railtie.rb#L84-L87
+ initializer :correct_precompile_targets, after: :set_default_precompile do |app|
+ app.config.assets.precompile.reject! { |entry| entry == Sprockets::Railtie::LOOSE_APP_ASSETS }
+
+ asset_roots = [config.root.join("app/assets").to_s]
+
+ if Gitlab.ee?
+ asset_roots << config.root.join("ee/app/assets").to_s
+ end
+
+ LOOSE_APP_ASSETS = lambda do |logical_path, filename|
+ filename.start_with?(*asset_roots) &&
+ !['.js', '.css', '.md', '.vue', '.graphql', ''].include?(File.extname(logical_path))
+ end
+
+ app.config.assets.precompile << LOOSE_APP_ASSETS
+ end
+
# This empty initializer forces the :let_zeitwerk_take_over initializer to run before we load
# initializers in config/initializers. This is done because autoloading before Zeitwerk takes
# over is deprecated but our initializers do a lot of autoloading.
@@ -318,6 +341,20 @@ module Gitlab
end
end
+ # Add EE assets. They should take precedence over CE. This means if two files exist, e.g.:
+ #
+ # ee/app/assets/stylesheets/example.scss
+ # app/assets/stylesheets/example.scss
+ #
+ # The ee/ version will be preferred.
+ initializer :prefer_ee_assets, after: :append_assets_path do |app|
+ if Gitlab.ee?
+ %w[images javascripts stylesheets].each do |path|
+ app.config.assets.paths.unshift("#{config.root}/ee/app/assets/#{path}")
+ end
+ end
+ end
+
config.after_initialize do
# Devise (see initializers/8_devise.rb) already reloads routes if
# eager loading is enabled, so don't do this twice since it's
diff --git a/config/database.yml.postgresql b/config/database.yml.postgresql
index 37c69ad326b..ca1ff4db1b4 100644
--- a/config/database.yml.postgresql
+++ b/config/database.yml.postgresql
@@ -28,6 +28,8 @@ development:
username: postgres
password: "secure password"
host: localhost
+ variables:
+ statement_timeout: 15s
#
# Staging specific
@@ -51,3 +53,5 @@ test: &test
password:
host: localhost
prepared_statements: false
+ variables:
+ statement_timeout: 15s
diff --git a/config/feature_categories.yml b/config/feature_categories.yml
index 7b85f910d85..97ba7586598 100644
--- a/config/feature_categories.yml
+++ b/config/feature_categories.yml
@@ -19,7 +19,7 @@
- auto_devops
- backup_restore
- behavior_analytics
-- billing
+- boards
- chatops
- cloud_native_installation
- cluster_cost_optimization
@@ -62,15 +62,15 @@
- importers
- incident_management
- infrastructure_as_code
+- insights
+- instance_statistics
- integrations
- interactive_application_security_testing
- internationalization
-- instance_statistics
- issue_tracking
- jenkins_importer
- jira_importer
- jupyter_notebooks
-- kanban_boards
- kubernetes_management
- license_compliance
- live_preview
@@ -83,10 +83,13 @@
- omnibus_package
- package_registry
- pages
+- pipeline_authoring
- pki_management
- planning_analytics
- product_analytics
- projects
+- provision
+- purchase
- quality_management
- release_evidence
- release_orchestration
diff --git a/config/feature_flags/development/additional_snowplow_tracking.yml b/config/feature_flags/development/additional_snowplow_tracking.yml
index f4f73a0bbd0..8cff8389dbb 100644
--- a/config/feature_flags/development/additional_snowplow_tracking.yml
+++ b/config/feature_flags/development/additional_snowplow_tracking.yml
@@ -1,7 +1,6 @@
----
name: additional_snowplow_tracking
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/12088
+rollout_issue_url:
+group: group::product_analytics
type: development
default_enabled: false
diff --git a/config/feature_flags/development/admin_approval_for_new_user_signups.yml b/config/feature_flags/development/admin_approval_for_new_user_signups.yml
new file mode 100644
index 00000000000..bbb21fcbffe
--- /dev/null
+++ b/config/feature_flags/development/admin_approval_for_new_user_signups.yml
@@ -0,0 +1,7 @@
+---
+name: admin_approval_for_new_user_signups
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43827
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/258980
+type: development
+group: group::access
+default_enabled: false
diff --git a/config/feature_flags/development/ajax_new_deploy_token.yml b/config/feature_flags/development/ajax_new_deploy_token.yml
index cffd589b32b..336e3004d52 100644
--- a/config/feature_flags/development/ajax_new_deploy_token.yml
+++ b/config/feature_flags/development/ajax_new_deploy_token.yml
@@ -1,7 +1,7 @@
---
name: ajax_new_deploy_token
-introduced_by_url:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27141
rollout_issue_url:
-group:
+group: group::progressive delivery
type: development
default_enabled: false
diff --git a/config/feature_flags/development/allow_group_deploy_token.yml b/config/feature_flags/development/allow_group_deploy_token.yml
index f08614b45e9..06a8659292d 100644
--- a/config/feature_flags/development/allow_group_deploy_token.yml
+++ b/config/feature_flags/development/allow_group_deploy_token.yml
@@ -1,7 +1,7 @@
---
name: allow_group_deploy_token
-introduced_by_url:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23460
rollout_issue_url:
-group:
+group: group::progressive delivery
type: development
default_enabled: true
diff --git a/config/feature_flags/development/allow_possible_spam.yml b/config/feature_flags/development/allow_possible_spam.yml
index 658e775af91..2e121519628 100644
--- a/config/feature_flags/development/allow_possible_spam.yml
+++ b/config/feature_flags/development/allow_possible_spam.yml
@@ -1,7 +1,7 @@
---
name: allow_possible_spam
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/17604
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/29830
+group: group::portfolio management
type: development
default_enabled: false
diff --git a/config/feature_flags/development/allow_unsafe_ruby_regexp.yml b/config/feature_flags/development/allow_unsafe_ruby_regexp.yml
index 4d3b13deda2..272e9f5ffa2 100644
--- a/config/feature_flags/development/allow_unsafe_ruby_regexp.yml
+++ b/config/feature_flags/development/allow_unsafe_ruby_regexp.yml
@@ -1,7 +1,7 @@
---
name: allow_unsafe_ruby_regexp
introduced_by_url:
-rollout_issue_url:
-group:
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/257849
type: development
+group: group::continuous integration
default_enabled: false
diff --git a/config/feature_flags/development/api_commits_without_count.yml b/config/feature_flags/development/api_commits_without_count.yml
new file mode 100644
index 00000000000..bd5ff8d5eb3
--- /dev/null
+++ b/config/feature_flags/development/api_commits_without_count.yml
@@ -0,0 +1,7 @@
+---
+name: api_commits_without_count
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43159
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/254994
+type: development
+group: team::Scalability
+default_enabled: false
diff --git a/config/feature_flags/development/api_kaminari_count_with_limit.yml b/config/feature_flags/development/api_kaminari_count_with_limit.yml
deleted file mode 100644
index 0224d606df1..00000000000
--- a/config/feature_flags/development/api_kaminari_count_with_limit.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: api_kaminari_count_with_limit
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: false
diff --git a/config/feature_flags/development/approval_rule.yml b/config/feature_flags/development/approval_rule.yml
new file mode 100644
index 00000000000..6b38fbc0db8
--- /dev/null
+++ b/config/feature_flags/development/approval_rule.yml
@@ -0,0 +1,7 @@
+---
+name: approval_rule
+introduced_by_url:
+rollout_issue_url:
+group:
+type: development
+default_enabled: true
diff --git a/config/feature_flags/development/artifacts_management_page.yml b/config/feature_flags/development/artifacts_management_page.yml
index 94d99974859..78659422a39 100644
--- a/config/feature_flags/development/artifacts_management_page.yml
+++ b/config/feature_flags/development/artifacts_management_page.yml
@@ -1,7 +1,7 @@
---
name: artifacts_management_page
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/16654
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/254938
+group: group::continuous integration
type: development
default_enabled: false
diff --git a/config/feature_flags/development/auto_create_cluster_management_project.yml b/config/feature_flags/development/auto_create_cluster_management_project.yml
index ca87e401d4a..d3b88f056ec 100644
--- a/config/feature_flags/development/auto_create_cluster_management_project.yml
+++ b/config/feature_flags/development/auto_create_cluster_management_project.yml
@@ -1,7 +1,7 @@
---
name: auto_create_cluster_management_project
-introduced_by_url:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23946
rollout_issue_url:
-group:
+group: group::configure
type: development
default_enabled: false
diff --git a/config/feature_flags/development/backfill_partitioned_audit_events.yml b/config/feature_flags/development/backfill_partitioned_audit_events.yml
deleted file mode 100644
index ae986c941cb..00000000000
--- a/config/feature_flags/development/backfill_partitioned_audit_events.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: backfill_partitioned_audit_events
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/boards_with_swimlanes.yml b/config/feature_flags/development/boards_with_swimlanes.yml
index e2db136b45c..19e5ab24206 100644
--- a/config/feature_flags/development/boards_with_swimlanes.yml
+++ b/config/feature_flags/development/boards_with_swimlanes.yml
@@ -1,7 +1,7 @@
---
name: boards_with_swimlanes
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::project management
type: development
default_enabled: false
diff --git a/config/feature_flags/development/broadcast_issue_updates.yml b/config/feature_flags/development/broadcast_issue_updates.yml
index 2db27dfb2ed..435678f869d 100644
--- a/config/feature_flags/development/broadcast_issue_updates.yml
+++ b/config/feature_flags/development/broadcast_issue_updates.yml
@@ -1,7 +1,7 @@
---
name: broadcast_issue_updates
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/30732
+rollout_issue_url: https://gitlab.com/gitlab-com/gl-infra/delivery/-/issues/1210
+group: group::project management
type: development
default_enabled: false
diff --git a/config/feature_flags/development/build_service_proxy.yml b/config/feature_flags/development/build_service_proxy.yml
index 165e3c15f81..0c0aaa8aca9 100644
--- a/config/feature_flags/development/build_service_proxy.yml
+++ b/config/feature_flags/development/build_service_proxy.yml
@@ -1,7 +1,7 @@
---
name: build_service_proxy
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/9723
+rollout_issue_url:
+group: group::editor
type: development
default_enabled: false
diff --git a/config/feature_flags/development/bulk_import.yml b/config/feature_flags/development/bulk_import.yml
new file mode 100644
index 00000000000..0a5a1e5654e
--- /dev/null
+++ b/config/feature_flags/development/bulk_import.yml
@@ -0,0 +1,7 @@
+---
+name: bulk_import
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42704
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/255310
+group: group::import
+type: development
+default_enabled: false
diff --git a/config/feature_flags/development/bulk_update_health_status.yml b/config/feature_flags/development/bulk_update_health_status.yml
index 5553f2e077f..fa06adfd60a 100644
--- a/config/feature_flags/development/bulk_update_health_status.yml
+++ b/config/feature_flags/development/bulk_update_health_status.yml
@@ -1,7 +1,7 @@
---
name: bulk_update_health_status
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::portfolio management
type: development
default_enabled: true
diff --git a/config/feature_flags/development/burnup_charts.yml b/config/feature_flags/development/burnup_charts.yml
index 9484af49530..5ead8362d90 100644
--- a/config/feature_flags/development/burnup_charts.yml
+++ b/config/feature_flags/development/burnup_charts.yml
@@ -1,7 +1,7 @@
---
name: burnup_charts
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::project management
type: development
default_enabled: false
diff --git a/config/feature_flags/development/ci_bulk_insert_on_create.yml b/config/feature_flags/development/ci_bulk_insert_on_create.yml
deleted file mode 100644
index 3227497ecfc..00000000000
--- a/config/feature_flags/development/ci_bulk_insert_on_create.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: ci_bulk_insert_on_create
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/ci_daily_code_coverage.yml b/config/feature_flags/development/ci_daily_code_coverage.yml
deleted file mode 100644
index c9add1bf460..00000000000
--- a/config/feature_flags/development/ci_daily_code_coverage.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: ci_daily_code_coverage
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/ci_disable_validates_dependencies.yml b/config/feature_flags/development/ci_disable_validates_dependencies.yml
index 65358a04340..ccd529d0a09 100644
--- a/config/feature_flags/development/ci_disable_validates_dependencies.yml
+++ b/config/feature_flags/development/ci_disable_validates_dependencies.yml
@@ -1,7 +1,7 @@
---
name: ci_disable_validates_dependencies
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/14009
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/257847
+group: group::continuous integration
type: development
default_enabled: false
diff --git a/config/feature_flags/development/ci_download_daily_code_coverage.yml b/config/feature_flags/development/ci_download_daily_code_coverage.yml
deleted file mode 100644
index d209f2bf9ad..00000000000
--- a/config/feature_flags/development/ci_download_daily_code_coverage.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: ci_download_daily_code_coverage
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/ci_job_heartbeats_runner.yml b/config/feature_flags/development/ci_job_heartbeats_runner.yml
deleted file mode 100644
index dcccd1512ed..00000000000
--- a/config/feature_flags/development/ci_job_heartbeats_runner.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: ci_job_heartbeats_runner
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/ci_job_jwt.yml b/config/feature_flags/development/ci_job_jwt.yml
index 3d18472add4..b0ad0790ebc 100644
--- a/config/feature_flags/development/ci_job_jwt.yml
+++ b/config/feature_flags/development/ci_job_jwt.yml
@@ -1,7 +1,7 @@
---
name: ci_job_jwt
-introduced_by_url:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/207125
rollout_issue_url:
-group:
+group: group::release management
type: development
default_enabled: true
diff --git a/config/feature_flags/development/ci_key_autocomplete.yml b/config/feature_flags/development/ci_key_autocomplete.yml
deleted file mode 100644
index 1c6bfa90a9d..00000000000
--- a/config/feature_flags/development/ci_key_autocomplete.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: ci_key_autocomplete
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/ci_new_artifact_file_reader.yml b/config/feature_flags/development/ci_new_artifact_file_reader.yml
index a6e9c67bd7e..9c0ad7a2d9c 100644
--- a/config/feature_flags/development/ci_new_artifact_file_reader.yml
+++ b/config/feature_flags/development/ci_new_artifact_file_reader.yml
@@ -4,4 +4,4 @@ introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40268
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/249588
group: group::pipeline authoring
type: development
-default_enabled: false
+default_enabled: true
diff --git a/config/feature_flags/development/ci_pipeline_rewind_iid.yml b/config/feature_flags/development/ci_pipeline_rewind_iid.yml
deleted file mode 100644
index 8b6bb378a0a..00000000000
--- a/config/feature_flags/development/ci_pipeline_rewind_iid.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: ci_pipeline_rewind_iid
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/ci_pipeline_status_omit_commit_sha_in_cache_key.yml b/config/feature_flags/development/ci_pipeline_status_omit_commit_sha_in_cache_key.yml
index eda5ab00ef1..911eab84b28 100644
--- a/config/feature_flags/development/ci_pipeline_status_omit_commit_sha_in_cache_key.yml
+++ b/config/feature_flags/development/ci_pipeline_status_omit_commit_sha_in_cache_key.yml
@@ -1,7 +1,7 @@
---
name: ci_pipeline_status_omit_commit_sha_in_cache_key
-introduced_by_url:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33712
rollout_issue_url:
-group:
+group: group::continuous integration
type: development
default_enabled: true
diff --git a/config/feature_flags/development/ci_pipeline_triggers_settings_vue_ui.yml b/config/feature_flags/development/ci_pipeline_triggers_settings_vue_ui.yml
new file mode 100644
index 00000000000..0a5c214308a
--- /dev/null
+++ b/config/feature_flags/development/ci_pipeline_triggers_settings_vue_ui.yml
@@ -0,0 +1,7 @@
+---
+name: ci_pipeline_triggers_settings_vue_ui
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41864
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/247486
+group: group::continuous integration
+type: development
+default_enabled: false
diff --git a/config/feature_flags/development/ci_skip_persistent_ref_existence_check.yml b/config/feature_flags/development/ci_skip_persistent_ref_existence_check.yml
index a9a79f80512..60c626295ab 100644
--- a/config/feature_flags/development/ci_skip_persistent_ref_existence_check.yml
+++ b/config/feature_flags/development/ci_skip_persistent_ref_existence_check.yml
@@ -1,7 +1,7 @@
---
name: ci_skip_persistent_ref_existence_check
-introduced_by_url:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/32391
rollout_issue_url:
-group:
+group: group::continuous integration
type: development
default_enabled: false
diff --git a/config/feature_flags/development/ci_synchronous_artifact_parsing.yml b/config/feature_flags/development/ci_synchronous_artifact_parsing.yml
index c5d1a44b61f..795ac08c7e9 100644
--- a/config/feature_flags/development/ci_synchronous_artifact_parsing.yml
+++ b/config/feature_flags/development/ci_synchronous_artifact_parsing.yml
@@ -1,7 +1,7 @@
---
name: ci_synchronous_artifact_parsing
-introduced_by_url:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26247
rollout_issue_url:
-group:
+group: group::progressive delivery
type: development
default_enabled: true
diff --git a/config/feature_flags/development/ci_update_queues_for_online_runners.yml b/config/feature_flags/development/ci_update_queues_for_online_runners.yml
deleted file mode 100644
index f2bce6e14b5..00000000000
--- a/config/feature_flags/development/ci_update_queues_for_online_runners.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: ci_update_queues_for_online_runners
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/cleanup_lfs_during_gc.yml b/config/feature_flags/development/cleanup_lfs_during_gc.yml
deleted file mode 100644
index 836784b1d9c..00000000000
--- a/config/feature_flags/development/cleanup_lfs_during_gc.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: cleanup_lfs_during_gc
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: false
diff --git a/config/feature_flags/development/cluster_agent_list.yml b/config/feature_flags/development/cluster_agent_list.yml
new file mode 100644
index 00000000000..a4468b662ae
--- /dev/null
+++ b/config/feature_flags/development/cluster_agent_list.yml
@@ -0,0 +1,7 @@
+---
+name: cluster_agent_list
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/228845
+rollout_issue_url: https://gitlab.com/groups/gitlab-org/-/epics/3834
+group: group::configure
+type: development
+default_enabled: true
diff --git a/config/feature_flags/development/cluster_management_project.yml b/config/feature_flags/development/cluster_management_project.yml
index 9d58efd5194..922b7e6cad3 100644
--- a/config/feature_flags/development/cluster_management_project.yml
+++ b/config/feature_flags/development/cluster_management_project.yml
@@ -1,7 +1,7 @@
---
name: cluster_management_project
-introduced_by_url:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/17866
rollout_issue_url:
-group:
+group: group::configure
type: development
default_enabled: true
diff --git a/config/feature_flags/development/clusters_list_redesign.yml b/config/feature_flags/development/clusters_list_redesign.yml
deleted file mode 100644
index 1a9ad73eb79..00000000000
--- a/config/feature_flags/development/clusters_list_redesign.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: clusters_list_redesign
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: false
diff --git a/config/feature_flags/development/container_registry_fast_tag_delete.yml b/config/feature_flags/development/container_registry_fast_tag_delete.yml
deleted file mode 100644
index dddac070355..00000000000
--- a/config/feature_flags/development/container_registry_fast_tag_delete.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: container_registry_fast_tag_delete
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/create_cloud_run_clusters.yml b/config/feature_flags/development/create_cloud_run_clusters.yml
index 30894fd2df1..6c479a3dd6c 100644
--- a/config/feature_flags/development/create_cloud_run_clusters.yml
+++ b/config/feature_flags/development/create_cloud_run_clusters.yml
@@ -1,7 +1,7 @@
---
name: create_cloud_run_clusters
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/19063
+rollout_issue_url:
+group: group::configure
type: development
default_enabled: true
diff --git a/config/feature_flags/development/dag_pipeline_tab.yml b/config/feature_flags/development/dag_pipeline_tab.yml
new file mode 100644
index 00000000000..f4261b51e05
--- /dev/null
+++ b/config/feature_flags/development/dag_pipeline_tab.yml
@@ -0,0 +1,7 @@
+---
+name: dag_pipeline_tab
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/30310
+rollout_issue_url:
+group: group::pipeline authoring
+type: development
+default_enabled: true
diff --git a/config/feature_flags/development/dashboard_pipeline_status.yml b/config/feature_flags/development/dashboard_pipeline_status.yml
index 241f5fea64f..8034177ee1e 100644
--- a/config/feature_flags/development/dashboard_pipeline_status.yml
+++ b/config/feature_flags/development/dashboard_pipeline_status.yml
@@ -1,7 +1,7 @@
---
name: dashboard_pipeline_status
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22029
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/209061
+group: group::continuous integration
type: development
default_enabled: true
diff --git a/config/feature_flags/development/debian_packages.yml b/config/feature_flags/development/debian_packages.yml
new file mode 100644
index 00000000000..8f706e81b6a
--- /dev/null
+++ b/config/feature_flags/development/debian_packages.yml
@@ -0,0 +1,7 @@
+---
+name: debian_packages
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42670
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/5835
+group: group::package
+type: development
+default_enabled: false
diff --git a/config/feature_flags/development/default_merge_ref_for_diffs.yml b/config/feature_flags/development/default_merge_ref_for_diffs.yml
new file mode 100644
index 00000000000..b982185c1fd
--- /dev/null
+++ b/config/feature_flags/development/default_merge_ref_for_diffs.yml
@@ -0,0 +1,7 @@
+---
+name: default_merge_ref_for_diffs
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/34472
+rollout_issue_url:
+type: development
+group: group::source code
+default_enabled: false
diff --git a/config/feature_flags/development/deploy_from_footer.yml b/config/feature_flags/development/deploy_from_footer.yml
deleted file mode 100644
index 8e31ab511b6..00000000000
--- a/config/feature_flags/development/deploy_from_footer.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: deploy_from_footer
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/deploy_keys_on_protected_branches.yml b/config/feature_flags/development/deploy_keys_on_protected_branches.yml
new file mode 100644
index 00000000000..ec5c13082ec
--- /dev/null
+++ b/config/feature_flags/development/deploy_keys_on_protected_branches.yml
@@ -0,0 +1,7 @@
+---
+name: deploy_keys_on_protected_branches
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35638
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/247866
+group: group::progressive delivery
+type: development
+default_enabled: false
diff --git a/config/feature_flags/development/deploy_tokens_api.yml b/config/feature_flags/development/deploy_tokens_api.yml
index d8691842822..27c583e760b 100644
--- a/config/feature_flags/development/deploy_tokens_api.yml
+++ b/config/feature_flags/development/deploy_tokens_api.yml
@@ -1,7 +1,7 @@
---
name: deploy_tokens_api
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/25219
+rollout_issue_url:
+group: group::package
type: development
default_enabled: true
diff --git a/config/feature_flags/development/design_management_allow_dangerous_images.yml b/config/feature_flags/development/design_management_allow_dangerous_images.yml
index d1d09f154a4..73c348a69ca 100644
--- a/config/feature_flags/development/design_management_allow_dangerous_images.yml
+++ b/config/feature_flags/development/design_management_allow_dangerous_images.yml
@@ -1,7 +1,7 @@
---
name: design_management_allow_dangerous_images
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/16160
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/34279
+group: group::knowledge
type: development
default_enabled: false
diff --git a/config/feature_flags/development/design_management_copy_designs.yml b/config/feature_flags/development/design_management_copy_designs.yml
new file mode 100644
index 00000000000..c61fb3a645a
--- /dev/null
+++ b/config/feature_flags/development/design_management_copy_designs.yml
@@ -0,0 +1,7 @@
+---
+name: design_management_copy_designs
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41714
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/247062
+group: group::knowledge
+type: development
+default_enabled: false \ No newline at end of file
diff --git a/config/feature_flags/development/design_management_reference_filter_gfm_pipeline.yml b/config/feature_flags/development/design_management_reference_filter_gfm_pipeline.yml
deleted file mode 100644
index e07b621ed23..00000000000
--- a/config/feature_flags/development/design_management_reference_filter_gfm_pipeline.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: design_management_reference_filter_gfm_pipeline
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/disable_metric_dashboard_refresh_rate.yml b/config/feature_flags/development/disable_metric_dashboard_refresh_rate.yml
index 193579cd11b..d0e549a465d 100644
--- a/config/feature_flags/development/disable_metric_dashboard_refresh_rate.yml
+++ b/config/feature_flags/development/disable_metric_dashboard_refresh_rate.yml
@@ -1,7 +1,7 @@
---
name: disable_metric_dashboard_refresh_rate
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229831
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229841
+group: group::health
type: development
default_enabled: false
diff --git a/config/feature_flags/development/disable_shared_runners_on_group.yml b/config/feature_flags/development/disable_shared_runners_on_group.yml
new file mode 100644
index 00000000000..86ccf59c8a0
--- /dev/null
+++ b/config/feature_flags/development/disable_shared_runners_on_group.yml
@@ -0,0 +1,7 @@
+---
+name: disable_shared_runners_on_group
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36080
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/258991
+type: development
+group: group::runner
+default_enabled: true
diff --git a/config/feature_flags/development/drop_license_management_artifact.yml b/config/feature_flags/development/drop_license_management_artifact.yml
index 59d749154b5..cb13486b7f5 100644
--- a/config/feature_flags/development/drop_license_management_artifact.yml
+++ b/config/feature_flags/development/drop_license_management_artifact.yml
@@ -1,7 +1,7 @@
---
name: drop_license_management_artifact
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: composition_analysis
type: development
default_enabled: true
diff --git a/config/feature_flags/development/epics_search.yml b/config/feature_flags/development/epics_search.yml
new file mode 100644
index 00000000000..00ad45e300f
--- /dev/null
+++ b/config/feature_flags/development/epics_search.yml
@@ -0,0 +1,7 @@
+---
+name: epics_search
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42456
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/250317
+group: group::global search
+type: development
+default_enabled: false \ No newline at end of file
diff --git a/config/feature_flags/development/export_lfs_objects_projects.yml b/config/feature_flags/development/export_lfs_objects_projects.yml
deleted file mode 100644
index 6ef3317de8d..00000000000
--- a/config/feature_flags/development/export_lfs_objects_projects.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: export_lfs_objects_projects
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/export_reduce_relation_batch_size.yml b/config/feature_flags/development/export_reduce_relation_batch_size.yml
index b32e2232933..d077079b586 100644
--- a/config/feature_flags/development/export_reduce_relation_batch_size.yml
+++ b/config/feature_flags/development/export_reduce_relation_batch_size.yml
@@ -1,7 +1,7 @@
---
name: export_reduce_relation_batch_size
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::import
type: development
default_enabled: false
diff --git a/config/feature_flags/development/feature_flags_new_version.yml b/config/feature_flags/development/feature_flags_new_version.yml
new file mode 100644
index 00000000000..3a89816c482
--- /dev/null
+++ b/config/feature_flags/development/feature_flags_new_version.yml
@@ -0,0 +1,7 @@
+---
+name: feature_flags_new_version
+introduced_by_url:
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/258831
+group: group::progressive delivery
+type: development
+default_enabled: true
diff --git a/config/feature_flags/development/forward_deployment_enabled.yml b/config/feature_flags/development/forward_deployment_enabled.yml
index 51cc99e39ae..f9aed1a0568 100644
--- a/config/feature_flags/development/forward_deployment_enabled.yml
+++ b/config/feature_flags/development/forward_deployment_enabled.yml
@@ -1,7 +1,7 @@
---
name: forward_deployment_enabled
-introduced_by_url:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/24412
rollout_issue_url:
-group:
+group: group::progressive delivery
type: development
default_enabled: true
diff --git a/config/feature_flags/development/git_push_create_all_pipelines.yml b/config/feature_flags/development/git_push_create_all_pipelines.yml
index 62dbe9bb02f..a205307d5fa 100644
--- a/config/feature_flags/development/git_push_create_all_pipelines.yml
+++ b/config/feature_flags/development/git_push_create_all_pipelines.yml
@@ -1,7 +1,7 @@
---
name: git_push_create_all_pipelines
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/27205
+rollout_issue_url:
+group: group::continuous integration
type: development
default_enabled: false
diff --git a/config/feature_flags/development/go_proxy.yml b/config/feature_flags/development/go_proxy.yml
index bde614dd84d..bd0c58faee5 100644
--- a/config/feature_flags/development/go_proxy.yml
+++ b/config/feature_flags/development/go_proxy.yml
@@ -1,7 +1,7 @@
---
name: go_proxy
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27746
+rollout_issue_url:
+group: group::package
type: development
default_enabled: false
diff --git a/config/feature_flags/development/go_proxy_disable_gomod_validation.yml b/config/feature_flags/development/go_proxy_disable_gomod_validation.yml
index 5e9671c66d5..336a88bb7ff 100644
--- a/config/feature_flags/development/go_proxy_disable_gomod_validation.yml
+++ b/config/feature_flags/development/go_proxy_disable_gomod_validation.yml
@@ -1,7 +1,7 @@
---
name: go_proxy_disable_gomod_validation
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/34338
+rollout_issue_url:
+group: group::package
type: development
default_enabled: false
diff --git a/config/feature_flags/development/graphql_board_lists.yml b/config/feature_flags/development/graphql_board_lists.yml
index 4e6bf000a8f..7e2696a06c8 100644
--- a/config/feature_flags/development/graphql_board_lists.yml
+++ b/config/feature_flags/development/graphql_board_lists.yml
@@ -1,7 +1,7 @@
---
name: graphql_board_lists
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::project management
type: development
default_enabled: false
diff --git a/config/feature_flags/development/graphql_lookahead_support.yml b/config/feature_flags/development/graphql_lookahead_support.yml
deleted file mode 100644
index 2c8825f309e..00000000000
--- a/config/feature_flags/development/graphql_lookahead_support.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: graphql_lookahead_support
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: false
diff --git a/config/feature_flags/development/graphql_milestone_stats.yml b/config/feature_flags/development/graphql_milestone_stats.yml
index daa72031879..a3ff14d3245 100644
--- a/config/feature_flags/development/graphql_milestone_stats.yml
+++ b/config/feature_flags/development/graphql_milestone_stats.yml
@@ -1,7 +1,7 @@
---
name: graphql_milestone_stats
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35066
+rollout_issue_url:
+group: group::release management
type: development
default_enabled: true
diff --git a/config/feature_flags/development/graphql_pipeline_header.yml b/config/feature_flags/development/graphql_pipeline_header.yml
new file mode 100644
index 00000000000..99019d4c849
--- /dev/null
+++ b/config/feature_flags/development/graphql_pipeline_header.yml
@@ -0,0 +1,7 @@
+---
+name: graphql_pipeline_header
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39494
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/254235
+group: group::pipeline authoring
+type: development
+default_enabled: false
diff --git a/config/feature_flags/development/graphql_release_data.yml b/config/feature_flags/development/graphql_release_data.yml
index c30fbf7fe13..609c0dc634a 100644
--- a/config/feature_flags/development/graphql_release_data.yml
+++ b/config/feature_flags/development/graphql_release_data.yml
@@ -1,7 +1,7 @@
---
name: graphql_release_data
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/30753
+rollout_issue_url:
+group: group::release management
type: development
default_enabled: true
diff --git a/config/feature_flags/development/graphql_releases_page.yml b/config/feature_flags/development/graphql_releases_page.yml
new file mode 100644
index 00000000000..607fc7027b5
--- /dev/null
+++ b/config/feature_flags/development/graphql_releases_page.yml
@@ -0,0 +1,7 @@
+---
+name: graphql_releases_page
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33095
+rollout_issue_url:
+group: group::release management
+type: development
+default_enabled: true
diff --git a/config/feature_flags/development/group_export_ndjson.yml b/config/feature_flags/development/group_export_ndjson.yml
index af495df2e48..3f7f61672f4 100644
--- a/config/feature_flags/development/group_export_ndjson.yml
+++ b/config/feature_flags/development/group_export_ndjson.yml
@@ -1,7 +1,7 @@
---
name: group_export_ndjson
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29590
+rollout_issue_url:
+group: group::import
type: development
default_enabled: true
diff --git a/config/feature_flags/development/group_import_export.yml b/config/feature_flags/development/group_import_export.yml
index 8a49fb29e45..0e88c45684f 100644
--- a/config/feature_flags/development/group_import_export.yml
+++ b/config/feature_flags/development/group_import_export.yml
@@ -1,7 +1,7 @@
---
name: group_import_export
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::import
type: development
default_enabled: true
diff --git a/config/feature_flags/development/group_import_ndjson.yml b/config/feature_flags/development/group_import_ndjson.yml
index cf438abe8cb..98f0d4722e4 100644
--- a/config/feature_flags/development/group_import_ndjson.yml
+++ b/config/feature_flags/development/group_import_ndjson.yml
@@ -1,7 +1,7 @@
---
name: group_import_ndjson
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29716
+rollout_issue_url:
+group: group::import
type: development
default_enabled: true
diff --git a/config/feature_flags/development/group_level_integrations.yml b/config/feature_flags/development/group_level_integrations.yml
index 8fc7da47ba1..c089333f72a 100644
--- a/config/feature_flags/development/group_level_integrations.yml
+++ b/config/feature_flags/development/group_level_integrations.yml
@@ -1,6 +1,6 @@
---
name: group_level_integrations
-introduced_by_url:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27557
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/238575
group: group::ecosystem
type: development
diff --git a/config/feature_flags/development/improved_mr_merged_at_queries.yml b/config/feature_flags/development/improved_mr_merged_at_queries.yml
deleted file mode 100644
index bf4c8814540..00000000000
--- a/config/feature_flags/development/improved_mr_merged_at_queries.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: improved_mr_merged_at_queries
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39329
-rollout_issue_url:
-group: group::analytics
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/inactive_policy_condition.yml b/config/feature_flags/development/inactive_policy_condition.yml
deleted file mode 100644
index bf577fb3123..00000000000
--- a/config/feature_flags/development/inactive_policy_condition.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: inactive_policy_condition
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/increased_diff_limits.yml b/config/feature_flags/development/increased_diff_limits.yml
new file mode 100644
index 00000000000..351e6926161
--- /dev/null
+++ b/config/feature_flags/development/increased_diff_limits.yml
@@ -0,0 +1,7 @@
+---
+name: increased_diff_limits
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40357
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/241185
+group: group::source code
+type: development
+default_enabled: false
diff --git a/config/feature_flags/development/ingress_modsecurity.yml b/config/feature_flags/development/ingress_modsecurity.yml
index cf289bb3d45..a8c8c3b26da 100644
--- a/config/feature_flags/development/ingress_modsecurity.yml
+++ b/config/feature_flags/development/ingress_modsecurity.yml
@@ -1,7 +1,7 @@
---
name: ingress_modsecurity
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/20194
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/258554
+group: "group::container security"
type: development
default_enabled: false
diff --git a/config/feature_flags/development/invisible_captcha.yml b/config/feature_flags/development/invisible_captcha.yml
index ee38a9c657a..e44e5a47947 100644
--- a/config/feature_flags/development/invisible_captcha.yml
+++ b/config/feature_flags/development/invisible_captcha.yml
@@ -1,7 +1,7 @@
---
name: invisible_captcha
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/31625
+rollout_issue_url:
+group: group::acquisition
type: development
default_enabled: false
diff --git a/config/feature_flags/development/invite_email_experiment.yml b/config/feature_flags/development/invite_email_experiment.yml
index 3797090724e..496f096f7c7 100644
--- a/config/feature_flags/development/invite_email_experiment.yml
+++ b/config/feature_flags/development/invite_email_experiment.yml
@@ -1,7 +1,7 @@
---
name: invite_email_experiment
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39628
+rollout_issue_url:
+group: group::acquisition
type: development
default_enabled: false
diff --git a/config/feature_flags/development/invite_members_group_modal.yml b/config/feature_flags/development/invite_members_group_modal.yml
new file mode 100644
index 00000000000..faa905f6557
--- /dev/null
+++ b/config/feature_flags/development/invite_members_group_modal.yml
@@ -0,0 +1,7 @@
+---
+name: invite_members_group_modal
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37906
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/247208
+group: group::expansion
+type: development
+default_enabled: false
diff --git a/config/feature_flags/development/issues_incident_details.yml b/config/feature_flags/development/issues_incident_details.yml
new file mode 100644
index 00000000000..d9f7d16312f
--- /dev/null
+++ b/config/feature_flags/development/issues_incident_details.yml
@@ -0,0 +1,7 @@
+---
+name: issues_incident_details
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43459
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/257842
+type: development
+group: group::health
+default_enabled: false
diff --git a/config/feature_flags/development/junit_pipeline_screenshots_view.yml b/config/feature_flags/development/junit_pipeline_screenshots_view.yml
index d9f61b572be..a148c4b70f3 100644
--- a/config/feature_flags/development/junit_pipeline_screenshots_view.yml
+++ b/config/feature_flags/development/junit_pipeline_screenshots_view.yml
@@ -1,7 +1,7 @@
---
name: junit_pipeline_screenshots_view
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/202114
+rollout_issue_url:
+group: 'group::verify testing'
type: development
default_enabled: false
diff --git a/config/feature_flags/development/limit_projects_in_groups_api.yml b/config/feature_flags/development/limit_projects_in_groups_api.yml
index 571c02578d3..efa763944ec 100644
--- a/config/feature_flags/development/limit_projects_in_groups_api.yml
+++ b/config/feature_flags/development/limit_projects_in_groups_api.yml
@@ -1,7 +1,7 @@
---
name: limit_projects_in_groups_api
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/20023
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/257829
+group: group::access
type: development
default_enabled: true
diff --git a/config/feature_flags/development/log_import_export_relation_creation.yml b/config/feature_flags/development/log_import_export_relation_creation.yml
index bfd4b27752b..4ab84c2193c 100644
--- a/config/feature_flags/development/log_import_export_relation_creation.yml
+++ b/config/feature_flags/development/log_import_export_relation_creation.yml
@@ -1,7 +1,7 @@
---
name: log_import_export_relation_creation
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::import
type: development
default_enabled: false
diff --git a/config/feature_flags/development/maintenance_mode.yml b/config/feature_flags/development/maintenance_mode.yml
index 8fba1216834..429e70b64a2 100644
--- a/config/feature_flags/development/maintenance_mode.yml
+++ b/config/feature_flags/development/maintenance_mode.yml
@@ -1,7 +1,7 @@
---
name: maintenance_mode
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/28158
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/217895
+group: group::geo
type: development
default_enabled: false
diff --git a/config/feature_flags/development/merge_orchestration_service.yml b/config/feature_flags/development/merge_orchestration_service.yml
index 134553e7344..2bdfe9b52a4 100644
--- a/config/feature_flags/development/merge_orchestration_service.yml
+++ b/config/feature_flags/development/merge_orchestration_service.yml
@@ -1,7 +1,7 @@
---
name: merge_orchestration_service
-introduced_by_url:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/28532
rollout_issue_url:
-group:
+group: group::continuous integration
type: development
default_enabled: true
diff --git a/config/feature_flags/development/merge_ref_head_comments.yml b/config/feature_flags/development/merge_ref_head_comments.yml
new file mode 100644
index 00000000000..6f391860e29
--- /dev/null
+++ b/config/feature_flags/development/merge_ref_head_comments.yml
@@ -0,0 +1,7 @@
+---
+name: merge_ref_head_comments
+introduced_by_url:
+rollout_issue_url:
+group:
+type: development
+default_enabled: true
diff --git a/config/feature_flags/development/merge_request_cached_pipeline_serializer.yml b/config/feature_flags/development/merge_request_cached_pipeline_serializer.yml
new file mode 100644
index 00000000000..1141d1f82a4
--- /dev/null
+++ b/config/feature_flags/development/merge_request_cached_pipeline_serializer.yml
@@ -0,0 +1,7 @@
+---
+name: merge_request_cached_pipeline_serializer
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38273
+rollout_issue_url:
+type: development
+group: group::source code
+default_enabled: false
diff --git a/config/feature_flags/development/merge_request_short_pipeline_serializer.yml b/config/feature_flags/development/merge_request_short_pipeline_serializer.yml
deleted file mode 100644
index c24dd106547..00000000000
--- a/config/feature_flags/development/merge_request_short_pipeline_serializer.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: merge_request_short_pipeline_serializer
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/merge_request_widget_graphql.yml b/config/feature_flags/development/merge_request_widget_graphql.yml
index 028553a47f8..cbe76a61fa5 100644
--- a/config/feature_flags/development/merge_request_widget_graphql.yml
+++ b/config/feature_flags/development/merge_request_widget_graphql.yml
@@ -1,7 +1,7 @@
---
name: merge_request_widget_graphql
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38311
+rollout_issue_url:
+group: group::source code
type: development
default_enabled: false
diff --git a/config/feature_flags/development/metrics_dashboard.yml b/config/feature_flags/development/metrics_dashboard.yml
index a252068aa9a..04a64b632ba 100644
--- a/config/feature_flags/development/metrics_dashboard.yml
+++ b/config/feature_flags/development/metrics_dashboard.yml
@@ -1,7 +1,7 @@
---
name: metrics_dashboard
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29634
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/257902
+group: group::health
type: development
default_enabled: true
diff --git a/config/feature_flags/development/migrate_bio_to_user_details.yml b/config/feature_flags/development/migrate_bio_to_user_details.yml
deleted file mode 100644
index f54e45f9bd3..00000000000
--- a/config/feature_flags/development/migrate_bio_to_user_details.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: migrate_bio_to_user_details
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/migrate_user_mentions.yml b/config/feature_flags/development/migrate_user_mentions.yml
index 3388fb020ad..5dd5667dfae 100644
--- a/config/feature_flags/development/migrate_user_mentions.yml
+++ b/config/feature_flags/development/migrate_user_mentions.yml
@@ -1,7 +1,7 @@
---
name: migrate_user_mentions
-introduced_by_url:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/34378
rollout_issue_url:
-group:
+group: group::project management
type: development
default_enabled: true
diff --git a/config/feature_flags/development/monaco_blobs.yml b/config/feature_flags/development/monaco_blobs.yml
index bb1215493d5..4a29921cdb4 100644
--- a/config/feature_flags/development/monaco_blobs.yml
+++ b/config/feature_flags/development/monaco_blobs.yml
@@ -1,7 +1,7 @@
---
name: monaco_blobs
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::editor
type: development
default_enabled: true
diff --git a/config/feature_flags/development/monaco_ci.yml b/config/feature_flags/development/monaco_ci.yml
index 5d6ace8d8ad..0f1c8b521f2 100644
--- a/config/feature_flags/development/monaco_ci.yml
+++ b/config/feature_flags/development/monaco_ci.yml
@@ -1,7 +1,7 @@
---
name: monaco_ci
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23666
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/249137
+group: group::editor
type: development
-default_enabled: false
+default_enabled: true
diff --git a/config/feature_flags/development/multi_select_board.yml b/config/feature_flags/development/multi_select_board.yml
index 35718606d62..3849fb0e12b 100644
--- a/config/feature_flags/development/multi_select_board.yml
+++ b/config/feature_flags/development/multi_select_board.yml
@@ -1,7 +1,7 @@
---
name: multi_select_board
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::project management
type: development
default_enabled: true
diff --git a/config/feature_flags/development/multiline_comments.yml b/config/feature_flags/development/multiline_comments.yml
index cb80d381e18..135e5be1dc5 100644
--- a/config/feature_flags/development/multiline_comments.yml
+++ b/config/feature_flags/development/multiline_comments.yml
@@ -1,7 +1,7 @@
---
name: multiline_comments
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/211255
+rollout_issue_url:
+group: group::source code
type: development
default_enabled: true
diff --git a/config/feature_flags/development/new_pipeline_form.yml b/config/feature_flags/development/new_pipeline_form.yml
index 96c7268ded0..3c1079a5f5c 100644
--- a/config/feature_flags/development/new_pipeline_form.yml
+++ b/config/feature_flags/development/new_pipeline_form.yml
@@ -1,7 +1,7 @@
---
name: new_pipeline_form
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35674
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229632
+group: group::continuous integration
type: development
default_enabled: false
diff --git a/config/feature_flags/development/new_release_page.yml b/config/feature_flags/development/new_release_page.yml
index a17438ba949..b2890b9ebf9 100644
--- a/config/feature_flags/development/new_release_page.yml
+++ b/config/feature_flags/development/new_release_page.yml
@@ -1,7 +1,7 @@
---
name: new_release_page
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35367
+rollout_issue_url:
+group: group::release management
type: development
default_enabled: true
diff --git a/config/feature_flags/development/not_issuable_queries.yml b/config/feature_flags/development/not_issuable_queries.yml
index f6bbceff505..a5cfba7980d 100644
--- a/config/feature_flags/development/not_issuable_queries.yml
+++ b/config/feature_flags/development/not_issuable_queries.yml
@@ -1,7 +1,7 @@
---
name: not_issuable_queries
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27639
+rollout_issue_url:
+group: group::project management
type: development
default_enabled: true
diff --git a/config/feature_flags/development/one_dimensional_matrix.yml b/config/feature_flags/development/one_dimensional_matrix.yml
new file mode 100644
index 00000000000..a0eb4fe9e7d
--- /dev/null
+++ b/config/feature_flags/development/one_dimensional_matrix.yml
@@ -0,0 +1,7 @@
+---
+name: one_dimensional_matrix
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42170
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/256062
+type: development
+group: group::pipeline authoring
+default_enabled: false
diff --git a/config/feature_flags/development/paginated_notes.yml b/config/feature_flags/development/paginated_notes.yml
index cd98b3b1bbf..a9209fbe4ae 100644
--- a/config/feature_flags/development/paginated_notes.yml
+++ b/config/feature_flags/development/paginated_notes.yml
@@ -1,7 +1,7 @@
---
name: paginated_notes
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/34628
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/254987
+group: group::source code
type: development
default_enabled: false
diff --git a/config/feature_flags/development/periodic_project_authorization_recalculation.yml b/config/feature_flags/development/periodic_project_authorization_recalculation.yml
deleted file mode 100644
index 90b9babcfca..00000000000
--- a/config/feature_flags/development/periodic_project_authorization_recalculation.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: periodic_project_authorization_recalculation
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/phabricator_import.yml b/config/feature_flags/development/phabricator_import.yml
index 32ccfed557e..1bce39b65f6 100644
--- a/config/feature_flags/development/phabricator_import.yml
+++ b/config/feature_flags/development/phabricator_import.yml
@@ -1,7 +1,7 @@
---
name: phabricator_import
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/13569
+rollout_issue_url: https://gitlab.com/groups/gitlab-org/-/epics/1197
+group: group::import
type: development
default_enabled: false
diff --git a/config/feature_flags/development/pipelines_security_report_summary.yml b/config/feature_flags/development/pipelines_security_report_summary.yml
index 943997f1a4f..b9e83bc0795 100644
--- a/config/feature_flags/development/pipelines_security_report_summary.yml
+++ b/config/feature_flags/development/pipelines_security_report_summary.yml
@@ -1,7 +1,7 @@
---
name: pipelines_security_report_summary
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/31136
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/235943
+group: group::dynamic analysis
type: development
default_enabled: false
diff --git a/config/feature_flags/development/product_analytics.yml b/config/feature_flags/development/product_analytics.yml
index 85bef678251..cc1859e149c 100644
--- a/config/feature_flags/development/product_analytics.yml
+++ b/config/feature_flags/development/product_analytics.yml
@@ -1,7 +1,7 @@
---
name: product_analytics
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36443
+rollout_issue_url:
+group: group::product_analytics
type: development
default_enabled: false
diff --git a/config/feature_flags/development/project_export_as_ndjson.yml b/config/feature_flags/development/project_export_as_ndjson.yml
index eee61f4ce26..f39b892f18a 100644
--- a/config/feature_flags/development/project_export_as_ndjson.yml
+++ b/config/feature_flags/development/project_export_as_ndjson.yml
@@ -1,7 +1,7 @@
---
name: project_export_as_ndjson
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26995
+rollout_issue_url:
+group: group::import
type: development
default_enabled: true
diff --git a/config/feature_flags/development/project_import_ndjson.yml b/config/feature_flags/development/project_import_ndjson.yml
index a7971d462fe..8dc924a97c2 100644
--- a/config/feature_flags/development/project_import_ndjson.yml
+++ b/config/feature_flags/development/project_import_ndjson.yml
@@ -1,7 +1,7 @@
---
name: project_import_ndjson
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27206
+rollout_issue_url:
+group: group::import
type: development
default_enabled: true
diff --git a/config/feature_flags/development/project_transactionless_destroy.yml b/config/feature_flags/development/project_transactionless_destroy.yml
index 07018632e7a..726875655e9 100644
--- a/config/feature_flags/development/project_transactionless_destroy.yml
+++ b/config/feature_flags/development/project_transactionless_destroy.yml
@@ -1,7 +1,7 @@
---
name: project_transactionless_destroy
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39367
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/255972
+group: group::continuous integration
type: development
default_enabled: false
diff --git a/config/feature_flags/development/prometheus_computed_alerts.yml b/config/feature_flags/development/prometheus_computed_alerts.yml
index ddba72b2f46..576894df589 100644
--- a/config/feature_flags/development/prometheus_computed_alerts.yml
+++ b/config/feature_flags/development/prometheus_computed_alerts.yml
@@ -1,7 +1,7 @@
---
name: prometheus_computed_alerts
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/13443
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/255304
+group: group::health
type: development
default_enabled: false
diff --git a/config/feature_flags/development/reactive_caching_limit_environment.yml b/config/feature_flags/development/reactive_caching_limit_environment.yml
index 84db37d1d34..8aa66c9d293 100644
--- a/config/feature_flags/development/reactive_caching_limit_environment.yml
+++ b/config/feature_flags/development/reactive_caching_limit_environment.yml
@@ -1,7 +1,7 @@
---
name: reactive_caching_limit_environment
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/34202
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/202633
+group: group::configure
type: development
default_enabled: false
diff --git a/config/feature_flags/development/real_time_issue_sidebar.yml b/config/feature_flags/development/real_time_issue_sidebar.yml
index 7cbaabe643f..37284b2642c 100644
--- a/config/feature_flags/development/real_time_issue_sidebar.yml
+++ b/config/feature_flags/development/real_time_issue_sidebar.yml
@@ -1,7 +1,7 @@
---
name: real_time_issue_sidebar
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/30239
+rollout_issue_url: https://gitlab.com/gitlab-com/gl-infra/delivery/-/issues/1210
+group: group::project management
type: development
default_enabled: false
diff --git a/config/feature_flags/development/release_asset_link_editing.yml b/config/feature_flags/development/release_asset_link_editing.yml
index 6e6cce2c343..6e5a0ba1b52 100644
--- a/config/feature_flags/development/release_asset_link_editing.yml
+++ b/config/feature_flags/development/release_asset_link_editing.yml
@@ -1,7 +1,7 @@
---
name: release_asset_link_editing
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26821
+rollout_issue_url:
+group: group::release management
type: development
default_enabled: true
diff --git a/config/feature_flags/development/release_asset_link_type.yml b/config/feature_flags/development/release_asset_link_type.yml
index baeb2e59f82..d49f246d2fa 100644
--- a/config/feature_flags/development/release_asset_link_type.yml
+++ b/config/feature_flags/development/release_asset_link_type.yml
@@ -1,7 +1,7 @@
---
name: release_asset_link_type
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33643
+rollout_issue_url:
+group: group::release management
type: development
default_enabled: true
diff --git a/config/feature_flags/development/release_evidence.yml b/config/feature_flags/development/release_evidence.yml
index a19994d51a7..c2f7ab16332 100644
--- a/config/feature_flags/development/release_evidence.yml
+++ b/config/feature_flags/development/release_evidence.yml
@@ -1,7 +1,7 @@
---
name: release_evidence
-introduced_by_url:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26509
rollout_issue_url:
-group:
+group: group::release management
type: development
default_enabled: true
diff --git a/config/feature_flags/development/release_evidence_collection.yml b/config/feature_flags/development/release_evidence_collection.yml
index c5ab8f8e5c0..be47f5220b1 100644
--- a/config/feature_flags/development/release_evidence_collection.yml
+++ b/config/feature_flags/development/release_evidence_collection.yml
@@ -1,7 +1,7 @@
---
name: release_evidence_collection
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::release management
type: development
default_enabled: true
diff --git a/config/feature_flags/development/release_issue_summary.yml b/config/feature_flags/development/release_issue_summary.yml
index ebd8ef2e4d0..93370422f2c 100644
--- a/config/feature_flags/development/release_issue_summary.yml
+++ b/config/feature_flags/development/release_issue_summary.yml
@@ -1,7 +1,7 @@
---
name: release_issue_summary
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/19451
+rollout_issue_url:
+group: group::release management
type: development
default_enabled: true
diff --git a/config/feature_flags/development/release_mr_issue_urls.yml b/config/feature_flags/development/release_mr_issue_urls.yml
index f6abf50f535..3f9c81b8fe9 100644
--- a/config/feature_flags/development/release_mr_issue_urls.yml
+++ b/config/feature_flags/development/release_mr_issue_urls.yml
@@ -1,7 +1,7 @@
---
name: release_mr_issue_urls
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/18727
+rollout_issue_url:
+group: group::release management
type: development
default_enabled: false
diff --git a/config/feature_flags/development/release_show_page.yml b/config/feature_flags/development/release_show_page.yml
index 5a3f1709452..fd18eca1b25 100644
--- a/config/feature_flags/development/release_show_page.yml
+++ b/config/feature_flags/development/release_show_page.yml
@@ -1,7 +1,7 @@
---
name: release_show_page
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23792
+rollout_issue_url:
+group: group::release management
type: development
default_enabled: true
diff --git a/config/feature_flags/development/remove_legacy_github_client.yml b/config/feature_flags/development/remove_legacy_github_client.yml
index adb0c7b5d03..ecb8e103949 100644
--- a/config/feature_flags/development/remove_legacy_github_client.yml
+++ b/config/feature_flags/development/remove_legacy_github_client.yml
@@ -1,7 +1,7 @@
---
name: remove_legacy_github_client
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::import
type: development
default_enabled: false
diff --git a/config/feature_flags/development/repack_after_shard_migration.yml b/config/feature_flags/development/repack_after_shard_migration.yml
index addfcf66537..18663a4e7af 100644
--- a/config/feature_flags/development/repack_after_shard_migration.yml
+++ b/config/feature_flags/development/repack_after_shard_migration.yml
@@ -1,7 +1,7 @@
---
name: repack_after_shard_migration
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/21502
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/195597
+group: group::source code
type: development
default_enabled: false
diff --git a/config/feature_flags/development/resource_access_token.yml b/config/feature_flags/development/resource_access_token.yml
deleted file mode 100644
index 80c5b2a5eb4..00000000000
--- a/config/feature_flags/development/resource_access_token.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: resource_access_token
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/safezip_use_rubyzip.yml b/config/feature_flags/development/safezip_use_rubyzip.yml
deleted file mode 100644
index 139283d93d2..00000000000
--- a/config/feature_flags/development/safezip_use_rubyzip.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: safezip_use_rubyzip
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/save_raw_usage_data.yml b/config/feature_flags/development/save_raw_usage_data.yml
index 9556f699b9b..f71393414ae 100644
--- a/config/feature_flags/development/save_raw_usage_data.yml
+++ b/config/feature_flags/development/save_raw_usage_data.yml
@@ -1,7 +1,7 @@
---
name: save_raw_usage_data
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38457
+rollout_issue_url:
+group: group::product_analytics
type: development
default_enabled: false
diff --git a/config/feature_flags/development/schema_linting.yml b/config/feature_flags/development/schema_linting.yml
index 1220b02be0d..3722f1dd97f 100644
--- a/config/feature_flags/development/schema_linting.yml
+++ b/config/feature_flags/development/schema_linting.yml
@@ -1,7 +1,7 @@
---
name: schema_linting
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35838
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/255919
+group: group::editor
type: development
default_enabled: false
diff --git a/config/feature_flags/development/search_filter_by_confidential.yml b/config/feature_flags/development/search_filter_by_confidential.yml
new file mode 100644
index 00000000000..0a952a4d25e
--- /dev/null
+++ b/config/feature_flags/development/search_filter_by_confidential.yml
@@ -0,0 +1,7 @@
+---
+name: search_filter_by_confidential
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40793
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/244923
+group: group::global search
+type: development
+default_enabled: false \ No newline at end of file
diff --git a/config/feature_flags/development/serverless_domain.yml b/config/feature_flags/development/serverless_domain.yml
index 160730117d6..f4821e5a14c 100644
--- a/config/feature_flags/development/serverless_domain.yml
+++ b/config/feature_flags/development/serverless_domain.yml
@@ -1,7 +1,7 @@
---
name: serverless_domain
-introduced_by_url:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/21222
rollout_issue_url:
-group:
+group: group::configure
type: development
default_enabled: false
diff --git a/config/feature_flags/development/service_desk_custom_address.yml b/config/feature_flags/development/service_desk_custom_address.yml
index 25cab0059c7..13a9ef6f37a 100644
--- a/config/feature_flags/development/service_desk_custom_address.yml
+++ b/config/feature_flags/development/service_desk_custom_address.yml
@@ -2,6 +2,6 @@
name: service_desk_custom_address
introduced_by_url:
rollout_issue_url:
-group:
+group: group::certify
type: development
default_enabled: false
diff --git a/config/feature_flags/development/settings_operations_prometheus_service.yml b/config/feature_flags/development/settings_operations_prometheus_service.yml
index de2df4b219d..1a4815a3fb9 100644
--- a/config/feature_flags/development/settings_operations_prometheus_service.yml
+++ b/config/feature_flags/development/settings_operations_prometheus_service.yml
@@ -1,7 +1,7 @@
---
name: settings_operations_prometheus_service
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/24296
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/258560
+group: group::health
type: development
default_enabled: false
diff --git a/config/feature_flags/development/show_contributor_on_note.yml b/config/feature_flags/development/show_contributor_on_note.yml
deleted file mode 100644
index 89533037244..00000000000
--- a/config/feature_flags/development/show_contributor_on_note.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: show_contributor_on_note
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40198
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/249179
-group: group::project management
-type: development
-default_enabled: false \ No newline at end of file
diff --git a/config/feature_flags/development/show_inherited_labels.yml b/config/feature_flags/development/show_inherited_labels.yml
new file mode 100644
index 00000000000..3fb26d91227
--- /dev/null
+++ b/config/feature_flags/development/show_inherited_labels.yml
@@ -0,0 +1,7 @@
+---
+name: show_inherited_labels
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42960
+rollout_issue_url:
+group: group::project management
+type: development
+default_enabled: false
diff --git a/config/feature_flags/development/similarity_search.yml b/config/feature_flags/development/similarity_search.yml
index b7d48c5b986..f99e80b5cc7 100644
--- a/config/feature_flags/development/similarity_search.yml
+++ b/config/feature_flags/development/similarity_search.yml
@@ -1,7 +1,7 @@
---
name: similarity_search
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37300/
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38675
+group: group::analytics
type: development
default_enabled: true
diff --git a/config/feature_flags/development/snippet_multiple_files.yml b/config/feature_flags/development/snippet_multiple_files.yml
index 28520cea19c..f495c3d4e64 100644
--- a/config/feature_flags/development/snippet_multiple_files.yml
+++ b/config/feature_flags/development/snippet_multiple_files.yml
@@ -1,7 +1,7 @@
---
name: snippet_multiple_files
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/32416
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/217809
+group: group::editor
type: development
default_enabled: false
diff --git a/config/feature_flags/development/snippets_binary_blob.yml b/config/feature_flags/development/snippets_binary_blob.yml
index c8d1b5d6ba2..6ab05b096ba 100644
--- a/config/feature_flags/development/snippets_binary_blob.yml
+++ b/config/feature_flags/development/snippets_binary_blob.yml
@@ -1,7 +1,7 @@
---
name: snippets_binary_blob
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::editor
type: development
default_enabled: false
diff --git a/config/feature_flags/development/snippets_vue.yml b/config/feature_flags/development/snippets_vue.yml
index b3b7e525a6e..83d9cc918c9 100644
--- a/config/feature_flags/development/snippets_vue.yml
+++ b/config/feature_flags/development/snippets_vue.yml
@@ -1,7 +1,7 @@
---
name: snippets_vue
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::editor
type: development
default_enabled: true
diff --git a/config/feature_flags/development/soft_email_confirmation.yml b/config/feature_flags/development/soft_email_confirmation.yml
index d471cf442de..ee951d84ed8 100644
--- a/config/feature_flags/development/soft_email_confirmation.yml
+++ b/config/feature_flags/development/soft_email_confirmation.yml
@@ -1,7 +1,7 @@
---
name: soft_email_confirmation
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/31245
+rollout_issue_url:
+group: group::acquisition
type: development
default_enabled: false
diff --git a/config/feature_flags/development/specialized_project_authorization_project_share_worker.yml b/config/feature_flags/development/specialized_project_authorization_project_share_worker.yml
index c09c5ad519b..951ea4b315f 100644
--- a/config/feature_flags/development/specialized_project_authorization_project_share_worker.yml
+++ b/config/feature_flags/development/specialized_project_authorization_project_share_worker.yml
@@ -1,7 +1,7 @@
---
name: specialized_project_authorization_project_share_worker
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/32864
+rollout_issue_url:
+group: group::access
type: development
default_enabled: false
diff --git a/config/feature_flags/development/specialized_project_authorization_workers.yml b/config/feature_flags/development/specialized_project_authorization_workers.yml
index 7fceff532f3..48372cac765 100644
--- a/config/feature_flags/development/specialized_project_authorization_workers.yml
+++ b/config/feature_flags/development/specialized_project_authorization_workers.yml
@@ -1,7 +1,7 @@
---
name: specialized_project_authorization_workers
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/31377
+rollout_issue_url:
+group: group::access
type: development
default_enabled: false
diff --git a/config/feature_flags/development/sql_set_operators.yml b/config/feature_flags/development/sql_set_operators.yml
index b8a838a13f1..196753fcd74 100644
--- a/config/feature_flags/development/sql_set_operators.yml
+++ b/config/feature_flags/development/sql_set_operators.yml
@@ -1,7 +1,7 @@
---
name: sql_set_operators
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39786#f99799ae4964b7650b877e081b669379d71bcca8
+group: group::access
type: development
default_enabled: false
diff --git a/config/feature_flags/development/store_ci_pipeline_counts_by_status.yml b/config/feature_flags/development/store_ci_pipeline_counts_by_status.yml
new file mode 100644
index 00000000000..62044bf855a
--- /dev/null
+++ b/config/feature_flags/development/store_ci_pipeline_counts_by_status.yml
@@ -0,0 +1,7 @@
+---
+name: store_ci_pipeline_counts_by_status
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43027
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/254721
+type: development
+group: group::analytics
+default_enabled: true
diff --git a/config/feature_flags/development/store_mentioned_users_to_db.yml b/config/feature_flags/development/store_mentioned_users_to_db.yml
deleted file mode 100644
index e19076f2b84..00000000000
--- a/config/feature_flags/development/store_mentioned_users_to_db.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: store_mentioned_users_to_db
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/suggest_pipeline.yml b/config/feature_flags/development/suggest_pipeline.yml
deleted file mode 100644
index bd467f4bcca..00000000000
--- a/config/feature_flags/development/suggest_pipeline.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: suggest_pipeline
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: false
diff --git a/config/feature_flags/development/sync_metrics_dashboards.yml b/config/feature_flags/development/sync_metrics_dashboards.yml
new file mode 100644
index 00000000000..2dd8964121a
--- /dev/null
+++ b/config/feature_flags/development/sync_metrics_dashboards.yml
@@ -0,0 +1,7 @@
+---
+name: sync_metrics_dashboards
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39658
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/241793
+group: group::apm
+type: development
+default_enabled: false
diff --git a/config/feature_flags/development/track_resource_state_change_events.yml b/config/feature_flags/development/track_resource_state_change_events.yml
deleted file mode 100644
index 3bfde5cf05e..00000000000
--- a/config/feature_flags/development/track_resource_state_change_events.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: track_resource_state_change_events
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/track_unique_visits.yml b/config/feature_flags/development/track_unique_visits.yml
index 6a60d327e87..10f78a32492 100644
--- a/config/feature_flags/development/track_unique_visits.yml
+++ b/config/feature_flags/development/track_unique_visits.yml
@@ -1,7 +1,7 @@
---
name: track_unique_visits
-introduced_by_url:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33146
rollout_issue_url:
-group:
+group: group::analytics
type: development
-default_enabled: false
+default_enabled: true
diff --git a/config/feature_flags/development/tribute_autocomplete.yml b/config/feature_flags/development/tribute_autocomplete.yml
index 31ee1b932d3..94cfc00c467 100644
--- a/config/feature_flags/development/tribute_autocomplete.yml
+++ b/config/feature_flags/development/tribute_autocomplete.yml
@@ -1,7 +1,7 @@
---
name: tribute_autocomplete
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/32671
+rollout_issue_url:
+group: group::project management
type: development
default_enabled: false
diff --git a/config/feature_flags/development/unified_diff_lines.yml b/config/feature_flags/development/unified_diff_lines.yml
index a676f0732dd..d580ef65104 100644
--- a/config/feature_flags/development/unified_diff_lines.yml
+++ b/config/feature_flags/development/unified_diff_lines.yml
@@ -4,4 +4,4 @@ introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40131
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/241188
group: group::source code
type: development
-default_enabled: false
+default_enabled: true
diff --git a/config/feature_flags/development/usage_data_a_compliance_audit_events_api.yml b/config/feature_flags/development/usage_data_a_compliance_audit_events_api.yml
new file mode 100644
index 00000000000..8b2e8063164
--- /dev/null
+++ b/config/feature_flags/development/usage_data_a_compliance_audit_events_api.yml
@@ -0,0 +1,7 @@
+---
+name: usage_data_a_compliance_audit_events_api
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41689
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/233786
+group: group::compliance
+type: development
+default_enabled: false
diff --git a/config/feature_flags/development/usage_data_api.yml b/config/feature_flags/development/usage_data_api.yml
index 0976b27d417..139e39807dc 100644
--- a/config/feature_flags/development/usage_data_api.yml
+++ b/config/feature_flags/development/usage_data_api.yml
@@ -1,7 +1,7 @@
---
name: usage_data_api
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41301
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/235459
-group: group::telemetry
+rollout_issue_url:
+group: group::product_analytics
type: development
default_enabled: false
diff --git a/config/feature_flags/development/usage_data_g_compliance_dashboard.yml b/config/feature_flags/development/usage_data_g_compliance_dashboard.yml
index 6f971788d88..24d6f81740b 100644
--- a/config/feature_flags/development/usage_data_g_compliance_dashboard.yml
+++ b/config/feature_flags/development/usage_data_g_compliance_dashboard.yml
@@ -1,7 +1,7 @@
---
name: usage_data_g_compliance_dashboard
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::compliance
type: development
default_enabled: false
diff --git a/config/feature_flags/development/usage_data_i_source_code_code_intelligence.yml b/config/feature_flags/development/usage_data_i_source_code_code_intelligence.yml
new file mode 100644
index 00000000000..9ea05b0c7df
--- /dev/null
+++ b/config/feature_flags/development/usage_data_i_source_code_code_intelligence.yml
@@ -0,0 +1,7 @@
+---
+name: usage_data_i_source_code_code_intelligence
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41881
+rollout_issue_url:
+group: group::source_code
+type: development
+default_enabled: true
diff --git a/config/feature_flags/development/usage_data_incident_management_alert_assigned.yml b/config/feature_flags/development/usage_data_incident_management_alert_assigned.yml
index 22589d00ae0..a50a3eafa1a 100644
--- a/config/feature_flags/development/usage_data_incident_management_alert_assigned.yml
+++ b/config/feature_flags/development/usage_data_incident_management_alert_assigned.yml
@@ -1,7 +1,7 @@
---
name: usage_data_incident_management_alert_assigned
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
+group: group::health
type: development
default_enabled: true
diff --git a/config/feature_flags/development/usage_data_incident_management_alert_status_changed.yml b/config/feature_flags/development/usage_data_incident_management_alert_status_changed.yml
index 10f987ecd65..957e316e153 100644
--- a/config/feature_flags/development/usage_data_incident_management_alert_status_changed.yml
+++ b/config/feature_flags/development/usage_data_incident_management_alert_status_changed.yml
@@ -1,7 +1,7 @@
---
name: usage_data_incident_management_alert_status_changed
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
+group: group::health
type: development
default_enabled: true
diff --git a/config/feature_flags/development/usage_data_incident_management_alert_todo.yml b/config/feature_flags/development/usage_data_incident_management_alert_todo.yml
index 67d18480a56..5e64899978a 100644
--- a/config/feature_flags/development/usage_data_incident_management_alert_todo.yml
+++ b/config/feature_flags/development/usage_data_incident_management_alert_todo.yml
@@ -1,7 +1,7 @@
---
name: usage_data_incident_management_alert_todo
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
+group: group::health
type: development
default_enabled: true
diff --git a/config/feature_flags/development/usage_data_incident_management_incident_assigned.yml b/config/feature_flags/development/usage_data_incident_management_incident_assigned.yml
index 1b48fe4ac9c..a582a44a3e2 100644
--- a/config/feature_flags/development/usage_data_incident_management_incident_assigned.yml
+++ b/config/feature_flags/development/usage_data_incident_management_incident_assigned.yml
@@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_assigned
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
+group: group::health
type: development
default_enabled: true
diff --git a/config/feature_flags/development/usage_data_incident_management_incident_change_confidential.yml b/config/feature_flags/development/usage_data_incident_management_incident_change_confidential.yml
index 6235382fd59..ad482676132 100644
--- a/config/feature_flags/development/usage_data_incident_management_incident_change_confidential.yml
+++ b/config/feature_flags/development/usage_data_incident_management_incident_change_confidential.yml
@@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_change_confidential
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
+group: group::health
type: development
default_enabled: true
diff --git a/config/feature_flags/development/usage_data_incident_management_incident_closed.yml b/config/feature_flags/development/usage_data_incident_management_incident_closed.yml
index 4f8f9b951ed..943b7a83e5e 100644
--- a/config/feature_flags/development/usage_data_incident_management_incident_closed.yml
+++ b/config/feature_flags/development/usage_data_incident_management_incident_closed.yml
@@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_closed
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
+group: group::health
type: development
default_enabled: true
diff --git a/config/feature_flags/development/usage_data_incident_management_incident_comment.yml b/config/feature_flags/development/usage_data_incident_management_incident_comment.yml
index b41c96d3b04..5d173e1e869 100644
--- a/config/feature_flags/development/usage_data_incident_management_incident_comment.yml
+++ b/config/feature_flags/development/usage_data_incident_management_incident_comment.yml
@@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_comment
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
+group: group::health
type: development
default_enabled: true
diff --git a/config/feature_flags/development/usage_data_incident_management_incident_created.yml b/config/feature_flags/development/usage_data_incident_management_incident_created.yml
index 693cc23dc3b..86e47d54d18 100644
--- a/config/feature_flags/development/usage_data_incident_management_incident_created.yml
+++ b/config/feature_flags/development/usage_data_incident_management_incident_created.yml
@@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_created
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
+group: group::health
type: development
default_enabled: true
diff --git a/config/feature_flags/development/usage_data_incident_management_incident_relate.yml b/config/feature_flags/development/usage_data_incident_management_incident_relate.yml
index 70b4c5c3f65..84ba0d34cc7 100644
--- a/config/feature_flags/development/usage_data_incident_management_incident_relate.yml
+++ b/config/feature_flags/development/usage_data_incident_management_incident_relate.yml
@@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_relate
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
+group: group::health
type: development
default_enabled: true
diff --git a/config/feature_flags/development/usage_data_incident_management_incident_reopened.yml b/config/feature_flags/development/usage_data_incident_management_incident_reopened.yml
index ccb108a4a28..ed71d353cfe 100644
--- a/config/feature_flags/development/usage_data_incident_management_incident_reopened.yml
+++ b/config/feature_flags/development/usage_data_incident_management_incident_reopened.yml
@@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_reopened
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
+group: group::health
type: development
default_enabled: true
diff --git a/config/feature_flags/development/usage_data_incident_management_incident_todo.yml b/config/feature_flags/development/usage_data_incident_management_incident_todo.yml
index c517a66a151..aa400fb4455 100644
--- a/config/feature_flags/development/usage_data_incident_management_incident_todo.yml
+++ b/config/feature_flags/development/usage_data_incident_management_incident_todo.yml
@@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_todo
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
+group: group::health
type: development
default_enabled: true
diff --git a/config/feature_flags/development/usage_data_incident_management_incident_unrelate.yml b/config/feature_flags/development/usage_data_incident_management_incident_unrelate.yml
index da418eb79a4..5798331175e 100644
--- a/config/feature_flags/development/usage_data_incident_management_incident_unrelate.yml
+++ b/config/feature_flags/development/usage_data_incident_management_incident_unrelate.yml
@@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_unrelate
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
+group: group::health
type: development
default_enabled: true
diff --git a/config/feature_flags/development/usage_data_incident_management_incident_zoom_meeting.yml b/config/feature_flags/development/usage_data_incident_management_incident_zoom_meeting.yml
index bbe81fdf3ed..921c466a167 100644
--- a/config/feature_flags/development/usage_data_incident_management_incident_zoom_meeting.yml
+++ b/config/feature_flags/development/usage_data_incident_management_incident_zoom_meeting.yml
@@ -1,7 +1,7 @@
---
name: usage_data_incident_management_incident_zoom_meeting
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40475
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229918
+group: group::health
type: development
default_enabled: true
diff --git a/config/feature_flags/development/users_search.yml b/config/feature_flags/development/users_search.yml
deleted file mode 100644
index 0397ee60225..00000000000
--- a/config/feature_flags/development/users_search.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: users_search
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: true
diff --git a/config/feature_flags/development/validate_import_decompressed_archive_size.yml b/config/feature_flags/development/validate_import_decompressed_archive_size.yml
index 28876f5180f..3e575657a78 100644
--- a/config/feature_flags/development/validate_import_decompressed_archive_size.yml
+++ b/config/feature_flags/development/validate_import_decompressed_archive_size.yml
@@ -1,7 +1,7 @@
---
name: validate_import_decompressed_archive_size
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::import
type: development
default_enabled: false
diff --git a/config/feature_flags/development/view_diffs_file_by_file.yml b/config/feature_flags/development/view_diffs_file_by_file.yml
index 4df755943ab..2ac8a7c3087 100644
--- a/config/feature_flags/development/view_diffs_file_by_file.yml
+++ b/config/feature_flags/development/view_diffs_file_by_file.yml
@@ -1,7 +1,7 @@
---
name: view_diffs_file_by_file
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35223
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/229848
+group: group::source code
type: development
default_enabled: true
diff --git a/config/feature_flags/development/vue_issuable_sidebar.yml b/config/feature_flags/development/vue_issuable_sidebar.yml
index d57852c9491..01c8bc3460b 100644
--- a/config/feature_flags/development/vue_issuable_sidebar.yml
+++ b/config/feature_flags/development/vue_issuable_sidebar.yml
@@ -1,7 +1,7 @@
---
name: vue_issuable_sidebar
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/18199
+rollout_issue_url:
+group: group::project management
type: development
default_enabled: false
diff --git a/config/feature_flags/development/vue_issuables_list.yml b/config/feature_flags/development/vue_issuables_list.yml
index 79ade237824..5fe5c7e3e9f 100644
--- a/config/feature_flags/development/vue_issuables_list.yml
+++ b/config/feature_flags/development/vue_issuables_list.yml
@@ -1,7 +1,7 @@
---
name: vue_issuables_list
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/15091
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/208093
+group: group::project management
type: development
default_enabled: false
diff --git a/config/feature_flags/development/vue_sidebar_labels.yml b/config/feature_flags/development/vue_sidebar_labels.yml
new file mode 100644
index 00000000000..13b1ae20dd7
--- /dev/null
+++ b/config/feature_flags/development/vue_sidebar_labels.yml
@@ -0,0 +1,7 @@
+---
+name: vue_sidebar_labels
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41561
+rollout_issue_url:
+group: group::project management
+type: development
+default_enabled: false
diff --git a/config/feature_flags/development/web_ide_primary_edit.yml b/config/feature_flags/development/web_ide_primary_edit.yml
index 8bea7fd94e7..33e3db0322b 100644
--- a/config/feature_flags/development/web_ide_primary_edit.yml
+++ b/config/feature_flags/development/web_ide_primary_edit.yml
@@ -1,7 +1,7 @@
---
name: web_ide_primary_edit
-introduced_by_url:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35957
rollout_issue_url:
-group:
+group: group::editor
type: development
default_enabled: false
diff --git a/config/feature_flags/development/webperf_experiment.yml b/config/feature_flags/development/webperf_experiment.yml
index 02c2a12cfaf..16f1f0ac773 100644
--- a/config/feature_flags/development/webperf_experiment.yml
+++ b/config/feature_flags/development/webperf_experiment.yml
@@ -1,7 +1,7 @@
---
name: webperf_experiment
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::editor
type: development
default_enabled: false
diff --git a/config/feature_flags/development/whats_new_drawer.yml b/config/feature_flags/development/whats_new_drawer.yml
index 6e31b17e05a..d6b7ffbbb57 100644
--- a/config/feature_flags/development/whats_new_drawer.yml
+++ b/config/feature_flags/development/whats_new_drawer.yml
@@ -1,7 +1,7 @@
---
name: whats_new_drawer
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38975
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/254186
+group: group::retention
type: development
default_enabled: false
diff --git a/config/feature_flags/development/widget_visibility_polling.yml b/config/feature_flags/development/widget_visibility_polling.yml
index c0c6962a46d..711d4be2fd1 100644
--- a/config/feature_flags/development/widget_visibility_polling.yml
+++ b/config/feature_flags/development/widget_visibility_polling.yml
@@ -1,7 +1,7 @@
---
name: widget_visibility_polling
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29318
+rollout_issue_url:
+group: group::source code
type: development
default_enabled: true
diff --git a/config/feature_flags/development/wiki_events_on_git_push.yml b/config/feature_flags/development/wiki_events_on_git_push.yml
deleted file mode 100644
index abab9029bae..00000000000
--- a/config/feature_flags/development/wiki_events_on_git_push.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: wiki_events_on_git_push
-introduced_by_url:
-rollout_issue_url:
-group:
-type: development
-default_enabled: false
diff --git a/config/feature_flags/licensed/minimal_access_role.yml b/config/feature_flags/licensed/minimal_access_role.yml
new file mode 100644
index 00000000000..ca27b86d35f
--- /dev/null
+++ b/config/feature_flags/licensed/minimal_access_role.yml
@@ -0,0 +1,7 @@
+---
+name: minimal_access_role
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40942
+rollout_issue_url:
+group: group::access
+type: licensed
+default_enabled: true
diff --git a/config/feature_flags/licensed/resource_access_token.yml b/config/feature_flags/licensed/resource_access_token.yml
new file mode 100644
index 00000000000..c236851881d
--- /dev/null
+++ b/config/feature_flags/licensed/resource_access_token.yml
@@ -0,0 +1,7 @@
+---
+name: resource_access_token
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29622
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/235765
+group: group::access
+type: licensed
+default_enabled: true
diff --git a/config/feature_flags/ops/api_kaminari_count_with_limit.yml b/config/feature_flags/ops/api_kaminari_count_with_limit.yml
new file mode 100644
index 00000000000..1fdeaa53b83
--- /dev/null
+++ b/config/feature_flags/ops/api_kaminari_count_with_limit.yml
@@ -0,0 +1,7 @@
+---
+name: api_kaminari_count_with_limit
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/23931
+rollout_issue_url:
+group: group::ecosystem
+type: ops
+default_enabled: false
diff --git a/config/feature_flags/ops/database_reindexing.yml b/config/feature_flags/ops/database_reindexing.yml
new file mode 100644
index 00000000000..ea000a052db
--- /dev/null
+++ b/config/feature_flags/ops/database_reindexing.yml
@@ -0,0 +1,7 @@
+---
+name: database_reindexing
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42705
+rollout_issue_url:
+group: group::database
+type: ops
+default_enabled: false \ No newline at end of file
diff --git a/config/feature_flags/ops/gitlab_sidekiq_enable_semi_reliable_fetcher.yml b/config/feature_flags/ops/gitlab_sidekiq_enable_semi_reliable_fetcher.yml
new file mode 100644
index 00000000000..680f8201d27
--- /dev/null
+++ b/config/feature_flags/ops/gitlab_sidekiq_enable_semi_reliable_fetcher.yml
@@ -0,0 +1,7 @@
+---
+name: gitlab_sidekiq_enable_semi_reliable_fetcher
+introduced_by_url:
+rollout_issue_url:
+group:
+type: ops
+default_enabled: true
diff --git a/config/feature_flags/ops/gitlab_sidekiq_reliable_fetcher.yml b/config/feature_flags/ops/gitlab_sidekiq_reliable_fetcher.yml
new file mode 100644
index 00000000000..ae395e19384
--- /dev/null
+++ b/config/feature_flags/ops/gitlab_sidekiq_reliable_fetcher.yml
@@ -0,0 +1,7 @@
+---
+name: gitlab_sidekiq_reliable_fetcher
+introduced_by_url:
+rollout_issue_url:
+group:
+type: ops
+default_enabled: true
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index 605729a1435..86df39830df 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -394,6 +394,14 @@ production: &base
# File that contains the shared secret key for verifying access for gitlab-pages.
# Default is '.gitlab_pages_secret' relative to Rails.root (i.e. root of the GitLab app).
# secret_file: /home/git/gitlab/.gitlab_pages_secret
+ object_store:
+ enabled: false
+ remote_directory: pages # The bucket name
+ connection:
+ provider: AWS
+ aws_access_key_id: AWS_ACCESS_KEY_ID
+ aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ region: us-east-1
## Mattermost
## For enabling Add to Mattermost button
@@ -1318,6 +1326,14 @@ test:
# user: YOUR_USERNAME
pages:
path: tmp/tests/pages
+ object_store:
+ enabled: false
+ remote_directory: pages # The bucket name
+ connection:
+ provider: AWS
+ aws_access_key_id: AWS_ACCESS_KEY_ID
+ aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ region: us-east-1
repositories:
storages:
default:
diff --git a/config/initializers/0_license.rb b/config/initializers/0_license.rb
index e7b46a14630..ce3103be2e4 100644
--- a/config/initializers/0_license.rb
+++ b/config/initializers/0_license.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
Gitlab.ee do
- public_key_file = File.read(Rails.root.join(".license_encryption_key.pub"))
+ prefix = ENV['GITLAB_LICENSE_MODE'] == 'test' ? 'test_' : ''
+ public_key_file = File.read(Rails.root.join(".#{prefix}license_encryption_key.pub"))
public_key = OpenSSL::PKey::RSA.new(public_key_file)
Gitlab::License.encryption_key = public_key
rescue
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index 15c9fd427ff..79052e2b1dd 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -297,6 +297,10 @@ Settings.pages['external_http'] ||= false unless Settings.pages['external_http']
Settings.pages['external_https'] ||= false unless Settings.pages['external_https'].present?
Settings.pages['artifacts_server'] ||= Settings.pages['enabled'] if Settings.pages['artifacts_server'].nil?
Settings.pages['secret_file'] ||= Rails.root.join('.gitlab_pages_secret')
+# We want pages zip archives to be stored on the same directory as old pages hierarchical structure
+# this will allow us to easier migrate existing instances with NFS
+Settings.pages['storage_path'] = Settings.pages['path']
+Settings.pages['object_store'] = ObjectStoreSettings.legacy_parse(Settings.pages['object_store'])
#
# Geo
@@ -520,8 +524,14 @@ Settings.cron_jobs['ci_platform_metrics_update_cron_worker']['job_class'] = 'CiP
Settings.cron_jobs['analytics_instance_statistics_count_job_trigger_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['analytics_instance_statistics_count_job_trigger_worker']['cron'] ||= '50 23 */1 * *'
Settings.cron_jobs['analytics_instance_statistics_count_job_trigger_worker']['job_class'] ||= 'Analytics::InstanceStatistics::CountJobTriggerWorker'
+Settings.cron_jobs['member_invitation_reminder_emails_worker'] ||= Settingslogic.new({})
+Settings.cron_jobs['member_invitation_reminder_emails_worker']['cron'] ||= '0 0 * * *'
+Settings.cron_jobs['member_invitation_reminder_emails_worker']['job_class'] = 'MemberInvitationReminderEmailsWorker'
Gitlab.ee do
+ Settings.cron_jobs['active_user_count_threshold_worker'] ||= Settingslogic.new({})
+ Settings.cron_jobs['active_user_count_threshold_worker']['cron'] ||= '0 12 * * *'
+ Settings.cron_jobs['active_user_count_threshold_worker']['job_class'] = 'ActiveUserCountThresholdWorker'
Settings.cron_jobs['adjourned_group_deletion_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['adjourned_group_deletion_worker']['cron'] ||= '0 3 * * *'
Settings.cron_jobs['adjourned_group_deletion_worker']['job_class'] = 'AdjournedGroupDeletionWorker'
@@ -727,6 +737,7 @@ Gitlab.ee do
Settings['kerberos'] ||= Settingslogic.new({})
Settings.kerberos['enabled'] = false if Settings.kerberos['enabled'].nil?
Settings.kerberos['keytab'] = nil if Settings.kerberos['keytab'].blank? # nil means use default keytab
+ Settings.kerberos['simple_ldap_linking_allowed_realms'] = [] if Settings.kerberos['simple_ldap_linking_allowed_realms'].blank?
Settings.kerberos['service_principal_name'] = nil if Settings.kerberos['service_principal_name'].blank? # nil means any SPN in keytab
Settings.kerberos['use_dedicated_port'] = false if Settings.kerberos['use_dedicated_port'].nil?
Settings.kerberos['https'] = Settings.gitlab.https if Settings.kerberos['https'].nil?
diff --git a/config/initializers/7_prometheus_metrics.rb b/config/initializers/7_prometheus_metrics.rb
index d5d8587f1c8..dbaebc83658 100644
--- a/config/initializers/7_prometheus_metrics.rb
+++ b/config/initializers/7_prometheus_metrics.rb
@@ -69,7 +69,9 @@ if !Rails.env.test? && Gitlab::Metrics.prometheus_metrics_enabled?
Gitlab::Metrics.gauge(:deployments, 'GitLab Version', {}, :max).set({ version: Gitlab::VERSION }, 1)
- Gitlab::Metrics::RequestsRackMiddleware.initialize_http_request_duration_seconds
+ unless Gitlab::Runtime.sidekiq?
+ Gitlab::Metrics::RequestsRackMiddleware.initialize_http_request_duration_seconds
+ end
rescue IOError => e
Gitlab::ErrorTracking.track_exception(e)
Gitlab::Metrics.error_detected!
diff --git a/config/initializers/forbid_sidekiq_in_transactions.rb b/config/initializers/forbid_sidekiq_in_transactions.rb
index 6bcd4dbd52f..f505fb5843a 100644
--- a/config/initializers/forbid_sidekiq_in_transactions.rb
+++ b/config/initializers/forbid_sidekiq_in_transactions.rb
@@ -46,7 +46,7 @@ end
module ActiveRecord
class Base
module SkipTransactionCheckAfterCommit
- def committed!(*)
+ def committed!(*args, **kwargs)
Sidekiq::Worker.skipping_transaction_check { super }
end
end
diff --git a/config/initializers/lograge.rb b/config/initializers/lograge.rb
index e3601a9538e..74b6218bf53 100644
--- a/config/initializers/lograge.rb
+++ b/config/initializers/lograge.rb
@@ -17,6 +17,13 @@ unless Gitlab::Runtime.sidekiq?
data[:duration_s] = Gitlab::Utils.ms_to_round_sec(data.delete(:duration)) if data[:duration]
data.merge!(::Gitlab::Metrics::Subscribers::ActiveRecord.db_counter_payload)
+ # Remove empty hashes to prevent type mismatches
+ # These are set to empty hashes in Lograge's ActionCable subscriber
+ # https://github.com/roidrage/lograge/blob/v0.11.2/lib/lograge/log_subscribers/action_cable.rb#L14-L16
+ %i(method path format).each do |key|
+ data[key] = nil if data[key] == {}
+ end
+
data
end
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index a33c28090e2..72e2b94fe07 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -3,13 +3,13 @@
def enable_reliable_fetch?
return true unless Feature::FlipperFeature.table_exists?
- Feature.enabled?(:gitlab_sidekiq_reliable_fetcher, default_enabled: true)
+ Feature.enabled?(:gitlab_sidekiq_reliable_fetcher, type: :ops, default_enabled: true)
end
def enable_semi_reliable_fetch_mode?
return true unless Feature::FlipperFeature.table_exists?
- Feature.enabled?(:gitlab_sidekiq_enable_semi_reliable_fetcher, default_enabled: true)
+ Feature.enabled?(:gitlab_sidekiq_enable_semi_reliable_fetcher, type: :ops, default_enabled: true)
end
# Custom Queues configuration
diff --git a/config/initializers/static_files.rb b/config/initializers/static_files.rb
index e02f0868e9f..b8e555d4f5e 100644
--- a/config/initializers/static_files.rb
+++ b/config/initializers/static_files.rb
@@ -21,16 +21,16 @@ if app.config.public_file_server.enabled
settings = {
enabled: true,
host: dev_server.host,
- port: dev_server.port,
manifest_host: dev_server.host,
- manifest_port: dev_server.port
+ manifest_port: dev_server.port,
+ port: dev_server.port
}
if Rails.env.development?
settings.merge!(
host: Gitlab.config.gitlab.host,
port: Gitlab.config.gitlab.port,
- https: false
+ https: Gitlab.config.gitlab.https
)
app.config.middleware.insert_before(
Gitlab::Middleware::Static,
diff --git a/config/initializers_before_autoloader/000_inflections.rb b/config/initializers_before_autoloader/000_inflections.rb
index a34b75d0382..85c53ab4320 100644
--- a/config/initializers_before_autoloader/000_inflections.rb
+++ b/config/initializers_before_autoloader/000_inflections.rb
@@ -24,11 +24,11 @@ ActiveSupport::Inflector.inflections do |inflect|
project_auto_devops
project_registry
project_statistics
+ snippet_repository_registry
system_note_metadata
- terraform_state_registry
+ terraform_state_version_registry
vulnerabilities_feedback
vulnerability_feedback
- snippet_repository_registry
)
inflect.acronym 'EE'
inflect.acronym 'CSP'
diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml
index e4a46be9bf3..14580e2bb3b 100644
--- a/config/locales/devise.en.yml
+++ b/config/locales/devise.en.yml
@@ -16,6 +16,8 @@ en:
timeout: "Your session expired. Please sign in again to continue."
unauthenticated: "You need to sign in or sign up before continuing."
unconfirmed: "You have to confirm your email address before continuing. Please check your email for the link we sent you, or click 'Resend confirmation email'."
+ blocked: "Your account has been blocked. Please contact your GitLab administrator if you think this is an error."
+ forbidden: "Your account does not have the required permission to login. Please contact your GitLab administrator if you think this is an error."
mailer:
confirmation_instructions:
subject: "Confirmation instructions"
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 7ff4e3bf7da..fb024b7ba2a 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -20,6 +20,8 @@ en:
token: "Grafana HTTP API Token"
grafana_url: "Grafana API URL"
grafana_enabled: "Grafana integration enabled"
+ service_desk_setting:
+ project_key: "Project name suffix"
user/user_detail:
job_title: 'Job title'
user/user_detail:
diff --git a/config/routes.rb b/config/routes.rb
index 9bd68bfeef6..dda505a3793 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -69,10 +69,6 @@ Rails.application.routes.draw do
# Begin of the /-/ scope.
# Use this scope for all new global routes.
scope path: '-' do
- # remove in 13.5
- get '/instance_statistics', to: redirect('admin/dev_ops_report')
- get '/instance_statistics/dev_ops_score', to: redirect('admin/dev_ops_report')
- get '/instance_statistics/cohorts', to: redirect('admin/cohorts')
# Autocomplete
get '/autocomplete/users' => 'autocomplete#users'
get '/autocomplete/users/:id' => 'autocomplete#user'
@@ -93,6 +89,8 @@ Rails.application.routes.draw do
resources :metrics, only: [:index]
mount Peek::Railtie => '/peek', as: 'peek_routes'
+ get 'runner_setup/platforms' => 'runner_setup#platforms'
+
# Boards resources shared between group and projects
resources :boards, only: [] do
resources :lists, module: :boards, only: [:index, :create, :update, :destroy] do
@@ -122,6 +120,7 @@ Rails.application.routes.draw do
get 'ide' => 'ide#index'
get 'ide/*vueroute' => 'ide#index', format: false
+ get 'ide/project/:namespace/:project/merge_requests/:id' => 'ide#index', format: false, as: :ide_merge_request
draw :operations
draw :jira_connect
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index bac8247de2e..60efd12bb57 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -91,8 +91,6 @@ namespace :admin do
resources :instance_statistics, only: :index
resource :dev_ops_report, controller: 'dev_ops_report', only: :show
- # remove in 13.5
- get '/dev_ops_score', to: redirect('admin/dev_ops_report')
resources :cohorts, only: :index
scope(path: 'projects/*namespace_id',
@@ -153,6 +151,7 @@ namespace :admin do
collection do
get :tag_list, format: :json
+ get :runner_setup_scripts, format: :json
end
end
diff --git a/config/routes/group.rb b/config/routes/group.rb
index e5bbfdf7548..7b74ccbe1b9 100644
--- a/config/routes/group.rb
+++ b/config/routes/group.rb
@@ -35,6 +35,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
put :reset_registration_token
patch :update_auto_devops
post :create_deploy_token, path: 'deploy_token/create', to: 'repository#create_deploy_token'
+ get :runner_setup_scripts, format: :json
end
resource :repository, only: [:show], controller: 'repository' do
@@ -61,6 +62,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
resources :milestones, constraints: { id: %r{[^/]+} } do
member do
+ get :issues
get :merge_requests
get :participants
get :labels
diff --git a/config/routes/import.rb b/config/routes/import.rb
index 0d3f202ba55..3ee44aa8659 100644
--- a/config/routes/import.rb
+++ b/config/routes/import.rb
@@ -69,6 +69,11 @@ namespace :import do
post :authorize
end
+ resource :bulk_import, only: [:create] do
+ post :configure
+ get :status
+ end
+
resource :manifest, only: [:create, :new], controller: :manifest do
get :status
get :realtime_changes
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 24b44646d95..c8fd5dc7e9e 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -93,6 +93,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
post :reset_cache
put :reset_registration_token
post :create_deploy_token, path: 'deploy_token/create', to: 'repository#create_deploy_token'
+ get :runner_setup_scripts, format: :json
end
resource :operations, only: [:show, :update] do
@@ -161,8 +162,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :milestones, constraints: { id: /\d+/ } do
member do
post :promote
- put :sort_issues
- put :sort_merge_requests
+ get :issues
get :merge_requests
get :participants
get :labels
@@ -311,6 +311,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :incidents, only: [:index]
+ get 'issues/incident/:id' => 'incidents#show', as: :issues_incident
+
namespace :error_tracking do
resources :projects, only: :index
end
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index 823ec2eddb3..a2cd2576d2b 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -62,6 +62,8 @@
- 1
- - cronjob
- 1
+- - dast_site_validation
+ - 1
- - default
- 1
- - delete_diff_files
@@ -76,6 +78,8 @@
- 1
- - deployment
- 3
+- - design_management_copy_design_collection
+ - 1
- - design_management_new_version
- 1
- - detect_repository_languages
@@ -164,6 +168,8 @@
- 1
- - metrics_dashboard_prune_old_annotations
- 1
+- - metrics_dashboard_sync_dashboards
+ - 1
- - migrate_external_diffs
- 1
- - namespaceless_project_destroy
@@ -232,6 +238,12 @@
- 1
- - propagate_integration
- 1
+- - propagate_integration_group
+ - 1
+- - propagate_integration_inherit
+ - 1
+- - propagate_integration_project
+ - 1
- - propagate_service_template
- 1
- - reactive_caching
diff --git a/config/webpack.config.js b/config/webpack.config.js
index 652ada1d832..ce00bef496d 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -278,6 +278,14 @@ module.exports = {
chunks: 'initial',
minChunks: autoEntriesCount * 0.9,
}),
+ graphql: {
+ priority: 16,
+ name: 'graphql',
+ chunks: 'all',
+ test: /[\\/]node_modules[\\/][^\\/]*(immer|apollo|graphql|zen-observable)[^\\/]*[\\/]/,
+ minChunks: 2,
+ reuseExistingChunk: true,
+ },
monaco: {
priority: 15,
name: 'monaco',
diff --git a/danger/metadata/Dangerfile b/danger/metadata/Dangerfile
index 1d7f863c201..b98d10d0654 100644
--- a/danger/metadata/Dangerfile
+++ b/danger/metadata/Dangerfile
@@ -40,10 +40,3 @@ has_pick_into_stable_label = gitlab.mr_labels.find { |label| label.start_with?('
if gitlab.branch_for_base != "master" && !has_pick_into_stable_label && !helper.security_mr?
warn "Most of the time, merge requests should target `master`. Otherwise, please set the relevant `Pick into X.Y` label."
end
-
-if gitlab.mr_json['title'].length > 72
- warn 'The title of this merge request is longer than 72 characters and ' \
- 'would violate our commit message rules when using the Squash on Merge ' \
- 'feature. Please consider adjusting the title, or rebase the ' \
- "commits manually and don't use Squash on Merge."
-end
diff --git a/danger/product_analytics/Dangerfile b/danger/product_analytics/Dangerfile
new file mode 100644
index 00000000000..01f2a4fee81
--- /dev/null
+++ b/danger/product_analytics/Dangerfile
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+PRODUCT_ANALYTICS_CHANGED_FILES_MESSAGE = <<~MSG
+For the following files, a review from the [Data team and Product Analytics team](https://gitlab.com/groups/gitlab-org/growth/product_analytics/engineers/-/group_members?with_inherited_permissions=exclude) is recommended
+Please check the ~"product analytics(telemetry)" [guide](https://docs.gitlab.com/ee/development/telemetry/usage_ping.html) and reach out to %<product_analytics_engineers_group>s group for a review.
+
+
+%<changed_files>s
+
+MSG
+
+UPDATE_METRICS_DEFINITIONS_MESSAGE = <<~MSG
+ When adding, changing, or updating metrics, please update the [Event dictionary Usage Ping table](https://docs.gitlab.com/ee/development/telemetry/event_dictionary.html#usage-ping).
+
+MSG
+
+PRODUCT_ANALYTICS_ENGINEERS_GROUP = '@gitlab-org/growth/product_analytics/engineers'
+
+tracking_files = [
+ 'lib/gitlab/tracking.rb',
+ 'spec/lib/gitlab/tracking_spec.rb',
+ 'app/helpers/tracking_helper.rb',
+ 'spec/helpers/tracking_helper_spec.rb',
+ 'app/assets/javascripts/tracking.js',
+ 'spec/frontend/tracking_spec.js'
+]
+
+usage_data_changed_files = helper.changed_files(/usage_data/)
+snowplow_events_changed_files = git.modified_files & tracking_files
+
+changed_files = (usage_data_changed_files + snowplow_events_changed_files)
+
+if changed_files.any?
+
+ mention = if helper.draft_mr?
+ "`#{PRODUCT_ANALYTICS_ENGINEERS_GROUP}`"
+ else
+ PRODUCT_ANALYTICS_ENGINEERS_GROUP
+ end
+
+ warn format(PRODUCT_ANALYTICS_CHANGED_FILES_MESSAGE, changed_files: helper.markdown_list(changed_files), product_analytics_engineers_group: mention)
+ warn format(UPDATE_METRICS_DEFINITIONS_MESSAGE) unless helper.changed_files(/usage_ping\.md/).any?
+
+ product_analytics_labels = ['product analytics(telemetry)']
+ product_analytics_labels << 'product analytics(telemetry)::review pending' unless helper.mr_has_labels?('product analytics(telemetry)::reviewed')
+
+ markdown(helper.prepare_labels_for_mr(product_analytics_labels))
+end
diff --git a/danger/telemetry/Dangerfile b/danger/telemetry/Dangerfile
deleted file mode 100644
index 6cfc1a005c3..00000000000
--- a/danger/telemetry/Dangerfile
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-TELEMETRY_CHANGED_FILES_MESSAGE = <<~MSG
-For the following files, a review from the [Data team and Telemetry team](https://gitlab.com/groups/gitlab-org/growth/telemetry/engineers/-/group_members?with_inherited_permissions=exclude) is recommended
-Please check the ~telemetry [guide](https://docs.gitlab.com/ee/development/telemetry/usage_ping.html) and reach out to @gitlab-org/growth/telemetry/engineers group for a review.
-
-%<changed_files>s
-
-MSG
-
-UPDATE_METRICS_DEFINITIONS_MESSAGE = <<~MSG
- When adding, changing, or updating metrics, please update the [Event dictionary Usage Ping table](https://docs.gitlab.com/ee/development/telemetry/event_dictionary.html#usage-ping).
-
-MSG
-
-tracking_files = [
- 'lib/gitlab/tracking.rb',
- 'spec/lib/gitlab/tracking_spec.rb',
- 'app/helpers/tracking_helper.rb',
- 'spec/helpers/tracking_helper_spec.rb',
- 'app/assets/javascripts/tracking.js',
- 'spec/frontend/tracking_spec.js'
-]
-
-usage_data_changed_files = helper.changed_files(/usage_data/)
-snowplow_events_changed_files = git.modified_files & tracking_files
-
-changed_files = (usage_data_changed_files + snowplow_events_changed_files)
-
-if changed_files.any?
- warn format(TELEMETRY_CHANGED_FILES_MESSAGE, changed_files: helper.markdown_list(changed_files))
- warn format(UPDATE_METRICS_DEFINITIONS_MESSAGE) unless helper.changed_files(/usage_ping\.md/).any?
-
- telemetry_labels = ['telemetry']
- telemetry_labels << 'telemetry::review pending' unless helper.mr_has_labels?('telemetry::reviewed')
-
- markdown(helper.prepare_labels_for_mr(telemetry_labels))
-end
diff --git a/data/whats_new/202008180001_12_10.yml b/data/whats_new/202008180001_12_10.yml
index 9acabbb76e6..66267bfcace 100644
--- a/data/whats_new/202008180001_12_10.yml
+++ b/data/whats_new/202008180001_12_10.yml
@@ -7,8 +7,8 @@
packages: [Ultimate, Gold]
url: https://docs.gitlab.com/ee/user/project/requirements/index.html
image_url:
- published_at: 2020-04-22
- release: 12.10
+ published_at: 2020-04-22
+ release: 12.10
- title: Retrieve CI/CD secrets from HashiCorp Vault
body: In this release, GitLab adds support for lightweight JSON Web Token (JWT) authentication to integrate with your existing HashiCorp Vault. Now, you can seamlessly provide secrets to CI/CD jobs by taking advantage of HashiCorp's JWT authentication method rather than manually having to provide secrets as a variable in GitLab.
stage: Release
diff --git a/db/fixtures/development/29_instance_statistics.rb b/db/fixtures/development/29_instance_statistics.rb
index c4af13d0f4d..e4ef0f26be0 100644
--- a/db/fixtures/development/29_instance_statistics.rb
+++ b/db/fixtures/development/29_instance_statistics.rb
@@ -6,9 +6,9 @@ Gitlab::Seeder.quiet do
model_class = Analytics::InstanceStatistics::Measurement
recorded_at = Date.today
- # Insert random counts for the last 10 weeks
- measurements = 10.times.flat_map do
- recorded_at = (recorded_at - 1.week).end_of_week.end_of_day - 5.minutes
+ # Insert random counts for the last 60 days
+ measurements = 60.times.flat_map do
+ recorded_at = (recorded_at - 1.day).end_of_day - 5.minutes
model_class.identifiers.map do |_, id|
{
diff --git a/db/migrate/20200905013247_add_golang_package_max_file_size_to_plan_limits.rb b/db/migrate/20200905013247_add_golang_package_max_file_size_to_plan_limits.rb
new file mode 100644
index 00000000000..0a6d0ce7339
--- /dev/null
+++ b/db/migrate/20200905013247_add_golang_package_max_file_size_to_plan_limits.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddGolangPackageMaxFileSizeToPlanLimits < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def change
+ add_column(:plan_limits, :golang_max_file_size, :bigint, default: 100.megabytes, null: false)
+ end
+end
diff --git a/db/migrate/20200907092610_add_user_id_to_group_import_states.rb b/db/migrate/20200907092610_add_user_id_to_group_import_states.rb
new file mode 100644
index 00000000000..231bafeabdb
--- /dev/null
+++ b/db/migrate/20200907092610_add_user_id_to_group_import_states.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class AddUserIdToGroupImportStates < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ unless column_exists?(:group_import_states, :user_id)
+ with_lock_retries do
+ add_column :group_import_states, :user_id, :bigint
+ end
+ end
+
+ add_concurrent_foreign_key :group_import_states, :users, column: :user_id, on_delete: :cascade
+ add_concurrent_index :group_import_states, :user_id, where: 'user_id IS NOT NULL', name: 'index_group_import_states_on_user_id'
+ end
+
+ def down
+ with_lock_retries do
+ remove_column :group_import_states, :user_id
+ end
+ end
+end
diff --git a/db/migrate/20200908094810_add_new_setting_to_namespace_setting.rb b/db/migrate/20200908094810_add_new_setting_to_namespace_setting.rb
new file mode 100644
index 00000000000..27fc93a2c53
--- /dev/null
+++ b/db/migrate/20200908094810_add_new_setting_to_namespace_setting.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class AddNewSettingToNamespaceSetting < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ with_lock_retries do
+ add_column :namespace_settings, :allow_mfa_for_subgroups, :boolean, default: true, null: false
+ end
+ end
+
+ def down
+ with_lock_retries do
+ remove_column :namespace_settings, :allow_mfa_for_subgroups
+ end
+ end
+end
diff --git a/db/migrate/20200909040555_create_package_events.rb b/db/migrate/20200909040555_create_package_events.rb
new file mode 100644
index 00000000000..000ff051a7c
--- /dev/null
+++ b/db/migrate/20200909040555_create_package_events.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class CreatePackageEvents < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ create_table :packages_events do |t|
+ t.integer :event_type, null: false, limit: 2
+ t.integer :event_scope, null: false, limit: 2
+ t.integer :originator_type, null: false, limit: 2
+ t.bigint :originator
+ t.datetime_with_timezone :created_at, null: false
+
+ t.references :package, primary_key: false, default: nil, index: true, foreign_key: { to_table: :packages_packages, on_delete: :nullify }, type: :bigint
+ end
+ end
+end
diff --git a/db/migrate/20200909083339_add_change_reviewer_merge_request_to_notification_settings.rb b/db/migrate/20200909083339_add_change_reviewer_merge_request_to_notification_settings.rb
new file mode 100644
index 00000000000..7024cc4a263
--- /dev/null
+++ b/db/migrate/20200909083339_add_change_reviewer_merge_request_to_notification_settings.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddChangeReviewerMergeRequestToNotificationSettings < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def change
+ add_column :notification_settings, :change_reviewer_merge_request, :boolean
+ end
+end
diff --git a/db/migrate/20200912152943_rename_admin_notification_email_application_setting.rb b/db/migrate/20200912152943_rename_admin_notification_email_application_setting.rb
new file mode 100644
index 00000000000..b469099014d
--- /dev/null
+++ b/db/migrate/20200912152943_rename_admin_notification_email_application_setting.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class RenameAdminNotificationEmailApplicationSetting < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ rename_column_concurrently :application_settings, :admin_notification_email, :abuse_notification_email
+ end
+
+ def down
+ undo_rename_column_concurrently :application_settings, :admin_notification_email, :abuse_notification_email
+ end
+end
diff --git a/db/migrate/20200914070140_add_expiration_policy_started_at_to_container_repositories.rb b/db/migrate/20200914070140_add_expiration_policy_started_at_to_container_repositories.rb
new file mode 100644
index 00000000000..fa787fe98b0
--- /dev/null
+++ b/db/migrate/20200914070140_add_expiration_policy_started_at_to_container_repositories.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class AddExpirationPolicyStartedAtToContainerRepositories < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def up
+ add_column(:container_repositories, :expiration_policy_started_at, :datetime_with_timezone)
+ end
+
+ def down
+ remove_column(:container_repositories, :expiration_policy_started_at)
+ end
+end
diff --git a/db/migrate/20200915134004_add_indices_to_approval_project_rules.rb b/db/migrate/20200915134004_add_indices_to_approval_project_rules.rb
new file mode 100644
index 00000000000..b7b0e1da2cb
--- /dev/null
+++ b/db/migrate/20200915134004_add_indices_to_approval_project_rules.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+# See https://docs.gitlab.com/ee/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddIndicesToApprovalProjectRules < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ RULE_TYPE_INDEX_NAME = 'index_approval_project_rules_on_id_with_regular_type'
+ RULE_ID_INDEX_NAME = 'index_approval_project_rules_users_on_approval_project_rule_id'
+
+ def up
+ add_concurrent_index :approval_project_rules, :id, where: 'rule_type = 0', name: RULE_TYPE_INDEX_NAME
+ add_concurrent_index :approval_project_rules_users, :approval_project_rule_id, name: RULE_ID_INDEX_NAME
+ end
+
+ def down
+ remove_concurrent_index :approval_project_rules, :id, where: 'rule_type = 0', name: RULE_TYPE_INDEX_NAME
+ remove_concurrent_index :approval_project_rules_users, :approval_project_rule_id, name: RULE_ID_INDEX_NAME
+ end
+end
diff --git a/db/migrate/20200916135044_add_state_id_index_to_merge_requests.rb b/db/migrate/20200916135044_add_state_id_index_to_merge_requests.rb
new file mode 100644
index 00000000000..bec162ff888
--- /dev/null
+++ b/db/migrate/20200916135044_add_state_id_index_to_merge_requests.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AddStateIdIndexToMergeRequests < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :merge_requests, [:target_project_id, :iid, :state_id], name: :index_merge_requests_on_target_project_id_and_iid_and_state_id
+ end
+
+ def down
+ remove_concurrent_index :merge_requests, [:target_project_id, :iid, :state_id], name: :index_merge_requests_on_target_project_id_and_iid_and_state_id
+ end
+end
diff --git a/db/migrate/20200916151442_add_result_index_to_authentication_events.rb b/db/migrate/20200916151442_add_result_index_to_authentication_events.rb
new file mode 100644
index 00000000000..13b0521038e
--- /dev/null
+++ b/db/migrate/20200916151442_add_result_index_to_authentication_events.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class AddResultIndexToAuthenticationEvents < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ INDEX_NAME = 'index_authentication_events_on_provider_user_id_created_at'
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :authentication_events, [:provider, :user_id, :created_at], where: 'result = 1', name: INDEX_NAME
+ end
+
+ def down
+ remove_concurrent_index_by_name :authentication_events, INDEX_NAME
+ end
+end
diff --git a/db/migrate/20200916165232_add_debian_max_file_size_to_plan_limits.rb b/db/migrate/20200916165232_add_debian_max_file_size_to_plan_limits.rb
new file mode 100644
index 00000000000..9a91a5d2195
--- /dev/null
+++ b/db/migrate/20200916165232_add_debian_max_file_size_to_plan_limits.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddDebianMaxFileSizeToPlanLimits < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def change
+ add_column :plan_limits, :debian_max_file_size, :bigint, default: 3.gigabytes, null: false
+ end
+end
diff --git a/db/migrate/20200921093826_add_index_to_user_preferences.rb b/db/migrate/20200921093826_add_index_to_user_preferences.rb
new file mode 100644
index 00000000000..78b04eb7a83
--- /dev/null
+++ b/db/migrate/20200921093826_add_index_to_user_preferences.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AddIndexToUserPreferences < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :user_preferences, :gitpod_enabled, name: :index_user_preferences_on_gitpod_enabled
+ end
+
+ def down
+ remove_concurrent_index :user_preferences, :gitpod_enabled, name: :index_user_preferences_on_gitpod_enabled
+ end
+end
diff --git a/db/migrate/20200921130028_add_pages_deployment_id_to_pages_metadata.rb b/db/migrate/20200921130028_add_pages_deployment_id_to_pages_metadata.rb
new file mode 100644
index 00000000000..395ce43d8ac
--- /dev/null
+++ b/db/migrate/20200921130028_add_pages_deployment_id_to_pages_metadata.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddPagesDeploymentIdToPagesMetadata < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def change
+ add_column :project_pages_metadata, :pages_deployment_id, :bigint
+ end
+end
diff --git a/db/migrate/20200921131313_add_foreign_key_to_pages_deployment_id_in_project_pages_metadata.rb b/db/migrate/20200921131313_add_foreign_key_to_pages_deployment_id_in_project_pages_metadata.rb
new file mode 100644
index 00000000000..8611f3ab943
--- /dev/null
+++ b/db/migrate/20200921131313_add_foreign_key_to_pages_deployment_id_in_project_pages_metadata.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class AddForeignKeyToPagesDeploymentIdInProjectPagesMetadata < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ INDEX_NAME = 'index_project_pages_metadata_on_pages_deployment_id'
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index(:project_pages_metadata, :pages_deployment_id, name: INDEX_NAME)
+ add_concurrent_foreign_key :project_pages_metadata, :pages_deployments, column: :pages_deployment_id, on_delete: :nullify
+ end
+
+ def down
+ remove_foreign_key_if_exists :project_pages_metadata, column: :pages_deployment_id
+ remove_concurrent_index_by_name(:project_pages_metadata, INDEX_NAME)
+ end
+end
diff --git a/db/migrate/20200921203231_remove_duplicate_cluster_agents_index.rb b/db/migrate/20200921203231_remove_duplicate_cluster_agents_index.rb
new file mode 100644
index 00000000000..3f073e32d84
--- /dev/null
+++ b/db/migrate/20200921203231_remove_duplicate_cluster_agents_index.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class RemoveDuplicateClusterAgentsIndex < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ INDEX = 'index_cluster_agents_on_project_id'
+
+ disable_ddl_transaction!
+
+ def up
+ remove_concurrent_index_by_name :cluster_agents, INDEX
+ end
+
+ def down
+ add_concurrent_index :cluster_agents, :project_id, name: INDEX
+ end
+end
diff --git a/db/migrate/20200922052316_create_issue_email_participants.rb b/db/migrate/20200922052316_create_issue_email_participants.rb
new file mode 100644
index 00000000000..a8aeb9d9a5a
--- /dev/null
+++ b/db/migrate/20200922052316_create_issue_email_participants.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class CreateIssueEmailParticipants < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ unless table_exists?(:issue_email_participants)
+ with_lock_retries do
+ create_table :issue_email_participants do |t|
+ t.references :issue, index: false, null: false, foreign_key: { on_delete: :cascade }
+ t.datetime_with_timezone :created_at, null: false
+ t.datetime_with_timezone :updated_at, null: false
+ t.text :email, null: false
+
+ t.index [:issue_id, :email], unique: true
+ end
+ end
+
+ add_text_limit(:issue_email_participants, :email, 255)
+ end
+ end
+
+ def down
+ with_lock_retries do
+ drop_table :issue_email_participants
+ end
+ end
+end
diff --git a/db/migrate/20200922093004_add_postgres_index_view.rb b/db/migrate/20200922093004_add_postgres_index_view.rb
new file mode 100644
index 00000000000..c16eae4dd0b
--- /dev/null
+++ b/db/migrate/20200922093004_add_postgres_index_view.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class AddPostgresIndexView < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def up
+ execute(<<~SQL)
+ CREATE VIEW postgres_indexes AS
+ SELECT
+ pg_namespace.nspname || '.' || pg_class.relname as identifier,
+ pg_index.indexrelid,
+ pg_namespace.nspname as schema,
+ pg_class.relname as name,
+ pg_index.indisunique as unique,
+ pg_index.indisvalid as valid_index,
+ pg_class.relispartition as partitioned,
+ pg_index.indisexclusion as exclusion,
+ pg_indexes.indexdef as definition,
+ pg_relation_size(pg_class.oid) as ondisk_size_bytes
+ FROM pg_index
+ INNER JOIN pg_class ON pg_class.oid = pg_index.indexrelid
+ INNER JOIN pg_namespace ON pg_class.relnamespace = pg_namespace.oid
+ INNER JOIN pg_indexes ON pg_class.relname = pg_indexes.indexname
+ WHERE pg_namespace.nspname <> 'pg_catalog'
+ SQL
+ end
+
+ def down
+ execute(<<~SQL)
+ DROP VIEW postgres_indexes
+ SQL
+ end
+end
diff --git a/db/migrate/20200923071622_add_description_to_requirements.rb b/db/migrate/20200923071622_add_description_to_requirements.rb
new file mode 100644
index 00000000000..b4b1250c10a
--- /dev/null
+++ b/db/migrate/20200923071622_add_description_to_requirements.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class AddDescriptionToRequirements < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ # rubocop:disable Migration/AddLimitToTextColumns
+ # limit for description is added in 20200923071644_add_text_limit_to_requirements_description
+ # for description_html limit is not set because it's for caching purposes and
+ # its value is generated from `description`
+ def change
+ add_column :requirements, :description, :text
+ add_column :requirements, :description_html, :text
+ end
+ # rubocop:enable Migration/AddLimitToTextColumns
+end
diff --git a/db/migrate/20200923071644_add_text_limit_to_requirements_description.rb b/db/migrate/20200923071644_add_text_limit_to_requirements_description.rb
new file mode 100644
index 00000000000..0172d6bbba3
--- /dev/null
+++ b/db/migrate/20200923071644_add_text_limit_to_requirements_description.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AddTextLimitToRequirementsDescription < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_text_limit :requirements, :description, 10_000
+ end
+
+ def down
+ remove_text_limit :requirements, :description
+ end
+end
diff --git a/db/migrate/20200923102312_update_programming_language_colors.rb b/db/migrate/20200923102312_update_programming_language_colors.rb
new file mode 100644
index 00000000000..37233bd3148
--- /dev/null
+++ b/db/migrate/20200923102312_update_programming_language_colors.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+require 'yaml'
+
+class UpdateProgrammingLanguageColors < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ class ProgrammingLanguage < ActiveRecord::Base; end
+
+ def up
+ YAML.load_file("vendor/languages.yml").each do |name, metadata|
+ color = metadata["color"]
+ next unless color.present?
+
+ ProgrammingLanguage.where(name: name).update(color: color)
+ end
+ end
+
+ def down
+ # noop
+ end
+end
diff --git a/db/migrate/20200923130057_remove_tmp_container_scanning_index.rb b/db/migrate/20200923130057_remove_tmp_container_scanning_index.rb
new file mode 100644
index 00000000000..e9ab6f9ff15
--- /dev/null
+++ b/db/migrate/20200923130057_remove_tmp_container_scanning_index.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class RemoveTmpContainerScanningIndex < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ INDEX_NAME = 'tmp_index_for_fixing_inconsistent_vulnerability_occurrences'
+ disable_ddl_transaction!
+
+ def up
+ remove_concurrent_index_by_name(:vulnerability_occurrences, INDEX_NAME)
+ end
+
+ def down
+ # report_type: 2 container scanning
+ add_concurrent_index(:vulnerability_occurrences, :id,
+ where: "LENGTH(location_fingerprint) = 40 AND report_type = 2",
+ name: INDEX_NAME)
+ end
+end
diff --git a/db/migrate/20200923140404_add_postgres_reindex_actions_table.rb b/db/migrate/20200923140404_add_postgres_reindex_actions_table.rb
new file mode 100644
index 00000000000..ed37e44e201
--- /dev/null
+++ b/db/migrate/20200923140404_add_postgres_reindex_actions_table.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class AddPostgresReindexActionsTable < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ create_table :postgres_reindex_actions, if_not_exists: true do |t|
+ t.datetime_with_timezone :action_start, null: false
+ t.datetime_with_timezone :action_end
+ t.bigint :ondisk_size_bytes_start, null: false
+ t.bigint :ondisk_size_bytes_end
+ t.integer :state, limit: 2, null: false, default: 0
+ t.text :index_identifier, null: false, index: true
+ end
+
+ add_text_limit(:postgres_reindex_actions, :index_identifier, 255)
+ end
+
+ def down
+ drop_table :postgres_reindex_actions
+ end
+end
diff --git a/db/migrate/20200927224750_add_incident_issue_type_index_to_issues.rb b/db/migrate/20200927224750_add_incident_issue_type_index_to_issues.rb
new file mode 100644
index 00000000000..ed4dd5b9cc1
--- /dev/null
+++ b/db/migrate/20200927224750_add_incident_issue_type_index_to_issues.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class AddIncidentIssueTypeIndexToIssues < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ INCIDENT_ISSUE_TYPE = 1
+ INDEX_NAME = 'index_issues_project_id_issue_type_incident'
+
+ def up
+ add_concurrent_index :issues, :project_id, where: "issue_type = #{INCIDENT_ISSUE_TYPE}", name: INDEX_NAME
+ end
+
+ def down
+ remove_concurrent_index_by_name(:issues, INDEX_NAME)
+ end
+end
diff --git a/db/migrate/20200928095732_add_state_to_dast_site_validation.rb b/db/migrate/20200928095732_add_state_to_dast_site_validation.rb
new file mode 100644
index 00000000000..7adeef54d71
--- /dev/null
+++ b/db/migrate/20200928095732_add_state_to_dast_site_validation.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class AddStateToDastSiteValidation < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ # rubocop:disable Migration/AddLimitToTextColumns
+ # limit is added in 20200928100408_add_text_limit_to_dast_site_validation_state.rb
+ def change
+ add_column :dast_site_validations, :state, :text, default: :pending, null: false
+ end
+ # rubocop:enable Migration/AddLimitToTextColumns
+end
diff --git a/db/migrate/20200928100408_add_text_limit_to_dast_site_validation_state.rb b/db/migrate/20200928100408_add_text_limit_to_dast_site_validation_state.rb
new file mode 100644
index 00000000000..18bf7ee4bdc
--- /dev/null
+++ b/db/migrate/20200928100408_add_text_limit_to_dast_site_validation_state.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AddTextLimitToDastSiteValidationState < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_text_limit :dast_site_validations, :state, 255
+ end
+
+ def down
+ remove_text_limit :dast_site_validations, :state
+ end
+end
diff --git a/db/migrate/20200928203531_create_alert_management_http_integrations.rb b/db/migrate/20200928203531_create_alert_management_http_integrations.rb
new file mode 100644
index 00000000000..fe13fe400e3
--- /dev/null
+++ b/db/migrate/20200928203531_create_alert_management_http_integrations.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+class CreateAlertManagementHttpIntegrations < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ UNIQUE_INDEX = 'index_http_integrations_on_active_and_project_and_endpoint'
+
+ disable_ddl_transaction!
+
+ def up
+ create_table :alert_management_http_integrations, if_not_exists: true do |t|
+ t.timestamps_with_timezone
+ t.bigint :project_id, index: true, null: false
+ t.boolean :active, null: false, default: false
+ t.text :encrypted_token, null: false
+ t.text :encrypted_token_iv, null: false
+ t.text :endpoint_identifier, null: false
+ t.text :name, null: false
+ end
+
+ add_text_limit :alert_management_http_integrations, :encrypted_token, 255
+ add_text_limit :alert_management_http_integrations, :encrypted_token_iv, 255
+ add_text_limit :alert_management_http_integrations, :endpoint_identifier, 255
+ add_text_limit :alert_management_http_integrations, :name, 255
+
+ add_index :alert_management_http_integrations,
+ [:active, :project_id, :endpoint_identifier],
+ unique: true,
+ name: UNIQUE_INDEX,
+ where: 'active'
+ end
+
+ def down
+ drop_table :alert_management_http_integrations
+ end
+end
diff --git a/db/migrate/20200928210524_add_http_integrations_project_foreign_key.rb b/db/migrate/20200928210524_add_http_integrations_project_foreign_key.rb
new file mode 100644
index 00000000000..f59a5a0b5bb
--- /dev/null
+++ b/db/migrate/20200928210524_add_http_integrations_project_foreign_key.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class AddHttpIntegrationsProjectForeignKey < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ with_lock_retries do
+ add_foreign_key :alert_management_http_integrations, :projects, column: :project_id, on_delete: :cascade # rubocop:disable Migration/AddConcurrentForeignKey
+ end
+ end
+
+ def down
+ with_lock_retries do
+ remove_foreign_key :alert_management_http_integrations, column: :project_id
+ end
+ end
+end
diff --git a/db/migrate/20200928233632_remove_terraform_state_verification_indexes.rb b/db/migrate/20200928233632_remove_terraform_state_verification_indexes.rb
new file mode 100644
index 00000000000..0256d580cd6
--- /dev/null
+++ b/db/migrate/20200928233632_remove_terraform_state_verification_indexes.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class RemoveTerraformStateVerificationIndexes < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ CHECKSUM_INDEX_NAME = "terraform_states_verification_checksum_partial".freeze
+ FAILURE_INDEX_NAME = "terraform_states_verification_failure_partial".freeze
+
+ disable_ddl_transaction!
+
+ def up
+ remove_concurrent_index :terraform_states, :verification_failure, name: FAILURE_INDEX_NAME
+ remove_concurrent_index :terraform_states, :verification_checksum, name: CHECKSUM_INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index :terraform_states, :verification_failure, where: "(verification_failure IS NOT NULL)", name: FAILURE_INDEX_NAME
+ add_concurrent_index :terraform_states, :verification_checksum, where: "(verification_checksum IS NOT NULL)", name: CHECKSUM_INDEX_NAME
+ end
+end
diff --git a/db/migrate/20200929063159_add_require_admin_approval_after_user_signup_to_application_settings.rb b/db/migrate/20200929063159_add_require_admin_approval_after_user_signup_to_application_settings.rb
new file mode 100644
index 00000000000..92d82757b79
--- /dev/null
+++ b/db/migrate/20200929063159_add_require_admin_approval_after_user_signup_to_application_settings.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddRequireAdminApprovalAfterUserSignupToApplicationSettings < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def change
+ add_column :application_settings, :require_admin_approval_after_user_signup, :boolean, default: false, null: false
+ end
+end
diff --git a/db/migrate/20200930094812_update_postgres_indexes_view.rb b/db/migrate/20200930094812_update_postgres_indexes_view.rb
new file mode 100644
index 00000000000..b36ea362e6f
--- /dev/null
+++ b/db/migrate/20200930094812_update_postgres_indexes_view.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+class UpdatePostgresIndexesView < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def up
+ execute(<<~SQL)
+ CREATE OR REPLACE VIEW postgres_indexes AS
+ SELECT
+ pg_namespace.nspname || '.' || pg_class.relname as identifier,
+ pg_index.indexrelid,
+ pg_namespace.nspname as schema,
+ pg_class.relname as name,
+ pg_index.indisunique as unique,
+ pg_index.indisvalid as valid_index,
+ pg_class.relispartition as partitioned,
+ pg_index.indisexclusion as exclusion,
+ pg_indexes.indexdef as definition,
+ pg_relation_size(pg_class.oid) as ondisk_size_bytes
+ FROM pg_index
+ INNER JOIN pg_class ON pg_class.oid = pg_index.indexrelid
+ INNER JOIN pg_namespace ON pg_class.relnamespace = pg_namespace.oid
+ INNER JOIN pg_indexes ON pg_class.relname = pg_indexes.indexname
+ WHERE pg_namespace.nspname <> 'pg_catalog'
+ AND pg_namespace.nspname IN (
+ current_schema(),
+ 'gitlab_partitions_dynamic',
+ 'gitlab_partitions_static'
+ )
+ SQL
+ end
+
+ def down
+ execute(<<~SQL)
+ CREATE OR REPLACE VIEW postgres_indexes AS
+ SELECT
+ pg_namespace.nspname || '.' || pg_class.relname as identifier,
+ pg_index.indexrelid,
+ pg_namespace.nspname as schema,
+ pg_class.relname as name,
+ pg_index.indisunique as unique,
+ pg_index.indisvalid as valid_index,
+ pg_class.relispartition as partitioned,
+ pg_index.indisexclusion as exclusion,
+ pg_indexes.indexdef as definition,
+ pg_relation_size(pg_class.oid) as ondisk_size_bytes
+ FROM pg_index
+ INNER JOIN pg_class ON pg_class.oid = pg_index.indexrelid
+ INNER JOIN pg_namespace ON pg_class.relnamespace = pg_namespace.oid
+ INNER JOIN pg_indexes ON pg_class.relname = pg_indexes.indexname
+ WHERE pg_namespace.nspname <> 'pg_catalog'
+ SQL
+ end
+end
diff --git a/db/migrate/20200930131343_add_index_on_project_id_and_sha_to_deployments.rb b/db/migrate/20200930131343_add_index_on_project_id_and_sha_to_deployments.rb
new file mode 100644
index 00000000000..19a536f8f6e
--- /dev/null
+++ b/db/migrate/20200930131343_add_index_on_project_id_and_sha_to_deployments.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+# See https://docs.gitlab.com/ee/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddIndexOnProjectIdAndShaToDeployments < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+ DOWNTIME = false
+ INDEX_NAME = 'index_deployments_on_project_id_sha'
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :deployments, [:project_id, :sha], name: INDEX_NAME
+ end
+
+ def down
+ remove_concurrent_index_by_name(:deployments, INDEX_NAME)
+ end
+end
diff --git a/db/migrate/20200930132319_add_api_fuzzing_to_plan_limits.rb b/db/migrate/20200930132319_add_api_fuzzing_to_plan_limits.rb
new file mode 100644
index 00000000000..9be79974ee4
--- /dev/null
+++ b/db/migrate/20200930132319_add_api_fuzzing_to_plan_limits.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddApiFuzzingToPlanLimits < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def change
+ add_column :plan_limits, "ci_max_artifact_size_api_fuzzing", :integer, default: 0, null: false
+ end
+end
diff --git a/db/post_migrate/20200511083541_cleanup_projects_with_missing_namespace.rb b/db/post_migrate/20200511083541_cleanup_projects_with_missing_namespace.rb
index 1ead10a4de6..9e606b2264b 100644
--- a/db/post_migrate/20200511083541_cleanup_projects_with_missing_namespace.rb
+++ b/db/post_migrate/20200511083541_cleanup_projects_with_missing_namespace.rb
@@ -66,8 +66,6 @@ class CleanupProjectsWithMissingNamespace < ActiveRecord::Migration[6.0]
end
def ensure_bio_is_assigned_to_user_details
- return if Feature.disabled?(:migrate_bio_to_user_details, default_enabled: true)
-
user_detail.bio = bio.to_s[0...255]
end
diff --git a/db/post_migrate/20200907092715_add_not_null_constraint_to_user_on_group_import_states.rb b/db/post_migrate/20200907092715_add_not_null_constraint_to_user_on_group_import_states.rb
new file mode 100644
index 00000000000..4f2edad43d8
--- /dev/null
+++ b/db/post_migrate/20200907092715_add_not_null_constraint_to_user_on_group_import_states.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AddNotNullConstraintToUserOnGroupImportStates < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_not_null_constraint :group_import_states, :user_id, validate: false
+ end
+
+ def down
+ remove_not_null_constraint :group_import_states, :user_id
+ end
+end
diff --git a/db/post_migrate/20200908064229_add_partial_index_to_ci_builds_table_on_user_id_name.rb b/db/post_migrate/20200908064229_add_partial_index_to_ci_builds_table_on_user_id_name.rb
new file mode 100644
index 00000000000..433fa957c38
--- /dev/null
+++ b/db/post_migrate/20200908064229_add_partial_index_to_ci_builds_table_on_user_id_name.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+class AddPartialIndexToCiBuildsTableOnUserIdName < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ disable_ddl_transaction!
+
+ INDEX_NAME = 'index_partial_ci_builds_on_user_id_name_parser_features'
+ FILTER_CONDITION = <<~SQL
+ (((type)::text = 'Ci::Build'::text) AND
+ ((name)::text = ANY (
+ ARRAY[
+ ('container_scanning'::character varying)::text,
+ ('dast'::character varying)::text,
+ ('dependency_scanning'::character varying)::text,
+ ('license_management'::character varying)::text,
+ ('license_scanning'::character varying)::text,
+ ('sast'::character varying)::text,
+ ('coverage_fuzzing'::character varying)::text,
+ ('secret_detection'::character varying)::text
+ ]
+ ))
+ )
+ SQL
+
+ def up
+ add_concurrent_index(:ci_builds,
+ [:user_id, :name],
+ where: FILTER_CONDITION,
+ name: INDEX_NAME)
+ end
+
+ def down
+ remove_concurrent_index_by_name :ci_builds, INDEX_NAME
+ end
+end
diff --git a/db/post_migrate/20200909161624_cleanup_group_import_states_with_null_user_id.rb b/db/post_migrate/20200909161624_cleanup_group_import_states_with_null_user_id.rb
new file mode 100644
index 00000000000..861d9e40e77
--- /dev/null
+++ b/db/post_migrate/20200909161624_cleanup_group_import_states_with_null_user_id.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+class CleanupGroupImportStatesWithNullUserId < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ # With BATCH_SIZE=1000 and group_import_states.count=600 on GitLab.com
+ # - 1 iteration will be run
+ # - each batch requires on average ~2500ms
+ # - 600 rows require on average ~1500ms
+ # Expected total run time: ~2500ms
+ BATCH_SIZE = 1000
+
+ disable_ddl_transaction!
+
+ class User < ActiveRecord::Base
+ self.table_name = 'users'
+ end
+
+ class Namespace < ActiveRecord::Base
+ self.table_name = 'namespaces'
+
+ belongs_to :owner, class_name: 'CleanupGroupImportStatesWithNullUserId::User'
+ end
+
+ class Member < ActiveRecord::Base
+ self.table_name = 'members'
+ self.inheritance_column = :_type_disabled
+
+ belongs_to :user, class_name: 'CleanupGroupImportStatesWithNullUserId::User'
+ end
+
+ class Group < Namespace
+ OWNER = 50
+
+ self.inheritance_column = :_type_disabled
+
+ def default_owner
+ owners.first || parent&.default_owner || owner
+ end
+
+ def parent
+ Group.find_by_id(parent_id)
+ end
+
+ def owners
+ Member.where(type: 'GroupMember', source_type: 'Namespace', source_id: id, requested_at: nil, access_level: OWNER).map(&:user)
+ end
+ end
+
+ class GroupImportState < ActiveRecord::Base
+ include ::EachBatch
+
+ self.table_name = 'group_import_states'
+
+ belongs_to :group, class_name: 'CleanupGroupImportStatesWithNullUserId::Group'
+ belongs_to :user, class_name: 'CleanupGroupImportStatesWithNullUserId::User'
+ end
+
+ def up
+ User.reset_column_information
+ Namespace.reset_column_information
+ Member.reset_column_information
+ Group.reset_column_information
+ GroupImportState.reset_column_information
+
+ GroupImportState.each_batch(of: BATCH_SIZE) do |batch|
+ batch.each do |group_import_state|
+ owner_id = Group.find_by_id(group_import_state.group_id)&.default_owner&.id
+
+ group_import_state.update!(user_id: owner_id) if owner_id
+ end
+ end
+
+ GroupImportState.where('user_id IS NULL').delete_all
+ end
+
+ def down
+ # no-op : can't go back to `NULL` without first dropping the `NOT NULL` constraint
+ end
+end
diff --git a/db/post_migrate/20200912153218_cleanup_admin_notification_email_application_setting_rename.rb b/db/post_migrate/20200912153218_cleanup_admin_notification_email_application_setting_rename.rb
new file mode 100644
index 00000000000..35c54b64ddf
--- /dev/null
+++ b/db/post_migrate/20200912153218_cleanup_admin_notification_email_application_setting_rename.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class CleanupAdminNotificationEmailApplicationSettingRename < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ cleanup_concurrent_column_rename :application_settings, :admin_notification_email, :abuse_notification_email
+ end
+
+ def down
+ undo_cleanup_concurrent_column_rename :application_settings, :admin_notification_email, :abuse_notification_email
+ end
+end
diff --git a/db/post_migrate/20200915185707_ensure_filled_file_store_on_package_files.rb b/db/post_migrate/20200915185707_ensure_filled_file_store_on_package_files.rb
new file mode 100644
index 00000000000..ec6f6df27bc
--- /dev/null
+++ b/db/post_migrate/20200915185707_ensure_filled_file_store_on_package_files.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class EnsureFilledFileStoreOnPackageFiles < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ BACKGROUND_MIGRATION_CLASS = 'SetNullPackageFilesFileStoreToLocalValue'
+ BATCH_SIZE = 5_000
+ LOCAL_STORE = 1 # equal to ObjectStorage::Store::LOCAL
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ module Packages
+ class PackageFile < ActiveRecord::Base
+ self.table_name = 'packages_package_files'
+
+ include ::EachBatch
+ end
+ end
+
+ def up
+ Gitlab::BackgroundMigration.steal(BACKGROUND_MIGRATION_CLASS)
+
+ # Do a manual update in case we lost BG jobs. The expected record count should be 0 or very low.
+ Packages::PackageFile.where(file_store: nil).each_batch(of: BATCH_SIZE) do |batch, index|
+ batch.update_all(file_store: LOCAL_STORE)
+ end
+ end
+
+ def down
+ # no-op
+ end
+end
diff --git a/db/post_migrate/20200915191156_validate_not_null_file_store_on_package_files.rb b/db/post_migrate/20200915191156_validate_not_null_file_store_on_package_files.rb
new file mode 100644
index 00000000000..5e6db9cec3f
--- /dev/null
+++ b/db/post_migrate/20200915191156_validate_not_null_file_store_on_package_files.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class ValidateNotNullFileStoreOnPackageFiles < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ # Remove index which was only added to fill file_store
+ INDEX_NAME = 'index_packages_package_files_file_store_is_null'
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ validate_not_null_constraint :packages_package_files, :file_store
+
+ remove_concurrent_index_by_name :packages_package_files, INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index :packages_package_files, :id, where: 'file_store IS NULL', name: INDEX_NAME
+ end
+end
diff --git a/db/post_migrate/20200917135802_remove_duplicated_cs_findings_without_vulnerability_id.rb b/db/post_migrate/20200917135802_remove_duplicated_cs_findings_without_vulnerability_id.rb
new file mode 100644
index 00000000000..0e161b5de78
--- /dev/null
+++ b/db/post_migrate/20200917135802_remove_duplicated_cs_findings_without_vulnerability_id.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class RemoveDuplicatedCsFindingsWithoutVulnerabilityId < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ BATCH_SIZE = 1_000
+
+ INTERVAL = 2.minutes
+
+ # 1_500 records will be deleted
+ def up
+ return unless Gitlab.com?
+
+ migration = Gitlab::BackgroundMigration::RemoveDuplicatedCsFindingsWithoutVulnerabilityId
+ migration_name = migration.to_s.demodulize
+ relation = migration::Finding.container_scanning.with_broken_fingerprint.where(vulnerability_id: nil)
+ queue_background_migration_jobs_by_range_at_intervals(relation,
+ migration_name,
+ INTERVAL,
+ batch_size: BATCH_SIZE)
+ end
+
+ def down
+ # no-op
+ end
+end
diff --git a/db/post_migrate/20200917165525_update_index_on_namespaces_for_type_and_id.rb b/db/post_migrate/20200917165525_update_index_on_namespaces_for_type_and_id.rb
new file mode 100644
index 00000000000..35b72b4f160
--- /dev/null
+++ b/db/post_migrate/20200917165525_update_index_on_namespaces_for_type_and_id.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class UpdateIndexOnNamespacesForTypeAndId < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ disable_ddl_transaction!
+
+ OLD_INDEX_NAME = 'index_namespaces_on_type_partial'
+ NEW_INDEX_NAME = 'index_namespaces_on_type_and_id_partial'
+
+ def up
+ add_concurrent_index(:namespaces, [:type, :id], where: 'type IS NOT NULL', name: NEW_INDEX_NAME)
+
+ remove_concurrent_index_by_name(:namespaces, OLD_INDEX_NAME)
+ end
+
+ def down
+ add_concurrent_index(:namespaces, :type, where: 'type IS NOT NULL', name: OLD_INDEX_NAME)
+
+ remove_concurrent_index_by_name(:namespaces, NEW_INDEX_NAME)
+ end
+end
diff --git a/db/post_migrate/20200922054642_drop_snowplow_iglu_registry_url_from_application_settings.rb b/db/post_migrate/20200922054642_drop_snowplow_iglu_registry_url_from_application_settings.rb
new file mode 100644
index 00000000000..d3e64f1f4c2
--- /dev/null
+++ b/db/post_migrate/20200922054642_drop_snowplow_iglu_registry_url_from_application_settings.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class DropSnowplowIgluRegistryUrlFromApplicationSettings < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def change
+ remove_column :application_settings, :snowplow_iglu_registry_url, :string, limit: 255
+ end
+end
diff --git a/db/post_migrate/20200922095954_remove_instance_statistics_visibility_private_from_application_settings.rb b/db/post_migrate/20200922095954_remove_instance_statistics_visibility_private_from_application_settings.rb
new file mode 100644
index 00000000000..1d01e54013d
--- /dev/null
+++ b/db/post_migrate/20200922095954_remove_instance_statistics_visibility_private_from_application_settings.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class RemoveInstanceStatisticsVisibilityPrivateFromApplicationSettings < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def up
+ remove_column :application_settings, :instance_statistics_visibility_private
+ end
+
+ def down
+ add_column :application_settings, :instance_statistics_visibility_private, :boolean, default: false, null: false
+ end
+end
diff --git a/db/post_migrate/20200922170907_change_index_on_pipeline_status.rb b/db/post_migrate/20200922170907_change_index_on_pipeline_status.rb
new file mode 100644
index 00000000000..61648788d7f
--- /dev/null
+++ b/db/post_migrate/20200922170907_change_index_on_pipeline_status.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class ChangeIndexOnPipelineStatus < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ OLD_INDEX_NAME = 'index_ci_pipelines_on_status'
+ NEW_INDEX_NAME = 'index_ci_pipelines_on_status_and_id'
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :ci_pipelines, [:status, :id], name: NEW_INDEX_NAME
+ remove_concurrent_index_by_name :ci_pipelines, name: OLD_INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index :ci_pipelines, :status, name: OLD_INDEX_NAME
+ remove_concurrent_index_by_name :ci_pipelines, name: NEW_INDEX_NAME
+ end
+end
diff --git a/db/post_migrate/20200922231755_remove_created_by_user_id_from_cluster_providers_aws.rb b/db/post_migrate/20200922231755_remove_created_by_user_id_from_cluster_providers_aws.rb
new file mode 100644
index 00000000000..02cc9676202
--- /dev/null
+++ b/db/post_migrate/20200922231755_remove_created_by_user_id_from_cluster_providers_aws.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class RemoveCreatedByUserIdFromClusterProvidersAws < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ INDEX_NAME = 'index_cluster_providers_aws_on_created_by_user_id'
+
+ disable_ddl_transaction!
+
+ def up
+ with_lock_retries do
+ remove_column :cluster_providers_aws, :created_by_user_id
+ end
+ end
+
+ def down
+ unless column_exists?(:cluster_providers_aws, :created_by_user_id)
+ add_column :cluster_providers_aws, :created_by_user_id, :integer
+ end
+
+ add_concurrent_index :cluster_providers_aws, :created_by_user_id, name: INDEX_NAME
+
+ add_concurrent_foreign_key :cluster_providers_aws, :users, column: :created_by_user_id, on_delete: :nullify
+ end
+end
diff --git a/db/post_migrate/20201001022100_validate_designs_filename_text_limit.rb b/db/post_migrate/20201001022100_validate_designs_filename_text_limit.rb
new file mode 100644
index 00000000000..35ed8c20671
--- /dev/null
+++ b/db/post_migrate/20201001022100_validate_designs_filename_text_limit.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class ValidateDesignsFilenameTextLimit < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ validate_text_limit :design_management_designs, :filename
+ end
+
+ def down
+ # no-op
+ end
+end
diff --git a/db/schema_migrations/20200905013247 b/db/schema_migrations/20200905013247
new file mode 100644
index 00000000000..b0926ca1d99
--- /dev/null
+++ b/db/schema_migrations/20200905013247
@@ -0,0 +1 @@
+860c45fd6293f2f8f10d7351cb5a2fbab2cc9147e56b538cb62d75469b039ef0 \ No newline at end of file
diff --git a/db/schema_migrations/20200907092610 b/db/schema_migrations/20200907092610
new file mode 100644
index 00000000000..63eecc83a3c
--- /dev/null
+++ b/db/schema_migrations/20200907092610
@@ -0,0 +1 @@
+de1051e8f2d9f042ac923686b8c61e743c695736e8c7ebfdf6f87ab1ed09a11f \ No newline at end of file
diff --git a/db/schema_migrations/20200907092715 b/db/schema_migrations/20200907092715
new file mode 100644
index 00000000000..700db177075
--- /dev/null
+++ b/db/schema_migrations/20200907092715
@@ -0,0 +1 @@
+2610104c89134b94d460a714f17c23e5e35d76147c1f25be6c02804a39725d6c \ No newline at end of file
diff --git a/db/schema_migrations/20200908064229 b/db/schema_migrations/20200908064229
new file mode 100644
index 00000000000..37958f04de8
--- /dev/null
+++ b/db/schema_migrations/20200908064229
@@ -0,0 +1 @@
+e73076f6d7540372ee16fe26dcb44e8b84dde69e27c46483ee26883e81059501 \ No newline at end of file
diff --git a/db/schema_migrations/20200908094810 b/db/schema_migrations/20200908094810
new file mode 100644
index 00000000000..a047e64107b
--- /dev/null
+++ b/db/schema_migrations/20200908094810
@@ -0,0 +1 @@
+327cd19cd6c0f273aea61ffebf10046fb7229516bc4ed28f3c0290bb3ad04755 \ No newline at end of file
diff --git a/db/schema_migrations/20200909040555 b/db/schema_migrations/20200909040555
new file mode 100644
index 00000000000..27855514146
--- /dev/null
+++ b/db/schema_migrations/20200909040555
@@ -0,0 +1 @@
+f68d29be164299e5ccf73347841d27b17f028941e37e3510d3da9d513762a17f \ No newline at end of file
diff --git a/db/schema_migrations/20200909083339 b/db/schema_migrations/20200909083339
new file mode 100644
index 00000000000..179a683412c
--- /dev/null
+++ b/db/schema_migrations/20200909083339
@@ -0,0 +1 @@
+8b2090e953e6205b65555408a88d3da7f6bce28b0baa52d1a43a3a3e8001b7e1 \ No newline at end of file
diff --git a/db/schema_migrations/20200909161624 b/db/schema_migrations/20200909161624
new file mode 100644
index 00000000000..941af468398
--- /dev/null
+++ b/db/schema_migrations/20200909161624
@@ -0,0 +1 @@
+2d7f514429e9a08ce13995feff43e221b3e2e74737ed48f81e104008d8ec24b9 \ No newline at end of file
diff --git a/db/schema_migrations/20200912152943 b/db/schema_migrations/20200912152943
new file mode 100644
index 00000000000..4dd1ae708cd
--- /dev/null
+++ b/db/schema_migrations/20200912152943
@@ -0,0 +1 @@
+b8d7a2ec9ecf51fd7cb9346e1484b45d5b472a85d90ad270d08c1cca1b44f039 \ No newline at end of file
diff --git a/db/schema_migrations/20200912153218 b/db/schema_migrations/20200912153218
new file mode 100644
index 00000000000..9c013281ea2
--- /dev/null
+++ b/db/schema_migrations/20200912153218
@@ -0,0 +1 @@
+fd632247f1588c537e83574edd7936c530e154091e3101d0404da3b7ef8b4bef \ No newline at end of file
diff --git a/db/schema_migrations/20200914070140 b/db/schema_migrations/20200914070140
new file mode 100644
index 00000000000..b9cac3a3726
--- /dev/null
+++ b/db/schema_migrations/20200914070140
@@ -0,0 +1 @@
+8cabe224e89ef77fe4a2bc1f8bf7381faaa10d1cab4907a26aeb2162a126af52 \ No newline at end of file
diff --git a/db/schema_migrations/20200915134004 b/db/schema_migrations/20200915134004
new file mode 100644
index 00000000000..a8857de0ada
--- /dev/null
+++ b/db/schema_migrations/20200915134004
@@ -0,0 +1 @@
+dd630c76819641ad64a5f6ae40ad4f49e7fbe1c783398d97886dc7e9852a245e \ No newline at end of file
diff --git a/db/schema_migrations/20200915185707 b/db/schema_migrations/20200915185707
new file mode 100644
index 00000000000..2161aa5acf8
--- /dev/null
+++ b/db/schema_migrations/20200915185707
@@ -0,0 +1 @@
+e1ae80d6f0a6372bb329d45257d9a0a8ca5b6a83718d2a10ee295b8c4c97f60e \ No newline at end of file
diff --git a/db/schema_migrations/20200915191156 b/db/schema_migrations/20200915191156
new file mode 100644
index 00000000000..5005ee15989
--- /dev/null
+++ b/db/schema_migrations/20200915191156
@@ -0,0 +1 @@
+d8ddec6b234d59b3b85705dfa7b724d3be4974bfa57fae70aa5c2dbdd2e73cfa \ No newline at end of file
diff --git a/db/schema_migrations/20200916135044 b/db/schema_migrations/20200916135044
new file mode 100644
index 00000000000..8bb10fbb1e3
--- /dev/null
+++ b/db/schema_migrations/20200916135044
@@ -0,0 +1 @@
+299e6fb38da303f06ab006a25701ff1c58ce2abfa8cf93c3c8b866ee0b2bd1cc \ No newline at end of file
diff --git a/db/schema_migrations/20200916151442 b/db/schema_migrations/20200916151442
new file mode 100644
index 00000000000..36b2a4e9962
--- /dev/null
+++ b/db/schema_migrations/20200916151442
@@ -0,0 +1 @@
+aef52404e6ce83d5d4b3de65ad00b665334f5ff2e5b7b6c3c622f79313657f26 \ No newline at end of file
diff --git a/db/schema_migrations/20200916165232 b/db/schema_migrations/20200916165232
new file mode 100644
index 00000000000..8e97fcf3efa
--- /dev/null
+++ b/db/schema_migrations/20200916165232
@@ -0,0 +1 @@
+18a3981a3becefe6700dd5fea87e8ba9478c0e83ddc80de1b3ee2ed77c221ce6 \ No newline at end of file
diff --git a/db/schema_migrations/20200917135802 b/db/schema_migrations/20200917135802
new file mode 100644
index 00000000000..7b948de646b
--- /dev/null
+++ b/db/schema_migrations/20200917135802
@@ -0,0 +1 @@
+8d9e75f7c6344b03cb740fa691fcbb5bea1751802741229158701bc1af975897 \ No newline at end of file
diff --git a/db/schema_migrations/20200917165525 b/db/schema_migrations/20200917165525
new file mode 100644
index 00000000000..bf01a95ad14
--- /dev/null
+++ b/db/schema_migrations/20200917165525
@@ -0,0 +1 @@
+0080b9192ba5b4ea3853cfd930d58e10b9619f3d9a54016b574712e5ec2084f6 \ No newline at end of file
diff --git a/db/schema_migrations/20200921093826 b/db/schema_migrations/20200921093826
new file mode 100644
index 00000000000..4a405de5215
--- /dev/null
+++ b/db/schema_migrations/20200921093826
@@ -0,0 +1 @@
+8d14013bcb4d8302c91e331f619fb6f621ab79907aebc421d99c9484ecd7a5d8 \ No newline at end of file
diff --git a/db/schema_migrations/20200921130028 b/db/schema_migrations/20200921130028
new file mode 100644
index 00000000000..b718f21c156
--- /dev/null
+++ b/db/schema_migrations/20200921130028
@@ -0,0 +1 @@
+60835078e0a0bd191e9b1f0316f894c5223d6849277992b5034ed4ff9a798fe4 \ No newline at end of file
diff --git a/db/schema_migrations/20200921131313 b/db/schema_migrations/20200921131313
new file mode 100644
index 00000000000..559701acf69
--- /dev/null
+++ b/db/schema_migrations/20200921131313
@@ -0,0 +1 @@
+e4f9e918c86705409555cde065f30ba0c0c405dfd1918f47a169a5dc5c244a8d \ No newline at end of file
diff --git a/db/schema_migrations/20200921203231 b/db/schema_migrations/20200921203231
new file mode 100644
index 00000000000..544b7146ebf
--- /dev/null
+++ b/db/schema_migrations/20200921203231
@@ -0,0 +1 @@
+7f62ce5117a16213bad6537dfeae2af4016262c533f8fa6b7a19572077bcf8d7 \ No newline at end of file
diff --git a/db/schema_migrations/20200922052316 b/db/schema_migrations/20200922052316
new file mode 100644
index 00000000000..31bd12178ef
--- /dev/null
+++ b/db/schema_migrations/20200922052316
@@ -0,0 +1 @@
+384d022662437de21b4b3b97bf2f1dec2925be6afe4b62828c97dc9b3b3fc77c \ No newline at end of file
diff --git a/db/schema_migrations/20200922054642 b/db/schema_migrations/20200922054642
new file mode 100644
index 00000000000..443c13c256e
--- /dev/null
+++ b/db/schema_migrations/20200922054642
@@ -0,0 +1 @@
+ad63096e49440f7f2a15ea2747689ca39f52fdcebc1949a1feed82a22f432e9e \ No newline at end of file
diff --git a/db/schema_migrations/20200922093004 b/db/schema_migrations/20200922093004
new file mode 100644
index 00000000000..04dd5dbfcf6
--- /dev/null
+++ b/db/schema_migrations/20200922093004
@@ -0,0 +1 @@
+705d010620b1aa95e86c8fb5fb9175fe77778376d228003e9fd2c8d0bb20a347 \ No newline at end of file
diff --git a/db/schema_migrations/20200922095954 b/db/schema_migrations/20200922095954
new file mode 100644
index 00000000000..2282da76380
--- /dev/null
+++ b/db/schema_migrations/20200922095954
@@ -0,0 +1 @@
+c55f27f817afc60462e5dc43755a4ddd76f1399f5e461bab4b36bf5e5b26ce0a \ No newline at end of file
diff --git a/db/schema_migrations/20200922170907 b/db/schema_migrations/20200922170907
new file mode 100644
index 00000000000..66ff701973d
--- /dev/null
+++ b/db/schema_migrations/20200922170907
@@ -0,0 +1 @@
+ab044b609a29e9a179813de79dab9770665917a8ed78db907755a64f2d4aa47c \ No newline at end of file
diff --git a/db/schema_migrations/20200922231755 b/db/schema_migrations/20200922231755
new file mode 100644
index 00000000000..504df45b957
--- /dev/null
+++ b/db/schema_migrations/20200922231755
@@ -0,0 +1 @@
+0019105cd2112e138b9926dc000b0c54b41fca6dfb2c4f658900040e0ecb3b70 \ No newline at end of file
diff --git a/db/schema_migrations/20200923071622 b/db/schema_migrations/20200923071622
new file mode 100644
index 00000000000..629b241ce0a
--- /dev/null
+++ b/db/schema_migrations/20200923071622
@@ -0,0 +1 @@
+1751fa6522a88582cb6a580acc95665f4e3f3a879f2365d5fd0a824ad1b4806d \ No newline at end of file
diff --git a/db/schema_migrations/20200923071644 b/db/schema_migrations/20200923071644
new file mode 100644
index 00000000000..431ed5af1f0
--- /dev/null
+++ b/db/schema_migrations/20200923071644
@@ -0,0 +1 @@
+0df2b1e65ef0dc563c55e575968e4fd768cec2e713e3b1c999cf584ef62b629d \ No newline at end of file
diff --git a/db/schema_migrations/20200923102312 b/db/schema_migrations/20200923102312
new file mode 100644
index 00000000000..52ed07034fc
--- /dev/null
+++ b/db/schema_migrations/20200923102312
@@ -0,0 +1 @@
+f19e61e3863905885c8b5b2129be2586d912d616a5b3b54e99a72c5760082059 \ No newline at end of file
diff --git a/db/schema_migrations/20200923130057 b/db/schema_migrations/20200923130057
new file mode 100644
index 00000000000..e95c2da0a1d
--- /dev/null
+++ b/db/schema_migrations/20200923130057
@@ -0,0 +1 @@
+84b272d61f6ab6e9f9f8eb059ba139a3fa0d2f1bbeb337f2e4e7cd4034822b44 \ No newline at end of file
diff --git a/db/schema_migrations/20200923140404 b/db/schema_migrations/20200923140404
new file mode 100644
index 00000000000..9c1bfd59b4c
--- /dev/null
+++ b/db/schema_migrations/20200923140404
@@ -0,0 +1 @@
+8a1898f62a47575c7ea428198163e04ff427e7ab6cd04eb9897930a6b7753681 \ No newline at end of file
diff --git a/db/schema_migrations/20200927224750 b/db/schema_migrations/20200927224750
new file mode 100644
index 00000000000..9454bec58c5
--- /dev/null
+++ b/db/schema_migrations/20200927224750
@@ -0,0 +1 @@
+8e0c5be3d6fe2d0d718c7b7a99d84b14dfc6006f780ec0622eb5aae937e6b367 \ No newline at end of file
diff --git a/db/schema_migrations/20200928095732 b/db/schema_migrations/20200928095732
new file mode 100644
index 00000000000..15f0d5e3eae
--- /dev/null
+++ b/db/schema_migrations/20200928095732
@@ -0,0 +1 @@
+5ff9bf6c542f686729abf18f282351d9bb5b895070c6f06c5fc8d125be4a38f7 \ No newline at end of file
diff --git a/db/schema_migrations/20200928100408 b/db/schema_migrations/20200928100408
new file mode 100644
index 00000000000..e49c397924d
--- /dev/null
+++ b/db/schema_migrations/20200928100408
@@ -0,0 +1 @@
+56984f720cfde6ad28b8612e092809252f948797fecb64dfc3b82d5b3b74f7ec \ No newline at end of file
diff --git a/db/schema_migrations/20200928203531 b/db/schema_migrations/20200928203531
new file mode 100644
index 00000000000..229e2dc9b1f
--- /dev/null
+++ b/db/schema_migrations/20200928203531
@@ -0,0 +1 @@
+fc9719e0822d17eacb375b4adb2eac35afba04cafc2bd429c82c502d2fe5f12e \ No newline at end of file
diff --git a/db/schema_migrations/20200928210524 b/db/schema_migrations/20200928210524
new file mode 100644
index 00000000000..41905703d01
--- /dev/null
+++ b/db/schema_migrations/20200928210524
@@ -0,0 +1 @@
+788fd828a7aa8fb8741f13596f54fc4d9f4f5caeaf34d08aed47bbefe363ae75 \ No newline at end of file
diff --git a/db/schema_migrations/20200928233632 b/db/schema_migrations/20200928233632
new file mode 100644
index 00000000000..d66fc8310f2
--- /dev/null
+++ b/db/schema_migrations/20200928233632
@@ -0,0 +1 @@
+c390843c8a93ca429d1442d7b54f5f9dc01e0343c71ed1a046f11968cc10810d \ No newline at end of file
diff --git a/db/schema_migrations/20200929063159 b/db/schema_migrations/20200929063159
new file mode 100644
index 00000000000..b7815e9bb39
--- /dev/null
+++ b/db/schema_migrations/20200929063159
@@ -0,0 +1 @@
+9ef08404b964ccae3e12332340f16c6b8bb2bbdb2c04ba105fe1c0c7e6bf84f2 \ No newline at end of file
diff --git a/db/schema_migrations/20200930094812 b/db/schema_migrations/20200930094812
new file mode 100644
index 00000000000..ad03614e47d
--- /dev/null
+++ b/db/schema_migrations/20200930094812
@@ -0,0 +1 @@
+b92b48a17bfd350a70017bfee99bcfb3dbc5ae9e33c8f23ab593666e5c3900aa \ No newline at end of file
diff --git a/db/schema_migrations/20200930131343 b/db/schema_migrations/20200930131343
new file mode 100644
index 00000000000..a872607f241
--- /dev/null
+++ b/db/schema_migrations/20200930131343
@@ -0,0 +1 @@
+45530bb3090d9e8df3a79f42a06b042e0c40f6e185078c6d79d7ec334175c7d5 \ No newline at end of file
diff --git a/db/schema_migrations/20200930132319 b/db/schema_migrations/20200930132319
new file mode 100644
index 00000000000..7bffd187de2
--- /dev/null
+++ b/db/schema_migrations/20200930132319
@@ -0,0 +1 @@
+1ded640c70d5e569f0f26729c96d2dc27c528bcb045e28f073ed8fce9f918d95 \ No newline at end of file
diff --git a/db/schema_migrations/20201001022100 b/db/schema_migrations/20201001022100
new file mode 100644
index 00000000000..9c11e9e6094
--- /dev/null
+++ b/db/schema_migrations/20201001022100
@@ -0,0 +1 @@
+a581071767c5484e8286951854b0effe04d4b8951f208f10ceb4812940363959 \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 6e921810cfb..52369090416 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -8830,6 +8830,31 @@ CREATE SEQUENCE alert_management_alerts_id_seq
ALTER SEQUENCE alert_management_alerts_id_seq OWNED BY alert_management_alerts.id;
+CREATE TABLE alert_management_http_integrations (
+ id bigint NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ updated_at timestamp with time zone NOT NULL,
+ project_id bigint NOT NULL,
+ active boolean DEFAULT false NOT NULL,
+ encrypted_token text NOT NULL,
+ encrypted_token_iv text NOT NULL,
+ endpoint_identifier text NOT NULL,
+ name text NOT NULL,
+ CONSTRAINT check_286943b636 CHECK ((char_length(encrypted_token_iv) <= 255)),
+ CONSTRAINT check_392143ccf4 CHECK ((char_length(name) <= 255)),
+ CONSTRAINT check_e270820180 CHECK ((char_length(endpoint_identifier) <= 255)),
+ CONSTRAINT check_f68577c4af CHECK ((char_length(encrypted_token) <= 255))
+);
+
+CREATE SEQUENCE alert_management_http_integrations_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+ALTER SEQUENCE alert_management_http_integrations_id_seq OWNED BY alert_management_http_integrations.id;
+
CREATE TABLE alerts_service_data (
id bigint NOT NULL,
service_id integer NOT NULL,
@@ -9031,7 +9056,6 @@ CREATE TABLE application_settings (
session_expire_delay integer DEFAULT 10080 NOT NULL,
import_sources text,
help_page_text text,
- admin_notification_email character varying,
shared_runners_enabled boolean DEFAULT true NOT NULL,
max_artifacts_size integer DEFAULT 100 NOT NULL,
runners_registration_token character varying,
@@ -9146,7 +9170,6 @@ CREATE TABLE application_settings (
snowplow_enabled boolean DEFAULT false NOT NULL,
snowplow_collector_hostname character varying,
snowplow_cookie_domain character varying,
- instance_statistics_visibility_private boolean DEFAULT false NOT NULL,
web_ide_clientside_preview_enabled boolean DEFAULT false NOT NULL,
user_show_add_ssh_key_message boolean DEFAULT true NOT NULL,
custom_project_templates_group_id integer,
@@ -9196,7 +9219,6 @@ CREATE TABLE application_settings (
throttle_incident_management_notification_enabled boolean DEFAULT false NOT NULL,
throttle_incident_management_notification_period_in_seconds integer DEFAULT 3600,
throttle_incident_management_notification_per_period integer DEFAULT 3600,
- snowplow_iglu_registry_url character varying(255),
push_event_hooks_limit integer DEFAULT 3 NOT NULL,
push_event_activities_limit integer DEFAULT 3 NOT NULL,
custom_http_clone_url_root character varying(511),
@@ -9272,6 +9294,8 @@ CREATE TABLE application_settings (
elasticsearch_client_request_timeout integer DEFAULT 0 NOT NULL,
gitpod_enabled boolean DEFAULT false NOT NULL,
gitpod_url text DEFAULT 'https://gitpod.io/'::text,
+ abuse_notification_email character varying,
+ require_admin_approval_after_user_signup boolean DEFAULT false NOT NULL,
CONSTRAINT check_2dba05b802 CHECK ((char_length(gitpod_url) <= 255)),
CONSTRAINT check_51700b31b5 CHECK ((char_length(default_branch_name) <= 255)),
CONSTRAINT check_9c6c447a13 CHECK ((char_length(maintenance_mode_message) <= 255)),
@@ -10701,7 +10725,6 @@ ALTER SEQUENCE cluster_projects_id_seq OWNED BY cluster_projects.id;
CREATE TABLE cluster_providers_aws (
id bigint NOT NULL,
cluster_id bigint NOT NULL,
- created_by_user_id integer,
num_nodes integer NOT NULL,
status integer NOT NULL,
created_at timestamp with time zone NOT NULL,
@@ -11083,7 +11106,8 @@ CREATE TABLE container_repositories (
name character varying NOT NULL,
created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL,
- status smallint
+ status smallint,
+ expiration_policy_started_at timestamp with time zone
);
CREATE SEQUENCE container_repositories_id_seq
@@ -11233,7 +11257,9 @@ CREATE TABLE dast_site_validations (
validation_strategy smallint NOT NULL,
url_base text NOT NULL,
url_path text NOT NULL,
+ state text DEFAULT 'pending'::text NOT NULL,
CONSTRAINT check_13b34efe4b CHECK ((char_length(url_path) <= 255)),
+ CONSTRAINT check_283be72e9b CHECK ((char_length(state) <= 255)),
CONSTRAINT check_cd3b538210 CHECK ((char_length(url_base) <= 255))
);
@@ -11411,7 +11437,8 @@ CREATE TABLE design_management_designs (
project_id integer NOT NULL,
issue_id integer,
filename character varying NOT NULL,
- relative_position integer
+ relative_position integer,
+ CONSTRAINT check_07155e2715 CHECK ((char_length((filename)::text) <= 255))
);
CREATE SEQUENCE design_management_designs_id_seq
@@ -12488,6 +12515,7 @@ CREATE TABLE group_import_states (
status smallint DEFAULT 0 NOT NULL,
jid text,
last_error text,
+ user_id bigint,
CONSTRAINT check_87b58f6b30 CHECK ((char_length(last_error) <= 255)),
CONSTRAINT check_96558fff96 CHECK ((char_length(jid) <= 100))
);
@@ -12674,6 +12702,24 @@ CREATE TABLE issue_assignees (
issue_id integer NOT NULL
);
+CREATE TABLE issue_email_participants (
+ id bigint NOT NULL,
+ issue_id bigint NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ updated_at timestamp with time zone NOT NULL,
+ email text NOT NULL,
+ CONSTRAINT check_2c321d408d CHECK ((char_length(email) <= 255))
+);
+
+CREATE SEQUENCE issue_email_participants_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+ALTER SEQUENCE issue_email_participants_id_seq OWNED BY issue_email_participants.id;
+
CREATE TABLE issue_links (
id integer NOT NULL,
source_id integer NOT NULL,
@@ -13561,7 +13607,8 @@ CREATE TABLE namespace_settings (
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
namespace_id integer NOT NULL,
- prevent_forking_outside_group boolean DEFAULT false NOT NULL
+ prevent_forking_outside_group boolean DEFAULT false NOT NULL,
+ allow_mfa_for_subgroups boolean DEFAULT true NOT NULL
);
CREATE TABLE namespace_statistics (
@@ -13727,7 +13774,8 @@ CREATE TABLE notification_settings (
notification_email character varying,
fixed_pipeline boolean,
new_release boolean,
- moved_project boolean DEFAULT true NOT NULL
+ moved_project boolean DEFAULT true NOT NULL,
+ change_reviewer_merge_request boolean
);
CREATE SEQUENCE notification_settings_id_seq
@@ -14069,6 +14117,25 @@ CREATE SEQUENCE packages_dependency_links_id_seq
ALTER SEQUENCE packages_dependency_links_id_seq OWNED BY packages_dependency_links.id;
+CREATE TABLE packages_events (
+ id bigint NOT NULL,
+ event_type smallint NOT NULL,
+ event_scope smallint NOT NULL,
+ originator_type smallint NOT NULL,
+ originator bigint,
+ created_at timestamp with time zone NOT NULL,
+ package_id bigint
+);
+
+CREATE SEQUENCE packages_events_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+ALTER SEQUENCE packages_events_id_seq OWNED BY packages_events.id;
+
CREATE TABLE packages_maven_metadata (
id bigint NOT NULL,
package_id bigint NOT NULL,
@@ -14121,7 +14188,8 @@ CREATE TABLE packages_package_files (
verified_at timestamp with time zone,
verification_failure character varying(255),
verification_retry_count integer,
- verification_checksum bytea
+ verification_checksum bytea,
+ CONSTRAINT check_4c5e6bb0b3 CHECK ((file_store IS NOT NULL))
);
CREATE SEQUENCE packages_package_files_id_seq
@@ -14363,7 +14431,10 @@ CREATE TABLE plan_limits (
nuget_max_file_size bigint DEFAULT 524288000 NOT NULL,
pypi_max_file_size bigint DEFAULT '3221225472'::bigint NOT NULL,
generic_packages_max_file_size bigint DEFAULT '5368709120'::bigint NOT NULL,
- project_feature_flags integer DEFAULT 200 NOT NULL
+ project_feature_flags integer DEFAULT 200 NOT NULL,
+ golang_max_file_size bigint DEFAULT 104857600 NOT NULL,
+ debian_max_file_size bigint DEFAULT '3221225472'::bigint NOT NULL,
+ ci_max_artifact_size_api_fuzzing integer DEFAULT 0 NOT NULL
);
CREATE SEQUENCE plan_limits_id_seq
@@ -14409,6 +14480,43 @@ CREATE SEQUENCE pool_repositories_id_seq
ALTER SEQUENCE pool_repositories_id_seq OWNED BY pool_repositories.id;
+CREATE VIEW postgres_indexes AS
+ SELECT (((pg_namespace.nspname)::text || '.'::text) || (pg_class.relname)::text) AS identifier,
+ pg_index.indexrelid,
+ pg_namespace.nspname AS schema,
+ pg_class.relname AS name,
+ pg_index.indisunique AS "unique",
+ pg_index.indisvalid AS valid_index,
+ pg_class.relispartition AS partitioned,
+ pg_index.indisexclusion AS exclusion,
+ pg_indexes.indexdef AS definition,
+ pg_relation_size((pg_class.oid)::regclass) AS ondisk_size_bytes
+ FROM (((pg_index
+ JOIN pg_class ON ((pg_class.oid = pg_index.indexrelid)))
+ JOIN pg_namespace ON ((pg_class.relnamespace = pg_namespace.oid)))
+ JOIN pg_indexes ON ((pg_class.relname = pg_indexes.indexname)))
+ WHERE ((pg_namespace.nspname <> 'pg_catalog'::name) AND (pg_namespace.nspname = ANY (ARRAY["current_schema"(), 'gitlab_partitions_dynamic'::name, 'gitlab_partitions_static'::name])));
+
+CREATE TABLE postgres_reindex_actions (
+ id bigint NOT NULL,
+ action_start timestamp with time zone NOT NULL,
+ action_end timestamp with time zone,
+ ondisk_size_bytes_start bigint NOT NULL,
+ ondisk_size_bytes_end bigint,
+ state smallint DEFAULT 0 NOT NULL,
+ index_identifier text NOT NULL,
+ CONSTRAINT check_f12527622c CHECK ((char_length(index_identifier) <= 255))
+);
+
+CREATE SEQUENCE postgres_reindex_actions_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+ALTER SEQUENCE postgres_reindex_actions_id_seq OWNED BY postgres_reindex_actions.id;
+
CREATE TABLE programming_languages (
id integer NOT NULL,
name character varying NOT NULL,
@@ -14710,7 +14818,8 @@ ALTER SEQUENCE project_mirror_data_id_seq OWNED BY project_mirror_data.id;
CREATE TABLE project_pages_metadata (
project_id bigint NOT NULL,
deployed boolean DEFAULT false NOT NULL,
- artifacts_archive_id bigint
+ artifacts_archive_id bigint,
+ pages_deployment_id bigint
);
CREATE TABLE project_repositories (
@@ -15324,7 +15433,10 @@ CREATE TABLE requirements (
cached_markdown_version integer,
state smallint DEFAULT 1 NOT NULL,
title character varying(255) NOT NULL,
- title_html text
+ title_html text,
+ description text,
+ description_html text,
+ CONSTRAINT check_785ae25b9d CHECK ((char_length(description) <= 10000))
);
CREATE SEQUENCE requirements_id_seq
@@ -17043,6 +17155,8 @@ ALTER TABLE ONLY alert_management_alert_user_mentions ALTER COLUMN id SET DEFAUL
ALTER TABLE ONLY alert_management_alerts ALTER COLUMN id SET DEFAULT nextval('alert_management_alerts_id_seq'::regclass);
+ALTER TABLE ONLY alert_management_http_integrations ALTER COLUMN id SET DEFAULT nextval('alert_management_http_integrations_id_seq'::regclass);
+
ALTER TABLE ONLY alerts_service_data ALTER COLUMN id SET DEFAULT nextval('alerts_service_data_id_seq'::regclass);
ALTER TABLE ONLY allowed_email_domains ALTER COLUMN id SET DEFAULT nextval('allowed_email_domains_id_seq'::regclass);
@@ -17379,6 +17493,8 @@ ALTER TABLE ONLY ip_restrictions ALTER COLUMN id SET DEFAULT nextval('ip_restric
ALTER TABLE ONLY issuable_severities ALTER COLUMN id SET DEFAULT nextval('issuable_severities_id_seq'::regclass);
+ALTER TABLE ONLY issue_email_participants ALTER COLUMN id SET DEFAULT nextval('issue_email_participants_id_seq'::regclass);
+
ALTER TABLE ONLY issue_links ALTER COLUMN id SET DEFAULT nextval('issue_links_id_seq'::regclass);
ALTER TABLE ONLY issue_metrics ALTER COLUMN id SET DEFAULT nextval('issue_metrics_id_seq'::regclass);
@@ -17495,6 +17611,8 @@ ALTER TABLE ONLY packages_dependencies ALTER COLUMN id SET DEFAULT nextval('pack
ALTER TABLE ONLY packages_dependency_links ALTER COLUMN id SET DEFAULT nextval('packages_dependency_links_id_seq'::regclass);
+ALTER TABLE ONLY packages_events ALTER COLUMN id SET DEFAULT nextval('packages_events_id_seq'::regclass);
+
ALTER TABLE ONLY packages_maven_metadata ALTER COLUMN id SET DEFAULT nextval('packages_maven_metadata_id_seq'::regclass);
ALTER TABLE ONLY packages_package_files ALTER COLUMN id SET DEFAULT nextval('packages_package_files_id_seq'::regclass);
@@ -17521,6 +17639,8 @@ ALTER TABLE ONLY plans ALTER COLUMN id SET DEFAULT nextval('plans_id_seq'::regcl
ALTER TABLE ONLY pool_repositories ALTER COLUMN id SET DEFAULT nextval('pool_repositories_id_seq'::regclass);
+ALTER TABLE ONLY postgres_reindex_actions ALTER COLUMN id SET DEFAULT nextval('postgres_reindex_actions_id_seq'::regclass);
+
ALTER TABLE ONLY product_analytics_events_experimental ALTER COLUMN id SET DEFAULT nextval('product_analytics_events_experimental_id_seq'::regclass);
ALTER TABLE ONLY programming_languages ALTER COLUMN id SET DEFAULT nextval('programming_languages_id_seq'::regclass);
@@ -17956,6 +18076,9 @@ ALTER TABLE ONLY alert_management_alert_user_mentions
ALTER TABLE ONLY alert_management_alerts
ADD CONSTRAINT alert_management_alerts_pkey PRIMARY KEY (id);
+ALTER TABLE ONLY alert_management_http_integrations
+ ADD CONSTRAINT alert_management_http_integrations_pkey PRIMARY KEY (id);
+
ALTER TABLE ONLY alerts_service_data
ADD CONSTRAINT alerts_service_data_pkey PRIMARY KEY (id);
@@ -18076,14 +18199,11 @@ ALTER TABLE ONLY chat_names
ALTER TABLE ONLY chat_teams
ADD CONSTRAINT chat_teams_pkey PRIMARY KEY (id);
-ALTER TABLE design_management_designs
- ADD CONSTRAINT check_07155e2715 CHECK ((char_length((filename)::text) <= 255)) NOT VALID;
-
ALTER TABLE vulnerability_scanners
ADD CONSTRAINT check_37608c9db5 CHECK ((char_length(vendor) <= 255)) NOT VALID;
-ALTER TABLE packages_package_files
- ADD CONSTRAINT check_4c5e6bb0b3 CHECK ((file_store IS NOT NULL)) NOT VALID;
+ALTER TABLE group_import_states
+ ADD CONSTRAINT check_cda75c7c3f CHECK ((user_id IS NOT NULL)) NOT VALID;
ALTER TABLE ONLY ci_build_needs
ADD CONSTRAINT ci_build_needs_pkey PRIMARY KEY (id);
@@ -18493,6 +18613,9 @@ ALTER TABLE ONLY ip_restrictions
ALTER TABLE ONLY issuable_severities
ADD CONSTRAINT issuable_severities_pkey PRIMARY KEY (id);
+ALTER TABLE ONLY issue_email_participants
+ ADD CONSTRAINT issue_email_participants_pkey PRIMARY KEY (id);
+
ALTER TABLE ONLY issue_links
ADD CONSTRAINT issue_links_pkey PRIMARY KEY (id);
@@ -18688,6 +18811,9 @@ ALTER TABLE ONLY packages_dependencies
ALTER TABLE ONLY packages_dependency_links
ADD CONSTRAINT packages_dependency_links_pkey PRIMARY KEY (id);
+ALTER TABLE ONLY packages_events
+ ADD CONSTRAINT packages_events_pkey PRIMARY KEY (id);
+
ALTER TABLE ONLY packages_maven_metadata
ADD CONSTRAINT packages_maven_metadata_pkey PRIMARY KEY (id);
@@ -18736,6 +18862,9 @@ ALTER TABLE ONLY plans
ALTER TABLE ONLY pool_repositories
ADD CONSTRAINT pool_repositories_pkey PRIMARY KEY (id);
+ALTER TABLE ONLY postgres_reindex_actions
+ ADD CONSTRAINT postgres_reindex_actions_pkey PRIMARY KEY (id);
+
ALTER TABLE ONLY programming_languages
ADD CONSTRAINT programming_languages_pkey PRIMARY KEY (id);
@@ -19315,8 +19444,6 @@ CREATE UNIQUE INDEX idx_jira_connect_subscriptions_on_installation_id_namespace_
CREATE INDEX idx_members_created_at_user_id_invite_token ON members USING btree (created_at) WHERE ((invite_token IS NOT NULL) AND (user_id IS NULL));
-CREATE INDEX idx_merge_requests_on_id_and_merge_jid ON merge_requests USING btree (id, merge_jid) WHERE ((merge_jid IS NOT NULL) AND (state_id = 4));
-
CREATE INDEX idx_merge_requests_on_source_project_and_branch_state_opened ON merge_requests USING btree (source_project_id, source_branch) WHERE (state_id = 1);
CREATE INDEX idx_merge_requests_on_state_id_and_merge_status ON merge_requests USING btree (state_id, merge_status) WHERE ((state_id = 1) AND ((merge_status)::text = 'can_be_merged'::text));
@@ -19381,6 +19508,8 @@ CREATE UNIQUE INDEX index_alert_management_alerts_on_project_id_and_iid ON alert
CREATE INDEX index_alert_management_alerts_on_prometheus_alert_id ON alert_management_alerts USING btree (prometheus_alert_id) WHERE (prometheus_alert_id IS NOT NULL);
+CREATE INDEX index_alert_management_http_integrations_on_project_id ON alert_management_http_integrations USING btree (project_id);
+
CREATE UNIQUE INDEX index_alert_user_mentions_on_alert_id ON alert_management_alert_user_mentions USING btree (alert_management_alert_id) WHERE (note_id IS NULL);
CREATE UNIQUE INDEX index_alert_user_mentions_on_alert_id_and_note_id ON alert_management_alert_user_mentions USING btree (alert_management_alert_id, note_id);
@@ -19447,6 +19576,8 @@ CREATE UNIQUE INDEX index_approval_project_rules_groups_1 ON approval_project_ru
CREATE INDEX index_approval_project_rules_groups_2 ON approval_project_rules_groups USING btree (group_id);
+CREATE INDEX index_approval_project_rules_on_id_with_regular_type ON approval_project_rules USING btree (id) WHERE (rule_type = 0);
+
CREATE INDEX index_approval_project_rules_on_project_id ON approval_project_rules USING btree (project_id);
CREATE INDEX index_approval_project_rules_on_rule_type ON approval_project_rules USING btree (rule_type);
@@ -19459,6 +19590,8 @@ CREATE UNIQUE INDEX index_approval_project_rules_users_1 ON approval_project_rul
CREATE INDEX index_approval_project_rules_users_2 ON approval_project_rules_users USING btree (user_id);
+CREATE INDEX index_approval_project_rules_users_on_approval_project_rule_id ON approval_project_rules_users USING btree (approval_project_rule_id);
+
CREATE UNIQUE INDEX index_approval_rule_name_for_code_owners_rule_type ON approval_merge_request_rules USING btree (merge_request_id, name) WHERE ((rule_type = 2) AND (section IS NULL));
CREATE UNIQUE INDEX index_approval_rule_name_for_sectional_code_owners_rule_type ON approval_merge_request_rules USING btree (merge_request_id, name, section) WHERE (rule_type = 2);
@@ -19481,6 +19614,8 @@ CREATE UNIQUE INDEX index_atlassian_identities_on_extern_uid ON atlassian_identi
CREATE INDEX index_authentication_events_on_provider ON authentication_events USING btree (provider);
+CREATE INDEX index_authentication_events_on_provider_user_id_created_at ON authentication_events USING btree (provider, user_id, created_at) WHERE (result = 1);
+
CREATE INDEX index_authentication_events_on_user_id ON authentication_events USING btree (user_id);
CREATE INDEX index_award_emoji_on_awardable_type_and_awardable_id ON award_emoji USING btree (awardable_type, awardable_id);
@@ -19709,7 +19844,7 @@ CREATE INDEX index_ci_pipelines_on_project_id_and_user_id_and_status_and_ref ON
CREATE INDEX index_ci_pipelines_on_project_idandrefandiddesc ON ci_pipelines USING btree (project_id, ref, id DESC);
-CREATE INDEX index_ci_pipelines_on_status ON ci_pipelines USING btree (status);
+CREATE INDEX index_ci_pipelines_on_status_and_id ON ci_pipelines USING btree (status, id);
CREATE INDEX index_ci_pipelines_on_user_id_and_created_at_and_config_source ON ci_pipelines USING btree (user_id, created_at, config_source);
@@ -19785,8 +19920,6 @@ CREATE INDEX index_cluster_agent_tokens_on_agent_id ON cluster_agent_tokens USIN
CREATE UNIQUE INDEX index_cluster_agent_tokens_on_token_encrypted ON cluster_agent_tokens USING btree (token_encrypted);
-CREATE INDEX index_cluster_agents_on_project_id ON cluster_agents USING btree (project_id);
-
CREATE UNIQUE INDEX index_cluster_agents_on_project_id_and_name ON cluster_agents USING btree (project_id, name);
CREATE UNIQUE INDEX index_cluster_groups_on_cluster_id_and_group_id ON cluster_groups USING btree (cluster_id, group_id);
@@ -19803,8 +19936,6 @@ CREATE UNIQUE INDEX index_cluster_providers_aws_on_cluster_id ON cluster_provide
CREATE INDEX index_cluster_providers_aws_on_cluster_id_and_status ON cluster_providers_aws USING btree (cluster_id, status);
-CREATE INDEX index_cluster_providers_aws_on_created_by_user_id ON cluster_providers_aws USING btree (created_by_user_id);
-
CREATE INDEX index_cluster_providers_gcp_on_cloud_run ON cluster_providers_gcp USING btree (cloud_run);
CREATE UNIQUE INDEX index_cluster_providers_gcp_on_cluster_id ON cluster_providers_gcp USING btree (cluster_id);
@@ -19933,6 +20064,8 @@ CREATE INDEX index_deployments_on_project_id_and_status_and_created_at ON deploy
CREATE INDEX index_deployments_on_project_id_and_updated_at_and_id ON deployments USING btree (project_id, updated_at DESC, id DESC);
+CREATE INDEX index_deployments_on_project_id_sha ON deployments USING btree (project_id, sha);
+
CREATE INDEX index_deployments_on_user_id_and_status_and_created_at ON deployments USING btree (user_id, status, created_at);
CREATE INDEX index_description_versions_on_epic_id ON description_versions USING btree (epic_id) WHERE (epic_id IS NOT NULL);
@@ -20219,12 +20352,16 @@ CREATE INDEX index_group_group_links_on_shared_with_group_id ON group_group_link
CREATE INDEX index_group_import_states_on_group_id ON group_import_states USING btree (group_id);
+CREATE INDEX index_group_import_states_on_user_id ON group_import_states USING btree (user_id) WHERE (user_id IS NOT NULL);
+
CREATE UNIQUE INDEX index_group_stages_on_group_id_group_value_stream_id_and_name ON analytics_cycle_analytics_group_stages USING btree (group_id, group_value_stream_id, name);
CREATE UNIQUE INDEX index_group_wiki_repositories_on_disk_path ON group_wiki_repositories USING btree (disk_path);
CREATE INDEX index_group_wiki_repositories_on_shard_id ON group_wiki_repositories USING btree (shard_id);
+CREATE UNIQUE INDEX index_http_integrations_on_active_and_project_and_endpoint ON alert_management_http_integrations USING btree (active, project_id, endpoint_identifier) WHERE active;
+
CREATE INDEX index_identities_on_saml_provider_id ON identities USING btree (saml_provider_id) WHERE (saml_provider_id IS NOT NULL);
CREATE INDEX index_identities_on_user_id ON identities USING btree (user_id);
@@ -20267,6 +20404,8 @@ CREATE UNIQUE INDEX index_issue_assignees_on_issue_id_and_user_id ON issue_assig
CREATE INDEX index_issue_assignees_on_user_id ON issue_assignees USING btree (user_id);
+CREATE UNIQUE INDEX index_issue_email_participants_on_issue_id_and_email ON issue_email_participants USING btree (issue_id, email);
+
CREATE INDEX index_issue_links_on_source_id ON issue_links USING btree (source_id);
CREATE UNIQUE INDEX index_issue_links_on_source_id_and_target_id ON issue_links USING btree (source_id, target_id);
@@ -20319,6 +20458,8 @@ CREATE INDEX index_issues_on_updated_at ON issues USING btree (updated_at);
CREATE INDEX index_issues_on_updated_by_id ON issues USING btree (updated_by_id) WHERE (updated_by_id IS NOT NULL);
+CREATE INDEX index_issues_project_id_issue_type_incident ON issues USING btree (project_id) WHERE (issue_type = 1);
+
CREATE UNIQUE INDEX index_jira_connect_installations_on_client_key ON jira_connect_installations USING btree (client_key);
CREATE INDEX index_jira_connect_subscriptions_on_namespace_id ON jira_connect_subscriptions USING btree (namespace_id);
@@ -20489,6 +20630,8 @@ CREATE INDEX index_merge_requests_on_target_project_id_and_created_at_and_id ON
CREATE UNIQUE INDEX index_merge_requests_on_target_project_id_and_iid ON merge_requests USING btree (target_project_id, iid);
+CREATE INDEX index_merge_requests_on_target_project_id_and_iid_and_state_id ON merge_requests USING btree (target_project_id, iid, state_id);
+
CREATE INDEX index_merge_requests_on_target_project_id_and_target_branch ON merge_requests USING btree (target_project_id, target_branch) WHERE ((state_id = 1) AND (merge_when_pipeline_succeeds = true));
CREATE INDEX index_merge_requests_on_title ON merge_requests USING btree (title);
@@ -20573,7 +20716,7 @@ CREATE UNIQUE INDEX index_namespaces_on_runners_token_encrypted ON namespaces US
CREATE INDEX index_namespaces_on_shared_and_extra_runners_minutes_limit ON namespaces USING btree (shared_runners_minutes_limit, extra_shared_runners_minutes_limit);
-CREATE INDEX index_namespaces_on_type_partial ON namespaces USING btree (type) WHERE (type IS NOT NULL);
+CREATE INDEX index_namespaces_on_type_and_id_partial ON namespaces USING btree (type, id) WHERE (type IS NOT NULL);
CREATE INDEX index_non_requested_project_members_on_source_id_and_type ON members USING btree (source_id, source_type) WHERE ((requested_at IS NULL) AND ((type)::text = 'ProjectMember'::text));
@@ -20669,14 +20812,14 @@ CREATE UNIQUE INDEX index_packages_dependencies_on_name_and_version_pattern ON p
CREATE INDEX index_packages_dependency_links_on_dependency_id ON packages_dependency_links USING btree (dependency_id);
+CREATE INDEX index_packages_events_on_package_id ON packages_events USING btree (package_id);
+
CREATE INDEX index_packages_maven_metadata_on_package_id_and_path ON packages_maven_metadata USING btree (package_id, path);
CREATE INDEX index_packages_nuget_dl_metadata_on_dependency_link_id ON packages_nuget_dependency_link_metadata USING btree (dependency_link_id);
CREATE UNIQUE INDEX index_packages_on_project_id_name_version_unique_when_generic ON packages_packages USING btree (project_id, name, version) WHERE (package_type = 7);
-CREATE INDEX index_packages_package_files_file_store_is_null ON packages_package_files USING btree (id) WHERE (file_store IS NULL);
-
CREATE INDEX index_packages_package_files_on_file_store ON packages_package_files USING btree (file_store);
CREATE INDEX index_packages_package_files_on_package_id_and_file_name ON packages_package_files USING btree (package_id, file_name);
@@ -20733,6 +20876,8 @@ CREATE INDEX index_pages_domains_on_wildcard ON pages_domains USING btree (wildc
CREATE UNIQUE INDEX index_partial_am_alerts_on_project_id_and_fingerprint ON alert_management_alerts USING btree (project_id, fingerprint) WHERE (status <> 2);
+CREATE INDEX index_partial_ci_builds_on_user_id_name_parser_features ON ci_builds USING btree (user_id, name) WHERE (((type)::text = 'Ci::Build'::text) AND ((name)::text = ANY (ARRAY[('container_scanning'::character varying)::text, ('dast'::character varying)::text, ('dependency_scanning'::character varying)::text, ('license_management'::character varying)::text, ('license_scanning'::character varying)::text, ('sast'::character varying)::text, ('coverage_fuzzing'::character varying)::text, ('secret_detection'::character varying)::text])));
+
CREATE UNIQUE INDEX index_partitioned_foreign_keys_unique_index ON partitioned_foreign_keys USING btree (to_table, from_table, from_column);
CREATE INDEX index_pat_on_user_id_and_expires_at ON personal_access_tokens USING btree (user_id, expires_at);
@@ -20757,6 +20902,8 @@ CREATE INDEX index_pool_repositories_on_shard_id ON pool_repositories USING btre
CREATE UNIQUE INDEX index_pool_repositories_on_source_project_id_and_shard_id ON pool_repositories USING btree (source_project_id, shard_id);
+CREATE INDEX index_postgres_reindex_actions_on_index_identifier ON postgres_reindex_actions USING btree (index_identifier);
+
CREATE UNIQUE INDEX index_programming_languages_on_name ON programming_languages USING btree (name);
CREATE INDEX index_project_access_tokens_on_project_id ON project_access_tokens USING btree (project_id);
@@ -20817,6 +20964,8 @@ CREATE INDEX index_project_mirror_data_on_status ON project_mirror_data USING bt
CREATE INDEX index_project_pages_metadata_on_artifacts_archive_id ON project_pages_metadata USING btree (artifacts_archive_id);
+CREATE INDEX index_project_pages_metadata_on_pages_deployment_id ON project_pages_metadata USING btree (pages_deployment_id);
+
CREATE UNIQUE INDEX index_project_pages_metadata_on_project_id ON project_pages_metadata USING btree (project_id);
CREATE INDEX index_project_pages_metadata_on_project_id_and_deployed_is_true ON project_pages_metadata USING btree (project_id) WHERE (deployed = true);
@@ -21329,6 +21478,8 @@ CREATE UNIQUE INDEX index_user_interacted_projects_on_project_id_and_user_id ON
CREATE INDEX index_user_interacted_projects_on_user_id ON user_interacted_projects USING btree (user_id);
+CREATE INDEX index_user_preferences_on_gitpod_enabled ON user_preferences USING btree (gitpod_enabled);
+
CREATE UNIQUE INDEX index_user_preferences_on_user_id ON user_preferences USING btree (user_id);
CREATE INDEX index_user_statuses_on_user_id ON user_statuses USING btree (user_id);
@@ -21563,16 +21714,10 @@ CREATE INDEX terraform_state_versions_verification_checksum_partial ON terraform
CREATE INDEX terraform_state_versions_verification_failure_partial ON terraform_state_versions USING btree (verification_failure) WHERE (verification_failure IS NOT NULL);
-CREATE INDEX terraform_states_verification_checksum_partial ON terraform_states USING btree (verification_checksum) WHERE (verification_checksum IS NOT NULL);
-
-CREATE INDEX terraform_states_verification_failure_partial ON terraform_states USING btree (verification_failure) WHERE (verification_failure IS NOT NULL);
-
CREATE INDEX tmp_build_stage_position_index ON ci_builds USING btree (stage_id, stage_idx) WHERE (stage_idx IS NOT NULL);
CREATE INDEX tmp_index_for_email_unconfirmation_migration ON emails USING btree (id) WHERE (confirmed_at IS NOT NULL);
-CREATE INDEX tmp_index_for_fixing_inconsistent_vulnerability_occurrences ON vulnerability_occurrences USING btree (id) WHERE ((length(location_fingerprint) = 40) AND (report_type = 2));
-
CREATE UNIQUE INDEX unique_merge_request_metrics_by_merge_request_id ON merge_request_metrics USING btree (merge_request_id);
CREATE UNIQUE INDEX users_security_dashboard_projects_unique_index ON users_security_dashboard_projects USING btree (project_id, user_id);
@@ -21872,6 +22017,9 @@ ALTER TABLE ONLY notification_settings
ALTER TABLE ONLY lists
ADD CONSTRAINT fk_0d3f677137 FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE;
+ALTER TABLE ONLY project_pages_metadata
+ ADD CONSTRAINT fk_0fd5b22688 FOREIGN KEY (pages_deployment_id) REFERENCES pages_deployments(id) ON DELETE SET NULL;
+
ALTER TABLE ONLY group_deletion_schedules
ADD CONSTRAINT fk_11e3ebfcdd FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
@@ -22115,6 +22263,9 @@ ALTER TABLE ONLY merge_requests
ALTER TABLE ONLY merge_request_metrics
ADD CONSTRAINT fk_7f28d925f3 FOREIGN KEY (merged_by_id) REFERENCES users(id) ON DELETE SET NULL;
+ALTER TABLE ONLY group_import_states
+ ADD CONSTRAINT fk_8053b3ebd6 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY sprints
ADD CONSTRAINT fk_80aa8a1f95 FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE;
@@ -22523,6 +22674,9 @@ ALTER TABLE ONLY user_synced_attributes_metadata
ALTER TABLE ONLY project_authorizations
ADD CONSTRAINT fk_rails_0f84bb11f3 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
+ALTER TABLE ONLY issue_email_participants
+ ADD CONSTRAINT fk_rails_0fdfd8b811 FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY merge_request_context_commits
ADD CONSTRAINT fk_rails_0fe0039f60 FOREIGN KEY (merge_request_id) REFERENCES merge_requests(id) ON DELETE CASCADE;
@@ -23399,6 +23553,9 @@ ALTER TABLE ONLY elasticsearch_indexed_namespaces
ALTER TABLE ONLY vulnerability_occurrence_identifiers
ADD CONSTRAINT fk_rails_be2e49e1d0 FOREIGN KEY (identifier_id) REFERENCES vulnerability_identifiers(id) ON DELETE CASCADE;
+ALTER TABLE ONLY alert_management_http_integrations
+ ADD CONSTRAINT fk_rails_bec49f52cc FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY vulnerability_occurrences
ADD CONSTRAINT fk_rails_bf5b788ca7 FOREIGN KEY (scanner_id) REFERENCES vulnerability_scanners(id) ON DELETE CASCADE;
@@ -23444,6 +23601,9 @@ ALTER TABLE ONLY merge_request_user_mentions
ALTER TABLE ONLY ci_job_artifacts
ADD CONSTRAINT fk_rails_c5137cb2c1 FOREIGN KEY (job_id) REFERENCES ci_builds(id) ON DELETE CASCADE;
+ALTER TABLE ONLY packages_events
+ ADD CONSTRAINT fk_rails_c6c20d0094 FOREIGN KEY (package_id) REFERENCES packages_packages(id) ON DELETE SET NULL;
+
ALTER TABLE ONLY project_settings
ADD CONSTRAINT fk_rails_c6df6e6328 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
@@ -23606,9 +23766,6 @@ ALTER TABLE ONLY alert_management_alert_user_mentions
ALTER TABLE ONLY snippet_statistics
ADD CONSTRAINT fk_rails_ebc283ccf1 FOREIGN KEY (snippet_id) REFERENCES snippets(id) ON DELETE CASCADE;
-ALTER TABLE ONLY cluster_providers_aws
- ADD CONSTRAINT fk_rails_ed1fdfaeb2 FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL;
-
ALTER TABLE ONLY project_security_settings
ADD CONSTRAINT fk_rails_ed4abe1338 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
diff --git a/doc/.vale/gitlab/InclusionAbleism.yml b/doc/.vale/gitlab/InclusionAbleism.yml
new file mode 100644
index 00000000000..29b26547130
--- /dev/null
+++ b/doc/.vale/gitlab/InclusionAbleism.yml
@@ -0,0 +1,14 @@
+---
+# Suggestion: gitlab.InclusionAbleism
+#
+# Suggests alternatives for words that foster ableism.
+#
+# For a list of all options, see https://errata-ai.gitbook.io/vale/getting-started/styles
+extends: substitution
+message: 'Use inclusive language. Consider "%s" instead of "%s".'
+link: https://docs.gitlab.com/ee/development/documentation/styleguide.html#inclusive-language
+level: suggestion
+ignorecase: true
+swap:
+ sanity (?:check|test): check for completeness
+ dummy: placeholder, sample, fake
diff --git a/doc/.vale/gitlab/InclusionCultural.yml b/doc/.vale/gitlab/InclusionCultural.yml
new file mode 100644
index 00000000000..5bfad84f100
--- /dev/null
+++ b/doc/.vale/gitlab/InclusionCultural.yml
@@ -0,0 +1,16 @@
+---
+# Warning: gitlab.InclusionCultural
+#
+# Suggests alternatives for words that are culturally inappropriate.
+#
+# For a list of all options, see https://errata-ai.gitbook.io/vale/getting-started/styles
+extends: substitution
+message: 'Use inclusive language. Consider "%s" instead of "%s".'
+link: https://docs.gitlab.com/ee/development/documentation/styleguide.html#inclusive-language
+level: warning
+ignorecase: true
+swap:
+ blacklist(?:ed|ing|s)?: denylist
+ whitelist(?:ed|ing|s)?: allowlist
+ master: primary, main
+ slave: secondary
diff --git a/doc/.vale/gitlab/InclusionGender.yml b/doc/.vale/gitlab/InclusionGender.yml
new file mode 100644
index 00000000000..389d76d7a24
--- /dev/null
+++ b/doc/.vale/gitlab/InclusionGender.yml
@@ -0,0 +1,18 @@
+---
+# Suggestion: gitlab.InclusionGender
+#
+# Suggests alternatives for words that are gender-specific.
+#
+# For a list of all options, see https://errata-ai.gitbook.io/vale/getting-started/styles
+extends: substitution
+message: 'Use inclusive language. Consider "%s" instead of "%s".'
+link: https://docs.gitlab.com/ee/development/documentation/styleguide.html#inclusive-language
+level: suggestion
+ignorecase: true
+swap:
+ mankind: humanity, people
+ manpower: GitLab team members
+ he: they
+ his: their
+ she: they
+ hers: their
diff --git a/doc/.vale/gitlab/SubstitutionWarning.yml b/doc/.vale/gitlab/SubstitutionWarning.yml
index 5324a48e38c..394bd5df08d 100644
--- a/doc/.vale/gitlab/SubstitutionWarning.yml
+++ b/doc/.vale/gitlab/SubstitutionWarning.yml
@@ -11,7 +11,6 @@ link: https://about.gitlab.com/handbook/communication/#top-misused-terms
level: warning
ignorecase: true
swap:
- admin: administrator
blacklist(ed|ing)?: denylist
code base: codebase
config: configuration
@@ -20,4 +19,3 @@ swap:
filesystem: file system
info: information
repo: repository
- whitelist(ed|ing)?: allowlist
diff --git a/doc/.vale/gitlab/spelling-exceptions.txt b/doc/.vale/gitlab/spelling-exceptions.txt
index bf816afdfab..eec1d03d1a5 100644
--- a/doc/.vale/gitlab/spelling-exceptions.txt
+++ b/doc/.vale/gitlab/spelling-exceptions.txt
@@ -298,6 +298,7 @@ OmniAuth
onboarding
OpenID
OpenShift
+Opsgenie
Packagist
parallelization
parallelizations
@@ -382,6 +383,7 @@ reusability
reverified
reverifies
reverify
+RHEL
rollout
rollouts
rsync
diff --git a/doc/README.md b/doc/README.md
index efae2cdd3ff..03ecbef56ea 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -92,7 +92,7 @@ The following documentation relates to the DevOps **Manage** stage:
| Manage topics | Description |
|:--------------------------------------------------------------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [Authentication and<br/>Authorization](administration/auth/README.md) **(CORE ONLY)** | Supported authentication and authorization providers. |
-| [GitLab Value Stream Analytics](user/project/cycle_analytics.md) | Measure the time it takes to go from an [idea to production](https://about.gitlab.com/blog/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/#from-idea-to-production-with-gitlab) for each project you have. |
+| [GitLab Value Stream Analytics](user/analytics/value_stream_analytics.md) | Measure the time it takes to go from an [idea to production](https://about.gitlab.com/blog/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/#from-idea-to-production-with-gitlab) for each project you have. |
| [Instance-level Analytics](user/admin_area/analytics/index.md) | Discover statistics on how many GitLab features you use and user activity. |
<div align="right">
@@ -121,7 +121,7 @@ The following documentation relates to the DevOps **Plan** stage:
| [Milestones](user/project/milestones/index.md) | Set milestones for delivery of issues and merge requests, with optional due date. |
| [Project Issue Board](user/project/issue_board.md) | Display issues on a Scrum or Kanban board. |
| [Quick Actions](user/project/quick_actions.md) | Shortcuts for common actions on issues or merge requests, replacing the need to click buttons or use dropdowns in GitLab's UI. |
-| [Related Issues](user/project/issues/related_issues.md) **(STARTER)** | Create a relationship between issues. |
+| [Related Issues](user/project/issues/related_issues.md) | Create a relationship between issues. |
| [Requirements Management](user/project/requirements/index.md) **(ULTIMATE)** | Check your products against a set of criteria. |
| [Roadmap](user/group/roadmap/index.md) **(ULTIMATE)** | Visualize epic timelines. |
| [Service Desk](user/project/service_desk.md) | A simple way to allow people to create issues in your GitLab instance without needing their own user account. |
@@ -295,7 +295,7 @@ The following documentation relates to the DevOps **Secure** stage:
| [Dependency Scanning](user/application_security/dependency_scanning/index.md) **(ULTIMATE)** | Analyze your dependencies for known vulnerabilities. |
| [Dynamic Application Security Testing (DAST)](user/application_security/dast/index.md) **(ULTIMATE)** | Analyze running web applications for known vulnerabilities. |
| [Group Security Dashboard](user/application_security/security_dashboard/index.md#group-security-dashboard) **(ULTIMATE)** | View vulnerabilities in all the projects in a group and its subgroups. |
-| [Instance Security Dashboard](user/application_security/security_dashboard/index.md#instance-security-dashboard) **(ULTIMATE)** | View vulnerabilities in all the projects you're interested in. |
+| [Instance Security Center](user/application_security/security_dashboard/index.md#instance-security-center) **(ULTIMATE)** | View vulnerabilities in all the projects you're interested in. |
| [License Compliance](user/compliance/license_compliance/index.md) **(ULTIMATE)** | Search your project's dependencies for their licenses. |
| [Pipeline Security](user/application_security/security_dashboard/index.md#pipeline-security) **(ULTIMATE)** | View the security reports for your project's pipelines. |
| [Project Security Dashboard](user/application_security/security_dashboard/index.md#project-security-dashboard) **(ULTIMATE)** | View the latest security reports for your project. |
diff --git a/doc/administration/audit_events.md b/doc/administration/audit_events.md
index 099346b2b0b..ac972e2e33e 100644
--- a/doc/administration/audit_events.md
+++ b/doc/administration/audit_events.md
@@ -9,7 +9,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
GitLab offers a way to view the changes made within the GitLab server for owners and administrators on a [paid plan](https://about.gitlab.com/pricing/).
GitLab system administrators can also take advantage of the logs located on the
-filesystem. See [the logs system documentation](logs.md) for more details.
+file system. See [the logs system documentation](logs.md) for more details.
## Overview
@@ -108,7 +108,7 @@ Server-wide audit logging introduces the ability to observe user actions across
the entire instance of your GitLab server, making it easy to understand who
changed what and when for audit purposes.
-To view the server-wide admin log, visit **Admin Area > Monitoring > Audit Log**.
+To view the server-wide administrator log, visit **Admin Area > Monitoring > Audit Log**.
In addition to the group and project events, the following user actions are also
recorded:
@@ -126,6 +126,7 @@ recorded:
- User was added ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/251) in GitLab 12.8)
- User was blocked via Admin Area ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/251) in GitLab 12.8)
- User was blocked via API ([introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/25872) in GitLab 12.9)
+- Failed second-factor authentication attempt ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/16826) in GitLab 13.5)
It's possible to filter particular actions by choosing an audit data type from
the filter dropdown box. You can further filter by specific group, project, or user
@@ -151,7 +152,7 @@ on adding these events into GitLab:
The current architecture of audit events is not prepared to receive a very high amount of records.
It may make the user interface for your project or audit logs very busy, and the disk space consumed by the
-`audit_events` PostgreSQL table will increase considerably. It's disabled by default
+`audit_events` PostgreSQL table may increase considerably. It's disabled by default
to prevent performance degradations on GitLab instances with very high Git write traffic.
In an upcoming release, Audit Logs for Git push events will be enabled
@@ -172,6 +173,7 @@ the steps bellow.
```ruby
Feature.enable(:repository_push_audit_event)
+ ```
## Export to CSV **(PREMIUM ONLY)**
@@ -183,6 +185,7 @@ the steps bellow.
CAUTION: **Warning:**
This feature might not be available to you. Check the **version history** note above for details.
+If available, you can enable it with a [feature flag](#enable-or-disable-audit-log-export-to-csv).
Export to CSV allows customers to export the current filter view of your audit log as a
CSV file,
diff --git a/doc/administration/auth/README.md b/doc/administration/auth/README.md
index 926a4abab7d..cf82454cfd2 100644
--- a/doc/administration/auth/README.md
+++ b/doc/administration/auth/README.md
@@ -18,7 +18,7 @@ providers:
- [Azure](../../integration/azure.md)
- [Bitbucket Cloud](../../integration/bitbucket.md)
- [CAS](../../integration/cas.md)
-- [Crowd](../../integration/crowd.md)
+- [Crowd](crowd.md)
- [Facebook](../../integration/facebook.md)
- [GitHub](../../integration/github.md)
- [GitLab.com](../../integration/gitlab.md)
diff --git a/doc/administration/geo/disaster_recovery/index.md b/doc/administration/geo/disaster_recovery/index.md
index 8862776ee1b..dc46c0756db 100644
--- a/doc/administration/geo/disaster_recovery/index.md
+++ b/doc/administration/geo/disaster_recovery/index.md
@@ -11,7 +11,7 @@ Geo replicates your database, your Git repositories, and few other assets.
We will support and replicate more data in the future, that will enable you to
failover with minimal effort, in a disaster situation.
-See [Geo current limitations](../index.md#current-limitations) for more information.
+See [Geo limitations](../index.md#limitations) for more information.
CAUTION: **Warning:**
Disaster recovery for multi-secondary configurations is in **Alpha**.
@@ -84,8 +84,8 @@ must disable the **primary** node.
single recommendation. You may need to:
- Reconfigure the load balancers.
- - Change DNS records (for example, point the primary DNS record to the **secondary**
- node in order to stop usage of the **primary** node).
+ - Change DNS records (for example, point the primary DNS record to the
+ **secondary** node to stop usage of the **primary** node).
- Stop the virtual servers.
- Block traffic through a firewall.
- Revoke object storage permissions from the **primary** node.
@@ -129,6 +129,9 @@ Note the following when promoting a secondary:
```
1. Promote the **secondary** node to the **primary** node.
+
+DANGER: **Danger:**
+In GitLab 13.2 and later versions, promoting a secondary node to a primary while the secondary is paused fails. We are [investigating the issue](https://gitlab.com/gitlab-org/gitlab/-/issues/225173). Do not pause replication before promoting a secondary. If the node is paused, please resume before promoting.
To promote the secondary node to primary along with preflight checks:
@@ -159,6 +162,9 @@ conjunction with multiple servers, as it can only
perform changes on a **secondary** with only a single machine. Instead, you must
do this manually.
+DANGER: **Danger:**
+In GitLab 13.2 and later versions, promoting a secondary node to a primary while the secondary is paused fails. We are [investigating the issue](https://gitlab.com/gitlab-org/gitlab/-/issues/225173). Do not pause replication before promoting a secondary. If the node is paused, please resume before promoting.
+
1. SSH in to the database node in the **secondary** and trigger PostgreSQL to
promote to read-write:
@@ -201,6 +207,9 @@ an external PostgreSQL database, as it can only perform changes on a **secondary
node with GitLab and the database on the same machine. As a result, a manual process is
required:
+DANGER: **Danger:**
+In GitLab 13.2 and later versions, promoting a secondary node to a primary while the secondary is paused fails. We are [investigating the issue](https://gitlab.com/gitlab-org/gitlab/-/issues/225173). Do not pause replication before promoting a secondary. If the node is paused, please resume before promoting.
+
1. Promote the replica database associated with the **secondary** site. This will
set the database to read-write:
- Amazon RDS - [Promoting a Read Replica to Be a Standalone DB Instance](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_ReadRepl.html#USER_ReadRepl.Promote)
diff --git a/doc/administration/geo/disaster_recovery/planned_failover.md b/doc/administration/geo/disaster_recovery/planned_failover.md
index 9b9c386652c..1238c4d8e2a 100644
--- a/doc/administration/geo/disaster_recovery/planned_failover.md
+++ b/doc/administration/geo/disaster_recovery/planned_failover.md
@@ -27,7 +27,7 @@ have a high degree of confidence in being able to perform them accurately.
## Not all data is automatically replicated
-If you are using any GitLab features that Geo [doesn't support](../index.md#current-limitations),
+If you are using any GitLab features that Geo [doesn't support](../index.md#limitations),
you must make separate provisions to ensure that the **secondary** node has an
up-to-date copy of any data associated with that feature. This may extend the
required scheduled maintenance period significantly.
diff --git a/doc/administration/geo/disaster_recovery/promotion_runbook.md b/doc/administration/geo/disaster_recovery/promotion_runbook.md
index fb2353513df..7eb6ef01aee 100644
--- a/doc/administration/geo/disaster_recovery/promotion_runbook.md
+++ b/doc/administration/geo/disaster_recovery/promotion_runbook.md
@@ -1,269 +1,5 @@
---
-stage: Enablement
-group: Geo
-info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
-type: howto
+redirect_to: runbooks/planned_failover_single_node.md
---
-CAUTION: **Caution:**
-This runbook is in **alpha**. For complete, production-ready documentation, see the
-[disaster recovery documentation](index.md).
-
-# Disaster Recovery (Geo) promotion runbooks **(PREMIUM ONLY)**
-
-## Geo planned failover runbook 1
-
-| Component | Configuration |
-| ----------- | --------------- |
-| PostgreSQL | Omnibus-managed |
-| Geo site | Single-node |
-| Secondaries | One |
-
-This runbook will guide you through a planned failover of a single-node Geo site
-with one secondary. The following general architecture is assumed:
-
-```mermaid
-graph TD
- subgraph main[Geo deployment]
- subgraph Primary[Primary site]
- Node_1[(GitLab node)]
- end
- subgraph Secondary1[Secondary site]
- Node_2[(GitLab node)]
- end
- end
-```
-
-This guide will result in the following:
-
-1. An offline primary.
-1. A promoted secondary that is now the new primary.
-
-What is not covered:
-
-1. Re-adding the old **primary** as a secondary.
-1. Adding a new secondary.
-
-### Preparation
-
-NOTE: **Note:**
-Before following any of those steps, make sure you have `root` access to the
-**secondary** to promote it, since there isn't provided an automated way to
-promote a Geo replica and perform a failover.
-
-On the **secondary** node, navigate to the **Admin Area > Geo** dashboard to
-review its status. Replicated objects (shown in green) should be close to 100%,
-and there should be no failures (shown in red). If a large proportion of
-objects aren't yet replicated (shown in gray), consider giving the node more
-time to complete.
-
-![Replication status](img/replication-status.png)
-
-If any objects are failing to replicate, this should be investigated before
-scheduling the maintenance window. After a planned failover, anything that
-failed to replicate will be **lost**.
-
-You can use the
-[Geo status API](../../../api/geo_nodes.md#retrieve-project-sync-or-verification-failures-that-occurred-on-the-current-node)
-to review failed objects and the reasons for failure.
-A common cause of replication failures is the data being missing on the
-**primary** node - you can resolve these failures by restoring the data from backup,
-or removing references to the missing data.
-
-The maintenance window won't end until Geo replication and verification is
-completely finished. To keep the window as short as possible, you should
-ensure these processes are close to 100% as possible during active use.
-
-If the **secondary** node is still replicating data from the **primary** node,
-follow these steps to avoid unnecessary data loss:
-
-1. Until a [read-only mode](https://gitlab.com/gitlab-org/gitlab/-/issues/14609)
- is implemented, updates must be prevented from happening manually to the
- **primary**. Note that your **secondary** node still needs read-only
- access to the **primary** node during the maintenance window:
-
- 1. At the scheduled time, using your cloud provider or your node's firewall, block
- all HTTP, HTTPS and SSH traffic to/from the **primary** node, **except** for your IP and
- the **secondary** node's IP.
-
- For instance, you can run the following commands on the **primary** node:
-
- ```shell
- sudo iptables -A INPUT -p tcp -s <secondary_node_ip> --destination-port 22 -j ACCEPT
- sudo iptables -A INPUT -p tcp -s <your_ip> --destination-port 22 -j ACCEPT
- sudo iptables -A INPUT --destination-port 22 -j REJECT
-
- sudo iptables -A INPUT -p tcp -s <secondary_node_ip> --destination-port 80 -j ACCEPT
- sudo iptables -A INPUT -p tcp -s <your_ip> --destination-port 80 -j ACCEPT
- sudo iptables -A INPUT --tcp-dport 80 -j REJECT
-
- sudo iptables -A INPUT -p tcp -s <secondary_node_ip> --destination-port 443 -j ACCEPT
- sudo iptables -A INPUT -p tcp -s <your_ip> --destination-port 443 -j ACCEPT
- sudo iptables -A INPUT --tcp-dport 443 -j REJECT
- ```
-
- From this point, users will be unable to view their data or make changes on the
- **primary** node. They will also be unable to log in to the **secondary** node.
- However, existing sessions will work for the remainder of the maintenance period, and
- public data will be accessible throughout.
-
- 1. Verify the **primary** node is blocked to HTTP traffic by visiting it in browser via
- another IP. The server should refuse connection.
-
- 1. Verify the **primary** node is blocked to Git over SSH traffic by attempting to pull an
- existing Git repository with an SSH remote URL. The server should refuse
- connection.
-
- 1. On the **primary** node, disable non-Geo periodic background jobs by navigating
- to **Admin Area > Monitoring > Background Jobs > Cron**, clicking `Disable All`,
- and then clicking `Enable` for the `geo_sidekiq_cron_config_worker` cron job.
- This job will re-enable several other cron jobs that are essential for planned
- failover to complete successfully.
-
-1. Finish replicating and verifying all data:
-
- CAUTION: **Caution:**
- Not all data is automatically replicated. Read more about
- [what is excluded](planned_failover.md#not-all-data-is-automatically-replicated).
-
- 1. If you are manually replicating any
- [data not managed by Geo](../replication/datatypes.md#limitations-on-replicationverification),
- trigger the final replication process now.
- 1. On the **primary** node, navigate to **Admin Area > Monitoring > Background Jobs > Queues**
- and wait for all queues except those with `geo` in the name to drop to 0.
- These queues contain work that has been submitted by your users; failing over
- before it is completed will cause the work to be lost.
- 1. On the **primary** node, navigate to **Admin Area > Geo** and wait for the
- following conditions to be true of the **secondary** node you are failing over to:
- - All replication meters to each 100% replicated, 0% failures.
- - All verification meters reach 100% verified, 0% failures.
- - Database replication lag is 0ms.
- - The Geo log cursor is up to date (0 events behind).
-
- 1. On the **secondary** node, navigate to **Admin Area > Monitoring > Background Jobs > Queues**
- and wait for all the `geo` queues to drop to 0 queued and 0 running jobs.
- 1. On the **secondary** node, use [these instructions](../../raketasks/check.md)
- to verify the integrity of CI artifacts, LFS objects, and uploads in file
- storage.
-
- At this point, your **secondary** node will contain an up-to-date copy of everything the
- **primary** node has, meaning nothing will be lost when you fail over.
-
-1. In this final step, you need to permanently disable the **primary** node.
-
- CAUTION: **Caution:**
- When the **primary** node goes offline, there may be data saved on the **primary** node
- that has not been replicated to the **secondary** node. This data should be treated
- as lost if you proceed.
-
- TIP: **Tip:**
- If you plan to [update the **primary** domain DNS record](index.md#step-4-optional-updating-the-primary-domain-dns-record),
- you may wish to lower the TTL now to speed up propagation.
-
- When performing a failover, we want to avoid a split-brain situation where
- writes can occur in two different GitLab instances. So to prepare for the
- failover, you must disable the **primary** node:
-
- - If you have SSH access to the **primary** node, stop and disable GitLab:
-
- ```shell
- sudo gitlab-ctl stop
- ```
-
- Prevent GitLab from starting up again if the server unexpectedly reboots:
-
- ```shell
- sudo systemctl disable gitlab-runsvdir
- ```
-
- NOTE: **Note:**
- (**CentOS only**) In CentOS 6 or older, there is no easy way to prevent GitLab from being
- started if the machine reboots isn't available (see [Omnibus GitLab issue #3058](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/3058)).
- It may be safest to uninstall the GitLab package completely with `sudo yum remove gitlab-ee`.
-
- NOTE: **Note:**
- (**Ubuntu 14.04 LTS**) If you are using an older version of Ubuntu
- or any other distribution based on the Upstart init system, you can prevent GitLab
- from starting if the machine reboots as `root` with
- `initctl stop gitlab-runsvvdir && echo 'manual' > /etc/init/gitlab-runsvdir.override && initctl reload-configuration`.
-
- - If you do not have SSH access to the **primary** node, take the machine offline and
- prevent it from rebooting. Since there are many ways you may prefer to accomplish
- this, we will avoid a single recommendation. You may need to:
-
- - Reconfigure the load balancers.
- - Change DNS records (for example, point the **primary** DNS record to the **secondary**
- node in order to stop usage of the **primary** node).
- - Stop the virtual servers.
- - Block traffic through a firewall.
- - Revoke object storage permissions from the **primary** node.
- - Physically disconnect a machine.
-
-### Promoting the **secondary** node
-
-Note the following when promoting a secondary:
-
-- A new **secondary** should not be added at this time. If you want to add a new
- **secondary**, do this after you have completed the entire process of promoting
- the **secondary** to the **primary**.
-- If you encounter an `ActiveRecord::RecordInvalid: Validation failed: Name has already been taken`
- error during this process, read
- [the troubleshooting advice](../replication/troubleshooting.md#fixing-errors-during-a-failover-or-when-promoting-a-secondary-to-a-primary-node).
-
-To promote the secondary node:
-
-1. SSH in to your **secondary** node and login as root:
-
- ```shell
- sudo -i
- ```
-
-1. Edit `/etc/gitlab/gitlab.rb` to reflect its new status as **primary** by
- removing any lines that enabled the `geo_secondary_role`:
-
- ```ruby
- ## In pre-11.5 documentation, the role was enabled as follows. Remove this line.
- geo_secondary_role['enable'] = true
-
- ## In 11.5+ documentation, the role was enabled as follows. Remove this line.
- roles ['geo_secondary_role']
- ```
-
-1. Run the following command to list out all preflight checks and automatically
- check if replication and verification are complete before scheduling a planned
- failover to ensure the process will go smoothly:
-
- ```shell
- gitlab-ctl promotion-preflight-checks
- ```
-
-1. Promote the **secondary**:
-
- ```shell
- gitlab-ctl promote-to-primary-node
- ```
-
- If you have already run the [preflight checks](planned_failover.md#preflight-checks)
- or don't want to run them, you can skip them:
-
- ```shell
- gitlab-ctl promote-to-primary-node --skip-preflight-check
- ```
-
- You can also promote the secondary node to primary **without any further confirmation**, even when preflight checks fail:
-
- ```shell
- sudo gitlab-ctl promote-to-primary-node --force
- ```
-
-1. Verify you can connect to the newly promoted **primary** node using the URL used
- previously for the **secondary** node.
-
- If successful, the **secondary** node has now been promoted to the **primary** node.
-
-### Next steps
-
-To regain geographic redundancy as quickly as possible, you should
-[add a new **secondary** node](../setup/index.md). To
-do that, you can re-add the old **primary** as a new secondary and bring it back
-online.
+This document was moved to [another location](runbooks/planned_failover_single_node.md).
diff --git a/doc/administration/geo/disaster_recovery/runbooks/planned_failover_multi_node.md b/doc/administration/geo/disaster_recovery/runbooks/planned_failover_multi_node.md
new file mode 100644
index 00000000000..1e3bac0b354
--- /dev/null
+++ b/doc/administration/geo/disaster_recovery/runbooks/planned_failover_multi_node.md
@@ -0,0 +1,274 @@
+---
+stage: Enablement
+group: Geo
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+type: howto
+---
+
+CAUTION: **Caution:**
+This runbook is in **alpha**. For complete, production-ready documentation, see the
+[disaster recovery documentation](../index.md).
+
+# Disaster Recovery (Geo) promotion runbooks **(PREMIUM ONLY)**
+
+## Geo planned failover for a multi-node configuration
+
+| Component | Configuration |
+|-------------|-----------------|
+| PostgreSQL | Omnibus-managed |
+| Geo site | Multi-node |
+| Secondaries | One |
+
+This runbook will guide you through a planned failover of a multi-node Geo site
+with one secondary. The following [2000 user reference architecture](../../../../administration/reference_architectures/2k_users.md) is assumed:
+
+```mermaid
+graph TD
+ subgraph main[Geo deployment]
+ subgraph Primary[Primary site, multi-node]
+ Node_1[Rails node 1]
+ Node_2[Rails node 2]
+ Node_3[PostgreSQL node]
+ Node_4[Gitaly node]
+ Node_5[Redis node]
+ Node_6[Monitoring node]
+ end
+ subgraph Secondary[Secondary site, multi-node]
+ Node_7[Rails node 1]
+ Node_8[Rails node 2]
+ Node_9[PostgreSQL node]
+ Node_10[Gitaly node]
+ Node_11[Redis node]
+ Node_12[Monitoring node]
+ end
+ end
+```
+
+The load balancer node and optional NFS server are omitted for clarity.
+
+This guide will result in the following:
+
+1. An offline primary.
+1. A promoted secondary that is now the new primary.
+
+What is not covered:
+
+1. Re-adding the old **primary** as a secondary.
+1. Adding a new secondary.
+
+### Preparation
+
+NOTE: **Note:**
+Before following any of those steps, make sure you have `root` access to the
+**secondary** to promote it, since there isn't provided an automated way to
+promote a Geo replica and perform a failover.
+
+On the **secondary** node, navigate to the **Admin Area > Geo** dashboard to
+review its status. Replicated objects (shown in green) should be close to 100%,
+and there should be no failures (shown in red). If a large proportion of
+objects aren't yet replicated (shown in gray), consider giving the node more
+time to complete.
+
+![Replication status](../img/replication-status.png)
+
+If any objects are failing to replicate, this should be investigated before
+scheduling the maintenance window. After a planned failover, anything that
+failed to replicate will be **lost**.
+
+You can use the
+[Geo status API](../../../../api/geo_nodes.md#retrieve-project-sync-or-verification-failures-that-occurred-on-the-current-node)
+to review failed objects and the reasons for failure.
+A common cause of replication failures is the data being missing on the
+**primary** node - you can resolve these failures by restoring the data from backup,
+or removing references to the missing data.
+
+The maintenance window won't end until Geo replication and verification is
+completely finished. To keep the window as short as possible, you should
+ensure these processes are close to 100% as possible during active use.
+
+If the **secondary** node is still replicating data from the **primary** node,
+follow these steps to avoid unnecessary data loss:
+
+1. Until a [read-only mode](https://gitlab.com/gitlab-org/gitlab/-/issues/14609)
+ is implemented, updates must be prevented from happening manually to the
+ **primary**. Note that your **secondary** node still needs read-only
+ access to the **primary** node during the maintenance window:
+
+ 1. At the scheduled time, using your cloud provider or your node's firewall, block
+ all HTTP, HTTPS and SSH traffic to/from the **primary** node, **except** for your IP and
+ the **secondary** node's IP.
+
+ For instance, you can run the following commands on the **primary** node:
+
+ ```shell
+ sudo iptables -A INPUT -p tcp -s <secondary_node_ip> --destination-port 22 -j ACCEPT
+ sudo iptables -A INPUT -p tcp -s <your_ip> --destination-port 22 -j ACCEPT
+ sudo iptables -A INPUT --destination-port 22 -j REJECT
+
+ sudo iptables -A INPUT -p tcp -s <secondary_node_ip> --destination-port 80 -j ACCEPT
+ sudo iptables -A INPUT -p tcp -s <your_ip> --destination-port 80 -j ACCEPT
+ sudo iptables -A INPUT --tcp-dport 80 -j REJECT
+
+ sudo iptables -A INPUT -p tcp -s <secondary_node_ip> --destination-port 443 -j ACCEPT
+ sudo iptables -A INPUT -p tcp -s <your_ip> --destination-port 443 -j ACCEPT
+ sudo iptables -A INPUT --tcp-dport 443 -j REJECT
+ ```
+
+ From this point, users will be unable to view their data or make changes on the
+ **primary** node. They will also be unable to log in to the **secondary** node.
+ However, existing sessions will work for the remainder of the maintenance period, and
+ public data will be accessible throughout.
+
+ 1. Verify the **primary** node is blocked to HTTP traffic by visiting it in browser via
+ another IP. The server should refuse connection.
+
+ 1. Verify the **primary** node is blocked to Git over SSH traffic by attempting to pull an
+ existing Git repository with an SSH remote URL. The server should refuse
+ connection.
+
+ 1. On the **primary** node, disable non-Geo periodic background jobs by navigating
+ to **Admin Area > Monitoring > Background Jobs > Cron**, clicking `Disable All`,
+ and then clicking `Enable` for the `geo_sidekiq_cron_config_worker` cron job.
+ This job will re-enable several other cron jobs that are essential for planned
+ failover to complete successfully.
+
+1. Finish replicating and verifying all data:
+
+ CAUTION: **Caution:**
+ Not all data is automatically replicated. Read more about
+ [what is excluded](../planned_failover.md#not-all-data-is-automatically-replicated).
+
+ 1. If you are manually replicating any
+ [data not managed by Geo](../../replication/datatypes.md#limitations-on-replicationverification),
+ trigger the final replication process now.
+ 1. On the **primary** node, navigate to **Admin Area > Monitoring > Background Jobs > Queues**
+ and wait for all queues except those with `geo` in the name to drop to 0.
+ These queues contain work that has been submitted by your users; failing over
+ before it is completed will cause the work to be lost.
+ 1. On the **primary** node, navigate to **Admin Area > Geo** and wait for the
+ following conditions to be true of the **secondary** node you are failing over to:
+ - All replication meters to each 100% replicated, 0% failures.
+ - All verification meters reach 100% verified, 0% failures.
+ - Database replication lag is 0ms.
+ - The Geo log cursor is up to date (0 events behind).
+
+ 1. On the **secondary** node, navigate to **Admin Area > Monitoring > Background Jobs > Queues**
+ and wait for all the `geo` queues to drop to 0 queued and 0 running jobs.
+ 1. On the **secondary** node, use [these instructions](../../../raketasks/check.md)
+ to verify the integrity of CI artifacts, LFS objects, and uploads in file
+ storage.
+
+ At this point, your **secondary** node will contain an up-to-date copy of everything the
+ **primary** node has, meaning nothing will be lost when you fail over.
+
+1. In this final step, you need to permanently disable the **primary** node.
+
+ CAUTION: **Caution:**
+ When the **primary** node goes offline, there may be data saved on the **primary** node
+ that has not been replicated to the **secondary** node. This data should be treated
+ as lost if you proceed.
+
+ TIP: **Tip:**
+ If you plan to [update the **primary** domain DNS record](../index.md#step-4-optional-updating-the-primary-domain-dns-record),
+ you may wish to lower the TTL now to speed up propagation.
+
+ When performing a failover, we want to avoid a split-brain situation where
+ writes can occur in two different GitLab instances. So to prepare for the
+ failover, you must disable the **primary** node:
+
+ - If you have SSH access to the **primary** node, stop and disable GitLab:
+
+ ```shell
+ sudo gitlab-ctl stop
+ ```
+
+ Prevent GitLab from starting up again if the server unexpectedly reboots:
+
+ ```shell
+ sudo systemctl disable gitlab-runsvdir
+ ```
+
+ NOTE: **Note:**
+ (**CentOS only**) In CentOS 6 or older, there is no easy way to prevent GitLab from being
+ started if the machine reboots isn't available (see [Omnibus GitLab issue #3058](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/3058)).
+ It may be safest to uninstall the GitLab package completely with `sudo yum remove gitlab-ee`.
+
+ NOTE: **Note:**
+ (**Ubuntu 14.04 LTS**) If you are using an older version of Ubuntu
+ or any other distribution based on the Upstart init system, you can prevent GitLab
+ from starting if the machine reboots as `root` with
+ `initctl stop gitlab-runsvvdir && echo 'manual' > /etc/init/gitlab-runsvdir.override && initctl reload-configuration`.
+
+ - If you do not have SSH access to the **primary** node, take the machine offline and
+ prevent it from rebooting. Since there are many ways you may prefer to accomplish
+ this, we will avoid a single recommendation. You may need to:
+
+ - Reconfigure the load balancers.
+ - Change DNS records (for example, point the **primary** DNS record to the
+ **secondary** node to stop using the **primary** node).
+ - Stop the virtual servers.
+ - Block traffic through a firewall.
+ - Revoke object storage permissions from the **primary** node.
+ - Physically disconnect a machine.
+
+### Promoting the **secondary** node
+
+NOTE: **Note:**
+A new **secondary** should not be added at this time. If you want to add a new
+**secondary**, do this after you have completed the entire process of promoting
+the **secondary** to the **primary**.
+
+CAUTION: **Caution:**
+If you encounter an `ActiveRecord::RecordInvalid: Validation failed: Name has already been taken` error during this process, read
+[the troubleshooting advice](../../replication/troubleshooting.md#fixing-errors-during-a-failover-or-when-promoting-a-secondary-to-a-primary-node).
+
+The `gitlab-ctl promote-to-primary-node` command cannot be used yet in
+conjunction with multiple servers, as it can only
+perform changes on a **secondary** with only a single machine. Instead, you must
+do this manually.
+
+DANGER: **Danger:**
+In GitLab 13.2 and later versions, promoting a secondary node to a primary while the secondary is paused fails. We are [investigating the issue](https://gitlab.com/gitlab-org/gitlab/-/issues/225173). Do not pause replication before promoting a secondary. If the node is paused, please resume before promoting.
+
+1. SSH in to the PostgreSQL node in the **secondary** and trigger PostgreSQL to
+ promote to read-write:
+
+ ```shell
+ sudo gitlab-pg-ctl promote
+ ```
+
+ In GitLab 12.8 and earlier, see [Message: `sudo: gitlab-pg-ctl: command not found`](../../replication/troubleshooting.md#message-sudo-gitlab-pg-ctl-command-not-found).
+
+1. Edit `/etc/gitlab/gitlab.rb` on every machine in the **secondary** to
+ reflect its new status as **primary** by removing any lines that enabled the
+ `geo_secondary_role`:
+
+ ```ruby
+ ## In pre-11.5 documentation, the role was enabled as follows. Remove this line.
+ geo_secondary_role['enable'] = true
+
+ ## In 11.5+ documentation, the role was enabled as follows. Remove this line.
+ roles ['geo_secondary_role']
+ ```
+
+ After making these changes [Reconfigure GitLab](../../../restart_gitlab.md#omnibus-gitlab-reconfigure) each
+ machine so the changes take effect.
+
+1. Promote the **secondary** to **primary**. SSH into a single Rails node
+ server and execute:
+
+ ```shell
+ sudo gitlab-rake geo:set_secondary_as_primary
+ ```
+
+1. Verify you can connect to the newly promoted **primary** using the URL used
+ previously for the **secondary**.
+
+1. Success! The **secondary** has now been promoted to **primary**.
+
+### Next steps
+
+To regain geographic redundancy as quickly as possible, you should
+[add a new **secondary** node](../../setup/index.md). To
+do that, you can re-add the old **primary** as a new secondary and bring it back
+online.
diff --git a/doc/administration/geo/disaster_recovery/runbooks/planned_failover_single_node.md b/doc/administration/geo/disaster_recovery/runbooks/planned_failover_single_node.md
new file mode 100644
index 00000000000..5e847030077
--- /dev/null
+++ b/doc/administration/geo/disaster_recovery/runbooks/planned_failover_single_node.md
@@ -0,0 +1,269 @@
+---
+stage: Enablement
+group: Geo
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+type: howto
+---
+
+CAUTION: **Caution:**
+This runbook is in **alpha**. For complete, production-ready documentation, see the
+[disaster recovery documentation](../index.md).
+
+# Disaster Recovery (Geo) promotion runbooks **(PREMIUM ONLY)**
+
+## Geo planned failover for a single-node configuration
+
+| Component | Configuration |
+|-------------|-----------------|
+| PostgreSQL | Omnibus-managed |
+| Geo site | Single-node |
+| Secondaries | One |
+
+This runbook will guide you through a planned failover of a single-node Geo site
+with one secondary. The following general architecture is assumed:
+
+```mermaid
+graph TD
+ subgraph main[Geo deployment]
+ subgraph Primary[Primary site]
+ Node_1[(GitLab node)]
+ end
+ subgraph Secondary1[Secondary site]
+ Node_2[(GitLab node)]
+ end
+ end
+```
+
+This guide will result in the following:
+
+1. An offline primary.
+1. A promoted secondary that is now the new primary.
+
+What is not covered:
+
+1. Re-adding the old **primary** as a secondary.
+1. Adding a new secondary.
+
+### Preparation
+
+NOTE: **Note:**
+Before following any of those steps, make sure you have `root` access to the
+**secondary** to promote it, since there isn't provided an automated way to
+promote a Geo replica and perform a failover.
+
+On the **secondary** node, navigate to the **Admin Area > Geo** dashboard to
+review its status. Replicated objects (shown in green) should be close to 100%,
+and there should be no failures (shown in red). If a large proportion of
+objects aren't yet replicated (shown in gray), consider giving the node more
+time to complete.
+
+![Replication status](../img/replication-status.png)
+
+If any objects are failing to replicate, this should be investigated before
+scheduling the maintenance window. After a planned failover, anything that
+failed to replicate will be **lost**.
+
+You can use the
+[Geo status API](../../../../api/geo_nodes.md#retrieve-project-sync-or-verification-failures-that-occurred-on-the-current-node)
+to review failed objects and the reasons for failure.
+A common cause of replication failures is the data being missing on the
+**primary** node - you can resolve these failures by restoring the data from backup,
+or removing references to the missing data.
+
+The maintenance window won't end until Geo replication and verification is
+completely finished. To keep the window as short as possible, you should
+ensure these processes are close to 100% as possible during active use.
+
+If the **secondary** node is still replicating data from the **primary** node,
+follow these steps to avoid unnecessary data loss:
+
+1. Until a [read-only mode](https://gitlab.com/gitlab-org/gitlab/-/issues/14609)
+ is implemented, updates must be prevented from happening manually to the
+ **primary**. Note that your **secondary** node still needs read-only
+ access to the **primary** node during the maintenance window:
+
+ 1. At the scheduled time, using your cloud provider or your node's firewall, block
+ all HTTP, HTTPS and SSH traffic to/from the **primary** node, **except** for your IP and
+ the **secondary** node's IP.
+
+ For instance, you can run the following commands on the **primary** node:
+
+ ```shell
+ sudo iptables -A INPUT -p tcp -s <secondary_node_ip> --destination-port 22 -j ACCEPT
+ sudo iptables -A INPUT -p tcp -s <your_ip> --destination-port 22 -j ACCEPT
+ sudo iptables -A INPUT --destination-port 22 -j REJECT
+
+ sudo iptables -A INPUT -p tcp -s <secondary_node_ip> --destination-port 80 -j ACCEPT
+ sudo iptables -A INPUT -p tcp -s <your_ip> --destination-port 80 -j ACCEPT
+ sudo iptables -A INPUT --tcp-dport 80 -j REJECT
+
+ sudo iptables -A INPUT -p tcp -s <secondary_node_ip> --destination-port 443 -j ACCEPT
+ sudo iptables -A INPUT -p tcp -s <your_ip> --destination-port 443 -j ACCEPT
+ sudo iptables -A INPUT --tcp-dport 443 -j REJECT
+ ```
+
+ From this point, users will be unable to view their data or make changes on the
+ **primary** node. They will also be unable to log in to the **secondary** node.
+ However, existing sessions will work for the remainder of the maintenance period, and
+ public data will be accessible throughout.
+
+ 1. Verify the **primary** node is blocked to HTTP traffic by visiting it in browser via
+ another IP. The server should refuse connection.
+
+ 1. Verify the **primary** node is blocked to Git over SSH traffic by attempting to pull an
+ existing Git repository with an SSH remote URL. The server should refuse
+ connection.
+
+ 1. On the **primary** node, disable non-Geo periodic background jobs by navigating
+ to **Admin Area > Monitoring > Background Jobs > Cron**, clicking `Disable All`,
+ and then clicking `Enable` for the `geo_sidekiq_cron_config_worker` cron job.
+ This job will re-enable several other cron jobs that are essential for planned
+ failover to complete successfully.
+
+1. Finish replicating and verifying all data:
+
+ CAUTION: **Caution:**
+ Not all data is automatically replicated. Read more about
+ [what is excluded](../planned_failover.md#not-all-data-is-automatically-replicated).
+
+ 1. If you are manually replicating any
+ [data not managed by Geo](../../replication/datatypes.md#limitations-on-replicationverification),
+ trigger the final replication process now.
+ 1. On the **primary** node, navigate to **Admin Area > Monitoring > Background Jobs > Queues**
+ and wait for all queues except those with `geo` in the name to drop to 0.
+ These queues contain work that has been submitted by your users; failing over
+ before it is completed will cause the work to be lost.
+ 1. On the **primary** node, navigate to **Admin Area > Geo** and wait for the
+ following conditions to be true of the **secondary** node you are failing over to:
+ - All replication meters to each 100% replicated, 0% failures.
+ - All verification meters reach 100% verified, 0% failures.
+ - Database replication lag is 0ms.
+ - The Geo log cursor is up to date (0 events behind).
+
+ 1. On the **secondary** node, navigate to **Admin Area > Monitoring > Background Jobs > Queues**
+ and wait for all the `geo` queues to drop to 0 queued and 0 running jobs.
+ 1. On the **secondary** node, use [these instructions](../../../raketasks/check.md)
+ to verify the integrity of CI artifacts, LFS objects, and uploads in file
+ storage.
+
+ At this point, your **secondary** node will contain an up-to-date copy of everything the
+ **primary** node has, meaning nothing will be lost when you fail over.
+
+1. In this final step, you need to permanently disable the **primary** node.
+
+ CAUTION: **Caution:**
+ When the **primary** node goes offline, there may be data saved on the **primary** node
+ that has not been replicated to the **secondary** node. This data should be treated
+ as lost if you proceed.
+
+ TIP: **Tip:**
+ If you plan to [update the **primary** domain DNS record](../index.md#step-4-optional-updating-the-primary-domain-dns-record),
+ you may wish to lower the TTL now to speed up propagation.
+
+ When performing a failover, we want to avoid a split-brain situation where
+ writes can occur in two different GitLab instances. So to prepare for the
+ failover, you must disable the **primary** node:
+
+ - If you have SSH access to the **primary** node, stop and disable GitLab:
+
+ ```shell
+ sudo gitlab-ctl stop
+ ```
+
+ Prevent GitLab from starting up again if the server unexpectedly reboots:
+
+ ```shell
+ sudo systemctl disable gitlab-runsvdir
+ ```
+
+ NOTE: **Note:**
+ (**CentOS only**) In CentOS 6 or older, there is no easy way to prevent GitLab from being
+ started if the machine reboots isn't available (see [Omnibus GitLab issue #3058](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/3058)).
+ It may be safest to uninstall the GitLab package completely with `sudo yum remove gitlab-ee`.
+
+ NOTE: **Note:**
+ (**Ubuntu 14.04 LTS**) If you are using an older version of Ubuntu
+ or any other distribution based on the Upstart init system, you can prevent GitLab
+ from starting if the machine reboots as `root` with
+ `initctl stop gitlab-runsvvdir && echo 'manual' > /etc/init/gitlab-runsvdir.override && initctl reload-configuration`.
+
+ - If you do not have SSH access to the **primary** node, take the machine offline and
+ prevent it from rebooting. Since there are many ways you may prefer to accomplish
+ this, we will avoid a single recommendation. You may need to:
+
+ - Reconfigure the load balancers.
+ - Change DNS records (for example, point the **primary** DNS record to the
+ **secondary** node to stop using the **primary** node).
+ - Stop the virtual servers.
+ - Block traffic through a firewall.
+ - Revoke object storage permissions from the **primary** node.
+ - Physically disconnect a machine.
+
+### Promoting the **secondary** node
+
+Note the following when promoting a secondary:
+
+- A new **secondary** should not be added at this time. If you want to add a new
+ **secondary**, do this after you have completed the entire process of promoting
+ the **secondary** to the **primary**.
+- If you encounter an `ActiveRecord::RecordInvalid: Validation failed: Name has already been taken`
+ error during this process, read
+ [the troubleshooting advice](../../replication/troubleshooting.md#fixing-errors-during-a-failover-or-when-promoting-a-secondary-to-a-primary-node).
+
+To promote the secondary node:
+
+1. SSH in to your **secondary** node and login as root:
+
+ ```shell
+ sudo -i
+ ```
+
+1. Edit `/etc/gitlab/gitlab.rb` to reflect its new status as **primary** by
+ removing any lines that enabled the `geo_secondary_role`:
+
+ ```ruby
+ ## In pre-11.5 documentation, the role was enabled as follows. Remove this line.
+ geo_secondary_role['enable'] = true
+
+ ## In 11.5+ documentation, the role was enabled as follows. Remove this line.
+ roles ['geo_secondary_role']
+ ```
+
+1. Run the following command to list out all preflight checks and automatically
+ check if replication and verification are complete before scheduling a planned
+ failover to ensure the process will go smoothly:
+
+ ```shell
+ gitlab-ctl promotion-preflight-checks
+ ```
+
+1. Promote the **secondary**:
+
+ ```shell
+ gitlab-ctl promote-to-primary-node
+ ```
+
+ If you have already run the [preflight checks](../planned_failover.md#preflight-checks)
+ or don't want to run them, you can skip them:
+
+ ```shell
+ gitlab-ctl promote-to-primary-node --skip-preflight-check
+ ```
+
+ You can also promote the secondary node to primary **without any further confirmation**, even when preflight checks fail:
+
+ ```shell
+ sudo gitlab-ctl promote-to-primary-node --force
+ ```
+
+1. Verify you can connect to the newly promoted **primary** node using the URL used
+ previously for the **secondary** node.
+
+ If successful, the **secondary** node has now been promoted to the **primary** node.
+
+### Next steps
+
+To regain geographic redundancy as quickly as possible, you should
+[add a new **secondary** node](../../setup/index.md). To
+do that, you can re-add the old **primary** as a new secondary and bring it back
+online.
diff --git a/doc/administration/geo/index.md b/doc/administration/geo/index.md
index 6fdf213ac78..47d19c1e12c 100644
--- a/doc/administration/geo/index.md
+++ b/doc/administration/geo/index.md
@@ -39,7 +39,7 @@ Implementing Geo provides the following benefits:
In addition, it:
-- Can be used for cloning and fetching projects, in addition to reading any data available in the GitLab web interface (see [current limitations](#current-limitations)).
+- Can be used for cloning and fetching projects, in addition to reading any data available in the GitLab web interface (see [limitations](#limitations)).
- Overcomes slow connections between distant offices, saving time by improving speed for distributed teams.
- Helps reducing the loading time for automated tasks, custom integrations, and internal workflows.
- Can quickly fail over to a **secondary** node in a [disaster recovery](disaster_recovery/index.md) scenario.
@@ -69,7 +69,7 @@ Keep in mind that:
- Replicate repositories, LFS Objects, and Attachments (HTTPS + JWT).
- Since GitLab Premium 10.0, the **primary** node no longer talks to **secondary** nodes to notify for changes (API).
- Pushing directly to a **secondary** node (for both HTTP and SSH, including Git LFS) was [introduced](https://about.gitlab.com/releases/2018/09/22/gitlab-11-3-released/) in [GitLab Premium](https://about.gitlab.com/pricing/#self-managed) 11.3.
-- There are [limitations](#current-limitations) in the current implementation.
+- There are [limitations](#limitations) when using Geo.
### Architecture
@@ -195,6 +195,9 @@ For information on how to update your Geo nodes to the latest GitLab version, se
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/35913) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.2.
+DANGER: **Danger:**
+In GitLab 13.2 and later versions, promoting a secondary node to a primary while the secondary is paused fails. We are [investigating the issue](https://gitlab.com/gitlab-org/gitlab/-/issues/225173). Do not pause replication before promoting a secondary. If the node is paused, please resume before promoting.
+
In some circumstances, like during [upgrades](replication/updating_the_geo_nodes.md) or a [planned failover](disaster_recovery/planned_failover.md), it is desirable to pause replication between the primary and secondary.
Pausing and resuming replication is done via a command line tool from the secondary node.
@@ -247,7 +250,7 @@ For more information on removing a Geo node, see [Removing **secondary** Geo nod
To find out how to disable Geo, see [Disabling Geo](replication/disable_geo.md).
-## Current limitations
+## Limitations
CAUTION: **Caution:**
This list of limitations only reflects the latest version of GitLab. If you are using an older version, extra limitations may be in place.
@@ -261,6 +264,7 @@ This list of limitations only reflects the latest version of GitLab. If you are
- Object pools for forked project deduplication work only on the **primary** node, and are duplicated on the **secondary** node.
- [External merge request diffs](../merge_request_diffs.md) will not be replicated if they are on-disk, and viewing merge requests will fail. However, external MR diffs in object storage **are** supported. The default configuration (in-database) does work.
- GitLab Runners cannot register with a **secondary** node. Support for this is [planned for the future](https://gitlab.com/gitlab-org/gitlab/-/issues/3294).
+- Geo **secondary** nodes can not be configured to [use high-availability configurations of PostgreSQL](https://gitlab.com/groups/gitlab-org/-/epics/2536).
### Limitations on replication/verification
diff --git a/doc/administration/geo/replication/datatypes.md b/doc/administration/geo/replication/datatypes.md
index 166a724f9c1..52a2b1521a9 100644
--- a/doc/administration/geo/replication/datatypes.md
+++ b/doc/administration/geo/replication/datatypes.md
@@ -47,6 +47,8 @@ verification methods:
| Blobs | Container registry _(object storage)_ | Geo with API/Managed/Docker API (*2*) | _Not implemented_ |
| Blobs | Package registry _(filesystem)_ | Geo with API | _Not implemented_ |
| Blobs | Package registry _(object storage)_ | Geo with API/Managed (*2*) | _Not implemented_ |
+| Blobs | Versioned Terraform State _(filesystem)_ | Geo with API | _Not implemented_ |
+| Blobs | Versioned Terraform State _(object storage)_ | Geo with API/Managed (*2*) | _Not implemented_ |
- (*1*): Redis replication can be used as part of HA with Redis sentinel. It's not used between Geo nodes.
- (*2*): Object storage replication can be performed by Geo or by your object storage provider/appliance
@@ -160,39 +162,33 @@ replicating data from those features will cause the data to be **lost**.
If you wish to use those features on a **secondary** node, or to execute a failover
successfully, you must replicate their data using some other means.
-| Feature | Replicated (added in GitLab version) | Verified (added in GitLab version) | Notes |
-|:------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------|:----------------------------------------------------------|:-----------------------------------------------------------------------------------------------------------|
-| Application data in PostgreSQL | **Yes** (10.2) | **Yes** (10.2) | |
-| Project repository | **Yes** (10.2) | **Yes** (10.7) | |
-| Project wiki repository | **Yes** (10.2) | **Yes** (10.7) | |
-| Project designs repository | **Yes** (12.7) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/32467) | |
-| Uploads | **Yes** (10.2) | [No](https://gitlab.com/groups/gitlab-org/-/epics/1817) | Verified only on transfer, or manually (*1*) |
-| LFS objects | **Yes** (10.2) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/8922) | Verified only on transfer, or manually (*1*). Unavailable for new LFS objects in 11.11.x and 12.0.x (*2*). |
-| CI job artifacts (other than traces) | **Yes** (10.4) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/8923) | Verified only manually (*1*) |
-| Archived traces | **Yes** (10.4) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/8923) | Verified only on transfer, or manually (*1*) |
-| Personal snippets | **Yes** (10.2) | **Yes** (10.2) | |
-| [Versioned snippets](../../../user/snippets.md#versioned-snippets) | [No](https://gitlab.com/groups/gitlab-org/-/epics/2809) | [No](https://gitlab.com/groups/gitlab-org/-/epics/2810) | |
-| Project snippets | **Yes** (10.2) | **Yes** (10.2) | |
-| Object pools for forked project deduplication | **Yes** | No | |
-| [Server-side Git hooks](../../server_hooks.md) | No | No | |
-| [Elasticsearch integration](../../../integration/elasticsearch.md) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/1186) | No | |
-| [GitLab Pages](../../pages/index.md) | [No](https://gitlab.com/groups/gitlab-org/-/epics/589) | No | |
-| [Container Registry](../../packages/container_registry.md) | **Yes** (12.3) | No | |
-| [NPM Registry](../../../user/packages/npm_registry/index.md) | **Yes** (13.2) | No | Behind feature flag `geo_package_file_replication`, enabled by default |
-| [Maven Repository](../../../user/packages/maven_repository/index.md) | **Yes** (13.2) | No | Behind feature flag `geo_package_file_replication`, enabled by default |
-| [Conan Repository](../../../user/packages/conan_repository/index.md) | **Yes** (13.2) | No | Behind feature flag `geo_package_file_replication`, enabled by default |
-| [NuGet Repository](../../../user/packages/nuget_repository/index.md) | **Yes** (13.2) | No | Behind feature flag `geo_package_file_replication`, enabled by default |
-| [PyPi Repository](../../../user/packages/pypi_repository/index.md) | **Yes** (13.2) | No | Behind feature flag `geo_package_file_replication`, enabled by default |
-| [Composer Repository](../../../user/packages/composer_repository/index.md) | **Yes** (13.2) | No | Behind feature flag `geo_package_file_replication`, enabled by default |
-| [External merge request diffs](../../merge_request_diffs.md) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/33817) | No | |
-| [Terraform State](../../terraform_state.md) | [No](https://gitlab.com/groups/gitlab-org/-/epics/3112)(*3*) | No | |
-| [Vulnerability Export](../../../user/application_security/security_dashboard/#export-vulnerabilities) | [No](https://gitlab.com/groups/gitlab-org/-/epics/3111)(*3*) | No | |
-| Content in object storage | **Yes** (12.4) | No | |
-
-- (*1*): The integrity can be verified manually using
- [Integrity Check Rake Task](../../raketasks/check.md) on both nodes and comparing
- the output between them.
-- (*2*): GitLab versions 11.11.x and 12.0.x are affected by [a bug that prevents any new
- LFS objects from replicating](https://gitlab.com/gitlab-org/gitlab/-/issues/32696).
-- (*3*): If you are using Object Storage, the replication can be performed by the
- Object Storage provider if supported. Please see [Geo with Object Storage](object_storage.md)
+| Feature | Replicated (added in GitLab version) | Verified (added in GitLab version) | Object Storage replication (please see [Geo with Object Storage](object_storage.md)) | Notes |
+|:---------------------------------------------------------------------------------------------------------------|:-----------------------------------------------------------------------------------|:----------------------------------------------------------|:-------------------------------------------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| [Application data in PostgreSQL](../../postgresql/index.md) | **Yes** (10.2) | **Yes** (10.2) | No | |
+| [Project repository](../../..//user/project/repository/) | **Yes** (10.2) | **Yes** (10.7) | No | |
+| [Project wiki repository](../../../user/project/wiki/) | **Yes** (10.2) | **Yes** (10.7) | No | |
+| [Uploads](../../uploads.md) | **Yes** (10.2) | [No](https://gitlab.com/groups/gitlab-org/-/epics/1817) | No | Verified only on transfer or manually using [Integrity Check Rake Task](../../raketasks/check.md) on both nodes and comparing the output between them. |
+| [LFS objects](../../lfs/index.md) | **Yes** (10.2) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/8922) | Via Object Storage provider if supported. Native Geo support (Beta). | Verified only on transfer or manually using [Integrity Check Rake Task](../../raketasks/check.md) on both nodes and comparing the output between them. GitLab versions 11.11.x and 12.0.x are affected by [a bug that prevents any new LFS objects from replicating](https://gitlab.com/gitlab-org/gitlab/-/issues/32696). |
+| [Personal snippets](../../../user/snippets.md#personal-snippets) | **Yes** (10.2) | **Yes** (10.2) | No | |
+| [Project snippets](../../../user/snippets.md#project-snippets) | **Yes** (10.2) | **Yes** (10.2) | No | |
+| [CI job artifacts (other than Job Logs)](../../../ci/pipelines/job_artifacts.md) | **Yes** (10.4) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/8923) | Via Object Storage provider if supported. Native Geo support (Beta) . | Verified only manually using [Integrity Check Rake Task](../../raketasks/check.md) on both nodes and comparing the output between them |
+| [Job logs](../../job_logs.md) | **Yes** (10.4) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/8923) | Via Object Storage provider if supported. Native Geo support (Beta). | Verified only on transfer or manually using [Integrity Check Rake Task](../../raketasks/check.md) on both nodes and comparing the output between them |
+| [Object pools for forked project deduplication](../../../development/git_object_deduplication.md) | **Yes** | No | No | |
+| [Container Registry](../../packages/container_registry.md) | **Yes** (12.3) | No | No | |
+| [Content in object storage (beta)](object_storage.md) | **Yes** (12.4) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/13845) | No | |
+| [Project designs repository](../../../user/project/issues/design_management.md) | **Yes** (12.7) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/32467) | Via Object Storage provider if supported. Native Geo support (Beta). | |
+| [NPM Registry](../../../user/packages/npm_registry/index.md) | **Yes** (13.2) | [No](https://gitlab.com/groups/gitlab-org/-/epics/1817) | Via Object Storage provider if supported. Native Geo support (Beta). | Behind feature flag `geo_package_file_replication`, enabled by default |
+| [Maven Repository](../../../user/packages/maven_repository/index.md) | **Yes** (13.2) | [No](https://gitlab.com/groups/gitlab-org/-/epics/1817) | Via Object Storage provider if supported. Native Geo support (Beta). | Behind feature flag `geo_package_file_replication`, enabled by default |
+| [Conan Repository](../../../user/packages/conan_repository/index.md) | **Yes** (13.2) | [No](https://gitlab.com/groups/gitlab-org/-/epics/1817) | Via Object Storage provider if supported. Native Geo support (Beta). | Behind feature flag `geo_package_file_replication`, enabled by default |
+| [NuGet Repository](../../../user/packages/nuget_repository/index.md) | **Yes** (13.2) | [No](https://gitlab.com/groups/gitlab-org/-/epics/1817) | Via Object Storage provider if supported. Native Geo support (Beta). | Behind feature flag `geo_package_file_replication`, enabled by default |
+| [PyPi Repository](../../../user/packages/pypi_repository/index.md) | **Yes** (13.2) | [No](https://gitlab.com/groups/gitlab-org/-/epics/1817) | Via Object Storage provider if supported. Native Geo support (Beta). | Behind feature flag `geo_package_file_replication`, enabled by default |
+| [Composer Repository](../../../user/packages/composer_repository/index.md) | **Yes** (13.2) | [No](https://gitlab.com/groups/gitlab-org/-/epics/1817) | Via Object Storage provider if supported. Native Geo support (Beta). | Behind feature flag `geo_package_file_replication`, enabled by default |
+| [Versioned Terraform State](../../terraform_state.md) | **Yes** (13.5) | No | Via Object Storage provider if supported. Native Geo support (Beta). | Behind feature flag `geo_terraform_state_version_replication`, enabled by default |
+| [External merge request diffs](../../merge_request_diffs.md) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/33817) | No | Via Object Storage provider if supported. Native Geo support (Beta). | |
+| [Versioned snippets](../../../user/snippets.md#versioned-snippets) | [No](https://gitlab.com/groups/gitlab-org/-/epics/2809) | [No](https://gitlab.com/groups/gitlab-org/-/epics/2810) | No | |
+| [Server-side Git hooks](../../server_hooks.md) | [No](https://gitlab.com/groups/gitlab-org/-/epics/1867) | No | No | |
+| [Elasticsearch integration](../../../integration/elasticsearch.md) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/1186) | No | No | |
+| [GitLab Pages](../../pages/index.md) | [No](https://gitlab.com/groups/gitlab-org/-/epics/589) | No | No | |
+| [CI Pipeline Artifacts](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/ci/pipeline_artifact.rb) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/238464) | No | Via Object Storage provider if supported. Native Geo support (Beta). | Persists additional artifacts after a pipeline completes |
+| [Dependency proxy images](../../../user/packages/dependency_proxy/index.md) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/259694) | No | No | Blocked on [Geo: Secondary Mimicry](https://gitlab.com/groups/gitlab-org/-/epics/1528). Note that replication of this cache is not needed for Disaster Recovery purposes because it can be recreated from external sources. |
+| [Vulnerability Export](../../../user/application_security/security_dashboard/#export-vulnerabilities) | [Not planned](https://gitlab.com/groups/gitlab-org/-/epics/3111) | No | Via Object Storage provider if supported. Native Geo support (Beta). | Not planned because they are ephemeral and sensitive. They can be regenerated on demand. |
diff --git a/doc/administration/geo/replication/disable_geo.md b/doc/administration/geo/replication/disable_geo.md
index aed8e5fc3bc..14a11d9c1e3 100644
--- a/doc/administration/geo/replication/disable_geo.md
+++ b/doc/administration/geo/replication/disable_geo.md
@@ -29,7 +29,7 @@ anymore on these nodes. You can follow our docs to [remove your secondary Geo no
If the current node that you want to keep using is a secondary node, you need to first promote it to primary.
You can use our steps on [how to promote a secondary node](../disaster_recovery/#step-3-promoting-a-secondary-node)
-in order to do that.
+to do that.
## Remove the primary node from the UI
diff --git a/doc/administration/geo/replication/faq.md b/doc/administration/geo/replication/faq.md
index 3892d73b465..f7f391b360e 100644
--- a/doc/administration/geo/replication/faq.md
+++ b/doc/administration/geo/replication/faq.md
@@ -67,3 +67,7 @@ That's totally fine. We use HTTP(s) to fetch repository changes from the **prima
## Is this possible to set up a Docker Registry for a **secondary** node that mirrors the one on the **primary** node?
Yes. See [Docker Registry for a **secondary** node](docker_registry.md).
+
+## Can I login to a secondary node?
+
+Yes, but secondary nodes receive all authentication data (like user accounts and logins) from the primary instance. This means you will be re-directed to the primary for authentication and routed back afterwards.
diff --git a/doc/administration/geo/replication/geo_validation_tests.md b/doc/administration/geo/replication/geo_validation_tests.md
index 8247b8c6336..efd070635cb 100644
--- a/doc/administration/geo/replication/geo_validation_tests.md
+++ b/doc/administration/geo/replication/geo_validation_tests.md
@@ -114,6 +114,22 @@ The following are GitLab upgrade validation tests we performed.
The following are PostgreSQL upgrade validation tests we performed.
+### September 2020
+
+[Verify PostgreSQL 12 upgrade for Geo installations](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/5454):
+
+- Description: With PostgreSQL 12 available as an opt-in version in GitLab 13.3, we tested upgrading
+ existing Geo installations from PostgreSQL 11 to 12. We also re-tested fresh installations of GitLab
+ with Geo after fixes were made to support PostgreSQL 12. These tests were done using a
+ [nightly build](https://packages.gitlab.com/gitlab/nightly-builds/packages/ubuntu/bionic/gitlab-ee_13.3.6+rnightly.169516.d5209202-0_amd64.deb)
+ of GitLab 13.4.
+- Outcome: Tests were successful for Geo deployments with a single database node on the primary and secondary.
+ We encountered known issues with repmgr and Patroni managed PostgreSQL clusters on the Geo primary. Using
+ PostgreSQL 12 with a database cluster on the primary is not recommended until the issues are resolved.
+- Known issues for PostgreSQL clusters:
+ - [Ensure Patroni detects PostgreSQL update](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/5423)
+ - [Allow configuring permanent replication slots in patroni](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/5628)
+
### August 2020
[Verify Geo installation with PostgreSQL 12](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/5453):
diff --git a/doc/administration/geo/replication/multiple_servers.md b/doc/administration/geo/replication/multiple_servers.md
index cba41c375a3..9828c52ee7d 100644
--- a/doc/administration/geo/replication/multiple_servers.md
+++ b/doc/administration/geo/replication/multiple_servers.md
@@ -133,7 +133,7 @@ Configure the following services, again using the non-Geo multi-node
documentation:
- [Configuring Redis for GitLab](../../redis/replication_and_failover.md#example-configuration-for-the-gitlab-application) for multiple nodes.
-- [Gitaly](../../high_availability/gitaly.md), which will store data that is
+- [Gitaly](../../gitaly/index.md), which will store data that is
synchronized from the **primary** node.
NOTE: **Note:**
diff --git a/doc/administration/geo/replication/troubleshooting.md b/doc/administration/geo/replication/troubleshooting.md
index f6d6f39fb19..0b6ff867f11 100644
--- a/doc/administration/geo/replication/troubleshooting.md
+++ b/doc/administration/geo/replication/troubleshooting.md
@@ -386,6 +386,15 @@ This happens when you have added IP addresses without a subnet mask in `postgres
To fix this, add the subnet mask in `/etc/gitlab/gitlab.rb` under `postgresql['md5_auth_cidr_addresses']`
to respect the CIDR format (i.e. `1.2.3.4/32`).
+### Message: `Found data in the gitlabhq_production database!` when running `gitlab-ctl replicate-geo-database`
+
+This happens if data is detected in the `projects` table. When one or more projects are detected, the operation
+is aborted to prevent accidental data loss. To bypass this message, pass the `--force` option to the command.
+
+In GitLab 13.4, a seed project is added when GitLab is first installed. This makes it necessary to pass `--force` even
+on a new Geo secondary node. There is an [issue to account for seed projects](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/5618)
+when checking the database.
+
### Very large repositories never successfully synchronize on the **secondary** node
GitLab places a timeout on all repository clones, including project imports
@@ -483,8 +492,8 @@ to start again from scratch, there are a few steps that can help you:
gitlab-ctl start geo-postgresql
```
- Reconfigure in order to recreate the folders and make sure permissions and ownership
- are correctly
+ Reconfigure to recreate the folders and make sure permissions and ownership
+ are correct:
```shell
gitlab-ctl reconfigure
diff --git a/doc/administration/geo/replication/updating_the_geo_nodes.md b/doc/administration/geo/replication/updating_the_geo_nodes.md
index b78aeb06ebf..1af2b8d0b88 100644
--- a/doc/administration/geo/replication/updating_the_geo_nodes.md
+++ b/doc/administration/geo/replication/updating_the_geo_nodes.md
@@ -21,14 +21,17 @@ Updating Geo nodes involves performing:
NOTE: **Note:**
These general update steps are not intended for [high-availability deployments](https://docs.gitlab.com/omnibus/update/README.html#multi-node--ha-deployment), and will cause downtime. If you want to avoid downtime, consider using [zero downtime updates](https://docs.gitlab.com/omnibus/update/README.html#zero-downtime-updates).
+DANGER: **Danger:**
+In GitLab 13.2 and later versions, promoting a secondary node to a primary while the secondary is paused fails. We are [investigating the issue](https://gitlab.com/gitlab-org/gitlab/-/issues/225173). Do not pause replication before promoting a secondary. If the node is paused, please resume before promoting.
+
To update the Geo nodes when a new GitLab version is released, update **primary**
and all **secondary** nodes:
1. **Optional:** [Pause replication on each **secondary** node.](../index.md#pausing-and-resuming-replication)
1. Log into the **primary** node.
-1. [Update GitLab on the **primary** node using Omnibus](https://docs.gitlab.com/omnibus/update/README.html).
+1. [Update GitLab on the **primary** node using Omnibus's Geo-specific steps](https://docs.gitlab.com/omnibus/update/README.html#geo-deployment).
1. Log into each **secondary** node.
-1. [Update GitLab on each **secondary** node using Omnibus](https://docs.gitlab.com/omnibus/update/README.html).
+1. [Update GitLab on each **secondary** node using Omnibus's Geo-specific steps](https://docs.gitlab.com/omnibus/update/README.html#geo-deployment).
1. If you paused replication in step 1, [resume replication on each **secondary**](../index.md#pausing-and-resuming-replication)
1. [Test](#check-status-after-updating) **primary** and **secondary** nodes, and check version in each.
diff --git a/doc/administration/geo/replication/version_specific_updates.md b/doc/administration/geo/replication/version_specific_updates.md
index 1ae246e3e61..71facb808ab 100644
--- a/doc/administration/geo/replication/version_specific_updates.md
+++ b/doc/administration/geo/replication/version_specific_updates.md
@@ -447,8 +447,8 @@ Omnibus is the following:
> **IMPORTANT**:
With GitLab 9.0, the PostgreSQL version is updated to 9.6 and manual steps are
-required in order to update the **secondary** nodes and keep the Streaming
-Replication working. Downtime is required, so plan ahead.
+required to update the **secondary** nodes and keep the Streaming Replication
+working. Downtime is required, so plan ahead.
The following steps apply only if you update from a 8.17 GitLab version to
9.0+. For previous versions, update to GitLab 8.17 first before attempting to
diff --git a/doc/administration/geo/setup/database.md b/doc/administration/geo/setup/database.md
index aefa8a0e399..09b9c71aeb7 100644
--- a/doc/administration/geo/setup/database.md
+++ b/doc/administration/geo/setup/database.md
@@ -17,9 +17,10 @@ NOTE: **Note:**
The stages of the setup process must be completed in the documented order.
Before attempting the steps in this stage, [complete all prior stages](../setup/index.md#using-omnibus-gitlab).
-This document describes the minimal steps you have to take in order to
-replicate your **primary** GitLab database to a **secondary** node's database. You may
-have to change some values according to your database setup, how big it is, etc.
+This document describes the minimal steps you have to take to replicate your
+**primary** GitLab database to a **secondary** node's database. You may have to
+change some values, based on attributes including your database's setup and
+size.
You are encouraged to first read through all the steps before executing them
in your testing/production environment.
@@ -433,6 +434,11 @@ data before running `pg_basebackup`.
NOTE: **Note:**
Replication slot names must only contain lowercase letters, numbers, and the underscore character.
+ NOTE: **Note:**
+ In GitLab 13.4, a seed project is added when GitLab is first installed. This makes it necessary to pass `--force` even
+ on a new Geo secondary node. There is an [issue to account for seed projects](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/5618)
+ when checking the database.
+
When prompted, enter the _plaintext_ password you set up for the `gitlab_replicator`
user in the first step.
diff --git a/doc/administration/gitaly/index.md b/doc/administration/gitaly/index.md
index e6b137bac29..750e6aab687 100644
--- a/doc/administration/gitaly/index.md
+++ b/doc/administration/gitaly/index.md
@@ -90,7 +90,7 @@ When running Gitaly on its own server, note the following regarding GitLab versi
leveraged for redundancy on block-level Git data, but only has to be mounted on the Gitaly
servers.
- From GitLab 11.8 to 12.2, it is possible to use Elasticsearch in a Gitaly setup that doesn't use
- NFS. In order to use Elasticsearch in these versions, the
+ NFS. To use Elasticsearch in these versions, the
[repository indexer](../../integration/elasticsearch.md#elasticsearch-repository-indexer)
must be enabled in your GitLab configuration.
- [Since GitLab 12.3](https://gitlab.com/gitlab-org/gitlab/-/issues/6481), the new indexer is
@@ -382,10 +382,10 @@ if previously enabled manually.
Gitaly makes the following assumptions:
- Your `gitaly1.internal` Gitaly server can be reached at `gitaly1.internal:8075` from your Gitaly
- clients, and that Gitaly server can read and write to `/mnt/gitlab/default` and
+ clients, and that Gitaly server can read, write, and set permissions on `/mnt/gitlab/default` and
`/mnt/gitlab/storage1`.
- Your `gitaly2.internal` Gitaly server can be reached at `gitaly2.internal:8075` from your Gitaly
- clients, and that Gitaly server can read and write to `/mnt/gitlab/storage2`.
+ clients, and that Gitaly server can read, write, and set permissions on `/mnt/gitlab/storage2`.
- Your `gitaly1.internal` and `gitaly2.internal` Gitaly servers can reach each other.
You can't define Gitaly servers with some as a local Gitaly server
@@ -424,17 +424,17 @@ server (with `gitaly_address`) unless you setup with special
storages:
default:
gitaly_address: tcp://gitaly1.internal:8075
- path: /some/dummy/path
+ path: /some/local/path
storage1:
gitaly_address: tcp://gitaly1.internal:8075
- path: /some/dummy/path
+ path: /some/local/path
storage2:
gitaly_address: tcp://gitaly2.internal:8075
- path: /some/dummy/path
+ path: /some/local/path
```
NOTE: **Note:**
- `/some/dummy/path` should be set to a local folder that exists, however no data will be stored in
+ `/some/local/path` should be set to a local folder that exists, however no data will be stored in
this folder. This will no longer be necessary after
[this issue](https://gitlab.com/gitlab-org/gitaly/-/issues/1282) is resolved.
@@ -482,6 +482,14 @@ git_data_dirs({
'storage1' => { 'gitaly_address' => 'tcp://gitlab.internal:8075', 'path' => '/mnt/gitlab/git-data' },
'storage2' => { 'gitaly_address' => 'tcp://gitaly2.internal:8075' },
})
+
+# Make Gitaly accept connections on all network interfaces
+gitaly['listen_addr'] = "0.0.0.0:8075"
+
+# Or for TLS
+gitaly['tls_listen_addr'] = "0.0.0.0:9999"
+gitaly['certificate_path'] = "/etc/gitlab/ssl/cert.pem"
+gitaly['key_path'] = "/etc/gitlab/ssl/key.pem"
```
`path` can only be included for storage shards on the local Gitaly server.
@@ -532,20 +540,12 @@ corresponding to each Gitaly server must be installed on that Gitaly server.
Additionally, the certificate (or its certificate authority) must be installed on all:
-- Gitaly servers, including the Gitaly server using the certificate.
+- Gitaly servers.
- Gitaly clients that communicate with it.
-The process is documented in the
-[GitLab custom certificate configuration](https://docs.gitlab.com/omnibus/settings/ssl.html#install-custom-public-certificates)
-and repeated below.
-
Note the following:
-- The certificate must specify the address you use to access the Gitaly server. If you are:
- - Addressing the Gitaly server by a hostname, you can either use the Common Name field for this,
- or add it as a Subject Alternative Name.
- - Addressing the Gitaly server by its IP address, you must add it as a Subject Alternative Name to
- the certificate. [gRPC does not support using an IP address as Common Name in a certificate](https://github.com/grpc/grpc/issues/2691).
+- The certificate must specify the address you use to access the Gitaly server. You must add the hostname or IP address as a Subject Alternative Name to the certificate.
- You can configure Gitaly servers with both an unencrypted listening address `listen_addr` and an
encrypted listening address `tls_listen_addr` at the same time. This allows you to gradually
transition from unencrypted to encrypted traffic if necessary.
@@ -631,17 +631,17 @@ To configure Gitaly with TLS:
storages:
default:
gitaly_address: tls://gitaly1.internal:9999
- path: /some/dummy/path
+ path: /some/local/path
storage1:
gitaly_address: tls://gitaly1.internal:9999
- path: /some/dummy/path
+ path: /some/local/path
storage2:
gitaly_address: tls://gitaly2.internal:9999
- path: /some/dummy/path
+ path: /some/local/path
```
NOTE: **Note:**
- `/some/dummy/path` should be set to a local folder that exists, however no data will be stored
+ `/some/local/path` should be set to a local folder that exists, however no data will be stored
in this folder. This will no longer be necessary after
[Gitaly issue #1282](https://gitlab.com/gitlab-org/gitaly/-/issues/1282) is resolved.
@@ -1021,6 +1021,9 @@ The second facet presents the only real solution. For this, we developed
## Troubleshooting Gitaly
+Check [Gitaly timeouts](../../user/admin_area/settings/gitaly_timeouts.md) when troubleshooting
+Gitaly.
+
### Checking versions when using standalone Gitaly servers
When using standalone Gitaly servers, you must make sure they are the same version
@@ -1242,13 +1245,6 @@ unset http_proxy
unset https_proxy
```
-### Gitaly not listening on new address after reconfiguring
-
-When updating the `gitaly['listen_addr']` or `gitaly['prometheus_listen_addr']`
-values, Gitaly may continue to listen on the old address after a `sudo gitlab-ctl reconfigure`.
-
-When this occurs, performing a `sudo gitlab-ctl restart` will resolve the issue. This will no longer be necessary after [this issue](https://gitlab.com/gitlab-org/gitaly/-/issues/2521) is resolved.
-
### Permission denied errors appearing in Gitaly logs when accessing repositories from a standalone Gitaly server
If this error occurs even though file permissions are correct, it's likely that
diff --git a/doc/administration/gitaly/praefect.md b/doc/administration/gitaly/praefect.md
index 876904a2093..45c077cded1 100644
--- a/doc/administration/gitaly/praefect.md
+++ b/doc/administration/gitaly/praefect.md
@@ -547,14 +547,14 @@ To configure Praefect with TLS:
storages:
default:
gitaly_address: tls://praefect1.internal:3305
- path: /some/dummy/path
+ path: /some/local/path
storage1:
gitaly_address: tls://praefect2.internal:3305
- path: /some/dummy/path
+ path: /some/local/path
```
NOTE: **Note:**
- `/some/dummy/path` should be set to a local folder that exists, however no
+ `/some/local/path` should be set to a local folder that exists, however no
data will be stored in this folder. This will no longer be necessary after
[this issue](https://gitlab.com/gitlab-org/gitaly/-/issues/1282) is resolved.
@@ -993,6 +993,8 @@ information, see the [strong consistency epic](https://gitlab.com/groups/gitlab-
To enable strong consistency:
+- In GitLab 13.5, you must use Git v2.28.0 or higher on Gitaly nodes to enable
+ strong consistency.
- In GitLab 13.4 and later, the strong consistency voting strategy has been
improved. Instead of requiring all nodes to agree, only the primary and half
of the secondaries need to agree. This strategy is enabled by default. To
diff --git a/doc/administration/gitaly/reference.md b/doc/administration/gitaly/reference.md
index 0c211c220d7..53001b946d8 100644
--- a/doc/administration/gitaly/reference.md
+++ b/doc/administration/gitaly/reference.md
@@ -138,8 +138,8 @@ Most of the time we use `git cat-file --batch` processes for that. For
better performance, Gitaly can re-use these `git cat-file` processes
across RPC calls. Previously used processes are kept around in a
["Git cat-file cache"](https://about.gitlab.com/blog/2019/07/08/git-performance-on-nfs/#enter-cat-file-cache).
-In order to control how much system resources this uses, we have a maximum number
-of cat-file processes that can go into the cache.
+To control how much system resources this uses, we have a maximum number of
+cat-file processes that can go into the cache.
The default limit is 100 `cat-file`s, which constitute a pair of
`git cat-file --batch` and `git cat-file --batch-check` processes. If
diff --git a/doc/administration/housekeeping.md b/doc/administration/housekeeping.md
index 4110f8b7646..2882b05f415 100644
--- a/doc/administration/housekeeping.md
+++ b/doc/administration/housekeeping.md
@@ -28,6 +28,9 @@ the `pushes_since_gc` value is 200 a `git gc` will be run.
`git add`.
- `git repack` ([man page](https://mirrors.edge.kernel.org/pub/software/scm/git/docs/git-repack.html)) re-organize existing packs into a single, more efficient pack.
+Housekeeping will also [remove unreferenced LFS files](../raketasks/cleanup.md#remove-unreferenced-lfs-files)
+from your project on the same schedule as the `git gc` operation, freeing up storage space for your project.
+
You can find this option under your project's **Settings > General > Advanced**.
![Housekeeping settings](img/housekeeping_settings.png)
diff --git a/doc/administration/img/export_audit_log_v13_4.png b/doc/administration/img/export_audit_log_v13_4.png
index 1b404b5742c..e4ba330b8a9 100644
--- a/doc/administration/img/export_audit_log_v13_4.png
+++ b/doc/administration/img/export_audit_log_v13_4.png
Binary files differ
diff --git a/doc/administration/incoming_email.md b/doc/administration/incoming_email.md
index c0c03044225..f8c1a550b67 100644
--- a/doc/administration/incoming_email.md
+++ b/doc/administration/incoming_email.md
@@ -90,7 +90,7 @@ 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.
+email address 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
diff --git a/doc/administration/index.md b/doc/administration/index.md
index a6448fcf64f..076658ead0e 100644
--- a/doc/administration/index.md
+++ b/doc/administration/index.md
@@ -52,8 +52,10 @@ Learn how to install, configure, update, and maintain your GitLab instance.
- [GitLab Pages configuration](pages/index.md): Enable and configure GitLab Pages.
- [GitLab Pages configuration for GitLab source installations](pages/source.md): Enable and configure GitLab Pages on [source installations](../install/installation.md#installation-from-source).
- [Uploads administration](uploads.md): Configure GitLab uploads storage.
-- [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.
+- [Environment variables](environment_variables.md): Supported environment
+ variables that can be used to override their default values to configure
+ GitLab.
+- [Plugins](file_hooks.md): With custom plugins, GitLab administrators can introduce custom integrations without modifying GitLab's source code.
- [Enforcing Terms of Service](../user/admin_area/settings/terms.md)
- [Third party offers](../user/admin_area/settings/third_party_offers.md)
- [Compliance](compliance.md): A collection of features from across the application that you may configure to help ensure that your GitLab instance and DevOps workflow meet compliance standards.
@@ -113,7 +115,7 @@ Learn how to install, configure, update, and maintain your GitLab instance.
- [Kerberos authentication](../integration/kerberos.md) **(STARTER ONLY)**
- See also other [authentication](../topics/authentication/index.md#gitlab-administrators) topics (for example, enforcing 2FA).
- [Email users](../tools/email.md): Email GitLab users from within GitLab. **(STARTER ONLY)**
-- [User Cohorts](../user/admin_area/user_cohorts.md): Display the monthly cohorts of new users and their activities over time.
+- [User Cohorts](../user/admin_area/analytics/user_cohorts.md): Display the monthly cohorts of new users and their activities over time.
- [Audit logs and events](audit_events.md): View the changes made within the GitLab server for:
- Groups and projects. **(STARTER)**
- Instances. **(PREMIUM ONLY)**
diff --git a/doc/administration/instance_limits.md b/doc/administration/instance_limits.md
index abd98002934..6729338e0c7 100644
--- a/doc/administration/instance_limits.md
+++ b/doc/administration/instance_limits.md
@@ -480,7 +480,7 @@ indexed](#maximum-file-size-indexed)).
- For self-managed installations it is unlimited by default
This limit can be configured for self-managed installations when [enabling
-Elasticsearch](../integration/elasticsearch.md#enabling-elasticsearch).
+Elasticsearch](../integration/elasticsearch.md#enabling-advanced-search).
NOTE: **Note:**
Set the limit to `0` to disable it.
@@ -552,6 +552,9 @@ Plan.default.actual_limits.update!(maven_max_file_size: 100.megabytes)
# For PyPI Packages
Plan.default.actual_limits.update!(pypi_max_file_size: 100.megabytes)
+
+# For Debian Packages
+Plan.default.actual_limits.update!(debian_max_file_size: 100.megabytes)
```
Set the limit to `0` to allow any file size.
diff --git a/doc/administration/job_artifacts.md b/doc/administration/job_artifacts.md
index 2a79923b793..fd658116289 100644
--- a/doc/administration/job_artifacts.md
+++ b/doc/administration/job_artifacts.md
@@ -99,8 +99,13 @@ 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 like AWS S3 to store job artifacts.
+If you configure GitLab to store artifacts on object storage, you may also want to
+[eliminate local disk usage for job logs](job_logs.md#prevent-local-disk-usage).
+In both cases, job logs are archived and moved to object storage when the job completes.
+
DANGER: **Danger:**
-If you configure GitLab to store CI logs and artifacts on object storage, you must also enable [incremental logging](job_logs.md#new-incremental-logging-architecture). Otherwise, job logs will disappear or not be saved.
+In a multi-server setup you must use one of the options to
+[eliminate local disk usage for job logs](job_logs.md#prevent-local-disk-usage), or job logs could be lost.
[Read more about using object storage with GitLab](object_storage.md).
@@ -117,9 +122,9 @@ For source installations the following settings are nested under `artifacts:` an
|---------|-------------|---------|
| `enabled` | Enable/disable object storage | `false` |
| `remote_directory` | The bucket name where Artifacts 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. | `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` |
+| `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. | `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 | |
#### Connection settings
@@ -203,9 +208,9 @@ _The artifacts are stored by default in
enabled: true
object_store:
enabled: true
- remote_directory: "artifacts" # The bucket name
+ remote_directory: "artifacts" # The bucket name
connection:
- provider: AWS # Only AWS supported at the moment
+ 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: eu-central-1
@@ -316,9 +321,9 @@ _The uploads are stored by default in
**In Omnibus installations:**
-In order to migrate back to local storage:
+To migrate back to local storage:
-1. Set both `direct_upload` and `background_upload` to false in `gitlab.rb`, under the artifacts object storage settings.
+1. Set both `direct_upload` and `background_upload` to `false` in `gitlab.rb`, under the artifacts object storage settings.
1. [Reconfigure GitLab](restart_gitlab.md#omnibus-gitlab-reconfigure).
1. Run `gitlab-rake gitlab:artifacts:migrate_to_local`.
1. Disable object_storage for artifacts in `gitlab.rb`:
@@ -419,10 +424,10 @@ generated by [GitLab Workhorse](https://gitlab.com/gitlab-org/gitlab-workhorse).
that are located in the artifacts archive itself.
The metadata file is in a binary format, with additional Gzip compression.
-GitLab does not extract the artifacts archive in order to save space, memory
-and disk I/O. It instead inspects the metadata file which contains all the
-relevant information. This is especially important when there is a lot of
-artifacts, or an archive is a very large file.
+GitLab doesn't extract the artifacts archive to save space, memory, and disk
+I/O. It instead inspects the metadata file which contains all the relevant
+information. This is especially important when there is a lot of artifacts, or
+an archive is a very large file.
When clicking on a specific file, [GitLab Workhorse](https://gitlab.com/gitlab-org/gitlab-workhorse) extracts it
from the archive and the download begins. This implementation saves space,
diff --git a/doc/administration/job_logs.md b/doc/administration/job_logs.md
index c34035e3c0c..c89ffb8da8b 100644
--- a/doc/administration/job_logs.md
+++ b/doc/administration/job_logs.md
@@ -65,6 +65,15 @@ job logs are automatically migrated to it along with the other job artifacts.
See "Phase 4: uploading" in [Data flow](#data-flow) to learn about the process.
+## Prevent local disk usage
+
+If you want to avoid any local disk usage for job logs,
+you can do so using one of the following options:
+
+- Enable the [beta incremental logging](#new-incremental-logging-architecture) feature.
+- Set the [job logs location](#changing-the-job-logs-local-location)
+ to an NFS drive.
+
## How to remove job logs
There isn't a way to automatically expire old job logs, but it's safe to remove
diff --git a/doc/administration/libravatar.md b/doc/administration/libravatar.md
index beecd9e4783..428a4b97a38 100644
--- a/doc/administration/libravatar.md
+++ b/doc/administration/libravatar.md
@@ -72,10 +72,9 @@ Then run `sudo gitlab-ctl reconfigure` for the changes to take effect.
missing images for user email addresses that are not found on the Libravatar
service.
-In order to use a set other than `identicon`, replace the `&d=identicon`
-portion of the URL with another supported set.
-For example, you can use the `retro` set, in which case the URL would look like:
-`plain_url: "http://cdn.libravatar.org/avatar/%{hash}?s=%{size}&d=retro"`
+To use a set other than `identicon`, replace the `&d=identicon` portion of the
+URL with another supported set. For example, you can use the `retro` set, in
+which case the URL would look like: `plain_url: "http://cdn.libravatar.org/avatar/%{hash}?s=%{size}&d=retro"`
## Usage examples for Microsoft Office 365
@@ -84,8 +83,8 @@ Note that this service requires a login, so this use case is most useful in a
corporate installation where all users have access to Office 365.
```ruby
-gitlab_rails['gravatar_plain_url'] = 'http://outlook.office365.com/owa/service.svc/s/GetPersonaPhoto?email=%{email}&size=HR120x120'
-gitlab_rails['gravatar_ssl_url'] = 'https://outlook.office365.com/owa/service.svc/s/GetPersonaPhoto?email=%{email}&size=HR120x120'
+gitlab_rails['gravatar_plain_url'] = 'http://outlook.office.com/owa/service.svc/s/GetPersonaPhoto?email=%{email}&size=HR120x120'
+gitlab_rails['gravatar_ssl_url'] = 'https://outlook.office.com/owa/service.svc/s/GetPersonaPhoto?email=%{email}&size=HR120x120'
```
<!-- ## Troubleshooting
diff --git a/doc/administration/logs.md b/doc/administration/logs.md
index fcd6264dafd..88736170d23 100644
--- a/doc/administration/logs.md
+++ b/doc/administration/logs.md
@@ -100,9 +100,9 @@ The ActionCable connection or channel class is used as the `controller`.
```json
{
- "method":{},
- "path":{},
- "format":{},
+ "method":null,
+ "path":null,
+ "format":null,
"controller":"IssuesChannel",
"action":"subscribe",
"status":200,
diff --git a/doc/administration/merge_request_diffs.md b/doc/administration/merge_request_diffs.md
index 3f4cd6e2751..3c093d44780 100644
--- a/doc/administration/merge_request_diffs.md
+++ b/doc/administration/merge_request_diffs.md
@@ -17,12 +17,11 @@ By default, merge request diffs are stored in the database, in a table named
`merge_request_diff_files`. Larger installations may find this table grows too
large, in which case, switching to external storage is recommended.
-## Using external storage
-
Merge request diffs can be stored on disk, or in object storage. In general, it
-is better to store the diffs in the database than on disk.
+is better to store the diffs in the database than on disk. A compromise is available
+that only [stores outdated diffs](#alternative-in-database-storage) outside of database.
-To enable external storage of merge request diffs, follow the instructions below.
+## Using external storage
**In Omnibus installations:**
@@ -68,16 +67,40 @@ To enable external storage of merge request diffs, follow the instructions below
## Using object storage
-CAUTION: **WARNING:**
- Currently migrating to object storage is **non-reversible**
+CAUTION: **Warning:**
+Currently migrating to object storage is **non-reversible**
Instead of storing the external diffs on disk, we recommended the use of an object
store like AWS S3 instead. This configuration relies on valid AWS credentials to
be configured already.
+**In Omnibus installations:**
+
+1. Edit `/etc/gitlab/gitlab.rb` and add the following line:
+
+ ```ruby
+ gitlab_rails['external_diffs_enabled'] = true
+ ```
+
+1. Set [object storage settings](#object-storage-settings).
+1. Save the file and [reconfigure GitLab](restart_gitlab.md#omnibus-gitlab-reconfigure) 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
+ external_diffs:
+ enabled: true
+ ```
+
+1. Set [object storage settings](#object-storage-settings).
+1. Save the file and [restart GitLab](restart_gitlab.md#installations-from-source) for the changes to take effect.
+
[Read more about using object storage with GitLab](object_storage.md).
-## Object Storage Settings
+### Object Storage Settings
NOTE: **Note:**
In GitLab 13.2 and later, we recommend using the
@@ -92,12 +115,12 @@ then `object_store:`. On Omnibus installations, they are prefixed by
|---------|-------------|---------|
| `enabled` | Enable/disable object storage | `false` |
| `remote_directory` | The bucket name where external diffs will be stored| |
-| `direct_upload` | Set to true to enable direct upload of external diffs 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` |
+| `direct_upload` | Set to `true` to enable direct upload of external diffs 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 | |
-### S3 compatible connection settings
+#### S3 compatible connection settings
See [the available connection settings for different providers](object_storage.md#connection-settings).
diff --git a/doc/administration/monitoring/gitlab_self_monitoring_project/index.md b/doc/administration/monitoring/gitlab_self_monitoring_project/index.md
index efe31997b25..562e0298871 100644
--- a/doc/administration/monitoring/gitlab_self_monitoring_project/index.md
+++ b/doc/administration/monitoring/gitlab_self_monitoring_project/index.md
@@ -9,17 +9,18 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/32351) in GitLab 12.7, behind a disabled feature flag (`self_monitoring_project`).
> - The feature flag was removed and the Self Monitoring Project was [made generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/198511) in GitLab 12.8.
-GitLab has been adding the ability for administrators to see insights into the health of
-their GitLab instance. In order to surface this experience in a native way, similar to how
-you would interact with an application deployed via GitLab, a base project called
-"GitLab self monitoring" with
-[internal visibility](../../../public_access/public_access.md#internal-projects) will be
-added under a group called "GitLab Instance Administrators" specifically created for
-visualizing and configuring the monitoring of your GitLab instance.
-
-All administrators at the time of creation of the project and group will be added
-as maintainers of the group and project, and as an admin, you'll be able to add new
-members to the group in order to give them maintainer access to the project.
+GitLab has been adding the ability for administrators to see insights into the
+health of their GitLab instance. To surface this experience in a native way
+(similar to how you would interact with an application deployed using GitLab),
+a base project called "GitLab self monitoring" with
+[internal visibility](../../../public_access/public_access.md#internal-projects)
+will be added under a group called "GitLab Instance Administrators"
+specifically created for visualizing and configuring the monitoring of your
+GitLab instance.
+
+All administrators at the time of creation of the project and group will be
+added as maintainers of the group and project, and as an admin, you'll be able
+to add new members to the group to give them maintainer access to the project.
This project is used to self monitor your GitLab instance. The metrics dashboard
of the project shows some basic resource usage charts, such as CPU and memory usage
@@ -74,7 +75,8 @@ you should
## Taking action on Prometheus alerts **(ULTIMATE)**
You can [add a webhook](../../../operations/metrics/alerts.md#external-prometheus-instances)
-to the Prometheus configuration in order for GitLab to receive notifications of any alerts.
+to the Prometheus configuration for GitLab to receive notifications of any
+alerts.
Once the webhook is setup, you can
[take action on incoming alerts](../../../operations/metrics/alerts.md#trigger-actions-from-alerts).
diff --git a/doc/administration/monitoring/performance/index.md b/doc/administration/monitoring/performance/index.md
index 6f22327e499..e3cccad5e0b 100644
--- a/doc/administration/monitoring/performance/index.md
+++ b/doc/administration/monitoring/performance/index.md
@@ -11,7 +11,7 @@ GitLab comes with its own application performance measuring system as of GitLab
Community and Enterprise editions.
Apart from this introduction, you are advised to read through the following
-documents in order to understand and properly configure GitLab Performance Monitoring:
+documents to understand and properly configure GitLab Performance Monitoring:
- [GitLab Configuration](gitlab_configuration.md)
- [Prometheus documentation](../prometheus/index.md)
diff --git a/doc/administration/monitoring/prometheus/gitlab_metrics.md b/doc/administration/monitoring/prometheus/gitlab_metrics.md
index ae31a3db023..06b67a19483 100644
--- a/doc/administration/monitoring/prometheus/gitlab_metrics.md
+++ b/doc/administration/monitoring/prometheus/gitlab_metrics.md
@@ -184,12 +184,12 @@ configuration option in `gitlab.yml`. These metrics are served from the
| `geo_package_files_synced` | Gauge | 13.3 | Number of syncable package files synced on secondary | `url` |
| `geo_package_files_failed` | Gauge | 13.3 | Number of syncable package files failed to sync on secondary | `url` |
| `geo_package_files_registry` | Gauge | 13.3 | Number of package files in the registry | `url` |
-| `geo_terraform_states` | Gauge | 13.3 | Number of terraform states on primary | `url` |
-| `geo_terraform_states_checksummed` | Gauge | 13.3 | Number of terraform states checksummed on primary | `url` |
-| `geo_terraform_states_checksum_failed` | Gauge | 13.3 | Number of terraform states failed to calculate the checksum on primary | `url` |
-| `geo_terraform_states_synced` | Gauge | 13.3 | Number of syncable terraform states synced on secondary | `url` |
-| `geo_terraform_states_failed` | Gauge | 13.3 | Number of syncable terraform states failed to sync on secondary | `url` |
-| `geo_terraform_states_registry` | Gauge | 13.3 | Number of terraform states in the registry | `url` |
+| `geo_terraform_state_versions` | Gauge | 13.5 | Number of terraform state versions on primary | `url` |
+| `geo_terraform_state_versions_checksummed` | Gauge | 13.5 | Number of terraform state versions checksummed on primary | `url` |
+| `geo_terraform_state_versions_checksum_failed` | Gauge | 13.5 | Number of terraform state versions failed to calculate the checksum on primary | `url` |
+| `geo_terraform_state_versions_synced` | Gauge | 13.5 | Number of syncable terraform state versions synced on secondary | `url` |
+| `geo_terraform_state_versions_failed` | Gauge | 13.5 | Number of syncable terraform state versions failed to sync on secondary | `url` |
+| `geo_terraform_state_versions_registry` | Gauge | 13.5 | Number of terraform state versions in the registry | `url` |
| `global_search_bulk_cron_queue_size` | Gauge | 12.10 | Number of database records waiting to be synchronized to Elasticsearch | |
| `global_search_awaiting_indexing_queue_size` | Gauge | 13.2 | Number of database updates waiting to be synchronized to Elasticsearch while indexing is paused | |
| `geo_merge_request_diffs` | Gauge | 13.4 | Number of merge request diffs on primary | `url` |
@@ -204,6 +204,9 @@ configuration option in `gitlab.yml`. These metrics are served from the
| `geo_snippet_repositories_synced` | Gauge | 13.4 | Number of syncable snippets synced on secondary | `url` |
| `geo_snippet_repositories_failed` | Gauge | 13.4 | Number of syncable snippets failed on secondary | `url` |
| `geo_snippet_repositories_registry` | Gauge | 13.4 | Number of syncable snippets in the registry | `url` |
+| `limited_capacity_worker_running_jobs` | Gauge | 13.5 | Number of running jobs | `worker` |
+| `limited_capacity_worker_max_running_jobs` | Gauge | 13.5 | Maximum number of running jobs | `worker` |
+| `limited_capacity_worker_remaining_work_count` | Gauge | 13.5 | Number of jobs waiting to be enqueued | `worker` |
## Database load balancing metrics **(PREMIUM ONLY)**
diff --git a/doc/administration/monitoring/prometheus/index.md b/doc/administration/monitoring/prometheus/index.md
index 7d93e9797be..fcfa253a1e3 100644
--- a/doc/administration/monitoring/prometheus/index.md
+++ b/doc/administration/monitoring/prometheus/index.md
@@ -60,9 +60,9 @@ it's not recommended to change the port Prometheus listens
on, as this might affect or conflict with other services running on the GitLab
server. Proceed at your own risk.
-In order to access Prometheus from outside the GitLab server you will need to
-set a FQDN or IP in `prometheus['listen_address']`.
-To change the address/port that Prometheus listens on:
+To access Prometheus from outside the GitLab server, set an FQDN or IP in
+`prometheus['listen_address']`. To change the address/port that Prometheus
+listens on:
1. Edit `/etc/gitlab/gitlab.rb`
1. Add or find and uncomment the following line:
diff --git a/doc/administration/nfs.md b/doc/administration/nfs.md
index b54f05ad536..8fd89b77cc3 100644
--- a/doc/administration/nfs.md
+++ b/doc/administration/nfs.md
@@ -296,6 +296,25 @@ Having multiple NFS mounts will require manually making sure the data directorie
are empty before attempting a restore. Read more about the
[restore prerequisites](../raketasks/backup_restore.md).
+## Testing NFS
+
+Once you've set up the NFS server and client, you can verify NFS is configured correctly
+by testing the following commands:
+
+```shell
+sudo mkdir /gitlab-nfs/test-dir
+sudo chown git /gitlab-nfs/test-dir
+sudo chgrp gitlab-www /gitlab-nfs/test-dir
+sudo chgrp root /gitlab-nfs/test-dir
+sudo chmod 2755 /gitlab-nfs/test-dir
+sudo -u git mkdir /gitlab-nfs/test-dir/test2
+sudo -u git chmod 2755 /gitlab-nfs/test-dir/test2
+sudo ls -lah /gitlab-nfs/test-dir/test2
+sudo -u git rm -r /gitlab-nfs/test-dir
+```
+
+Any `Operation not permitted` errors means you should investigate your NFS server export options.
+
## NFS in a Firewalled Environment
If the traffic between your NFS server and NFS client(s) is subject to port filtering
diff --git a/doc/administration/object_storage.md b/doc/administration/object_storage.md
index 39365ffe404..f1352446136 100644
--- a/doc/administration/object_storage.md
+++ b/doc/administration/object_storage.md
@@ -369,7 +369,7 @@ Here are the valid connection parameters for Rackspace Cloud, provided by
| `rackspace_username` | The username of the Rackspace account with access to the container | `joe.smith` |
| `rackspace_api_key` | The API key of the Rackspace account with access to the container | `ABC123DEF456ABC123DEF456ABC123DE` |
| `rackspace_region` | The Rackspace storage region to use, a three letter code from the [list of service access endpoints](https://developer.rackspace.com/docs/cloud-files/v1/general-api-info/service-access/) | `iad` |
-| `rackspace_temp_url_key` | The private key you have set in the Rackspace API for temporary URLs. Read more [here](https://developer.rackspace.com/docs/cloud-files/v1/use-cases/public-access-to-your-cloud-files-account/#tempurl) | `ABC123DEF456ABC123DEF456ABC123DE` |
+| `rackspace_temp_url_key` | The private key you have set in the Rackspace API for [temporary URLs](https://developer.rackspace.com/docs/cloud-files/v1/use-cases/public-access-to-your-cloud-files-account/#tempurl). | `ABC123DEF456ABC123DEF456ABC123DE` |
NOTE: **Note:**
Regardless of whether the container has public access enabled or disabled, Fog will
@@ -503,13 +503,13 @@ supported by consolidated configuration form, refer to the following guides:
|Object storage type|Supported by consolidated configuration?|
|-------------------|----------------------------------------|
| [Backups](../raketasks/backup_restore.md#uploading-backups-to-a-remote-cloud-storage)|No|
-| [Job artifacts](job_artifacts.md#using-object-storage) and [incremental logging](job_logs.md#new-incremental-logging-architecture) | Yes |
+| [Job artifacts](job_artifacts.md#using-object-storage) including archived job logs | Yes |
| [LFS objects](lfs/index.md#storing-lfs-objects-in-remote-object-storage) | Yes |
| [Uploads](uploads.md#using-object-storage) | Yes |
| [Container Registry](packages/container_registry.md#use-object-storage) (optional feature) | No |
| [Merge request diffs](merge_request_diffs.md#using-object-storage) | Yes |
| [Mattermost](https://docs.mattermost.com/administration/config-settings.html#file-storage)| No |
-| [Packages](packages/index.md#using-object-storage) (optional feature) **(PREMIUM ONLY)** | Yes |
+| [Packages](packages/index.md#using-object-storage) (optional feature) | Yes |
| [Dependency Proxy](packages/dependency_proxy.md#using-object-storage) (optional feature) **(PREMIUM ONLY)** | Yes |
| [Pseudonymizer](pseudonymizer.md#configuration) (optional feature) **(ULTIMATE ONLY)** | No |
| [Autoscale runner caching](https://docs.gitlab.com/runner/configuration/autoscale.html#distributed-runners-caching) (optional for improved performance) | No |
@@ -520,12 +520,13 @@ supported by consolidated configuration form, refer to the following guides:
If you're working to [scale out](reference_architectures/index.md) your GitLab implementation,
or add fault tolerance and redundancy, you may be
looking at removing dependencies on block or network filesystems.
-See the following guides and
+See the following additional guides and
[note that Pages requires disk storage](#gitlab-pages-requires-nfs):
1. Make sure the [`git` user home directory](https://docs.gitlab.com/omnibus/settings/configuration.html#moving-the-home-directory-for-a-user) is on local disk.
1. Configure [database lookup of SSH keys](operations/fast_ssh_key_lookup.md)
to eliminate the need for a shared `authorized_keys` file.
+1. [Prevent local disk usage for job logs](job_logs.md#prevent-local-disk-usage).
## Warnings, limitations, and known issues
@@ -569,7 +570,8 @@ The dependency on disk storage also prevents Pages being deployed using the
### Incremental logging is required for CI to use object storage
If you configure GitLab to use object storage for CI logs and artifacts,
-[you must also enable incremental logging](job_artifacts.md#using-object-storage).
+you can avoid [local disk usage for job logs](job_logs.md#data-flow) by enabling
+[beta incremental logging](job_logs.md#new-incremental-logging-architecture).
### Proxy Download
diff --git a/doc/administration/packages/container_registry.md b/doc/administration/packages/container_registry.md
index 74af5c8149b..29bf83ce2e0 100644
--- a/doc/administration/packages/container_registry.md
+++ b/doc/administration/packages/container_registry.md
@@ -10,15 +10,13 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> - Container Registry manifest `v1` support was added in GitLab 8.9 to support
> Docker versions earlier than 1.10.
-NOTE: **Note:**
-This document is the administrator's guide. To learn how to use GitLab Container
-Registry, see the [user documentation](../../user/packages/container_registry/index.md).
+With the GitLab Container Registry, every project can have its
+own space to store Docker images.
-With the Container Registry integrated into GitLab, every project can have its
-own space to store its Docker images.
+Read more about the Docker Registry in [the Docker documentation](https://docs.docker.com/registry/introduction/).
-You can read more about the Docker Registry at
-<https://docs.docker.com/registry/introduction/>.
+This document is the administrator's guide. To learn how to use the GitLab Container
+Registry, see the [user documentation](../../user/packages/container_registry/index.md).
## Enable the Container Registry
@@ -37,7 +35,6 @@ Otherwise, the Container Registry is not enabled. To enable it:
- You can configure it for your [GitLab domain](#configure-container-registry-under-an-existing-gitlab-domain), or
- You can configure it for [a different domain](#configure-container-registry-under-its-own-domain).
-NOTE: **Note:**
The Container Registry works under HTTPS by default. You can use HTTP
but it's not recommended and is out of the scope of this document.
Read the [insecure Registry documentation](https://docs.docker.com/registry/insecure/)
@@ -47,12 +44,12 @@ if you want to implement this.
If you have installed GitLab from source:
-1. You will have to [install Registry](https://docs.docker.com/registry/deploying/) by yourself.
-1. After the installation is complete, you will have to configure the Registry's
- settings in `gitlab.yml` in order to enable it.
-1. Use the sample NGINX configuration file that is found under
+1. You must [install Registry](https://docs.docker.com/registry/deploying/) by yourself.
+1. After the installation is complete, to enable it, you must configure the Registry's
+ settings in `gitlab.yml`.
+1. Use the sample NGINX configuration file from under
[`lib/support/nginx/registry-ssl`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/support/nginx/registry-ssl) and edit it to match the
- `host`, `port` and TLS certs paths.
+ `host`, `port`, and TLS certificate paths.
The contents of `gitlab.yml` are:
@@ -67,19 +64,18 @@ registry:
issuer: gitlab-issuer
```
-where:
+Where:
| Parameter | Description |
| --------- | ----------- |
| `enabled` | `true` or `false`. Enables the Registry in GitLab. By default this is `false`. |
-| `host` | The host URL under which the Registry will run and the users will be able to use. |
-| `port` | The port under which the external Registry domain will listen on. |
-| `api_url` | The internal API URL under which the Registry is exposed to. It defaults to `http://localhost:5000`. |
+| `host` | The host URL under which the Registry runs and users can use. |
+| `port` | The port the external Registry domain listens on. |
+| `api_url` | The internal API URL under which the Registry is exposed. It defaults to `http://localhost:5000`. |
| `key` | The private key location that is a pair of Registry's `rootcertbundle`. Read the [token auth configuration documentation](https://docs.docker.com/registry/configuration/#token). |
| `path` | This should be the same directory like specified in Registry's `rootdirectory`. Read the [storage configuration documentation](https://docs.docker.com/registry/configuration/#storage). This path needs to be readable by the GitLab user, the web-server user and the Registry user. Read more in [#configure-storage-for-the-container-registry](#configure-storage-for-the-container-registry). |
| `issuer` | This should be the same value as configured in Registry's `issuer`. Read the [token auth configuration documentation](https://docs.docker.com/registry/configuration/#token). |
-NOTE: **Note:**
A Registry init file is not shipped with GitLab if you install it from source.
Hence, [restarting GitLab](../restart_gitlab.md#installations-from-source) will not restart the Registry should
you modify its settings. Read the upstream documentation on how to achieve that.
@@ -98,21 +94,20 @@ auth:
```
CAUTION: **Caution:**
-If `auth` is not set up, users will be able to pull Docker images without authentication.
+If `auth` is not set up, users can pull Docker images without authentication.
## Container Registry domain configuration
There are two ways you can configure the Registry's external domain. Either:
-- [Use the existing GitLab domain](#configure-container-registry-under-an-existing-gitlab-domain) where in that case
- the Registry will have to listen on a port and reuse GitLab's TLS certificate,
+- [Use the existing GitLab domain](#configure-container-registry-under-an-existing-gitlab-domain).
+ The Registry listens on a port and reuse GitLab's TLS certificate.
- [Use a completely separate domain](#configure-container-registry-under-its-own-domain) with a new TLS certificate
for that domain.
-Since the container Registry requires a TLS certificate, in the end it all boils
-down to how easy or pricey it is to get a new one.
+Because the Container Registry requires a TLS certificate, cost may be a factor.
-Please take this into consideration before configuring the Container Registry
+Take this into consideration before configuring the Container Registry
for the first time.
### Configure Container Registry under an existing GitLab domain
@@ -126,9 +121,8 @@ Registry is exposed to the outside world is `5050`, here is what you need to set
in `gitlab.rb` or `gitlab.yml` if you are using Omnibus GitLab or installed
GitLab from source respectively.
-NOTE: **Note:**
-Be careful to choose a port different than the one that Registry listens to (`5000` by default),
-otherwise you will run into conflicts.
+Ensure you choose a port different than the one that Registry listens to (`5000` by default),
+otherwise there will be conflicts.
**Omnibus GitLab installations**
@@ -139,7 +133,7 @@ otherwise you will run into conflicts.
registry_external_url 'https://gitlab.example.com:5050'
```
- Note how the `registry_external_url` is listening on HTTPS under the
+ The `registry_external_url` is listening on HTTPS under the
existing GitLab URL, but on a different port.
If your TLS certificate is not in `/etc/gitlab/ssl/gitlab.example.com.crt`
@@ -160,7 +154,6 @@ otherwise you will run into conflicts.
openssl s_client -showcerts -servername gitlab.example.com -connect gitlab.example.com:5050 > cacert.pem
```
-NOTE: **Note:**
If your certificate provider provides the CA Bundle certificates, append them to the TLS certificate file.
**Installations from source**
@@ -188,11 +181,10 @@ docker login gitlab.example.com:5050
### Configure Container Registry under its own domain
If the Registry is configured to use its own domain, you will need a TLS
-certificate for that specific domain (e.g., `registry.example.com`) or maybe
+certificate for that specific domain (for example, `registry.example.com`) or maybe
a wildcard certificate if hosted under a subdomain of your existing GitLab
-domain (e.g., `registry.gitlab.example.com`).
+domain, for example, `registry.gitlab.example.com`.
-NOTE: **Note:**
As well as manually generated SSL certificates (explained here), certificates automatically
generated by Let's Encrypt are also [supported in Omnibus installs](https://docs.gitlab.com/omnibus/settings/ssl.html#host-services).
@@ -210,19 +202,19 @@ Let's assume that you want the container Registry to be accessible at
chmod 600 /etc/gitlab/ssl/registry.gitlab.example.com.*
```
-1. Once the TLS certificate is in place, edit `/etc/gitlab/gitlab.rb` with:
+1. After the TLS certificate is in place, edit `/etc/gitlab/gitlab.rb` with:
```ruby
registry_external_url 'https://registry.gitlab.example.com'
```
- Note how the `registry_external_url` is listening on HTTPS.
+ The `registry_external_url` is listening on HTTPS.
1. Save the file and [reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
-If you have a [wildcard certificate](https://en.wikipedia.org/wiki/Wildcard_certificate), you need to specify the path to the
-certificate in addition to the URL, in this case `/etc/gitlab/gitlab.rb` will
-look like:
+If you have a [wildcard certificate](https://en.wikipedia.org/wiki/Wildcard_certificate), you must specify the path to the
+certificate in addition to the URL, in this case `/etc/gitlab/gitlab.rb`
+looks like:
```ruby
registry_nginx['ssl_certificate'] = "/etc/gitlab/ssl/certificate.pem"
@@ -252,9 +244,8 @@ docker login registry.gitlab.example.com
## Disable Container Registry site-wide
-NOTE: **Note:**
-Disabling the Registry in the Rails GitLab application as set by the following
-steps, will not remove any existing Docker images. This is handled by the
+When you disable the Registry by following these steps, you do not
+remove any existing Docker images. This is handled by the
Registry application itself.
**Omnibus GitLab**
@@ -331,8 +322,7 @@ The different supported drivers are:
| swift | OpenStack Swift Object Storage |
| oss | Aliyun OSS |
-NOTE: **Note:**
-Although most S3 compatible services (like [MinIO](https://min.io/)) should work with the registry, we only guarantee support for AWS S3. Because we cannot assert the correctness of third-party S3 implementations, we can debug issues, but we cannot patch the registry unless an issue is reproducible against an AWS S3 bucket.
+Although most S3 compatible services (like [MinIO](https://min.io/)) should work with the Container Registry, we only guarantee support for AWS S3. Because we cannot assert the correctness of third-party S3 implementations, we can debug issues, but we cannot patch the registry unless an issue is reproducible against an AWS S3 bucket.
Read more about the individual driver's configuration options in the
[Docker Registry docs](https://docs.docker.com/registry/configuration/#storage).
@@ -347,8 +337,7 @@ This path is accessible to:
- The user running the Container Registry daemon.
- The user running GitLab.
-CAUTION: **Warning:**
-You should confirm that all GitLab, Registry and web server users
+All GitLab, Registry, and web server users must
have access to this directory.
**Omnibus GitLab installations**
@@ -387,13 +376,10 @@ driver for the Container Registry.
[Read more about using object storage with GitLab](../object_storage.md).
CAUTION: **Warning:**
-GitLab will not backup Docker images that are not stored on the
-filesystem. Remember to enable backups with your object storage provider if
+GitLab does not back up Docker images that are not stored on the
+filesystem. Enable backups with your object storage provider if
desired.
-NOTE: **Note:**
-`regionendpoint` is only required when configuring an S3 compatible service such as MinIO. It takes a URL such as `http://127.0.0.1:9000`.
-
**Omnibus GitLab installations**
To configure the `s3` storage driver in Omnibus:
@@ -411,15 +397,15 @@ To configure the `s3` storage driver in Omnibus:
}
}
```
+
+ - `regionendpoint` is only required when configuring an S3 compatible service such as MinIO. It takes a URL such as `http://127.0.0.1:9000`.
+ - `your-s3-bucket` should be the name of a bucket that exists, and can't include subdirectories.
1. Save the file and [reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
-NOTE: **Note:**
-`your-s3-bucket` should only be the name of a bucket that exists, and can't include subdirectories.
-
**Installations from source**
-Configuring the storage driver is done in your registry configuration YML file created
+Configuring the storage driver is done in the registry configuration YML file created
when you [deployed your Docker registry](https://docs.docker.com/registry/deploying/).
`s3` storage driver example:
@@ -438,8 +424,7 @@ storage:
enabled: true
```
-NOTE: **Note:**
-`your-s3-bucket` should only be the name of a bucket that exists, and can't include subdirectories.
+`your-s3-bucket` should be the name of a bucket that exists, and can't include subdirectories.
#### Migrate to object storage without downtime
@@ -489,7 +474,7 @@ you can pull from the Container Registry, but you cannot push.
DANGER: **Danger:**
The [`--delete`](https://docs.aws.amazon.com/cli/latest/reference/s3/sync.html)
- flag will delete files that exist in the destination but not in the source.
+ flag deletes files that exist in the destination but not in the source.
Make sure not to swap the source and destination, or you will delete all data in the Registry.
1. Verify all Container Registry files have been uploaded to object storage
@@ -565,10 +550,6 @@ configurable in future releases.
## Change the registry's internal port
-NOTE: **Note:**
-This is not to be confused with the port that GitLab itself uses to expose
-the Registry to the world.
-
The Registry server listens on localhost at port `5000` by default,
which is the address for which the Registry server should accept connections.
In the examples below we set the Registry's port to `5001`.
@@ -589,7 +570,7 @@ In the examples below we set the Registry's port to `5001`.
[`http:addr`](https://docs.docker.com/registry/configuration/#http) value:
```yaml
- http
+ http:
addr: localhost:5001
```
@@ -603,9 +584,8 @@ on how to achieve that.
## Use an external container registry with GitLab as an auth endpoint
-NOTE: **Note:**
-In using an external container registry, some features associated with the
-container registry may be unavailable or have [inherent risks](./../../user/packages/container_registry/index.md#use-with-external-container-registries)
+If you use an external container registry, some features associated with the
+container registry may be unavailable or have [inherent risks](./../../user/packages/container_registry/index.md#use-with-external-container-registries).
**Omnibus GitLab**
@@ -619,10 +599,9 @@ You can use GitLab as an auth endpoint with an external container registry.
gitlab_rails['registry_issuer'] = "omnibus-gitlab-issuer"
```
- NOTE: **Note:**
`gitlab_rails['registry_enabled'] = true` is needed to enable GitLab's
Container Registry features and authentication endpoint. GitLab's bundled
- Container Registry service will not be started even with this enabled.
+ Container Registry service does not start, even with this enabled.
1. A certificate-key pair is required for GitLab and the external container
registry to communicate securely. You will need to create a certificate-key
@@ -641,11 +620,10 @@ You can use GitLab as an auth endpoint with an external container registry.
gitlab_rails['registry_key_path'] = "/custom/path/to/registry-key.key"
```
- NOTE: **Note:**
- The file specified at `registry_key_path` gets populated with the
- content specified by `internal_key`, each time reconfigure is executed. If
- no file is specified, Omnibus GitLab will default it to
- `/var/opt/gitlab/gitlab-rails/etc/gitlab-registry.key` and will populate
+ Each time reconfigure is executed, the file specified at `registry_key_path`
+ gets populated with the content specified by `internal_key`. If
+ no file is specified, Omnibus GitLab defaults it to
+ `/var/opt/gitlab/gitlab-rails/etc/gitlab-registry.key` and populates
it.
1. To change the container registry URL displayed in the GitLab Container
@@ -686,8 +664,7 @@ response to events happening within the registry.
Read more about the Container Registry notifications configuration options in the
[Docker Registry notifications documentation](https://docs.docker.com/registry/notifications/).
-NOTE: **Note:**
-Multiple endpoints can be configured for the Container Registry.
+You can configure multiple endpoints for the Container Registry.
**Omnibus GitLab installations**
@@ -761,22 +738,10 @@ project.container_repositories.find_each do |repo|
end
```
-NOTE: **Note:**
You can also [run cleanup on a schedule](../../user/packages/container_registry/index.md#cleanup-policy).
## Container Registry garbage collection
-NOTE: **Note:**
-The garbage collection tools are only available when you've installed GitLab
-via an Omnibus package or the [cloud native chart](https://docs.gitlab.com/charts/charts/registry/#garbage-collection).
-
-DANGER: **Danger:**
-By running the built-in garbage collection command, it will cause downtime to
-the Container Registry. If you run this command on an instance in an environment
-where one of your other instances is still writing to the Registry storage,
-referenced manifests will be removed. To avoid that, make sure Registry is set to
-[read-only mode](#performing-garbage-collection-without-downtime) before proceeding.
-
Container Registry can use considerable amounts of disk space. To clear up
some unused layers, the registry includes a garbage collect command.
@@ -791,6 +756,15 @@ it only unlinks tags from manifests and image blobs. To recycle the Container
Registry data in the whole GitLab instance, you can use the built-in command
provided by `gitlab-ctl`.
+Prerequisites:
+
+- You must have installed GitLab by using an Omnibus package or the
+ [cloud native chart](https://docs.gitlab.com/charts/charts/registry/#garbage-collection).
+- You must set the Registry to [read-only mode](#performing-garbage-collection-without-downtime).
+ Running garbage collection causes downtime for the Container Registry. If you run this command
+ on an instance in an environment where another instances is still writing to the Registry storage,
+ referenced manifests will be removed.
+
### Understanding the content-addressable layers
Consider the following example, where you first build the image:
@@ -818,15 +792,14 @@ no longer directly accessible via the `:latest` tag.
> [Introduced](https://gitlab.com/gitlab-org/omnibus-gitlab/-/merge_requests/987) in Omnibus GitLab 8.12.
-There are a couple of considerations you need to note before running the
-built-in command:
+Before you run the built-in command, note the following:
-- The built-in command will stop the registry before it starts the garbage collection.
+- The built-in command stops the registry before it starts the garbage collection.
- The garbage collect command takes some time to complete, depending on the
amount of data that exists.
-- If you changed the location of registry configuration file, you will need to
+- If you changed the location of registry configuration file, you must
specify its path.
-- After the garbage collection is done, the registry should start up automatically.
+- After the garbage collection is done, the registry should start automatically.
If you did not change the default location of the configuration file, run:
@@ -882,7 +855,6 @@ During this time,
you will be able to pull from the Container Registry, but you will not be able to
push.
-NOTE: **Note:**
By default, the [registry storage path](#configure-storage-for-the-container-registry)
is `/var/opt/gitlab/gitlab-rails/shared/registry`.
@@ -1065,7 +1037,7 @@ Start with a value between `25000000` (25MB) and `50000000` (50MB).
s3:
accesskey: 'AKIAKIAKI'
secretkey: 'secret123'
- bucket: 'gitlab-registry-bucket-AKIAKIAKI'
+ bucket: 'gitlab-registry-bucket-AKIAKIAKI'
chunksize: 25000000
```
@@ -1166,12 +1138,7 @@ curl localhost:5001/debug/vars
### Advanced Troubleshooting
-NOTE: **Note:**
-The following section is only recommended for experts.
-
-Sometimes it's not obvious what is wrong, and you may need to dive deeper into
-the communication between the Docker client and the Registry to find out
-what's wrong. We will use a concrete example in the past to illustrate how to
+We will use a concrete example in the past to illustrate how to
diagnose a problem with the S3 setup.
#### Unexpected 403 error during push
diff --git a/doc/administration/packages/dependency_proxy.md b/doc/administration/packages/dependency_proxy.md
index 2f9cfecc9d4..fba3d51f741 100644
--- a/doc/administration/packages/dependency_proxy.md
+++ b/doc/administration/packages/dependency_proxy.md
@@ -11,7 +11,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
GitLab can be utilized as a dependency proxy for a variety of common package managers.
This is the administration documentation. If you want to learn how to use the
-dependency proxies, see the [user guide](../../user/group/dependency_proxy/index.md).
+dependency proxies, see the [user guide](../../user/packages/dependency_proxy/index.md).
## Enabling the Dependency Proxy feature
@@ -135,28 +135,28 @@ This section describes the earlier configuration format.
##
## The location where build dependency_proxy are stored (default: shared/dependency_proxy).
##
- #storage_path: shared/dependency_proxy
+ # storage_path: shared/dependency_proxy
object_store:
enabled: false
- remote_directory: dependency_proxy # The bucket name.
- #direct_upload: false # Use Object Storage directly for uploads instead of background uploads if enabled (Default: false).
- #background_upload: true # Temporary option to limit automatic upload (Default: true).
- #proxy_download: false # Passthrough all downloads via GitLab instead of using Redirects to Object Storage.
+ remote_directory: dependency_proxy # The bucket name.
+ # direct_upload: false # Use Object Storage directly for uploads instead of background uploads if enabled (Default: false).
+ # background_upload: true # Temporary option to limit automatic upload (Default: true).
+ # proxy_download: false # Passthrough all downloads via GitLab instead of using Redirects to Object Storage.
connection:
+ ##
+ ## If the provider is AWS S3, use the following
+ ##
+ provider: AWS
+ region: us-east-1
+ aws_access_key_id: AWS_ACCESS_KEY_ID
+ aws_secret_access_key: AWS_SECRET_ACCESS_KEY
##
- ## If the provider is AWS S3, uncomment the following
+ ## If the provider is other than AWS (an S3-compatible one), comment out the previous 4 lines and use the following instead:
##
- #provider: AWS
- #region: us-east-1
- #aws_access_key_id: AWS_ACCESS_KEY_ID
- #aws_secret_access_key: AWS_SECRET_ACCESS_KEY
- ##
- ## If the provider is other than AWS (an S3-compatible one), uncomment the following
- ##
- #host: 's3.amazonaws.com' # default: s3.amazonaws.com.
- #aws_signature_version: 4 # For creation of signed URLs. Set to 2 if provider does not support v4.
- #endpoint: 'https://s3.amazonaws.com' # Useful for S3-compliant services such as DigitalOcean Spaces.
- #path_style: false # If true, use 'host/bucket_name/object' instead of 'bucket_name.host/object'.
+ # host: 's3.amazonaws.com' # default: s3.amazonaws.com.
+ # aws_signature_version: 4 # For creation of signed URLs. Set to 2 if provider does not support v4.
+ # endpoint: 'https://s3.amazonaws.com' # Useful for S3-compliant services such as DigitalOcean Spaces.
+ # path_style: false # If true, use 'host/bucket_name/object' instead of 'bucket_name.host/object'.
```
1. [Restart GitLab](../restart_gitlab.md#installations-from-source "How to restart GitLab") for the changes to take effect.
diff --git a/doc/administration/packages/index.md b/doc/administration/packages/index.md
index 1061f3c33db..6b9e9ae3d5f 100644
--- a/doc/administration/packages/index.md
+++ b/doc/administration/packages/index.md
@@ -142,33 +142,33 @@ We recommend using the [consolidated object storage settings](../object_storage.
1. Edit the `packages` section in `config/gitlab.yml` (uncomment where necessary):
```yaml
- packages:
- enabled: true
+ packages:
+ enabled: true
+ ##
+ ## The location where build packages are stored (default: shared/packages).
+ ##
+ # storage_path: shared/packages
+ object_store:
+ enabled: false
+ remote_directory: packages # The bucket name.
+ # direct_upload: false # Use Object Storage directly for uploads instead of background uploads if enabled (Default: false).
+ # background_upload: true # Temporary option to limit automatic upload (Default: true).
+ # proxy_download: false # Passthrough all downloads via GitLab instead of using Redirects to Object Storage.
+ connection:
##
- ## The location where build packages are stored (default: shared/packages).
+ ## If the provider is AWS S3, use the following:
##
- #storage_path: shared/packages
- object_store:
- enabled: false
- remote_directory: packages # The bucket name.
- #direct_upload: false # Use Object Storage directly for uploads instead of background uploads if enabled (Default: false).
- #background_upload: true # Temporary option to limit automatic upload (Default: true).
- #proxy_download: false # Passthrough all downloads via GitLab instead of using Redirects to Object Storage.
- connection:
- ##
- ## If the provider is AWS S3, uncomment the following
- ##
- #provider: AWS
- #region: us-east-1
- #aws_access_key_id: AWS_ACCESS_KEY_ID
- #aws_secret_access_key: AWS_SECRET_ACCESS_KEY
- ##
- ## If the provider is other than AWS (an S3-compatible one), uncomment the following
- ##
- #host: 's3.amazonaws.com' # default: s3.amazonaws.com.
- #aws_signature_version: 4 # For creation of signed URLs. Set to 2 if provider does not support v4.
- #endpoint: 'https://s3.amazonaws.com' # Useful for S3-compliant services such as DigitalOcean Spaces.
- #path_style: false # If true, use 'host/bucket_name/object' instead of 'bucket_name.host/object'.
+ provider: AWS
+ region: us-east-1
+ aws_access_key_id: AWS_ACCESS_KEY_ID
+ aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ ##
+ ## If the provider is other than AWS (an S3-compatible one), comment out the previous 4 lines and use the following instead:
+ ##
+ # host: 's3.amazonaws.com' # default: s3.amazonaws.com.
+ # aws_signature_version: 4 # For creation of signed URLs. Set to 2 if provider does not support v4.
+ # endpoint: 'https://s3.amazonaws.com' # Useful for S3-compliant services such as DigitalOcean Spaces.
+ # path_style: false # If true, use 'host/bucket_name/object' instead of 'bucket_name.host/object'.
```
1. Save the file and [restart GitLab](../restart_gitlab.md#installations-from-source) for the changes to take effect.
diff --git a/doc/administration/pages/index.md b/doc/administration/pages/index.md
index 3c0030be629..53b45c0ac83 100644
--- a/doc/administration/pages/index.md
+++ b/doc/administration/pages/index.md
@@ -370,8 +370,8 @@ Pages access control is disabled by default. To enable it:
1. Users can now configure it in their [projects' settings](../../user/project/pages/pages_access_control.md).
NOTE: **Important:**
-For multi-node setups, in order for this setting to be effective, it has to be applied
-to all the App nodes as well as the Sidekiq nodes.
+For this setting to be effective with multi-node setups, it has to be applied to
+all the App nodes and Sidekiq nodes.
#### Disabling public access to all Pages websites
@@ -397,8 +397,7 @@ redeployed. This issue will be resolved by
### Running behind a proxy
Like the rest of GitLab, Pages can be used in those environments where external
-internet connectivity is gated by a proxy. In order to use a proxy for GitLab
-pages:
+internet connectivity is gated by a proxy. To use a proxy for GitLab Pages:
1. Configure in `/etc/gitlab/gitlab.rb`:
@@ -508,7 +507,8 @@ To override the global maximum pages size for a specific group:
## Running GitLab Pages on a separate server
-You can run the GitLab Pages daemon on a separate server in order to decrease the load on your main application server.
+You can run the GitLab Pages daemon on a separate server to decrease the load on
+your main application server.
To configure GitLab Pages on a separate server:
diff --git a/doc/administration/postgresql/external.md b/doc/administration/postgresql/external.md
index 632b68fb014..4a164c66578 100644
--- a/doc/administration/postgresql/external.md
+++ b/doc/administration/postgresql/external.md
@@ -1,3 +1,9 @@
+---
+stage: Enablement
+group: Database
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+---
+
# Configure GitLab using an external PostgreSQL service
If you're hosting GitLab on a cloud provider, you can optionally use a
diff --git a/doc/administration/postgresql/index.md b/doc/administration/postgresql/index.md
index 2720d8e696b..c7ec46db654 100644
--- a/doc/administration/postgresql/index.md
+++ b/doc/administration/postgresql/index.md
@@ -1,11 +1,14 @@
---
+stage: Enablement
+group: Database
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: reference
---
# Configuring PostgreSQL for scaling
In this section, you'll be guided through configuring a PostgreSQL database to
-be used with GitLab in one of our [Scalable and Highly Available Setups](../reference_architectures/index.md).
+be used with GitLab in one of our [reference architectures](../reference_architectures/index.md).
There are essentially three setups to choose from.
## PostgreSQL replication and failover with Omnibus GitLab **(PREMIUM ONLY)**
diff --git a/doc/administration/postgresql/pgbouncer.md b/doc/administration/postgresql/pgbouncer.md
index 9db3e017359..b946c0949c4 100644
--- a/doc/administration/postgresql/pgbouncer.md
+++ b/doc/administration/postgresql/pgbouncer.md
@@ -1,4 +1,7 @@
---
+stage: Enablement
+group: Database
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: reference
---
diff --git a/doc/administration/postgresql/replication_and_failover.md b/doc/administration/postgresql/replication_and_failover.md
index bc2af167e6c..6b84b7ac725 100644
--- a/doc/administration/postgresql/replication_and_failover.md
+++ b/doc/administration/postgresql/replication_and_failover.md
@@ -1,10 +1,16 @@
+---
+stage: Enablement
+group: Database
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+---
+
# PostgreSQL replication and failover with Omnibus GitLab **(PREMIUM ONLY)**
-This document will focus only on configuration supported with [GitLab Premium](https://about.gitlab.com/pricing/), using the Omnibus GitLab package.
-If you are a Community Edition or Starter user, consider using a cloud hosted solution.
-This document will not cover installations from source.
+This document focuses on configuration supported with [GitLab Premium](https://about.gitlab.com/pricing/), using the Omnibus GitLab package.
+If you're a Community Edition or Starter user, consider using a cloud hosted solution.
+This document doesn't cover installations from source.
-If a setup with replication and failover is not what you were looking for, see
+If a setup with replication and failover isn't what you were looking for, see
the [database configuration document](https://docs.gitlab.com/omnibus/settings/database.html)
for the Omnibus GitLab packages.
@@ -87,9 +93,9 @@ information.
#### Network information
-PostgreSQL does not listen on any network interface by default. It needs to know
-which IP address to listen on in order to be accessible to other services.
-Similarly, PostgreSQL access is controlled based on the network source.
+PostgreSQL doesn't listen on any network interface by default. It needs to know
+which IP address to listen on to be accessible to other services. Similarly,
+PostgreSQL access is controlled based on the network source.
This is why you will need:
@@ -1348,3 +1354,93 @@ You can switch an exiting database cluster to use Patroni instead of repmgr with
1. Repeat the last two steps for all replica nodes. `gitlab.rb` should look the same on all nodes.
1. Optional: You can remove `gitlab_repmgr` database and role on the primary.
+
+### Upgrading PostgreSQL major version in a Patroni cluster
+
+As of GitLab 13.3, PostgreSQL 11.7 and 12.3 are both shipped with Omnibus GitLab. GitLab still
+uses PostgreSQL 11 by default. Therefore `gitlab-ctl pg-upgrade` does not automatically upgrade
+to PostgreSQL 12. If you want to upgrade to PostgreSQL 12, you must ask for it explicitly.
+
+CAUTION: **Warning:**
+The procedure for upgrading PostgreSQL in a Patroni cluster is different than when upgrading using repmgr.
+The following outlines the key differences and important considerations that need to be accounted for when
+upgrading PostgreSQL.
+
+Here are a few key facts that you must consider before upgrading PostgreSQL:
+
+- The main point is that you will have to **shut down the Patroni cluster**. This means that your
+ GitLab deployment will be down for the duration of database upgrade or, at least, as long as your leader
+ node is upgraded. This can be **a significant downtime depending on the size of your database**.
+
+- Upgrading PostgreSQL creates a new data directory with a new control data. From Patroni's perspective
+ this is a new cluster that needs to be bootstrapped again. Therefore, as part of the upgrade procedure,
+ the cluster state, which is stored in Consul, will be wiped out. Once the upgrade is completed, Patroni
+ will be instructed to bootstrap a new cluster. **Note that this will change your _cluster ID_**.
+
+- The procedures for upgrading leader and replicas are not the same. That is why it is important to use the
+ right procedure on each node.
+
+- Upgrading a replica node **deletes the data directory and resynchronizes it** from the leader using the
+ configured replication method (currently `pg_basebackup` is the only available option). It might take some
+ time for replica to catch up with the leader, depending on the size of your database.
+
+- An overview of the upgrade procedure is outlined in [Patoni's documentation](https://patroni.readthedocs.io/en/latest/existing_data.html#major-upgrade-of-postgresql-version).
+ You can still use `gitlab-ctl pg-upgrade` which implements this procedure with a few adjustments.
+
+Considering these, you should carefully plan your PostgreSQL upgrade:
+
+1. Find out which node is the leader and which node is a replica:
+
+ ```shell
+ gitlab-ctl patroni members
+ ```
+
+ NOTE: **Note:**
+ `gitlab-ctl pg-upgrade` tries to detect the role of the node. If for any reason the auto-detection
+ does not work or you believe it did not detect the role correctly, you can use the `--leader` or `--replica`
+ arguments to manually override it.
+
+1. Stop Patroni **only on replicas**.
+
+ ```shell
+ sudo gitlab-ctl stop patroni
+ ```
+
+1. Enable the maintenance mode on the **application node**:
+
+ ```shell
+ sudo gitlab-ctl deploy-page up
+ ```
+
+1. Upgrade PostgreSQL on **the leader node** and make sure that the upgrade is completed successfully:
+
+ ```shell
+ sudo gitlab-ctl pg-upgrade -V 12
+ ```
+
+1. Check the status of the leader and cluster. You can only proceed if you have a healthy leader:
+
+ ```shell
+ gitlab-ctl patroni check-leader
+
+ # OR
+
+ gitlab-ctl patroni members
+ ```
+
+1. You can now disable the maintenance mode on the **application node**:
+
+ ```shell
+ sudo gitlab-ctl deploy-page down
+ ```
+
+1. Upgrade PostgreSQL **on replicas** (you can do this in parallel on all of them):
+
+ ```shell
+ sudo gitlab-ctl pg-upgrade -V 12
+ ```
+
+CAUTION: **Warning:**
+Reverting PostgreSQL upgrade with `gitlab-ctl revert-pg-upgrade` has the same considerations as
+`gitlab-ctl pg-upgrade`. It can be complicated and may involve deletion of the data directory.
+If you need to do that, please contact GitLab support.
diff --git a/doc/administration/postgresql/standalone.md b/doc/administration/postgresql/standalone.md
index 2747749066e..2ac74e8a4a0 100644
--- a/doc/administration/postgresql/standalone.md
+++ b/doc/administration/postgresql/standalone.md
@@ -1,15 +1,21 @@
+---
+stage: Enablement
+group: Database
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+---
+
# Standalone PostgreSQL using Omnibus GitLab **(CORE ONLY)**
If you wish to have your database service hosted separately from your GitLab
-application server(s), you can do this using the PostgreSQL binaries packaged
+application servers, you can do this using the PostgreSQL binaries packaged
together with Omnibus GitLab. This is recommended as part of our
[reference architecture for up to 2,000 users](../reference_architectures/2k_users.md).
## Setting it up
-1. SSH into the PostgreSQL server.
-1. [Download/install](https://about.gitlab.com/install/) the Omnibus GitLab
- package you want using **steps 1 and 2** from the GitLab downloads page.
+1. SSH in to the PostgreSQL server.
+1. [Download and install](https://about.gitlab.com/install/) the Omnibus GitLab
+ package you want using *steps 1 and 2* from the GitLab downloads page.
- Do not complete any other steps on the download page.
1. Generate a password hash for PostgreSQL. This assumes you will use the default
username of `gitlab` (recommended). The command will request a password
diff --git a/doc/administration/raketasks/check.md b/doc/administration/raketasks/check.md
index 15014fffd01..d13e6328b2f 100644
--- a/doc/administration/raketasks/check.md
+++ b/doc/administration/raketasks/check.md
@@ -154,3 +154,34 @@ If the issue persists, try triggering `gc` via the
p = Project.find_by_path("project-name")
Projects::HousekeepingService.new(p, :gc).execute
```
+
+### Delete references to missing remote uploads
+
+`gitlab-rake gitlab:uploads:check VERBOSE=1` detects remote objects that do not exist because they were
+deleted externally but their references still exist in the GitLab database.
+
+Example output with error message:
+
+```shell
+$ sudo gitlab-rake gitlab:uploads:check VERBOSE=1
+Checking integrity of Uploads
+- 100..434: Failures: 2
+- Upload: 100: Remote object does not exist
+- Upload: 101: Remote object does not exist
+Done!
+```
+
+To delete these references to remote uploads that were deleted externally, open the [GitLab Rails Console](../troubleshooting/navigating_gitlab_via_rails_console.md#starting-a-rails-console-session)
+and run:
+[Rails Console](../troubleshooting/navigating_gitlab_via_rails_console.md#starting-a-rails-console-session):
+
+```ruby
+uploads_deleted=0
+Upload.find_each do |upload|
+ next if upload.retrieve_uploader.file.exists?
+ uploads_deleted=uploads_deleted + 1
+ p upload ### allow verification before destroy
+ # p upload.destroy! ### uncomment to actually destroy
+end
+p "#{uploads_deleted} remote objects were destroyed."
+```
diff --git a/doc/administration/raketasks/doctor.md b/doc/administration/raketasks/doctor.md
index 62d0af70706..c97aa5a4de1 100644
--- a/doc/administration/raketasks/doctor.md
+++ b/doc/administration/raketasks/doctor.md
@@ -47,9 +47,8 @@ I, [2020-06-11T17:18:15.575711 #27148] INFO -- : Done!
### Verbose mode
-In order to get more detailed information about which
-rows and columns cannot be decrypted, you can pass a VERBOSE
-environment variable:
+To get more detailed information about which rows and columns can't be
+decrypted, you can pass a `VERBOSE` environment variable:
**Omnibus Installation**
diff --git a/doc/administration/raketasks/github_import.md b/doc/administration/raketasks/github_import.md
index a46a2b34687..7f673a2c850 100644
--- a/doc/administration/raketasks/github_import.md
+++ b/doc/administration/raketasks/github_import.md
@@ -2,9 +2,8 @@
> [Introduced]( https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/10308) in GitLab 9.1.
-In order to retrieve and import GitHub repositories, you will need a
-[GitHub personal access token](https://github.com/settings/tokens).
-A username should be passed as the second argument to the Rake task
+To retrieve and import GitHub repositories, you need a [GitHub personal access token](https://github.com/settings/tokens).
+A username should be passed as the second argument to the Rake task,
which will become the owner of the project. You can resume an import
with the same command.
diff --git a/doc/administration/read_only_gitlab.md b/doc/administration/read_only_gitlab.md
new file mode 100644
index 00000000000..681102a8c39
--- /dev/null
+++ b/doc/administration/read_only_gitlab.md
@@ -0,0 +1,125 @@
+---
+stage: Enablement
+group: Distribution
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+---
+
+# Place GitLab into a read-only state **(CORE ONLY)**
+
+CAUTION: **Warning:**
+This document should be used as a temporary solution.
+There's work in progress to make this
+[possible with Geo](https://gitlab.com/groups/gitlab-org/-/epics/2149).
+
+In some cases, you might want to place GitLab under a read-only state.
+The configuration for doing so depends on your desired outcome.
+
+## Make the repositories read-only
+
+The first thing you'll want to accomplish is to ensure that no changes can be
+made to your repositories. There's two ways you can accomplish that:
+
+- Either stop Unicorn/Puma to make the internal API unreachable:
+
+ ```shell
+ sudo gitlab-ctl stop puma # or unicorn
+ ```
+
+- Or, open up a Rails console:
+
+ ```shell
+ sudo gitlab-rails console
+ ```
+
+ And set the repositories for all projects read-only:
+
+ ```ruby
+ Project.all.find_each { |project| project.update!(repository_read_only: true) }
+ ```
+
+ When you're ready to revert this, you can do so with the following command:
+
+ ```ruby
+ Project.all.find_each { |project| project.update!(repository_read_only: false) }
+ ```
+
+## Shut down the GitLab UI
+
+If you don't mind shutting down the GitLab UI, then the easiest approach is to
+stop `sidekiq` and `puma`/`unicorn`, and you'll effectively ensure that no
+changes can be made to GitLab:
+
+```shell
+sudo gitlab-ctl stop sidekiq
+sudo gitlab-ctl stop puma # or unicorn
+```
+
+When you're ready to revert this:
+
+```shell
+sudo gitlab-ctl start sidekiq
+sudo gitlab-ctl start puma # or unicorn
+```
+
+## Make the database read-only
+
+If you want to allow users to use the GitLab UI, then you'll need to ensure that
+the database is read-only:
+
+1. Take a [GitLab backup](../raketasks/backup_restore.md#back-up-gitlab)
+ in case things don't go as expected.
+1. Enter PostgreSQL on the console as an admin user:
+
+ ```shell
+ sudo \
+ -u gitlab-psql /opt/gitlab/embedded/bin/psql \
+ -h /var/opt/gitlab/postgresql gitlabhq_production
+ ```
+
+1. Create the `gitlab_read_only` user. Note that the password is set to `mypassword`,
+ change that to your liking:
+
+ ```sql
+ -- NOTE: Use the password defined earlier
+ CREATE USER gitlab_read_only WITH password 'mypassword';
+ GRANT CONNECT ON DATABASE gitlabhq_production to gitlab_read_only;
+ GRANT USAGE ON SCHEMA public TO gitlab_read_only;
+ GRANT SELECT ON ALL TABLES IN SCHEMA public TO gitlab_read_only;
+ GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO gitlab_read_only;
+
+ -- Tables created by "gitlab" should be made read-only for "gitlab_read_only"
+ -- automatically.
+ ALTER DEFAULT PRIVILEGES FOR USER gitlab IN SCHEMA public GRANT SELECT ON TABLES TO gitlab_read_only;
+ ALTER DEFAULT PRIVILEGES FOR USER gitlab IN SCHEMA public GRANT SELECT ON SEQUENCES TO gitlab_read_only;
+ ```
+
+1. Get the hashed password of the `gitlab_read_only` user and copy the result:
+
+ ```shell
+ sudo gitlab-ctl pg-password-md5 gitlab_read_only
+ ```
+
+1. Edit `/etc/gitlab/gitlab.rb` and add the password from the previous step:
+
+ ```ruby
+ postgresql['sql_user_password'] = 'a2e20f823772650f039284619ab6f239'
+ postgresql['sql_user'] = "gitlab_read_only"
+ ```
+
+1. Reconfigure GitLab and restart PostgreSQL:
+
+ ```shell
+ sudo gitlab-ctl reconfigure
+ sudo gitlab-ctl restart postgresql
+ ```
+
+When you're ready to revert the read-only state, you'll need to remove the added
+lines in `/etc/gitlab/gitlab.rb`, and reconfigure GitLab and restart PostgreSQL:
+
+```shell
+sudo gitlab-ctl reconfigure
+sudo gitlab-ctl restart postgresql
+```
+
+Once you verify all works as expected, you can remove the `gitlab_read_only`
+user from the database.
diff --git a/doc/administration/redis/replication_and_failover.md b/doc/administration/redis/replication_and_failover.md
index ca041adb1d8..72a52ba0a1f 100644
--- a/doc/administration/redis/replication_and_failover.md
+++ b/doc/administration/redis/replication_and_failover.md
@@ -124,9 +124,9 @@ each other over the network.
### Sentinel setup overview
Sentinels watch both other Sentinels and Redis nodes. Whenever a Sentinel
-detects that a Redis node is not responding, it will announce that to the
-other Sentinels. They have to reach the **quorum**, that is the minimum amount
-of Sentinels that agrees a node is down, in order to be able to start a failover.
+detects that a Redis node isn't responding, it announces the node's status to
+the other Sentinels. The Sentinels have to reach a _quorum_ (the minimum amount
+of Sentinels agreeing a node is down) to be able to start a failover.
Whenever the **quorum** is met, the **majority** of all known Sentinel nodes
need to be available and reachable, so that they can elect the Sentinel **leader**
diff --git a/doc/administration/redis/replication_and_failover_external.md b/doc/administration/redis/replication_and_failover_external.md
index ce452d30fc2..680d36b6fde 100644
--- a/doc/administration/redis/replication_and_failover_external.md
+++ b/doc/administration/redis/replication_and_failover_external.md
@@ -228,13 +228,13 @@ which ideally should not have Redis or Sentinels in the same machine:
sentinels:
-
host: 10.0.0.1
- port: 26379 # point to sentinel, not to redis port
+ port: 26379 # point to sentinel, not to redis port
-
host: 10.0.0.2
- port: 26379 # point to sentinel, not to redis port
+ port: 26379 # point to sentinel, not to redis port
-
host: 10.0.0.3
- port: 26379 # point to sentinel, not to redis port
+ port: 26379 # point to sentinel, not to redis port
```
1. [Restart GitLab](../restart_gitlab.md#installations-from-source) for the changes to take effect.
@@ -353,13 +353,13 @@ or a failover promotes a different **Primary** node.
sentinels:
-
host: 10.0.0.1
- port: 26379 # point to sentinel, not to redis port
+ port: 26379 # point to sentinel, not to redis port
-
host: 10.0.0.2
- port: 26379 # point to sentinel, not to redis port
+ port: 26379 # point to sentinel, not to redis port
-
host: 10.0.0.3
- port: 26379 # point to sentinel, not to redis port
+ port: 26379 # point to sentinel, not to redis port
```
1. [Restart GitLab](../restart_gitlab.md#installations-from-source) for the changes to take effect.
diff --git a/doc/administration/redis/troubleshooting.md b/doc/administration/redis/troubleshooting.md
index 402b60e5b7b..c9976ac791d 100644
--- a/doc/administration/redis/troubleshooting.md
+++ b/doc/administration/redis/troubleshooting.md
@@ -146,13 +146,13 @@ production:
sentinels:
-
host: 10.0.0.1
- port: 26379 # point to sentinel, not to redis port
+ port: 26379 # point to sentinel, not to redis port
-
host: 10.0.0.2
- port: 26379 # point to sentinel, not to redis port
+ port: 26379 # point to sentinel, not to redis port
-
host: 10.0.0.3
- port: 26379 # point to sentinel, not to redis port
+ port: 26379 # point to sentinel, not to redis port
```
When in doubt, read the [Redis Sentinel documentation](https://redis.io/topics/sentinel).
diff --git a/doc/administration/reference_architectures/10k_users.md b/doc/administration/reference_architectures/10k_users.md
index 5f8ab6683a9..2f32cf9fb04 100644
--- a/doc/administration/reference_architectures/10k_users.md
+++ b/doc/administration/reference_architectures/10k_users.md
@@ -17,21 +17,21 @@ full list of reference architectures, see
| Service | Nodes | Configuration | GCP | AWS | Azure |
|--------------------------------------------|-------------|-------------------------|-----------------|-------------|----------|
-| External load balancing node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| Consul | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| PostgreSQL | 3 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
-| PgBouncer | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| Internal load balancing node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| Redis - Cache | 3 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
-| Redis - Queues / Shared State | 3 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
-| Redis Sentinel - Cache | 3 | 1 vCPU, 1.7GB memory | g1-small | t2.small | B1MS |
-| Redis Sentinel - Queues / Shared State | 3 | 1 vCPU, 1.7GB memory | g1-small | t2.small | B1MS |
-| Gitaly | 2 (minimum) | 16 vCPU, 60GB memory | n1-standard-16 | m5.4xlarge | D16s v3 |
-| Sidekiq | 4 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
-| GitLab Rails | 3 | 32 vCPU, 28.8GB memory | n1-highcpu-32 | c5.9xlarge | F32s v2 |
-| Monitoring node | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
-| Object Storage | n/a | n/a | n/a | n/a | n/a |
-| NFS Server | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
+| External load balancing node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| Consul | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| PostgreSQL | 3 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
+| PgBouncer | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| Internal load balancing node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| Redis - Cache | 3 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
+| Redis - Queues / Shared State | 3 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
+| Redis Sentinel - Cache | 3 | 1 vCPU, 1.7 GB memory | g1-small | t2.small | B1MS |
+| Redis Sentinel - Queues / Shared State | 3 | 1 vCPU, 1.7 GB memory | g1-small | t2.small | B1MS |
+| Gitaly | 2 (minimum) | 16 vCPU, 60 GB memory | n1-standard-16 | m5.4xlarge | D16s v3 |
+| Sidekiq | 4 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
+| GitLab Rails | 3 | 32 vCPU, 28.8 GB memory | n1-highcpu-32 | c5.9xlarge | F32s v2 |
+| Monitoring node | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
+| Object storage | n/a | n/a | n/a | n/a | n/a |
+| NFS server | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
The Google Cloud Platform (GCP) architectures were built and tested using the
[Intel Xeon E5 v3 (Haswell)](https://cloud.google.com/compute/docs/cpu-platforms)
@@ -40,41 +40,43 @@ or higher, are required for your CPU or node counts. For more information, see
our [Sysbench](https://github.com/akopytov/sysbench)-based
[CPU benchmark](https://gitlab.com/gitlab-org/quality/performance/-/wikis/Reference-Architectures/GCP-CPU-Benchmarks).
-For data objects (such as LFS, Uploads, or Artifacts), an
-[object storage service](#configure-the-object-storage) is recommended instead
-of NFS where possible, due to better performance and availability. Since this
-doesn't require a node to be set up, *Object Storage* is noted as not
-applicable (n/a) in the previous table.
+Due to better performance and availability, for data objects (such as LFS,
+uploads, or artifacts), using an [object storage service](#configure-the-object-storage)
+is recommended instead of using NFS. Using an object storage service also
+doesn't require you to provision and maintain a node.
## Setup components
To set up GitLab and its components to accommodate up to 10,000 users:
1. [Configure the external load balancing node](#configure-the-external-load-balancer)
- that will handle the load balancing of the three GitLab application services nodes.
+ to handle the load balancing of the GitLab application services nodes.
1. [Configure Consul](#configure-consul).
1. [Configure PostgreSQL](#configure-postgresql), the database for GitLab.
1. [Configure PgBouncer](#configure-pgbouncer).
-1. [Configure the internal load balancing node](#configure-the-internal-load-balancer)
+1. [Configure the internal load balancing node](#configure-the-internal-load-balancer).
1. [Configure Redis](#configure-redis).
1. [Configure Gitaly](#configure-gitaly),
which provides access to the Git repositories.
1. [Configure Sidekiq](#configure-sidekiq).
1. [Configure the main GitLab Rails application](#configure-gitlab-rails)
- to run Puma/Unicorn, Workhorse, GitLab Shell, and to serve all frontend requests (UI, API, Git
- over HTTP/SSH).
-1. [Configure Prometheus](#configure-prometheus) to monitor your GitLab environment.
-1. [Configure the Object Storage](#configure-the-object-storage)
+ to run Puma/Unicorn, Workhorse, GitLab Shell, and to serve all frontend
+ requests (which include UI, API, and Git over HTTP/SSH).
+1. [Configure Prometheus](#configure-prometheus) to monitor your GitLab
+ environment.
+1. [Configure the object storage](#configure-the-object-storage)
used for shared data objects.
-1. [Configure NFS (Optional)](#configure-nfs-optional)
- to have shared disk storage service as an alternative to Gitaly and/or Object Storage (although
- not recommended). NFS is required for GitLab Pages, you can skip this step if you're not using
- that feature.
+1. [Configure Advanced Search](#configure-advanced-search) (optional) for faster,
+ more advanced code search across your entire GitLab instance.
+1. [Configure NFS](#configure-nfs-optional) (optional, and not recommended)
+ to have shared disk storage service as an alternative to Gitaly or object
+ storage. You can skip this step if you're not using GitLab Pages (which
+ requires NFS).
-We start with all servers on the same 10.6.0.0/24 private network range, they
-can connect to each other freely on those addresses.
+The servers start on the same 10.6.0.0/24 private network range, and can
+connect to each other freely on these addresses.
-Here is a list and description of each machine and the assigned IP:
+The following list includes descriptions of each server and its assigned IP:
- `10.6.0.10`: External Load Balancer
- `10.6.0.11`: Consul 1
@@ -1752,6 +1754,7 @@ On each node perform the following:
roles ['application_role']
gitaly['enable'] = false
nginx['enable'] = true
+ sidekiq['enable'] = false
## PostgreSQL connection details
# Disable PostgreSQL on the application node
@@ -1795,7 +1798,6 @@ On each node perform the following:
# Set the network addresses that the exporters used for monitoring will listen on
node_exporter['listen_address'] = '0.0.0.0:9100'
gitlab_workhorse['prometheus_listen_addr'] = '0.0.0.0:9229'
- sidekiq['listen_address'] = "0.0.0.0"
puma['listen'] = '0.0.0.0'
# Add the monitoring node's IP address to the monitoring whitelist and allow it to
@@ -1836,7 +1838,7 @@ On each node perform the following:
1. Specify the necessary NFS mounts in `/etc/fstab`.
The exact contents of `/etc/fstab` will depend on how you chose
- to configure your NFS server. See the [NFS documentation](../high_availability/nfs.md)
+ to configure your NFS server. See the [NFS documentation](../nfs.md)
for examples and the various options.
1. Create the shared directories. These may be different depending on your NFS
@@ -1889,18 +1891,22 @@ for more information.
### GitLab Rails post-configuration
-Initialize the GitLab database, by running the following in one of the Rails nodes:
+1. Designate one application node for running database migrations during
+ installation and updates. Initialize the GitLab database and ensure all
+ migrations ran:
-```shell
-sudo gitlab-rake gitlab:db:configure
-```
+ ```shell
+ sudo gitlab-rake gitlab:db:configure
+ ```
-NOTE: **Note:**
-If you encounter a `rake aborted!` error stating that PgBouncer is failing to connect to
-PostgreSQL it may be that your PgBouncer node's IP address is missing from
-PostgreSQL's `trust_auth_cidr_addresses` in `gitlab.rb` on your database nodes. See
-[PgBouncer error `ERROR: pgbouncer cannot connect to server`](troubleshooting.md#pgbouncer-error-error-pgbouncer-cannot-connect-to-server)
-in the Troubleshooting section before proceeding.
+ NOTE: **Note:**
+ If you encounter a `rake aborted!` error stating that PgBouncer is failing to connect to
+ PostgreSQL it may be that your PgBouncer node's IP address is missing from
+ PostgreSQL's `trust_auth_cidr_addresses` in `gitlab.rb` on your database nodes. See
+ [PgBouncer error `ERROR: pgbouncer cannot connect to server`](troubleshooting.md#pgbouncer-error-error-pgbouncer-cannot-connect-to-server)
+ in the Troubleshooting section before proceeding.
+
+1. [Configure fast lookup of authorized SSH keys in the database](../operations/fast_ssh_key_lookup.md).
<div align="right">
<a type="button" class="btn btn-default" href="#setup-components">
@@ -2033,13 +2039,32 @@ work.
</a>
</div>
+## Configure Advanced Search **(STARTER ONLY)**
+
+NOTE: **Note:**
+Elasticsearch cluster design and requirements are dependent on your specific data.
+For recommended best practices on how to set up your Elasticsearch cluster
+alongside your instance, read how to
+[choose the optimal cluster configuration](../../integration/elasticsearch.md#guidance-on-choosing-optimal-cluster-configuration).
+
+You can leverage Elasticsearch and enable Advanced Search for faster, more
+advanced code search across your entire GitLab instance.
+
+[Learn how to set it up.](../../integration/elasticsearch.md)
+
+<div align="right">
+ <a type="button" class="btn btn-default" href="#setup-components">
+ Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
+ </a>
+</div>
+
## Configure NFS (optional)
[Object storage](#configure-the-object-storage), along with [Gitaly](#configure-gitaly)
are recommended over NFS wherever possible for improved performance. If you intend
to use GitLab Pages, this currently [requires NFS](troubleshooting.md#gitlab-pages-requires-nfs).
-See how to [configure NFS](../high_availability/nfs.md).
+See how to [configure NFS](../nfs.md).
<div align="right">
<a type="button" class="btn btn-default" href="#setup-components">
diff --git a/doc/administration/reference_architectures/1k_users.md b/doc/administration/reference_architectures/1k_users.md
index d376c1b7575..f54a25b7a9b 100644
--- a/doc/administration/reference_architectures/1k_users.md
+++ b/doc/administration/reference_architectures/1k_users.md
@@ -20,8 +20,8 @@ many organizations .
| Users | Configuration | GCP | AWS | Azure |
|--------------|-------------------------|----------------|-----------------|----------------|
-| Up to 500 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
-| Up to 1,000 | 8 vCPU, 7.2GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
+| Up to 500 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
+| Up to 1,000 | 8 vCPU, 7.2 GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
The Google Cloud Platform (GCP) architectures were built and tested using the
[Intel Xeon E5 v3 (Haswell)](https://cloud.google.com/compute/docs/cpu-platforms)
@@ -30,9 +30,9 @@ or higher, are required for your CPU or node counts. For more information, see
our [Sysbench](https://github.com/akopytov/sysbench)-based
[CPU benchmark](https://gitlab.com/gitlab-org/quality/performance/-/wikis/Reference-Architectures/GCP-CPU-Benchmarks).
-In addition to the stated configurations, we recommend having at least 2GB of
+In addition to the stated configurations, we recommend having at least 2 GB of
swap on your server, even if you currently have enough available memory. Having
-swap will help reduce the chance of errors occurring if your available memory
+swap helps to reduce the chance of errors occurring if your available memory
changes. We also recommend configuring the kernel's swappiness setting to a
lower value (such as `10`) to make the most of your memory, while still having
the swap available when needed.
@@ -45,5 +45,18 @@ For this default reference architecture, to install GitLab use the standard
NOTE: **Note:**
You can also optionally configure GitLab to use an
[external PostgreSQL service](../postgresql/external.md) or an
-[external object storage service](../high_availability/object_storage.md) for
+[external object storage service](../object_storage.md) for
added performance and reliability at a reduced complexity cost.
+
+## Configure Advanced Search **(STARTER ONLY)**
+
+NOTE: **Note:**
+Elasticsearch cluster design and requirements are dependent on your specific data.
+For recommended best practices on how to set up your Elasticsearch cluster
+alongside your instance, read how to
+[choose the optimal cluster configuration](../../integration/elasticsearch.md#guidance-on-choosing-optimal-cluster-configuration).
+
+You can leverage Elasticsearch and enable Advanced Search for faster, more
+advanced code search across your entire GitLab instance.
+
+[Learn how to set it up.](../../integration/elasticsearch.md)
diff --git a/doc/administration/reference_architectures/25k_users.md b/doc/administration/reference_architectures/25k_users.md
index 2ef555bff29..55de38645d7 100644
--- a/doc/administration/reference_architectures/25k_users.md
+++ b/doc/administration/reference_architectures/25k_users.md
@@ -17,21 +17,21 @@ full list of reference architectures, see
| Service | Nodes | Configuration | GCP | AWS | Azure |
|-----------------------------------------|-------------|-------------------------|-----------------|-------------|----------|
-| External load balancing node | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
-| Consul | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| PostgreSQL | 3 | 8 vCPU, 30GB memory | n1-standard-8 | m5.2xlarge | D8s v3 |
-| PgBouncer | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| Internal load balancing node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| Redis - Cache | 3 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
-| Redis - Queues / Shared State | 3 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
-| Redis Sentinel - Cache | 3 | 1 vCPU, 1.7GB memory | g1-small | t2.small | B1MS |
-| Redis Sentinel - Queues / Shared State | 3 | 1 vCPU, 1.7GB memory | g1-small | t2.small | B1MS |
-| Gitaly | 2 (minimum) | 32 vCPU, 120GB memory | n1-standard-32 | m5.8xlarge | D32s v3 |
-| Sidekiq | 4 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
-| GitLab Rails | 5 | 32 vCPU, 28.8GB memory | n1-highcpu-32 | c5.9xlarge | F32s v2 |
-| Monitoring node | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
-| Object Storage | n/a | n/a | n/a | n/a | n/a |
-| NFS Server | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
+| External load balancing node | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
+| Consul | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| PostgreSQL | 3 | 8 vCPU, 30 GB memory | n1-standard-8 | m5.2xlarge | D8s v3 |
+| PgBouncer | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| Internal load balancing node | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.large | F2s v2 |
+| Redis - Cache | 3 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
+| Redis - Queues / Shared State | 3 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
+| Redis Sentinel - Cache | 3 | 1 vCPU, 1.7 GB memory | g1-small | t2.small | B1MS |
+| Redis Sentinel - Queues / Shared State | 3 | 1 vCPU, 1.7 GB memory | g1-small | t2.small | B1MS |
+| Gitaly | 2 (minimum) | 32 vCPU, 120 GB memory | n1-standard-32 | m5.8xlarge | D32s v3 |
+| Sidekiq | 4 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
+| GitLab Rails | 5 | 32 vCPU, 28.8 GB memory | n1-highcpu-32 | c5.9xlarge | F32s v2 |
+| Monitoring node | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
+| Object storage | n/a | n/a | n/a | n/a | n/a |
+| NFS server | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
The Google Cloud Platform (GCP) architectures were built and tested using the
[Intel Xeon E5 v3 (Haswell)](https://cloud.google.com/compute/docs/cpu-platforms)
@@ -40,41 +40,43 @@ or higher, are required for your CPU or node counts. For more information, see
our [Sysbench](https://github.com/akopytov/sysbench)-based
[CPU benchmark](https://gitlab.com/gitlab-org/quality/performance/-/wikis/Reference-Architectures/GCP-CPU-Benchmarks).
-For data objects (such as LFS, Uploads, or Artifacts), an
-[object storage service](#configure-the-object-storage) is recommended instead
-of NFS where possible, due to better performance and availability. Since this
-doesn't require a node to be set up, *Object Storage* is noted as not
-applicable (n/a) in the previous table.
+Due to better performance and availability, for data objects (such as LFS,
+uploads, or artifacts), using an [object storage service](#configure-the-object-storage)
+is recommended instead of using NFS. Using an object storage service also
+doesn't require you to provision and maintain a node.
## Setup components
To set up GitLab and its components to accommodate up to 25,000 users:
1. [Configure the external load balancing node](#configure-the-external-load-balancer)
- that will handle the load balancing of the three GitLab application services nodes.
+ to handle the load balancing of the GitLab application services nodes.
1. [Configure Consul](#configure-consul).
1. [Configure PostgreSQL](#configure-postgresql), the database for GitLab.
1. [Configure PgBouncer](#configure-pgbouncer).
-1. [Configure the internal load balancing node](#configure-the-internal-load-balancer)
+1. [Configure the internal load balancing node](#configure-the-internal-load-balancer).
1. [Configure Redis](#configure-redis).
1. [Configure Gitaly](#configure-gitaly),
which provides access to the Git repositories.
1. [Configure Sidekiq](#configure-sidekiq).
1. [Configure the main GitLab Rails application](#configure-gitlab-rails)
- to run Puma/Unicorn, Workhorse, GitLab Shell, and to serve all frontend requests (UI, API, Git
- over HTTP/SSH).
-1. [Configure Prometheus](#configure-prometheus) to monitor your GitLab environment.
-1. [Configure the Object Storage](#configure-the-object-storage)
+ to run Puma/Unicorn, Workhorse, GitLab Shell, and to serve all frontend
+ requests (which include UI, API, and Git over HTTP/SSH).
+1. [Configure Prometheus](#configure-prometheus) to monitor your GitLab
+ environment.
+1. [Configure the object storage](#configure-the-object-storage)
used for shared data objects.
-1. [Configure NFS (Optional)](#configure-nfs-optional)
- to have shared disk storage service as an alternative to Gitaly and/or Object Storage (although
- not recommended). NFS is required for GitLab Pages, you can skip this step if you're not using
- that feature.
+1. [Configure Advanced Search](#configure-advanced-search) (optional) for faster,
+ more advanced code search across your entire GitLab instance.
+1. [Configure NFS](#configure-nfs-optional) (optional, and not recommended)
+ to have shared disk storage service as an alternative to Gitaly or object
+ storage. You can skip this step if you're not using GitLab Pages (which
+ requires NFS).
-We start with all servers on the same 10.6.0.0/24 private network range, they
-can connect to each other freely on those addresses.
+The servers start on the same 10.6.0.0/24 private network range, and can
+connect to each other freely on these addresses.
-Here is a list and description of each machine and the assigned IP:
+The following list includes descriptions of each server and its assigned IP:
- `10.6.0.10`: External Load Balancer
- `10.6.0.11`: Consul 1
@@ -1752,6 +1754,7 @@ On each node perform the following:
roles ['application_role']
gitaly['enable'] = false
nginx['enable'] = true
+ sidekiq['enable'] = false
## PostgreSQL connection details
# Disable PostgreSQL on the application node
@@ -1795,7 +1798,6 @@ On each node perform the following:
# Set the network addresses that the exporters used for monitoring will listen on
node_exporter['listen_address'] = '0.0.0.0:9100'
gitlab_workhorse['prometheus_listen_addr'] = '0.0.0.0:9229'
- sidekiq['listen_address'] = "0.0.0.0"
puma['listen'] = '0.0.0.0'
# Add the monitoring node's IP address to the monitoring whitelist and allow it to
@@ -1836,7 +1838,7 @@ On each node perform the following:
1. Specify the necessary NFS mounts in `/etc/fstab`.
The exact contents of `/etc/fstab` will depend on how you chose
- to configure your NFS server. See the [NFS documentation](../high_availability/nfs.md)
+ to configure your NFS server. See the [NFS documentation](../nfs.md)
for examples and the various options.
1. Create the shared directories. These may be different depending on your NFS
@@ -1889,18 +1891,22 @@ for more information.
### GitLab Rails post-configuration
-Initialize the GitLab database, by running the following in one of the Rails nodes:
+1. Designate one application node for running database migrations during
+ installation and updates. Initialize the GitLab database and ensure all
+ migrations ran:
-```shell
-sudo gitlab-rake gitlab:db:configure
-```
+ ```shell
+ sudo gitlab-rake gitlab:db:configure
+ ```
-NOTE: **Note:**
-If you encounter a `rake aborted!` error stating that PgBouncer is failing to connect to
-PostgreSQL it may be that your PgBouncer node's IP address is missing from
-PostgreSQL's `trust_auth_cidr_addresses` in `gitlab.rb` on your database nodes. See
-[PgBouncer error `ERROR: pgbouncer cannot connect to server`](troubleshooting.md#pgbouncer-error-error-pgbouncer-cannot-connect-to-server)
-in the Troubleshooting section before proceeding.
+ NOTE: **Note:**
+ If you encounter a `rake aborted!` error stating that PgBouncer is failing to connect to
+ PostgreSQL it may be that your PgBouncer node's IP address is missing from
+ PostgreSQL's `trust_auth_cidr_addresses` in `gitlab.rb` on your database nodes. See
+ [PgBouncer error `ERROR: pgbouncer cannot connect to server`](troubleshooting.md#pgbouncer-error-error-pgbouncer-cannot-connect-to-server)
+ in the Troubleshooting section before proceeding.
+
+1. [Configure fast lookup of authorized SSH keys in the database](../operations/fast_ssh_key_lookup.md).
<div align="right">
<a type="button" class="btn btn-default" href="#setup-components">
@@ -2033,13 +2039,32 @@ work.
</a>
</div>
+## Configure Advanced Search **(STARTER ONLY)**
+
+NOTE: **Note:**
+Elasticsearch cluster design and requirements are dependent on your specific data.
+For recommended best practices on how to set up your Elasticsearch cluster
+alongside your instance, read how to
+[choose the optimal cluster configuration](../../integration/elasticsearch.md#guidance-on-choosing-optimal-cluster-configuration).
+
+You can leverage Elasticsearch and enable Advanced Search for faster, more
+advanced code search across your entire GitLab instance.
+
+[Learn how to set it up.](../../integration/elasticsearch.md)
+
+<div align="right">
+ <a type="button" class="btn btn-default" href="#setup-components">
+ Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
+ </a>
+</div>
+
## Configure NFS (optional)
[Object storage](#configure-the-object-storage), along with [Gitaly](#configure-gitaly)
are recommended over NFS wherever possible for improved performance. If you intend
to use GitLab Pages, this currently [requires NFS](troubleshooting.md#gitlab-pages-requires-nfs).
-See how to [configure NFS](../high_availability/nfs.md).
+See how to [configure NFS](../nfs.md).
<div align="right">
<a type="button" class="btn btn-default" href="#setup-components">
diff --git a/doc/administration/reference_architectures/2k_users.md b/doc/administration/reference_architectures/2k_users.md
index 34b90964fbf..1c174fc6e03 100644
--- a/doc/administration/reference_architectures/2k_users.md
+++ b/doc/administration/reference_architectures/2k_users.md
@@ -17,14 +17,14 @@ For a full list of reference architectures, see
| Service | Nodes | Configuration | GCP | AWS | Azure |
|------------------------------------------|--------|-------------------------|----------------|--------------|---------|
-| Load balancer | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| PostgreSQL | 1 | 2 vCPU, 7.5GB memory | n1-standard-2 | m5.large | D2s v3 |
-| Redis | 1 | 1 vCPU, 3.75GB memory | n1-standard-1 | m5.large | D2s v3 |
-| Gitaly | 1 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
-| GitLab Rails | 2 | 8 vCPU, 7.2GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
-| Monitoring node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| Load balancer | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| PostgreSQL | 1 | 2 vCPU, 7.5 GB memory | n1-standard-2 | m5.large | D2s v3 |
+| Redis | 1 | 1 vCPU, 3.75 GB memory | n1-standard-1 | m5.large | D2s v3 |
+| Gitaly | 1 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
+| GitLab Rails | 2 | 8 vCPU, 7.2 GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
+| Monitoring node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
| Object storage | n/a | n/a | n/a | n/a | n/a |
-| NFS server (optional, not recommended) | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
+| NFS server (optional, not recommended) | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
The Google Cloud Platform (GCP) architectures were built and tested using the
[Intel Xeon E5 v3 (Haswell)](https://cloud.google.com/compute/docs/cpu-platforms)
@@ -43,7 +43,7 @@ doesn't require you to provision and maintain a node.
To set up GitLab and its components to accommodate up to 2,000 users:
1. [Configure the external load balancing node](#configure-the-external-load-balancer)
- to handle the load balancing of the two GitLab application services nodes.
+ to handle the load balancing of the GitLab application services nodes.
1. [Configure PostgreSQL](#configure-postgresql), the database for GitLab.
1. [Configure Redis](#configure-redis).
1. [Configure Gitaly](#configure-gitaly), which provides access to the Git
@@ -55,6 +55,8 @@ To set up GitLab and its components to accommodate up to 2,000 users:
environment.
1. [Configure the object storage](#configure-the-object-storage) used for
shared data objects.
+1. [Configure Advanced Search](#configure-advanced-search) (optional) for faster,
+ more advanced code search across your entire GitLab instance.
1. [Configure NFS](#configure-nfs-optional) (optional, and not recommended)
to have shared disk storage service as an alternative to Gitaly or object
storage. You can skip this step if you're not using GitLab Pages (which
@@ -664,6 +666,11 @@ On each node perform the following:
sudo gitlab-ctl tail gitaly
```
+1. Save the `/etc/gitlab/gitlab-secrets.json` file from one of the two
+ application nodes and install it on the other application node and the
+ [Gitaly node](#configure-gitaly) and
+ [reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure).
+
NOTE: **Note:**
When you specify `https` in the `external_url`, as in the example
above, GitLab assumes you have SSL certificates in `/etc/gitlab/ssl/`. If
@@ -671,6 +678,25 @@ certificates are not present, NGINX will fail to start. See the
[NGINX documentation](https://docs.gitlab.com/omnibus/settings/nginx.html#enable-https)
for more information.
+### GitLab Rails post-configuration
+
+1. Designate one application node for running database migrations during
+ installation and updates. Initialize the GitLab database and ensure all
+ migrations ran:
+
+ ```shell
+ sudo gitlab-rake gitlab:db:configure
+ ```
+
+ NOTE: **Note:**
+ If you encounter a `rake aborted!` error stating that PgBouncer is failing to connect to
+ PostgreSQL it may be that your PgBouncer node's IP address is missing from
+ PostgreSQL's `trust_auth_cidr_addresses` in `gitlab.rb` on your database nodes. See
+ [PgBouncer error `ERROR: pgbouncer cannot connect to server`](troubleshooting.md#pgbouncer-error-error-pgbouncer-cannot-connect-to-server)
+ in the Troubleshooting section before proceeding.
+
+1. [Configure fast lookup of authorized SSH keys in the database](../operations/fast_ssh_key_lookup.md).
+
<div align="right">
<a type="button" class="btn btn-default" href="#setup-components">
Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
@@ -851,6 +877,25 @@ functioning backups is encountered.
</a>
</div>
+## Configure Advanced Search **(STARTER ONLY)**
+
+NOTE: **Note:**
+Elasticsearch cluster design and requirements are dependent on your specific data.
+For recommended best practices on how to set up your Elasticsearch cluster
+alongside your instance, read how to
+[choose the optimal cluster configuration](../../integration/elasticsearch.md#guidance-on-choosing-optimal-cluster-configuration).
+
+You can leverage Elasticsearch and enable Advanced Search for faster, more
+advanced code search across your entire GitLab instance.
+
+[Learn how to set it up.](../../integration/elasticsearch.md)
+
+<div align="right">
+ <a type="button" class="btn btn-default" href="#setup-components">
+ Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
+ </a>
+</div>
+
## Configure NFS (optional)
For improved performance, [object storage](#configure-the-object-storage),
diff --git a/doc/administration/reference_architectures/3k_users.md b/doc/administration/reference_architectures/3k_users.md
index be944586e43..f69e19ffef8 100644
--- a/doc/administration/reference_architectures/3k_users.md
+++ b/doc/administration/reference_architectures/3k_users.md
@@ -24,18 +24,18 @@ costly-to-operate environment by using the
| Service | Nodes | Configuration | GCP | AWS | Azure |
|--------------------------------------------|-------------|-----------------------|----------------|-------------|---------|
-| External load balancing node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| Redis | 3 | 2 vCPU, 7.5GB memory | n1-standard-2 | m5.large | D2s v3 |
-| Consul + Sentinel | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| PostgreSQL | 3 | 2 vCPU, 7.5GB memory | n1-standard-2 | m5.large | D2s v3 |
-| PgBouncer | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| Internal load balancing node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| Gitaly | 2 (minimum) | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
-| Sidekiq | 4 | 2 vCPU, 7.5GB memory | n1-standard-2 | m5.large | D2s v3 |
-| GitLab Rails | 3 | 8 vCPU, 7.2GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
-| Monitoring node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| Object Storage | n/a | n/a | n/a | n/a | n/a |
-| NFS Server (optional, not recommended) | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
+| External load balancing node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| Redis | 3 | 2 vCPU, 7.5 GB memory | n1-standard-2 | m5.large | D2s v3 |
+| Consul + Sentinel | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| PostgreSQL | 3 | 2 vCPU, 7.5 GB memory | n1-standard-2 | m5.large | D2s v3 |
+| PgBouncer | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| Internal load balancing node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| Gitaly | 2 (minimum) | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
+| Sidekiq | 4 | 2 vCPU, 7.5 GB memory | n1-standard-2 | m5.large | D2s v3 |
+| GitLab Rails | 3 | 8 vCPU, 7.2 GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
+| Monitoring node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| Object storage | n/a | n/a | n/a | n/a | n/a |
+| NFS server (optional, not recommended) | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
The Google Cloud Platform (GCP) architectures were built and tested using the
[Intel Xeon E5 v3 (Haswell)](https://cloud.google.com/compute/docs/cpu-platforms)
@@ -44,41 +44,43 @@ or higher, are required for your CPU or node counts. For more information, see
our [Sysbench](https://github.com/akopytov/sysbench)-based
[CPU benchmark](https://gitlab.com/gitlab-org/quality/performance/-/wikis/Reference-Architectures/GCP-CPU-Benchmarks).
-For data objects (such as LFS, Uploads, or Artifacts), an
-[object storage service](#configure-the-object-storage) is recommended instead
-of NFS where possible, due to better performance and availability. Since this
-doesn't require a node to be set up, *Object Storage* is noted as not
-applicable (n/a) in the previous table.
+Due to better performance and availability, for data objects (such as LFS,
+uploads, or artifacts), using an [object storage service](#configure-the-object-storage)
+is recommended instead of using NFS. Using an object storage service also
+doesn't require you to provision and maintain a node.
## Setup components
To set up GitLab and its components to accommodate up to 3,000 users:
1. [Configure the external load balancing node](#configure-the-external-load-balancer)
- that will handle the load balancing of the two GitLab application services nodes.
+ to handle the load balancing of the GitLab application services nodes.
1. [Configure Redis](#configure-redis).
1. [Configure Consul and Sentinel](#configure-consul-and-sentinel).
1. [Configure PostgreSQL](#configure-postgresql), the database for GitLab.
1. [Configure PgBouncer](#configure-pgbouncer).
-1. [Configure the internal load balancing node](#configure-the-internal-load-balancer)
+1. [Configure the internal load balancing node](#configure-the-internal-load-balancer).
1. [Configure Gitaly](#configure-gitaly),
which provides access to the Git repositories.
1. [Configure Sidekiq](#configure-sidekiq).
1. [Configure the main GitLab Rails application](#configure-gitlab-rails)
- to run Puma/Unicorn, Workhorse, GitLab Shell, and to serve all frontend requests (UI, API, Git
- over HTTP/SSH).
-1. [Configure Prometheus](#configure-prometheus) to monitor your GitLab environment.
-1. [Configure the Object Storage](#configure-the-object-storage)
+ to run Puma/Unicorn, Workhorse, GitLab Shell, and to serve all frontend
+ requests (which include UI, API, and Git over HTTP/SSH).
+1. [Configure Prometheus](#configure-prometheus) to monitor your GitLab
+ environment.
+1. [Configure the object storage](#configure-the-object-storage)
used for shared data objects.
-1. [Configure NFS (Optional)](#configure-nfs-optional)
- to have shared disk storage service as an alternative to Gitaly and/or Object Storage (although
- not recommended). NFS is required for GitLab Pages, you can skip this step if you're not using
- that feature.
+1. [Configure Advanced Search](#configure-advanced-search) (optional) for faster,
+ more advanced code search across your entire GitLab instance.
+1. [Configure NFS](#configure-nfs-optional) (optional, and not recommended)
+ to have shared disk storage service as an alternative to Gitaly or object
+ storage. You can skip this step if you're not using GitLab Pages (which
+ requires NFS).
-We start with all servers on the same 10.6.0.0/16 private network range, they
-can connect to each other freely on those addresses.
+The servers start on the same 10.6.0.0/24 private network range, and can
+connect to each other freely on these addresses.
-Here is a list and description of each machine and the assigned IP:
+The following list includes descriptions of each server and its assigned IP:
- `10.6.0.10`: External Load Balancer
- `10.6.0.61`: Redis Primary
@@ -1582,6 +1584,11 @@ On each node perform the following:
sudo gitlab-ctl tail gitaly
```
+1. Save the `/etc/gitlab/gitlab-secrets.json` file from one of the two
+ application nodes and install it on the other application node and the
+ [Gitaly node](#configure-gitaly) and
+ [reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure).
+
1. Verify the GitLab services are running:
```shell
@@ -1759,6 +1766,25 @@ work.
</a>
</div>
+## Configure Advanced Search **(STARTER ONLY)**
+
+NOTE: **Note:**
+Elasticsearch cluster design and requirements are dependent on your specific data.
+For recommended best practices on how to set up your Elasticsearch cluster
+alongside your instance, read how to
+[choose the optimal cluster configuration](../../integration/elasticsearch.md#guidance-on-choosing-optimal-cluster-configuration).
+
+You can leverage Elasticsearch and enable Advanced Search for faster, more
+advanced code search across your entire GitLab instance.
+
+[Learn how to set it up.](../../integration/elasticsearch.md)
+
+<div align="right">
+ <a type="button" class="btn btn-default" href="#setup-components">
+ Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
+ </a>
+</div>
+
## Configure NFS (optional)
[Object storage](#configure-the-object-storage), along with [Gitaly](#configure-gitaly)
diff --git a/doc/administration/reference_architectures/50k_users.md b/doc/administration/reference_architectures/50k_users.md
index e812eed0227..7ea571108fb 100644
--- a/doc/administration/reference_architectures/50k_users.md
+++ b/doc/administration/reference_architectures/50k_users.md
@@ -17,21 +17,21 @@ full list of reference architectures, see
| Service | Nodes | Configuration | GCP | AWS | Azure |
|-----------------------------------------|-------------|-------------------------|-----------------|--------------|----------|
-| External load balancing node | 1 | 8 vCPU, 7.2GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
-| Consul | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| PostgreSQL | 3 | 16 vCPU, 60GB memory | n1-standard-16 | m5.4xlarge | D16s v3 |
-| PgBouncer | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| Internal load balancing node | 1 | 8 vCPU, 7.2GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
-| Redis - Cache | 3 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
-| Redis - Queues / Shared State | 3 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
-| Redis Sentinel - Cache | 3 | 1 vCPU, 1.7GB memory | g1-small | t2.small | B1MS |
-| Redis Sentinel - Queues / Shared State | 3 | 1 vCPU, 1.7GB memory | g1-small | t2.small | B1MS |
-| Gitaly | 2 (minimum) | 64 vCPU, 240GB memory | n1-standard-64 | m5.16xlarge | D64s v3 |
-| Sidekiq | 4 | 4 vCPU, 15GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
-| GitLab Rails | 12 | 32 vCPU, 28.8GB memory | n1-highcpu-32 | c5.9xlarge | F32s v2 |
-| Monitoring node | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
-| Object Storage | n/a | n/a | n/a | n/a | n/a |
-| NFS Server | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
+| External load balancing node | 1 | 8 vCPU, 7.2 GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
+| Consul | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| PostgreSQL | 3 | 16 vCPU, 60 GB memory | n1-standard-16 | m5.4xlarge | D16s v3 |
+| PgBouncer | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| Internal load balancing node | 1 | 8 vCPU, 7.2 GB memory | n1-highcpu-8 | c5.2xlarge | F8s v2 |
+| Redis - Cache | 3 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
+| Redis - Queues / Shared State | 3 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
+| Redis Sentinel - Cache | 3 | 1 vCPU, 1.7 GB memory | g1-small | t2.small | B1MS |
+| Redis Sentinel - Queues / Shared State | 3 | 1 vCPU, 1.7 GB memory | g1-small | t2.small | B1MS |
+| Gitaly | 2 (minimum) | 64 vCPU, 240 GB memory | n1-standard-64 | m5.16xlarge | D64s v3 |
+| Sidekiq | 4 | 4 vCPU, 15 GB memory | n1-standard-4 | m5.xlarge | D4s v3 |
+| GitLab Rails | 12 | 32 vCPU, 28.8 GB memory | n1-highcpu-32 | c5.9xlarge | F32s v2 |
+| Monitoring node | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
+| Object storage | n/a | n/a | n/a | n/a | n/a |
+| NFS server | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
The Google Cloud Platform (GCP) architectures were built and tested using the
[Intel Xeon E5 v3 (Haswell)](https://cloud.google.com/compute/docs/cpu-platforms)
@@ -40,41 +40,43 @@ or higher, are required for your CPU or node counts. For more information, see
our [Sysbench](https://github.com/akopytov/sysbench)-based
[CPU benchmark](https://gitlab.com/gitlab-org/quality/performance/-/wikis/Reference-Architectures/GCP-CPU-Benchmarks).
-For data objects (such as LFS, Uploads, or Artifacts), an
-[object storage service](#configure-the-object-storage) is recommended instead
-of NFS where possible, due to better performance and availability. Since this
-doesn't require a node to be set up, *Object Storage* is noted as not
-applicable (n/a) in the previous table.
+Due to better performance and availability, for data objects (such as LFS,
+uploads, or artifacts), using an [object storage service](#configure-the-object-storage)
+is recommended instead of using NFS. Using an object storage service also
+doesn't require you to provision and maintain a node.
## Setup components
To set up GitLab and its components to accommodate up to 50,000 users:
1. [Configure the external load balancing node](#configure-the-external-load-balancer)
- that will handle the load balancing of the three GitLab application services nodes.
+ to handle the load balancing of the GitLab application services nodes.
1. [Configure Consul](#configure-consul).
1. [Configure PostgreSQL](#configure-postgresql), the database for GitLab.
1. [Configure PgBouncer](#configure-pgbouncer).
-1. [Configure the internal load balancing node](#configure-the-internal-load-balancer)
+1. [Configure the internal load balancing node](#configure-the-internal-load-balancer).
1. [Configure Redis](#configure-redis).
1. [Configure Gitaly](#configure-gitaly),
which provides access to the Git repositories.
1. [Configure Sidekiq](#configure-sidekiq).
1. [Configure the main GitLab Rails application](#configure-gitlab-rails)
- to run Puma/Unicorn, Workhorse, GitLab Shell, and to serve all frontend requests (UI, API, Git
- over HTTP/SSH).
-1. [Configure Prometheus](#configure-prometheus) to monitor your GitLab environment.
-1. [Configure the Object Storage](#configure-the-object-storage)
+ to run Puma/Unicorn, Workhorse, GitLab Shell, and to serve all frontend
+ requests (which include UI, API, and Git over HTTP/SSH).
+1. [Configure Prometheus](#configure-prometheus) to monitor your GitLab
+ environment.
+1. [Configure the object storage](#configure-the-object-storage)
used for shared data objects.
-1. [Configure NFS (Optional)](#configure-nfs-optional)
- to have shared disk storage service as an alternative to Gitaly and/or Object Storage (although
- not recommended). NFS is required for GitLab Pages, you can skip this step if you're not using
- that feature.
+1. [Configure Advanced Search](#configure-advanced-search) (optional) for faster,
+ more advanced code search across your entire GitLab instance.
+1. [Configure NFS](#configure-nfs-optional) (optional, and not recommended)
+ to have shared disk storage service as an alternative to Gitaly or object
+ storage. You can skip this step if you're not using GitLab Pages (which
+ requires NFS).
-We start with all servers on the same 10.6.0.0/24 private network range, they
-can connect to each other freely on those addresses.
+The servers start on the same 10.6.0.0/24 private network range, and can
+connect to each other freely on these addresses.
-Here is a list and description of each machine and the assigned IP:
+The following list includes descriptions of each server and its assigned IP:
- `10.6.0.10`: External Load Balancer
- `10.6.0.11`: Consul 1
@@ -1752,6 +1754,7 @@ On each node perform the following:
roles ['application_role']
gitaly['enable'] = false
nginx['enable'] = true
+ sidekiq['enable'] = false
## PostgreSQL connection details
# Disable PostgreSQL on the application node
@@ -1795,7 +1798,6 @@ On each node perform the following:
# Set the network addresses that the exporters used for monitoring will listen on
node_exporter['listen_address'] = '0.0.0.0:9100'
gitlab_workhorse['prometheus_listen_addr'] = '0.0.0.0:9229'
- sidekiq['listen_address'] = "0.0.0.0"
puma['listen'] = '0.0.0.0'
# Add the monitoring node's IP address to the monitoring whitelist and allow it to
@@ -1836,7 +1838,7 @@ On each node perform the following:
1. Specify the necessary NFS mounts in `/etc/fstab`.
The exact contents of `/etc/fstab` will depend on how you chose
- to configure your NFS server. See the [NFS documentation](../high_availability/nfs.md)
+ to configure your NFS server. See the [NFS documentation](../nfs.md)
for examples and the various options.
1. Create the shared directories. These may be different depending on your NFS
@@ -1889,18 +1891,22 @@ for more information.
### GitLab Rails post-configuration
-Initialize the GitLab database, by running the following in one of the Rails nodes:
+1. Designate one application node for running database migrations during
+ installation and updates. Initialize the GitLab database and ensure all
+ migrations ran:
-```shell
-sudo gitlab-rake gitlab:db:configure
-```
+ ```shell
+ sudo gitlab-rake gitlab:db:configure
+ ```
-NOTE: **Note:**
-If you encounter a `rake aborted!` error stating that PgBouncer is failing to connect to
-PostgreSQL it may be that your PgBouncer node's IP address is missing from
-PostgreSQL's `trust_auth_cidr_addresses` in `gitlab.rb` on your database nodes. See
-[PgBouncer error `ERROR: pgbouncer cannot connect to server`](troubleshooting.md#pgbouncer-error-error-pgbouncer-cannot-connect-to-server)
-in the Troubleshooting section before proceeding.
+ NOTE: **Note:**
+ If you encounter a `rake aborted!` error stating that PgBouncer is failing to connect to
+ PostgreSQL it may be that your PgBouncer node's IP address is missing from
+ PostgreSQL's `trust_auth_cidr_addresses` in `gitlab.rb` on your database nodes. See
+ [PgBouncer error `ERROR: pgbouncer cannot connect to server`](troubleshooting.md#pgbouncer-error-error-pgbouncer-cannot-connect-to-server)
+ in the Troubleshooting section before proceeding.
+
+1. [Configure fast lookup of authorized SSH keys in the database](../operations/fast_ssh_key_lookup.md).
<div align="right">
<a type="button" class="btn btn-default" href="#setup-components">
@@ -2033,13 +2039,32 @@ work.
</a>
</div>
+## Configure Advanced Search **(STARTER ONLY)**
+
+NOTE: **Note:**
+Elasticsearch cluster design and requirements are dependent on your specific data.
+For recommended best practices on how to set up your Elasticsearch cluster
+alongside your instance, read how to
+[choose the optimal cluster configuration](../../integration/elasticsearch.md#guidance-on-choosing-optimal-cluster-configuration).
+
+You can leverage Elasticsearch and enable Advanced Search for faster, more
+advanced code search across your entire GitLab instance.
+
+[Learn how to set it up.](../../integration/elasticsearch.md)
+
+<div align="right">
+ <a type="button" class="btn btn-default" href="#setup-components">
+ Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
+ </a>
+</div>
+
## Configure NFS (optional)
[Object storage](#configure-the-object-storage), along with [Gitaly](#configure-gitaly)
are recommended over NFS wherever possible for improved performance. If you intend
to use GitLab Pages, this currently [requires NFS](troubleshooting.md#gitlab-pages-requires-nfs).
-See how to [configure NFS](../high_availability/nfs.md).
+See how to [configure NFS](../nfs.md).
<div align="right">
<a type="button" class="btn btn-default" href="#setup-components">
diff --git a/doc/administration/reference_architectures/5k_users.md b/doc/administration/reference_architectures/5k_users.md
index 6dfa588b092..b32560d7055 100644
--- a/doc/administration/reference_architectures/5k_users.md
+++ b/doc/administration/reference_architectures/5k_users.md
@@ -24,18 +24,18 @@ costly-to-operate environment by using the
| Service | Nodes | Configuration | GCP | AWS | Azure |
|--------------------------------------------|-------------|-------------------------|----------------|-------------|----------|
-| External load balancing node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| Redis | 3 | 2 vCPU, 7.5GB memory | n1-standard-2 | m5.large | D2s v3 |
-| Consul + Sentinel | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| PostgreSQL | 3 | 2 vCPU, 7.5GB memory | n1-standard-2 | m5.large | D2s v3 |
-| PgBouncer | 3 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| Internal load balancing node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| Gitaly | 2 (minimum) | 8 vCPU, 30GB memory | n1-standard-8 | m5.2xlarge | D8s v3 |
-| Sidekiq | 4 | 2 vCPU, 7.5GB memory | n1-standard-2 | m5.large | D2s v3 |
-| GitLab Rails | 3 | 16 vCPU, 14.4GB memory | n1-highcpu-16 | c5.4xlarge | F16s v2 |
-| Monitoring node | 1 | 2 vCPU, 1.8GB memory | n1-highcpu-2 | c5.large | F2s v2 |
-| Object Storage | n/a | n/a | n/a | n/a | n/a |
-| NFS Server (optional, not recommended) | 1 | 4 vCPU, 3.6GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
+| External load balancing node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| Redis | 3 | 2 vCPU, 7.5 GB memory | n1-standard-2 | m5.large | D2s v3 |
+| Consul + Sentinel | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| PostgreSQL | 3 | 2 vCPU, 7.5 GB memory | n1-standard-2 | m5.large | D2s v3 |
+| PgBouncer | 3 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| Internal load balancing node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| Gitaly | 2 (minimum) | 8 vCPU, 30 GB memory | n1-standard-8 | m5.2xlarge | D8s v3 |
+| Sidekiq | 4 | 2 vCPU, 7.5 GB memory | n1-standard-2 | m5.large | D2s v3 |
+| GitLab Rails | 3 | 16 vCPU, 14.4 GB memory | n1-highcpu-16 | c5.4xlarge | F16s v2 |
+| Monitoring node | 1 | 2 vCPU, 1.8 GB memory | n1-highcpu-2 | c5.large | F2s v2 |
+| Object storage | n/a | n/a | n/a | n/a | n/a |
+| NFS server (optional, not recommended) | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5.xlarge | F4s v2 |
The Google Cloud Platform (GCP) architectures were built and tested using the
[Intel Xeon E5 v3 (Haswell)](https://cloud.google.com/compute/docs/cpu-platforms)
@@ -44,41 +44,43 @@ or higher, are required for your CPU or node counts. For more information, see
our [Sysbench](https://github.com/akopytov/sysbench)-based
[CPU benchmark](https://gitlab.com/gitlab-org/quality/performance/-/wikis/Reference-Architectures/GCP-CPU-Benchmarks).
-For data objects (such as LFS, Uploads, or Artifacts), an
-[object storage service](#configure-the-object-storage) is recommended instead
-of NFS where possible, due to better performance and availability. Since this
-doesn't require a node to be set up, *Object Storage* is noted as not
-applicable (n/a) in the previous table.
+Due to better performance and availability, for data objects (such as LFS,
+uploads, or artifacts), using an [object storage service](#configure-the-object-storage)
+is recommended instead of using NFS. Using an object storage service also
+doesn't require you to provision and maintain a node.
## Setup components
To set up GitLab and its components to accommodate up to 5,000 users:
1. [Configure the external load balancing node](#configure-the-external-load-balancer)
- that will handle the load balancing of the two GitLab application services nodes.
+ to handle the load balancing of the GitLab application services nodes.
1. [Configure Redis](#configure-redis).
1. [Configure Consul and Sentinel](#configure-consul-and-sentinel).
1. [Configure PostgreSQL](#configure-postgresql), the database for GitLab.
1. [Configure PgBouncer](#configure-pgbouncer).
-1. [Configure the internal load balancing node](#configure-the-internal-load-balancer)
+1. [Configure the internal load balancing node](#configure-the-internal-load-balancer).
1. [Configure Gitaly](#configure-gitaly),
which provides access to the Git repositories.
1. [Configure Sidekiq](#configure-sidekiq).
1. [Configure the main GitLab Rails application](#configure-gitlab-rails)
- to run Puma/Unicorn, Workhorse, GitLab Shell, and to serve all frontend requests (UI, API, Git
- over HTTP/SSH).
-1. [Configure Prometheus](#configure-prometheus) to monitor your GitLab environment.
-1. [Configure the Object Storage](#configure-the-object-storage)
+ to run Puma/Unicorn, Workhorse, GitLab Shell, and to serve all frontend
+ requests (which include UI, API, and Git over HTTP/SSH).
+1. [Configure Prometheus](#configure-prometheus) to monitor your GitLab
+ environment.
+1. [Configure the object storage](#configure-the-object-storage)
used for shared data objects.
-1. [Configure NFS (Optional)](#configure-nfs-optional)
- to have shared disk storage service as an alternative to Gitaly and/or Object Storage (although
- not recommended). NFS is required for GitLab Pages, you can skip this step if you're not using
- that feature.
+1. [Configure Advanced Search](#configure-advanced-search) (optional) for faster,
+ more advanced code search across your entire GitLab instance.
+1. [Configure NFS](#configure-nfs-optional) (optional, and not recommended)
+ to have shared disk storage service as an alternative to Gitaly or object
+ storage. You can skip this step if you're not using GitLab Pages (which
+ requires NFS).
-We start with all servers on the same 10.6.0.0/16 private network range, they
-can connect to each other freely on those addresses.
+The servers start on the same 10.6.0.0/24 private network range, and can
+connect to each other freely on these addresses.
-Here is a list and description of each machine and the assigned IP:
+The following list includes descriptions of each server and its assigned IP:
- `10.6.0.10`: External Load Balancer
- `10.6.0.61`: Redis Primary
@@ -1581,6 +1583,11 @@ On each node perform the following:
sudo gitlab-ctl tail gitaly
```
+1. Save the `/etc/gitlab/gitlab-secrets.json` file from one of the two
+ application nodes and install it on the other application node and the
+ [Gitaly node](#configure-gitaly) and
+ [reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure).
+
1. Verify the GitLab services are running:
```shell
@@ -1758,6 +1765,25 @@ work.
</a>
</div>
+## Configure Advanced Search **(STARTER ONLY)**
+
+NOTE: **Note:**
+Elasticsearch cluster design and requirements are dependent on your specific data.
+For recommended best practices on how to set up your Elasticsearch cluster
+alongside your instance, read how to
+[choose the optimal cluster configuration](../../integration/elasticsearch.md#guidance-on-choosing-optimal-cluster-configuration).
+
+You can leverage Elasticsearch and enable Advanced Search for faster, more
+advanced code search across your entire GitLab instance.
+
+[Learn how to set it up.](../../integration/elasticsearch.md)
+
+<div align="right">
+ <a type="button" class="btn btn-default" href="#setup-components">
+ Back to setup components <i class="fa fa-angle-double-up" aria-hidden="true"></i>
+ </a>
+</div>
+
## Configure NFS (optional)
[Object storage](#configure-the-object-storage), along with [Gitaly](#configure-gitaly)
diff --git a/doc/administration/reference_architectures/index.md b/doc/administration/reference_architectures/index.md
index 3964b8daeb7..8816d0eecf4 100644
--- a/doc/administration/reference_architectures/index.md
+++ b/doc/administration/reference_architectures/index.md
@@ -105,7 +105,7 @@ is the least complex to setup. This provides a point-in-time recovery of a prede
> - Supported tiers: [GitLab Starter, Premium, and Ultimate](https://about.gitlab.com/pricing/)
This requires separating out GitLab into multiple application nodes with an added
-[load balancer](../high_availability/load_balancer.md). The load balancer will distribute traffic
+[load balancer](../load_balancer.md). The load balancer will distribute traffic
across GitLab application nodes. Meanwhile, each application node connects to a
shared file server and database systems on the back end. This way, if one of the
application servers fails, the workflow is not interrupted.
diff --git a/doc/administration/reply_by_email_postfix_setup.md b/doc/administration/reply_by_email_postfix_setup.md
index f950134889d..6d34772ad24 100644
--- a/doc/administration/reply_by_email_postfix_setup.md
+++ b/doc/administration/reply_by_email_postfix_setup.md
@@ -71,7 +71,7 @@ The instructions make the assumption that you will be using the email address `i
sudo postfix start
```
-1. Send the new `incoming` user a dummy email to test SMTP, by entering the following into the SMTP prompt:
+1. Send the new `incoming` user an email to test SMTP, by entering the following into the SMTP prompt:
```plaintext
ehlo localhost
@@ -251,7 +251,7 @@ Courier, which we will install later to add IMAP authentication, requires mailbo
If you get a `Connection refused` error instead, make sure your firewall is set up to allow inbound traffic on port 25.
- 1. Send the `incoming` user a dummy email to test SMTP, by entering the following into the SMTP prompt:
+ 1. Send the `incoming` user an email to test SMTP, by entering the following into the SMTP prompt:
```plaintext
ehlo gitlab.example.com
diff --git a/doc/administration/smime_signing_email.md b/doc/administration/smime_signing_email.md
index b1e7e349978..6ded7fdd7d0 100644
--- a/doc/administration/smime_signing_email.md
+++ b/doc/administration/smime_signing_email.md
@@ -75,8 +75,8 @@ extensions), which contain the following in a single encrypted file:
- Intermediate certificates (if any)
- Private key
-In order to export the required files in PEM encoding from the PKCS#12 file,
-the `openssl` command can be used:
+To export the required files in PEM encoding from the PKCS#12 file, the
+`openssl` command can be used:
```shell
#-- Extract private key in PEM encoding (no password, unencrypted)
diff --git a/doc/administration/snippets/index.md b/doc/administration/snippets/index.md
index 95de3b8c183..5e61d20c683 100644
--- a/doc/administration/snippets/index.md
+++ b/doc/administration/snippets/index.md
@@ -20,8 +20,8 @@ abuse of the feature. The default value is **52428800 Bytes** (50 MB).
The content size limit will be applied when a snippet is created or updated.
-In order not to break any existing snippets, the limit doesn't have any
-effect on them until a snippet is edited again and the content changes.
+This limit doesn't affect existing snippets until they're updated and their
+content changes.
### Snippets size limit configuration
diff --git a/doc/administration/troubleshooting/elasticsearch.md b/doc/administration/troubleshooting/elasticsearch.md
index e13261e3074..9b805226e2d 100644
--- a/doc/administration/troubleshooting/elasticsearch.md
+++ b/doc/administration/troubleshooting/elasticsearch.md
@@ -206,7 +206,7 @@ The best place to start is to determine if the issue is with creating an empty i
If it is, check on the Elasticsearch side to determine if the `gitlab-production` (the
name for the GitLab index) exists. If it exists, manually delete it on the Elasticsearch
side and attempt to recreate it from the
-[`recreate_index`](../../integration/elasticsearch.md#gitlab-elasticsearch-rake-tasks)
+[`recreate_index`](../../integration/elasticsearch.md#gitlab-advanced-search-rake-tasks)
Rake task.
If you still encounter issues, try creating an index manually on the Elasticsearch
@@ -225,8 +225,8 @@ during the indexing of projects. If errors do occur, they will either stem from
If the indexing process does not present errors, you will want to check the status of the indexed projects. You can do this via the following Rake tasks:
-- [`sudo gitlab-rake gitlab:elastic:index_projects_status`](../../integration/elasticsearch.md#gitlab-elasticsearch-rake-tasks) (shows the overall status)
-- [`sudo gitlab-rake gitlab:elastic:projects_not_indexed`](../../integration/elasticsearch.md#gitlab-elasticsearch-rake-tasks) (shows specific projects that are not indexed)
+- [`sudo gitlab-rake gitlab:elastic:index_projects_status`](../../integration/elasticsearch.md#gitlab-advanced-search-rake-tasks) (shows the overall status)
+- [`sudo gitlab-rake gitlab:elastic:projects_not_indexed`](../../integration/elasticsearch.md#gitlab-advanced-search-rake-tasks) (shows specific projects that are not indexed)
If:
diff --git a/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md b/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md
index 9a23a115765..b7bcde52a42 100644
--- a/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md
+++ b/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md
@@ -525,8 +525,8 @@ conflicting_permanent_redirects.destroy_all
### Close a merge request properly (if merged but still marked as open)
```ruby
-p = Project.find_by_full_path('')
-m = project.merge_requests.find_by(iid: )
+p = Project.find_by_full_path('<full/path/to/project>')
+m = p.merge_requests.find_by(iid: <iid>)
u = User.find_by_username('')
MergeRequests::PostMergeService.new(p, u).execute(m)
```
diff --git a/doc/administration/troubleshooting/kubernetes_cheat_sheet.md b/doc/administration/troubleshooting/kubernetes_cheat_sheet.md
index 01532032b49..cbcccd96d86 100644
--- a/doc/administration/troubleshooting/kubernetes_cheat_sheet.md
+++ b/doc/administration/troubleshooting/kubernetes_cheat_sheet.md
@@ -84,8 +84,7 @@ and they will assist you with any issues you are having.
## GitLab-specific Kubernetes information
-- Minimal config that can be used to test a Kubernetes Helm chart can be found
- [here](https://gitlab.com/gitlab-org/charts/gitlab/-/issues/620).
+- Minimal config that can be used to [test a Kubernetes Helm chart](https://gitlab.com/gitlab-org/charts/gitlab/-/issues/620).
- Tailing logs of a separate pod. An example for a Webservice pod:
diff --git a/doc/administration/troubleshooting/ssl.md b/doc/administration/troubleshooting/ssl.md
index e6c081e1eea..dc3c08b61ac 100644
--- a/doc/administration/troubleshooting/ssl.md
+++ b/doc/administration/troubleshooting/ssl.md
@@ -205,6 +205,6 @@ Some of these errors come from the Excon Ruby gem, and could be generated in cir
where GitLab is configured to initiate an HTTPS session to a remote server
that is serving just HTTP.
-One scenario is that you're using [object storage](../high_availability/object_storage.md)
+One scenario is that you're using [object storage](../object_storage.md)
which is not served under HTTPS. GitLab is misconfigured and attempts a TLS handshake,
but the object storage will respond with plain HTTP.
diff --git a/doc/administration/uploads.md b/doc/administration/uploads.md
index 71a41719003..d9679971fa5 100644
--- a/doc/administration/uploads.md
+++ b/doc/administration/uploads.md
@@ -68,9 +68,9 @@ For source installations the following settings are nested under `uploads:` and
|---------|-------------|---------|
| `enabled` | Enable/disable object storage | `false` |
| `remote_directory` | The bucket name where Uploads will be stored| |
-| `direct_upload` | Set to true to remove Puma from the Upload path. Workhorse handles the actual Artifact Upload to Object Storage while Puma does minimal processing to keep track of the upload. There is no need for local shared storage. The option may be removed if support for a single storage type for all files is introduced. Read more on [direct upload](../development/uploads.md#direct-upload). | `false` |
-| `background_upload` | Set to false to disable automatic upload. Option may be removed once upload is direct to S3 (if `direct_upload` is set to `true` it will override `background_upload`) | `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` |
+| `direct_upload` | Set to `true` to remove Puma from the Upload path. Workhorse handles the actual Artifact Upload to Object Storage while Puma does minimal processing to keep track of the upload. There is no need for local shared storage. The option may be removed if support for a single storage type for all files is introduced. Read more on [direct upload](../development/uploads.md#direct-upload). | `false` |
+| `background_upload` | Set to `false` to disable automatic upload. Option may be removed once upload is direct to S3 (if `direct_upload` is set to `true` it will override `background_upload`) | `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 | |
### Connection settings
diff --git a/doc/api/README.md b/doc/api/README.md
index 53df4114a71..c3605db16eb 100644
--- a/doc/api/README.md
+++ b/doc/api/README.md
@@ -158,9 +158,21 @@ for example, without needing to explicitly pass an access token.
With a few API endpoints you can use a [GitLab CI/CD job token](../user/project/new_ci_build_permissions_model.md#job-token)
to authenticate with the API:
+- Packages:
+ - [Composer Repository](../user/packages/composer_repository/index.md)
+ - [Conan Repository](../user/packages/conan_repository/index.md)
+ - [Container Registry](../user/packages/container_registry/index.md) (`$CI_REGISTRY_PASSWORD` is actually `$CI_JOB_TOKEN`, but this may change in the future)
+ - [Go Proxy](../user/packages/go_proxy/index.md)
+ - [Maven Repository](../user/packages/maven_repository/index.md#authenticating-with-a-ci-job-token)
+ - [NPM Repository](../user/packages/npm_registry/index.md#authenticating-with-a-ci-job-token)
+ - [Nuget Repository](../user/packages/nuget_repository/index.md)
+ - [PyPI Repository](../user/packages/pypi_repository/index.md#using-gitlab-ci-with-pypi-packages)
- [Get job artifacts](job_artifacts.md#get-job-artifacts)
-- [Pipeline triggers](pipeline_triggers.md)
+- [Pipeline triggers](pipeline_triggers.md) (via `token=` parameter)
- [Release creation](releases/index.md#create-a-release)
+- [Terraform plan](../user/infrastructure/index.md)
+
+The token is valid as long as the job is running.
### Impersonation tokens
@@ -297,21 +309,22 @@ The following table gives an overview of how the API functions generally behave.
The following table shows the possible return codes for API requests.
-| Return values | Description |
-| ------------- | ----------- |
-| `200 OK` | The `GET`, `PUT` or `DELETE` request was successful, the resource(s) itself is returned as JSON. |
-| `204 No Content` | The server has successfully fulfilled the request and that there is no additional content to send in the response payload body. |
-| `201 Created` | The `POST` request was successful and the resource is returned as JSON. |
-| `304 Not Modified` | Indicates that the resource has not been modified since the last request. |
-| `400 Bad Request` | A required attribute of the API request is missing, e.g., the title of an issue is not given. |
-| `401 Unauthorized` | The user is not authenticated, a valid [user token](#authentication) is necessary. |
-| `403 Forbidden` | The request is not allowed, e.g., the user is not allowed to delete a project. |
-| `404 Not Found` | A resource could not be accessed, e.g., an ID for a resource could not be found. |
+| Return values | Description |
+| ------------------------ | ----------- |
+| `200 OK` | The `GET`, `PUT` or `DELETE` request was successful, the resource(s) itself is returned as JSON. |
+| `204 No Content` | The server has successfully fulfilled the request and that there is no additional content to send in the response payload body. |
+| `201 Created` | The `POST` request was successful and the resource is returned as JSON. |
+| `304 Not Modified` | Indicates that the resource has not been modified since the last request. |
+| `400 Bad Request` | A required attribute of the API request is missing, e.g., the title of an issue is not given. |
+| `401 Unauthorized` | The user is not authenticated, a valid [user token](#authentication) is necessary. |
+| `403 Forbidden` | The request is not allowed, e.g., the user is not allowed to delete a project. |
+| `404 Not Found` | A resource could not be accessed, e.g., an ID for a resource could not be found. |
| `405 Method Not Allowed` | The request is not supported. |
-| `409 Conflict` | A conflicting resource already exists, e.g., creating a project with a name that already exists. |
-| `412` | Indicates the request was denied. May happen if the `If-Unmodified-Since` header is provided when trying to delete a resource, which was modified in between. |
-| `422 Unprocessable` | The entity could not be processed. |
-| `500 Server Error` | While handling the request something went wrong server-side. |
+| `409 Conflict` | A conflicting resource already exists, e.g., creating a project with a name that already exists. |
+| `412` | Indicates the request was denied. May happen if the `If-Unmodified-Since` header is provided when trying to delete a resource, which was modified in between. |
+| `422 Unprocessable` | The entity could not be processed. |
+| `429 Too Many Requests` | The user exceeded the [application rate limits](../administration/instance_limits.md#rate-limits). |
+| `500 Server Error` | While handling the request, something went wrong server-side. |
## Pagination
@@ -588,7 +601,7 @@ Such errors appear in two cases:
- A required attribute of the API request is missing, e.g., the title of an
issue is not given
-- An attribute did not pass the validation, e.g., user bio is too long
+- An attribute did not pass the validation, e.g., the user bio is too long
When an attribute is missing, you will get something like:
diff --git a/doc/api/api_resources.md b/doc/api/api_resources.md
index 898aa713331..3d448b2a65f 100644
--- a/doc/api/api_resources.md
+++ b/doc/api/api_resources.md
@@ -9,7 +9,7 @@ Available resources for the [GitLab API](README.md) can be grouped in the follow
See also:
- [V3 to V4](v3_to_v4.md).
-- Adding [deploy keys for multiple projects](deploy_key_multiple_projects.md).
+- Adding [deploy keys for multiple projects](deploy_keys.md#adding-deploy-keys-to-multiple-projects).
- [API Resources for various templates](#templates-api-resources).
## Project resources
diff --git a/doc/api/container_registry.md b/doc/api/container_registry.md
index e4687994017..2fe72f53d87 100644
--- a/doc/api/container_registry.md
+++ b/doc/api/container_registry.md
@@ -24,7 +24,6 @@ GET /projects/:id/registry/repositories
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) accessible by the authenticated user. |
| `tags` | boolean | no | If the parameter is included as true, each repository will include an array of `"tags"` in the response. |
-| `name` | string | no | Returns a list of repositories with a name that matches the value. ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29763) in GitLab 13.0). |
| `tags_count` | boolean | no | If the parameter is included as true, each repository will include `"tags_count"` in the response ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/32141) in GitLab 13.1). |
```shell
@@ -41,7 +40,8 @@ Example response:
"path": "group/project",
"project_id": 9,
"location": "gitlab.example.com:5000/group/project",
- "created_at": "2019-01-10T13:38:57.391Z"
+ "created_at": "2019-01-10T13:38:57.391Z",
+ "cleanup_policy_started_at": "2020-01-10T15:40:57.391Z"
},
{
"id": 2,
@@ -49,7 +49,8 @@ Example response:
"path": "group/project/releases",
"project_id": 9,
"location": "gitlab.example.com:5000/group/project/releases",
- "created_at": "2019-01-10T13:39:08.229Z"
+ "created_at": "2019-01-10T13:39:08.229Z",
+ "cleanup_policy_started_at": "2020-08-17T03:12:35.489Z"
}
]
```
@@ -66,7 +67,6 @@ GET /groups/:id/registry/repositories
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) accessible by the authenticated user. |
| `tags` | boolean | no | If the parameter is included as true, each repository will include an array of `"tags"` in the response. |
-| `name` | string | no | Returns a list of repositories with a name that matches the value. ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29763) in GitLab 13.0). |
| `tags_count` | boolean | no | If the parameter is included as true, each repository will include `"tags_count"` in the response ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/32141) in GitLab 13.1). |
```shell
@@ -84,6 +84,7 @@ Example response:
"project_id": 9,
"location": "gitlab.example.com:5000/group/project",
"created_at": "2019-01-10T13:38:57.391Z",
+ "cleanup_policy_started_at": "2020-08-17T03:12:35.489Z",
"tags_count": 1,
"tags": [
{
@@ -100,6 +101,7 @@ Example response:
"project_id": 11,
"location": "gitlab.example.com:5000/group/other_project",
"created_at": "2019-01-10T13:39:08.229Z",
+ "cleanup_policy_started_at": "2020-01-10T15:40:57.391Z",
"tags_count": 3,
"tags": [
{
@@ -228,7 +230,7 @@ DELETE /projects/:id/registry/repositories/:repository_id/tags/:tag_name
curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/registry/repositories/2/tags/v10.0.0"
```
-This action does not delete blobs. In order to delete them and recycle disk space,
+This action doesn't delete blobs. To delete them and recycle disk space,
[run the garbage collection](https://docs.gitlab.com/omnibus/maintenance/README.html#removing-unused-layers-not-referenced-by-manifests).
## Delete registry repository tags in bulk
@@ -254,18 +256,19 @@ DELETE /projects/:id/registry/repositories/:repository_id/tags
This API call performs the following operations:
-1. It orders all tags by creation date. The creation date is the time of the
- manifest creation, not the time of tag push.
-1. It removes only the tags matching the given `name_regex_delete` (or deprecated `name_regex`), keeping any that match `name_regex_keep`.
-1. It never removes the tag named `latest`.
-1. It keeps N latest matching tags (if `keep_n` is specified).
-1. It only removes tags that are older than X amount of time (if `older_than` is specified).
-1. It schedules the asynchronous job to be executed in the background.
-
-These operations are executed asynchronously and it might
-take time to get executed. You can run this at most
-once an hour for a given container repository.
-This action does not delete blobs. In order to delete them and recycle disk space,
+- It orders all tags by creation date. The creation date is the time of the
+ manifest creation, not the time of tag push.
+- It removes only the tags matching the given `name_regex_delete` (or deprecated
+ `name_regex`), keeping any that match `name_regex_keep`.
+- It never removes the tag named `latest`.
+- It keeps N latest matching tags (if `keep_n` is specified).
+- It only removes tags that are older than X amount of time (if `older_than` is
+ specified).
+- It schedules the asynchronous job to be executed in the background.
+
+These operations are executed asynchronously and can take time to get executed.
+You can run this at most once an hour for a given container repository. This
+action doesn't delete blobs. To delete them and recycle disk space,
[run the garbage collection](https://docs.gitlab.com/omnibus/maintenance/README.html#removing-unused-layers-not-referenced-by-manifests).
NOTE: **Note:**
diff --git a/doc/api/epics.md b/doc/api/epics.md
index 91ea92c8589..5c7366c8457 100644
--- a/doc/api/epics.md
+++ b/doc/api/epics.md
@@ -267,6 +267,7 @@ POST /groups/:id/epics
| `labels` | string | no | The comma separated list of labels |
| `description` | string | no | The description of the epic. Limited to 1,048,576 characters. |
| `confidential` | boolean | no | Whether the epic should be confidential |
+| `created_at` | string | no | When the epic was created. Date time string, ISO 8601 formatted, for example `2016-03-11T03:45:40Z` . Requires administrator or project/group owner privileges ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/255309) in GitLab 13.5) |
| `start_date_is_fixed` | boolean | no | Whether start date should be sourced from `start_date_fixed` or from milestones (since 11.3) |
| `start_date_fixed` | string | no | The fixed start date of an epic (since 11.3) |
| `due_date_is_fixed` | boolean | no | Whether due date should be sourced from `due_date_fixed` or from milestones (since 11.3) |
@@ -349,6 +350,7 @@ PUT /groups/:id/epics/:epic_iid
| `description` | string | no | The description of an epic. Limited to 1,048,576 characters. |
| `confidential` | boolean | no | Whether the epic should be confidential |
| `labels` | string | no | The comma separated list of labels |
+| `updated_at` | string | no | When the epic was updated. Date time string, ISO 8601 formatted, for example `2016-03-11T03:45:40Z` . Requires administrator or project/group owner privileges ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/255309) in GitLab 13.5) |
| `start_date_is_fixed` | boolean | no | Whether start date should be sourced from `start_date_fixed` or from milestones (since 11.3) |
| `start_date_fixed` | string | no | The fixed start date of an epic (since 11.3) |
| `due_date_is_fixed` | boolean | no | Whether due date should be sourced from `due_date_fixed` or from milestones (since 11.3) |
@@ -422,10 +424,10 @@ DELETE /groups/:id/epics/:epic_iid
curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/1/epics/5"
```
-## Create a to-do
+## Create a to do
-Manually creates a to-do for the current user on an epic. If
-there already exists a to-do for the user on that epic, status code `304` is
+Manually creates a to do for the current user on an epic. If
+there already exists a to do for the user on that epic, status code `304` is
returned.
```plaintext
diff --git a/doc/api/feature_flags.md b/doc/api/feature_flags.md
index 1088154b599..6ea01a34a25 100644
--- a/doc/api/feature_flags.md
+++ b/doc/api/feature_flags.md
@@ -151,7 +151,7 @@ POST /projects/:id/feature_flags
| `description` | string | no | The description of the feature flag. |
| `active` | boolean | no | The active state of the flag. Defaults to true. [Supported](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38350) in GitLab 13.3 and later. |
| `strategies` | JSON | no | The feature flag [strategies](../operations/feature_flags.md#feature-flag-strategies). |
-| `strategies:name` | JSON | no | The strategy name. |
+| `strategies:name` | JSON | no | The strategy name. Can be `default`, `gradualRolloutUserId`, `userWithId`, or `gitlabUserList`. In [GitLab 13.5](https://gitlab.com/gitlab-org/gitlab/-/issues/36380) and later, can be [`flexibleRollout`](https://unleash.github.io/docs/activation_strategy#flexiblerollout). |
| `strategies:parameters` | JSON | no | The strategy parameters. |
| `strategies:scopes` | JSON | no | The scopes for the strategy. |
| `strategies:scopes:environment_scope` | string | no | The environment spec for the scope. |
diff --git a/doc/api/geo_nodes.md b/doc/api/geo_nodes.md
index 8d2052f7373..064bd26ee72 100644
--- a/doc/api/geo_nodes.md
+++ b/doc/api/geo_nodes.md
@@ -1,7 +1,7 @@
# Geo Nodes API **(PREMIUM ONLY)**
-In order to interact with Geo node endpoints, you need to authenticate yourself
-as an admin.
+To interact with Geo node endpoints, you need to authenticate yourself as an
+admin.
## Create a new Geo node
@@ -460,12 +460,12 @@ Example response:
"package_files_registry_count": 10,
"package_files_synced_count": 6,
"package_files_failed_count": 3,
- "terraform_states_count": 10,
- "terraform_states_checksummed_count": 10,
- "terraform_states_checksum_failed_count": 0,
- "terraform_states_registry_count": 10,
- "terraform_states_synced_count": 6,
- "terraform_states_failed_count": 3
+ "terraform_state_versions_count": 10,
+ "terraform_state_versions_checksummed_count": 10,
+ "terraform_state_versions_checksum_failed_count": 0,
+ "terraform_state_versions_registry_count": 10,
+ "terraform_state_versions_synced_count": 6,
+ "terraform_state_versions_failed_count": 3,
"snippet_repositories_count": 10,
"snippet_repositories_checksummed_count": 10,
"snippet_repositories_checksum_failed_count": 0,
diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql
index 01d5044057a..84ac8d30660 100644
--- a/doc/api/graphql/reference/gitlab_schema.graphql
+++ b/doc/api/graphql/reference/gitlab_schema.graphql
@@ -435,6 +435,16 @@ Values for sorting alerts
"""
enum AlertManagementAlertSort {
"""
+ Created at ascending order
+ """
+ CREATED_ASC
+
+ """
+ Created at descending order
+ """
+ CREATED_DESC
+
+ """
Created time by ascending order
"""
CREATED_TIME_ASC
@@ -495,6 +505,16 @@ enum AlertManagementAlertSort {
STATUS_DESC
"""
+ Updated at ascending order
+ """
+ UPDATED_ASC
+
+ """
+ Updated at descending order
+ """
+ UPDATED_DESC
+
+ """
Created time by ascending order
"""
UPDATED_TIME_ASC
@@ -507,22 +527,22 @@ enum AlertManagementAlertSort {
"""
Created at ascending order
"""
- created_asc
+ created_asc @deprecated(reason: "Use CREATED_ASC. Deprecated in 13.5")
"""
Created at descending order
"""
- created_desc
+ created_desc @deprecated(reason: "Use CREATED_DESC. Deprecated in 13.5")
"""
Updated at ascending order
"""
- updated_asc
+ updated_asc @deprecated(reason: "Use UPDATED_ASC. Deprecated in 13.5")
"""
Updated at descending order
"""
- updated_desc
+ updated_desc @deprecated(reason: "Use UPDATED_DESC. Deprecated in 13.5")
}
"""
@@ -1035,7 +1055,7 @@ type Board {
Returns the last _n_ elements from the list.
"""
last: Int
- ): EpicConnection
+ ): BoardEpicConnection
"""
Whether or not backlog list is hidden.
@@ -1134,6 +1154,478 @@ type BoardEdge {
}
"""
+Represents an epic on an issue board
+"""
+type BoardEpic implements CurrentUserTodos & Noteable {
+ """
+ Author of the epic
+ """
+ author: User!
+
+ """
+ Children (sub-epics) of the epic
+ """
+ children(
+ """
+ Returns the elements in the list that come after the specified cursor.
+ """
+ after: String
+
+ """
+ Filter epics by author
+ """
+ authorUsername: String
+
+ """
+ Returns the elements in the list that come before the specified cursor.
+ """
+ before: String
+
+ """
+ List items within a time frame where items.end_date is between startDate and
+ endDate parameters (startDate parameter must be present)
+ """
+ endDate: Time
+
+ """
+ Returns the first _n_ elements from the list.
+ """
+ first: Int
+
+ """
+ IID of the epic, e.g., "1"
+ """
+ iid: ID
+
+ """
+ Filter epics by iid for autocomplete
+ """
+ iidStartsWith: String
+
+ """
+ List of IIDs of epics, e.g., [1, 2]
+ """
+ iids: [ID!]
+
+ """
+ Filter epics by labels
+ """
+ labelName: [String!]
+
+ """
+ Returns the last _n_ elements from the list.
+ """
+ last: Int
+
+ """
+ Filter epics by milestone title, computed from epic's issues
+ """
+ milestoneTitle: String
+
+ """
+ Search query for epic title or description
+ """
+ search: String
+
+ """
+ List epics by sort order
+ """
+ sort: EpicSort
+
+ """
+ List items within a time frame where items.start_date is between startDate
+ and endDate parameters (endDate parameter must be present)
+ """
+ startDate: Time
+
+ """
+ Filter epics by state
+ """
+ state: EpicState
+ ): EpicConnection
+
+ """
+ Timestamp of when the epic was closed
+ """
+ closedAt: Time
+
+ """
+ Indicates if the epic is confidential
+ """
+ confidential: Boolean
+
+ """
+ Timestamp of when the epic was created
+ """
+ createdAt: Time
+
+ """
+ Todos for the current user
+ """
+ currentUserTodos(
+ """
+ Returns the elements in the list that come after the specified cursor.
+ """
+ after: String
+
+ """
+ Returns the elements in the list that come before the specified cursor.
+ """
+ before: String
+
+ """
+ Returns the first _n_ elements from the list.
+ """
+ first: Int
+
+ """
+ Returns the last _n_ elements from the list.
+ """
+ last: Int
+
+ """
+ State of the todos
+ """
+ state: TodoStateEnum
+ ): TodoConnection!
+
+ """
+ Number of open and closed descendant epics and issues
+ """
+ descendantCounts: EpicDescendantCount
+
+ """
+ Total weight of open and closed issues in the epic and its descendants
+ """
+ descendantWeightSum: EpicDescendantWeights
+
+ """
+ Description of the epic
+ """
+ description: String
+
+ """
+ All discussions on this noteable
+ """
+ discussions(
+ """
+ Returns the elements in the list that come after the specified cursor.
+ """
+ after: String
+
+ """
+ Returns the elements in the list that come before the specified cursor.
+ """
+ before: String
+
+ """
+ Returns the first _n_ elements from the list.
+ """
+ first: Int
+
+ """
+ Returns the last _n_ elements from the list.
+ """
+ last: Int
+ ): DiscussionConnection!
+
+ """
+ Number of downvotes the epic has received
+ """
+ downvotes: Int!
+
+ """
+ Due date of the epic
+ """
+ dueDate: Time
+
+ """
+ Fixed due date of the epic
+ """
+ dueDateFixed: Time
+
+ """
+ Inherited due date of the epic from milestones
+ """
+ dueDateFromMilestones: Time
+
+ """
+ Indicates if the due date has been manually set
+ """
+ dueDateIsFixed: Boolean
+
+ """
+ Group to which the epic belongs
+ """
+ group: Group!
+
+ """
+ Indicates if the epic has children
+ """
+ hasChildren: Boolean!
+
+ """
+ Indicates if the epic has direct issues
+ """
+ hasIssues: Boolean!
+
+ """
+ Indicates if the epic has a parent epic
+ """
+ hasParent: Boolean!
+
+ """
+ Current health status of the epic
+ """
+ healthStatus: EpicHealthStatus
+
+ """
+ ID of the epic
+ """
+ id: ID!
+
+ """
+ Internal ID of the epic
+ """
+ iid: ID!
+
+ """
+ A list of issues associated with the epic
+ """
+ issues(
+ """
+ Returns the elements in the list that come after the specified cursor.
+ """
+ after: String
+
+ """
+ Returns the elements in the list that come before the specified cursor.
+ """
+ before: String
+
+ """
+ Returns the first _n_ elements from the list.
+ """
+ first: Int
+
+ """
+ Returns the last _n_ elements from the list.
+ """
+ last: Int
+ ): EpicIssueConnection
+
+ """
+ Labels assigned to the epic
+ """
+ labels(
+ """
+ Returns the elements in the list that come after the specified cursor.
+ """
+ after: String
+
+ """
+ Returns the elements in the list that come before the specified cursor.
+ """
+ before: String
+
+ """
+ Returns the first _n_ elements from the list.
+ """
+ first: Int
+
+ """
+ Returns the last _n_ elements from the list.
+ """
+ last: Int
+ ): LabelConnection
+
+ """
+ All notes on this noteable
+ """
+ notes(
+ """
+ Returns the elements in the list that come after the specified cursor.
+ """
+ after: String
+
+ """
+ Returns the elements in the list that come before the specified cursor.
+ """
+ before: String
+
+ """
+ Returns the first _n_ elements from the list.
+ """
+ first: Int
+
+ """
+ Returns the last _n_ elements from the list.
+ """
+ last: Int
+ ): NoteConnection!
+
+ """
+ Parent epic of the epic
+ """
+ parent: Epic
+
+ """
+ List of participants for the epic
+ """
+ participants(
+ """
+ Returns the elements in the list that come after the specified cursor.
+ """
+ after: String
+
+ """
+ Returns the elements in the list that come before the specified cursor.
+ """
+ before: String
+
+ """
+ Returns the first _n_ elements from the list.
+ """
+ first: Int
+
+ """
+ Returns the last _n_ elements from the list.
+ """
+ last: Int
+ ): UserConnection
+
+ """
+ Internal reference of the epic. Returned in shortened format by default
+ """
+ reference(
+ """
+ Indicates if the reference should be returned in full
+ """
+ full: Boolean = false
+ ): String!
+
+ """
+ URI path of the epic-issue relationship
+ """
+ relationPath: String
+
+ """
+ The relative position of the epic in the epic tree
+ """
+ relativePosition: Int
+
+ """
+ Start date of the epic
+ """
+ startDate: Time
+
+ """
+ Fixed start date of the epic
+ """
+ startDateFixed: Time
+
+ """
+ Inherited start date of the epic from milestones
+ """
+ startDateFromMilestones: Time
+
+ """
+ Indicates if the start date has been manually set
+ """
+ startDateIsFixed: Boolean
+
+ """
+ State of the epic
+ """
+ state: EpicState!
+
+ """
+ Indicates the currently logged in user is subscribed to the epic
+ """
+ subscribed: Boolean!
+
+ """
+ Title of the epic
+ """
+ title: String
+
+ """
+ Timestamp of when the epic was updated
+ """
+ updatedAt: Time
+
+ """
+ Number of upvotes the epic has received
+ """
+ upvotes: Int!
+
+ """
+ Permissions for the current user on the resource
+ """
+ userPermissions: EpicPermissions!
+
+ """
+ User preferences for the epic on the issue board
+ """
+ userPreferences: BoardEpicUserPreferences
+
+ """
+ Web path of the epic
+ """
+ webPath: String!
+
+ """
+ Web URL of the epic
+ """
+ webUrl: String!
+}
+
+"""
+The connection type for BoardEpic.
+"""
+type BoardEpicConnection {
+ """
+ A list of edges.
+ """
+ edges: [BoardEpicEdge]
+
+ """
+ A list of nodes.
+ """
+ nodes: [BoardEpic]
+
+ """
+ Information to aid in pagination.
+ """
+ pageInfo: PageInfo!
+}
+
+"""
+An edge in a connection.
+"""
+type BoardEpicEdge {
+ """
+ A cursor for use in pagination.
+ """
+ cursor: String!
+
+ """
+ The item at the end of the edge.
+ """
+ node: BoardEpic
+}
+
+"""
+Represents user preferences for a board epic
+"""
+type BoardEpicUserPreferences {
+ """
+ Indicates epic should be displayed as collapsed
+ """
+ collapsed: Boolean!
+}
+
+"""
Identifier of Board
"""
scalar BoardID
@@ -2914,14 +3406,19 @@ input CreateRequirementInput {
clientMutationId: String
"""
- The project full path the requirement is associated with
+ Description of the requirement
+ """
+ description: String
+
+ """
+ Full project path the requirement is associated with
"""
projectPath: ID!
"""
Title of the requirement
"""
- title: String!
+ title: String
}
"""
@@ -2939,7 +3436,7 @@ type CreateRequirementPayload {
errors: [String!]!
"""
- The requirement after mutation
+ Requirement after mutation
"""
requirement: Requirement
}
@@ -3920,6 +4417,11 @@ A collection of designs
"""
type DesignCollection {
"""
+ Copy state of the design collection
+ """
+ copyState: DesignCollectionCopyState
+
+ """
Find a specific design
"""
design(
@@ -4047,6 +4549,26 @@ type DesignCollection {
}
"""
+Copy state of a DesignCollection
+"""
+enum DesignCollectionCopyState {
+ """
+ The DesignCollection encountered an error during a copy
+ """
+ ERROR
+
+ """
+ The DesignCollection is being copied
+ """
+ IN_PROGRESS
+
+ """
+ The DesignCollection has no copy in progress
+ """
+ READY
+}
+
+"""
The connection type for Design.
"""
type DesignConnection {
@@ -4471,6 +4993,41 @@ input DestroyBoardInput {
}
"""
+Autogenerated input type of DestroyBoardList
+"""
+input DestroyBoardListInput {
+ """
+ A unique identifier for the client performing the mutation.
+ """
+ clientMutationId: String
+
+ """
+ Global ID of the list to destroy. Only label lists are accepted.
+ """
+ listId: ListID!
+}
+
+"""
+Autogenerated return type of DestroyBoardList
+"""
+type DestroyBoardListPayload {
+ """
+ A unique identifier for the client performing the mutation.
+ """
+ clientMutationId: String
+
+ """
+ Errors encountered during execution of the mutation.
+ """
+ errors: [String!]!
+
+ """
+ The list after mutation.
+ """
+ list: BoardList
+}
+
+"""
Autogenerated return type of DestroyBoard
"""
type DestroyBoardPayload {
@@ -5197,7 +5754,7 @@ type Epic implements CurrentUserTodos & Noteable {
): EpicConnection
"""
- Timestamp of the epic's closure
+ Timestamp of when the epic was closed
"""
closedAt: Time
@@ -5207,7 +5764,7 @@ type Epic implements CurrentUserTodos & Noteable {
confidential: Boolean
"""
- Timestamp of the epic's creation
+ Timestamp of when the epic was created
"""
createdAt: Time
@@ -5502,7 +6059,7 @@ type Epic implements CurrentUserTodos & Noteable {
title: String
"""
- Timestamp of the epic's last activity
+ Timestamp of when the epic was updated
"""
updatedAt: Time
@@ -6328,6 +6885,37 @@ type GeoNode {
internalUrl: String
"""
+ Find merge request diff registries on this Geo node. Available only when
+ feature flag `geo_merge_request_diff_replication` is enabled
+ """
+ mergeRequestDiffRegistries(
+ """
+ Returns the elements in the list that come after the specified cursor.
+ """
+ after: String
+
+ """
+ Returns the elements in the list that come before the specified cursor.
+ """
+ before: String
+
+ """
+ Returns the first _n_ elements from the list.
+ """
+ first: Int
+
+ """
+ Filters registries by their ID
+ """
+ ids: [ID!]
+
+ """
+ Returns the last _n_ elements from the list.
+ """
+ last: Int
+ ): MergeRequestDiffRegistryConnection
+
+ """
The interval (in days) in which the repository verification is valid. Once expired, it will be reverified
"""
minimumReverificationInterval: Int
@@ -6338,7 +6926,7 @@ type GeoNode {
name: String
"""
- Package file registries of the GeoNode. Available only when feature flag `geo_package_file_replication` is enabled
+ Package file registries of the GeoNode
"""
packageFileRegistries(
"""
@@ -6418,10 +7006,9 @@ type GeoNode {
syncObjectStorage: Boolean
"""
- Find terraform state registries on this Geo node. Available only when feature
- flag `geo_terraform_state_replication` is enabled
+ Find terraform state version registries on this Geo node
"""
- terraformStateRegistries(
+ terraformStateVersionRegistries(
"""
Returns the elements in the list that come after the specified cursor.
"""
@@ -6446,7 +7033,7 @@ type GeoNode {
Returns the last _n_ elements from the list.
"""
last: Int
- ): TerraformStateRegistryConnection
+ ): TerraformStateVersionRegistryConnection
"""
The user-facing URL for this Geo node
@@ -6507,9 +7094,9 @@ type Group {
"""
board(
"""
- Find a board by its ID
+ The board's ID
"""
- id: ID
+ id: ID!
): Board
"""
@@ -6781,6 +7368,16 @@ type Group {
assigneeUsername: String
"""
+ Usernames of users assigned to the issue
+ """
+ assigneeUsernames: [String!]
+
+ """
+ Username of the author of the issue
+ """
+ authorUsername: String
+
+ """
Returns the elements in the list that come before the specified cursor.
"""
before: String
@@ -8617,6 +9214,16 @@ Values for sorting issues
"""
enum IssueSort {
"""
+ Created at ascending order
+ """
+ CREATED_ASC
+
+ """
+ Created at descending order
+ """
+ CREATED_DESC
+
+ """
Due date by ascending order
"""
DUE_DATE_ASC
@@ -8657,11 +9264,41 @@ enum IssueSort {
PRIORITY_DESC
"""
+ Published issues shown last
+ """
+ PUBLISHED_ASC
+
+ """
+ Published issues shown first
+ """
+ PUBLISHED_DESC
+
+ """
Relative position by ascending order
"""
RELATIVE_POSITION_ASC
"""
+ Severity from less critical to more critical
+ """
+ SEVERITY_ASC
+
+ """
+ Severity from more critical to less critical
+ """
+ SEVERITY_DESC
+
+ """
+ Updated at ascending order
+ """
+ UPDATED_ASC
+
+ """
+ Updated at descending order
+ """
+ UPDATED_DESC
+
+ """
Weight by ascending order
"""
WEIGHT_ASC
@@ -8674,22 +9311,22 @@ enum IssueSort {
"""
Created at ascending order
"""
- created_asc
+ created_asc @deprecated(reason: "Use CREATED_ASC. Deprecated in 13.5")
"""
Created at descending order
"""
- created_desc
+ created_desc @deprecated(reason: "Use CREATED_DESC. Deprecated in 13.5")
"""
Updated at ascending order
"""
- updated_asc
+ updated_asc @deprecated(reason: "Use UPDATED_ASC. Deprecated in 13.5")
"""
Updated at descending order
"""
- updated_desc
+ updated_desc @deprecated(reason: "Use UPDATED_DESC. Deprecated in 13.5")
}
"""
@@ -9251,6 +9888,11 @@ Identifier of Label
scalar LabelID
"""
+Identifier of List
+"""
+scalar ListID
+
+"""
List limit metric setting
"""
enum ListLimitMetric {
@@ -9319,6 +9961,26 @@ enum MeasurementIdentifier {
PIPELINES
"""
+ Pipeline count with canceled status
+ """
+ PIPELINES_CANCELED
+
+ """
+ Pipeline count with failed status
+ """
+ PIPELINES_FAILED
+
+ """
+ Pipeline count with skipped status
+ """
+ PIPELINES_SKIPPED
+
+ """
+ Pipeline count with success status
+ """
+ PIPELINES_SUCCEEDED
+
+ """
Project count
"""
PROJECTS
@@ -10019,6 +10681,86 @@ type MergeRequestCreatePayload {
}
"""
+Represents the Geo sync and verification state of a Merge Request diff
+"""
+type MergeRequestDiffRegistry {
+ """
+ Timestamp when the MergeRequestDiffRegistry was created
+ """
+ createdAt: Time
+
+ """
+ ID of the MergeRequestDiffRegistry
+ """
+ id: ID!
+
+ """
+ Error message during sync of the MergeRequestDiffRegistry
+ """
+ lastSyncFailure: String
+
+ """
+ Timestamp of the most recent successful sync of the MergeRequestDiffRegistry
+ """
+ lastSyncedAt: Time
+
+ """
+ ID of the Merge Request diff
+ """
+ mergeRequestDiffId: ID!
+
+ """
+ Timestamp after which the MergeRequestDiffRegistry should be resynced
+ """
+ retryAt: Time
+
+ """
+ Number of consecutive failed sync attempts of the MergeRequestDiffRegistry
+ """
+ retryCount: Int
+
+ """
+ Sync state of the MergeRequestDiffRegistry
+ """
+ state: RegistryState
+}
+
+"""
+The connection type for MergeRequestDiffRegistry.
+"""
+type MergeRequestDiffRegistryConnection {
+ """
+ A list of edges.
+ """
+ edges: [MergeRequestDiffRegistryEdge]
+
+ """
+ A list of nodes.
+ """
+ nodes: [MergeRequestDiffRegistry]
+
+ """
+ Information to aid in pagination.
+ """
+ pageInfo: PageInfo!
+}
+
+"""
+An edge in a connection.
+"""
+type MergeRequestDiffRegistryEdge {
+ """
+ A cursor for use in pagination.
+ """
+ cursor: String!
+
+ """
+ The item at the end of the edge.
+ """
+ node: MergeRequestDiffRegistry
+}
+
+"""
An edge in a connection.
"""
type MergeRequestEdge {
@@ -10368,6 +11110,16 @@ Values for sorting merge requests
"""
enum MergeRequestSort {
"""
+ Created at ascending order
+ """
+ CREATED_ASC
+
+ """
+ Created at descending order
+ """
+ CREATED_DESC
+
+ """
Label priority by ascending order
"""
LABEL_PRIORITY_ASC
@@ -10408,24 +11160,34 @@ enum MergeRequestSort {
PRIORITY_DESC
"""
+ Updated at ascending order
+ """
+ UPDATED_ASC
+
+ """
+ Updated at descending order
+ """
+ UPDATED_DESC
+
+ """
Created at ascending order
"""
- created_asc
+ created_asc @deprecated(reason: "Use CREATED_ASC. Deprecated in 13.5")
"""
Created at descending order
"""
- created_desc
+ created_desc @deprecated(reason: "Use CREATED_DESC. Deprecated in 13.5")
"""
Updated at ascending order
"""
- updated_asc
+ updated_asc @deprecated(reason: "Use UPDATED_ASC. Deprecated in 13.5")
"""
Updated at descending order
"""
- updated_desc
+ updated_desc @deprecated(reason: "Use UPDATED_DESC. Deprecated in 13.5")
}
"""
@@ -10805,6 +11567,7 @@ type Mutation {
designManagementMove(input: DesignManagementMoveInput!): DesignManagementMovePayload
designManagementUpload(input: DesignManagementUploadInput!): DesignManagementUploadPayload
destroyBoard(input: DestroyBoardInput!): DestroyBoardPayload
+ destroyBoardList(input: DestroyBoardListInput!): DestroyBoardListPayload
destroyNote(input: DestroyNoteInput!): DestroyNotePayload
destroySnippet(input: DestroySnippetInput!): DestroySnippetPayload
@@ -10812,7 +11575,7 @@ type Mutation {
Toggles the resolved state of a discussion
"""
discussionToggleResolve(input: DiscussionToggleResolveInput!): DiscussionToggleResolvePayload
- dismissVulnerability(input: DismissVulnerabilityInput!): DismissVulnerabilityPayload
+ dismissVulnerability(input: DismissVulnerabilityInput!): DismissVulnerabilityPayload @deprecated(reason: "Use vulnerabilityDismiss. Deprecated in 13.5")
epicAddIssue(input: EpicAddIssueInput!): EpicAddIssuePayload
epicSetSubscription(input: EpicSetSubscriptionInput!): EpicSetSubscriptionPayload
epicTreeReorder(input: EpicTreeReorderInput!): EpicTreeReorderPayload
@@ -10847,6 +11610,7 @@ type Mutation {
pipelineRetry(input: PipelineRetryInput!): PipelineRetryPayload
removeAwardEmoji(input: RemoveAwardEmojiInput!): RemoveAwardEmojiPayload @deprecated(reason: "Use awardEmojiRemove. Deprecated in 13.2")
removeProjectFromSecurityDashboard(input: RemoveProjectFromSecurityDashboardInput!): RemoveProjectFromSecurityDashboardPayload
+ revertVulnerabilityToDetected(input: RevertVulnerabilityToDetectedInput!): RevertVulnerabilityToDetectedPayload @deprecated(reason: "Use vulnerabilityRevertToDetected. Deprecated in 13.5")
runDastScan(input: RunDASTScanInput!): RunDASTScanPayload @deprecated(reason: "Use DastOnDemandScanCreate. Deprecated in 13.4")
todoMarkDone(input: TodoMarkDoneInput!): TodoMarkDonePayload
todoRestore(input: TodoRestoreInput!): TodoRestorePayload
@@ -10875,7 +11639,10 @@ type Mutation {
updateNote(input: UpdateNoteInput!): UpdateNotePayload
updateRequirement(input: UpdateRequirementInput!): UpdateRequirementPayload
updateSnippet(input: UpdateSnippetInput!): UpdateSnippetPayload
+ vulnerabilityConfirm(input: VulnerabilityConfirmInput!): VulnerabilityConfirmPayload
+ vulnerabilityDismiss(input: VulnerabilityDismissInput!): VulnerabilityDismissPayload
vulnerabilityResolve(input: VulnerabilityResolveInput!): VulnerabilityResolvePayload
+ vulnerabilityRevertToDetected(input: VulnerabilityRevertToDetectedInput!): VulnerabilityRevertToDetectedPayload
}
"""
@@ -11409,7 +12176,7 @@ type PackageEdge {
}
"""
-Represents the sync and verification state of a package file
+Represents the Geo sync and verification state of a package file
"""
type PackageFileRegistry {
"""
@@ -11505,6 +12272,11 @@ enum PackageTypeEnum {
GENERIC
"""
+ Packages from the golang package manager
+ """
+ GOLANG
+
+ """
Packages from the maven package manager
"""
MAVEN
@@ -11959,9 +12731,9 @@ type Project {
"""
board(
"""
- Find a board by its ID
+ The board's ID
"""
- id: ID
+ id: ID!
): Board
"""
@@ -12249,6 +13021,16 @@ type Project {
assigneeUsername: String
"""
+ Usernames of users assigned to the issue
+ """
+ assigneeUsernames: [String!]
+
+ """
+ Username of the author of the issue
+ """
+ authorUsername: String
+
+ """
Issues closed after this date
"""
closedAfter: Time
@@ -12339,6 +13121,16 @@ type Project {
assigneeUsername: String
"""
+ Usernames of users assigned to the issue
+ """
+ assigneeUsernames: [String!]
+
+ """
+ Username of the author of the issue
+ """
+ authorUsername: String
+
+ """
Issues closed after this date
"""
closedAfter: Time
@@ -12419,6 +13211,16 @@ type Project {
assigneeUsername: String
"""
+ Usernames of users assigned to the issue
+ """
+ assigneeUsernames: [String!]
+
+ """
+ Username of the author of the issue
+ """
+ authorUsername: String
+
+ """
Returns the elements in the list that come before the specified cursor.
"""
before: String
@@ -13017,7 +13819,7 @@ type Project {
requestAccessEnabled: Boolean
"""
- Find a single requirement. Available only when feature flag `requirements_management` is enabled.
+ Find a single requirement
"""
requirement(
"""
@@ -13057,7 +13859,7 @@ type Project {
requirementStatesCount: RequirementStatesCount
"""
- Find requirements. Available only when feature flag `requirements_management` is enabled.
+ Find requirements
"""
requirements(
"""
@@ -13257,6 +14059,31 @@ type Project {
tagList: String
"""
+ Terraform states associated with the project
+ """
+ terraformStates(
+ """
+ Returns the elements in the list that come after the specified cursor.
+ """
+ after: String
+
+ """
+ Returns the elements in the list that come before the specified cursor.
+ """
+ before: String
+
+ """
+ Returns the first _n_ elements from the list.
+ """
+ first: Int
+
+ """
+ Returns the last _n_ elements from the list.
+ """
+ last: Int
+ ): TerraformStateConnection
+
+ """
Permissions for the current user on the resource
"""
userPermissions: ProjectPermissions!
@@ -14001,6 +14828,11 @@ type Query {
Search query for project name, path, or description
"""
search: String
+
+ """
+ Include namespace in project search
+ """
+ searchNamespaces: Boolean
): ProjectConnection
"""
@@ -14249,6 +15081,16 @@ type Query {
"""
startDate: ISO8601Date!
): VulnerabilitiesCountByDayAndSeverityConnection @deprecated(reason: "Use `vulnerabilitiesCountByDay`. Deprecated in 13.3")
+
+ """
+ Find a vulnerability
+ """
+ vulnerability(
+ """
+ The Global ID of the Vulnerability
+ """
+ id: VulnerabilityID!
+ ): Vulnerability
}
"""
@@ -14840,6 +15682,16 @@ type Requirement {
createdAt: Time!
"""
+ Description of the requirement
+ """
+ description: String
+
+ """
+ The GitLab Flavored Markdown rendering of `description`
+ """
+ descriptionHtml: String
+
+ """
ID of the requirement
"""
id: ID!
@@ -14900,6 +15752,11 @@ type Requirement {
title: String
"""
+ The GitLab Flavored Markdown rendering of `title`
+ """
+ titleHtml: String
+
+ """
Timestamp of when the requirement was last updated
"""
updatedAt: Time!
@@ -15020,6 +15877,41 @@ interface ResolvableInterface {
resolvedBy: User
}
+"""
+Autogenerated input type of RevertVulnerabilityToDetected
+"""
+input RevertVulnerabilityToDetectedInput {
+ """
+ A unique identifier for the client performing the mutation.
+ """
+ clientMutationId: String
+
+ """
+ ID of the vulnerability to be reverted
+ """
+ id: VulnerabilityID!
+}
+
+"""
+Autogenerated return type of RevertVulnerabilityToDetected
+"""
+type RevertVulnerabilityToDetectedPayload {
+ """
+ A unique identifier for the client performing the mutation.
+ """
+ clientMutationId: String
+
+ """
+ Errors encountered during execution of the mutation.
+ """
+ errors: [String!]!
+
+ """
+ The vulnerability after revert
+ """
+ vulnerability: Vulnerability
+}
+
type RootStorageStatistics {
"""
The CI artifacts size in bytes
@@ -15273,6 +16165,26 @@ type SastCiConfigurationAnalyzersEntityEdge {
}
"""
+Represents the analyzers entity in SAST CI configuration
+"""
+input SastCiConfigurationAnalyzersEntityInput {
+ """
+ State of the analyzer
+ """
+ enabled: Boolean!
+
+ """
+ Name of analyzer
+ """
+ name: String!
+
+ """
+ List of variables for the analyzer
+ """
+ variables: [SastCiConfigurationEntityInput!]
+}
+
+"""
Represents an entity in SAST CI configuration
"""
type SastCiConfigurationEntity {
@@ -15397,6 +16309,11 @@ Represents a CI configuration of SAST
"""
input SastCiConfigurationInput {
"""
+ List of analyzers and related variables for the SAST configuration
+ """
+ analyzers: [SastCiConfigurationAnalyzersEntityInput!]
+
+ """
List of global entities related to SAST configuration
"""
global: [SastCiConfigurationEntityInput!]
@@ -16183,7 +17100,32 @@ type Snippet implements Noteable {
"""
Snippet blobs
"""
- blobs: [SnippetBlob!]!
+ blobs(
+ """
+ Returns the elements in the list that come after the specified cursor.
+ """
+ after: String
+
+ """
+ Returns the elements in the list that come before the specified cursor.
+ """
+ before: String
+
+ """
+ Returns the first _n_ elements from the list.
+ """
+ first: Int
+
+ """
+ Returns the last _n_ elements from the list.
+ """
+ last: Int
+
+ """
+ Paths of the blobs
+ """
+ paths: [String!]
+ ): SnippetBlobConnection
"""
Timestamp this snippet was created
@@ -16407,6 +17349,41 @@ input SnippetBlobActionInputType {
}
"""
+The connection type for SnippetBlob.
+"""
+type SnippetBlobConnection {
+ """
+ A list of edges.
+ """
+ edges: [SnippetBlobEdge]
+
+ """
+ A list of nodes.
+ """
+ nodes: [SnippetBlob]
+
+ """
+ Information to aid in pagination.
+ """
+ pageInfo: PageInfo!
+}
+
+"""
+An edge in a connection.
+"""
+type SnippetBlobEdge {
+ """
+ A cursor for use in pagination.
+ """
+ cursor: String!
+
+ """
+ The item at the end of the edge.
+ """
+ node: SnippetBlob
+}
+
+"""
Represents how the blob content should be displayed
"""
type SnippetBlobViewer {
@@ -16520,22 +17497,42 @@ enum Sort {
"""
Created at ascending order
"""
- created_asc
+ CREATED_ASC
+
+ """
+ Created at descending order
+ """
+ CREATED_DESC
+
+ """
+ Updated at ascending order
+ """
+ UPDATED_ASC
+
+ """
+ Updated at descending order
+ """
+ UPDATED_DESC
+
+ """
+ Created at ascending order
+ """
+ created_asc @deprecated(reason: "Use CREATED_ASC. Deprecated in 13.5")
"""
Created at descending order
"""
- created_desc
+ created_desc @deprecated(reason: "Use CREATED_DESC. Deprecated in 13.5")
"""
Updated at ascending order
"""
- updated_asc
+ updated_asc @deprecated(reason: "Use UPDATED_ASC. Deprecated in 13.5")
"""
Updated at descending order
"""
- updated_desc
+ updated_desc @deprecated(reason: "Use UPDATED_DESC. Deprecated in 13.5")
}
type Submodule implements Entry {
@@ -16630,64 +17627,131 @@ type TaskCompletionStatus {
count: Int!
}
+type TerraformState {
+ """
+ Timestamp the Terraform state was created
+ """
+ createdAt: Time!
+
+ """
+ ID of the Terraform state
+ """
+ id: ID!
+
+ """
+ Timestamp the Terraform state was locked
+ """
+ lockedAt: Time
+
+ """
+ The user currently holding a lock on the Terraform state
+ """
+ lockedByUser: User
+
+ """
+ Name of the Terraform state
+ """
+ name: String!
+
+ """
+ Timestamp the Terraform state was updated
+ """
+ updatedAt: Time!
+}
+
+"""
+The connection type for TerraformState.
+"""
+type TerraformStateConnection {
+ """
+ A list of edges.
+ """
+ edges: [TerraformStateEdge]
+
+ """
+ A list of nodes.
+ """
+ nodes: [TerraformState]
+
+ """
+ Information to aid in pagination.
+ """
+ pageInfo: PageInfo!
+}
+
+"""
+An edge in a connection.
+"""
+type TerraformStateEdge {
+ """
+ A cursor for use in pagination.
+ """
+ cursor: String!
+
+ """
+ The item at the end of the edge.
+ """
+ node: TerraformState
+}
+
"""
-Represents the sync and verification state of a terraform state
+Represents the Geo sync and verification state of a terraform state version
"""
-type TerraformStateRegistry {
+type TerraformStateVersionRegistry {
"""
- Timestamp when the TerraformStateRegistry was created
+ Timestamp when the TerraformStateVersionRegistry was created
"""
createdAt: Time
"""
- ID of the TerraformStateRegistry
+ ID of the TerraformStateVersionRegistry
"""
id: ID!
"""
- Error message during sync of the TerraformStateRegistry
+ Error message during sync of the TerraformStateVersionRegistry
"""
lastSyncFailure: String
"""
- Timestamp of the most recent successful sync of the TerraformStateRegistry
+ Timestamp of the most recent successful sync of the TerraformStateVersionRegistry
"""
lastSyncedAt: Time
"""
- Timestamp after which the TerraformStateRegistry should be resynced
+ Timestamp after which the TerraformStateVersionRegistry should be resynced
"""
retryAt: Time
"""
- Number of consecutive failed sync attempts of the TerraformStateRegistry
+ Number of consecutive failed sync attempts of the TerraformStateVersionRegistry
"""
retryCount: Int
"""
- Sync state of the TerraformStateRegistry
+ Sync state of the TerraformStateVersionRegistry
"""
state: RegistryState
"""
- ID of the TerraformState
+ ID of the terraform state version
"""
- terraformStateId: ID!
+ terraformStateVersionId: ID!
}
"""
-The connection type for TerraformStateRegistry.
+The connection type for TerraformStateVersionRegistry.
"""
-type TerraformStateRegistryConnection {
+type TerraformStateVersionRegistryConnection {
"""
A list of edges.
"""
- edges: [TerraformStateRegistryEdge]
+ edges: [TerraformStateVersionRegistryEdge]
"""
A list of nodes.
"""
- nodes: [TerraformStateRegistry]
+ nodes: [TerraformStateVersionRegistry]
"""
Information to aid in pagination.
@@ -16698,7 +17762,7 @@ type TerraformStateRegistryConnection {
"""
An edge in a connection.
"""
-type TerraformStateRegistryEdge {
+type TerraformStateVersionRegistryEdge {
"""
A cursor for use in pagination.
"""
@@ -16707,7 +17771,7 @@ type TerraformStateRegistryEdge {
"""
The item at the end of the edge.
"""
- node: TerraformStateRegistry
+ node: TerraformStateVersionRegistry
}
"""
@@ -17943,6 +19007,11 @@ input UpdateRequirementInput {
clientMutationId: String
"""
+ Description of the requirement
+ """
+ description: String
+
+ """
The iid of the requirement to update
"""
iid: String!
@@ -17953,7 +19022,7 @@ input UpdateRequirementInput {
lastTestReportState: TestReportState
"""
- The project full path the requirement is associated with
+ Full project path the requirement is associated with
"""
projectPath: ID!
@@ -17983,7 +19052,7 @@ type UpdateRequirementPayload {
errors: [String!]!
"""
- The requirement after mutation
+ Requirement after mutation
"""
requirement: Requirement
}
@@ -18666,7 +19735,7 @@ type VulnerabilitiesCountByDayEdge {
"""
Represents a vulnerability
"""
-type Vulnerability {
+type Vulnerability implements Noteable {
"""
Description of the vulnerability
"""
@@ -18678,6 +19747,31 @@ type Vulnerability {
detectedAt: Time!
"""
+ All discussions on this noteable
+ """
+ discussions(
+ """
+ Returns the elements in the list that come after the specified cursor.
+ """
+ after: String
+
+ """
+ Returns the elements in the list that come before the specified cursor.
+ """
+ before: String
+
+ """
+ Returns the first _n_ elements from the list.
+ """
+ first: Int
+
+ """
+ Returns the last _n_ elements from the list.
+ """
+ last: Int
+ ): DiscussionConnection!
+
+ """
GraphQL ID of the vulnerability
"""
id: ID!
@@ -18723,6 +19817,31 @@ type Vulnerability {
location: VulnerabilityLocation
"""
+ All notes on this noteable
+ """
+ notes(
+ """
+ Returns the elements in the list that come after the specified cursor.
+ """
+ after: String
+
+ """
+ Returns the elements in the list that come before the specified cursor.
+ """
+ before: String
+
+ """
+ Returns the first _n_ elements from the list.
+ """
+ first: Int
+
+ """
+ Returns the last _n_ elements from the list.
+ """
+ last: Int
+ ): NoteConnection!
+
+ """
Primary identifier of the vulnerability.
"""
primaryIdentifier: VulnerabilityIdentifier
@@ -18781,6 +19900,41 @@ type Vulnerability {
}
"""
+Autogenerated input type of VulnerabilityConfirm
+"""
+input VulnerabilityConfirmInput {
+ """
+ A unique identifier for the client performing the mutation.
+ """
+ clientMutationId: String
+
+ """
+ ID of the vulnerability to be confirmed
+ """
+ id: VulnerabilityID!
+}
+
+"""
+Autogenerated return type of VulnerabilityConfirm
+"""
+type VulnerabilityConfirmPayload {
+ """
+ A unique identifier for the client performing the mutation.
+ """
+ clientMutationId: String
+
+ """
+ Errors encountered during execution of the mutation.
+ """
+ errors: [String!]!
+
+ """
+ The vulnerability after state change
+ """
+ vulnerability: Vulnerability
+}
+
+"""
The connection type for Vulnerability.
"""
type VulnerabilityConnection {
@@ -18801,6 +19955,46 @@ type VulnerabilityConnection {
}
"""
+Autogenerated input type of VulnerabilityDismiss
+"""
+input VulnerabilityDismissInput {
+ """
+ A unique identifier for the client performing the mutation.
+ """
+ clientMutationId: String
+
+ """
+ Reason why vulnerability should be dismissed
+ """
+ comment: String
+
+ """
+ ID of the vulnerability to be dismissed
+ """
+ id: ID!
+}
+
+"""
+Autogenerated return type of VulnerabilityDismiss
+"""
+type VulnerabilityDismissPayload {
+ """
+ A unique identifier for the client performing the mutation.
+ """
+ clientMutationId: String
+
+ """
+ Errors encountered during execution of the mutation.
+ """
+ errors: [String!]!
+
+ """
+ The vulnerability after dismissal
+ """
+ vulnerability: Vulnerability
+}
+
+"""
An edge in a connection.
"""
type VulnerabilityEdge {
@@ -19141,7 +20335,7 @@ input VulnerabilityResolveInput {
clientMutationId: String
"""
- ID of the vulnerability to be resolveed
+ ID of the vulnerability to be resolved
"""
id: VulnerabilityID!
}
@@ -19167,6 +20361,41 @@ type VulnerabilityResolvePayload {
}
"""
+Autogenerated input type of VulnerabilityRevertToDetected
+"""
+input VulnerabilityRevertToDetectedInput {
+ """
+ A unique identifier for the client performing the mutation.
+ """
+ clientMutationId: String
+
+ """
+ ID of the vulnerability to be reverted
+ """
+ id: VulnerabilityID!
+}
+
+"""
+Autogenerated return type of VulnerabilityRevertToDetected
+"""
+type VulnerabilityRevertToDetectedPayload {
+ """
+ A unique identifier for the client performing the mutation.
+ """
+ clientMutationId: String
+
+ """
+ Errors encountered during execution of the mutation.
+ """
+ errors: [String!]!
+
+ """
+ The vulnerability after revert
+ """
+ vulnerability: Vulnerability
+}
+
+"""
Represents a vulnerability scanner
"""
type VulnerabilityScanner {
@@ -19278,6 +20507,26 @@ Vulnerability sort values
"""
enum VulnerabilitySort {
"""
+ Detection timestamp in ascending order
+ """
+ detected_asc
+
+ """
+ Detection timestamp in descending order
+ """
+ detected_desc
+
+ """
+ Report Type in ascending order
+ """
+ report_type_asc
+
+ """
+ Report Type in descending order
+ """
+ report_type_desc
+
+ """
Severity in ascending order
"""
severity_asc
@@ -19286,6 +20535,16 @@ enum VulnerabilitySort {
Severity in descending order
"""
severity_desc
+
+ """
+ Title in ascending order
+ """
+ title_asc
+
+ """
+ Title in descending order
+ """
+ title_desc
}
"""
diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json
index 6458a676612..15aa205bed3 100644
--- a/doc/api/graphql/reference/gitlab_schema.json
+++ b/doc/api/graphql/reference/gitlab_schema.json
@@ -1227,23 +1227,47 @@
{
"name": "updated_desc",
"description": "Updated at descending order",
+ "isDeprecated": true,
+ "deprecationReason": "Use UPDATED_DESC. Deprecated in 13.5"
+ },
+ {
+ "name": "updated_asc",
+ "description": "Updated at ascending order",
+ "isDeprecated": true,
+ "deprecationReason": "Use UPDATED_ASC. Deprecated in 13.5"
+ },
+ {
+ "name": "created_desc",
+ "description": "Created at descending order",
+ "isDeprecated": true,
+ "deprecationReason": "Use CREATED_DESC. Deprecated in 13.5"
+ },
+ {
+ "name": "created_asc",
+ "description": "Created at ascending order",
+ "isDeprecated": true,
+ "deprecationReason": "Use CREATED_ASC. Deprecated in 13.5"
+ },
+ {
+ "name": "UPDATED_DESC",
+ "description": "Updated at descending order",
"isDeprecated": false,
"deprecationReason": null
},
{
- "name": "updated_asc",
+ "name": "UPDATED_ASC",
"description": "Updated at ascending order",
"isDeprecated": false,
"deprecationReason": null
},
{
- "name": "created_desc",
+ "name": "CREATED_DESC",
"description": "Created at descending order",
"isDeprecated": false,
"deprecationReason": null
},
{
- "name": "created_asc",
+ "name": "CREATED_ASC",
"description": "Created at ascending order",
"isDeprecated": false,
"deprecationReason": null
@@ -2764,7 +2788,7 @@
],
"type": {
"kind": "OBJECT",
- "name": "EpicConnection",
+ "name": "BoardEpicConnection",
"ofType": null
},
"isDeprecated": false,
@@ -3042,6 +3066,1263 @@
"possibleTypes": null
},
{
+ "kind": "OBJECT",
+ "name": "BoardEpic",
+ "description": "Represents an epic on an issue board",
+ "fields": [
+ {
+ "name": "author",
+ "description": "Author of the epic",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "User",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "children",
+ "description": "Children (sub-epics) of the epic",
+ "args": [
+ {
+ "name": "startDate",
+ "description": "List items within a time frame where items.start_date is between startDate and endDate parameters (endDate parameter must be present)",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Time",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "endDate",
+ "description": "List items within a time frame where items.end_date is between startDate and endDate parameters (startDate parameter must be present)",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Time",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "iid",
+ "description": "IID of the epic, e.g., \"1\"",
+ "type": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "iids",
+ "description": "List of IIDs of epics, e.g., [1, 2]",
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ }
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "state",
+ "description": "Filter epics by state",
+ "type": {
+ "kind": "ENUM",
+ "name": "EpicState",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "search",
+ "description": "Search query for epic title or description",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "sort",
+ "description": "List epics by sort order",
+ "type": {
+ "kind": "ENUM",
+ "name": "EpicSort",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "authorUsername",
+ "description": "Filter epics by author",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "labelName",
+ "description": "Filter epics by labels",
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "milestoneTitle",
+ "description": "Filter epics by milestone title, computed from epic's issues",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "iidStartsWith",
+ "description": "Filter epics by iid for autocomplete",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "after",
+ "description": "Returns the elements in the list that come after the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "before",
+ "description": "Returns the elements in the list that come before the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "first",
+ "description": "Returns the first _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "last",
+ "description": "Returns the last _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "EpicConnection",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "closedAt",
+ "description": "Timestamp of when the epic was closed",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Time",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "confidential",
+ "description": "Indicates if the epic is confidential",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Boolean",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "createdAt",
+ "description": "Timestamp of when the epic was created",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Time",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "currentUserTodos",
+ "description": "Todos for the current user",
+ "args": [
+ {
+ "name": "after",
+ "description": "Returns the elements in the list that come after the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "before",
+ "description": "Returns the elements in the list that come before the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "first",
+ "description": "Returns the first _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "last",
+ "description": "Returns the last _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "state",
+ "description": "State of the todos",
+ "type": {
+ "kind": "ENUM",
+ "name": "TodoStateEnum",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "TodoConnection",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "descendantCounts",
+ "description": "Number of open and closed descendant epics and issues",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "EpicDescendantCount",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "descendantWeightSum",
+ "description": "Total weight of open and closed issues in the epic and its descendants",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "EpicDescendantWeights",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "description",
+ "description": "Description of the epic",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "discussions",
+ "description": "All discussions on this noteable",
+ "args": [
+ {
+ "name": "after",
+ "description": "Returns the elements in the list that come after the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "before",
+ "description": "Returns the elements in the list that come before the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "first",
+ "description": "Returns the first _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "last",
+ "description": "Returns the last _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "DiscussionConnection",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "downvotes",
+ "description": "Number of downvotes the epic has received",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "dueDate",
+ "description": "Due date of the epic",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Time",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "dueDateFixed",
+ "description": "Fixed due date of the epic",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Time",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "dueDateFromMilestones",
+ "description": "Inherited due date of the epic from milestones",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Time",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "dueDateIsFixed",
+ "description": "Indicates if the due date has been manually set",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Boolean",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "group",
+ "description": "Group to which the epic belongs",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "Group",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "hasChildren",
+ "description": "Indicates if the epic has children",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "Boolean",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "hasIssues",
+ "description": "Indicates if the epic has direct issues",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "Boolean",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "hasParent",
+ "description": "Indicates if the epic has a parent epic",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "Boolean",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "healthStatus",
+ "description": "Current health status of the epic",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "EpicHealthStatus",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "id",
+ "description": "ID of the epic",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "iid",
+ "description": "Internal ID of the epic",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "issues",
+ "description": "A list of issues associated with the epic",
+ "args": [
+ {
+ "name": "after",
+ "description": "Returns the elements in the list that come after the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "before",
+ "description": "Returns the elements in the list that come before the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "first",
+ "description": "Returns the first _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "last",
+ "description": "Returns the last _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "EpicIssueConnection",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "labels",
+ "description": "Labels assigned to the epic",
+ "args": [
+ {
+ "name": "after",
+ "description": "Returns the elements in the list that come after the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "before",
+ "description": "Returns the elements in the list that come before the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "first",
+ "description": "Returns the first _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "last",
+ "description": "Returns the last _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "LabelConnection",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "notes",
+ "description": "All notes on this noteable",
+ "args": [
+ {
+ "name": "after",
+ "description": "Returns the elements in the list that come after the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "before",
+ "description": "Returns the elements in the list that come before the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "first",
+ "description": "Returns the first _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "last",
+ "description": "Returns the last _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "NoteConnection",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "parent",
+ "description": "Parent epic of the epic",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "Epic",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "participants",
+ "description": "List of participants for the epic",
+ "args": [
+ {
+ "name": "after",
+ "description": "Returns the elements in the list that come after the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "before",
+ "description": "Returns the elements in the list that come before the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "first",
+ "description": "Returns the first _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "last",
+ "description": "Returns the last _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "UserConnection",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "reference",
+ "description": "Internal reference of the epic. Returned in shortened format by default",
+ "args": [
+ {
+ "name": "full",
+ "description": "Indicates if the reference should be returned in full",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Boolean",
+ "ofType": null
+ },
+ "defaultValue": "false"
+ }
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "relationPath",
+ "description": "URI path of the epic-issue relationship",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "relativePosition",
+ "description": "The relative position of the epic in the epic tree",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "startDate",
+ "description": "Start date of the epic",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Time",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "startDateFixed",
+ "description": "Fixed start date of the epic",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Time",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "startDateFromMilestones",
+ "description": "Inherited start date of the epic from milestones",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Time",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "startDateIsFixed",
+ "description": "Indicates if the start date has been manually set",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Boolean",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "state",
+ "description": "State of the epic",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "ENUM",
+ "name": "EpicState",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "subscribed",
+ "description": "Indicates the currently logged in user is subscribed to the epic",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "Boolean",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "title",
+ "description": "Title of the epic",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "updatedAt",
+ "description": "Timestamp of when the epic was updated",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Time",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "upvotes",
+ "description": "Number of upvotes the epic has received",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "userPermissions",
+ "description": "Permissions for the current user on the resource",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "EpicPermissions",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "userPreferences",
+ "description": "User preferences for the epic on the issue board",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "BoardEpicUserPreferences",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "webPath",
+ "description": "Web path of the epic",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "webUrl",
+ "description": "Web URL of the epic",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+ {
+ "kind": "INTERFACE",
+ "name": "Noteable",
+ "ofType": null
+ },
+ {
+ "kind": "INTERFACE",
+ "name": "CurrentUserTodos",
+ "ofType": null
+ }
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
+ "name": "BoardEpicConnection",
+ "description": "The connection type for BoardEpic.",
+ "fields": [
+ {
+ "name": "edges",
+ "description": "A list of edges.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "BoardEpicEdge",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "nodes",
+ "description": "A list of nodes.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "BoardEpic",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "pageInfo",
+ "description": "Information to aid in pagination.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "PageInfo",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
+ "name": "BoardEpicEdge",
+ "description": "An edge in a connection.",
+ "fields": [
+ {
+ "name": "cursor",
+ "description": "A cursor for use in pagination.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "node",
+ "description": "The item at the end of the edge.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "BoardEpic",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
+ "name": "BoardEpicUserPreferences",
+ "description": "Represents user preferences for a board epic",
+ "fields": [
+ {
+ "name": "collapsed",
+ "description": "Indicates epic should be displayed as collapsed",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "Boolean",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
"kind": "SCALAR",
"name": "BoardID",
"description": "Identifier of Board",
@@ -7872,19 +9153,25 @@
"name": "title",
"description": "Title of the requirement",
"type": {
- "kind": "NON_NULL",
- "name": null,
- "ofType": {
- "kind": "SCALAR",
- "name": "String",
- "ofType": null
- }
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "description",
+ "description": "Description of the requirement",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
},
"defaultValue": null
},
{
"name": "projectPath",
- "description": "The project full path the requirement is associated with",
+ "description": "Full project path the requirement is associated with",
"type": {
"kind": "NON_NULL",
"name": null,
@@ -7958,7 +9245,7 @@
},
{
"name": "requirement",
- "description": "The requirement after mutation",
+ "description": "Requirement after mutation",
"args": [
],
@@ -8373,6 +9660,11 @@
"possibleTypes": [
{
"kind": "OBJECT",
+ "name": "BoardEpic",
+ "ofType": null
+ },
+ {
+ "kind": "OBJECT",
"name": "Design",
"ofType": null
},
@@ -10790,6 +12082,20 @@
"description": "A collection of designs",
"fields": [
{
+ "name": "copyState",
+ "description": "Copy state of the design collection",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "ENUM",
+ "name": "DesignCollectionCopyState",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "design",
"description": "Find a specific design",
"args": [
@@ -11107,6 +12413,35 @@
"possibleTypes": null
},
{
+ "kind": "ENUM",
+ "name": "DesignCollectionCopyState",
+ "description": "Copy state of a DesignCollection",
+ "fields": null,
+ "inputFields": null,
+ "interfaces": null,
+ "enumValues": [
+ {
+ "name": "READY",
+ "description": "The DesignCollection has no copy in progress",
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "IN_PROGRESS",
+ "description": "The DesignCollection is being copied",
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "ERROR",
+ "description": "The DesignCollection encountered an error during a copy",
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "possibleTypes": null
+ },
+ {
"kind": "OBJECT",
"name": "DesignConnection",
"description": "The connection type for Design.",
@@ -12358,6 +13693,108 @@
"possibleTypes": null
},
{
+ "kind": "INPUT_OBJECT",
+ "name": "DestroyBoardListInput",
+ "description": "Autogenerated input type of DestroyBoardList",
+ "fields": null,
+ "inputFields": [
+ {
+ "name": "listId",
+ "description": "Global ID of the list to destroy. Only label lists are accepted.",
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "ListID",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "clientMutationId",
+ "description": "A unique identifier for the client performing the mutation.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "interfaces": null,
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
+ "name": "DestroyBoardListPayload",
+ "description": "Autogenerated return type of DestroyBoardList",
+ "fields": [
+ {
+ "name": "clientMutationId",
+ "description": "A unique identifier for the client performing the mutation.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "errors",
+ "description": "Errors encountered during execution of the mutation.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "list",
+ "description": "The list after mutation.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "BoardList",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
"kind": "OBJECT",
"name": "DestroyBoardPayload",
"description": "Autogenerated return type of DestroyBoard",
@@ -14559,7 +15996,7 @@
},
{
"name": "closedAt",
- "description": "Timestamp of the epic's closure",
+ "description": "Timestamp of when the epic was closed",
"args": [
],
@@ -14587,7 +16024,7 @@
},
{
"name": "createdAt",
- "description": "Timestamp of the epic's creation",
+ "description": "Timestamp of when the epic was created",
"args": [
],
@@ -15354,7 +16791,7 @@
},
{
"name": "updatedAt",
- "description": "Timestamp of the epic's last activity",
+ "description": "Timestamp of when the epic was updated",
"args": [
],
@@ -17689,6 +19126,77 @@
"deprecationReason": null
},
{
+ "name": "mergeRequestDiffRegistries",
+ "description": "Find merge request diff registries on this Geo node. Available only when feature flag `geo_merge_request_diff_replication` is enabled",
+ "args": [
+ {
+ "name": "ids",
+ "description": "Filters registries by their ID",
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ }
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "after",
+ "description": "Returns the elements in the list that come after the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "before",
+ "description": "Returns the elements in the list that come before the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "first",
+ "description": "Returns the first _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "last",
+ "description": "Returns the last _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "MergeRequestDiffRegistryConnection",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "minimumReverificationInterval",
"description": "The interval (in days) in which the repository verification is valid. Once expired, it will be reverified",
"args": [
@@ -17718,7 +19226,7 @@
},
{
"name": "packageFileRegistries",
- "description": "Package file registries of the GeoNode. Available only when feature flag `geo_package_file_replication` is enabled",
+ "description": "Package file registries of the GeoNode",
"args": [
{
"name": "ids",
@@ -17919,8 +19427,8 @@
"deprecationReason": null
},
{
- "name": "terraformStateRegistries",
- "description": "Find terraform state registries on this Geo node. Available only when feature flag `geo_terraform_state_replication` is enabled",
+ "name": "terraformStateVersionRegistries",
+ "description": "Find terraform state version registries on this Geo node",
"args": [
{
"name": "ids",
@@ -17983,7 +19491,7 @@
],
"type": {
"kind": "OBJECT",
- "name": "TerraformStateRegistryConnection",
+ "name": "TerraformStateVersionRegistryConnection",
"ofType": null
},
"isDeprecated": false,
@@ -18185,11 +19693,15 @@
"args": [
{
"name": "id",
- "description": "Find a board by its ID",
+ "description": "The board's ID",
"type": {
- "kind": "SCALAR",
- "name": "ID",
- "ofType": null
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ }
},
"defaultValue": null
}
@@ -18849,6 +20361,16 @@
"defaultValue": null
},
{
+ "name": "authorUsername",
+ "description": "Username of the author of the issue",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
"name": "assigneeUsername",
"description": "Username of a user assigned to the issue",
"type": {
@@ -18859,6 +20381,24 @@
"defaultValue": null
},
{
+ "name": "assigneeUsernames",
+ "description": "Usernames of users assigned to the issue",
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ },
+ "defaultValue": null
+ },
+ {
"name": "assigneeId",
"description": "ID of a user assigned to the issues, \"none\" and \"any\" values supported",
"type": {
@@ -23861,23 +25401,47 @@
{
"name": "updated_desc",
"description": "Updated at descending order",
+ "isDeprecated": true,
+ "deprecationReason": "Use UPDATED_DESC. Deprecated in 13.5"
+ },
+ {
+ "name": "updated_asc",
+ "description": "Updated at ascending order",
+ "isDeprecated": true,
+ "deprecationReason": "Use UPDATED_ASC. Deprecated in 13.5"
+ },
+ {
+ "name": "created_desc",
+ "description": "Created at descending order",
+ "isDeprecated": true,
+ "deprecationReason": "Use CREATED_DESC. Deprecated in 13.5"
+ },
+ {
+ "name": "created_asc",
+ "description": "Created at ascending order",
+ "isDeprecated": true,
+ "deprecationReason": "Use CREATED_ASC. Deprecated in 13.5"
+ },
+ {
+ "name": "UPDATED_DESC",
+ "description": "Updated at descending order",
"isDeprecated": false,
"deprecationReason": null
},
{
- "name": "updated_asc",
+ "name": "UPDATED_ASC",
"description": "Updated at ascending order",
"isDeprecated": false,
"deprecationReason": null
},
{
- "name": "created_desc",
+ "name": "CREATED_DESC",
"description": "Created at descending order",
"isDeprecated": false,
"deprecationReason": null
},
{
- "name": "created_asc",
+ "name": "CREATED_ASC",
"description": "Created at ascending order",
"isDeprecated": false,
"deprecationReason": null
@@ -23937,6 +25501,18 @@
"deprecationReason": null
},
{
+ "name": "SEVERITY_ASC",
+ "description": "Severity from less critical to more critical",
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "SEVERITY_DESC",
+ "description": "Severity from more critical to less critical",
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "WEIGHT_ASC",
"description": "Weight by ascending order",
"isDeprecated": false,
@@ -23947,6 +25523,18 @@
"description": "Weight by descending order",
"isDeprecated": false,
"deprecationReason": null
+ },
+ {
+ "name": "PUBLISHED_ASC",
+ "description": "Published issues shown last",
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "PUBLISHED_DESC",
+ "description": "Published issues shown first",
+ "isDeprecated": false,
+ "deprecationReason": null
}
],
"possibleTypes": null
@@ -25672,6 +27260,16 @@
"possibleTypes": null
},
{
+ "kind": "SCALAR",
+ "name": "ListID",
+ "description": "Identifier of List",
+ "fields": null,
+ "inputFields": null,
+ "interfaces": null,
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
"kind": "ENUM",
"name": "ListLimitMetric",
"description": "List limit metric setting",
@@ -25845,6 +27443,30 @@
"description": "Pipeline count",
"isDeprecated": false,
"deprecationReason": null
+ },
+ {
+ "name": "PIPELINES_SUCCEEDED",
+ "description": "Pipeline count with success status",
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "PIPELINES_FAILED",
+ "description": "Pipeline count with failed status",
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "PIPELINES_CANCELED",
+ "description": "Pipeline count with canceled status",
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "PIPELINES_SKIPPED",
+ "description": "Pipeline count with skipped status",
+ "isDeprecated": false,
+ "deprecationReason": null
}
],
"possibleTypes": null
@@ -27832,6 +29454,251 @@
},
{
"kind": "OBJECT",
+ "name": "MergeRequestDiffRegistry",
+ "description": "Represents the Geo sync and verification state of a Merge Request diff",
+ "fields": [
+ {
+ "name": "createdAt",
+ "description": "Timestamp when the MergeRequestDiffRegistry was created",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Time",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "id",
+ "description": "ID of the MergeRequestDiffRegistry",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "lastSyncFailure",
+ "description": "Error message during sync of the MergeRequestDiffRegistry",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "lastSyncedAt",
+ "description": "Timestamp of the most recent successful sync of the MergeRequestDiffRegistry",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Time",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "mergeRequestDiffId",
+ "description": "ID of the Merge Request diff",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "retryAt",
+ "description": "Timestamp after which the MergeRequestDiffRegistry should be resynced",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Time",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "retryCount",
+ "description": "Number of consecutive failed sync attempts of the MergeRequestDiffRegistry",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "state",
+ "description": "Sync state of the MergeRequestDiffRegistry",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "ENUM",
+ "name": "RegistryState",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
+ "name": "MergeRequestDiffRegistryConnection",
+ "description": "The connection type for MergeRequestDiffRegistry.",
+ "fields": [
+ {
+ "name": "edges",
+ "description": "A list of edges.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "MergeRequestDiffRegistryEdge",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "nodes",
+ "description": "A list of nodes.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "MergeRequestDiffRegistry",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "pageInfo",
+ "description": "Information to aid in pagination.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "PageInfo",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
+ "name": "MergeRequestDiffRegistryEdge",
+ "description": "An edge in a connection.",
+ "fields": [
+ {
+ "name": "cursor",
+ "description": "A cursor for use in pagination.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "node",
+ "description": "The item at the end of the edge.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "MergeRequestDiffRegistry",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
"name": "MergeRequestEdge",
"description": "An edge in a connection.",
"fields": [
@@ -28873,23 +30740,47 @@
{
"name": "updated_desc",
"description": "Updated at descending order",
+ "isDeprecated": true,
+ "deprecationReason": "Use UPDATED_DESC. Deprecated in 13.5"
+ },
+ {
+ "name": "updated_asc",
+ "description": "Updated at ascending order",
+ "isDeprecated": true,
+ "deprecationReason": "Use UPDATED_ASC. Deprecated in 13.5"
+ },
+ {
+ "name": "created_desc",
+ "description": "Created at descending order",
+ "isDeprecated": true,
+ "deprecationReason": "Use CREATED_DESC. Deprecated in 13.5"
+ },
+ {
+ "name": "created_asc",
+ "description": "Created at ascending order",
+ "isDeprecated": true,
+ "deprecationReason": "Use CREATED_ASC. Deprecated in 13.5"
+ },
+ {
+ "name": "UPDATED_DESC",
+ "description": "Updated at descending order",
"isDeprecated": false,
"deprecationReason": null
},
{
- "name": "updated_asc",
+ "name": "UPDATED_ASC",
"description": "Updated at ascending order",
"isDeprecated": false,
"deprecationReason": null
},
{
- "name": "created_desc",
+ "name": "CREATED_DESC",
"description": "Created at descending order",
"isDeprecated": false,
"deprecationReason": null
},
{
- "name": "created_asc",
+ "name": "CREATED_ASC",
"description": "Created at ascending order",
"isDeprecated": false,
"deprecationReason": null
@@ -31031,6 +32922,33 @@
"deprecationReason": null
},
{
+ "name": "destroyBoardList",
+ "description": null,
+ "args": [
+ {
+ "name": "input",
+ "description": null,
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "INPUT_OBJECT",
+ "name": "DestroyBoardListInput",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "DestroyBoardListPayload",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "destroyNote",
"description": null,
"args": [
@@ -31135,8 +33053,8 @@
"name": "DismissVulnerabilityPayload",
"ofType": null
},
- "isDeprecated": false,
- "deprecationReason": null
+ "isDeprecated": true,
+ "deprecationReason": "Use vulnerabilityDismiss. Deprecated in 13.5"
},
{
"name": "epicAddIssue",
@@ -31949,6 +33867,33 @@
"deprecationReason": null
},
{
+ "name": "revertVulnerabilityToDetected",
+ "description": null,
+ "args": [
+ {
+ "name": "input",
+ "description": null,
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "INPUT_OBJECT",
+ "name": "RevertVulnerabilityToDetectedInput",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "RevertVulnerabilityToDetectedPayload",
+ "ofType": null
+ },
+ "isDeprecated": true,
+ "deprecationReason": "Use vulnerabilityRevertToDetected. Deprecated in 13.5"
+ },
+ {
"name": "runDastScan",
"description": null,
"args": [
@@ -32408,6 +34353,60 @@
"deprecationReason": null
},
{
+ "name": "vulnerabilityConfirm",
+ "description": null,
+ "args": [
+ {
+ "name": "input",
+ "description": null,
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "INPUT_OBJECT",
+ "name": "VulnerabilityConfirmInput",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "VulnerabilityConfirmPayload",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "vulnerabilityDismiss",
+ "description": null,
+ "args": [
+ {
+ "name": "input",
+ "description": null,
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "INPUT_OBJECT",
+ "name": "VulnerabilityDismissInput",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "VulnerabilityDismissPayload",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "vulnerabilityResolve",
"description": null,
"args": [
@@ -32433,6 +34432,33 @@
},
"isDeprecated": false,
"deprecationReason": null
+ },
+ {
+ "name": "vulnerabilityRevertToDetected",
+ "description": null,
+ "args": [
+ {
+ "name": "input",
+ "description": null,
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "INPUT_OBJECT",
+ "name": "VulnerabilityRevertToDetectedInput",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "VulnerabilityRevertToDetectedPayload",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
}
],
"inputFields": null,
@@ -33768,6 +35794,11 @@
},
{
"kind": "OBJECT",
+ "name": "BoardEpic",
+ "ofType": null
+ },
+ {
+ "kind": "OBJECT",
"name": "Design",
"ofType": null
},
@@ -33795,6 +35826,11 @@
"kind": "OBJECT",
"name": "Snippet",
"ofType": null
+ },
+ {
+ "kind": "OBJECT",
+ "name": "Vulnerability",
+ "ofType": null
}
]
},
@@ -34030,7 +36066,7 @@
{
"kind": "OBJECT",
"name": "PackageFileRegistry",
- "description": "Represents the sync and verification state of a package file",
+ "description": "Represents the Geo sync and verification state of a package file",
"fields": [
{
"name": "createdAt",
@@ -34321,6 +36357,12 @@
"description": "Packages from the generic package manager",
"isDeprecated": false,
"deprecationReason": null
+ },
+ {
+ "name": "GOLANG",
+ "description": "Packages from the golang package manager",
+ "isDeprecated": false,
+ "deprecationReason": null
}
],
"possibleTypes": null
@@ -35637,11 +37679,15 @@
"args": [
{
"name": "id",
- "description": "Find a board by its ID",
+ "description": "The board's ID",
"type": {
- "kind": "SCALAR",
- "name": "ID",
- "ofType": null
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ }
},
"defaultValue": null
}
@@ -36366,6 +38412,16 @@
"defaultValue": null
},
{
+ "name": "authorUsername",
+ "description": "Username of the author of the issue",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
"name": "assigneeUsername",
"description": "Username of a user assigned to the issue",
"type": {
@@ -36376,6 +38432,24 @@
"defaultValue": null
},
{
+ "name": "assigneeUsernames",
+ "description": "Usernames of users assigned to the issue",
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ },
+ "defaultValue": null
+ },
+ {
"name": "assigneeId",
"description": "ID of a user assigned to the issues, \"none\" and \"any\" values supported",
"type": {
@@ -36577,6 +38651,16 @@
"defaultValue": null
},
{
+ "name": "authorUsername",
+ "description": "Username of the author of the issue",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
"name": "assigneeUsername",
"description": "Username of a user assigned to the issue",
"type": {
@@ -36587,6 +38671,24 @@
"defaultValue": null
},
{
+ "name": "assigneeUsernames",
+ "description": "Usernames of users assigned to the issue",
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ },
+ "defaultValue": null
+ },
+ {
"name": "assigneeId",
"description": "ID of a user assigned to the issues, \"none\" and \"any\" values supported",
"type": {
@@ -36754,6 +38856,16 @@
"defaultValue": null
},
{
+ "name": "authorUsername",
+ "description": "Username of the author of the issue",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
"name": "assigneeUsername",
"description": "Username of a user assigned to the issue",
"type": {
@@ -36764,6 +38876,24 @@
"defaultValue": null
},
{
+ "name": "assigneeUsernames",
+ "description": "Usernames of users assigned to the issue",
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ },
+ "defaultValue": null
+ },
+ {
"name": "assigneeId",
"description": "ID of a user assigned to the issues, \"none\" and \"any\" values supported",
"type": {
@@ -38129,7 +40259,7 @@
},
{
"name": "requirement",
- "description": "Find a single requirement. Available only when feature flag `requirements_management` is enabled.",
+ "description": "Find a single requirement",
"args": [
{
"name": "iid",
@@ -38232,7 +40362,7 @@
},
{
"name": "requirements",
- "description": "Find requirements. Available only when feature flag `requirements_management` is enabled.",
+ "description": "Find requirements",
"args": [
{
"name": "iid",
@@ -38727,6 +40857,59 @@
"deprecationReason": null
},
{
+ "name": "terraformStates",
+ "description": "Terraform states associated with the project",
+ "args": [
+ {
+ "name": "after",
+ "description": "Returns the elements in the list that come after the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "before",
+ "description": "Returns the elements in the list that come before the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "first",
+ "description": "Returns the first _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "last",
+ "description": "Returns the last _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "TerraformStateConnection",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "userPermissions",
"description": "Permissions for the current user on the resource",
"args": [
@@ -40982,6 +43165,16 @@
"defaultValue": null
},
{
+ "name": "searchNamespaces",
+ "description": "Include namespace in project search",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Boolean",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
@@ -41617,6 +43810,33 @@
},
"isDeprecated": true,
"deprecationReason": "Use `vulnerabilitiesCountByDay`. Deprecated in 13.3"
+ },
+ {
+ "name": "vulnerability",
+ "description": "Find a vulnerability",
+ "args": [
+ {
+ "name": "id",
+ "description": "The Global ID of the Vulnerability",
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "VulnerabilityID",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "Vulnerability",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
}
],
"inputFields": null,
@@ -43218,6 +45438,34 @@
"deprecationReason": null
},
{
+ "name": "description",
+ "description": "Description of the requirement",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "descriptionHtml",
+ "description": "The GitLab Flavored Markdown rendering of `description`",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "id",
"description": "ID of the requirement",
"args": [
@@ -43381,6 +45629,20 @@
"deprecationReason": null
},
{
+ "name": "titleHtml",
+ "description": "The GitLab Flavored Markdown rendering of `title`",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "updatedAt",
"description": "Timestamp of when the requirement was last updated",
"args": [
@@ -43790,6 +46052,108 @@
]
},
{
+ "kind": "INPUT_OBJECT",
+ "name": "RevertVulnerabilityToDetectedInput",
+ "description": "Autogenerated input type of RevertVulnerabilityToDetected",
+ "fields": null,
+ "inputFields": [
+ {
+ "name": "id",
+ "description": "ID of the vulnerability to be reverted",
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "VulnerabilityID",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "clientMutationId",
+ "description": "A unique identifier for the client performing the mutation.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "interfaces": null,
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
+ "name": "RevertVulnerabilityToDetectedPayload",
+ "description": "Autogenerated return type of RevertVulnerabilityToDetected",
+ "fields": [
+ {
+ "name": "clientMutationId",
+ "description": "A unique identifier for the client performing the mutation.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "errors",
+ "description": "Errors encountered during execution of the mutation.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "vulnerability",
+ "description": "The vulnerability after revert",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "Vulnerability",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
"kind": "OBJECT",
"name": "RootStorageStatistics",
"description": null,
@@ -44479,6 +46843,63 @@
"possibleTypes": null
},
{
+ "kind": "INPUT_OBJECT",
+ "name": "SastCiConfigurationAnalyzersEntityInput",
+ "description": "Represents the analyzers entity in SAST CI configuration",
+ "fields": null,
+ "inputFields": [
+ {
+ "name": "name",
+ "description": "Name of analyzer",
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "enabled",
+ "description": "State of the analyzer",
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "Boolean",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "variables",
+ "description": "List of variables for the analyzer",
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "INPUT_OBJECT",
+ "name": "SastCiConfigurationEntityInput",
+ "ofType": null
+ }
+ }
+ },
+ "defaultValue": null
+ }
+ ],
+ "interfaces": null,
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
"kind": "OBJECT",
"name": "SastCiConfigurationEntity",
"description": "Represents an entity in SAST CI configuration",
@@ -44848,6 +47269,24 @@
}
},
"defaultValue": null
+ },
+ {
+ "name": "analyzers",
+ "description": "List of analyzers and related variables for the SAST configuration",
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "INPUT_OBJECT",
+ "name": "SastCiConfigurationAnalyzersEntityInput",
+ "ofType": null
+ }
+ }
+ },
+ "defaultValue": null
}
],
"interfaces": null,
@@ -47364,24 +49803,69 @@
"name": "blobs",
"description": "Snippet blobs",
"args": [
-
- ],
- "type": {
- "kind": "NON_NULL",
- "name": null,
- "ofType": {
- "kind": "LIST",
- "name": null,
- "ofType": {
- "kind": "NON_NULL",
+ {
+ "name": "paths",
+ "description": "Paths of the blobs",
+ "type": {
+ "kind": "LIST",
"name": null,
"ofType": {
- "kind": "OBJECT",
- "name": "SnippetBlob",
- "ofType": null
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
}
- }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "after",
+ "description": "Returns the elements in the list that come after the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "before",
+ "description": "Returns the elements in the list that come before the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "first",
+ "description": "Returns the first _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "last",
+ "description": "Returns the last _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
}
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "SnippetBlobConnection",
+ "ofType": null
},
"isDeprecated": false,
"deprecationReason": null
@@ -48037,6 +50521,118 @@
},
{
"kind": "OBJECT",
+ "name": "SnippetBlobConnection",
+ "description": "The connection type for SnippetBlob.",
+ "fields": [
+ {
+ "name": "edges",
+ "description": "A list of edges.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "SnippetBlobEdge",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "nodes",
+ "description": "A list of nodes.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "SnippetBlob",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "pageInfo",
+ "description": "Information to aid in pagination.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "PageInfo",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
+ "name": "SnippetBlobEdge",
+ "description": "An edge in a connection.",
+ "fields": [
+ {
+ "name": "cursor",
+ "description": "A cursor for use in pagination.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "node",
+ "description": "The item at the end of the edge.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "SnippetBlob",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
"name": "SnippetBlobViewer",
"description": "Represents how the blob content should be displayed",
"fields": [
@@ -48414,23 +51010,47 @@
{
"name": "updated_desc",
"description": "Updated at descending order",
+ "isDeprecated": true,
+ "deprecationReason": "Use UPDATED_DESC. Deprecated in 13.5"
+ },
+ {
+ "name": "updated_asc",
+ "description": "Updated at ascending order",
+ "isDeprecated": true,
+ "deprecationReason": "Use UPDATED_ASC. Deprecated in 13.5"
+ },
+ {
+ "name": "created_desc",
+ "description": "Created at descending order",
+ "isDeprecated": true,
+ "deprecationReason": "Use CREATED_DESC. Deprecated in 13.5"
+ },
+ {
+ "name": "created_asc",
+ "description": "Created at ascending order",
+ "isDeprecated": true,
+ "deprecationReason": "Use CREATED_ASC. Deprecated in 13.5"
+ },
+ {
+ "name": "UPDATED_DESC",
+ "description": "Updated at descending order",
"isDeprecated": false,
"deprecationReason": null
},
{
- "name": "updated_asc",
+ "name": "UPDATED_ASC",
"description": "Updated at ascending order",
"isDeprecated": false,
"deprecationReason": null
},
{
- "name": "created_desc",
+ "name": "CREATED_DESC",
"description": "Created at descending order",
"isDeprecated": false,
"deprecationReason": null
},
{
- "name": "created_asc",
+ "name": "CREATED_ASC",
"description": "Created at ascending order",
"isDeprecated": false,
"deprecationReason": null
@@ -48764,12 +51384,237 @@
},
{
"kind": "OBJECT",
- "name": "TerraformStateRegistry",
- "description": "Represents the sync and verification state of a terraform state",
+ "name": "TerraformState",
+ "description": null,
+ "fields": [
+ {
+ "name": "createdAt",
+ "description": "Timestamp the Terraform state was created",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "Time",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "id",
+ "description": "ID of the Terraform state",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "lockedAt",
+ "description": "Timestamp the Terraform state was locked",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Time",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "lockedByUser",
+ "description": "The user currently holding a lock on the Terraform state",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "User",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "name",
+ "description": "Name of the Terraform state",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "updatedAt",
+ "description": "Timestamp the Terraform state was updated",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "Time",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
+ "name": "TerraformStateConnection",
+ "description": "The connection type for TerraformState.",
+ "fields": [
+ {
+ "name": "edges",
+ "description": "A list of edges.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "TerraformStateEdge",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "nodes",
+ "description": "A list of nodes.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "TerraformState",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "pageInfo",
+ "description": "Information to aid in pagination.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "PageInfo",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
+ "name": "TerraformStateEdge",
+ "description": "An edge in a connection.",
+ "fields": [
+ {
+ "name": "cursor",
+ "description": "A cursor for use in pagination.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "node",
+ "description": "The item at the end of the edge.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "TerraformState",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
+ "name": "TerraformStateVersionRegistry",
+ "description": "Represents the Geo sync and verification state of a terraform state version",
"fields": [
{
"name": "createdAt",
- "description": "Timestamp when the TerraformStateRegistry was created",
+ "description": "Timestamp when the TerraformStateVersionRegistry was created",
"args": [
],
@@ -48783,7 +51628,7 @@
},
{
"name": "id",
- "description": "ID of the TerraformStateRegistry",
+ "description": "ID of the TerraformStateVersionRegistry",
"args": [
],
@@ -48801,7 +51646,7 @@
},
{
"name": "lastSyncFailure",
- "description": "Error message during sync of the TerraformStateRegistry",
+ "description": "Error message during sync of the TerraformStateVersionRegistry",
"args": [
],
@@ -48815,7 +51660,7 @@
},
{
"name": "lastSyncedAt",
- "description": "Timestamp of the most recent successful sync of the TerraformStateRegistry",
+ "description": "Timestamp of the most recent successful sync of the TerraformStateVersionRegistry",
"args": [
],
@@ -48829,7 +51674,7 @@
},
{
"name": "retryAt",
- "description": "Timestamp after which the TerraformStateRegistry should be resynced",
+ "description": "Timestamp after which the TerraformStateVersionRegistry should be resynced",
"args": [
],
@@ -48843,7 +51688,7 @@
},
{
"name": "retryCount",
- "description": "Number of consecutive failed sync attempts of the TerraformStateRegistry",
+ "description": "Number of consecutive failed sync attempts of the TerraformStateVersionRegistry",
"args": [
],
@@ -48857,7 +51702,7 @@
},
{
"name": "state",
- "description": "Sync state of the TerraformStateRegistry",
+ "description": "Sync state of the TerraformStateVersionRegistry",
"args": [
],
@@ -48870,8 +51715,8 @@
"deprecationReason": null
},
{
- "name": "terraformStateId",
- "description": "ID of the TerraformState",
+ "name": "terraformStateVersionId",
+ "description": "ID of the terraform state version",
"args": [
],
@@ -48897,8 +51742,8 @@
},
{
"kind": "OBJECT",
- "name": "TerraformStateRegistryConnection",
- "description": "The connection type for TerraformStateRegistry.",
+ "name": "TerraformStateVersionRegistryConnection",
+ "description": "The connection type for TerraformStateVersionRegistry.",
"fields": [
{
"name": "edges",
@@ -48911,7 +51756,7 @@
"name": null,
"ofType": {
"kind": "OBJECT",
- "name": "TerraformStateRegistryEdge",
+ "name": "TerraformStateVersionRegistryEdge",
"ofType": null
}
},
@@ -48929,7 +51774,7 @@
"name": null,
"ofType": {
"kind": "OBJECT",
- "name": "TerraformStateRegistry",
+ "name": "TerraformStateVersionRegistry",
"ofType": null
}
},
@@ -48964,7 +51809,7 @@
},
{
"kind": "OBJECT",
- "name": "TerraformStateRegistryEdge",
+ "name": "TerraformStateVersionRegistryEdge",
"description": "An edge in a connection.",
"fields": [
{
@@ -48993,7 +51838,7 @@
],
"type": {
"kind": "OBJECT",
- "name": "TerraformStateRegistry",
+ "name": "TerraformStateVersionRegistry",
"ofType": null
},
"isDeprecated": false,
@@ -52552,38 +55397,48 @@
"defaultValue": null
},
{
- "name": "state",
- "description": "State of the requirement",
+ "name": "description",
+ "description": "Description of the requirement",
"type": {
- "kind": "ENUM",
- "name": "RequirementState",
+ "kind": "SCALAR",
+ "name": "String",
"ofType": null
},
"defaultValue": null
},
{
- "name": "iid",
- "description": "The iid of the requirement to update",
+ "name": "projectPath",
+ "description": "Full project path the requirement is associated with",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
- "name": "String",
+ "name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
- "name": "projectPath",
- "description": "The project full path the requirement is associated with",
+ "name": "state",
+ "description": "State of the requirement",
+ "type": {
+ "kind": "ENUM",
+ "name": "RequirementState",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "iid",
+ "description": "The iid of the requirement to update",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
- "name": "ID",
+ "name": "String",
"ofType": null
}
},
@@ -52661,7 +55516,7 @@
},
{
"name": "requirement",
- "description": "The requirement after mutation",
+ "description": "Requirement after mutation",
"args": [
],
@@ -54606,6 +57461,63 @@
"deprecationReason": null
},
{
+ "name": "discussions",
+ "description": "All discussions on this noteable",
+ "args": [
+ {
+ "name": "after",
+ "description": "Returns the elements in the list that come after the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "before",
+ "description": "Returns the elements in the list that come before the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "first",
+ "description": "Returns the first _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "last",
+ "description": "Returns the last _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "DiscussionConnection",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "id",
"description": "GraphQL ID of the vulnerability",
"args": [
@@ -54731,6 +57643,63 @@
"deprecationReason": null
},
{
+ "name": "notes",
+ "description": "All notes on this noteable",
+ "args": [
+ {
+ "name": "after",
+ "description": "Returns the elements in the list that come after the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "before",
+ "description": "Returns the elements in the list that come before the specified cursor.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "first",
+ "description": "Returns the first _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "last",
+ "description": "Returns the last _n_ elements from the list.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "Int",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "OBJECT",
+ "name": "NoteConnection",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "primaryIdentifier",
"description": "Primary identifier of the vulnerability.",
"args": [
@@ -54899,6 +57868,112 @@
],
"inputFields": null,
"interfaces": [
+ {
+ "kind": "INTERFACE",
+ "name": "Noteable",
+ "ofType": null
+ }
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "INPUT_OBJECT",
+ "name": "VulnerabilityConfirmInput",
+ "description": "Autogenerated input type of VulnerabilityConfirm",
+ "fields": null,
+ "inputFields": [
+ {
+ "name": "id",
+ "description": "ID of the vulnerability to be confirmed",
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "VulnerabilityID",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "clientMutationId",
+ "description": "A unique identifier for the client performing the mutation.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "interfaces": null,
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
+ "name": "VulnerabilityConfirmPayload",
+ "description": "Autogenerated return type of VulnerabilityConfirm",
+ "fields": [
+ {
+ "name": "clientMutationId",
+ "description": "A unique identifier for the client performing the mutation.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "errors",
+ "description": "Errors encountered during execution of the mutation.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "vulnerability",
+ "description": "The vulnerability after state change",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "Vulnerability",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
],
"enumValues": null,
@@ -54972,6 +58047,118 @@
"possibleTypes": null
},
{
+ "kind": "INPUT_OBJECT",
+ "name": "VulnerabilityDismissInput",
+ "description": "Autogenerated input type of VulnerabilityDismiss",
+ "fields": null,
+ "inputFields": [
+ {
+ "name": "id",
+ "description": "ID of the vulnerability to be dismissed",
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "ID",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "comment",
+ "description": "Reason why vulnerability should be dismissed",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "clientMutationId",
+ "description": "A unique identifier for the client performing the mutation.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "interfaces": null,
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
+ "name": "VulnerabilityDismissPayload",
+ "description": "Autogenerated return type of VulnerabilityDismiss",
+ "fields": [
+ {
+ "name": "clientMutationId",
+ "description": "A unique identifier for the client performing the mutation.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "errors",
+ "description": "Errors encountered during execution of the mutation.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "vulnerability",
+ "description": "The vulnerability after dismissal",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "Vulnerability",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
"kind": "OBJECT",
"name": "VulnerabilityEdge",
"description": "An edge in a connection.",
@@ -56005,7 +59192,7 @@
"inputFields": [
{
"name": "id",
- "description": "ID of the vulnerability to be resolveed",
+ "description": "ID of the vulnerability to be resolved",
"type": {
"kind": "NON_NULL",
"name": null,
@@ -56100,6 +59287,108 @@
"possibleTypes": null
},
{
+ "kind": "INPUT_OBJECT",
+ "name": "VulnerabilityRevertToDetectedInput",
+ "description": "Autogenerated input type of VulnerabilityRevertToDetected",
+ "fields": null,
+ "inputFields": [
+ {
+ "name": "id",
+ "description": "ID of the vulnerability to be reverted",
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "VulnerabilityID",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "clientMutationId",
+ "description": "A unique identifier for the client performing the mutation.",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "interfaces": null,
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
+ "kind": "OBJECT",
+ "name": "VulnerabilityRevertToDetectedPayload",
+ "description": "Autogenerated return type of VulnerabilityRevertToDetected",
+ "fields": [
+ {
+ "name": "clientMutationId",
+ "description": "A unique identifier for the client performing the mutation.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "errors",
+ "description": "Errors encountered during execution of the mutation.",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "LIST",
+ "name": null,
+ "ofType": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ }
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "vulnerability",
+ "description": "The vulnerability after revert",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "OBJECT",
+ "name": "Vulnerability",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "inputFields": null,
+ "interfaces": [
+
+ ],
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
"kind": "OBJECT",
"name": "VulnerabilityScanner",
"description": "Represents a vulnerability scanner",
@@ -56443,6 +59732,42 @@
"description": "Severity in ascending order",
"isDeprecated": false,
"deprecationReason": null
+ },
+ {
+ "name": "title_desc",
+ "description": "Title in descending order",
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "title_asc",
+ "description": "Title in ascending order",
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "detected_desc",
+ "description": "Detection timestamp in descending order",
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "detected_asc",
+ "description": "Detection timestamp in ascending order",
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "report_type_desc",
+ "description": "Report Type in descending order",
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "report_type_asc",
+ "description": "Report Type in ascending order",
+ "isDeprecated": false,
+ "deprecationReason": null
}
],
"possibleTypes": null
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index fc27298aff2..4a3675df15d 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -209,6 +209,57 @@ Represents a project or group board.
| `name` | String | Name of the board |
| `weight` | Int | Weight of the board. |
+### BoardEpic
+
+Represents an epic on an issue board.
+
+| Field | Type | Description |
+| ----- | ---- | ----------- |
+| `author` | User! | Author of the epic |
+| `closedAt` | Time | Timestamp of when the epic was closed |
+| `confidential` | Boolean | Indicates if the epic is confidential |
+| `createdAt` | Time | Timestamp of when the epic was created |
+| `descendantCounts` | EpicDescendantCount | Number of open and closed descendant epics and issues |
+| `descendantWeightSum` | EpicDescendantWeights | Total weight of open and closed issues in the epic and its descendants |
+| `description` | String | Description of the epic |
+| `downvotes` | Int! | Number of downvotes the epic has received |
+| `dueDate` | Time | Due date of the epic |
+| `dueDateFixed` | Time | Fixed due date of the epic |
+| `dueDateFromMilestones` | Time | Inherited due date of the epic from milestones |
+| `dueDateIsFixed` | Boolean | Indicates if the due date has been manually set |
+| `group` | Group! | Group to which the epic belongs |
+| `hasChildren` | Boolean! | Indicates if the epic has children |
+| `hasIssues` | Boolean! | Indicates if the epic has direct issues |
+| `hasParent` | Boolean! | Indicates if the epic has a parent epic |
+| `healthStatus` | EpicHealthStatus | Current health status of the epic |
+| `id` | ID! | ID of the epic |
+| `iid` | ID! | Internal ID of the epic |
+| `parent` | Epic | Parent epic of the epic |
+| `reference` | String! | Internal reference of the epic. Returned in shortened format by default |
+| `relationPath` | String | URI path of the epic-issue relationship |
+| `relativePosition` | Int | The relative position of the epic in the epic tree |
+| `startDate` | Time | Start date of the epic |
+| `startDateFixed` | Time | Fixed start date of the epic |
+| `startDateFromMilestones` | Time | Inherited start date of the epic from milestones |
+| `startDateIsFixed` | Boolean | Indicates if the start date has been manually set |
+| `state` | EpicState! | State of the epic |
+| `subscribed` | Boolean! | Indicates the currently logged in user is subscribed to the epic |
+| `title` | String | Title of the epic |
+| `updatedAt` | Time | Timestamp of when the epic was updated |
+| `upvotes` | Int! | Number of upvotes the epic has received |
+| `userPermissions` | EpicPermissions! | Permissions for the current user on the resource |
+| `userPreferences` | BoardEpicUserPreferences | User preferences for the epic on the issue board |
+| `webPath` | String! | Web path of the epic |
+| `webUrl` | String! | Web URL of the epic |
+
+### BoardEpicUserPreferences
+
+Represents user preferences for a board epic.
+
+| Field | Type | Description |
+| ----- | ---- | ----------- |
+| `collapsed` | Boolean! | Indicates epic should be displayed as collapsed |
+
### BoardList
Represents a list for an issue board.
@@ -499,7 +550,7 @@ Autogenerated return type of CreateRequirement.
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
-| `requirement` | Requirement | The requirement after mutation |
+| `requirement` | Requirement | Requirement after mutation |
### CreateSnippetPayload
@@ -685,6 +736,7 @@ A collection of designs.
| Field | Type | Description |
| ----- | ---- | ----------- |
+| `copyState` | DesignCollectionCopyState | Copy state of the design collection |
| `design` | Design | Find a specific design |
| `designAtVersion` | DesignAtVersion | Find a design as of a version |
| `issue` | Issue! | Issue associated with the design collection |
@@ -739,6 +791,16 @@ A specific version in which designs were added, modified or deleted.
| `id` | ID! | ID of the design version |
| `sha` | ID! | SHA of the design version |
+### DestroyBoardListPayload
+
+Autogenerated return type of DestroyBoardList.
+
+| Field | Type | Description |
+| ----- | ---- | ----------- |
+| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
+| `errors` | String! => Array | Errors encountered during execution of the mutation. |
+| `list` | BoardList | The list after mutation. |
+
### DestroyBoardPayload
Autogenerated return type of DestroyBoard.
@@ -878,9 +940,9 @@ Represents an epic.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `author` | User! | Author of the epic |
-| `closedAt` | Time | Timestamp of the epic's closure |
+| `closedAt` | Time | Timestamp of when the epic was closed |
| `confidential` | Boolean | Indicates if the epic is confidential |
-| `createdAt` | Time | Timestamp of the epic's creation |
+| `createdAt` | Time | Timestamp of when the epic was created |
| `descendantCounts` | EpicDescendantCount | Number of open and closed descendant epics and issues |
| `descendantWeightSum` | EpicDescendantWeights | Total weight of open and closed issues in the epic and its descendants |
| `description` | String | Description of the epic |
@@ -907,7 +969,7 @@ Represents an epic.
| `state` | EpicState! | State of the epic |
| `subscribed` | Boolean! | Indicates the currently logged in user is subscribed to the epic |
| `title` | String | Title of the epic |
-| `updatedAt` | Time | Timestamp of the epic's last activity |
+| `updatedAt` | Time | Timestamp of when the epic was updated |
| `upvotes` | Int! | Number of upvotes the epic has received |
| `userPermissions` | EpicPermissions! | Permissions for the current user on the resource |
| `webPath` | String! | Web path of the epic |
@@ -1486,6 +1548,21 @@ Autogenerated return type of MergeRequestCreate.
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `mergeRequest` | MergeRequest | The merge request after mutation |
+### MergeRequestDiffRegistry
+
+Represents the Geo sync and verification state of a Merge Request diff.
+
+| Field | Type | Description |
+| ----- | ---- | ----------- |
+| `createdAt` | Time | Timestamp when the MergeRequestDiffRegistry was created |
+| `id` | ID! | ID of the MergeRequestDiffRegistry |
+| `lastSyncFailure` | String | Error message during sync of the MergeRequestDiffRegistry |
+| `lastSyncedAt` | Time | Timestamp of the most recent successful sync of the MergeRequestDiffRegistry |
+| `mergeRequestDiffId` | ID! | ID of the Merge Request diff |
+| `retryAt` | Time | Timestamp after which the MergeRequestDiffRegistry should be resynced |
+| `retryCount` | Int | Number of consecutive failed sync attempts of the MergeRequestDiffRegistry |
+| `state` | RegistryState | Sync state of the MergeRequestDiffRegistry |
+
### MergeRequestPermissions
Check permissions for the current user on a merge request.
@@ -1702,7 +1779,7 @@ Represents a package.
### PackageFileRegistry
-Represents the sync and verification state of a package file.
+Represents the Geo sync and verification state of a package file.
| Field | Type | Description |
| ----- | ---- | ----------- |
@@ -1837,7 +1914,7 @@ Autogenerated return type of PipelineRetry.
| `removeSourceBranchAfterMerge` | Boolean | Indicates if `Delete source branch` option should be enabled by default for all new merge requests of the project |
| `repository` | Repository | Git repository of the project |
| `requestAccessEnabled` | Boolean | Indicates if users can request member access to the project |
-| `requirement` | Requirement | Find a single requirement. Available only when feature flag `requirements_management` is enabled. |
+| `requirement` | Requirement | Find a single requirement |
| `requirementStatesCount` | RequirementStatesCount | Number of requirements for the project by their state |
| `sastCiConfiguration` | SastCiConfiguration | SAST CI configuration for the project |
| `securityDashboardPath` | String | Path to project's security dashboard |
@@ -2049,12 +2126,15 @@ Represents a requirement.
| ----- | ---- | ----------- |
| `author` | User! | Author of the requirement |
| `createdAt` | Time! | Timestamp of when the requirement was created |
+| `description` | String | Description of the requirement |
+| `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` |
| `id` | ID! | ID of the requirement |
| `iid` | ID! | Internal ID of the requirement |
| `lastTestReportState` | TestReportState | Latest requirement test report state |
| `project` | Project! | Project to which the requirement belongs |
| `state` | RequirementState! | State of the requirement |
| `title` | String | Title of the requirement |
+| `titleHtml` | String | The GitLab Flavored Markdown rendering of `title` |
| `updatedAt` | Time! | Timestamp of when the requirement was last updated |
| `userPermissions` | RequirementPermissions! | Permissions for the current user on the resource |
@@ -2079,6 +2159,16 @@ Counts of requirements by their state.
| `archived` | Int | Number of archived requirements |
| `opened` | Int | Number of opened requirements |
+### RevertVulnerabilityToDetectedPayload
+
+Autogenerated return type of RevertVulnerabilityToDetected.
+
+| Field | Type | Description |
+| ----- | ---- | ----------- |
+| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
+| `errors` | String! => Array | Errors encountered during execution of the mutation. |
+| `vulnerability` | Vulnerability | The vulnerability after revert |
+
### RootStorageStatistics
| Field | Type | Description |
@@ -2302,7 +2392,6 @@ Represents a snippet entry.
| ----- | ---- | ----------- |
| `author` | User | The owner of the snippet |
| `blob` **{warning-solid}** | SnippetBlob! | **Deprecated:** Use `blobs`. Deprecated in 13.3 |
-| `blobs` | SnippetBlob! => Array | Snippet blobs |
| `createdAt` | Time! | Timestamp this snippet was created |
| `description` | String | Description of the snippet |
| `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` |
@@ -2384,20 +2473,31 @@ Completion status of tasks.
| `completedCount` | Int! | Number of completed tasks |
| `count` | Int! | Number of total tasks |
-### TerraformStateRegistry
+### TerraformState
+
+| Field | Type | Description |
+| ----- | ---- | ----------- |
+| `createdAt` | Time! | Timestamp the Terraform state was created |
+| `id` | ID! | ID of the Terraform state |
+| `lockedAt` | Time | Timestamp the Terraform state was locked |
+| `lockedByUser` | User | The user currently holding a lock on the Terraform state |
+| `name` | String! | Name of the Terraform state |
+| `updatedAt` | Time! | Timestamp the Terraform state was updated |
+
+### TerraformStateVersionRegistry
-Represents the sync and verification state of a terraform state.
+Represents the Geo sync and verification state of a terraform state version.
| Field | Type | Description |
| ----- | ---- | ----------- |
-| `createdAt` | Time | Timestamp when the TerraformStateRegistry was created |
-| `id` | ID! | ID of the TerraformStateRegistry |
-| `lastSyncFailure` | String | Error message during sync of the TerraformStateRegistry |
-| `lastSyncedAt` | Time | Timestamp of the most recent successful sync of the TerraformStateRegistry |
-| `retryAt` | Time | Timestamp after which the TerraformStateRegistry should be resynced |
-| `retryCount` | Int | Number of consecutive failed sync attempts of the TerraformStateRegistry |
-| `state` | RegistryState | Sync state of the TerraformStateRegistry |
-| `terraformStateId` | ID! | ID of the TerraformState |
+| `createdAt` | Time | Timestamp when the TerraformStateVersionRegistry was created |
+| `id` | ID! | ID of the TerraformStateVersionRegistry |
+| `lastSyncFailure` | String | Error message during sync of the TerraformStateVersionRegistry |
+| `lastSyncedAt` | Time | Timestamp of the most recent successful sync of the TerraformStateVersionRegistry |
+| `retryAt` | Time | Timestamp after which the TerraformStateVersionRegistry should be resynced |
+| `retryCount` | Int | Number of consecutive failed sync attempts of the TerraformStateVersionRegistry |
+| `state` | RegistryState | Sync state of the TerraformStateVersionRegistry |
+| `terraformStateVersionId` | ID! | ID of the terraform state version |
### TestReport
@@ -2611,7 +2711,7 @@ Autogenerated return type of UpdateRequirement.
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
-| `requirement` | Requirement | The requirement after mutation |
+| `requirement` | Requirement | Requirement after mutation |
### UpdateSnippetPayload
@@ -2700,6 +2800,26 @@ Represents a vulnerability.
| `userPermissions` | VulnerabilityPermissions! | Permissions for the current user on the resource |
| `vulnerabilityPath` | String | URL to the vulnerability's details page |
+### VulnerabilityConfirmPayload
+
+Autogenerated return type of VulnerabilityConfirm.
+
+| Field | Type | Description |
+| ----- | ---- | ----------- |
+| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
+| `errors` | String! => Array | Errors encountered during execution of the mutation. |
+| `vulnerability` | Vulnerability | The vulnerability after state change |
+
+### VulnerabilityDismissPayload
+
+Autogenerated return type of VulnerabilityDismiss.
+
+| Field | Type | Description |
+| ----- | ---- | ----------- |
+| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
+| `errors` | String! => Array | Errors encountered during execution of the mutation. |
+| `vulnerability` | Vulnerability | The vulnerability after dismissal |
+
### VulnerabilityIdentifier
Represents a vulnerability identifier.
@@ -2812,6 +2932,16 @@ Autogenerated return type of VulnerabilityResolve.
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `vulnerability` | Vulnerability | The vulnerability after state change |
+### VulnerabilityRevertToDetectedPayload
+
+Autogenerated return type of VulnerabilityRevertToDetected.
+
+| Field | Type | Description |
+| ----- | ---- | ----------- |
+| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
+| `errors` | String! => Array | Errors encountered during execution of the mutation. |
+| `vulnerability` | Vulnerability | The vulnerability after revert |
+
### VulnerabilityScanner
Represents a vulnerability scanner.
@@ -2890,6 +3020,8 @@ Values for sorting alerts.
| Value | Description |
| ----- | ----------- |
+| `CREATED_ASC` | Created at ascending order |
+| `CREATED_DESC` | Created at descending order |
| `CREATED_TIME_ASC` | Created time by ascending order |
| `CREATED_TIME_DESC` | Created time by descending order |
| `ENDED_AT_ASC` | End time by ascending order |
@@ -2902,12 +3034,14 @@ Values for sorting alerts.
| `STARTED_AT_DESC` | Start time by descending order |
| `STATUS_ASC` | Status by order: Ignored > Resolved > Acknowledged > Triggered |
| `STATUS_DESC` | Status by order: Triggered > Acknowledged > Resolved > Ignored |
+| `UPDATED_ASC` | Updated at ascending order |
+| `UPDATED_DESC` | Updated at descending order |
| `UPDATED_TIME_ASC` | Created time by ascending order |
| `UPDATED_TIME_DESC` | Created time by descending order |
-| `created_asc` | Created at ascending order |
-| `created_desc` | Created at descending order |
-| `updated_asc` | Updated at ascending order |
-| `updated_desc` | Updated at descending order |
+| `created_asc` **{warning-solid}** | **Deprecated:** Use CREATED_ASC. Deprecated in 13.5 |
+| `created_desc` **{warning-solid}** | **Deprecated:** Use CREATED_DESC. Deprecated in 13.5 |
+| `updated_asc` **{warning-solid}** | **Deprecated:** Use UPDATED_ASC. Deprecated in 13.5 |
+| `updated_desc` **{warning-solid}** | **Deprecated:** Use UPDATED_DESC. Deprecated in 13.5 |
### AlertManagementSeverity
@@ -3007,6 +3141,16 @@ Mode of a commit action.
| `PASSED_VALIDATION` | Site validation process finished successfully |
| `PENDING_VALIDATION` | Site validation process has not started |
+### DesignCollectionCopyState
+
+Copy state of a DesignCollection.
+
+| Value | Description |
+| ----- | ----------- |
+| `ERROR` | The DesignCollection encountered an error during a copy |
+| `IN_PROGRESS` | The DesignCollection is being copied |
+| `READY` | The DesignCollection has no copy in progress |
+
### DesignVersionEvent
Mutation event of a design within a version.
@@ -3115,6 +3259,8 @@ Values for sorting issues.
| Value | Description |
| ----- | ----------- |
+| `CREATED_ASC` | Created at ascending order |
+| `CREATED_DESC` | Created at descending order |
| `DUE_DATE_ASC` | Due date by ascending order |
| `DUE_DATE_DESC` | Due date by descending order |
| `LABEL_PRIORITY_ASC` | Label priority by ascending order |
@@ -3123,13 +3269,19 @@ Values for sorting issues.
| `MILESTONE_DUE_DESC` | Milestone due date by descending order |
| `PRIORITY_ASC` | Priority by ascending order |
| `PRIORITY_DESC` | Priority by descending order |
+| `PUBLISHED_ASC` | Published issues shown last |
+| `PUBLISHED_DESC` | Published issues shown first |
| `RELATIVE_POSITION_ASC` | Relative position by ascending order |
+| `SEVERITY_ASC` | Severity from less critical to more critical |
+| `SEVERITY_DESC` | Severity from more critical to less critical |
+| `UPDATED_ASC` | Updated at ascending order |
+| `UPDATED_DESC` | Updated at descending order |
| `WEIGHT_ASC` | Weight by ascending order |
| `WEIGHT_DESC` | Weight by descending order |
-| `created_asc` | Created at ascending order |
-| `created_desc` | Created at descending order |
-| `updated_asc` | Updated at ascending order |
-| `updated_desc` | Updated at descending order |
+| `created_asc` **{warning-solid}** | **Deprecated:** Use CREATED_ASC. Deprecated in 13.5 |
+| `created_desc` **{warning-solid}** | **Deprecated:** Use CREATED_DESC. Deprecated in 13.5 |
+| `updated_asc` **{warning-solid}** | **Deprecated:** Use UPDATED_ASC. Deprecated in 13.5 |
+| `updated_desc` **{warning-solid}** | **Deprecated:** Use UPDATED_DESC. Deprecated in 13.5 |
### IssueState
@@ -3184,6 +3336,10 @@ Possible identifier types for a measurement.
| `ISSUES` | Issue count |
| `MERGE_REQUESTS` | Merge request count |
| `PIPELINES` | Pipeline count |
+| `PIPELINES_CANCELED` | Pipeline count with canceled status |
+| `PIPELINES_FAILED` | Pipeline count with failed status |
+| `PIPELINES_SKIPPED` | Pipeline count with skipped status |
+| `PIPELINES_SUCCEEDED` | Pipeline count with success status |
| `PROJECTS` | Project count |
| `USERS` | User count |
@@ -3193,6 +3349,8 @@ Values for sorting merge requests.
| Value | Description |
| ----- | ----------- |
+| `CREATED_ASC` | Created at ascending order |
+| `CREATED_DESC` | Created at descending order |
| `LABEL_PRIORITY_ASC` | Label priority by ascending order |
| `LABEL_PRIORITY_DESC` | Label priority by descending order |
| `MERGED_AT_ASC` | Merge time by ascending order |
@@ -3201,10 +3359,12 @@ Values for sorting merge requests.
| `MILESTONE_DUE_DESC` | Milestone due date by descending order |
| `PRIORITY_ASC` | Priority by ascending order |
| `PRIORITY_DESC` | Priority by descending order |
-| `created_asc` | Created at ascending order |
-| `created_desc` | Created at descending order |
-| `updated_asc` | Updated at ascending order |
-| `updated_desc` | Updated at descending order |
+| `UPDATED_ASC` | Updated at ascending order |
+| `UPDATED_DESC` | Updated at descending order |
+| `created_asc` **{warning-solid}** | **Deprecated:** Use CREATED_ASC. Deprecated in 13.5 |
+| `created_desc` **{warning-solid}** | **Deprecated:** Use CREATED_DESC. Deprecated in 13.5 |
+| `updated_asc` **{warning-solid}** | **Deprecated:** Use UPDATED_ASC. Deprecated in 13.5 |
+| `updated_desc` **{warning-solid}** | **Deprecated:** Use UPDATED_DESC. Deprecated in 13.5 |
### MergeRequestState
@@ -3259,6 +3419,7 @@ Values for sorting projects.
| `COMPOSER` | Packages from the composer package manager |
| `CONAN` | Packages from the conan package manager |
| `GENERIC` | Packages from the generic package manager |
+| `GOLANG` | Packages from the golang package manager |
| `MAVEN` | Packages from the maven package manager |
| `NPM` | Packages from the npm package manager |
| `NUGET` | Packages from the nuget package manager |
@@ -3428,10 +3589,14 @@ Common sort values.
| Value | Description |
| ----- | ----------- |
-| `created_asc` | Created at ascending order |
-| `created_desc` | Created at descending order |
-| `updated_asc` | Updated at ascending order |
-| `updated_desc` | Updated at descending order |
+| `CREATED_ASC` | Created at ascending order |
+| `CREATED_DESC` | Created at descending order |
+| `UPDATED_ASC` | Updated at ascending order |
+| `UPDATED_DESC` | Updated at descending order |
+| `created_asc` **{warning-solid}** | **Deprecated:** Use CREATED_ASC. Deprecated in 13.5 |
+| `created_desc` **{warning-solid}** | **Deprecated:** Use CREATED_DESC. Deprecated in 13.5 |
+| `updated_asc` **{warning-solid}** | **Deprecated:** Use UPDATED_ASC. Deprecated in 13.5 |
+| `updated_desc` **{warning-solid}** | **Deprecated:** Use UPDATED_DESC. Deprecated in 13.5 |
### TestReportState
@@ -3558,8 +3723,14 @@ Vulnerability sort values.
| Value | Description |
| ----- | ----------- |
+| `detected_asc` | Detection timestamp in ascending order |
+| `detected_desc` | Detection timestamp in descending order |
+| `report_type_asc` | Report Type in ascending order |
+| `report_type_desc` | Report Type in descending order |
| `severity_asc` | Severity in ascending order |
| `severity_desc` | Severity in descending order |
+| `title_asc` | Title in ascending order |
+| `title_desc` | Title in descending order |
### VulnerabilityState
diff --git a/doc/api/groups.md b/doc/api/groups.md
index ae3300e24fb..a74bb64816a 100644
--- a/doc/api/groups.md
+++ b/doc/api/groups.md
@@ -167,6 +167,89 @@ GET /groups/:id/subgroups
]
```
+## List a group's descendant groups
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/217115) in GitLab 13.5
+
+Get a list of visible descendant groups of this group.
+When accessed without authentication, only public groups are returned.
+
+By default, this request returns 20 results at a time because the API results [are paginated](README.md#pagination).
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+| ------------------------ | ----------------- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) of the immediate parent group |
+| `skip_groups` | array of integers | no | Skip the group IDs passed |
+| `all_available` | boolean | no | Show all the groups you have access to (defaults to `false` for authenticated users, `true` for admin). Attributes `owned` and `min_access_level` have precedence |
+| `search` | string | no | Return the list of authorized groups matching the search criteria |
+| `order_by` | string | no | Order groups by `name`, `path`, or `id`. 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 explicitly owned by the current user |
+| `min_access_level` | integer | no | Limit to groups where current user has at least this [access level](members.md#valid-access-levels) |
+
+```plaintext
+GET /groups/:id/descendant_groups
+```
+
+```json
+[
+ {
+ "id": 2,
+ "name": "Bar Group",
+ "path": "foo/bar",
+ "description": "A subgroup of Foo Group",
+ "visibility": "public",
+ "share_with_group_lock": false,
+ "require_two_factor_authentication": false,
+ "two_factor_grace_period": 48,
+ "project_creation_level": "developer",
+ "auto_devops_enabled": null,
+ "subgroup_creation_level": "owner",
+ "emails_disabled": null,
+ "mentions_disabled": null,
+ "lfs_enabled": true,
+ "default_branch_protection": 2,
+ "avatar_url": "http://gitlab.example.com/uploads/group/avatar/1/bar.jpg",
+ "web_url": "http://gitlab.example.com/groups/foo/bar",
+ "request_access_enabled": false,
+ "full_name": "Bar Group",
+ "full_path": "foo/bar",
+ "file_template_project_id": 1,
+ "parent_id": 123,
+ "created_at": "2020-01-15T12:36:29.590Z"
+ },
+ {
+ "id": 3,
+ "name": "Baz Group",
+ "path": "foo/bar/baz",
+ "description": "A subgroup of Bar Group",
+ "visibility": "public",
+ "share_with_group_lock": false,
+ "require_two_factor_authentication": false,
+ "two_factor_grace_period": 48,
+ "project_creation_level": "developer",
+ "auto_devops_enabled": null,
+ "subgroup_creation_level": "owner",
+ "emails_disabled": null,
+ "mentions_disabled": null,
+ "lfs_enabled": true,
+ "default_branch_protection": 2,
+ "avatar_url": "http://gitlab.example.com/uploads/group/avatar/1/baz.jpg",
+ "web_url": "http://gitlab.example.com/groups/foo/bar/baz",
+ "request_access_enabled": false,
+ "full_name": "Baz Group",
+ "full_path": "foo/bar/baz",
+ "file_template_project_id": 1,
+ "parent_id": 123,
+ "created_at": "2020-01-15T12:36:29.590Z"
+ }
+]
+```
+
## List a group's projects
Get a list of projects in this group. When accessed without authentication, only public projects are returned.
@@ -696,7 +779,7 @@ This is similar to creating a [New group](#new-group). You'll need the `parent_i
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --header "Content-Type: application/json" \
- --data '{"path": "<subgroup_path>", "name": "<subgroup_name>", "parent_id": <parent_group_id> } \
+ --data '{"path": "<subgroup_path>", "name": "<subgroup_name>", "parent_id": <parent_group_id> }' \
"https://gitlab.example.com/api/v4/groups/"
```
diff --git a/doc/api/issue_links.md b/doc/api/issue_links.md
index b6502bf099c..757910d0946 100644
--- a/doc/api/issue_links.md
+++ b/doc/api/issue_links.md
@@ -1,8 +1,11 @@
-# Issue links API **(STARTER)**
+# Issue links API **(CORE)**
+
+> The simple "relates to" relationship [moved](https://gitlab.com/gitlab-org/gitlab/-/issues/212329) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.4.
## List issue relations
-Get a list of related issues of a given issue, sorted by the relationship creation datetime (ascending).
+Get a list of a given issue's [related issues](../user/project/issues/related_issues.md),
+sorted by the relationship creation datetime (ascending).
Issues will be filtered according to the user authorizations.
```plaintext
@@ -55,19 +58,20 @@ Parameters:
## Create an issue link
-Creates a two-way relation between two issues. User must be allowed to update both issues in order to succeed.
+Creates a two-way relation between two issues. The user must be allowed to
+update both issues to succeed.
```plaintext
POST /projects/:id/issues/:issue_iid/links
```
-| 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 |
+| 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 |
| `target_project_id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) of a target project |
-| `target_issue_iid` | integer/string | yes | The internal ID of a target project's issue |
-| `link_type` | string | no | The type of the relation ("relates_to", "blocks", "is_blocked_by"), defaults to "relates_to"). |
+| `target_issue_iid` | integer/string | yes | The internal ID of a target project's issue |
+| `link_type` | string | no | The type of the relation ("relates_to", "blocks", "is_blocked_by"), defaults to "relates_to"). |
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/4/issues/1/links?target_project_id=5&target_issue_iid=1"
diff --git a/doc/api/issues.md b/doc/api/issues.md
index d8249869cab..b50ea7b42be 100644
--- a/doc/api/issues.md
+++ b/doc/api/issues.md
@@ -16,7 +16,7 @@ are paginated.
Read more on [pagination](README.md#pagination).
-CAUTION: **Deprecation:**
+DANGER: **Deprecated:**
The `reference` attribute in responses is deprecated in favor of `references`.
Introduced in [GitLab 12.6](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/20354).
@@ -33,19 +33,19 @@ use parameter `scope=all`.
```plaintext
GET /issues
-GET /issues?state=opened
-GET /issues?state=closed
+GET /issues?assignee_id=5
+GET /issues?author_id=5
+GET /issues?confidential=true
+GET /issues?iids[]=42&iids[]=43
GET /issues?labels=foo
GET /issues?labels=foo,bar
GET /issues?labels=foo,bar&state=opened
GET /issues?milestone=1.0.0
GET /issues?milestone=1.0.0&state=opened
-GET /issues?iids[]=42&iids[]=43
-GET /issues?author_id=5
-GET /issues?assignee_id=5
GET /issues?my_reaction_emoji=star
GET /issues?search=foo&in=title
-GET /issues?confidential=true
+GET /issues?state=closed
+GET /issues?state=opened
```
| Attribute | Type | Required | Description |
@@ -194,7 +194,7 @@ the `health_status` parameter:
]
```
-NOTE: **Note:**
+DANGER: **Deprecated:**
The `assignee` column is deprecated. We now show it as a single-sized array `assignees` to conform
to the GitLab EE API.
@@ -212,19 +212,19 @@ The preferred way to do this, is by using [personal access tokens](../user/profi
```plaintext
GET /groups/:id/issues
-GET /groups/:id/issues?state=opened
-GET /groups/:id/issues?state=closed
+GET /groups/:id/issues?assignee_id=5
+GET /groups/:id/issues?author_id=5
+GET /groups/:id/issues?confidential=true
+GET /groups/:id/issues?iids[]=42&iids[]=43
GET /groups/:id/issues?labels=foo
GET /groups/:id/issues?labels=foo,bar
GET /groups/:id/issues?labels=foo,bar&state=opened
GET /groups/:id/issues?milestone=1.0.0
GET /groups/:id/issues?milestone=1.0.0&state=opened
-GET /groups/:id/issues?iids[]=42&iids[]=43
-GET /groups/:id/issues?search=issue+title+or+description
-GET /groups/:id/issues?author_id=5
-GET /groups/:id/issues?assignee_id=5
GET /groups/:id/issues?my_reaction_emoji=star
-GET /groups/:id/issues?confidential=true
+GET /groups/:id/issues?search=issue+title+or+description
+GET /groups/:id/issues?state=closed
+GET /groups/:id/issues?state=opened
```
| Attribute | Type | Required | Description |
@@ -372,7 +372,7 @@ the `health_status` parameter:
]
```
-NOTE: **Note:**
+DANGER: **Deprecated:**
The `assignee` column is deprecated. We now show it as a single-sized array `assignees` to conform to the GitLab EE API.
NOTE: **Note:**
@@ -389,19 +389,19 @@ The preferred way to do this, is by using [personal access tokens](../user/profi
```plaintext
GET /projects/:id/issues
-GET /projects/:id/issues?state=opened
-GET /projects/:id/issues?state=closed
+GET /projects/:id/issues?assignee_id=5
+GET /projects/:id/issues?author_id=5
+GET /projects/:id/issues?confidential=true
+GET /projects/:id/issues?iids[]=42&iids[]=43
GET /projects/:id/issues?labels=foo
GET /projects/:id/issues?labels=foo,bar
GET /projects/:id/issues?labels=foo,bar&state=opened
GET /projects/:id/issues?milestone=1.0.0
GET /projects/:id/issues?milestone=1.0.0&state=opened
-GET /projects/:id/issues?iids[]=42&iids[]=43
-GET /projects/:id/issues?search=issue+title+or+description
-GET /projects/:id/issues?author_id=5
-GET /projects/:id/issues?assignee_id=5
GET /projects/:id/issues?my_reaction_emoji=star
-GET /projects/:id/issues?confidential=true
+GET /projects/:id/issues?search=issue+title+or+description
+GET /projects/:id/issues?state=closed
+GET /projects/:id/issues?state=opened
```
| Attribute | Type | Required | Description |
@@ -555,7 +555,7 @@ the `health_status` parameter:
]
```
-NOTE: **Note:**
+DANGER: **Deprecated:**
The `assignee` column is deprecated. We now show it as a single-sized array `assignees` to conform to the GitLab EE API.
NOTE: **Note:**
@@ -574,7 +574,7 @@ GET /issues/:id
| Attribute | Type | Required | Description |
|-------------|---------|----------|--------------------------------------|
-| `id` | integer | yes | The ID of the issue |
+| `id` | integer | yes | The ID of the issue |
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/issues/41"
@@ -663,10 +663,10 @@ Example response:
"weight": null,
"has_tasks": false,
"_links": {
- "self": "http://gitlab.dummy:3000/api/v4/projects/1/issues/1",
- "notes": "http://gitlab.dummy:3000/api/v4/projects/1/issues/1/notes",
- "award_emoji": "http://gitlab.dummy:3000/api/v4/projects/1/issues/1/award_emoji",
- "project": "http://gitlab.dummy:3000/api/v4/projects/1"
+ "self": "http://gitlab.example:3000/api/v4/projects/1/issues/1",
+ "notes": "http://gitlab.example:3000/api/v4/projects/1/issues/1/notes",
+ "award_emoji": "http://gitlab.example:3000/api/v4/projects/1/issues/1/award_emoji",
+ "project": "http://gitlab.example:3000/api/v4/projects/1"
},
"references": {
"short": "#1",
@@ -712,19 +712,19 @@ the `epic` property:
}
```
-NOTE: **Note:**
+DANGER: **Deprecated:**
The `assignee` column is deprecated. We now show it as a single-sized array `assignees` to conform
to the GitLab EE API.
+DANGER: **Deprecated:**
+The `epic_iid` attribute is deprecated, and [will be removed in version 5](https://gitlab.com/gitlab-org/gitlab/-/issues/35157).
+Please use `iid` of the `epic` attribute instead.
+
NOTE: **Note:**
The `closed_by` attribute was [introduced in GitLab 10.6](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/17042).
This value is only present for issues closed after GitLab 10.6 and if the user account
that closed the issue still exists.
-NOTE: **Note:**
-The `epic_iid` attribute is deprecated, and [will be removed in version 5](https://gitlab.com/gitlab-org/gitlab/-/issues/35157).
-Please use `iid` of the `epic` attribute instead.
-
## Single project issue
Get a single project issue.
@@ -874,17 +874,17 @@ property:
]
```
-NOTE: **Note:**
+DANGER: **Deprecated:**
The `assignee` column is deprecated. We now show it as a single-sized array `assignees` to conform to the GitLab EE API.
+DANGER: **Deprecated:**
+The `epic_iid` attribute is deprecated and [will be removed in version 5](https://gitlab.com/gitlab-org/gitlab/-/issues/35157).
+Please use `iid` of the `epic` attribute instead.
+
NOTE: **Note:**
The `closed_by` attribute was [introduced in GitLab 10.6](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/17042). This value is only present for issues closed after GitLab 10.6 and if the user account that closed
the issue still exists.
-NOTE: **Note:**
-The `epic_iid` attribute is deprecated and [will be removed in version 5](https://gitlab.com/gitlab-org/gitlab/-/issues/35157).
-Please use `iid` of the `epic` attribute instead.
-
## New issue
Creates a new project issue.
@@ -895,21 +895,21 @@ POST /projects/:id/issues
| Attribute | Type | Required | Description |
|-------------------------------------------|----------------|----------|--------------|
+| `assignee_ids` | integer array | no | The ID of the user(s) to assign the issue to. |
+| `confidential` | boolean | no | Set an issue to be confidential. Default is `false`. |
+| `created_at` | string | no | When the issue was created. Date time string, ISO 8601 formatted, for example `2016-03-11T03:45:40Z`. Requires administrator or project/group owner rights. |
+| `description` | string | no | The description of an issue. Limited to 1,048,576 characters. |
+| `discussion_to_resolve` | string | no | The ID of a discussion to resolve. This fills out the issue with a default description and mark the discussion as resolved. Use in combination with `merge_request_to_resolve_discussions_of`. |
+| `due_date` | string | no | The due date. Date time string in the format YEAR-MONTH-DAY, for example `2016-03-11` |
+| `epic_id` **(PREMIUM)** | integer | no | ID of the epic to add the issue to. Valid values are greater than or equal to 0. |
+| `epic_iid` **(PREMIUM)** | integer | no | IID of the epic to add the issue to. Valid values are greater than or equal to 0. (deprecated, [will be removed in version 5](https://gitlab.com/gitlab-org/gitlab/-/issues/35157)) |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `iid` | integer/string | no | The internal ID of the project's issue (requires administrator or project owner rights) |
-| `title` | string | yes | The title of an issue |
-| `description` | string | no | The description of an issue. Limited to 1,048,576 characters. |
-| `confidential` | boolean | no | Set an issue to be confidential. Default is `false`. |
-| `assignee_ids` | integer array | no | The ID of the user(s) to assign the issue to. |
-| `milestone_id` | integer | no | The global ID of a milestone to assign issue |
| `labels` | string | no | Comma-separated label names for an issue |
-| `created_at` | string | no | Date time string, ISO 8601 formatted, for example `2016-03-11T03:45:40Z` (requires administrator or project/group owner rights) |
-| `due_date` | string | no | Date time string in the format YEAR-MONTH-DAY, for example `2016-03-11` |
| `merge_request_to_resolve_discussions_of` | integer | no | The IID of a merge request in which to resolve all issues. This fills out the issue with a default description and mark all discussions as resolved. When passing a description or title, these values take precedence over the default values.|
-| `discussion_to_resolve` | string | no | The ID of a discussion to resolve. This fills out the issue with a default description and mark the discussion as resolved. Use in combination with `merge_request_to_resolve_discussions_of`. |
+| `milestone_id` | integer | no | The global ID of a milestone to assign issue |
+| `title` | string | yes | The title of an issue |
| `weight` **(STARTER)** | integer | no | The weight of the issue. Valid values are greater than or equal to 0. |
-| `epic_id` **(PREMIUM)** | integer | no | ID of the epic to add the issue to. Valid values are greater than or equal to 0. |
-| `epic_iid` **(PREMIUM)** | integer | no | IID of the epic to add the issue to. Valid values are greater than or equal to 0. (deprecated, [will be removed in version 5](https://gitlab.com/gitlab-org/gitlab/-/issues/35157)) |
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/4/issues?title=Issues%20with%20auth&labels=bug"
@@ -1002,7 +1002,7 @@ the `health_status` parameter:
]
```
-NOTE: **Note:**
+DANGER: **Deprecated:**
The `assignee` column is deprecated. We now show it as a single-sized array `assignees` to conform to the GitLab EE API.
NOTE: **Note:**
@@ -1019,29 +1019,43 @@ See [Issues rate limits](../user/admin_area/settings/rate_limit_on_issues_creati
Updates an existing project issue. This call is also used to mark an issue as
closed.
+At least one of the following parameters is required for the request to be successful:
+
+- `:assignee_id`
+- `:assignee_ids`
+- `:confidential`
+- `:created_at`
+- `:description`
+- `:discussion_locked`
+- `:due_date`
+- `:labels`
+- `:milestone_id`
+- `:state_event`
+- `:title`
+
```plaintext
PUT /projects/:id/issues/:issue_iid
```
| Attribute | Type | Required | Description |
|----------------|---------|----------|------------------------------------------------------------------------------------------------------------|
+| `add_labels` | string | no | Comma-separated label names to add to an issue. |
+| `assignee_ids` | integer array | no | The ID of the user(s) to assign the issue to. Set to `0` or provide an empty value to unassign all assignees. |
+| `confidential` | boolean | no | Updates an issue to be confidential |
+| `description` | string | no | The description of an issue. Limited to 1,048,576 characters. |
+| `discussion_locked` | boolean | no | Flag indicating if the issue's discussion is locked. If the discussion is locked only project members can add or edit comments. |
+| `due_date` | string | no | The due date. Date time string in the format YEAR-MONTH-DAY, for example `2016-03-11` |
+| `epic_id` **(PREMIUM)** | integer | no | ID of the epic to add the issue to. Valid values are greater than or equal to 0. |
+| `epic_iid` **(PREMIUM)** | integer | no | IID of the epic to add the issue to. Valid values are greater than or equal to 0. (deprecated, [will be removed in version 5](https://gitlab.com/gitlab-org/gitlab/-/issues/35157)) |
| `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 |
-| `title` | string | no | The title of an issue |
-| `description` | string | no | The description of an issue. Limited to 1,048,576 characters. |
-| `confidential` | boolean | no | Updates an issue to be confidential |
-| `assignee_ids` | integer array | no | The ID of the user(s) to assign the issue to. Set to `0` or provide an empty value to unassign all assignees. |
-| `milestone_id` | integer | no | The global ID of a milestone to assign the issue to. Set to `0` or provide an empty value to unassign a milestone.|
| `labels` | string | no | Comma-separated label names for an issue. Set to an empty string to unassign all labels. |
-| `add_labels` | string | no | Comma-separated label names to add to an issue. |
+| `milestone_id` | integer | no | The global ID of a milestone to assign the issue to. Set to `0` or provide an empty value to unassign a milestone.|
| `remove_labels`| string | no | Comma-separated label names to remove from an issue. |
| `state_event` | string | no | The state event of an issue. Set `close` to close the issue and `reopen` to reopen it |
-| `updated_at` | string | no | Date time string, ISO 8601 formatted, for example `2016-03-11T03:45:40Z` (requires administrator or project owner rights). Empty string or null values are not accepted.|
-| `due_date` | string | no | Date time string in the format YEAR-MONTH-DAY, for example `2016-03-11` |
+| `title` | string | no | The title of an issue |
+| `updated_at` | string | no | When the issue was updated. Date time string, ISO 8601 formatted, for example `2016-03-11T03:45:40Z` (requires administrator or project owner rights). Empty string or null values are not accepted.|
| `weight` **(STARTER)** | integer | no | The weight of the issue. Valid values are greater than or equal to 0. 0 |
-| `discussion_locked` | boolean | no | Flag indicating if the issue's discussion is locked. If the discussion is locked only project members can add or edit comments. |
-| `epic_id` **(PREMIUM)** | integer | no | ID of the epic to add the issue to. Valid values are greater than or equal to 0. |
-| `epic_iid` **(PREMIUM)** | integer | no | IID of the epic to add the issue to. Valid values are greater than or equal to 0. (deprecated, [will be removed in version 5](https://gitlab.com/gitlab-org/gitlab/-/issues/35157)) |
```shell
curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/4/issues/85?state_event=close"
@@ -1142,15 +1156,12 @@ the `health_status` parameter:
```
NOTE: **Note:**
-At least one of following parameters is required to be passed for the request to be successful: `:assignee_id`, `:assignee_ids`, `:confidential`, `:created_at`, `:description`, `:discussion_locked`, `:due_date`, `:labels`, `:milestone_id`, `:state_event`, or `:title`.
-
-NOTE: **Note:**
-`assignee` column is deprecated. We now show it as a single-sized array `assignees` to conform to the GitLab EE API.
-
-NOTE: **Note:**
The `closed_by` attribute was [introduced in GitLab 10.6](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/17042). This value is only present for issues closed after GitLab 10.6 and if the user account that closed
the issue still exists.
+DANGER: **Deprecated:**
+`assignee` column is deprecated. We now show it as a single-sized array `assignees` to conform to the GitLab EE API.
+
## Delete an issue
Only for admins and project owners. Deletes the issue in question.
@@ -1309,7 +1320,7 @@ the `health_status` parameter:
]
```
-NOTE: **Note:**
+DANGER: **Deprecated:**
The `assignee` column is deprecated. We now show it as a single-sized array `assignees` to conform to the GitLab EE API.
NOTE: **Note:**
@@ -1418,7 +1429,7 @@ the `weight` parameter:
}
```
-NOTE: **Note:**
+DANGER: **Deprecated:**
The `assignee` column is deprecated. We now show it as a single-sized array `assignees` to conform to the GitLab EE API.
NOTE: **Note:**
@@ -1496,10 +1507,10 @@ Example response:
}
```
-## Create a to-do
+## Create a to do
-Manually creates a to-do for the current user on an issue. If
-there already exists a to-do for the user on that issue, status code `304` is
+Manually creates a to do for the current user on an issue. If
+there already exists a to do for the user on that issue, status code `304` is
returned.
```plaintext
@@ -1608,7 +1619,7 @@ Example response:
}
```
-NOTE: **Note:**
+DANGER: **Deprecated:**
The `assignee` column is deprecated. We now show it as a single-sized array `assignees` to conform to the GitLab EE API.
NOTE: **Note:**
@@ -1625,9 +1636,9 @@ POST /projects/:id/issues/:issue_iid/time_estimate
| Attribute | Type | Required | Description |
|-------------|---------|----------|------------------------------------------|
+| `duration` | string | yes | The duration in human format. e.g: 3h30m |
| `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 |
-| `duration` | string | yes | The duration in human format. e.g: 3h30m |
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/issues/93/time_estimate?duration=3h30m"
@@ -1682,9 +1693,9 @@ POST /projects/:id/issues/:issue_iid/add_spent_time
| Attribute | Type | Required | Description |
|-------------|---------|----------|------------------------------------------|
+| `duration` | string | yes | The duration in human format. e.g: 3h30m |
| `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 |
-| `duration` | string | yes | The duration in human format. e.g: 3h30m |
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/issues/93/add_spent_time?duration=1h"
diff --git a/doc/api/license.md b/doc/api/license.md
index 71e95fc3202..dcdf019059b 100644
--- a/doc/api/license.md
+++ b/doc/api/license.md
@@ -1,7 +1,7 @@
# License **(CORE ONLY)**
-In order to interact with license endpoints, you need to authenticate yourself
-as an admin.
+To interact with license endpoints, you need to authenticate yourself as an
+admin.
## Retrieve information about the current license
diff --git a/doc/api/lint.md b/doc/api/lint.md
index f4d8a0bc011..df7198ac5c6 100644
--- a/doc/api/lint.md
+++ b/doc/api/lint.md
@@ -4,11 +4,14 @@ group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
-# Validate the `.gitlab-ci.yml` (API)
+# CI Lint API
+
+## Validate the CI YAML config
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/5953) in GitLab 8.12.
-Checks if your `.gitlab-ci.yml` file is valid.
+Checks if CI/CD YAML configuration is valid. This endpoint validates basic CI/CD
+configuration syntax. It doesn't have any namespace specific context.
```plaintext
POST /ci/lint
@@ -16,13 +19,15 @@ POST /ci/lint
| Attribute | Type | Required | Description |
| ---------- | ------- | -------- | -------- |
-| `content` | string | yes | the `.gitlab-ci.yaml` content|
+| `content` | string | yes | The CI/CD configuration content. |
+| `include_merged_yaml` | boolean | no | If the [expanded CI/CD configuration](#yaml-expansion) should be included in the response. |
```shell
curl --header "Content-Type: application/json" "https://gitlab.example.com/api/v4/ci/lint" --data '{"content": "{ \"image\": \"ruby:2.6\", \"services\": [\"postgres\"], \"before_script\": [\"bundle install\", \"bundle exec rake db:create\"], \"variables\": {\"DB_NAME\": \"postgres\"}, \"types\": [\"test\", \"deploy\", \"notify\"], \"rspec\": { \"script\": \"rake spec\", \"tags\": [\"ruby\", \"postgres\"], \"only\": [\"branches\"]}}"}'
```
-Be sure to copy paste the exact contents of `.gitlab-ci.yml` as YAML is very picky about indentation and spaces.
+Be sure to paste the exact contents of your GitLab CI/CD YAML config because YAML
+is very sensitive about indentation and spacing.
Example responses:
@@ -53,3 +58,39 @@ Example responses:
"error": "content is missing"
}
```
+
+### YAML expansion
+
+The expansion only works for CI configurations that don't have local [includes](../ci/yaml/README.md#include).
+
+Example contents of a `.gitlab-ci.yml` passed to the CI Lint API with
+`include_merged_yaml` set as true:
+
+```yaml
+include:
+ remote: 'https://example.com/remote.yaml'
+
+test:
+ stage: test
+ script:
+ - echo 1
+```
+
+Example contents of `https://example.com/remote.yaml`:
+
+```yaml
+another_test:
+ stage: test
+ script:
+ - echo 2
+```
+
+Example response:
+
+```json
+{
+ "status": "valid",
+ "errors": [],
+ "merged_config": "---\n:another_test:\n :stage: test\n :script: echo 2\n:test:\n :stage: test\n :script: echo 1\n"
+}
+```
diff --git a/doc/api/members.md b/doc/api/members.md
index 76d63b277c4..43012a5cb80 100644
--- a/doc/api/members.md
+++ b/doc/api/members.md
@@ -223,6 +223,58 @@ Example response:
}
```
+## List all billable members of a group
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/217384) in GitLab 13.5.
+
+Gets a list of group members who counts as billable, including members in the sub group/project.
+
+This function takes [pagination](README.md#pagination) parameters `page` and `per_page` to restrict the list of users.
+
+```plaintext
+GET /groups/:id/billable_members
+```
+
+| 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 |
+
+```shell
+curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/:id/billable_members"
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 1,
+ "username": "raymond_smith",
+ "name": "Raymond Smith",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/c2525a7f58ae3776070e44c106c48e15?s=80&d=identicon",
+ "web_url": "http://192.168.1.8:3000/root",
+ },
+ {
+ "id": 2,
+ "username": "john_doe",
+ "name": "John Doe",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/c2525a7f58ae3776070e44c106c48e15?s=80&d=identicon",
+ "web_url": "http://192.168.1.8:3000/root",
+ "email": "john@example.com"
+ },
+ {
+ "id": 3,
+ "username": "foo_bar",
+ "name": "Foo bar",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/c2525a7f58ae3776070e44c106c48e15?s=80&d=identicon",
+ "web_url": "http://192.168.1.8:3000/root"
+ }
+]
+```
+
## Add a member to a group or project
Adds a member to a group or project.
diff --git a/doc/api/merge_request_approvals.md b/doc/api/merge_request_approvals.md
index 643d03b6fb8..89e4224c735 100644
--- a/doc/api/merge_request_approvals.md
+++ b/doc/api/merge_request_approvals.md
@@ -60,7 +60,7 @@ POST /projects/:id/approvals
| `disable_overriding_approvers_per_merge_request` | boolean | no | Allow/Disallow overriding approvers per MR |
| `merge_requests_author_approval` | boolean | no | Allow/Disallow authors from self approving merge requests; `true` means authors can self approve |
| `merge_requests_disable_committers_approval` | boolean | no | Allow/Disallow committers from self approving merge requests |
-| `require_password_to_approve` | boolean | no | Require approver to enter a password in order to authenticate before adding the approval |
+| `require_password_to_approve` | boolean | no | Require approver to enter a password to authenticate before adding the approval |
```json
{
diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md
index faefc445210..944a5530100 100644
--- a/doc/api/merge_requests.md
+++ b/doc/api/merge_requests.md
@@ -2085,10 +2085,10 @@ the `approvals_before_merge` parameter:
}
```
-## Create a to-do
+## Create a to do
-Manually creates a to-do for the current user on a merge request.
-If there already exists a to-do for the user on that merge request,
+Manually creates a to do for the current user on a merge request.
+If there already exists a to do for the user on that merge request,
status code `304` is returned.
```plaintext
diff --git a/doc/api/namespaces.md b/doc/api/namespaces.md
index ba59d467bc8..35a8226b4d8 100644
--- a/doc/api/namespaces.md
+++ b/doc/api/namespaces.md
@@ -87,6 +87,23 @@ the `plan` parameter associated with a namespace:
]
```
+Users on GitLab.com will also see a `max_seats_used` parameter. `max_seats_used`
+is the highest number of users the group had.
+
+`max_seats_used` will be non-zero only for namespaces on paid plans.
+
+```json
+[
+ {
+ "id": 1,
+ "name": "user1",
+ "billable_members_count": 2,
+ "max_seats_used": 3,
+ ...
+ }
+]
+```
+
NOTE: **Note:**
Only group maintainers/owners are presented with `members_count_with_descendants`, as well as `plan` **(BRONZE ONLY)**.
@@ -123,6 +140,7 @@ Example response:
"web_url": "https://gitlab.example.com/groups/twitter",
"members_count_with_descendants": 2,
"billable_members_count": 2,
+ "max_seats_used": 0,
"plan": "default",
"trial_ends_on": null,
"trial": false
@@ -162,6 +180,7 @@ Example response:
"web_url": "https://gitlab.example.com/groups/group1",
"members_count_with_descendants": 2,
"billable_members_count": 2,
+ "max_seats_used": 0,
"plan": "default",
"trial_ends_on": null,
"trial": false
@@ -188,6 +207,7 @@ Example response:
"web_url": "https://gitlab.example.com/groups/group1",
"members_count_with_descendants": 2,
"billable_members_count": 2,
+ "max_seats_used": 0,
"plan": "default",
"trial_ends_on": null,
"trial": false
diff --git a/doc/api/openapi/openapi.yaml b/doc/api/openapi/openapi.yaml
index 8aa4de62501..8c46804d86f 100644
--- a/doc/api/openapi/openapi.yaml
+++ b/doc/api/openapi/openapi.yaml
@@ -9,9 +9,9 @@ info:
When viewing this on gitlab.com, you can test API calls directly from the browser
against the `gitlab.com` instance, if you are logged in.
The feature uses the current [GitLab session cookie](https://docs.gitlab.com/ee/api/README.html#session-cookie),
- so each request is made using your account.
+ so each request is made using your account.
- Read more at <https://docs.gitlab.com/ee/development/documentation/styleguide.html#restful-api>.
+ Read more at <https://docs.gitlab.com/ee/development/documentation/restful_api_styleguide.html>.
version: "v4"
title: "GitLab API"
termsOfService: "https://about.gitlab.com/terms/"
diff --git a/doc/api/packages.md b/doc/api/packages.md
index cf65b518844..d4e69b9bc66 100644
--- a/doc/api/packages.md
+++ b/doc/api/packages.md
@@ -26,7 +26,7 @@ GET /projects/:id/packages
| `id` | integer/string | yes | ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `order_by`| string | no | The field to use as order. One of `created_at` (default), `name`, `version`, or `type`. |
| `sort` | string | no | The direction of the order, either `asc` (default) for ascending order or `desc` for descending order. |
-| `package_type` | string | no | Filter the returned packages by type. One of `conan`, `maven`, `npm`, `pypi`, `composer`, or `nuget`. (_Introduced in GitLab 12.9_)
+| `package_type` | string | no | Filter the returned packages by type. One of `conan`, `maven`, `npm`, `pypi`, `composer`, `nuget`, or `golang`. (_Introduced in GitLab 12.9_)
| `package_name` | string | no | Filter the project packages with a fuzzy search by name. (_Introduced in GitLab 12.9_)
```shell
@@ -50,6 +50,19 @@ Example response:
"version": "1.0.3",
"package_type": "npm",
"created_at": "2019-11-27T03:37:38.711Z"
+ },
+ {
+ "id": 3,
+ "name": "Hello/0.1@mycompany/stable",
+ "conan_package_name": "Hello",
+ "version": "0.1",
+ "package_type": "conan",
+ "_links": {
+ "web_path": "/foo/bar/-/packages/3",
+ "delete_api_path": "https://gitlab.example.com/api/v4/projects/1/packages/3"
+ },
+ "created_at": "2029-12-16T20:33:34.316Z",
+ "tags": []
}
]
```
@@ -73,7 +86,7 @@ GET /groups/:id/packages
| `exclude_subgroups` | boolean | false | If the parameter is included as true, packages from projects from subgroups are not listed. Default is `false`. |
| `order_by`| string | no | The field to use as order. One of `created_at` (default), `name`, `version`, `type`, or `project_path`. |
| `sort` | string | no | The direction of the order, either `asc` (default) for ascending order or `desc` for descending order. |
-| `package_type` | string | no | Filter the returned packages by type. One of `conan`, `maven`, `npm`, `pypi`, `composer`, or `nuget`. (_Introduced in GitLab 12.9_) |
+| `package_type` | string | no | Filter the returned packages by type. One of `conan`, `maven`, `npm`, `pypi`, `composer`, `nuget`, or `golang`. (_Introduced in GitLab 12.9_) |
| `package_name` | string | no | Filter the project packages with a fuzzy search by name. (_[Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/30980) in GitLab 13.0_)
```shell
diff --git a/doc/api/releases/index.md b/doc/api/releases/index.md
index 357f7e7a125..54dc2ecaa33 100644
--- a/doc/api/releases/index.md
+++ b/doc/api/releases/index.md
@@ -365,7 +365,7 @@ POST /projects/:id/releases
| `assets:links` | array of hash | no | An array of assets links. |
| `assets:links:name`| string | required by: `assets:links` | The name of the link. |
| `assets:links:url` | string | required by: `assets:links` | The URL of the link. |
-| `assets:links:filepath` | string | no | Optional path for a [Direct Asset link](../../user/project/releases.md).
+| `assets:links:filepath` | string | no | Optional path for a [Direct Asset link](../../user/project/releases/index.md).
| `assets:links:link_type` | string | no | The type of the link: `other`, `runbook`, `image`, `package`. Defaults to `other`.
| `released_at` | datetime | no | The date when the release will be/was ready. Defaults to the current time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). |
diff --git a/doc/api/repositories.md b/doc/api/repositories.md
index 305216f853a..7e94ff7b7f2 100644
--- a/doc/api/repositories.md
+++ b/doc/api/repositories.md
@@ -24,7 +24,8 @@ Parameters:
- `path` (optional) - The path inside repository. Used to get content of subdirectories
- `ref` (optional) - The name of a repository branch or tag or if not given the default branch
- `recursive` (optional) - Boolean value used to get a recursive tree (false by default)
-- `per_page` (optional) - Number of results to show per page. If not specified, defaults to `20`
+- `per_page` (optional) - Number of results to show per page. If not specified, defaults to `20`.
+ Read more on [pagination](README.md#pagination).
```json
[
diff --git a/doc/api/resource_iteration_events.md b/doc/api/resource_iteration_events.md
index f774cdfe9c7..47f4d70fdf1 100644
--- a/doc/api/resource_iteration_events.md
+++ b/doc/api/resource_iteration_events.md
@@ -6,14 +6,8 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Resource iteration events API **(STARTER)**
-> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40850) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.4
-> - It's [deployed behind a feature flag](../user/feature_flags.md), enabled by default.
-> - It's enabled on GitLab.com.
-> - It's recommended for production use.
-> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#enable-or-disable-iterations-events-tracking).
-
-NOTE: **Note:**
-This feature might not be available to you. Check the **version history** note above for details.
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/229463) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.4.
+> - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/229463) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.5.
Resource iteration events keep track of what happens to GitLab [issues](../user/project/issues/).
@@ -154,22 +148,3 @@ Example response:
"action": "remove"
}
```
-
-### Enable or disable iterations events tracking **(STARTER)**
-
-Iterations events tracking is under development but ready for production use.
-It is deployed behind a feature flag that is **enabled by default**.
-[GitLab administrators with access to the GitLab Rails console](../administration/feature_flags.md)
-can opt to disable it.
-
-To enable it:
-
-```ruby
-Feature.enable(:track_iteration_change_events)
-```
-
-To disable it:
-
-```ruby
-Feature.disable(:track_iteration_change_events)
-```
diff --git a/doc/api/runners.md b/doc/api/runners.md
index 436abe0a706..55dcf1dc3ee 100644
--- a/doc/api/runners.md
+++ b/doc/api/runners.md
@@ -271,22 +271,6 @@ Example response:
}
```
-## Remove a runner
-
-Remove a runner.
-
-```plaintext
-DELETE /runners/:id
-```
-
-| Attribute | Type | Required | Description |
-|-----------|---------|----------|---------------------|
-| `id` | integer | yes | The ID of a runner |
-
-```shell
-curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/runners/6"
-```
-
## List runner's jobs
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/15432) in GitLab 10.3.
@@ -466,7 +450,7 @@ Example response:
Disable a specific runner from the project. It works only if the project isn't
the only project associated with the specified runner. If so, an error is
-returned. Use the [Remove a runner](#remove-a-runner) call instead.
+returned. Use the call to [delete a runner](#delete-a-runner) instead.
```plaintext
DELETE /projects/:id/runners/:runner_id
@@ -580,9 +564,32 @@ Example response:
}
```
-## Delete a registered runner
+## Delete a runner
+
+There are two ways to delete a runner:
+
+- By specifying the runner ID.
+- By specifying the runner's authentication token.
-Deletes a registered runner.
+### Delete a runner by ID
+
+To delete the runner by ID, use your access token with the runner's ID:
+
+```plaintext
+DELETE /runners/:id
+```
+
+| Attribute | Type | Required | Description |
+|-------------|---------|----------|---------------------|
+| `id` | integer | yes | The ID of a runner. The ID is visible in the UI under **Settings > CI/CD**. Expand **Runners**, and below the **Remove Runner** button is an ID preceded by the pound sign, for example, `#6`. |
+
+```shell
+curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/runners/6"
+```
+
+### Delete a runner by authentication token
+
+To delete the runner by using its authentication token:
```plaintext
DELETE /runners
@@ -590,7 +597,7 @@ DELETE /runners
| Attribute | Type | Required | Description |
|-------------|---------|----------|---------------------|
-| `token` | string | yes | Runner's [authentication token](#registration-and-authentication-tokens). |
+| `token` | string | yes | The runner's [authentication token](#registration-and-authentication-tokens). |
```shell
curl --request DELETE "https://gitlab.example.com/api/v4/runners" --form "token=<authentication_token>"
diff --git a/doc/api/search.md b/doc/api/search.md
index cb90b9a064c..ae663f64755 100644
--- a/doc/api/search.md
+++ b/doc/api/search.md
@@ -27,7 +27,7 @@ GET /search
Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones, snippet_titles, users.
-If Elasticsearch is enabled additional scopes available are blobs, wiki_blobs and commits. Find more about [the feature](../integration/elasticsearch.md). **(STARTER)**
+If Elasticsearch is enabled additional scopes available are blobs, wiki_blobs, notes, and commits. Find more about [the feature](../integration/elasticsearch.md). **(STARTER)**
The response depends on the requested scope.
@@ -362,6 +362,40 @@ Example response:
NOTE: **Note:**
`filename` is deprecated in favor of `path`. Both return the full path of the file inside the repository, but in the future `filename` will be only the file name and not the full path. For details, see [issue 34521](https://gitlab.com/gitlab-org/gitlab/-/issues/34521).
+### Scope: notes **(STARTER)**
+
+This scope is available only if [Elasticsearch](../integration/elasticsearch.md) is enabled.
+
+```shell
+curl --header "PRIVATE-TOKEN: <your_access_token>" "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: users
```shell
@@ -402,7 +436,7 @@ GET /groups/:id/search
Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones, users.
-If Elasticsearch is enabled additional scopes available are blobs, wiki_blobs and commits. Find more about [the feature](../integration/elasticsearch.md). **(STARTER)**
+If Elasticsearch is enabled additional scopes available are blobs, wiki_blobs, notes, and commits. Find more about [the feature](../integration/elasticsearch.md). **(STARTER)**
The response depends on the requested scope.
@@ -706,6 +740,40 @@ Example response:
NOTE **Note:**
`filename` is deprecated in favor of `path`. Both return the full path of the file inside the repository, but in the future `filename` will be only the file name and not the full path. For details, see [issue 34521](https://gitlab.com/gitlab-org/gitlab/-/issues/34521).
+### Scope: notes **(STARTER)**
+
+This scope is available only if [Elasticsearch](../integration/elasticsearch.md) is enabled.
+
+```shell
+curl --header "PRIVATE-TOKEN: <your_access_token>" "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: users
```shell
diff --git a/doc/api/services.md b/doc/api/services.md
index 405047a433d..7c01e43a4d8 100644
--- a/doc/api/services.md
+++ b/doc/api/services.md
@@ -659,7 +659,7 @@ Parameters:
| `webhook` | string | true | The Hangouts Chat webhook. For example, `https://chat.googleapis.com/v1/spaces...`. |
| `notify_only_broken_pipelines` | boolean | false | Send notifications for broken pipelines |
| `notify_only_default_branch` | boolean | false | DEPRECATED: This parameter has been replaced with `branches_to_be_notified` |
-| `branches_to_be_notified` | string | all | Branches to send notifications for. Valid options are "all", "default", "protected", and "default_and_protected" |
+| `branches_to_be_notified` | string | false | Branches to send notifications for. Valid options are "all", "default", "protected", and "default_and_protected" |
| `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 |
@@ -714,7 +714,7 @@ Parameters:
| `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 |
-| `confidental_note_events` | boolean | false | Enable notifications for confidential note events |
+| `confidential_note_events` | boolean | false | Enable notifications for confidential note events |
| `pipeline_events` | boolean | false | Enable notifications for pipeline events |
### Delete HipChat service
diff --git a/doc/api/settings.md b/doc/api/settings.md
index c8a466d1fcd..deff2535f9c 100644
--- a/doc/api/settings.md
+++ b/doc/api/settings.md
@@ -2,8 +2,8 @@
These API calls allow you to read and modify GitLab instance
[application settings](#list-of-settings-that-can-be-accessed-via-api-calls)
-as appear in `/admin/application_settings/general`. You have to be an
-administrator in order to perform this action.
+as they appear in `/admin/application_settings/general`. You must be an
+administrator to perform this action.
## Get current application settings
@@ -185,13 +185,14 @@ Example responses: **(PREMIUM ONLY)**
## List of settings that can be accessed via API calls
-In general, all settings are optional. Certain settings though, if enabled, will
-require other settings to be set in order to function properly. These requirements
-are listed in the descriptions of the relevant settings.
+In general, all settings are optional. Certain settings though, if enabled,
+require other settings to be set to function properly. These requirements are
+listed in the descriptions of the relevant settings.
-| Attribute | Type | Required | Description |
-| --------- | ---- | :------: | ----------- |
-| `admin_notification_email` | string | no | Abuse reports will be sent to this address if it is set. Abuse reports are always available in the Admin Area. |
+| Attribute | Type | Required | Description |
+|------------------------------------------|------------------|:------------------------------------:|-------------|
+| `admin_notification_email` | string | no | Deprecated: Use `abuse_notification_email` instead. If set, [abuse reports](../user/admin_area/abuse_reports.md) are sent to this address. Abuse reports are always available in the Admin Area. |
+| `abuse_notification_email` | string | no | If set, [abuse reports](../user/admin_area/abuse_reports.md) are sent to this address. Abuse reports are always available in the Admin Area. |
| `after_sign_out_path` | string | no | Where to redirect users after logout. |
| `after_sign_up_text` | string | no | Text shown to the user after signing up |
| `akismet_api_key` | string | required by: `akismet_enabled` | API key for Akismet spam protection. |
@@ -255,10 +256,10 @@ are listed in the descriptions of the relevant settings.
| `external_auth_client_cert` | string | no | (**If enabled, requires:** `external_auth_client_key`) The certificate to use to authenticate with the external authorization service |
| `external_auth_client_key_pass` | string | no | Passphrase to use for the private key when authenticating with the external service this is encrypted when stored |
| `external_auth_client_key` | string | required by: `external_auth_client_cert` | Private key for the certificate when authentication is required for the external authorization service, this is encrypted when stored |
-| `external_authorization_service_default_label` | string | required by: `external_authorization_service_enabled` | The default classification label to use when requesting authorization and no classification label has been specified on the project |
+| `external_authorization_service_default_label` | string | required by:<br>`external_authorization_service_enabled` | The default classification label to use when requesting authorization and no classification label has been specified on the project. |
| `external_authorization_service_enabled` | boolean | no | (**If enabled, requires:** `external_authorization_service_default_label`, `external_authorization_service_timeout` and `external_authorization_service_url`) Enable using an external authorization service for accessing projects |
-| `external_authorization_service_timeout` | float | required by: `external_authorization_service_enabled` | The timeout after which an authorization request is aborted, in seconds. When a request times out, access is denied to the user. (min: 0.001, max: 10, step: 0.001) |
-| `external_authorization_service_url` | string | required by: `external_authorization_service_enabled` | URL to which authorization requests will be directed |
+| `external_authorization_service_timeout` | float | required by:<br>`external_authorization_service_enabled` | The timeout after which an authorization request is aborted, in seconds. When a request times out, access is denied to the user. (min: 0.001, max: 10, step: 0.001). |
+| `external_authorization_service_url` | string | required by:<br>`external_authorization_service_enabled` | URL to which authorization requests are directed. |
| `file_template_project_id` | integer | no | **(PREMIUM)** The ID of a project to load custom file templates from |
| `first_day_of_week` | integer | no | Start day of the week for calendar views and date pickers. Valid values are `0` (default) for Sunday, `1` for Monday, and `6` for Saturday. |
| `geo_node_allowed_ips` | string | yes | **(PREMIUM)** Comma-separated list of IPs and CIDRs of allowed secondary nodes. For example, `1.1.1.1, 2.2.2.0/24`. |
@@ -321,7 +322,7 @@ are listed in the descriptions of the relevant settings.
| `receive_max_input_size` | integer | no | Maximum push size (MB). |
| `repository_checks_enabled` | boolean | no | GitLab will periodically run `git fsck` in all project and wiki repositories to look for silent disk corruption issues. |
| `repository_size_limit` | integer | no | **(PREMIUM)** Size limit per repository (MB) |
-| `repository_storages_weighted` | hash of strings to integers | no | (GitLab 13.1 and later) Hash of names of taken from `gitlab.yml` to weights. New projects are created in one of these stores, chosen by a weighted random selection. |
+| `repository_storages_weighted` | hash of strings to integers | no | (GitLab 13.1 and later) Hash of names of taken from `gitlab.yml` to [weights](../administration/repository_storage_paths.md#choose-where-new-repositories-will-be-stored). New projects are created in one of these stores, chosen by a weighted random selection. |
| `repository_storages` | array of strings | no | (GitLab 13.0 and earlier) List of names of enabled storage paths, taken from `gitlab.yml`. New projects are created in one of these stores, chosen at random. |
| `require_two_factor_authentication` | boolean | no | (**If enabled, requires:** `two_factor_grace_period`) Require all users to set up Two-factor authentication. |
| `restricted_visibility_levels` | array of strings | no | Selected levels cannot be used by non-admin users for groups, projects or snippets. Can take `private`, `internal` and `public` as a parameter. Default is `null` which means there is no restriction. |
@@ -351,14 +352,14 @@ are listed in the descriptions of the relevant settings.
| `terminal_max_session_time` | integer | no | Maximum time for web terminal websocket connection (in seconds). Set to `0` for unlimited time. |
| `terms` | text | required by: `enforce_terms` | (**Required by:** `enforce_terms`) Markdown content for the ToS. |
| `throttle_authenticated_api_enabled` | boolean | no | (**If enabled, requires:** `throttle_authenticated_api_period_in_seconds` and `throttle_authenticated_api_requests_per_period`) Enable authenticated API request rate limit. Helps reduce request volume (for example, from crawlers or abusive bots). |
-| `throttle_authenticated_api_period_in_seconds` | integer | required by: `throttle_authenticated_api_enabled` | Rate limit period in seconds. |
-| `throttle_authenticated_api_requests_per_period` | integer | required by: `throttle_authenticated_api_enabled` | Max requests per period per user. |
+| `throttle_authenticated_api_period_in_seconds` | integer | required by:<br>`throttle_authenticated_api_enabled` | Rate limit period in seconds. |
+| `throttle_authenticated_api_requests_per_period` | integer | required by:<br>`throttle_authenticated_api_enabled` | Max requests per period per user. |
| `throttle_authenticated_web_enabled` | boolean | no | (**If enabled, requires:** `throttle_authenticated_web_period_in_seconds` and `throttle_authenticated_web_requests_per_period`) Enable authenticated web request rate limit. Helps reduce request volume (for example, from crawlers or abusive bots). |
-| `throttle_authenticated_web_period_in_seconds` | integer | required by: `throttle_authenticated_web_enabled` | Rate limit period in seconds. |
-| `throttle_authenticated_web_requests_per_period` | integer | required by: `throttle_authenticated_web_enabled` | Max requests per period per user. |
+| `throttle_authenticated_web_period_in_seconds` | integer | required by:<br>`throttle_authenticated_web_enabled` | Rate limit period in seconds. |
+| `throttle_authenticated_web_requests_per_period` | integer | required by:<br>`throttle_authenticated_web_enabled` | Max requests per period per user. |
| `throttle_unauthenticated_enabled` | boolean | no | (**If enabled, requires:** `throttle_unauthenticated_period_in_seconds` and `throttle_unauthenticated_requests_per_period`) Enable unauthenticated request rate limit. Helps reduce request volume (for example, from crawlers or abusive bots). |
-| `throttle_unauthenticated_period_in_seconds` | integer | required by: `throttle_unauthenticated_enabled` | Rate limit period in seconds. |
-| `throttle_unauthenticated_requests_per_period` | integer | required by: `throttle_unauthenticated_enabled` | Max requests per period per IP. |
+| `throttle_unauthenticated_period_in_seconds` | integer | required by:<br>`throttle_unauthenticated_enabled` | Rate limit period in seconds. |
+| `throttle_unauthenticated_requests_per_period` | integer | required by:<br>`throttle_unauthenticated_enabled` | Max requests per period per IP. |
| `time_tracking_limit_to_hours` | boolean | no | Limit display of time tracking units to hours. Default is `false`. |
| `two_factor_grace_period` | integer | required by: `require_two_factor_authentication` | Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication. |
| `unique_ips_limit_enabled` | boolean | no | (**If enabled, requires:** `unique_ips_limit_per_user` and `unique_ips_limit_time_window`) Limit sign in from multiple ips. |
diff --git a/doc/api/todos.md b/doc/api/todos.md
index ebe10ecbd49..ab36021d694 100644
--- a/doc/api/todos.md
+++ b/doc/api/todos.md
@@ -4,13 +4,13 @@ group: Project Management
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
-# To-dos API
+# To dos API
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/3188) in GitLab 8.10.
-## Get a list of to-dos
+## Get a list of to dos
-Returns a list of to-dos. When no filter is applied, it returns all pending to-dos
+Returns a list of to dos. When no filter is applied, it returns all pending to dos
for the current user. Different filters allow the user to precise the request.
```plaintext
@@ -25,8 +25,8 @@ Parameters:
| `author_id` | integer | no | The ID of an author |
| `project_id` | integer | no | The ID of a project |
| `group_id` | integer | no | The ID of a group |
-| `state` | string | no | The state of the to-do. Can be either `pending` or `done` |
-| `type` | string | no | The type of a to-do. Can be either `Issue`, `MergeRequest`, `DesignManagement::Design` or `AlertManagement::Alert` |
+| `state` | string | no | The state of the to do. Can be either `pending` or `done` |
+| `type` | string | no | The type of a to do. Can be either `Issue`, `MergeRequest`, `DesignManagement::Design` or `AlertManagement::Alert` |
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/todos"
@@ -187,10 +187,10 @@ Example Response:
]
```
-## Mark a to-do as done
+## Mark a to do as done
-Marks a single pending to-do given by its ID for the current user as done. The
-to-do marked as done is returned in the response.
+Marks a single pending to do given by its ID for the current user as done. The
+to do marked as done is returned in the response.
```plaintext
POST /todos/:id/mark_as_done
@@ -200,7 +200,7 @@ Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
-| `id` | integer | yes | The ID of a to-do |
+| `id` | integer | yes | The ID of a to do |
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/todos/130/mark_as_done"
@@ -285,9 +285,9 @@ Example Response:
}
```
-## Mark all to-dos as done
+## Mark all to dos as done
-Marks all pending to-dos for the current user as done. It returns the HTTP status code `204` with an empty response.
+Marks all pending to dos for the current user as done. It returns the HTTP status code `204` with an empty response.
```plaintext
POST /todos/mark_as_done
diff --git a/doc/api/users.md b/doc/api/users.md
index 634e0bd0842..dfd00571bcb 100644
--- a/doc/api/users.md
+++ b/doc/api/users.md
@@ -950,7 +950,7 @@ Returns `204 No Content` on success, or `404 Not found` if the key cannot be fou
## List all GPG keys for given user
-Get a list of a specified user's GPG keys. Available only for admins.
+Get a list of a specified user's GPG keys. This endpoint can be accessed without authentication.
```plaintext
GET /users/:id/gpg_keys
diff --git a/doc/api/vulnerabilities.md b/doc/api/vulnerabilities.md
index 73c765e2ccc..f89f2bda2eb 100644
--- a/doc/api/vulnerabilities.md
+++ b/doc/api/vulnerabilities.md
@@ -220,3 +220,53 @@ Example response:
"closed_at": null
}
```
+
+## Revert vulnerability to detected state
+
+Reverts a given vulnerability to detected state. Returns status code `304` if the vulnerability is already in detected state.
+
+If an authenticated user does not have permission to
+[revert vulnerability to detected state](../user/permissions.md#project-members-permissions),
+this request will result in a `403` status code.
+
+```plaintext
+POST /vulnerabilities/:id/revert
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer or string | yes | The ID of a vulnerability to revert to detected state |
+
+```shell
+curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/vulnerabilities/5/dismiss"
+```
+
+Example response:
+
+```json
+{
+ "id": 2,
+ "title": "Predictable pseudorandom number generator",
+ "description": null,
+ "state": "detected",
+ "severity": "medium",
+ "confidence": "medium",
+ "report_type": "sast",
+ "project": {
+ "id": 32,
+ "name": "security-reports",
+ "full_path": "/gitlab-examples/security/security-reports",
+ "full_name": "gitlab-examples / security / security-reports"
+ },
+ "author_id": 1,
+ "updated_by_id": null,
+ "last_edited_by_id": null,
+ "closed_by_id": null,
+ "start_date": null,
+ "due_date": null,
+ "created_at": "2019-10-13T15:08:40.219Z",
+ "updated_at": "2019-10-13T15:09:40.382Z",
+ "last_edited_at": null,
+ "closed_at": null
+}
+```
diff --git a/doc/api/vulnerability_exports.md b/doc/api/vulnerability_exports.md
index d19d41b647e..0ee8a18a46a 100644
--- a/doc/api/vulnerability_exports.md
+++ b/doc/api/vulnerability_exports.md
@@ -198,18 +198,18 @@ The response will be `404 Not Found` if the vulnerability export is not finished
Example response:
```csv
-Group Name,Project Name,Scanner Type,Scanner Name,Status,Vulnerability,Details,Additional Info,Severity,CVE
-Gitlab.org,Defend,container_scanning,Clair,confirmed,CVE-2017-16997 in glibc,,CVE-2017-16997 in glibc,critical,CVE-2017-16997
+Group Name,Project Name,Scanner Type,Scanner Name,Status,Vulnerability,Details,Additional Info,Severity,CVE,CWE,Other Identifiers
+Gitlab.org,Defend,container_scanning,Clair,detected,CVE-2017-16997 in glibc,,CVE-2017-16997 in glibc,critical,CVE-2017-16997
Gitlab.org,Defend,container_scanning,Clair,detected,CVE-2017-18269 in glibc,,CVE-2017-18269 in glibc,critical,CVE-2017-18269
Gitlab.org,Defend,container_scanning,Clair,detected,CVE-2018-1000001 in glibc,,CVE-2018-1000001 in glibc,high,CVE-2018-1000001
Gitlab.org,Defend,container_scanning,Clair,detected,CVE-2016-10228 in glibc,,CVE-2016-10228 in glibc,medium,CVE-2016-10228
-Gitlab.org,Defend,container_scanning,Clair,confirmed,CVE-2010-4052 in glibc,,CVE-2010-4052 in glibc,low,CVE-2010-4052
+Gitlab.org,Defend,container_scanning,Clair,detected,CVE-2010-4052 in glibc,,CVE-2010-4052 in glibc,low,CVE-2010-4052
Gitlab.org,Defend,container_scanning,Clair,detected,CVE-2018-18520 in elfutils,,CVE-2018-18520 in elfutils,low,CVE-2018-18520
-Gitlab.org,Defend,container_scanning,Clair,detected,CVE-2018-16869 in nettle,,CVE-2018-16869 in nettle,unknown,CVE-2018-16869
-Gitlab.org,Defend,dependency_scanning,Gemnasium,detected,Regular Expression Denial of Service in debug,,Regular Expression Denial of Service in debug,unknown,yarn.lock:debug:gemnasium:37283ed4-0380-40d7-ada7-2d994afcc62a
-Gitlab.org,Defend,dependency_scanning,Gemnasium,detected,Authentication bypass via incorrect DOM traversal and canonicalization in saml2-js,,Authentication bypass via incorrect DOM traversal and canonicalization in saml2-js,unknown,yarn.lock:saml2-js:gemnasium:9952e574-7b5b-46fa-a270-aeb694198a98
-Gitlab.org,Defend,sast,Find Security Bugs,detected,Predictable pseudorandom number generator,,Predictable pseudorandom number generator,medium,818bf5dacb291e15d9e6dc3c5ac32178:PREDICTABLE_RANDOM:src/main/java/com/gitlab/security_products/tests/App.java:47
-Gitlab.org,Defend,sast,Find Security Bugs,detected,Cipher with no integrity,,Cipher with no integrity,medium,e6449b89335daf53c0db4c0219bc1634:CIPHER_INTEGRITY:src/main/java/com/gitlab/security_products/tests/App.java:29
-Gitlab.org,Defend,sast,Find Security Bugs,detected,Predictable pseudorandom number generator,,Predictable pseudorandom number generator,medium,e8ff1d01f74cd372f78da8f5247d3e73:PREDICTABLE_RANDOM:src/main/java/com/gitlab/security_products/tests/App.java:41
-Gitlab.org,Defend,sast,Find Security Bugs,confirmed,ECB mode is insecure 2,,ECB mode is insecure,medium,ea0f905fc76f2739d5f10a1fd1e37a10:ECB_MODE:src/main/java/com/gitlab/security_products/tests/App.java:29
-Gitlab.org,Defend,```
+Gitlab.org,Defend,container_scanning,Clair,detected,CVE-2018-16869 in nettle,,CVE-2018-16869 in nettle,unknown,CVE-2018-16869,CWE-1
+Gitlab.org,Defend,dependency_scanning,Gemnasium,detected,Regular Expression Denial of Service in debug,,Regular Expression Denial of Service in debug,unknown,CVE-2021-1234,CWE-2,"""yarn.lock:debug:gemnasium:37283ed4-0380-40d7-ada7-2d994afcc62a"""
+Gitlab.org,Defend,dependency_scanning,Gemnasium,detected,Authentication bypass via incorrect DOM traversal and canonicalization in saml2-js,,Authentication bypass via incorrect DOM traversal and canonicalization in saml2-js,unknown,,,"""yarn.lock:saml2-js:gemnasium:9952e574-7b5b-46fa-a270-aeb694198a98"""
+Gitlab.org,Defend,sast,Find Security Bugs,detected,Predictable pseudorandom number generator,,Predictable pseudorandom number generator,medium,,,"""818bf5dacb291e15d9e6dc3c5ac32178:PREDICTABLE_RANDOM:src/main/java/com/gitlab/security_products/tests/App.java:47"""
+Gitlab.org,Defend,sast,Find Security Bugs,detected,Cipher with no integrity,,Cipher with no integrity,medium,,,"""e6449b89335daf53c0db4c0219bc1634:CIPHER_INTEGRITY:src/main/java/com/gitlab/security_products/tests/App.java:29"""
+Gitlab.org,Defend,sast,Find Security Bugs,detected,Predictable pseudorandom number generator,,Predictable pseudorandom number generator,medium,,,"""e8ff1d01f74cd372f78da8f5247d3e73:PREDICTABLE_RANDOM:src/main/java/com/gitlab/security_products/tests/App.java:41"""
+Gitlab.org,Defend,sast,Find Security Bugs,detected,ECB mode is insecure,,ECB mode is insecure,medium,,,"""ea0f905fc76f2739d5f10a1fd1e37a10:ECB_MODE:src/main/java/com/gitlab/security_products/tests/App.java:29"""
+```
diff --git a/doc/architecture/blueprints/cloud_native_build_logs/index.md b/doc/architecture/blueprints/cloud_native_build_logs/index.md
new file mode 100644
index 00000000000..0b02da21109
--- /dev/null
+++ b/doc/architecture/blueprints/cloud_native_build_logs/index.md
@@ -0,0 +1,137 @@
+---
+comments: false
+description: 'Next iteration of build logs architecture at GitLab'
+---
+
+# Cloud Native Build Logs
+
+Cloud native and the adoption of Kubernetes has been recognised by GitLab to be
+one of the top two biggest tailwinds that are helping us grow faster as a
+company behind the project.
+
+This effort is described in a more details [in the infrastructure team
+handbook](https://about.gitlab.com/handbook/engineering/infrastructure/production/kubernetes/gitlab-com/).
+
+## Traditional build logs
+
+Traditional job logs depend a lot on availability of a local shared storage.
+
+Every time a GitLab Runner sends a new partial build output, we write this
+output to a file on a disk. This is simple, but this mechanism depends on
+shared local storage - the same file needs to be available on every GitLab web
+node machine, because GitLab Runner might connect to a different one every time
+it performs an API request. Sidekiq also needs access to the file because when
+a job is complete, a trace file contents will be sent to the object store.
+
+## New architecture
+
+New architecture writes data to Redis instead of writing build logs into a
+file.
+
+In order to make this performant and resilient enough, we implemented a chunked
+I/O mechanism - we store data in Redis in chunks, and migrate them to an object
+store once we reach a desired chunk size.
+
+Simplified sequence diagram is available below.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant U as User
+ participant R as Runner
+ participant G as GitLab (rails)
+ participant I as Redis
+ participant D as Database
+ participant O as Object store
+
+ loop incremental trace update sent by a runner
+ Note right of R: Runner appends a build trace
+ R->>+G: PATCH trace [build.id, offset, data]
+ G->>+D: find or create chunk [chunk.index]
+ D-->>-G: chunk [id, index]
+ G->>I: append chunk data [chunk.index, data]
+ G-->>-R: 200 OK
+ end
+
+ Note right of R: User retrieves a trace
+ U->>+G: GET build trace
+ loop every trace chunk
+ G->>+D: find chunk [index]
+ D-->>-G: chunk [id]
+ G->>+I: read chunk data [chunk.index]
+ I-->>-G: chunk data [data, size]
+ end
+ G-->>-U: build trace
+
+ Note right of R: Trace chunk is full
+ R->>+G: PATCH trace [build.id, offset, data]
+ G->>+D: find or create chunk [chunk.index]
+ D-->>-G: chunk [id, index]
+ G->>I: append chunk data [chunk.index, data]
+ G->>G: chunk full [index]
+ G-->>-R: 200 OK
+ G->>+I: read chunk data [chunk.index]
+ I-->>-G: chunk data [data, size]
+ G->>O: send chunk data [data, size]
+ G->>+D: update data store type [chunk.id]
+ G->>+I: delete chunk data [chunk.index]
+```
+
+## NFS coupling
+
+In 2017, we experienced serious problems of scaling our NFS infrastructure. We
+even tried to replace NFS with
+[CephFS](https://docs.ceph.com/docs/master/cephfs/) - unsuccessfully.
+
+Since that time it has become apparent that the cost of operations and
+maintenance of a NFS cluster is significant and that if we ever decide to
+migrate to Kubernetes [we need to decouple GitLab from a shared local storage
+and
+NFS](https://gitlab.com/gitlab-org/gitlab-pages/-/issues/426#note_375646396).
+
+1. NFS might be a single point of failure
+1. NFS can only be reliably scaled vertically
+1. Moving to Kubernetes means increasing the number of mount points by an order
+ of magnitude
+1. NFS depends on extremely reliable network which can be difficult to provide
+ in Kubernetes environment
+1. Storing customer data on NFS involves additional security risks
+
+Moving GitLab to Kubernetes without NFS decoupling would result in an explosion
+of complexity, maintenance cost and enormous, negative impact on availability.
+
+## Iterations
+
+1. ✓ Implement the new architecture in way that it does not depend on shared local storage
+1. ✓ Evaluate performance and edge-cases, iterate to improve the new architecture
+1. ✓ Design cloud native build logs correctness verification mechanisms
+1. ✓ Build observability mechanisms around performance and correctness
+1. Rollout the feature into production environment incrementally
+
+The work needed to make the new architecture production ready and enabled on
+GitLab.com is being tracked in [Cloud Native Build Logs on
+GitLab.com](https://gitlab.com/groups/gitlab-org/-/epics/4275) epic.
+
+Enabling this feature on GitLab.com is a subtask of [making the new
+architecture generally
+available](https://gitlab.com/groups/gitlab-org/-/epics/3791) for everyone.
+
+## Who
+
+Proposal:
+
+| Role | Who
+|------------------------------|-------------------------|
+| Author | Grzegorz Bizon |
+| Architecture Evolution Coach | Gerardo Lopez-Fernandez |
+| Engineering Leader | Darby Frey |
+| Domain Expert | Kamil Trzciński |
+| Domain Expert | Sean McGivern |
+
+DRIs:
+
+| Role | Who
+|------------------------------|------------------------|
+| Product | Jason Yavorska |
+| Leadership | Darby Frey |
+| Engineering | Grzegorz Bizon |
diff --git a/doc/architecture/blueprints/cloud_native_gitlab_pages/index.md b/doc/architecture/blueprints/cloud_native_gitlab_pages/index.md
new file mode 100644
index 00000000000..7aa7e10ace3
--- /dev/null
+++ b/doc/architecture/blueprints/cloud_native_gitlab_pages/index.md
@@ -0,0 +1,131 @@
+---
+comments: false
+description: 'Making GitLab Pages a Cloud Native application - architecture blueprint.'
+---
+
+# GitLab Pages New Architecture
+
+GitLab Pages is an important component of the GitLab product. It is mostly
+being used to serve static content, and has a limited set of well defined
+responsibilities. That being said, unfortunately it has become a blocker for
+GitLab.com Kubernetes migration.
+
+Cloud Native and the adoption of Kubernetes has been recognised by GitLab to be
+one of the top two biggest tailwinds that are helping us grow faster as a
+company behind the project.
+
+This effort is described in more detail [in the infrastructure team handbook
+page](https://about.gitlab.com/handbook/engineering/infrastructure/production/kubernetes/gitlab-com/).
+
+GitLab Pages is tightly coupled with NFS and in order to unblock Kubernetes
+migration a significant change to GitLab Pages' architecture is required. This
+is an ongoing work that we have started more than a year ago. This blueprint
+might be useful to understand why it is important, and what is the roadmap.
+
+## How GitLab Pages Works
+
+GitLab Pages is a daemon designed to serve static content, written in
+[Go](https://golang.org/).
+
+Initially, GitLab Pages has been designed to store static content on a local
+shared block storage (NFS) in a hierarchical group > project directory
+structure. Each directory, representing a project, was supposed to contain a
+configuration file and static content that GitLab Pages daemon was supposed to
+read and serve.
+
+```mermaid
+graph LR
+ A(GitLab Rails) -- Writes new pages deployment --> B[(NFS)]
+ C(GitLab Pages) -. Reads static content .-> B
+```
+
+This initial design has become outdated because of a few reasons - NFS coupling
+being one of them - and we decided to replace it with more "decoupled
+service"-like architecture. The new architecture, that we are working on, is
+described in this blueprint.
+
+## NFS coupling
+
+In 2017, we experienced serious problems of scaling our NFS infrastructure. We
+even tried to replace NFS with
+[CephFS](https://docs.ceph.com/docs/master/cephfs/) - unsuccessfully.
+
+Since that time it has become apparent that the cost of operations and
+maintenance of a NFS cluster is significant and that if we ever decide to
+migrate to Kubernetes [we need to decouple GitLab from a shared local storage
+and
+NFS](https://gitlab.com/gitlab-org/gitlab-pages/-/issues/426#note_375646396).
+
+1. NFS might be a single point of failure
+1. NFS can only be reliably scaled vertically
+1. Moving to Kubernetes means increasing the number of mount points by an order
+ of magnitude
+1. NFS depends on extremely reliable network which can be difficult to provide
+ in Kubernetes environment
+1. Storing customer data on NFS involves additional security risks
+
+Moving GitLab to Kubernetes without NFS decoupling would result in an explosion
+of complexity, maintenance cost and enormous, negative impact on availability.
+
+## New GitLab Pages Architecture
+
+- GitLab Pages is going to source domains' configuration from GitLab's internal
+ API, instead of reading `config.json` files from a local shared storage.
+- GitLab Pages is going to serve static content from Object Storage.
+
+```mermaid
+graph TD
+ A(User) -- Pushes pages deployment --> B{GitLab}
+ C((GitLab Pages)) -. Reads configuration from API .-> B
+ C -. Reads static content .-> D[(Object Storage)]
+ C -- Serves static content --> E(Visitors)
+```
+
+This new architecture has been briefly described in [the blog
+post](https://about.gitlab.com/blog/2020/08/03/how-gitlab-pages-uses-the-gitlab-api-to-serve-content/)
+too.
+
+## Iterations
+
+1. ✓ Redesign GitLab Pages configuration source to use GitLab's API
+1. ✓ Evaluate performance and build reliable caching mechanisms
+1. ✓ Incrementally rollout the new source on GitLab.com
+1. ✓ Make GitLab Pages API domains config source enabled by default
+1. Enable experimentation with different servings through feature flags
+1. Triangulate object store serving design through meaningful experiments
+1. Design pages migration mechanisms that can work incrementally
+1. Gradually migrate towards object storage serving on GitLab.com
+
+[GitLab Pages Architecture](https://gitlab.com/groups/gitlab-org/-/epics/1316)
+epic with detailed roadmap is also available.
+
+## Who
+
+Proposal:
+
+| Role | Who
+|------------------------------|-------------------------|
+| Author | Grzegorz Bizon |
+| Architecture Evolution Coach | Kamil Trzciński |
+| Engineering Leader | Daniel Croft |
+| Domain Expert | Grzegorz Bizon |
+| Domain Expert | Vladimir Shushlin |
+| Domain Expert | Jaime Martinez |
+
+DRIs:
+
+| Role | Who
+|------------------------------|------------------------|
+| Product | Jackie Porter |
+| Leadership | Daniel Croft |
+| Engineering | Kamil Trzciński |
+
+Domain Experts:
+
+| Role | Who
+|------------------------------|------------------------|
+| Domain Expert | Kamil Trzciński |
+| Domain Expert | Grzegorz Bizon |
+| Domain Expert | Vladimir Shushlin |
+| Domain Expert | Jaime Martinez |
+| Domain Expert | Krasimir Angelov |
diff --git a/doc/architecture/blueprints/feature_flags_development/index.md b/doc/architecture/blueprints/feature_flags_development/index.md
new file mode 100644
index 00000000000..9e6fd452dcb
--- /dev/null
+++ b/doc/architecture/blueprints/feature_flags_development/index.md
@@ -0,0 +1,136 @@
+---
+comments: false
+description: 'Internal usage of Feature Flags for GitLab development'
+---
+
+# Usage of Feature Flags for GitLab development
+
+Usage of feature flags become crucial for the development of GitLab. The
+feature flags are a convenient way to ship changes early, and safely rollout
+them to wide audience ensuring that feature is stable and performant.
+
+Since the presence of feature is controlled with a dedicated condition, a
+developer can decide for a best time for testing the feature, ensuring that
+feature is not enable prematurely.
+
+## Challenges
+
+The extensive usage of feature flags poses a few challenges
+
+- Each feature flag that we add to codebase is a ~"technical debt" as it adds a
+ matrix of configurations.
+- Testing each combination of feature flags is close to impossible, so we
+ instead try to optimise our testing of feature flags to the most common
+ scenarios.
+- There's a growing challenge of maintaining a growing number of feature flags.
+ We sometimes forget how our feature flags are configured or why we haven't
+ yet removed the feature flag.
+- The usage of feature flags can also be confusing to people outside of
+ development that might not fully understand dependence of ~feature or ~bug
+ fix on feature flag and how this feature flag is configured. Or if the feature
+ should be announced as part of release post.
+- Maintaining feature flags poses additional challenge of having to manage
+ different configurations across different environments/target. We have
+ different configuration of feature flags for testing, for development, for
+ staging, for production and what is being shipped to our customers as part of
+ on-premise offering.
+
+## Goals
+
+The biggest challenge today with our feature flags usage is their implicit
+nature. Feature flags are part of the codebase, making them hard to understand
+outside of development function.
+
+We should aim to make our feature flag based development to be accessible to
+any interested party.
+
+- developer / engineer
+ - can easily add a new feature flag, and configure it's state
+ - can quickly find who to reach if touches another feature flag
+ - can quickly find stale feature flags
+- engineering manager
+ - can understand what feature flags her/his group manages
+- engineering manager and director
+ - can understand how much ~"technical debt" is inflicted due to amount of feature flags that we have to manage
+ - can understand how many feature flags are added and removed in each release
+- product manager and documentation writer
+ - can understand what features are gated by what feature flags
+ - can understand if feature and thus feature flag is generally available on GitLab.com
+ - can understand if feature and thus feature flag is enabled by default for on-premise installations
+- delivery engineer
+ - can understand what feature flags are introduced and changed between subsequent deployments
+- support and reliability engineer
+ - can understand how feature flags changed between releases: what feature flags become enabled, what removed
+ - can quickly find relevant information about feature flag to know individuals which might help with an ongoing support request or incident
+
+## Proposal
+
+To help with above goals we should aim to make our feature flags usage explicit
+and understood by all involved parties.
+
+Introduce a YAML-described `feature-flags/<name-of-feature.yml>` that would
+allow us to have:
+
+1. A central place where all feature flags are documented,
+1. A description of why the given feature flag was introduced,
+1. A what relevant issue and merge request it was introduced by,
+1. Build automated documentation with all feature flags in the codebase,
+1. Track how many feature flags are per given group
+1. Track how many feature flags are added and removed between releases
+1. Make this information easily accessible for all
+1. Allow our customers to easily discover how to enable features and quickly
+ find out information what did change between different releases
+
+### The `YAML`
+
+```yaml
+---
+name: ci_disallow_to_create_merge_request_pipelines_in_target_project
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40724
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/235119
+group: group::progressive delivery
+type: development
+default_enabled: false
+```
+
+## Reasons
+
+These are reason why these changes are needed:
+
+- we have around 500 different feature flags today
+- we have hard time tracking their usage
+- we have ambiguous usage of feature flag with different `default_enabled:` and
+ different `actors` used
+- we lack a clear indication who owns what feature flag and where to find
+ relevant informations
+- we do not emphasise the desire to create feature flag rollout issue to
+ indicate that feature flag is in fact a ~"technical debt"
+- we don't know exactly what feature flags we have in our codebase
+- we don't know exactly how our feature flags are configured for different
+ environments: what is being used for `test`, what we ship for `on-premise`,
+ what is our settings for `staging`, `qa` and `production`
+
+## Iterations
+
+This work is being done as part of dedicated epic: [Improve internal usage of
+Feature Flags](https://gitlab.com/groups/gitlab-org/-/epics/3551). This epic
+describes a meta reasons for making these changes.
+
+## Who
+
+Proposal:
+
+| Role | Who
+|------------------------------|-------------------------|
+| Author | Kamil Trzciński |
+| Architecture Evolution Coach | Gerardo Lopez-Fernandez |
+| Engineering Leader | Kamil Trzciński |
+| Domain Expert | Shinya Maeda |
+
+DRIs:
+
+| Role | Who
+|------------------------------|------------------------|
+| Product | Kenny Johnston |
+| Leadership | Craig Gomes |
+| Engineering | Kamil Trzciński |
diff --git a/doc/architecture/index.md b/doc/architecture/index.md
new file mode 100644
index 00000000000..0a2ade6b7b0
--- /dev/null
+++ b/doc/architecture/index.md
@@ -0,0 +1,9 @@
+---
+comments: false
+description: 'Architecture Practice at GitLab'
+---
+
+# Architecture at GitLab
+
+- [Architecture at GitLab](https://about.gitlab.com/handbook/engineering/architecture/)
+- [Architecture Workflow](https://about.gitlab.com/handbook/engineering/architecture/workflow/)
diff --git a/doc/ci/README.md b/doc/ci/README.md
index 9d3f7f2a8f2..ec8697224b8 100644
--- a/doc/ci/README.md
+++ b/doc/ci/README.md
@@ -24,7 +24,7 @@ webcast to learn about continuous methods and how GitLab’s built-in CI can hel
## Overview
Continuous Integration works by pushing small code chunks to your
-application's code base hosted in a Git repository, and, to every
+application's code base hosted in a Git repository, and to every
push, run a pipeline of scripts to build, test, and validate the
code changes before merging them into the main branch.
diff --git a/doc/ci/ci_cd_for_external_repos/bitbucket_integration.md b/doc/ci/ci_cd_for_external_repos/bitbucket_integration.md
index 74c48f087b2..f8e2a2b7eb0 100644
--- a/doc/ci/ci_cd_for_external_repos/bitbucket_integration.md
+++ b/doc/ci/ci_cd_for_external_repos/bitbucket_integration.md
@@ -9,7 +9,7 @@ type: howto
GitLab CI/CD can be used with Bitbucket Cloud by:
-1. Creating a [CI/CD project](../../user/project/ci_cd_for_external_repo.md).
+1. Creating a [CI/CD project](index.md).
1. Connecting your Git repository via URL.
To use GitLab CI/CD with a Bitbucket Cloud repository:
diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md
index 045fcd39c4d..a319c5f09ab 100644
--- a/doc/ci/docker/using_docker_build.md
+++ b/doc/ci/docker/using_docker_build.md
@@ -48,7 +48,6 @@ The simplest approach is to install GitLab Runner in `shell` execution mode.
GitLab Runner then executes job scripts as the `gitlab-runner` user.
1. Install [GitLab Runner](https://gitlab.com/gitlab-org/gitlab-runner/#installation).
-
1. During GitLab Runner installation select `shell` as method of executing job scripts or use command:
```shell
@@ -90,7 +89,6 @@ GitLab Runner then executes job scripts as the `gitlab-runner` user.
1. You can now use `docker` command (and **install** `docker-compose` if needed).
-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).
@@ -101,7 +99,6 @@ The second approach is to use the special Docker-in-Docker (dind)
(`docker`) and run the job script in context of that
image in privileged mode.
-NOTE: **Note:**
`docker-compose` is not part of Docker-in-Docker (dind). To use `docker-compose` in your
CI builds, follow the `docker-compose`
[installation instructions](https://docs.docker.com/compose/install/).
@@ -149,22 +146,17 @@ released.
#### TLS enabled
-NOTE: **Note:**
-Requires GitLab Runner 11.11 or later, but is not supported if GitLab
-Runner is installed using the [Helm
-chart](https://docs.gitlab.com/runner/install/kubernetes.html). See the
-[related
-issue](https://gitlab.com/gitlab-org/charts/gitlab-runner/-/issues/83) for
-details.
-
The Docker daemon supports connection over TLS and it's done by default
for Docker 19.03.12 or higher. This is the **suggested** way to use the
Docker-in-Docker service and
[GitLab.com shared runners](../../user/gitlab_com/index.md#shared-runners)
support this.
-1. Install [GitLab Runner](https://docs.gitlab.com/runner/install/).
+GitLab Runner 11.11 or later is required, but it is not supported if GitLab
+Runner is installed using the [Helm chart](https://docs.gitlab.com/runner/install/kubernetes.html).
+See the [related issue](https://gitlab.com/gitlab-org/charts/gitlab-runner/-/issues/83) for details.
+1. Install [GitLab Runner](https://docs.gitlab.com/runner/install/).
1. Register GitLab Runner from the command line to use `docker` and `privileged`
mode:
@@ -225,7 +217,7 @@ support this.
# The 'docker' hostname is the alias of the service container as described at
# https://docs.gitlab.com/ee/ci/docker/using_docker_images.html#accessing-the-services.
#
- # Note that if you're using GitLab Runner 12.7 or earlier with the Kubernetes executor and Kubernetes 1.6 or earlier,
+ # If you're using GitLab Runner 12.7 or earlier with the Kubernetes executor and Kubernetes 1.6 or earlier,
# the variable must be set to tcp://localhost:2376 because of how the
# Kubernetes executor connects services to the job container
# DOCKER_HOST: tcp://localhost:2376
@@ -287,7 +279,7 @@ variables:
# The 'docker' hostname is the alias of the service container as described at
# https://docs.gitlab.com/ee/ci/docker/using_docker_images.html#accessing-the-services
#
- # Note that if you're using GitLab Runner 12.7 or earlier with the Kubernetes executor and Kubernetes 1.6 or earlier,
+ # If you're using GitLab Runner 12.7 or earlier with the Kubernetes executor and Kubernetes 1.6 or earlier,
# the variable must be set to tcp://localhost:2375 because of how the
# Kubernetes executor connects services to the job container
# DOCKER_HOST: tcp://localhost:2375
@@ -324,7 +316,6 @@ are done to the services as well, making these incompatible.
In order to do that, follow the steps:
1. Install [GitLab Runner](https://docs.gitlab.com/runner/install/).
-
1. Register GitLab Runner from the command line to use `docker` and share `/var/run/docker.sock`:
```shell
@@ -506,7 +497,6 @@ environment = ["DOCKER_DRIVER=overlay2"]
If you're running multiple runners, you have to modify all configuration files.
-NOTE: **Note:**
Read more about the [runner configuration](https://docs.gitlab.com/runner/configuration/)
and [using the OverlayFS storage driver](https://docs.docker.com/engine/userguide/storagedriver/overlayfs-driver/).
diff --git a/doc/ci/docker/using_docker_images.md b/doc/ci/docker/using_docker_images.md
index 0fcd95c41ed..185de159416 100644
--- a/doc/ci/docker/using_docker_images.md
+++ b/doc/ci/docker/using_docker_images.md
@@ -138,7 +138,6 @@ still succeeds even if that warning was printed. For example:
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 does not add any software from the
defined `services` image(s) to the job's container.
@@ -186,7 +185,6 @@ access to it from your build container under two hostnames to choose from:
- `tutum-wordpress`
- `tutum__wordpress`
-NOTE: **Note:**
Hostnames with underscores are not RFC valid and may cause problems in 3rd party
applications.
@@ -364,10 +362,9 @@ For example, the following two definitions are equal:
| `name` | yes, when used with any other option | 9.4 | Full name of the image that should be used. It should contain the Registry part if needed. |
| `entrypoint` | no | 9.4 |Command or script that should be executed as the container's entrypoint. It's translated to Docker's `--entrypoint` option while creating the container. The syntax is similar to [`Dockerfile`'s `ENTRYPOINT`](https://docs.docker.com/engine/reference/builder/#entrypoint) directive, where each shell token is a separate string in the array. |
| `command` | no | 9.4 |Command or script that should be used as the container's command. It's translated to arguments passed to Docker after the image's name. The syntax is similar to [`Dockerfile`'s `CMD`](https://docs.docker.com/engine/reference/builder/#cmd) directive, where each shell token is a separate string in the array. |
-| `alias` | no | 9.4 |Additional alias that can be used to access the service from the job's container. Read [Accessing the services](#accessing-the-services) for more information. |
+| `alias` (1) | no | 9.4 |Additional alias that can be used to access the service from the job's container. Read [Accessing the services](#accessing-the-services) for more information. |
-NOTE: **Note:**
-Alias support for the Kubernetes executor was [introduced](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/2229) in GitLab Runner 12.8, and is only available for Kubernetes version 1.7 or later.
+(1) Alias support for the Kubernetes executor was [introduced](https://gitlab.com/gitlab-org/gitlab-runner/-/issues/2229) in GitLab Runner 12.8, and is only available for Kubernetes version 1.7 or later.
### Starting multiple services from the same image
@@ -532,7 +529,6 @@ To define which should be used, the GitLab Runner process reads the configuratio
If the `--user` flag is provided to run the GitLab Runner child processes as unprivileged user,
the home directory of the main GitLab Runner process user is used.
-NOTE: **Note:**
GitLab Runner reads this configuration **only** from `config.toml` and ignores it if
it's provided as an environment variable. This is because GitLab Runner uses **only**
`config.toml` configuration and does not interpolate **ANY** environment variables at
@@ -547,6 +543,7 @@ runtime.
at least version **1.8** if you want to use private registries.
- Available for [Kubernetes executor](https://docs.gitlab.com/runner/executors/kubernetes.html)
in GitLab Runner 13.1 and later.
+- [Credentials Store](#using-credentials-store) and [Credential Helpers](#using-credential-helpers) require binaries to be added to the GitLab Runner's `$PATH`, and require access to do so. Therefore, these features are not available on shared runners or any other runner where the user does not have access to the environment where the runner is installed.
### Using statically-defined credentials
@@ -600,7 +597,7 @@ There are two ways to determine the value of `DOCKER_AUTH_CONFIG`:
Open a terminal and execute the following command:
```shell
- # Note the use of "-n" - it prevents encoding a newline in the password.
+ # The use of "-n" - prevents encoding a newline in the password.
echo -n "my_username:my_password" | base64
# Example output to copy
@@ -650,7 +647,6 @@ follow these steps:
You can add configuration for as many registries as you want, adding more
registries to the `"auths"` hash as described above.
-NOTE: **Note:**
The full `hostname:port` combination is required everywhere
for the runner to match the `DOCKER_AUTH_CONFIG`. For example, if
`registry.example.com:5000/namespace/image:tag` is specified in `.gitlab-ci.yml`,
@@ -679,17 +675,14 @@ To add `DOCKER_AUTH_CONFIG` to a runner:
environment = ["DOCKER_AUTH_CONFIG={\"auths\":{\"registry.example.com:5000\":{\"auth\":\"bXlfdXNlcm5hbWU6bXlfcGFzc3dvcmQ=\"}}}"]
```
-1. Restart the runner service.
-
-NOTE: **Note:**
-The double quotes included in the `DOCKER_AUTH_CONFIG`
-data must be escaped with backslashes. This prevents them from being
-interpreted as TOML.
+ - The double quotes included in the `DOCKER_AUTH_CONFIG`
+ data must be escaped with backslashes. This prevents them from being
+ interpreted as TOML.
+ - The `environment` option is a list. Your runner may
+ have existing entries and you should add this to the list, not replace
+ it.
-NOTE: **Note:**
-The `environment` option is a list. So your runner may
-have existing entries and you should add this to the list, not replace
-it.
+1. Restart the runner service.
### Using Credentials Store
@@ -717,10 +710,9 @@ To configure credentials store, follow these steps:
`${GITLAB_RUNNER_HOME}/.docker/config.json`. GitLab Runner reads this configuration file
and uses the needed helper for this specific repository.
-NOTE: **Note:**
`credsStore` is used to access ALL the registries.
-If you want to use both images from private registry and public images from DockerHub,
-pulling from DockerHub would fail, because Docker daemon tries to use the same credentials for **ALL** the registries.
+If you want to use both images from private registry and public images from Docker Hub,
+pulling from Docker Hub would fail, because Docker daemon tries to use the same credentials for **ALL** the registries.
### Using Credential Helpers
@@ -732,10 +724,8 @@ image which is private and requires you to log in into a private container regis
To configure access for `aws_account_id.dkr.ecr.region.amazonaws.com`, follow these steps:
1. Make sure `docker-credential-ecr-login` is available in GitLab Runner's `$PATH`.
-
1. Have any of the following [AWS credentials setup](https://github.com/awslabs/amazon-ecr-credential-helper#aws-credentials).
Make sure that GitLab Runner can access the credentials.
-
1. Make GitLab Runner use it. There are two ways to accomplish this. Either:
- Create a [variable](../variables/README.md#gitlab-cicd-environment-variables)
@@ -791,7 +781,6 @@ service containers.
For all possible configuration variables check the documentation of each image
provided in their corresponding Docker hub page.
-NOTE: **Note:**
All variables are passed to all services containers. It's not
designed to distinguish which variable should go where.
diff --git a/doc/ci/environments/index.md b/doc/ci/environments/index.md
index 07d0dac6163..589607fbed8 100644
--- a/doc/ci/environments/index.md
+++ b/doc/ci/environments/index.md
@@ -118,11 +118,9 @@ With this configuration, we:
- Ensure that our app is able to be built successfully.
- Lastly we deploy to the staging server.
-NOTE: **Note:**
-The `environment` keyword defines where the app is deployed.
-The environment `name` and `url` is exposed in various places
-within GitLab. Each time a job that has an environment specified
-succeeds, a deployment is recorded, along with the Git SHA, and environment name.
+Note that the `environment` keyword defines where the app is deployed. The environment `name` and
+`url` is exposed in various places within GitLab. Each time a job that has an environment specified
+succeeds, a deployment is recorded along with the Git SHA and environment name.
CAUTION: **Caution:**
Some characters are not allowed in environment names. Use only letters,
@@ -288,12 +286,11 @@ You can find the "play" button in the pipelines, environments, deployments, and
| Deployments | ![Deployments manual action](../img/environments_manual_action_deployments.png) |
| Jobs | ![Builds manual action](../img/environments_manual_action_jobs.png) |
-Clicking on the play button in any view will trigger the `deploy_prod` job, and the
-deployment will be recorded as a new environment named `production`.
+Clicking the play button in any view triggers the `deploy_prod` job. The deployment is recorded as a
+new environment named `production`.
-NOTE: **Note:**
-If your environment's name is `production` (all lowercase),
-it will get recorded in [Value Stream Analytics](../../user/project/cycle_analytics.md).
+If your environment's name is `production` (all lowercase), it's recorded in
+[Value Stream Analytics](../../user/analytics/value_stream_analytics.md).
### Configuring dynamic environments
@@ -371,9 +368,8 @@ For the value of:
the example above: `https://$CI_COMMIT_REF_NAME.example.com`, which would give a URL
of `https://100-do-the-thing.example.com`.
-NOTE: **Note:**
-You are not required to use the same prefix or only slashes (`/`) in the dynamic environments'
-names. However, using this format will enable the [grouping similar environments](#grouping-similar-environments)
+You aren't required to use the same prefix or only slashes (`/`) in the dynamic environments' names.
+However, using this format enables the [grouping similar environments](#grouping-similar-environments)
feature.
### Configuring Kubernetes deployments
@@ -384,6 +380,12 @@ If you are deploying to a [Kubernetes cluster](../../user/project/clusters/index
associated with your project, you can configure these deployments from your
`gitlab-ci.yml` file.
+NOTE: **Note:**
+Kubernetes configuration isn't supported for Kubernetes clusters that are
+[managed by GitLab](../../user/project/clusters/index.md#gitlab-managed-clusters).
+To follow progress on support for GitLab-managed clusters, see the
+[relevant issue](https://gitlab.com/gitlab-org/gitlab/-/issues/38054).
+
The following configuration options are supported:
- [`namespace`](https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/)
@@ -411,12 +413,6 @@ trace on the deployment job page:
![Deployment cluster information](../img/environments_deployment_cluster_v12_8.png)
-NOTE: **Note:**
-Kubernetes configuration is not supported for Kubernetes clusters
-that are [managed by GitLab](../../user/project/clusters/index.md#gitlab-managed-clusters).
-To follow progress on support for GitLab-managed clusters, see the
-[relevant issue](https://gitlab.com/gitlab-org/gitlab/-/issues/38054).
-
#### Configuring incremental rollouts
Learn how to release production changes to only a portion of your Kubernetes pods with
@@ -514,9 +510,8 @@ review_app:
This example requires that NGINX and GitLab Runner are set up on the server this job will run on.
-NOTE: **Note:**
-See the [limitations](#limitations) section for some edge cases regarding the naming of
-your branches and Review Apps.
+See the [limitations](#limitations) section for some edge cases regarding the naming of your
+branches and Review Apps.
The complete example provides the following workflow to developers:
@@ -617,13 +612,12 @@ To retry or rollback a deployment:
#### What to expect with a rollback
-Pressing the **Rollback** button on a specific commit will trigger a _new_ deployment with its
-own unique job ID.
-
-This means that you will see a new deployment that points to the commit you are rolling back to.
+Pressing the **Rollback** button on a specific commit triggers a _new_ deployment with its own
+unique job ID. This means that you will see a new deployment that points to the commit you're
+rolling back to.
-NOTE: **Note:**
-The defined deployment process in the job's `script` determines whether the rollback succeeds or not.
+Note that the defined deployment process in the job's `script` determines whether the rollback
+succeeds.
### Using the environment URL
@@ -662,9 +656,8 @@ Stopping an environment:
This is often used when multiple developers are working on a project at the same time,
each of them pushing to their own branches, causing many dynamic environments to be created.
-NOTE: **Note:**
-Starting with GitLab 8.14, dynamic environments are stopped automatically
-when their associated branch is deleted.
+Starting with GitLab 8.14, dynamic environments stop automatically when their associated branch is
+deleted.
#### Automatically stopping an environment
@@ -721,29 +714,25 @@ You can read more in the [`.gitlab-ci.yml` reference](../yaml/README.md#environm
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/20956) in GitLab 12.8.
-You can set a expiry time to environments and stop them automatically after a certain period.
+You can set an expiry time for environments and stop them automatically after a certain period.
-For example, consider the use of this feature with Review Apps environments.
-When you set up Review Apps, sometimes they keep running for a long time
-because some merge requests are left as open. An example for this situation is when the author of the merge
-request is not actively working on it, due to priority changes or a different approach was decided on, and the merge requests was simply forgotten.
-Idle environments waste resources, therefore they
-should be terminated as soon as possible.
+For example, consider the use of this feature with Review App environments. When you set up Review
+Apps, sometimes they keep running for a long time because some merge requests are left open and
+forgotten. Such idle environments waste resources and should be terminated as soon as possible.
-To address this problem, you can specify an optional expiration date for
-Review Apps environments. When the expiry time is reached, GitLab will automatically trigger a job
-to stop the environment, eliminating the need of manually doing so. In case an environment is updated, the expiration is renewed
-ensuring that only active merge requests keep running Review Apps.
+To address this problem, you can specify an optional expiration date for Review App environments.
+When the expiry time is reached, GitLab automatically triggers a job to stop the environment,
+eliminating the need of manually doing so. In case an environment is updated, the expiration is
+renewed ensuring that only active merge requests keep running Review Apps.
-To enable this feature, you need to specify the [`environment:auto_stop_in`](../yaml/README.md#environmentauto_stop_in) keyword in `.gitlab-ci.yml`.
-You can specify a human-friendly date as the value, such as `1 hour and 30 minutes` or `1 day`.
-`auto_stop_in` uses the same format of [`artifacts:expire_in` docs](../yaml/README.md#artifactsexpire_in).
+To enable this feature, you must specify the [`environment:auto_stop_in`](../yaml/README.md#environmentauto_stop_in)
+keyword in `.gitlab-ci.yml`. You can specify a human-friendly date as the value, such as
+`1 hour and 30 minutes` or `1 day`. `auto_stop_in` uses the same format of
+[`artifacts:expire_in` docs](../yaml/README.md#artifactsexpire_in).
-NOTE: **Note:**
-Due to the resource limitation, a background worker for stopping environments only
-runs once every hour. This means environments will not be stopped at the exact
-timestamp as the specified period, but will be stopped when the hourly cron worker
-detects expired environments.
+Note that due to resource limitation, a background worker for stopping environments only runs once
+every hour. This means that environments aren't stopped at the exact timestamp specified, but are
+instead stopped when the hourly cron worker detects expired environments.
##### Auto-stop example
@@ -903,7 +892,6 @@ you can monitor the behavior of your app running in each environment. For the mo
dashboard to appear, you need to Configure Prometheus to collect at least one
[supported metric](../../user/project/integrations/prometheus_library/index.md).
-NOTE: **Note:**
Since GitLab 9.2, all deployments to an environment are shown directly on the monitoring dashboard.
Once configured, GitLab will attempt to retrieve [supported performance metrics](../../user/project/integrations/prometheus_library/index.md)
@@ -938,6 +926,11 @@ This is a powerful feature that allows you to debug issues without leaving the c
of your web browser. To enable it, just follow the instructions given in the service integration
documentation.
+NOTE: **Note:**
+Container-based deployments often lack basic tools (like an editor), and may
+be stopped or restarted at any time. If this happens, you will lose all your
+changes. Treat this as a debugging tool, not a comprehensive online IDE.
+
Once enabled, your environments will gain a "terminal" button:
![Terminal button on environment index](../img/environments_terminal_button_on_index.png)
@@ -961,11 +954,6 @@ by your deployment so you can:
You can open multiple terminals to the same environment, they each get their own shell
session and even a multiplexer like `screen` or `tmux`.
-NOTE: **Note:**
-Container-based deployments often lack basic tools (like an editor), and may
-be stopped or restarted at any time. If this happens, you will lose all your
-changes. Treat this as a debugging tool, not a comprehensive online IDE.
-
### Check out deployments locally
Since GitLab 8.13, a reference in the Git repository is saved for each deployment, so
@@ -1024,9 +1012,8 @@ As you can see, you can use specific matching for selecting a particular environ
and also use wildcard matching (`*`) for selecting a particular environment group,
such as [Review Apps](../review_apps/index.md) (`review/*`).
-NOTE: **Note:**
-The most _specific_ spec takes precedence over the other wildcard matching.
-In this case, `review/feature-1` spec takes precedence over `review/*` and `*` specs.
+Note that the most _specific_ spec takes precedence over the other wildcard matching. In this case,
+the `review/feature-1` spec takes precedence over `review/*` and `*` specs.
### Environments Dashboard **(PREMIUM)**
diff --git a/doc/ci/environments/protected_environments.md b/doc/ci/environments/protected_environments.md
index 5dda0cc81f6..87bced29906 100644
--- a/doc/ci/environments/protected_environments.md
+++ b/doc/ci/environments/protected_environments.md
@@ -9,8 +9,6 @@ type: concepts, howto
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/6303) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.3.
-## Overview
-
[Environments](../environments/index.md) can be used for different reasons:
- Some of them are just for testing.
diff --git a/doc/ci/examples/authenticating-with-hashicorp-vault/index.md b/doc/ci/examples/authenticating-with-hashicorp-vault/index.md
index 73896547675..4a0ff2fa6ac 100644
--- a/doc/ci/examples/authenticating-with-hashicorp-vault/index.md
+++ b/doc/ci/examples/authenticating-with-hashicorp-vault/index.md
@@ -41,15 +41,14 @@ The JWT's payload looks like this:
"nbf": 1585798372, # Not valid before
"exp": 1585713886, # Expire at
"sub": "job_1212", # Subject (job id)
- "namespace_id": "1",
- "namespace_path": "mygroup",
- "project_id": "22",
- "project_path": "mygroup/myproject",
- "user_id": "42",
- "user_login": "myuser",
- "user_email": "myuser@example.com",
- "pipeline_id": "1212",
- "job_id": "1212",
+ "namespace_id": "1", # Use this to scope to group or user level namespace by id
+ "namespace_path": "mygroup", # Use this to scope to group or user level namespace by path
+ "project_id": "22", #
+ "project_path": "mygroup/myproject", #
+ "user_id": "42", # Id of the user executing the job
+ "user_email": "myuser@example.com", # Email of the user executing the job
+ "pipeline_id": "1212", #
+ "job_id": "1212", #
"ref": "auto-deploy-2020-04-01", # Git ref for this job
"ref_type": "branch", # Git ref type, branch or tag
"ref_protected": "true" # true if this git ref is protected, false otherwise
diff --git a/doc/ci/examples/end_to_end_testing_webdriverio/index.md b/doc/ci/examples/end_to_end_testing_webdriverio/index.md
index 1f6c81a68aa..2866a34899a 100644
--- a/doc/ci/examples/end_to_end_testing_webdriverio/index.md
+++ b/doc/ci/examples/end_to_end_testing_webdriverio/index.md
@@ -9,7 +9,7 @@ type: tutorial
[Review Apps](../../review_apps/index.md) are great: for every merge request
(or branch, for that matter), the new code can be copied and deployed to a fresh production-like live
-environment, making it incredibly low-effort to assess the impact of the changes. Thus, when we use a dependency manager like
+environment, reducing the effort to assess the impact of changes. Thus, when we use a dependency manager like
[Dependencies.io](https://www.dependencies.io/), it can submit a merge request with an updated dependency,
and it will immediately be clear that the application can still be properly built and deployed. After all, you can _see_ it
running!
diff --git a/doc/ci/examples/laravel_with_gitlab_and_envoy/index.md b/doc/ci/examples/laravel_with_gitlab_and_envoy/index.md
index 8927c5c3480..d3f2d8223ef 100644
--- a/doc/ci/examples/laravel_with_gitlab_and_envoy/index.md
+++ b/doc/ci/examples/laravel_with_gitlab_and_envoy/index.md
@@ -397,8 +397,6 @@ To be able to build, test, and deploy our app with GitLab CI/CD, we need to prep
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:
@@ -441,9 +439,9 @@ 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/packages/container_registry/index.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 > Visibility, project features, permissions**.
+You may need to enable the Container Registry for your project to see this tab. You'll find it under your project's **Settings > General > Visibility, project features, permissions**.
-To start using Container Registry on our machine, we first need to login to the GitLab registry using our GitLab username and password:
+To start using Container Registry on our machine, we first need to sign in to the GitLab registry using our GitLab username and password:
```shell
docker login registry.gitlab.com
diff --git a/doc/ci/img/ci_lint.png b/doc/ci/img/ci_lint.png
index e62de011293..fdc3868cdce 100644
--- a/doc/ci/img/ci_lint.png
+++ b/doc/ci/img/ci_lint.png
Binary files differ
diff --git a/doc/ci/img/ci_lint_dry_run.png b/doc/ci/img/ci_lint_dry_run.png
index 4092b66d534..61d6379f70e 100644
--- a/doc/ci/img/ci_lint_dry_run.png
+++ b/doc/ci/img/ci_lint_dry_run.png
Binary files differ
diff --git a/doc/ci/img/gitlab_vault_workflow_v13_4.png b/doc/ci/img/gitlab_vault_workflow_v13_4.png
new file mode 100644
index 00000000000..80d07362bf4
--- /dev/null
+++ b/doc/ci/img/gitlab_vault_workflow_v13_4.png
Binary files differ
diff --git a/doc/ci/introduction/index.md b/doc/ci/introduction/index.md
index db2749233e8..12df0751c47 100644
--- a/doc/ci/introduction/index.md
+++ b/doc/ci/introduction/index.md
@@ -154,7 +154,7 @@ commits to a feature branch in a remote repository in GitLab,
the CI/CD pipeline set for your project is triggered. By doing
so, GitLab CI/CD:
-- Runs automated scripts (sequential or parallel) to:
+- Runs automated scripts (sequentially or in parallel) to:
- Build and test your app.
- Preview the changes per merge request with Review Apps, as you
would see in your `localhost`.
diff --git a/doc/ci/metrics_reports.md b/doc/ci/metrics_reports.md
index 4f4471225a0..53e097760e6 100644
--- a/doc/ci/metrics_reports.md
+++ b/doc/ci/metrics_reports.md
@@ -9,8 +9,6 @@ type: reference
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9788) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.10. Requires GitLab Runner 11.10 and above.
-## Overview
-
GitLab provides a lot of great reporting tools for [merge requests](../user/project/merge_requests/index.md) - [Unit test reports](unit_test_reports.md), [code quality](../user/project/merge_requests/code_quality.md), performance tests, etc. While JUnit is a great open framework for tests that "pass" or "fail", it is also important to see other types of metrics from a given change.
You can configure your job to use custom Metrics Reports, and GitLab will display a report on the merge request so that it's easier and faster to identify changes without having to check the entire log.
diff --git a/doc/ci/migration/jenkins.md b/doc/ci/migration/jenkins.md
index 8dff99f7244..1130c11f472 100644
--- a/doc/ci/migration/jenkins.md
+++ b/doc/ci/migration/jenkins.md
@@ -23,7 +23,7 @@ that were able to quickly complete this migration:
1. Use the [Jenkins Wrapper](#jenkinsfile-wrapper) to temporarily maintain fragile Jenkins jobs.
1. Migrate the build and CI jobs and configure them to show results directly in your merge requests. They can use [Auto DevOps](../../topics/autodevops/index.md) as a starting point, and [customize](../../topics/autodevops/customize.md) or [decompose](../../topics/autodevops/customize.md#using-components-of-auto-devops) the configuration as needed.
1. Add [Review Apps](../review_apps/index.md).
- 1. Migrate the deployment jobs using [cloud deployment templates](../cloud_deployment/index.md), adding [environments](../environments/index.md), and [deploy boards](../..//user/project/deploy_boards.md).
+ 1. Migrate the deployment jobs using [cloud deployment templates](../cloud_deployment/index.md), adding [environments](../environments/index.md), and [deploy boards](../../user/project/deploy_boards.md).
1. Work to unwrap any jobs still running with the use of the Jenkins wrapper.
1. Take stock of any common CI/CD job definitions then create and share [templates](#templates) for them.
1. Check the [pipeline efficiency documentation](../pipelines/pipeline_efficiency.md)
@@ -83,7 +83,7 @@ There are some high level differences between the products worth mentioning:
- You can control which jobs run in which cases, depending on how they are triggered,
with the [`rules` syntax](../yaml/README.md#rules).
-- GitLab [pipeline scheduling concepts](../pipelines/schedules.md) are also different than with Jenkins.
+- GitLab [pipeline scheduling concepts](../pipelines/schedules.md) are also different from Jenkins.
- You can reuse pipeline configurations using the [`include` keyword](../yaml/README.md#include)
and [templates](#templates). Your templates can be kept in a central repository (with different
permissions), and then any project can use them. This central project could also
diff --git a/doc/ci/multi_project_pipelines.md b/doc/ci/multi_project_pipelines.md
index 3f9a00b6cc8..a379a6e8211 100644
--- a/doc/ci/multi_project_pipelines.md
+++ b/doc/ci/multi_project_pipelines.md
@@ -280,3 +280,11 @@ Any pipelines that complete successfully for new tags in the subscribed project
will now trigger a pipeline on the current project's default branch. The maximum
number of upstream pipeline subscriptions is 2 by default, for both the upstream and
downstream projects. This [application limit](../administration/instance_limits.md#number-of-cicd-subscriptions-to-a-project) can be changed on self-managed instances by a GitLab administrator.
+
+## Downstream private projects confidentiality concern
+
+If you trigger a pipeline in a downstream private project, the name of the project
+and the status of the pipeline is visible in the upstream project's pipelines page.
+
+If you have a public project that can trigger downstream pipelines in a private
+project, make sure to check that there are no confidentiality problems.
diff --git a/doc/ci/parent_child_pipelines.md b/doc/ci/parent_child_pipelines.md
index 83fa1d355e6..f2a4020cc4a 100644
--- a/doc/ci/parent_child_pipelines.md
+++ b/doc/ci/parent_child_pipelines.md
@@ -68,7 +68,7 @@ microservice_a:
trigger:
include:
- local: path/to/microservice_a.yml
- - template: SAST.gitlab-ci.yml
+ - template: Security/SAST.gitlab-ci.yml
```
NOTE: **Note:**
@@ -82,7 +82,7 @@ microservice_a:
trigger:
include:
- local: path/to/microservice_a.yml
- - template: SAST.gitlab-ci.yml
+ - template: Security/SAST.gitlab-ci.yml
strategy: depend
```
diff --git a/doc/ci/pipelines/img/ci_efficiency_pipeline_dag_critical_path.png b/doc/ci/pipelines/img/ci_efficiency_pipeline_dag_critical_path.png
index 1715e8224ab..421fddaf38d 100644
--- a/doc/ci/pipelines/img/ci_efficiency_pipeline_dag_critical_path.png
+++ b/doc/ci/pipelines/img/ci_efficiency_pipeline_dag_critical_path.png
Binary files differ
diff --git a/doc/ci/pipelines/img/ci_efficiency_pipeline_health_grafana_dashboard.png b/doc/ci/pipelines/img/ci_efficiency_pipeline_health_grafana_dashboard.png
index 0956e76804e..59276bda727 100644
--- a/doc/ci/pipelines/img/ci_efficiency_pipeline_health_grafana_dashboard.png
+++ b/doc/ci/pipelines/img/ci_efficiency_pipeline_health_grafana_dashboard.png
Binary files differ
diff --git a/doc/ci/pipelines/index.md b/doc/ci/pipelines/index.md
index 1b9048089bd..f172032630d 100644
--- a/doc/ci/pipelines/index.md
+++ b/doc/ci/pipelines/index.md
@@ -461,6 +461,28 @@ this line should be hidden when collapsed
section_end:1560896353:my_first_section\r\e[0K
```
+#### Pre-collapse sections
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/198413) in GitLab 13.5.
+
+You can make the job log automatically collapse collapsible sections by adding the `collapsed` option to the section start.
+Add `[collapsed=true]` after the section name and before the `\r`. The section end marker
+remains unchanged:
+
+- Section start marker with `[collapsed=true]`: `section_start:UNIX_TIMESTAMP:SECTION_NAME[collapsed=true]\r\e[0K` + `TEXT_OF_SECTION_HEADER`
+- Section end marker: `section_end:UNIX_TIMESTAMP:SECTION_NAME\r\e[0K`
+
+Add the updated section start text to the CI configuration. For example,
+using `echo`:
+
+```yaml
+job1:
+ script:
+ - echo -e "section_start:`date +%s`:my_first_section[collapsed=true]\r\e[0KHeader of the 1st collapsible section"
+ - echo 'this line should be hidden automatically after loading the job log'
+ - echo -e "section_end:`date +%s`:my_first_section\r\e[0K"
+```
+
## Visualize pipelines
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/5742) in GitLab 8.11.
diff --git a/doc/ci/pipelines/job_artifacts.md b/doc/ci/pipelines/job_artifacts.md
index 750a76bfaa0..39633163ac7 100644
--- a/doc/ci/pipelines/job_artifacts.md
+++ b/doc/ci/pipelines/job_artifacts.md
@@ -431,6 +431,17 @@ To erase a job:
In order to retrieve a job artifact of a different project, you might need to use a private token in order to [authenticate and download](../../api/job_artifacts.md#get-job-artifacts) the artifacts.
+## Troubleshooting
+
+### Error message `No files to upload`
+
+This is often preceded by other errors or warnings that specify the filename and why it wasn't
+generated in the first place. Please check the entire job log for such messages.
+
+If you find no helpful messages, please retry the failed job after activating
+[CI debug logging](../variables/README.md#debug-logging).
+This provides useful information to investigate further.
+
<!-- ## Troubleshooting
Include any troubleshooting steps that you can foresee. If you know beforehand what issues
diff --git a/doc/ci/pipelines/settings.md b/doc/ci/pipelines/settings.md
index 849eb66d07f..4dcdf469e5c 100644
--- a/doc/ci/pipelines/settings.md
+++ b/doc/ci/pipelines/settings.md
@@ -37,10 +37,10 @@ in `.gitlab-ci.yml`.
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/28919) in GitLab 12.0.
NOTE: **Note:**
-As of GitLab 12.0, newly created projects will automatically have a default
+As of GitLab 12.0, newly created projects automatically have a default
`git depth` value of `50`.
-It is possible to limit the number of changes that GitLab CI/CD will fetch when cloning
+It is possible to limit the number of changes that GitLab CI/CD fetches when cloning
a repository. Setting a limit to `git depth` can speed up Pipelines execution. Maximum
allowed value is `1000`.
@@ -75,7 +75,7 @@ For information about setting a maximum artifact size for a project, see
> - [Support for external `.gitlab-ci.yml` locations](https://gitlab.com/gitlab-org/gitlab/-/issues/14376) introduced in GitLab 12.6.
By default we look for the `.gitlab-ci.yml` file in the project's root
-directory. If needed, you can specify an alternate path and file name, including locations outside the project.
+directory. If needed, you can specify an alternate path and filename, including locations outside the project.
To customize the path:
@@ -93,12 +93,12 @@ paths and file names include:
- `my/path/.gitlab-ci.yml`
- `my/path/.my-custom-file.yml`
-If the CI configuration will be hosted on an external site, the URL link must end with `.yml`:
+If hosting the CI configuration on an external site, the URL link must end with `.yml`:
- `http://example.com/generate/ci/config.yml`
-If the CI configuration will be hosted in a different project within GitLab, the path must be relative
-to the root directory in the other project, with the group and project name added to the end:
+If hosting the CI configuration in a different project within GitLab, the path must be relative
+to the root directory in the other project. Include the group and project name at the end:
- `.gitlab-ci.yml@mygroup/another-project`
- `my/path/.my-custom-file.yml@mygroup/another-project`
@@ -109,7 +109,7 @@ configuration file. For example:
- Create a public project to host the configuration file.
- Give write permissions on the project only to users who are allowed to edit the file.
-Other users and projects will be able to access the configuration file without being
+Other users and projects can access the configuration file without being
able to edit it.
## Test coverage parsing
@@ -125,8 +125,8 @@ can use <https://rubular.com> to test your regex. The regex returns the **last**
match found in the output.
If the pipeline succeeds, the coverage is shown in the merge request widget and
-in the jobs table. If multiple jobs in the pipeline have coverage reports, they will
-be averaged.
+in the jobs table. If multiple jobs in the pipeline have coverage reports, they are
+averaged.
![MR widget coverage](img/pipelines_test_coverage_mr_widget.png)
@@ -140,7 +140,7 @@ in the pipelines settings page.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/209121) the ability to download a `.csv` in GitLab 12.10.
> - [Graph introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/33743) in GitLab 13.1.
-If you want to see the evolution of your project code coverage over time,
+To see the evolution of your project code coverage over time,
you can view a graph or download a CSV file with this data. From your project:
1. Go to **{chart}** **Project Analytics > Repository** to see the historic data for each job listed in the dropdown above the graph.
@@ -150,13 +150,13 @@ you can view a graph or download a CSV file with this data. From your project:
### Removing color codes
-Some test coverage tools output with ANSI color codes that won't be
-parsed correctly by the regular expression and will cause coverage
+Some test coverage tools output with ANSI color codes that aren't
+parsed correctly by the regular expression. This causes coverage
parsing to fail.
-If your coverage tool doesn't provide an option to disable color
-codes in the output, you can pipe the output of the coverage tool through a
-small one line script that will strip the color codes off.
+Some coverage tools don't provide an option to disable color
+codes in the output. If so, pipe the output of the coverage tool through a
+small one line script that strips the color codes off.
For example:
@@ -172,7 +172,7 @@ Pipeline visibility is determined by:
- The **Public pipelines** project setting under your project's **Settings > CI/CD > General pipelines**.
NOTE: **Note:**
-If the project visibility is set to **Private**, the [**Public pipelines** setting will have no effect](../enable_or_disable_ci.md#per-project-user-setting).
+If the project visibility is set to **Private**, the [**Public pipelines** setting has no effect](../enable_or_disable_ci.md#per-project-user-setting).
This also determines the visibility of these related features:
@@ -204,16 +204,14 @@ If **Public pipelines** is disabled:
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/9362) in GitLab 9.1.
-If you want all pending non-HEAD pipelines on branches to auto-cancel each time
-a new pipeline is created, such as after a Git push or manually from the UI,
-you can enable this in the project settings:
+You can set pending or running pipelines to cancel automatically when a new pipeline runs on the same branch. You can enable this in the project settings:
1. Go to **Settings > CI / CD**.
1. Expand **General Pipelines**.
1. Check the **Auto-cancel redundant, pending pipelines** checkbox.
1. Click **Save changes**.
-Note that only jobs with [interruptible](../yaml/README.md#interruptible) set to `true` will be cancelled.
+Note that only jobs with [interruptible](../yaml/README.md#interruptible) set to `true` are cancelled.
## Skip outdated deployment jobs
@@ -232,18 +230,18 @@ To avoid this scenario:
1. Check the **Skip outdated deployment jobs** checkbox.
1. Click **Save changes**.
-The pending deployment jobs will be skipped.
+When enabled, any older deployments job are skipped when a new deployment starts.
For more information, see [Deployment safety](../environments/deployment_safety.md).
## 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
+badges for your project. The latest successful pipeline is used to read
the pipeline status and test coverage values.
Visit the pipelines settings page in your project to see the exact link to
-your badges, as well as ways to embed the badge image in your HTML or Markdown
+your badges. You can also see ways to embed the badge image in your HTML or Markdown
pages.
![Pipelines badges](img/pipelines_settings_badges.png)
@@ -276,8 +274,8 @@ https://gitlab.example.com/<namespace>/<project>/badges/<branch>/pipeline.svg?ig
### Test coverage report badge
-GitLab makes it possible to define the regular expression for [coverage report](#test-coverage-parsing),
-that each job log will be matched against. This means that each job in the
+GitLab makes it possible to define the regular expression for the [coverage report](#test-coverage-parsing),
+that each job log is matched against. This means that each job in the
pipeline can have the test coverage percentage value defined.
The test coverage badge can be accessed using following link:
@@ -288,7 +286,7 @@ https://gitlab.example.com/<namespace>/<project>/badges/<branch>/coverage.svg
If you would like to get the coverage report from a specific job, you can add
the `job=coverage_job_name` parameter to the URL. For example, the following
-Markdown code will embed the test coverage report badge of the `coverage` job
+Markdown code embeds the test coverage report badge of the `coverage` job
into your `README.md`:
```markdown
@@ -297,7 +295,7 @@ into your `README.md`:
### Badge styles
-Pipeline badges can be rendered in different styles by adding the `style=style_name` parameter to the URL. Currently two styles are available:
+Pipeline badges can be rendered in different styles by adding the `style=style_name` parameter to the URL. Two styles are available:
#### Flat (default)
diff --git a/doc/ci/review_apps/img/toolbar_feeback_form.png b/doc/ci/review_apps/img/toolbar_feeback_form.png
deleted file mode 100644
index fe1c7e6e611..00000000000
--- a/doc/ci/review_apps/img/toolbar_feeback_form.png
+++ /dev/null
Binary files differ
diff --git a/doc/ci/review_apps/img/toolbar_feedback_form_v13_5.png b/doc/ci/review_apps/img/toolbar_feedback_form_v13_5.png
new file mode 100644
index 00000000000..dc4a13b2152
--- /dev/null
+++ b/doc/ci/review_apps/img/toolbar_feedback_form_v13_5.png
Binary files differ
diff --git a/doc/ci/review_apps/index.md b/doc/ci/review_apps/index.md
index e2d5cbcbea4..7110117709f 100644
--- a/doc/ci/review_apps/index.md
+++ b/doc/ci/review_apps/index.md
@@ -33,7 +33,7 @@ In the above example:
## How Review Apps work
A Review App is a mapping of a branch with an [environment](../environments/index.md).
-Access to the Review App is made available as a link on the [merge request](../../user/project/merge_requests.md) relevant to the branch.
+Access to the Review App is made available as a link on the [merge request](../../user/project/merge_requests/index.md) relevant to the branch.
The following is an example of a merge request with an environment set dynamically.
@@ -188,20 +188,35 @@ Once you have the route mapping set up, it will take effect in the following loc
## Visual Reviews **(STARTER)**
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/10761) in GitLab Starter 12.0.
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/10761) in GitLab Starter 12.0.
+> - It's [deployed behind a feature flag](../../user/feature_flags.md), enabled by default.
+> - It's enabled on GitLab.com.
+> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#enable-or-disable-visual-reviews). **(STARTER ONLY)**
-With Visual Reviews, you can provide a feedback form to your Review Apps so
-that reviewers can post comments directly from the app back to the merge request
-that spawned the Review App.
+With Visual Reviews, members of any team (Product, Design, Quality, and so on) can provide feedback comments through a form in your review apps. The comments are added to the merge request that triggered the review app.
-### Configuring Visual Reviews
+### Using Visual Reviews
-Ensure that the `anonymous_visual_review_feedback` feature flag is enabled.
-Administrators can enable with a Rails console as follows:
+After Visual Reviews has been [configured](#configure-review-apps-for-visual-reviews) for the
+Review App, the Visual Reviews feedback form is overlaid on the right side of every page.
-```ruby
-Feature.enable(:anonymous_visual_review_feedback)
-```
+![Visual review feedback form](img/toolbar_feedback_form_v13_5.png)
+
+To use the feedback form to make a comment in the merge request:
+
+1. Click the **Review** tab on the right side of a page.
+1. Make a comment on the visual review. You can make use of all the
+ [Markdown annotations](../../user/markdown.md) that are also available in
+ merge request comments.
+1. Enter your personal information:
+ - If [`data-require-auth`](#authentication-for-visual-reviews) is `true`, you must enter your [personal access token](../../user/profile/personal_access_tokens.md).
+ - Otherwise, enter your name, and optionally your email.
+1. Click **Send feedback**.
+
+<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
+To see Visual reviews in action, see the [Visual Reviews Walk through](https://youtu.be/1_tvWTlPfM4).
+
+### Configure Review Apps for Visual Reviews
The feedback form is served through a script you add to pages in your Review App.
If you have [Developer permissions](../../user/permissions.md) to the project,
@@ -212,18 +227,18 @@ if [route maps](#route-maps) are configured in the project.
![review button](img/review_button.png)
The provided script should be added to the `<head>` of your application and
-consists of some project and merge request specific values. Here's what it
-looks like:
+consists of some project and merge request specific values. Here's how it
+looks for a project with code hosted in a project on GitLab.com:
```html
<script
data-project-id='11790219'
data-merge-request-id='1'
- data-mr-url='https://gitlab.example.com'
+ data-mr-url='https://gitlab.com'
data-project-path='sarah/review-app-tester'
data-require-auth='true'
id='review-app-toolbar-script'
- src='https://gitlab.example.com/assets/webpack/visual_review_toolbar.js'>
+ src='https://gitlab.com/assets/webpack/visual_review_toolbar.js'>
</script>
```
@@ -239,21 +254,21 @@ to replace those values at runtime when each review app is created:
- `data-mr-url` is the URL of the GitLab instance and will be the same for all
review apps.
- `data-project-path` is the project's path, which can be found by `CI_PROJECT_PATH`.
-- `data-require-auth` is optional for public projects but required for [private and internal ones](#visual-reviews-in-private-or-internal-projects). If this is set to `true`, the user will be required to enter their [personal access token](../../user/profile/personal_access_tokens.md) instead of their name and email.
+- `data-require-auth` is optional for public projects but required for [private and internal ones](#authentication-for-visual-reviews). If this is set to `true`, the user will be required to enter their [personal access token](../../user/profile/personal_access_tokens.md) instead of their name and email.
- `id` is always `review-app-toolbar-script`, you don't need to change that.
- `src` is the source of the review toolbar script, which resides in the
respective GitLab instance and will be the same for all review apps.
-For example, in a Ruby application, you would need to have this script:
+For example, in a Ruby application with code hosted on in a project GitLab.com, you would need to have this script:
```html
<script
data-project-id="ENV['CI_PROJECT_ID']"
data-merge-request-id="ENV['CI_MERGE_REQUEST_IID']"
- data-mr-url='https://gitlab.example.com'
+ data-mr-url='https://gitlab.com'
data-project-path="ENV['CI_PROJECT_PATH']"
id='review-app-toolbar-script'
- src='https://gitlab.example.com/assets/webpack/visual_review_toolbar.js'>
+ src='https://gitlab.com/assets/webpack/visual_review_toolbar.js'>
</script>
```
@@ -273,33 +288,34 @@ can supply the ID by either:​​
- Dynamically adding the `data-merge-request-id` value during the build of the app.
- Supplying it manually through the visual review form in the app.
-### Visual Reviews in private or internal projects
+### Enable or disable Visual Reviews **(STARTER ONLY)**
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/42750#note_317271120) in GitLab 12.10.
+Visual Reviews is deployed behind a feature flag that is **enabled by default**.
+[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md)
+can opt to disable it.
-To enable visual reviews for private and internal projects, set the
-[`data-require-auth` variable](#configuring-visual-reviews) to `true`. When enabled,
-the user must enter a [personal access token](../../user/profile/personal_access_tokens.md)
-with `api` scope before submitting feedback.
+To disable it:
-### Using Visual Reviews
+```ruby
+Feature.disable(:anonymous_visual_review_feedback)
+```
-After Visual Reviews has been [enabled](#configuring-visual-reviews) for the
-Review App, the Visual Reviews feedback form is overlaid on the app's pages at
-the bottom-right corner.
+To enable it:
-![Visual review feedback form](img/toolbar_feeback_form.png)
+```ruby
+Feature.enable(:anonymous_visual_review_feedback)
+```
-To use the feedback form:
+### Authentication for Visual Reviews
-1. Make a comment on the visual review. You can make use of all the
- [Markdown annotations](../../user/markdown.md) that are also available in
- merge request comments.
-1. If `data-require-auth` is `true`, you must enter your [personal access token](../../user/profile/personal_access_tokens.md). Otherwise, you must enter your name, and optionally, your email.
-1. Finally, click **Send feedback**.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/42750#note_317271120) in GitLab 12.10.
+
+To enable visual reviews for private and internal projects, set the
+[`data-require-auth` variable](#enable-or-disable-visual-reviews) to `true`. When enabled,
+the user must enter a [personal access token](../../user/profile/personal_access_tokens.md)
+with `api` scope before submitting feedback.
-After you make and submit a comment in the visual review box, it will appear
-automatically in the respective merge request.
+This same method can be used to require authentication for any public projects.
## Limitations
diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md
index 32561e6b98c..afcbd68aaf5 100644
--- a/doc/ci/runners/README.md
+++ b/doc/ci/runners/README.md
@@ -39,11 +39,10 @@ multiple projects.
If you are using a self-managed instance of GitLab:
-- Your administrator can install and register shared runners by viewing the instructions
- [here](https://docs.gitlab.com/runner/install/index.html).
+- Your administrator can install and register shared runners by [following the documentation](https://docs.gitlab.com/runner/install/index.html).
<!-- going to your project's
<!-- **Settings > CI / CD**, expanding the **Runners** section, and clicking **Show runner installation instructions**.-->
- <!-- These instructions are also available [here](https://docs.gitlab.com/runner/install/index.html).-->
+ <!-- These instructions are also available [in the documentation](https://docs.gitlab.com/runner/install/index.html).-->
- The administrator can also configure a maximum number of shared runner [pipeline minutes for
each group](../../user/admin_area/settings/continuous_integration.md#shared-runners-pipeline-minutes-quota).
diff --git a/doc/ci/secrets/index.md b/doc/ci/secrets/index.md
index 6d561fe00a3..fb1183cd68b 100644
--- a/doc/ci/secrets/index.md
+++ b/doc/ci/secrets/index.md
@@ -17,23 +17,36 @@ Unlike CI variables, which are always presented to a job, secrets must be explic
required by a job. Read [GitLab CI/CD pipeline configuration reference](../yaml/README.md#secrets)
for more information about the syntax.
-GitLab has selected [Vault by Hashicorp](https://www.vaultproject.io) as the
+GitLab has selected [Vault by HashiCorp](https://www.vaultproject.io) as the
first supported provider, and [KV-V2](https://www.vaultproject.io/docs/secrets/kv/kv-v2)
as the first supported secrets engine.
GitLab authenticates using Vault's
-[JWT Auth method](https://www.vaultproject.io/docs/auth/jwt#jwt-authentication), using
+[JSON Web Token (JWT) authentication method](https://www.vaultproject.io/docs/auth/jwt#jwt-authentication), using
the [JSON Web Token](https://gitlab.com/gitlab-org/gitlab/-/issues/207125) (`CI_JOB_JWT`)
introduced in GitLab 12.10.
You must [configure your Vault server](#configure-your-vault-server) before you
can use [use Vault secrets in a CI job](#use-vault-secrets-in-a-ci-job).
+The flow for using GitLab with HashiCorp Vault
+is summarized by this diagram:
+
+![Flow between GitLab and HashiCorp](../img/gitlab_vault_workflow_v13_4.png "How GitLab CI_JOB_JWT works with HashiCorp Vault")
+
+1. Configure your vault and secrets.
+1. Generate your JWT and provide it to your CI job.
+1. Runner contacts HashiCorp Vault and authenticates using the JWT.
+1. HashiCorp Vault verifies the JWT.
+1. HashiCorp Vault checks the bounded claims and attaches policies.
+1. HashiCorp Vault returns the token.
+1. Runner reads secrets from the HashiCorp Vault.
+
NOTE: **Note:**
-Read the [Authenticating and Reading Secrets With Hashicorp Vault](../examples/authenticating-with-hashicorp-vault/index.md)
-tutorial for a version of this feature that is available to all
+Read the [Authenticating and Reading Secrets With HashiCorp Vault](../examples/authenticating-with-hashicorp-vault/index.md)
+tutorial for a version of this feature. It's available to all
subscription levels, supports writing secrets to and deleting secrets from Vault,
-and multiple secrets engines.
+and supports multiple secrets engines.
## Configure your Vault server
@@ -90,7 +103,7 @@ the secrets stored in Vault by defining them with the `vault` keyword:
```yaml
secrets:
DATABASE_PASSWORD:
- vault: production/db/password@ops # translates to secret `ops/data/production/db`, field `password`
+ vault: production/db/password@ops # translates to secret `ops/data/production/db`, field `password`
```
In this example:
@@ -149,7 +162,7 @@ generated by this GitLab instance may be allowed to authenticate using this role
For a full list of `CI_JOB_JWT` claims, read the
[How it works](../examples/authenticating-with-hashicorp-vault/index.md#how-it-works) section of the
-[Authenticating and Reading Secrets With Hashicorp Vault](../examples/authenticating-with-hashicorp-vault/index.md) tutorial.
+[Authenticating and Reading Secrets With HashiCorp Vault](../examples/authenticating-with-hashicorp-vault/index.md) tutorial.
You can also specify some attributes for the resulting Vault tokens, such as time-to-live,
IP address range, and number of uses. The full list of options is available in
diff --git a/doc/ci/triggers/README.md b/doc/ci/triggers/README.md
index 2b006b8779b..d88a6afbadb 100644
--- a/doc/ci/triggers/README.md
+++ b/doc/ci/triggers/README.md
@@ -95,9 +95,9 @@ Read more about the [jobs API](../../api/job_artifacts.md#download-the-artifacts
## Adding a new trigger
-You can add a new trigger by going to your project's
-**Settings ➔ CI/CD** under **Triggers**. The **Add trigger** button will
-create a new token which you can then use to trigger a rerun of this
+Go to your
+**Settings ➔ CI/CD** under **Triggers** to add a new trigger. The **Add trigger** button creates
+a new token which you can then use to trigger a rerun of this
particular project's pipeline.
Every new trigger you create, gets assigned a different token which you can
@@ -121,7 +121,7 @@ POST /projects/:id/trigger/pipeline
```
The required parameters are the [trigger's `token`](#authentication-tokens)
-and the Git `ref` on which the trigger will be performed. Valid refs are
+and the Git `ref` on which the trigger is performed. Valid refs are
branches or tags. The `:id` of a project can be found by
[querying the API](../../api/projects.md) or by visiting the **CI/CD**
settings page which provides self-explanatory examples.
@@ -146,7 +146,7 @@ curl --request POST \
https://gitlab.example.com/api/v4/projects/9/trigger/pipeline
```
-In this case, the project with ID `9` will get rebuilt on `master` branch.
+In this case, the project with ID `9` gets rebuilt on `master` branch.
Alternatively, you can pass the `token` and `ref` arguments in the query string:
@@ -169,9 +169,9 @@ build_docs:
- tags
```
-This means that whenever a new tag is pushed on project A, the job will run and the
-`build_docs` job will be executed, triggering a rebuild of project B. The
-`stage: deploy` ensures that this job will run only after all jobs with
+This means that whenever a new tag is pushed on project A, the job runs and the
+`build_docs` job is executed, triggering a rebuild of project B. The
+`stage: deploy` ensures that this job runs only after all jobs with
`stage: test` complete successfully.
## Triggering a pipeline from a webhook
@@ -190,7 +190,7 @@ trigger in the source repository. `ref` should be URL-encoded if it contains sla
## Making use of trigger variables
You can pass any number of arbitrary variables in the trigger API call and they
-will be available in GitLab CI/CD so that they can be used in your `.gitlab-ci.yml`
+are available in GitLab CI/CD so that they can be used in your `.gitlab-ci.yml`
file. The parameter is of the form:
```plaintext
@@ -237,7 +237,7 @@ upload_package:
```
You can then trigger a rebuild while you pass the `UPLOAD_TO_S3` variable
-and the script of the `upload_package` job will run:
+and the script of the `upload_package` job is run:
```shell
curl --request POST \
@@ -266,7 +266,7 @@ branch of project with ID `9` every night at `00:30`:
## Legacy triggers
-Old triggers, created before GitLab 9.0 will be marked as legacy.
+Old triggers, created before GitLab 9.0 are marked as legacy.
Triggers with the legacy label do not have an associated user and only have
access to the current project. They are considered deprecated and will be
diff --git a/doc/ci/unit_test_reports.md b/doc/ci/unit_test_reports.md
index 5a59a175a89..79da54ccffe 100644
--- a/doc/ci/unit_test_reports.md
+++ b/doc/ci/unit_test_reports.md
@@ -10,8 +10,6 @@ type: reference
> - [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/45318) in GitLab 11.2. Requires GitLab Runner 11.2 and above.
> - [Renamed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39737) from JUnit test reports to Unit test reports in GitLab 13.4.
-## Overview
-
It is very common that a [CI/CD pipeline](pipelines/index.md) contains a
test job that will verify your code.
If the tests fail, the pipeline fails and users get notified. The person that
diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md
index 1a982fa4e19..c83065aebaa 100644
--- a/doc/ci/variables/README.md
+++ b/doc/ci/variables/README.md
@@ -103,8 +103,8 @@ variables:
You can then call its value in your script:
```yaml
- script:
- - echo "$TEST"
+script:
+ - echo "$TEST"
```
For more details, see [`.gitlab-ci.yml` defined variables](#gitlab-ciyml-defined-variables).
@@ -571,7 +571,7 @@ in which you wish to use it.
## Where variables can be used
-Click [here](where_variables_can_be_used.md) for a section that describes where and how the different types of variables can be used.
+[This section](where_variables_can_be_used.md) describes where and how the different types of variables can be used.
## Advanced use
@@ -609,8 +609,8 @@ then available as environment variables on the running application
container.
CAUTION: **Caution:**
-Variables with multi-line values are not currently supported due to
-limitations with the current Auto DevOps scripting environment.
+Variables with multi-line values are not supported due to
+limitations with the Auto DevOps scripting environment.
### Override a variable by manually running a pipeline
@@ -785,7 +785,7 @@ Examples:
##### Enable or disable parenthesis support for variables **(CORE ONLY)**
-The feature is currently deployed behind a feature flag that is **enabled by default**.
+The feature is deployed behind a feature flag that is **enabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md)
can opt to disable it for your instance.
@@ -820,8 +820,7 @@ NOTE: **Note:**
The available regular expression syntax is limited. See [related issue](https://gitlab.com/gitlab-org/gitlab/-/issues/35438)
for more details.
-If needed, you can use a test pipeline to determine whether a regular expression will
-work in a variable. The example below tests the `^mast.*` regular expression directly,
+If needed, you can use a test pipeline to determine whether a regular expression works in a variable. The example below tests the `^mast.*` regular expression directly,
as well as from within a variable:
```yaml
diff --git a/doc/ci/variables/predefined_variables.md b/doc/ci/variables/predefined_variables.md
index 915041b71a6..7341c7a917d 100644
--- a/doc/ci/variables/predefined_variables.md
+++ b/doc/ci/variables/predefined_variables.md
@@ -69,7 +69,8 @@ Kubernetes-specific environment variables are detailed in the
| `CI_JOB_MANUAL` | 8.12 | all | The flag to indicate that job was manually started |
| `CI_JOB_NAME` | 9.0 | 0.5 | The name of the job as defined in `.gitlab-ci.yml` |
| `CI_JOB_STAGE` | 9.0 | 0.5 | The name of the stage as defined in `.gitlab-ci.yml` |
-| `CI_JOB_TOKEN` | 9.0 | 1.2 | Token used for authenticating with the [GitLab Container Registry](../../user/packages/container_registry/index.md), downloading [dependent repositories](../../user/project/new_ci_build_permissions_model.md#dependent-repositories), and accessing [GitLab-managed Terraform state](../../user/infrastructure/index.md#gitlab-managed-terraform-state). |
+| `CI_JOB_STATUS` | all | 13.5 | The state of the job as each runner stage is executed. Use with [`after_script`](../yaml/README.md#before_script-and-after_script) where `CI_JOB_STATUS` can be either: `success`, `failed` or `canceled`. |
+| `CI_JOB_TOKEN` | 9.0 | 1.2 | Token used for authenticating with [a few API endpoints](../../api/README.md#gitlab-ci-job-token) and downloading [dependent repositories](../../user/project/new_ci_build_permissions_model.md#dependent-repositories). The token is valid as long as the job is running. |
| `CI_JOB_JWT` | 12.10 | all | RS256 JSON web token that can be used for authenticating with third party systems that support JWT authentication, for example [HashiCorp's Vault](../secrets/index.md). |
| `CI_JOB_URL` | 11.1 | 0.5 | Job details URL |
| `CI_KUBERNETES_ACTIVE` | 13.0 | all | Included with the value `true` only if the pipeline has a Kubernetes cluster available for deployments. Not included if no cluster is available. Can be used as an alternative to [`only:kubernetes`/`except:kubernetes`](../yaml/README.md#onlykubernetesexceptkubernetes) with [`rules:if`](../yaml/README.md#rulesif) |
diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md
index 40df9c7c986..98ff5c22e7c 100644
--- a/doc/ci/yaml/README.md
+++ b/doc/ci/yaml/README.md
@@ -33,11 +33,8 @@ We have complete examples of configuring pipelines:
> - <i class="fa fa-youtube-play youtube" aria-hidden="true"></i>&nbsp;Learn how [Verizon reduced rebuilds](https://about.gitlab.com/blog/2019/02/14/verizon-customer-story/)
> from 30 days to under 8 hours with GitLab.
-NOTE: **Note:**
If you have a [mirrored repository that GitLab pulls from](../../user/project/repository/repository_mirroring.md#pulling-from-a-remote-repository),
-you may need to enable pipeline triggering. Go to your project's
-
-**Settings > Repository > Pull from a remote repository > Trigger pipelines for mirror updates**.
+you may need to enable pipeline triggering. Go to your project's **Settings > Repository > Pull from a remote repository > Trigger pipelines for mirror updates**.
## Introduction
@@ -961,7 +958,7 @@ GitLab performs a reverse deep merge based on the keys. GitLab:
- Merges the `rspec` contents into `.tests` recursively.
- Doesn't merge the values of the keys.
-The result is this `rspec` job:
+The result is this `rspec` job, where `script: rake test` is overwritten by `script: rake rspec`:
```yaml
rspec:
@@ -974,9 +971,6 @@ rspec:
- $RSPEC
```
-NOTE: **Note:**
-Note that `script: rake test` has been overwritten by `script: rake rspec`.
-
If you do want to include the `rake test`, see [`before_script` and `after_script`](#before_script-and-after_script).
`.tests` in this example is a [hidden job](#hide-jobs), but it's
@@ -1547,11 +1541,11 @@ docker build:
- Dockerfile
- docker/scripts/*
when: manual
- # - when: never would be redundant here, this is implied any time rules are listed.
+ # - when: never would be redundant here, this is implied any time rules are listed.
```
-Keywords such as `branches` or `refs` that are currently available for
-`only`/`except` are not yet available in `rules` as they are being individually
+Keywords such as `branches` or `refs` that are available for
+`only`/`except` are not available in `rules`. They are being individually
considered for their usage and behavior in this context. Future keyword improvements
are being discussed in our [epic for improving `rules`](https://gitlab.com/groups/gitlab-org/-/epics/2783),
where anyone can add suggestions or requests.
@@ -1754,7 +1748,7 @@ the pipeline if the following is true:
In the example below, the `test` job will `only` be created when **all** of the following are true:
-- The pipeline has been [scheduled](../../user/project/pipelines/schedules.md) **or** runs for `master`.
+- The pipeline has been [scheduled](../pipelines/schedules.md) **or** runs for `master`.
- The `variables` keyword matches.
- The `kubernetes` service is active on the project.
@@ -3578,6 +3572,11 @@ job split into three separate jobs.
Use `matrix:` to configure different variables for jobs that are running in parallel.
There can be from 2 to 50 jobs.
+In GitLab 13.5 and later, you can have one-dimensional matrices with a single job.
+The ability to have one-dimensional matrices is [deployed behind a feature flag](../../user/feature_flags.md),
+disabled by default. It's disabled on GitLab.com. To use it in a GitLab self-managed
+instance, ask a GitLab administrator to [enable the `one_dimensional_matrix:` feature flag](../../administration/feature_flags.md). **(CORE-ONLY)**
+
Every job gets the same `CI_NODE_TOTAL` [environment variable](../variables/README.md#predefined-environment-variables) value, and a unique `CI_NODE_INDEX` value.
```yaml
@@ -4056,7 +4055,9 @@ release-cli create --name "Release $CI_COMMIT_SHA" --description "Created using
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33014) in GitLab 13.4.
`secrets` indicates the [CI Secrets](../secrets/index.md) this job needs. It should be a hash,
-and the keys should be the names of the environment variables the job needs to access the secrets.
+and the keys should be the names of the environment variables that are made available to the job.
+The value of each secret is saved in a temporary file. This file's path is stored in these
+environment variables.
#### `secrets:vault` **(PREMIUM)**
@@ -4072,7 +4073,7 @@ field to fetch the value for:
job:
secrets:
DATABASE_PASSWORD:
- vault: production/db/password # translates to secret `kv-v2/data/production/db`, field `password`
+ vault: production/db/password # translates to secret `kv-v2/data/production/db`, field `password`
```
You can specify a custom secrets engine path by adding a suffix starting with `@`:
@@ -4081,7 +4082,7 @@ You can specify a custom secrets engine path by adding a suffix starting with `@
job:
secrets:
DATABASE_PASSWORD:
- vault: production/db/password@ops # translates to secret `ops/data/production/db`, field `password`
+ vault: production/db/password@ops # translates to secret `ops/data/production/db`, field `password`
```
In the detailed form of the syntax, you can specify all details explicitly:
diff --git a/doc/customization/welcome_message.md b/doc/customization/welcome_message.md
index 45f1fed355e..9667e5e380a 100644
--- a/doc/customization/welcome_message.md
+++ b/doc/customization/welcome_message.md
@@ -1,5 +1,5 @@
---
-redirect_to: 'branded_login_page.md'
+redirect_to: '../user/admin_area/appearance.md#sign-in--sign-up-pages'
---
-This document was moved to [another location](branded_login_page.md).
+This document was moved to [another location](../user/admin_area/appearance.md#sign-in--sign-up-pages).
diff --git a/doc/development/README.md b/doc/development/README.md
index abdd5c662f3..ee4643ed5a5 100644
--- a/doc/development/README.md
+++ b/doc/development/README.md
@@ -43,6 +43,7 @@ For information on how to install, configure, update, and upgrade your own GitLa
**Must-reads:**
+- [Guide on adapting existing and introducing new components](architecture.md#adapting-existing-and-introducing-new-components)
- [Code review guidelines](code_review.md) for reviewing code and having code reviewed
- [Database review guidelines](database_review.md) for reviewing database-related changes and complex SQL queries, and having them reviewed
- [Secure coding guidelines](secure_coding_guidelines.md)
diff --git a/doc/development/adding_service_component.md b/doc/development/adding_service_component.md
index 2801e27145d..bb5af286c1d 100644
--- a/doc/development/adding_service_component.md
+++ b/doc/development/adding_service_component.md
@@ -46,7 +46,7 @@ TIP: **Tip:**
**For services that depend on the existing GitLab codebase:**
-The first iteration should be opt-in, either through the `gitlab.yml` configuration or through [feature flags](feature_flags.md). For these types of services it is often necessary to [bundle the service and its dependencies with GitLab](#bundling-a-service-with-gitlab) as part of the initial integration.
+The first iteration should be opt-in, either through the `gitlab.yml` configuration or through [feature flags](feature_flags/index.md). For these types of services it is often necessary to [bundle the service and its dependencies with GitLab](#bundling-a-service-with-gitlab) as part of the initial integration.
TIP: **Tip:**
[ActionCable](https://docs.gitlab.com/omnibus/settings/actioncable.html) is an example of a service that has been added this way.
diff --git a/doc/development/api_graphql_styleguide.md b/doc/development/api_graphql_styleguide.md
index 18fc0fb7d33..d083ed8f67e 100644
--- a/doc/development/api_graphql_styleguide.md
+++ b/doc/development/api_graphql_styleguide.md
@@ -49,6 +49,20 @@ See also:
- [Exposing Global IDs](#exposing-global-ids).
- [Mutation arguments](#object-identifier-arguments).
+We have a custom scalar type (`Types::GlobalIDType`) which should be used as the
+type of input and output arguments when the value is a `GlobalID`. The benefits
+of using this type instead of `ID` are:
+
+- it validates that the value is a `GlobalID`
+- it parses it into a `GlobalID` before passing it to user code
+- it can be parameterized on the type of the object (e.g.
+ `GlobalIDType[Project]`) which offers even better validation and security.
+
+Consider using this type for all new arguments and result types. Remember that
+it is perfectly possible to parameterize this type with a concern or a
+supertype, if you want to accept a wider range of objects (e.g.
+`GlobalIDType[Issuable]` vs `GlobalIDType[Issue]`).
+
## Types
We use a code-first schema, and we declare what type everything is in Ruby.
@@ -780,6 +794,25 @@ to advertise the need for lookahead:
For an example of real world use, please
see [`ResolvesMergeRequests`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/graphql/resolvers/concerns/resolves_merge_requests.rb).
+### Negated arguments
+
+Negated filters can filter some resources (for example, find all issues that
+have the `bug` label, but don't have the `bug2` label assigned). The `not`
+argument is the preferred syntax to pass negated arguments:
+
+```graphql
+issues(labelName: "bug", not: {labelName: "bug2"}) {
+ nodes {
+ id
+ title
+ }
+}
+```
+
+To avoid duplicated argument definitions, you can place these arguments in a reusable module (or
+class, if the arguments are nested). Alternatively, you can consider to add a
+[helper resolver method](https://gitlab.com/gitlab-org/gitlab/-/issues/258969).
+
## Pass a parent object into a child Presenter
Sometimes you need to access the resolved query parent in a child context to compute fields. Usually the parent is only
@@ -827,10 +860,39 @@ mutation.
### Building Mutations
-Mutations live in `app/graphql/mutations` ideally grouped per
+Mutations are stored in `app/graphql/mutations`, ideally grouped per
resources they are mutating, similar to our services. They should
inherit `Mutations::BaseMutation`. The fields defined on the mutation
-will be returned as the result of the mutation.
+are returned as the result of the mutation.
+
+#### Update mutation granularity
+
+GitLab's service-oriented architecture means that most mutations call a Create, Delete, or Update
+service, for example `UpdateMergeRequestService`.
+For Update mutations, a you might want to only update one aspect of an object, and thus only need a
+_fine-grained_ mutation, for example `MergeRequest::SetWip`.
+
+It's acceptable to have both fine-grained mutations and coarse-grained mutations, but be aware
+that too many fine-grained mutations can lead to organizational challenges in maintainability, code
+comprehensibility, and testing.
+Each mutation requires a new class, which can lead to technical debt.
+It also means the schema becomes very big, and we want users to easily navigate our schema.
+As each new mutation also needs tests (including slower request integration tests), adding mutations
+slows down the test suite.
+
+To minimize changes:
+
+- Use existing mutations, such as `MergeRequest::Update`, when available.
+- Expose existing services as a coarse-grained mutation.
+
+When a fine-grained mutation might be more appropriate:
+
+- Modifying a property that requires specific permissions or other specialized logic.
+- Exposing a state-machine-like transition (locking issues, merging MRs, closing epics, etc).
+- Accepting nested properties (where we accept properties for a child object).
+- The semantics of the mutation can be expressed clearly and concisely.
+
+See [issue #233063](https://gitlab.com/gitlab-org/gitlab/-/issues/233063) for further context.
### Naming conventions
@@ -1361,5 +1423,4 @@ For information on generating GraphQL documentation and schema files, see
[updating the schema documentation](rake_tasks.md#update-graphql-documentation-and-schema-definitions).
To help our readers, you should also add a new page to our [GraphQL API](../api/graphql/index.md) documentation.
-For guidance, see the [GraphQL API](documentation/styleguide.md#graphql-api) section
-of our documentation style guide.
+For guidance, see the [GraphQL API](documentation/graphql_styleguide.md) page.
diff --git a/doc/development/api_styleguide.md b/doc/development/api_styleguide.md
index 400752c69e9..47c06377d88 100644
--- a/doc/development/api_styleguide.md
+++ b/doc/development/api_styleguide.md
@@ -17,7 +17,7 @@ Each new or updated API endpoint must come with documentation, unless it is inte
The docs should be in the same merge request, or, if strictly necessary,
in a follow-up with the same milestone as the original merge request.
-See the [Documentation Style Guide RESTful API section](documentation/styleguide.md#restful-api) for details on documenting API resources in Markdown as well as in OpenAPI definition files.
+See the [Documentation Style Guide RESTful API page](documentation/restful_api_styleguide.md) for details on documenting API resources in Markdown as well as in OpenAPI definition files.
## Methods and parameters description
diff --git a/doc/development/architecture.md b/doc/development/architecture.md
index f6b1c8cd914..2646ad7052b 100644
--- a/doc/development/architecture.md
+++ b/doc/development/architecture.md
@@ -1,32 +1,91 @@
-# GitLab Architecture Overview
+# GitLab architecture overview
## Software delivery
-There are two software distributions of GitLab: the open source [Community Edition](https://gitlab.com/gitlab-org/gitlab-foss/) (CE), and the open core [Enterprise Edition](https://gitlab.com/gitlab-org/gitlab/) (EE). GitLab is available under [different subscriptions](https://about.gitlab.com/pricing/).
+There are two software distributions of GitLab:
-New versions of GitLab are released in stable branches and the master branch is for bleeding edge development.
+- The open source [Community Edition](https://gitlab.com/gitlab-org/gitlab-foss/) (CE).
+- The open core [Enterprise Edition](https://gitlab.com/gitlab-org/gitlab/) (EE).
-For information, see the [GitLab Release Process](https://gitlab.com/gitlab-org/release/docs/-/tree/master#gitlab-release-process).
+GitLab is available under [different subscriptions](https://about.gitlab.com/pricing/).
-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 the exception of informal security updates deemed critical.
+New versions of GitLab are released from stable branches, and the `master` branch is used for
+bleeding-edge development.
+
+For more information, visit the [GitLab Release Process](https://about.gitlab.com/handbook/engineering/releases/).
+
+Both distributions require additional components. These components are described in the
+[Component details](#components) section, and all have their own repositories.
+New versions of each dependent component are usually tags, but staying on the `master` branch of the
+GitLab codebase gives you the latest stable version of those components. New versions are
+generally released around the same time as GitLab releases, with the exception of informal security
+updates deemed critical.
## Components
-A typical install of GitLab will be on GNU/Linux. It uses NGINX or Apache as a web front end to proxypass the Unicorn web server. By default, communication between Unicorn and the front end is via a Unix domain socket but forwarding requests via TCP is also supported. The web front end accesses `/home/git/gitlab/public` bypassing the Unicorn server to serve static pages, uploads (e.g. avatar images or attachments), and pre-compiled assets. GitLab serves web pages and the [GitLab API](../api/README.md) using the Unicorn web server. It uses Sidekiq as a job queue which, in turn, uses Redis as a non-persistent database backend for job information, meta data, and incoming jobs.
+A typical install of GitLab is on GNU/Linux, but growing number of deployments also use the
+Kubernetes platform. The largest known GitLab instance is on GitLab.com, which is deployed using our
+[official GitLab Helm chart](https://docs.gitlab.com/charts/) and the [official Linux package](https://about.gitlab.com/install/).
+
+A typical installation uses NGINX or Apache as a web server to proxy through
+[GitLab Workhorse](https://gitlab.com/gitlab-org/gitlab-workhorse) and into the [Puma](https://puma.io)
+application server. GitLab serves web pages and the [GitLab API](../api/README.md) using the Puma
+application server. It uses Sidekiq as a job queue which, in turn, uses Redis as a non-persistent
+database backend for job information, metadata, and incoming jobs.
-We also support deploying GitLab on Kubernetes using our [GitLab Helm chart](https://docs.gitlab.com/charts/).
+By default, communication between Puma and Workhorse is via a Unix domain socket, but forwarding
+requests via TCP is also supported. Workhorse accesses the `gitlab/public` directory, bypassing the
+Puma application server to serve static pages, uploads (for example, avatar images or attachments),
+and pre-compiled assets.
-The GitLab web app uses PostgreSQL for persistent database information (e.g. users, permissions, issues, other meta data). GitLab stores the bare Git repositories it serves in `/home/git/repositories` by default. It also keeps default branch and hook information with the bare repository.
+The GitLab application uses PostgreSQL for persistent database information (for example, users,
+permissions, issues, or other metadata). GitLab stores the bare Git repositories in the location
+defined in [the configuration file, `repositories:` section](https://gitlab.com/gitlab-org/gitlab/blob/master/config/gitlab.yml.example).
+It also keeps default branch and hook information with the bare repository.
-When serving repositories over HTTP/HTTPS GitLab utilizes the GitLab API to resolve authorization and access as well as serving Git objects.
+When serving repositories over HTTP/HTTPS GitLab uses the GitLab API to resolve authorization and
+access and to serve Git objects.
-The add-on component GitLab Shell serves repositories over SSH. It manages the SSH keys within `/home/git/.ssh/authorized_keys` which should not be manually edited. GitLab Shell accesses the bare repositories through Gitaly to serve Git objects and communicates with Redis to submit jobs to Sidekiq for GitLab to process. GitLab Shell queries the GitLab API to determine authorization and access.
+The add-on component GitLab Shell serves repositories over SSH. It manages the SSH keys within the
+location defined in [the configuration file, `GitLab Shell` section](https://gitlab.com/gitlab-org/gitlab/blob/master/config/gitlab.yml.example).
+The file in that location should never be manually edited. GitLab Shell accesses the bare
+repositories through Gitaly to serve Git objects, and communicates with Redis to submit jobs to
+Sidekiq for GitLab to process. GitLab Shell queries the GitLab API to determine authorization and access.
-Gitaly executes Git operations from GitLab Shell and the GitLab web app, and provides an API to the GitLab web app to get attributes from Git (e.g. title, branches, tags, other meta data), and to get blobs (e.g. diffs, commits, files).
+Gitaly executes Git operations from GitLab Shell and the GitLab web app, and provides an API to the
+GitLab web app to get attributes from Git (for example, title, branches, tags, or other metadata),
+and to get blobs (for example, diffs, commits, or files).
You may also be interested in the [production architecture of GitLab.com](https://about.gitlab.com/handbook/engineering/infrastructure/production/architecture/).
-### Simplified Component Overview
+## Adapting existing and introducing new components
+
+There are fundamental differences in how the application behaves when it is installed on a
+traditional Linux machine compared to a containerized platform, such as Kubernetes.
+
+Compared to [our official installation methods](https://about.gitlab.com/install/), some of the
+notable differences are:
+
+- Official Linux packages can access files on the same file system with different services.
+ [Shared files](shared_files.md) are not an option for the application running on the Kubernetes
+ platform.
+- Official Linux packages by default have services that have access to the shared configuration and
+ network. This is not the case for services running in Kubernetes, where services might be running
+ in complete isolation, or only accessible through specific ports.
+
+In other words, the shared state between services needs to be carefully considered when
+architecting new features and adding new components. Services that need to have access to the same
+files, need to be able to exchange information through the appropriate APIs. Whenever possible,
+this should not be done with files.
+
+Since components written with the API-first philosophy in mind are compatible with both methods, all
+new features and services must be written to consider Kubernetes compatibility **first**.
+
+The simplest way to ensure this, is to add support for your feature or service to
+[the official GitLab Helm chart](https://docs.gitlab.com/charts/) or reach out to
+[the Distribution team](https://about.gitlab.com/handbook/engineering/development/enablement/distribution/#how-to-work-with-distribution).
+
+### Simplified component overview
This is a simplified architecture diagram that can be used to
understand GitLab's architecture.
@@ -51,15 +110,15 @@ SSH -- TCP 22 --> GitLabShell[GitLab Shell]
SMTP[SMTP Gateway]
Geo[GitLab Geo Node] -- TCP 22, 80, 443 --> NGINX
-GitLabShell --TCP 8080 -->Unicorn["Unicorn (GitLab Rails)"]
+GitLabShell --TCP 8080 -->Puma["Puma (GitLab Rails)"]
GitLabShell --> Praefect
-Unicorn --> PgBouncer[PgBouncer]
-Unicorn --> Redis
-Unicorn --> Praefect
+Puma --> PgBouncer[PgBouncer]
+Puma --> Redis
+Puma --> Praefect
Sidekiq --> Redis
Sidekiq --> PgBouncer
Sidekiq --> Praefect
-GitLabWorkhorse[GitLab Workhorse] --> Unicorn
+GitLabWorkhorse[GitLab Workhorse] --> Puma
GitLabWorkhorse --> Redis
GitLabWorkhorse --> Praefect
Praefect --> Gitaly
@@ -67,7 +126,7 @@ NGINX --> GitLabWorkhorse
NGINX -- TCP 8090 --> GitLabPages[GitLab Pages]
NGINX --> Grafana[Grafana]
Grafana -- TCP 9090 --> Prometheus[Prometheus]
-Prometheus -- TCP 80, 443 --> Unicorn
+Prometheus -- TCP 80, 443 --> Puma
RedisExporter[Redis Exporter] --> Redis
Prometheus -- TCP 9121 --> RedisExporter
PostgreSQLExporter[PostgreSQL Exporter] --> PostgreSQL
@@ -83,27 +142,27 @@ PgBouncer --> Consul
PostgreSQL --> Consul
PgBouncer --> PostgreSQL
NGINX --> Registry
-Unicorn --> Registry
+Puma --> Registry
NGINX --> Mattermost
-Mattermost --- Unicorn
+Mattermost --- Puma
Prometheus --> Alertmanager
Migrations --> PostgreSQL
Runner -- TCP 443 --> NGINX
-Unicorn -- TCP 9200 --> Elasticsearch
+Puma -- TCP 9200 --> Elasticsearch
Sidekiq -- TCP 9200 --> Elasticsearch
Sidekiq -- TCP 80, 443 --> Sentry
-Unicorn -- TCP 80, 443 --> Sentry
+Puma -- TCP 80, 443 --> Sentry
Sidekiq -- UDP 6831 --> Jaeger
-Unicorn -- UDP 6831 --> Jaeger
+Puma -- UDP 6831 --> Jaeger
Gitaly -- UDP 6831 --> Jaeger
GitLabShell -- UDP 6831 --> Jaeger
GitLabWorkhorse -- UDP 6831 --> Jaeger
Alertmanager -- TCP 25 --> SMTP
Sidekiq -- TCP 25 --> SMTP
-Unicorn -- TCP 25 --> SMTP
-Unicorn -- TCP 369 --> LDAP
+Puma -- TCP 25 --> SMTP
+Puma -- TCP 369 --> LDAP
Sidekiq -- TCP 369 --> LDAP
-Unicorn -- TCP 443 --> ObjectStorage["Object Storage"]
+Puma -- TCP 443 --> ObjectStorage["Object Storage"]
Sidekiq -- TCP 443 --> ObjectStorage
GitLabWorkhorse -- TCP 443 --> ObjectStorage
Registry -- TCP 443 --> ObjectStorage
@@ -121,7 +180,7 @@ click Gitaly "./architecture.html#gitaly"
click Jaeger "./architecture.html#jaeger"
click GitLabWorkhorse "./architecture.html#gitlab-workhorse"
click LDAP "./architecture.html#ldap-authentication"
-click Unicorn "./architecture.html#unicorn"
+click Puma "./architecture.html#puma"
click GitLabShell "./architecture.html#gitlab-shell"
click SSH "./architecture.html#ssh-request-22"
click Sidekiq "./architecture.html#sidekiq"
@@ -201,7 +260,7 @@ Table description links:
| [Runner](#gitlab-runner) | Executes GitLab CI/CD jobs | ⤓ | ✅ | ⚙ | ✅ | ⚙ | ⚙ | CE & EE |
| [Sentry integration](#sentry) | Error tracking for deployed apps | ⤓ | ⤓ | ⤓ | ⤓ | ⤓ | ⤓ | CE & EE |
| [Sidekiq](#sidekiq) | Background jobs processor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | CE & EE |
-| [Unicorn (GitLab Rails)](#unicorn) | Handles requests for the web interface and API | ✅ | ✅ | ✅ | ✅ | ⚙ | ✅ | CE & EE |
+| [Puma (GitLab Rails)](#puma) | Handles requests for the web interface and API | ✅ | ✅ | ✅ | ✅ | ⚙ | ✅ | CE & EE |
### Component details
@@ -271,7 +330,7 @@ Consul is a tool for service discovery and configuration. Consul is distributed,
- [Source](../integration/elasticsearch.md)
- [GDK](https://gitlab.com/gitlab-org/gitlab-development-kit/blob/master/doc/howto/elasticsearch.md)
- Layer: Core Service (Data)
-- GitLab.com: [Get Advanced Global Search working on GitLab.com](https://gitlab.com/groups/gitlab-org/-/epics/153) epic.
+- GitLab.com: [Get Advanced Search working on GitLab.com](https://gitlab.com/groups/gitlab-org/-/epics/153) epic.
Elasticsearch is a distributed RESTful search engine built for the cloud.
@@ -368,13 +427,13 @@ GitLab CI/CD is the open-source continuous integration service included with Git
- [Project page](https://gitlab.com/gitlab-org/gitlab-workhorse/blob/master/README.md)
- Configuration:
- [Omnibus](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/files/gitlab-config-template/gitlab.rb.template)
- - [Charts](https://docs.gitlab.com/charts/charts/gitlab/unicorn/)
+ - [Charts](https://docs.gitlab.com/charts/charts/gitlab/webservice/)
- [Source](../install/installation.md#install-gitlab-workhorse)
- Layer: Core Service (Processor)
- Process: `gitlab-workhorse`
- GitLab.com: [Service Architecture](https://about.gitlab.com/handbook/engineering/infrastructure/production/architecture/#service-architecture)
-[GitLab Workhorse](https://gitlab.com/gitlab-org/gitlab-workhorse) is a program designed at GitLab to help alleviate pressure from Unicorn. You can read more about the [historical reasons for developing](https://about.gitlab.com/blog/2016/04/12/a-brief-history-of-gitlab-workhorse/). It's designed to act as a smart reverse proxy to help speed up GitLab as a whole.
+[GitLab Workhorse](https://gitlab.com/gitlab-org/gitlab-workhorse) is a program designed at GitLab to help alleviate pressure from Puma. You can read more about the [historical reasons for developing](https://about.gitlab.com/blog/2016/04/12/a-brief-history-of-gitlab-workhorse/). It's designed to act as a smart reverse proxy to help speed up GitLab as a whole.
#### Grafana
@@ -411,7 +470,8 @@ For monitoring deployed apps, see [Jaeger tracing documentation](../operations/t
- Layer: Core Service
- Process: `logrotate`
-GitLab is comprised of a large number of services that all log. We started bundling our own logrotate as of 7.4 to make sure we were logging responsibly. This is just a packaged version of the common open source offering.
+GitLab is comprised of a large number of services that all log. We started bundling our own Logrotate
+as of GitLab 7.4 to make sure we were logging responsibly. This is just a packaged version of the common open source offering.
#### Mattermost
@@ -603,8 +663,30 @@ For monitoring deployed apps, see the [Sentry integration docs](../operations/er
Sidekiq is a Ruby background job processor that pulls jobs from the Redis queue and processes them. Background jobs allow GitLab to provide a faster request/response cycle by moving work into the background.
+#### Puma
+
+NOTE: **Note:**
+Starting with GitLab 13.0, Puma is the default web server and Unicorn has been
+disabled by default.
+
+- [Project page](https://gitlab.com/gitlab-org/gitlab/blob/master/README.md)
+- Configuration:
+ - [Omnibus](https://docs.gitlab.com/omnibus/settings/puma.html)
+ - [Charts](https://docs.gitlab.com/charts/charts/gitlab/webservice/)
+ - [Source](../install/installation.md#configure-it)
+ - [GDK](https://gitlab.com/gitlab-org/gitlab/blob/master/config/gitlab.yml.example)
+- Layer: Core Service (Processor)
+- Process: `puma`
+- GitLab.com: [Puma](../user/gitlab_com/index.md#puma)
+
+[Puma](https://puma.io/) is a Ruby application server that is used to run the core Rails Application that provides the user facing features in GitLab. Often process output you will see this as `bundle` or `config.ru` depending on the GitLab version.
+
#### Unicorn
+NOTE: **Note:**
+Starting with GitLab 13.0, Puma is the default web server and Unicorn has been
+disabled by default.
+
- [Project page](https://gitlab.com/gitlab-org/gitlab/blob/master/README.md)
- Configuration:
- [Omnibus](https://docs.gitlab.com/omnibus/settings/unicorn.html)
@@ -669,7 +751,7 @@ You can install them after you create a cluster. This includes:
- [JupyterHub](https://jupyter.org)
- [Knative](https://cloud.google.com/knative/)
-## GitLab by Request Type
+## GitLab by request type
GitLab provides two "interfaces" for end users to access the service:
@@ -678,20 +760,20 @@ GitLab provides two "interfaces" for end users to access the service:
It's important to understand the distinction as some processes are used in both and others are exclusive to a specific request type.
-### GitLab Web HTTP Request Cycle
+### GitLab Web HTTP request cycle
When making a request to an HTTP Endpoint (think `/users/sign_in`) the request will take the following path through the GitLab Service:
- NGINX - Acts as our first line reverse proxy.
-- GitLab Workhorse - This determines if it needs to go to the Rails application or somewhere else to reduce load on Unicorn.
-- Unicorn - Since this is a web request, and it needs to access the application it will go to Unicorn.
+- GitLab Workhorse - This determines if it needs to go to the Rails application or somewhere else to reduce load on Puma.
+- Puma - Since this is a web request, and it needs to access the application it will go to Puma.
- PostgreSQL/Gitaly/Redis - Depending on the type of request, it may hit these services to store or retrieve data.
-### GitLab Git Request Cycle
+### GitLab Git request cycle
Below we describe the different paths that HTTP vs. SSH Git requests will take. There is some overlap with the Web Request Cycle but also some differences.
-### Web Request (80/443)
+### Web request (80/443)
Git operations over HTTP use the stateless "smart" protocol described in the
[Git documentation](https://git-scm.com/docs/http-protocol), but responsibility
@@ -736,7 +818,7 @@ sequenceDiagram
The sequence is similar for `git push`, except `git-receive-pack` is used
instead of `git-upload-pack`.
-### SSH Request (22)
+### SSH request (22)
Git operations over SSH can use the stateful protocol described in the
[Git documentation](https://git-scm.com/docs/pack-protocol#_ssh_transport), but
@@ -801,7 +883,7 @@ except there is no round-trip into Gitaly - Rails performs the action as part
of the [internal API](internal_api.md) call, and GitLab Shell streams the
response back to the user directly.
-## System Layout
+## System layout
When referring to `~git` in the pictures it means the home directory of the Git user which is typically `/home/git`.
@@ -811,9 +893,9 @@ The bare repositories are located in `/home/git/repositories`. GitLab is a Ruby
To serve repositories over SSH there's an add-on application called GitLab Shell which is installed in `/home/git/gitlab-shell`.
-### Installation Folder Summary
+### Installation folder summary
-To summarize here's the [directory structure of the `git` user home directory](../install/structure.md).
+To summarize here's the [directory structure of the `git` user home directory](../install/installation.md#gitlab-directory-structure).
### Processes
@@ -823,12 +905,12 @@ ps aux | grep '^git'
GitLab has several components to operate. It requires a persistent database
(PostgreSQL) and Redis database, and uses Apache `httpd` or NGINX to proxypass
-Unicorn. All these components should run as different system users to GitLab
-(e.g., `postgres`, `redis` and `www-data`, instead of `git`).
+Puma. All these components should run as different system users to GitLab
+(for example, `postgres`, `redis`, and `www-data`, instead of `git`).
-As the `git` user it starts Sidekiq and Unicorn (a simple Ruby HTTP server
+As the `git` user it starts Sidekiq and Puma (a simple Ruby HTTP server
running on port `8080` by default). Under the GitLab user there are normally 4
-processes: `unicorn_rails master` (1 process), `unicorn_rails worker`
+processes: `puma master` (1 process), `puma cluster worker`
(2 processes), `sidekiq` (1 process).
### Repository access
@@ -841,7 +923,7 @@ See the README for more information.
### Init scripts of the services
-The GitLab init script starts and stops Unicorn and Sidekiq:
+The GitLab init script starts and stops Puma and Sidekiq:
```plaintext
/etc/init.d/gitlab
@@ -881,9 +963,9 @@ Usage: /etc/init.d/postgresql {start|stop|restart|reload|force-reload|status} [v
### Log locations of the services
-GitLab (includes Unicorn and Sidekiq logs):
+GitLab (includes Puma and Sidekiq logs):
-- `/home/git/gitlab/log/` contains `application.log`, `production.log`, `sidekiq.log`, `unicorn.stdout.log`, `git_json.log` and `unicorn.stderr.log` normally.
+- `/home/git/gitlab/log/` contains `application.log`, `production.log`, `sidekiq.log`, `puma.stdout.log`, `git_json.log` and `puma.stderr.log` normally.
GitLab Shell:
@@ -914,17 +996,18 @@ PostgreSQL:
### GitLab specific configuration files
-GitLab has configuration files located in `/home/git/gitlab/config/*`. Commonly referenced config files include:
+GitLab has configuration files located in `/home/git/gitlab/config/*`. Commonly referenced
+configuration files include:
-- `gitlab.yml` - GitLab configuration.
-- `unicorn.rb` - Unicorn web server settings.
-- `database.yml` - Database connection settings.
+- `gitlab.yml` - GitLab configuration
+- `puma.rb` - Puma web server settings
+- `database.yml` - Database connection settings
GitLab Shell has a configuration file at `/home/git/gitlab-shell/config.yml`.
-### Maintenance Tasks
+### Maintenance tasks
-[GitLab](https://gitlab.com/gitlab-org/gitlab/tree/master) provides Rake tasks with which you see version information and run a quick check on your configuration to ensure it is configured properly within the application. See [maintenance Rake tasks](../raketasks/maintenance.md).
+[GitLab](https://gitlab.com/gitlab-org/gitlab/tree/master) provides Rake tasks with which you see version information and run a quick check on your configuration to ensure it is configured properly within the application. See [maintenance Rake tasks](../administration/raketasks/maintenance.md).
In a nutshell, do the following:
```shell
@@ -934,7 +1017,8 @@ bundle exec rake gitlab:env:info RAILS_ENV=production
bundle exec rake gitlab:check RAILS_ENV=production
```
-Note: It is recommended to log into the `git` user using `sudo -i -u git` or `sudo su - git`. While the sudo commands provided by GitLab work in Ubuntu they do not always work in RHEL.
+Note: It is recommended to log into the `git` user using `sudo -i -u git` or `sudo su - git`. While
+the `sudo` commands provided by GitLab work in Ubuntu they do not always work in RHEL.
## GitLab.com
diff --git a/doc/development/background_migrations.md b/doc/development/background_migrations.md
index 4b58758b5c7..e5cc2ae4d1d 100644
--- a/doc/development/background_migrations.md
+++ b/doc/development/background_migrations.md
@@ -1,4 +1,11 @@
-# Background Migrations
+---
+type: reference, dev
+stage: none
+group: Development
+info: "See the Technical Writers assigned to Development Guidelines: https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments-to-development-guidelines"
+---
+
+# Background migrations
Background migrations can be used to perform data migrations that would
otherwise take a very long time (hours, days, years, etc) to complete. For
@@ -92,6 +99,20 @@ bulk_migrate_async(
)
```
+Note that this will queue a Sidekiq job immediately: if you have a large number
+of records, this may not be what you want. You can use the function
+`queue_background_migration_jobs_by_range_at_intervals` to split the job into
+batches:
+
+```ruby
+queue_background_migration_jobs_by_range_at_intervals(
+ ClassName,
+ BackgroundMigrationClassName,
+ 2.minutes,
+ batch_size: 10_000
+ )
+```
+
You'll also need to make sure that newly created data is either migrated, or
saved in both the old and new version upon creation. For complex and time
consuming migrations it's best to schedule a background job using an
diff --git a/doc/development/changelog.md b/doc/development/changelog.md
index f57e666540c..d992f394de3 100644
--- a/doc/development/changelog.md
+++ b/doc/development/changelog.md
@@ -35,8 +35,7 @@ the `author` field. GitLab team members **should not**.
- [Security fixes](https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md)
**must** have a changelog entry, without `merge_request` value
and with `type` set to `security`.
-- Any user-facing change **should** have a changelog entry. Example: "GitLab now
- uses system fonts for all text."
+- Any user-facing change **should** have a changelog entry. This includes both visual changes (regardless of how minor), and changes to the rendered DOM which impact how a screen reader may announce the content.
- Performance improvements **should** have a changelog entry.
- Changes that need to be documented in the Telemetry [Event Dictionary](telemetry/event_dictionary.md)
also require a changelog entry.
diff --git a/doc/development/code_review.md b/doc/development/code_review.md
index 2e319efa5f3..436acb1e4d7 100644
--- a/doc/development/code_review.md
+++ b/doc/development/code_review.md
@@ -67,6 +67,10 @@ page, with these behaviors:
contains the string 'OOO', or the emoji is `:palm_tree:` or `:beach:`.
1. [Trainee maintainers](https://about.gitlab.com/handbook/engineering/workflow/code-review/#trainee-maintainer)
are three times as likely to be picked as other reviewers.
+1. People whose [GitLab status](../user/profile/index.md#current-status) emoji
+ is `:large_blue_circle:` are more likely to be picked. This applies to both reviewers and trainee maintainers.
+ - Reviewers with `:large_blue_circle:` are two times as likely to be picked as other reviewers.
+ - Trainee maintainers with `:large_blue_circle:` are four times as likely to be picked as other reviewers.
1. It always picks the same reviewers and maintainers for the same
branch name (unless their OOO status changes, as in point 1). It
removes leading `ce-` and `ee-`, and trailing `-ce` and `-ee`, so
@@ -100,6 +104,7 @@ with [domain expertise](#domain-experts).
by a [Software Engineer in Test](https://about.gitlab.com/handbook/engineering/quality/#individual-contributors)**.
1. If your merge request only includes end-to-end changes (*3*) **or** if the MR author is a [Software Engineer in Test](https://about.gitlab.com/handbook/engineering/quality/#individual-contributors), it must be **approved by a [Quality maintainer](https://about.gitlab.com/handbook/engineering/projects/#gitlab_maintainers_qa)**
1. If your merge request includes a new or updated [application limit](https://about.gitlab.com/handbook/product/product-processes/#introducing-application-limits), it must be **approved by a [product manager](https://about.gitlab.com/company/team/)**.
+1. If your merge request includes Product Analytics (telemetry) changes, it should be reviewed and approved by a [Product analytics engineer](https://gitlab.com/gitlab-org/growth/telemetry/engineers).
- (*1*): Please note that specs other than JavaScript specs are considered backend code.
- (*2*): We encourage you to seek guidance from a database maintainer if your merge
diff --git a/doc/development/contributing/issue_workflow.md b/doc/development/contributing/issue_workflow.md
index bb7b4713a5e..42c67b97a35 100644
--- a/doc/development/contributing/issue_workflow.md
+++ b/doc/development/contributing/issue_workflow.md
@@ -320,6 +320,11 @@ look for issues labeled `~"Accepting merge requests"` with a [weight of 1](https
More experienced contributors are very welcome to tackle
[any of them](https://gitlab.com/groups/gitlab-org/-/issues?state=opened&label_name[]=Accepting+merge+requests&assignee_id=None).
+For more complex features that have a weight of 2 or more and clear scope, we recommend looking at issues
+with the [label `~"Community Challenge"`](https://gitlab.com/gitlab-org/gitlab/-/issues?scope=all&utf8=%E2%9C%93&state=opened&label_name[]=Accepting%20merge%20requests&label_name[]=Community%20challenge).
+If your MR for the `~"Community Challenge"` issue gets merged, you will also have a chance to win a custom
+GitLab merchandise.
+
If you've decided that you would like to work on an issue, please @-mention
the [appropriate product manager](https://about.gitlab.com/handbook/product/#who-to-talk-to-for-what)
as soon as possible. The product manager will then pull in appropriate GitLab team
diff --git a/doc/development/contributing/merge_request_workflow.md b/doc/development/contributing/merge_request_workflow.md
index d88b159b666..b26a8a775b3 100644
--- a/doc/development/contributing/merge_request_workflow.md
+++ b/doc/development/contributing/merge_request_workflow.md
@@ -218,7 +218,7 @@ requirements.
1. [Secure coding guidelines](https://gitlab.com/gitlab-com/gl-security/security-guidelines) have been followed.
1. [Documented](../documentation/index.md) in the `/doc` directory.
1. [Changelog entry added](../changelog.md), if necessary.
-1. Reviewed by relevant (UX/FE/BE/tech writing) reviewers and all concerns are addressed.
+1. Reviewed by relevant reviewers and all concerns are addressed for Availability, Regressions, Security. Documentation reviews should take place as soon as possible, but they should not block a merge request.
1. Merged by a project maintainer.
1. Create an issue in the [infrastructure issue tracker](https://gitlab.com/gitlab-com/gl-infra/infrastructure/-/issues) to inform the Infrastructure department when your contribution is changing default settings or introduces a new setting, if relevant.
1. Confirmed to be working in the [Canary stage](https://about.gitlab.com/handbook/engineering/#canary-testing) or on GitLab.com once the contribution is deployed.
diff --git a/doc/development/database/index.md b/doc/development/database/index.md
index 9ea5b6fcaac..0ae39ed7ca7 100644
--- a/doc/development/database/index.md
+++ b/doc/development/database/index.md
@@ -24,6 +24,7 @@
- [Background migrations](../background_migrations.md)
- [Swapping tables](../swapping_tables.md)
- [Deleting migrations](../deleting_migrations.md)
+- [Partitioning tables](table_partitioning.md)
## Debugging
diff --git a/doc/development/database/table_partitioning.md b/doc/development/database/table_partitioning.md
new file mode 100644
index 00000000000..00981ef28a2
--- /dev/null
+++ b/doc/development/database/table_partitioning.md
@@ -0,0 +1,253 @@
+# Database table partitioning
+
+Table partitioning is a powerful database feature that allows a table's
+data to be split into smaller physical tables that act as a single large
+table. If the application is designed to work with partitioning in mind,
+there can be multiple benefits, such as:
+
+- Query performance can be improved greatly, because the database can
+cheaply eliminate much of the data from the search space, while still
+providing full SQL capabilities.
+
+- Bulk deletes can be achieved with minimal impact on the database by
+dropping entire partitions. This is a natural fit for features that need
+to periodically delete data that falls outside the retention window.
+
+- Administrative tasks like `VACUUM` and index rebuilds can operate on
+individual partitions, rather than across a single massive table.
+
+Unfortunately, not all models fit a partitioning scheme, and there are
+significant drawbacks if implemented incorrectly. Additionally, tables
+can only be partitioned at their creation, making it nontrivial to apply
+partitioning to a busy database. A suite of migration tools are available
+to enable backend developers to partition existing tables, but the
+migration process is rather heavy, taking multiple steps split across
+several releases. Due to the limitations of partitioning and the related
+migrations, you should understand how partitioning fits your use case
+before attempting to leverage this feature.
+
+## Determining when to use partitioning
+
+While partitioning can be very useful when properly applied, it's
+imperative to identify if the data and workload of a table naturally fit a
+partitioning scheme. There are a few details you'll have to understand
+in order to decide if partitioning is a good fit for your particular
+problem.
+
+First, a table is partitioned on a partition key, which is a column or
+set of columns which determine how the data will be split across the
+partitions. The partition key is used by the database when reading or
+writing data, to decide which partition(s) need to be accessed. The
+partition key should be a column that would be included in a `WHERE`
+clause on almost all queries accessing that table.
+
+Second, it's necessary to understand the strategy the database will
+use to split the data across the partitions. The scheme supported by the
+GitLab migration helpers is date-range partitioning, where each partition
+in the table contains data for a single month. In this case, the partitioning
+key would need to be a timestamp or date column. In order for this type of
+partitioning to work well, most queries would need to access data within a
+certain date range.
+
+For a more concrete example, the `audit_events` table can be used, which
+was the first table to be partitioned in the application database
+(scheduled for deployment with the GitLab 13.5 release). This
+table tracks audit entries of security events that happen in the
+application. In almost all cases, users want to see audit activity that
+occurs in a certain timeframe. As a result, date-range partitioning
+was a natural fit for how the data would be accessed.
+
+To look at this in more detail, imagine a simplified `audit_events` schema:
+
+```sql
+CREATE TABLE audit_events (
+ id SERIAL NOT NULL PRIMARY KEY,
+ author_id INT NOT NULL,
+ details jsonb NOT NULL,
+ created_at timestamptz NOT NULL);
+```
+
+Now imagine typical queries in the UI would display the data within a
+certain date range, like a single week:
+
+```sql
+SELECT *
+FROM audit_events
+WHERE created_at >= '2020-01-01 00:00:00'
+ AND created_at < '2020-01-08 00:00:00'
+ORDER BY created_at DESC
+LIMIT 100
+```
+
+If the table is partioned on the `created_at` column the base table would
+look like:
+
+```sql
+CREATE TABLE audit_events (
+ id SERIAL NOT NULL,
+ author_id INT NOT NULL,
+ details jsonb NOT NULL,
+ created_at timestamptz NOT NULL,
+ PRIMARY KEY (id, created_at))
+PARTITION BY RANGE(created_at);
+```
+
+NOTE: **Note:**
+The primary key of a partitioned table must include the partition key as
+part of the primary key definition.
+
+And we might have a list of partitions for the table, such as:
+
+```sql
+audit_events_202001 FOR VALUES FROM ('2020-01-01') TO ('2020-02-01')
+audit_events_202002 FOR VALUES FROM ('2020-02-01') TO ('2020-03-01')
+audit_events_202003 FOR VALUES FROM ('2020-03-01') TO ('2020-04-01')
+```
+
+Each partition is a separate physical table, with the same structure as
+the base `audit_events` table, but contains only data for rows where the
+partition key falls in the specified range. For example, the partition
+`audit_events_202001` contains rows where the `created_at` column is
+greater than or equal to `2020-01-01` and less than `2020-02-01`.
+
+Now, if we look at the previous example query again, the database can
+use the `WHERE` to recognize that all matching rows will be in the
+`audit_events_202001` partition. Rather than searching all of the data
+in all of the partitions, it can search only the single month's worth
+of data in the appropriate partition. In a large table, this can
+dramatically reduce the amount of data the database needs to access.
+However, imagine a query that does not filter based on the partitioning
+key, such as:
+
+```sql
+SELECT *
+FROM audit_events
+WHERE author_id = 123
+ORDER BY created_at DESC
+LIMIT 100
+```
+
+In this example, the database can't prune any partitions from the search,
+because matching data could exist in any of them. As a result, it has to
+query each partition individually, and aggregate the rows into a single result
+set. Since `author_id` would be indexed, the performance impact could
+likely be acceptable, but on more complex queries the overhead can be
+substantial. Partitioning should only be leveraged if the access patterns
+of the data support the partitioning strategy, otherwise performance will
+suffer.
+
+## Partitioning a table
+
+Unfortunately, tables can only be partitioned at their creation, making
+it nontrivial to apply to a busy database. A suite of migration
+tools have been developed to enable backend developers to partition
+existing tables. This migration process takes multiple steps which must
+be split across several releases.
+
+### Caveats
+
+The partitioning migration helpers work by creating a partitioned duplicate
+of the original table and using a combination of a trigger and a background
+migration to copy data into the new table. Changes to the original table
+schema can be made in parallel with the partitioning migration, but they
+must take care to not break the underlying mechanism that makes the migration
+work. For example, if a column is added to the table that is being
+partitioned, both the partitioned table and the trigger definition need to
+be updated to match.
+
+### Step 1: Creating the partitioned copy (Release N)
+
+The first step is to add a migration to create the partitioned copy of
+the original table. This migration will also create the appropriate
+partitions based on the data in the original table, and install a
+trigger that will sync writes from the original table into the
+partitioned copy.
+
+An example migration of partitioning the `audit_events` table by its
+`created_at` column would look like:
+
+```ruby
+class PartitionAuditEvents < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::PartitioningMigrationHelpers
+
+ def up
+ partition_table_by_date :audit_events, :created_at
+ end
+
+ def down
+ drop_partitioned_table_for :audit_events
+ end
+end
+```
+
+Once this has executed, any inserts, updates or deletes in the
+original table will also be duplicated in the new table. For updates and
+deletes, the operation will only have an effect if the corresponding row
+exists in the partitioned table.
+
+### Step 2: Backfill the partitioned copy (Release N)
+
+The second step is to add a post-deployment migration that will schedule
+the background jobs that will backfill existing data from the original table
+into the partitioned copy.
+
+Continuing the above example, the migration would look like:
+
+```ruby
+class BackfillPartitionAuditEvents < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::PartitioningMigrationHelpers
+
+ def up
+ enqueue_partitioning_data_migration :audit_events
+ end
+
+ def down
+ cleanup_partitioning_data_migration :audit_events
+ end
+end
+```
+
+This step uses the same mechanism as any background migration, so you
+may want to read the [Background Migration](../background_migrations.md)
+guide for details on that process. Background jobs are scheduled every
+2 minutes and copy `50_000` records at a time, which can be used to
+estimate the timing of the background migration portion of the
+partitioning migration.
+
+### Step 3: Post-backfill cleanup (Release N+1)
+
+The third step must occur at least one release after the release that
+includes the background migration. This gives time for the background
+migration to execute properly in self-managed installations. In this step,
+add another post-deployment migration that will cleanup after the
+background migration. This includes forcing any remaining jobs to
+execute, and copying data that may have been missed, due to dropped or
+failed jobs.
+
+Once again, continuing the example, this migration would look like:
+
+```ruby
+class CleanupPartitionedAuditEventsBackfill < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::PartitioningMigrationHelpers
+
+ def up
+ finalize_backfilling_partitioned_table :audit_events
+ end
+
+ def down
+ # no op
+ end
+end
+```
+
+After this migration has completed, the original table and partitioned
+table should contain identical data. The trigger installed on the
+original table guarantees that the data will remain in sync going
+forward.
+
+### Step 4: Swap the partitioned and non-partitioned tables (Release N+1)
+
+The final step of the migration will make the partitioned table ready
+for use by the application. This section will be updated when the
+migration helper is ready, for now development can be followed in the
+[Tracking Issue](https://gitlab.com/gitlab-org/gitlab/-/issues/241267).
diff --git a/doc/development/database_debugging.md b/doc/development/database_debugging.md
index 61e8ac60bfe..e3430204561 100644
--- a/doc/development/database_debugging.md
+++ b/doc/development/database_debugging.md
@@ -19,7 +19,7 @@ If you just want to delete everything and start over with an empty DB (approxima
bundle exec rake db:reset RAILS_ENV=development
```
-If you just want to delete everything and start over with dummy data (approximately 4 minutes). This
+If you just want to delete everything and start over with sample data (approximately 4 minutes). This
also does `db:reset` and runs DB-specific migrations:
```shell
diff --git a/doc/development/database_review.md b/doc/development/database_review.md
index f56ffdbad21..8a10391419a 100644
--- a/doc/development/database_review.md
+++ b/doc/development/database_review.md
@@ -184,10 +184,6 @@ test its execution using `CREATE INDEX CONCURRENTLY` in the `#database-lab` Slac
- [Check query plans](understanding_explain_plans.md) and suggest improvements
to queries (changing the query, schema or adding indexes and similar)
- General guideline is for queries to come in below 100ms execution time
- - If queries rely on prior migrations that are not present yet on production
- (eg indexes, columns), you can use a [one-off instance from the restore
- pipeline](https://ops.gitlab.net/gitlab-com/gl-infra/gitlab-restore/postgres-gprd)
- in order to establish a proper testing environment. If you don't have access to this project, reach out to #database on Slack to get advice on how to proceed.
- Avoid N+1 problems and minimalize the [query count](merge_request_performance_guidelines.md#query-counts).
### Timing guidelines for migrations
diff --git a/doc/development/documentation/feature_flags.md b/doc/development/documentation/feature_flags.md
index a4a6ee2fa0f..0f03ceeb4b5 100644
--- a/doc/development/documentation/feature_flags.md
+++ b/doc/development/documentation/feature_flags.md
@@ -52,7 +52,7 @@ For feature flags disabled by default, if they can be used by end users:
do not say anything about it.
- Say whether it's recommended for production use.
- Document how to enable and disable it.
-- Add a warning to the user saying that the feature is disabled.
+- Add a warning to the user saying that the feature might be disabled.
For example, for a feature disabled by default, disabled on GitLab.com, cannot
be enabled for a single project, and is not ready for production use:
@@ -250,7 +250,7 @@ be enabled by project, and is ready for production use:
> - [Introduced](link-to-issue) in GitLab 12.0.
> - It's [deployed behind a feature flag](<replace with path to>/user/feature_flags.md), enabled by default.
> - It's enabled on GitLab.com.
-> - It can be enabled or disable for a single project.
+> - It can be enabled or disabled for a single project.
> - It's recommended for production use.
> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#anchor-to-section). **(CORE ONLY)**
diff --git a/doc/development/documentation/graphql_styleguide.md b/doc/development/documentation/graphql_styleguide.md
new file mode 100644
index 00000000000..53a645a2936
--- /dev/null
+++ b/doc/development/documentation/graphql_styleguide.md
@@ -0,0 +1,92 @@
+---
+type: reference, dev
+stage: none
+group: Development
+info: "See the Technical Writers assigned to Development Guidelines: https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments-to-development-guidelines"
+description: "Writing styles, markup, formatting, and other standards for GraphQL API's GitLab Documentation."
+---
+
+# GraphQL API
+
+GraphQL APIs are different from [RESTful APIs](restful_api_styleguide.md). Reference
+information is generated in our [GraphQL reference](../../api/graphql/reference/index.md).
+
+However, it's helpful to include examples on how to use GraphQL for different
+*use cases*, with samples that readers can use directly in the
+[GraphiQL explorer](../api_graphql_styleguide.md#graphiql).
+
+This section describes the steps required to add your GraphQL examples to
+GitLab documentation.
+
+## Add a dedicated GraphQL page
+
+To create a dedicated GraphQL page, create a new `.md` file in the
+`doc/api/graphql/` directory. Give that file a functional name, such as
+`import_from_specific_location.md`.
+
+## Start the page with an explanation
+
+Include a page title that describes the GraphQL functionality in a few words,
+such as:
+
+```markdown
+# Search for [substitute kind of data]
+```
+
+Describe the search. One sentence may be all you need. More information may
+help readers learn how to use the example for their GitLab deployments.
+
+## Include a procedure using the GraphiQL explorer
+
+The GraphiQL explorer can help readers test queries with working deployments.
+Set up the section with the following:
+
+- Use the following title:
+
+ ```markdown
+ ## Set up the GraphiQL explorer
+ ```
+
+- Include a code block with the query that anyone can include in their
+ instance of the GraphiQL explorer:
+
+ ````markdown
+ ```graphql
+ query {
+ <insert queries here>
+ }
+ ```
+ ````
+
+- Tell the user what to do:
+
+ ```markdown
+ 1. Open the GraphiQL explorer tool in the following URL: `https://gitlab.com/-/graphql-explorer`.
+ 1. Paste the `query` listed above into the left window of your GraphiQL explorer tool.
+ 1. Select **Play** to get the result shown here:
+ ```
+
+- Include a screenshot of the result in the GraphiQL explorer. Follow the naming
+ convention described in the [Save the image](styleguide.md#save-the-image) section of the Documentation style guide.
+- Follow up with an example of what you can do with the output. Make sure the
+ example is something that readers can do on their own deployments.
+- Include a link to the [GraphQL API resources](../../api/graphql/reference/index.md).
+
+## Add the GraphQL example to the global navigation
+
+You should include a link for your new document in the global navigation (the list on the
+left side of the documentation website). To do so, open a second MR, against the
+[GitLab documentation repository](https://gitlab.com/gitlab-org/gitlab-docs/).
+
+We store our global navgation in the [`default-nav.yaml`](https://gitlab.com/gitlab-org/gitlab-docs/-/blob/master/content/_data/default-nav.yaml) file, in the
+`content/_data` subdirectory. You can find the GraphQL section under the
+following line:
+
+```yaml
+- category_title: GraphQL
+```
+
+Be aware that CI tests for that second MR will fail with a bad link until the
+main MR that adds the new GraphQL page is merged. Therefore, only merge the MR against the
+[`gitlab-docs`](https://gitlab.com/gitlab-org/gitlab-docs) repository after the content has
+been merged and live on `docs.gitlab.com`.
diff --git a/doc/development/documentation/index.md b/doc/development/documentation/index.md
index d6f24d6374d..3a02ea5aa83 100644
--- a/doc/development/documentation/index.md
+++ b/doc/development/documentation/index.md
@@ -1,4 +1,7 @@
---
+stage: none
+group: Documentation Guidelines
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
description: Learn how to contribute to GitLab Documentation.
---
@@ -52,9 +55,9 @@ docs-only merge requests using the following guide:
[Contributions to GitLab docs](workflow.md) are welcome from the entire GitLab community.
-To ensure that GitLab docs are current, there are special processes and responsibilities for all [feature changes](feature-change-workflow.md), that is development work that impacts the appearance, usage, or administration of a feature.
+To ensure that GitLab docs are current, there are special processes and responsibilities for all [feature changes](workflow.md), that is development work that impacts the appearance, usage, or administration of a feature.
-However, anyone can contribute [documentation improvements](improvement-workflow.md) that are not associated with a feature change. For example, adding a new doc on how to accomplish a use case that's already possible with GitLab or with third-party tools and GitLab.
+However, anyone can contribute [documentation improvements](workflow.md) that are not associated with a feature change. For example, adding a new doc on how to accomplish a use case that's already possible with GitLab or with third-party tools and GitLab.
## Markdown and styles
@@ -128,7 +131,7 @@ The following metadata should be added when a page is moved to another location:
- `redirect_to`: The relative path and filename (with an `.md` extension) of the
location to which visitors should be redirected for a moved page.
- [Learn more](#changing-document-location).
+ [Learn more](#move-or-rename-a-page).
- `disqus_identifier`: Identifier for Disqus commenting system. Used to keep
comments with a page that's been moved to a new URL.
[Learn more](#redirections-for-pages-with-disqus-comments).
@@ -156,17 +159,18 @@ Nanoc layout), which will be displayed at the top of the page if defined:
[algorithm](https://gitlab.com/gitlab-org/gitlab-docs/-/blob/master/lib/helpers/reading_time.rb)
to calculate the reading time based on the number of words.
-## Changing document location
+## Move or rename a page
+
+Moving or renaming a document is the same as changing its location. This process
+requires specific steps to ensure that visitors can find the new
+documentation page, whether they're using `/help` from a GitLab instance or by
+visiting <https://docs.gitlab.com>.
-Changing a document's location requires specific steps to ensure that
-users can seamlessly access the new doc page, whether they are accessing content
-on a GitLab instance domain at `/help` or at <https://docs.gitlab.com>. Be sure to assign a
-technical writer if you have any questions during the process (such as
-whether the move is necessary), and ensure that a technical writer reviews this
-change prior to merging.
+Be sure to assign a technical writer to a page move or rename MR. Technical
+Writers can help with any questions and can review your change.
-If you indeed need to change a document's location, do not remove the old
-document, but instead replace all of its content with the following:
+To change a document's location, don't remove the old document, but instead
+replace all of its content with the following:
```markdown
---
@@ -176,14 +180,18 @@ redirect_to: '../path/to/file/index.md'
This document was moved to [another location](../path/to/file/index.md).
```
-Where `../path/to/file/index.md` is usually the relative path to the old document.
+Replace `../path/to/file/index.md` with the relative path to the old document.
+
+The `redirect_to` variable supports both full and relative URLs; for example:
+
+- `https://docs.gitlab.com/ee/path/to/file.html`
+- `../path/to/file.html`
+- `path/to/file.md`
-The `redirect_to` variable supports both full and relative URLs, for example
-`https://docs.gitlab.com/ee/path/to/file.html`, `../path/to/file.html`, `path/to/file.md`.
-It ensures that the redirect will work for <https://docs.gitlab.com> and any `*.md` paths
-will be compiled to `*.html`.
-The new line underneath the front matter informs the user that the document
-changed location and is useful for someone that browses that file from the repository.
+The redirect works for <https://docs.gitlab.com>, and any `*.md` paths are
+changed to `*.html`. The description line following the `redirect_to` code
+informs the visitor that the document changed location if the redirect process
+doesn't complete successfully.
For example, if you move `doc/workflow/lfs/index.md` to
`doc/administration/lfs.md`, then the steps would be:
@@ -462,7 +470,7 @@ If you want to know the in-depth details, here's what's really happening:
The following GitLab features are used among others:
- [Manual actions](../../ci/yaml/README.md#whenmanual)
-- [Multi project pipelines](../../ci/multi_project_pipeline_graphs.md)
+- [Multi project pipelines](../../ci/multi_project_pipelines.md)
- [Review Apps](../../ci/review_apps/index.md)
- [Artifacts](../../ci/yaml/README.md#artifacts)
- [Specific runner](../../ci/runners/README.md#prevent-a-specific-runner-from-being-enabled-for-other-projects)
diff --git a/doc/development/documentation/restful_api_styleguide.md b/doc/development/documentation/restful_api_styleguide.md
new file mode 100644
index 00000000000..b12578b5d98
--- /dev/null
+++ b/doc/development/documentation/restful_api_styleguide.md
@@ -0,0 +1,180 @@
+---
+type: reference, dev
+stage: none
+group: Development
+info: "See the Technical Writers assigned to Development Guidelines: https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments-to-development-guidelines"
+description: "Writing styles, markup, formatting, and other standards for GitLab's RESTful APIs."
+---
+
+# RESTful API
+
+REST API resources are documented in Markdown under
+[`/doc/api`](https://gitlab.com/gitlab-org/gitlab/-/tree/master/doc/api). Each
+resource has its own Markdown file, which is linked from `api_resources.md`.
+
+When modifying the Markdown, also update the corresponding
+[OpenAPI definition](https://gitlab.com/gitlab-org/gitlab/-/tree/master/doc/api/openapi)
+if one exists for the resource. If not, consider creating one. Match the latest
+[OpenAPI 3.0.x specification](https://swagger.io/specification/). (For more
+information, see the discussion in this
+[issue](https://gitlab.com/gitlab-org/gitlab/-/issues/16023#note_370901810).)
+
+In the Markdown doc for a resource (AKA endpoint):
+
+- Every method must have the REST API request. For example:
+
+ ```plaintext
+ GET /projects/:id/repository/branches
+ ```
+
+- Every method must have a detailed [description of the parameters](#method-description).
+- Every method must have a cURL example.
+- Every method must have a response body (in JSON format).
+
+## API topic template
+
+The following can be used as a template to get started:
+
+````markdown
+## Descriptive title
+
+One or two sentence description of what endpoint does.
+
+```plaintext
+METHOD /endpoint
+```
+
+| Attribute | Type | Required | Description |
+|:------------|:---------|:---------|:----------------------|
+| `attribute` | datatype | yes/no | Detailed description. |
+| `attribute` | datatype | yes/no | Detailed description. |
+
+Example request:
+
+```shell
+curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/endpoint?parameters"
+```
+
+Example response:
+
+```json
+[
+ {
+ }
+]
+```
+````
+
+## Method description
+
+Use the following table headers to describe the methods. Attributes should
+always be in code blocks using backticks (`` ` ``).
+
+```markdown
+| Attribute | Type | Required | Description |
+|:----------|:-----|:---------|:------------|
+```
+
+Rendered example:
+
+| Attribute | Type | Required | Description |
+|:----------|:-------|:---------|:--------------------|
+| `user` | string | yes | The GitLab username. |
+
+## cURL commands
+
+- Use `https://gitlab.example.com/api/v4/` as an endpoint.
+- Wherever needed use this personal access token: `<your_access_token>`.
+- Always put the request first. `GET` is the default so you don't have to
+ include it.
+- Wrap the URL in double quotes (`"`).
+- Prefer to use examples using the personal access token and don't pass data of
+ username and password.
+
+| Methods | Description |
+|:------------------------------------------- |:------------------------------------------------------|
+| `--header "PRIVATE-TOKEN: <your_access_token>"` | Use this method as is, whenever authentication needed. |
+| `--request POST` | Use this method when creating new objects |
+| `--request PUT` | Use this method when updating existing objects |
+| `--request DELETE` | Use this method when removing existing objects |
+
+## cURL Examples
+
+The following sections include a set of [cURL](https://curl.haxx.se) examples
+you can use in the API documentation.
+
+CAUTION: **Caution:**
+Do not use information for real users, URLs, or tokens. For documentation, refer to our
+relevant style guide sections on [Fake user information](styleguide.md#fake-user-information),
+[Fake URLs](styleguide.md#fake-urls), and [Fake tokens](styleguide.md#fake-tokens).
+
+### Simple cURL command
+
+Get the details of a group:
+
+```shell
+curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/gitlab-org"
+```
+
+### cURL example with parameters passed in the URL
+
+Create a new project under the authenticated user's namespace:
+
+```shell
+curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects?name=foo"
+```
+
+### Post data using cURL's `--data`
+
+Instead of using `--request 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 the authenticated user's namespace.
+
+```shell
+curl --data "name=foo" --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects"
+```
+
+### Post data using JSON content
+
+NOTE: **Note:**
+In this example we create a new group. Watch carefully the single and double
+quotes.
+
+```shell
+curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --header "Content-Type: application/json" --data '{"path": "my-group", "name": "My group"}' "https://gitlab.example.com/api/v4/groups"
+```
+
+### Post data using form-data
+
+Instead of using JSON or urlencode you can use multipart/form-data which
+properly handles data encoding:
+
+```shell
+curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --form "title=ssh-key" --form "key=ssh-rsa AAAAB3NzaC1yc2EA..." "https://gitlab.example.com/api/v4/users/25/keys"
+```
+
+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
+
+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
+contains spaces in its title. Observe how spaces are escaped using the `%20`
+ASCII code.
+
+```shell
+curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/42/issues?title=Hello%20Dude"
+```
+
+Use `%2F` for slashes (`/`).
+
+### Pass arrays to API calls
+
+The GitLab API sometimes accepts arrays of strings or integers. For example, to
+exclude specific users when requesting a list of users for a project, you would
+do something like this:
+
+```shell
+curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" --data "skip_users[]=<user_id>" --data "skip_users[]=<user_id>" "https://gitlab.example.com/api/v4/projects/<project_id>/users"
+```
diff --git a/doc/development/documentation/styleguide.md b/doc/development/documentation/styleguide.md
index 984c64b9e9e..e262a2c4fbe 100644
--- a/doc/development/documentation/styleguide.md
+++ b/doc/development/documentation/styleguide.md
@@ -251,7 +251,7 @@ The table below shows what kind of documentation goes where.
| `doc/legal/` | Legal documents about contributing to GitLab. |
| `doc/install/` | Contains instructions for installing GitLab. |
| `doc/update/` | Contains instructions for updating GitLab. |
-| `doc/topics/` | Indexes per topic (`doc/topics/topic-name/index.md`): all resources for that topic. |
+| `doc/topics/` | Indexes per topic (`doc/topics/topic_name/index.md`): all resources for that topic. |
### Work with directories and files
@@ -369,7 +369,7 @@ create an issue or an MR to propose a change to the user interface text.
- milestones
- reorder issues
- runner, runners, shared runners
- - a to-do, to-dos
+ - a to-do item, to dos
- *Some features are capitalized*, typically nouns naming GitLab-specific
capabilities or tools. For example:
- GitLab CI/CD
@@ -410,7 +410,7 @@ Use forms of *sign in*, instead of *log in* or *login*. For example:
Exceptions to this rule include the concept of *single sign-on* and
references to user interface elements. For example:
-- To sign in to product X, enter your credentials, and then click **Log in**.
+- To sign in to product X, enter your credentials, and then select **Log in**.
### Inclusive language
@@ -418,8 +418,11 @@ We strive to create documentation that is inclusive. This section includes
guidance and examples in the following categories:
- [Gender-specific wording](#avoid-gender-specific-wording).
+ (Tested in [`InclusionGender.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/.vale/gitlab/InclusionGender.yml).)
- [Ableist language](#avoid-ableist-language).
+ (Tested in [`InclusionAbleism.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/.vale/gitlab/InclusionAbleism.yml).)
- [Cultural sensitivity](#culturally-sensitive-language).
+ (Tested in [`InclusionCultural.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/.vale/gitlab/InclusionCultural.yml).)
We write our developer documentation with inclusivity and diversity in mind. This
page is not an exhaustive reference, but describes some general guidelines and
@@ -433,12 +436,16 @@ a gender-neutral pronoun.
Avoid the use of gender-specific pronouns, unless referring to a specific person.
+<!-- vale gitlab.InclusionGender = NO -->
+
| Use | Avoid |
|-----------------------------------|---------------------------------|
| People, humanity | Mankind |
| GitLab Team Members | Manpower |
| You can install; They can install | He can install; She can install |
+<!-- vale gitlab.InclusionGender = YES -->
+
If you need to set up [Fake user information](#fake-user-information), use
diverse or non-gendered names with common surnames.
@@ -446,6 +453,8 @@ diverse or non-gendered names with common surnames.
Avoid terms that are also used in negative stereotypes for different groups.
+<!-- vale gitlab.InclusionAbleism = NO -->
+
| Use | Avoid |
|------------------------|----------------------|
| Check for completeness | Sanity check |
@@ -455,6 +464,8 @@ Avoid terms that are also used in negative stereotypes for different groups.
| Active/Inactive | Enabled/Disabled |
| On/Off | Enabled/Disabled |
+<!-- vale gitlab.InclusionAbleism = YES -->
+
Credit: [Avoid ableist language](https://developers.google.com/style/inclusive-documentation#ableist-language)
in the Google Developer Style Guide.
@@ -464,13 +475,57 @@ Avoid terms that reflect negative cultural stereotypes and history. In most
cases, you can replace terms such as `master` and `slave` with terms that are
more precise and functional, such as `primary` and `secondary`.
+<!-- vale gitlab.InclusionCultural = NO -->
+
| Use | Avoid |
|----------------------|-----------------------|
| Primary / secondary | Master / slave |
| Allowlist / denylist | Blacklist / whitelist |
+<!-- vale gitlab.InclusionCultural = YES -->
+
For more information see the following [Internet Draft specification](https://tools.ietf.org/html/draft-knodel-terminology-02).
+### Fake user information
+
+You may need to include user information in entries such as a REST call or user profile.
+**Do not** use real user information or email addresses in GitLab documentation. For email
+addresses and names, do use:
+
+- **Email addresses**: Use an email address ending in `example.com`.
+- **Names**: Use strings like `example_username`. Alternatively, use diverse or
+ non-gendered names with common surnames, such as `Sidney Jones`, `Zhang Wei`,
+ or `Maria Garcia`.
+
+### Fake URLs
+
+When including sample URLs in the documentation, use:
+
+- `example.com` when the domain name is generic.
+- `gitlab.example.com` when referring to self-managed instances of GitLab.
+
+### Fake tokens
+
+There may be times where a token is needed to demonstrate an API call using
+cURL or a variable used in CI. It is strongly advised not to use real tokens in
+documentation even if the probability of a token being exploited is low.
+
+You can use the following fake tokens as examples:
+
+| Token type | Token value |
+|:----------------------|:-------------------------------------------------------------------|
+| Private user token | `<your_access_token>` |
+| Personal access token | `n671WNGecHugsdEDPsyo` |
+| Application ID | `2fcb195768c39e9a94cec2c2e32c59c0aad7a3365c10892e8116b5d83d4096b6` |
+| Application secret | `04f294d1eaca42b8692017b426d53bbc8fe75f827734f0260710b83a556082df` |
+| CI/CD variable | `Li8j-mLUVA3eZYjPfd_H` |
+| Specific runner token | `yrnZW46BrtBFqM7xDzE7dddd` |
+| Shared runner token | `6Vk7ZsosqQyfreAxXTZr` |
+| Trigger token | `be20d8dcc028677c931e04f3871a9b` |
+| Webhook secret token | `6XhDroRcYPM5by_h-HLY` |
+| Health check token | `Tu7BgjR9qeZTEyRzGG2P` |
+| Request profile token | `7VgpS4Ax5utVD2esNstz` |
+
### Language to avoid
When creating documentation, limit or avoid the use of the following verb
@@ -832,8 +887,10 @@ When creating tables of lists of features (such as whether or not features are
available to certain roles on the [Permissions](../../user/permissions.md#project-members-permissions)
page), use the following phrases (based on the SVG icons):
-- *No*: **{dotted-circle}** No
-- *Yes*: **{check-circle}** Yes
+| Option | Markdown | Displayed result |
+|--------|--------------------------|------------------------|
+| No | `**{dotted-circle}** No` | **{dotted-circle}** No |
+| Yes | `**{check-circle}** Yes` | **{check-circle}** Yes |
## Quotes
@@ -890,8 +947,8 @@ search engine optimization (SEO), use the imperative, where possible.
For guidelines on capitalizing headings, see the section on [capitalization](#capitalization).
NOTE: **Note:**
-If you change an existing title, be careful. Any such changes may affect not
-only [links](#anchor-links) within the page, but may also affect links to the
+If you change an existing title, be careful. These changes might affect not
+only [links](#anchor-links) within the page, but might also affect links to the
GitLab documentation from both the GitLab application and external sites.
### Anchor links
@@ -1095,14 +1152,26 @@ document to ensure it links to the most recent version of the file.
## Navigation
-To indicate the steps of navigation through the user interface:
+When documenting navigation through the user interface:
-- Use the exact word as shown in the UI, including any capital letters as-is.
+- Use the exact wording as shown in the UI, including any capital letters as-is.
- Use bold text for navigation items and the char "greater than" (`>`) as a
- separator (for example, `Navigate to your project's **Settings > CI/CD**` ).
+ separator. For example: `Navigate to your project's **Settings > CI/CD**`.
- If there are any expandable menus, make sure to mention that the user needs to
- expand the tab to find the settings you're referring to (for example,
- `Navigate to your project's **Settings > CI/CD** and expand **General pipelines**`).
+ expand the tab to find the settings you're referring to. For example:
+ `Navigate to your project's **Settings > CI/CD** and expand **General pipelines**`.
+
+### Navigational elements
+
+Use the following terms when referring to the main GitLab user interface
+elements:
+
+- **Top menu**: This is the top menu that spans the width of the user interface.
+ It includes the GitLab logo, search field, counters, and the user's avatar.
+- **Left sidebar**: This is the navigation sidebar on the left of the user
+ interface, specific to the project or group.
+- **Right sidebar**: This is the navigation sidebar on the right of the user
+ interface, specific to the open issue, merge request, or epic.
## Images
@@ -1162,9 +1231,6 @@ that:
- Are accurate, succinct, and unique.
- Don't use *image of …* or *graphic of…* to describe the image.
-Also, if a heading immediately follows an image, be sure to add three dashes
-(`---`) between the image and the heading.
-
### Remove image shadow
All images displayed on the [GitLab documentation site](https://docs.gitlab.com)
@@ -1249,7 +1315,7 @@ reviewed and approved by a technical writer.
1. In YouTube, visit the video URL you want to display. Copy the regular URL
from your browser (`https://www.youtube.com/watch?v=VIDEO-ID`) and replace
the video title and link in the line under `<div class="video-fallback">`.
-1. In YouTube, click **Share**, and then click **Embed**.
+1. In YouTube, select **Share**, and then select **Embed**.
1. Copy the `<iframe>` source (`src`) **URL only**
(`https://www.youtube.com/embed/VIDEO-ID`),
and paste it, replacing the content of the `src` field in the
@@ -1292,6 +1358,9 @@ the documentation site, but will be displayed on GitLab's `/help`.
added to code blocks. To make things easier for the user, always add a full
code block for things that can be useful to copy and paste, as they can easily
do it with the button on code blocks.
+- HTTP methods (`HTTP POST`) and HTTP status codes, both full (`404 File Not Found`)
+ and abbreviated (`404`), should be wrapped in inline code blocks when used in sentences.
+ For example: Send a `DELETE` request to delete the runner. Send a `POST` request to create one.
- Add a blank line above and below code blocks.
- When providing a shell command and its output, prefix the shell command with `$`
and leave a blank line between the command and the output.
@@ -1416,17 +1485,17 @@ interface:
Use an icon when you find yourself having to describe an interface element. For
example:
-- Do: Click the Admin Area icon ( **{admin}** ).
-- Don't: Click the Admin Area icon (the wrench icon).
+- Do: Select the Admin Area icon ( **{admin}** ).
+- Don't: Select the Admin Area icon (the wrench icon).
## Alert boxes
When you need to call special attention to particular sentences, use the
following markup to create highlighted alert boxes.
-Note that the alert boxes only work for one paragraph only. Multiple paragraphs,
-lists, headers and so on, will not render correctly. For multiple lines, use
-[blockquotes](#blockquotes) instead.
+Alert boxes work for one paragraph only. Multiple paragraphs, lists, and headers
+won't render correctly. For multiple lines, use [blockquotes](#blockquotes)
+instead.
Alert boxes render only on the GitLab documentation site (<https://docs.gitlab.com>).
Within GitLab itself, they will appear as plain Markdown text (like the examples
@@ -1444,23 +1513,20 @@ guidelines, but for consistency you should try to use these values:
### Note
-Notes catch the eye of most readers, and therefore should be used very sparingly.
-In most cases, content considered for a note should be included:
+Notes indicate additional information that is of special use to the reader.
+Notes are most effective when used _sparingly_.
-- As just another sentence in the previous paragraph or the most-relevant
- paragraph.
-- As its own standalone paragraph.
-- As content under a new subheading that introduces the topic, making it more
- visible or findable.
+Try to avoid them. Too many notes can impact the scannability of a topic and
+create an overly busy page.
-#### When to use
+Instead of adding a note, try one of these alternatives:
-Use a note when there is a reason that most or all readers who browse the
-section should see the content. That is, if missed, it’s likely to cause major
-trouble for a minority of users or significant trouble for a majority of users.
+- Re-write the sentence as part of the most-relevant paragraph.
+- Put the information into its own standalone paragraph.
+- Put the content under a new subheading that introduces the topic, which makes
+ it more visible.
-Weigh the costs of distracting users to whom the content is not relevant against
-the cost of users missing the content if it were not expressed as a note.
+If you must use a note, use the following formatting:
```markdown
NOTE: **Note:**
@@ -1582,12 +1648,11 @@ application:
The following are recommended verbs for specific uses with user interface
elements:
-| Recommended | Used for | Replaces |
-|:--------------------|:---------------------------|:---------------------------|
-| *click* | buttons, links, menu items | "hit", "press", "select" |
-| *select* or *clear* | checkboxes | "enable", "click", "press" |
-| *select* | dropdowns | "pick" |
-| *expand* | expandable sections | "open" |
+| Recommended | Used for | Replaces |
+|:--------------------|:--------------------------------------|:---------------------------|
+| *select* | buttons, links, menu items, dropdowns | "click, "press," "hit" |
+| *select* or *clear* | checkboxes | "enable", "click", "press" |
+| *expand* | expandable sections | "open" |
### Other Verbs
@@ -1614,6 +1679,15 @@ heading level.
### Text for documentation requiring version text
+When a feature is new or updated, you can add version information as a bulleted
+item in the **Version history**, or as an inline reference with related text.
+
+#### Version text in the **Version History**
+
+If all content in a section is related, add version text in a bulleted list
+following the heading for the section. To render correctly, it must be on its
+own line and surrounded by blank lines.
+
- For features that need to declare the GitLab version that the feature was
introduced. Text similar to the following should be added immediately below
the heading as a blockquote:
@@ -1663,9 +1737,20 @@ heading level.
and replaced by [Feature name](link-to-feature-documentation).
```
-NOTE: **Note:**
-Version text must be on its own line and surrounded by blank lines to render
-correctly.
+#### Inline version text
+
+If you're adding content to an existing topic, you can add version information
+inline with the existing text.
+
+In this case, add `([introduced/deprecated](<link-to-issue>) in GitLab X.X)`.
+If applicable, include the paid tier: `([introduced/deprecated](<link-to-issue>) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.4)`
+
+Including the issue link is encouraged, but isn't a requirement. For example:
+
+```markdown
+The voting strategy (introduced in GitLab 13.4) requires
+the primary and secondary voters to agree.
+```
### Versions in the past or future
@@ -1894,216 +1979,9 @@ steps aren't required, consider setting up a [table](#tables) with headers of
Learn how to [document features deployed behind flags](feature_flags.md). For
guidance on developing GitLab with feature flags, see [Feature flags in development of GitLab](../feature_flags/index.md).
-## RESTful API
-
-REST API resources are documented in Markdown under
-[`/doc/api`](https://gitlab.com/gitlab-org/gitlab/-/tree/master/doc/api). Each
-resource has its own Markdown file, which is linked from `api_resources.md`.
-
-When modifying the Markdown, also update the corresponding
-[OpenAPI definition](https://gitlab.com/gitlab-org/gitlab/-/tree/master/doc/api/openapi)
-if one exists for the resource. If not, consider creating one. Match the latest
-[OpenAPI 3.0.x specification](https://swagger.io/specification/). (For more
-information, see the discussion in this
-[issue](https://gitlab.com/gitlab-org/gitlab/-/issues/16023#note_370901810).)
-
-In the Markdown doc for a resource (AKA endpoint):
-
-- Every method must have the REST API request. For example:
-
- ```plaintext
- GET /projects/:id/repository/branches
- ```
-
-- Every method must have a detailed [description of the parameters](#method-description).
-- Every method must have a cURL example.
-- Every method must have a response body (in JSON format).
-
-### API topic template
-
-The following can be used as a template to get started:
-
-````markdown
-## Descriptive title
-
-One or two sentence description of what endpoint does.
-
-```plaintext
-METHOD /endpoint
-```
-
-| Attribute | Type | Required | Description |
-|:------------|:---------|:---------|:----------------------|
-| `attribute` | datatype | yes/no | Detailed description. |
-| `attribute` | datatype | yes/no | Detailed description. |
-
-Example request:
-
-```shell
-curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/endpoint?parameters"
-```
-
-Example response:
-
-```json
-[
- {
- }
-]
-```
-````
-
-### Fake user information
-
-You may need to demonstrate an API call or a cURL command that includes the name
-and email address of a user. Don't use real user information in API calls:
-
-- **Email addresses**: Use an email address ending in `example.com`.
-- **Names**: Use strings like `Example Username`. Alternatively, use diverse or
- non-gendered names with common surnames, such as `Sidney Jones`, `Zhang Wei`,
- or `Maria Garcia`.
-
-### Fake URLs
-
-When including sample URLs in the documentation, use:
-
-- `example.com` when the domain name is generic.
-- `gitlab.example.com` when referring to self-managed instances of GitLab.
-
-### Fake tokens
-
-There may be times where a token is needed to demonstrate an API call using
-cURL or a variable used in CI. It is strongly advised not to use real tokens in
-documentation even if the probability of a token being exploited is low.
-
-You can use the following fake tokens as examples:
-
-| Token type | Token value |
-|:----------------------|:-------------------------------------------------------------------|
-| Private user token | `<your_access_token>` |
-| Personal access token | `n671WNGecHugsdEDPsyo` |
-| Application ID | `2fcb195768c39e9a94cec2c2e32c59c0aad7a3365c10892e8116b5d83d4096b6` |
-| Application secret | `04f294d1eaca42b8692017b426d53bbc8fe75f827734f0260710b83a556082df` |
-| CI/CD variable | `Li8j-mLUVA3eZYjPfd_H` |
-| Specific runner token | `yrnZW46BrtBFqM7xDzE7dddd` |
-| Shared runner token | `6Vk7ZsosqQyfreAxXTZr` |
-| Trigger token | `be20d8dcc028677c931e04f3871a9b` |
-| Webhook secret token | `6XhDroRcYPM5by_h-HLY` |
-| Health check token | `Tu7BgjR9qeZTEyRzGG2P` |
-| Request profile token | `7VgpS4Ax5utVD2esNstz` |
-
-### Method description
-
-Use the following table headers to describe the methods. Attributes should
-always be in code blocks using backticks (`` ` ``).
-
-```markdown
-| Attribute | Type | Required | Description |
-|:----------|:-----|:---------|:------------|
-```
-
-Rendered example:
-
-| Attribute | Type | Required | Description |
-|:----------|:-------|:---------|:--------------------|
-| `user` | string | yes | The GitLab username |
-
-### cURL commands
-
-- Use `https://gitlab.example.com/api/v4/` as an endpoint.
-- Wherever needed use this personal access token: `<your_access_token>`.
-- Always put the request first. `GET` is the default so you don't have to
- include it.
-- Wrap the URL in double quotes (`"`).
-- Prefer to use examples using the personal access token and don't pass data of
- username and password.
-
-| Methods | Description |
-|:------------------------------------------- |:------------------------------------------------------|
-| `--header "PRIVATE-TOKEN: <your_access_token>"` | Use this method as is, whenever authentication needed |
-| `--request POST` | Use this method when creating new objects |
-| `--request PUT` | Use this method when updating existing objects |
-| `--request DELETE` | Use this method when removing existing objects |
-
-### cURL Examples
-
-The following sections include a set of [cURL](https://curl.haxx.se) examples
-you can use in the API documentation.
-
-#### Simple cURL command
-
-Get the details of a group:
-
-```shell
-curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/gitlab-org"
-```
-
-#### cURL example with parameters passed in the URL
-
-Create a new project under the authenticated user's namespace:
-
-```shell
-curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects?name=foo"
-```
-
-#### Post data using cURL's `--data`
-
-Instead of using `--request 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 the authenticated user's namespace.
-
-```shell
-curl --data "name=foo" --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects"
-```
-
-#### Post data using JSON content
-
-NOTE: **Note:**
-In this example we create a new group. Watch carefully the single and double
-quotes.
-
-```shell
-curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --header "Content-Type: application/json" --data '{"path": "my-group", "name": "My group"}' "https://gitlab.example.com/api/v4/groups"
-```
-
-#### Post data using form-data
-
-Instead of using JSON or urlencode you can use multipart/form-data which
-properly handles data encoding:
-
-```shell
-curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --form "title=ssh-key" --form "key=ssh-rsa AAAAB3NzaC1yc2EA..." "https://gitlab.example.com/api/v4/users/25/keys"
-```
-
-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
-
-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
-contains spaces in its title. Observe how spaces are escaped using the `%20`
-ASCII code.
-
-```shell
-curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/42/issues?title=Hello%20Dude"
-```
-
-Use `%2F` for slashes (`/`).
-
-#### Pass arrays to API calls
-
-The GitLab API sometimes accepts arrays of strings or integers. For example, to
-exclude specific users when requesting a list of users for a project, you would
-do something like this:
-
-```shell
-curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" --data "skip_users[]=<user_id>" --data "skip_users[]=<user_id>" "https://gitlab.example.com/api/v4/projects/<project_id>/users"
-```
-
## GraphQL API
-GraphQL APIs are different from [RESTful APIs](#restful-api). Reference
+GraphQL APIs are different from [RESTful APIs](restful_api_styleguide.md). Reference
information is generated in our [GraphQL reference](../../api/graphql/reference/index.md).
However, it's helpful to include examples on how to use GraphQL for different
@@ -2158,7 +2036,7 @@ Set up the section with the following:
```markdown
1. Open the GraphiQL explorer tool in the following URL: `https://gitlab.com/-/graphql-explorer`.
1. Paste the `query` listed above into the left window of your GraphiQL explorer tool.
- 1. Click Play to get the result shown here:
+ 1. Select Play to get the result shown here:
```
- Include a screenshot of the result in the GraphiQL explorer. Follow the naming
diff --git a/doc/development/elasticsearch.md b/doc/development/elasticsearch.md
index e70cf456101..639759e5014 100644
--- a/doc/development/elasticsearch.md
+++ b/doc/development/elasticsearch.md
@@ -9,7 +9,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
This area is to maintain a compendium of useful information when working with Elasticsearch.
Information on how to enable Elasticsearch and perform the initial indexing is in
-the [Elasticsearch integration documentation](../integration/elasticsearch.md#enabling-elasticsearch).
+the [Elasticsearch integration documentation](../integration/elasticsearch.md#enabling-advanced-search).
## Deep Dive
diff --git a/doc/development/emails.md b/doc/development/emails.md
index cf7f49ee834..de9607fef64 100644
--- a/doc/development/emails.md
+++ b/doc/development/emails.md
@@ -27,7 +27,7 @@ Please note that [S/MIME signed](../administration/smime_signing_email.md) email
## Mailer previews
Rails provides a way to preview our mailer templates in HTML and plaintext using
-dummy data.
+sample data.
The previews live in [`app/mailers/previews`](https://gitlab.com/gitlab-org/gitlab-foss/tree/master/app/mailers/previews) and can be viewed at
[`/rails/mailers`](http://localhost:3000/rails/mailers).
diff --git a/doc/development/experiment_guide/index.md b/doc/development/experiment_guide/index.md
index 07b803603a5..00a39ac0fc6 100644
--- a/doc/development/experiment_guide/index.md
+++ b/doc/development/experiment_guide/index.md
@@ -16,46 +16,97 @@ In either case, an outcome of the experiment should be posted to the issue with
## Code reviews
-Since the code of experiments will not be part of the codebase for a long time and we want to iterate fast to retrieve data, the code quality of experiments might sometimes not fulfill our standards but should not negatively impact the availability of GitLab whether the experiment is running or not.
-Initially experiments will only be deployed to a fraction of users but we still want a flawless experience for those users. Therefore, experiments still require tests.
-
-For reviewers and maintainers: if you find code that would usually not make it through the review, but is temporarily acceptable, please mention your concerns but note that it's not necessary to change.
-The author then adds a comment to this piece of code and adds a link to the issue that resolves the experiment. If the experiment is successful and becomes part of the product these follow up issues should be addressed.
+Experiments' code quality can fail our standards for several reasons. These
+reasons can include not being added to the codebase for a long time, or because
+of fast iteration to retrieve data. However, having the experiment run (or not
+run) shouldn't impact GitLab's availability. To avoid or identify issues,
+experiments are initially deployed to a small number of users. Regardless,
+experiments still need tests.
+
+If, as a reviewer or maintainer, you find code that would usually fail review
+but is acceptable for now, mention your concerns with a note that there's no
+need to change the code. The author can then add a comment to this piece of code
+and link to the issue that resolves the experiment. If the experiment is
+successful and becomes part of the product, any follow up issues should be
+addressed.
## How to create an A/B test
-- Add the experiment to the `Gitlab::Experimentation::EXPERIMENTS` hash in [`experimentation.rb`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib%2Fgitlab%2Fexperimentation.rb):
-
- ```ruby
- EXPERIMENTS = {
- other_experiment: {
- #...
- },
- # Add your experiment here:
- signup_flow: {
- environment: ::Gitlab.dev_env_or_com?, # Target environment, defaults to enabled for development and GitLab.com
- tracking_category: 'Growth::Acquisition::Experiment::SignUpFlow' # Used for providing the category when setting up tracking data
- }
- }.freeze
- ```
-
-- Use the experiment in a controller:
-
- ```ruby
- class RegistrationController < ApplicationController
- def show
- # experiment_enabled?(:feature_name) is also available in views and helpers
- if experiment_enabled?(:signup_flow)
- # render the experiment
- else
- # render the original version
- end
- end
- end
- ```
-
-- Track necessary events. See the [telemetry guide](../telemetry/index.md) for details.
-- After the merge request is merged, use [`chatops`](../../ci/chatops/README.md) in the
+### Implementation
+
+1. Add the experiment to the `Gitlab::Experimentation::EXPERIMENTS` hash in [`experimentation.rb`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib%2Fgitlab%2Fexperimentation.rb):
+
+ ```ruby
+ EXPERIMENTS = {
+ other_experiment: {
+ #...
+ },
+ # Add your experiment here:
+ signup_flow: {
+ environment: ::Gitlab.dev_env_or_com?, # Target environment, defaults to enabled for development and GitLab.com
+ tracking_category: 'Growth::Acquisition::Experiment::SignUpFlow' # Used for providing the category when setting up tracking data
+ }
+ }.freeze
+ ```
+
+1. Use the experiment in the code.
+
+ - Use this standard for the experiment in a controller:
+
+ ```ruby
+ class RegistrationController < ApplicationController
+ def show
+ # experiment_enabled?(:experiment_key) is also available in views and helpers
+ if experiment_enabled?(:signup_flow)
+ # render the experiment
+ else
+ # render the original version
+ end
+ end
+ end
+ ```
+
+ - Make the experiment available to the frontend in a controller:
+
+ ```ruby
+ before_action do
+ push_frontend_experiment(:signup_flow)
+ end
+ ```
+
+ The above will check whether the experiment is enabled and push the result to the frontend.
+
+ You can check the state of the feature flag in JavaScript:
+
+ ```javascript
+ import { isExperimentEnabled } from '~/experimentation';
+
+ if ( isExperimentEnabled('signupFlow') ) {
+ // ...
+ }
+ ```
+
+ - It is also possible to run an experiment outside of the controller scope, for example in a worker:
+
+ ```ruby
+ class SomeWorker
+ def perform
+ # Check if the experiment is enabled at all (the percentage_of_time_value > 0)
+ return unless Gitlab::Experimentation.enabled?(:experiment_key)
+
+ # Since we cannot access cookies in a worker, we need to bucket models based on a unique, unchanging attribute instead.
+ # Use the following method to check if the experiment is enabled for a certain attribute, for example a username or email address:
+ if Gitlab::Experimentation.enabled_for_attribute?(:experiment_key, some_attribute)
+ # execute experimental code
+ else
+ # execute control code
+ end
+ end
+ end
+ ```
+
+1. Track necessary events. See the [telemetry guide](../telemetry/index.md) for details.
+1. After the merge request is merged, use [`chatops`](../../ci/chatops/README.md) in the
[appropriate channel](../feature_flags/controls.md#communicate-the-change) to start the experiment for 10% of the users.
The feature flag should have the name of the experiment with the `_experiment_percentage` suffix appended.
For visibility, please also share any commands run against production in the `#s_growth` channel:
@@ -69,3 +120,19 @@ For visibility, please also share any commands run against production in the `#s
```shell
/chatops run feature delete signup_flow_experiment_percentage
```
+
+### Tests and test helpers
+
+Use the following in Jest to test the experiment is enabled.
+
+```javascript
+import { withGonExperiment } from 'helpers/experimentation_helper';
+
+describe('given experiment is enabled', () => {
+ withGonExperiment('signupFlow');
+
+ it('should do the experimental thing', () => {
+ expect(wrapper.find('.js-some-experiment-triggered-element')).toEqual(expect.any(Element));
+ });
+});
+```
diff --git a/doc/development/fe_guide/architecture.md b/doc/development/fe_guide/architecture.md
index 3d27f67a8a6..c7e1ba59f23 100644
--- a/doc/development/fe_guide/architecture.md
+++ b/doc/development/fe_guide/architecture.md
@@ -15,5 +15,5 @@ You can find the Frontend Architecture experts on the [team page](https://about.
## Examples
-You can find documentation about the desired architecture for a new feature
-built with Vue.js [here](vue.md).
+You can find [documentation about the desired architecture](vue.md) for a new
+feature built with Vue.js.
diff --git a/doc/development/fe_guide/dependencies.md b/doc/development/fe_guide/dependencies.md
index 7f078df887d..ba97e7e39be 100644
--- a/doc/development/fe_guide/dependencies.md
+++ b/doc/development/fe_guide/dependencies.md
@@ -18,9 +18,9 @@ automatically create merge requests for updating dependencies of several project
up-to-date list of projects managed by the renovate bot in the project’s README. Some key dependencies
updated using renovate are:
-- [`@gitlab/ui`](https://gitlab.com/gitlab-org/gitlab-ui/)
-- [`@gitlab/svgs`](https://gitlab.com/gitlab-org/gitlab-svgs/)
-- [`@gitlab/eslint-config`](https://gitlab.com/gitlab-org/gitlab-eslint-config)
+- [`@gitlab/ui`](https://gitlab.com/gitlab-org/gitlab-ui)
+- [`@gitlab/svgs`](https://gitlab.com/gitlab-org/gitlab-svgs)
+- [`@gitlab/eslint-plugin`](https://gitlab.com/gitlab-org/frontend/eslint-plugin)
### Blocked dependencies
diff --git a/doc/development/fe_guide/icons.md b/doc/development/fe_guide/icons.md
index b539293e9cf..67add5709d9 100644
--- a/doc/development/fe_guide/icons.md
+++ b/doc/development/fe_guide/icons.md
@@ -1,9 +1,10 @@
# Icons and SVG Illustrations
-We manage our own Icon and Illustration library in the [`gitlab-svgs`](https://gitlab.com/gitlab-org/gitlab-svgs) repository.
-This repository is published on [npm](https://www.npmjs.com/package/@gitlab/svgs) and managed as a dependency via yarn.
-You can browse all available Icons and Illustrations [here](https://gitlab-org.gitlab.io/gitlab-svgs).
-To upgrade to a new version run `yarn upgrade @gitlab/svgs`.
+We manage our own icon and illustration library in the [`gitlab-svgs`](https://gitlab.com/gitlab-org/gitlab-svgs)
+repository. This repository is published on [npm](https://www.npmjs.com/package/@gitlab/svgs),
+and is managed as a dependency with yarn. You can browse all available
+[icons and illustrations](https://gitlab-org.gitlab.io/gitlab-svgs). To upgrade
+to a new version run `yarn upgrade @gitlab/svgs`.
## Icons
@@ -21,10 +22,11 @@ To use a sprite Icon in HAML or Rails we use a specific helper function :
sprite_icon(icon_name, size: nil, css_class: '')
```
-- **icon_name** Use the icon_name that you can find in the SVG Sprite
- ([Overview is available here](https://gitlab-org.gitlab.io/gitlab-svgs)).
-- **size (optional)** Use one of the following sizes : 16, 24, 32, 48, 72 (this will be translated into a `s16` class)
-- **css_class (optional)** If you want to add additional CSS classes
+- **icon_name**: Use the icon_name for the SVG sprite in the list of
+ ([GitLab SVGs](https://gitlab-org.gitlab.io/gitlab-svgs)).
+- **size (optional)**: Use one of the following sizes : 16, 24, 32, 48, 72 (this
+ will be translated into a `s16` class)
+- **css_class (optional)**: If you want to add additional CSS classes.
**Example**
@@ -66,10 +68,12 @@ export default {
</template>
```
-- **name** Name of the Icon in the SVG Sprite ([Overview is available here](https://gitlab-org.gitlab.io/gitlab-svgs)).
-- **size (optional)** Number value for the size which is then mapped to a specific CSS class
- (Available Sizes: 8, 12, 16, 18, 24, 32, 48, 72 are mapped to `sXX` CSS classes)
-- **class (optional)** Additional CSS Classes to add to the SVG tag.
+- **name**: Name of the icon of the SVG sprite, as listed in the
+ ([GitLab SVG Previewer](https://gitlab-org.gitlab.io/gitlab-svgs)).
+- **size: (optional)** Number value for the size which is then mapped to a
+ specific CSS class (Available sizes: 8, 12, 16, 18, 24, 32, 48, 72 are mapped
+ to `sXX` CSS classes)
+- **class (optional)**: Additional CSS classes to add to the SVG tag.
### Usage in HTML/JS
diff --git a/doc/development/fe_guide/tooling.md b/doc/development/fe_guide/tooling.md
index 5685ac5abcd..b93c0a9736b 100644
--- a/doc/development/fe_guide/tooling.md
+++ b/doc/development/fe_guide/tooling.md
@@ -101,7 +101,10 @@ Our code is automatically formatted with [Prettier](https://prettier.io) to foll
### 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.
+The recommended method to include Prettier in your workflow is to set up your
+preferred editor (all major editors are supported) accordingly. We suggest
+setting up Prettier to run when each file is saved. For instructions about using
+Prettier in your preferred editor, see the [Prettier documentation](https://prettier.io/docs/en/editors.html).
Please take care that you only let Prettier format the same file types as the global Yarn script does (`.js`, `.vue`, `.graphql`, and `.scss`). In VSCode by example you can easily exclude file formats in your settings file:
diff --git a/doc/development/fe_guide/vuex.md b/doc/development/fe_guide/vuex.md
index 4badf3f0845..528dcb3b7f4 100644
--- a/doc/development/fe_guide/vuex.md
+++ b/doc/development/fe_guide/vuex.md
@@ -32,8 +32,9 @@ When using Vuex at GitLab, separate these concerns into different files to impro
└── mutation_types.js # mutation types
```
-The following example shows an application that lists and adds users to the state.
-(For a more complex example implementation take a look at the security applications store in [here](https://gitlab.com/gitlab-org/gitlab/tree/master/ee/app/assets/javascripts/vue_shared/security_reports/store))
+The following example shows an application that lists and adds users to the
+state. (For a more complex example implementation, review the security
+applications stored in this [repository](https://gitlab.com/gitlab-org/gitlab/tree/master/ee/app/assets/javascripts/vue_shared/security_reports/store)).
### `index.js`
diff --git a/doc/development/feature_categorization/index.md b/doc/development/feature_categorization/index.md
index 3fd402ebe84..31ec9ee65b6 100644
--- a/doc/development/feature_categorization/index.md
+++ b/doc/development/feature_categorization/index.md
@@ -75,38 +75,23 @@ A feature category can be specified on an entire controller
using:
```ruby
-class Projects::MergeRequestsController < ApplicationController
- feature_category :source_code_management
+class Boards::ListsController < ApplicationController
+ feature_category :kanban_boards
end
```
The feature category can be limited to a list of actions using the
-`only` argument, actions can be excluded using the `except` argument.
+second argument:
```ruby
-class Projects::MergeRequestsController < ApplicationController
- feature_category :code_testing, only: [:metrics_reports]
- feature_category :source_code_management, except: [:test_reports, :coverage_reports]
+class DashboardController < ApplicationController
+ feature_category :issue_tracking, [:issues, :issues_calendar]
+ feature_category :code_review, [:merge_requests]
end
```
-`except` and `only` arguments can not be combined.
-
-When specifying `except` all other actions will get the specified
-category assigned.
-
-The assignment can also be scoped using `if` and `unless` procs:
-
-```ruby
-class Projects::MergeRequestsController < ApplicationController
- feature_category :source_code_management,
- unless: -> (action) { action.include?("reports") }
- if: -> (action) { action.include?("widget") }
-end
-```
-
-In this case, both procs need to be satisfied for the action to get
-the category assigned.
+These forms cannot be mixed: if a controller has more than one category,
+every single action must be listed.
### Excluding controller actions from feature categorization
@@ -125,6 +110,5 @@ The `spec/controllers/every_controller_spec.rb` will iterate over all
defined routes, and check the controller to see if a category is
assigned to all actions.
-The spec also validates if the used feature categories are known. And
-if the actions used in `only` and `except` configuration still exist
-as routes.
+The spec also validates if the used feature categories are known. And if
+the actions used in configuration still exist as routes.
diff --git a/doc/development/feature_flags/development.md b/doc/development/feature_flags/development.md
index 29bd0ca0a7e..a75f1f818dd 100644
--- a/doc/development/feature_flags/development.md
+++ b/doc/development/feature_flags/development.md
@@ -52,10 +52,10 @@ invocations:
```ruby
# Check if feature flag is enabled
-Feature.enabled?(:my_ops_flag, project, type: ops)
+Feature.enabled?(:my_ops_flag, project, type: :ops)
# Check if feature flag is disabled
-Feature.disabled?(:my_ops_flag, project, type: ops)
+Feature.disabled?(:my_ops_flag, project, type: :ops)
# Push feature flag to Frontend
push_frontend_feature_flag(:my_ops_flag, project, type: :ops)
diff --git a/doc/development/frontend.md b/doc/development/frontend.md
index f46cc377f95..8bb5cf7af62 100644
--- a/doc/development/frontend.md
+++ b/doc/development/frontend.md
@@ -1,4 +1,5 @@
+---
+redirect_to: 'fe_guide/index.md'
+---
-# Frontend Development Guidelines
-
-This page has moved [here](fe_guide/index.md).
+This document was moved to [another location](fe_guide/index.md).
diff --git a/doc/development/geo/framework.md b/doc/development/geo/framework.md
index b720a6ca47e..bc7a5c57916 100644
--- a/doc/development/geo/framework.md
+++ b/doc/development/geo/framework.md
@@ -91,8 +91,6 @@ module Geo
::Packages::PackageFile
end
- # Change this to `true` to release replication of this model. Then remove
- # this override in the next release.
# The feature flag follows the format `geo_#{replicable_name}_replication`,
# so here it would be `geo_package_file_replication`
def self.replication_enabled_by_default?
@@ -220,8 +218,6 @@ For example, to add support for files referenced by a `Widget` model with a
model_record.file
end
- # Change this to `true` to release replication of this model. Then remove
- # this override in the next release.
# The feature flag follows the format `geo_#{replicable_name}_replication`,
# so here it would be `geo_widget_replication`
def self.replication_enabled_by_default?
@@ -637,7 +633,7 @@ the Admin Area UI, and Prometheus!
include ::Types::Geo::RegistryType
graphql_name 'WidgetRegistry'
- description 'Represents the sync and verification state of a widget'
+ description 'Represents the Geo sync and verification state of a widget'
field :widget_id, GraphQL::ID_TYPE, null: false, description: 'ID of the Widget'
end
@@ -676,6 +672,12 @@ the Admin Area UI, and Prometheus!
}
```
+1. Update the GraphQL reference documentation:
+
+ ```shell
+ bundle exec rake gitlab:graphql:compile_docs
+ ```
+
Individual widget synchronization and verification data should now be available
via the GraphQL API!
@@ -693,3 +695,33 @@ To do: This should be done as part of
Widget sync and verification data (aggregate and individual) should now be
available in the Admin UI!
+
+#### Releasing the feature
+
+1. In `ee/app/replicators/geo/widget_replicator.rb`, delete the `self.replication_enabled_by_default?` method:
+
+ ```ruby
+ module Geo
+ class WidgetReplicator < Gitlab::Geo::Replicator
+ ...
+
+ # REMOVE THIS METHOD
+ def self.replication_enabled_by_default?
+ false
+ end
+ # REMOVE THIS METHOD
+
+ ...
+ end
+ end
+ ```
+
+1. In `ee/app/graphql/types/geo/geo_node_type.rb`, remove the `feature_flag` option for the released type:
+
+ ```ruby
+ field :widget_registries, ::Types::Geo::WidgetRegistryType.connection_type,
+ null: true,
+ resolver: ::Resolvers::Geo::WidgetRegistriesResolver,
+ description: 'Find widget registries on this Geo node',
+ feature_flag: :geo_widget_replication # REMOVE THIS LINE
+ ```
diff --git a/doc/development/gotchas.md b/doc/development/gotchas.md
index f7b44e74c17..1044b7edcb8 100644
--- a/doc/development/gotchas.md
+++ b/doc/development/gotchas.md
@@ -3,6 +3,21 @@
The purpose of this guide is to document potential "gotchas" that contributors
might encounter or should avoid during development of GitLab CE and EE.
+## Do not read files from app/assets directory
+
+Since GitLab 10.8, Omnibus has [dropped the `app/assets` directory](https://gitlab.com/gitlab-org/omnibus-gitlab/-/merge_requests/2456),
+after asset compilation. The `ee/app/assets`, `vendor/assets` directories are dropped as well.
+
+This means that reading files from that directory will fail in Omnibus-installed GitLab instances:
+
+```ruby
+file = Rails.root.join('app/assets/images/logo.svg')
+
+# This file does not exist, read will fail with:
+# Errno::ENOENT: No such file or directory @ rb_sysopen
+File.read(file)
+```
+
## Do not assert against the absolute value of a sequence-generated attribute
Consider the following factory:
diff --git a/doc/development/graphql_guide/index.md b/doc/development/graphql_guide/index.md
index 62650f7cd3f..9d7fb5ba0a8 100644
--- a/doc/development/graphql_guide/index.md
+++ b/doc/development/graphql_guide/index.md
@@ -8,6 +8,6 @@ feedback, and suggestions.
- [GraphQL API development style guide](../api_graphql_styleguide.md): development style guide for
GraphQL.
-- [GraphQL API documentation style guide](../documentation/styleguide.md#graphql-api): documentation
+- [GraphQL API documentation style guide](../documentation/graphql_styleguide.md): documentation
style guide for GraphQL.
- [GraphQL API](../../api/graphql/index.md): user documentation for the GitLab GraphQL API.
diff --git a/doc/development/i18n/externalization.md b/doc/development/i18n/externalization.md
index b5c5a199b1e..cc711589916 100644
--- a/doc/development/i18n/externalization.md
+++ b/doc/development/i18n/externalization.md
@@ -432,7 +432,7 @@ To avoid this error, use the applicable HTML entity code (`&lt;` or `&gt;`) inst
- In JavaScript:
```javascript
- import { sanitize } from 'dompurify';
+ import { sanitize } from '~/lib/dompurify';
const i18n = { LESS_THAN_ONE_HOUR: sanitize(__('In &lt; 1 hour'), { ALLOWED_TAGS: [] }) };
diff --git a/doc/development/i18n/index.md b/doc/development/i18n/index.md
index 929eded3f8e..2d84fe4536f 100644
--- a/doc/development/i18n/index.md
+++ b/doc/development/i18n/index.md
@@ -38,10 +38,10 @@ Voting for translations is also valuable, helping to confirm good and flag inacc
See [Translation guidelines](translation.md).
-### Proof reading
+### Proofreading
-Proof reading helps ensure the accuracy and consistency of translations. All
-translations are proof read before being accepted. If a translations requires
+Proofreading helps ensure the accuracy and consistency of translations. All
+translations are proofread 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
diff --git a/doc/development/integrations/secure.md b/doc/development/integrations/secure.md
index dcfd0f40bf0..b085f1244c1 100644
--- a/doc/development/integrations/secure.md
+++ b/doc/development/integrations/secure.md
@@ -374,12 +374,16 @@ which is shared by the analyzers that GitLab maintains. You can [contribute](htt
new generic identifiers to if needed. Analyzers may also produce vendor-specific or product-specific
identifiers, which don't belong in the [common library](https://gitlab.com/gitlab-org/security-products/analyzers/common).
-The first item of the `identifiers` array is called the primary identifier.
+The first item of the `identifiers` array is called the [primary
+identifier](../../user/application_security/terminology/#primary-identifier).
The primary identifier is particularly important, because it is used to
[track vulnerabilities](#tracking-and-merging-vulnerabilities) as new commits are pushed to the repository.
Identifiers are also used to [merge duplicate vulnerabilities](#tracking-and-merging-vulnerabilities)
reported for the same commit, except for `CWE` and `WASC`.
+Not all vulnerabilities have CVEs, and a CVE can be identified multiple times. As a result, a CVE
+isn't a stable identifier and you shouldn't assume it as such when tracking vulnerabilities.
+
### Location
The `location` indicates where the vulnerability has been detected.
diff --git a/doc/development/integrations/secure_partner_integration.md b/doc/development/integrations/secure_partner_integration.md
index 830cb84e257..19fd86f4bf6 100644
--- a/doc/development/integrations/secure_partner_integration.md
+++ b/doc/development/integrations/secure_partner_integration.md
@@ -36,7 +36,7 @@ best place to integrate your own product and its results into GitLab.
- Pipeline jobs serve a variety of purposes. Jobs can do scanning for and have
implications for app security, corporate policy, or compliance. When complete,
the job reports back on its status and creates a
- [job artifact](../../user/project/pipelines/job_artifacts.md) as a result.
+ [job artifact](../../ci/pipelines/job_artifacts.md) as a result.
- The [Merge Request Security Widget](../../user/project/merge_requests/testing_and_reports_in_merge_requests.md#security-reports)
displays the results of the pipeline's security checks and the developer can
review them. The developer can review both a summary and a detailed version
@@ -44,7 +44,7 @@ best place to integrate your own product and its results into GitLab.
- If certain policies (such as [merge request approvals](../../user/project/merge_requests/merge_request_approvals.md))
are in place for a project, developers must resolve specific findings or get
an approval from a specific list of people.
-- The [security dashboard](../../user/application_security/security_dashboard/index.md#gitlab-security-dashboard)
+- The [security dashboard](../../user/application_security/security_dashboard/index.md)
also shows results which can developers can use to quickly see all the
vulnerabilities that need to be addressed in the code.
- When the developer reads the details about a vulnerability, they are
@@ -78,7 +78,7 @@ and complete an integration with the Secure stage.
to successfully display your own product's results with the rest of GitLab.
- See detailed [technical directions](secure.md) for this step.
- Read more about [job report artifacts](../../ci/pipelines/job_artifacts.md#artifactsreports).
- - Read about [job artifacts](../../user/project/pipelines/job_artifacts.md).
+ - Read about [job artifacts](../../ci/pipelines/job_artifacts.md).
- Your report artifact must be in one of our currently supported formats.
For more information, see the [documentation on reports](secure.md#report).
- Documentation for [SAST reports](../../user/application_security/sast/index.md#reports-json-format).
@@ -89,7 +89,7 @@ and complete an integration with the Secure stage.
and add the label `devops::secure`.
- Once the job is completed, the data can be seen:
- In the [Merge Request Security Report](../../user/project/merge_requests/testing_and_reports_in_merge_requests.md#security-reports) ([MR Security Report data flow](https://gitlab.com/snippets/1910005#merge-request-view)).
- - While [browsing a Job Artifact](../../user/project/pipelines/job_artifacts.md).
+ - While [browsing a Job Artifact](../../ci/pipelines/job_artifacts.md).
- In the [Security Dashboard](../../user/application_security/security_dashboard/index.md) ([Dashboard data flow](https://gitlab.com/snippets/1910005#project-and-group-dashboards)).
1. Optional: Provide a way to interact with results as Vulnerabilities:
- Users can interact with the findings from your artifact within their workflow. They can dismiss the findings or accept them and create a backlog issue.
diff --git a/doc/development/kubernetes.md b/doc/development/kubernetes.md
index 64089d51c7b..a54798b4c35 100644
--- a/doc/development/kubernetes.md
+++ b/doc/development/kubernetes.md
@@ -15,14 +15,14 @@ This document provides various guidelines when developing for GitLab's
Some Kubernetes operations, such as creating restricted project
namespaces are performed on the GitLab Rails application. These
-operations are performed using a [client library](#client-library).
-These operations will carry an element of risk as the operations will be
-run as the same user running the GitLab Rails application, see the
-[security](#security) section below.
+operations are performed using a [client library](#client-library),
+and carry an element of risk. The operations are
+run as the same user running the GitLab Rails application. For more information,
+read the [security](#security) section below.
Some Kubernetes operations, such as installing cluster applications are
performed on one-off pods on the Kubernetes cluster itself. These
-installation pods are currently named `install-<application_name>` and
+installation pods are named `install-<application_name>` and
are created within the `gitlab-managed-apps` namespace.
In terms of code organization, we generally add objects that represent
@@ -33,33 +33,32 @@ Kubernetes resources in
We use the [`kubeclient`](https://rubygems.org/gems/kubeclient) gem to
perform Kubernetes API calls. As the `kubeclient` gem does not support
-different API Groups (e.g. `apis/rbac.authorization.k8s.io`) from a
+different API Groups (such as `apis/rbac.authorization.k8s.io`) from a
single client, we have created a wrapper class,
[`Gitlab::Kubernetes::KubeClient`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/kubernetes/kube_client.rb)
-that will enable you to achieve this.
+that enable you to achieve this.
-Selected Kubernetes API groups are currently supported. Do add support
+Selected Kubernetes API groups are supported. Do add support
for new API groups or methods to
[`Gitlab::Kubernetes::KubeClient`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/kubernetes/kube_client.rb)
if you need to use them. New API groups or API group versions can be
-added to `SUPPORTED_API_GROUPS` - internally, this will create an
+added to `SUPPORTED_API_GROUPS` - internally, this creates an
internal client for that group. New methods can be added as a delegation
to the relevant internal client.
### Performance considerations
-All calls to the Kubernetes API must be in a background process. Do not
-perform Kubernetes API calls within a web request as this will block
-Unicorn and can easily lead to a Denial Of Service (DoS) attack in GitLab as
+All calls to the Kubernetes API must be in a background process. Don't
+perform Kubernetes API calls within a web request. This blocks
+Unicorn, and can lead to a denial-of-service (DoS) attack in GitLab as
the Kubernetes cluster response times are outside of our control.
The easiest way to ensure your calls happen a background process is to
delegate any such work to happen in a [Sidekiq worker](sidekiq_style_guide.md).
-There are instances where you would like to make calls to Kubernetes and
-return the response and as such a background worker does not seem to be
-a good fit. For such cases you should make use of [reactive
-caching](https://gitlab.com/gitlab-org/gitlab/blob/master/app/models/concerns/reactive_caching.rb).
+You may want to make calls to Kubernetes and return the response, but a background
+worker isn't a good fit. Consider using
+[reactive caching](https://gitlab.com/gitlab-org/gitlab/blob/master/app/models/concerns/reactive_caching.rb).
For example:
```ruby
@@ -76,7 +75,7 @@ For example:
### Testing
-We have some Webmock stubs in
+We have some WebMock stubs in
[`KubernetesHelpers`](https://gitlab.com/gitlab-org/gitlab/blob/master/spec/support/helpers/kubernetes_helpers.rb)
which can help with mocking out calls to Kubernetes API in your tests.
@@ -86,8 +85,8 @@ This section outlines the process for allowing a GitLab instance to create EKS c
The following prerequisites are required:
-A `Customer` AWS account. This is the account in which the
-EKS cluster will be created. The following resources must be present:
+A `Customer` AWS account. The EKS cluster is created in this account. The following
+resources must be present:
- A provisioning role that has permissions to create the cluster
and associated resources. It must list the `GitLab` AWS account
@@ -114,7 +113,7 @@ The process for creating a cluster is as follows:
which takes somewhere between 10 and 15 minutes in most cases.
1. When the stack is ready, GitLab stores the cluster details and generates
another set of temporary credentials, this time to allow connecting to
- the cluster via Kubeclient. These credentials are valid for one minute.
+ the cluster via `kubeclient`. These credentials are valid for one minute.
1. GitLab configures the worker nodes so that they are able to authenticate
to the cluster, and creates a service account for itself for future operations.
1. Credentials that are no longer required are removed. This deletes the following
@@ -126,7 +125,7 @@ The process for creating a cluster is as follows:
## Security
-### SSRF
+### Server Side Request Forgery (SSRF) attacks
As URLs for Kubernetes clusters are user controlled it is easily
susceptible to Server Side Request Forgery (SSRF) attacks. You should
@@ -153,11 +152,11 @@ Mitigation strategies include:
app.make_errored!("Kubernetes error: #{e.error_code}")
```
-## Debugging
+## Debugging Kubernetes integrations
Logs related to the Kubernetes integration can be found in
[`kubernetes.log`](../administration/logs.md#kuberneteslog). On a local
-GDK install, this will be present in `log/kubernetes.log`.
+GDK install, these logs are present in `log/kubernetes.log`.
Some services such as
[`Clusters::Applications::InstallService`](https://gitlab.com/gitlab-org/gitlab/blob/master/app/services/clusters/applications/install_service.rb#L18)
@@ -176,7 +175,9 @@ kubectl logs <pod_name> --follow -n gitlab-managed-apps
## GitLab Managed Apps
-GitLab provides [GitLab Managed Apps](../user/clusters/applications.md), a one-click install for various applications which can be added directly to your configured cluster.
+GitLab provides [GitLab Managed Apps](../user/clusters/applications.md), a one-click
+install for various applications which can be added directly to your configured cluster.
**<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
-For an overview of how to add a new GitLab-managed app, see [How to add GitLab-managed-apps to Kubernetes integration](https://youtu.be/mKm-jkranEk).**
+For an overview of how to add a new GitLab-managed app, see
+[How to add GitLab-managed-apps to Kubernetes integration](https://youtu.be/mKm-jkranEk).**
diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md
index 207dd02d258..740ac54c415 100644
--- a/doc/development/migration_style_guide.md
+++ b/doc/development/migration_style_guide.md
@@ -647,7 +647,7 @@ tables: `namespaces`. This can be translated to:
```sql
ALTER TABLE namespaces
ALTER COLUMN request_access_enabled
-DEFAULT false
+SET DEFAULT false
```
In this particular case, the default value exists and we're just changing the metadata for
diff --git a/doc/development/new_fe_guide/development/accessibility.md b/doc/development/new_fe_guide/development/accessibility.md
index b9ee5c3a549..9c63ccad6e1 100644
--- a/doc/development/new_fe_guide/development/accessibility.md
+++ b/doc/development/new_fe_guide/development/accessibility.md
@@ -12,7 +12,7 @@ WAI-ARIA (the Accessible Rich Internet Applications specification) defines a way
The `role` attribute describes the role the element plays in the context of the document.
-Check the list of WAI-ARIA roles [here](https://www.w3.org/TR/wai-aria-1.1/#landmark_roles)
+Review the list of [WAI-ARIA roles](https://www.w3.org/TR/wai-aria-1.1/#landmark_roles).
## Icons
diff --git a/doc/development/new_fe_guide/development/index.md b/doc/development/new_fe_guide/development/index.md
index 5dced3dc466..119dbc58012 100644
--- a/doc/development/new_fe_guide/development/index.md
+++ b/doc/development/new_fe_guide/development/index.md
@@ -12,6 +12,6 @@ Learn how to implement an accessible frontend.
Learn how to keep our frontend performant.
-## [Testing](testing.md)
+## [Testing](../../testing_guide/frontend_testing.md)
Learn how to keep our frontend tested.
diff --git a/doc/development/packages.md b/doc/development/packages.md
index 55e22d4bb5f..9504d5802c3 100644
--- a/doc/development/packages.md
+++ b/doc/development/packages.md
@@ -87,7 +87,7 @@ Composer package naming scope is Instance Level.
To avoid name conflict for instance-level endpoints you will need to define a package naming convention
that gives a way to identify the project that the package belongs to. This generally involves using the project
ID or full project path in the package name. See
-[Conan's naming convention](../user/packages/conan_repository/index.md#package-recipe-naming-convention-for-instance-level-remote) as an example.
+[Conan's naming convention](../user/packages/conan_repository/index.md#package-recipe-naming-convention-for-instance-remotes) as an example.
For group and project-level endpoints, naming can be less constrained and it will be up to the group and project
members to be certain that there is no conflict between two package names. However, the system should prevent
diff --git a/doc/development/pipelines.md b/doc/development/pipelines.md
index 7756ef376fc..d12220d8c95 100644
--- a/doc/development/pipelines.md
+++ b/doc/development/pipelines.md
@@ -122,7 +122,6 @@ graph RL;
click 2_2-5 "https://app.periscopedata.com/app/gitlab/652085/Engineering-Productivity---Pipeline-Build-Durations?widget=8404303&udv=0"
subgraph "Needs `setup-test-env` & `compile-test-assets`";
2_2-2 & 2_2-4 & 2_2-5 --> 1-6 & 1-3;
- 2_2-3 --> 1-6 & 1-4;
end
2_3-1["build-assets-image (2.5 minutes)"];
@@ -228,7 +227,6 @@ graph RL;
click 2_2-5 "https://app.periscopedata.com/app/gitlab/652085/Engineering-Productivity---Pipeline-Build-Durations?widget=8404303&udv=0"
subgraph "Needs `setup-test-env` & `compile-test-assets`";
2_2-2 & 2_2-4 & 2_2-5 --> 1-6 & 1-3;
- 2_2-3 --> 1-6 & 1-4;
end
2_3-1["build-assets-image (2.5 minutes)"];
diff --git a/doc/development/profiling.md b/doc/development/profiling.md
index f7ead94d725..f5a4d1edb92 100644
--- a/doc/development/profiling.md
+++ b/doc/development/profiling.md
@@ -91,9 +91,6 @@ 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 (restricted to GitLab Team members only):
-`https://redash.gitlab.com/dashboard/gitlab-profiler-statistics`
-
## Sherlock
Sherlock is a custom profiling tool built into GitLab. Sherlock is _only_
diff --git a/doc/development/prometheus.md b/doc/development/prometheus.md
index 902d4e6a1d0..b8cf5b997ba 100644
--- a/doc/development/prometheus.md
+++ b/doc/development/prometheus.md
@@ -47,3 +47,13 @@ using a Prometheus managed application in Kubernetes:
```
1. Open `localhost:9090` in your browser to display the Prometheus user interface.
+
+## Script access to Prometheus
+
+You can script the access to Prometheus, extracting the name of the pod automatically like this:
+
+```shell
+POD_INFORMATION=$(kubectl get pods -n gitlab-managed-apps | grep 'prometheus-prometheus-server')
+POD_NAME=$(echo $POD_INFORMATION | awk '{print $1;}')
+kubectl port-forward $POD_NAME 9090:9090 -n gitlab-managed-apps
+```
diff --git a/doc/development/reactive_caching.md b/doc/development/reactive_caching.md
index f3386305e93..cf125c46565 100644
--- a/doc/development/reactive_caching.md
+++ b/doc/development/reactive_caching.md
@@ -85,9 +85,7 @@ The ReactiveCaching concern can be used in models as well as `project_services`
1. Implement the `calculate_reactive_cache` method in your model/service.
1. Call `with_reactive_cache` in your model/service where the cached value is needed.
-1. If the `calculate_reactive_cache` method above submits requests to external services
-(e.g. Prometheus, K8s), make sure to change the
-[`reactive_cache_work_type` accordingly](#selfreactive_cache_work_type).
+1. Set the [`reactive_cache_work_type` accordingly](#selfreactive_cache_work_type).
### In controllers
@@ -252,7 +250,7 @@ self.reactive_cache_hard_limit = 5.megabytes
- This is the type of work performed by the `calculate_reactive_cache` method. Based on this attribute,
it's able to pick the right worker to process the caching job. Make sure to
set it as `:external_dependency` if the work performs any external request
-(e.g. Kubernetes, Sentry).
+(e.g. Kubernetes, Sentry); otherwise set it to `:no_dependency`.
#### `self.reactive_cache_worker_finder`
diff --git a/doc/development/redis.md b/doc/development/redis.md
index d205082b9c6..530a97c33a7 100644
--- a/doc/development/redis.md
+++ b/doc/development/redis.md
@@ -68,6 +68,10 @@ which is enabled for the `cache` and `shared_state`
## Redis in structured logging
+For GitLab Team Members: There are [basic](https://www.youtube.com/watch?v=Uhdj19Dc6vU) and
+[advanced](https://youtu.be/jw1Wv2IJxzs) videos that show how you can work with the Redis
+structured logging fields on GitLab.com.
+
Our [structured logging](logging.md#use-structured-json-logging) for web
requests and Sidekiq jobs contains fields for the duration, call count,
bytes written, and bytes read per Redis instance, along with a total for
@@ -96,10 +100,14 @@ requests that read the most data from the cache, we can just sort by
### The slow log
+TIP: **Tip:**
+There is a [video showing how to see the slow log](https://youtu.be/BBI68QuYRH8) (GitLab internal)
+on GitLab.com
+
On GitLab.com, entries from the [Redis
slow log](https://redis.io/commands/slowlog) are available in the
`pubsub-redis-inf-gprd*` index with the [`redis.slowlog`
-tag](https://log.gprd.gitlab.net/app/kibana#/discover?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:now-1d,to:now))&_a=(columns:!(json.type,json.command,json.exec_time),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:AWSQX_Vf93rHTYrsexmk,key:json.tag,negate:!f,params:(query:redis.slowlog),type:phrase),query:(match:(json.tag:(query:redis.slowlog,type:phrase))))),index:AWSQX_Vf93rHTYrsexmk)).
+tag](https://log.gprd.gitlab.net/app/kibana#/discover?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:now-1d,to:now))&_a=(columns:!(json.type,json.command,json.exec_time_s),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:AWSQX_Vf93rHTYrsexmk,key:json.tag,negate:!f,params:(query:redis.slowlog),type:phrase),query:(match:(json.tag:(query:redis.slowlog,type:phrase))))),index:AWSQX_Vf93rHTYrsexmk)).
This shows commands that have taken a long time and may be a performance
concern.
diff --git a/doc/development/reference_processing.md b/doc/development/reference_processing.md
index 527fb94f228..cf587043cae 100644
--- a/doc/development/reference_processing.md
+++ b/doc/development/reference_processing.md
@@ -77,6 +77,22 @@ a minimum implementation of `AbstractReferenceFilter` should define:
and an identifier, find the object. For example, this in a reference filter for
merge requests, this might be `project.merge_requests.where(iid: iid)`.
+### Add a new reference prefix and filter
+
+For reference filters for new objects, use a prefix format following the pattern
+`^<object_type>#`, because:
+
+1. Varied single-character prefixes are hard for users to track. Especially for
+ lower-use object types, this can diminish value for the feature.
+1. Suitable single-character prefixes are limited.
+1. Following a consistent pattern allows users to infer the existence of new features.
+
+To add a reference prefix for a new object `apple`,which has both a name and ID,
+format the reference as:
+
+- `^apple#123` for identification by ID.
+- `^apple#"Granny Smith"` for identification by name.
+
### Performance
#### Find object optimization
diff --git a/doc/development/secure_coding_guidelines.md b/doc/development/secure_coding_guidelines.md
index 1961d1dcc34..8e7cc1ec9f2 100644
--- a/doc/development/secure_coding_guidelines.md
+++ b/doc/development/secure_coding_guidelines.md
@@ -82,7 +82,8 @@ This Ruby Regex specialty can have security impact, as often regular expressions
#### Examples
-GitLab specific examples can be found [here](https://gitlab.com/gitlab-org/gitlab/-/issues/36029#note_251262187) and [there](https://gitlab.com/gitlab-org/gitlab/-/issues/33569).
+GitLab-specific examples can be found in the following [path traversal](https://gitlab.com/gitlab-org/gitlab/-/issues/36029#note_251262187)
+and [open redirect](https://gitlab.com/gitlab-org/gitlab/-/issues/33569) issues.
Another example would be this fictional Ruby on Rails controller:
diff --git a/doc/development/shell_scripting_guide/index.md b/doc/development/shell_scripting_guide/index.md
index 622c90d7a97..704b46c7efa 100644
--- a/doc/development/shell_scripting_guide/index.md
+++ b/doc/development/shell_scripting_guide/index.md
@@ -1,7 +1,5 @@
# Shell scripting standards and style guidelines
-## Overview
-
GitLab consists of many various services and sub-projects. The majority of
their backend code is written in [Ruby](https://www.ruby-lang.org) and
[Go](https://golang.org). However, some of them use shell scripts for
@@ -15,7 +13,7 @@ should be eventually harmonized with this guide. If there are any per-project
deviations from this guide, they should be described in the
`README.md` or `PROCESS.md` file for such a project.
-### Avoid using shell scripts
+## Avoid using shell scripts
CAUTION: **Caution:**
This is a must-read section.
diff --git a/doc/development/sql.md b/doc/development/sql.md
index 3b969c7d27a..55a8192578b 100644
--- a/doc/development/sql.md
+++ b/doc/development/sql.md
@@ -220,7 +220,7 @@ Project.select(:id, :user_id).joins(:merge_requests)
Never use ActiveRecord's `pluck` to pluck a set of values into memory only to
use them as an argument for another query. For example, this will execute an
-extra unecessary database query and load a lot of unecessary data into memory:
+extra unnecessary database query and load a lot of unnecessary data into memory:
```ruby
projects = Project.all.pluck(:id)
diff --git a/doc/development/telemetry/snowplow.md b/doc/development/telemetry/snowplow.md
index f427d7d1488..d6e3f0de2c8 100644
--- a/doc/development/telemetry/snowplow.md
+++ b/doc/development/telemetry/snowplow.md
@@ -297,6 +297,11 @@ class Projects::CreateService < BaseService
end
```
+### Unit testing
+
+Use the `expect_snowplow_event` helper when testing backend Snowplow events. See [testing best practices](
+https://docs.gitlab.com/ee/development/testing_guide/best_practices.html#test-snowplow-events) for details.
+
### Performance
We use the [AsyncEmitter](https://github.com/snowplow/snowplow/wiki/Ruby-Tracker#52-the-asyncemitter-class) when tracking events, which allows for instrumentation calls to be run in a background thread. This is still an active area of development.
diff --git a/doc/development/telemetry/usage_ping.md b/doc/development/telemetry/usage_ping.md
index ff4e7e0797b..a671ac17621 100644
--- a/doc/development/telemetry/usage_ping.md
+++ b/doc/development/telemetry/usage_ping.md
@@ -31,7 +31,7 @@ More useful links:
- The usage data is primarily composed of row counts for different tables in the instance’s database. By comparing these counts month over month (or week over week), we can get a rough sense for how an instance is using the different features within the product. In addition to counts, other facts
that help us classify and understand GitLab installations are collected.
- Usage ping is important to GitLab as we use it to calculate our Stage Monthly Active Users (SMAU) which helps us measure the success of our stages and features.
-- Once usage ping is enabled, GitLab will gather data from the other instances and will be able to show usage statistics of your instance to your users.
+- While usage ping is enabled, GitLab will gather data from the other instances and will be able to show usage statistics of your instance to your users.
### Why should we enable Usage Ping?
@@ -126,10 +126,11 @@ happened over time, such as how many CI pipelines have run. They are monotonic a
Observations are facts collected from one or more GitLab instances and can carry arbitrary data. There are no
general guidelines around how to collect those, due to the individual nature of that data.
-There are four types of counters which are all found in `usage_data.rb`:
+There are several types of counters which are all found in `usage_data.rb`:
- **Ordinary Batch Counters:** Simple count of a given ActiveRecord_Relation
- **Distinct Batch Counters:** Distinct count of a given ActiveRecord_Relation on given column
+- **Sum Batch Counters:** Sum the values of a given ActiveRecord_Relation on given column
- **Alternative Counters:** Used for settings and configurations
- **Redis Counters:** Used for in-memory counts.
@@ -200,6 +201,47 @@ distinct_count(::Note.with_suggestions.where(time_period), :author_id, start: ::
distinct_count(::Clusters::Applications::CertManager.where(time_period).available.joins(:cluster), 'clusters.user_id')
```
+### Sum Batch Counters
+
+Handles `ActiveRecord::StatementInvalid` error
+
+Sum the values of a given ActiveRecord_Relation on given column and handles errors.
+
+Method: `sum(relation, column, batch_size: nil, start: nil, finish: nil)`
+
+Arguments:
+
+- `relation` the ActiveRecord_Relation to perform the operation
+- `column` the column to sum on
+- `batch_size`: if none set it will use default value 1000 from `Gitlab::Database::BatchCounter`
+- `start`: custom start of the batch counting in order to avoid complex min calculations
+- `end`: custom end of the batch counting in order to avoid complex min calculations
+
+Examples:
+
+```ruby
+sum(JiraImportState.finished, :imported_issues_count)
+```
+
+### Grouping & Batch Operations
+
+The `count`, `distinct_count`, and `sum` batch counters can accept an `ActiveRecord::Relation`
+object, which groups by a specified column. With a grouped relation, the methods do batch counting,
+handle errors, and returns a hash table of key-value pairs.
+
+Examples:
+
+```ruby
+count(Namespace.group(:type))
+# returns => {nil=>179, "Group"=>54}
+
+distinct_count(Project.group(:visibility_level), :creator_id)
+# returns => {0=>1, 10=>1, 20=>11}
+
+sum(Issue.group(:state_id), :weight))
+# returns => {1=>3542, 2=>6820}
+```
+
### Redis Counters
Handles `::Redis::CommandError` and `Gitlab::UsageDataCounters::BaseCounter::UnknownEvent`
@@ -360,7 +402,7 @@ Implemented using Redis methods [PFADD](https://redis.io/commands/pfadd) and [PF
| `event` | string | yes | The event name it should be tracked |
Response
-
+w
Return 200 if tracking failed for any reason.
- `200` if event was tracked or any errors
@@ -368,6 +410,18 @@ Implemented using Redis methods [PFADD](https://redis.io/commands/pfadd) and [PF
- `401 Unauthorized` if user is not authenticated
- `403 Forbidden` for invalid CSRF token provided
+1. Track events using JavaScript/Vue API helper which calls the API above
+
+ Example usage for an existing event already defined in [known events](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/usage_data_counters/known_events.yml):
+
+ Note that `usage_data_api` and `usage_data_#{event_name}` should be enabled in order to be able to track events
+
+ ```javascript
+ import api from '~/api';
+
+ api.trackRedisHllUserEvent('my_already_defined_event_name'),
+ ```
+
1. Track event using base module `Gitlab::UsageDataCounters::HLLRedisCounter.track_event(entity_id, event_name)`.
Arguments:
@@ -396,15 +450,6 @@ Recommendations:
All events added in [`known_events.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/usage_data_counters/known_events.yml) are automatically added to usage data generation under the `redis_hll_counters` key. This column is stored in [version-app as a JSON](https://gitlab.com/gitlab-services/version-gitlab-com/-/blob/master/db/schema.rb#L209).
For each event we add metrics for the weekly and monthly time frames, and totals for each where applicable:
-- `#{event_name}_weekly` data for 7 days for daily [aggregation](#adding-new-events) events and data for last complete week for weekly [aggregation](#adding-new-events) events.
-- `#{event_name}_monthly` data for 28 days for daily [aggregation](#adding-new-events) events and data for last 4 complete weeks for weekly [aggregation](#adding-new-events) events.
-- `#{category}_total_unique_counts_weekly` total unique counts for events in same category for last 7 days or last complete week, if events are in the same Redis slot and if we have more than one metric.
-- `#{event_name}_weekly` - Data for 7 days for daily [aggregation](#adding-new-events) events and data for the last complete week for weekly [aggregation](#adding-new-events) events.
-- `#{event_name}_monthly` - Data for 28 days for daily [aggregation](#adding-new-events) events and data for the last 4 complete weeks for weekly [aggregation](#adding-new-events) events.
-- `#{category}_total_unique_counts_weekly` - Total unique counts for events in the same category for the last 7 days or the last complete week, if events are in the same Redis slot and we have more than one metric.
-- `#{event_name}_weekly`: Data for 7 days for daily [aggregation](#adding-new-events) events and data for last complete week for weekly [aggregation](#adding-new-events) events.
-- `#{event_name}_monthly`: Data for 28 days for daily [aggregation](#adding-new-events) events and data for last 4 complete weeks for weekly [aggregation](#adding-new-events) events.
-- `#{category}_total_unique_counts_weekly` total unique counts for events in same category for last 7 days or last complete week, if events are in the same Redis slot and if we have more than one metric.
- `#{event_name}_weekly`: Data for 7 days for daily [aggregation](#adding-new-events) events and data for the last complete week for weekly [aggregation](#adding-new-events) events.
- `#{event_name}_monthly`: Data for 28 days for daily [aggregation](#adding-new-events) events and data for the last 4 complete weeks for weekly [aggregation](#adding-new-events) events.
- `#{category}_total_unique_counts_weekly`: Total unique counts for events in the same category for the last 7 days or the last complete week, if events are in the same Redis slot and we have more than one metric.
diff --git a/doc/development/testing_guide/best_practices.md b/doc/development/testing_guide/best_practices.md
index 6ef9be381b4..53aa84cffcb 100644
--- a/doc/development/testing_guide/best_practices.md
+++ b/doc/development/testing_guide/best_practices.md
@@ -151,11 +151,14 @@ In order to reuse a single object for all calls to a named factory in implicit p
can be used:
```ruby
+RSpec.describe API::Search, factory_default: :keep do
let_it_be(:namespace) { create_default(:namespace) }
```
Then every project we create will use this `namespace`, without us having to pass
-it as `namespace: namespace`.
+it as `namespace: namespace`. In order to make it work along with `let_it_be`, `factory_default: :keep`
+must be explicitly specified. That will keep the default factory for every example in a suite instead of
+recreating it for each example.
Maybe we don't need to create 208 different projects - we
can create one and reuse it. In addition, we can see that only about 1/3 of the
@@ -497,10 +500,9 @@ let_it_be(:project, refind: true) { create(:project) }
### Time-sensitive tests
-[Timecop](https://github.com/travisjeffery/timecop) is available in our
-Ruby-based tests for verifying things that are time-sensitive. Any test that
-exercises or verifies something time-sensitive should make use of Timecop to
-prevent transient test failures.
+[`ActiveSupport::Testing::TimeHelpers`](https://api.rubyonrails.org/v6.0.3.1/classes/ActiveSupport/Testing/TimeHelpers.html)
+can be used to verify things that are time-sensitive. Any test that exercises or verifies something time-sensitive
+should make use of these helpers to prevent transient test failures.
Example:
@@ -508,7 +510,7 @@ Example:
it 'is overdue' do
issue = build(:issue, due_date: Date.tomorrow)
- Timecop.freeze(3.days.from_now) do
+ travel_to(3.days.from_now) do
expect(issue).to be_overdue
end
end
diff --git a/doc/development/testing_guide/ci.md b/doc/development/testing_guide/ci.md
index 6917639454c..8091142410c 100644
--- a/doc/development/testing_guide/ci.md
+++ b/doc/development/testing_guide/ci.md
@@ -28,9 +28,6 @@ After that, the next pipeline will use the up-to-date `knapsack/report-master.js
The GitLab test suite is [monitored](../performance.md#rspec-profiling) for the `master` branch, and any branch
that includes `rspec-profile` in their name.
-A [public dashboard](https://redash.gitlab.com/public/dashboards/l1WhHXaxrCWM5Ai9D7YDqHKehq6OU3bx5gssaiWe?org_slug=default) is available for everyone to see. Feel free to look at the
-slowest test files and try to improve them.
-
## CI setup
- Rails logging to `log/test.log` is disabled by default in CI [for
diff --git a/doc/development/testing_guide/end_to_end/best_practices.md b/doc/development/testing_guide/end_to_end/best_practices.md
index 36cb49256a6..866a949d795 100644
--- a/doc/development/testing_guide/end_to_end/best_practices.md
+++ b/doc/development/testing_guide/end_to_end/best_practices.md
@@ -310,3 +310,56 @@ end
# Using native mouse click events in the case of a mask/overlay
click_element_coordinates(:title)
```
+
+## Ensure `expect` statements wait efficiently
+
+In general, we use an `expect` statement to check that something _is_ as we expect it. For example:
+
+```ruby
+Page::Project::Pipeline::Show.perform do |pipeline|
+ expect(pipeline).to have_job("a_job")
+end
+```
+
+### Ensure `expect` checks for negation efficiently
+
+However, sometimes we want to check that something is _not_ as we _don't_ want it to be. In other
+words, we want to make sure something is absent. In such a case we should use an appropriate
+predicate method that returns quickly, rather than waiting for a state that won't appear.
+
+It's most efficient to use a predicate method that returns immediately when there is no job, or waits
+until it disappears:
+
+```ruby
+# Good
+Page::Project::Pipeline::Show.perform do |pipeline|
+ expect(pipeline).to have_no_job("a_job")
+end
+```
+
+### Problematic alternatives
+
+Alternatively, if we want to check that a job doesn't exist it might be tempting to use `not_to`:
+
+```ruby
+# Bad
+Page::Project::Pipeline::Show.perform do |pipeline|
+ expect(pipeline).not_to have_job("a_job")
+end
+```
+
+For this statement to pass, `have_job("a_job")` has to return `false` so that `not_to` can negate it.
+The problem is that `have_job("a_job")` waits up to ten seconds for `"a job"` to appear before
+returning `false`. Under the expected condition this test will take ten seconds longer than it needs to.
+
+Instead, we could force no wait:
+
+```ruby
+# Not as bad but potentially flaky
+Page::Project::Pipeline::Show.perform do |pipeline|
+ expect(pipeline).not_to have_job("a_job", wait: 0)
+end
+```
+
+The problem is that if `"a_job"` is present and we're waiting for it to disappear, this statement
+will fail.
diff --git a/doc/development/testing_guide/end_to_end/feature_flags.md b/doc/development/testing_guide/end_to_end/feature_flags.md
index 87a9738b313..e571774167d 100644
--- a/doc/development/testing_guide/end_to_end/feature_flags.md
+++ b/doc/development/testing_guide/end_to_end/feature_flags.md
@@ -1,29 +1,70 @@
# Testing with feature flags
-To run a specific test with a feature flag enabled you can use the `QA::Runtime::Feature` class to enable and disable feature flags ([via the API](../../../api/features.md)).
+To run a specific test with a feature flag enabled you can use the `QA::Runtime::Feature` class to
+enable and disable feature flags ([via the API](../../../api/features.md)).
-Note that administrator authorization is required to change feature flags. `QA::Runtime::Feature` will automatically authenticate as an administrator as long as you provide an appropriate access token via `GITLAB_QA_ADMIN_ACCESS_TOKEN` (recommended), or provide `GITLAB_ADMIN_USERNAME` and `GITLAB_ADMIN_PASSWORD`.
+Note that administrator authorization is required to change feature flags. `QA::Runtime::Feature`
+will automatically authenticate as an administrator as long as you provide an appropriate access
+token via `GITLAB_QA_ADMIN_ACCESS_TOKEN` (recommended), or provide `GITLAB_ADMIN_USERNAME`
+and `GITLAB_ADMIN_PASSWORD`.
-Please be sure to include the tag `:requires_admin` so that the test can be skipped in environments where admin access is not available.
+Please be sure to include the tag `:requires_admin` so that the test can be skipped in environments
+where admin access is not available.
+
+CAUTION: **Caution:**
+You are strongly advised to [enable feature flags only for a group, project, user](../../feature_flags/development.md#feature-actors),
+or [feature group](../../feature_flags/development.md#feature-groups). This makes it possible to
+test a feature in a shared environment without affecting other users.
+
+For example, the code below would enable a feature flag named `:feature_flag_name` for the project
+created by the test:
```ruby
RSpec.describe "with feature flag enabled", :requires_admin do
+ let(:project) { Resource::Project.fabricate_via_api! }
+
before do
- Runtime::Feature.enable('feature_flag_name')
+ Runtime::Feature.enable(:feature_flag_name, project: project)
end
it "feature flag test" do
- # Execute a test with a feature flag enabled
+ # Execute the test with the feature flag enabled.
+ # It will only affect the project created in this test.
end
after do
- Runtime::Feature.disable('feature_flag_name')
+ Runtime::Feature.disable(:feature_flag_name, project: project)
end
end
```
+Note that the `enable` and `disable` methods first set the flag and then check that the updated
+value is returned by the API.
+
+Similarly, you can enable a feature for a group, user, or feature group:
+
+```ruby
+group = Resource::Group.fabricate_via_api!
+Runtime::Feature.enable(:feature_flag_name, group: group)
+
+user = Resource::User.fabricate_via_api!
+Runtime::Feature.enable(:feature_flag_name, user: user)
+
+feature_group = "a_feature_group"
+Runtime::Feature.enable(:feature_flag_name, feature_group: feature_group)
+```
+
+If no scope is provided, the feature flag will be set instance-wide:
+
+```ruby
+# This will affect all users!
+Runtime::Feature.enable(:feature_flag_name)
+```
+
## Running a scenario with a feature flag enabled
-It's also possible to run an entire scenario with a feature flag enabled, without having to edit existing tests or write new ones.
+It's also possible to run an entire scenario with a feature flag enabled, without having to edit
+existing tests or write new ones.
-Please see the [QA README](https://gitlab.com/gitlab-org/gitlab/tree/master/qa#running-tests-with-a-feature-flag-enabled) for details.
+Please see the [QA README](https://gitlab.com/gitlab-org/gitlab/tree/master/qa#running-tests-with-a-feature-flag-enabled)
+for details.
diff --git a/doc/gitlab-basics/README.md b/doc/gitlab-basics/README.md
index a7f18e13f74..3850655aaed 100644
--- a/doc/gitlab-basics/README.md
+++ b/doc/gitlab-basics/README.md
@@ -31,7 +31,7 @@ The following are guides to basic GitLab functionality:
- [Add a file](add-file.md), to add new files to a project's repository.
- [Create an issue](../user/project/issues/managing_issues.md#create-a-new-issue),
to start collaborating within a project.
-- [Create a merge request](add-merge-request.md), to request changes made in a branch
+- [Create a merge request](../user/project/merge_requests/creating_merge_requests.md), to request changes made in a branch
be merged into a project's repository.
- See how these features come together in the [GitLab Flow introduction video](https://youtu.be/InKNIvky2KE)
and [GitLab Flow page](../topics/gitlab_flow.md).
diff --git a/doc/gitlab-basics/create-branch.md b/doc/gitlab-basics/create-branch.md
index 0ad5cb53e97..13f20869b51 100644
--- a/doc/gitlab-basics/create-branch.md
+++ b/doc/gitlab-basics/create-branch.md
@@ -9,11 +9,11 @@ type: howto
A branch is an independent line of development in a [project](../user/project/index.md).
-When you create a new branch (in your [terminal](basic-git-commands.md) or with
+When you create a new branch (in your [terminal](start-using-git.md) or with
[the web interface](../user/project/repository/web_editor.md#create-a-new-branch)),
you are creating a snapshot of a certain branch, usually the main `master` branch,
at it's current state. From there, you can start to make your own changes without
affecting the main codebase. The history of your changes will be tracked in your branch.
When your changes are ready, you then merge them into the rest of the codebase with a
-[merge request](add-merge-request.md).
+[merge request](../user/project/merge_requests/creating_merge_requests.md).
diff --git a/doc/gitlab-basics/create-project.md b/doc/gitlab-basics/create-project.md
index f411ac769c0..c8db1664b89 100644
--- a/doc/gitlab-basics/create-project.md
+++ b/doc/gitlab-basics/create-project.md
@@ -20,7 +20,7 @@ To create a project in GitLab:
- Create a [blank project](#blank-projects).
- Create a project using with one of the available [project templates](#project-templates).
- [Import a project](../user/project/import/index.md) from a different repository,
- if enabled on your GitLab instance. Contact your GitLab admin if this is unavailable.
+ if enabled on your GitLab instance. Contact your GitLab administrator if this is unavailable.
- Run [CI/CD pipelines for external repositories](../ci/ci_cd_for_external_repos/index.md). **(PREMIUM)**
NOTE: **Note:**
@@ -129,7 +129,7 @@ To use a custom project template on the **New project** page:
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/26388) in GitLab 10.5.
When you create a new repository locally, instead of going to GitLab to manually
-create a new project and then [clone the repo](start-using-git.md#clone-a-repository)
+create a new project and then [clone the repository](start-using-git.md#clone-a-repository)
locally, you can directly push it to GitLab to create the new project, all without leaving
your terminal. If you have access rights to the associated namespace, GitLab will
automatically create a new project under that GitLab namespace with its visibility
diff --git a/doc/gitlab-geo/after_setup.md b/doc/gitlab-geo/after_setup.md
deleted file mode 100644
index c8a7b9d1096..00000000000
--- a/doc/gitlab-geo/after_setup.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/geo/replication/using_a_geo_server.md'
----
-
-This document was moved to [another location](../administration/geo/replication/using_a_geo_server.md).
diff --git a/doc/gitlab-geo/bring-primary-back.md b/doc/gitlab-geo/bring-primary-back.md
deleted file mode 100644
index 8c43f4d805f..00000000000
--- a/doc/gitlab-geo/bring-primary-back.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/geo/disaster_recovery/bring_primary_back.md'
----
-
-This document was moved to [another location](../administration/geo/disaster_recovery/bring_primary_back.md).
diff --git a/doc/gitlab-geo/configuration.md b/doc/gitlab-geo/configuration.md
deleted file mode 100644
index b46a2caea4a..00000000000
--- a/doc/gitlab-geo/configuration.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/geo/replication/configuration.md'
----
-
-This document was moved to [another location](../administration/geo/replication/configuration.md).
diff --git a/doc/gitlab-geo/configuration_source.md b/doc/gitlab-geo/configuration_source.md
deleted file mode 100644
index b46a2caea4a..00000000000
--- a/doc/gitlab-geo/configuration_source.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/geo/replication/configuration.md'
----
-
-This document was moved to [another location](../administration/geo/replication/configuration.md).
diff --git a/doc/gitlab-geo/database.md b/doc/gitlab-geo/database.md
deleted file mode 100644
index 2f68068d95b..00000000000
--- a/doc/gitlab-geo/database.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/geo/setup/database.md'
----
-
-This document was moved to [another location](../administration/geo/setup/database.md).
diff --git a/doc/gitlab-geo/database_source.md b/doc/gitlab-geo/database_source.md
deleted file mode 100644
index 2f68068d95b..00000000000
--- a/doc/gitlab-geo/database_source.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/geo/setup/database.md'
----
-
-This document was moved to [another location](../administration/geo/setup/database.md).
diff --git a/doc/gitlab-geo/disaster-recovery.md b/doc/gitlab-geo/disaster-recovery.md
deleted file mode 100644
index d42e815a879..00000000000
--- a/doc/gitlab-geo/disaster-recovery.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/geo/disaster_recovery/index.md'
----
-
-This document was moved to [another location](../administration/geo/disaster_recovery/index.md).
diff --git a/doc/gitlab-geo/docker_registry.md b/doc/gitlab-geo/docker_registry.md
deleted file mode 100644
index 26a708f6845..00000000000
--- a/doc/gitlab-geo/docker_registry.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/geo/replication/docker_registry.md'
----
-
-This document was moved to [another location](../administration/geo/replication/docker_registry.md).
diff --git a/doc/gitlab-geo/faq.md b/doc/gitlab-geo/faq.md
deleted file mode 100644
index f1952bc7e4c..00000000000
--- a/doc/gitlab-geo/faq.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/geo/replication/faq.md'
----
-
-This document was moved to [another location](../administration/geo/replication/faq.md).
diff --git a/doc/gitlab-geo/ha.md b/doc/gitlab-geo/ha.md
deleted file mode 100644
index 0be70791d45..00000000000
--- a/doc/gitlab-geo/ha.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/geo/replication/multiple_servers.md'
----
-
-This document was moved to [another location](../administration/geo/replication/multiple_servers.md).
diff --git a/doc/gitlab-geo/object_storage.md b/doc/gitlab-geo/object_storage.md
deleted file mode 100644
index 1f29b7b7e8c..00000000000
--- a/doc/gitlab-geo/object_storage.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/geo/replication/object_storage.md'
----
-
-This document was moved to [another location](../administration/geo/replication/object_storage.md).
diff --git a/doc/gitlab-geo/planned-failover.md b/doc/gitlab-geo/planned-failover.md
deleted file mode 100644
index 720b6bc9424..00000000000
--- a/doc/gitlab-geo/planned-failover.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/geo/disaster_recovery/planned_failover.md'
----
-
-This document was moved to [another location](../administration/geo/disaster_recovery/planned_failover.md).
diff --git a/doc/gitlab-geo/security-review.md b/doc/gitlab-geo/security-review.md
deleted file mode 100644
index a0a5b0e536c..00000000000
--- a/doc/gitlab-geo/security-review.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/geo/replication/security_review.md'
----
-
-This document was moved to [another location](../administration/geo/replication/security_review.md).
diff --git a/doc/gitlab-geo/ssh.md b/doc/gitlab-geo/ssh.md
deleted file mode 100644
index 4f8db687850..00000000000
--- a/doc/gitlab-geo/ssh.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/operations/fast_ssh_key_lookup.md'
----
-
-This document was moved to [another location](../administration/operations/fast_ssh_key_lookup.md).
diff --git a/doc/gitlab-geo/troubleshooting.md b/doc/gitlab-geo/troubleshooting.md
deleted file mode 100644
index 25fe1372c69..00000000000
--- a/doc/gitlab-geo/troubleshooting.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/geo/replication/troubleshooting.md'
----
-
-This document was moved to [another location](../administration/geo/replication/troubleshooting.md).
diff --git a/doc/gitlab-geo/tuning.md b/doc/gitlab-geo/tuning.md
deleted file mode 100644
index 84ac40f99db..00000000000
--- a/doc/gitlab-geo/tuning.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/geo/replication/tuning.md'
----
-
-This document was moved to [another location](../administration/geo/replication/tuning.md).
diff --git a/doc/gitlab-geo/updating_the_geo_nodes.md b/doc/gitlab-geo/updating_the_geo_nodes.md
deleted file mode 100644
index 28234ec02ed..00000000000
--- a/doc/gitlab-geo/updating_the_geo_nodes.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/geo/replication/updating_the_geo_nodes.md'
----
-
-This document was moved to [another location](../administration/geo/replication/updating_the_geo_nodes.md).
diff --git a/doc/gitlab-geo/using_a_geo_server.md b/doc/gitlab-geo/using_a_geo_server.md
deleted file mode 100644
index c8a7b9d1096..00000000000
--- a/doc/gitlab-geo/using_a_geo_server.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: '../administration/geo/replication/using_a_geo_server.md'
----
-
-This document was moved to [another location](../administration/geo/replication/using_a_geo_server.md).
diff --git a/doc/install/README.md b/doc/install/README.md
index f1ef368685e..1f9ccc8115d 100644
--- a/doc/install/README.md
+++ b/doc/install/README.md
@@ -1,4 +1,7 @@
---
+stage: Enablement
+group: Distribution
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
comments: false
description: Read through the GitLab installation methods.
type: index
@@ -68,7 +71,7 @@ GitLab maintains a set of official Docker images based on the Omnibus GitLab pac
If the Omnibus GitLab package is not available in your distribution, you can
install GitLab from source: Useful for unsupported systems like \*BSD. For an
-overview of the directory structure, read the [structure documentation](structure.md).
+overview of the directory structure, read the [structure documentation](installation.md#gitlab-directory-structure).
[**> Install GitLab from source.**](installation.md)
diff --git a/doc/install/aws/index.md b/doc/install/aws/index.md
index aba76ecf50e..bad01175363 100644
--- a/doc/install/aws/index.md
+++ b/doc/install/aws/index.md
@@ -1,4 +1,7 @@
---
+stage: Enablement
+group: Distribution
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: howto
---
diff --git a/doc/install/azure/index.md b/doc/install/azure/index.md
index b6e3025a0e0..b5e72a9b84a 100644
--- a/doc/install/azure/index.md
+++ b/doc/install/azure/index.md
@@ -422,7 +422,7 @@ on any cloud service you choose.
## Where to next?
-Check out our other [Technical Articles](../../articles/index.md) or browse the [GitLab Documentation](../../README.md) to learn more about GitLab.
+Check out our other [Technical Articles](../../topics/index.md) or browse the [GitLab Documentation](../../README.md) to learn more about GitLab.
### Useful links
diff --git a/doc/install/docker.md b/doc/install/docker.md
index c2d7655d526..ca780caa563 100644
--- a/doc/install/docker.md
+++ b/doc/install/docker.md
@@ -1,4 +1,7 @@
---
+stage: Enablement
+group: Distribution
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: index
---
diff --git a/doc/install/google_cloud_platform/index.md b/doc/install/google_cloud_platform/index.md
index 8ca5c5c266a..da2b30df476 100644
--- a/doc/install/google_cloud_platform/index.md
+++ b/doc/install/google_cloud_platform/index.md
@@ -1,4 +1,7 @@
---
+stage: Enablement
+group: Distribution
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
description: 'Learn how to install a GitLab instance on Google Cloud Platform.'
type: howto
---
diff --git a/doc/install/installation.md b/doc/install/installation.md
index e2c77073983..bfdf797cc77 100644
--- a/doc/install/installation.md
+++ b/doc/install/installation.md
@@ -1,4 +1,7 @@
---
+stage: Enablement
+group: Distribution
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: howto
---
diff --git a/doc/install/openshift_and_gitlab/index.md b/doc/install/openshift_and_gitlab/index.md
index 022e2095c68..7ac5aa6667a 100644
--- a/doc/install/openshift_and_gitlab/index.md
+++ b/doc/install/openshift_and_gitlab/index.md
@@ -1,6 +1,8 @@
---
+stage: Enablement
+group: Distribution
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: howto
-date: 2016-06-28
---
# How to install GitLab on OpenShift Origin 3
@@ -372,7 +374,7 @@ running scaled to 2.
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](../../administration/high_availability/gitlab.md) for the application servers.
+OpenShift by following the [HA documentation](../../administration/reference_architectures/index.md) 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
diff --git a/doc/install/relative_url.md b/doc/install/relative_url.md
index 09069a2d1fd..82cf134f968 100644
--- a/doc/install/relative_url.md
+++ b/doc/install/relative_url.md
@@ -1,4 +1,7 @@
---
+stage: Enablement
+group: Distribution
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: reference
---
diff --git a/doc/install/requirements.md b/doc/install/requirements.md
index da0128fecc3..bc320bcd335 100644
--- a/doc/install/requirements.md
+++ b/doc/install/requirements.md
@@ -1,4 +1,7 @@
---
+stage: Enablement
+group: Distribution
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: reference
---
diff --git a/doc/install/structure.md b/doc/install/structure.md
index 87ef11c60fe..ca90a3de1b8 100644
--- a/doc/install/structure.md
+++ b/doc/install/structure.md
@@ -2,4 +2,4 @@
redirect_to: installation.md#gitlab-directory-structure
---
-This page was moved to [another location](#installation.md#gitlab-directory-structure).
+This page was moved to [another location](installation.md#gitlab-directory-structure).
diff --git a/doc/integration/README.md b/doc/integration/README.md
index c5c21644d1c..c22c383410f 100644
--- a/doc/integration/README.md
+++ b/doc/integration/README.md
@@ -26,7 +26,7 @@ GitLab can be configured to authenticate access requests with the following auth
- Enable sign in with [Bitbucket](bitbucket.md) accounts.
- Configure GitLab to sign in using [CAS](cas.md).
- Integrate with [Kerberos](kerberos.md).
-- Enable sign in via [LDAP](ldap.md).
+- Enable sign in via [LDAP](../administration/auth/ldap/index.md).
- Enable [OAuth2 provider](oauth_provider.md) application creation.
- Use [OmniAuth](omniauth.md) to enable sign in via Twitter, GitHub, GitLab.com, Google,
Bitbucket, Facebook, Shibboleth, SAML, Crowd, Azure or Authentiq ID.
diff --git a/doc/integration/cas.md b/doc/integration/cas.md
index cf4e501e772..eee801350eb 100644
--- a/doc/integration/cas.md
+++ b/doc/integration/cas.md
@@ -45,10 +45,10 @@ To enable the CAS OmniAuth provider you must register your application with your
- { name: 'cas3',
label: 'cas',
args: {
- url: 'CAS_SERVER',
- login_url: '/CAS_PATH/login',
- service_validate_url: '/CAS_PATH/p3/serviceValidate',
- logout_url: '/CAS_PATH/logout'} }
+ url: 'CAS_SERVER',
+ login_url: '/CAS_PATH/login',
+ service_validate_url: '/CAS_PATH/p3/serviceValidate',
+ logout_url: '/CAS_PATH/logout' } }
```
1. Change 'CAS_PATH' to the root of your CAS instance (ie. `cas`).
diff --git a/doc/integration/elasticsearch.md b/doc/integration/elasticsearch.md
index f40955ad8ff..54375db1831 100644
--- a/doc/integration/elasticsearch.md
+++ b/doc/integration/elasticsearch.md
@@ -10,8 +10,8 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/109 "Elasticsearch Merge Request") in GitLab [Starter](https://about.gitlab.com/pricing/) 8.4.
> - Support for [Amazon Elasticsearch](https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-gsg.html) was [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1305) in GitLab [Starter](https://about.gitlab.com/pricing/) 9.0.
-This document describes how to set up Elasticsearch with GitLab. After
-Elasticsearch is enabled, you'll have the benefit of fast search response times
+This document describes how to enable Advanced Search. After
+Advanced Search is enabled, you'll have the benefit of fast search response times
and the advantage of the following special searches:
- [Advanced Search](../user/search/advanced_global_search.md)
@@ -80,7 +80,7 @@ replicas can not be as there is no other node to which Elasticsearch can assign
replica.
After the data is added to the database or repository and [Elasticsearch is
-enabled in the Admin Area](#enabling-elasticsearch) the search index will be
+enabled in the Admin Area](#enabling-advanced-search) the search index will be
updated automatically.
## Elasticsearch repository indexer
@@ -155,15 +155,20 @@ Example:
PREFIX=/usr sudo -E make install
```
-After installation, be sure to [enable Elasticsearch](#enabling-elasticsearch).
+After installation, be sure to [enable Elasticsearch](#enabling-advanced-search).
-## Enabling Elasticsearch
+NOTE: **Note:**
+If you see an error such as `Permission denied - /home/git/gitlab-elasticsearch-indexer/` while indexing, you
+may need to set the `production -> elasticsearch -> indexer_path` setting in your `gitlab.yml` file to
+`/usr/local/bin/gitlab-elasticsearch-indexer`, which is where the binary is installed.
+
+## Enabling Advanced Search
NOTE: **Note:**
For GitLab instances with more than 50GB repository data you can follow the instructions for [Indexing large
instances](#indexing-large-instances) below.
-To enable Elasticsearch, you need to have admin access to GitLab:
+To enable Advanced Search, you need to have admin access to GitLab:
1. Navigate to **Admin Area** (wrench icon), then **Settings > General**
and expand the **Advanced Search** section.
@@ -172,7 +177,7 @@ To enable Elasticsearch, you need to have admin access to GitLab:
To see the Advanced Search section, you need an active Starter
[license](../user/admin_area/license.md).
-1. Configure the [Elasticsearch settings](#elasticsearch-configuration) for
+1. Configure the [Advanced Search settings](#advanced-search-configuration) for
your Elasticsearch cluster. Do not enable **Elasticsearch indexing** or
**Search with Elasticsearch enabled** yet.
1. Click **Save changes** for the changes to take effect.
@@ -206,7 +211,7 @@ To enable Elasticsearch, you need to have admin access to GitLab:
**Admin Area > Settings > General > Advanced Search** and click **Save
changes**.
-### Elasticsearch configuration
+### Advanced Search configuration
The following Elasticsearch settings are available:
@@ -238,14 +243,14 @@ If you select `Limit namespaces and projects that can be indexed`, more options
You can select namespaces and projects to index exclusively. Note that if the namespace is a group it will include
any sub-groups and projects belonging to those sub-groups to be indexed as well.
-Elasticsearch only provides cross-group code/commit search (global) if all name-spaces are indexed. In this particular scenario where only a subset of namespaces are indexed, a global search will not provide a code or commit scope. This will be possible only in the scope of an indexed namespace. Currently there is no way to code/commit search in multiple indexed namespaces (when only a subset of namespaces has been indexed). For example if two groups are indexed, there is no way to run a single code search on both. You can only run a code search on the first group and then on the second.
+Advanced Search only provides cross-group code/commit search (global) if all name-spaces are indexed. In this particular scenario where only a subset of namespaces are indexed, a global search will not provide a code or commit scope. This will be possible only in the scope of an indexed namespace. Currently there is no way to code/commit search in multiple indexed namespaces (when only a subset of namespaces has been indexed). For example if two groups are indexed, there is no way to run a single code search on both. You can only run a code search on the first group and then on the second.
You can filter the selection dropdown by writing part of the namespace or project name you're interested in.
![limit namespace filter](img/limit_namespace_filter.png)
NOTE: **Note:**
-If no namespaces or projects are selected, no Elasticsearch indexing will take place.
+If no namespaces or projects are selected, no Advanced Search indexing will take place.
CAUTION: **Warning:**
If you have already indexed your instance, you will have to regenerate the index in order to delete all existing data
@@ -253,7 +258,7 @@ for filtering to work correctly. To do this run the Rake tasks `gitlab:elastic:r
`gitlab:elastic:clear_index_status`. Afterwards, removing a namespace or a project from the list will delete the data
from the Elasticsearch index as expected.
-## Disabling Elasticsearch
+## Disabling Advanced Search
To disable the Elasticsearch integration:
@@ -283,7 +288,7 @@ alias such as we can change the underlying index at will.
NOTE: **Note:**
Any index attached to the production alias is deemed a `primary` and will be
-used by the GitLab Elasticsearch integration.
+used by the GitLab Advanced Search integration.
### Pause the indexing
@@ -421,12 +426,12 @@ After the reindexing is completed, the original index will be scheduled to be de
While the reindexing is running, you will be able to follow its progress under that same section.
-## GitLab Elasticsearch Rake tasks
+## GitLab Advanced Search Rake tasks
Rake tasks are available to:
- [Build and install](#building-and-installing) the indexer.
-- Delete indexes when [disabling Elasticsearch](#disabling-elasticsearch).
+- Delete indexes when [disabling Elasticsearch](#disabling-advanced-search).
- Add GitLab data to an index.
The following are some available Rake tasks:
@@ -467,7 +472,7 @@ Indexing project repositories...I, [2019-03-04T21:27:03.083410 #3384] INFO -- :
I, [2019-03-04T21:27:05.215266 #3384] INFO -- : Indexing GitLab User / test (ID=33) is done!
```
-## Elasticsearch index scopes
+## Advanced Search index scopes
When performing a search, the GitLab index will use the following scopes:
@@ -499,7 +504,7 @@ For basic guidance on choosing a cluster configuration you may refer to [Elastic
- `refresh_interval` is a per index setting. You may want to adjust that from default `1s` to a bigger value if you don't need data in realtime. This will change how soon you will see fresh results. If that's important for you, you should leave it as close as possible to the default value.
- You might want to raise [`indices.memory.index_buffer_size`](https://www.elastic.co/guide/en/elasticsearch/reference/current/indexing-buffer.html) to 30% or 40% if you have a lot of heavy indexing operations.
-### Elasticsearch integration settings guidance
+### Advanced Search integration settings guidance
- The `Number of Elasticsearch shards` setting usually corresponds with the number of CPUs available in your cluster. For example, if you have a 3-node cluster with 4 cores each, this means you will benefit from having at least 3*4=12 shards in the cluster. Please note, it's only possible to change the shards number by using [Split index API](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-split-index.html) or by reindexing to a different index with a changed number of shards.
- The `Number of Elasticsearch replicas` setting should most of the time be equal to `1` (each shard will have 1 replica). Using `0` is not recommended, because losing one node will corrupt the index.
@@ -507,7 +512,7 @@ For basic guidance on choosing a cluster configuration you may refer to [Elastic
### Indexing large instances
This section may be helpful in the event that the other
-[basic instructions](#enabling-elasticsearch) cause problems
+[basic instructions](#enabling-advanced-search) cause problems
due to large volumes of data being indexed.
CAUTION: **Warning:**
@@ -516,7 +521,7 @@ Make sure to prepare for this task by having a [Scalable and Highly Available
Setup](../administration/reference_architectures/index.md) or creating [extra
Sidekiq processes](../administration/operations/extra_sidekiq_processes.md).
-1. [Configure your Elasticsearch host and port](#enabling-elasticsearch).
+1. [Configure your Elasticsearch host and port](#enabling-advanced-search).
1. Create empty indexes:
```shell
@@ -537,7 +542,7 @@ Sidekiq processes](../administration/operations/extra_sidekiq_processes.md).
bundle exec rake gitlab:elastic:clear_index_status RAILS_ENV=production
```
-1. [Enable **Elasticsearch indexing**](#enabling-elasticsearch).
+1. [Enable **Elasticsearch indexing**](#enabling-advanced-search).
1. Indexing large Git repositories can take a while. To speed up the process, you can [tune for indexing speed](https://www.elastic.co/guide/en/elasticsearch/reference/current/tune-for-indexing-speed.html#tune-for-indexing-speed):
- You can temporarily disable [`refresh`](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-refresh.html), the operation responsible for making changes to an index available to search.
@@ -664,7 +669,7 @@ Sidekiq processes](../administration/operations/extra_sidekiq_processes.md).
} }'
```
-1. After the indexing has completed, enable [**Search with Elasticsearch enabled**](#enabling-elasticsearch).
+1. After the indexing has completed, enable [**Search with Elasticsearch enabled**](#enabling-advanced-search).
### Deleted documents
@@ -698,96 +703,114 @@ However, some larger installations may wish to tune the merge policy settings:
## Troubleshooting
-### Common issues
+Here are some common pitfalls and how to overcome them.
+
+### How can I verify that my GitLab instance is using Elasticsearch?
+
+There are a couple of ways to achieve that:
-Here are some common pitfalls and how to overcome them:
+- Whenever you perform a search there will be a link on the search results page
+ in the top right hand corner saying "Advanced search functionality is enabled".
+ This is always correctly identifying whether the current project/namespace
+ being searched is using Elasticsearch.
-- **How can I verify my GitLab instance is using Elasticsearch?**
+- From the admin area under **Settings > General > Elasticsearch** check that the
+ Advanced Search settings are checked.
- The easiest method is via the rails console (`sudo gitlab-rails console`) by running the following:
+ Those same settings there can be obtained from the Rails console if necessary:
```ruby
- u = User.find_by_username('your-username')
- s = SearchService.new(u, {:search => 'search_term'})
- pp s.search_objects.class.name
+ ::Gitlab::CurrentSettings.elasticsearch_search? # Whether or not searches will use Elasticsearch
+ ::Gitlab::CurrentSettings.elasticsearch_indexing? # Whether or not content will be indexed in Elasticsearch
+ ::Gitlab::CurrentSettings.elasticsearch_limit_indexing? # Whether or not Elasticsearch is limited only to certain projects/namespaces
```
- If you see `"ActiveRecord::Relation"`, you are **not** using Elasticsearch.
-
- If you see `"Kaminari::PaginatableArray"` you are using Elasticsearch.
+- If Elasticsearch is limited to specific namespaces and you need to know if
+ Elasticsearch is being used for a specific project or namespace, you can use
+ the Rails console:
- NOTE: **Note:**
- The above instructions are used to verify that GitLab is using Elasticsearch only when indexing all namespaces. This is not to be used for scenarios that only index a [subset of namespaces](#limiting-namespaces-and-projects).
+ ```ruby
+ ::Gitlab::CurrentSettings.search_using_elasticsearch?(scope: Namespace.find_by_full_path("/my-namespace"))
+ ::Gitlab::CurrentSettings.search_using_elasticsearch?(scope: Project.find_by_full_path("/my-namespace/my-project"))
+ ```
-- **I updated GitLab and now I can't find anything**
+### I updated GitLab and now I can't find anything
- We continuously make updates to our indexing strategies and aim to support
- newer versions of Elasticsearch. When indexing changes are made, it may
- be necessary for you to [reindex](#zero-downtime-reindexing) after updating GitLab.
+We continuously make updates to our indexing strategies and aim to support
+newer versions of Elasticsearch. When indexing changes are made, it may
+be necessary for you to [reindex](#zero-downtime-reindexing) after updating GitLab.
-- **I indexed all the repositories but I can't find anything**
+### I indexed all the repositories but I can't get any hits for my search term in the UI
- Make sure you indexed all the database data [as stated above](#enabling-elasticsearch).
+Make sure you indexed all the database data [as stated above](#enabling-advanced-search).
- Beyond that, check via the [Elasticsearch Search API](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html) to see if the data shows up on the Elasticsearch side.
+If there aren't any results (hits) in the UI search, check if you are seeing the same results via the rails console (`sudo gitlab-rails console`):
- If it shows up via the [Elasticsearch Search API](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html), check that it shows up via the rails console (`sudo gitlab-rails console`):
+```ruby
+u = User.find_by_username('your-username')
+s = SearchService.new(u, {:search => 'search_term', :scope => 'blobs'})
+pp s.search_objects.to_a
+```
- ```ruby
- u = User.find_by_username('your-username')
- s = SearchService.new(u, {:search => 'search_term', :scope => 'blobs'})
- pp s.search_objects.to_a
- ```
+Beyond that, check via the [Elasticsearch Search API](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html) to see if the data shows up on the Elasticsearch side:
- NOTE: **Note:**
- The above instructions are not to be used for scenarios that only index a [subset of namespaces](#limiting-namespaces-and-projects).
+```shell
+curl --request GET <elasticsearch_server_ip>:9200/gitlab-production/_search?q=<search_term>
+```
- See [Elasticsearch Index Scopes](#elasticsearch-index-scopes) for more information on searching for specific types of data.
+More [complex Elasticsearch API calls](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-filter-context.html) are also possible.
-- **I indexed all the repositories but then switched Elasticsearch servers and now I can't find anything**
+It is important to understand at which level the problem is manifesting (UI, Rails code, Elasticsearch side) to be able to [troubleshoot further](../administration/troubleshooting/elasticsearch.md#search-results-workflow).
- You will need to re-run all the Rake tasks to reindex the database, repositories, and wikis.
+NOTE: **Note:**
+The above instructions are not to be used for scenarios that only index a [subset of namespaces](#limiting-namespaces-and-projects).
-- **The indexing process is taking a very long time**
+See [Elasticsearch Index Scopes](#advanced-search-index-scopes) for more information on searching for specific types of data.
- The more data present in your GitLab instance, the longer the indexing process takes.
+### I indexed all the repositories but then switched Elasticsearch servers and now I can't find anything
-- **There are some projects that weren't indexed, but we don't know which ones**
+You will need to re-run all the Rake tasks to reindex the database, repositories, and wikis.
- You can run `sudo gitlab-rake gitlab:elastic:projects_not_indexed` to display projects that aren't indexed.
+### The indexing process is taking a very long time
-- **No new data is added to the Elasticsearch index when I push code**
+The more data present in your GitLab instance, the longer the indexing process takes.
- NOTE: **Note:**
- This was [fixed in GitLab 13.2](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35936) and the Rake task is not available for versions greater than that.
+### There are some projects that weren't indexed, but I don't know which ones
- When performing the initial indexing of blobs, we lock all projects until the project finishes indexing. It could happen that an error during the process causes one or multiple projects to remain locked. In order to unlock them, run:
+You can run `sudo gitlab-rake gitlab:elastic:projects_not_indexed` to display projects that aren't indexed.
- ```shell
- sudo gitlab-rake gitlab:elastic:clear_locked_projects
- ```
+### No new data is added to the Elasticsearch index when I push code
-- **"Can't specify parent if no parent field has been configured"**
+NOTE: **Note:**
+This was [fixed in GitLab 13.2](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35936) and the Rake task is not available for versions greater than that.
- If you enabled Elasticsearch before GitLab 8.12 and have not rebuilt indexes you will get
- exception in lots of different cases:
+When performing the initial indexing of blobs, we lock all projects until the project finishes indexing. It could happen that an error during the process causes one or multiple projects to remain locked. In order to unlock them, run:
- ```plaintext
- Elasticsearch::Transport::Transport::Errors::BadRequest([400] {
- "error": {
- "root_cause": [{
- "type": "illegal_argument_exception",
- "reason": "Can't specify parent if no parent field has been configured"
- }],
- "type": "illegal_argument_exception",
- "reason": "Can't specify parent if no parent field has been configured"
- },
- "status": 400
- }):
- ```
+```shell
+sudo gitlab-rake gitlab:elastic:clear_locked_projects
+```
+
+### `Can't specify parent if no parent field has been configured` error
+
+If you enabled Elasticsearch before GitLab 8.12 and have not rebuilt indexes you will get
+exception in lots of different cases:
+
+```plaintext
+Elasticsearch::Transport::Transport::Errors::BadRequest([400] {
+ "error": {
+ "root_cause": [{
+ "type": "illegal_argument_exception",
+ "reason": "Can't specify parent if no parent field has been configured"
+ }],
+ "type": "illegal_argument_exception",
+ "reason": "Can't specify parent if no parent field has been configured"
+ },
+ "status": 400
+}):
+```
- This is because we changed the index mapping in GitLab 8.12 and the old indexes should be removed and built from scratch again,
- see details in the [update guide](../update/upgrading_from_source.md).
+This is because we changed the index mapping in GitLab 8.12 and the old indexes should be removed and built from scratch again,
+see details in the [update guide](../update/upgrading_from_source.md).
- Exception `Elasticsearch::Transport::Transport::Errors::BadRequest`
@@ -809,50 +832,51 @@ Here are some common pitfalls and how to overcome them:
for this setting ("Maximum Size of HTTP Request Payloads"), based on the size of
the underlying instance.
-- **My single node Elasticsearch cluster status never goes from `yellow` to `green` even though everything seems to be running properly**
+### My single node Elasticsearch cluster status never goes from `yellow` to `green` even though everything seems to be running properly
- **For a single node Elasticsearch cluster the functional cluster health status will be yellow** (will never be green) because the primary shard is allocated but replicas can not be as there is no other node to which Elasticsearch can assign a replica. This also applies if you are using the
-[Amazon Elasticsearch](https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/aes-handling-errors.html#aes-handling-errors-yellow-cluster-status) service.
+**For a single node Elasticsearch cluster the functional cluster health status will be yellow** (never green) because the primary shard is allocated but replicas cannot be as there is no other node to which Elasticsearch can assign a replica. This also applies if you are using the [Amazon Elasticsearch](https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/aes-handling-errors.html#aes-handling-errors-yellow-cluster-status) service.
- CAUTION: **Warning:**
- Setting the number of replicas to `0` is not something that we recommend (this is not allowed in the GitLab Elasticsearch Integration menu). If you are planning to add more Elasticsearch nodes (for a total of more than 1 Elasticsearch) the number of replicas will need to be set to an integer value larger than `0`. Failure to do so will result in lack of redundancy (losing one node will corrupt the index).
+CAUTION: **Warning:**
+Setting the number of replicas to `0` is discouraged (this is not allowed in the GitLab Elasticsearch Integration menu). If you are planning to add more Elasticsearch nodes (for a total of more than 1 Elasticsearch) the number of replicas will need to be set to an integer value larger than `0`. Failure to do so will result in lack of redundancy (losing one node will corrupt the index).
- If you have a **hard requirement to have a green status for your single node Elasticsearch cluster**, please make sure you understand the risks outlined in the previous paragraph and then simply run the following query to set the number of replicas to `0`(the cluster will no longer try to create any shard replicas):
+If you have a **hard requirement to have a green status for your single node Elasticsearch cluster**, please make sure you understand the risks outlined in the previous paragraph and then run the following query to set the number of replicas to `0`(the cluster will no longer try to create any shard replicas):
- ```shell
- curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{
- "index" : {
- "number_of_replicas" : 0
- }
- }'
- ```
+```shell
+curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{
+"index" : {
+ "number_of_replicas" : 0
+ }
+}'
+```
-- **I'm getting a `health check timeout: no Elasticsearch node available` error in Sidekiq during the indexing process**
+### `health check timeout: no Elasticsearch node available` error in Sidekiq
- ```plaintext
- Gitlab::Elastic::Indexer::Error: time="2020-01-23T09:13:00Z" level=fatal msg="health check timeout: no Elasticsearch node available"
- ```
+If you're getting a `health check timeout: no Elasticsearch node available` error in Sidekiq during the indexing process:
+
+```plaintext
+Gitlab::Elastic::Indexer::Error: time="2020-01-23T09:13:00Z" level=fatal msg="health check timeout: no Elasticsearch node available"
+```
- You probably have not used either `http://` or `https://` as part of your value in the **"URL"** field of the Elasticsearch Integration Menu. Please make sure you are using either `http://` or `https://` in this field as the [Elasticsearch client for Go](https://github.com/olivere/elastic) that we are using [needs the prefix for the URL to be accepted as valid](https://github.com/olivere/elastic/commit/a80af35aa41856dc2c986204e2b64eab81ccac3a).
- Once you have corrected the formatting of the URL, delete the index (via the [dedicated Rake task](#gitlab-elasticsearch-rake-tasks)) and [reindex the content of your instance](#enabling-elasticsearch).
+You probably have not used either `http://` or `https://` as part of your value in the **"URL"** field of the Elasticsearch Integration Menu. Please make sure you are using either `http://` or `https://` in this field as the [Elasticsearch client for Go](https://github.com/olivere/elastic) that we are using [needs the prefix for the URL to be accepted as valid](https://github.com/olivere/elastic/commit/a80af35aa41856dc2c986204e2b64eab81ccac3a).
+Once you have corrected the formatting of the URL, delete the index (via the [dedicated Rake task](#gitlab-advanced-search-rake-tasks)) and [reindex the content of your instance](#enabling-advanced-search).
### Low-level troubleshooting
There is a [more structured, lower-level troubleshooting document](../administration/troubleshooting/elasticsearch.md) for when you experience other issues, including poor performance.
-### Known Issues
+### Known issues
-- **[Elasticsearch `code_analyzer` doesn't account for all code cases](https://gitlab.com/groups/gitlab-org/-/epics/3621)**
+[Elasticsearch `code_analyzer` doesn't account for all code cases](https://gitlab.com/groups/gitlab-org/-/epics/3621).
- The `code_analyzer` pattern and filter configuration is being evaluated for improvement. We have fixed [most edge cases](https://gitlab.com/groups/gitlab-org/-/epics/3621#note_363429094) that were not returning expected search results due to our pattern and filter configuration.
+The `code_analyzer` pattern and filter configuration is being evaluated for improvement. We have fixed [most edge cases](https://gitlab.com/groups/gitlab-org/-/epics/3621#note_363429094) that were not returning expected search results due to our pattern and filter configuration.
- Improvements to the `code_analyzer` pattern and filters is being discussed in [epic 3621](https://gitlab.com/groups/gitlab-org/-/epics/3621).
+Improvements to the `code_analyzer` pattern and filters are being discussed in [epic 3621](https://gitlab.com/groups/gitlab-org/-/epics/3621).
### Reverting to Basic Search
Sometimes there may be issues with your Elasticsearch index data and as such
GitLab will allow you to revert to "basic search" when there are no search
results and assuming that basic search is supported in that scope. This "basic
-search" will behave as though you don't have Elasticsearch enabled at all for
+search" will behave as though you don't have Advanced Search enabled at all for
your instance and search using other data sources (ie. PostgreSQL data and Git
data).
diff --git a/doc/integration/facebook.md b/doc/integration/facebook.md
index fc65191994d..dbefb560fe7 100644
--- a/doc/integration/facebook.md
+++ b/doc/integration/facebook.md
@@ -83,7 +83,7 @@ To enable the Facebook OmniAuth provider you must register your application with
```yaml
- { name: 'facebook', app_id: 'YOUR_APP_ID',
- app_secret: 'YOUR_APP_SECRET' }
+ app_secret: 'YOUR_APP_SECRET' }
```
1. Change 'YOUR_APP_ID' to the API key from Facebook page in step 10.
diff --git a/doc/integration/github.md b/doc/integration/github.md
index ccce4672cbf..4444db46853 100644
--- a/doc/integration/github.md
+++ b/doc/integration/github.md
@@ -71,17 +71,18 @@ Follow these steps to incorporate the GitHub OAuth 2 app in your GitLab server:
```yaml
- { name: 'github', app_id: 'YOUR_APP_ID',
- app_secret: 'YOUR_APP_SECRET',
- args: { scope: 'user:email' } }
+ app_secret: 'YOUR_APP_SECRET',
+ args: { scope: 'user:email' } }
```
For GitHub Enterprise:
```yaml
- - { name: 'github', app_id: 'YOUR_APP_ID',
- app_secret: 'YOUR_APP_SECRET',
- url: "https://github.example.com/",
- args: { scope: 'user:email' } }
+ - { name: 'github',
+ app_id: 'YOUR_APP_ID',
+ app_secret: 'YOUR_APP_SECRET',
+ url: "https://github.example.com/",
+ args: { scope: 'user:email' } }
```
**Replace `https://github.example.com/` with your GitHub URL.**
@@ -125,11 +126,12 @@ omnibus_gitconfig['system'] = { "http" => ["sslVerify = false"] }
For installation from source:
```yaml
-- { name: 'github', app_id: 'YOUR_APP_ID',
- app_secret: 'YOUR_APP_SECRET',
- url: "https://github.example.com/",
- verify_ssl: false,
- args: { scope: 'user:email' } }
+- { name: 'github',
+ app_id: 'YOUR_APP_ID',
+ app_secret: 'YOUR_APP_SECRET',
+ url: "https://github.example.com/",
+ verify_ssl: false,
+ args: { scope: 'user:email' } }
```
You will also need to disable Git SSL verification on the server hosting GitLab.
diff --git a/doc/integration/gitlab.md b/doc/integration/gitlab.md
index f423863ce8e..a200f6b6470 100644
--- a/doc/integration/gitlab.md
+++ b/doc/integration/gitlab.md
@@ -63,9 +63,10 @@ GitLab.com will generate an application ID and secret key for you to use.
For installations from source:
```yaml
- - { name: 'gitlab', app_id: 'YOUR_APP_ID',
- app_secret: 'YOUR_APP_SECRET',
- args: { scope: 'api' } }
+ - { name: 'gitlab',
+ app_id: 'YOUR_APP_ID',
+ app_secret: 'YOUR_APP_SECRET',
+ args: { scope: 'api' } }
```
1. Change 'YOUR_APP_ID' to the Application ID from the GitLab.com application page.
diff --git a/doc/integration/gitpod.md b/doc/integration/gitpod.md
index f26483e3b5e..bd038eaf47b 100644
--- a/doc/integration/gitpod.md
+++ b/doc/integration/gitpod.md
@@ -72,3 +72,4 @@ To disable it:
```ruby
Feature.disable(:gitpod)
+```
diff --git a/doc/integration/google.md b/doc/integration/google.md
index 0f848bbc7aa..4cf589c1da8 100644
--- a/doc/integration/google.md
+++ b/doc/integration/google.md
@@ -84,9 +84,10 @@ On your GitLab server:
For installations from source:
```yaml
- - { name: 'google_oauth2', app_id: 'YOUR_APP_ID',
- app_secret: 'YOUR_APP_SECRET',
- args: { access_type: 'offline', approval_prompt: '' } }
+ - { name: 'google_oauth2',
+ app_id: 'YOUR_APP_ID',
+ app_secret: 'YOUR_APP_SECRET',
+ args: { access_type: 'offline', approval_prompt: '' } }
```
1. Change `YOUR_APP_ID` to the client ID from the Google Developer page
diff --git a/doc/integration/jira_development_panel.md b/doc/integration/jira_development_panel.md
index 9b7aa5829c1..e91b024546e 100644
--- a/doc/integration/jira_development_panel.md
+++ b/doc/integration/jira_development_panel.md
@@ -148,7 +148,7 @@ GitLab. No other error messages appear in any logs.
If there was an issue with SSL/TLS, this error message will be generated.
-- The [GitLab Jira integration](jira.md) requires GitLab to connect to Jira. Any
+- The [GitLab Jira integration](../user/project/integrations/jira.md) requires GitLab to connect to Jira. Any
TLS issues that arise from a private certificate authority or self-signed
certificate [are resolved on the GitLab server](https://docs.gitlab.com/omnibus/settings/ssl.html#other-certificate-authorities),
as GitLab is the TLS client.
diff --git a/doc/integration/kerberos.md b/doc/integration/kerberos.md
index 1b14b5a986f..11ea74f3430 100644
--- a/doc/integration/kerberos.md
+++ b/doc/integration/kerberos.md
@@ -207,9 +207,10 @@ remove the OmniAuth provider named `kerberos` from your `gitlab.yml` /
```yaml
omniauth:
+ # Rest of configuration omitted
# ...
providers:
- - { name: 'kerberos' } # <-- remove this line
+ - { name: 'kerberos' } # <-- remove this line
```
1. [Restart GitLab](../administration/restart_gitlab.md#installations-from-source) for the changes to take effect.
diff --git a/doc/integration/omniauth.md b/doc/integration/omniauth.md
index dd183ad9eb0..cf09c2f2803 100644
--- a/doc/integration/omniauth.md
+++ b/doc/integration/omniauth.md
@@ -50,7 +50,7 @@ earlier version, you'll need to explicitly enable it.
- `allow_single_sign_on` allows you to specify the providers you want to allow to
automatically create an account. It defaults to `false`. If `false` users must
be created manually or they will not be able to sign in via OmniAuth.
-- `auto_link_ldap_user` can be used if you have [LDAP / ActiveDirectory](ldap.md)
+- `auto_link_ldap_user` can be used if you have [LDAP / ActiveDirectory](../administration/auth/ldap/index.md)
integration enabled. It defaults to `false`. When enabled, users automatically
created through an OmniAuth provider will have their LDAP identity created in GitLab as well.
- `block_auto_created_users` defaults to `true`. If `true` auto created users will
@@ -104,21 +104,21 @@ To change these settings:
```yaml
## OmniAuth settings
- omniauth:
- # Allow login via Twitter, Google, etc. using OmniAuth providers
- # Versions prior to 11.4 require this to be set to true
- # enabled: true
+ omniauth:
+ # Allow login via Twitter, Google, etc. using OmniAuth providers
+ # Versions prior to 11.4 require this to be set to true
+ # enabled: true
- # CAUTION!
- # This allows users to login without having a user account first. Define the allowed providers
- # using an array, e.g. ["saml", "twitter"], or as true/false to allow all providers or none.
- # User accounts will be created automatically when authentication was successful.
- allow_single_sign_on: ["saml", "twitter"]
+ # CAUTION!
+ # This allows users to login without having a user account first. Define the allowed providers
+ # using an array, e.g. ["saml", "twitter"], or as true/false to allow all providers or none.
+ # User accounts will be created automatically when authentication was successful.
+ allow_single_sign_on: ["saml", "twitter"]
- auto_link_ldap_user: true
+ auto_link_ldap_user: true
- # Locks down those users until they have been cleared by the admin (default: true).
- block_auto_created_users: true
+ # Locks down those users until they have been cleared by the admin (default: true).
+ block_auto_created_users: true
```
Now we can choose one or more of the [Supported Providers](#supported-providers)
@@ -142,7 +142,7 @@ The chosen OmniAuth provider is now active and can be used to sign in to GitLab
## Automatically Link Existing Users to OmniAuth Users
-> [Introduced in GitLab 13.4.](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36664)
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36664) in GitLab 13.4.
You can automatically link OmniAuth users with existing GitLab users if their email addresses match.
For example, the following setting is used to enable the auto link feature for both a SAML provider and the Twitter OAuth provider:
diff --git a/doc/integration/salesforce.md b/doc/integration/salesforce.md
index 7e0b2518e76..dbd0a03e3cf 100644
--- a/doc/integration/salesforce.md
+++ b/doc/integration/salesforce.md
@@ -64,7 +64,7 @@ To get the credentials (a pair of Client ID and Client Secret), you must [create
- { name: 'salesforce',
app_id: 'SALESFORCE_CLIENT_ID',
app_secret: 'SALESFORCE_CLIENT_SECRET'
- }
+ }
```
1. Change `SALESFORCE_CLIENT_ID` to the Consumer Key from the Salesforce connected application page.
diff --git a/doc/integration/saml.md b/doc/integration/saml.md
index e7e94b21683..ee08a0026cd 100644
--- a/doc/integration/saml.md
+++ b/doc/integration/saml.md
@@ -113,16 +113,16 @@ in your SAML IdP:
omniauth:
providers:
- {
- name: 'saml',
- args: {
- assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml/callback',
- idp_cert_fingerprint: '43:51:43:a1:b5:fc:8b:b7:0a:3a:a9:b1:0f:66:73:a8',
- idp_sso_target_url: 'https://login.example.com/idp',
- issuer: 'https://gitlab.example.com',
- name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
- },
- label: 'Company Login' # optional label for SAML login button, defaults to "Saml"
- }
+ name: 'saml',
+ args: {
+ assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml/callback',
+ idp_cert_fingerprint: '43:51:43:a1:b5:fc:8b:b7:0a:3a:a9:b1:0f:66:73:a8',
+ idp_sso_target_url: 'https://login.example.com/idp',
+ issuer: 'https://gitlab.example.com',
+ name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
+ },
+ label: 'Company Login' # optional label for SAML login button, defaults to "Saml"
+ }
```
1. Change the value for `assertion_consumer_service_url` to match the HTTPS endpoint
@@ -210,7 +210,7 @@ Example:
idp_sso_target_url: 'https://login.example.com/idp',
issuer: 'https://gitlab.example.com',
name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient'
- } }
+ } }
```
### External Groups **(STARTER ONLY)**
@@ -228,7 +228,7 @@ SAML login supports automatic identification on whether a user should be conside
idp_sso_target_url: 'https://login.example.com/idp',
issuer: 'https://gitlab.example.com',
name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
- } }
+ } }
```
### Admin Groups **(STARTER ONLY)**
@@ -248,7 +248,7 @@ considered admin users.
idp_sso_target_url: 'https://login.example.com/idp',
issuer: 'https://gitlab.example.com',
name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient'
- } }
+ } }
```
### Auditor Groups **(STARTER ONLY)**
@@ -270,7 +270,7 @@ considered auditor users.
idp_sso_target_url: 'https://login.example.com/idp',
issuer: 'https://gitlab.example.com',
name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient'
- } }
+ } }
```
## Bypass two factor authentication
@@ -328,22 +328,22 @@ In addition to the changes in GitLab, make sure that your IdP is returning the
omniauth:
providers:
- {
- name: 'saml',
- args: {
- assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml/callback',
- idp_cert_fingerprint: '43:51:43:a1:b5:fc:8b:b7:0a:3a:a9:b1:0f:66:73:a8',
- idp_sso_target_url: 'https://login.example.com/idp',
- issuer: 'https://gitlab.example.com',
- name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
- upstream_two_factor_authn_contexts:
- [
- 'urn:oasis:names:tc:SAML:2.0:ac:classes:CertificateProtectedTransport',
- 'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorOTPSMS',
- 'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorIGTOKEN'
- ]
- },
- label: 'Company Login' # optional label for SAML login button, defaults to "Saml"
- }
+ name: 'saml',
+ args: {
+ assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml/callback',
+ idp_cert_fingerprint: '43:51:43:a1:b5:fc:8b:b7:0a:3a:a9:b1:0f:66:73:a8',
+ idp_sso_target_url: 'https://login.example.com/idp',
+ issuer: 'https://gitlab.example.com',
+ name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
+ upstream_two_factor_authn_contexts:
+ [
+ 'urn:oasis:names:tc:SAML:2.0:ac:classes:CertificateProtectedTransport',
+ 'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorOTPSMS',
+ 'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorIGTOKEN'
+ ]
+ },
+ label: 'Company Login' # optional label for SAML login button, defaults to "Saml"
+ }
```
1. Save the file and [restart GitLab](../administration/restart_gitlab.md#installations-from-source) for the changes to take effect
@@ -436,7 +436,7 @@ args: {
issuer: 'https://gitlab.example.com',
name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
attribute_statements: { email: ['EmailAddress'] },
- allowed_clock_drift: 1 # for one second clock drift
+ allowed_clock_drift: 1 # for one second clock drift
}
```
@@ -561,10 +561,10 @@ args: {
<redacted>
-----END PRIVATE KEY-----',
security: {
- authn_requests_signed: true, # enable signature on AuthNRequest
- want_assertions_signed: true, # enable the requirement of signed assertion
- embed_sign: true, # embedded signature or HTTP GET parameter signature
- metadata_signed: false, # enable signature on Metadata
+ authn_requests_signed: true, # enable signature on AuthNRequest
+ want_assertions_signed: true, # enable the requirement of signed assertion
+ embed_sign: true, # embedded signature or HTTP GET parameter signature
+ metadata_signed: false, # enable signature on Metadata
signature_method: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
digest_method: 'http://www.w3.org/2001/04/xmlenc#sha256',
}
@@ -588,6 +588,52 @@ Refer to the documentation for your SAML Identity Provider for information on ho
The [Generated passwords for users created through integrated authentication](../security/passwords_for_integrated_authentication_methods.md) guide provides an overview of how GitLab generates and sets passwords for users created via SAML.
+## Configuring Group SAML on a self-managed GitLab instance **(PREMIUM ONLY)**
+
+For information on the GitLab.com implementation, please see the [SAML SSO for GitLab.com groups page](../user/group/saml_sso).
+
+Group SAML SSO helps if you need to allow access via multiple SAML identity providers, but as a multi-tenant solution is less suited to cases where you administer your own GitLab instance.
+
+To proceed with configuring Group SAML SSO instead, you'll need to enable the `group_saml` OmniAuth provider. This can be done from:
+
+- `gitlab.rb` for [Omnibus GitLab installations](#omnibus-installations).
+- `gitlab/config/gitlab.yml` for [source installations](#source-installations).
+
+### Limitations
+
+Group SAML on a self-managed instance is limited when compared to the recommended
+[instance-wide SAML](../user/group/saml_sso/index.md). The recommended solution allows you to take advantage of:
+
+- [LDAP compatibility](../administration/auth/ldap/index.md).
+- [LDAP Group Sync](../user/group/index.md#manage-group-memberships-via-ldap).
+- [Required groups](#required-groups).
+- [Admin groups](#admin-groups).
+- [Auditor groups](#auditor-groups).
+
+### Omnibus installations
+
+1. Make sure GitLab is
+ [configured with HTTPS](../install/installation.md#using-https).
+1. Enable OmniAuth and the `group_saml` provider in `gitlab.rb`:
+
+ ```ruby
+ gitlab_rails['omniauth_enabled'] = true
+ gitlab_rails['omniauth_providers'] = [{ name: 'group_saml' }]
+ ```
+
+### Source installations
+
+1. Make sure GitLab is
+ [configured with HTTPS](../install/installation.md#using-https).
+1. Enable OmniAuth and the `group_saml` provider in `gitlab/config/gitlab.yml`:
+
+ ```yaml
+ omniauth:
+ enabled: true
+ providers:
+ - { name: 'group_saml' }
+ ```
+
## Troubleshooting
You can find the base64-encoded SAML Response in the [`production_json.log`](../administration/logs.md#production_jsonlog).
diff --git a/doc/integration/twitter.md b/doc/integration/twitter.md
index 95de56f24d8..e501eac0c5f 100644
--- a/doc/integration/twitter.md
+++ b/doc/integration/twitter.md
@@ -65,7 +65,8 @@ To enable the Twitter OmniAuth provider you must register your application with
For installations from source:
```yaml
- - { name: 'twitter', app_id: 'YOUR_APP_ID',
+ - { name: 'twitter',
+ app_id: 'YOUR_APP_ID',
app_secret: 'YOUR_APP_SECRET' }
```
diff --git a/doc/intro/README.md b/doc/intro/README.md
index 4bf9c766fc4..02429f387ee 100644
--- a/doc/intro/README.md
+++ b/doc/intro/README.md
@@ -28,7 +28,7 @@ Create issues, labels, milestones, cast your vote, and review issues.
Create merge requests and review code.
- [Fork a project and contribute to it](../user/project/repository/forking_workflow.md)
-- [Create a new merge request](../gitlab-basics/add-merge-request.md)
+- [Create a new merge request](../user/project/merge_requests/creating_merge_requests.md)
- [Automatically close issues from merge requests](../user/project/issues/managing_issues.md#closing-issues-automatically)
- [Automatically merge when pipeline succeeds](../user/project/merge_requests/merge_when_pipeline_succeeds.md)
- [Revert any commit](../user/project/merge_requests/revert_changes.md)
diff --git a/doc/operations/feature_flags.md b/doc/operations/feature_flags.md
index fe7be48270a..8db1c0945c6 100644
--- a/doc/operations/feature_flags.md
+++ b/doc/operations/feature_flags.md
@@ -105,7 +105,8 @@ ID for the feature to be enabled. See the [Ruby example](#ruby-application-examp
### User IDs
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/8240) in GitLab 12.2. [Updated](https://gitlab.com/gitlab-org/gitlab/-/issues/34363) to be defined per environment in GitLab 12.6.
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/8240) in GitLab 12.2.
+> - [Updated](https://gitlab.com/gitlab-org/gitlab/-/issues/34363) to be defined per environment in GitLab 12.6.
Enables the feature for a list of target users. It is implemented
using the Unleash [`userWithId`](https://unleash.github.io/docs/activation_strategy#userwithid)
@@ -353,31 +354,9 @@ end
## Feature Flag Related Issues **(PREMIUM)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36617) in GitLab 13.2.
-> - It's deployed behind a feature flag, enabled by default.
-> - It's enabled on GitLab.com.
-> - It can't be enabled or disabled per-project
-> - It's recommended for production use.
-> - For GitLab self-managed instances, GitLab administrators can opt to disable it.
+> - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/251234) in GitLab 13.5.
-You can link related issues to a feature flag. In the **Linked issues** section, click the `+` button and input the issue reference number or the full URL of the issue.
+You can link related issues to a feature flag. In the **Linked issues** section,
+click the `+` button and input the issue reference number or the full URL of the issue.
This feature is similar to the [related issues](../user/project/issues/related_issues.md) feature.
-
-### Enable or disable Feature Flag Related Issues **(CORE ONLY)**
-
-Feature Flag Related Issues is under development but ready for production use.
-It is deployed behind a feature flag that is **enabled by default**.
-[GitLab administrators with access to the GitLab Rails console](../administration/feature_flags.md)
-can opt to disable it for your instance.
-
-To disable it:
-
-```ruby
-Feature.disable(:feature_flags_issue_links)
-```
-
-To enable it:
-
-```ruby
-Feature.enable(:feature_flags_issue_links)
-```
diff --git a/doc/operations/incident_management/alert_details.md b/doc/operations/incident_management/alert_details.md
index 860e6d32ae4..459331ea0a5 100644
--- a/doc/operations/incident_management/alert_details.md
+++ b/doc/operations/incident_management/alert_details.md
@@ -1,200 +1,5 @@
---
-stage: Monitor
-group: Health
-info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+redirect_to: alerts.md
---
-# Alert details page
-
-Navigate to the Alert details view by visiting the
-[Alert list](./alerts.md) and selecting an alert from the
-list. You need least Developer [permissions](../../user/permissions.md) to access
-alerts.
-
-TIP: **Tip:**
-To review live examples of GitLab alerts, visit the
-[alert list](https://gitlab.com/gitlab-examples/ops/incident-setup/everyone/tanuki-inc/-/alert_management)
-for this demo project. Click any alert in the list to examine its alert details
-page.
-
-Alerts provide **Overview** and **Alert details** tabs to give you the right
-amount of information you need.
-
-## Alert overview tab
-
-The **Overview** tab provides basic information about the alert:
-
-![Alert Detail Overview](./img/alert_detail_overview_v13_1.png)
-
-## Alert details tab
-
-![Alert Full Details](./img/alert_detail_full_v13_1.png)
-
-### Update an alert's status
-
-The Alert detail view enables you to update the Alert Status.
-See [Create and manage alerts in GitLab](./alerts.md) for more details.
-
-### Create an issue from an alert
-
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/217745) in GitLab 13.1.
-
-The Alert detail view enables you to create an issue with a
-description automatically populated from an alert. To create the issue,
-click the **Create Issue** button. You can then view the issue from the
-alert by clicking the **View Issue** button.
-
-Closing a GitLab issue associated with an alert changes the alert's status to Resolved.
-See [Create and manage alerts in GitLab](alerts.md) for more details about alert statuses.
-
-### Update an alert's assignee
-
-> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3066) in GitLab 13.1.
-
-The Alert detail view allows users to update the Alert assignee.
-
-In large teams, where there is shared ownership of an alert, it can be difficult
-to track who is investigating and working on it. The Alert detail view
-enables you to update the Alert assignee:
-
-NOTE: **Note:**
-GitLab currently only supports a single assignee per alert.
-
-1. To display the list of current alerts, click
- **{cloud-gear}** **Operations > Alerts**:
-
- ![Alert List View Assignee(s)](./img/alert_list_assignees_v13_1.png)
-
-1. Select your desired alert to display its **Alert Details View**:
-
- ![Alert Details View Assignee(s)](./img/alert_details_assignees_v13_1.png)
-
-1. If the right sidebar is not expanded, click
- **{angle-double-right}** **Expand sidebar** to expand it.
-1. In the right sidebar, locate the **Assignee** and click **Edit**. From the
- dropdown menu, select each user you want to assign to the alert. GitLab creates
- a [to-do list item](../../user/todos.md) for each user.
-
- ![Alert Details View Assignee(s)](./img/alert_todo_assignees_v13_1.png)
-
-To remove an assignee, click **Edit** next to the **Assignee** dropdown menu and
-deselect the user from the list of assignees, or click **Unassigned**.
-
-### Alert system notes
-
-> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3066) in GitLab 13.1.
-
-When you take action on an alert, this is logged as a system note,
-which is visible in the Alert Details view. This gives you a linear
-timeline of the alert's investigation and assignment history.
-
-The following actions will result in a system note:
-
-- [Updating the status of an alert](#update-an-alerts-status)
-- [Creating an issue based on an alert](#create-an-issue-from-an-alert)
-- [Assignment of an alert to a user](#update-an-alerts-assignee)
-
-![Alert Details View System Notes](./img/alert_detail_system_notes_v13_1.png)
-
-### Create a to-do from an alert
-
-> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3066) in GitLab 13.1.
-
-You can manually create [To-Do list items](../../user/todos.md) for yourself from the
-Alert details screen, and view them later on your **To-Do List**. To add a to-do:
-
-1. To display the list of current alerts, click
- **{cloud-gear}** **Operations > Alerts**.
-1. Select your desired alert to display its **Alert Management Details View**.
-1. Click the **Add a To-Do** button in the right sidebar:
-
- ![Alert Details Add A To Do](./img/alert_detail_add_todo_v13_1.png)
-
-Click the **To-Do** **{todo-done}** in the navigation bar to view your current to-do list.
-
-![Alert Details Added to Do](./img/alert_detail_added_todo_v13_1.png)
-
-### View an alert's metrics data
-
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/217768) in GitLab 13.2.
-
-To view the metrics for an alert:
-
- 1. Sign in as a user with Developer or higher [permissions](../../user/permissions.md).
- 1. Navigate to **{cloud-gear}** **Operations > Alerts**.
- 1. Click the alert you want to view.
- 1. Below the title of the alert, click the **Metrics** tab.
-
-![Alert Metrics View](img/alert_detail_metrics_v13_2.png)
-
-For GitLab-managed Prometheus instances, metrics data is automatically available
-for the alert, making it easy to see surrounding behavior. See
-[Managed Prometheus instances](../metrics/alerts.md#managed-prometheus-instances)
-for information on setting up alerts.
-
-For externally-managed Prometheus instances, you can configure your alerting rules to
-display a chart in the alert. See
-[Embedding metrics based on alerts in incident issues](../metrics/embed.md#embedding-metrics-based-on-alerts-in-incident-issues)
-for information on how to appropriately configure your alerting rules. See
-[External Prometheus instances](../metrics/alerts.md#external-prometheus-instances)
-for information on setting up alerts for your self-managed Prometheus instance.
-
-## Use cases for assigning alerts
-
-Consider a team formed by different sections of monitoring, collaborating on a
-single application. After an alert surfaces, it's extremely important to
-route the alert to the team members who can address and resolve the alert.
-
-Assigning Alerts eases collaboration and delegation. All
-assignees are shown in your team's work-flows, and all assignees receive
-notifications, simplifying communication and ownership of the alert.
-
-After completing their portion of investigating or fixing the alert, users can
-unassign their account from the alert when their role is complete.
-The alert status can be updated on the [Alert list](./alerts.md) to
-reflect if the alert has been resolved.
-
-## View an alert's logs
-
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/217768) in GitLab 13.3.
-
-To view the logs for an alert:
-
- 1. Sign in as a user with Developer or higher [permissions](../../user/permissions.md).
- 1. Navigate to **{cloud-gear}** **Operations > Alerts**.
- 1. Click the alert you want to view.
- 1. Below the title of the alert, click the **Metrics** tab.
- 1. Click the [menu](../metrics/dashboards/index.md#chart-context-menu) of the metric chart to view options.
- 1. Click **View logs**.
-
-Read [View logs from metrics panel](#view-logs-from-metrics-panel) for additional information.
-
-## Embed metrics in incidents and issues
-
-You can embed metrics anywhere [GitLab Markdown](../../user/markdown.md) is used, such as descriptions,
-comments on issues, and merge requests. Embedding metrics helps you share them
-when discussing incidents or performance issues. You can output the dashboard directly
-into any issue, merge request, epic, or any other Markdown text field in GitLab
-by [copying and pasting the link to the metrics dashboard](../metrics/embed.md#embedding-gitlab-managed-kubernetes-metrics).
-
-You can embed both
-[GitLab-hosted metrics](../metrics/embed.md) and
-[Grafana metrics](../metrics/embed_grafana.md)
-in incidents and issue templates.
-
-### Context menu
-
-You can view more details about an embedded metrics panel from the context menu.
-To access the context menu, click the **{ellipsis_v}** **More actions** dropdown box
-above the upper right corner of the panel. For a list of options, see
-[Chart context menu](../metrics/dashboards/index.md#chart-context-menu).
-
-#### View logs from metrics panel
-
-> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/201846) in GitLab Ultimate 12.8.
-> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/25455) to [GitLab Core](https://about.gitlab.com/pricing/) 12.9.
-
-Viewing logs from a metrics panel can be useful if you're triaging an application
-incident and need to [explore logs](../metrics/dashboards/index.md#chart-context-menu)
-from across your application. These logs help you understand what is affecting
-your application's performance and resolve any problems.
+This document was moved to [another location](alerts.md).
diff --git a/doc/operations/incident_management/alert_integrations.md b/doc/operations/incident_management/alert_integrations.md
new file mode 100644
index 00000000000..f2225ad9992
--- /dev/null
+++ b/doc/operations/incident_management/alert_integrations.md
@@ -0,0 +1,132 @@
+---
+stage: Monitor
+group: Health
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+---
+
+# Generic alerts integration
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/13203) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.4.
+> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/42640) to [GitLab Core](https://about.gitlab.com/pricing/) in 12.8.
+
+GitLab can accept alerts from any source via a generic webhook receiver.
+When you set up the generic alerts integration, a unique endpoint will
+be created which can receive a payload in JSON format, and will in turn
+create an issue with the payload in the body of the issue. You can always
+[customize the payload](#customizing-the-payload) to your liking.
+
+The entire payload will be posted in the issue discussion as a comment
+authored by the GitLab Alert Bot.
+
+NOTE: **Note:**
+In GitLab versions 13.1 and greater, you can configure
+[External Prometheus instances](../metrics/alerts.md#external-prometheus-instances)
+to use this endpoint.
+
+## Setting up generic alerts
+
+To obtain credentials for setting up a generic alerts integration:
+
+1. Sign in to GitLab as a user with maintainer [permissions](../../user/permissions.md)
+ for a project.
+1. Navigate to the **Operations** page for your project, depending on your
+ installed version of GitLab:
+ - *In GitLab versions 13.1 and greater,* navigate to **Settings > Operations**
+ in your project.
+ - *In GitLab versions prior to 13.1,* navigate to **Settings > Integrations**
+ in your project. GitLab will display a banner encouraging you to enable
+ the Alerts endpoint in **Settings > Operations** instead.
+1. Click **Alerts endpoint**.
+1. Toggle the **Active** alert setting to display the **URL** and **Authorization Key**
+ for the webhook configuration.
+
+## Customizing the payload
+
+You can customize the payload by sending the following parameters. All fields
+other than `title` are optional:
+
+| Property | Type | Description |
+| ------------------------- | --------------- | ----------- |
+| `title` | String | The title of the incident. Required. |
+| `description` | String | A high-level summary of the problem. |
+| `start_time` | DateTime | The time of the incident. If none is provided, a timestamp of the issue will be used. |
+| `end_time` | DateTime | For existing alerts only. When provided, the alert is resolved and the associated incident is closed. |
+| `service` | String | The affected service. |
+| `monitoring_tool` | String | The name of the associated monitoring tool. |
+| `hosts` | String or Array | One or more hosts, as to where this incident occurred. |
+| `severity` | String | The severity of the alert. Must be one of `critical`, `high`, `medium`, `low`, `info`, `unknown`. Default is `critical`. |
+| `fingerprint` | String or Array | The unique identifier of the alert. This can be used to group occurrences of the same alert. |
+| `gitlab_environment_name` | String | The name of the associated GitLab [environment](../../ci/environments/index.md). This can be used to associate your alert to your environment. |
+
+You can also add custom fields to the alert's payload. The values of extra
+parameters aren't limited to primitive types (such as strings or numbers), but
+can be a nested JSON object. For example:
+
+```json
+{ "foo": { "bar": { "baz": 42 } } }
+```
+
+TIP: **Payload size:**
+Ensure your requests are smaller than the [payload application limits](../../administration/instance_limits.md#generic-alert-json-payloads).
+
+Example request:
+
+```shell
+curl --request POST \
+ --data '{"title": "Incident title"}' \
+ --header "Authorization: Bearer <authorization_key>" \
+ --header "Content-Type: application/json" \
+ <url>
+```
+
+The `<authorization_key>` and `<url>` values can be found when [setting up generic alerts](#setting-up-generic-alerts).
+
+Example payload:
+
+```json
+{
+ "title": "Incident title",
+ "description": "Short description of the incident",
+ "start_time": "2019-09-12T06:00:55Z",
+ "service": "service affected",
+ "monitoring_tool": "value",
+ "hosts": "value",
+ "severity": "high",
+ "fingerprint": "d19381d4e8ebca87b55cda6e8eee7385",
+ "foo": {
+ "bar": {
+ "baz": 42
+ }
+ }
+}
+```
+
+## Triggering test alerts
+
+> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3066) in GitLab Core in 13.2.
+
+After a [project maintainer or owner](#setting-up-generic-alerts)
+[configures generic alerts](#setting-up-generic-alerts), you can trigger a test
+alert to confirm your integration works properly.
+
+1. Sign in as a user with Developer or greater [permissions](../../user/permissions.md).
+1. Navigate to **Settings > Operations** in your project.
+1. Click **Alerts endpoint** to expand the section.
+1. Enter a sample payload in **Alert test payload** (valid JSON is required).
+1. Click **Test alert payload**.
+
+GitLab displays an error or success message, depending on the outcome of your test.
+
+## Automatic grouping of identical alerts **(PREMIUM)**
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/214557) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.2.
+
+In GitLab versions 13.2 and greater, GitLab groups alerts based on their
+payload. When an incoming alert contains the same payload as another alert
+(excluding the `start_time` and `hosts` attributes), GitLab groups these alerts
+together and displays a counter on the [Alert Management List](./incidents.md)
+and details pages.
+
+If the existing alert is already `resolved`, GitLab creates a new alert instead.
+
+![Alert Management List](./img/alert_list_v13_1.png)
diff --git a/doc/operations/incident_management/alert_notifications.md b/doc/operations/incident_management/alert_notifications.md
new file mode 100644
index 00000000000..1bff563579c
--- /dev/null
+++ b/doc/operations/incident_management/alert_notifications.md
@@ -0,0 +1,35 @@
+---
+stage: Monitor
+group: Health
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+---
+
+# Alert notifications
+
+GitLab can react to alerts triggered from your applications. When an alert is
+triggered in GitLab by [managed-Prometheus](../../user/project/integrations/prometheus.md#managed-prometheus-on-kubernetes)
+or triggered using an external source and received with an integration, it's
+important for a responder to be notified.
+
+Responders can receive notifications using the methods described on this page.
+
+## Slack notifications
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216326) in GitLab 13.1.
+
+You can be alerted by a Slack message when a new alert has been triggered.
+
+For setup information, see the [Slack Notifications Service docs](../../user/project/integrations/slack.md).
+
+## Email notifications
+
+If a project has been [configured to create incidents for triggered alerts](incidents.md#configure-incidents),
+projects members with the _Owner_ or _Maintainer_ role will be sent an email
+notification. To send additional email notifications to project members with the
+Developer role:
+
+1. Navigate to **Settings > Operations**.
+1. Expand the **Incidents** section.
+1. In the **Alert Integration** tab, select the **Send a separate email notification to Developers**
+ check box.
+1. Select **Save changes**.
diff --git a/doc/operations/incident_management/alerts.md b/doc/operations/incident_management/alerts.md
index d908af63000..bbfbb59c644 100644
--- a/doc/operations/incident_management/alerts.md
+++ b/doc/operations/incident_management/alerts.md
@@ -6,34 +6,36 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Create and manage alerts in GitLab
-Users with at least Developer [permissions](../../user/permissions.md) can access
-the Alert Management list at **{cloud-gear}** **Operations > Alerts** in your
-project's sidebar. The Alert Management list displays alerts sorted by start time,
-but you can change the sort order by clicking the headers in the Alert Management list.
+Users with at least Developer [permissions](../../user/permissions.md) can
+access the Alert Management list at **Operations > Alerts** in your project's
+sidebar. The Alert Management list displays alerts sorted by start time, but
+you can change the sort order by clicking the headers in the Alert Management list.
([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/217745) in GitLab 13.1.)
The alert list displays the following information:
-![Alert List](./img/alert_list_v13_1.png)
+![Alert List](img/alert_list_v13_1.png)
-- **Search** - The alert list supports a simple free text search on the title,
+- **Search**: The alert list supports a simple free text search on the title,
description, monitoring tool, and service fields.
([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/213884) in GitLab 13.1.)
-- **Severity** - The current importance of a alert and how much attention it should
- receive. For a listing of all statuses, read [Alert Management severity](#alert-severity).
-- **Start time** - How long ago the alert fired. This field uses the standard
- GitLab pattern of `X time ago`, but is supported by a granular date/time tooltip
- depending on the user's locale.
-- **Alert description** - The description of the alert, which attempts to capture the most meaningful data.
-- **Event count** - The number of times that an alert has fired.
-- **Issue** - A link to the incident issue that has been created for the alert.
-- **Status** - The current status of the alert:
+- **Severity**: The current importance of a alert and how much attention it
+ should receive. For a listing of all statuses, read [Alert Management severity](#alert-severity).
+- **Start time**: How long ago the alert fired. This field uses the standard
+ GitLab pattern of `X time ago`, but is supported by a granular date/time
+ tooltip depending on the user's locale.
+- **Alert description**: The description of the alert, which attempts to
+ capture the most meaningful data.
+- **Event count**: The number of times that an alert has fired.
+- **Issue**: A link to the incident issue that has been created for the alert.
+- **Status**: The current status of the alert:
- **Triggered**: No one has begun investigation.
- **Acknowledged**: Someone is actively investigating the problem.
- **Resolved**: No further work is required.
-
+
TIP: **Tip:**
-Check out a [live example](https://gitlab.com/gitlab-examples/ops/incident-setup/everyone/tanuki-inc/-/alert_management)
+Check out a live example available from the
+[`tanuki-inc` project page](https://gitlab-examples-ops-incident-setup-everyone-tanuki-inc.34.69.64.147.nip.io/)
in GitLab to examine alerts in action.
## Enable Alerts
@@ -42,65 +44,62 @@ NOTE: **Note:**
You need at least Maintainer [permissions](../../user/permissions.md) to enable
the Alerts feature.
-There are several ways to accept alerts into your GitLab project.
-Enabling any of these methods enables the Alert list. After configuring
-alerts, visit **{cloud-gear}** **Operations > Alerts** in your project's sidebar
-to view the list of alerts.
+There are several ways to accept alerts into your GitLab project. Enabling any
+of these methods enables the Alert list. After configuring alerts, visit
+**Operations > Alerts** in your project's sidebar to view the list of alerts.
### Enable GitLab-managed Prometheus alerts
You can install the GitLab-managed Prometheus application on your Kubernetes
-cluster. For more information, read
+cluster. For more information, see
[Managed Prometheus on Kubernetes](../../user/project/integrations/prometheus.md#managed-prometheus-on-kubernetes).
-When GitLab-managed Prometheus is installed, the [Alerts list](alerts.md)
-is also enabled.
+When GitLab-managed Prometheus is installed, the Alerts list is also enabled.
-To populate the alerts with data, read
+To populate the alerts with data, see
[GitLab-Managed Prometheus instances](../metrics/alerts.md#managed-prometheus-instances).
### Enable external Prometheus alerts
You can configure an externally-managed Prometheus instance to send alerts
-to GitLab. To set up this configuration, read the [configuring Prometheus](../metrics/alerts.md#external-prometheus-instances) documentation. Activating the external Prometheus
-configuration also enables the [Alerts list](./alerts.md).
+to GitLab. To set up this configuration, read the
+[configuring Prometheus](../metrics/alerts.md#external-prometheus-instances)
+documentation. Activating the external Prometheus configuration also enables
+the Alerts list.
-To populate the alerts with data, read
-[External Prometheus instances](../metrics/alerts.md#external-prometheus-instances).
+To populate the alerts with data, see [External Prometheus instances](../metrics/alerts.md#external-prometheus-instances).
### Enable a Generic Alerts endpoint
-GitLab provides the Generic Alerts endpoint so you can accept alerts from a third-party
-alerts service. Read the
-[instructions for toggling generic alerts](generic_alerts.md#setting-up-generic-alerts)
-to add this option. After configuring the endpoint, the
-[Alerts list](./alerts.md) is enabled.
+GitLab provides the Generic Alerts endpoint so you can accept alerts from a
+third-party alerts service. Read the [instructions for toggling generic alerts](alert_integrations.md#setting-up-generic-alerts)
+to add this option. After configuring the endpoint, the Alerts list is enabled.
-To populate the alerts with data, read [Customizing the payload](./generic_alerts.md#customizing-the-payload) for requests to the alerts endpoint.
+To populate the alerts with data, see [Customizing the payload](alert_integrations.md#customizing-the-payload)
+for requests to the alerts endpoint.
### Opsgenie integration **(PREMIUM)**
> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3066) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.2.
-A new way of monitoring Alerts via a GitLab integration is with
-[Opsgenie](https://www.atlassian.com/software/opsgenie).
+You can monitor alerts using a GitLab integration with [Opsgenie](https://www.atlassian.com/software/opsgenie).
NOTE: **Note:**
-If you enable the Opsgenie integration, you can't have other GitLab alert services,
-such as [Generic Alerts](./generic_alerts.md) or
-Prometheus alerts, active at the same time.
+If you enable the Opsgenie integration, you can't have other GitLab alert
+services, such as [Generic Alerts](generic_alerts.md) or Prometheus alerts,
+active at the same time.
To enable Opsgenie integration:
1. Sign in as a user with Maintainer or Owner [permissions](../../user/permissions.md).
-1. Navigate to **{cloud-gear}** **Operations > Alerts**.
-1. In the **Integrations** select box, select Opsgenie.
-1. Click the **Active** toggle.
-1. In the **API URL**, enter the base URL for your Opsgenie integration, such
- as `https://app.opsgenie.com/alert/list`.
-1. Click **Save changes**.
+1. Navigate to **Operations > Alerts**.
+1. In the **Integrations** select box, select **Opsgenie**.
+1. Select the **Active** toggle.
+1. In the **API URL** field, enter the base URL for your Opsgenie integration,
+ such as `https://app.opsgenie.com/alert/list`.
+1. Select **Save changes**.
-After enabling the integration, navigate to the Alerts list page at
-**{cloud-gear}** **Operations > Alerts**, and click **View alerts in Opsgenie**.
+After you enable the integration, navigate to the Alerts list page at
+**Operations > Alerts**, and then select **View alerts in Opsgenie**.
## Alert severity
@@ -108,15 +107,209 @@ Each level of alert contains a uniquely shaped and color-coded icon to help
you identify the severity of a particular alert. These severity icons help you
immediately identify which alerts you should prioritize investigating:
-![Alert Management Severity System](./img/alert_management_severity_v13_0.png)
+![Alert Management Severity System](img/alert_management_severity_v13_0.png)
Alerts contain one of the following icons:
-| Severity | Icon | Color (hexadecimal) |
-|---|---|---|
-| Critical | **{severity-critical}** | `#8b2615` |
-| High | **{severity-high}** | `#c0341d` |
-| Medium | **{severity-medium}** | `#fca429` |
-| Low | **{severity-low}** | `#fdbc60` |
-| Info | **{severity-info}** | `#418cd8` |
-| Unknown | **{severity-unknown}** | `#bababa` |
+| Severity | Icon | Color (hexadecimal) |
+|----------|-------------------------|---------------------|
+| Critical | **{severity-critical}** | `#8b2615` |
+| High | **{severity-high}** | `#c0341d` |
+| Medium | **{severity-medium}** | `#fca429` |
+| Low | **{severity-low}** | `#fdbc60` |
+| Info | **{severity-info}** | `#418cd8` |
+| Unknown | **{severity-unknown}** | `#bababa` |
+
+## Alert details page
+
+Navigate to the Alert details view by visiting the [Alert list](./alerts.md)
+and selecting an alert from the list. You need least Developer [permissions](../../user/permissions.md)
+to access alerts.
+
+TIP: **Tip:**
+To review live examples of GitLab alerts, visit the
+[alert list](https://gitlab.com/gitlab-examples/ops/incident-setup/everyone/tanuki-inc/-/alert_management)
+for this demo project. Select any alert in the list to examine its alert details
+page.
+
+Alerts provide **Overview** and **Alert details** tabs to give you the right
+amount of information you need.
+
+### Alert overview tab
+
+The **Overview** tab provides basic information about the alert:
+
+![Alert Detail Overview](./img/alert_detail_overview_v13_1.png)
+
+### Alert details tab
+
+![Alert Full Details](./img/alert_detail_full_v13_1.png)
+
+#### Update an alert's status
+
+The Alert detail view enables you to update the Alert Status.
+See [Create and manage alerts in GitLab](./alerts.md) for more details.
+
+#### Create an issue from an alert
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/217745) in GitLab 13.1.
+
+The Alert detail view enables you to create an issue with a
+description populated from an alert. To create the issue,
+select the **Create Issue** button. You can then view the issue from the
+alert by selecting the **View Issue** button.
+
+Closing a GitLab issue associated with an alert changes the alert's status to
+Resolved. See [Create and manage alerts in GitLab](alerts.md) for more details
+about alert statuses.
+
+#### Update an alert's assignee
+
+> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3066) in GitLab 13.1.
+
+The Alert detail view allows users to update the Alert assignee.
+
+In large teams, where there is shared ownership of an alert, it can be
+difficult to track who is investigating and working on it. The Alert detail
+view enables you to update the Alert assignee:
+
+NOTE: **Note:**
+GitLab supports only a single assignee per alert.
+
+1. To display the list of current alerts, navigate to **Operations > Alerts**:
+
+ ![Alert List View Assignee(s)](./img/alert_list_assignees_v13_1.png)
+
+1. Select your desired alert to display its **Alert Details View**:
+
+ ![Alert Details View Assignee(s)](./img/alert_details_assignees_v13_1.png)
+
+1. If the right sidebar is not expanded, select
+ **{angle-double-right}** **Expand sidebar** to expand it.
+1. In the right sidebar, locate the **Assignee**, and then select **Edit**.
+ From the dropdown menu, select each user you want to assign to the alert.
+ GitLab creates a [to-do item](../../user/todos.md) for each user.
+
+ ![Alert Details View Assignee(s)](./img/alert_todo_assignees_v13_1.png)
+
+To remove an assignee, select **Edit** next to the **Assignee** dropdown menu
+and deselect the user from the list of assignees, or select **Unassigned**.
+
+#### Alert system notes
+
+> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3066) in GitLab 13.1.
+
+When you take action on an alert, this is logged as a system note,
+which is visible in the Alert Details view. This gives you a linear
+timeline of the alert's investigation and assignment history.
+
+The following actions will result in a system note:
+
+- [Updating the status of an alert](#update-an-alerts-status)
+- [Creating an issue based on an alert](#create-an-issue-from-an-alert)
+- [Assignment of an alert to a user](#update-an-alerts-assignee)
+
+![Alert Details View System Notes](./img/alert_detail_system_notes_v13_1.png)
+
+#### Create a to do from an alert
+
+> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3066) in GitLab 13.1.
+
+You can manually create [To-Do list items](../../user/todos.md) for yourself
+from the Alert details screen, and view them later on your **To-Do List**. To
+add a to do:
+
+1. To display the list of current alerts, navigate to **Operations > Alerts**.
+1. Select your desired alert to display its **Alert Management Details View**.
+1. Select the **Add a To-Do** button in the right sidebar:
+
+ ![Alert Details Add A To Do](./img/alert_detail_add_todo_v13_1.png)
+
+Select the **To-Do List** **{todo-done}** in the navigation bar to view your current to-do list.
+
+![Alert Details Added to do](./img/alert_detail_added_todo_v13_1.png)
+
+#### View an alert's metrics data
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/217768) in GitLab 13.2.
+
+To view the metrics for an alert:
+
+ 1. Sign in as a user with Developer or higher [permissions](../../user/permissions.md).
+ 1. Navigate to **Operations > Alerts**.
+ 1. Select the alert you want to view.
+ 1. Below the title of the alert, select the **Metrics** tab.
+
+![Alert Metrics View](img/alert_detail_metrics_v13_2.png)
+
+For GitLab-managed Prometheus instances, metrics data is available for the
+alert, making it easy to see surrounding behavior. For information about
+setting up alerts, see [Managed Prometheus instances](../metrics/alerts.md#managed-prometheus-instances).
+
+For externally-managed Prometheus instances, you can configure your alerting
+rules to display a chart in the alert. For information about how to configure
+your alerting rules, see [Embedding metrics based on alerts in incident issues](../metrics/embed.md#embedding-metrics-based-on-alerts-in-incident-issues). See
+[External Prometheus instances](../metrics/alerts.md#external-prometheus-instances)
+for information about setting up alerts for your self-managed Prometheus
+instance.
+
+### Use cases for assigning alerts
+
+Consider a team formed by different sections of monitoring, collaborating on a
+single application. After an alert surfaces, it's extremely important to route
+the alert to the team members who can address and resolve the alert.
+
+Assigning Alerts eases collaboration and delegation. All assignees are shown in
+your team's work-flows, and all assignees receive notifications, simplifying
+communication and ownership of the alert.
+
+After completing their portion of investigating or fixing the alert, users can
+unassign their account from the alert when their role is complete. You can
+update the alert on the [Alert list](./alerts.md) to reflect if the alert has
+been resolved.
+
+### View an alert's logs
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/217768) in GitLab 13.3.
+
+To view the logs for an alert:
+
+ 1. Sign in as a user with Developer or higher [permissions](../../user/permissions.md).
+ 1. Navigate to **Operations > Alerts**.
+ 1. Select the alert you want to view.
+ 1. Below the title of the alert, select the **Metrics** tab.
+ 1. Select the [menu](../metrics/dashboards/index.md#chart-context-menu) of
+ the metric chart to view options.
+ 1. Select **View logs**.
+
+For additional information, see [View logs from metrics panel](#view-logs-from-metrics-panel).
+
+### Embed metrics in incidents and issues
+
+You can embed metrics anywhere [GitLab Markdown](../../user/markdown.md) is
+used, such as descriptions, comments on issues, and merge requests. Embedding
+metrics helps you share them when discussing incidents or performance issues.
+You can output the dashboard directly into any issue, merge request, epic, or
+any other Markdown text field in GitLab by
+[copying and pasting the link to the metrics dashboard](../metrics/embed.md#embedding-gitlab-managed-kubernetes-metrics).
+
+You can embed both [GitLab-hosted metrics](../metrics/embed.md) and
+[Grafana metrics](../metrics/embed_grafana.md) in incidents and issue
+templates.
+
+#### Context menu
+
+You can view more details about an embedded metrics panel from the context
+menu. To access the context menu, select the **{ellipsis_v}** **More actions**
+dropdown box above the upper right corner of the panel. For a list of options,
+see [Chart context menu](../metrics/dashboards/index.md#chart-context-menu).
+
+##### View logs from metrics panel
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/201846) in GitLab Ultimate 12.8.
+> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/25455) to [GitLab Core](https://about.gitlab.com/pricing/) 12.9.
+
+Viewing logs from a metrics panel can be useful if you're triaging an
+application incident and need to [explore logs](../metrics/dashboards/index.md#chart-context-menu)
+from across your application. These logs help you understand what's affecting
+your application's performance and how to resolve any problems.
diff --git a/doc/operations/incident_management/generic_alerts.md b/doc/operations/incident_management/generic_alerts.md
index 11d4dbc6924..a8f2f9a58a6 100644
--- a/doc/operations/incident_management/generic_alerts.md
+++ b/doc/operations/incident_management/generic_alerts.md
@@ -1,126 +1,5 @@
---
-stage: Monitor
-group: Health
-info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+redirect_to: alert_notifications.md
---
-# Generic alerts integration
-
-> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/13203) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.4.
-> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/42640) to [GitLab Core](https://about.gitlab.com/pricing/) in 12.8.
-
-GitLab can accept alerts from any source via a generic webhook receiver.
-When you set up the generic alerts integration, a unique endpoint will
-be created which can receive a payload in JSON format, and will in turn
-create an issue with the payload in the body of the issue. You can always
-[customize the payload](#customizing-the-payload) to your liking.
-
-The entire payload will be posted in the issue discussion as a comment
-authored by the GitLab Alert Bot.
-
-NOTE: **Note:**
-In GitLab versions 13.1 and greater, you can configure
-[External Prometheus instances](../metrics/alerts.md#external-prometheus-instances)
-to use this endpoint.
-
-## Setting up generic alerts
-
-To obtain credentials for setting up a generic alerts integration:
-
-- Sign in to GitLab as a user with maintainer [permissions](../../user/permissions.md) for a project.
-- Navigate to the **Operations** page for your project, depending on your installed version of GitLab:
- - *In GitLab versions 13.1 and greater,* navigate to **Settings > Operations** in your project.
- - *In GitLab versions prior to 13.1,* navigate to **Settings > Integrations** in your project. GitLab will display a banner encouraging you to enable the Alerts endpoint in **Settings > Operations** instead.
-- Click **Alerts endpoint**.
-- Toggle the **Active** alert setting to display the **URL** and **Authorization Key** for the webhook configuration.
-
-## Customizing the payload
-
-You can customize the payload by sending the following parameters. All fields other than `title` are optional:
-
-| Property | Type | Description |
-| -------- | ---- | ----------- |
-| `title` | String | The title of the incident. Required. |
-| `description` | String | A high-level summary of the problem. |
-| `start_time` | DateTime | The time of the incident. If none is provided, a timestamp of the issue will be used. |
-| `end_time` | DateTime | For existing alerts only. When provided, the alert is resolved and the associated incident is closed. |
-| `service` | String | The affected service. |
-| `monitoring_tool` | String | The name of the associated monitoring tool. |
-| `hosts` | String or Array | One or more hosts, as to where this incident occurred. |
-| `severity` | String | The severity of the alert. Must be one of `critical`, `high`, `medium`, `low`, `info`, `unknown`. Default is `critical`. |
-| `fingerprint` | String or Array | The unique identifier of the alert. This can be used to group occurrences of the same alert. |
-| `gitlab_environment_name` | String | The name of the associated GitLab [environment](../../ci/environments/index.md). This can be used to associate your alert to your environment. |
-
-You can also add custom fields to the alert's payload. The values of extra parameters
-are not limited to primitive types, such as strings or numbers, but can be a nested
-JSON object. For example:
-
-```json
-{ "foo": { "bar": { "baz": 42 } } }
-```
-
-TIP: **Payload size:**
-Ensure your requests are smaller than the [payload application limits](../../administration/instance_limits.md#generic-alert-json-payloads).
-
-Example request:
-
-```shell
-curl --request POST \
- --data '{"title": "Incident title"}' \
- --header "Authorization: Bearer <authorization_key>" \
- --header "Content-Type: application/json" \
- <url>
-```
-
-The `<authorization_key>` and `<url>` values can be found when [setting up generic alerts](#setting-up-generic-alerts).
-
-Example payload:
-
-```json
-{
- "title": "Incident title",
- "description": "Short description of the incident",
- "start_time": "2019-09-12T06:00:55Z",
- "service": "service affected",
- "monitoring_tool": "value",
- "hosts": "value",
- "severity": "high",
- "fingerprint": "d19381d4e8ebca87b55cda6e8eee7385",
- "foo": {
- "bar": {
- "baz": 42
- }
- }
-}
-```
-
-## Triggering test alerts
-
-> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3066) in GitLab Core in 13.2.
-
-After a [project maintainer or owner](#setting-up-generic-alerts)
-[configures generic alerts](#setting-up-generic-alerts), you can trigger a
-test alert to confirm your integration works properly.
-
-1. Sign in as a user with Developer or greater [permissions](../../user/permissions.md).
-1. Navigate to **Settings > Operations** in your project.
-1. Click **Alerts endpoint** to expand the section.
-1. Enter a sample payload in **Alert test payload** (valid JSON is required).
-1. Click **Test alert payload**.
-
-GitLab displays an error or success message, depending on the outcome of your test.
-
-## Automatic grouping of identical alerts **(PREMIUM)**
-
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/214557) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.2.
-
-In GitLab versions 13.2 and greater, GitLab groups alerts based on their payload.
-When an incoming alert contains the same payload as another alert (excluding the
-`start_time` and `hosts` attributes), GitLab groups these alerts together and
-displays a counter on the
-[Alert Management List](./incidents.md)
-and details pages.
-
-If the existing alert is already `resolved`, then a new alert will be created instead.
-
-![Alert Management List](./img/alert_list_v13_1.png)
+This document was moved to [another location](alert_notifications.md).
diff --git a/doc/operations/incident_management/incidents.md b/doc/operations/incident_management/incidents.md
index 3ff02b3dc6b..1086885a58b 100644
--- a/doc/operations/incident_management/incidents.md
+++ b/doc/operations/incident_management/incidents.md
@@ -9,7 +9,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
While no configuration is required to use the [manual features](#create-an-incident-manually)
of incident management, some simple [configuration](#configure-incidents) is needed to automate incident creation.
-For users with at least Developer [permissions](../../user/permissions.md), the
+For users with at least Guest [permissions](../../user/permissions.md), the
Incident Management list is available at **Operations > Incidents**
in your project's sidebar. The list contains the following metrics:
@@ -28,7 +28,8 @@ in your project's sidebar. The list contains the following metrics:
- **{severity-unknown}** **Unknown**
NOTE: **Note:**
- Editing incident severity on the incident details page was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/229402) in GitLab 13.4.
+ Editing incident severity on the incident details page was
+ [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/229402) in GitLab 13.4.
- **Incident** - The description of the incident, which attempts to capture the
most meaningful data.
@@ -71,7 +72,7 @@ to create issues when alerts are triggered:
[Trigger actions from alerts](../metrics/alerts.md#trigger-actions-from-alerts) **(ULTIMATE)**.
1. To create issues from alerts, select the template in the **Issue Template**
select box.
-1. To send [separate email notifications](index.md#notify-developers-of-alerts) to users
+1. To send [separate email notifications](alert_notifications.md#email-notifications) to users
with [Developer permissions](../../user/permissions.md), select
**Send a separate email notification to Developers**.
1. Click **Save changes**.
@@ -84,7 +85,7 @@ when you receive notification that the alert is resolved.
## Create an incident manually
-If you have at least Developer [permissions](../../user/permissions.md), to create an Incident, you have two options.
+If you have at least Guest [permissions](../../user/permissions.md), to create an Incident, you have two options.
### From the Incidents List
diff --git a/doc/operations/incident_management/index.md b/doc/operations/incident_management/index.md
index 28e69a6bbfe..df190f52393 100644
--- a/doc/operations/incident_management/index.md
+++ b/doc/operations/incident_management/index.md
@@ -14,28 +14,7 @@ being developed, efficiency and awareness can be increased.
GitLab offers solutions for handling incidents in your applications and services,
such as [setting up Prometheus alerts](#configure-prometheus-alerts),
-[displaying metrics](./alert_details.md#embed-metrics-in-incidents-and-issues), and sending notifications.
-
-## Alert notifications
-
-### Slack Notifications
-
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216326) in GitLab 13.1.
-
-You can be alerted via a Slack message when a new alert has been received.
-
-See the [Slack Notifications Service docs](../../user/project/integrations/slack.md) for information on how to set this up.
-
-### Notify developers of alerts
-
-GitLab can react to the alerts triggered from your applications and services
-by creating issues and alerting developers through email. By default, GitLab
-sends these emails to [owners and maintainers](../../user/permissions.md) of the project.
-These emails contain details of the alert, and a link for more information.
-
-To send separate email notifications to users with
-[Developer permissions](../../user/permissions.md), see
-[Configure incidents](./incidents.md#configure-incidents).
+[displaying metrics](./alerts.md#embed-metrics-in-incidents-and-issues), and sending notifications.
## Configure Prometheus alerts
@@ -54,7 +33,7 @@ When [configuring the generic alerts integration](./generic_alerts.md), GitLab
creates a unique endpoint which receives a JSON-formatted, customizable payload.
After configuration, you can manage your alerts using either the
-[alerts section](./alerts.md) or the [alert details section](./alert_details.md).
+[alerts section](./alerts.md) or the [alert details section](./alerts.md#alert-details-page).
## Integrate incidents with Slack
diff --git a/doc/operations/index.md b/doc/operations/index.md
index 7ab34502277..b49487b60e6 100644
--- a/doc/operations/index.md
+++ b/doc/operations/index.md
@@ -35,7 +35,7 @@ using metrics and logs, and promote the critical alerts to incidents.
Are your alerts too noisy? Alerts configured on GitLab metrics can configured
and fine-tuned in GitLab immediately following a fire-fight.
-- [Manage alerts and incidents](../user/incident_management/index.md) in GitLab.
+- [Manage alerts and incidents](incident_management/index.md) in GitLab.
- [Configure alerts for metrics](metrics/alerts.md#set-up-alerts-for-prometheus-metrics) in GitLab.
- Create a [status page](incident_management/status_page.md)
to communicate efficiently to your users during an incident.
diff --git a/doc/operations/metrics/alerts.md b/doc/operations/metrics/alerts.md
index 5b880ab9746..c0a16da3702 100644
--- a/doc/operations/metrics/alerts.md
+++ b/doc/operations/metrics/alerts.md
@@ -15,7 +15,7 @@ your team when environment performance falls outside of the boundaries you set.
## Managed Prometheus instances
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/6590) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.2 for [custom metrics](index.md#adding-custom-metrics), and GitLab 11.3 for [library metrics](../../user/project/integrations/prometheus_library/metrics.md).
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/6590) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.2 for [custom metrics](index.md#adding-custom-metrics), and GitLab 11.3 for [library metrics](../../user/project/integrations/prometheus_library/index.md).
For managed Prometheus instances using auto configuration, you can
[configure alerts for metrics](index.md#adding-custom-metrics) directly in the
@@ -70,7 +70,8 @@ receivers:
bearer_token: 9e1cbfcd546896a9ea8be557caf13a76
send_resolved: true
url: http://192.168.178.31:3001/root/manual_prometheus/prometheus/alerts/notify.json
- ...
+ # Rest of configuration omitted
+ # ...
```
For GitLab to associate your alerts with an [environment](../../ci/environments/index.md),
diff --git a/doc/operations/metrics/dashboards/img/metrics_dashboard_panel_preview_v13_3.png b/doc/operations/metrics/dashboards/img/metrics_dashboard_panel_preview_v13_3.png
index 4f6d3b3dfa4..3c3203265e1 100644
--- a/doc/operations/metrics/dashboards/img/metrics_dashboard_panel_preview_v13_3.png
+++ b/doc/operations/metrics/dashboards/img/metrics_dashboard_panel_preview_v13_3.png
Binary files differ
diff --git a/doc/operations/metrics/dashboards/index.md b/doc/operations/metrics/dashboards/index.md
index ffcb7dc92c6..42dc5bbfb6d 100644
--- a/doc/operations/metrics/dashboards/index.md
+++ b/doc/operations/metrics/dashboards/index.md
@@ -51,16 +51,16 @@ To create a new dashboard from the command line:
- group: 'Group Title'
panels:
- type: area-chart
- title: "Chart Title"
- y_label: "Y-Axis"
+ title: 'Chart Title'
+ y_label: 'Y-Axis'
y_axis:
format: number
precision: 0
metrics:
- id: my_metric_id
query_range: 'http_requests_total'
- label: "Instance: {{instance}}, method: {{method}}"
- unit: "count"
+ label: 'Instance: {{instance}}, method: {{method}}'
+ unit: 'count'
```
1. Save the file, commit, and push to your repository. The file must be present in your **default** branch.
diff --git a/doc/operations/metrics/dashboards/panel_types.md b/doc/operations/metrics/dashboards/panel_types.md
index b2cbdcb88d9..20f6e25cb6b 100644
--- a/doc/operations/metrics/dashboards/panel_types.md
+++ b/doc/operations/metrics/dashboards/panel_types.md
@@ -17,16 +17,16 @@ dashboard: 'Dashboard Title'
panel_groups:
- group: 'Group Title'
panels:
- - type: area-chart # or line-chart
+ - type: area-chart # or line-chart
title: 'Area Chart Title'
- y_label: "Y-Axis"
+ y_label: 'Y-Axis'
y_axis:
format: number
precision: 0
metrics:
- id: area_http_requests_total
query_range: 'http_requests_total'
- label: "Instance: {{instance}}, Method: {{method}}"
+ label: 'Instance: {{instance}}, Method: {{method}}'
unit: "count"
```
@@ -55,23 +55,23 @@ panel_groups:
- group: 'Group Title'
panels:
- type: anomaly-chart
- title: "Chart Title"
+ title: 'Chart Title'
y_label: "Y-Axis"
metrics:
- id: anomaly_requests_normal
query_range: 'http_requests_total'
- label: "# of Requests"
- unit: "count"
+ label: '# of Requests'
+ unit: 'count'
metrics:
- id: anomaly_requests_upper_limit
query_range: 10000
- label: "Max # of requests"
- unit: "count"
+ label: 'Max # of requests'
+ unit: 'count'
metrics:
- id: anomaly_requests_lower_limit
query_range: 2000
- label: "Min # of requests"
- unit: "count"
+ label: 'Min # of requests'
+ unit: 'count'
```
Note the following properties:
@@ -93,13 +93,13 @@ panel_groups:
- group: 'Group title'
panels:
- type: bar
- title: "Http Handlers"
+ title: 'HTTP Handlers'
x_label: 'Response Size'
y_axis:
- name: "Handlers"
+ name: 'Handlers'
metrics:
- id: prometheus_http_response_size_bytes_bucket
- query_range: "sum(increase(prometheus_http_response_size_bytes_bucket[1d])) by (handler)"
+ query_range: 'sum(increase(prometheus_http_response_size_bytes_bucket[1d])) by (handler)'
unit: 'Bytes'
```
@@ -121,13 +121,13 @@ dashboard: 'Dashboard Title'
panel_groups:
- group: 'Group title'
panels:
- - title: "Column"
- type: "column"
+ - title: 'Column'
+ type: 'column'
metrics:
- id: 1024_memory
query: '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'
unit: MB
- label: "Memory Usage"
+ label: 'Memory Usage'
```
Note the following properties:
@@ -153,19 +153,19 @@ panel_groups:
priority: 5
panels:
- type: 'stacked-column'
- title: "Stacked column"
- y_label: "y label"
+ title: 'Stacked column'
+ y_label: 'y label'
x_label: 'x label'
metrics:
- id: memory_1
query_range: 'memory_query'
- label: "memory query 1"
- unit: "count"
+ label: 'memory query 1'
+ unit: 'count'
series_name: 'group 1'
- id: memory_2
query_range: 'memory_query_2'
- label: "memory query 2"
- unit: "count"
+ label: 'memory query 2'
+ unit: 'count'
series_name: 'group 2'
```
@@ -185,13 +185,13 @@ dashboard: 'Dashboard Title'
panel_groups:
- group: 'Group Title'
panels:
- - title: "Single Stat"
- type: "single-stat"
+ - title: 'Single Stat'
+ type: 'single-stat'
metrics:
- id: 10
query: 'max(go_memstats_alloc_bytes{job="prometheus"})'
unit: MB
- label: "Total"
+ label: 'Total'
```
Note the following properties:
@@ -215,14 +215,14 @@ dashboard: 'Dashboard Title'
panel_groups:
- group: 'Group Title'
panels:
- - title: "Single Stat"
- type: "single-stat"
+ - title: 'Single Stat'
+ type: 'single-stat'
max_value: 100
metrics:
- id: 10
query: 'max(go_memstats_alloc_bytes{job="prometheus"})'
unit: '%'
- label: "Total"
+ label: 'Total'
```
For example, if you have a query value of `53.6`, adding `%` as the unit results in a single stat value of `53.6%`, but if the maximum expected value of the query is `120`, the value would be `44.6%`. Adding the `max_value` causes the correct percentage value to display.
@@ -242,15 +242,15 @@ dashboard: 'Dashboard Title'
panel_groups:
- group: 'Group Title'
panels:
- - title: "Gauge"
- type: "gauge"
+ - title: 'Gauge'
+ type: 'gauge'
min_value: 0
max_value: 1000
split: 5
thresholds:
values: [60, 90]
- mode: "percentage"
- format: "kilobytes"
+ mode: 'percentage'
+ format: 'kilobytes'
metrics:
- id: 10
query: 'floor(max(prometheus_http_response_size_bytes_bucket)/1000)'
@@ -289,13 +289,13 @@ dashboard: 'Dashboard Title'
panel_groups:
- group: 'Group Title'
panels:
- - title: "Heatmap"
- type: "heatmap"
+ - title: 'Heatmap'
+ type: 'heatmap'
metrics:
- id: 10
query: 'sum(rate(nginx_upstream_responses_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[60m])) by (status_code)'
unit: req/sec
- label: "Status code"
+ label: 'Status code'
```
Note the following properties:
diff --git a/doc/operations/metrics/dashboards/yaml.md b/doc/operations/metrics/dashboards/yaml.md
index f92ba4079e9..fac2c7b40d7 100644
--- a/doc/operations/metrics/dashboards/yaml.md
+++ b/doc/operations/metrics/dashboards/yaml.md
@@ -103,8 +103,8 @@ When a static label is used and a query returns multiple time series, then all t
metrics:
- id: my_metric_id
query_range: 'http_requests_total'
- label: "Time Series"
- unit: "count"
+ label: 'Time Series'
+ unit: 'count'
```
This may render a legend like this:
@@ -117,8 +117,8 @@ For labels to be more explicit, using variables that reflect time series labels
metrics:
- id: my_metric_id
query_range: 'http_requests_total'
- label: "Instance: {{instance}}, method: {{method}}"
- unit: "count"
+ label: 'Instance: {{instance}}, method: {{method}}'
+ unit: 'count'
```
The resulting rendered legend will look like this:
@@ -131,8 +131,8 @@ There is also a shorthand value for dynamic dashboard labels that make use of on
metrics:
- id: my_metric_id
query_range: 'http_requests_total'
- label: "Method"
- unit: "count"
+ label: 'Method'
+ unit: 'count'
```
This works by lowercasing the value of `label` and, if there are more words separated by spaces, replacing those spaces with an underscore (`_`). The transformed value is then checked against the labels of the time series returned by the Prometheus query. If a time series label is found that is equal to the transformed value, then the label value will be used and rendered in the legend like this:
diff --git a/doc/push_rules/push_rules.md b/doc/push_rules/push_rules.md
index 1643c96d229..032ec1cb8ca 100644
--- a/doc/push_rules/push_rules.md
+++ b/doc/push_rules/push_rules.md
@@ -97,7 +97,7 @@ The following options are available.
| Restrict by commit message (negative match)| **Starter** 11.1 | Only commit messages that do not match this regular expression are allowed to be pushed. Leave empty to allow any commit message. Uses multiline mode, which can be disabled using `(?-m)`. |
| Restrict by branch name | **Starter** 9.3 | Only branch names that match this regular expression are allowed to be pushed. Leave empty to allow any branch name. |
| Restrict by commit author's email | **Starter** 7.10 | Only commit author's email that match this regular expression are allowed to be pushed. Leave empty to allow any email. |
-| Prohibited file names | **Starter** 7.10 | Any committed filenames that match this regular expression are not allowed to be pushed. Leave empty to allow any filenames. |
+| Prohibited file names | **Starter** 7.10 | Any committed filenames that match this regular expression and do not already exist in the repository are not allowed to be pushed. Leave empty to allow any filenames. See [common examples](#prohibited-file-names). |
| Maximum file size | **Starter** 7.12 | Pushes that contain added or updated files that exceed this file size (in MB) are rejected. Set to 0 to allow files of any size. Files tracked by Git LFS are exempted. |
TIP: **Tip:**
@@ -178,6 +178,44 @@ pry.history
bash_history
```
+## Prohibited file names
+
+> Introduced in [GitLab Starter](https://about.gitlab.com/pricing/) 7.10.
+
+Each file name contained in a Git push is compared to the regular expression in this field. Filenames in Git consist of both the file's name and any directory that may precede it. A singular regular expression can contain multiple independent matches used as exclusions. File names can be broadly matched to any location in the repository, or restricted to specific locations. Filenames can also be partial matches used to exclude file types by extension.
+
+The following examples make use of regex string boundary characters which match the beginning of a string (`^`), and the end (`$`). They also include instances where either the directory path or the filename can include `.` or `/`. Both of these special regex characters have to be escaped with a backslash `\` to be used as normal characters in a match condition.
+
+Example: prevent pushing any `.exe` files to any location in the repository. This is an example of a partial match, which can match any filename that contains `.exe` at the end:
+
+```plaintext
+\.exe$
+```
+
+Example: prevent a specific configuration file in the repository root from being pushed:
+
+```plaintext
+^config\.yml$
+```
+
+Example: prevent a specific configuration file in a known directory from being pushed:
+
+```plaintext
+^directory-name\/config\.yml$
+```
+
+Example: prevent the specific file named `install.exe` from being pushed to any location in the repository. Note that the parenthesized expression `(^|\/)` will match either a file following a directory separator or a file in the root directory of the repository:
+
+```plaintext
+(^|\/)install\.exe$
+```
+
+Example: combining all of the above in a single expression. Note that all of the preceding expressions rely on the end of string character `$`, so we can move that part of each expression to the end of the grouped collection of match conditions where it will be appended to all matches:
+
+```plaintext
+(\.exe|^config\.yml|^directory-name\/config\.yml|(^|\/)install\.exe)$
+```
+
<!-- ## Troubleshooting
Include any troubleshooting steps that you can foresee. If you know beforehand what issues
diff --git a/doc/raketasks/README.md b/doc/raketasks/README.md
index 523486d5137..7aec74f1243 100644
--- a/doc/raketasks/README.md
+++ b/doc/raketasks/README.md
@@ -21,7 +21,7 @@ The following are available Rake tasks:
| [Clean up](cleanup.md) | Clean up unneeded items from GitLab instances. |
| [Development](../development/rake_tasks.md) | Tasks for GitLab contributors. |
| [Doctor tasks](../administration/raketasks/doctor.md) | Checks for data integrity issues. |
-| [Elasticsearch](../integration/elasticsearch.md#gitlab-elasticsearch-rake-tasks) **(STARTER ONLY)** | Maintain Elasticsearch in a GitLab instance. |
+| [Elasticsearch](../integration/elasticsearch.md#gitlab-advanced-search-rake-tasks) **(STARTER ONLY)** | Maintain Elasticsearch in a GitLab instance. |
| [Enable namespaces](features.md) | Enable usernames and namespaces for user projects. |
| [General maintenance](../administration/raketasks/maintenance.md) | General maintenance and self-check tasks. |
| [Geo maintenance](../administration/raketasks/geo.md) **(PREMIUM ONLY)** | [Geo](../administration/geo/index.md)-related maintenance. |
diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md
index 68b076258ce..c050931ce3b 100644
--- a/doc/raketasks/backup_restore.md
+++ b/doc/raketasks/backup_restore.md
@@ -10,48 +10,41 @@ of GitLab on which it was created. The best way to migrate your repositories
from one server to another is through backup restore.
CAUTION: **Warning:**
-GitLab will not backup items that are not stored on the
-filesystem. If using [object storage](../administration/object_storage.md),
-remember to enable backups with your object storage provider if desired.
+GitLab won't back up items that aren't stored in the filesystem. If you're
+using [object storage](../administration/object_storage.md), be sure to enable
+backups with your object storage provider, if desired.
## Requirements
-In order to be able to backup and restore, you need one essential tool
-installed on your system.
+To be able to backup and restore, ensure that Rsync is installed on your
+system. If you installed GitLab:
-- **Rsync**: If you installed GitLab:
- - Using the Omnibus package, you're all set.
- - From source, make sure `rsync` is installed. For example:
+- _Using the Omnibus package_, you're all set.
+- _From source_, you need to determine if `rsync` is installed. For example:
- ```shell
- # Debian/Ubuntu
- sudo apt-get install rsync
+ ```shell
+ # Debian/Ubuntu
+ sudo apt-get install rsync
- # RHEL/CentOS
- sudo yum install rsync
- ```
+ # RHEL/CentOS
+ sudo yum install rsync
+ ```
## Backup timestamp
-NOTE: **Note:**
-In GitLab 9.2 the timestamp format was changed from `EPOCH_YYYY_MM_DD` to
-`EPOCH_YYYY_MM_DD_GitLab_version`, for example `1493107454_2018_04_25`
-would become `1493107454_2018_04_25_10.6.4-ce`.
-
The backup archive will be saved in `backup_path`, which is specified in the
-`config/gitlab.yml` file.
-The filename will be `[TIMESTAMP]_gitlab_backup.tar`, where `TIMESTAMP`
-identifies the time at which each backup was created, plus the GitLab version.
-The timestamp is needed if you need to restore GitLab and multiple backups are
-available.
+`config/gitlab.yml` file. The filename will be `[TIMESTAMP]_gitlab_backup.tar`,
+where `TIMESTAMP` identifies the time at which each backup was created, plus
+the GitLab version. The timestamp is needed if you need to restore GitLab and
+multiple backups are available.
For example, if the backup name is `1493107454_2018_04_25_10.6.4-ce_gitlab_backup.tar`,
-then the timestamp is `1493107454_2018_04_25_10.6.4-ce`.
+the timestamp is `1493107454_2018_04_25_10.6.4-ce`.
## Back up GitLab
-GitLab provides a simple command line interface to back up your whole instance.
-It backs up your:
+GitLab provides a command line interface to back up your entire instance,
+including:
- Database
- Attachments
@@ -63,47 +56,59 @@ It backs up your:
- GitLab Pages content
CAUTION: **Warning:**
-GitLab does not back up any configuration files, SSL certificates, or system files.
-You are highly advised to [read about storing configuration files](#storing-configuration-files).
+GitLab does not back up any configuration files, SSL certificates, or system
+files. You are highly advised to read about [storing configuration files](#storing-configuration-files).
-Use this command if you've installed GitLab with the Omnibus package:
+Depending on your version of GitLab, use the following command if you installed
+GitLab using the Omnibus package:
-```shell
-sudo gitlab-backup create
-```
+- GitLab 12.2 or later:
+
+ ```shell
+ sudo gitlab-backup create
+ ```
-NOTE: **Note:**
-For GitLab 12.1 and earlier, use `gitlab-rake gitlab:backup:create`.
+- GitLab 12.1 and earlier:
-Use this if you've installed GitLab from source:
+ ```shell
+ gitlab-rake gitlab:backup:create
+ ```
+
+If you installed GitLab from source, use the following command:
```shell
sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production
```
-If you are running GitLab within a Docker container, you can run the backup from the host:
+If you're running GitLab from within a Docker container, run the backup from
+the host, based on your installed version of GitLab:
-```shell
-docker exec -t <container name> gitlab-backup create
-```
+- GitLab 12.2 or later:
+
+ ```shell
+ docker exec -t <container name> gitlab-backup create
+ ```
-NOTE: **Note:**
-For GitLab 12.1 and earlier, use `gitlab-rake gitlab:backup:create`.
+- GitLab 12.1 and earlier:
-If you are using the [GitLab Helm chart](https://gitlab.com/gitlab-org/charts/gitlab) on a
-Kubernetes cluster, you can run the backup task using `backup-utility` script on
-the GitLab task runner pod via `kubectl`. Refer to [backing up a GitLab installation](https://gitlab.com/gitlab-org/charts/gitlab/blob/master/doc/backup-restore/backup.md#backing-up-a-gitlab-installation) for more details:
+ ```shell
+ gitlab-rake gitlab:backup:create
+ ```
+
+If you're using the [GitLab Helm chart](https://gitlab.com/gitlab-org/charts/gitlab)
+on a Kubernetes cluster, you can run the backup task by using `kubectl` to run the `backup-utility`
+script on the GitLab task runner pod. For more details, see
+[backing up a GitLab installation](https://gitlab.com/gitlab-org/charts/gitlab/blob/master/doc/backup-restore/backup.md#backing-up-a-gitlab-installation).
```shell
kubectl exec -it <gitlab task-runner pod> backup-utility
```
-Similarly to the Kubernetes case, if you have scaled out your GitLab
-cluster to use multiple application servers, you should pick a
-designated node (that won't be auto-scaled away) for running the
-backup Rake task. Because the backup Rake task is tightly coupled to
-the main Rails application, this is typically a node on which you're
-also running Unicorn/Puma and/or Sidekiq.
+Similar to the Kubernetes case, if you have scaled out your GitLab cluster to
+use multiple application servers, you should pick a designated node (that won't
+be auto-scaled away) for running the backup Rake task. Because the backup Rake
+task is tightly coupled to the main Rails application, this is typically a node
+on which you're also running Unicorn/Puma or Sidekiq.
Example output:
@@ -136,11 +141,11 @@ Deleting old backups... [SKIPPING]
### Storing configuration files
-The [backup Rake task](#back-up-gitlab) GitLab provides
-does **not** store your configuration files. The primary reason for this is that your
-database contains encrypted information for two-factor authentication, the CI/CD
-'secure variables', and so on. Storing encrypted information along with its key in the
-same place defeats the purpose of using encryption in the first place.
+The [backup Rake task](#back-up-gitlab) GitLab provides does _not_ store your
+configuration files. The primary reason for this is that your database contains
+items including encrypted information for two-factor authentication and the
+CI/CD _secure variables_. Storing encrypted information in the same location
+as its key defeats the purpose of using encryption in the first place.
CAUTION: **Warning:**
The secrets file is essential to preserve your database encryption key.
@@ -158,30 +163,31 @@ For installation from source:
- `/home/git/gitlab/config/gitlab.yml`
For [Docker installations](https://docs.gitlab.com/omnibus/docker/), you must
-back up the volume where the configuration files are stored. If you have created
-the GitLab container according to the documentation, it should be under
-`/srv/gitlab/config`.
+back up the volume where the configuration files are stored. If you created
+the GitLab container according to the documentation, it should be in the
+`/srv/gitlab/config` directory.
-For [GitLab Helm chart Installations](https://gitlab.com/gitlab-org/charts/gitlab) on a
-Kubernetes cluster, you must follow the [Backup the secrets](https://docs.gitlab.com/charts/backup-restore/backup.html#backup-the-secrets) instructions.
+For [GitLab Helm chart installations](https://gitlab.com/gitlab-org/charts/gitlab)
+on a Kubernetes cluster, you must follow the
+[Backup the secrets](https://docs.gitlab.com/charts/backup-restore/backup.html#backup-the-secrets)
+instructions.
You may also want to back up any TLS keys and certificates, and your
[SSH host keys](https://superuser.com/questions/532040/copy-ssh-keys-from-one-server-to-another-server/532079#532079).
-If you use Omnibus GitLab, see some additional information
-[to backup your configuration](https://docs.gitlab.com/omnibus/settings/backups.html).
+If you use Omnibus GitLab, review additional information to
+[backup your configuration](https://docs.gitlab.com/omnibus/settings/backups.html).
In the unlikely event that the secrets file is lost, see the
[troubleshooting section](#when-the-secrets-file-is-lost).
### Backup options
-The command line tool GitLab provides to backup your instance can take more options.
+The command line tool GitLab provides to backup your instance can accept more
+options.
#### Backup strategy option
-> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/8728) in GitLab 8.17.
-
The default backup strategy is to essentially stream data from the respective
data locations to the backup using the Linux command `tar` and `gzip`. This works
fine in most cases, but can cause problems when data is rapidly changing.
@@ -203,8 +209,7 @@ To use the `copy` strategy instead of the default streaming strategy, specify
sudo gitlab-backup create STRATEGY=copy
```
-NOTE: **Note:**
-For GitLab 12.1 and earlier, use `gitlab-rake gitlab:backup:create`.
+Users of GitLab 12.1 and earlier should use the command `gitlab-rake gitlab:backup:create` instead.
#### Backup filename
@@ -212,34 +217,41 @@ CAUTION: **Warning:**
If you use a custom backup filename, you will not be able to
[limit the lifetime of the backups](#limit-backup-lifetime-for-local-files-prune-old-backups).
-By default a backup file is created according to the specification in [the Backup timestamp](#backup-timestamp) section above. You can however override the `[TIMESTAMP]` part of the filename by setting the `BACKUP` environment variable. For example:
+By default, a backup file is created according to the specification in the
+previous [Backup timestamp](#backup-timestamp) section. You can, however,
+override the `[TIMESTAMP]` portion of the filename by setting the `BACKUP`
+environment variable. For example:
```shell
sudo gitlab-backup create BACKUP=dump
```
-NOTE: **Note:**
-For GitLab 12.1 and earlier, use `gitlab-rake gitlab:backup:create`.
+Users of GitLab 12.1 and earlier should use the command `gitlab-rake gitlab:backup:create` instead.
-The resulting file will then be `dump_gitlab_backup.tar`. This is useful for systems that make use of rsync and incremental backups, and will result in considerably faster transfer speeds.
+The resulting file will then be `dump_gitlab_backup.tar`. This is useful for
+systems that make use of rsync and incremental backups, and will result in
+considerably faster transfer speeds.
#### Rsyncable
-To make sure the generated archive is intelligently transferable by rsync, the `GZIP_RSYNCABLE=yes` option can be set. This will set the `--rsyncable` option to `gzip`. This is only useful in combination with setting [the Backup filename option](#backup-filename).
+To ensure the generated archive is transferable by rsync, you can set the `GZIP_RSYNCABLE=yes`
+option. This sets the `--rsyncable` option to `gzip`, which is useful only in
+combination with setting [the Backup filename option](#backup-filename).
-Note that the `--rsyncable` option in `gzip` is not guaranteed to be available on all distributions. To verify that it is available in your distribution you can run `gzip --help` or consult the man pages.
+Note that the `--rsyncable` option in `gzip` isn't guaranteed to be available
+on all distributions. To verify that it's available in your distribution, run
+`gzip --help` or consult the man pages.
```shell
sudo gitlab-backup create BACKUP=dump GZIP_RSYNCABLE=yes
```
-NOTE: **Note:**
-For GitLab 12.1 and earlier, use `gitlab-rake gitlab:backup:create`.
+Users of GitLab 12.1 and earlier should use the command `gitlab-rake gitlab:backup:create` instead.
#### Excluding specific directories from the backup
-You can choose what should be exempt from the backup up by adding the environment variable `SKIP`.
-The available options are:
+You can choose what should be exempt from the backup by adding the environment
+variable `SKIP`. The available options are:
- `db` (database)
- `uploads` (attachments)
@@ -252,8 +264,8 @@ The available options are:
Use a comma to specify several options at the same time:
-All wikis will be backed up as part of the `repositories` group. Non-existent wikis
-will be skipped during a backup.
+All wikis will be backed up as part of the `repositories` group. Non-existent
+wikis will be skipped during a backup.
For Omnibus GitLab packages:
@@ -261,8 +273,7 @@ For Omnibus GitLab packages:
sudo gitlab-backup create SKIP=db,uploads
```
-NOTE: **Note:**
-For GitLab 12.1 and earlier, use `gitlab-rake gitlab:backup:create`.
+Users of GitLab 12.1 and earlier should use the command `gitlab-rake gitlab:backup:create` instead.
For installations from source:
@@ -317,13 +328,15 @@ sudo -u git -H GITLAB_ASSUME_YES=1 bundle exec rake gitlab:backup:restore RAILS_
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37158) in GitLab 13.3.
-Repositories can be backed up concurrently to help fully utilise CPU time. The following variables
-are available to modify the default behavior of the Rake task:
+Repositories can be backed up concurrently to help fully utilize CPU time. The
+following variables are available to modify the default behavior of the Rake
+task:
-- `GITLAB_BACKUP_MAX_CONCURRENCY` sets the maximum number of projects to backup at the same time.
- Defaults to 1.
-- `GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY` sets the maximum number of projects to backup at the same time on each storage. This allows the repository backups to be spread across storages.
- Defaults to 1.
+- `GITLAB_BACKUP_MAX_CONCURRENCY`: The maximum number of projects to back up at
+ the same time. Defaults to `1`.
+- `GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY`: The maximum number of projects to
+ back up at the same time on each storage. This allows the repository backups
+ to be spread across storages. Defaults to `1`.
For example, for Omnibus GitLab installations:
@@ -339,12 +352,11 @@ sudo -u git -H bundle exec rake gitlab:backup:create GITLAB_BACKUP_MAX_CONCURREN
#### Uploading backups to a remote (cloud) storage
-Starting with GitLab 7.4 you can let the backup script upload the `.tar` file it creates.
-It uses the [Fog library](http://fog.io/) to perform the upload.
-In the example below we use Amazon S3 for storage, but Fog also lets you use
-[other storage providers](http://fog.io/storage/). GitLab
-[imports cloud drivers](https://gitlab.com/gitlab-org/gitlab/blob/30f5b9a5b711b46f1065baf755e413ceced5646b/Gemfile#L88)
-for AWS, Google, OpenStack Swift, Rackspace, and Aliyun as well. A local driver is
+You can let the backup script upload (using the [Fog library](http://fog.io/))
+the `.tar` file it creates. In the following example, we use Amazon S3 for
+storage, but Fog also lets you use [other storage providers](http://fog.io/storage/).
+GitLab also [imports cloud drivers](https://gitlab.com/gitlab-org/gitlab/blob/30f5b9a5b711b46f1065baf755e413ceced5646b/Gemfile#L88)
+for AWS, Google, OpenStack Swift, Rackspace, and Aliyun. A local driver is
[also available](#uploading-to-locally-mounted-shares).
[Read more about using object storage with GitLab](../administration/object_storage.md).
@@ -367,11 +379,12 @@ For Omnibus GitLab packages:
gitlab_rails['backup_upload_remote_directory'] = 'my.s3.bucket'
```
-1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect
+1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure)
+ for the changes to take effect
##### Digital Ocean Spaces
-This example can be used for a bucket in Amsterdam (AMS3).
+This example can be used for a bucket in Amsterdam (AMS3):
1. Add the following to `/etc/gitlab/gitlab.rb`:
@@ -386,20 +399,20 @@ This example can be used for a bucket in Amsterdam (AMS3).
gitlab_rails['backup_upload_remote_directory'] = 'my.s3.bucket'
```
-1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect
+1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure)
+ for the changes to take effect
-NOTE: **Note:**
-If you see `400 Bad Request` by using Digital Ocean Spaces, the cause may be the
-usage of backup encryption. Remove or comment the line that
-contains `gitlab_rails['backup_encryption']` since Digital Ocean Spaces
-doesn't support encryption.
+If you see a `400 Bad Request` error message when using Digital Ocean Spaces,
+the cause may be the use of backup encryption. Because Digital Ocean Spaces
+doesn't support encryption, remove or comment the line that contains
+`gitlab_rails['backup_encryption']`.
##### 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).
+Not all S3 providers are fully compatible with the Fog library. For example,
+if you see a `411 Length Required` error message 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,9 +444,10 @@ For installations from source:
# storage_class: 'STANDARD'
```
-1. [Restart GitLab](../administration/restart_gitlab.md#installations-from-source) for the changes to take effect
+1. [Restart GitLab](../administration/restart_gitlab.md#installations-from-source)
+ for the changes to take effect
-If you are uploading your backups to S3 you will probably want to create a new
+If you're uploading your backups to S3, you'll probably want to create a new
IAM user with restricted access rights. To give the upload user access only for
uploading backups create the following IAM profile, replacing `my.s3.bucket`
with the name of your bucket:
@@ -486,16 +500,16 @@ with the name of your bucket:
##### Using Google Cloud Storage
-If you want to use Google Cloud Storage to save backups, you'll have to create
-an access key from the Google console first:
+To use Google Cloud Storage to save backups, you must first create an
+access key from the Google console:
-1. Go to the storage settings page <https://console.cloud.google.com/storage/settings>
-1. Select "Interoperability" and create an access key
-1. Make note of the "Access Key" and "Secret" and replace them in the
- configurations below
-1. In the buckets advanced settings ensure the Access Control option "Set object-level
- and bucket-level permissions" is selected
-1. Make sure you already have a bucket created
+1. Go to the [Google storage settings page](https://console.cloud.google.com/storage/settings).
+1. Select **Interoperability**, and then create an access key.
+1. Make note of the **Access Key** and **Secret** and replace them in the
+ following configurations.
+1. In the buckets advanced settings ensure the Access Control option
+ **Set object-level and bucket-level permissions** is selected.
+1. Ensure you have already created a bucket.
For Omnibus GitLab packages:
@@ -516,7 +530,8 @@ For Omnibus GitLab packages:
gitlab_rails['backup_upload_remote_directory'] = 'my.google.bucket'
```
-1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect
+1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure)
+ for the changes to take effect
For installations from source:
@@ -532,7 +547,8 @@ For installations from source:
remote_directory: 'my.google.bucket'
```
-1. [Restart GitLab](../administration/restart_gitlab.md#installations-from-source) for the changes to take effect
+1. [Restart GitLab](../administration/restart_gitlab.md#installations-from-source)
+ for the changes to take effect
##### Using Azure Blob storage
@@ -552,7 +568,8 @@ For Omnibus GitLab packages:
gitlab_rails['backup_upload_remote_directory'] = '<AZURE BLOB CONTAINER>'
```
-1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect
+1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure)
+ for the changes to take effect
For installations from source:
@@ -568,13 +585,14 @@ For installations from source:
remote_directory: '<AZURE BLOB CONTAINER>'
```
-1. [Restart GitLab](../administration/restart_gitlab.md#installations-from-source) for the changes to take effect
+1. [Restart GitLab](../administration/restart_gitlab.md#installations-from-source)
+ for the changes to take effect
-See [the table of Azure parameters](../administration/object_storage.md#azure-blob-storage) for more details.
+For more details, see the [table of Azure parameters](../administration/object_storage.md#azure-blob-storage).
##### Specifying a custom directory for backups
-Note: This option only works for remote storage. If you want to group your backups
+This option works only for remote storage. If you want to group your backups,
you can pass a `DIRECTORY` environment variable:
```shell
@@ -582,26 +600,25 @@ sudo gitlab-backup create DIRECTORY=daily
sudo gitlab-backup create DIRECTORY=weekly
```
-NOTE: **Note:**
-For GitLab 12.1 and earlier, use `gitlab-rake gitlab:backup:create`.
+Users of GitLab 12.1 and earlier should use the command `gitlab-rake gitlab:backup:create` instead.
#### Uploading to locally mounted shares
-You may also send backups to a mounted share (for example, `NFS`,`CIFS`, or `SMB`) by
-using the Fog [`Local`](https://github.com/fog/fog-local#usage) storage provider.
-The directory pointed to by the `local_root` key **must** be owned by the `git`
-user **when mounted** (mounting with the `uid=` of the `git` user for `CIFS` and
-`SMB`) or the user that you are executing the backup tasks under (for Omnibus
-packages, this is the `git` user).
+You may also send backups to a mounted share (for example, `NFS`,`CIFS`, or
+`SMB`) by using the Fog [`Local`](https://github.com/fog/fog-local#usage)
+storage provider. The directory pointed to by the `local_root` key _must_ be
+owned by the `git` user _when mounted_ (mounting with the `uid=` of the `git`
+user for `CIFS` and `SMB`) or the user that you are executing the backup tasks
+as (for Omnibus packages, this is the `git` user).
-The `backup_upload_remote_directory` **must** be set in addition to the
+The `backup_upload_remote_directory` _must_ be set in addition to the
`local_root` key. This is the sub directory inside the mounted directory that
backups will be copied to, and will be created if it does not exist. If the
directory that you want to copy the tarballs to is the root of your mounted
-directory, just use `.` instead.
+directory, use `.` instead.
-NOTE: **Note:**
-Since file system performance may affect GitLab's overall performance, we do not recommend using EFS for storage. See the [relevant documentation](../administration/nfs.md#avoid-using-awss-elastic-file-system-efs) for more details.
+Because file system performance may affect GitLab's overall performance,
+[GitLab doesn't recommend using EFS for storage](../administration/nfs.md#avoid-using-awss-elastic-file-system-efs).
For Omnibus GitLab packages:
@@ -618,7 +635,8 @@ For Omnibus GitLab packages:
gitlab_rails['backup_upload_remote_directory'] = 'gitlab_backups'
```
-1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
+1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure)
+ for the changes to take effect.
For installations from source:
@@ -636,14 +654,16 @@ For installations from source:
remote_directory: 'gitlab_backups'
```
-1. [Restart GitLab](../administration/restart_gitlab.md#installations-from-source) for the changes to take effect.
+1. [Restart GitLab](../administration/restart_gitlab.md#installations-from-source)
+ for the changes to take effect.
#### Backup archive permissions
The backup archives created by GitLab (`1393513186_2014_02_27_gitlab_backup.tar`)
-will have owner/group `git`/`git` and 0600 permissions by default.
-This is meant to avoid other system users reading GitLab's data.
-If you need the backup archives to have different permissions you can use the 'archive_permissions' setting.
+will have owner/group `git`/`git` and 0600 permissions by default. This is
+meant to avoid other system users reading GitLab's data. If you need the backup
+archives to have different permissions, you can use the `archive_permissions`
+setting.
For Omnibus GitLab packages:
@@ -653,7 +673,8 @@ For Omnibus GitLab packages:
gitlab_rails['backup_archive_permissions'] = 0644 # Makes the backup archives world-readable
```
-1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
+1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure)
+ for the changes to take effect.
For installations from source:
@@ -664,7 +685,8 @@ For installations from source:
archive_permissions: 0644 # Makes the backup archives world-readable
```
-1. [Restart GitLab](../administration/restart_gitlab.md#installations-from-source) for the changes to take effect.
+1. [Restart GitLab](../administration/restart_gitlab.md#installations-from-source)
+ for the changes to take effect.
#### Configuring cron to make daily backups
@@ -689,8 +711,7 @@ For Omnibus GitLab packages:
0 2 * * * /opt/gitlab/bin/gitlab-backup create CRON=1
```
- NOTE: **Note:**
- For GitLab 12.1 and earlier, use `gitlab-rake gitlab:backup:create`.
+ Users of GitLab 12.1 and earlier should use the command `gitlab-rake gitlab:backup:create` instead.
For installations from source:
@@ -707,26 +728,25 @@ For installations from source:
0 2 * * * cd /home/git/gitlab && PATH=/usr/local/bin:/usr/bin:/bin bundle exec rake gitlab:backup:create RAILS_ENV=production CRON=1
```
-NOTE: **Note:**
-The `CRON=1` environment setting tells the backup script to suppress all progress output if there are no errors.
-This is recommended to reduce cron spam.
+The `CRON=1` environment setting directs the backup script to hide all progress
+output if there aren't any errors. This is recommended to reduce cron spam.
### Limit backup lifetime for local files (prune old backups)
CAUTION: **Warning:**
-This will not work if you have used a [custom filename](#backup-filename)
+The process described in this section will not work if you used a [custom filename](#backup-filename)
for your backups.
-NOTE: **Note:**
-This configuration option only manages local files. GitLab does not automatically
-prune old files stored in a third-party [object storage](#uploading-backups-to-a-remote-cloud-storage)
-because the user may not have permission to list and delete files. It is
+To prevent regular backups from using all your disk space, you may want to set a limited lifetime
+for backups. The next time the backup task runs, backups older than the `backup_keep_time` are
+pruned.
+
+This configuration option manages only local files. GitLab doesn't prune old
+files stored in a third-party [object storage](#uploading-backups-to-a-remote-cloud-storage)
+because the user may not have permission to list and delete files. It's
recommended that you configure the appropriate retention policy for your object
storage (for example, [AWS S3](https://docs.aws.amazon.com/AmazonS3/latest/user-guide/create-lifecycle.html)).
-You may want to set a limited lifetime for backups to prevent regular
-backups using all your disk space. The next time the backup task is run, backups older than the `backup_keep_time` will be pruned.
-
For Omnibus GitLab packages:
1. Edit `/etc/gitlab/gitlab.rb`:
@@ -736,7 +756,8 @@ For Omnibus GitLab packages:
gitlab_rails['backup_keep_time'] = 604800
```
-1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
+1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure)
+ for the changes to take effect.
For installations from source:
@@ -748,61 +769,64 @@ For installations from source:
keep_time: 604800
```
-1. [Restart GitLab](../administration/restart_gitlab.md#installations-from-source) for the changes to take effect.
+1. [Restart GitLab](../administration/restart_gitlab.md#installations-from-source)
+ for the changes to take effect.
## Restore GitLab
-GitLab provides a simple command line interface to restore your whole installation,
+GitLab provides a command line interface to restore your entire installation,
and is flexible enough to fit your needs.
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.
+information. Be sure to read and test the complete 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
-GitLab that you created it on, for example CE 9.1.0.
+You can restore a backup only to _the exact same version and type (CE/EE)_ of
+GitLab that you created it on (for example CE 9.1.0).
-If your backup is a different version than the current installation, you will
+If your backup is a different version than the current installation, you'll
need to [downgrade your GitLab installation](https://docs.gitlab.com/omnibus/update/README.html#downgrade)
before restoring the backup.
### Restore prerequisites
-You need to have a working GitLab installation before you can perform
-a restore. This is mainly because the system user performing the
-restore actions (`git`) is usually not allowed to create or delete
-the SQL database it needs to import data into (`gitlabhq_production`).
-All existing data will be either erased (SQL) or moved to a separate
-directory (repositories, uploads).
+You need to have a working GitLab installation before you can perform a
+restore. This is because the system user performing the restore actions (`git`)
+is usually not allowed to create or delete the SQL database needed to import
+data into (`gitlabhq_production`). All existing data will be either erased
+(SQL) or moved to a separate directory (such as repositories and uploads).
-To restore a backup, you will also need to restore `/etc/gitlab/gitlab-secrets.json`
-(for Omnibus packages) or `/home/git/gitlab/.secret` (for installations
-from source). This file contains the database encryption key,
+To restore a backup, you'll also need to restore `/etc/gitlab/gitlab-secrets.json`
+(for Omnibus packages) or `/home/git/gitlab/.secret` (for installations from
+source). This file contains the database encryption key,
[CI/CD variables](../ci/variables/README.md#gitlab-cicd-environment-variables), and
variables used for [two-factor authentication](../user/profile/account/two_factor_authentication.md).
If you fail to restore this encryption key file along with the application data
backup, users with two-factor authentication enabled and GitLab Runner will
lose access to your GitLab server.
-You may also want to restore any TLS keys, certificates, or [SSH host keys](https://superuser.com/questions/532040/copy-ssh-keys-from-one-server-to-another-server/532079#532079).
+You may also want to restore any TLS keys, certificates, or
+[SSH host keys](https://superuser.com/questions/532040/copy-ssh-keys-from-one-server-to-another-server/532079#532079).
-Starting with GitLab 12.9 if an untarred backup (like the ones made with
+Starting with GitLab 12.9, if an untarred backup (like the ones made with
`SKIP=tar`) is found, and no backup is chosen with `BACKUP=<timestamp>`, the
untarred backup is used.
Depending on your case, you might want to run the restore command with one or
more of the following options:
-- `BACKUP=timestamp_of_backup` - Required if more than one backup exists.
+- `BACKUP=timestamp_of_backup`: Required if more than one backup exists.
Read what the [backup timestamp is about](#backup-timestamp).
-- `force=yes` - Does not ask if the authorized_keys file should get regenerated and assumes 'yes' for warning that database tables will be removed, enabling the "Write to authorized_keys file" setting, and updating LDAP providers.
+- `force=yes`: Doesn't ask if the authorized_keys file should get regenerated,
+ and assumes 'yes' for warning that database tables will be removed,
+ enabling the "Write to authorized_keys file" setting, and updating LDAP
+ providers.
-If you are restoring into directories that are mount points, you will need to make
-sure these directories are empty before attempting a restore. Otherwise GitLab
-will attempt to move these directories before restoring the new data and this
-would cause an error.
+If you're restoring into directories that are mount points, you must ensure these directories are
+empty before attempting a restore. Otherwise, GitLab attempts to move these directories before
+restoring the new data, which causes an error.
-Read more on [configuring NFS mounts](../administration/nfs.md)
+Read more about [configuring NFS mounts](../administration/nfs.md)
### Restore for installation from source
@@ -845,7 +869,7 @@ Restoring repositories:
Deleting tmp directories...[DONE]
```
-Next, restore `/home/git/gitlab/.secret` if necessary as mentioned above.
+Next, restore `/home/git/gitlab/.secret` if necessary, as previously mentioned.
Restart GitLab:
@@ -862,7 +886,7 @@ This procedure assumes that:
- You have run `sudo gitlab-ctl reconfigure` at least once.
- GitLab is running. If not, start it using `sudo gitlab-ctl start`.
-First make sure your backup tar file is in the backup directory described in the
+First ensure your backup tar file is in the backup directory described in the
`gitlab.rb` configuration `gitlab_rails['backup_path']`. The default is
`/var/opt/gitlab/backups`. It needs to be owned by the `git` user.
@@ -890,15 +914,16 @@ restore:
sudo gitlab-backup restore BACKUP=11493107454_2018_04_25_10.6.4-ce
```
-NOTE: **Note:**
-For GitLab 12.1 and earlier, use `gitlab-rake gitlab:backup:restore`.
+Users of GitLab 12.1 and earlier should use the command `gitlab-rake gitlab:backup:create` instead.
CAUTION: **Warning:**
-`gitlab-rake gitlab:backup:restore` does not set the right file system permissions on your Registry directory.
-This is a [known issue](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/62759). On GitLab 12.2 or newer, you can
-use `gitlab-backup restore` to avoid this issue.
+`gitlab-rake gitlab:backup:restore` doesn't set the correct file system
+permissions on your Registry directory. This is a [known issue](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/62759).
+On GitLab 12.2 or later, you can use `gitlab-backup restore` to avoid this
+issue.
-Next, restore `/etc/gitlab/gitlab-secrets.json` if necessary as mentioned above.
+Next, restore `/etc/gitlab/gitlab-secrets.json` if necessary, as previously
+mentioned.
Reconfigure, restart and check GitLab:
@@ -908,29 +933,31 @@ sudo gitlab-ctl restart
sudo gitlab-rake gitlab:check SANITIZE=true
```
-If there is a GitLab version mismatch between your backup tar file and the installed
-version of GitLab, the restore command will abort with an error. Install the
-[correct GitLab version](https://packages.gitlab.com/gitlab/) and try again.
+If there's a GitLab version mismatch between your backup tar file and the
+installed version of GitLab, the restore command aborts with an error
+message. Install the [correct GitLab version](https://packages.gitlab.com/gitlab/),
+and then try again.
-NOTE: **Note:**
-There is currently a [known issue](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/3470) for restore not working
-with `pgbouncer`. In order to workaround the issue, the Rails node will need to bypass `pgbouncer` and connect
-directly to the primary database node. This can be done by setting `gitlab_rails['db_host']` and `gitlab_rails['port']`
-to connect to the primary database node and [reconfiguring GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure).
+There is a [known issue](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/3470)
+for restore not working with `pgbouncer`. To work around the issue, the Rails
+node must bypass `pgbouncer` and connect directly to the primary
+database node. You can do this by setting `gitlab_rails['db_host']` and
+`gitlab_rails['port']` to connect to the primary database node and
+[reconfiguring GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure).
### Restore for Docker image and GitLab Helm chart installations
-For GitLab installations using the Docker image or the GitLab Helm chart on
-a Kubernetes cluster, the restore task expects the restore directories to be empty.
-However, with Docker and Kubernetes volume mounts, some system level directories
-may be created at the volume roots, like `lost+found` directory found in Linux
-operating systems. These directories are usually owned by `root`, which can
-cause access permission errors since the restore Rake task runs as `git` user.
-So, to restore a GitLab installation, users have to confirm the restore target
-directories are empty.
+For GitLab installations using the Docker image or the GitLab Helm chart on a
+Kubernetes cluster, the restore task expects the restore directories to be
+empty. However, with Docker and Kubernetes volume mounts, some system level
+directories may be created at the volume roots, such as the `lost+found`
+directory found in Linux operating systems. These directories are usually owned
+by `root`, which can cause access permission errors since the restore Rake task
+runs as the `git` user. To restore a GitLab installation, users have to confirm
+the restore target directories are empty.
-For both these installation types, the backup tarball has to be available in the
-backup location (default location is `/var/opt/gitlab/backups`).
+For both these installation types, the backup tarball has to be available in
+the backup location (default location is `/var/opt/gitlab/backups`).
For Docker installations, the restore task can be run from host:
@@ -953,43 +980,43 @@ docker restart <name of container>
docker exec -it <name of container> gitlab-rake gitlab:check SANITIZE=true
```
-NOTE: **Note:**
-For GitLab 12.1 and earlier, use `gitlab-rake gitlab:backup:restore`.
+Users of GitLab 12.1 and earlier should use the command `gitlab-rake gitlab:backup:create` instead.
CAUTION: **Warning:**
-`gitlab-rake gitlab:backup:restore` does not set the right file system permissions on your Registry directory.
-This is a [known issue](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/62759). On GitLab 12.2 or newer, you can
-use `gitlab-backup restore` to avoid this issue.
+`gitlab-rake gitlab:backup:restore` doesn't set the correct file system
+permissions on your Registry directory. This is a [known issue](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/62759).
+On GitLab 12.2 or later, you can use `gitlab-backup restore` to avoid this
+issue.
The GitLab Helm chart uses a different process, documented in
[restoring a GitLab Helm chart installation](https://gitlab.com/gitlab-org/charts/gitlab/blob/master/doc/backup-restore/restore.md).
### Restoring only one or a few project(s) or group(s) from a backup
-While the Rake task used to restore a GitLab instance doesn't support
-restoring a single project or group, you can use a workaround by
-restoring your backup to a separate, temporary GitLab instance, and
-export your project or group from there:
+Although the Rake task used to restore a GitLab instance doesn't support
+restoring a single project or group, you can use a workaround by restoring
+your backup to a separate, temporary GitLab instance, and then export your
+project or group from there:
1. [Install a new GitLab](../install/README.md) instance at the same version as
the backed-up instance from which you want to restore.
-1. [Restore the backup](#restore-gitlab) into this new instance and
+1. [Restore the backup](#restore-gitlab) into this new instance, then
export your [project](../user/project/settings/import_export.md)
- or [group](../user/group/settings/import_export.md). Make sure to
- read the **Important Notes** on either export feature's documentation
- to understand what will be exported and what not.
-1. Once the export is complete, go to the old instance and import it.
-1. After importing only the project(s) or group(s) that you wanted is complete,
- you may delete the new, temporary GitLab instance.
-
-NOTE: **Note:**
+ or [group](../user/group/settings/import_export.md). Be sure to read the
+ **Important Notes** on either export feature's documentation to understand
+ what is and isn't exported.
+1. After the export is complete, go to the old instance and then import it.
+1. After importing the projects or groups that you wanted is complete, you may
+ delete the new, temporary GitLab instance.
+
A feature request to provide direct restore of individual projects or groups
is being discussed in [issue #17517](https://gitlab.com/gitlab-org/gitlab/-/issues/17517).
## Alternative backup strategies
-If your GitLab server contains a lot of Git repository data you may find the GitLab backup script to be too slow.
-In this case you can consider using filesystem snapshots as part of your backup strategy.
+If your GitLab server contains a lot of Git repository data, you may find the
+GitLab backup script to be too slow. In this case you can consider using
+filesystem snapshots as part of your backup strategy.
Example: Amazon EBS
@@ -1006,29 +1033,33 @@ Example: LVM snapshots + rsync
> Now we can have a longer running rsync job which will create a consistent replica on the remote server.
> The replica includes all repositories, uploads and PostgreSQL data.
-If you are running GitLab on a virtualized server you can possibly also create VM snapshots of the entire GitLab server.
-It is not uncommon however for a VM snapshot to require you to power down the server, so this approach is probably of limited practical use.
+If you're running GitLab on a virtualized server, you can possibly also create
+VM snapshots of the entire GitLab server. It's not uncommon however for a VM
+snapshot to require you to power down the server, which limits this solution's
+practical use.
## Additional notes
-This documentation is for GitLab Community and Enterprise Edition. We backup
-GitLab.com and make sure your data is secure, but you can't use these methods
-to export / backup your data yourself from GitLab.com.
+This documentation is for GitLab Community and Enterprise Edition. We back up
+GitLab.com and ensure your data is secure. You can't, however, use these
+methods to export or back up your data yourself from GitLab.com.
-Issues are stored in the database. They can't be stored in Git itself.
+Issues are stored in the database, and can't be stored in Git itself.
-To migrate your repositories from one server to another with an up-to-date version of
-GitLab, you can use the [import Rake task](import.md) to do a mass import of the
-repository. Note that if you do an import Rake task, rather than a backup restore, you
-will have all your repositories, but not any other data.
+To migrate your repositories from one server to another with an up-to-date
+version of GitLab, use the [import Rake task](import.md) to do a mass import of
+the repository. If you do an import Rake task rather than a backup restore,
+you get all of your repositories, but no other data.
## Troubleshooting
-The following are possible problems you might encounter with possible solutions.
+The following are possible problems you might encounter, along with potential
+solutions.
### Restoring database backup using Omnibus packages outputs warnings
-If you are using backup restore procedures you might encounter the following warnings:
+If you're using backup restore procedures, you may encounter the following
+warning messages:
```plaintext
psql:/var/opt/gitlab/backups/db/database.sql:22: ERROR: must be owner of extension plpgsql
@@ -1036,22 +1067,31 @@ psql:/var/opt/gitlab/backups/db/database.sql:2931: WARNING: no privileges could
psql:/var/opt/gitlab/backups/db/database.sql:2933: WARNING: no privileges were granted for "public" (two occurrences)
```
-Be advised that, backup is successfully restored in spite of these warnings.
+Be advised that the backup is successfully restored in spite of these warning
+messages.
-The Rake task runs this as the `gitlab` user which does not have the superuser access to the database. When restore is initiated it will also run as `gitlab` user but it will also try to alter the objects it does not have access to.
-Those objects have no influence on the database backup/restore but they give this annoying warning.
+The Rake task runs this as the `gitlab` user, which doesn't have superuser
+access to the database. When restore is initiated, it also runs as the `gitlab`
+user, but it also tries to alter the objects it doesn't have access to.
+Those objects have no influence on the database backup or restore, but display
+a warning message.
-For more information see similar questions on PostgreSQL issue tracker [here](https://www.postgresql.org/message-id/201110220712.30886.adrian.klaver@gmail.com) and [here](https://www.postgresql.org/message-id/2039.1177339749@sss.pgh.pa.us) as well as [stack overflow](https://stackoverflow.com/questions/4368789/error-must-be-owner-of-language-plpgsql).
+For more information, see:
+
+- PostgreSQL issue tracker:
+ - [Not being a superuser](https://www.postgresql.org/message-id/201110220712.30886.adrian.klaver@gmail.com).
+ - [Having different owners](https://www.postgresql.org/message-id/2039.1177339749@sss.pgh.pa.us).
+
+- Stack Overflow: [Resulting errors](https://stackoverflow.com/questions/4368789/error-must-be-owner-of-language-plpgsql).
### When the secrets file is lost
-If you have failed to [back up the secrets file](#storing-configuration-files), you'll
-need to perform a number of steps to get GitLab working properly again.
+If you didn't [back up the secrets file](#storing-configuration-files), you
+must complete several steps to get GitLab working properly again.
-The secrets file is responsible for storing the encryption key for several
-columns containing sensitive information. If the key is lost, GitLab will be
-unable to decrypt those columns. This will break a wide range of functionality,
-including (but not restricted to):
+The secrets file is responsible for storing the encryption key for the columns
+that contain required, sensitive information. If the key is lost, GitLab can't
+decrypt those columns, preventing access to the following items:
- [CI/CD variables](../ci/variables/README.md)
- [Kubernetes / GCP integration](../user/project/clusters/index.md)
@@ -1061,38 +1101,40 @@ including (but not restricted to):
- [Project mirroring](../user/project/repository/repository_mirroring.md)
- [Web hooks](../user/project/integrations/webhooks.md)
-In cases like CI/CD variables and runner authentication, you might
-experience some unexpected behavior such as:
+In cases like CI/CD variables and runner authentication, you can experience
+unexpected behaviors, such as:
- Stuck jobs.
- 500 errors.
-In this case, you are required to reset all the tokens for CI/CD variables
-and runner authentication, which is described in more detail below. After
-resetting the tokens, you should be able to visit your project and the jobs
-will have started running again. Use the information in the following sections at your own risk.
+In this case, you must reset all the tokens for CI/CD variables and
+runner authentication, which is described in more detail in the following
+sections. After resetting the tokens, you should be able to visit your project
+and the jobs will have started running again.
+
+Use the information in the following sections at your own risk.
#### Check for undecryptable values
-You can check whether you have undecryptable values in the database using
-the [Secrets Doctor Rake task](../administration/raketasks/doctor.md).
+You can determine if you have undecryptable values in the database by using the
+[Secrets Doctor Rake task](../administration/raketasks/doctor.md).
#### Take a backup
-You will need to directly modify GitLab data to work around your lost secrets file.
+You must directly modify GitLab data to work around your lost secrets file.
CAUTION: **Warning:**
-Make sure you've taken a backup beforehand, particularly a full database backup.
+Be sure to create a full database backup before attempting any changes.
#### Disable user two-factor authentication (2FA)
-Users with 2FA enabled will not be able to log into GitLab. In that case,
-you need to [disable 2FA for everyone](../security/two_factor_authentication.md#disabling-2fa-for-everyone)
-and then users will have to reactivate 2FA from scratch.
+Users with 2FA enabled can't sign in to GitLab. In that case, you must
+[disable 2FA for everyone](../security/two_factor_authentication.md#disabling-2fa-for-everyone),
+after which users must reactivate 2FA.
#### Reset CI/CD variables
-1. Enter the DB console:
+1. Enter the database console:
For Omnibus GitLab packages:
@@ -1106,14 +1148,14 @@ and then users will have to reactivate 2FA from scratch.
sudo -u git -H bundle exec rails dbconsole -e production
```
-1. Check the `ci_group_variables` and `ci_variables` tables:
+1. Examine the `ci_group_variables` and `ci_variables` tables:
```sql
SELECT * FROM public."ci_group_variables";
SELECT * FROM public."ci_variables";
```
- Those are the variables that you need to delete.
+ These are the variables that you need to delete.
1. Drop the table:
@@ -1122,12 +1164,11 @@ and then users will have to reactivate 2FA from scratch.
DELETE FROM ci_variables;
```
-1. You may need to reconfigure or restart GitLab for the changes to take
- effect.
+You may need to reconfigure or restart GitLab for the changes to take effect.
#### Reset runner registration tokens
-1. Enter the DB console:
+1. Enter the database console:
For Omnibus GitLab packages:
@@ -1141,11 +1182,11 @@ and then users will have to reactivate 2FA from scratch.
sudo -u git -H bundle exec rails dbconsole -e production
```
-1. Clear all the tokens for projects, groups, and the whole instance:
+1. Clear all tokens for projects, groups, and the entire instance:
CAUTION: **Caution:**
- The last UPDATE operation will stop the runners being able to pick up
- new jobs. You must register new runners.
+ The final `UPDATE` operation stops the runners from being able to pick
+ up new jobs. You must register new runners.
```sql
-- Clear project tokens
@@ -1160,7 +1201,7 @@ and then users will have to reactivate 2FA from scratch.
#### Reset pending pipeline jobs
-1. Enter the DB console:
+1. Enter the database console:
For Omnibus GitLab packages:
@@ -1181,19 +1222,18 @@ and then users will have to reactivate 2FA from scratch.
UPDATE ci_builds SET token = null, token_encrypted = null;
```
-A similar strategy can be employed for the remaining features - by removing the
-data that cannot be decrypted, GitLab can be brought back into working order,
-and the lost data can be manually replaced.
+A similar strategy can be employed for the remaining features. By removing the
+data that can't be decrypted, GitLab can be returned to operation, and the
+lost data can be manually replaced.
#### Fix project integrations
-If you've lost your secrets, the
-[projects' integrations settings pages](../user/project/integrations/index.md)
-are probably generating 500 errors.
+If you've lost your secrets, the [projects' integrations settings pages](../user/project/integrations/index.md)
+are probably displaying `500` error messages.
The fix is to truncate the `web_hooks` table:
-1. Enter the DB console:
+1. Enter the database console:
For Omnibus GitLab packages:
@@ -1207,7 +1247,7 @@ The fix is to truncate the `web_hooks` table:
sudo -u git -H bundle exec rails dbconsole -e production
```
-1. Truncate the table
+1. Truncate the table:
```sql
-- truncate web_hooks table
@@ -1216,11 +1256,11 @@ The fix is to truncate the `web_hooks` table:
### Container Registry push failures after restoring from a backup
-If you use the [Container Registry](../user/packages/container_registry/index.md), you
-may see pushes to the registry fail after restoring your backup on an Omnibus
-GitLab instance after restoring the registry data.
+If you use the [Container Registry](../user/packages/container_registry/index.md),
+pushes to the registry may fail after restoring your backup on an Omnibus GitLab
+instance after restoring the registry data.
-These failures will mention permission issues in the registry logs, like:
+These failures mention permission issues in the registry logs, similar to:
```plaintext
level=error
@@ -1230,9 +1270,9 @@ err.detail="filesystem: mkdir /var/opt/gitlab/gitlab-rails/shared/registry/docke
err.message="unknown error"
```
-This is caused by the restore being run as the unprivileged user `git` which was
-unable to assign the correct ownership to the registry files during the restore
-([issue 62759](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/62759 "Incorrect permissions on registry filesystem after restore")).
+This issue is caused by the restore running as the unprivileged user `git`,
+which is unable to assign the correct ownership to the registry files during
+the restore process ([issue 62759](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/62759 "Incorrect permissions on registry filesystem after restore")).
To get your registry working again:
@@ -1240,14 +1280,12 @@ To get your registry working again:
sudo chown -R registry:registry /var/opt/gitlab/gitlab-rails/shared/registry/docker
```
-NOTE: **Note:**
-If you have changed the default filesystem location for the registry, you will
-want to run the `chown` against your custom location instead of
-`/var/opt/gitlab/gitlab-rails/shared/registry/docker`.
+If you changed the default filesystem location for the registry, run `chown`
+against your custom location, instead of `/var/opt/gitlab/gitlab-rails/shared/registry/docker`.
### Backup fails to complete with Gzip error
-While running the backup, you may receive a Gzip error:
+When running the backup, you may receive a Gzip error message:
```shell
sudo /opt/gitlab/bin/gitlab-backup create
@@ -1259,7 +1297,8 @@ gzip: stdout: Input/output error
Backup failed
```
-If this happens, check the following:
+If this happens, examine the following:
-1. Confirm there is sufficient disk space for the Gzip operation.
-1. If NFS is being used, check if the mount option `timeout` is set. The default is `600`, and changing this to smaller values have resulted in this error.
+- Confirm there is sufficient disk space for the Gzip operation.
+- If NFS is being used, check if the mount option `timeout` is set. The
+ default is `600`, and changing this to smaller values results in this error.
diff --git a/doc/security/rack_attack.md b/doc/security/rack_attack.md
index d3de2222c39..b386917f399 100644
--- a/doc/security/rack_attack.md
+++ b/doc/security/rack_attack.md
@@ -4,8 +4,6 @@ type: reference, howto
# Rack Attack initializer
-## Overview
-
[Rack Attack](https://github.com/kickstarter/rack-attack), also known as Rack::Attack, is a Ruby gem
that is meant to protect GitLab with the ability to customize throttling and
to block user IP addresses.
diff --git a/doc/security/two_factor_authentication.md b/doc/security/two_factor_authentication.md
index 9d49e1d3af2..0c2d616b003 100644
--- a/doc/security/two_factor_authentication.md
+++ b/doc/security/two_factor_authentication.md
@@ -68,6 +68,17 @@ The following are important notes about 2FA:
## Disabling 2FA for everyone
+CAUTION: **Caution:**
+Disabling 2FA for everyone does not disable the [enforce 2FA for all users](#enforcing-2fa-for-all-users)
+or [enforce 2FA for all users in a group](#enforcing-2fa-for-all-users-in-a-group)
+settings. In addition to the steps in this section, you will need to disable any enforced 2FA
+settings so users aren't asked to set up 2FA again, the next time the user signs in to GitLab.
+Disabling 2FA for everyone does not disable the [enforce 2FA for all users](#enforcing-2fa-for-all-users)
+or [enforce 2FA for all users in a group](#enforcing-2fa-for-all-users-in-a-group)
+settings if they have been configured. In addition to the steps in this section,
+you will need to disable any enforced 2FA settings so users aren't asked to setup
+2FA again when the next login to GitLab.
+
There may be some special situations where you want to disable 2FA for everyone
even when forced 2FA is disabled. There is a Rake task for that:
diff --git a/doc/ssh/README.md b/doc/ssh/README.md
index 1c5654ae96c..6a4dc5426f8 100644
--- a/doc/ssh/README.md
+++ b/doc/ssh/README.md
@@ -24,7 +24,7 @@ connections to GitLab repositories.
## Requirements
To support SSH, GitLab requires the installation of the OpenSSH client, which
-comes pre-installed on GNU/Linux and macOS, but not on Windows.
+comes pre-installed on GNU/Linux and macOS, as well as on Windows 10.
Make sure that your system includes SSH version 6.5 or newer, as that excludes
the now insecure MD5 signature scheme. The following command returns the version of
diff --git a/doc/topics/autodevops/customize.md b/doc/topics/autodevops/customize.md
index 13aa8f7e035..45083dfeb64 100644
--- a/doc/topics/autodevops/customize.md
+++ b/doc/topics/autodevops/customize.md
@@ -332,7 +332,7 @@ applications.
| `AUTO_DEVOPS_CHART_REPOSITORY_USERNAME` | From GitLab 11.11, used to set a username to connect to the Helm repository. Defaults to no credentials. Also set `AUTO_DEVOPS_CHART_REPOSITORY_PASSWORD`. |
| `AUTO_DEVOPS_CHART_REPOSITORY_PASSWORD` | From GitLab 11.11, used to set a password to connect to the Helm repository. Defaults to no credentials. Also set `AUTO_DEVOPS_CHART_REPOSITORY_USERNAME`. |
| `AUTO_DEVOPS_DEPLOY_DEBUG` | From GitLab 13.1, if this variable is present, Helm will output debug logs. |
-| `AUTO_DEVOPS_ALLOW_TO_FORCE_DEPLOY_V<N>` | From [auto-deploy-image](https://gitlab.com/gitlab-org/cluster-integration/auto-deploy-image) v1.0.0, if this variable is present, a new major version of chart is forcibly deployed. [More details](upgrading_chart.md#ignore-warning-and-continue-deploying) |
+| `AUTO_DEVOPS_ALLOW_TO_FORCE_DEPLOY_V<N>` | From [auto-deploy-image](https://gitlab.com/gitlab-org/cluster-integration/auto-deploy-image) v1.0.0, if this variable is present, a new major version of chart is forcibly deployed. For more information, see [Ignore warnings and continue deploying](upgrading_auto_deploy_dependencies.md#ignore-warnings-and-continue-deploying). |
| `AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE` | From GitLab 12.5, used in combination with [ModSecurity feature flag](../../user/clusters/applications.md#web-application-firewall-modsecurity) to toggle [ModSecurity's `SecRuleEngine`](https://github.com/SpiderLabs/ModSecurity/wiki/Reference-Manual-(v2.x)#SecRuleEngine) behavior. Defaults to `DetectionOnly`. |
| `BUILDPACK_URL` | Buildpack's full URL. Can point to either [a Git repository URL or a tarball URL](#custom-buildpacks). |
| `CANARY_ENABLED` | From GitLab 11.0, used to define a [deploy policy for canary environments](#deploy-policy-for-canary-environments). |
diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md
index a39f93a26e1..a7bd810abbe 100644
--- a/doc/topics/autodevops/index.md
+++ b/doc/topics/autodevops/index.md
@@ -3,10 +3,11 @@
> - [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/37115) in GitLab 10.0.
> - Generally available on GitLab 11.0.
-Auto DevOps provides pre-defined CI/CD configuration allowing you to automatically
-detect, build, test, deploy, and monitor your applications. Leveraging CI/CD
-best practices and tools, Auto DevOps aims to simplify the setup and execution
-of a mature and modern software development lifecycle.
+Auto DevOps are default CI/CD templates that auto-discover the source code you have. They
+enable GitLab to automatically detect, build, test, deploy, and monitor your applications.
+Leveraging [CI/CD best practices](../../ci/pipelines/pipeline_efficiency.md) and tools,
+Auto DevOps aims to simplify the setup and execution of a mature and modern software
+development lifecycle.
## Overview
@@ -83,9 +84,9 @@ project in a simple and automatic way:
1. [Auto Build](stages.md#auto-build)
1. [Auto Test](stages.md#auto-test)
-1. [Auto Code Quality](stages.md#auto-code-quality) **(STARTER)**
-1. [Auto SAST (Static Application Security Testing)](stages.md#auto-sast) **(ULTIMATE)**
-1. [Auto Secret Detection](stages.md#auto-secret-detection) **(ULTIMATE)**
+1. [Auto Code Quality](stages.md#auto-code-quality)
+1. [Auto SAST (Static Application Security Testing)](stages.md#auto-sast)
+1. [Auto Secret Detection](stages.md#auto-secret-detection)
1. [Auto Dependency Scanning](stages.md#auto-dependency-scanning) **(ULTIMATE)**
1. [Auto License Compliance](stages.md#auto-license-compliance) **(ULTIMATE)**
1. [Auto Container Scanning](stages.md#auto-container-scanning) **(ULTIMATE)**
@@ -317,7 +318,7 @@ metadata:
name: gitlab-managed-apps-default-proxy
namespace: gitlab-managed-apps
spec:
- env:
+ env:
- name: http_proxy
value: "PUT_YOUR_HTTP_PROXY_HERE"
- name: https_proxy
diff --git a/doc/topics/autodevops/stages.md b/doc/topics/autodevops/stages.md
index b58c369714e..44eebb748a6 100644
--- a/doc/topics/autodevops/stages.md
+++ b/doc/topics/autodevops/stages.md
@@ -124,7 +124,10 @@ The supported buildpacks are:
If your application needs a buildpack that is not in the above list, you
might want to use a [custom buildpack](customize.md#custom-buildpacks).
-## Auto Code Quality **(STARTER)**
+## Auto Code Quality
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1984) in [GitLab Starter](https://about.gitlab.com/pricing/) 9.3.
+> - Made [available in all tiers](https://gitlab.com/gitlab-org/gitlab/-/issues/212499) in GitLab 13.2.
Auto Code Quality uses the
[Code Quality image](https://gitlab.com/gitlab-org/ci-cd/codequality) to run
@@ -133,9 +136,10 @@ report, it's uploaded as an artifact which you can later download and check
out. The merge request widget also displays any
[differences between the source and target branches](../../user/project/merge_requests/code_quality.md).
-## Auto SAST **(ULTIMATE)**
+## Auto SAST
-> Introduced in [GitLab Ultimate](https://about.gitlab.com/pricing/) 10.3.
+> - Introduced in [GitLab Ultimate](https://about.gitlab.com/pricing/) 10.3.
+> - Select functionality made available in all tiers beginning in 13.1
Static Application Security Testing (SAST) uses the
[SAST Docker image](https://gitlab.com/gitlab-org/security-products/sast) to run static
@@ -151,9 +155,10 @@ warnings.
To learn more about [how SAST works](../../user/application_security/sast/index.md),
see the documentation.
-## Auto Secret Detection **(ULTIMATE)**
+## Auto Secret Detection
-> Introduced in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.1.
+> - Introduced in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.1.
+> - [Select functionality made available in all tiers](../../user/application_security/secret_detection/#making-secret-detection-available-to-all-gitlab-tiers) in 13.3
Secret Detection uses the
[Secret Detection Docker image](https://gitlab.com/gitlab-org/security-products/analyzers/secrets) to run Secret Detection on the current code, and checks for leaked secrets. The
@@ -466,7 +471,7 @@ application runs.
### Upgrade auto-deploy-app Chart
-You can upgrade auto-deploy-app chart by following the [upgrade guide](upgrading_chart.md).
+You can upgrade the auto-deploy-app chart by following the [upgrade guide](upgrading_auto_deploy_dependencies.md).
### Workers
diff --git a/doc/topics/autodevops/upgrading_auto_deploy_dependencies.md b/doc/topics/autodevops/upgrading_auto_deploy_dependencies.md
new file mode 100644
index 00000000000..1aefb6b34df
--- /dev/null
+++ b/doc/topics/autodevops/upgrading_auto_deploy_dependencies.md
@@ -0,0 +1,262 @@
+---
+stage: Release
+group: Progressive Delivery
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+type: reference
+---
+
+# Upgrading deployments for newer Auto Deploy dependencies (Auto Deploy template, auto-deploy-image and auto-deploy-app chart)
+
+[Auto Deploy](stages.md#auto-deploy) is a feature that deploys your application to a Kubernetes cluster.
+It consists of several dependencies:
+
+- [Auto Deploy template](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml) is a set of pipeline jobs and scripts that makes use of `auto-deploy-image`.
+- [`auto-deploy-image`](https://gitlab.com/gitlab-org/cluster-integration/auto-deploy-image) is the executable image that communicates with the Kubernetes cluster.
+- [`auto-deploy-app chart`](https://gitlab.com/gitlab-org/cluster-integration/auto-deploy-image/-/tree/master/assets/auto-deploy-app) is the Helm chart for deploying your application.
+
+The `auto-deploy-image` and `auto-deploy-app` charts use [Semantic Versioning](https://semver.org/).
+By default, your Auto DevOps project keeps using the stable and non-breaking version.
+However, these dependencies could be upgraded in a major version release of GitLab
+with breaking changes requiring you to upgrade your deployments.
+
+This guide explains how to upgrade your deployments with newer or different major versions of Auto Deploy dependencies.
+
+## Verify dependency versions
+
+The process to check the current versions differs depending on which template you
+are using. First verify which template is in use:
+
+- For self-managed instances, the [stable Auto Deploy template bundled with the GitLab package](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml)
+ is being used.
+- [The GitLab.com stable Auto Deploy template](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml)
+ is being used if **one** of the following is true:
+ - Your Auto DevOps project doesn't have a `.gitlab-ci.yml` file.
+ - Your Auto DevOps project has a `.gitlab-ci.yml` and [includes](../../ci/yaml/README.md#includetemplate)
+ the `Auto-DevOps.gitlab-ci.yml` template.
+- [The latest Auto Deploy template](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Jobs/Deploy.latest.gitlab-ci.yml)
+ is being used if **both** of the following is true:
+ - Your Auto DevOps project has a `.gitlab-ci.yml` file and [includes](../../ci/yaml/README.md#includetemplate)
+ the `Auto-DevOps.gitlab-ci.yml` template.
+ - It also includes [the latest Auto Deploy template](#early-adopters)
+
+If you know what template is being used:
+
+- The `auto-deploy-image` version is in the template (for example `auto-deploy-image:v1.0.3`).
+- The `auto-deploy-app` chart version is [in the auto-deploy-image repository](https://gitlab.com/gitlab-org/cluster-integration/auto-deploy-image/-/blob/v1.0.3/assets/auto-deploy-app/Chart.yaml)
+ (for example `version: 1.0.3`).
+
+## Compatibility
+
+The following table explains the version compatibility between GitLab and Auto Deploy dependencies:
+
+| GitLab version | `auto-deploy-image` version | Notes |
+|------------------|-----------------------------|-------|
+| v10.0 to v14.0 | v0.1.0 to v2.0.0 | v0 and v1 auto-deploy-image are backwards compatible. |
+| v13.4 and higher | v2.0.0 and higher | v2 auto-deploy-image contains breaking changes, as explained in the [upgrade guide](#upgrade-deployments-to-the-v2-auto-deploy-image). |
+
+You can find the current stable version of auto-deploy-image in the [Auto Deploy stable template](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml).
+
+## Upgrade Guide
+
+Projects using Auto DevOps must use the unmodified chart managed by GitLab.
+[Customized charts](customize.md#custom-helm-chart) are unsupported.
+
+### Upgrade deployments to the v1 `auto-deploy-image`
+
+The v1 chart is backward compatible with the v0 chart, so no configuration changes are needed.
+
+### Upgrade deployments to the v2 `auto-deploy-image`
+
+The v2 auto-deploy-image contains multiple dependency and architectural changes.
+If your Auto DevOps project has an active environment deployed with the v1 `auto-deploy-image`,
+please proceed with the following upgrade guide. Otherwise, you can skip this process.
+
+#### Helm 3
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/228609) in GitLab 13.4.
+
+The `auto-deploy-image` uses the Helm binary to manipulate the releases.
+Previously, `auto-deploy-image` used Helm v2, which used Tiller in a cluster.
+In the v2 `auto-deploy-image`, it uses Helm v3 that doesn't require Tiller anymore.
+
+If your Auto DevOps project has an active environment that was deployed with the v1
+`auto-deploy-image`, use the following steps to upgrade to v2, which uses Helm 3:
+
+1. Modify your `.gitlab-ci.yml` with:
+
+ ```yaml
+ include:
+ - template: Auto-DevOps.gitlab-ci.yml
+ - remote: https://gitlab.com/hfyngvason/ci-templates/-/raw/master/Helm-2to3.gitlab-ci.yml
+
+ variables:
+ # If this variable is not present, the migration jobs will not show up
+ MIGRATE_HELM_2TO3: "true"
+
+ .auto-deploy:
+ image: registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v2.0.0-beta.1
+ variables:
+ AUTO_DEVOPS_FORCE_DEPLOY_V2: 1
+ ```
+
+1. Run the `<environment-name>:helm-2to3:migrate` job.
+1. Deploy your environment as usual. This deployment uses Helm 3.
+1. If the deployment succeeds, you can safely run `environment:helm-2to3:cleanup`.
+ This deletes all Helm 2 release data from the namespace.
+
+ If you accidentally delete the Helm 2 releases before you are ready, the `<environment-name>:helm2to3:migrate`
+ job saves a backup for 1 week in a job artifact called `helm-2-release-backups`.
+ The backup is in a Kubernetes manifest file that can be restored using
+ `kubectl apply -f $backup`.
+1. Remove the `MIGRATE_HELM_2TO3` variable.
+
+#### Traffic routing change for canary deployments and incremental rollouts
+
+> [Introduced](https://gitlab.com/gitlab-org/cluster-integration/auto-deploy-image/-/merge_requests/109) in GitLab 13.4.
+
+Auto Deploy supports advanced deployment strategies such as [canary deployments](customize.md#deploy-policy-for-canary-environments)
+and [incremental rollouts](../../ci/environments/incremental_rollouts.md).
+
+Previously, `auto-deploy-image` created one service to balance the traffic between
+unstable and stable tracks by changing the replica ratio. In the v2 `auto-deploy-image`,
+it controls the traffic with [Canary Ingress](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#canary).
+
+For more details, see the [v2 `auto-deploy-app` chart resource architecture](#v2-chart-resource-architecture).
+
+If your Auto DevOps project has active `canary` or `rollout` track releases in the
+`production` environment deployed with the v1 `auto-deploy-image`, use the following
+steps to upgrade to v2:
+
+1. Verify your project is [using the v1 `auto-deploy-image`](#verify-dependency-versions).
+ If not, [specify the version](#use-a-specific-version-of-auto-deploy-dependencies).
+1. If you're in the process of deploying `canary` or `rollout` deployments, promote
+ them to `production` first to delete the unstable tracks.
+1. Verify your project is [using the v2 `auto-deploy-image`](#verify-dependency-versions).
+ If not, [specify the version](#use-a-specific-version-of-auto-deploy-dependencies).
+1. Add an `AUTO_DEVOPS_FORCE_DEPLOY_V2` environment variable with a value of `true`
+ in the GitLab CI/CD settings.
+1. Create a new pipeline and run the `production` job to renew the resource architecture
+ with the v2 `auto-deploy-app chart`.
+1. Remove the `AUTO_DEVOPS_FORCE_DEPLOY_V2` environment variable.
+
+### Use a specific version of Auto Deploy dependencies
+
+To use a specifc version of Auto Deploy dependencies, specify the previous Auto Deploy
+stable template that contains the [desired version of `auto-deploy-image` and `auto-deploy-app`](#verify-dependency-versions).
+
+For example, if the template is bundled in GitLab v13.3, change your `.gitlab-ci.yml` to:
+
+```yaml
+include:
+ - template: Auto-DevOps.gitlab-ci.yml
+ - remote: https://gitlab.com/gitlab-org/gitlab/-/blob/v13.3.0-ee/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml
+```
+
+### Ignore warnings and continue deploying
+
+If you are certain that the new chart version is safe to be deployed, you can add
+the `AUTO_DEVOPS_FORCE_DEPLOY_V<major-version-number>` [environment variable](customize.md#build-and-deployment)
+to force the deployment to continue.
+
+For example, if you want to deploy the `v2.0.0` chart on a deployment that previously
+used the `v0.17.0` chart, add `AUTO_DEVOPS_FORCE_DEPLOY_V2`.
+
+## Early adopters
+
+If you want to use the latest beta or unstable version of `auto-deploy-image`, include
+the latest Auto Deploy template into your `.gitlab-ci.yml`:
+
+```yaml
+include:
+ - template: Auto-DevOps.gitlab-ci.yml
+ - remote: https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Jobs/Deploy.latest.gitlab-ci.yml
+```
+
+CAUTION: **Warning:**
+Using a beta or unstable `auto-deploy-image` could cause unrecoverable damage to
+your environments. Do not test it with important projects or environments.
+
+The next stable template update is [planned for GitLab v14.0](https://gitlab.com/gitlab-org/gitlab/-/issues/232788).
+
+## Resource Architectures of the `auto-deploy-app` chart
+
+### v0 and v1 chart resource architecture
+
+```mermaid
+graph TD;
+subgraph gl-managed-app
+Z[Nginx Ingress]
+end
+Z[Nginx Ingress] --> A(Ingress);
+Z[Nginx Ingress] --> B(Ingress);
+subgraph stg namespace
+B[Ingress] --> H(...);
+end
+subgraph prd namespace
+A[Ingress] --> D(Service);
+D[Service] --> E(Deployment:Pods:app:stable);
+D[Service] --> F(Deployment:Pods:app:canary);
+D[Service] --> I(Deployment:Pods:app:rollout);
+E(Deployment:Pods:app:stable)---id1[(Pods:Postgres)]
+F(Deployment:Pods:app:canary)---id1[(Pods:Postgres)]
+I(Deployment:Pods:app:rollout)---id1[(Pods:Postgres)]
+end
+```
+
+### v2 chart resource architecture
+
+```mermaid
+graph TD;
+subgraph gl-managed-app
+Z[Nginx Ingress]
+end
+Z[Nginx Ingress] --> A(Ingress);
+Z[Nginx Ingress] --> B(Ingress);
+Z[Nginx Ingress] --> |If canary is present or incremental rollout/|J(Canary Ingress);
+subgraph stg namespace
+B[Ingress] --> H(...);
+end
+subgraph prd namespace
+subgraph stable track
+A[Ingress] --> D[Service];
+D[Service] --> E(Deployment:Pods:app:stable);
+end
+subgraph canary track
+J(Canary Ingress) --> K[Service]
+K[Service] --> F(Deployment:Pods:app:canary);
+end
+E(Deployment:Pods:app:stable)---id1[(Pods:Postgres)]
+F(Deployment:Pods:app:canary)---id1[(Pods:Postgres)]
+end
+```
+
+## Troubleshooting
+
+### Major version mismatch warning
+
+If deploying a chart that has a major version that is different from the previous one,
+the new chart might not be correctly deployed. This could be due to an architectural
+change. If that happens, the deployment job fails with a message similar to:
+
+```plaintext
+*************************************************************************************
+ [WARNING]
+Detected a major version difference between the the chart that is currently deploying (auto-deploy-app-v0.7.0), and the previously deployed chart (auto-deploy-app-v1.0.0).
+A new major version might not be backward compatible with the current release (production). The deployment could fail or be stuck in an unrecoverable status.
+...
+```
+
+To clear this error message and resume deployments, you must do one of the following:
+
+- Manually [upgrade the chart version](#upgrade-guide).
+- [Use a specific chart version](#use-a-specific-version-of-auto-deploy-dependencies).
+
+### Error: `missing key "app.kubernetes.io/managed-by": must be set to "Helm"`
+
+If your cluster has a deployment that was deployed with the v1 `auto-deploy-image`,
+you might encounter the following error:
+
+- `Error: rendered manifests contain a resource that already exists. Unable to continue with install: Secret "production-postgresql" in namespace "<project-name>-production" exists and cannot be imported into the current release: invalid ownership metadata; label validation error: missing key "app.kubernetes.io/managed-by": must be set to "Helm"; annotation validation error: missing key "meta.helm.sh/release-name": must be set to "production-postgresql"; annotation validation error: missing key "meta.helm.sh/release-namespace": must be set to "<project-name>-production"`
+
+This is because the previous deployment was deployed with Helm2, which is not compatible with Helm3.
+To resolve the problem, please follow the [upgrade guide](#upgrade-deployments-to-the-v2-auto-deploy-image).
diff --git a/doc/topics/autodevops/upgrading_chart.md b/doc/topics/autodevops/upgrading_chart.md
index ffa485f6d2c..e4fb84d4509 100644
--- a/doc/topics/autodevops/upgrading_chart.md
+++ b/doc/topics/autodevops/upgrading_chart.md
@@ -1,72 +1,5 @@
-# Upgrading auto-deploy-app chart for Auto DevOps
+---
+redirect_to: 'upgrading_auto_deploy_dependencies.md'
+---
-Auto DevOps provides the auto-deploy-app chart for deploying your application to the
-Kubernetes cluster with Helm/Tiller. Major version changes of this chart could have
-a significantly different resource architecture, and may not be backwards compatible.
-
-This guide provides instructions on how to upgrade your deployments to use the latest
-chart and resource architecture.
-
-## Compatibility
-
-The following table lists the version compatibility between GitLab and [auto-deploy-image](https://gitlab.com/gitlab-org/cluster-integration/auto-deploy-image) (with the [auto-deploy-app chart](https://gitlab.com/gitlab-org/cluster-integration/auto-deploy-image/-/tree/master/assets/auto-deploy-app)).
-
-| GitLab version | auto-deploy-image version | Notes |
-|------------------|---------------------------|--------------------------------------------|
-| v10.0 and higher | v0.1.0 and higher | v0 and v1 charts are backwards compatible. |
-
-## Upgrade Guide
-
-The Auto DevOps project must use the unmodified chart managed by GitLab.
-[Customized charts](customize.md#custom-helm-chart) are unsupported.
-
-### v1 chart
-
-The v1 chart is backward compatible with the v0 chart, so no configuration changes are needed.
-
-## Troubleshooting
-
-### Major version mismatch warning
-
-If deploying a chart that has a major version that is different from the previous one,
-the new chart might not be correctly deployed. This could be due to an architectural
-change. If that happens, the deployment job fails with a message similar to:
-
-```plaintext
-*************************************************************************************
- [WARNING]
-Detected a major version difference between the the chart that is currently deploying (auto-deploy-app-v0.7.0), and the previously deployed chart (auto-deploy-app-v1.0.0).
-A new major version might not be backward compatible with the current release (production). The deployment could fail or be stuck in an unrecoverable status.
-...
-```
-
-To clear this error message and resume deployments, you must do one of the following:
-
-- Manually [upgrade the chart version](#upgrade-guide).
-- [Use a specific chart version](#use-a-specific-chart-version).
-
-#### Use a specific chart version
-
-To use a specific chart version, you must specify a corresponding version of [auto-deploy-image](https://gitlab.com/gitlab-org/cluster-integration/auto-deploy-image).
-Do this by [customizing the image in your `.gitlab-ci.yml`](customize.md#customizing-gitlab-ciyml).
-
-For example, create the following `.gitlab-ci.yml` file in the project. It configures Auto DevOps
-to use [auto-deploy-image](https://gitlab.com/gitlab-org/cluster-integration/auto-deploy-image) version `v0.17.0`
-for deployment jobs. It will download the chart from [chart repository](https://charts.gitlab.io/):
-
-```yaml
-include:
- - template: Auto-DevOps.gitlab-ci.yml
-
-.auto-deploy:
- image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v0.17.0"
-```
-
-#### Ignore warning and continue deploying
-
-If you are certain that the new chart version is safe to be deployed,
-you can add the `AUTO_DEVOPS_FORCE_DEPLOY_V<N>` [environment variable](customize.md#build-and-deployment)
-to force the deployment to continue, where `<N>` is the major version.
-
-For example, if you want to deploy the v2.0.0 chart on a deployment that previously
-used the v0.17.0 chart, add `AUTO_DEVOPS_FORCE_DEPLOY_V2`.
+This document was moved to [another location](upgrading_auto_deploy_dependencies.md).
diff --git a/doc/university/README.md b/doc/university/README.md
index d029c91a19f..63252000dd7 100644
--- a/doc/university/README.md
+++ b/doc/university/README.md
@@ -65,7 +65,6 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres
1. [Making GitLab Great for Everyone - Video](https://www.youtube.com/watch?v=GGC40y4vMx0) - Response to "Dear GitHub" letter
1. [Using Innersourcing to Improve Collaboration](https://about.gitlab.com/blog/2014/09/05/innersourcing-using-the-open-source-workflow-to-improve-collaboration-within-an-organization/)
1. [The Software Development Market and GitLab - Video](https://www.youtube.com/watch?v=sXlhgPK1NTY&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e&index=6) - [Slides](https://docs.google.com/presentation/d/1vCU-NbZWz8NTNK8Vu3y4zGMAHb5DpC8PE5mHtw1PWfI/edit)
-1. [The GitLab Book Club](bookclub/index.md)
1. [GitLab Resources](https://about.gitlab.com/resources/)
### 1.7 Community and Support
@@ -76,7 +75,6 @@ The GitLab University curriculum is composed of GitLab videos, screencasts, pres
- Getting Technical Support
- Being part of our Great Community and Contributing to GitLab
1. [Getting Started with the GitLab Development Kit (GDK)](https://about.gitlab.com/blog/2016/06/08/getting-started-with-gitlab-development-kit/)
-1. [GitLab Training Workshops](training/end-user/README.md)
1. [GitLab Professional Services](https://about.gitlab.com/services/)
### 1.8 GitLab Training Material
diff --git a/doc/university/training/index.md b/doc/university/training/index.md
index 69f82392027..4c54108b4fa 100644
--- a/doc/university/training/index.md
+++ b/doc/university/training/index.md
@@ -17,7 +17,7 @@ This section contains the following topics:
- [Cherry pick](topics/cherry_picking.md).
- [Code review and collaboration with Merge Requests](topics/merge_requests.md).
- [Configure your environment](topics/env_setup.md).
-- [Explore GitLab](topics/explore_gitlab.md).
+- [Explore GitLab](../../gitlab-basics/README.md).
- [Feature branching](topics/feature_branching.md).
- [Getting started](topics/getting_started.md).
- [GitLab flow](gitlab_flow.md).
diff --git a/doc/update/10.0-ce-to-ee.md b/doc/update/10.0-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/10.0-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/10.0-to-10.1.md b/doc/update/10.0-to-10.1.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/10.0-to-10.1.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/10.1-ce-to-ee.md b/doc/update/10.1-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/10.1-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/10.1-to-10.2.md b/doc/update/10.1-to-10.2.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/10.1-to-10.2.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/10.2-ce-to-ee.md b/doc/update/10.2-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/10.2-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/10.2-to-10.3.md b/doc/update/10.2-to-10.3.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/10.2-to-10.3.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/10.3-ce-to-ee.md b/doc/update/10.3-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/10.3-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/10.3-to-10.4.md b/doc/update/10.3-to-10.4.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/10.3-to-10.4.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/10.4-ce-to-ee.md b/doc/update/10.4-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/10.4-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/10.4-to-10.5.md b/doc/update/10.4-to-10.5.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/10.4-to-10.5.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/10.5-ce-to-ee.md b/doc/update/10.5-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/10.5-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/10.5-to-10.6.md b/doc/update/10.5-to-10.6.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/10.5-to-10.6.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/10.6-ce-to-ee.md b/doc/update/10.6-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/10.6-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/10.6-to-10.7.md b/doc/update/10.6-to-10.7.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/10.6-to-10.7.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/10.7-ce-to-ee.md b/doc/update/10.7-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/10.7-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/10.7-to-10.8.md b/doc/update/10.7-to-10.8.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/10.7-to-10.8.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/10.8-ce-to-ee.md b/doc/update/10.8-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/10.8-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/10.8-to-11.0.md b/doc/update/10.8-to-11.0.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/10.8-to-11.0.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/11.0-ce-to-ee.md b/doc/update/11.0-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/11.0-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/11.0-to-11.1.md b/doc/update/11.0-to-11.1.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/11.0-to-11.1.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/11.1-ce-to-ee.md b/doc/update/11.1-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/11.1-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/11.1-to-11.2.md b/doc/update/11.1-to-11.2.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/11.1-to-11.2.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/11.2-ce-to-ee.md b/doc/update/11.2-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/11.2-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/11.2-to-11.3.md b/doc/update/11.2-to-11.3.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/11.2-to-11.3.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/11.3-ce-to-ee.md b/doc/update/11.3-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/11.3-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/11.3-to-11.4.md b/doc/update/11.3-to-11.4.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/11.3-to-11.4.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/11.4-ce-to-ee.md b/doc/update/11.4-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/11.4-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/11.4-to-11.5.md b/doc/update/11.4-to-11.5.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/11.4-to-11.5.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/11.5-ce-to-ee.md b/doc/update/11.5-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/11.5-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/11.5-to-11.6.md b/doc/update/11.5-to-11.6.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/11.5-to-11.6.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/11.6-ce-to-ee.md b/doc/update/11.6-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/11.6-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/11.6-to-11.7.md b/doc/update/11.6-to-11.7.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/11.6-to-11.7.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/11.7-ce-to-ee.md b/doc/update/11.7-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/11.7-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/11.7-to-11.8.md b/doc/update/11.7-to-11.8.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/11.7-to-11.8.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/11.8-ce-to-ee.md b/doc/update/11.8-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/11.8-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/2.6-to-3.0.md b/doc/update/2.6-to-3.0.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/2.6-to-3.0.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/2.9-to-3.0.md b/doc/update/2.9-to-3.0.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/2.9-to-3.0.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/3.0-to-3.1.md b/doc/update/3.0-to-3.1.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/3.0-to-3.1.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/3.1-to-4.0.md b/doc/update/3.1-to-4.0.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/3.1-to-4.0.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/4.0-to-4.1.md b/doc/update/4.0-to-4.1.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/4.0-to-4.1.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/4.1-to-4.2.md b/doc/update/4.1-to-4.2.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/4.1-to-4.2.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/4.2-to-5.0.md b/doc/update/4.2-to-5.0.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/4.2-to-5.0.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/5.0-to-5.1.md b/doc/update/5.0-to-5.1.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/5.0-to-5.1.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/5.1-to-5.2.md b/doc/update/5.1-to-5.2.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/5.1-to-5.2.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/5.1-to-5.4.md b/doc/update/5.1-to-5.4.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/5.1-to-5.4.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/5.1-to-6.0.md b/doc/update/5.1-to-6.0.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/5.1-to-6.0.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/5.2-to-5.3.md b/doc/update/5.2-to-5.3.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/5.2-to-5.3.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/5.3-to-5.4.md b/doc/update/5.3-to-5.4.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/5.3-to-5.4.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/5.4-to-6.0.md b/doc/update/5.4-to-6.0.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/5.4-to-6.0.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/6.0-ce-to-ee.md b/doc/update/6.0-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/6.0-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/6.0-to-6.1.md b/doc/update/6.0-to-6.1.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/6.0-to-6.1.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/6.1-ce-to-ee.md b/doc/update/6.1-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/6.1-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/6.1-to-6.2.md b/doc/update/6.1-to-6.2.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/6.1-to-6.2.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/6.2-ce-to-ee.md b/doc/update/6.2-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/6.2-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/6.2-to-6.3.md b/doc/update/6.2-to-6.3.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/6.2-to-6.3.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/6.3-ce-to-ee.md b/doc/update/6.3-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/6.3-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/6.3-to-6.4.md b/doc/update/6.3-to-6.4.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/6.3-to-6.4.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/6.4-ce-to-ee.md b/doc/update/6.4-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/6.4-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/6.4-to-6.5.md b/doc/update/6.4-to-6.5.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/6.4-to-6.5.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/6.5-ce-to-ee.md b/doc/update/6.5-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/6.5-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/6.5-to-6.6.md b/doc/update/6.5-to-6.6.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/6.5-to-6.6.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/6.6-ce-to-ee.md b/doc/update/6.6-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/6.6-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/6.6-to-6.7.md b/doc/update/6.6-to-6.7.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/6.6-to-6.7.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/6.7-ce-to-ee.md b/doc/update/6.7-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/6.7-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/6.7-to-6.8.md b/doc/update/6.7-to-6.8.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/6.7-to-6.8.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/6.8-ce-to-ee.md b/doc/update/6.8-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/6.8-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/6.8-to-6.9.md b/doc/update/6.8-to-6.9.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/6.8-to-6.9.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/6.9-ce-to-ee.md b/doc/update/6.9-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/6.9-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/6.9-to-7.0.md b/doc/update/6.9-to-7.0.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/6.9-to-7.0.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/6.x-or-7.x-to-7.14.md b/doc/update/6.x-or-7.x-to-7.14.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/6.x-or-7.x-to-7.14.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/7.0-ce-to-ee.md b/doc/update/7.0-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/7.0-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/7.0-to-7.1.md b/doc/update/7.0-to-7.1.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/7.0-to-7.1.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/7.1-ce-to-ee.md b/doc/update/7.1-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/7.1-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/7.1-to-7.2.md b/doc/update/7.1-to-7.2.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/7.1-to-7.2.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/7.10-ce-to-ee.md b/doc/update/7.10-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/7.10-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/7.10-to-7.11.md b/doc/update/7.10-to-7.11.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/7.10-to-7.11.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/7.11-ce-to-ee.md b/doc/update/7.11-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/7.11-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/7.11-to-7.12.md b/doc/update/7.11-to-7.12.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/7.11-to-7.12.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/7.12-ce-to-ee.md b/doc/update/7.12-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/7.12-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/7.12-to-7.13.md b/doc/update/7.12-to-7.13.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/7.12-to-7.13.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/7.13-ce-to-ee.md b/doc/update/7.13-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/7.13-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/7.13-to-7.14.md b/doc/update/7.13-to-7.14.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/7.13-to-7.14.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/7.14-ce-to-ee.md b/doc/update/7.14-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/7.14-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/7.14-to-8.0.md b/doc/update/7.14-to-8.0.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/7.14-to-8.0.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/7.2-to-7.3.md b/doc/update/7.2-to-7.3.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/7.2-to-7.3.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/7.3-ce-to-ee.md b/doc/update/7.3-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/7.3-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/7.3-to-7.4.md b/doc/update/7.3-to-7.4.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/7.3-to-7.4.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/7.4-ce-to-ee.md b/doc/update/7.4-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/7.4-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/7.4-to-7.5.md b/doc/update/7.4-to-7.5.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/7.4-to-7.5.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/7.5-ce-to-ee.md b/doc/update/7.5-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/7.5-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/7.5-to-7.6.md b/doc/update/7.5-to-7.6.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/7.5-to-7.6.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/7.6-ce-to-ee.md b/doc/update/7.6-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/7.6-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/7.6-to-7.7.md b/doc/update/7.6-to-7.7.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/7.6-to-7.7.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/7.7-ce-to-ee.md b/doc/update/7.7-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/7.7-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/7.7-to-7.8.md b/doc/update/7.7-to-7.8.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/7.7-to-7.8.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/7.8-ce-to-ee.md b/doc/update/7.8-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/7.8-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/7.8-to-7.9.md b/doc/update/7.8-to-7.9.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/7.8-to-7.9.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/7.9-ce-to-ee.md b/doc/update/7.9-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/7.9-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/7.9-to-7.10.md b/doc/update/7.9-to-7.10.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/7.9-to-7.10.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.0-ce-to-ee.md b/doc/update/8.0-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.0-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.0-to-8.1.md b/doc/update/8.0-to-8.1.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.0-to-8.1.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.1-ce-to-ee.md b/doc/update/8.1-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.1-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.1-to-8.2.md b/doc/update/8.1-to-8.2.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.1-to-8.2.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.10-ce-to-ee.md b/doc/update/8.10-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.10-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.10-to-8.11.md b/doc/update/8.10-to-8.11.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.10-to-8.11.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.11-ce-to-ee.md b/doc/update/8.11-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.11-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.11-to-8.12.md b/doc/update/8.11-to-8.12.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.11-to-8.12.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.12-ce-to-ee.md b/doc/update/8.12-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.12-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.12-to-8.13.md b/doc/update/8.12-to-8.13.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.12-to-8.13.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.13-ce-to-ee.md b/doc/update/8.13-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.13-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.13-to-8.14.md b/doc/update/8.13-to-8.14.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.13-to-8.14.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.14-ce-to-ee.md b/doc/update/8.14-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.14-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.14-to-8.15.md b/doc/update/8.14-to-8.15.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.14-to-8.15.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.15-ce-to-ee.md b/doc/update/8.15-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.15-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.15-to-8.16.md b/doc/update/8.15-to-8.16.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.15-to-8.16.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.16-ce-to-ee.md b/doc/update/8.16-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.16-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.16-to-8.17.md b/doc/update/8.16-to-8.17.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.16-to-8.17.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.17-ce-to-ee.md b/doc/update/8.17-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.17-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.17-to-9.0.md b/doc/update/8.17-to-9.0.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.17-to-9.0.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.2-ce-to-ee.md b/doc/update/8.2-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.2-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.2-to-8.3.md b/doc/update/8.2-to-8.3.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.2-to-8.3.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.3-ce-to-ee.md b/doc/update/8.3-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.3-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.3-to-8.4.md b/doc/update/8.3-to-8.4.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.3-to-8.4.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.4-ce-to-ee.md b/doc/update/8.4-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.4-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.4-to-8.5.md b/doc/update/8.4-to-8.5.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.4-to-8.5.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.5-ce-to-ee.md b/doc/update/8.5-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.5-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.5-to-8.6.md b/doc/update/8.5-to-8.6.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.5-to-8.6.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.6-ce-to-ee.md b/doc/update/8.6-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.6-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.6-to-8.7.md b/doc/update/8.6-to-8.7.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.6-to-8.7.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.7-ce-to-ee.md b/doc/update/8.7-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.7-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.7-to-8.8.md b/doc/update/8.7-to-8.8.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.7-to-8.8.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.8-ce-to-ee.md b/doc/update/8.8-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.8-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.8-to-8.9.md b/doc/update/8.8-to-8.9.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.8-to-8.9.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/8.9-ce-to-ee.md b/doc/update/8.9-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/8.9-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/8.9-to-8.10.md b/doc/update/8.9-to-8.10.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/8.9-to-8.10.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/9.0-ce-to-ee.md b/doc/update/9.0-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/9.0-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/9.0-to-9.1.md b/doc/update/9.0-to-9.1.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/9.0-to-9.1.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/9.1-ce-to-ee.md b/doc/update/9.1-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/9.1-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/9.1-to-9.2.md b/doc/update/9.1-to-9.2.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/9.1-to-9.2.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/9.2-ce-to-ee.md b/doc/update/9.2-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/9.2-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/9.2-to-9.3.md b/doc/update/9.2-to-9.3.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/9.2-to-9.3.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/9.3-ce-to-ee.md b/doc/update/9.3-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/9.3-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/9.3-to-9.4.md b/doc/update/9.3-to-9.4.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/9.3-to-9.4.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/9.4-ce-to-ee.md b/doc/update/9.4-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/9.4-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/9.4-to-9.5.md b/doc/update/9.4-to-9.5.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/9.4-to-9.5.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/update/9.5-ce-to-ee.md b/doc/update/9.5-ce-to-ee.md
deleted file mode 100644
index 10c9e21fa81..00000000000
--- a/doc/update/9.5-ce-to-ee.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_ce_to_ee.md
----
-
-This document was moved to [another location](upgrading_from_ce_to_ee.md).
diff --git a/doc/update/9.5-to-10.0.md b/doc/update/9.5-to-10.0.md
deleted file mode 100644
index 8514aa13f48..00000000000
--- a/doc/update/9.5-to-10.0.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-redirect_to: upgrading_from_source.md
----
-
-This document was moved to [another location](upgrading_from_source.md).
diff --git a/doc/user/admin_area/abuse_reports.md b/doc/user/admin_area/abuse_reports.md
index 0283f1a9eff..4c346d7dfe8 100644
--- a/doc/user/admin_area/abuse_reports.md
+++ b/doc/user/admin_area/abuse_reports.md
@@ -20,6 +20,9 @@ To receive notifications of new abuse reports by e-mail, follow these steps:
1. Expand the **Abuse reports** section.
1. Provide an email address.
+The notification email address can also be set and retrieved
+[using the API](../../api/settings.md#list-of-settings-that-can-be-accessed-via-api-calls).
+
## Reporting abuse
To find out more about reporting abuse, see [abuse reports user
@@ -31,14 +34,14 @@ To access abuse reports, go to **Admin Area > Abuse Reports**.
There are 3 ways to resolve an abuse report, with a button for each method:
-- Remove user & report. This will:
- - [Delete the reported user](../profile/account/delete_account.md) from the
+- Remove user & report. This:
+ - [Deletes the reported user](../profile/account/delete_account.md) from the
instance.
- - Remove the abuse report from the list.
+ - Removes the abuse report from the list.
- [Block user](#blocking-users).
-- Remove report. This will:
- - Remove the abuse report from the list.
- - Remove access restrictions for the reported user.
+- Remove report. This:
+ - Removes the abuse report from the list.
+ - Removes access restrictions for the reported user.
The following is an example of the **Abuse Reports** page:
@@ -54,8 +57,7 @@ Blocking a user:
- Leaves them in the abuse report list.
- Changes the **Block user** button to a disabled **Already blocked** button.
-The user will be notified with the
-[following message](https://gitlab.com/gitlab-org/gitlab/blob/master/app/workers/email_receiver_worker.rb#L38):
+The user is notified with the following message:
```plaintext
Your account has been blocked. If you believe this is in error, contact a staff member.
diff --git a/doc/user/admin_area/activating_deactivating_users.md b/doc/user/admin_area/activating_deactivating_users.md
index 29f162616bf..8b3a7682841 100644
--- a/doc/user/admin_area/activating_deactivating_users.md
+++ b/doc/user/admin_area/activating_deactivating_users.md
@@ -39,7 +39,7 @@ A user can be deactivated from the Admin Area. To do this:
Please note that for the deactivation option to be visible to an admin, the user:
- Must be currently active.
-- Must not have signed in, or have any activity, in the last 180 days.
+- Must not have signed in, or have any activity, in the last 90 days.
Users can also be deactivated using the [GitLab API](../../api/users.md#deactivate-user).
diff --git a/doc/user/admin_area/analytics/img/cohorts_v13_4.png b/doc/user/admin_area/analytics/img/cohorts_v13_4.png
index 4af1841a033..6f7dd5101f2 100644
--- a/doc/user/admin_area/analytics/img/cohorts_v13_4.png
+++ b/doc/user/admin_area/analytics/img/cohorts_v13_4.png
Binary files differ
diff --git a/doc/user/admin_area/analytics/img/dev_ops_report_v13_4.png b/doc/user/admin_area/analytics/img/dev_ops_report_v13_4.png
index 1fa070a6915..d47d86cd514 100644
--- a/doc/user/admin_area/analytics/img/dev_ops_report_v13_4.png
+++ b/doc/user/admin_area/analytics/img/dev_ops_report_v13_4.png
Binary files differ
diff --git a/doc/user/admin_area/analytics/index.md b/doc/user/admin_area/analytics/index.md
index b3336b471f8..f79245c7325 100644
--- a/doc/user/admin_area/analytics/index.md
+++ b/doc/user/admin_area/analytics/index.md
@@ -4,7 +4,8 @@
Administrators have access to instance-wide analytics, as shown in **Admin Area > Analytics**.
-There are two kinds of statistics:
+There are several kinds of statistics:
- [DevOps Report](dev_ops_report.md): Provides an overview of your entire instance's feature usage.
+- [Instance Statistics](instance_statistics.md): Shows how much data your instance contains, and how that is changing.
- [User Cohorts](user_cohorts.md): Display the monthly cohorts of new users and their activities over time.
diff --git a/doc/user/admin_area/analytics/instance_statistics.md b/doc/user/admin_area/analytics/instance_statistics.md
new file mode 100644
index 00000000000..bac0e845d2c
--- /dev/null
+++ b/doc/user/admin_area/analytics/instance_statistics.md
@@ -0,0 +1,18 @@
+# Instance Statistics
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/235754) in GitLab 13.4.
+
+Instance Statistics gives you an overview of how much data your instance contains, and how quickly this volume is changing over time.
+
+## Total counts
+
+At the top of the page, Instance Statistics shows total counts for:
+
+- Users
+- Projects
+- Groups
+- Issues
+- Merge Requests
+- Pipelines
+
+These figures can be useful for understanding how much data your instance contains in total.
diff --git a/doc/user/admin_area/credentials_inventory.md b/doc/user/admin_area/credentials_inventory.md
index 7f2d49dafea..e845f22140b 100644
--- a/doc/user/admin_area/credentials_inventory.md
+++ b/doc/user/admin_area/credentials_inventory.md
@@ -9,8 +9,6 @@ type: howto
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/20912) in GitLab 12.6.
-## Overview
-
GitLab administrators are responsible for the overall security of their instance. To assist, GitLab provides a Credentials inventory to keep track of all the credentials that can be used to access their self-managed instance.
Using Credentials inventory, you can see all the personal access tokens (PAT) and SSH keys that exist in your GitLab instance. In addition, you can [revoke them](#revoke-a-users-personal-access-token) and see:
diff --git a/doc/user/admin_area/settings/gitaly_timeouts.md b/doc/user/admin_area/settings/gitaly_timeouts.md
index 2003d02c9b3..cac05678a1a 100644
--- a/doc/user/admin_area/settings/gitaly_timeouts.md
+++ b/doc/user/admin_area/settings/gitaly_timeouts.md
@@ -3,36 +3,28 @@ stage: Create
group: Gitaly
info: "To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers"
type: reference
-type: reference
---
-# Gitaly timeouts
-
-![Gitaly timeouts](img/gitaly_timeouts.png)
-
-3 timeout types can be configured to make sure that long running
-Gitaly calls don't needlessly take up resources.
-
-- Default timeout
-
-This timeout is the default for most Gitaly calls.
-It should be shorter than the worker timeout that can be configured
-for
-[Puma](https://docs.gitlab.com/omnibus/settings/puma.html#puma-settings)
-or [Unicorn](https://docs.gitlab.com/omnibus/settings/unicorn.html).
-This makes sure that Gitaly calls made within a web request cannot
-exceed these the entire request timeout.
+# Gitaly timeouts **(CORE ONLY)**
-The default for this timeout is 55 seconds.
+[Gitaly](../../../administration/gitaly/index.md) timeouts are configurable. The timeouts can be
+configured to make sure that long running Gitaly calls don't needlessly take up resources.
-- Fast timeout
+To access Gitaly timeout settings:
-This is the timeout for very short Gitaly calls.
+1. Go to **Admin Area > Settings > Preferences**.
+1. Expand the **Gitaly** section.
-The default for this timeout is 10 seconds.
+## Available timeouts
-- Medium timeout
+The following timeouts can be modified:
-This timeout should be between the default and the fast timeout
+- **Default Timeout Period**. This timeout is the default for most Gitaly calls. It should be shorter than the
+ worker timeout that can be configured for [Puma](https://docs.gitlab.com/omnibus/settings/puma.html#puma-settings)
+ or [Unicorn](https://docs.gitlab.com/omnibus/settings/unicorn.html). Used to make sure that Gitaly
+ calls made within a web request cannot exceed the entire request timeout.
+ Defaults to 55 seconds.
-The default for this timeout is 30 seconds.
+- **Fast Timeout Period**. This is the timeout for very short Gitaly calls. Defaults to 10 seconds.
+- **Medium Timeout Period**. This timeout should be between the default and the fast timeout.
+ Defaults to 30 seconds.
diff --git a/doc/user/admin_area/settings/img/gitaly_timeouts.png b/doc/user/admin_area/settings/img/gitaly_timeouts.png
deleted file mode 100644
index 28394d238f7..00000000000
--- a/doc/user/admin_area/settings/img/gitaly_timeouts.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/admin_area/settings/index.md b/doc/user/admin_area/settings/index.md
index ba9bccbf3e7..a107e607d7f 100644
--- a/doc/user/admin_area/settings/index.md
+++ b/doc/user/admin_area/settings/index.md
@@ -30,7 +30,7 @@ Access the default page for admin area settings by navigating to **Admin Area >
| Option | Description |
| ------ | ----------- |
-| [Elasticsearch](../../../integration/elasticsearch.md#enabling-elasticsearch) | Elasticsearch integration. Elasticsearch AWS IAM. |
+| [Elasticsearch](../../../integration/elasticsearch.md#enabling-advanced-search) | Elasticsearch integration. Elasticsearch AWS IAM. |
| [PlantUML](../../../administration/integration/plantuml.md#gitlab) | Allow rendering of PlantUML diagrams in AsciiDoc documents. |
| [Slack application](../../../user/project/integrations/gitlab_slack_application.md#configuration) **(FREE ONLY)** | Slack integration allows you to interact with GitLab via slash commands in a chat window. This option is only available on GitLab.com, though it may be [available for self-managed instances in the future](https://gitlab.com/gitlab-org/gitlab/-/issues/28164). |
| [Third party offers](third_party_offers.md) | Control the display of third party offers. |
@@ -102,7 +102,7 @@ Access the default page for admin area settings by navigating to **Admin Area >
| Option | Description |
| ------ | ----------- |
| [Email](email.md) | Various email settings. |
-| [Help page](../../../customization/help_message.md) | Help page text and support page URL. |
+| [Help page](help_page.md) | Help page text and support page URL. |
| [Pages](../../../administration/pages/index.md#custom-domain-verification) | Size and domain settings for static websites |
| [Real-time features](../../../administration/polling.md) | Change this value to influence how frequently the GitLab UI polls for updates. |
| [Gitaly timeouts](gitaly_timeouts.md) | Configure Gitaly timeouts. |
diff --git a/doc/user/admin_area/settings/instance_template_repository.md b/doc/user/admin_area/settings/instance_template_repository.md
index 97380b93295..20f2812bc39 100644
--- a/doc/user/admin_area/settings/instance_template_repository.md
+++ b/doc/user/admin_area/settings/instance_template_repository.md
@@ -9,8 +9,6 @@ type: reference
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/5986) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.3.
-## Overview
-
In hosted systems, enterprises often have a need to share their own templates
across teams. This feature allows an administrator to pick a project to be the
instance-wide collection of file templates. These templates are then exposed to
diff --git a/doc/user/analytics/img/mr_throughput_chart_v13_3.png b/doc/user/analytics/img/mr_throughput_chart_v13_3.png
index 04fa54f323c..100c9a8557c 100644
--- a/doc/user/analytics/img/mr_throughput_chart_v13_3.png
+++ b/doc/user/analytics/img/mr_throughput_chart_v13_3.png
Binary files differ
diff --git a/doc/user/analytics/img/mr_throughput_table_v13_3.png b/doc/user/analytics/img/mr_throughput_table_v13_3.png
index 63ffb9389f4..bb63770dc3f 100644
--- a/doc/user/analytics/img/mr_throughput_table_v13_3.png
+++ b/doc/user/analytics/img/mr_throughput_table_v13_3.png
Binary files differ
diff --git a/doc/user/analytics/img/new_value_stream_v13_3.png b/doc/user/analytics/img/new_value_stream_v13_3.png
index 4284b8ab194..bc8502e85a6 100644
--- a/doc/user/analytics/img/new_value_stream_v13_3.png
+++ b/doc/user/analytics/img/new_value_stream_v13_3.png
Binary files differ
diff --git a/doc/user/analytics/img/vsa_filter_bar_v13.3.png b/doc/user/analytics/img/vsa_filter_bar_v13.3.png
index 71e59892434..506765f63cb 100644
--- a/doc/user/analytics/img/vsa_filter_bar_v13.3.png
+++ b/doc/user/analytics/img/vsa_filter_bar_v13.3.png
Binary files differ
diff --git a/doc/user/analytics/productivity_analytics.md b/doc/user/analytics/productivity_analytics.md
index 653836de8be..290aae6395f 100644
--- a/doc/user/analytics/productivity_analytics.md
+++ b/doc/user/analytics/productivity_analytics.md
@@ -13,7 +13,7 @@ Track development velocity with Productivity Analytics.
For many companies, the development cycle is a black box and getting an estimate of how
long, on average, it takes to deliver features is an enormous endeavor.
-While [Value Stream Analytics](../project/cycle_analytics.md) focuses on the entire
+While [Value Stream Analytics](../analytics/value_stream_analytics.md) focuses on the entire
Software Development Life Cycle (SDLC) process, Productivity Analytics provides a way for Engineering Management to drill down in a systematic way to uncover patterns and causes for success or failure at an individual, project, or group level.
Productivity can slow down for many reasons ranging from degrading code base to quickly growing teams. In order to investigate, department or team leaders can start by visualizing the time it takes for merge requests to be merged.
diff --git a/doc/user/analytics/value_stream_analytics.md b/doc/user/analytics/value_stream_analytics.md
index 14012d4a28d..49157823d9f 100644
--- a/doc/user/analytics/value_stream_analytics.md
+++ b/doc/user/analytics/value_stream_analytics.md
@@ -12,26 +12,26 @@ info: To determine the technical writer assigned to the Stage/Group associated w
Value Stream Analytics measures the time spent to go from an
[idea to production](https://about.gitlab.com/blog/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/#from-idea-to-production-with-gitlab)
-(also known as cycle time) for each of your projects. Value Stream Analytics displays the median time
+(also known as cycle time) for each of your projects or groups. Value Stream Analytics displays the median time
spent in each stage defined in the process.
-For information on how to contribute to the development of Value Stream Analytics, see our [contributor documentation](../../development/value_stream_analytics.md).
-
Value Stream Analytics is useful in order to quickly determine the velocity of a given
project. It points to bottlenecks in the development process, enabling management
to uncover, triage, and identify the root cause of slowdowns in the software development life cycle.
-Value Stream Analytics is tightly coupled with the [GitLab flow](../../topics/gitlab_flow.md) and
-calculates a separate median for each stage.
+For information on how to contribute to the development of Value Stream Analytics, see our [contributor documentation](../../development/value_stream_analytics.md).
+
+## Project Level Value Stream Analytics **CORE**
+
+Project Level Value Stream Analytics is available via **Project > Analytics > Value Stream**.
-## Overview
+## Group Level Value Stream Analytics **PREMIUM**
-Value Stream Analytics is available:
+From GitLab 12.9, group level Value Stream Analytics is available via **Group > Analytics > Value Stream**.
-- From GitLab 12.9, at the group level via **Group > Analytics > Value Stream**. **(PREMIUM)**
-- At the project level via **Project > Analytics > Value Stream**.
+## Default stages
-There are seven stages that are tracked as part of the Value Stream Analytics calculations.
+The stages tracked by Value Stream Analytics by default represent the [GitLab flow](../../topics/gitlab_flow.md). These stages can be customized in Group Level Value Stream Analytics.
- **Issue** (Tracker)
- Time to schedule an issue (by milestone or by adding it to an issue board)
@@ -123,7 +123,7 @@ How this works, behind the scenes:
we need for the stages, like issue creation date, merge request merge time,
etc.
-To sum up, anything that doesn't follow [GitLab flow](../../workflow/gitlab_flow.md) will not be tracked and the
+To sum up, anything that doesn't follow [GitLab flow](../../topics/gitlab_flow.md) will not be tracked and the
Value Stream Analytics dashboard will not present any data for:
- Merge requests that do not close an issue.
diff --git a/doc/user/application_security/configuration/index.md b/doc/user/application_security/configuration/index.md
index a6ad701360e..ead34ca227e 100644
--- a/doc/user/application_security/configuration/index.md
+++ b/doc/user/application_security/configuration/index.md
@@ -19,8 +19,9 @@ then in the left sidebar go to **Security & Compliance > Configuration**.
For each security control the page displays:
-- **Status** - Status of the security control: enabled, not enabled, or available.
-- **Manage** - A management option or a link to the documentation.
+- **Security Control:** Name, description, and a documentation link.
+- **Status:** The security control's status (enabled, not enabled, or available).
+- **Manage:** A management option or a documentation link.
## Status
@@ -29,12 +30,11 @@ The status of each security control is determined by the project's latest defaul
If a job with the expected security report artifact exists in the pipeline, the feature's status is
_enabled_.
-For SAST, click **View history** to see the `.gitlab-ci.yml` file’s history.
-
-NOTE: **Note:**
If the latest pipeline used [Auto DevOps](../../../topics/autodevops/index.md),
all security features are configured by default.
+For SAST, click **View history** to see the `.gitlab-ci.yml` file's history.
+
## Manage
You can configure the following security controls:
diff --git a/doc/user/application_security/container_scanning/index.md b/doc/user/application_security/container_scanning/index.md
index 880e5a3875a..47b7347deba 100644
--- a/doc/user/application_security/container_scanning/index.md
+++ b/doc/user/application_security/container_scanning/index.md
@@ -9,8 +9,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/3672) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 10.4.
-## Overview
-
Your application's Docker image may itself be based on Docker images that contain known
vulnerabilities. By including an extra job in your pipeline that scans for those vulnerabilities and
displays them in a merge request, you can use GitLab to audit your Docker-based apps.
@@ -19,7 +17,6 @@ By default, container scanning in GitLab is based on [Clair](https://github.com/
containers. [GitLab's Klar analyzer](https://gitlab.com/gitlab-org/security-products/analyzers/klar/)
scans the containers and serves as a wrapper for Clair.
-NOTE: **Note:**
To integrate security scanners other than Clair and Klar into GitLab, see
[Security scanner integration](../../../development/integrations/secure.md).
@@ -65,7 +62,7 @@ To enable container scanning in your pipeline, you need the following:
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG:$CI_COMMIT_SHA
script:
- - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
+ - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
- docker build -t $IMAGE_TAG .
- docker push $IMAGE_TAG
```
@@ -119,7 +116,7 @@ build:
IMAGE: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG:$CI_COMMIT_SHA
script:
- docker info
- - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
+ - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
- docker build -t $IMAGE .
- docker push $IMAGE
@@ -219,8 +216,7 @@ To use container scanning in an offline environment, you need:
- GitLab Runner with the [`docker` or `kubernetes` executor](#requirements).
- To configure a local Docker container registry with copies of the container scanning [analyzer](https://gitlab.com/gitlab-org/security-products/analyzers/klar) images, found in the [container scanning container registry](https://gitlab.com/gitlab-org/security-products/analyzers/klar/container_registry).
-NOTE: **Note:**
-GitLab Runner has a [default `pull policy` of `always`](https://docs.gitlab.com/runner/executors/docker.html#using-the-always-pull-policy),
+Note that GitLab Runner has a [default `pull policy` of `always`](https://docs.gitlab.com/runner/executors/docker.html#using-the-always-pull-policy),
meaning the runner tries to pull Docker images from the GitLab container registry even if a local
copy is available. The GitLab Runner [`pull_policy` can be set to `if-not-present`](https://docs.gitlab.com/runner/executors/docker.html#using-the-if-not-present-pull-policy)
in an offline environment if you prefer using only locally available Docker images. However, we
@@ -287,7 +283,7 @@ build_latest_vulnerabilities:
script:
- docker pull arminc/clair-db:latest
- docker tag arminc/clair-db:latest $CI_REGISTRY/namespace/clair-vulnerabilities-db
- - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
+ - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
- docker push $CI_REGISTRY/namespace/clair-vulnerabilities-db
```
@@ -433,3 +429,7 @@ This is a result of a bug in Docker which is now [fixed](https://github.com/cont
To prevent the error, ensure the Docker version that the runner is using is
`18.09.03` or higher. For more information, see
[issue #10241](https://gitlab.com/gitlab-org/gitlab/-/issues/10241 "Investigate why Container Scanning is not working with NFS mounts").
+
+### Getting warning message `gl-container-scanning-report.json: no matching files`
+
+For information on this, see the [general Application Security troubleshooting section](../../../ci/pipelines/job_artifacts.md#error-message-no-files-to-upload).
diff --git a/doc/user/application_security/dast/img/dast_v13_2.png b/doc/user/application_security/dast/img/dast_v13_2.png
deleted file mode 100644
index bbf7944eb40..00000000000
--- a/doc/user/application_security/dast/img/dast_v13_2.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/application_security/dast/img/dast_v13_4.png b/doc/user/application_security/dast/img/dast_v13_4.png
new file mode 100644
index 00000000000..d9c1d1b5c66
--- /dev/null
+++ b/doc/user/application_security/dast/img/dast_v13_4.png
Binary files differ
diff --git a/doc/user/application_security/dast/index.md b/doc/user/application_security/dast/index.md
index 73a8e727389..d83b7e34d51 100644
--- a/doc/user/application_security/dast/index.md
+++ b/doc/user/application_security/dast/index.md
@@ -9,17 +9,17 @@ type: reference, howto
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/4348) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 10.4.
-NOTE: **Note:**
-The whitepaper ["A Seismic Shift in Application Security"](https://about.gitlab.com/resources/whitepaper-seismic-shift-application-security/)
-explains how **4 of the top 6 attacks were application based**. Download it
-to learn how to protect your organization.
-
Running [static checks](../sast/index.md) on your code is the first step to detect
vulnerabilities that can put the security of your code at risk. Yet, once
deployed, your application is exposed to a new category of possible attacks,
such as cross-site scripting or broken authentication flaws. This is where
Dynamic Application Security Testing (DAST) comes into place.
+NOTE: **Note:**
+The whitepaper ["A Seismic Shift in Application Security"](https://about.gitlab.com/resources/whitepaper-seismic-shift-application-security/)
+explains how 4 of the top 6 attacks were application based. Download it to learn how to protect your
+organization.
+
## Overview
If you're using [GitLab CI/CD](../../../ci/README.md), you can analyze your running web applications
@@ -32,11 +32,10 @@ provided by [Auto DevOps](../../../topics/autodevops/index.md).
GitLab checks the DAST report, compares the found vulnerabilities between the source and target
branches, and shows the information on the merge request.
-NOTE: **Note:**
-This comparison logic uses only the latest pipeline executed for the target branch's base commit.
-Running the pipeline on any other commit has no effect on the merge request.
+Note that this comparison logic uses only the latest pipeline executed for the target branch's base
+commit. Running the pipeline on any other commit has no effect on the merge request.
-![DAST Widget](img/dast_v13_2.png)
+![DAST Widget](img/dast_v13_4.png)
By clicking on one of the detected linked vulnerabilities, you can
see the details and the URL(s) affected.
@@ -53,12 +52,11 @@ However, DAST can be [configured](#full-scan)
to also perform an *active scan*: attack your application and produce a more extensive security report.
It can be very useful combined with [Review Apps](../../../ci/review_apps/index.md).
-NOTE: **Note:**
-A pipeline may consist of multiple jobs, including SAST and DAST scanning. If any
-job fails to finish for any reason, the security dashboard doesn't show DAST scanner
-output. For example, if the DAST job finishes but the SAST job fails, the security
-dashboard doesn't show DAST results. The analyzer outputs an
-[exit code](../../../development/integrations/secure.md#exit-code) on failure.
+Note that a pipeline may consist of multiple jobs, including SAST and DAST scanning. If any job
+fails to finish for any reason, the security dashboard doesn't show DAST scanner output. For
+example, if the DAST job finishes but the SAST job fails, the security dashboard doesn't show DAST
+results. On failure, the analyzer outputs an
+[exit code](../../../development/integrations/secure.md#exit-code).
## Use cases
@@ -206,8 +204,8 @@ variables:
DAST_FULL_SCAN_ENABLED: "true"
```
-NOTE: **Note:**
-If your DAST job exceeds the job timeout and you need to reduce the scan duration, we shared some tips for optimizing DAST scans in a [blog post](https://about.gitlab.com/blog/2020/08/31/how-to-configure-dast-full-scans-for-complex-web-applications/).
+If your DAST job exceeds the job timeout and you need to reduce the scan duration, we shared some
+tips for optimizing DAST scans in a [blog post](https://about.gitlab.com/blog/2020/08/31/how-to-configure-dast-full-scans-for-complex-web-applications/).
#### Domain validation
@@ -398,11 +396,9 @@ variables:
DAST_API_HOST_OVERRIDE: api-test.host.com
```
-NOTE: **Note:**
-Using a host override is ONLY supported when importing the API
-specification from a URL. It does not work and will be ignored when importing
-the specification from a file. This is due to a limitation in the ZAP OpenAPI
-extension.
+Note that using a host override is ONLY supported when importing the API specification from a URL.
+It doesn't work and is ignored when importing the specification from a file. This is due to a
+limitation in the ZAP OpenAPI extension.
#### Authentication using headers
@@ -427,7 +423,8 @@ A URL scan allows you to specify which parts of a website are scanned by DAST.
#### Define the URLs to scan
-To specify the paths to be scanned, add a comma-separated list of the paths to the `DAST_PATHS` environment variable. Note that you can only scan paths of a single host.
+To specify the paths to scan, add a comma-separated list of the paths to the `DAST_PATHS`
+environment variable. Note that you can only scan paths of a single host.
```yaml
include:
@@ -437,8 +434,11 @@ variables:
DAST_PATHS=/page1.html,/category1/page1.html,/page3.html
```
-NOTE: **Note:**
-`DAST_AUTH_EXCLUDE_URLS` are ignored when `DAST_PATHS` is set.
+When using `DAST_PATHS`, note the following:
+
+- The `DAST_PATHS` environment variable has a limit of about 130kb. If you have a list or paths
+ greater than this, you should create multiple DAST jobs and split the paths over each job.
+- The `DAST_AUTH_EXCLUDE_URLS` environment variable is ignored when `DAST_PATHS` is set.
#### Full Scan
@@ -590,8 +590,7 @@ To use DAST in an offline environment, you need:
[container image](https://gitlab.com/gitlab-org/security-products/dast), found in the
[DAST container registry](https://gitlab.com/gitlab-org/security-products/dast/container_registry).
-NOTE: **Note:**
-GitLab Runner has a [default `pull policy` of `always`](https://docs.gitlab.com/runner/executors/docker.html#using-the-always-pull-policy),
+Note that GitLab Runner has a [default `pull policy` of `always`](https://docs.gitlab.com/runner/executors/docker.html#using-the-always-pull-policy),
meaning the runner tries to pull Docker images from the GitLab container registry even if a local
copy is available. The GitLab Runner [`pull_policy` can be set to `if-not-present`](https://docs.gitlab.com/runner/executors/docker.html#using-the-if-not-present-pull-policy)
in an offline environment if you prefer using only locally available Docker images. However, we
@@ -672,11 +671,6 @@ To delete an existing site profile:
## Scanner profile
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/222767) in GitLab 13.4.
-> - [Deployed behind a feature flag](../../feature_flags.md), enabled by default.
-> - Enabled on GitLab.com.
-> - Can be enabled or disabled per-project.
-> - Recommended for production use.
-> - For GitLab self-managed instances, GitLab administrators can [disable this feature](#enable-or-disable-dast-scanner-profiles).
A scanner profile defines the scanner settings used to run an on-demand scan:
@@ -711,29 +705,6 @@ To delete a scanner profile:
1. Click **Manage** in the **DAST Profiles** row.
1. Click **{remove}** in the scanner profile's row.
-### Enable or disable DAST scanner profiles
-
-The scanner profile feature is ready for production use. It's deployed behind a feature flag that
-is **enabled by default**. [GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md) can opt to disable it.
-
-To disable it:
-
-```ruby
-# Instance-wide
-Feature.disable(:security_on_demand_scans_scanner_profiles)
-# or by project
-Feature.disable(:security_on_demand_scans_scanner_profiles, Project.find(<project id>))
-```
-
-To enable it:
-
-```ruby
-# Instance-wide
-Feature.enable(:security_on_demand_scans_scanner_profiles)
-# or by project
-Feature.enable(:security_on_demand_scans_scanner_profiles, Project.find(<project ID>))
-```
-
## On-demand scans
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218465) in GitLab 13.2.
@@ -756,7 +727,8 @@ An on-demand DAST scan:
NOTE: **Note:**
You must have permission to run an on-demand DAST scan against a protected branch.
-The default branch is automatically protected. For more details, see [Pipeline security on protected branches](../../../ci/pipelines/index.md#pipeline-security-on-protected-branches).
+The default branch is automatically protected. For more information, see
+[Pipeline security on protected branches](../../../ci/pipelines/index.md#pipeline-security-on-protected-branches).
To run an on-demand DAST scan, you need:
@@ -923,6 +895,10 @@ Change the number after `-Xmx` to the required memory amount.
If your DAST job exceeds the job timeout and you need to reduce the scan duration, we shared some tips for optimizing DAST scans in a [blog post](https://about.gitlab.com/blog/2020/08/31/how-to-configure-dast-full-scans-for-complex-web-applications/).
+### Getting warning message `gl-dast-report.json: no matching files`
+
+For information on this, see the [general Application Security troubleshooting section](../../../ci/pipelines/job_artifacts.md#error-message-no-files-to-upload).
+
<!-- ## Troubleshooting
Include any troubleshooting steps that you can foresee. If you know beforehand what issues
diff --git a/doc/user/application_security/dependency_scanning/index.md b/doc/user/application_security/dependency_scanning/index.md
index 5cce336d04c..67d2ae2d3a7 100644
--- a/doc/user/application_security/dependency_scanning/index.md
+++ b/doc/user/application_security/dependency_scanning/index.md
@@ -9,25 +9,26 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/5105) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 10.7.
-Dependency Scanning helps to find security vulnerabilities in your dependencies automatically
-while you're developing and testing your applications, such as when your
-application is using an external (open source) library that is known to be vulnerable.
+GitLab's Dependency Scanning feature can automatically find security vulnerabilities in your
+dependencies while you're developing and testing your applications. For example, dependency scanning
+lets you know if your application uses an external (open source) library that is known to be
+vulnerable. You can then take action to protect your application.
## Overview
-If you're using [GitLab CI/CD](../../../ci/README.md), you can analyze your dependencies for known
-vulnerabilities using Dependency Scanning.
-All dependencies are scanned, including the transitive dependencies (also known as nested dependencies).
-You can take advantage of Dependency Scanning by either [including the Dependency Scanning template](#configuration)
-in your existing `.gitlab-ci.yml` file or by implicitly using
-the [Auto Dependency Scanning](../../../topics/autodevops/stages.md#auto-dependency-scanning)
+If you're using [GitLab CI/CD](../../../ci/README.md), you can use dependency scanning to analyze
+your dependencies for known vulnerabilities. GitLab scans all dependencies, including transitive
+dependencies (also known as nested dependencies). You can take advantage of dependency scanning by
+either [including the dependency scanning template](#configuration)
+in your existing `.gitlab-ci.yml` file, or by implicitly using
+the [auto dependency scanning](../../../topics/autodevops/stages.md#auto-dependency-scanning)
provided by [Auto DevOps](../../../topics/autodevops/index.md).
-GitLab checks the Dependency Scanning report, compares the found vulnerabilities
+GitLab checks the dependency scanning report, compares the found vulnerabilities
between the source and target branches, and shows the information on the
merge request.
-![Dependency Scanning Widget](img/dependency_scanning_v13_2.png)
+![Dependency scanning Widget](img/dependency_scanning_v13_2.png)
The results are sorted by the severity of the vulnerability:
@@ -40,7 +41,7 @@ The results are sorted by the severity of the vulnerability:
## Requirements
-To run Dependency Scanning jobs, by default, you need GitLab Runner with the
+To run dependency scanning jobs, by default, you need GitLab Runner with the
[`docker`](https://docs.gitlab.com/runner/executors/docker.html) or
[`kubernetes`](https://docs.gitlab.com/runner/install/kubernetes.html) executor.
If you're using the shared runners on GitLab.com, this is enabled by default.
@@ -56,24 +57,24 @@ The current detection logic limits the maximum search depth to two levels. For e
The following languages and dependency managers are supported:
-| Language (package managers) | Supported files | Scan tool(s) |
-|----------------------------- | --------------- | ------------ |
-| C# .NET ([NuGet](https://www.nuget.org/) 4.9+) | [`packages.lock.json`](https://docs.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files#enabling-lock-file) | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
-| C/C++ ([Conan](https://conan.io/)) | [`conan.lock`](https://docs.conan.io/en/latest/versioning/lockfiles.html) | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
-| Java ([Gradle](https://gradle.org/), [Maven](https://maven.apache.org/)) | `build.gradle`, `build.gradle.kts`, `pom.xml` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
-| JavaScript ([npm](https://www.npmjs.com/), [yarn](https://classic.yarnpkg.com/en/)) | `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium), [Retire.js](https://retirejs.github.io/retire.js/) |
-| Go ([Golang](https://golang.org/)) | `go.sum` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
-| PHP ([Composer](https://getcomposer.org/)) | `composer.lock` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
-| Python ([setuptools](https://setuptools.readthedocs.io/en/latest/), [pip](https://pip.pypa.io/en/stable/), [Pipenv](https://pipenv.pypa.io/en/latest/)) | `setup.py`, `requirements.txt`, `requirements.pip`, `requires.txt`, `Pipfile` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
-| Ruby ([Bundler](https://bundler.io/)) | `Gemfile.lock`, `gems.locked` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium), [bundler-audit](https://github.com/rubysec/bundler-audit) |
-| Scala ([sbt](https://www.scala-sbt.org/)) | `build.sbt` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
+| Package Managers | Languages | Supported files | Scan tools |
+| ------------------- | --------- | --------------- | ------------ |
+| [Bundler](https://bundler.io/) | Ruby | `Gemfile.lock`, `gems.locked` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium), [bundler-audit](https://github.com/rubysec/bundler-audit) |
+| [Composer](https://getcomposer.org/) | PHP | `composer.lock` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
+| [Conan](https://conan.io/) | C, C++ | [`conan.lock`](https://docs.conan.io/en/latest/versioning/lockfiles.html) | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
+| [Golang](https://golang.org/) | Go | `go.sum` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
+| [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/) | Java | `build.gradle`, `build.gradle.kts`, `pom.xml` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
+| [npm](https://www.npmjs.com/), [yarn](https://classic.yarnpkg.com/en/) | JavaScript | `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium), [Retire.js](https://retirejs.github.io/retire.js/) |
+| [NuGet](https://www.nuget.org/) 4.9+ | .NET, C# | [`packages.lock.json`](https://docs.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files#enabling-lock-file) | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
+| [setuptools](https://setuptools.readthedocs.io/en/latest/), [pip](https://pip.pypa.io/en/stable/), [Pipenv](https://pipenv.pypa.io/en/latest/) | Python | `setup.py`, `requirements.txt`, `requirements.pip`, `requires.txt`, `Pipfile` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
+| [sbt](https://www.scala-sbt.org/) | Scala | `build.sbt` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) |
Plans are underway for supporting the following languages, dependency managers, and dependency files. For details, see the issue link for each.
-| Language (package managers) | Supported files | Scan tool(s) | Issue |
-|----------------------------- | --------------- | ------------ | ----- |
-| Python ([Poetry](https://python-poetry.org/)) | `poetry.lock` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) | [GitLab#7006](https://gitlab.com/gitlab-org/gitlab/-/issues/7006) |
-| Python ([Pipenv](https://pipenv.pypa.io/en/latest/)) | `Pipfile.lock` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) | [GitLab#11756](https://gitlab.com/gitlab-org/gitlab/-/issues/11756) |
+| Package Managers | Languages | Supported files | Scan tools |
+| ------------------- | --------- | --------------- | ------------ |
+| [Pipenv](https://pipenv.pypa.io/en/latest/) | Python | `Pipfile.lock` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) | [GitLab#11756](https://gitlab.com/gitlab-org/gitlab/-/issues/11756) |
+| [Poetry](https://python-poetry.org/) | Python | `poetry.lock` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) | [GitLab#7006](https://gitlab.com/gitlab-org/gitlab/-/issues/7006) |
## Contribute your scanner
@@ -81,7 +82,7 @@ The [Security Scanner Integration](../../../development/integrations/secure.md)
## Configuration
-To enable Dependency Scanning for GitLab 11.9 and later, you must
+To enable dependency scanning for GitLab 11.9 and later, you must
[include](../../../ci/yaml/README.md#includetemplate) the
[`Dependency-Scanning.gitlab-ci.yml` template](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Security/Dependency-Scanning.gitlab-ci.yml)
that is provided as a part of your GitLab installation.
@@ -95,16 +96,16 @@ include:
- template: Dependency-Scanning.gitlab-ci.yml
```
-The included template creates Dependency Scanning jobs in your CI/CD
+The included template creates dependency scanning jobs in your CI/CD
pipeline and scans your project's source code for possible vulnerabilities.
The results are saved as a
-[Dependency Scanning report artifact](../../../ci/pipelines/job_artifacts.md#artifactsreportsdependency_scanning)
+[dependency scanning report artifact](../../../ci/pipelines/job_artifacts.md#artifactsreportsdependency_scanning)
that you can later download and analyze. Due to implementation limitations, we
-always take the latest Dependency Scanning artifact available.
+always take the latest dependency scanning artifact available.
-### Customizing the Dependency Scanning settings
+### Customizing the dependency scanning settings
-The Dependency Scanning settings can be changed through [environment variables](#available-variables) by using the
+The dependency scanning settings can be changed through [environment variables](#available-variables) by using the
[`variables`](../../../ci/yaml/README.md#variables) parameter in `.gitlab-ci.yml`.
For example:
@@ -119,7 +120,7 @@ variables:
Because template is [evaluated before](../../../ci/yaml/README.md#include) the pipeline
configuration, the last mention of the variable takes precedence.
-### Overriding Dependency Scanning jobs
+### Overriding dependency scanning jobs
CAUTION: **Deprecation:**
Beginning in GitLab 13.0, the use of [`only` and `except`](../../../ci/yaml/README.md#onlyexcept-basic)
@@ -141,10 +142,10 @@ gemnasium-dependency_scanning:
### Available variables
-Dependency Scanning can be [configured](#customizing-the-dependency-scanning-settings)
+Dependency scanning can be [configured](#customizing-the-dependency-scanning-settings)
using environment variables.
-#### Configuring Dependency Scanning
+#### Configuring dependency scanning
The following variables allow configuration of global dependency scanning settings.
@@ -156,7 +157,7 @@ The following variables allow configuration of global dependency scanning settin
| `DS_EXCLUDED_PATHS` | Exclude vulnerabilities from output based on the paths. A comma-separated list of patterns. Patterns can be globs, or file or folder paths (for example, `doc,spec`). Parent directories also match patterns. Default: `"spec, test, tests, tmp"` |
| `SECURE_LOG_LEVEL` | Set the minimum logging level. Messages of this logging level or higher are output. From highest to lowest severity, the logging levels are: `fatal`, `error`, `warn`, `info`, `debug`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/10880) in GitLab 13.1. Default: `info` |
-#### Configuring specific analyzers used by Dependency Scanning
+#### Configuring specific analyzers used by dependency scanning
The following variables are used for configuring specific analyzers (used for a specific language/framework).
@@ -176,7 +177,7 @@ The following variables are used for configuring specific analyzers (used for a
| `MAVEN_CLI_OPTS` | `gemnasium-maven` | `"-DskipTests --batch-mode"` | List of command line arguments that are passed to `maven` by the analyzer. See an example for [using private repositories](../index.md#using-private-maven-repos). |
| `GRADLE_CLI_OPTS` | `gemnasium-maven` | | List of command line arguments that are passed to `gradle` by the analyzer. |
| `SBT_CLI_OPTS` | `gemnasium-maven` | | List of command-line arguments that the analyzer passes to `sbt`. |
-| `BUNDLER_AUDIT_UPDATE_DISABLED` | `bundler-audit` | `"false"` | Disable automatic updates for the `bundler-audit` analyzer. Useful if you're running Dependency Scanning in an offline, air-gapped environment.|
+| `BUNDLER_AUDIT_UPDATE_DISABLED` | `bundler-audit` | `"false"` | Disable automatic updates for the `bundler-audit` analyzer. Useful if you're running dependency scanning in an offline, air-gapped environment.|
| `BUNDLER_AUDIT_ADVISORY_DB_URL` | `bundler-audit` | `https://github.com/rubysec/ruby-advisory-db` | URL of the advisory database used by bundler-audit. |
| `BUNDLER_AUDIT_ADVISORY_DB_REF_NAME` | `bundler-audit` | `master` | Git ref for the advisory database specified by `BUNDLER_AUDIT_ADVISORY_DB_URL`. |
| `RETIREJS_JS_ADVISORY_DB` | `retire.js` | `https://raw.githubusercontent.com/RetireJS/retire.js/master/repository/jsrepository.json` | Path or URL to `retire.js` JS vulnerability data file. Note that if the URL hosting the data file uses a custom SSL certificate, for example in an offline installation, you can pass the certificate in the `ADDITIONAL_CA_CERT_BUNDLE` environment variable. |
@@ -214,16 +215,16 @@ For more information about the vulnerabilities database update, check the
## Dependency List
-An additional benefit of Dependency Scanning is the ability to view your
+An additional benefit of dependency scanning is the ability to view your
project's dependencies and their known vulnerabilities. Read more about
the [Dependency List](../dependency_list/index.md).
## Reports JSON format
-The Dependency Scanning tool emits a JSON report file. For more information, see the
+The dependency scanning tool emits a JSON report file. For more information, see the
[schema for this report](https://gitlab.com/gitlab-org/security-products/security-report-schemas/-/blob/master/dist/dependency-scanning-report-format.json).
-Here's an example Dependency Scanning report:
+Here's an example dependency scanning report:
```json-doc
{
@@ -342,36 +343,35 @@ You can search the [gemnasium-db](https://gitlab.com/gitlab-org/security-product
to find a vulnerability in the Gemnasium database.
You can also [submit new vulnerabilities](https://gitlab.com/gitlab-org/security-products/gemnasium-db/blob/master/CONTRIBUTING.md).
-## Running Dependency Scanning in an offline environment
+## Running dependency scanning in an offline environment
For self-managed GitLab instances in an environment with limited, restricted, or intermittent access
-to external resources through the internet, some adjustments are required for Dependency Scanning
+to external resources through the internet, some adjustments are required for dependency scanning
jobs to run successfully. For more information, see [Offline environments](../offline_deployments/index.md).
-### Requirements for offline Dependency Scanning
+### Requirements for offline dependency scanning
-Here are the requirements for using Dependency Scanning in an offline environment:
+Here are the requirements for using dependency scanning in an offline environment:
- GitLab Runner with the [`docker` or `kubernetes` executor](#requirements).
-- Docker Container Registry with locally available copies of Dependency Scanning [analyzer](https://gitlab.com/gitlab-org/security-products/analyzers) images.
+- Docker Container Registry with locally available copies of dependency scanning [analyzer](https://gitlab.com/gitlab-org/security-products/analyzers) images.
- Host an offline Git copy of the [gemnasium-db advisory database](https://gitlab.com/gitlab-org/security-products/gemnasium-db/).
This is required because, in an offline environment, the Gemnasium analyzer can't fetch the latest
advisories from the online repository.
- _Only if scanning Ruby projects_: Host an offline Git copy of the [advisory database](https://github.com/rubysec/ruby-advisory-db).
- _Only if scanning npm/yarn projects_: Host an offline copy of the [retire.js](https://github.com/RetireJS/retire.js/) [node](https://github.com/RetireJS/retire.js/blob/master/repository/npmrepository.json) and [js](https://github.com/RetireJS/retire.js/blob/master/repository/jsrepository.json) advisory databases.
-NOTE: **Note:**
-GitLab Runner has a [default `pull policy` of `always`](https://docs.gitlab.com/runner/executors/docker.html#using-the-always-pull-policy),
+Note that GitLab Runner has a [default `pull policy` of `always`](https://docs.gitlab.com/runner/executors/docker.html#using-the-always-pull-policy),
meaning the runner tries to pull Docker images from the GitLab container registry even if a local
copy is available. The GitLab Runner [`pull_policy` can be set to `if-not-present`](https://docs.gitlab.com/runner/executors/docker.html#using-the-if-not-present-pull-policy)
in an offline environment if you prefer using only locally available Docker images. However, we
recommend keeping the pull policy setting to `always` if not in an offline environment, as this
enables the use of updated scanners in your CI/CD pipelines.
-### Make GitLab Dependency Scanning analyzer images available inside your Docker registry
+### Make GitLab dependency scanning analyzer images available inside your Docker registry
-For Dependency Scanning with all [supported languages and frameworks](#supported-languages-and-package-managers),
-import the following default Dependency Scanning analyzer images from `registry.gitlab.com` into
+For dependency scanning with all [supported languages and frameworks](#supported-languages-and-package-managers),
+import the following default dependency scanning analyzer images from `registry.gitlab.com` into
your [local Docker container registry](../../packages/container_registry/index.md):
```plaintext
@@ -392,7 +392,7 @@ For details on saving and transporting Docker images as a file, see Docker's doc
[`docker save`](https://docs.docker.com/engine/reference/commandline/save/), [`docker load`](https://docs.docker.com/engine/reference/commandline/load/),
[`docker export`](https://docs.docker.com/engine/reference/commandline/export/), and [`docker import`](https://docs.docker.com/engine/reference/commandline/import/).
-### Set Dependency Scanning CI job variables to use local Dependency Scanning analyzers
+### Set dependency scanning CI job variables to use local dependency scanning analyzers
Add the following configuration to your `.gitlab-ci.yml` file. You must change the value of
`SECURE_ANALYZERS_PREFIX` to refer to your local Docker container registry. You must also change the
@@ -479,7 +479,19 @@ As a workaround, remove the [`retire.js`](analyzers.md#selecting-specific-analyz
### `Error response from daemon: error processing tar file: docker-tar: relocation error`
-This error occurs when the Docker version that runs the Dependency Scanning job is `19.03.00`.
+This error occurs when the Docker version that runs the dependency scanning job is `19.03.00`.
Consider updating to Docker `19.03.1` or greater. Older versions are not
affected. Read more in
[this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/13830#note_211354992 "Current SAST container fails").
+
+### Getting warning message `gl-dependency-scanning-report.json: no matching files`
+
+For information on this, see the [general Application Security troubleshooting section](../../../ci/pipelines/job_artifacts.md#error-message-no-files-to-upload).
+
+### Limitation when using rules:exists
+
+The [dependency scanning CI template](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Security/Dependency-Scanning.gitlab-ci.yml)
+uses the [`rules:exists`](../../../ci/yaml/README.md#rulesexists)
+syntax. This directive is limited to 10000 checks and always returns `true` after reaching this
+number. Because of this, and depending on the number of files in your repository, a dependency
+scanning job might be triggered even if the scanner doesn't support your project.
diff --git a/doc/user/application_security/img/cve_request_communication.png b/doc/user/application_security/img/cve_request_communication.png
index 0766b371c11..5c58df463ef 100644
--- a/doc/user/application_security/img/cve_request_communication.png
+++ b/doc/user/application_security/img/cve_request_communication.png
Binary files differ
diff --git a/doc/user/application_security/img/cve_request_communication_publication.png b/doc/user/application_security/img/cve_request_communication_publication.png
index 9e34c217e13..9eb6f2f8d9f 100644
--- a/doc/user/application_security/img/cve_request_communication_publication.png
+++ b/doc/user/application_security/img/cve_request_communication_publication.png
Binary files differ
diff --git a/doc/user/application_security/img/new_cve_request_issue.png b/doc/user/application_security/img/new_cve_request_issue.png
index a342c73992e..6ea7ca4a2ab 100644
--- a/doc/user/application_security/img/new_cve_request_issue.png
+++ b/doc/user/application_security/img/new_cve_request_issue.png
Binary files differ
diff --git a/doc/user/application_security/img/unconfigured_security_approval_rules_and_enabled_jobs_v13_4.png b/doc/user/application_security/img/unconfigured_security_approval_rules_and_enabled_jobs_v13_4.png
index f497b0fbc4e..7b04988afdb 100644
--- a/doc/user/application_security/img/unconfigured_security_approval_rules_and_enabled_jobs_v13_4.png
+++ b/doc/user/application_security/img/unconfigured_security_approval_rules_and_enabled_jobs_v13_4.png
Binary files differ
diff --git a/doc/user/application_security/img/unconfigured_security_approval_rules_and_jobs_v13_4.png b/doc/user/application_security/img/unconfigured_security_approval_rules_and_jobs_v13_4.png
index fc847b578f5..b9b6dd13294 100644
--- a/doc/user/application_security/img/unconfigured_security_approval_rules_and_jobs_v13_4.png
+++ b/doc/user/application_security/img/unconfigured_security_approval_rules_and_jobs_v13_4.png
Binary files differ
diff --git a/doc/user/application_security/img/vulnerability-check_v13_4.png b/doc/user/application_security/img/vulnerability-check_v13_4.png
index e0b53059b45..3e38f6eebe7 100644
--- a/doc/user/application_security/img/vulnerability-check_v13_4.png
+++ b/doc/user/application_security/img/vulnerability-check_v13_4.png
Binary files differ
diff --git a/doc/user/application_security/img/vulnerability_solution.png b/doc/user/application_security/img/vulnerability_solution.png
index d86b89a5f99..63e9c1473b6 100644
--- a/doc/user/application_security/img/vulnerability_solution.png
+++ b/doc/user/application_security/img/vulnerability_solution.png
Binary files differ
diff --git a/doc/user/application_security/index.md b/doc/user/application_security/index.md
index d509176f2b2..413a9f894e2 100644
--- a/doc/user/application_security/index.md
+++ b/doc/user/application_security/index.md
@@ -22,10 +22,10 @@ Testing (SAST), and Secret Detection by adding the following to your `.gitlab-ci
```yaml
include:
- - template: Dependency-Scanning.gitlab-ci.yml
- - template: License-Scanning.gitlab-ci.yml
- - template: SAST.gitlab-ci.yml
- - template: Secret-Detection.gitlab-ci.yml
+ - template: Security/Dependency-Scanning.gitlab-ci.yml
+ - template: Security/License-Scanning.gitlab-ci.yml
+ - template: Security/SAST.gitlab-ci.yml
+ - template: Security/Secret-Detection.gitlab-ci.yml
```
To add Dynamic Application Security Testing (DAST) scanning, add the following to your
@@ -33,7 +33,7 @@ To add Dynamic Application Security Testing (DAST) scanning, add the following t
```yaml
include:
- - template: DAST.gitlab-ci.yml
+ - template: Security/DAST.gitlab-ci.yml
variables:
DAST_WEBSITE: https://staging.example.com
@@ -449,7 +449,7 @@ To fix this issue, you can either:
```yaml
include:
- template: SAST.gitlab-ci.yml
+ template: Security/SAST.gitlab-ci.yml
spotbugs-sast:
stage: unit-tests
@@ -458,6 +458,15 @@ To fix this issue, you can either:
[Learn more on overriding SAST jobs](sast/index.md#overriding-sast-jobs).
All the security scanning tools define their stage, so this error can occur with all of them.
+### Getting warning messages `… report.json: no matching files`
+
+This is often followed by the [error `No files to upload`](../../ci/pipelines/job_artifacts.md#error-message-no-files-to-upload),
+and preceded by other errors or warnings that indicate why the JSON report wasn't generated. Please
+check the entire job log for such messages. If you don't find these messages, retry the failed job
+after setting `SECURE_LOG_LEVEL: "debug"` as a
+[custom environment variable](../../ci/variables/README.md#custom-environment-variables).
+This provides useful information to investigate further.
+
### Getting error message `sast job: config key may not be used with 'rules': only/except`
When [including](../../ci/yaml/README.md#includetemplate) a `.gitlab-ci.yml` template
@@ -490,7 +499,7 @@ would look similar to:
```yaml
include:
- - template: SAST.gitlab-ci.yml
+ - template: Security/SAST.gitlab-ci.yml
# Ensure that the scanning is only executed on master or merge requests
spotbugs-sast:
@@ -505,7 +514,7 @@ would be written as follows:
```yaml
include:
- - template: SAST.gitlab-ci.yml
+ - template: Security/SAST.gitlab-ci.yml
# Ensure that the scanning is only executed on master or merge requests
spotbugs-sast:
@@ -519,7 +528,7 @@ it would look similar to:
```yaml
include:
- - template: SAST.gitlab-ci.yml
+ - template: Security/SAST.gitlab-ci.yml
# Ensure that the scanning is not executed on tags
spotbugs-sast:
@@ -531,7 +540,7 @@ To transition to the new `rules` syntax, the override would be rewritten as:
```yaml
include:
- - template: SAST.gitlab-ci.yml
+ - template: Security/SAST.gitlab-ci.yml
# Ensure that the scanning is not executed on tags
spotbugs-sast:
diff --git a/doc/user/application_security/sast/analyzers.md b/doc/user/application_security/sast/analyzers.md
index 727f077aa09..665cd41ab05 100644
--- a/doc/user/application_security/sast/analyzers.md
+++ b/doc/user/application_security/sast/analyzers.md
@@ -53,7 +53,7 @@ In `.gitlab-ci.yml` define:
```yaml
include:
- - template: SAST.gitlab-ci.yml
+ - template: Security/SAST.gitlab-ci.yml
variables:
SECURE_ANALYZERS_PREFIX: my-docker-registry/gl-images
@@ -70,7 +70,7 @@ In `.gitlab-ci.yml` define:
```yaml
include:
- - template: SAST.gitlab-ci.yml
+ - template: Security/SAST.gitlab-ci.yml
variables:
SAST_DEFAULT_ANALYZERS: "bandit,flawfinder"
@@ -86,7 +86,7 @@ default analyzers. In `.gitlab-ci.yml` define:
```yaml
include:
- - template: SAST.gitlab-ci.yml
+ - template: Security/SAST.gitlab-ci.yml
variables:
SAST_DEFAULT_ANALYZERS: ""
diff --git a/doc/user/application_security/sast/index.md b/doc/user/application_security/sast/index.md
index a4fc3c9e638..f950d48c6d3 100644
--- a/doc/user/application_security/sast/index.md
+++ b/doc/user/application_security/sast/index.md
@@ -11,8 +11,8 @@ type: reference, howto
NOTE: **Note:**
The whitepaper ["A Seismic Shift in Application Security"](https://about.gitlab.com/resources/whitepaper-seismic-shift-application-security/)
-explains how **4 of the top 6 attacks were application based**. Download it
-to learn how to protect your organization.
+explains how 4 of the top 6 attacks were application based. Download it to learn how to protect your
+organization.
If you're using [GitLab CI/CD](../../../ci/README.md), you can analyze your source code for known
vulnerabilities using Static Application Security Testing (SAST). GitLab checks the SAST report and
@@ -31,8 +31,10 @@ The results are sorted by the priority of the vulnerability:
1. Unknown
1. Everything else
-NOTE: **Note:**
-A pipeline consists of multiple jobs, including SAST and DAST scanning. If any job fails to finish for any reason, the security dashboard doesn't show SAST scanner output. For example, if the SAST job finishes but the DAST job fails, the security dashboard doesn't show SAST results. The analyzer outputs an [exit code](../../../development/integrations/secure.md#exit-code) on failure.
+A pipeline consists of multiple jobs, including SAST and DAST scanning. If any job fails to finish
+for any reason, the security dashboard doesn't show SAST scanner output. For example, if the SAST
+job finishes but the DAST job fails, the security dashboard doesn't show SAST results. On failure,
+the analyzer outputs an [exit code](../../../development/integrations/secure.md#exit-code).
## Use cases
@@ -63,35 +65,33 @@ You can also [view our language roadmap](https://about.gitlab.com/direction/secu
| Language (package managers) / framework | Scan tool | Introduced in GitLab Version |
|--------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| .NET Core | [Security Code Scan](https://security-code-scan.github.io) | 11.0, [moved](https://gitlab.com/groups/gitlab-org/-/epics/2098) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.3 |
-| .NET Framework | [Security Code Scan](https://security-code-scan.github.io) | 13.0, [moved](https://gitlab.com/groups/gitlab-org/-/epics/2098) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.3 |
-| Apex (Salesforce) | [PMD](https://pmd.github.io/pmd/index.html) | 12.1, [moved](https://gitlab.com/groups/gitlab-org/-/epics/2098) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.3 |
-| C/C++ | [Flawfinder](https://github.com/david-a-wheeler/flawfinder) | 10.7, [moved](https://gitlab.com/groups/gitlab-org/-/epics/2098) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.3 |
-| Elixir (Phoenix) | [Sobelow](https://github.com/nccgroup/sobelow) | 11.10, [moved](https://gitlab.com/groups/gitlab-org/-/epics/2098) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.3 |
-| Go | [Gosec](https://github.com/securego/gosec) | 10.7, [moved](https://gitlab.com/groups/gitlab-org/-/epics/2098) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.3 |
-| Groovy ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/) and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 11.3 (Gradle) & 11.9 (Ant, Maven, SBT), [moved](https://gitlab.com/groups/gitlab-org/-/epics/2098) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.3 |
-| Helm Charts | [Kubesec](https://github.com/controlplaneio/kubesec) | 13.1, [moved](https://gitlab.com/groups/gitlab-org/-/epics/2098) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.3 |
-| Java ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/) and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 10.6 (Maven), 10.8 (Gradle) & 11.9 (Ant, SBT), [moved](https://gitlab.com/groups/gitlab-org/-/epics/2098) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.3 |
-| JavaScript | [ESLint security plugin](https://github.com/nodesecurity/eslint-plugin-security) | 11.8, [moved](https://gitlab.com/groups/gitlab-org/-/epics/2098) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.2 |
-| Kubernetes manifests | [Kubesec](https://github.com/controlplaneio/kubesec) | 12.6, [moved](https://gitlab.com/groups/gitlab-org/-/epics/2098) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.3 |
-| Node.js | [NodeJsScan](https://github.com/ajinabraham/NodeJsScan) | 11.1, [moved](https://gitlab.com/groups/gitlab-org/-/epics/2098) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.3 |
-| PHP | [phpcs-security-audit](https://github.com/FloeDesignTechnologies/phpcs-security-audit) | 10.8, [moved](https://gitlab.com/groups/gitlab-org/-/epics/2098) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.3 |
-| Python ([pip](https://pip.pypa.io/en/stable/)) | [bandit](https://github.com/PyCQA/bandit) | 10.3, [moved](https://gitlab.com/groups/gitlab-org/-/epics/2098) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.3 |
-| React | [ESLint react plugin](https://github.com/yannickcr/eslint-plugin-react) | 12.5, [moved](https://gitlab.com/groups/gitlab-org/-/epics/2098) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.2 |
-| Ruby on Rails | [brakeman](https://brakemanscanner.org) | 10.3, [moved](https://gitlab.com/groups/gitlab-org/-/epics/2098) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.1 |
-| Scala ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/) and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 11.0 (SBT) & 11.9 (Ant, Gradle, Maven), [moved](https://gitlab.com/groups/gitlab-org/-/epics/2098) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.3 |
+| .NET Core | [Security Code Scan](https://security-code-scan.github.io) | 11.0 |
+| .NET Framework | [Security Code Scan](https://security-code-scan.github.io) | 13.0 |
+| Apex (Salesforce) | [PMD](https://pmd.github.io/pmd/index.html) | 12.1 |
+| C/C++ | [Flawfinder](https://github.com/david-a-wheeler/flawfinder) | 10.7 |
+| Elixir (Phoenix) | [Sobelow](https://github.com/nccgroup/sobelow) | 11.1 |
+| Go | [Gosec](https://github.com/securego/gosec) | 10.7 |
+| Groovy ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/) and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 11.3 (Gradle) & 11.9 (Ant, Maven, SBT) |
+| Helm Charts | [Kubesec](https://github.com/controlplaneio/kubesec) | 13.1 |
+| Java ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/) and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 10.6 (Maven), 10.8 (Gradle) & 11.9 (Ant, SBT) |
+| JavaScript | [ESLint security plugin](https://github.com/nodesecurity/eslint-plugin-security) | 11.8 |
+| Kubernetes manifests | [Kubesec](https://github.com/controlplaneio/kubesec) | 12.6 |
+| Node.js | [NodeJsScan](https://github.com/ajinabraham/NodeJsScan) | 11.1 |
+| PHP | [phpcs-security-audit](https://github.com/FloeDesignTechnologies/phpcs-security-audit) | 10.8 |
+| Python ([pip](https://pip.pypa.io/en/stable/)) | [bandit](https://github.com/PyCQA/bandit) | 10.3 |
+| React | [ESLint react plugin](https://github.com/yannickcr/eslint-plugin-react) | 12.5 |
+| Ruby on Rails | [brakeman](https://brakemanscanner.org) | 10.3 |
+| Scala ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/) and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 11.0 (SBT) & 11.9 (Ant, Gradle, Maven) |
| TypeScript | [ESLint security plugin](https://github.com/nodesecurity/eslint-plugin-security) | 11.9, [merged](https://gitlab.com/gitlab-org/gitlab/-/issues/36059) with ESLint in 13.2 |
-NOTE: **Note:**
-The Java analyzers can also be used for variants like the
+Note that the Java analyzers can also be used for variants like the
[Gradle wrapper](https://docs.gradle.org/current/userguide/gradle_wrapper.html),
-[Grails](https://grails.org/) and the [Maven wrapper](https://github.com/takari/maven-wrapper).
+[Grails](https://grails.org/),
+and the [Maven wrapper](https://github.com/takari/maven-wrapper).
### Making SAST analyzers available to all GitLab tiers
-All open source (OSS) analyzers have been moved to the GitLab Core tier. Progress can be
-tracked in the corresponding
-[epic](https://gitlab.com/groups/gitlab-org/-/epics/2098).
+All open source (OSS) analyzers have been moved to the GitLab Core tier as of GitLab 13.3.
#### Summary of features per tier
@@ -147,6 +147,7 @@ always take the latest SAST artifact available.
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3659) in GitLab Ultimate 13.3.
> - [Improved](https://gitlab.com/gitlab-org/gitlab/-/issues/232862) in GitLab Ultimate 13.4.
+> - [Improved](https://gitlab.com/groups/gitlab-org/-/epics/3635) in GitLab Ultimate 13.5.
You can enable and configure SAST with a basic configuration using the **SAST Configuration**
page:
@@ -154,9 +155,11 @@ page:
1. From the project's home page, go to **Security & Compliance** > **Configuration** in the
left sidebar.
1. If the project does not have a `gitlab-ci.yml` file, click **Enable** in the Static Application Security Testing (SAST) row, otherwise click **Configure**.
-1. Enter the custom SAST values, then click **Create Merge Request**.
+1. Enter the custom SAST values.
Custom values are stored in the `.gitlab-ci.yml` file. For variables not in the SAST Configuration page, their values are left unchanged. Default values are inherited from the GitLab SAST template.
+1. Optionally, expand the **SAST analyzers** section, select individual [SAST analyzers](./analyzers.md) and enter custom analyzer values.
+1. Click **Create Merge Request**.
1. Review and merge the merge request.
### Customizing the SAST settings
@@ -169,7 +172,7 @@ set the `SAST_GOSEC_LEVEL` variable to `2`:
```yaml
include:
- - template: SAST.gitlab-ci.yml
+ - template: Security/SAST.gitlab-ci.yml
variables:
SAST_GOSEC_LEVEL: 2
@@ -191,7 +194,7 @@ inclusion and specify any additional keys under it. For example, this enables `F
```yaml
include:
- - template: SAST.gitlab-ci.yml
+ - template: Security/SAST.gitlab-ci.yml
spotbugs-sast:
variables:
@@ -222,7 +225,7 @@ Kubesec analyzer. In `.gitlab-ci.yml`, define:
```yaml
include:
- - template: SAST.gitlab-ci.yml
+ - template: Security/SAST.gitlab-ci.yml
variables:
SCAN_KUBERNETES_MANIFESTS: "true"
@@ -248,7 +251,7 @@ stages:
- test
include:
- - template: SAST.gitlab-ci.yml
+ - template: Security/SAST.gitlab-ci.yml
build:
stage: build
@@ -270,11 +273,10 @@ spotbugs-sast:
sast: gl-sast-report.json
```
-NOTE: **Note:**
-The path to the vendored directory must be specified explicitly to allow
-the analyzer to recognize the compiled artifacts. This configuration can vary per
-analyzer but in the case of Java above, `MAVEN_REPO_PATH` can be used.
-See [Analyzer settings](#analyzer-settings) for the complete list of available options.
+To allow the analyzer to recognize the compiled artifacts, you must explicitly specify the path to
+the vendored directory. This configuration can vary per analyzer but in the case of Java above, you
+can use `MAVEN_REPO_PATH`. See
+[Analyzer settings](#analyzer-settings) for the complete list of available options.
### Available variables
@@ -480,7 +482,6 @@ To use SAST in an offline environment, you need:
- A Docker Container Registry with locally available copies of SAST [analyzer](https://gitlab.com/gitlab-org/security-products/analyzers) images.
- Configure certificate checking of packages (optional).
-NOTE: **Note:**
GitLab Runner has a [default `pull policy` of `always`](https://docs.gitlab.com/runner/executors/docker.html#using-the-always-pull-policy),
meaning the runner tries to pull Docker images from the GitLab container registry even if a local
copy is available. The GitLab Runner [`pull_policy` can be set to `if-not-present`](https://docs.gitlab.com/runner/executors/docker.html#using-the-if-not-present-pull-policy)
@@ -525,7 +526,7 @@ Add the following configuration to your `.gitlab-ci.yml` file. You must replace
```yaml
include:
- - template: SAST.gitlab-ci.yml
+ - template: Security/SAST.gitlab-ci.yml
variables:
SECURE_ANALYZERS_PREFIX: "localhost:5000/analyzers"
@@ -549,3 +550,7 @@ This error occurs when the Docker version that runs the SAST job is `19.03.0`.
Consider updating to Docker `19.03.1` or greater. Older versions are not
affected. Read more in
[this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/13830#note_211354992 "Current SAST container fails").
+
+### Getting warning message `gl-sast-report.json: no matching files`
+
+For information on this, see the [general Application Security troubleshooting section](../../../ci/pipelines/job_artifacts.md#error-message-no-files-to-upload).
diff --git a/doc/user/application_security/secret_detection/index.md b/doc/user/application_security/secret_detection/index.md
index f3e411cdc16..aea9b91d9f2 100644
--- a/doc/user/application_security/secret_detection/index.md
+++ b/doc/user/application_security/secret_detection/index.md
@@ -9,8 +9,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> [Introduced](https://about.gitlab.com/releases/2019/03/22/gitlab-11-9-released/#detect-secrets-and-credentials-in-the-repository) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.9.
-## Overview
-
A recurring problem when developing applications is that developers may unintentionally commit
secrets and credentials to their remote repositories. If other people have access to the source,
or if the project is public, the sensitive information is then exposed and can be leveraged by
@@ -67,26 +65,27 @@ as shown in the following table:
## Configuration
-NOTE: **Note:**
-With GitLab 13.1 Secret Detection was split into its own CI/CD template.
+> GitLab 13.1 splits Secret Detection from the [SAST configuration](../sast#configuration) into its own CI/CD template. If you're using GitLab 13.0 or earlier and SAST is enabled, then Secret Detection is already enabled.
Secret Detection is performed by a [specific analyzer](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Security/Secret-Detection.gitlab-ci.yml)
-during the `secret-detection` job. It runs regardless of the programming
-language of your app.
+during the `secret-detection` job. It runs regardless of your app's programming language.
-The Secret Detection analyzer includes [Gitleaks](https://github.com/zricethezav/gitleaks) and [TruffleHog](https://github.com/dxa4481/truffleHog) checks.
+The Secret Detection analyzer includes [Gitleaks](https://github.com/zricethezav/gitleaks) and
+[TruffleHog](https://github.com/dxa4481/truffleHog) checks.
-NOTE: **Note:**
-The Secret Detection analyzer will ignore "Password in URL" vulnerabilities if the password begins
-with a dollar sign (`$`) as this likely indicates the password being used is an environment
-variable. For example, `https://username:$password@example.com/path/to/repo` won't be
-detected, whereas `https://username:password@example.com/path/to/repo` would be detected.
+Note that the Secret Detection analyzer ignores Password-in-URL vulnerabilities if the password
+begins with a dollar sign (`$`), as this likely indicates the password is an environment variable.
+For example, `https://username:$password@example.com/path/to/repo` isn't detected, while
+`https://username:password@example.com/path/to/repo` is.
NOTE: **Note:**
-You don't have to configure Secret Detection manually as shown in this section if you're using [Auto Secret Detection](../../../topics/autodevops/stages.md#auto-secret-detection)
+You don't have to configure Secret Detection manually as shown in this section if you're using
+[Auto Secret Detection](../../../topics/autodevops/stages.md#auto-secret-detection)
provided by [Auto DevOps](../../../topics/autodevops/index.md).
-To enable Secret Detection for GitLab 13.1 and later, you must include the `Secret-Detection.gitlab-ci.yml` template that’s provided as a part of your GitLab installation. For GitLab versions earlier than 11.9, you can copy and use the job as defined in that template.
+To enable Secret Detection for GitLab 13.1 and later, you must include the
+`Secret-Detection.gitlab-ci.yml` template that's provided as a part of your GitLab installation. For
+GitLab versions earlier than 11.9, you can copy and use the job as defined in that template.
Add the following to your `.gitlab-ci.yml` file:
@@ -103,30 +102,6 @@ The results are saved as a
that you can later download and analyze. Due to implementation limitations, we
always take the latest Secret Detection artifact available.
-### Using the SAST Template
-
-Prior to GitLab 13.1, Secret Detection was part of [SAST configuration](../sast#configuration).
-If you already have SAST enabled for your app configured before GitLab 13.1,
-you don't need to manually configure it.
-
-CAUTION: **Planned Deprecation:**
-In a future GitLab release, configuring Secret Detection with the SAST template will be deprecated. Please begin using `Secret-Detection.gitlab-ci.yml`
-to prevent future issues. We have made a
-[video to guide you through the process of transitioning](https://www.youtube.com/watch?v=W2tjcQreDwQ)
-to this new template.
-
-<div class="video-fallback">
- See the video: <a href="https://www.youtube.com/watch?v=W2tjcQreDwQ">Walkthrough of historical secret scan</a>.
-</div>
-<figure class="video-container">
- <iframe src="https://www.youtube.com/embed/W2tjcQreDwQ" frameborder="0" allowfullscreen="true"> </iframe>
-</figure>
-
-When using the SAST template, Secret Detection is performed by a [specific analyzer](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml#L180)
-during the `sast` job. It runs regardless of the programming
-language of your app, and you don't need to change your
-CI/CD configuration file to enable it. Results are available in the SAST report.
-
### Customizing settings
The Secret Detection scan settings can be changed through [environment variables](#available-variables)
@@ -197,3 +172,9 @@ We have created a [short video walkthrough](https://youtu.be/wDtc_K00Y0A) showca
<figure class="video-container">
<iframe src="https://www.youtube.com/embed/wDtc_K00Y0A" frameborder="0" allowfullscreen="true"> </iframe>
</figure>
+
+## Troubleshooting
+
+### Getting warning message `gl-secret-detection-report.json: no matching files`
+
+For information on this, see the [general Application Security troubleshooting section](../../../ci/pipelines/job_artifacts.md#error-message-no-files-to-upload).
diff --git a/doc/user/application_security/security_dashboard/img/group_vulnerability_report_v13_4.png b/doc/user/application_security/security_dashboard/img/group_vulnerability_report_v13_4.png
index 67a7bb5f368..0310ef3ea0a 100644
--- a/doc/user/application_security/security_dashboard/img/group_vulnerability_report_v13_4.png
+++ b/doc/user/application_security/security_dashboard/img/group_vulnerability_report_v13_4.png
Binary files differ
diff --git a/doc/user/application_security/security_dashboard/img/instance_security_center_settings_v13_4.png b/doc/user/application_security/security_dashboard/img/instance_security_center_settings_v13_4.png
new file mode 100644
index 00000000000..4223494c294
--- /dev/null
+++ b/doc/user/application_security/security_dashboard/img/instance_security_center_settings_v13_4.png
Binary files differ
diff --git a/doc/user/application_security/security_dashboard/img/instance_security_dashboard_empty_v13_4.png b/doc/user/application_security/security_dashboard/img/instance_security_dashboard_empty_v13_4.png
index 3c618090be8..5edceb32e5c 100644
--- a/doc/user/application_security/security_dashboard/img/instance_security_dashboard_empty_v13_4.png
+++ b/doc/user/application_security/security_dashboard/img/instance_security_dashboard_empty_v13_4.png
Binary files differ
diff --git a/doc/user/application_security/security_dashboard/img/instance_security_dashboard_v13_4.png b/doc/user/application_security/security_dashboard/img/instance_security_dashboard_v13_4.png
index d010adcc90c..5379b5c6e5d 100644
--- a/doc/user/application_security/security_dashboard/img/instance_security_dashboard_v13_4.png
+++ b/doc/user/application_security/security_dashboard/img/instance_security_dashboard_v13_4.png
Binary files differ
diff --git a/doc/user/application_security/security_dashboard/img/project_security_dashboard_dismissal_v13_4.png b/doc/user/application_security/security_dashboard/img/project_security_dashboard_dismissal_v13_4.png
new file mode 100644
index 00000000000..eb1dfe6c6f4
--- /dev/null
+++ b/doc/user/application_security/security_dashboard/img/project_security_dashboard_dismissal_v13_4.png
Binary files differ
diff --git a/doc/user/application_security/security_dashboard/img/project_security_dashboard_v13_3.png b/doc/user/application_security/security_dashboard/img/project_security_dashboard_v13_3.png
index 34c64f830ba..adae37e0190 100644
--- a/doc/user/application_security/security_dashboard/img/project_security_dashboard_v13_3.png
+++ b/doc/user/application_security/security_dashboard/img/project_security_dashboard_v13_3.png
Binary files differ
diff --git a/doc/user/application_security/security_dashboard/img/project_security_dashboard_v13_4.png b/doc/user/application_security/security_dashboard/img/project_security_dashboard_v13_4.png
new file mode 100644
index 00000000000..ea4f188c80e
--- /dev/null
+++ b/doc/user/application_security/security_dashboard/img/project_security_dashboard_v13_4.png
Binary files differ
diff --git a/doc/user/application_security/security_dashboard/img/vulnerability_list_table_v13_4.png b/doc/user/application_security/security_dashboard/img/vulnerability_list_table_v13_4.png
index eb91cfc47ad..760942c3239 100644
--- a/doc/user/application_security/security_dashboard/img/vulnerability_list_table_v13_4.png
+++ b/doc/user/application_security/security_dashboard/img/vulnerability_list_table_v13_4.png
Binary files differ
diff --git a/doc/user/application_security/security_dashboard/index.md b/doc/user/application_security/security_dashboard/index.md
index 51d9b4f45cd..f21f53e6895 100644
--- a/doc/user/application_security/security_dashboard/index.md
+++ b/doc/user/application_security/security_dashboard/index.md
@@ -5,21 +5,26 @@ group: Threat Insights
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
-# GitLab Security Dashboard **(ULTIMATE)**
+# GitLab Security Dashboard, Security Center, and Vulnerability Reports **(ULTIMATE)**
-The Security Dashboard is a good place to get an overview of all the security
-vulnerabilities in your groups, projects, and pipelines.
+GitLab provides a comprehensive set of features for viewing and managing vulnerabilities:
+
+- Security dashboards: An overview of the security status in your instance, groups, and projects.
+- Vulnerability reports: Detailed lists of all vulnerabilities for the instance, group, project, or
+ pipeline. This is where you triage and manage vulnerabilities.
+- Security Center: A dedicated area for vulnerability management at the instance level. This
+ includes a security dashboard, vulnerability report, and settings.
You can also drill down into a vulnerability and get extra information. This includes the project it
comes from, any related file(s), and metadata that helps you analyze the risk it poses. You can also
dismiss a vulnerability or create an issue for it.
-To benefit from the Security Dashboard you must first configure one of the
+To benefit from these features, you must first configure one of the
[security scanners](../index.md).
## Supported reports
-The Security Dashboard displays vulnerabilities detected by scanners such as:
+The vulnerability report displays vulnerabilities detected by scanners such as:
- [Container Scanning](../container_scanning/index.md)
- [Dynamic Application Security Testing](../dast/index.md)
@@ -29,7 +34,7 @@ The Security Dashboard displays vulnerabilities detected by scanners such as:
## Requirements
-To use the instance, group, project, or pipeline security dashboard:
+To use the security dashboards and vulnerability reports:
1. At least one project inside a group must be configured with at least one of
the [supported reports](#supported-reports).
@@ -41,15 +46,19 @@ To use the instance, group, project, or pipeline security dashboard:
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/13496) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.3.
-At the pipeline level, the Security section displays the vulnerabilities present in the branch of the project the pipeline was run against.
+At the pipeline level, the Security section displays the vulnerabilities present in the branch of
+the project the pipeline ran against.
![Pipeline Security Dashboard](img/pipeline_security_dashboard_v13_3.png)
Visit the page for any pipeline that ran any of the [supported reports](#supported-reports). To view
the pipeline's security findings, select the **Security** tab when viewing the pipeline.
-NOTE: **Note:**
-A pipeline consists of multiple jobs, including SAST and DAST scanning. If any job fails to finish for any reason, the security dashboard will not show SAST scanner output. For example, if the SAST job finishes but the DAST job fails, the security dashboard will not show SAST results. The analyzer will output an [exit code](../../../development/integrations/secure.md#exit-code) on failure.
+A pipeline consists of multiple jobs, including SAST and DAST scanning. If any job fails to finish
+for any reason, the security dashboard doesn't show SAST scanner output. For example, if the SAST
+job finishes but the DAST job fails, the security dashboard doesn't show SAST results. On failure,
+the analyzer outputs an
+[exit code](../../../development/integrations/secure.md#exit-code).
## Project Security Dashboard
@@ -65,7 +74,7 @@ Critical, High, Medium, Low, Info, Unknown). Below this, a table shows each vuln
and description. Clicking a vulnerability takes you to its [Vulnerability Details](../vulnerabilities)
page to view more information about that vulnerability.
-![Project Security Dashboard](img/project_security_dashboard_v13_3.png)
+![Project Security Dashboard](img/project_security_dashboard_v13_4.png)
You can filter the vulnerabilities by one or more of the following:
@@ -78,7 +87,7 @@ You can also dismiss vulnerabilities in the table:
1. Select the checkbox for each vulnerability you want to dismiss.
1. In the menu that appears, select the reason for dismissal and click **Dismiss Selected**.
-![Project Security Dashboard](img/project_security_dashboard_v13_2.png)
+![Project Security Dashboard](img/project_security_dashboard_dismissal_v13_4.png)
## Group Security Dashboard
@@ -86,79 +95,99 @@ You can also dismiss vulnerabilities in the table:
The group Security Dashboard gives an overview of the vulnerabilities in the default branches of the
projects in a group and its subgroups. Access it by navigating to **Security > Security Dashboard**
-for your group. By default, the Security Dashboard displays all detected and confirmed
-vulnerabilities.
+after selecting your group. By default, the Security Dashboard displays all detected and confirmed
+vulnerabilities. If you don't see the vulnerabilities over time graph, the likely cause is that you
+have not selected a group.
-NOTE: **Note:**
-The Security Dashboard only shows projects with [security reports](#supported-reports) enabled in a
-group.
+Note that the Security Dashboard only shows projects with
+[security reports](#supported-reports)
+enabled in a group.
![Dashboard with action buttons and metrics](img/group_security_dashboard_v13_3.png)
There is a timeline chart that shows how many open
-vulnerabilities your projects had at various points in time. You can filter among 30, 60, and
-90 days, with the default being 90. Hover over the chart to get more details about
-the open vulnerabilities at a specific time.
+vulnerabilities your projects had at various points in time. You can display the vulnerability
+trends over a 30, 60, or 90-day time frame (the default is 90 days). Hover over the chart to get
+more details about the open vulnerabilities at a specific time.
Next to the timeline chart is a list of projects, grouped and sorted by the severity of the vulnerability found:
-- F: 1 or more "critical"
-- D: 1 or more "high" or "unknown"
-- C: 1 or more "medium"
-- B: 1 or more "low"
-- A: 0 vulnerabilities
+- F: One or more "critical"
+- D: One or more "high" or "unknown"
+- C: One or more "medium"
+- B: One or more "low"
+- A: Zero vulnerabilities
Projects with no vulnerability tests configured will not appear in the list. Additionally, dismissed
-vulnerabilities are not included either.
+vulnerabilities are excluded.
+
+Navigate to the group's [vulnerability report](#vulnerability-report) to view the vulnerabilities found.
-Navigate to the group's [Vulnerability Report](#vulnerability-list) to view the vulnerabilities found.
+## Instance Security Center
-## Instance Security Dashboard
+> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3426) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.4.
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/6953) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.8.
+The Security Center is where you manage vulnerabilities for your instance. It displays the
+vulnerabilities present in the default branches of all the projects you configure. It includes the
+following:
-At the instance level, the Security Dashboard displays the vulnerabilities present in the default
-branches of all the projects you configure to display on the dashboard. It includes all the
-[group Security Dashboard's](#group-security-dashboard)
-features.
+- The [group security dashboard's](#group-security-dashboard) features.
+- A [vulnerability report](#vulnerability-report).
+- A dedicated settings area to configure which projects to display.
![Instance Security Dashboard with projects](img/instance_security_dashboard_v13_4.png)
-You can access the Instance Security Dashboard from the menu
+You can access the Instance Security Center from the menu
bar at the top of the page. Under **More**, select **Security**.
-![Instance Security Dashboard navigation link](img/instance_security_dashboard_link_v12_4.png)
+![Instance Security Center navigation link](img/instance_security_dashboard_link_v12_4.png)
-The dashboard is empty before you add projects to it.
+The dashboard and vulnerability report are empty before you add projects.
-![Uninitialized Instance Security Dashboard](img/instance_security_dashboard_empty_v13_4.png)
+![Uninitialized Instance Security Center](img/instance_security_dashboard_empty_v13_4.png)
-### Adding projects to the dashboard
+### Adding projects to the Security Center
-To add projects to the dashboard:
+To add projects to the Security Center:
1. Click **Settings** in the left navigation bar or click the **Add projects** button.
1. Search for and add one or more projects using the **Search your projects** field.
1. Click the **Add projects** button.
-After you add projects, the Security Dashboard displays the vulnerabilities found in those projects'
-default branches.
+![Adding projects to Instance Security Center](img/instance_security_center_settings_v13_4.png)
+
+After you add projects, the security dashboard and vulnerability report display the vulnerabilities
+found in those projects' default branches.
## Export vulnerabilities
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/213014) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.10.
-You can export all your vulnerabilities in CSV format by clicking the **{upload}** **Export**
-button located at top right of the **Security Dashboard**. After the report
-is built, the CSV report downloads to your local machine. The report contains all
-vulnerabilities for the projects defined in the **Security Dashboard**,
-as filters don't apply to the export function.
-
-![Export vulnerabilities](img/instance_security_dashboard_export_csv_v13_4.png)
+You can export all your vulnerabilities in CSV (comma separated values) format by clicking the
+**{upload}** **Export** button located at top right of the Security Dashboard. When the report is
+ready, the CSV report downloads to your local machine. The report contains all vulnerabilities for
+the projects defined in the Security Dashboard, as filters don't apply to the export function.
NOTE: **Note:**
It may take several minutes for the download to start if your project contains
-thousands of vulnerabilities. Do not close the page until the download finishes.
+thousands of vulnerabilities. Don't close the page until the download finishes.
+
+The fields in the export include:
+
+- Group Name
+- Project Name
+- Scanner Type
+- Scanner Name
+- Status
+- Vulnerability
+- Details
+- Additional Info
+- Severity
+- [CVE](https://cve.mitre.org/)
+- [CWE](https://cwe.mitre.org/)
+- Other Identifiers
+
+![Export vulnerabilities](img/instance_security_dashboard_export_csv_v13_4.png)
## Keeping the dashboards up to date
@@ -191,14 +220,14 @@ When using [Auto DevOps](../../../topics/autodevops/index.md), use
[special environment variables](../../../topics/autodevops/customize.md#environment-variables)
to configure daily security scans.
-## Vulnerability list
+## Vulnerability report
-Each dashboard's vulnerability list contains vulnerabilities from the latest scans that were merged
+Each vulnerability report contains vulnerabilities from the latest scans that were merged
into the default branch.
![Vulnerability Report](img/group_vulnerability_report_v13_4.png)
-You can filter which vulnerabilities the Security Dashboard displays by:
+You can filter which vulnerabilities the vulnerability report displays by:
- Status
- Severity
@@ -211,8 +240,14 @@ To create an issue associated with the vulnerability, click the **Create Issue**
![Create an issue for the vulnerability](img/vulnerability_page_v13_1.png)
-Once you create the issue, the vulnerability list contains a link to the issue and an icon whose
-color indicates the issue's status (green for open issues, blue for closed issues).
+Once you create the issue, the linked issue icon in the vulnerability list:
+
+- Indicates that an issue has been created for that vulnerability.
+- Shows a tooltip that contains a link to the issue and an icon whose
+ color indicates the issue's status:
+
+ - Open issues: green
+ - Closed issues: blue
![Display attached issues](img/vulnerability_list_table_v13_4.png)
diff --git a/doc/user/application_security/terminology/index.md b/doc/user/application_security/terminology/index.md
index 8006a49ba35..f975de213ef 100644
--- a/doc/user/application_security/terminology/index.md
+++ b/doc/user/application_security/terminology/index.md
@@ -13,7 +13,6 @@ This terminology list for GitLab Secure and Defend aims to:
- Improve the effectiveness of communication regarding GitLab's application security features.
- Get new contributors up to speed faster.
-NOTE: **Note:**
This document defines application security terms in the specific context of GitLab's Secure and
Defend products. Terms may therefore have different meanings outside of GitLab Secure and Defend.
diff --git a/doc/user/application_security/threat_monitoring/index.md b/doc/user/application_security/threat_monitoring/index.md
index 5414800b290..391666a077e 100644
--- a/doc/user/application_security/threat_monitoring/index.md
+++ b/doc/user/application_security/threat_monitoring/index.md
@@ -105,11 +105,10 @@ disabled state. Once enabled,a predefined policy deploys to the
selected environment's deployment platform and you can manage it like
the regular policies.
-NOTE: **Note:**
-If you're using [Auto DevOps](../../../topics/autodevops/index.md) and
-change a policy in this section, your `auto-deploy-values.yaml` file
-doesn't update. Auto DevOps users must make changes by following
-the [Container Network Policy documentation](../../../topics/autodevops/stages.md#network-policy).
+Note that if you're using [Auto DevOps](../../../topics/autodevops/index.md)
+and change a policy in this section, your `auto-deploy-values.yaml` file doesn't update. Auto DevOps
+users must make changes by following the
+[Container Network Policy documentation](../../../topics/autodevops/stages.md#network-policy).
### Changing enforcement status
@@ -119,12 +118,9 @@ To change a network policy's enforcement status:
- Click the **Enforcement status** toggle to update the selected policy.
- Click the **Apply changes** button to deploy network policy changes.
-NOTE: **Note:**
-Disabled network policies have the
-`network-policy.gitlab.com/disabled_by: gitlab` selector inside the
-`podSelector` block. This narrows the scope of such a policy and as a
-result it doesn't affect any pods. The policy itself is still deployed
-to the corresponding deployment namespace.
+Disabled network policies have the `network-policy.gitlab.com/disabled_by: gitlab` selector inside
+the `podSelector` block. This narrows the scope of such a policy and as a result it doesn't affect
+any pods. The policy itself is still deployed to the corresponding deployment namespace.
### Container Network Policy editor
@@ -135,10 +131,8 @@ create a new policy click the **New policy** button located in the
**Policy** tab's header. To edit an existing policy, click**Edit
policy** in the selected policy drawer.
-NOTE: **Note:**
-The policy editor only supports the
-[CiliumNetworkPolicy](https://docs.cilium.io/en/v1.8/policy/)specification. Regular
-Kubernetes
+Note that the policy editor only supports the
+[CiliumNetworkPolicy](https://docs.cilium.io/en/v1.8/policy/)specification. Regular Kubernetes
[NetworkPolicy](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.19/#networkpolicy-v1-networking-k8s-io)
resources aren't supported.
diff --git a/doc/user/application_security/vulnerabilities/index.md b/doc/user/application_security/vulnerabilities/index.md
index ff383fdf553..ee3fd6c4dd4 100644
--- a/doc/user/application_security/vulnerabilities/index.md
+++ b/doc/user/application_security/vulnerabilities/index.md
@@ -14,6 +14,7 @@ Each security vulnerability in a project's [Security Dashboard](../security_dash
- Details of the vulnerability.
- The status of the vulnerability within the project.
- Available actions for the vulnerability.
+- Issues related to the vulnerability.
On the vulnerability page, you can interact with the vulnerability in
several different ways:
@@ -23,6 +24,7 @@ several different ways:
- [Create issue](#creating-an-issue-for-a-vulnerability) - Create a new issue with the
title and description pre-populated with information from the vulnerability report.
By default, such issues are [confidential](../../project/issues/confidential_issues.md).
+- [Link issues](#link-issues-to-the-vulnerability) - Link existing issues to vulnerability.
- [Solution](#automatic-remediation-for-vulnerabilities) - For some vulnerabilities,
a solution is provided for how to fix the vulnerability.
@@ -38,6 +40,9 @@ the following values:
| Dismissed | A user has seen this vulnerability and dismissed it |
| Resolved | The vulnerability has been fixed and is no longer in the codebase |
+A timeline shows you when the vulnerability status has changed,
+and allows you to comment on a change.
+
## Creating an issue for a vulnerability
You can create an issue for a vulnerability by selecting the **Create issue** button.
@@ -47,6 +52,12 @@ project the vulnerability came from, and pre-populates it with useful informatio
the vulnerability report. After the issue is created, GitLab redirects you to the
issue page so you can edit, assign, or comment on the issue.
+## Link issues to the vulnerability
+
+You can link one or more existing issues to the vulnerability. This allows you to
+indicate that this vulnerability affects multiple issues. It also allows you to indicate
+that the resolution of one issue would resolve multiple vulnerabilities.
+
## Automatic remediation for vulnerabilities
You can fix some vulnerabilities by applying the solution that GitLab automatically
diff --git a/doc/user/clusters/agent/index.md b/doc/user/clusters/agent/index.md
index 7b745577cc4..4065ee3337b 100644
--- a/doc/user/clusters/agent/index.md
+++ b/doc/user/clusters/agent/index.md
@@ -6,18 +6,23 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# GitLab Kubernetes Agent **(PREMIUM ONLY)**
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/223061) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.4.
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/223061) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.4.
+> - It's disabled on GitLab.com. Rolling this feature out to GitLab.com is [planned](https://gitlab.com/groups/gitlab-org/-/epics/3834).
-## Goals
+CAUTION: **Warning:**
+This feature might not be available to you. Check the **version history** note above for details.
-The [GitLab Kubernetes Agent](https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent) is an active in-cluster component for solving GitLab and Kubernetes integration tasks in a secure and cloud native way.
+The [GitLab Kubernetes Agent](https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent)
+is an active in-cluster component for solving GitLab and Kubernetes integration
+tasks in a secure and cloud-native way. It enables:
-Features:
+- Integrating GitLab with a Kubernetes cluster behind a firewall or NAT
+ (network address translation).
+- Pull-based GitOps deployments by leveraging the
+ [GitOps Engine](https://github.com/argoproj/gitops-engine).
+- Real-time access to API endpoints within a cluster.
-1. Makes it possible to integrate GitLab with a Kubernetes cluster behind a firewall or NAT
-1. Enables pull-based GitOps deployments by leveraging the [GitOps Engine](https://github.com/argoproj/gitops-engine)
-1. Allows for real-time access to API endpoints within a cluster.
-1. Many more features are planned. Please [review our roadmap](https://gitlab.com/groups/gitlab-org/-/epics/3329).
+Many more features are planned. Please [review our roadmap](https://gitlab.com/groups/gitlab-org/-/epics/3329).
## Architecture
@@ -39,37 +44,45 @@ sequenceDiagram
end
```
-Please refer to our [full architecture documentation in the Agent project](https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/blob/master/doc/architecture.md#high-level-architecture).
+Please refer to our [full architecture documentation](https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/blob/master/doc/architecture.md#high-level-architecture)
+in the Agent project.
-## Getting started with GitOps using the GitLab Agent and the GitLab Cloud Native Helm chart
+## Get started with GitOps and the GitLab Agent
There are several components that work in concert for the Agent to accomplish GitOps deployments:
-1. A Kubernetes cluster that is properly configured
-1. A configuration repository that contains a `config.yaml` file. This `config.yaml` tells the Agent which repositories to synchronize with.
-1. A manifest repository that contains a `manifest.yaml`. This `manifest.yaml` (which can be autogenerated) is tracked by the Agent and any changes to the file are automatically applied to the cluster.
+- A properly-configured Kubernetes cluster.
+- A configuration repository that contains a `config.yaml` file, which tells the
+ Agent which repositories to synchronize with.
+- A manifest repository that contains a `manifest.yaml`, which is tracked by the
+ Agent and can be auto-generated. Any changes to `manifest.yaml` are applied to the cluster.
-The setup process involves a few steps that, once completed, will enable GitOps deployments to work
+The setup process involves a few steps to enable GitOps deployments:
-1. Installing the Agent server via GitLab Helm chart
-1. Defining a configuration directory
-1. Creating an Agent record in GitLab
-1. Generating and copying a Secret token used to connect to the Agent
-1. Installing the Agent into the cluster
-1. Creating a `manifest.yaml`
+1. Installing the Agent server via GitLab Helm chart.
+1. Defining a configuration directory.
+1. Creating an Agent record in GitLab.
+1. Generating and copying a Secret token used to connect to the Agent.
+1. Installing the Agent into the cluster.
+1. Creating a `manifest.yaml`.
-### Installing the Agent server via Helm
+### Install the Agent server
-Currently the GitLab Kubernetes Agent can only be deployed via our [Helm chart](https://gitlab.com/gitlab-org/charts/gitlab).
+The GitLab Kubernetes Agent can only be deployed through our
+[Helm chart](https://gitlab.com/gitlab-org/charts/gitlab). If you don't already
+have GitLab installed via Helm, please refer to our
+[installation documentation](https://docs.gitlab.com/charts/installation/).
-NOTE: We are working quickly to [include the Agent in Official Linux Package](https://gitlab.com/gitlab-org/gitlab/-/issues/223060).
+NOTE: **Note:**
+GitLab plans to include the Agent in the [official Linux Package](https://gitlab.com/gitlab-org/gitlab/-/issues/223060) and on [GitLab.com](https://gitlab.com/groups/gitlab-org/-/epics/3834).
-If you don't already have GitLab installed via Helm please refer to our [installation documentation](https://docs.gitlab.com/charts/installation/)
-
-When installing/upgrading the GitLab Helm chart please consider the following Helm 2 example (if using Helm 3 please modify):
+When installing or upgrading the GitLab Helm chart, consider the following Helm 2 example.
+(If you're using Helm 3, you must modify this example.) You must set `global.kas.enabled=true`
+for the Agent to be properly installed and configured:
```shell
-helm upgrade --force --install gitlab . \
+helm repo update
+helm upgrade --force --install gitlab gitlab/gitlab \
--timeout 600 \
--set global.hosts.domain=<YOUR_DOMAIN> \
--set global.hosts.externalIP=<YOUR_IP> \
@@ -78,15 +91,14 @@ helm upgrade --force --install gitlab . \
--set global.kas.enabled=true
```
-`global.kas.enabled=true` must be set in order for the Agent to be properly installed and configured.
-
-### Defining a configuration repository
+### Define a configuration repository
-Next you will need a GitLab repository that will contain your Agent configuration.
+Next, you need a GitLab repository to contain your Agent configuration. The minimal
+repository layout looks like this:
-The minimal repository layout looks like this:
-
-`.gitlab/agents/<agent-name>/config.yaml`
+```plaintext
+.gitlab/agents/<agent-name>/config.yaml
+```
The `config.yaml` file contents should look like this:
@@ -96,31 +108,24 @@ gitops:
- id: "path-to/your-awesome-project"
```
-### Creating an Agent record in GitLab
-
-Next you will need to create an GitLab Rails Agent record so that your GitLab project so that the Agent itself can associate with a GitLab project. This process will also yield a Secret that you will use to configure the Agent in subsequent steps.
+### Create an Agent record in GitLab
-There are two ways to accomplish this:
+Next, create an GitLab Rails Agent record so the Agent can associate itself with
+a GitLab project. Creating this record also creates a Secret needed to configure
+the Agent in subsequent steps. You can create an Agent record either:
-1. Via the Rails console
-1. Via GraphQL
+- Through the Rails console, by running `rails c`:
-To do this you could either run `rails c` or via GraphQL. From `rails c`:
-
-```ruby
+ ```ruby
project = ::Project.find_by_full_path("path-to/your-awesome-project")
agent = ::Clusters::Agent.create(name: "<agent-name>", project: project)
token = ::Clusters::AgentToken.create(agent: agent)
token.token # this will print out the token you need to use on the next step
-```
+ ```
-or using GraphQL:
+- Through GraphQL: **(PREMIUM ONLY)**
-with this approach, you'll need a premium license to use this feature.
-
-If you are new to using the GitLab GraphQL API please refer to the [Getting started with the GraphQL API page](../../../api/graphql/getting_started.md) or check out the [GraphQL Explorer](https://gitlab.com/-/graphql-explorer).
-
-```json
+ ```graphql
mutation createAgent {
createClusterAgent(input: { projectPath: "path-to/your-awesome-project", name: "<agent-name>" }) {
clusterAgent {
@@ -141,37 +146,77 @@ If you are new to using the GitLab GraphQL API please refer to the [Getting star
errors
}
}
-```
+ ```
-Note that GraphQL will only show you the token once, after you've created it.
+ NOTE: **Note:**
+ GraphQL only displays the token once, after creating it.
-### Creating the Kubernetes secret
+ If you are new to using the GitLab GraphQL API, refer to the
+ [Getting started with the GraphQL API page](../../../api/graphql/getting_started.md),
+ or the [GraphQL Explorer](https://gitlab.com/-/graphql-explorer).
-Once the token has been generated it needs to be applied to the Kubernetes cluster.
+### Create the Kubernetes secret
-If you didn't previously define or create a namespace you need to do that first:
+After generating the token, you must apply it to the Kubernetes cluster.
-```shell
-kubectl create namespace <YOUR-DESIRED-NAMESPACE>
-```
+1. If you haven't previous defined or created a namespace, run the following command:
+
+ ```shell
+ kubectl create namespace <YOUR-DESIRED-NAMESPACE>
+ ```
+
+1. Run the following command to create your Secret:
-Run the following command to create your Secret:
+ ```shell
+ kubectl create secret generic -n <YOUR-DESIRED-NAMESPACE> gitlab-agent-token --from-literal=token='YOUR_AGENT_TOKEN'
+ ```
+
+### Install the Agent into the cluster
+
+Next, install the in-cluster component of the Agent. This example file contains the
+Kubernetes resources required for the Agent to be installed. You can modify this
+example [`resources.yml` file](#example-resourcesyml-file) in the following ways:
+
+- You can replace `gitlab-agent` with `<YOUR-DESIRED-NAMESPACE>`.
+- For the `kas-address` (Kubernetes Agent Server), the agent can use the WebSockets
+ or gRPC protocols to connect to the Agent Server. Depending on your cluster
+ configuration and GitLab architecture, you may need to use one or the other.
+ For the `gitlab-kas` Helm chart, an Ingress is created for the Agent Server using
+ the `/-/kubernetes-agent` endpoint. This can be used for the WebSockets protocol connection.
+ - Specify the `grpc` scheme (such as `grpc://gitlab-kas:5005`) to use gRPC directly.
+ Encrypted gRPC is not supported yet. Follow the
+ [Support TLS for gRPC communication issue](https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/issues/7)
+ for progress updates.
+ - Specify the `ws` scheme (such as `ws://gitlab-kas-ingress:80/-/kubernetes-agent`)
+ to use an unencrypted WebSockets connection.
+ - Specify the `wss` scheme (such as `wss://gitlab-kas-ingress:443/-/kubernetes-agent`)
+ to use an encrypted WebSockets connection. This is the recommended option if
+ installing the Agent into a separate cluster from your Agent Server.
+- If you defined your own secret name, replace `gitlab-agent-token` with your secret name.
+
+To apply this file, run the following command:
```shell
-kubectl create secret generic -n <YOUR-DESIRED-NAMESPACE> gitlab-agent-token --from-literal=token='YOUR_AGENT_TOKEN'
+kubectl apply -n gitlab-agent -f ./resources.yml
```
-### Installing the Agent into the cluster
-
-Next you are now ready to install the in-cluster component of the Agent. The below is an example YAML file of the Kubernetes resources required for the Agent to be installed.
+To review your configuration, run the following command:
-Let's highlight a few of the details in the example below:
+```shell
+$ kubectl get pods --all-namespaces
-1. You can replace `gitlab-agent` with <YOUR-DESIRED-NAMESPACE>
-1. For the `kas-address` (Kubernetes Agent Server), you can replace `grpc://host.docker.internal:5005` with the address of the kas agent that was initialized via your Helm install.
-1. If you defined your own secret name, then replace `gitlab-agent-token` with your secret name.
+NAMESPACE NAME READY STATUS RESTARTS AGE
+gitlab-agent gitlab-agent-77689f7dcb-5skqk 1/1 Running 0 51s
+kube-system coredns-f9fd979d6-n6wcw 1/1 Running 0 14m
+kube-system etcd-minikube 1/1 Running 0 14m
+kube-system kube-apiserver-minikube 1/1 Running 0 14m
+kube-system kube-controller-manager-minikube 1/1 Running 0 14m
+kube-system kube-proxy-j6zdh 1/1 Running 0 14m
+kube-system kube-scheduler-minikube 1/1 Running 0 14m
+kube-system storage-provisioner 1/1 Running 0 14m
+```
-`./resources.yml`
+#### Example `resources.yml` file
```yaml
apiVersion: v1
@@ -200,7 +245,7 @@ spec:
args:
- --token-file=/config/token
- --kas-address
- - grpc://host.docker.internal:5005 # {"$openapi":"kas-address"}
+ - grpc://host.docker.internal:5005 # {"$openapi":"kas-address"}
volumeMounts:
- name: token-volume
mountPath: /config
@@ -268,37 +313,31 @@ subjects:
- name: gitlab-agent
kind: ServiceAccount
namespace: gitlab-agent
-
```
-```shell
-kubectl apply -n gitlab-agent -f ./resources.yml
-```
+### Create a `manifest.yaml`
+
+In a previous step, you configured a `config.yaml` to point to the GitLab projects
+the Agent should synchronize. In each of those projects, you must create a `manifest.yaml`
+file for the Agent to monitor. You can auto-generate this `manifest.yaml` with a
+templating engine or other means.
+
+Each time you commit and push a change to this file, the Agent logs the change:
```plaintext
-$ kubectl get pods --all-namespaces
-NAMESPACE NAME READY STATUS RESTARTS AGE
-gitlab-agent gitlab-agent-77689f7dcb-5skqk 1/1 Running 0 51s
-kube-system coredns-f9fd979d6-n6wcw 1/1 Running 0 14m
-kube-system etcd-minikube 1/1 Running 0 14m
-kube-system kube-apiserver-minikube 1/1 Running 0 14m
-kube-system kube-controller-manager-minikube 1/1 Running 0 14m
-kube-system kube-proxy-j6zdh 1/1 Running 0 14m
-kube-system kube-scheduler-minikube 1/1 Running 0 14m
-kube-system storage-provisioner 1/1 Running 0 14m
+2020-09-15_14:09:04.87946 gitlab-k8s-agent : time="2020-09-15T10:09:04-04:00" level=info msg="Config: new commit" agent_id=1 commit_id=e6a3651f1faa2e928fe6120e254c122451be4eea
```
-### Creating a `manifest.yaml`
-
-In the above step, you configured a `config.yaml` to point to which GitLab projects the Agent should synchronize. Within each one of those projects, you need to create a `manifest.yaml` file which the Agent will monitor. This `manifest.yaml` can be autogenerated by a templating engine or other means.
+#### Example `manifest.yaml` file
-Example `manifest.yaml`:
+This file creates a simple NGINX deployment.
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
+ namespace: gitlab-agent # Can be any namespace managed by you that the agent has access to.
spec:
selector:
matchLabels:
@@ -316,14 +355,9 @@ spec:
- containerPort: 80
```
-The above file creates a simple NGINX deployment.
-
-Each time you commit and push a change to the `manifest.yaml` the Agent will observe the change. Example log:
-
-```plaintext
-2020-09-15_14:09:04.87946 gitlab-k8s-agent : time="2020-09-15T10:09:04-04:00" level=info msg="Config: new commit" agent_id=1 commit_id=e6a3651f1faa2e928fe6120e254c122451be4eea
-```
-
## Example projects
-Basic GitOps example deploying NGINX: [Configuration repository](https://gitlab.com/gitlab-org/configure/examples/kubernetes-agent), [Manifest repository](https://gitlab.com/gitlab-org/configure/examples/gitops-project)
+This basic GitOps example deploys NGINX:
+
+- [Configuration repository](https://gitlab.com/gitlab-org/configure/examples/kubernetes-agent)
+- [Manifest repository](https://gitlab.com/gitlab-org/configure/examples/gitops-project)
diff --git a/doc/user/clusters/applications.md b/doc/user/clusters/applications.md
index 2243ffa0cb1..c3b22526919 100644
--- a/doc/user/clusters/applications.md
+++ b/doc/user/clusters/applications.md
@@ -6,37 +6,29 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# GitLab Managed Apps
-GitLab provides **GitLab Managed Apps**, a one-click install for various applications which can
-be added directly to your configured cluster.
-
-These applications are needed for [Review Apps](../../ci/review_apps/index.md)
-and [deployments](../../ci/environments/index.md) when using [Auto DevOps](../../topics/autodevops/index.md).
-
-You can install them after you
-[create a cluster](../project/clusters/add_remove_clusters.md).
+GitLab provides **GitLab Managed Apps**, a one-click install for various
+applications which can be added directly to your configured cluster. These
+applications are needed for [Review Apps](../../ci/review_apps/index.md) and
+[deployments](../../ci/environments/index.md) when using [Auto DevOps](../../topics/autodevops/index.md).
+You can install them after you [create a cluster](../project/clusters/add_remove_clusters.md).
## Installing applications
-Applications managed by GitLab will be installed onto the `gitlab-managed-apps` namespace.
-
-This namespace:
+Applications managed by GitLab are installed onto the `gitlab-managed-apps`
+namespace. This namespace:
- Is different from the namespace used for project deployments.
- Is created once.
- Has a non-configurable name.
-To see a list of available applications to install. For a:
+To view a list of available applications to install for a:
- [Project-level cluster](../project/clusters/index.md), navigate to your project's
**Operations > Kubernetes**.
- [Group-level cluster](../group/clusters/index.md), navigate to your group's
**Kubernetes** page.
-NOTE: **Note:**
-As of GitLab 11.6, Helm will be upgraded to the latest version supported
-by GitLab before installing any of the applications.
-
-The following applications can be installed:
+You can install the following applications:
- [Helm](#helm)
- [Ingress](#ingress)
@@ -49,10 +41,9 @@ The following applications can be installed:
- [Elastic Stack](#elastic-stack)
- [Fluentd](#fluentd)
-With the exception of Knative, the applications will be installed in a dedicated
+With the exception of Knative, the applications are installed in a dedicated
namespace called `gitlab-managed-apps`.
-NOTE: **Note:**
Some applications are installable only for a project-level cluster.
Support for installing these applications in a group-level cluster is
planned for future releases.
@@ -65,6 +56,9 @@ you should be careful as GitLab cannot detect it. In this case, installing
Helm via the applications will result in the cluster having it twice, which
can lead to confusion during deployments.
+In GitLab versions 11.6 and greater, Helm is upgraded to the latest version
+supported by GitLab before installing any of the applications.
+
### Helm
> - Introduced in GitLab 10.2 for project-level clusters.
@@ -81,7 +75,6 @@ applications. Prior to [GitLab 13.2](https://gitlab.com/gitlab-org/gitlab/-/issu
GitLab used an in-cluster Tiller server in the `gitlab-managed-apps`
namespace. This server can now be safely removed.
-NOTE: **Note:**
GitLab's Helm integration does not support installing applications behind a proxy,
but a [workaround](../../topics/autodevops/index.md#install-applications-behind-a-proxy)
is available.
@@ -90,26 +83,25 @@ is available.
> Introduced in GitLab 11.6 for project- and group-level clusters.
-[cert-manager](https://cert-manager.io/docs/) is a native
-Kubernetes certificate management controller that helps with issuing
-certificates. Installing cert-manager on your cluster will issue a
-certificate by [Let's Encrypt](https://letsencrypt.org/) and ensure that
-certificates are valid and up-to-date.
+[cert-manager](https://cert-manager.io/docs/) is a native Kubernetes certificate
+management controller that helps with issuing certificates. Installing
+cert-manager on your cluster issues a certificate by [Let's Encrypt](https://letsencrypt.org/)
+and ensures that certificates are valid and up-to-date.
The chart used to install this application depends on the version of GitLab used. In:
-- GitLab 12.3 and newer, the [jetstack/cert-manager](https://github.com/jetstack/cert-manager)
+- GitLab 12.3 and newer, the [`jetstack/cert-manager`](https://github.com/jetstack/cert-manager)
chart is used with a [`values.yaml`](https://gitlab.com/gitlab-org/gitlab/blob/master/vendor/cert_manager/values.yaml)
file.
-- GitLab 12.2 and older, the [stable/cert-manager](https://github.com/helm/charts/tree/master/stable/cert-manager)
+- GitLab 12.2 and older, the [`stable/cert-manager`](https://gi2wthub.com/helm/charts/tree/master/stable/cert-manager)
chart was used.
-If you have installed cert-manager prior to GitLab 12.3, Let's Encrypt will
-[block requests from older versions of cert-manager](https://community.letsencrypt.org/t/blocking-old-cert-manager-versions/98753).
+If you installed cert-manager prior to GitLab 12.3, Let's Encrypt
+[blocks requests](https://community.letsencrypt.org/t/blocking-old-cert-manager-versions/98753)
+from older versions of `cert-manager`. To resolve this:
-To resolve this:
-
-1. Uninstall cert-manager (consider [backing up any additional configuration](https://cert-manager.io/docs/tutorials/backup/)).
+1. [Back up any additional configuration](https://cert-manager.io/docs/tutorials/backup/).
+1. Uninstall cert-manager.
1. Install cert-manager again.
### GitLab Runner
@@ -117,26 +109,21 @@ To resolve this:
> - Introduced in GitLab 10.6 for project-level clusters.
> - Introduced in GitLab 11.10 for group-level clusters.
-[GitLab Runner](https://docs.gitlab.com/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](../../ci/README.md), the open-source continuous integration
-service included with GitLab that coordinates the jobs.
-
-If the project is on GitLab.com, shared runners are available
-(the first 2000 minutes are free, you can
-[buy more later](../../subscriptions/gitlab_com/index.md#purchase-additional-ci-minutes))
-and you do not have to deploy one if they are enough for your needs. If a
-project-specific runner is desired, or there are no shared runners, it is easy
-to deploy one.
-
-Note that the deployed runner will be set as **privileged**, which means it will essentially
-have root access to the underlying machine. This is required to build Docker images,
-so it is the default. Make sure you read the
-[security implications](../project/clusters/index.md#security-implications)
+[GitLab Runner](https://docs.gitlab.com/runner/) is the open source project that
+is used to run your jobs and send the results back to GitLab. It's used in
+conjunction with [GitLab CI/CD](../../ci/README.md), the open-source continuous
+integration service included with GitLab that coordinates the jobs.
+
+If the project is on GitLab.com, [shared runners](../gitlab_com/index.md#shared-runners)
+are available. You don't have to deploy one if they are enough for your
+needs. If a project-specific runner is desired, or there are no shared runners,
+you can deploy one.
+
+The deployed runner is set as **privileged**. Root access to the underlying
+server is required to build Docker images, so it is the default. Be sure to read
+the [security implications](../project/clusters/index.md#security-implications)
before deploying one.
-NOTE: **Note:**
The [`runner/gitlab-runner`](https://gitlab.com/gitlab-org/charts/gitlab-runner)
chart is used to install this application, using
[a preconfigured `values.yaml`](https://gitlab.com/gitlab-org/charts/gitlab-runner/-/blob/master/values.yaml)
@@ -147,11 +134,13 @@ file. Customizing the installation by modifying this file is not supported.
> - Introduced in GitLab 10.2 for project-level clusters.
> - Introduced in GitLab 11.6 for group-level clusters.
-[Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) provides load balancing, SSL termination, and name-based virtual hosting
+[Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/)
+provides load balancing, SSL termination, and name-based virtual hosting
out of the box. 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.
-The Ingress Controller installed is [Ingress-NGINX](https://kubernetes.io/docs/concepts/services-networking/ingress/),
+The Ingress Controller installed is
+[Ingress-NGINX](https://kubernetes.io/docs/concepts/services-networking/ingress/),
which is supported by the Kubernetes community.
NOTE: **Note:**
@@ -159,10 +148,10 @@ With the following procedure, a load balancer must be installed in your cluster
to obtain the endpoint. You can use either
Ingress, or Knative's own load balancer ([Istio](https://istio.io)) if using Knative.
-In order to publish your web application, you first need to find the endpoint which will be either an IP
+To publish your web application, you first need to find the endpoint, which is either an IP
address or a hostname associated with your load balancer.
-To install it, click on the **Install** button for Ingress. GitLab will attempt
+To install it, click on the **Install** button for Ingress. GitLab attempts
to determine the external endpoint and it should be available within a few minutes.
#### Determining the external endpoint automatically
@@ -178,11 +167,15 @@ using the `KUBE_INGRESS_BASE_DOMAIN` environment variable.
If the endpoint doesn't appear and your cluster runs on Google Kubernetes Engine:
-1. Check your [Kubernetes cluster on Google Kubernetes Engine](https://console.cloud.google.com/kubernetes) to ensure there are no errors on its nodes.
-1. Ensure you have enough [Quotas](https://console.cloud.google.com/iam-admin/quotas) on Google Kubernetes Engine. For more information, see [Resource Quotas](https://cloud.google.com/compute/quotas).
-1. Check [Google Cloud's Status](https://status.cloud.google.com/) to ensure they are not having any disruptions.
+1. [Examine your Kubernetes cluster](https://console.cloud.google.com/kubernetes)
+ on Google Kubernetes Engine to ensure there are no errors on its nodes.
+1. Ensure you have enough [Quotas](https://console.cloud.google.com/iam-admin/quotas)
+ on Google Kubernetes Engine. For more information, see
+ [Resource Quotas](https://cloud.google.com/compute/quotas).
+1. Review [Google Cloud's Status](https://status.cloud.google.com/) for service
+ disruptions.
-Once installed, you may see a `?` for "Ingress IP Address" depending on the
+After installing, you may see a `?` for **Ingress IP Address** depending on the
cloud provider. For EKS specifically, this is because the ELB is created
with a DNS name, not an IP address. If GitLab is still unable to
determine the endpoint of your Ingress or Knative application, you can
@@ -208,58 +201,58 @@ The output of the following examples will show the external endpoint 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.
-If you installed Ingress via the **Applications**, run the following command:
+- If you installed Ingress using the **Applications**, run the following
+ command:
-```shell
-kubectl get service --namespace=gitlab-managed-apps ingress-nginx-ingress-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip}'
-```
+ ```shell
+ kubectl get service --namespace=gitlab-managed-apps ingress-nginx-ingress-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip}'
+ ```
-Some Kubernetes clusters return a hostname instead, like [Amazon EKS](https://aws.amazon.com/eks/). For these platforms, run:
+- Some Kubernetes clusters return a hostname instead, like
+ [Amazon EKS](https://aws.amazon.com/eks/). For these platforms, run:
-```shell
-kubectl get service --namespace=gitlab-managed-apps ingress-nginx-ingress-controller -o jsonpath='{.status.loadBalancer.ingress[0].hostname}'
-```
+ ```shell
+ kubectl get service --namespace=gitlab-managed-apps ingress-nginx-ingress-controller -o jsonpath='{.status.loadBalancer.ingress[0].hostname}'
+ ```
-For Istio/Knative, the command will be different:
+ If EKS is used, an [Elastic Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/)
+ is also created, which will incur additional AWS costs.
-```shell
-kubectl get svc --namespace=istio-system istio-ingressgateway -o jsonpath='{.status.loadBalancer.ingress[0].ip} '
-```
+- For Istio/Knative, the command will be different:
-Otherwise, you can list the IP addresses of all load balancers:
+ ```shell
+ kubectl get svc --namespace=istio-system istio-ingressgateway -o jsonpath='{.status.loadBalancer.ingress[0].ip} '
+ ```
-```shell
-kubectl get svc --all-namespaces -o jsonpath='{range.items[?(@.status.loadBalancer.ingress)]}{.status.loadBalancer.ingress[*].ip} '
-```
+- Otherwise, you can list the IP addresses of all load balancers:
-NOTE: **Note:**
-If EKS is used, an [Elastic Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/)
-will also be created, which will incur additional AWS costs.
+ ```shell
+ kubectl get svc --all-namespaces -o jsonpath='{range.items[?(@.status.loadBalancer.ingress)]}{.status.loadBalancer.ingress[*].ip} '
+ ```
-NOTE: **Note:**
-You may see a trailing `%` on some Kubernetes versions, **do not include it**.
+You may see a trailing `%` on some Kubernetes versions. Do not include it.
-The Ingress is now available at this address and will route incoming requests to
-the proper service based on the DNS name in the request. To support this, a
-wildcard DNS CNAME record should be created for the desired domain name. For example,
+The Ingress is now available at this address, and routes incoming requests to
+the proper service based on the DNS name in the request. To support this, create
+a wildcard DNS CNAME record for the desired domain name. For example,
`*.myekscluster.com` would point to the Ingress hostname obtained earlier.
#### 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.
+your apps won't be reachable, and you'd have to change the DNS record again.
+To avoid that, 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 external endpoint
-Once you've set up the external endpoint, you should associate it with a [wildcard DNS
-record](https://en.wikipedia.org/wiki/Wildcard_DNS_record) such as `*.example.com.`
-in order to be able to reach your apps. If your external endpoint is an IP address,
-use an A record. If your external endpoint is a hostname, use a CNAME record.
+After you have set up the external endpoint, associate it with a
+[wildcard DNS record](https://en.wikipedia.org/wiki/Wildcard_DNS_record) (such
+as `*.example.com.`) to reach your apps. If your external endpoint is an IP
+address, use an A record. If your external endpoint is a hostname, use a CNAME
+record.
#### Web Application Firewall (ModSecurity)
@@ -269,16 +262,15 @@ A Web Application Firewall (WAF) examines traffic being sent or received,
and can block malicious traffic before it reaches your application. The benefits
of a WAF are:
-- Real-time security monitoring for your application
-- Logging of all your HTTP traffic to the application
-- Access control for your application
-- Highly configurable logging and blocking rules
+- Real-time security monitoring for your application.
+- Logging of all your HTTP traffic to the application.
+- Access control for your application.
+- Highly configurable logging and blocking rules.
-Out of the box, GitLab provides you with a WAF known as [`ModSecurity`](https://www.modsecurity.org/).
-
-ModSecurity is a toolkit for real-time web application monitoring, logging,
-and access control. With GitLab's offering, the [OWASP's Core Rule Set](https://www.modsecurity.org/CRS/Documentation/),
-which provides generic attack detection capabilities, is automatically applied.
+By default, GitLab provides you with a WAF known as [`ModSecurity`](https://www.modsecurity.org/),
+which is a toolkit for real-time web application monitoring, logging, and access
+control. GitLab's offering applies the [OWASP's Core Rule Set](https://www.modsecurity.org/CRS/Documentation/),
+which provides generic attack detection capabilities.
This feature:
@@ -299,57 +291,61 @@ There is a small performance overhead by enabling ModSecurity. If this is
considered significant for your application, you can disable ModSecurity's
rule engine for your deployed application in any of the following ways:
-1. Setting [the deployment variable](../../topics/autodevops/index.md)
-`AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE` to `Off`. This will prevent ModSecurity
-from processing any requests for the given application or environment.
-
-1. Switching its respective toggle to the disabled position and applying changes through the **Save changes** button. This will reinstall
-Ingress with the recent changes.
+1. Set [the deployment variable](../../topics/autodevops/index.md)
+ `AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE` to `Off` to prevent ModSecurity
+ from processing any requests for the given application or environment.
+1. Switch its respective toggle to the disabled position, and then apply changes
+ by selecting **Save changes** to reinstall Ingress with the recent changes.
![Disabling WAF](../../topics/web_application_firewall/img/guide_waf_ingress_save_changes_v12_10.png)
##### Logging and blocking modes
To help you tune your WAF rules, you can globally set your WAF to either
-**Logging** or **Blocking** mode:
+*Logging* or *Blocking* mode:
-- **Logging mode** - Allows traffic matching the rule to pass, and logs the event.
-- **Blocking mode** - Prevents traffic matching the rule from passing, and logs the event.
+- *Logging mode*: Allows traffic matching the rule to pass, and logs the event.
+- *Blocking mode*: Prevents traffic matching the rule from passing, and logs the event.
To change your WAF's mode:
-1. [Install ModSecurity](../../topics/web_application_firewall/quick_start_guide.md) if you have not already done so.
+1. If you haven't already done so, [install ModSecurity](../../topics/web_application_firewall/quick_start_guide.md).
1. Navigate to **Operations > Kubernetes**.
1. In **Applications**, scroll to **Ingress**.
1. Under **Global default**, select your desired mode.
-1. Click **Save changes**.
+1. Select **Save changes**.
##### WAF version updates
-Enabling, disabling, or changing the logging mode for **ModSecurity** is only allowed within same version of [Ingress](#ingress) due to limitations in [Helm](https://helm.sh/) which might be overcome in future releases.
+Enabling, disabling, or changing the logging mode for **ModSecurity** is only
+allowed within same version of [Ingress](#ingress) due to limitations in
+[Helm](https://helm.sh/) which might be overcome in future releases.
-**ModSecurity** UI controls are disabled if the version deployed differs from the one available in GitLab, while actions at the [Ingress](#ingress) level, such as uninstalling, can still be performed:
+**ModSecurity** user interface controls are disabled if the version deployed
+differs from the one available in GitLab, while actions at the [Ingress](#ingress)
+level, such as uninstalling, can still be performed:
![WAF settings disabled](../../topics/web_application_firewall/img/guide_waf_ingress_disabled_settings_v12_10.png)
-Updating [Ingress](#ingress) to the most recent version enables you to take advantage of bug fixes, security fixes, and performance improvements. To update [Ingress application](#ingress), you must first uninstall it, and then re-install it as described in [Install ModSecurity](../../topics/web_application_firewall/quick_start_guide.md).
+Update [Ingress](#ingress) to the most recent version to take advantage of bug
+fixes, security fixes, and performance improvements. To update the
+[Ingress application](#ingress), you must first uninstall it, and then re-install
+it as described in [Install ModSecurity](../../topics/web_application_firewall/quick_start_guide.md).
##### Viewing Web Application Firewall traffic
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/14707) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.9.
You can view Web Application Firewall traffic by navigating to your project's
-**Security & Compliance > Threat Monitoring** page.
-
-From there, you can see tracked over time:
+**Security & Compliance > Threat Monitoring** page. From there, you can see
+tracked over time:
- The total amount of traffic to your application.
- The proportion of traffic that is considered anomalous by the Web Application
Firewall's default [OWASP ruleset](https://www.modsecurity.org/CRS/Documentation/).
-If a significant percentage of traffic is anomalous, it should be investigated
-for potential threats, which can be done by
-[examining the Web Application Firewall logs](#web-application-firewall-modsecurity).
+If a significant percentage of traffic is anomalous, investigate it for potential threats
+by [examining the Web Application Firewall logs](#web-application-firewall-modsecurity).
![Threat Monitoring](img/threat_monitoring_v12_9.png)
@@ -358,55 +354,51 @@ for potential threats, which can be done by
> - Introduced in GitLab 11.0 for project-level clusters.
> - Introduced in GitLab 12.3 for group and instance-level clusters.
-[JupyterHub](https://jupyterhub.readthedocs.io/en/stable/) is a
-multi-user service for managing notebooks across a team. [Jupyter
-Notebooks](https://jupyter-notebook.readthedocs.io/en/latest/) provide a
-web-based interactive programming environment used for data analysis,
+[JupyterHub](https://jupyterhub.readthedocs.io/en/stable/) is a multi-user service
+for managing notebooks across a team. [Jupyter Notebooks](https://jupyter-notebook.readthedocs.io/en/latest/)
+provide a web-based interactive programming environment used for data analysis,
visualization, and machine learning.
-Authentication will be enabled only for [project
-members](../project/members/index.md) for project-level clusters and group
-members for group-level clusters with [Developer or
-higher](../permissions.md) access to the associated project or group.
+The [`jupyter/jupyterhub`](https://jupyterhub.github.io/helm-chart/)
+chart is used to install this application with a
+[`values.yaml`](https://gitlab.com/gitlab-org/gitlab/blob/master/vendor/jupyter/values.yaml)
+file.
+
+Authentication is enabled only for [project members](../project/members/index.md)
+for project-level clusters and group members for group-level clusters with
+[Developer or higher](../permissions.md) access to the associated project or group.
-We use a [custom Jupyter
-image](https://gitlab.com/gitlab-org/jupyterhub-user-image/blob/master/Dockerfile)
+GitLab uses a [custom Jupyter image](https://gitlab.com/gitlab-org/jupyterhub-user-image/blob/master/Dockerfile)
that installs additional useful packages on top of the base Jupyter. You
will also see ready-to-use DevOps Runbooks built with Nurtch's [Rubix library](https://github.com/Nurtch/rubix).
-More information on
-creating executable runbooks can be found in [our Runbooks
-documentation](../project/clusters/runbooks/index.md#configure-an-executable-runbook-with-gitlab). Note that
+More information on creating executable runbooks can be found in
+[our Runbooks documentation](../project/clusters/runbooks/index.md#configure-an-executable-runbook-with-gitlab).
Ingress must be installed and have an IP address assigned before
JupyterHub can be installed.
-NOTE: **Note:**
-The [`jupyter/jupyterhub`](https://jupyterhub.github.io/helm-chart/)
-chart is used to install this application with a
-[`values.yaml`](https://gitlab.com/gitlab-org/gitlab/blob/master/vendor/jupyter/values.yaml)
-file.
-
#### Jupyter Git Integration
> - [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/28783) in GitLab 12.0 for project-level clusters.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/32512) in GitLab 12.3 for group and instance-level clusters.
-When installing JupyterHub onto your Kubernetes cluster, [JupyterLab's Git extension](https://github.com/jupyterlab/jupyterlab-git)
-is automatically provisioned and configured using the authenticated user's:
+When installing JupyterHub onto your Kubernetes cluster,
+[JupyterLab's Git extension](https://github.com/jupyterlab/jupyterlab-git)
+is provisioned and configured using the authenticated user's:
- Name.
- Email.
- Newly created access token.
-JupyterLab's Git extension enables full version control of your notebooks as well as issuance of Git commands within Jupyter.
-Git commands can be issued via the **Git** tab on the left panel or via Jupyter's command line prompt.
+JupyterLab's Git extension enables full version control of your notebooks, and
+issuance of Git commands within Jupyter. You can issue Git commands through the
+**Git** tab on the left panel, or through Jupyter's command-line prompt.
-NOTE: **Note:**
-JupyterLab's Git extension stores the user token in the JupyterHub DB in encrypted format
-and in the single user Jupyter instance as plain text. This is because [Git requires storing
-credentials as plain text](https://git-scm.com/docs/git-credential-store). Potentially, if
-a nefarious user finds a way to read from the file system in the single user Jupyter instance
-they could retrieve the token.
+JupyterLab's Git extension stores the user token in the JupyterHub DB in encrypted
+format, and in the single user Jupyter instance as plain text, because
+[Git requires storing credentials as plain text](https://git-scm.com/docs/git-credential-store)
+Potentially, if a nefarious user finds a way to read from the file system in the
+single-user Jupyter instance, they could retrieve the token.
![Jupyter's Git Extension](img/jupyter-git-extension.gif)
@@ -425,18 +417,16 @@ cluster. It is used in conjunction with, and includes
[Istio](https://istio.io) to provide an external IP address for all
programs hosted by Knative.
-You will be prompted to enter a wildcard
-domain where your applications will be exposed. Configure your DNS
-server to use the external IP address for that domain. For any
-application created and installed, they will be accessible as
-`<program_name>.<kubernetes_namespace>.<domain_name>`. This will require
-your Kubernetes cluster to have [RBAC
-enabled](../project/clusters/add_remove_clusters.md#rbac-cluster-resources).
-
-NOTE: **Note:**
The [`knative/knative`](https://storage.googleapis.com/triggermesh-charts)
chart is used to install this application.
+During installation, you must enter a wildcard domain where your applications
+will be exposed. Configure your DNS server to use the external IP address for that
+domain. Applications created and installed are accessible as
+`<program_name>.<kubernetes_namespace>.<domain_name>`, which requires
+your Kubernetes cluster to have
+[RBAC enabled](../project/clusters/add_remove_clusters.md#rbac-cluster-resources).
+
### Prometheus
> - Introduced in GitLab 10.4 for project-level clusters.
@@ -451,15 +441,14 @@ GitLab is able to monitor applications automatically, using the
memory metrics are automatically collected, and response metrics are retrieved
from NGINX Ingress as well.
-To enable monitoring, simply install Prometheus into the cluster with the
-**Install** button.
-
-NOTE: **Note:**
The [`stable/prometheus`](https://github.com/helm/charts/tree/master/stable/prometheus)
chart is used to install this application with a
[`values.yaml`](https://gitlab.com/gitlab-org/gitlab/blob/master/vendor/prometheus/values.yaml)
file.
+To enable monitoring, install Prometheus into the cluster with the **Install**
+button.
+
### Crossplane
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/34702) in GitLab 12.5 for project-level clusters.
@@ -483,15 +472,14 @@ The Crossplane GitLab-managed application:
PostgreSQL (for example, CloudSQL from GCP or RDS from AWS) and other services
required by the application via the Auto DevOps pipeline.
-For information on configuring Crossplane installed on the cluster, see
-[Crossplane configuration](crossplane.md).
-
-NOTE: **Note:**
[`alpha/crossplane`](https://github.com/crossplane/crossplane/tree/v0.4.1/cluster/charts/crossplane) chart v0.4.1 is used to
install Crossplane using the
[`values.yaml`](https://github.com/crossplane/crossplane/blob/master/cluster/charts/crossplane/values.yaml.tmpl)
file.
+For information about configuring Crossplane installed on the cluster, see
+[Crossplane configuration](crossplane.md).
+
### Elastic Stack
> Introduced in GitLab 12.7 for project- and group-level clusters.
@@ -500,37 +488,33 @@ file.
log analysis solution which helps in deep searching, analyzing and visualizing the logs
generated from different machines.
-GitLab is able to gather logs from pods in your cluster automatically.
-Filebeat will run as a DaemonSet on each node in your cluster, and it will ship container logs to Elasticsearch for querying.
-GitLab will then connect to Elasticsearch for logs instead of the Kubernetes API,
-and you will have access to more advanced querying capabilities.
-
-Log data is automatically deleted after 30 days using [Curator](https://www.elastic.co/guide/en/elasticsearch/client/curator/5.5/about.html).
+GitLab can gather logs from pods in your cluster. Filebeat runs as a DaemonSet
+on each node in your cluster, and ships container logs to Elasticsearch for
+querying. GitLab then connects to Elasticsearch for logs, instead of the
+Kubernetes API, giving you access to more advanced querying capabilities. Log
+data is deleted after 30 days, using [Curator](https://www.elastic.co/guide/en/elasticsearch/client/curator/5.5/about.html).
To enable log shipping:
-1. Ensure your cluster contains at least 3 nodes of instance types larger than
- `f1-micro`, `g1-small`, or `n1-standard-1`.
+1. Ensure your cluster contains at least three nodes of instance types larger
+ than `f1-micro`, `g1-small`, or `n1-standard-1`.
1. Navigate to **Operations > Kubernetes**.
1. In **Kubernetes Cluster**, select a cluster.
-1. In the **Applications** section, find **Elastic Stack** and click **Install**.
+1. In the **Applications** section, find **Elastic Stack**, and then select
+ **Install**.
-NOTE: **Note:**
The [`gitlab/elastic-stack`](https://gitlab.com/gitlab-org/charts/elastic-stack)
chart is used to install this application with a
[`values.yaml`](https://gitlab.com/gitlab-org/gitlab/blob/master/vendor/elastic_stack/values.yaml)
-file.
+file. The chart deploys three identical Elasticsearch pods which can't be
+colocated, and each requires one CPU and 2 GB of RAM, making them
+incompatible with clusters containing fewer than three nodes, or consisting of
+`f1-micro`, `g1-small`, `n1-standard-1`, or `*-highcpu-2` instance types.
NOTE: **Note:**
-The chart deploys 3 identical Elasticsearch pods which can't be colocated, and each
-requires 1 CPU and 2 GB of RAM, making them incompatible with clusters containing
-fewer than 3 nodes or consisting of `f1-micro`, `g1-small`, `n1-standard-1`, or
-`*-highcpu-2` instance types.
-
-NOTE: **Note:**
-The Elastic Stack cluster application is intended as a log aggregation solution and is not related to our
-[Advanced Search](../search/advanced_global_search.md) functionality, which uses a separate
-Elasticsearch cluster.
+The Elastic Stack cluster application is intended as a log aggregation solution
+and is not related to our [Advanced Search](../search/advanced_global_search.md)
+functionality, which uses a separate Elasticsearch cluster.
#### Optional: deploy Kibana to perform advanced queries
@@ -1280,7 +1264,7 @@ You can check the default [`values.yaml`](https://gitlab.com/gitlab-org/gitlab/-
You can customize the installation of Elastic Stack by defining
`.gitlab/managed-apps/elastic-stack/values.yaml` file in your cluster
management project. Refer to the
-[chart](https://gitlab.com/gitlab-org/charts/elastic-stack) for the
+[chart](https://gitlab.com/gitlab-org/charts/elastic-stack) for all
available configuration options.
NOTE: **Note:**
@@ -1337,7 +1321,7 @@ You can customize the installation of Fluentd by defining
`.gitlab/managed-apps/fluentd/values.yaml` file in your cluster management
project. Refer to the
[configuration chart for the current development release of Fluentd](https://github.com/helm/charts/tree/master/stable/fluentd#configuration)
-for the available configuration options.
+for all available configuration options.
NOTE: **Note:**
The configuration chart link points to the current development release, which
@@ -1360,7 +1344,7 @@ knative:
You can customize the installation of Knative by defining `.gitlab/managed-apps/knative/values.yaml`
file in your cluster management project. Refer to the [chart](https://gitlab.com/gitlab-org/charts/knative)
-for the available configuration options.
+for all available configuration options.
Here is an example configuration for Knative:
diff --git a/doc/user/compliance/compliance_dashboard/img/compliance_dashboard_v13_3_1.png b/doc/user/compliance/compliance_dashboard/img/compliance_dashboard_v13_3_1.png
index a06f8812b41..89f4e917567 100644
--- a/doc/user/compliance/compliance_dashboard/img/compliance_dashboard_v13_3_1.png
+++ b/doc/user/compliance/compliance_dashboard/img/compliance_dashboard_v13_3_1.png
Binary files differ
diff --git a/doc/user/compliance/license_compliance/img/license-check_v13_4.png b/doc/user/compliance/license_compliance/img/license-check_v13_4.png
index d3658cbaa18..bc80f938395 100644
--- a/doc/user/compliance/license_compliance/img/license-check_v13_4.png
+++ b/doc/user/compliance/license_compliance/img/license-check_v13_4.png
Binary files differ
diff --git a/doc/user/compliance/license_compliance/index.md b/doc/user/compliance/license_compliance/index.md
index 79c2d97b972..382d536be74 100644
--- a/doc/user/compliance/license_compliance/index.md
+++ b/doc/user/compliance/license_compliance/index.md
@@ -127,6 +127,11 @@ is used to detect the languages/frameworks and in turn analyzes the licenses.
The License Compliance settings can be changed through [environment variables](#available-variables) by using the
[`variables`](../../../ci/yaml/README.md#variables) parameter in `.gitlab-ci.yml`.
+### When License Compliance runs
+
+When using the GitLab `License-Scanning.gitlab-ci.yml` template, the License Compliance job doesn't
+wait for other stages to complete.
+
### Available variables
License Compliance can be configured using environment variables.
@@ -446,7 +451,7 @@ package manager. For a comprehensive list, consult [the Conan documentation](htt
The default [Conan](https://conan.io/) configuration sets [`CONAN_LOGIN_USERNAME`](https://docs.conan.io/en/latest/reference/env_vars.html#conan-login-username-conan-login-username-remote-name)
to `ci_user`, and binds [`CONAN_PASSWORD`](https://docs.conan.io/en/latest/reference/env_vars.html#conan-password-conan-password-remote-name)
to the [`CI_JOB_TOKEN`](../../../ci/variables/predefined_variables.md)
-for the running job. This allows Conan projects to fetch packages from a [GitLab Conan Repository](../../packages/conan_repository/#fetching-conan-package-information-from-the-gitlab-package-registry)
+for the running job. This allows Conan projects to fetch packages from a [GitLab Conan Repository](../../packages/conan_repository/#fetch-conan-package-information-from-the-package-registry)
if a GitLab remote is specified in the `.conan/remotes.json` file.
To override the default credentials specify a [`CONAN_LOGIN_USERNAME_{REMOTE_NAME}`](https://docs.conan.io/en/latest/reference/env_vars.html#conan-login-username-conan-login-username-remote-name)
diff --git a/doc/user/gitlab_com/index.md b/doc/user/gitlab_com/index.md
index 67dd31efe2c..2adfd7532fd 100644
--- a/doc/user/gitlab_com/index.md
+++ b/doc/user/gitlab_com/index.md
@@ -38,6 +38,14 @@ The IP address for `mg.gitlab.com` is subject to change at any time.
[See our backup strategy](https://about.gitlab.com/handbook/engineering/infrastructure/production/#backups).
+There are several ways to perform backups of your content on GitLab.com.
+
+Projects can be backed up in their entirety by exporting them either [through the UI](../project/settings/import_export.md) or [API](../../api/project_import_export.md#schedule-an-export), the latter of which can be used to programmatically upload exports to a storage platform such as AWS S3.
+
+With exports, be sure to take note of [what is and is not](../project/settings/import_export.md#exported-contents), included in a project export.
+
+Since GitLab is built on Git, you can back up **just** the repository of a project by [cloning](../../gitlab-basics/start-using-git.md#clone-a-repository) it to another machine. Similarly, if you need to back up just the wiki of a repository it can also be cloned and all files uploaded to that wiki will come with it [if they were uploaded after 2020-08-22](../project/wiki/index.md#creating-a-new-wiki-page).
+
## Alternative SSH port
GitLab.com can be reached via a [different SSH port](https://about.gitlab.com/blog/2016/02/18/gitlab-dot-com-now-supports-an-alternate-git-plus-ssh-port/) for `git+ssh`.
@@ -474,6 +482,10 @@ More information on this particular change can be found at
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>.
+## Puma
+
+GitLab.com uses the default of 60 seconds for [Puma request timeouts](https://docs.gitlab.com/omnibus/settings/puma.html#worker-timeout).
+
## Unicorn
GitLab.com adjusts the memory limits for the [unicorn-worker-killer](https://rubygems.org/gems/unicorn-worker-killer) gem.
diff --git a/doc/user/group/clusters/index.md b/doc/user/group/clusters/index.md
index ebf38aef4a6..2d664da686f 100644
--- a/doc/user/group/clusters/index.md
+++ b/doc/user/group/clusters/index.md
@@ -14,6 +14,9 @@ Similar to [project-level](../../project/clusters/index.md) and
group-level Kubernetes clusters allow you to connect a Kubernetes cluster to
your group, enabling you to use the same cluster across multiple projects.
+To view your group level Kubernetes clusters, navigate to your project and select
+**Kubernetes** from the left-hand menu.
+
## Installing applications
GitLab can install and manage some applications in your group-level
diff --git a/doc/user/group/contribution_analytics/index.md b/doc/user/group/contribution_analytics/index.md
index bcc6d958427..a689b7c380b 100644
--- a/doc/user/group/contribution_analytics/index.md
+++ b/doc/user/group/contribution_analytics/index.md
@@ -9,8 +9,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> - Introduced in [GitLab Starter](https://about.gitlab.com/pricing/) 8.3.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/3090) for subgroups in GitLab 12.2.
-## Overview
-
With Contribution Analytics you can get an overview of the following activity in your
group:
diff --git a/doc/user/group/index.md b/doc/user/group/index.md
index 32b76cf9280..2c31ef3bf0b 100644
--- a/doc/user/group/index.md
+++ b/doc/user/group/index.md
@@ -395,7 +395,7 @@ milestones.
Get an overview of the vulnerabilities of all the projects in a group and its subgroups.
-[Learn more about the Group Security Dashboard.](security_dashboard/index.md)
+[Learn more about the Group Security Dashboard.](../application_security/security_dashboard/index.md)
## Insights **(ULTIMATE)**
diff --git a/doc/user/group/repositories_analytics/index.md b/doc/user/group/repositories_analytics/index.md
index b013e371ed2..323411ccbab 100644
--- a/doc/user/group/repositories_analytics/index.md
+++ b/doc/user/group/repositories_analytics/index.md
@@ -1,7 +1,7 @@
---
type: reference
stage: Verify
-group: Analytics
+group: Testing
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
diff --git a/doc/user/group/saml_sso/index.md b/doc/user/group/saml_sso/index.md
index 57b9cc92c51..3d24e7b8d44 100644
--- a/doc/user/group/saml_sso/index.md
+++ b/doc/user/group/saml_sso/index.md
@@ -5,7 +5,7 @@ group: Access
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
-# SAML SSO for GitLab.com groups **(PREMIUM)**
+# SAML SSO for GitLab.com groups **(SILVER ONLY)**
> Introduced in GitLab 11.0.
@@ -256,53 +256,6 @@ For example, to unlink the `MyOrg` account, the following **Disconnect** button
| Issuer | How GitLab identifies itself to the identity provider. Also known as a "Relying party trust identifier". |
| Certificate fingerprint | Used to confirm that communications over SAML are secure by checking that the server is signing communications with the correct certificate. Also known as a certificate thumbprint. |
-## Configuring on a self-managed GitLab instance **(PREMIUM ONLY)**
-
-For self-managed GitLab instances we strongly recommend using the
-[instance-wide SAML OmniAuth Provider](../../../integration/saml.md) instead.
-
-Group SAML SSO helps if you need to allow access via multiple SAML identity providers, but as a multi-tenant solution is less suited to cases where you administer your own GitLab instance.
-
-To proceed with configuring Group SAML SSO instead, you'll need to enable the `group_saml` OmniAuth provider. This can be done from:
-
-- `gitlab.rb` for [Omnibus GitLab installations](#omnibus-installations).
-- `gitlab/config/gitlab.yml` for [source installations](#source-installations).
-
-### Limitations
-
-Group SAML on a self-managed instance is limited when compared to the recommended
-[instance-wide SAML](../../../integration/saml.md). The recommended solution allows you to take advantage of:
-
-- [LDAP compatibility](../../../administration/auth/ldap/index.md).
-- [LDAP Group Sync](../index.md#manage-group-memberships-via-ldap).
-- [Required groups](../../../integration/saml.md#required-groups).
-- [Admin groups](../../../integration/saml.md#admin-groups).
-- [Auditor groups](../../../integration/saml.md#auditor-groups).
-
-### Omnibus installations
-
-1. Make sure GitLab is
- [configured with HTTPS](../../../install/installation.md#using-https).
-1. Enable OmniAuth and the `group_saml` provider in `gitlab.rb`:
-
- ```ruby
- gitlab_rails['omniauth_enabled'] = true
- gitlab_rails['omniauth_providers'] = [{ name: 'group_saml' }]
- ```
-
-### Source installations
-
-1. Make sure GitLab is
- [configured with HTTPS](../../../install/installation.md#using-https).
-1. Enable OmniAuth and the `group_saml` provider in `gitlab/config/gitlab.yml`:
-
- ```yaml
- omniauth:
- enabled: true
- providers:
- - { name: 'group_saml' }
- ```
-
## Passwords for users created via SAML SSO for Groups
The [Generated passwords for users created through integrated authentication](../../../security/passwords_for_integrated_authentication_methods.md) guide provides an overview of how GitLab generates and sets passwords for users created via SAML SSO for Groups.
diff --git a/doc/user/group/saml_sso/scim_setup.md b/doc/user/group/saml_sso/scim_setup.md
index 4f74e672392..985e6ec13be 100644
--- a/doc/user/group/saml_sso/scim_setup.md
+++ b/doc/user/group/saml_sso/scim_setup.md
@@ -260,15 +260,9 @@ It is important that this SCIM `id` and SCIM `externalId` are configured to the
Group owners can see the list of users and the `externalId` stored for each user in the group SAML SSO Settings page.
-Alternatively, the [SCIM API](../../../api/scim.md#get-a-list-of-saml-users) can be used to manually retrieve the `externalId` we have stored for users, also called the `external_uid` or `NameId`.
+A possible alternative is to use the [SCIM API](../../../api/scim.md#get-a-list-of-saml-users) to manually retrieve the `externalId` we have stored for users, also called the `external_uid` or `NameId`.
-For example:
-
-```shell
-curl 'https://gitlab.example.com/api/scim/v2/groups/GROUP_NAME/Users?startIndex=1"' --header "Authorization: Bearer <your_scim_token>" --header "Content-Type: application/scim+json"
-```
-
-To see how this compares to the value returned as the SAML NameId, you can have the user use a [SAML Tracer](index.md#saml-debugging-tools).
+To see how the `external_uid` compares to the value returned as the SAML NameId, you can have the user use a [SAML Tracer](index.md#saml-debugging-tools).
#### Update or fix mismatched SCIM externalId and SAML NameId
@@ -285,15 +279,9 @@ you can address the problem in the following ways:
- You can have users unlink and relink themselves, based on the ["SAML authentication failed: User has already been taken"](./index.md#message-saml-authentication-failed-user-has-already-been-taken) section.
- You can unlink all users simultaneously, by removing all users from the SAML app while provisioning is turned on.
-- You can use the [SCIM API](../../../api/scim.md#update-a-single-saml-user) to manually correct the `externalId` stored for users to match the SAML `NameId`.
+- It may be possible to use the [SCIM API](../../../api/scim.md#update-a-single-saml-user) to manually correct the `externalId` stored for users to match the SAML `NameId`.
To look up a user, you'll need to know the desired value that matches the `NameId` as well as the current `externalId`.
-It is then possible to issue a manual SCIM#update request, for example:
-
-```shell
-curl --verbose --request PATCH 'https://gitlab.com/api/scim/v2/groups/YOUR_GROUP/Users/OLD_EXTERNAL_UID' --data '{ "Operations": [{"op":"Replace","path":"externalId","value":"NEW_EXTERNAL_UID"}] }' --header "Authorization: Bearer <your_scim_token>" --header "Content-Type: application/scim+json"
-```
-
It is important not to update these to incorrect values, since this will cause users to be unable to sign in. It is also important not to assign a value to the wrong user, as this would cause users to get signed into the wrong account.
#### I need to change my SCIM app
diff --git a/doc/user/img/feature_flags_history_note_info_v13_2.png b/doc/user/img/feature_flags_history_note_info_v13_2.png
index 403a6002603..07d096b6dde 100644
--- a/doc/user/img/feature_flags_history_note_info_v13_2.png
+++ b/doc/user/img/feature_flags_history_note_info_v13_2.png
Binary files differ
diff --git a/doc/user/img/version_history_notes_collapsed_v13_2.png b/doc/user/img/version_history_notes_collapsed_v13_2.png
index 42ea11ae8ff..b85c9cb36dd 100644
--- a/doc/user/img/version_history_notes_collapsed_v13_2.png
+++ b/doc/user/img/version_history_notes_collapsed_v13_2.png
Binary files differ
diff --git a/doc/user/index.md b/doc/user/index.md
index ce8713591ab..32a1c235882 100644
--- a/doc/user/index.md
+++ b/doc/user/index.md
@@ -46,8 +46,9 @@ GitLab is a Git-based platform that integrates a great number of essential tools
- Building, testing, and deploying with built-in [Continuous Integration](../ci/README.md).
- Deploying personal and professional static websites with [GitLab Pages](project/pages/index.md).
- Integrating with Docker by using [GitLab Container Registry](packages/container_registry/index.md).
-- Tracking the development lifecycle by using [GitLab Value Stream Analytics](project/cycle_analytics.md).
+- Tracking the development lifecycle by using [GitLab Value Stream Analytics](analytics/value_stream_analytics.md).
- Provide support with [Service Desk](project/service_desk.md).
+- [Export issues as CSV](project/issues/csv_export.md).
With GitLab Enterprise Edition, you can also:
@@ -60,8 +61,7 @@ With GitLab Enterprise Edition, you can also:
- Leverage [Elasticsearch](../integration/elasticsearch.md) with [Advanced Search](search/advanced_global_search.md) and [Advanced Search Syntax](search/advanced_search_syntax.md) for faster, more advanced code search across your entire GitLab instance.
- [Authenticate users with Kerberos](../integration/kerberos.md).
- [Mirror a repository](project/repository/repository_mirroring.md) from elsewhere on your local server.
-- [Export issues as CSV](project/issues/csv_export.md).
-- View your entire CI/CD pipeline involving more than one project with [Multiple-Project Pipelines](../ci/multi_project_pipeline_graphs.md).
+- View your entire CI/CD pipeline involving more than one project with [Multiple-Project Pipelines](../ci/multi_project_pipelines.md).
- [Lock files](project/file_lock.md) to prevent conflicts.
- View the current health and status of each CI environment running on Kubernetes with [Deploy Boards](project/deploy_boards.md).
- Leverage continuous delivery method with [Canary Deployments](project/canary_deployments.md).
diff --git a/doc/user/infrastructure/index.md b/doc/user/infrastructure/index.md
index 227a67f8c8a..216cf9ed607 100644
--- a/doc/user/infrastructure/index.md
+++ b/doc/user/infrastructure/index.md
@@ -82,6 +82,10 @@ local machine, this is a simple way to get started:
-backend-config="retry_wait_min=5"
```
+ NOTE: **Note:**
+ The name of your state can contain only uppercase and lowercase letters,
+ decimal digits, hyphens and underscores.
+
You can now run `terraform plan` and `terraform apply` as you normally would.
## Get started using GitLab CI
diff --git a/doc/user/instance/clusters/index.md b/doc/user/instance/clusters/index.md
index 8059b8ca642..c370f9d8725 100644
--- a/doc/user/instance/clusters/index.md
+++ b/doc/user/instance/clusters/index.md
@@ -2,14 +2,14 @@
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/39840) in GitLab 11.11.
-## Overview
-
Similar to [project-level](../../project/clusters/index.md)
and [group-level](../../group/clusters/index.md) Kubernetes clusters,
instance-level Kubernetes clusters allow you to connect a Kubernetes cluster to
the GitLab instance, which enables you to use the same cluster across multiple
projects.
+The instance level Kubernetes clusters can be found in the top menu by navigating to your instance's **{admin}** **Admin Area > Kubernetes**.
+
## Cluster precedence
GitLab will try to match clusters in the following order:
diff --git a/doc/user/packages/composer_repository/img/project_id_v13_5.png b/doc/user/packages/composer_repository/img/project_id_v13_5.png
new file mode 100644
index 00000000000..45861b6ff1b
--- /dev/null
+++ b/doc/user/packages/composer_repository/img/project_id_v13_5.png
Binary files differ
diff --git a/doc/user/packages/composer_repository/index.md b/doc/user/packages/composer_repository/index.md
index 89e02b4847c..219ccef0a01 100644
--- a/doc/user/packages/composer_repository/index.md
+++ b/doc/user/packages/composer_repository/index.md
@@ -4,172 +4,165 @@ group: Package
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
-# GitLab Composer Repository
+# Composer packages in the Package Registry
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/15886) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.2.
> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/221259) to GitLab Core in 13.3.
-With the GitLab Composer Repository, every project can have its own space to store [Composer](https://getcomposer.org/) packages.
+Publish [Composer](https://getcomposer.org/) packages in your project's Package Registry.
+Then, install the packages whenever you need to use them as a dependency.
-## Enabling the Composer Repository
+## Create a Composer package
-NOTE: **Note:**
-This option is available only if your GitLab administrator has
-[enabled support for the Package Registry](../../../administration/packages/index.md).
+If you do not have a Composer package, create one and check it in to
+a repository. This example shows a GitLab repository, but the repository
+can be any public or private repository.
-When the Composer Repository is enabled, it is available for all new projects
-by default. To enable it for existing projects, or if you want to disable it:
+1. Create a directory called `my-composer-package` and change to that directory:
-1. Navigate to your project's **Settings > General > Visibility, project features, permissions**.
-1. Find the Packages feature and enable or disable it.
-1. Click on **Save changes** for the changes to take effect.
+ ```shell
+ mkdir my-composer-package && cd my-composer-package
+ ```
-You should then be able to see the **Packages & Registries** section on the left sidebar.
+1. Run [`composer init`](https://getcomposer.org/doc/03-cli.md#init) and answer the prompts.
-## Getting started
+ For namespace, enter your unique [namespace](../../../user/group/index.md#namespaces), like your GitLab username or group name.
-This section covers creating a new example Composer package to publish. This is a
-quickstart to test out the **GitLab Composer Registry**.
+ A file called `composer.json` is created:
-To complete this section, you need a recent version of [Composer](https://getcomposer.org/).
+ ```json
+ {
+ "name": "<namespace>/composer-test",
+ "type": "library",
+ "license": "GPL-3.0-only",
+ "version": "1.0.0"
+ }
+ ```
-### Creating a package project
+1. Run Git commands to tag the changes and push them to your repository:
-Understanding how to create a full Composer project is outside the scope of this
-guide, but you can create a small package to test out the registry. Start by
-creating a new directory called `my-composer-package`:
+ ```shell
+ git init
+ git add composer.json
+ git commit -m 'Composer package test'
+ git tag v1.0.0
+ git remote add origin git@gitlab.example.com:<namespace>/<project-name>.git
+ git push --set-upstream origin master
+ git push origin v1.0.0
+ ```
-```shell
-mkdir my-composer-package && cd my-composer-package
-```
+The package is now in your GitLab Package Registry.
-Create a new `composer.json` file inside this directory to set up the basic project:
+## Publish a Composer package by using the API
-```shell
-touch composer.json
-```
+Publish a Composer package to the Package Registry,
+so that anyone who can access the project can use the package as a dependency.
-Inside `composer.json`, add the following code:
+Prerequisites:
-```json
-{
- "name": "<namespace>/composer-test",
- "type": "library",
- "license": "GPL-3.0-only",
- "version": "1.0.0"
-}
-```
+- A package in a GitLab repository.
+- The project ID, which is on the project's home page.
+- A [personal access token](../../../user/profile/personal_access_tokens.md) with the scope set to `api`.
-Replace `<namespace>` with a unique namespace like your GitLab username or group name.
+ NOTE: **Note:**
+ [Deploy tokens](./../../project/deploy_tokens/index.md) are
+ [not yet supported](https://gitlab.com/gitlab-org/gitlab/-/issues/240897) for use with Composer.
-After this basic package structure is created, we need to tag it in Git and push it to the repository.
+To publish the package:
-```shell
-git init
-git add composer.json
-git commit -m 'Composer package test'
-git tag v1.0.0
-git remote add origin git@gitlab.com:<namespace>/<project-name>.git
-git push --set-upstream origin master
-git push origin v1.0.0
-```
+- Send a `POST` request to the [Packages API](../../../api/packages.md).
+
+ For example, you can use `curl`:
-### Publishing the package
+ ```shell
+ curl --data tag=<tag> "https://__token__:<personal-access-token>@gitlab.example.com/api/v4/projects/<project_id>/packages/composer"
+ ```
-Now that the basics of our project is completed, we can publish the package.
-To publish the package, you need:
+ - `<personal-access-token>` is your personal access token.
+ - `<project_id>` is your project ID.
+ - `<tag>` is the Git tag name of the version you want to publish.
+ To publish a branch, use `branch=<branch>` instead of `tag=<tag>`.
-- A personal access token or `CI_JOB_TOKEN`.
+You can view the published package by going to **Packages & Registries > Package Registry** and
+selecting the **Composer** tab.
- ([Deploy tokens](./../../project/deploy_tokens/index.md) are not yet supported for use with Composer.)
+## Publish a Composer package by using CI/CD
-- Your project ID which can be found on the home page of your project.
+You can publish a Composer package to the Package Registry as part of your CI/CD process.
-To publish the package hosted on GitLab, make a `POST` request to the GitLab package API.
-A tool like `curl` can be used to make this request:
+1. Specify a `CI_JOB_TOKEN` in your `.gitlab-ci.yml` file:
-You can generate a [personal access token](../../../user/profile/personal_access_tokens.md) with the scope set to `api` for repository authentication. For example:
+ ```yaml
+ stages:
+ - deploy
-```shell
-curl --data tag=<tag> 'https://__token__:<personal-access-token>@gitlab.com/api/v4/projects/<project_id>/packages/composer'
-```
+ deploy:
+ stage: deploy
+ script:
+ - 'curl --header "Job-Token: $CI_JOB_TOKEN" --data tag=<tag> "https://gitlab.example.com/api/v4/projects/$CI_PROJECT_ID/packages/composer"'
+ ```
-Where:
+1. Run the pipeline.
-- `<personal-access-token>` is your personal access token.
-- `<project_id>` is your project ID.
-- `<tag>` is the Git tag name of the version you want to publish. In this example it should be `v1.0.0`. Notice that instead of `tag=<tag>` you can also use `branch=<branch>` to publish branches.
+You can view the published package by going to **Packages & Registries > Package Registry** and selecting the **Composer** tab.
-If the above command succeeds, you now should be able to see the package under the **Packages & Registries** section of your project page.
+### Use a CI/CD template
-### Publishing the package with CI/CD
+A more detailed Composer CI/CD file is also available as a `.gitlab-ci.yml` template:
-To work with Composer commands within [GitLab CI/CD](./../../../ci/README.md), you can
-publish Composer packages by using `CI_JOB_TOKEN` in your `.gitlab-ci.yml` file:
+1. On the left sidebar, click **Project overview**.
+1. Above the file list, click **Set up CI/CD**. If this button is not available, select **CI/CD Configuration** and then **Edit**.
+1. From the **Apply a template** list, select **Composer**.
-```yaml
-stages:
- - deploy
+CAUTION: **Warning:**
+Do not save unless you want to overwrite the existing CI/CD file.
-deploy:
- stage: deploy
- script:
- - 'curl --header "Job-Token: $CI_JOB_TOKEN" --data tag=<tag> "https://gitlab.example.com/api/v4/projects/$CI_PROJECT_ID/packages/composer"'
-```
+## Install a Composer package
-### Installing a package
+Install a package from the Package Registry so you can use it as a dependency.
-To install your package, you need:
+Prerequisites:
-- A personal access token. You can generate a [personal access token](../../../user/profile/personal_access_tokens.md) with the scope set to `api` for repository authentication.
-- Your group ID which can be found on the home page of your project's group.
+- A package in the Package Registry.
+- The group ID, which is on the group's home page.
+- A [personal access token](../../../user/profile/personal_access_tokens.md) with the scope set to `api`.
-Add the GitLab Composer package repository to your existing project's `composer.json` file, along with the package name and version you want to install like so:
+ NOTE: **Note:**
+ [Deploy tokens](./../../project/deploy_tokens/index.md) are
+ [not yet supported](https://gitlab.com/gitlab-org/gitlab/-/issues/240897) for use with Composer.
-```json
-{
- ...
- "repositories": [
- { "type": "composer", "url": "https://gitlab.com/api/v4/group/<group_id>/-/packages/composer/packages.json" }
- ],
- "require": {
- ...
- "<package_name>": "<version>"
- },
- ...
-}
-```
+To install a package:
-Where:
+1. Add the Package Registry URL to your project's `composer.json` file, along with the package name and version you want to install:
-- `<group_id>` is the group ID found under your project's group page.
-- `<package_name>` is your package name as defined in your package's `composer.json` file.
-- `<version>` is your package version (`1.0.0` in this example).
+ ```json
+ {
+ ...
+ "repositories": [
+ { "type": "composer", "url": "https://gitlab.example.com/api/v4/group/<group_id>/-/packages/composer/packages.json" }
+ ],
+ "require": {
+ ...
+ "<package_name>": "<version>"
+ },
+ ...
+ }
+ ```
-You also need to create a `auth.json` file with your GitLab credentials:
+ - `<group_id>` is the group ID.
+ - `<package_name>` is the package name defined in your package's `composer.json` file.
+ - `<version>` is the package version.
-```json
-{
- "gitlab-token": {
- "gitlab.com": "<personal_access_token>"
- }
-}
-```
+1. Create an `auth.json` file with your GitLab credentials:
-Where:
+ ```shell
+ composer config gitlab-token.<DOMAIN-NAME> <personal_access_token>
+ ```
-- `<personal_access_token>` is your personal access token.
-
-With the `composer.json` and `auth.json` files configured, you can install the package by running `composer`:
-
-```shell
-composer update
-```
-
-If successful, you should be able to see the output indicating that the package has been successfully installed.
+Output indicates that the package has been successfully installed.
CAUTION: **Important:**
-Make sure to never commit the `auth.json` file to your repository. To install packages from a CI job,
+Never commit the `auth.json` file to your repository. To install packages from a CI/CD job,
consider using the [`composer config`](https://getcomposer.org/doc/articles/handling-private-packages-with-satis.md#authentication) tool with your personal access token
stored in a [GitLab CI/CD environment variable](../../../ci/variables/README.md) or in
-[Hashicorp Vault](../../../ci/secrets/index.md).
+[HashiCorp Vault](../../../ci/secrets/index.md).
diff --git a/doc/user/packages/conan_repository/img/conan_package_view.png b/doc/user/packages/conan_repository/img/conan_package_view.png
deleted file mode 100644
index 742fb4195da..00000000000
--- a/doc/user/packages/conan_repository/img/conan_package_view.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/packages/conan_repository/index.md b/doc/user/packages/conan_repository/index.md
index 7c3082e0f83..1f485de6293 100644
--- a/doc/user/packages/conan_repository/index.md
+++ b/doc/user/packages/conan_repository/index.md
@@ -4,153 +4,150 @@ group: Package
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
-# GitLab Conan Repository
+# Conan packages in the Package Registry
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/8248) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.6.
> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/221259) to GitLab Core in 13.3.
-With the GitLab Conan Repository, every
-project can have its own space to store Conan packages.
+Publish Conan packages in your project’s Package Registry. Then install the
+packages whenever you need to use them as a dependency.
-![GitLab Conan Repository](img/conan_package_view.png)
+To publish Conan packages to the Package Registry, add the
+Package Registry as a remote and authenticate with it.
-## Enabling the Conan Repository
+Then you can run `conan` commands and publish your package to the Package Registry.
-NOTE: **Note:**
-This option is available only if your GitLab administrator has
-[enabled support for the Conan Repository](../../../administration/packages/index.md).
-
-After the Conan Repository is enabled, it's available for all new projects
-by default. To enable it for existing projects, or if you want to disable it:
-
-1. Navigate to your project's **Settings > General > Visibility, project features, permissions**.
-1. Find the Packages feature and enable or disable it.
-1. Click on **Save changes** for the changes to take effect.
-
-You should then be able to see the **Packages & Registries** section on the left sidebar.
+## Build a Conan package
-## Getting started
+This section explains how to install Conan and build a package for your C/C++ project.
-This section covers installing Conan and building a package for your C/C++ project. This is a quickstart if you're new
-to Conan. If you already are using Conan and understand how to build your own packages, move on to the [next section](#adding-the-gitlab-package-registry-as-a-conan-remote).
+If you already use Conan and know how to build your own packages, go to the [next section](#add-the-package-registry-as-a-conan-remote).
-### Installing Conan
+### Install Conan
-Follow the instructions at [conan.io](https://conan.io/downloads.html) to download the Conan package manager to your local development environment.
+Download the Conan package manager to your local development environment by following
+the instructions at [conan.io](https://conan.io/downloads.html).
-Once installation is complete, verify you can use Conan in your terminal by running
+When installation is complete, verify you can use Conan in your terminal by running:
```shell
conan --version
```
-You should see the Conan version printed in the output:
+The Conan version is printed in the output:
```plaintext
Conan version 1.20.5
```
-### Installing CMake
+### Install CMake
+
+When you develop with C++ and Conan, you have a range of compilers to choose from.
+This example uses the CMake compiler.
+
+To install CMake:
+
+- For Mac, use [homebrew](https://brew.sh/) and run `brew install cmake`.
+- For other operating systems, follow the instructions at [cmake.org](https://cmake.org/install/).
-When developing with C++ and Conan, you have a wide range of options for compilers. This tutorial walks through using the cmake
-compiler. In your terminal, run the command
+When installation is complete, verify you can use CMake in your terminal by running:
```shell
cmake --version
```
-You should see the cmake version printed in the output. If you see something else, you may have to install cmake.
+The CMake version is printed in the output.
-On a Mac, you can use [homebrew](https://brew.sh/) to install cmake by running `brew install cmake`. Otherwise, follow
-instructions at [cmake.org](https://cmake.org/install/) for your operating system.
+### Create a project
-### Creating a project
+To test the Package Registry, you need a C++ project. If you don't already have one, you can clone the
+Conan [hello world starter project](https://github.com/conan-io/hello).
-Understanding what is needed to create a valid and compilable C++ project is out of the scope of this guide, but if you're new to C++ and want to try out the GitLab
-package registry, Conan.io has a great [hello world starter project](https://github.com/conan-io/hello) that you can clone to get started.
+### Build a package
-Clone the repository and it can be used for the rest of the tutorial if you don't have your own C++ project.
+To build a package:
-### Building a package
+1. Open a terminal and navigate to your project's root folder.
+1. Generate a new recipe by running `conan new` with a package name and version:
-In your terminal, navigate to the root folder of your project. Generate a new recipe by running `conan new` and providing it with a
-package name and version:
+ ```shell
+ conan new Hello/0.1 -t
+ ```
-```shell
-conan new Hello/0.1 -t
-```
-
-Next, create a package for that recipe by running `conan create` providing the Conan user and channel:
+1. Create a package for the recipe by running `conan create` with the Conan user and channel:
-```shell
-conan create . mycompany/beta
-```
+ ```shell
+ conan create . mycompany/beta
+ ```
-NOTE: **Note:**
-If you are using the [instance level remote](#instance-level-remote), a specific [naming convention](#package-recipe-naming-convention-for-instance-level-remote) must be followed.
+ NOTE: **Note:**
+ If you use an [instance remote](#add-a-remote-for-your-instance), you must follow a specific [naming convention](#package-recipe-naming-convention-for-instance-remotes).
-These two example commands generate a final package with the recipe `Hello/0.1@mycompany/beta`.
+A package with the recipe `Hello/0.1@mycompany/beta` is created.
-For more advanced details on creating and managing your packages, refer to the [Conan docs](https://docs.conan.io/en/latest/creating_packages.html).
+For more details on creating and managing Conan packages, see the [Conan docs](https://docs.conan.io/en/latest/creating_packages.html).
-You are now ready to upload your package to the GitLab registry. To get started, first you need to set GitLab as a remote. Then you need to add a Conan user for that remote to authenticate your requests.
+## Add the Package Registry as a Conan remote
-## Adding the GitLab Package Registry as a Conan remote
+To run `conan` commands, you must add the Package Registry as a Conan remote for your project or instance.
-You can add the GitLab Package Registry as a Conan remote at the project or instance level.
-
-### Project level remote
+### Add a remote for your project
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/11679) in GitLab 13.4.
-The project level remote allows you to work with packages within a given project.
-The advantage of using the project level remote is there are no restrictions to your
-package name, however all GitLab Conan packages require a full recipe
-with the user and channel (`package_name/version@user/channel`).
+Set a remote so you can work with packages in a project without
+having to specify the remote name in every command.
+
+When you set a remote for a project, there are no restrictions to your package names.
+However, your commands must include the full recipe, including the user and channel,
+for example, `package_name/version@user/channel`.
To add the remote:
-```shell
-conan remote add gitlab https://gitlab.example.com/api/v4/projects/<project_id>/packages/conan
-```
+1. In your terminal, run this command:
-Once the remote is set, you can use the remote when running Conan commands by adding `--remote=gitlab` to the end of your commands.
+ ```shell
+ conan remote add gitlab https://gitlab.example.com/api/v4/projects/<project_id>/packages/conan
+ ```
-For example:
+1. Use the remote by adding `--remote=gitlab` to the end of your Conan command.
-```shell
-conan search Hello* --all --remote=gitlab
-```
+ For example:
-### Instance level remote
+ ```shell
+ conan search Hello* --all --remote=gitlab
+ ```
-The instance level remote allows you to use a single remote to access packages accross your entire
-GitLab instance. However, when using this remote, there are certain
-[package naming restrictions](#package-recipe-naming-convention-for-instance-level-remote)
-that must be followed.
+### Add a remote for your instance
-Add a new remote to your Conan configuration:
+Use a single remote to access packages across your entire GitLab instance.
-```shell
-conan remote add gitlab https://gitlab.example.com/api/v4/packages/conan
-```
+However, when using this remote, you must follow these
+[package naming restrictions](#package-recipe-naming-convention-for-instance-remotes).
+
+To add the remote:
-Once the remote is set, you can use the remote when running Conan commands by adding `--remote=gitlab` to the end of your commands.
+1. In your terminal, run this command:
-For example:
+ ```shell
+ conan remote add gitlab https://gitlab.example.com/api/v4/packages/conan
+ ```
-```shell
-conan search 'Hello*' --remote=gitlab
-```
+1. Use the remote by adding `--remote=gitlab` to the end of your Conan command.
-#### Package recipe naming convention for instance level remote
+ For example:
-The standard Conan recipe convention looks like `package_name/version@user/channel`,
-but if you're using the [instance level remote](#instance-level-remote), the recipe
+ ```shell
+ conan search 'Hello*' --remote=gitlab
+ ```
+
+#### Package recipe naming convention for instance remotes
+
+The standard Conan recipe convention is `package_name/version@user/channel`,
+but if you're using an [instance remote](#add-a-remote-for-your-instance), the recipe
`user` must be the plus sign (`+`) separated project path.
-The following table shows some example recipes you can give your package based on
-the project name and path.
+Example recipe names:
| Project | Package | Supported |
| ---------------------------------- | ----------------------------------------------- | --------- |
@@ -159,179 +156,202 @@ the project name and path.
| `gitlab-org/gitlab-ce` | `my-package/1.0.0@gitlab-org+gitlab-ce/stable` | Yes |
| `gitlab-org/gitlab-ce` | `my-package/1.0.0@foo/stable` | No |
-NOTE: **Note:**
-[Project level remotes](#project-level-remote) allow for more flexible package names.
+[Project remotes](#add-a-remote-for-your-project) have a more flexible naming convention.
-## Authenticating to the GitLab Conan Repository
+## Authenticate to the Package Registry
-You need a personal access token or deploy token.
+To authenticate to the Package Registry, you need either a personal access token or deploy token.
-For repository authentication:
+- If you use a [personal access token](../../../user/profile/personal_access_tokens.md), set the scope to `api`.
+- If you use a [deploy token](./../../project/deploy_tokens/index.md), set the scope to `read_package_registry`, `write_package_registry`, or both.
-- You can generate a [personal access token](../../../user/profile/personal_access_tokens.md) with the scope set to `api`.
-- You can generate a [deploy token](./../../project/deploy_tokens/index.md) with the scope set to `read_package_registry`, `write_package_registry`, or both.
+### Add your credentials to the GitLab remote
-### Adding a Conan user to the GitLab remote
+Associate your token with the GitLab remote, so that you don't have to explicitly
+add a token to every Conan command.
-Once you have a personal access token and have [set your Conan remote](#adding-the-gitlab-package-registry-as-a-conan-remote), you can associate the token with the remote so you don't have to explicitly add them to each Conan command you run:
+Prerequisites:
+
+- You must have an authentication token.
+- The Conan remote [must be set](#add-the-package-registry-as-a-conan-remote).
+
+In a terminal, run this command. In this example, the remote name is `gitlab`. Use the name of your remote.
```shell
conan user <gitlab_username or deploy_token_username> -r gitlab -p <personal_access_token or deploy_token>
```
-NOTE: **Note:**
-If you named your remote something other than `gitlab`, your remote name should be used in this command instead of `gitlab`.
-
-From now on, when you run commands using `--remote=gitlab`, your username and password is automatically included in the requests.
-
-NOTE: **Note:**
-The personal access token is not stored locally at any moment. Conan uses JSON Web Tokens (JWT), so when you run this command, Conan requests an expirable token from GitLab using your token. The JWT does expire on a regular basis, so you need to re-enter your personal access token when that happens.
+Now when you run commands with `--remote=gitlab`, your username and password are automatically included in the requests.
-Alternatively, you could explicitly include your credentials in any given command.
-For example:
+Alternately, you can explicitly include your credentials in any given command. For example:
```shell
CONAN_LOGIN_USERNAME=<gitlab_username or deploy_token_username> CONAN_PASSWORD=<personal_access_token or deploy_token> conan upload Hello/0.1@mycompany/beta --all --remote=gitlab
```
-### Setting a default remote to your project (optional)
+NOTE: **Note:**
+Your authentication with GitLab expires on a regular basis,
+so occasionally you may need to re-enter your personal access token.
+
+### Set a default remote for your project (optional)
+
+If you want to interact with the GitLab Package Registry without having to specify a remote,
+you can tell Conan to always use the Package Registry for your packages.
-If you'd like Conan to always use GitLab as the registry for your package, you can tell Conan to always reference the GitLab remote for a given package recipe:
+In a terminal, run this command.
```shell
conan remote add_ref Hello/0.1@mycompany/beta gitlab
```
NOTE: **Note:**
-The package recipe does include the version, so setting the default remote for `Hello/0.1@user/channel` will not work for `Hello/0.2@user/channel`.
-This functionality is best suited for when you want to consume or install packages from the GitLab registry without having to specify a remote.
+The package recipe includes the version, so the default remote for `Hello/0.1@user/channel` does not work for `Hello/0.2@user/channel`.
-The rest of the example commands in this documentation assume that you've added a Conan user with your credentials to the `gitlab` remote and will not include the explicit credentials or remote option. With that said, be aware that any of the commands could be run without having added a user or default remote:
+If you do not set a default user or remote, you can still include the user and remote in your commands:
```shell
`CONAN_LOGIN_USERNAME=<gitlab_username or deploy_token_username> CONAN_PASSWORD=<personal_access_token or deploy_token> <conan command> --remote=gitlab
```
-## Uploading a package
+## Publish a Conan package
-First you need to [create your Conan package locally](https://docs.conan.io/en/latest/creating_packages/getting_started.html).
+Publish a Conan package to the Package Registry, so that anyone who can access the project can use the package as a dependency.
-NOTE: **Note:**
-If you are using the [instance level remote](#instance-level-remote), a specific [naming convention](#package-recipe-naming-convention-for-instance-level-remote) must be followed.
+Prerequisites:
+
+To publish a Conan package, you need:
-Ensure you have a project created on GitLab and that the personal access token you're using has the correct permissions for write access to the container registry by selecting the `api` [scope](../../../user/profile/personal_access_tokens.md#limiting-scopes-of-a-personal-access-token).
+- The Package Registry [set as a remote](#add-the-package-registry-as-a-conan-remote).
+- [Authentication](#authenticate-to-the-package-registry) set up with the Package Registry.
+- A local [Conan package](https://docs.conan.io/en/latest/creating_packages/getting_started.html).
+ - For an instance remote, the package must meet the [naming convention](#package-recipe-naming-convention-for-instance-remotes).
+- A project ID, which is on the project's homepage.
-You can upload your package to the GitLab Package Registry using the `conan upload` command:
+To publish the package, use the `conan upload` command:
```shell
conan upload Hello/0.1@mycompany/beta --all
```
-## Installing a package
+## Publish a Conan package by using CI/CD
-Conan packages are commonly installed as dependencies using the `conanfile.txt` file.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/11678) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.7.
-In your project where you would like to install the Conan package as a dependency, open `conanfile.txt` or create
-an empty file named `conanfile.txt` in the root of your project.
+To work with Conan commands in [GitLab CI/CD](./../../../ci/README.md), you can use
+`CI_JOB_TOKEN` in place of the personal access token in your commands.
-Add the Conan recipe to the `[requires]` section of the file:
+You can provide the `CONAN_LOGIN_USERNAME` and `CONAN_PASSWORD` with each
+Conan command in your `.gitlab-ci.yml` file. For example:
-```ini
- [requires]
- Hello/0.1@mycompany/beta
+```yaml
+image: conanio/gcc7
- [generators]
- cmake
+create_package:
+ stage: deploy
+ script:
+ - conan remote add gitlab https://gitlab.example.com/api/v4/packages/conan
+ - conan new <package-name>/0.1 -t
+ - conan create . <group-name>+<project-name>/stable
+ - CONAN_LOGIN_USERNAME=ci_user CONAN_PASSWORD=${CI_JOB_TOKEN} conan upload <package-name>/0.1@<group-name>+<project-name>/stable --all --remote=gitlab
```
-Next, create a build directory from the root of your project and navigate to it:
+Additional Conan images to use as the basis of your CI file are available
+in the [Conan docs](https://docs.conan.io/en/latest/howtos/run_conan_in_docker.html#available-docker-images).
-```shell
-mkdir build && cd build
-```
+## Install a Conan package
-Now you can install the dependencies listed in `conanfile.txt`:
+Install a Conan package from the Package Registry so you can use it as a dependency.
-```shell
-conan install ..
-```
+Conan packages are often installed as dependencies by using the `conanfile.txt` file.
+
+Prerequisites:
+
+To install a Conan package, you need:
+
+- The Package Registry [set as a remote](#add-the-package-registry-as-a-conan-remote).
+- [Authentication](#authenticate-to-the-package-registry) set up with the Package Registry.
+
+1. In the project where you want to install the package as a dependency, open `conanfile.txt`.
+ Or, in the root of your project, create a file called `conanfile.txt`.
+
+1. Add the Conan recipe to the `[requires]` section of the file:
+
+ ```ini
+ [requires]
+ Hello/0.1@mycompany/beta
+
+ [generators]
+ cmake
+ ```
+
+1. At the root of your project, create a `build` directory and change to that directory:
+
+ ```shell
+ mkdir build && cd build
+ ```
+
+1. Install the dependencies listed in `conanfile.txt`:
+
+ ```shell
+ conan install <options>
+ ```
NOTE: **Note:**
-If you're trying to install the package you just created in this tutorial, not much will happen since that package
-already exists on your local machine.
+If you try to install the package you just created in this tutorial, the package
+already exists on your local machine, so this command has no effect.
-## Removing a package
+## Remove a Conan package
There are two ways to remove a Conan package from the GitLab Package Registry.
-- **Using the Conan client in the command line:**
+- From the command line, using the Conan client:
```shell
conan remove Hello/0.2@user/channel --remote=gitlab
```
- You need to explicitly include the remote in this command, otherwise the package is only removed from your
+ You must explicitly include the remote in this command, otherwise the package is only removed from your
local system cache.
NOTE: **Note:**
This command removes all recipe and binary package files from the Package Registry.
-- **GitLab project interface**: in the packages view of your project page, you can delete packages by clicking the red trash icons.
+- From the GitLab user interface:
-## Searching the GitLab Package Registry for Conan packages
+ Go to your project's **Packages & Registries > Package Registry**. Remove the package by clicking the red trash icon.
-The `conan search` command can be run searching by full or partial package name, or by exact recipe.
+## Search for Conan packages in the Package Registry
-To search using a partial name, use the wildcard symbol `*`, which should be placed at the end of your search (for example, `my-packa*`):
+To search by full or partial package name, or by exact recipe, run the `conan search` command.
-```shell
-conan search Hello --all --remote=gitlab
-conan search He* --all --remote=gitlab
-conan search Hello/0.1@mycompany/beta --all --remote=gitlab
-```
+- To search for all packages with a specific package name:
+
+ ```shell
+ conan search Hello --remote=gitlab
+ ```
+
+- To search for a partial name, like all packages starting with `He`:
+
+ ```shell
+ conan search He* --remote=gitlab
+ ```
-The scope of your search includes all projects you have permission to access, this includes your private projects as well as all public projects.
+The scope of your search includes all projects you have permission to access. This includes your private projects as well as all public projects.
-## Fetching Conan package information from the GitLab Package Registry
+## Fetch Conan package information from the Package Registry
-The `conan info` command returns information about a given package:
+The `conan info` command returns information about a package:
```shell
conan info Hello/0.1@mycompany/beta
```
-## List of supported CLI commands
+## Supported CLI commands
The GitLab Conan repository supports the following Conan CLI commands:
-- `conan upload`: Upload your recipe and package files to the GitLab Package Registry.
-- `conan install`: Install a conan package from the GitLab Package Registry, this includes using the `conanfile.txt` file.
-- `conan search`: Search the GitLab Package Registry for public packages, and private packages you have permission to view.
-- `conan info`: View the information on a given package from the GitLab Package Registry.
-- `conan remove`: Delete the package from the GitLab Package Registry.
-
-## Using GitLab CI with Conan packages
-
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/11678) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.7.
-
-To work with Conan commands within [GitLab CI/CD](./../../../ci/README.md), you can use
-`CI_JOB_TOKEN` in place of the personal access token in your commands.
-
-It's easiest to provide the `CONAN_LOGIN_USERNAME` and `CONAN_PASSWORD` with each
-Conan command in your `.gitlab-ci.yml` file. For example:
-
-```yaml
-image: conanio/gcc7
-
-create_package:
- stage: deploy
- script:
- - conan remote add gitlab https://gitlab.example.com/api/v4/packages/conan
- - conan new <package-name>/0.1 -t
- - conan create . <group-name>+<project-name>/stable
- - CONAN_LOGIN_USERNAME=ci_user CONAN_PASSWORD=${CI_JOB_TOKEN} conan upload <package-name>/0.1@<group-name>+<project-name>/stable --all --remote=gitlab
-
-```
-
-You can find additional Conan images to use as the base of your CI file
-in the [Conan docs](https://docs.conan.io/en/latest/howtos/run_conan_in_docker.html#available-docker-images).
+- `conan upload`: Upload your recipe and package files to the Package Registry.
+- `conan install`: Install a Conan package from the Package Registry, this includes using the `conanfile.txt` file.
+- `conan search`: Search the Package Registry for public packages, and private packages you have permission to view.
+- `conan info`: View the information on a given package from the Package Registry.
+- `conan remove`: Delete the package from the Package Registry.
diff --git a/doc/user/packages/container_registry/img/container_registry_group_repositories_v13_1.png b/doc/user/packages/container_registry/img/container_registry_group_repositories_v13_1.png
deleted file mode 100644
index bbbba44eb9b..00000000000
--- a/doc/user/packages/container_registry/img/container_registry_group_repositories_v13_1.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/packages/container_registry/img/container_registry_hover_path_13_4.png b/doc/user/packages/container_registry/img/container_registry_hover_path_13_4.png
new file mode 100644
index 00000000000..2d16c11fe61
--- /dev/null
+++ b/doc/user/packages/container_registry/img/container_registry_hover_path_13_4.png
Binary files differ
diff --git a/doc/user/packages/container_registry/img/container_registry_repositories_v13_1.png b/doc/user/packages/container_registry/img/container_registry_repositories_v13_1.png
deleted file mode 100644
index 13a6d1a4470..00000000000
--- a/doc/user/packages/container_registry/img/container_registry_repositories_v13_1.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/packages/container_registry/img/container_registry_repositories_with_quickstart_v13_1.png b/doc/user/packages/container_registry/img/container_registry_repositories_with_quickstart_v13_1.png
deleted file mode 100644
index 35a02182a77..00000000000
--- a/doc/user/packages/container_registry/img/container_registry_repositories_with_quickstart_v13_1.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/packages/container_registry/img/container_registry_repository_details_v13.0.png b/doc/user/packages/container_registry/img/container_registry_repository_details_v13.0.png
deleted file mode 100644
index 088e80221de..00000000000
--- a/doc/user/packages/container_registry/img/container_registry_repository_details_v13.0.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/packages/container_registry/index.md b/doc/user/packages/container_registry/index.md
index 077666bc036..690db3986f1 100644
--- a/doc/user/packages/container_registry/index.md
+++ b/doc/user/packages/container_registry/index.md
@@ -16,100 +16,44 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> - The group level Container Registry was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/23315) in GitLab 12.10.
> - Searching by image repository name was [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/31322) in GitLab 13.0.
-NOTE: **Note:**
-This document is the user guide. To learn how to enable GitLab Container
-Registry across your GitLab instance, visit the
-[administrator documentation](../../../administration/packages/container_registry.md).
-
-With the Docker Container Registry integrated into GitLab, every project can
+With the Docker Container Registry integrated into GitLab, every GitLab project can
have its own space to store its Docker images.
You can read more about Docker Registry at <https://docs.docker.com/registry/introduction/>.
-![Container Registry repositories](img/container_registry_repositories_v13_1.png)
-
-## Enable the Container Registry for your project
-
-CAUTION: **Warning:**
-The Container Registry follows the visibility settings of the project. If the project is public, so is the Container Registry.
-
-If you cannot find the **Packages & Registries > Container Registry** entry under your
-project's sidebar, it is not enabled in your GitLab instance. Ask your
-administrator to enable GitLab Container Registry following the
-[administration documentation](../../../administration/packages/container_registry.md).
-
-If you are using GitLab.com, this is enabled by default so you can start using
-the Registry immediately. Currently there is a soft (10GB) size restriction for
-Registry on GitLab.com, as part of the [repository size limit](../../project/repository/index.md).
-
-Once enabled for your GitLab instance, to enable Container Registry for your
-project:
-
-1. Go to your project's **Settings > General** page.
-1. Expand the **Visibility, project features, permissions** section
- and enable the **Container Registry** feature on your project. For new
- projects this might be enabled by default. For existing projects
- (prior GitLab 8.8), enable it explicitly.
-1. Press **Save changes** for the changes to take effect. You should now be able
- to see the **Packages & Registries > Container Registry** link in the sidebar.
-
-## Control Container Registry from within GitLab
-
-GitLab offers a simple Container Registry management panel. This management panel is available
-for both projects and groups.
-
-### Control Container Registry for your project
-
-Navigate to your project's **{package}** **Packages & Registries > Container Registry**.
-
-![Container Registry project repositories](img/container_registry_repositories_with_quickstart_v13_1.png)
-
-This view allows you to:
-
-- Show all the image repositories that belong to the project.
-- Filter image repositories by their name.
-- [Delete](#delete-images-from-within-gitlab) one or more image repository.
-- Navigate to the image repository details page.
-- Show a **Quick start** dropdown with the most common commands to log in, build and push.
-- Show a banner if the optional [cleanup policy](#cleanup-policy) is enabled for this project.
-
-### Control Container Registry for your group
-
-Navigate to your group's **{package}** **Packages & Registries > Container Registry**.
-
-![Container Registry group repositories](img/container_registry_group_repositories_v13_1.png)
+NOTE: **Note:**
+This document is the user guide. To learn how to enable the Container
+Registry for your GitLab instance, visit the
+[administrator documentation](../../../administration/packages/container_registry.md).
-This view allows you to:
+## View the Container Registry
-- Show all the image repositories of the projects that belong to this group.
-- [Delete](#delete-images-from-within-gitlab) one or more image repositories.
-- Navigate to a specific image repository details page.
+You can view the Container Registry for a project or group.
-### Image Repository details page
+1. Go to your project or group.
+1. Go to **Packages & Registries > Container Registry**.
-Clicking on the name of any image repository navigates to the details.
+You can search, sort, filter, and [delete](#delete-images-from-within-gitlab) containers on this page.
-![Container Registry project repository details](img/container_registry_repository_details_v13.0.png)
+CAUTION: **Warning:**
+If a project is public, so is the Container Registry.
-NOTE: **Note:**
-The following page has the same functionalities both in the **Group level container registry**
-and in the **Project level container registry**.
+## Use images from the Container Registry
-This view:
+To download and run a container image hosted in the GitLab Container Registry:
-- Shows all the image repository details.
-- Shows all the tags of the image repository.
-- Allows you to quickly copy the tag path (by clicking on the clipboard button near the tag name).
-- Allows you to [delete one or more tags](#delete-images-from-within-gitlab).
+1. Copy the link to your container image:
+ - Go to your project or group's **Packages & Registries > Container Registry**
+ and find the image you want.
+ - Next to the image name, click the **Copy** button.
-## Use images from GitLab Container Registry
+ ![Container Registry image URL](img/container_registry_hover_path_13_4.png)
-To download and run a container from images hosted in GitLab Container Registry,
-use `docker run`:
+1. Use `docker run` with the image link:
-```shell
-docker run [options] registry.example.com/group/project/image [arguments]
-```
+ ```shell
+ docker run [options] registry.example.com/group/project/image [arguments]
+ ```
For more information on running Docker containers, visit the
[Docker documentation](https://docs.docker.com/engine/userguide/intro/).
@@ -118,10 +62,10 @@ For more information on running Docker containers, visit the
If you visit the **Packages & Registries > Container Registry** link under your project's
menu, you can see the explicit instructions to login to the Container Registry
-using your GitLab credentials.
+by using your GitLab credentials.
For example if the Registry's URL is `registry.example.com`, then you should be
-able to login with:
+able to log in with:
```shell
docker login registry.example.com
@@ -175,6 +119,10 @@ registry.example.com/group/project/image:latest
registry.example.com/group/project/my/image:rc1
```
+NOTE: **Note:**
+Currently there is a soft (10GB) size restriction for
+the Container Registry on GitLab.com, as part of the [repository size limit](../../project/repository/index.md).
+
## Build and push images using GitLab CI/CD
While you can build and push your images from your local machine, take
@@ -183,7 +131,7 @@ You can then create workflows and automate any processes that involve testing,
building, and eventually deploying your project from the Docker image you
created.
-Before diving into the details, some things you should be aware of:
+Before diving into details, some things you should be aware of:
- You must [authenticate to the container registry](#authenticating-to-the-container-registry-with-gitlab-cicd)
before running any commands. You can do this in the `before_script` if multiple
@@ -359,15 +307,15 @@ in addition to the steps in the
Below is an example of what your `.gitlab-ci.yml` should look like:
```yaml
- build:
- image: $CI_REGISTRY/group/project/docker:19.03.12
- services:
- - name: $CI_REGISTRY/group/project/docker:19.03.12-dind
- alias: docker
- stage: build
- script:
- - docker build -t my-docker-image .
- - docker run my-docker-image /script/to/run/tests
+build:
+ image: $CI_REGISTRY/group/project/docker:19.03.12
+ services:
+ - name: $CI_REGISTRY/group/project/docker:19.03.12-dind
+ alias: docker
+ stage: build
+ script:
+ - docker build -t my-docker-image .
+ - docker run my-docker-image /script/to/run/tests
```
If you forget to set the service alias, the `docker:19.03.12` image is unable to find the
@@ -394,7 +342,7 @@ the deleted images.
To delete images from within GitLab:
-1. Navigate to your project's or group's **{package}** **Packages & Registries > Container Registry**.
+1. Navigate to your project's or group's **Packages & Registries > Container Registry**.
1. From the **Container Registry** page, you can select what you want to delete,
by either:
@@ -406,8 +354,6 @@ To delete images from within GitLab:
1. In the dialog box, click **Remove tag**.
- ![Container Registry tags](img/container_registry_repository_details_v13.0.png)
-
### Delete images using the API
If you want to automate the process of deleting images, GitLab provides an API. For more
@@ -512,6 +458,11 @@ Cleanup policies can be run on all projects, with these exceptions:
for all projects (even those created before 12.8) in
[GitLab application settings](../../../api/settings.md#change-application-settings)
by setting `container_expiration_policies_enable_historic_entries` to true.
+ Alternatively, you can execute the following command in the [Rails console](../../../administration/troubleshooting/navigating_gitlab_via_rails_console.md#starting-a-rails-console-session):
+
+ ```ruby
+ ApplicationSetting.last.update(container_expiration_policies_enable_historic_entries: true)
+ ```
There are performance risks with enabling it for all projects, especially if you
are using an [external registry](./index.md#use-with-external-container-registries).
@@ -636,7 +587,7 @@ you can use the Container Registry to store Helm Charts. However, due to the way
and stored by Docker, it is not possible for GitLab to parse this data and meet performance standards.
[This epic](https://gitlab.com/groups/gitlab-org/-/epics/2313) updates the architecture of the Container Registry to support Helm Charts.
-You can read more about the above challenges [here](https://gitlab.com/gitlab-org/gitlab/-/issues/38047#note_298842890).
+[Read more about the above challenges](https://gitlab.com/gitlab-org/gitlab/-/issues/38047#note_298842890).
## Limitations
@@ -647,6 +598,19 @@ Container Registry, you must delete all existing images.
- Prior to GitLab 12.10, any tags that use the same image ID as the `latest` tag
are not deleted by the cleanup policy.
+## Disable the Container Registry for a project
+
+The Container Registry is enabled by default.
+
+You can, however, remove the Container Registry for a project:
+
+1. Go to your project's **Settings > General** page.
+1. Expand the **Visibility, project features, permissions** section
+ and disable **Container Registry**.
+1. Click **Save changes**.
+
+The **Packages & Registries > Container Registry** entry is removed from the project's sidebar.
+
## Troubleshooting the GitLab Container Registry
### Docker connection error
@@ -661,6 +625,13 @@ To get around this, you can [change the group path](../../group/index.md#changin
[change the project path](../../project/settings/index.md#renaming-a-repository) or change the branch
name.
+You may also get a `404 Not Found` or `Unknown Manifest` message if you are using
+a Docker Engine version earlier than 17.12. Later versions of Docker Engine use
+[the v2 API](https://docs.docker.com/registry/spec/manifest-v2-2/).
+
+The images in your GitLab Container Registry must also use the Docker v2 API.
+For information on how to update your images, see the [Docker help](https://docs.docker.com/registry/spec/deprecated-schema-v1).
+
### Troubleshoot as a GitLab server admin
Troubleshooting the GitLab Container Registry, most of the times, requires
diff --git a/doc/user/packages/dependency_proxy/img/group_dependency_proxy.png b/doc/user/packages/dependency_proxy/img/group_dependency_proxy.png
deleted file mode 100644
index e550d296d5a..00000000000
--- a/doc/user/packages/dependency_proxy/img/group_dependency_proxy.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/packages/dependency_proxy/index.md b/doc/user/packages/dependency_proxy/index.md
index e3ee8909165..3fa21ef486b 100644
--- a/doc/user/packages/dependency_proxy/index.md
+++ b/doc/user/packages/dependency_proxy/index.md
@@ -8,80 +8,80 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/7934) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.11.
-NOTE: **Note:**
-This is the user guide. In order to use the dependency proxy, an administrator
-must first [configure it](../../../administration/packages/dependency_proxy.md).
+The GitLab Dependency Proxy is a local proxy you can use for your frequently-accessed
+upstream images.
-For many organizations, it is desirable to have a local proxy for frequently used
-upstream images/packages. In the case of CI/CD, the proxy is responsible for
-receiving a request and returning the upstream image from a registry, acting
-as a pull-through cache.
+In the case of CI/CD, the Dependency Proxy receives a request and returns the
+upstream image from a registry, acting as a pull-through cache.
-The dependency proxy is available in the group level. To access it, navigate to
-a group's **Packages & Registries > Dependency Proxy**.
+## Prerequisites
-![Dependency Proxy group page](img/group_dependency_proxy.png)
+To use the Dependency Proxy:
-## Supported dependency proxies
+- Your group must be public. Authentication for private groups is [not supported yet](https://gitlab.com/gitlab-org/gitlab/-/issues/11582).
-NOTE: **Note:**
-For a list of the upcoming additions to the proxies, visit the
-[direction page](https://about.gitlab.com/direction/package/dependency_proxy/#top-vision-items).
+### Supported images and packages
-The following dependency proxies are supported.
+The following images and packages are supported.
-| Dependency proxy | GitLab version |
+| Image/Package | GitLab version |
| ---------------- | -------------- |
| Docker | 11.11+ |
-## Using the Docker dependency proxy
+For a list of planned additions, view the
+[direction page](https://about.gitlab.com/direction/package/dependency_proxy/#top-vision-items).
+
+## View the Dependency Proxy
+
+To view the Dependency Proxy:
+
+- Go to your group's **Packages & Registries > Dependency Proxy**.
+
+The Dependency Proxy is not available for projects.
+
+## Use the Dependency Proxy for Docker images
+
+You can use GitLab as a source for your Docker images.
-With the Docker dependency proxy, you can use GitLab as a source for a Docker image.
-To get a Docker image into the dependency proxy:
+Prerequisites:
-1. Find the proxy URL on your group's page under **Packages & Registries > Dependency Proxy**,
- for example `gitlab.com/groupname/dependency_proxy/containers`.
-1. Trigger GitLab to pull the Docker image you want (e.g., `alpine:latest` or
- `linuxserver/nextcloud:latest`) and store it in the proxy storage by using
- one of the following ways:
+- Your images must be stored on [Docker Hub](https://hub.docker.com/).
+- Docker Hub must be available. Follow [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/241639)
+ for progress on accessing images when Docker Hub is down.
- - Manually pulling the Docker image:
+To store a Docker image in Dependency Proxy storage:
+
+1. Go to your group's **Packages & Registries > Dependency Proxy**.
+1. Copy the **Dependency Proxy URL**.
+1. Use one of these commands. In these examples, the image is `alpine:latest`.
+
+ - Add the URL to your [`.gitlab-ci.yml`](../../../ci/yaml/README.md#image) file:
```shell
- docker pull gitlab.com/groupname/dependency_proxy/containers/alpine:latest
+ image: gitlab.example.com/groupname/dependency_proxy/containers/alpine:latest
```
- - From a `Dockerfile`:
+ - Manually pull the Docker image:
```shell
- FROM gitlab.com/groupname/dependency_proxy/containers/alpine:latest
+ docker pull gitlab.example.com/groupname/dependency_proxy/containers/alpine:latest
```
- - In [`.gitlab-ci.yml`](../../../ci/yaml/README.md#image):
+ - Add the URL to a `Dockerfile`:
```shell
- image: gitlab.com/groupname/dependency_proxy/containers/alpine:latest
+ FROM gitlab.example.com/groupname/dependency_proxy/containers/alpine:latest
```
GitLab pulls the Docker image from Docker Hub and caches the blobs
on the GitLab server. The next time you pull the same image, GitLab gets the latest
-information about the image from Docker Hub but serves the existing blobs
+information about the image from Docker Hub, but serves the existing blobs
from the GitLab server.
-The blobs are kept forever, and there is no hard limit on how much data can be
-stored.
-
-## Clearing the cache
+## Clear the Dependency Proxy cache
-It is possible to use the GitLab API to purge the dependency proxy cache for a
-given group to gain back disk space that may be taken up by image blobs that
-are no longer needed. See the [dependency proxy API documentation](../../../api/dependency_proxy.md)
-for more details.
-
-## Limitations
-
-The following limitations apply:
+Blobs are kept forever on the GitLab server, and there is no hard limit on how much data can be
+stored.
-- Only [public groups are supported](https://gitlab.com/gitlab-org/gitlab/-/issues/11582) (authentication is not supported yet).
-- Only Docker Hub is supported.
-- This feature requires Docker Hub being available.
+To reclaim disk space used by image blobs that are no longer needed, use
+the [Dependency Proxy API](../../../api/dependency_proxy.md).
diff --git a/doc/user/packages/go_proxy/index.md b/doc/user/packages/go_proxy/index.md
index edf1528a751..bd3b5b49ebd 100644
--- a/doc/user/packages/go_proxy/index.md
+++ b/doc/user/packages/go_proxy/index.md
@@ -50,7 +50,7 @@ Feature.disable(:go_proxy, Project.find(2))
### Enable the Package Registry
The Package Registry is enabled for new projects by default. If you cannot find
-the **{package}** **Packages > List** entry under your project's sidebar, verify
+the **Packages > List** entry under your project's sidebar, verify
the following:
1. Your GitLab administrator has
diff --git a/doc/user/packages/npm_registry/index.md b/doc/user/packages/npm_registry/index.md
index 2a1c12c2afd..c1b2b16e39d 100644
--- a/doc/user/packages/npm_registry/index.md
+++ b/doc/user/packages/npm_registry/index.md
@@ -286,6 +286,22 @@ By default, when an NPM package is not found in the GitLab NPM Registry, the req
Administrators can disable this behavior in the [Continuous Integration settings](../../admin_area/settings/continuous_integration.md).
+### Installing packages from other organizations
+
+You can route package requests to organizations and users outside of GitLab.
+
+To do this, add lines to your `.npmrc` file, replacing `my-org` with the namespace or group that owns your project's repository. The name is case-sensitive and must match the name of your group or namespace exactly.
+
+```shell
+@foo:registry=https://gitlab.example.com/api/v4/packages/npm/
+//gitlab.com/api/v4/packages/npm/:_authToken= "<your_token>"
+//gitlab.com/api/v4/projects/<your_project_id>/packages/npm/:_authToken= "<your_token>"
+
+@my-other-org:registry=https://gitlab.example.com/api/v4/packages/npm/
+//gitlab.com/api/v4/packages/npm/:_authToken= "<your_token>"
+//gitlab.com/api/v4/projects/<your_project_id>/packages/npm/:_authToken= "<your_token>"
+```
+
## Removing a package
In the packages view of your project page, you can delete packages by clicking
@@ -418,5 +434,8 @@ npm dist-tag rm @scope/package@version my-tag # Delete a tag from the package
npm install @scope/package@my-tag # Install a specific tag
```
+NOTE: **Note:**
+You cannot use your `CI_JOB_TOKEN` or deploy token with the `npm dist-tag` commands. View [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/258835) for details.
+
CAUTION: **Warning:**
Due to a bug in NPM 6.9.0, deleting dist tags fails. Make sure your NPM version is greater than 6.9.1.
diff --git a/doc/user/packages/nuget_repository/index.md b/doc/user/packages/nuget_repository/index.md
index 9fb50ce71fb..c40db409903 100644
--- a/doc/user/packages/nuget_repository/index.md
+++ b/doc/user/packages/nuget_repository/index.md
@@ -250,21 +250,21 @@ is updated:
1. Add a `deploy` job to your `.gitlab-ci.yml` file:
```yaml
- image: mcr.microsoft.com/dotnet/core/sdk:3.1
-
- stages:
- - deploy
-
- deploy:
- stage: deploy
- script:
- - dotnet restore -p:Configuration=Release
- - dotnet build -c Release
- - dotnet pack -c Release
- - dotnet nuget add source "$CI_SERVER_URL/api/v4/projects/$CI_PROJECT_ID/packages/nuget/index.json" --name gitlab --username gitlab-ci-token --password $CI_JOB_TOKEN --store-password-in-clear-text
- - dotnet nuget push "bin/Release/*.nupkg" --source gitlab
- only:
- - master
+ image: mcr.microsoft.com/dotnet/core/sdk:3.1
+
+ stages:
+ - deploy
+
+ deploy:
+ stage: deploy
+ script:
+ - dotnet restore -p:Configuration=Release
+ - dotnet build -c Release
+ - dotnet pack -c Release
+ - dotnet nuget add source "$CI_SERVER_URL/api/v4/projects/$CI_PROJECT_ID/packages/nuget/index.json" --name gitlab --username gitlab-ci-token --password $CI_JOB_TOKEN --store-password-in-clear-text
+ - dotnet nuget push "bin/Release/*.nupkg" --source gitlab
+ only:
+ - master
```
1. Commit the changes and push it to your GitLab repository to trigger a new CI build.
diff --git a/doc/user/packages/package_registry/index.md b/doc/user/packages/package_registry/index.md
index 9f954627b05..c4c00f39bae 100644
--- a/doc/user/packages/package_registry/index.md
+++ b/doc/user/packages/package_registry/index.md
@@ -17,7 +17,7 @@ packages, which can be easily consumed as a dependency in downstream projects.
You can view packages for your project or group.
1. Go to the project or group.
-1. Go to **{package}** **Packages & Registries > Package Registry**.
+1. Go to **Packages & Registries > Package Registry**.
You can search, sort, and filter packages on this page.
@@ -31,7 +31,7 @@ authenticate with GitLab by using the `CI_JOB_TOKEN`.
CI/CD templates, which you can use to get started, are in [this repo](https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/ci/templates).
-Learn more about [using CI/CD to build Maven packages](../maven_repository/index.md#creating-maven-packages-with-gitlab-cicd), [NPM packages](../npm_registry/index.md#publishing-a-package-with-cicd), [Composer packages](../composer_repository/index.md#publishing-the-package-with-cicd), [NuGet Packages](../nuget_repository/index.md#publishing-a-nuget-package-with-cicd), [Conan Packages](../conan_repository/index.md#using-gitlab-ci-with-conan-packages), and [PyPI packages](../pypi_repository/index.md#using-gitlab-ci-with-pypi-packages).
+Learn more about [using CI/CD to build Maven packages](../maven_repository/index.md#creating-maven-packages-with-gitlab-cicd), [NPM packages](../npm_registry/index.md#publishing-a-package-with-cicd), [Composer packages](../composer_repository/index.md#publish-a-composer-package-by-using-cicd), [NuGet Packages](../nuget_repository/index.md#publishing-a-nuget-package-with-cicd), [Conan Packages](../conan_repository/index.md#publish-a-conan-package-by-using-cicd), and [PyPI packages](../pypi_repository/index.md#using-gitlab-ci-with-pypi-packages).
If you use CI/CD to build a package, extended activity
information is displayed when you view the package details:
@@ -45,7 +45,7 @@ user who triggered it.
To download a package:
-1. Go to **{package}** **Packages & Registries > Package Registry**.
+1. Go to **Packages & Registries > Package Registry**.
1. Click the name of the package you want to download.
1. In the **Activity** section, click the name of the package you want to download.
@@ -60,7 +60,7 @@ You can delete packages by using [the API](../../../api/packages.md#delete-a-pro
To delete a package in the UI, from your group or project:
-1. Go to **{package}** **Packages & Registries > Package Registry**.
+1. Go to **Packages & Registries > Package Registry**.
1. Find the name of the package you want to delete.
1. Click **Delete**.
@@ -71,7 +71,7 @@ The package is permanently deleted.
The Package Registry is automatically enabled.
If you are using a self-managed instance of GitLab, your administrator can remove
-the menu item, **{package}** **Packages & Registries**, from the GitLab sidebar. For more information,
+the menu item, **Packages & Registries**, from the GitLab sidebar. For more information,
see the [administration documentation](../../../administration/packages/index.md).
You can also remove the Package Registry for your project specifically:
@@ -81,7 +81,7 @@ You can also remove the Package Registry for your project specifically:
**Packages** feature.
1. Click **Save changes**.
-The **{package}** **Packages & Registries > Package Registry** entry is removed from the sidebar.
+The **Packages & Registries > Package Registry** entry is removed from the sidebar.
## Package workflows
diff --git a/doc/user/packages/pypi_repository/index.md b/doc/user/packages/pypi_repository/index.md
index 97f3f69d676..7d79da7d79b 100644
--- a/doc/user/packages/pypi_repository/index.md
+++ b/doc/user/packages/pypi_repository/index.md
@@ -274,7 +274,7 @@ Where:
Install the latest version of a package using the following command:
```shell
-pip install --index-url https://__token__:<personal_access_token>@gitlab.com/api/v4/projects/<project_id>/packages/pypi/simple --no-deps <package_name>
+pip install --extra-index-url https://__token__:<personal_access_token>@gitlab.com/api/v4/projects/<project_id>/packages/pypi/simple --no-deps <package_name>
```
Where:
@@ -287,7 +287,7 @@ If you were following the guide above and want to test installing the
`MyPyPiPackage` package, you can run the following:
```shell
-pip install mypypipackage --no-deps --index-url https://__token__:<personal_access_token>@gitlab.com/api/v4/projects/<your_project_id>/packages/pypi/simple
+pip install mypypipackage --no-deps --extra-index-url https://__token__:<personal_access_token>@gitlab.com/api/v4/projects/<your_project_id>/packages/pypi/simple
```
This should result in the following:
diff --git a/doc/user/packages/workflows/project_registry.md b/doc/user/packages/workflows/project_registry.md
index 571cda09e69..be4793f72f3 100644
--- a/doc/user/packages/workflows/project_registry.md
+++ b/doc/user/packages/workflows/project_registry.md
@@ -80,11 +80,11 @@ Now you can [deploy Maven packages](../maven_repository/index.md#uploading-packa
#### Conan
-For Conan, first you need to add GitLab as a Conan registry remote. Follow the instructions in the [GitLab Conan Repository docs](../conan_repository/index.md#adding-the-gitlab-package-registry-as-a-conan-remote)
+For Conan, first you need to add GitLab as a Conan registry remote. Follow the instructions in the [GitLab Conan Repository docs](../conan_repository/index.md#add-the-package-registry-as-a-conan-remote)
to do so. Then, create your package using the plus-separated (`+`) project path as your Conan user. For example,
if your project is located at `https://gitlab.com/foo/bar/my-proj`, then you can [create your Conan package](../conan_repository/index.md)
using `conan create . foo+bar+my-proj/channel`, where `channel` is your package channel (`stable`, `beta`, etc.). Once your package
-is created, you are ready to [upload your package](../conan_repository/index.md#uploading-a-package) depending on your final package recipe. For example:
+is created, you are ready to [upload your package](../conan_repository/index.md#publish-a-conan-package) depending on your final package recipe. For example:
```shell
CONAN_LOGIN_USERNAME=<gitlab-username> CONAN_PASSWORD=<personal_access_token> conan upload MyPackage/1.0.0@foo+bar+my-proj/channel --all --remote=gitlab
diff --git a/doc/user/permissions.md b/doc/user/permissions.md
index e2baac1a962..3c11de76b81 100644
--- a/doc/user/permissions.md
+++ b/doc/user/permissions.md
@@ -8,7 +8,7 @@ Users have different abilities depending on the access level they have in a
particular group or project. If a user is both in a project's group and the
project itself, the highest permission level is used.
-On public and internal projects the Guest role is not enforced. All users can:
+On public and internal projects, the Guest role is not enforced. All users can:
- Create issues.
- Leave comments.
@@ -72,7 +72,7 @@ The following table depicts the various user permission levels in a project.
| Set issue weight | | ✓ | ✓ | ✓ | ✓ |
| Lock issue threads | | ✓ | ✓ | ✓ | ✓ |
| Manage issue tracker | | ✓ | ✓ | ✓ | ✓ |
-| Manage related issues **(STARTER)** | | ✓ | ✓ | ✓ | ✓ |
+| Manage related issues | | ✓ | ✓ | ✓ | ✓ |
| Manage labels | | ✓ | ✓ | ✓ | ✓ |
| Create code snippets | | ✓ | ✓ | ✓ | ✓ |
| See a commit status | | ✓ | ✓ | ✓ | ✓ |
@@ -116,6 +116,7 @@ The following table depicts the various user permission levels in a project.
| Create vulnerability from vulnerability finding **(ULTIMATE)** | | | ✓ | ✓ | ✓ |
| Resolve vulnerability **(ULTIMATE)** | | | ✓ | ✓ | ✓ |
| Dismiss vulnerability **(ULTIMATE)** | | | ✓ | ✓ | ✓ |
+| Revert vulnerability to detected state **(ULTIMATE)** | | | ✓ | ✓ | ✓ |
| Apply code change suggestions | | | ✓ | ✓ | ✓ |
| Create and edit wiki pages | | | ✓ | ✓ | ✓ |
| Rewrite/remove Git tags | | | ✓ | ✓ | ✓ |
@@ -159,6 +160,7 @@ The following table depicts the various user permission levels in a project.
| Remove fork relationship | | | | | ✓ |
| Delete project | | | | | ✓ |
| Archive project | | | | | ✓ |
+| Export project | | | | ✓ | ✓ |
| Delete issues | | | | | ✓ |
| Delete pipelines | | | | | ✓ |
| Delete merge request | | | | | ✓ |
@@ -175,7 +177,7 @@ The following table depicts the various user permission levels in a project.
\* Owner permission is only available at the group or personal namespace level (and for instance admins) and is inherited by its projects.
-1. Guest users are able to perform this action on public and internal projects, but not private projects.
+1. Guest users are able to perform this action on public and internal projects, but not private projects. This doesn't apply to [external users](#external-users) where explicit access must be given even if the project is internal.
1. Guest users can only view the confidential issues they created themselves.
1. If **Public pipelines** is enabled in **Project Settings > CI/CD**.
1. Not allowed for Guest, Reporter, Developer, Maintainer, or Owner. See [Protected Branches](./project/protected_branches.md).
@@ -195,7 +197,7 @@ which visibility level you select on project settings.
- Disabled: disabled for everyone
- Only team members: only team members can see even if your project is public or internal
-- Everyone with access: everyone can see depending on your project visibility level
+- Everyone with access: everyone can see depending on your project's visibility level
- Everyone: enabled for everyone (only available for GitLab Pages)
### Protected branches
@@ -224,7 +226,7 @@ Read through the documentation on [permissions for File Locking](project/file_lo
### Confidential Issues permissions
-Confidential issues can be accessed by reporters and higher permission levels,
+Confidential issues can be accessed by users with reporter and higher permission levels,
as well as by guest users that create a confidential issue. To learn more,
read through the documentation on [permissions and access to confidential issues](project/issues/confidential_issues.md#permissions-and-access-to-confidential-issues).
@@ -283,7 +285,7 @@ group.
- The [instance level](admin_area/settings/visibility_and_access_controls.md#default-project-creation-protection).
- The [group level](group/index.md#default-project-creation-level).
1. Does not apply to subgroups.
-1. Developers can push commits to the default branch of a new project only if the [default branch protection](group/index.md#changing-the-default-branch-protection-of-a-group) is set to "Partially protected" or "Not protected".
+1. Developers can push commits to the default branch of a new project only if the [default branch protection](group/index.md#changing-the-default-branch-protection-of-a-group) is set to "Partially protected" or "Not protected".
### Subgroup permissions
@@ -311,7 +313,7 @@ External users:
Access can be granted by adding the user as member to the project or group.
Like usual users, they receive a role in the project or group with all
the abilities that are mentioned in the [permissions table above](#project-members-permissions).
-For example, if an external user is added as Guest, and your project is
+For example, if an external user is added as Guest, and your project is internal or
private, they do not have access to the code; you need to grant the external
user access at the Reporter level or above if you want them to have access to the code. You should
always take into account the
@@ -339,7 +341,7 @@ The **Internal users** field allows specifying an email address regex pattern to
identify default internal users. New users whose email address matches the regex
pattern are set to internal by default rather than an external collaborator.
-The regex pattern format is Ruby, but it needs to be convertible to JavaScript,
+The regex pattern format is in Ruby, but it needs to be convertible to JavaScript,
and the ignore case flag is set (`/regex pattern/i`). Here are some examples:
- Use `\.internal@domain\.com$` to mark email addresses ending with
@@ -384,6 +386,16 @@ with the permissions described on the documentation on [auditor users permission
[Read more about Auditor users.](../administration/auditor_users.md)
+## Users with minimal access **(PREMIUM ONLY)**
+
+>[Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40942) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.4.
+
+Administrators can add members with a "minimal access" role to a parent group. Such users don't
+automatically have access to projects and subgroups underneath. To support such access, administrators must explicitly add these "minimal access" users to the specific subgroups/projects.
+
+Users with minimal access can list the group in the UI and through the API. However, they cannot see
+details such as projects or subgroups. They do not have access to the group's page or list any of itssubgroups or projects.
+
## Project features
Project features like wiki and issues can be hidden from users depending on
diff --git a/doc/user/profile/account/create_accounts.md b/doc/user/profile/account/create_accounts.md
index 26c2c1bed89..09bfa7afc9e 100644
--- a/doc/user/profile/account/create_accounts.md
+++ b/doc/user/profile/account/create_accounts.md
@@ -31,9 +31,9 @@ You can also [create users through the API](../../../api/users.md) as an admin.
![Admin User Form](img/admin_user_form.png)
-## Create users through integrations
+## Create users through authentication integrations
Users will be:
-- Automatically created upon first login with the [LDAP integration](../../../administration/auth/ldap/index.md).
-- Created when first logging in via an [OmniAuth provider](../../../integration/omniauth.md) if the `allow_single_sign_on` setting is present.
+- Automatically created upon first sign in with the [LDAP integration](../../../administration/auth/ldap/index.md).
+- Created when first signing in via an [OmniAuth provider](../../../integration/omniauth.md) if the `allow_single_sign_on` setting is present.
diff --git a/doc/user/profile/personal_access_tokens.md b/doc/user/profile/personal_access_tokens.md
index 1b8c16f401c..1c0c1255ee3 100644
--- a/doc/user/profile/personal_access_tokens.md
+++ b/doc/user/profile/personal_access_tokens.md
@@ -14,7 +14,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
If you're unable to use [OAuth2](../../api/oauth2.md), you can use a personal access token to authenticate with the [GitLab API](../../api/README.md#personalproject-access-tokens).
-You can also use personal access tokens with Git to authenticate over HTTP or SSH. Personal access tokens are required when [Two-Factor Authentication (2FA)](../account/two_factor_authentication.md) is enabled. In both cases, you can authenticate with a token in place of your password.
+You can also use personal access tokens with Git to authenticate over HTTP or SSH. Personal access tokens are required when [Two-Factor Authentication (2FA)](account/two_factor_authentication.md) is enabled. In both cases, you can authenticate with a token in place of your password.
Personal access tokens expire on the date you define, at midnight UTC.
diff --git a/doc/user/project/clusters/add_eks_clusters.md b/doc/user/project/clusters/add_eks_clusters.md
index b3b1b51a543..b2eb1c51745 100644
--- a/doc/user/project/clusters/add_eks_clusters.md
+++ b/doc/user/project/clusters/add_eks_clusters.md
@@ -61,21 +61,10 @@ To create and add a new Kubernetes cluster to your project, group, or instance:
- **Admin Area > Kubernetes**, for an instance-level cluster.
1. Click **Add Kubernetes cluster**.
1. Under the **Create new cluster** tab, click **Amazon EKS**. You will be provided with an
- `Account ID` and `External ID` to use in the next step.
-1. In the [IAM Management Console](https://console.aws.amazon.com/iam/home), create an EKS management IAM role.
- To do so, follow the [Amazon EKS cluster IAM role](https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html) instructions
- to create a IAM role suitable for managing the AWS EKS cluster's resources on your behalf.
- In addition to the policies that guide suggests, you must also include the `AmazonEKSClusterPolicy`
- policy for this role in order for GitLab to manage the EKS cluster correctly.
-1. In the [IAM Management Console](https://console.aws.amazon.com/iam/home), create an IAM role:
- 1. From the left panel, select **Roles**.
- 1. Click **Create role**.
- 1. Under `Select type of trusted entity`, select **Another AWS account**.
- 1. Enter the Account ID from GitLab into the `Account ID` field.
- 1. Check **Require external ID**.
- 1. Enter the External ID from GitLab into the `External ID` field.
- 1. Click **Next: Permissions**.
- 1. Click **Create Policy**, which will open a new window.
+ `Account ID` and `External ID` needed for later steps.
+1. In the [IAM Management Console](https://console.aws.amazon.com/iam/home), create an IAM policy:
+ 1. From the left panel, select **Policies**.
+ 1. Click **Create Policy**, which opens a new window.
1. Select the **JSON** tab, and paste in the following snippet in place of the existing content:
```json
@@ -131,7 +120,20 @@ To create and add a new Kubernetes cluster to your project, group, or instance:
1. Click **Review policy**.
1. Enter a suitable name for this policy, and click **Create Policy**. You can now close this window.
- 1. Switch back to the "Create role" window, and select the policy you just created.
+
+1. In the [IAM Management Console](https://console.aws.amazon.com/iam/home), create an EKS management IAM role.
+ To do so, follow the [Amazon EKS cluster IAM role](https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html) instructions
+ to create a IAM role suitable for managing the AWS EKS cluster's resources on your behalf.
+ In addition to the policies that guide suggests, you must also include the `AmazonEKSClusterPolicy`
+ policy for this role in order for GitLab to manage the EKS cluster correctly.
+1. In the [IAM Management Console](https://console.aws.amazon.com/iam/home), create an IAM role:
+ 1. From the left panel, select **Roles**.
+ 1. Click **Create role**.
+ 1. Under `Select type of trusted entity`, select **Another AWS account**.
+ 1. Enter the Account ID from GitLab into the `Account ID` field.
+ 1. Check **Require external ID**.
+ 1. Enter the External ID from GitLab into the `External ID` field.
+ 1. Click **Next: Permissions**, and select the policy you just created.
1. Click **Next: Tags**, and optionally enter any tags you wish to associate with this role.
1. Click **Next: Review**.
1. Enter a role name and optional description into the fields provided.
diff --git a/doc/user/project/clusters/add_remove_clusters.md b/doc/user/project/clusters/add_remove_clusters.md
index 18d9fa67ee1..21f216aa50a 100644
--- a/doc/user/project/clusters/add_remove_clusters.md
+++ b/doc/user/project/clusters/add_remove_clusters.md
@@ -191,8 +191,20 @@ To add a Kubernetes cluster to your project, group, or instance:
```
NOTE: **Note:**
- If the command returns the entire certificate chain, you need copy the *root ca*
- certificate at the bottom of the chain.
+ If the command returns the entire certificate chain, you must copy the Root CA
+ certificate and any intermediate certificates at the bottom of the chain.
+ A chain file has following structure:
+
+ ```plaintext
+ -----BEGIN MY CERTIFICATE-----
+ -----END MY CERTIFICATE-----
+ -----BEGIN INTERMEDIATE CERTIFICATE-----
+ -----END INTERMEDIATE CERTIFICATE-----
+ -----BEGIN INTERMEDIATE CERTIFICATE-----
+ -----END INTERMEDIATE CERTIFICATE-----
+ -----BEGIN ROOT CERTIFICATE-----
+ -----END ROOT CERTIFICATE-----
+ ```
1. **Token** -
GitLab authenticates against Kubernetes using service tokens, which are
@@ -257,7 +269,7 @@ To add a Kubernetes cluster to your project, group, or instance:
Copy the `<authentication_token>` value from the output:
- ```yaml
+ ```plaintext
Name: gitlab-token-b5zv4
Namespace: kube-system
Labels: <none>
diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md
index 8d188f00ceb..e60e3fcd4e7 100644
--- a/doc/user/project/clusters/index.md
+++ b/doc/user/project/clusters/index.md
@@ -12,8 +12,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> - [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/39840) in
> GitLab 11.11 for [instances](../../instance/clusters/index.md).
-## Overview
-
Using the GitLab project Kubernetes integration, you can:
- Use [Review Apps](../../../ci/review_apps/index.md).
@@ -31,6 +29,11 @@ Besides integration at the project level, Kubernetes clusters can also be
integrated at the [group level](../../group/clusters/index.md) or
[GitLab instance level](../../instance/clusters/index.md).
+To view your project level Kubernetes clusters, navigate to **Operations > Kubernetes**
+from your project. On this page, you can [add a new cluster](#adding-and-removing-clusters)
+and view information about your existing clusters, such as nodes count and rough estimates
+of memory and CPU usage.
+
## Setting up
### Supported cluster versions
@@ -265,20 +268,43 @@ If your cluster was created before GitLab 12.2, default `KUBE_NAMESPACE` will be
### Custom namespace
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/27630) in GitLab 12.6.
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/27630) in GitLab 12.6.
+> - An option to use project-wide namespaces [was added](https://gitlab.com/gitlab-org/gitlab/-/issues/38054) in GitLab 13.5.
+
+The Kubernetes integration provides a `KUBECONFIG` with an auto-generated namespace
+to deployment jobs. It defaults to using project-environment specific namespaces
+of the form `<prefix>-<environment>`, where `<prefix>` is of the form
+`<project_name>-<project_id>`. To learn more, read [Deployment variables](#deployment-variables).
-The Kubernetes integration defaults to project-environment-specific namespaces
-of the form `<project_name>-<project_id>-<environment>` (see [Deployment
-variables](#deployment-variables)).
+You can customize the deployment namespace in a few ways:
-For **non**-GitLab-managed clusters, the namespace can be customized using
-[`environment:kubernetes:namespace`](../../../ci/environments/index.md#configuring-kubernetes-deployments)
-in `.gitlab-ci.yml`.
+- You can choose between a **namespace per [environment](../../../ci/environments/index.md)**
+ or a **namespace per project**. A namespace per environment is the default and recommended
+ setting, as it prevents the mixing of resources between production and non-production environments.
+- When using a project-level cluster, you can additionally customize the namespace prefix.
+ When using namespace-per-environment, the deployment namespace is `<prefix>-<environment>`,
+ but otherwise just `<prefix>`.
+- For **non-managed** clusters, the auto-generated namespace is set in the `KUBECONFIG`,
+ but the user is responsible for ensuring its existence. You can fully customize
+ this value using
+ [`environment:kubernetes:namespace`](../../../ci/environments/index.md#configuring-kubernetes-deployments)
+ in `.gitlab-ci.yml`.
NOTE: **Note:**
-When using a [GitLab-managed cluster](#gitlab-managed-clusters), the
-namespaces are created automatically prior to deployment and [can not be
-customized](https://gitlab.com/gitlab-org/gitlab/-/issues/38054).
+When you customize the namespace, existing environments remain linked to their current
+namespaces until you [clear the cluster cache](#clearing-the-cluster-cache).
+
+CAUTION: **Warning:**
+By default, anyone who can create a deployment job can access any CI variable within
+an environment's deployment job. This includes `KUBECONFIG`, which gives access to
+any secret available to the associated service account in your cluster.
+To keep your production credentials safe, consider using
+[Protected Environments](../../../ci/environments/protected_environments.md),
+combined with either
+
+- a GitLab-managed cluster and namespace per environment,
+- *or*, an environment-scoped cluster per protected environment. The same cluster
+ can be added multiple times with multiple restricted service accounts.
### Integrations
diff --git a/doc/user/project/clusters/serverless/aws.md b/doc/user/project/clusters/serverless/aws.md
index 543ffdbce8f..d662dc4f715 100644
--- a/doc/user/project/clusters/serverless/aws.md
+++ b/doc/user/project/clusters/serverless/aws.md
@@ -222,7 +222,8 @@ the environment of the deployed function:
```yaml
provider:
- ...
+ # Other configuration omitted
+ # ...
environment:
A_VARIABLE: ${env:A_VARIABLE}
```
@@ -245,10 +246,10 @@ functions:
hello:
handler: src/handler.hello
events:
- - http: # Rewrite this part to enable CORS
+ - http: # Rewrite this part to enable CORS
path: hello
method: get
- cors: true # <-- CORS here
+ cors: true # <-- CORS here
```
You also need to return CORS specific headers in your function response:
diff --git a/doc/user/project/deploy_keys/index.md b/doc/user/project/deploy_keys/index.md
index 81c9008c5b3..4f344554016 100644
--- a/doc/user/project/deploy_keys/index.md
+++ b/doc/user/project/deploy_keys/index.md
@@ -12,7 +12,7 @@ more repositories, by importing an SSH public key to your GitLab instance.
This is useful for cloning repositories to your Continuous
Integration (CI) server. By using deploy keys, you don't have to set up a
-dummy user account.
+fake user account.
There are two types of deploy keys:
diff --git a/doc/user/project/description_templates.md b/doc/user/project/description_templates.md
index aa5987bf5f9..e0c4097d1c5 100644
--- a/doc/user/project/description_templates.md
+++ b/doc/user/project/description_templates.md
@@ -49,8 +49,8 @@ To create a Markdown file:
1. Click the `+` button next to `master` and click **New file**.
1. Add the name of your issue template to the **File name** text field next to `master`.
- Make sure words are separated with underscores and that your file has the `.md` extension, for
- example `feature_request.md`.
+ Make sure that your file has the `.md` extension, for
+ example `feature_request.md` or `Feature Request.md`.
1. Commit and push to your default branch.
If you don't have a `.gitlab/issue_templates` directory in your repository, you'll need to create it.
@@ -79,6 +79,9 @@ This will enable the `Bug` dropdown option when creating or editing issues. When
to the issue description field. The 'Reset template' button will discard any
changes you made after picking the template and return it to its initial status.
+TIP: **Tip:**
+You can create short-cut links to create an issue using a designated template. For example: `https://gitlab.com/gitlab-org/gitlab/-/issues/new?issuable_template=Feature%20proposal`.
+
![Description templates](img/description_templates.png)
## Setting a default template for merge requests and issues **(STARTER)**
diff --git a/doc/user/project/img/issue_board_default_lists_v13_4.png b/doc/user/project/img/issue_board_default_lists_v13_4.png
new file mode 100644
index 00000000000..23cdc9b4e22
--- /dev/null
+++ b/doc/user/project/img/issue_board_default_lists_v13_4.png
Binary files differ
diff --git a/doc/user/project/img/issue_board_welcome_message.png b/doc/user/project/img/issue_board_welcome_message.png
deleted file mode 100644
index 357dff42488..00000000000
--- a/doc/user/project/img/issue_board_welcome_message.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/import/bitbucket.md b/doc/user/project/import/bitbucket.md
index 89130d5822f..56266718d12 100644
--- a/doc/user/project/import/bitbucket.md
+++ b/doc/user/project/import/bitbucket.md
@@ -76,6 +76,3 @@ If you've accidentally started the import process with the wrong account, follow
1. Revoke GitLab access to your Bitbucket account, essentially reversing the process in the following procedure: [Import your Bitbucket repositories](#import-your-bitbucket-repositories).
1. Sign out of the Bitbucket account. Follow the procedure linked from the previous step.
-
-NOTE: **Note:**
-To import a repository including LFS objects from a Bitbucket server repository, use the [Repo by URL](../import/repo_by_url.md) importer.
diff --git a/doc/user/project/import/bitbucket_server.md b/doc/user/project/import/bitbucket_server.md
index d0499730bfe..d8ba4f86924 100644
--- a/doc/user/project/import/bitbucket_server.md
+++ b/doc/user/project/import/bitbucket_server.md
@@ -37,12 +37,7 @@ Import your projects from Bitbucket Server to GitLab with minimal effort.
empty changes.
1. Attachments in Markdown are currently not imported.
1. Task lists are not imported.
-1. Emoji reactions are not imported.
-1. [LFS objects](../../../topics/git/lfs/index.md) are not imported.
-
- NOTE: **Note:**
- To import a repository including LFS objects from a Bitbucket server repository, use the [Repo by URL](../import/repo_by_url.md) importer.
-
+1. Emoji reactions are not imported
1. Project filtering does not support fuzzy search (only `starts with` or `full
match strings` are currently supported)
@@ -69,20 +64,43 @@ namespace that started the import process.
#### User assignment by username
-Alternatively, user assignment by username is available behind a `bitbucket_server_user_mapping_by_username` feature flag.
-The importer will try to find a user in the GitLab user database using author's `username` or `slug` or `displayName`.
-Falls back to author's `email` if user is not found by username.
-Similarly to user assignment by email, if no such user is available, the project creator is set as the author.
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218609) in GitLab 13.4.
+> - It's [deployed behind a feature flag](../../feature_flags.md), disabled by default.
+> - It's disabled on GitLab.com.
+> - It's not recommended for production use.
+> - To use it in GitLab self-managed instances, ask a GitLab administrator to enable it.
+
+CAUTION: **Warning:**
+This feature might not be available to you. Check the **version history** note above for details.
+
+If you've enabled this feature, the importer tries to find a user in the GitLab user database with
+the author's:
+
+- `username`
+- `slug`
+- `displayName`
+
+If the user is not found by any of these properties, the search falls back to the author's
+`email` address.
-To enable or disable user assignment by username:
+Alternatively, if there is also no email address, the project creator is set as the author.
-Start a [Rails console](../../../administration/troubleshooting/debug.md#starting-a-rails-console-session).
+##### Enable or disable User assignment by username
+
+User assignment by username is under development and not ready for production use. It is
+deployed behind a feature flag that is **disabled by default**.
+[GitLab administrators with access to the GitLab Rails console](<replace with path to>/administration/feature_flags.md)
+can enable it.
+
+To enable it:
```ruby
-# Enable
Feature.enable(:bitbucket_server_user_mapping_by_username)
+```
-# Disable
+To disable it:
+
+```ruby
Feature.disable(:bitbucket_server_user_mapping_by_username)
```
diff --git a/doc/user/project/import/github.md b/doc/user/project/import/github.md
index 4cd0c9e02c7..be1641f8b16 100644
--- a/doc/user/project/import/github.md
+++ b/doc/user/project/import/github.md
@@ -35,25 +35,20 @@ The namespace is a user or group in GitLab, such as `gitlab.com/janedoe` or `git
This process does not migrate or import any types of groups or organizations from GitHub to GitLab.
-### If you're using GitLab.com
-
-If you're using GitLab.com, you can alternatively import
-GitHub repositories using a [personal access token](#using-a-github-token),
-but we don't recommend this method because it can't associate all user activity
-(such as issues and pull requests) with matching GitLab users.
-
-### If you're importing from GitLab Enterprise
-
-If you're importing from GitHub Enterprise, you must enable [GitHub integration][gh-import].
-
-### If you're using a self-managed GitLab instance
-
-If you're an administrator of a self-managed GitLab instance, you must enable
-[GitHub integration][gh-import].
-
-If you're an administrator of a self-managed GitLab instance, you can also use the
-[GitHub Rake task](../../../administration/raketasks/github_import.md) to import projects from
-GitHub without the constraints of a Sidekiq worker.
+### Use cases
+
+The steps you take depend on whether you are importing from GitHub.com or GitHub Enterprise, as well as whether you are importing to GitLab.com or self-managed GitLab instance.
+
+- If you're importing to GitLab.com, you can alternatively import GitHub repositories
+ using a [personal access token](#using-a-github-token). We do not recommend
+ this method, as it does not associate all user activity (such as issues and
+ pull requests) with matching GitLab users.
+- If you're importing to a self-managed GitLab instance, you can alternatively use the
+ [GitHub Rake task](../../../administration/raketasks/github_import.md) to import
+ projects without the constraints of a [Sidekiq](../../../development/sidekiq_style_guide.md) worker.
+- If you're importing from GitHub Enterprise to your self-managed GitLab instance, you must first enable
+ [GitHub integration](../../../integration/github.md). However, you cannot import projects from GitHub Enterprise to GitLab.com.
+- If you're importing from GitHub.com to your self-managed GitLab instance, you do not need to set up GitHub integration.
## How it works
diff --git a/doc/user/project/import/img/manifest_status_v13_3.png b/doc/user/project/import/img/manifest_status_v13_3.png
index 3f0063e6715..c1a55ba1f50 100644
--- a/doc/user/project/import/img/manifest_status_v13_3.png
+++ b/doc/user/project/import/img/manifest_status_v13_3.png
Binary files differ
diff --git a/doc/user/project/index.md b/doc/user/project/index.md
index c79f2be1d3f..a00f93bac9c 100644
--- a/doc/user/project/index.md
+++ b/doc/user/project/index.md
@@ -34,7 +34,7 @@ When you create a project in GitLab, you'll have access to a large number of
- [Protected tags](protected_tags.md): Control over who has
permission to create tags, and prevent accidental update or deletion
- [Repository mirroring](repository/repository_mirroring.md)
- - [Signing commits](gpg_signed_commits/index.md): use GPG to sign your commits
+ - [Signing commits](repository/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.
- [Web IDE](web_ide/index.md)
- [CVE ID Requests](../application_security/cve_id_request.md): Request a CVE identifier to track a
@@ -96,9 +96,9 @@ When you create a project in GitLab, you'll have access to a large number of
- [Wiki](wiki/index.md): document your GitLab project in an integrated Wiki.
- [Snippets](../snippets.md): store, share and collaborate on code snippets.
-- [Value Stream Analytics](cycle_analytics.md): review your development lifecycle.
+- [Value Stream Analytics](../analytics/value_stream_analytics.md): review your development lifecycle.
- [Insights](insights/index.md): configure the Insights that matter for your projects. **(ULTIMATE)**
-- [Security Dashboard](security_dashboard.md): Security Dashboard. **(ULTIMATE)**
+- [Security Dashboard](../application_security/security_dashboard/index.md): Security Dashboard. **(ULTIMATE)**
- [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.
diff --git a/doc/user/project/integrations/prometheus_library/kubernetes.md b/doc/user/project/integrations/prometheus_library/kubernetes.md
index 29efe08e53d..d03241f98a9 100644
--- a/doc/user/project/integrations/prometheus_library/kubernetes.md
+++ b/doc/user/project/integrations/prometheus_library/kubernetes.md
@@ -12,7 +12,7 @@ GitLab has support for automatically detecting and monitoring Kubernetes metrics
## Requirements
-The [Prometheus](../prometheus.md) and [Kubernetes](../kubernetes.md)
+The [Prometheus](../prometheus.md) and [Kubernetes](../../clusters/index.md)
integration services must be enabled.
## Metrics supported
diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md
index f8172a0f988..e039e861d2c 100644
--- a/doc/user/project/issue_board.md
+++ b/doc/user/project/issue_board.md
@@ -8,8 +8,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/5554) in [GitLab 8.11](https://about.gitlab.com/releases/2016/08/22/gitlab-8-11-released/#issue-board).
-## Overview
-
The GitLab Issue Board is a software project management tool used to plan,
organize, and visualize a workflow for a feature or product release.
It can be used as a [Kanban](https://en.wikipedia.org/wiki/Kanban_(development)) or a
@@ -374,27 +372,22 @@ If you're not able to do some of the things above, make sure you have the right
### First time using an issue board
-The first time you open an issue board, you are presented with
-the default lists (**Open** and **Closed**) and a welcome message that gives
-you two options. You can either:
+> The automatic creation of the **To Do** and **Doing** lists was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/202144) in GitLab 13.4.
-- Create a predefined set of labels (by default: **To Do** and **Doing**) and create their
- corresponding lists to the issue board.
-- Opt-out and use your own lists.
+The first time you open an issue board, you are presented with the default lists
+(**Open**, **To Do**, **Doing**, and **Closed**).
-![issue board welcome message](img/issue_board_welcome_message.png)
+If the **To Do** and **Doing** labels don't exist in the project or group, they are created, and
+their lists appear as empty. If any of them already exists, the list is filled with the issues that
+have that label.
-If you choose to use and create the predefined lists, they will appear as empty
-because the labels associated to them will not exist up until that moment,
-which means the system has no way of populating them automatically. That's of
-course if the predefined labels don't already exist. If any of them does exist,
-the list will be created and filled with the issues that have that label.
+![issue board default lists](img/issue_board_default_lists_v13_4.png)
### Create a new list
Create a new list by clicking the **Add list** button in the upper right corner of the issue board.
-![issue board welcome message](img/issue_board_add_list.png)
+![creating a new list in an issue board](img/issue_board_add_list.png)
Then, choose the label or user to create the list from. The new list will be inserted
at the end of the lists, before **Done**. Moving and reordering lists is as
diff --git a/doc/user/project/issues/design_management.md b/doc/user/project/issues/design_management.md
index 7c9278c8403..b92d99fc4a8 100644
--- a/doc/user/project/issues/design_management.md
+++ b/doc/user/project/issues/design_management.md
@@ -10,8 +10,6 @@ info: "To determine the technical writer assigned to the Stage/Group associated
> - Support for SVGs was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/12771) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.4.
> - Design Management was [moved](https://gitlab.com/gitlab-org/gitlab/-/issues/212566) to GitLab Core in 13.0.
-## Overview
-
Design Management allows you to upload design assets (wireframes, mockups, etc.)
to GitLab issues and keep them stored in one single place, accessed by the Design
Management's page within an issue, giving product designers, product managers, and engineers a
@@ -114,9 +112,6 @@ Designs with the same filename as an existing uploaded design will create a new
of the design, and will replace the previous version. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/34353) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.9, dropping a design on an existing uploaded design will also create a new version,
provided the filenames are the same.
-Designs cannot be added if the issue has been moved, or its
-[discussion is locked](../../discussions/#lock-discussions).
-
### Skipped designs
Designs with the same filename as an existing uploaded design _and_ whose content has not changed will be skipped.
@@ -185,9 +180,7 @@ viewed by browsing previous versions.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/34382) in GitLab 13.3.
-You can change the order of designs by dragging them to a new position:
-
-![Reorder designs](img/designs_reordering_v13_3.gif)
+You can change the order of designs by dragging them to a new position.
## Starting discussions on designs
@@ -231,7 +224,7 @@ Note that your resolved comment pins will disappear from the Design to free up s
However, if you need to revisit or find a resolved discussion, all of your resolved threads will be
available in the **Resolved Comment** area at the bottom of the right sidebar.
-## Add To-Do for Designs
+## Add to dos for designs
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/198439) in GitLab 13.4.
> - It's [deployed behind a feature flag](../../feature_flags.md), enabled by default.
@@ -242,11 +235,11 @@ available in the **Resolved Comment** area at the bottom of the right sidebar.
CAUTION: **Warning:**
This feature might not be available to you. Check the **version history** note above for details.
-Add a to-do for a design by clicking **Add a To-Do** on the design sidebar:
+Add a to do for a design by clicking **Add a To-Do** on the design sidebar:
![To-Do button](img/design_todo_button_v13_4.png)
-### Enable or disable the design To-Do button **(CORE ONLY)**
+### Enable or disable the design to-do button **(CORE ONLY)**
The **Add a To-Do** button for Designs is under development but ready for production use. It is
deployed behind a feature flag that is **enabled by default**.
@@ -268,10 +261,7 @@ Feature.disable(:design_management_todo_button)
## Referring to designs in Markdown
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/217160) in **GitLab 13.1**.
-> - It is deployed behind a feature flag, disabled by default.
-> - It is disabled on GitLab.com.
-> - It is not recommended for production use.
-> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-design-references). **(CORE ONLY)**
+> - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/258662) in **GitLab 13.5**
We support referring to designs in [Markdown](../../markdown.md), which is available
throughout the application, including in merge request and issue descriptions, in discussions and comments, and in wiki pages.
@@ -287,25 +277,6 @@ This will be rendered as:
> See [#123[homescreen.png]](https://gitlab.com/your-group/your-project/-/issues/123/designs/homescreen.png)
-### Enable or disable design references **(CORE ONLY)**
-
-Design reference parsing is
-deployed behind a feature flag that is **enabled by default**.
-[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
-can disable it for your instance.
-
-To disable it:
-
-```ruby
-Feature.disable(:design_management_reference_filter_gfm_pipeline)
-```
-
-To re-enable it:
-
-```ruby
-Feature.enable(:design_management_reference_filter_gfm_pipeline)
-```
-
## Design activity records
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/33051) in GitLab 13.1.
diff --git a/doc/user/project/issues/due_dates.md b/doc/user/project/issues/due_dates.md
index 55b45bf9d3d..b3ebefadef0 100644
--- a/doc/user/project/issues/due_dates.md
+++ b/doc/user/project/issues/due_dates.md
@@ -46,7 +46,7 @@ the icon and the date colored red. You can sort issues by those that are
Due dates also appear in your [to-do list](../../todos.md).
-![Issues with due dates in the to-dos](img/due_dates_todos.png)
+![Issues with due dates in the to dos](img/due_dates_todos.png)
The day before an open issue is due, an email will be sent to all participants
of the issue. Like the due date, the "day before the due date" is determined by the
diff --git a/doc/user/project/issues/img/designs_reordering_v13_3.gif b/doc/user/project/issues/img/designs_reordering_v13_3.gif
deleted file mode 100644
index 496eea532e2..00000000000
--- a/doc/user/project/issues/img/designs_reordering_v13_3.gif
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/issues/index.md b/doc/user/project/issues/index.md
index 060266a478f..434af3a4a49 100644
--- a/doc/user/project/issues/index.md
+++ b/doc/user/project/issues/index.md
@@ -156,12 +156,15 @@ collaborate with your team.
efficiently and with less effort by tracking groups of issues that share a theme, across
projects and milestones.
-### Related issues **(STARTER)**
+### Related issues
You can mark two issues as related, so that when viewing one, the other is always
listed in its [Related Issues](related_issues.md) section. This can help display important
context, such as past work, dependencies, or duplicates.
+Users on [GitLab Starter, GitLab Bronze, and higher tiers](https://about.gitlab.com/pricing/), can
+also mark issues as blocking or blocked by another issue.
+
### Crosslinking issues
You can [cross-link issues](crosslinking_issues.md) by referencing an issue from another
diff --git a/doc/user/project/issues/issue_data_and_actions.md b/doc/user/project/issues/issue_data_and_actions.md
index 5356e6aeb40..95d7f2edac5 100644
--- a/doc/user/project/issues/issue_data_and_actions.md
+++ b/doc/user/project/issues/issue_data_and_actions.md
@@ -36,7 +36,7 @@ You can find all the information for that issue on one screen.
- **15.** [Edit](#edit)
- **16.** [Description](#description)
- **17.** [Mentions](#mentions)
-- **18.** [Related Issues **(STARTER)**](#related-issues)
+- **18.** [Related Issues](#related-issues)
- **19.** [Related Merge Requests](#related-merge-requests)
- **20.** [Award emoji](#award-emoji)
- **21.** [Show all activity](#show-all-activity)
@@ -208,7 +208,7 @@ TIP: **Tip:**
Avoid mentioning `@all` in issues and merge requests, as it sends an email notification
to all the members of that project's group, which can be interpreted as spam.
-### Related Issues **(STARTER)**
+### Related Issues
Issues that were mentioned as [related issues](related_issues.md) are listed here.
You can also click the `+` to add more related issues.
@@ -242,7 +242,7 @@ and selecting either:
Also:
- You can mention a user or a group present in your GitLab instance with
- `@username` or `@groupname` and they will be notified via To-Do items
+ `@username` or `@groupname` and they will be notified via to-do items
and email, unless they have [disabled all notifications](#notifications)
in their profile settings.
- Mentions for yourself (the current logged in user), will be highlighted
diff --git a/doc/user/project/issues/managing_issues.md b/doc/user/project/issues/managing_issues.md
index 0e0731528be..b033dc79dcc 100644
--- a/doc/user/project/issues/managing_issues.md
+++ b/doc/user/project/issues/managing_issues.md
@@ -127,6 +127,7 @@ field).
| title | `issue[title]` | |
| description | `issue[description]` | |
| description template | `issuable_template` | |
+| issue type | `issue[issue_type]` | Either `incident` or `issue` |
| confidential | `issue[confidential]` | Parameter value must be `true` to set to confidential |
Follow these examples to form your new issue URL with prefilled fields.
diff --git a/doc/user/project/issues/multiple_assignees_for_issues.md b/doc/user/project/issues/multiple_assignees_for_issues.md
index c11977f5bee..b1806460c08 100644
--- a/doc/user/project/issues/multiple_assignees_for_issues.md
+++ b/doc/user/project/issues/multiple_assignees_for_issues.md
@@ -8,8 +8,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/1904) in [GitLab Starter 9.2](https://about.gitlab.com/releases/2017/05/22/gitlab-9-2-released/#multiple-assignees-for-issues).
-## Overview
-
In large teams, where there is shared ownership of an issue, it can be difficult
to track who is working on it, who already completed their contributions, who
didn't even start yet.
diff --git a/doc/user/project/issues/related_issues.md b/doc/user/project/issues/related_issues.md
index 954e3771722..b040bcf3b03 100644
--- a/doc/user/project/issues/related_issues.md
+++ b/doc/user/project/issues/related_issues.md
@@ -4,33 +4,40 @@ group: Project Management
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
-# Related issues **(STARTER)**
+# Related issues **(CORE)**
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1797) in [GitLab Starter](https://about.gitlab.com/pricing/) 9.4.
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1797) in [GitLab Starter](https://about.gitlab.com/pricing/) 9.4.
+> - The simple "relates to" relationship [moved](https://gitlab.com/gitlab-org/gitlab/-/issues/212329) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.4.
Related issues are a bi-directional relationship between any two issues
and appear in a block below the issue description. Issues can be across groups
and projects.
+You can set any issue as:
+
+- Related to another issue
+- Blocking another issue **(STARTER)**
+- Blocked by another issue **(STARTER)**
+
The relationship only shows up in the UI if the user can see both issues.
When you try to close an issue that has open blockers, a warning is displayed.
TIP: **Tip:**
-To manage related issues through our API, see the [API documentation](../../../api/issue_links.md).
+To manage related issues through our API, visit the [issue links API documentation](../../../api/issue_links.md).
## Adding a related issue
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/2035) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.8.
> - [Improved](https://gitlab.com/gitlab-org/gitlab/-/issues/34239) to warn when attempting to close an issue that is blocked by others in [GitLab Starter](https://about.gitlab.com/pricing/) 13.0.
-> When you try to close an issue with open blockers, you'll see a warning that you can dismiss.
+> When you try to close an issue with open blockers, you see a warning that you can dismiss.
1. Relate one issue to another by clicking the related issues "+" button
in the header of the related issue block.
1. Input the issue reference number or paste in the full URL of the issue.
-1. Select whether the current issue relates to, blocks, or is blocked by the issues being entered.
+1. **(STARTER)** Select whether the current issue relates to, blocks, or is blocked by the issues being entered.
![Adding a related issue](img/related_issues_add_v12_8.png)
@@ -42,11 +49,11 @@ in the header of the related issue block.
- same group: `project#44`
- different group: `group/project#44`
- Valid references will be added to a temporary list that you can review.
+ Valid references are added to a temporary list that you can review.
1. When you have added all the related issues, click **Add** to submit.
-When you have finished adding all related issues, you will be able to see
+When you have finished adding all related issues, you can see
them categorized so their relationships can be better understood visually.
![Related issue block](img/related_issue_block_v12_8.png)
@@ -56,7 +63,7 @@ them categorized so their relationships can be better understood visually.
In the related issues block, click the "x" icon on the right-side of each issue
token that you wish to remove.
-Due to the bi-directional relationship, it will no longer appear in either issue.
+Due to the bi-directional relationship, it no longer appears in either issue.
![Removing a related issue](img/related_issues_remove_v12_8.png)
diff --git a/doc/user/project/labels.md b/doc/user/project/labels.md
index 0d02b89be7e..1f2504a4f60 100644
--- a/doc/user/project/labels.md
+++ b/doc/user/project/labels.md
@@ -6,8 +6,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Labels
-## Overview
-
As your count of issues, merge requests, and epics grows in GitLab, it's more and more challenging
to keep track of those items. Especially as your organization grows from just a few people to
hundreds or thousands. This is where labels come in. They help you organize and tag your work
diff --git a/doc/user/project/merge_requests/img/commit_nav_v13_4.png b/doc/user/project/merge_requests/img/commit_nav_v13_4.png
new file mode 100644
index 00000000000..0ae6ce32693
--- /dev/null
+++ b/doc/user/project/merge_requests/img/commit_nav_v13_4.png
Binary files differ
diff --git a/doc/user/project/merge_requests/reviewing_and_managing_merge_requests.md b/doc/user/project/merge_requests/reviewing_and_managing_merge_requests.md
index 3a18cacde64..2e0c0d7aeeb 100644
--- a/doc/user/project/merge_requests/reviewing_and_managing_merge_requests.md
+++ b/doc/user/project/merge_requests/reviewing_and_managing_merge_requests.md
@@ -122,6 +122,8 @@ the commits to open the single-commit view. From there, you can navigate among t
by clicking the **Prev** and **Next** buttons on the top-right of the page or by using the
<kbd>X</kbd> and <kbd>C</kbd> keyboard shortcuts.
+![Merge requests commit navigation](img/commit_nav_v13_4.png)
+
### Incrementally expand merge request diffs
By default, the diff shows only the parts of a file which are changed.
diff --git a/doc/user/project/merge_requests/test_coverage_visualization.md b/doc/user/project/merge_requests/test_coverage_visualization.md
index 56b5774f15b..b81158c3653 100644
--- a/doc/user/project/merge_requests/test_coverage_visualization.md
+++ b/doc/user/project/merge_requests/test_coverage_visualization.md
@@ -5,13 +5,10 @@ info: To determine the technical writer assigned to the Stage/Group associated w
type: reference, howto
---
-# Test Coverage Visualization **(CORE ONLY)**
+# Test Coverage Visualization
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/3708) in GitLab 12.9.
-> - [Feature flag enabled](https://gitlab.com/gitlab-org/gitlab/-/issues/211410) in GitLab 13.4.
-> - It's enabled on GitLab.com.
-> - It can be disabled per-project.
-> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enabling-the-feature). **(CORE ONLY)**
+> - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/249811) in GitLab 13.5.
With the help of [GitLab CI/CD](../../../ci/README.md), you can collect the test
coverage information of your favorite testing or coverage-analysis tool, and visualize
@@ -58,7 +55,9 @@ NOTE: **Note:**
The Cobertura XML parser currently does not support the `sources` element and ignores it. It is assumed that
the `filename` of a `class` element contains the full path relative to the project root.
-## Example test coverage configuration
+## Example test coverage configurations
+
+### JavaScript example
The following [`gitlab-ci.yml`](../../../ci/yaml/README.md) example uses [Mocha](https://mochajs.org/)
JavaScript testing and [NYC](https://github.com/istanbuljs/nyc) coverage-tooling to
@@ -74,26 +73,84 @@ test:
cobertura: coverage/cobertura-coverage.xml
```
-## Enabling the feature
+### Java examples
+
+#### Maven example
-This feature comes with the `:coverage_report_view` feature flag enabled by
-default. It is enabled on GitLab.com. [GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
-can disable it for your instance. Test coverage visualization can be enabled or disabled per-project.
+The following [`gitlab-ci.yml`](../../../ci/yaml/README.md) example for Java uses [Maven](https://maven.apache.org/)
+to build the project and [Jacoco](https://www.eclemma.org/jacoco/) coverage-tooling to
+generate the coverage artifact.
+You can check the [Docker image configuration and scripts](https://gitlab.com/haynes/jacoco2cobertura) if you want to build your own image.
-To enable it:
+GitLab expects the artifact in the Cobertura format, so you have to execute a few
+scripts before uploading it. The `test-jdk11` job tests the code and generates an
+XML artifact. The `coverage-jdk-11` job converts the artifact into a Cobertura report:
-```ruby
-# Instance-wide
-Feature.enable(:coverage_report_view)
-# or by project
-Feature.enable(:coverage_report_view, Project.find(<project id>))
+```yaml
+test-jdk11:
+ stage: test
+ image: maven:3.6.3-jdk-11
+ script:
+ - 'mvn $MAVEN_CLI_OPTS clean org.jacoco:jacoco-maven-plugin:prepare-agent test jacoco:report'
+ artifacts:
+ paths:
+ - target/site/jacoco/jacoco.xml
+
+coverage-jdk11:
+ # Must be in a stage later than test-jdk11's stage.
+ # The `visualize` stage does not exist by default.
+ # Please define it first, or chose an existing stage like `deploy`.
+ stage: visualize
+ image: haynes/jacoco2cobertura:1.0.3
+ script:
+ # convert report from jacoco to cobertura
+ - 'python /opt/cover2cover.py target/site/jacoco/jacoco.xml src/main/java > target/site/cobertura.xml'
+ # read the <source></source> tag and prepend the path to every filename attribute
+ - 'python /opt/source2filename.py target/site/cobertura.xml'
+ needs: ["test-jdk11"]
+ dependencies:
+ - test-jdk11
+ artifacts:
+ reports:
+ cobertura: target/site/cobertura.xml
```
-To disable it:
+#### Gradle example
-```ruby
-# Instance-wide
-Feature.disable(:coverage_report_view)
-# or by project
-Feature.disable(:coverage_report_view, Project.find(<project id>))
+The following [`gitlab-ci.yml`](../../../ci/yaml/README.md) example for Java uses [Gradle](https://gradle.org/)
+to build the project and [Jacoco](https://www.eclemma.org/jacoco/) coverage-tooling to
+generate the coverage artifact.
+You can check the [Docker image configuration and scripts](https://gitlab.com/haynes/jacoco2cobertura) if you want to build your own image.
+
+GitLab expects the artifact in the Cobertura format, so you have to execute a few
+scripts before uploading it. The `test-jdk11` job tests the code and generates an
+XML artifact. The `coverage-jdk-11` job converts the artifact into a Cobertura report:
+
+```yaml
+test-jdk11:
+ stage: test
+ image: gradle:6.6.1-jdk11
+ script:
+ - 'gradle test jacocoTestReport' # jacoco must be configured to create an xml report
+ artifacts:
+ paths:
+ - build/jacoco/jacoco.xml
+
+coverage-jdk11:
+ # Must be in a stage later than test-jdk11's stage.
+ # The `visualize` stage does not exist by default.
+ # Please define it first, or chose an existing stage like `deploy`.
+ stage: visualize
+ image: haynes/jacoco2cobertura:1.0.3
+ script:
+ # convert report from jacoco to cobertura
+ - 'python /opt/cover2cover.py build/jacoco/jacoco.xml src/main/java > build/cobertura.xml'
+ # read the <source></source> tag and prepend the path to every filename attribute
+ - 'python /opt/source2filename.py build/cobertura.xml'
+ needs: ["test-jdk11"]
+ dependencies:
+ - test-jdk11
+ artifacts:
+ reports:
+ cobertura: build/cobertura.xml
```
diff --git a/doc/user/project/milestones/burndown_charts.md b/doc/user/project/milestones/burndown_charts.md
index 0c8bba831a9..913904d962b 100644
--- a/doc/user/project/milestones/burndown_charts.md
+++ b/doc/user/project/milestones/burndown_charts.md
@@ -14,8 +14,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> value, so the burndown chart considers them as closed on the milestone
> `start_date`. In that case, a warning will be displayed.
-## Overview
-
Burndown Charts are visual representations of the progress of completing a milestone.
![burndown chart](img/burndown_chart.png)
diff --git a/doc/user/project/milestones/index.md b/doc/user/project/milestones/index.md
index 9d02a22f91e..2290a10a35f 100644
--- a/doc/user/project/milestones/index.md
+++ b/doc/user/project/milestones/index.md
@@ -7,8 +7,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Milestones
-## Overview
-
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.
diff --git a/doc/user/project/new_ci_build_permissions_model.md b/doc/user/project/new_ci_build_permissions_model.md
index c3825371030..a7a72ca4d82 100644
--- a/doc/user/project/new_ci_build_permissions_model.md
+++ b/doc/user/project/new_ci_build_permissions_model.md
@@ -223,7 +223,7 @@ This is how an example usage can look like:
```yaml
test:
script:
- - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
+ - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
- docker pull $CI_REGISTRY/group/other-project:latest
- docker run $CI_REGISTRY/group/other-project:latest
```
@@ -236,5 +236,4 @@ to projects and their project permissions.
### API
-GitLab API cannot be used via `CI_JOB_TOKEN` but there is a [proposal](https://gitlab.com/gitlab-org/gitlab/-/issues/35067)
-to support it.
+GitLab API can be used via `CI_JOB_TOKEN`, see [the relevant documentation](../../api/README.md#gitlab-ci-job-token).
diff --git a/doc/user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md b/doc/user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md
index 4b4b430b663..597d6737a8f 100644
--- a/doc/user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md
+++ b/doc/user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md
@@ -25,7 +25,7 @@ This feature covers only certificates for **custom domains**, not the wildcard c
Before you can enable automatic provisioning of an SSL certificate for your domain, make sure you have:
-- Created a [project](../getting_started_part_two.md) in GitLab
+- Created a [project](../index.md#getting-started) in GitLab
containing your website's source code.
- Acquired a domain (`example.com`) and added a [DNS entry](index.md)
pointing it to your Pages website.
diff --git a/doc/user/project/pages/getting_started_part_three.md b/doc/user/project/pages/getting_started_part_three.md
index 9bc9fe97fd3..4bf5300aa13 100644
--- a/doc/user/project/pages/getting_started_part_three.md
+++ b/doc/user/project/pages/getting_started_part_three.md
@@ -1 +1,5 @@
+---
+redirect_to: 'custom_domains_ssl_tls_certification/index.md'
+---
+
This document was moved to [another location](custom_domains_ssl_tls_certification/index.md).
diff --git a/doc/user/project/pages/index.md b/doc/user/project/pages/index.md
index 6c3b911d033..4f389716f08 100644
--- a/doc/user/project/pages/index.md
+++ b/doc/user/project/pages/index.md
@@ -124,3 +124,24 @@ If you are running a self-managed instance of GitLab (GitLab Community Edition a
[follow the administration steps](../../../administration/pages/index.md) to configure Pages.
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i> Watch a [video tutorial](https://www.youtube.com/watch?v=dD8c7WNcc6s) about how to get started with GitLab Pages administration.
+
+## Security for GitLab Pages
+
+If your username is `foo`, your GitLab Pages website is located at `foo.gitlab.io`.
+GitLab allows usernames to contain a `.`, so a user named `bar.foo` could create
+a GitLab Pages website `bar.foo.gitlab.io` that effectively is a subdomain of your
+`foo.gitlab.io` website. Be careful if you use JavaScript to set cookies for your website.
+The safe way to manually set cookies with JavaScript is to not specify the `domain` at all:
+
+```javascript
+// Safe: This cookie is only visible to foo.gitlab.io
+document.cookie = "key=value";
+
+// Unsafe: This cookie is visible to foo.gitlab.io and its subdomains,
+// regardless of the presence of the leading dot.
+document.cookie = "key=value;domain=.foo.gitlab.io";
+document.cookie = "key=value;domain=foo.gitlab.io";
+```
+
+This issue doesn't affect users with a custom domain, or users who don't set any
+cookies manually with JavaScript.
diff --git a/doc/user/project/quick_actions.md b/doc/user/project/quick_actions.md
index 518cf472b50..14ddd9dad6e 100644
--- a/doc/user/project/quick_actions.md
+++ b/doc/user/project/quick_actions.md
@@ -39,7 +39,7 @@ The following quick actions are applicable to descriptions, discussions and thre
| `/copy_metadata <!merge_request>` | ✓ | ✓ | | Copy labels and milestone from another merge request in the project. |
| `/copy_metadata <#issue>` | ✓ | ✓ | | Copy labels and milestone from another issue in the project. |
| `/create_merge_request <branch name>` | ✓ | | | Create a new merge request starting from the current issue. |
-| `/done` | ✓ | ✓ | ✓ | Mark To-Do as done. |
+| `/done` | ✓ | ✓ | ✓ | Mark to do as done. |
| `/due <date>` | ✓ | | | Set due date. Examples of valid `<date>` include `in 2 days`, `this Friday` and `December 31st`. |
| `/duplicate <#issue>` | ✓ | | | Close this issue and mark as a duplicate of another issue. **(CORE)** Also, mark both as related. **(STARTER)** |
| `/epic <epic>` | ✓ | | | Add to epic `<epic>`. The `<epic>` value should be in the format of `&epic`, `group&epic`, or a URL to an epic. **(PREMIUM)** |
@@ -74,7 +74,7 @@ The following quick actions are applicable to descriptions, discussions and thre
| `/tableflip <comment>` | ✓ | ✓ | ✓ | Append the comment with `(╯°□°)╯︵ ┻━┻`. |
| `/target_branch <local branch name>` | | ✓ | | Set target branch. |
| `/title <new title>` | ✓ | ✓ | ✓ | Change title. |
-| `/todo` | ✓ | ✓ | ✓ | Add a To-Do. |
+| `/todo` | ✓ | ✓ | ✓ | Add a to do. |
| `/unassign @user1 @user2` | ✓ | ✓ | | Remove specific assignees. **(STARTER)** |
| `/unassign` | ✓ | ✓ | | Remove all assignees. |
| `/unlabel ~label1 ~label2` or `/remove_label ~label1 ~label2` | ✓ | ✓ | ✓ | Remove all or specific labels. |
diff --git a/doc/user/project/repository/reducing_the_repo_size_using_git.md b/doc/user/project/repository/reducing_the_repo_size_using_git.md
index 28fdda07b05..ad79fd8a8f9 100644
--- a/doc/user/project/repository/reducing_the_repo_size_using_git.md
+++ b/doc/user/project/repository/reducing_the_repo_size_using_git.md
@@ -19,7 +19,7 @@ over [`git filter-branch`](https://git-scm.com/docs/git-filter-branch) and
[BFG](https://rtyley.github.io/bfg-repo-cleaner/).
DANGER: **Danger:**
-Rewriting repository history is a destructive operation. Make sure to backup your repository before
+Rewriting repository history is a destructive operation. Make sure to back up your repository before
you begin. The best way back up a repository is to
[export the project](../settings/import_export.md#exporting-a-project-and-its-data).
@@ -230,6 +230,7 @@ This will:
- Run `git gc` against the repository to remove unreferenced objects. Repacking your repository will temporarily
cause the size of your repository to increase significantly, because the old pack files are not removed until the
new pack files have been created.
+- Unlink any unused LFS objects currently attached to your project, freeing up storage space.
- Recalculate the size of your repository on disk.
You will receive an email notification with the recalculated repository size after the cleanup has completed.
@@ -266,21 +267,20 @@ You can still:
- Create new issues.
- Clone the project.
-If you exceed the repository size limit, you might try to:
+If you exceed the repository size limit, you can:
1. Remove some data.
1. Make a new commit.
1. Push back to the repository.
-Perhaps you might also:
+If these actions are insufficient, you can also:
- Move some blobs to LFS.
- Remove some old dependency updates from history.
-Unfortunately, this workflow won't work. Deleting files in a commit doesn't actually reduce the size
-of the repository because the earlier commits and blobs still exist.
-
-What you need to do is rewrite history. We recommend the open-source community-maintained tool
+Unfortunately, this workflow doesn't work. Deleting files in a commit doesn't actually reduce the
+size of the repository, because the earlier commits and blobs still exist. Instead, you must rewrite
+history. We recommend the open-source community-maintained tool
[`git filter-repo`](https://github.com/newren/git-filter-repo).
NOTE: **Note:**
diff --git a/doc/user/project/service_desk.md b/doc/user/project/service_desk.md
index e9b07f54b91..0a1b81a6359 100644
--- a/doc/user/project/service_desk.md
+++ b/doc/user/project/service_desk.md
@@ -10,8 +10,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/214839) to [GitLab Starter](https://about.gitlab.com/pricing/) in 13.0.
> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/215364) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.2.
-## Overview
-
Service Desk is a module that allows your team to connect directly
with any external party through email right inside of GitLab; no external tools required.
An ongoing conversation right where your software is built ensures that user feedback ends
@@ -129,7 +127,7 @@ in the email, `%{ISSUE_PATH}` placeholder which will be replaced by
You can customize the email display name. Emails sent from Service Desk will have
this name in the `From` header. The default display name is `GitLab Support Bot`.
-### Using custom email address
+### Using custom email address **(CORE ONLY)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/2201) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.0.
diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md
index 395d4bf30c5..9902f208468 100644
--- a/doc/user/project/settings/index.md
+++ b/doc/user/project/settings/index.md
@@ -270,7 +270,7 @@ To restore a project marked for deletion:
Forking is a great way to [contribute to a project](../repository/forking_workflow.md)
of which you're not a member.
If you want to use the fork for yourself and don't need to send
-[merge requests](../merge_requests.md) to the upstream project,
+[merge requests](../merge_requests/index.md) to the upstream project,
you can safely remove the fork relationship.
CAUTION: **Caution:**
diff --git a/doc/user/project/settings/project_access_tokens.md b/doc/user/project/settings/project_access_tokens.md
index 57cb610a2e9..8be2ac6185f 100644
--- a/doc/user/project/settings/project_access_tokens.md
+++ b/doc/user/project/settings/project_access_tokens.md
@@ -48,9 +48,10 @@ API calls made with a project access token are associated with the corresponding
These users will appear in **Members** but can not be modified.
Furthermore, the bot user can not be added to any other project.
-When the project access token is [revoked](#revoking-a-project-access-token) the bot user will be deleted and all
-records will be moved to a system-wide user with the username "Ghost User". For more information,
-see [Associated Records](../../profile/account/delete_account.md#associated-records).
+- The username is set to `project_{project_id}_bot` for the first access token, such as `project_123_bot`.
+- The username is set to `project_{project_id}_bot{bot_count}` for further access tokens, such as `project_123_bot1`.
+
+When the project access token is [revoked](#revoking-a-project-access-token) the bot user is then deleted and all records are moved to a system-wide user with the username "Ghost User". For more information, see [Associated Records](../../profile/account/delete_account.md#associated-records).
Project bot users are a [GitLab-created service account](../../../subscriptions/self_managed/index.md#choose-the-number-of-users), but count as a licensed seat.
These users will not count against your licensed seat in the future when [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/223695) is resolved.
diff --git a/doc/user/project/static_site_editor/img/front_matter_ui_v13_4.png b/doc/user/project/static_site_editor/img/front_matter_ui_v13_4.png
new file mode 100644
index 00000000000..89864858ed3
--- /dev/null
+++ b/doc/user/project/static_site_editor/img/front_matter_ui_v13_4.png
Binary files differ
diff --git a/doc/user/project/static_site_editor/index.md b/doc/user/project/static_site_editor/index.md
index ce14cefba92..4984152d4eb 100644
--- a/doc/user/project/static_site_editor/index.md
+++ b/doc/user/project/static_site_editor/index.md
@@ -14,6 +14,7 @@ description: "The static site editor enables users to edit content on static web
> - Markdown front matter hidden on the WYSIWYG editor [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216834) in GitLab 13.1.
> - Support for `*.md.erb` files [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/223171) in GitLab 13.2.
> - Non-Markdown content blocks uneditable on the WYSIWYG mode [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216836) in GitLab 13.3.
+> - Ability to edit page front matter [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/235921) in GitLab 13.4.
DANGER: **Danger:**
In GitLab 13.0, we [introduced breaking changes](https://gitlab.com/gitlab-org/gitlab/-/issues/213282)
@@ -60,6 +61,11 @@ click of a button:
![Static Site Editor](img/wysiwyg_editor_v13_3.png)
+You can also edit the page's front matter both in WYSIWYG mode via the side-drawer and in Markdown
+mode.
+
+![Editing page front matter in the Static Site Editor](img/front_matter_ui_v13_4.png)
+
When an editor submits their changes, in the background, GitLab automatically
creates a new branch, commits their changes, and opens a merge request. The
editor lands directly on the merge request, and then they can assign it to
@@ -90,6 +96,12 @@ From GitLab 13.1 onwards, the YAML front matter of Markdown files is hidden on t
WYSIWYG editor to avoid unintended changes. To edit it, use the Markdown editing mode, the regular
GitLab file editor, or the Web IDE.
+NOTE: **Note:**
+A new configuration file for the Static Site Editor was [introduced](https://gitlab.com/groups/gitlab-org/-/epics/4267)
+in GitLab 13.4. Beginning in 13.5, the `.gitlab/static-site-editor.yml` file will store additional
+configuration options for the editor. When the functionality of the existing `data/config.yml` file
+is replicated in the new configuration file, `data/config.yml` will be formally deprecated.
+
### Use the Static Site Editor to edit your content
For instance, suppose you are a recently hired technical writer at a large
diff --git a/doc/user/project/web_ide/index.md b/doc/user/project/web_ide/index.md
index 821b42af049..4da9b5f88ff 100644
--- a/doc/user/project/web_ide/index.md
+++ b/doc/user/project/web_ide/index.md
@@ -32,7 +32,7 @@ file path fragments to start seeing results.
## Syntax highlighting
As expected from an IDE, syntax highlighting for many languages within
-the Web IDE will make your direct editing even easier.
+the Web IDE makes your direct editing even easier.
The Web IDE currently provides:
@@ -79,7 +79,7 @@ which apply to the entire Web IDE screen.
> - Support for validation based on custom schemas [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/226982) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.4.
The Web IDE provides validation support for certain JSON and YAML files using schemas
-based on the [JSON Schema Store](https://www.schemastore.org/json/).
+based on the [JSON Schema Store](https://www.schemastore.org/json/).
### Predefined schemas
@@ -143,7 +143,7 @@ The Web IDE supports configuration of certain editor settings by using
[`.editorconfig` files](https://editorconfig.org/). When opening a file, the
Web IDE looks for a file named `.editorconfig` in the current directory
and all parent directories. If a configuration file is found and has settings
-that match the file's path, these settings will be enforced on the opened file.
+that match the file's path, these settings are enforced on the opened file.
The Web IDE currently supports the following `.editorconfig` settings:
@@ -166,7 +166,7 @@ review the list of changed files.
Once you have finalized your changes, you can add a commit message, commit the
changes and directly create a merge request. In case you don't have write
-access to the selected branch, you will see a warning, but still be able to create
+access to the selected branch, you see a warning, but can still create
a new branch and start a merge request.
To discard a change in a particular file, click the **Discard changes** button on that
@@ -201,8 +201,7 @@ left.
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/19318) in [GitLab Core](https://about.gitlab.com/pricing/) 11.0.
To switch between your authored and assigned merge requests, click the
-dropdown in the top of the sidebar to open a list of merge requests. You will
-need to commit or discard all your changes before switching to a different merge
+dropdown in the top of the sidebar to open a list of merge requests. You need to commit or discard all your changes before switching to a different merge
request.
## Switching branches
@@ -211,7 +210,7 @@ request.
To switch between branches of the current project repository, click the dropdown
in the top of the sidebar to open a list of branches.
-You will need to commit or discard all your changes before switching to a
+You need to commit or discard all your changes before switching to a
different branch.
## Markdown editing
@@ -226,7 +225,7 @@ supports [GitLab Flavored Markdown](../../markdown.md#gitlab-flavored-markdown-g
You can also upload any local images by pasting them directly in the Markdown file.
The image is uploaded to the same directory and is named `image.png` by default.
If another file already exists with the same name, a numeric suffix is automatically
-added to the file name.
+added to the filename.
## Live Preview
@@ -249,7 +248,7 @@ The Live Preview feature needs to be enabled in the GitLab instances
admin settings. Live Preview is enabled for all projects on
GitLab.com
-![Admin Live Preview setting](img/admin_live_preview_v13_0.png)
+![Administrator Live Preview setting](img/admin_live_preview_v13_0.png)
Once you have done that, you can preview projects with a `package.json` file and
a `main` entry point inside the Web IDE. An example `package.json` is shown
@@ -292,7 +291,7 @@ to work:
[enabled](../../../administration/integration/terminal.md#enabling-and-disabling-terminal-support). **(ULTIMATE ONLY)**
If you have the terminal open and the job has finished with its tasks, the
-terminal will block the job from finishing for the duration configured in
+terminal blocks the job from finishing for the duration configured in
[`[session_server].session_timeout`](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-session_server-section)
until you close the terminal window.
@@ -308,15 +307,15 @@ In order to enable the Web IDE terminals you need to create the file
file is fairly similar to the [CI configuration file](../../../ci/yaml/README.md)
syntax but with some restrictions:
-- No global blocks can be defined (ie: `before_script` or `after_script`)
+- No global blocks can be defined (i.e., `before_script` or `after_script`)
- Only one job named `terminal` can be added to this file.
- Only the keywords `image`, `services`, `tags`, `before_script`, `script`, and
`variables` are allowed to be used to configure the job.
- To connect to the interactive terminal, the `terminal` job must be still alive
- and running, otherwise the terminal won't be able to connect to the job's session.
+ and running, otherwise the terminal cannot connect to the job's session.
By default the `script` keyword has the value `sleep 60` to prevent
the job from ending and giving the Web IDE enough time to connect. This means
- that, if you override the default `script` value, you'll have to add a command
+ that, if you override the default `script` value, you have to add a command
which would keep the job running, like `sleep`.
In the code below there is an example of this configuration file:
@@ -333,40 +332,39 @@ terminal:
NODE_ENV: "test"
```
-Once the terminal has started, the console will be displayed and we could access
+Once the terminal has started, the console is displayed and we could access
the project repository files.
**Important**. The terminal job is branch dependent. This means that the
-configuration file used to trigger and configure the terminal will be the one in
+configuration file used to trigger and configure the terminal is the one in
the selected branch of the Web IDE.
-If there is no configuration file in a branch, an error message will be shown.
+If there is no configuration file in a branch, an error message is shown.
### Running interactive terminals in the Web IDE
-If Interactive Terminals are available for the current user, the **Terminal** button
-will be visible in the right sidebar of the Web IDE. Click this button to open
+If Interactive Terminals are available for the current user, the **Terminal** button is visible in the right sidebar of the Web IDE. Click this button to open
or close the terminal tab.
-Once open, the tab will show the **Start Web Terminal** button. This button may
+Once open, the tab shows the **Start Web Terminal** button. This button may
be disabled if the environment is not configured correctly. If so, a status
-message will describe the issue. Here are some reasons why **Start Web Terminal**
+message describes the issue. Here are some reasons why **Start Web Terminal**
may be disabled:
- `.gitlab/.gitlab-webide.yml` does not exist or is set up incorrectly.
- No active private runners are available for the project.
-If active, clicking the **Start Web Terminal** button will load the terminal view
+If active, clicking the **Start Web Terminal** button loads the terminal view
and start connecting to the runner's terminal. At any time, the **Terminal** tab
-can be closed and reopened and the state of the terminal will not be affected.
+can be closed and reopened and the state of the terminal is not affected.
When the terminal is started and is successfully connected to the runner, then the
-runner's shell prompt will appear in the terminal. From here, you can enter
-commands that will be executed within the runner's environment. This is similar
+runner's shell prompt appears in the terminal. From here, you can enter
+commands executed within the runner's environment. This is similar
to running commands in a local terminal or through SSH.
While the terminal is running, it can be stopped by clicking **Stop Terminal**.
-This will disconnect the terminal and stop the runner's terminal job. From here,
+This disconnects the terminal and stops the runner's terminal job. From here,
click **Restart Terminal** to start a new terminal session.
### File syncing to web terminal
@@ -408,14 +406,14 @@ terminal:
more information.
- `$CI_PROJECT_DIR` is a
[predefined environment variable](../../../ci/variables/predefined_variables.md)
- for GitLab Runner. This is where your project's repository will be.
+ for GitLab Runners. This is where your project's repository resides.
Once you have configured the web terminal for file syncing, then when the web
-terminal is started, a **Terminal** status will be visible in the status bar.
+terminal is started, a **Terminal** status is visible in the status bar.
![Web IDE Client Side Evaluation](img/terminal_status.png)
-Changes made to your files via the Web IDE will sync to the running terminal
+Changes made to your files via the Web IDE sync to the running terminal
when:
- <kbd>Ctrl</kbd> + <kbd>S</kbd> (or <kbd>Cmd</kbd> + <kbd>S</kbd> on Mac)
@@ -425,9 +423,12 @@ when:
### Limitations
-Interactive Terminals is in a beta phase and will continue to be improved upon in upcoming
-releases. In the meantime, please note that the user is limited to having only one
-active terminal at a time.
+The Web IDE has a few limitations:
+
+- Interactive Terminals is in a beta phase and continues to be improved in upcoming releases. In the meantime, please note that the user is limited to having only one
+ active terminal at a time.
+
+- LFS files can be rendered and displayed but they cannot be updated and committed using the Web IDE. If an LFS file is modified and pushed to the repository, the LFS pointer in the repository will be overwritten with the modified LFS file content.
### Troubleshooting
diff --git a/doc/user/project/wiki/index.md b/doc/user/project/wiki/index.md
index 40ef5e216fd..86d7cfa5d16 100644
--- a/doc/user/project/wiki/index.md
+++ b/doc/user/project/wiki/index.md
@@ -163,48 +163,13 @@ Similar to versioned diff file views, you can see the changes made in a given Wi
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/14902) in **GitLab 12.10.**
> - Git events were [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216014) in **GitLab 13.0.**
-> - It's enabled on GitLab.com.
-> - Git access activity creation is managed by a feature flag.
-> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-wiki-events-in-git). **(CORE ONLY)**
+> - [Feature flag for Git events was removed](https://gitlab.com/gitlab-org/gitlab/-/issues/258665) in **GitLab 13.5**
Wiki events (creation, deletion, and updates) are tracked by GitLab and
displayed on the [user profile](../../profile/index.md#user-profile),
[group](../../group/index.md#view-group-activity),
and [project](../index.md#project-activity) activity pages.
-### Enable or disable Wiki events in Git **(CORE ONLY)**
-
-Tracking wiki events through Git is under development and not ready for production use. It is
-deployed behind a feature flag that is **disabled by default**.
-[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
-can enable it for your instance.
-
-To enable it:
-
-```ruby
-Feature.enable(:wiki_events_on_git_push)
-```
-
-To enable for just a particular project:
-
-```ruby
-project = Project.find_by_full_path('your-group/your-project')
-Feature.enable(:wiki_events_on_git_push, project)
-```
-
-To disable it:
-
-```ruby
-Feature.disable(:wiki_events_on_git_push)
-```
-
-To disable for just a particular project:
-
-```ruby
-project = Project.find_by_full_path('your-group/your-project')
-Feature.disable(:wiki_events_on_git_push, project)
-```
-
## Adding and editing wiki pages locally
Since wikis are based on Git repositories, you can clone them locally and edit
diff --git a/doc/user/search/advanced_global_search.md b/doc/user/search/advanced_global_search.md
index 53ec8b35631..3152229c39f 100644
--- a/doc/user/search/advanced_global_search.md
+++ b/doc/user/search/advanced_global_search.md
@@ -1,6 +1,6 @@
---
-stage: Create
-group: Editor
+stage: Enablement
+group: Global Search
info: "To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers"
type: reference
---
diff --git a/doc/user/search/advanced_search_syntax.md b/doc/user/search/advanced_search_syntax.md
index 804d4c540ac..5817852dd97 100644
--- a/doc/user/search/advanced_search_syntax.md
+++ b/doc/user/search/advanced_search_syntax.md
@@ -1,6 +1,6 @@
---
-stage: Create
-group: Editor
+stage: Enablement
+group: Global Search
info: "To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers"
type: reference
---
@@ -62,6 +62,7 @@ The Advanced Search Syntax also supports the use of filters. The available filte
- filename: Filters by filename. You can use the glob (`*`) operator for fuzzy matching.
- path: Filters by path. You can use the glob (`*`) operator for fuzzy matching.
- extension: Filters by extension in the filename. Please write the extension without a leading dot. Exact match only.
+- blob: Filters by Git `object-id`. Exact match only.
To use them, simply add them to your query in the format `<filter_name>:<value>` without
any spaces between the colon (`:`) and the value.
@@ -83,6 +84,7 @@ Filters can be inversed to **filter out** results from the result set, by prefix
- `-filename`
- `-path`
- `-extension`
+- `-blob`
Examples:
diff --git a/doc/user/search/img/basic_search.png b/doc/user/search/img/basic_search.png
new file mode 100644
index 00000000000..234805d5a4f
--- /dev/null
+++ b/doc/user/search/img/basic_search.png
Binary files differ
diff --git a/doc/user/search/img/basic_search_results.png b/doc/user/search/img/basic_search_results.png
new file mode 100644
index 00000000000..52aa2e3fe6c
--- /dev/null
+++ b/doc/user/search/img/basic_search_results.png
Binary files differ
diff --git a/doc/user/search/index.md b/doc/user/search/index.md
index 475a72385ac..57981205691 100644
--- a/doc/user/search/index.md
+++ b/doc/user/search/index.md
@@ -129,14 +129,6 @@ characters to begin your search. For example, if you want to search for
issues that have the assignee "Simone Presley", you'll need to type at
least "Sim" before autocomplete gives any relevant results.
-## Code search
-
-To search through code or other documents in a single project, you can use
-the search field on the top-right of your screen while the project page is open.
-
-![code search dropdown](img/project_search_dropdown.png)
-![code search results](img/project_code_search.png)
-
## Search history
You can view recent searches by clicking on the little arrow-clock icon, which is to the left of the search input. Click the search entry to run that search again. This feature is available for issues and merge requests. Searches are stored locally in your browser.
@@ -155,24 +147,6 @@ Some filters can be added multiple times. These include but are not limited to a
![multiple assignees filtering](img/multiple_assignees.png)
-## Shortcut
-
-You'll also find a shortcut on the search field on the top-right of the project's dashboard to
-quickly access issues and merge requests created or assigned to you within that project:
-
-![search per project - shortcut](img/project_search.png)
-
-### Autocomplete suggestions
-
-You can also type in this search bar to see autocomplete suggestions for:
-
-- Projects and groups
-- Various help pages (try and type **API help**)
-- Project feature pages (try and type **milestones**)
-- Various settings pages (try and type **user settings**)
-- Recently viewed issues (try and type some word from the title of a recently viewed issue)
-- Recently viewed merge requests (try and type some word from the title of a recently merge request)
-
## To-Do List
Your [To-Do List](../todos.md#gitlab-to-do-list) can be searched by "to do" and "done".
@@ -219,6 +193,59 @@ and **Labels**, select multiple issues to add to a list of your choice:
![search and select issues to add to board](img/search_issues_board.png)
+## Shortcut
+
+You'll find a shortcut on the search field on the top-right of the project's dashboard to
+quickly access issues and merge requests created or assigned to you within that project:
+
+![search per project - shortcut](img/project_search.png)
+
+### Autocomplete suggestions
+
+You can also type in this search bar to see autocomplete suggestions for:
+
+- Projects and groups
+- Various help pages (try and type **API help**)
+- Project feature pages (try and type **milestones**)
+- Various settings pages (try and type **user settings**)
+- Recently viewed issues (try and type some word from the title of a recently viewed issue)
+- Recently viewed merge requests (try and type some word from the title of a recently merge request)
+
+## Basic search
+
+The Basic search in GitLab is a global search service that allows you to search
+across the entire GitLab instance, within a group, or a single project. Basic search is
+backed by the database and allows searching in:
+
+- Projects
+- Issues
+- Merge requests
+- Milestones
+- Users
+- Code (Project only)
+- Comments (Project only)
+- Commits (Project only)
+- Wiki (Project only)
+
+To start a search, type into the search bar on the top-right of the screen. You can always search
+in all GitLab and may also see the options to search within a group or project if you are in the
+group or project dashboard.
+
+![basic search](img/basic_search.png)
+
+Once the results are returned, you can modify the search, select a different type of data to
+search, or choose a specific group or project.
+
+![basic_search_results](img/basic_search_results.png)
+
+### Code search
+
+To search through code or other documents in a single project, you can use
+the search field on the top-right of your screen while the project page is open.
+
+![code search dropdown](img/project_search_dropdown.png)
+![code search results](img/project_code_search.png)
+
## Advanced Search **(STARTER)**
Leverage Elasticsearch for faster, more advanced code search across your entire
diff --git a/doc/user/snippets.md b/doc/user/snippets.md
index 15391f034a8..6335460ef96 100644
--- a/doc/user/snippets.md
+++ b/doc/user/snippets.md
@@ -131,8 +131,8 @@ There are two main ways of how you can discover snippets in GitLab.
For exploring all snippets that are visible to you, you can go to the Snippets
dashboard of your GitLab instance via the top navigation. For GitLab.com you can
-find it [here](https://gitlab.com/dashboard/snippets). This navigates you to an
-overview that shows snippets you created and allows you to explore all snippets.
+navigate to an [overview]((https://gitlab.com/dashboard/snippets)) that shows snippets
+you created and allows you to explore all snippets.
If you want to discover snippets that belong to a specific project, you can navigate
to the Snippets page via the left side navigation on the project page.
diff --git a/doc/user/todos.md b/doc/user/todos.md
index 1fca3c0ab64..46f589c1b11 100644
--- a/doc/user/todos.md
+++ b/doc/user/todos.md
@@ -15,7 +15,7 @@ spend your time. This can include taking an action, or keeping track of things
do your work, being able to get started quickly is important.
Your *To-Do List* offers a chronological list of items waiting for your input
-(known as *to-dos*) in a single dashboard.
+(known as *to do items*) in a single dashboard.
The To-Do List supports tracking [actions](#what-triggers-a-to-do) related to
the following:
@@ -26,18 +26,18 @@ the following:
![to-do screenshot showing a list of items to check on](img/todos_index.png)
-You can access your To-Do List by clicking the **{task-done}** To-Do List icon
-next to the search bar in the top navigation. If the to-do item count is:
+You can access your To-Do List by clicking the To-Do List icon (**{task-done}**)
+next to the search bar in the top navigation. If the to do item count is:
-- *Less than 100*, the number in blue is the number of to-do items.
+- *Less than 100*, the number in blue is the number of to do items.
- *100 or more*, the number displays as 99+. The exact number displays in the
To-Do List.
![To Do icon](img/todos_icon.png)
-## What triggers a to-do
+## What triggers a to do
-A to-do item appears on your To-Do List when:
+A to do item appears on your To-Do List when:
- An issue or merge request is assigned to you.
- You're `@mentioned` in the description or comment of an issue or merge request
@@ -57,23 +57,23 @@ A to-do item appears on your To-Do List when:
and you're the user that added it. **(PREMIUM)**
When several trigger actions occur for the same user on the same object (for
-example, an issue), GitLab displays only the first action as a single to-do
+example, an issue), GitLab displays only the first action as a single to do
item.
-To-do triggers aren't affected by [GitLab notification email settings](profile/notifications.md).
+To do item triggers aren't affected by [GitLab notification email settings](profile/notifications.md).
NOTE: **Note:**
-When a user no longer has access to a resource related to a to-do (such as an
-issue, merge request, project, or group), for security reasons GitLab deletes
-any related to-do items within the next hour. Deletion is delayed to prevent
-data loss, in the case where a user's access is accidentally revoked.
+When a user no longer has access to a resource related to a to do item (such as
+an issue, merge request, project, or group), for security reasons GitLab
+deletes any related to do items within the next hour. Deletion is delayed to
+prevent data loss, in the case where a user's access is accidentally revoked.
-### Directly addressing a to-do
+### Directly addressing a to do
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/7926) in GitLab 9.0.
-If you're mentioned at the start of a line, the to-do you receive will be listed
-as *directly addressed*. For example, in the following comment:
+If you're mentioned at the start of a line, the to do item you receive will be
+listed as *directly addressed*. For example, in the following comment:
```markdown
@alice What do you think? cc: @bob
@@ -87,23 +87,23 @@ as *directly addressed*. For example, in the following comment:
@erin @frank thank you!
```
-The people receiving directly addressed to-do items are `@alice`, `@erin`, and
-`@frank`. Directly addressed to-do items only differ from mentions in their type
+The people receiving directly addressed to do items are `@alice`, `@erin`, and
+`@frank`. Directly addressed to do items only differ from mentions in their type
for filtering purposes; otherwise, they appear as normal.
-### Manually creating a to-do
+### Manually creating a to do
You can add an issue or merge request (or epic **(ULTIMATE)**) to your
To-Do List by clicking its **Add a To Do** button.
![Adding a To Do from the issuable sidebar](img/todos_add_todo_sidebar.png)
-## Marking a to-do as done
+## Marking a to do as done
Any action to an issue or merge request (or epic **(ULTIMATE)**) will mark its
-corresponding to-do as done.
+corresponding to do item as done.
-Actions that dismiss to-do items include:
+Actions that dismiss to do items include:
- Changing the assignee
- Changing the milestone
@@ -111,27 +111,28 @@ Actions that dismiss to-do items include:
- Commenting on the issue
Your To-Do List is personal, and items are only marked as done if you take
-action. If you close the issue or merge request, your to-do is marked as done.
+action. If you close the issue or merge request, your to do item is marked as
+done.
To prevent other users from closing issues without you being notified, if
someone else closes, merges, or takes action on an issue or merge request (or
-epic **(ULTIMATE)**), your to-do will remain pending.
+epic **(ULTIMATE)**), your to do item remains pending.
-There's just one to-do for each of these, so mentioning a user many times in an
-issue will only trigger one to-do item.
+There's just one to do item for each of these, so mentioning a user many times
+in an issue only triggers one to do item.
-If no action is needed, you can manually mark the to-do as done by clicking its
-corresponding **Done** button to have GitLab remove the item from your
-To-Do List.
+If no action is needed, you can manually mark the to do item as done by
+clicking its corresponding **Done** button to have GitLab remove the item from
+your To-Do List.
-![A to-do in the To-Do List](img/todos_todo_list_item.png)
+![A to do in the To-Do List](img/todos_todo_list_item.png)
-You can also mark a to-do as done by clicking the **Mark as done** button in the
-sidebar of an issue or merge request (or epic **(ULTIMATE)**).
+You can also mark a to do item as done by clicking the **Mark as done** button
+in the sidebar of an issue or merge request (or epic **(ULTIMATE)**).
![Mark as done from the issuable sidebar](img/todos_mark_done_sidebar.png)
-You can mark all your to-do items as done at once by clicking the
+You can mark all your to do items as done at once by clicking the
**Mark all as done** button.
## Filtering your To-Do List
@@ -142,9 +143,9 @@ You can use the following types of filters with your To-Do List:
| ------- | ---------------------------------------------------------------- |
| Project | Filter by project. |
| Group | Filter by group. |
-| Author | Filter by the author that triggered the To Do. |
+| Author | Filter by the author that triggered the to do. |
| Type | Filter by issue, merge request, design, or epic. **(ULTIMATE)** |
-| Action | Filter by the action that triggered the To Do. |
+| Action | Filter by the action that triggered the to do. |
You can also filter by more than one of these at the same time. The previously
described [triggering actions](#what-triggers-a-to-do) include:
diff --git a/geo_architecture.png b/geo_architecture.png
new file mode 100644
index 00000000000..d1ad3d1d93d
--- /dev/null
+++ b/geo_architecture.png
Binary files differ
diff --git a/haml_lint/linter/documentation_links.rb b/haml_lint/linter/documentation_links.rb
index f8e0eec5cdc..a36b095bc11 100644
--- a/haml_lint/linter/documentation_links.rb
+++ b/haml_lint/linter/documentation_links.rb
@@ -13,7 +13,7 @@ module HamlLint
DOCS_DIRECTORY = File.join(File.expand_path('../..', __dir__), 'doc')
HELP_PATH_LINK_PATTERN = <<~PATTERN
- `(send nil? :help_page_path $...)
+ `(send nil? {:help_page_url :help_page_path} $...)
PATTERN
MARKDOWN_HEADER = %r{\A\#{1,6}\s+(?<header>.+)\Z}.freeze
diff --git a/lib/api/admin/instance_clusters.rb b/lib/api/admin/instance_clusters.rb
index 8208d10c089..0db2321199a 100644
--- a/lib/api/admin/instance_clusters.rb
+++ b/lib/api/admin/instance_clusters.rb
@@ -37,6 +37,7 @@ module API
requires :name, type: String, desc: 'Cluster name'
optional :enabled, type: Boolean, default: true, desc: 'Determines if cluster is active or not, defaults to true'
optional :environment_scope, default: '*', type: String, desc: 'The associated environment to the cluster'
+ optional :namespace_per_environment, default: true, type: Boolean, desc: 'Deploy each environment to a separate Kubernetes namespace'
optional :domain, type: String, desc: 'Cluster base domain'
optional :management_project_id, type: Integer, desc: 'The ID of the management project'
optional :managed, type: Boolean, default: true, desc: 'Determines if GitLab will manage namespaces and service accounts for this cluster, defaults to true'
@@ -70,6 +71,7 @@ module API
optional :name, type: String, desc: 'Cluster name'
optional :enabled, type: Boolean, desc: 'Enable or disable Gitlab\'s connection to your Kubernetes cluster'
optional :environment_scope, type: String, desc: 'The associated environment to the cluster'
+ optional :namespace_per_environment, default: true, type: Boolean, desc: 'Deploy each environment to a separate Kubernetes namespace'
optional :domain, type: String, desc: 'Cluster base domain'
optional :management_project_id, type: Integer, desc: 'The ID of the management project'
optional :platform_kubernetes_attributes, type: Hash, desc: %q(Platform Kubernetes data) do
diff --git a/lib/api/api.rb b/lib/api/api.rb
index b37751e1b47..546d726243e 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -196,6 +196,8 @@ module API
mount ::API::ComposerPackages
mount ::API::ConanProjectPackages
mount ::API::ConanInstancePackages
+ mount ::API::DebianGroupPackages
+ mount ::API::DebianProjectPackages
mount ::API::MavenPackages
mount ::API::NpmPackages
mount ::API::GenericPackages
@@ -216,6 +218,7 @@ module API
mount ::API::ProjectStatistics
mount ::API::ProjectTemplates
mount ::API::Terraform::State
+ mount ::API::Terraform::StateVersion
mount ::API::ProtectedBranches
mount ::API::ProtectedTags
mount ::API::Releases
@@ -236,6 +239,7 @@ module API
mount ::API::Templates
mount ::API::Todos
mount ::API::Triggers
+ mount ::API::Unleash
mount ::API::UsageData
mount ::API::UserCounts
mount ::API::Users
@@ -245,6 +249,7 @@ module API
end
mount ::API::Internal::Base
+ mount ::API::Internal::Lfs
mount ::API::Internal::Pages
mount ::API::Internal::Kubernetes
diff --git a/lib/api/boards_responses.rb b/lib/api/boards_responses.rb
index 68497a08fb8..6a86c02bf4a 100644
--- a/lib/api/boards_responses.rb
+++ b/lib/api/boards_responses.rb
@@ -45,7 +45,7 @@ module API
def destroy_list(list)
destroy_conditionally!(list) do |list|
service = ::Boards::Lists::DestroyService.new(board_parent, current_user)
- unless service.execute(list)
+ if service.execute(list).error?
render_api_error!({ error: 'List could not be deleted!' }, 400)
end
end
diff --git a/lib/api/ci/runner.rb b/lib/api/ci/runner.rb
index 08903dce3dc..e293c299d75 100644
--- a/lib/api/ci/runner.rb
+++ b/lib/api/ci/runner.rb
@@ -181,6 +181,7 @@ module API
.new(job, declared_params(include_missing: false))
service.execute.then do |result|
+ header 'X-GitLab-Trace-Update-Interval', result.backoff
status result.status
end
end
diff --git a/lib/api/commits.rb b/lib/api/commits.rb
index 20877fb5c5f..3097bcc0ef1 100644
--- a/lib/api/commits.rb
+++ b/lib/api/commits.rb
@@ -62,19 +62,29 @@ module API
first_parent: first_parent,
order: order)
- commit_count =
- if all || path || before || after || first_parent
- user_project.repository.count_commits(ref: ref, path: path, before: before, after: after, all: all, first_parent: first_parent)
- else
- # Cacheable commit count.
- user_project.repository.commit_count_for_ref(ref)
- end
+ serializer = with_stats ? Entities::CommitWithStats : Entities::Commit
- paginated_commits = Kaminari.paginate_array(commits, total_count: commit_count)
+ if Feature.enabled?(:api_commits_without_count, user_project)
+ # This tells kaminari that there is 1 more commit after the one we've
+ # loaded, meaning there will be a next page, if the currently loaded set
+ # of commits is equal to the requested page size.
+ commit_count = offset + commits.size + 1
+ paginated_commits = Kaminari.paginate_array(commits, total_count: commit_count)
- serializer = with_stats ? Entities::CommitWithStats : Entities::Commit
+ present paginate(paginated_commits, exclude_total_headers: true), with: serializer
+ else
+ commit_count =
+ if all || path || before || after || first_parent
+ user_project.repository.count_commits(ref: ref, path: path, before: before, after: after, all: all, first_parent: first_parent)
+ else
+ # Cacheable commit count.
+ user_project.repository.commit_count_for_ref(ref)
+ end
- present paginate(paginated_commits), with: serializer
+ paginated_commits = Kaminari.paginate_array(commits, total_count: commit_count)
+
+ present paginate(paginated_commits), with: serializer
+ end
end
desc 'Commit multiple file changes as one commit' do
diff --git a/lib/api/composer_packages.rb b/lib/api/composer_packages.rb
index 31d097c4bea..69e44ffcaf9 100644
--- a/lib/api/composer_packages.rb
+++ b/lib/api/composer_packages.rb
@@ -123,7 +123,7 @@ module API
bad_request!
end
- package_event('push_package')
+ track_package_event('push_package', :composer)
::Packages::Composer::CreatePackageService
.new(authorized_user_project, current_user, declared_params)
diff --git a/lib/api/conan_package_endpoints.rb b/lib/api/conan_package_endpoints.rb
index 445447cfcd2..9b6867a328b 100644
--- a/lib/api/conan_package_endpoints.rb
+++ b/lib/api/conan_package_endpoints.rb
@@ -246,7 +246,7 @@ module API
delete do
authorize!(:destroy_package, project)
- package_event('delete_package', category: 'API::ConanPackages')
+ track_package_event('delete_package', :conan, category: 'API::ConanPackages')
package.destroy
end
diff --git a/lib/api/debian_group_packages.rb b/lib/api/debian_group_packages.rb
new file mode 100644
index 00000000000..c56d84ed313
--- /dev/null
+++ b/lib/api/debian_group_packages.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module API
+ class DebianGroupPackages < Grape::API::Instance
+ params do
+ requires :id, type: String, desc: 'The ID of a group'
+ end
+
+ resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
+ before do
+ not_found! unless ::Feature.enabled?(:debian_packages, user_group)
+
+ authorize_read_package!(user_group)
+ end
+
+ namespace ':id/-/packages/debian' do
+ include DebianPackageEndpoints
+ end
+ end
+ end
+end
diff --git a/lib/api/debian_package_endpoints.rb b/lib/api/debian_package_endpoints.rb
new file mode 100644
index 00000000000..168b3ca7a4f
--- /dev/null
+++ b/lib/api/debian_package_endpoints.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+module API
+ module DebianPackageEndpoints
+ extend ActiveSupport::Concern
+
+ DISTRIBUTION_REGEX = %r{[a-zA-Z0-9][a-zA-Z0-9.-]*}.freeze
+ COMPONENT_REGEX = %r{[a-z-]+}.freeze
+ ARCHITECTURE_REGEX = %r{[a-z][a-z0-9]*}.freeze
+ LETTER_REGEX = %r{(lib)?[a-z0-9]}.freeze
+ PACKAGE_REGEX = API::NO_SLASH_URL_PART_REGEX
+ DISTRIBUTION_REQUIREMENTS = {
+ distribution: DISTRIBUTION_REGEX
+ }.freeze
+ COMPONENT_ARCHITECTURE_REQUIREMENTS = {
+ component: COMPONENT_REGEX,
+ architecture: ARCHITECTURE_REGEX
+ }.freeze
+ COMPONENT_LETTER_SOURCE_PACKAGE_REQUIREMENTS = {
+ component: COMPONENT_REGEX,
+ letter: LETTER_REGEX,
+ source_package: PACKAGE_REGEX
+ }.freeze
+ FILE_NAME_REQUIREMENTS = {
+ file_name: API::NO_SLASH_URL_PART_REGEX
+ }.freeze
+
+ included do
+ helpers ::API::Helpers::PackagesHelpers
+ helpers ::API::Helpers::Packages::BasicAuthHelpers
+
+ format :txt
+
+ rescue_from ArgumentError do |e|
+ render_api_error!(e.message, 400)
+ end
+
+ rescue_from ActiveRecord::RecordInvalid do |e|
+ render_api_error!(e.message, 400)
+ end
+
+ before do
+ require_packages_enabled!
+ end
+
+ params do
+ requires :distribution, type: String, desc: 'The Debian Codename', regexp: Gitlab::Regex.debian_distribution_regex
+ end
+
+ namespace 'dists/*distribution', requirements: DISTRIBUTION_REQUIREMENTS do
+ # GET {projects|groups}/:id/-/packages/debian/dists/*distribution/Release.gpg
+ desc 'The Release file signature' do
+ detail 'This feature was introduced in GitLab 13.5'
+ end
+
+ route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
+ get 'Release.gpg' do
+ not_found!
+ end
+
+ # GET {projects|groups}/:id/-/packages/debian/dists/*distribution/Release
+ desc 'The unsigned Release file' do
+ detail 'This feature was introduced in GitLab 13.5'
+ end
+
+ route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
+ get 'Release' do
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/5835#note_414103286
+ 'TODO Release'
+ end
+
+ # GET {projects|groups}/:id/-/packages/debian/dists/*distribution/InRelease
+ desc 'The signed Release file' do
+ detail 'This feature was introduced in GitLab 13.5'
+ end
+
+ route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
+ get 'InRelease' do
+ not_found!
+ end
+
+ params do
+ requires :component, type: String, desc: 'The Debian Component', regexp: Gitlab::Regex.debian_component_regex
+ requires :architecture, type: String, desc: 'The Debian Architecture', regexp: Gitlab::Regex.debian_architecture_regex
+ end
+
+ namespace ':component/binary-:architecture', requirements: COMPONENT_ARCHITECTURE_REQUIREMENTS do
+ # GET {projects|groups}/:id/-/packages/debian/dists/*distribution/:component/binary-:architecture/Packages
+ desc 'The binary files index' do
+ detail 'This feature was introduced in GitLab 13.5'
+ end
+
+ route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
+ get 'Packages' do
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/5835#note_414103286
+ 'TODO Packages'
+ end
+ end
+ end
+
+ params do
+ requires :component, type: String, desc: 'The Debian Component', regexp: Gitlab::Regex.debian_component_regex
+ requires :letter, type: String, desc: 'The Debian Classification (first-letter or lib-first-letter)'
+ requires :source_package, type: String, desc: 'The Debian Source Package Name', regexp: Gitlab::Regex.debian_package_name_regex
+ end
+
+ namespace 'pool/:component/:letter/:source_package', requirements: COMPONENT_LETTER_SOURCE_PACKAGE_REQUIREMENTS do
+ # GET {projects|groups}/:id/-/packages/debian/pool/:component/:letter/:source_package/:file_name
+ params do
+ requires :file_name, type: String, desc: 'The Debian File Name'
+ end
+ desc 'The package' do
+ detail 'This feature was introduced in GitLab 13.5'
+ end
+
+ route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
+ get ':file_name', requirements: FILE_NAME_REQUIREMENTS do
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/5835#note_414103286
+ 'TODO File'
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/debian_project_packages.rb b/lib/api/debian_project_packages.rb
new file mode 100644
index 00000000000..7cd796aac2b
--- /dev/null
+++ b/lib/api/debian_project_packages.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module API
+ class DebianProjectPackages < Grape::API::Instance
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ end
+
+ resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
+ before do
+ not_found! unless ::Feature.enabled?(:debian_packages, user_project)
+
+ authorize_read_package!
+ end
+
+ namespace ':id/-/packages/debian' do
+ include DebianPackageEndpoints
+
+ params do
+ requires :file_name, type: String, desc: 'The file name'
+ end
+
+ namespace 'incoming/:file_name', requirements: FILE_NAME_REQUIREMENTS do
+ # PUT {projects|groups}/:id/-/packages/debian/incoming/:file_name
+ params do
+ requires :file, type: ::API::Validations::Types::WorkhorseFile, desc: 'The package file to be published (generated by Multipart middleware)'
+ end
+
+ route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
+ put do
+ authorize_upload!(authorized_user_project)
+ bad_request!('File is too large') if authorized_user_project.actual_limits.exceeded?(:debian_max_file_size, params[:file].size)
+
+ track_package_event('push_package', :debian)
+
+ created!
+ rescue ObjectStorage::RemoteStoreError => e
+ Gitlab::ErrorTracking.track_exception(e, extra: { file_name: params[:file_name], project_id: authorized_user_project.id })
+
+ forbidden!
+ end
+
+ # PUT {projects|groups}/:id/-/packages/debian/incoming/:file_name/authorize
+ route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
+ post 'authorize' do
+ authorize_workhorse!(
+ subject: authorized_user_project,
+ has_length: false,
+ maximum_size: authorized_user_project.actual_limits.debian_max_file_size
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/entities/cluster.rb b/lib/api/entities/cluster.rb
index 4cb54e988ce..67459092a33 100644
--- a/lib/api/entities/cluster.rb
+++ b/lib/api/entities/cluster.rb
@@ -4,7 +4,7 @@ module API
module Entities
class Cluster < Grape::Entity
expose :id, :name, :created_at, :domain
- expose :provider_type, :platform_type, :environment_scope, :cluster_type
+ expose :provider_type, :platform_type, :environment_scope, :cluster_type, :namespace_per_environment
expose :user, using: Entities::UserBasic
expose :platform_kubernetes, using: Entities::Platform::Kubernetes
expose :provider_gcp, using: Entities::Provider::Gcp
diff --git a/lib/api/entities/container_registry.rb b/lib/api/entities/container_registry.rb
index cff627ab50a..c430b73580b 100644
--- a/lib/api/entities/container_registry.rb
+++ b/lib/api/entities/container_registry.rb
@@ -16,6 +16,7 @@ module API
expose :project_id
expose :location
expose :created_at
+ expose :expiration_policy_started_at, as: :cleanup_policy_started_at
expose :tags_count, if: -> (_, options) { options[:tags_count] }
expose :tags, using: Tag, if: -> (_, options) { options[:tags] }
end
diff --git a/lib/api/entities/feature_flag.rb b/lib/api/entities/feature_flag.rb
new file mode 100644
index 00000000000..82fdb20af00
--- /dev/null
+++ b/lib/api/entities/feature_flag.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module API
+ module Entities
+ class FeatureFlag < Grape::Entity
+ expose :name
+ expose :description
+ expose :active
+ expose :version, if: :feature_flags_new_version_enabled
+ expose :created_at
+ expose :updated_at
+ expose :scopes, using: FeatureFlag::LegacyScope
+ expose :strategies, using: FeatureFlag::Strategy, if: :feature_flags_new_version_enabled
+ end
+ end
+end
diff --git a/lib/api/entities/feature_flag/detailed_legacy_scope.rb b/lib/api/entities/feature_flag/detailed_legacy_scope.rb
new file mode 100644
index 00000000000..47078c1dfde
--- /dev/null
+++ b/lib/api/entities/feature_flag/detailed_legacy_scope.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module API
+ module Entities
+ class FeatureFlag < Grape::Entity
+ class DetailedLegacyScope < LegacyScope
+ expose :name
+ end
+ end
+ end
+end
diff --git a/lib/api/entities/feature_flag/legacy_scope.rb b/lib/api/entities/feature_flag/legacy_scope.rb
new file mode 100644
index 00000000000..7329f71c599
--- /dev/null
+++ b/lib/api/entities/feature_flag/legacy_scope.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module API
+ module Entities
+ class FeatureFlag < Grape::Entity
+ class LegacyScope < Grape::Entity
+ expose :id
+ expose :active
+ expose :environment_scope
+ expose :strategies
+ expose :created_at
+ expose :updated_at
+ end
+ end
+ end
+end
diff --git a/lib/api/entities/feature_flag/scope.rb b/lib/api/entities/feature_flag/scope.rb
new file mode 100644
index 00000000000..906fe718257
--- /dev/null
+++ b/lib/api/entities/feature_flag/scope.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module API
+ module Entities
+ class FeatureFlag < Grape::Entity
+ class Scope < Grape::Entity
+ expose :id
+ expose :environment_scope
+ end
+ end
+ end
+end
diff --git a/lib/api/entities/feature_flag/strategy.rb b/lib/api/entities/feature_flag/strategy.rb
new file mode 100644
index 00000000000..32699be0ee3
--- /dev/null
+++ b/lib/api/entities/feature_flag/strategy.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module API
+ module Entities
+ class FeatureFlag < Grape::Entity
+ class Strategy < Grape::Entity
+ expose :id
+ expose :name
+ expose :parameters
+ expose :scopes, using: FeatureFlag::Scope
+ end
+ end
+ end
+end
diff --git a/lib/api/entities/feature_flag/user_list.rb b/lib/api/entities/feature_flag/user_list.rb
new file mode 100644
index 00000000000..bc8b12ea22e
--- /dev/null
+++ b/lib/api/entities/feature_flag/user_list.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module API
+ module Entities
+ class FeatureFlag < Grape::Entity
+ class UserList < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id
+ expose :iid
+ expose :project_id
+ expose :created_at
+ expose :updated_at
+ expose :name
+ expose :user_xids
+
+ expose :path do |list|
+ project_feature_flags_user_list_path(list.project, list)
+ end
+
+ expose :edit_path do |list|
+ edit_project_feature_flags_user_list_path(list.project, list)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/entities/package.rb b/lib/api/entities/package.rb
index d903f50befa..b54f0e04a9d 100644
--- a/lib/api/entities/package.rb
+++ b/lib/api/entities/package.rb
@@ -7,7 +7,19 @@ module API
extend ::API::Entities::EntityHelpers
expose :id
- expose :name
+
+ expose :name do |package|
+ if package.conan?
+ package.conan_recipe
+ else
+ package.name
+ end
+ end
+
+ expose :conan_package_name, if: ->(package) { package.conan? } do |package|
+ package.name
+ end
+
expose :version
expose :package_type
diff --git a/lib/api/entities/unleash_feature.rb b/lib/api/entities/unleash_feature.rb
new file mode 100644
index 00000000000..8ee87d1fc11
--- /dev/null
+++ b/lib/api/entities/unleash_feature.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module API
+ module Entities
+ class UnleashFeature < Grape::Entity
+ expose :name
+ expose :description, unless: ->(feature) { feature.description.nil? }
+ expose :active, as: :enabled
+ expose :strategies do |flag|
+ flag.strategies.map do |strategy|
+ if legacy_strategy?(strategy)
+ UnleashLegacyStrategy.represent(strategy)
+ elsif gitlab_user_list_strategy?(strategy)
+ UnleashGitlabUserListStrategy.represent(strategy)
+ else
+ UnleashStrategy.represent(strategy)
+ end
+ end
+ end
+
+ private
+
+ def legacy_strategy?(strategy)
+ !strategy.respond_to?(:name)
+ end
+
+ def gitlab_user_list_strategy?(strategy)
+ strategy.name == ::Operations::FeatureFlags::Strategy::STRATEGY_GITLABUSERLIST
+ end
+ end
+ end
+end
diff --git a/lib/api/entities/unleash_gitlab_user_list_strategy.rb b/lib/api/entities/unleash_gitlab_user_list_strategy.rb
new file mode 100644
index 00000000000..5617f8002d9
--- /dev/null
+++ b/lib/api/entities/unleash_gitlab_user_list_strategy.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module API
+ module Entities
+ class UnleashGitlabUserListStrategy < Grape::Entity
+ expose :name do |_strategy|
+ ::Operations::FeatureFlags::Strategy::STRATEGY_USERWITHID
+ end
+ expose :parameters do |strategy|
+ { userIds: strategy.user_list.user_xids }
+ end
+ end
+ end
+end
diff --git a/lib/api/entities/unleash_legacy_strategy.rb b/lib/api/entities/unleash_legacy_strategy.rb
new file mode 100644
index 00000000000..5d5954f8da0
--- /dev/null
+++ b/lib/api/entities/unleash_legacy_strategy.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module API
+ module Entities
+ class UnleashLegacyStrategy < Grape::Entity
+ expose :name do |strategy|
+ strategy['name']
+ end
+ expose :parameters do |strategy|
+ strategy['parameters']
+ end
+ end
+ end
+end
diff --git a/lib/api/entities/unleash_strategy.rb b/lib/api/entities/unleash_strategy.rb
new file mode 100644
index 00000000000..7627ce3873c
--- /dev/null
+++ b/lib/api/entities/unleash_strategy.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module API
+ module Entities
+ class UnleashStrategy < Grape::Entity
+ expose :name
+ expose :parameters
+ end
+ end
+end
diff --git a/lib/api/entities/user_with_admin.rb b/lib/api/entities/user_with_admin.rb
index c225ade6eb6..ab7bc738ff8 100644
--- a/lib/api/entities/user_with_admin.rb
+++ b/lib/api/entities/user_with_admin.rb
@@ -8,3 +8,5 @@ module API
end
end
end
+
+API::Entities::UserWithAdmin.prepend_if_ee('EE::API::Entities::UserWithAdmin')
diff --git a/lib/api/generic_packages.rb b/lib/api/generic_packages.rb
index 98b8a40c7c9..a0c29ada950 100644
--- a/lib/api/generic_packages.rb
+++ b/lib/api/generic_packages.rb
@@ -2,6 +2,11 @@
module API
class GenericPackages < Grape::API::Instance
+ GENERIC_PACKAGES_REQUIREMENTS = {
+ package_name: API::NO_SLASH_URL_PART_REGEX,
+ file_name: API::NO_SLASH_URL_PART_REGEX
+ }.freeze
+
before do
require_packages_enabled!
authenticate!
@@ -17,17 +22,94 @@ module API
route_setting :authentication, job_token_allowed: true
namespace ':id/packages/generic' do
- get 'ping' do
- :pong
+ namespace ':package_name/*package_version/:file_name', requirements: GENERIC_PACKAGES_REQUIREMENTS do
+ desc 'Workhorse authorize generic package file' do
+ detail 'This feature was introduced in GitLab 13.5'
+ end
+
+ route_setting :authentication, job_token_allowed: true
+
+ params do
+ requires :package_name, type: String, desc: 'Package name', regexp: Gitlab::Regex.generic_package_name_regex, file_path: true
+ requires :package_version, type: String, desc: 'Package version', regexp: Gitlab::Regex.generic_package_version_regex
+ requires :file_name, type: String, desc: 'Package file name', regexp: Gitlab::Regex.generic_package_file_name_regex, file_path: true
+ end
+
+ put 'authorize' do
+ authorize_workhorse!(subject: project, maximum_size: project.actual_limits.generic_packages_max_file_size)
+ end
+
+ desc 'Upload package file' do
+ detail 'This feature was introduced in GitLab 13.5'
+ end
+
+ params do
+ requires :package_name, type: String, desc: 'Package name', regexp: Gitlab::Regex.generic_package_name_regex, file_path: true
+ requires :package_version, type: String, desc: 'Package version', regexp: Gitlab::Regex.generic_package_version_regex
+ requires :file_name, type: String, desc: 'Package file name', regexp: Gitlab::Regex.generic_package_file_name_regex, file_path: true
+ requires :file, type: ::API::Validations::Types::WorkhorseFile, desc: 'The package file to be published (generated by Multipart middleware)'
+ end
+
+ route_setting :authentication, job_token_allowed: true
+
+ put do
+ authorize_upload!(project)
+ bad_request!('File is too large') if max_file_size_exceeded?
+
+ track_event('push_package')
+
+ create_package_file_params = declared_params.merge(build: current_authenticated_job)
+ ::Packages::Generic::CreatePackageFileService
+ .new(project, current_user, create_package_file_params)
+ .execute
+
+ created!
+ rescue ObjectStorage::RemoteStoreError => e
+ Gitlab::ErrorTracking.track_exception(e, extra: { file_name: params[:file_name], project_id: project.id })
+
+ forbidden!
+ end
+
+ desc 'Download package file' do
+ detail 'This feature was introduced in GitLab 13.5'
+ end
+
+ params do
+ requires :package_name, type: String, desc: 'Package name', regexp: Gitlab::Regex.generic_package_name_regex, file_path: true
+ requires :package_version, type: String, desc: 'Package version', regexp: Gitlab::Regex.generic_package_version_regex
+ requires :file_name, type: String, desc: 'Package file name', regexp: Gitlab::Regex.generic_package_file_name_regex, file_path: true
+ end
+
+ route_setting :authentication, job_token_allowed: true
+
+ get do
+ authorize_read_package!(project)
+
+ package = ::Packages::Generic::PackageFinder.new(project).execute!(params[:package_name], params[:package_version])
+ package_file = ::Packages::PackageFileFinder.new(package, params[:file_name]).execute!
+
+ track_event('pull_package')
+
+ present_carrierwave_file!(package_file.file)
+ end
end
end
end
helpers do
include ::API::Helpers::PackagesHelpers
+ include ::API::Helpers::Packages::BasicAuthHelpers
def require_generic_packages_available!
- not_found! unless Feature.enabled?(:generic_packages, user_project)
+ not_found! unless Feature.enabled?(:generic_packages, project)
+ end
+
+ def project
+ authorized_user_project
+ end
+
+ def max_file_size_exceeded?
+ project.actual_limits.exceeded?(:generic_packages_max_file_size, params[:file].size)
end
end
end
diff --git a/lib/api/github/entities.rb b/lib/api/github/entities.rb
index c28a0b8eb7e..fe228c9a2d2 100644
--- a/lib/api/github/entities.rb
+++ b/lib/api/github/entities.rb
@@ -119,7 +119,9 @@ module API
expose :username, as: :login
expose :user_url, as: :url
expose :user_url, as: :html_url
- expose :avatar_url
+ expose :avatar_url do |user|
+ user.avatar_url(only_path: false)
+ end
private
diff --git a/lib/api/group_clusters.rb b/lib/api/group_clusters.rb
index ae41d9f13b8..77095ee62e0 100644
--- a/lib/api/group_clusters.rb
+++ b/lib/api/group_clusters.rb
@@ -41,6 +41,7 @@ module API
requires :name, type: String, desc: 'Cluster name'
optional :enabled, type: Boolean, default: true, desc: 'Determines if cluster is active or not, defaults to true'
optional :environment_scope, default: '*', type: String, desc: 'The associated environment to the cluster'
+ optional :namespace_per_environment, default: true, type: Boolean, desc: 'Deploy each environment to a separate Kubernetes namespace'
optional :domain, type: String, desc: 'Cluster base domain'
optional :management_project_id, type: Integer, desc: 'The ID of the management project'
optional :managed, type: Boolean, default: true, desc: 'Determines if GitLab will manage namespaces and service accounts for this cluster, defaults to true'
@@ -74,6 +75,7 @@ module API
optional :name, type: String, desc: 'Cluster name'
optional :domain, type: String, desc: 'Cluster base domain'
optional :environment_scope, type: String, desc: 'The associated environment to the cluster'
+ optional :namespace_per_environment, default: true, type: Boolean, desc: 'Deploy each environment to a separate Kubernetes namespace'
optional :management_project_id, type: Integer, desc: 'The ID of the management project'
optional :platform_kubernetes_attributes, type: Hash, desc: %q(Platform Kubernetes data) do
optional :api_url, type: String, desc: 'URL to access the Kubernetes API'
diff --git a/lib/api/group_container_repositories.rb b/lib/api/group_container_repositories.rb
index 25b3059f63b..5b6a3bd36cf 100644
--- a/lib/api/group_container_repositories.rb
+++ b/lib/api/group_container_repositories.rb
@@ -4,6 +4,8 @@ module API
class GroupContainerRepositories < Grape::API::Instance
include PaginationParams
+ helpers ::API::Helpers::PackagesHelpers
+
before { authorize_read_group_container_images! }
REPOSITORY_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS.merge(
@@ -27,7 +29,7 @@ module API
user: current_user, subject: user_group
).execute
- track_event('list_repositories')
+ track_package_event('list_repositories', :container)
present paginate(repositories), with: Entities::ContainerRegistry::Repository, tags: params[:tags], tags_count: params[:tags_count]
end
diff --git a/lib/api/groups.rb b/lib/api/groups.rb
index 813e41b4d39..efd4b22a591 100644
--- a/lib/api/groups.rb
+++ b/lib/api/groups.rb
@@ -29,7 +29,12 @@ module API
# rubocop: disable CodeReuse/ActiveRecord
def find_groups(params, parent_id = nil)
- find_params = params.slice(:all_available, :custom_attributes, :owned, :min_access_level)
+ find_params = params.slice(
+ :all_available,
+ :custom_attributes,
+ :owned, :min_access_level,
+ :include_parent_descendants
+ )
find_params[:parent] = if params[:top_level_only]
[nil]
@@ -309,6 +314,19 @@ module API
present_groups params, groups
end
+ desc 'Get a list of descendant groups of this group.' do
+ success Entities::Group
+ end
+ params do
+ use :group_list_params
+ use :with_custom_attributes
+ end
+ get ":id/descendant_groups" do
+ finder_params = declared_params(include_missing: false).merge(include_parent_descendants: true)
+ groups = find_groups(finder_params, params[:id])
+ present_groups params, groups
+ end
+
desc 'Transfer a project to the group namespace. Available only for admin.' do
success Entities::GroupDetail
end
diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb
index 1912a06682e..690160cd5ac 100644
--- a/lib/api/helpers.rb
+++ b/lib/api/helpers.rb
@@ -544,7 +544,6 @@ module API
feature_name = "usage_data_#{event_name}"
return unless Feature.enabled?(feature_name)
- return unless Gitlab::CurrentSettings.usage_ping_enabled?
Gitlab::UsageDataCounters::HLLRedisCounter.track_event(values, event_name)
rescue => error
diff --git a/lib/api/helpers/packages/conan/api_helpers.rb b/lib/api/helpers/packages/conan/api_helpers.rb
index dcbf933a4e1..934e18bdd0a 100644
--- a/lib/api/helpers/packages/conan/api_helpers.rb
+++ b/lib/api/helpers/packages/conan/api_helpers.rb
@@ -158,7 +158,7 @@ module API
conan_package_reference: params[:conan_package_reference]
).execute!
- package_event('pull_package', category: 'API::ConanPackages') if params[:file_name] == ::Packages::Conan::FileMetadatum::PACKAGE_BINARY
+ track_package_event('pull_package', :conan, category: 'API::ConanPackages') if params[:file_name] == ::Packages::Conan::FileMetadatum::PACKAGE_BINARY
present_carrierwave_file!(package_file.file)
end
@@ -169,7 +169,7 @@ module API
def track_push_package_event
if params[:file_name] == ::Packages::Conan::FileMetadatum::PACKAGE_BINARY && params[:file].size > 0 # rubocop: disable Style/ZeroLengthPredicate
- package_event('push_package', category: 'API::ConanPackages')
+ track_package_event('push_package', :conan, category: 'API::ConanPackages')
end
end
diff --git a/lib/api/helpers/packages/dependency_proxy_helpers.rb b/lib/api/helpers/packages/dependency_proxy_helpers.rb
index 254af7690a2..577ba97d68a 100644
--- a/lib/api/helpers/packages/dependency_proxy_helpers.rb
+++ b/lib/api/helpers/packages/dependency_proxy_helpers.rb
@@ -10,6 +10,7 @@ module API
def redirect_registry_request(forward_to_registry, package_type, options)
if forward_to_registry && redirect_registry_request_available?
+ track_event("#{package_type}_request_forward")
redirect(registry_url(package_type, options))
else
yield
diff --git a/lib/api/helpers/packages_helpers.rb b/lib/api/helpers/packages_helpers.rb
index 403f5ea3851..e1898d28ef7 100644
--- a/lib/api/helpers/packages_helpers.rb
+++ b/lib/api/helpers/packages_helpers.rb
@@ -40,7 +40,7 @@ module API
params = { has_length: has_length }
params[:maximum_size] = maximum_size unless has_length
- ::Packages::PackageFileUploader.workhorse_authorize(params)
+ ::Packages::PackageFileUploader.workhorse_authorize(**params)
end
def authorize_upload!(subject = user_project)
@@ -48,7 +48,8 @@ module API
require_gitlab_workhorse!
end
- def package_event(event_name, **args)
+ def track_package_event(event_name, scope, **args)
+ ::Packages::CreateEventService.new(nil, current_user, event_name: event_name, scope: scope).execute
track_event(event_name, **args)
end
end
diff --git a/lib/api/helpers/pagination.rb b/lib/api/helpers/pagination.rb
index a6ae9a87f98..227aec224e5 100644
--- a/lib/api/helpers/pagination.rb
+++ b/lib/api/helpers/pagination.rb
@@ -3,8 +3,8 @@
module API
module Helpers
module Pagination
- def paginate(relation)
- Gitlab::Pagination::OffsetPagination.new(self).paginate(relation)
+ def paginate(*args)
+ Gitlab::Pagination::OffsetPagination.new(self).paginate(*args)
end
end
end
diff --git a/lib/api/helpers/presentable.rb b/lib/api/helpers/presentable.rb
index a5186cc56ea..f05467ba40b 100644
--- a/lib/api/helpers/presentable.rb
+++ b/lib/api/helpers/presentable.rb
@@ -23,7 +23,7 @@ module API
def initialize(object, options = {})
options = options.opts_hash if options.is_a?(Grape::Entity::Options)
- super(object.present(options), options)
+ super(object.present(options), **options)
end
end
end
diff --git a/lib/api/helpers/runner.rb b/lib/api/helpers/runner.rb
index 34a2fb09875..1c85669a626 100644
--- a/lib/api/helpers/runner.rb
+++ b/lib/api/helpers/runner.rb
@@ -51,9 +51,7 @@ module API
job_forbidden!(job, 'Job is not running') unless job.running?
end
- if Gitlab::Ci::Features.job_heartbeats_runner?(job.project)
- job.runner&.heartbeat(get_runner_ip)
- end
+ job.runner&.heartbeat(get_runner_ip)
job
end
diff --git a/lib/api/helpers/services_helpers.rb b/lib/api/helpers/services_helpers.rb
index 4bceda51900..4adb27a7414 100644
--- a/lib/api/helpers/services_helpers.rb
+++ b/lib/api/helpers/services_helpers.rb
@@ -381,6 +381,12 @@ module API
type: String,
desc: 'The Hangouts Chat webhook. e.g. https://chat.googleapis.com/v1/spaces…'
},
+ {
+ required: false,
+ name: :branches_to_be_notified,
+ type: String,
+ desc: 'Branches for which notifications are to be sent'
+ },
chat_notification_events
].flatten,
'hipchat' => [
diff --git a/lib/api/helpers/settings_helpers.rb b/lib/api/helpers/settings_helpers.rb
index 65aec6ae2e7..451e578fdd6 100644
--- a/lib/api/helpers/settings_helpers.rb
+++ b/lib/api/helpers/settings_helpers.rb
@@ -12,6 +12,7 @@ module API
def self.optional_attributes
[*::ApplicationSettingsHelper.visible_attributes,
*::ApplicationSettingsHelper.external_authorization_service_attributes,
+ *::ApplicationSettingsHelper.deprecated_attributes,
:performance_bar_allowed_group_id].freeze
end
end
diff --git a/lib/api/internal/lfs.rb b/lib/api/internal/lfs.rb
new file mode 100644
index 00000000000..adedc38b847
--- /dev/null
+++ b/lib/api/internal/lfs.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module API
+ module Internal
+ class Lfs < Grape::API::Instance
+ use Rack::Sendfile
+
+ before { authenticate_by_gitlab_shell_token! }
+
+ helpers do
+ def find_lfs_object(lfs_oid)
+ LfsObject.find_by_oid(lfs_oid)
+ end
+ end
+
+ namespace 'internal' do
+ namespace 'lfs' do
+ desc 'Get LFS URL for object ID' do
+ detail 'This feature was introduced in GitLab 13.5.'
+ end
+ params do
+ requires :oid, type: String, desc: 'The object ID to query'
+ requires :gl_repository, type: String, desc: "Project identifier (e.g. project-1)"
+ end
+ get "/" do
+ lfs_object = find_lfs_object(params[:oid])
+
+ not_found! unless lfs_object
+
+ _, project, repo_type = Gitlab::GlRepository.parse(params[:gl_repository])
+
+ not_found! unless repo_type.project? && project
+ not_found! unless lfs_object.project_allowed_access?(project)
+
+ file = lfs_object.file
+
+ not_found! unless file&.exists?
+
+ content_type 'application/octet-stream'
+
+ if file.file_storage?
+ sendfile file.path
+ else
+ workhorse_headers = Gitlab::Workhorse.send_url(file.url)
+ header workhorse_headers[0], workhorse_headers[1]
+ env['api.format'] = :binary
+ body nil
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/lint.rb b/lib/api/lint.rb
index f7796b1e969..51a87f9433c 100644
--- a/lib/api/lint.rb
+++ b/lib/api/lint.rb
@@ -6,17 +6,22 @@ module API
desc 'Validation of .gitlab-ci.yml content'
params do
requires :content, type: String, desc: 'Content of .gitlab-ci.yml'
+ optional :include_merged_yaml, type: Boolean, desc: 'Whether or not to include merged CI config yaml in the response'
end
post '/lint' do
- error = Gitlab::Ci::YamlProcessor.validation_message(params[:content],
- user: current_user)
+ result = Gitlab::Ci::YamlProcessor.new(params[:content], user: current_user).execute
+ error = result.errors.first
status 200
- if error.blank?
- { status: 'valid', errors: [] }
- else
- { status: 'invalid', errors: [error] }
+ response = if error.blank?
+ { status: 'valid', errors: [] }
+ else
+ { status: 'invalid', errors: [error] }
+ end
+
+ response.tap do |response|
+ response[:merged_yaml] = result.merged_yaml if params[:include_merged_yaml]
end
end
end
diff --git a/lib/api/maven_packages.rb b/lib/api/maven_packages.rb
index e6d9a9a7c20..d1dd3babb8b 100644
--- a/lib/api/maven_packages.rb
+++ b/lib/api/maven_packages.rb
@@ -107,7 +107,7 @@ module API
when 'sha1'
package_file.file_sha1
else
- package_event('pull_package') if jar_file?(format)
+ track_package_event('pull_package', :maven) if jar_file?(format)
present_carrierwave_file_with_head_support!(package_file.file)
end
end
@@ -145,7 +145,7 @@ module API
when 'sha1'
package_file.file_sha1
else
- package_event('pull_package') if jar_file?(format)
+ track_package_event('pull_package', :maven) if jar_file?(format)
present_carrierwave_file_with_head_support!(package_file.file)
end
@@ -181,7 +181,7 @@ module API
when 'sha1'
package_file.file_sha1
else
- package_event('pull_package') if jar_file?(format)
+ track_package_event('pull_package', :maven) if jar_file?(format)
present_carrierwave_file_with_head_support!(package_file.file)
end
@@ -233,7 +233,7 @@ module API
when 'md5'
nil
else
- package_event('push_package') if jar_file?(format)
+ track_package_event('push_package', :maven) if jar_file?(format)
file_params = {
file: params[:file],
diff --git a/lib/api/npm_packages.rb b/lib/api/npm_packages.rb
index fca405b76b7..41238221aad 100644
--- a/lib/api/npm_packages.rb
+++ b/lib/api/npm_packages.rb
@@ -141,7 +141,7 @@ module API
package_file = ::Packages::PackageFileFinder
.new(package, params[:file_name]).execute!
- package_event('pull_package')
+ track_package_event('pull_package', package)
present_carrierwave_file!(package_file.file)
end
@@ -157,7 +157,7 @@ module API
put ':id/packages/npm/:package_name', requirements: NPM_ENDPOINT_REQUIREMENTS do
authorize_create_package!(user_project)
- package_event('push_package')
+ track_package_event('push_package', :npm)
created_package = ::Packages::Npm::CreatePackageService
.new(user_project, current_user, params.merge(build: current_authenticated_job)).execute
diff --git a/lib/api/nuget_packages.rb b/lib/api/nuget_packages.rb
index f84a3acbe6d..c0c6efb66b5 100644
--- a/lib/api/nuget_packages.rb
+++ b/lib/api/nuget_packages.rb
@@ -42,7 +42,7 @@ module API
def package_finder(finder_params = {})
::Packages::Nuget::PackageFinder.new(
authorized_user_project,
- finder_params.merge(package_name: params[:package_name])
+ **finder_params.merge(package_name: params[:package_name])
)
end
end
@@ -73,7 +73,7 @@ module API
get 'index', format: :json do
authorize_read_package!(authorized_user_project)
- track_event('nuget_service_index')
+ track_package_event('cli_metadata', :nuget)
present ::Packages::Nuget::ServiceIndexPresenter.new(authorized_user_project),
with: ::API::Entities::Nuget::ServiceIndex
@@ -105,7 +105,7 @@ module API
package_file = ::Packages::CreatePackageFileService.new(package, file_params)
.execute
- package_event('push_package')
+ track_package_event('push_package', :nuget)
::Packages::Nuget::ExtractionWorker.perform_async(package_file.id) # rubocop:disable CodeReuse/Worker
@@ -198,7 +198,7 @@ module API
not_found!('Package') unless package_file
- package_event('pull_package')
+ track_package_event('pull_package', :nuget)
# nuget and dotnet don't support 302 Moved status codes, supports_direct_download has to be set to false
present_carrierwave_file!(package_file.file, supports_direct_download: false)
@@ -233,7 +233,7 @@ module API
.new(authorized_user_project, params[:q], search_options)
.execute
- package_event('search_package')
+ track_package_event('search_package', :nuget)
present ::Packages::Nuget::SearchResultsPresenter.new(search),
with: ::API::Entities::Nuget::SearchResults
diff --git a/lib/api/project_clusters.rb b/lib/api/project_clusters.rb
index 0e5605984e6..6f189110d76 100644
--- a/lib/api/project_clusters.rb
+++ b/lib/api/project_clusters.rb
@@ -45,6 +45,7 @@ module API
optional :enabled, type: Boolean, default: true, desc: 'Determines if cluster is active or not, defaults to true'
optional :domain, type: String, desc: 'Cluster base domain'
optional :environment_scope, default: '*', type: String, desc: 'The associated environment to the cluster'
+ optional :namespace_per_environment, default: true, type: Boolean, desc: 'Deploy each environment to a separate Kubernetes namespace'
optional :management_project_id, type: Integer, desc: 'The ID of the management project'
optional :managed, type: Boolean, default: true, desc: 'Determines if GitLab will manage namespaces and service accounts for this cluster, defaults to true'
requires :platform_kubernetes_attributes, type: Hash, desc: %q(Platform Kubernetes data) do
@@ -78,6 +79,7 @@ module API
optional :name, type: String, desc: 'Cluster name'
optional :domain, type: String, desc: 'Cluster base domain'
optional :environment_scope, type: String, desc: 'The associated environment to the cluster'
+ optional :namespace_per_environment, default: true, type: Boolean, desc: 'Deploy each environment to a separate Kubernetes namespace'
optional :management_project_id, type: Integer, desc: 'The ID of the management project'
optional :platform_kubernetes_attributes, type: Hash, desc: %q(Platform Kubernetes data) do
optional :api_url, type: String, desc: 'URL to access the Kubernetes API'
diff --git a/lib/api/project_container_repositories.rb b/lib/api/project_container_repositories.rb
index 8f2a62bc5a4..173e7799325 100644
--- a/lib/api/project_container_repositories.rb
+++ b/lib/api/project_container_repositories.rb
@@ -3,6 +3,7 @@
module API
class ProjectContainerRepositories < Grape::API::Instance
include PaginationParams
+ helpers ::API::Helpers::PackagesHelpers
REPOSITORY_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS.merge(
tag_name: API::NO_SLASH_URL_PART_REGEX)
@@ -28,7 +29,7 @@ module API
user: current_user, subject: user_project
).execute
- track_event( 'list_repositories')
+ track_package_event('list_repositories', :container)
present paginate(repositories), with: Entities::ContainerRegistry::Repository, tags: params[:tags], tags_count: params[:tags_count]
end
@@ -43,7 +44,7 @@ module API
authorize_admin_container_image!
DeleteContainerRepositoryWorker.perform_async(current_user.id, repository.id) # rubocop:disable CodeReuse/Worker
- track_event('delete_repository')
+ track_package_event('delete_repository', :container)
status :accepted
end
@@ -60,7 +61,7 @@ module API
authorize_read_container_image!
tags = Kaminari.paginate_array(repository.tags)
- track_event('list_tags')
+ track_package_event('list_tags', :container)
present paginate(tags), with: Entities::ContainerRegistry::Tag
end
@@ -89,7 +90,7 @@ module API
declared_params.except(:repository_id).merge(container_expiration_policy: false))
# rubocop:enable CodeReuse/Worker
- track_event('delete_tag_bulk')
+ track_package_event('delete_tag_bulk', :container)
status :accepted
end
@@ -125,7 +126,7 @@ module API
.execute(repository)
if result[:status] == :success
- track_event('delete_tag')
+ track_package_event('delete_tag', :container)
status :ok
else
diff --git a/lib/api/project_export.rb b/lib/api/project_export.rb
index 377d61689b3..6e4097fd76c 100644
--- a/lib/api/project_export.rb
+++ b/lib/api/project_export.rb
@@ -55,7 +55,7 @@ module API
export_strategy = if after_export_params[:url].present?
params = after_export_params.slice(:url, :http_method).symbolize_keys
- Gitlab::ImportExport::AfterExportStrategies::WebUploadStrategy.new(params)
+ Gitlab::ImportExport::AfterExportStrategies::WebUploadStrategy.new(**params)
end
if export_strategy&.invalid?
diff --git a/lib/api/project_import.rb b/lib/api/project_import.rb
index 9f43c3c7993..6ac7f02f305 100644
--- a/lib/api/project_import.rb
+++ b/lib/api/project_import.rb
@@ -4,8 +4,6 @@ module API
class ProjectImport < Grape::API::Instance
include PaginationParams
- MAXIMUM_FILE_SIZE = 50.megabytes
-
helpers Helpers::ProjectsHelpers
helpers Helpers::FileUploadHelpers
helpers Helpers::RateLimiter
diff --git a/lib/api/pypi_packages.rb b/lib/api/pypi_packages.rb
index c07db68f8a8..55cea075243 100644
--- a/lib/api/pypi_packages.rb
+++ b/lib/api/pypi_packages.rb
@@ -72,7 +72,7 @@ module API
package = packages_finder(project).by_file_name_and_sha256(filename, params[:sha256])
package_file = ::Packages::PackageFileFinder.new(package, filename, with_file_name_like: false).execute
- package_event('pull_package')
+ track_package_event('pull_package', :pypi)
present_carrierwave_file!(package_file.file, supports_direct_download: true)
end
@@ -91,7 +91,7 @@ module API
get 'simple/*package_name', format: :txt do
authorize_read_package!(authorized_user_project)
- package_event('list_package')
+ track_package_event('list_package', :pypi)
packages = find_package_versions
presenter = ::Packages::Pypi::PackagePresenter.new(packages, authorized_user_project)
@@ -122,7 +122,7 @@ module API
authorize_upload!(authorized_user_project)
bad_request!('File is too large') if authorized_user_project.actual_limits.exceeded?(:pypi_max_file_size, params[:content].size)
- package_event('push_package')
+ track_package_event('push_package', :pypi)
::Packages::Pypi::CreatePackageService
.new(authorized_user_project, current_user, declared_params)
diff --git a/lib/api/search.rb b/lib/api/search.rb
index b9c6a823f4f..8b6569dd57d 100644
--- a/lib/api/search.rb
+++ b/lib/api/search.rb
@@ -62,12 +62,6 @@ module API
# Defining this method here as a noop allows us to easily extend it in
# EE, without having to modify this file directly.
end
-
- def check_users_search_allowed!
- if params[:scope].to_sym == :users && Feature.disabled?(:users_search, default_enabled: true)
- render_api_error!({ error: _("Scope not supported with disabled 'users_search' feature!") }, 400)
- end
- end
end
resource :search do
@@ -85,7 +79,6 @@ module API
end
get do
verify_search_scope!(resource: nil)
- check_users_search_allowed!
present search, with: entity
end
@@ -107,7 +100,6 @@ module API
end
get ':id/(-/)search' do
verify_search_scope!(resource: user_group)
- check_users_search_allowed!
present search(group_id: user_group.id), with: entity
end
@@ -129,8 +121,6 @@ module API
use :pagination
end
get ':id/(-/)search' do
- check_users_search_allowed!
-
present search({ project_id: user_project.id, repository_ref: params[:ref] }), with: entity
end
end
diff --git a/lib/api/settings.rb b/lib/api/settings.rb
index 6e5534d0c9a..4056d8602f3 100644
--- a/lib/api/settings.rb
+++ b/lib/api/settings.rb
@@ -29,7 +29,8 @@ module API
success Entities::ApplicationSetting
end
params do
- optional :admin_notification_email, type: String, desc: 'Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area.'
+ optional :admin_notification_email, type: String, desc: 'Deprecated: Use :abuse_notification_email instead. Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area.'
+ optional :abuse_notification_email, type: String, desc: 'Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area.'
optional :after_sign_up_text, type: String, desc: 'Text shown after sign up'
optional :after_sign_out_path, type: String, desc: 'We will redirect users to this page after they sign out'
optional :akismet_enabled, type: Boolean, desc: 'Helps prevent bots from creating issues'
@@ -194,6 +195,11 @@ module API
attrs[:allow_local_requests_from_web_hooks_and_services] = attrs.delete(:allow_local_requests_from_hooks_and_services)
end
+ # support legacy names, can be removed in v5
+ if attrs.has_key?(:admin_notification_email)
+ attrs[:abuse_notification_email] = attrs.delete(:admin_notification_email)
+ end
+
# since 13.0 it's not possible to disable hashed storage - support can be removed in 14.0
attrs.delete(:hashed_storage_enabled) if attrs.has_key?(:hashed_storage_enabled)
diff --git a/lib/api/terraform/state_version.rb b/lib/api/terraform/state_version.rb
new file mode 100644
index 00000000000..5a4bc620cf6
--- /dev/null
+++ b/lib/api/terraform/state_version.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+module API
+ module Terraform
+ class StateVersion < Grape::API::Instance
+ default_format :json
+
+ before do
+ authenticate!
+ authorize! :read_terraform_state, user_project
+ end
+
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ end
+
+ resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
+ namespace ':id/terraform/state/:name/versions/:serial' do
+ params do
+ requires :name, type: String, desc: 'The name of a Terraform state'
+ requires :serial, type: Integer, desc: 'The version number of the state'
+ end
+
+ helpers do
+ def remote_state_handler
+ ::Terraform::RemoteStateHandler.new(user_project, current_user, name: params[:name])
+ end
+
+ def find_version(serial)
+ remote_state_handler.find_with_lock do |state|
+ version = state.versions.find_by_version(serial)
+
+ if version.present?
+ yield version
+ else
+ not_found!
+ end
+ end
+ end
+ end
+
+ desc 'Get a terraform state version'
+ route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
+ get do
+ find_version(params[:serial]) do |version|
+ env['api.format'] = :binary # Bypass json serialization
+ body version.file.read
+ status :ok
+ end
+ end
+
+ desc 'Delete a terraform state version'
+ route_setting :authentication, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
+ delete do
+ authorize! :admin_terraform_state, user_project
+
+ find_version(params[:serial]) do |version|
+ version.destroy!
+
+ body false
+ status :no_content
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/api/unleash.rb b/lib/api/unleash.rb
new file mode 100644
index 00000000000..8db23c3aaec
--- /dev/null
+++ b/lib/api/unleash.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+module API
+ class Unleash < Grape::API::Instance
+ include PaginationParams
+
+ namespace :feature_flags do
+ resource :unleash, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
+ params do
+ requires :project_id, type: String, desc: 'The ID of a project'
+ optional :instance_id, type: String, desc: 'The Instance ID of Unleash Client'
+ optional :app_name, type: String, desc: 'The Application Name of Unleash Client'
+ end
+ route_param :project_id do
+ before do
+ authorize_by_unleash_instance_id!
+ end
+
+ get do
+ # not supported yet
+ status :ok
+ end
+
+ desc 'Get a list of features (deprecated, v2 client support)'
+ get 'features' do
+ present :version, 1
+ present :features, feature_flags, with: ::API::Entities::UnleashFeature
+ end
+
+ desc 'Get a list of features'
+ get 'client/features' do
+ present :version, 1
+ present :features, feature_flags, with: ::API::Entities::UnleashFeature
+ end
+
+ post 'client/register' do
+ # not supported yet
+ status :ok
+ end
+
+ post 'client/metrics' do
+ # not supported yet
+ status :ok
+ end
+ end
+ end
+ end
+
+ helpers do
+ def project
+ @project ||= find_project(params[:project_id])
+ end
+
+ def unleash_instance_id
+ env['HTTP_UNLEASH_INSTANCEID'] || params[:instance_id]
+ end
+
+ def unleash_app_name
+ env['HTTP_UNLEASH_APPNAME'] || params[:app_name]
+ end
+
+ def authorize_by_unleash_instance_id!
+ unauthorized! unless Operations::FeatureFlagsClient
+ .find_for_project_and_token(project, unleash_instance_id)
+ end
+
+ def feature_flags
+ return [] unless unleash_app_name.present?
+
+ legacy_flags = Operations::FeatureFlagScope.for_unleash_client(project, unleash_app_name)
+ new_version_flags = Operations::FeatureFlag.for_unleash_client(project, unleash_app_name)
+
+ legacy_flags + new_version_flags
+ end
+ end
+ end
+end
diff --git a/lib/api/users.rb b/lib/api/users.rb
index 73bb43b88fc..b20ee590124 100644
--- a/lib/api/users.rb
+++ b/lib/api/users.rb
@@ -348,7 +348,7 @@ module API
end
# rubocop: enable CodeReuse/ActiveRecord
- desc 'Get the GPG keys of a specified user. Available only for admins.' do
+ desc 'Get the GPG keys of a specified user.' do
detail 'This feature was added in GitLab 10.0'
success Entities::GpgKey
end
@@ -358,8 +358,6 @@ module API
end
# rubocop: disable CodeReuse/ActiveRecord
get ':id/gpg_keys' do
- authenticated_as_admin!
-
user = User.find_by(id: params[:id])
not_found!('User') unless user
diff --git a/lib/api/v3/github.rb b/lib/api/v3/github.rb
index 593f90460ac..08bf395fa98 100644
--- a/lib/api/v3/github.rb
+++ b/lib/api/v3/github.rb
@@ -51,7 +51,7 @@ module API
def find_project_with_access(params)
project = find_project!(
- ::Gitlab::Jira::Dvcs.restore_full_path(params.slice(:namespace, :project).symbolize_keys)
+ ::Gitlab::Jira::Dvcs.restore_full_path(**params.slice(:namespace, :project).symbolize_keys)
)
not_found! unless can?(current_user, :download_code, project)
project
diff --git a/lib/backup/artifacts.rb b/lib/backup/artifacts.rb
index c2266f0bad6..6a45baa60ec 100644
--- a/lib/backup/artifacts.rb
+++ b/lib/backup/artifacts.rb
@@ -1,9 +1,7 @@
# frozen_string_literal: true
-require 'backup/files'
-
module Backup
- class Artifacts < Files
+ class Artifacts < Backup::Files
attr_reader :progress
def initialize(progress)
diff --git a/lib/backup/builds.rb b/lib/backup/builds.rb
index 5e795a449de..9c3b7165de7 100644
--- a/lib/backup/builds.rb
+++ b/lib/backup/builds.rb
@@ -1,9 +1,7 @@
# frozen_string_literal: true
-require 'backup/files'
-
module Backup
- class Builds < Files
+ class Builds < Backup::Files
attr_reader :progress
def initialize(progress)
diff --git a/lib/backup/lfs.rb b/lib/backup/lfs.rb
index 0dfe56e214f..514d52d7f65 100644
--- a/lib/backup/lfs.rb
+++ b/lib/backup/lfs.rb
@@ -1,9 +1,7 @@
# frozen_string_literal: true
-require 'backup/files'
-
module Backup
- class Lfs < Files
+ class Lfs < Backup::Files
attr_reader :progress
def initialize(progress)
diff --git a/lib/backup/pages.rb b/lib/backup/pages.rb
index d7aab33d7cb..ae293073ba2 100644
--- a/lib/backup/pages.rb
+++ b/lib/backup/pages.rb
@@ -1,9 +1,7 @@
# frozen_string_literal: true
-require 'backup/files'
-
module Backup
- class Pages < Files
+ class Pages < Backup::Files
attr_reader :progress
def initialize(progress)
diff --git a/lib/backup/registry.rb b/lib/backup/registry.rb
index d16ed2facf1..9645a07dfb8 100644
--- a/lib/backup/registry.rb
+++ b/lib/backup/registry.rb
@@ -1,9 +1,7 @@
# frozen_string_literal: true
-require 'backup/files'
-
module Backup
- class Registry < Files
+ class Registry < Backup::Files
attr_reader :progress
def initialize(progress)
diff --git a/lib/backup/repositories.rb b/lib/backup/repositories.rb
new file mode 100644
index 00000000000..6818d485862
--- /dev/null
+++ b/lib/backup/repositories.rb
@@ -0,0 +1,236 @@
+# frozen_string_literal: true
+
+require 'yaml'
+
+module Backup
+ class Repositories
+ attr_reader :progress
+
+ def initialize(progress)
+ @progress = progress
+ end
+
+ def dump(max_concurrency:, max_storage_concurrency:)
+ prepare
+
+ if max_concurrency <= 1 && max_storage_concurrency <= 1
+ return dump_consecutive
+ end
+
+ if Project.excluding_repository_storage(Gitlab.config.repositories.storages.keys).exists?
+ raise Error, 'repositories.storages in gitlab.yml is misconfigured'
+ end
+
+ semaphore = Concurrent::Semaphore.new(max_concurrency)
+ errors = Queue.new
+
+ threads = Gitlab.config.repositories.storages.keys.map do |storage|
+ Thread.new do
+ Rails.application.executor.wrap do
+ dump_storage(storage, semaphore, max_storage_concurrency: max_storage_concurrency)
+ rescue => e
+ errors << e
+ end
+ end
+ end
+
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
+ threads.each(&:join)
+ end
+
+ raise errors.pop unless errors.empty?
+ end
+
+ def restore
+ Project.find_each(batch_size: 1000) do |project|
+ restore_repository(project, Gitlab::GlRepository::PROJECT)
+ restore_repository(project, Gitlab::GlRepository::WIKI)
+ restore_repository(project, Gitlab::GlRepository::DESIGN)
+ end
+
+ restore_object_pools
+ end
+
+ private
+
+ def restore_repository(container, type)
+ BackupRestore.new(
+ progress,
+ type.repository_for(container),
+ backup_repos_path
+ ).restore(always_create: type.project?)
+ end
+
+ def backup_repos_path
+ File.join(Gitlab.config.backup.path, 'repositories')
+ end
+
+ def prepare
+ FileUtils.rm_rf(backup_repos_path)
+ FileUtils.mkdir_p(Gitlab.config.backup.path)
+ FileUtils.mkdir(backup_repos_path, mode: 0700)
+ end
+
+ def dump_consecutive
+ Project.includes(:route, :group, namespace: :owner).find_each(batch_size: 1000) do |project|
+ dump_project(project)
+ end
+ end
+
+ def dump_storage(storage, semaphore, max_storage_concurrency:)
+ errors = Queue.new
+ queue = InterlockSizedQueue.new(1)
+
+ threads = Array.new(max_storage_concurrency) do
+ Thread.new do
+ Rails.application.executor.wrap do
+ while project = queue.pop
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
+ semaphore.acquire
+ end
+
+ begin
+ dump_project(project)
+ rescue => e
+ errors << e
+ break
+ ensure
+ semaphore.release
+ end
+ end
+ end
+ end
+ end
+
+ Project.for_repository_storage(storage).includes(:route, :group, namespace: :owner).find_each(batch_size: 100) do |project|
+ break unless errors.empty?
+
+ queue.push(project)
+ end
+
+ raise errors.pop unless errors.empty?
+ ensure
+ queue.close
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
+ threads.each(&:join)
+ end
+ end
+
+ def dump_project(project)
+ backup_repository(project, Gitlab::GlRepository::PROJECT)
+ backup_repository(project, Gitlab::GlRepository::WIKI)
+ backup_repository(project, Gitlab::GlRepository::DESIGN)
+ end
+
+ def backup_repository(container, type)
+ BackupRestore.new(
+ progress,
+ type.repository_for(container),
+ backup_repos_path
+ ).backup
+ end
+
+ def restore_object_pools
+ PoolRepository.includes(:source_project).find_each do |pool|
+ progress.puts " - Object pool #{pool.disk_path}..."
+
+ pool.source_project ||= pool.member_projects.first.root_of_fork_network
+ pool.state = 'none'
+ pool.save
+
+ pool.schedule
+ end
+ end
+
+ class BackupRestore
+ attr_accessor :progress, :repository, :backup_repos_path
+
+ def initialize(progress, repository, backup_repos_path)
+ @progress = progress
+ @repository = repository
+ @backup_repos_path = backup_repos_path
+ end
+
+ def backup
+ progress.puts " * #{display_repo_path} ... "
+
+ if repository.empty?
+ progress.puts " * #{display_repo_path} ... " + "[SKIPPED]".color(:cyan)
+ return
+ end
+
+ FileUtils.mkdir_p(repository_backup_path)
+
+ repository.bundle_to_disk(path_to_bundle)
+ repository.gitaly_repository_client.backup_custom_hooks(custom_hooks_tar)
+
+ progress.puts " * #{display_repo_path} ... " + "[DONE]".color(:green)
+
+ rescue => e
+ progress.puts "[Failed] backing up #{display_repo_path}".color(:red)
+ progress.puts "Error #{e}".color(:red)
+ end
+
+ def restore(always_create: false)
+ progress.puts " * #{display_repo_path} ... "
+
+ repository.remove rescue nil
+
+ if File.exist?(path_to_bundle)
+ repository.create_from_bundle(path_to_bundle)
+ restore_custom_hooks
+ elsif always_create
+ repository.create_repository
+ end
+
+ progress.puts " * #{display_repo_path} ... " + "[DONE]".color(:green)
+
+ rescue => e
+ progress.puts "[Failed] restoring #{display_repo_path}".color(:red)
+ progress.puts "Error #{e}".color(:red)
+ end
+
+ private
+
+ def display_repo_path
+ "#{repository.full_path} (#{repository.disk_path})"
+ end
+
+ def repository_backup_path
+ @repository_backup_path ||= File.join(backup_repos_path, repository.disk_path)
+ end
+
+ def path_to_bundle
+ @path_to_bundle ||= File.join(backup_repos_path, repository.disk_path + '.bundle')
+ end
+
+ def restore_custom_hooks
+ return unless File.exist?(custom_hooks_tar)
+
+ repository.gitaly_repository_client.restore_custom_hooks(custom_hooks_tar)
+ end
+
+ def custom_hooks_tar
+ File.join(repository_backup_path, "custom_hooks.tar")
+ end
+ end
+
+ class InterlockSizedQueue < SizedQueue
+ extend ::Gitlab::Utils::Override
+
+ override :pop
+ def pop(*)
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
+ super
+ end
+ end
+
+ override :push
+ def push(*)
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
+ super
+ end
+ end
+ end
+ end
+end
diff --git a/lib/backup/repository.rb b/lib/backup/repository.rb
deleted file mode 100644
index eb0b230904e..00000000000
--- a/lib/backup/repository.rb
+++ /dev/null
@@ -1,265 +0,0 @@
-# frozen_string_literal: true
-
-require 'yaml'
-
-module Backup
- class Repository
- attr_reader :progress
-
- def initialize(progress)
- @progress = progress
- end
-
- def dump(max_concurrency:, max_storage_concurrency:)
- prepare
-
- if max_concurrency <= 1 && max_storage_concurrency <= 1
- return dump_consecutive
- end
-
- if Project.excluding_repository_storage(Gitlab.config.repositories.storages.keys).exists?
- raise Error, 'repositories.storages in gitlab.yml is misconfigured'
- end
-
- semaphore = Concurrent::Semaphore.new(max_concurrency)
- errors = Queue.new
-
- threads = Gitlab.config.repositories.storages.keys.map do |storage|
- Thread.new do
- Rails.application.executor.wrap do
- dump_storage(storage, semaphore, max_storage_concurrency: max_storage_concurrency)
- rescue => e
- errors << e
- end
- end
- end
-
- ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
- threads.each(&:join)
- end
-
- raise errors.pop unless errors.empty?
- end
-
- def backup_project(project)
- path_to_project_bundle = path_to_bundle(project)
- Gitlab::GitalyClient::RepositoryService.new(project.repository)
- .create_bundle(path_to_project_bundle)
-
- backup_custom_hooks(project)
- rescue => e
- progress_warn(project, e, 'Failed to backup repo')
- end
-
- def backup_custom_hooks(project)
- FileUtils.mkdir_p(project_backup_path(project))
-
- custom_hooks_path = custom_hooks_tar(project)
- Gitlab::GitalyClient::RepositoryService.new(project.repository)
- .backup_custom_hooks(custom_hooks_path)
- end
-
- def restore_custom_hooks(project)
- return unless Dir.exist?(project_backup_path(project))
- return if Dir.glob("#{project_backup_path(project)}/custom_hooks*").none?
-
- custom_hooks_path = custom_hooks_tar(project)
- Gitlab::GitalyClient::RepositoryService.new(project.repository)
- .restore_custom_hooks(custom_hooks_path)
- end
-
- def restore
- Project.find_each(batch_size: 1000) do |project|
- progress.print " * #{project.full_path} ... "
-
- restore_repo_success =
- begin
- try_restore_repository(project)
- rescue => err
- progress.puts "Error: #{err}".color(:red)
- false
- end
-
- if restore_repo_success
- progress.puts "[DONE]".color(:green)
- else
- progress.puts "[Failed] restoring #{project.full_path} repository".color(:red)
- end
-
- wiki = ProjectWiki.new(project)
- wiki.repository.remove rescue nil
- path_to_wiki_bundle = path_to_bundle(wiki)
-
- if File.exist?(path_to_wiki_bundle)
- progress.print " * #{wiki.full_path} ... "
- begin
- wiki.repository.create_from_bundle(path_to_wiki_bundle)
- restore_custom_hooks(wiki)
-
- progress.puts "[DONE]".color(:green)
- rescue => e
- progress.puts "[Failed] restoring #{wiki.full_path} wiki".color(:red)
- progress.puts "Error #{e}".color(:red)
- end
- end
- end
-
- restore_object_pools
- end
-
- protected
-
- def try_restore_repository(project)
- path_to_project_bundle = path_to_bundle(project)
- project.repository.remove rescue nil
-
- if File.exist?(path_to_project_bundle)
- project.repository.create_from_bundle(path_to_project_bundle)
- restore_custom_hooks(project)
- else
- project.repository.create_repository
- end
-
- true
- end
-
- def path_to_bundle(project)
- File.join(backup_repos_path, project.disk_path + '.bundle')
- end
-
- def project_backup_path(project)
- File.join(backup_repos_path, project.disk_path)
- end
-
- def custom_hooks_tar(project)
- File.join(project_backup_path(project), "custom_hooks.tar")
- end
-
- def backup_repos_path
- File.join(Gitlab.config.backup.path, 'repositories')
- end
-
- def prepare
- FileUtils.rm_rf(backup_repos_path)
- FileUtils.mkdir_p(Gitlab.config.backup.path)
- FileUtils.mkdir(backup_repos_path, mode: 0700)
- end
-
- private
-
- def dump_consecutive
- Project.includes(:route, :group, namespace: :owner).find_each(batch_size: 1000) do |project|
- dump_project(project)
- end
- end
-
- def dump_storage(storage, semaphore, max_storage_concurrency:)
- errors = Queue.new
- queue = InterlockSizedQueue.new(1)
-
- threads = Array.new(max_storage_concurrency) do
- Thread.new do
- Rails.application.executor.wrap do
- while project = queue.pop
- ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
- semaphore.acquire
- end
-
- begin
- dump_project(project)
- rescue => e
- errors << e
- break
- ensure
- semaphore.release
- end
- end
- end
- end
- end
-
- Project.for_repository_storage(storage).includes(:route, :group, namespace: :owner).find_each(batch_size: 100) do |project|
- break unless errors.empty?
-
- queue.push(project)
- end
-
- raise errors.pop unless errors.empty?
- ensure
- queue.close
- ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
- threads.each(&:join)
- end
- end
-
- def dump_project(project)
- progress.puts " * #{display_repo_path(project)} ... "
-
- if project.hashed_storage?(:repository)
- FileUtils.mkdir_p(File.dirname(File.join(backup_repos_path, project.disk_path)))
- else
- FileUtils.mkdir_p(File.join(backup_repos_path, project.namespace.full_path)) if project.namespace
- end
-
- if !empty_repo?(project)
- backup_project(project)
- progress.puts " * #{display_repo_path(project)} ... " + "[DONE]".color(:green)
- else
- progress.puts " * #{display_repo_path(project)} ... " + "[SKIPPED]".color(:cyan)
- end
-
- wiki = ProjectWiki.new(project)
-
- if !empty_repo?(wiki)
- backup_project(wiki)
- progress.puts " * #{display_repo_path(project)} ... " + "[DONE] Wiki".color(:green)
- else
- progress.puts " * #{display_repo_path(project)} ... " + "[SKIPPED] Wiki".color(:cyan)
- end
- end
-
- def progress_warn(project, cmd, output)
- progress.puts "[WARNING] Executing #{cmd}".color(:orange)
- progress.puts "Ignoring error on #{display_repo_path(project)} - #{output}".color(:orange)
- end
-
- def empty_repo?(project_or_wiki)
- project_or_wiki.repository.expire_emptiness_caches
- project_or_wiki.repository.empty?
- end
-
- def display_repo_path(project)
- project.hashed_storage?(:repository) ? "#{project.full_path} (#{project.disk_path})" : project.full_path
- end
-
- def restore_object_pools
- PoolRepository.includes(:source_project).find_each do |pool|
- progress.puts " - Object pool #{pool.disk_path}..."
-
- pool.source_project ||= pool.member_projects.first.root_of_fork_network
- pool.state = 'none'
- pool.save
-
- pool.schedule
- end
- end
-
- class InterlockSizedQueue < SizedQueue
- extend ::Gitlab::Utils::Override
-
- override :pop
- def pop(*)
- ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
- super
- end
- end
-
- override :push
- def push(*)
- ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
- super
- end
- end
- end
- end
-end
diff --git a/lib/backup/uploads.rb b/lib/backup/uploads.rb
index b6a62bc3f29..9665624f71b 100644
--- a/lib/backup/uploads.rb
+++ b/lib/backup/uploads.rb
@@ -1,9 +1,7 @@
# frozen_string_literal: true
-require 'backup/files'
-
module Backup
- class Uploads < Files
+ class Uploads < Backup::Files
attr_reader :progress
def initialize(progress)
diff --git a/lib/banzai/filter/design_reference_filter.rb b/lib/banzai/filter/design_reference_filter.rb
index 2ab47c5c6db..1754fec93d4 100644
--- a/lib/banzai/filter/design_reference_filter.rb
+++ b/lib/banzai/filter/design_reference_filter.rb
@@ -3,8 +3,6 @@
module Banzai
module Filter
class DesignReferenceFilter < AbstractReferenceFilter
- FEATURE_FLAG = :design_management_reference_filter_gfm_pipeline
-
class Identifier
include Comparable
attr_reader :issue_iid, :filename
@@ -35,14 +33,6 @@ module Banzai
self.reference_type = :design
- # This filter must be enabled by setting the
- # design_management_reference_filter_gfm_pipeline flag
- def call
- return doc unless enabled?
-
- super
- end
-
def find_object(project, identifier)
records_per_parent[project][identifier]
end
@@ -112,10 +102,6 @@ module Banzai
.in_groups_of(100, false) # limitation of by_issue_id_and_filename, so we batch
.flat_map { |ids| DesignManagement::Design.by_issue_id_and_filename(ids) }
end
-
- def enabled?
- Feature.enabled?(FEATURE_FLAG, parent, default_enabled: true)
- end
end
end
end
diff --git a/lib/banzai/reference_parser/mentioned_group_parser.rb b/lib/banzai/reference_parser/mentioned_group_parser.rb
index a0892e15df8..75d05ef59f9 100644
--- a/lib/banzai/reference_parser/mentioned_group_parser.rb
+++ b/lib/banzai/reference_parser/mentioned_group_parser.rb
@@ -16,7 +16,7 @@ module Banzai
end
def nodes_visible_to_user(user, nodes)
- groups = lazy { grouped_objects_for_nodes(nodes, Group, GROUP_ATTR) }
+ groups = lazy { grouped_objects_for_nodes(nodes, references_relation, GROUP_ATTR) }
nodes.select do |node|
node.has_attribute?(GROUP_ATTR) && can_read_group_reference?(node, user, groups)
diff --git a/lib/feature.rb b/lib/feature.rb
index 71241e98723..1f8c530bee5 100644
--- a/lib/feature.rb
+++ b/lib/feature.rb
@@ -138,7 +138,7 @@ class Feature
def register_definitions
return unless check_feature_flags_definition?
- Feature::Definition.load_all!
+ Feature::Definition.reload!
end
def register_hot_reloader
diff --git a/lib/feature/definition.rb b/lib/feature/definition.rb
index ee779a86952..0ba1bdc4799 100644
--- a/lib/feature/definition.rb
+++ b/lib/feature/definition.rb
@@ -84,17 +84,14 @@ class Feature
end
def definitions
- @definitions ||= {}
+ # We lazily load all definitions
+ # The hot reloading might request a feature flag
+ # before we can properly call `load_all!`
+ @definitions ||= load_all!
end
- def load_all!
- definitions.clear
-
- paths.each do |glob_path|
- load_all_from_path!(glob_path)
- end
-
- definitions
+ def reload!
+ @definitions = load_all!
end
def valid_usage!(key, type:, default_enabled:)
@@ -110,9 +107,7 @@ class Feature
def register_hot_reloader!
# Reload feature flags on change of this file or any `.yml`
file_watcher = Rails.configuration.file_watcher.new(reload_files, reload_directories) do
- # We use `Feature::Definition` as on Ruby code-reload
- # a new class definition is created
- Feature::Definition.load_all!
+ Feature::Definition.reload!
end
Rails.application.reloaders << file_watcher
@@ -123,6 +118,16 @@ class Feature
private
+ def load_all!
+ # We currently do not load feature flag definitions
+ # in production environments
+ return [] unless Gitlab.dev_or_test_env?
+
+ paths.each_with_object({}) do |glob_path, definitions|
+ load_all_from_path!(definitions, glob_path)
+ end
+ end
+
def load_from_file(path)
definition = File.read(path)
definition = YAML.safe_load(definition)
@@ -133,7 +138,7 @@ class Feature
raise Feature::InvalidFeatureFlagError, "Invalid definition for `#{path}`: #{e.message}"
end
- def load_all_from_path!(glob_path)
+ def load_all_from_path!(definitions, glob_path)
Dir.glob(glob_path).each do |path|
definition = load_from_file(path)
@@ -146,7 +151,7 @@ class Feature
end
def reload_files
- [File.expand_path(__FILE__)]
+ []
end
def reload_directories
diff --git a/lib/feature/shared.rb b/lib/feature/shared.rb
index c06f699ef27..9ec56ee6b52 100644
--- a/lib/feature/shared.rb
+++ b/lib/feature/shared.rb
@@ -9,12 +9,14 @@ class Feature
# optional: defines if a on-disk definition is required for this feature flag type
# rollout_issue: defines if `bin/feature-flag` asks for rollout issue
# default_enabled: defines a default state of a feature flag when created by `bin/feature-flag`
+ # ee_only: defines that a feature flag can only be created in a context of EE
# example: usage being shown when exception is raised
TYPES = {
development: {
description: 'Short lived, used to enable unfinished code to be deployed',
- optional: true,
+ optional: false,
rollout_issue: true,
+ ee_only: false,
default_enabled: false,
example: <<-EOS
Feature.enabled?(:my_feature_flag, project)
@@ -26,6 +28,7 @@ class Feature
description: "Long-lived feature flags that control operational aspects of GitLab's behavior",
optional: true,
rollout_issue: false,
+ ee_only: false,
default_enabled: false,
example: <<-EOS
Feature.enabled?(:my_ops_flag, type: ops)
@@ -36,6 +39,7 @@ class Feature
description: 'Permanent feature flags used to temporarily disable licensed features.',
optional: true,
rollout_issue: false,
+ ee_only: true,
default_enabled: true,
example: <<-EOS
project.feature_available?(:my_licensed_feature)
@@ -44,13 +48,15 @@ class Feature
}
}.freeze
+ # The ordering of PARAMS defines an order in YAML
+ # This is done to ease the file comparison
PARAMS = %i[
name
- default_enabled
- type
introduced_by_url
rollout_issue_url
+ type
group
+ default_enabled
].freeze
end
end
diff --git a/lib/gitlab/alert_management/alert_params.rb b/lib/gitlab/alert_management/alert_params.rb
deleted file mode 100644
index 3bb839c1114..00000000000
--- a/lib/gitlab/alert_management/alert_params.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
- module AlertManagement
- class AlertParams
- MONITORING_TOOLS = {
- prometheus: 'Prometheus'
- }.freeze
-
- def self.from_generic_alert(project:, payload:)
- parsed_payload = Gitlab::Alerting::NotificationPayloadParser.call(payload, project).with_indifferent_access
- annotations = parsed_payload[:annotations]
-
- {
- project_id: project.id,
- title: annotations[:title],
- description: annotations[:description],
- monitoring_tool: annotations[:monitoring_tool],
- service: annotations[:service],
- hosts: Array(annotations[:hosts]),
- payload: payload,
- started_at: parsed_payload['startsAt'],
- ended_at: parsed_payload['endsAt'],
- severity: annotations[:severity],
- fingerprint: annotations[:fingerprint],
- environment: annotations[:environment]
- }
- end
-
- def self.from_prometheus_alert(project:, parsed_alert:)
- {
- project_id: project.id,
- title: parsed_alert.title,
- description: parsed_alert.description,
- monitoring_tool: MONITORING_TOOLS[:prometheus],
- payload: parsed_alert.payload,
- started_at: parsed_alert.starts_at,
- ended_at: parsed_alert.ends_at,
- fingerprint: parsed_alert.gitlab_fingerprint,
- environment: parsed_alert.environment,
- prometheus_alert: parsed_alert.gitlab_alert
- }
- end
- end
- end
-end
diff --git a/lib/gitlab/alert_management/alert_status_counts.rb b/lib/gitlab/alert_management/alert_status_counts.rb
index 382026236e0..e88436d479b 100644
--- a/lib/gitlab/alert_management/alert_status_counts.rb
+++ b/lib/gitlab/alert_management/alert_status_counts.rb
@@ -30,7 +30,7 @@ module Gitlab
end
def all
- counts.values.sum # rubocop:disable CodeReuse/ActiveRecord
+ counts.values.sum
end
private
diff --git a/lib/gitlab/alert_management/payload/generic.rb b/lib/gitlab/alert_management/payload/generic.rb
index 7efdfac75dc..e8e85155bef 100644
--- a/lib/gitlab/alert_management/payload/generic.rb
+++ b/lib/gitlab/alert_management/payload/generic.rb
@@ -8,6 +8,8 @@ module Gitlab
DEFAULT_TITLE = 'New: Incident'
DEFAULT_SEVERITY = 'critical'
+ attribute :description, paths: 'description'
+ attribute :ends_at, paths: 'end_time', type: :time
attribute :environment_name, paths: 'gitlab_environment_name'
attribute :hosts, paths: 'hosts'
attribute :monitoring_tool, paths: 'monitoring_tool'
@@ -23,3 +25,5 @@ module Gitlab
end
end
end
+
+Gitlab::AlertManagement::Payload::Generic.prepend_if_ee('EE::Gitlab::AlertManagement::Payload::Generic')
diff --git a/lib/gitlab/alerting/alert.rb b/lib/gitlab/alerting/alert.rb
deleted file mode 100644
index 94b81b7d290..00000000000
--- a/lib/gitlab/alerting/alert.rb
+++ /dev/null
@@ -1,215 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
- module Alerting
- class Alert
- include ActiveModel::Model
- include Gitlab::Utils::StrongMemoize
- include Presentable
-
- attr_accessor :project, :payload, :am_alert
-
- def self.for_alert_management_alert(project:, alert:)
- params = if alert.prometheus?
- alert.payload
- else
- Gitlab::Alerting::NotificationPayloadParser.call(alert.payload.to_h, alert.project)
- end
-
- self.new(project: project, payload: params, am_alert: alert)
- end
-
- def gitlab_alert
- strong_memoize(:gitlab_alert) do
- parse_gitlab_alert_from_payload
- end
- end
-
- def metric_id
- strong_memoize(:metric_id) do
- payload&.dig('labels', 'gitlab_alert_id')
- end
- end
-
- def gitlab_prometheus_alert_id
- strong_memoize(:gitlab_prometheus_alert_id) do
- payload&.dig('labels', 'gitlab_prometheus_alert_id')
- end
- end
-
- def title
- strong_memoize(:title) do
- gitlab_alert&.title || parse_title_from_payload
- end
- end
-
- def description
- strong_memoize(:description) do
- parse_description_from_payload
- end
- end
-
- def environment
- strong_memoize(:environment) do
- gitlab_alert&.environment || parse_environment_from_payload
- end
- end
-
- def annotations
- strong_memoize(:annotations) do
- parse_annotations_from_payload || []
- end
- end
-
- def starts_at
- strong_memoize(:starts_at) do
- parse_datetime_from_payload('startsAt')
- end
- end
-
- def starts_at_raw
- strong_memoize(:starts_at_raw) do
- payload&.dig('startsAt')
- end
- end
-
- def ends_at
- strong_memoize(:ends_at) do
- parse_datetime_from_payload('endsAt')
- end
- end
-
- def full_query
- strong_memoize(:full_query) do
- gitlab_alert&.full_query || parse_expr_from_payload
- end
- end
-
- def y_label
- strong_memoize(:y_label) do
- parse_y_label_from_payload || title
- end
- end
-
- def alert_markdown
- strong_memoize(:alert_markdown) do
- parse_alert_markdown_from_payload
- end
- end
-
- def status
- strong_memoize(:status) do
- payload&.dig('status')
- end
- end
-
- def firing?
- status == 'firing'
- end
-
- def resolved?
- status == 'resolved'
- end
-
- def gitlab_managed?
- metric_id.present?
- end
-
- def gitlab_fingerprint
- Gitlab::AlertManagement::Fingerprint.generate(plain_gitlab_fingerprint)
- end
-
- def valid?
- payload.respond_to?(:dig) && project && title && starts_at
- end
-
- def present
- super(presenter_class: Projects::Prometheus::AlertPresenter)
- end
-
- private
-
- def plain_gitlab_fingerprint
- if gitlab_managed?
- [metric_id, starts_at_raw].join('/')
- else # self managed
- [starts_at_raw, title, full_query].join('/')
- end
- end
-
- def parse_environment_from_payload
- environment_name = payload&.dig('labels', 'gitlab_environment_name')
-
- return unless environment_name
-
- EnvironmentsFinder.new(project, nil, { name: environment_name })
- .find
- &.first
- end
-
- def parse_gitlab_alert_from_payload
- alerts_found = matching_gitlab_alerts
-
- return if alerts_found.blank? || alerts_found.size > 1
-
- alerts_found.first
- end
-
- def matching_gitlab_alerts
- return unless metric_id || gitlab_prometheus_alert_id
-
- Projects::Prometheus::AlertsFinder
- .new(project: project, metric: metric_id, id: gitlab_prometheus_alert_id)
- .execute
- end
-
- def parse_title_from_payload
- payload&.dig('annotations', 'title') ||
- payload&.dig('annotations', 'summary') ||
- payload&.dig('labels', 'alertname')
- end
-
- def parse_description_from_payload
- payload&.dig('annotations', 'description')
- end
-
- def parse_annotations_from_payload
- payload&.dig('annotations')&.map do |label, value|
- Alerting::AlertAnnotation.new(label: label, value: value)
- end
- end
-
- def parse_datetime_from_payload(field)
- value = payload&.dig(field)
- return unless value
-
- # value is a rfc3339 timestamp
- # Timestamps from Prometheus and Alertmanager are UTC RFC3339 timestamps like: '2018-03-12T09:06:00Z' (Z represents 0 offset or UTC)
- # .utc sets the datetime zone to `UTC`
- Time.rfc3339(value).utc
- rescue ArgumentError
- end
-
- # Parses `g0.expr` from `generatorURL`.
- #
- # Example: http://localhost:9090/graph?g0.expr=vector%281%29&g0.tab=1
- def parse_expr_from_payload
- url = payload&.dig('generatorURL')
- return unless url
-
- uri = URI(url)
-
- Rack::Utils.parse_query(uri.query).fetch('g0.expr')
- rescue URI::InvalidURIError, KeyError
- end
-
- def parse_alert_markdown_from_payload
- payload&.dig('annotations', 'gitlab_incident_markdown')
- end
-
- def parse_y_label_from_payload
- payload&.dig('annotations', 'gitlab_y_label')
- end
- end
- end
-end
diff --git a/lib/gitlab/alerting/alert_annotation.rb b/lib/gitlab/alerting/alert_annotation.rb
deleted file mode 100644
index a4b3a97b08c..00000000000
--- a/lib/gitlab/alerting/alert_annotation.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
- module Alerting
- class AlertAnnotation
- include ActiveModel::Model
-
- attr_accessor :label, :value
- end
- end
-end
diff --git a/lib/gitlab/alerting/notification_payload_parser.rb b/lib/gitlab/alerting/notification_payload_parser.rb
deleted file mode 100644
index 348f851f551..00000000000
--- a/lib/gitlab/alerting/notification_payload_parser.rb
+++ /dev/null
@@ -1,107 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
- module Alerting
- class NotificationPayloadParser
- BadPayloadError = Class.new(StandardError)
-
- DEFAULT_TITLE = 'New: Incident'
- DEFAULT_SEVERITY = 'critical'
-
- def initialize(payload, project)
- @payload = payload.to_h.with_indifferent_access
- @project = project
- end
-
- def self.call(payload, project)
- new(payload, project).call
- end
-
- def call
- {
- 'annotations' => annotations,
- 'startsAt' => starts_at,
- 'endsAt' => ends_at
- }.compact
- end
-
- private
-
- attr_reader :payload, :project
-
- def title
- payload[:title].presence || DEFAULT_TITLE
- end
-
- def severity
- payload[:severity].presence || DEFAULT_SEVERITY
- end
-
- def fingerprint
- Gitlab::AlertManagement::Fingerprint.generate(payload[:fingerprint])
- end
-
- def annotations
- primary_params
- .reverse_merge(flatten_secondary_params)
- .transform_values(&:presence)
- .compact
- end
-
- def primary_params
- {
- 'title' => title,
- 'description' => payload[:description],
- 'monitoring_tool' => payload[:monitoring_tool],
- 'service' => payload[:service],
- 'hosts' => hosts.presence,
- 'severity' => severity,
- 'fingerprint' => fingerprint,
- 'environment' => environment
- }
- end
-
- def hosts
- Array(payload[:hosts]).reject(&:blank?)
- end
-
- def current_time
- Time.current.change(usec: 0).rfc3339
- end
-
- def starts_at
- Time.parse(payload[:start_time].to_s).rfc3339
- rescue ArgumentError
- current_time
- end
-
- def ends_at
- Time.parse(payload[:end_time].to_s).rfc3339
- rescue ArgumentError
- nil
- end
-
- def environment
- environment_name = payload[:gitlab_environment_name]
-
- return unless environment_name
-
- EnvironmentsFinder.new(project, nil, { name: environment_name })
- .find
- &.first
- end
-
- def secondary_params
- payload.except(:start_time, :end_time)
- end
-
- def flatten_secondary_params
- Gitlab::Utils::SafeInlineHash.merge_keys!(secondary_params)
- rescue ArgumentError
- raise BadPayloadError, 'The payload is too big'
- end
- end
- end
-end
-
-Gitlab::Alerting::NotificationPayloadParser.prepend_if_ee('EE::Gitlab::Alerting::NotificationPayloadParser')
diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb
index 609eef5e365..001c083c778 100644
--- a/lib/gitlab/auth.rb
+++ b/lib/gitlab/auth.rb
@@ -371,7 +371,7 @@ module Gitlab
end
def find_build_by_token(token)
- ::Ci::Build.running.find_by_token(token)
+ ::Ci::AuthJobFinder.new(token: token).execute
end
def user_auth_attempt!(user, success:)
diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb
index ccf52bae9a5..3d3f7212053 100644
--- a/lib/gitlab/auth/auth_finders.rb
+++ b/lib/gitlab/auth/auth_finders.rb
@@ -290,7 +290,7 @@ module Gitlab
end
def api_request?
- current_request.path.starts_with?('/api/')
+ current_request.path.starts_with?(Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, '/api/'))
end
def archive_request?
diff --git a/lib/gitlab/background_migration/backfill_snippet_repositories.rb b/lib/gitlab/background_migration/backfill_snippet_repositories.rb
index 21538000fec..8befade8c3a 100644
--- a/lib/gitlab/background_migration/backfill_snippet_repositories.rb
+++ b/lib/gitlab/background_migration/backfill_snippet_repositories.rb
@@ -109,7 +109,7 @@ module Gitlab
end
def create_commit(snippet)
- snippet.snippet_repository.multi_files_action(commit_author(snippet), snippet_action(snippet), commit_attrs)
+ snippet.snippet_repository.multi_files_action(commit_author(snippet), snippet_action(snippet), **commit_attrs)
end
# If the user is not allowed to access git or update the snippet
diff --git a/lib/gitlab/background_migration/migrate_users_bio_to_user_details.rb b/lib/gitlab/background_migration/migrate_users_bio_to_user_details.rb
index ca64d13b118..bbe2164ae4e 100644
--- a/lib/gitlab/background_migration/migrate_users_bio_to_user_details.rb
+++ b/lib/gitlab/background_migration/migrate_users_bio_to_user_details.rb
@@ -13,8 +13,6 @@ module Gitlab
end
def perform(start_id, stop_id)
- return if Feature.disabled?(:migrate_bio_to_user_details, default_enabled: true)
-
relation = User
.select("id AS user_id", "substring(COALESCE(bio, '') from 1 for 255) AS bio")
.where("(COALESCE(bio, '') IS DISTINCT FROM '')")
diff --git a/lib/gitlab/background_migration/remove_duplicated_cs_findings_without_vulnerability_id.rb b/lib/gitlab/background_migration/remove_duplicated_cs_findings_without_vulnerability_id.rb
new file mode 100644
index 00000000000..cd305adc7cd
--- /dev/null
+++ b/lib/gitlab/background_migration/remove_duplicated_cs_findings_without_vulnerability_id.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+# rubocop:disable Style/Documentation
+
+module Gitlab
+ module BackgroundMigration
+ class RemoveDuplicatedCsFindingsWithoutVulnerabilityId
+ def perform(start_id, stop_id)
+ end
+ end
+ end
+end
+
+Gitlab::BackgroundMigration::RemoveDuplicatedCsFindingsWithoutVulnerabilityId.prepend_if_ee('EE::Gitlab::BackgroundMigration::RemoveDuplicatedCsFindingsWithoutVulnerabilityId')
diff --git a/lib/gitlab/background_migration/user_mentions/lib/banzai/reference_parser.rb b/lib/gitlab/background_migration/user_mentions/lib/banzai/reference_parser.rb
new file mode 100644
index 00000000000..3def5eb3369
--- /dev/null
+++ b/lib/gitlab/background_migration/user_mentions/lib/banzai/reference_parser.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module BackgroundMigration
+ module UserMentions
+ module Lib
+ module Banzai
+ # isolated Banzai::ReferenceParser
+ module ReferenceParser
+ # Returns the reference parser class for the given type
+ #
+ # Example:
+ #
+ # Banzai::ReferenceParser['isolated_mentioned_group']
+ #
+ # This would return the `::Gitlab::BackgroundMigration::UserMentions::Lib::Banzai::ReferenceParser::IsolatedMentionedGroupParser` class.
+ def self.[](name)
+ const_get("::Gitlab::BackgroundMigration::UserMentions::Lib::Banzai::ReferenceParser::#{name.to_s.camelize}Parser", false)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/user_mentions/lib/banzai/reference_parser/isolated_mentioned_group_parser.rb b/lib/gitlab/background_migration/user_mentions/lib/banzai/reference_parser/isolated_mentioned_group_parser.rb
new file mode 100644
index 00000000000..d3d032ba433
--- /dev/null
+++ b/lib/gitlab/background_migration/user_mentions/lib/banzai/reference_parser/isolated_mentioned_group_parser.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module BackgroundMigration
+ module UserMentions
+ module Lib
+ module Banzai
+ module ReferenceParser
+ # isolated Banzai::ReferenceParser::MentionedGroupParser
+ class IsolatedMentionedGroupParser < ::Banzai::ReferenceParser::MentionedGroupParser
+ extend ::Gitlab::Utils::Override
+
+ self.reference_type = :user
+
+ override :references_relation
+ def references_relation
+ ::Gitlab::BackgroundMigration::UserMentions::Models::Group
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/user_mentions/lib/gitlab/isolated_reference_extractor.rb b/lib/gitlab/background_migration/user_mentions/lib/gitlab/isolated_reference_extractor.rb
new file mode 100644
index 00000000000..1d3a3af81a1
--- /dev/null
+++ b/lib/gitlab/background_migration/user_mentions/lib/gitlab/isolated_reference_extractor.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module BackgroundMigration
+ module UserMentions
+ module Lib
+ module Gitlab
+ # Extract possible GFM references from an arbitrary String for further processing.
+ class IsolatedReferenceExtractor < ::Gitlab::ReferenceExtractor
+ REFERABLES = %i(isolated_mentioned_group).freeze
+
+ REFERABLES.each do |type|
+ define_method("#{type}s") do
+ @references[type] ||= isolated_references(type)
+ end
+ end
+
+ def isolated_references(type)
+ context = ::Banzai::RenderContext.new(project, current_user)
+ processor = ::Gitlab::BackgroundMigration::UserMentions::Lib::Banzai::ReferenceParser[type].new(context)
+
+ refs = processor.process(html_documents)
+ refs[:visible]
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/user_mentions/models/concerns/isolated_mentionable.rb b/lib/gitlab/background_migration/user_mentions/models/concerns/isolated_mentionable.rb
index 69ba3f9132b..be9c0ad2b3a 100644
--- a/lib/gitlab/background_migration/user_mentions/models/concerns/isolated_mentionable.rb
+++ b/lib/gitlab/background_migration/user_mentions/models/concerns/isolated_mentionable.rb
@@ -36,7 +36,8 @@ module Gitlab
if extractor
extractors[current_user] = extractor
else
- extractor = extractors[current_user] ||= ::Gitlab::ReferenceExtractor.new(project, current_user)
+ extractor = extractors[current_user] ||=
+ Gitlab::BackgroundMigration::UserMentions::Lib::Gitlab::IsolatedReferenceExtractor.new(project, current_user)
extractor.reset_memoized_values
end
@@ -71,7 +72,7 @@ module Gitlab
mentioned_users_ids = array_to_sql(refs.mentioned_users.pluck(:id))
mentioned_projects_ids = array_to_sql(refs.mentioned_projects.pluck(:id))
- mentioned_groups_ids = array_to_sql(refs.mentioned_groups.pluck(:id))
+ mentioned_groups_ids = array_to_sql(refs.isolated_mentioned_groups.pluck(:id))
return if mentioned_users_ids.blank? && mentioned_projects_ids.blank? && mentioned_groups_ids.blank?
diff --git a/lib/gitlab/background_migration/user_mentions/models/concerns/namespace/recursive_traversal.rb b/lib/gitlab/background_migration/user_mentions/models/concerns/namespace/recursive_traversal.rb
new file mode 100644
index 00000000000..5cadfa45b5b
--- /dev/null
+++ b/lib/gitlab/background_migration/user_mentions/models/concerns/namespace/recursive_traversal.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module BackgroundMigration
+ module UserMentions
+ module Models
+ module Concerns
+ module Namespace
+ # extracted methods for recursive traversing of namespace hierarchy
+ module RecursiveTraversal
+ extend ActiveSupport::Concern
+
+ def root_ancestor
+ return self if persisted? && parent_id.nil?
+
+ strong_memoize(:root_ancestor) do
+ Gitlab::ObjectHierarchy
+ .new(self.class.where(id: id))
+ .base_and_ancestors
+ .reorder(nil)
+ .find_by(parent_id: nil)
+ end
+ end
+
+ # Returns all ancestors, self, and descendants of the current namespace.
+ def self_and_hierarchy
+ Gitlab::ObjectHierarchy
+ .new(self.class.where(id: id))
+ .all_objects
+ end
+
+ # Returns all the ancestors of the current namespaces.
+ def ancestors
+ return self.class.none unless parent_id
+
+ Gitlab::ObjectHierarchy
+ .new(self.class.where(id: parent_id))
+ .base_and_ancestors
+ end
+
+ # returns all ancestors upto but excluding the given namespace
+ # when no namespace is given, all ancestors upto the top are returned
+ def ancestors_upto(top = nil, hierarchy_order: nil)
+ Gitlab::ObjectHierarchy.new(self.class.where(id: id))
+ .ancestors(upto: top, hierarchy_order: hierarchy_order)
+ end
+
+ def self_and_ancestors(hierarchy_order: nil)
+ return self.class.where(id: id) unless parent_id
+
+ Gitlab::ObjectHierarchy
+ .new(self.class.where(id: id))
+ .base_and_ancestors(hierarchy_order: hierarchy_order)
+ end
+
+ # Returns all the descendants of the current namespace.
+ def descendants
+ Gitlab::ObjectHierarchy
+ .new(self.class.where(parent_id: id))
+ .base_and_descendants
+ end
+
+ def self_and_descendants
+ Gitlab::ObjectHierarchy
+ .new(self.class.where(id: id))
+ .base_and_descendants
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/user_mentions/models/group.rb b/lib/gitlab/background_migration/user_mentions/models/group.rb
new file mode 100644
index 00000000000..bc04172b9a2
--- /dev/null
+++ b/lib/gitlab/background_migration/user_mentions/models/group.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module BackgroundMigration
+ module UserMentions
+ module Models
+ # isolated Group model
+ class Group < ::Gitlab::BackgroundMigration::UserMentions::Models::Namespace
+ self.store_full_sti_class = false
+ has_one :saml_provider
+
+ def self.declarative_policy_class
+ "GroupPolicy"
+ end
+
+ def max_member_access_for_user(user)
+ return GroupMember::NO_ACCESS unless user
+
+ return GroupMember::OWNER if user.admin?
+
+ max_member_access = members_with_parents.where(user_id: user)
+ .reorder(access_level: :desc)
+ .first
+ &.access_level
+
+ max_member_access || GroupMember::NO_ACCESS
+ end
+
+ def members_with_parents
+ # Avoids an unnecessary SELECT when the group has no parents
+ source_ids =
+ if has_parent?
+ self_and_ancestors.reorder(nil).select(:id)
+ else
+ id
+ end
+
+ group_hierarchy_members = GroupMember.active_without_invites_and_requests
+ .where(source_id: source_ids)
+
+ GroupMember.from_union([group_hierarchy_members,
+ members_from_self_and_ancestor_group_shares])
+ end
+
+ # rubocop: disable Metrics/AbcSize
+ def members_from_self_and_ancestor_group_shares
+ group_group_link_table = GroupGroupLink.arel_table
+ group_member_table = GroupMember.arel_table
+
+ source_ids =
+ if has_parent?
+ self_and_ancestors.reorder(nil).select(:id)
+ else
+ id
+ end
+
+ group_group_links_query = GroupGroupLink.where(shared_group_id: source_ids)
+ cte = Gitlab::SQL::CTE.new(:group_group_links_cte, group_group_links_query)
+ cte_alias = cte.table.alias(GroupGroupLink.table_name)
+
+ # Instead of members.access_level, we need to maximize that access_level at
+ # the respective group_group_links.group_access.
+ member_columns = GroupMember.attribute_names.map do |column_name|
+ if column_name == 'access_level'
+ smallest_value_arel([cte_alias[:group_access], group_member_table[:access_level]],
+ 'access_level')
+ else
+ group_member_table[column_name]
+ end
+ end
+
+ GroupMember
+ .with(cte.to_arel)
+ .select(*member_columns)
+ .from([group_member_table, cte.alias_to(group_group_link_table)])
+ .where(group_member_table[:requested_at].eq(nil))
+ .where(group_member_table[:source_id].eq(group_group_link_table[:shared_with_group_id]))
+ .where(group_member_table[:source_type].eq('Namespace'))
+ end
+ # rubocop: enable Metrics/AbcSize
+
+ def smallest_value_arel(args, column_alias)
+ Arel::Nodes::As.new(
+ Arel::Nodes::NamedFunction.new('LEAST', args),
+ Arel::Nodes::SqlLiteral.new(column_alias))
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/user_mentions/models/namespace.rb b/lib/gitlab/background_migration/user_mentions/models/namespace.rb
new file mode 100644
index 00000000000..6d7b9a86e69
--- /dev/null
+++ b/lib/gitlab/background_migration/user_mentions/models/namespace.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module BackgroundMigration
+ module UserMentions
+ module Models
+ # isolated Namespace model
+ class Namespace < ApplicationRecord
+ include ::Gitlab::VisibilityLevel
+ include ::Gitlab::Utils::StrongMemoize
+ include Gitlab::BackgroundMigration::UserMentions::Models::Concerns::Namespace::RecursiveTraversal
+
+ belongs_to :parent, class_name: "::Gitlab::BackgroundMigration::UserMentions::Models::Namespace"
+
+ def visibility_level_field
+ :visibility_level
+ end
+
+ def has_parent?
+ parent_id.present? || parent.present?
+ end
+
+ # Overridden in EE::Namespace
+ def feature_available?(_feature)
+ false
+ end
+ end
+ end
+ end
+ end
+end
+
+Namespace.prepend_if_ee('::EE::Namespace')
diff --git a/lib/gitlab/checks/matching_merge_request.rb b/lib/gitlab/checks/matching_merge_request.rb
index 71361b12d07..db7af0088d0 100644
--- a/lib/gitlab/checks/matching_merge_request.rb
+++ b/lib/gitlab/checks/matching_merge_request.rb
@@ -20,3 +20,5 @@ module Gitlab
end
end
end
+
+Gitlab::Checks::MatchingMergeRequest.prepend_if_ee('EE::Gitlab::Checks::MatchingMergeRequest')
diff --git a/lib/gitlab/ci/ansi2json/converter.rb b/lib/gitlab/ci/ansi2json/converter.rb
index 0373a12ab69..6d152c052dc 100644
--- a/lib/gitlab/ci/ansi2json/converter.rb
+++ b/lib/gitlab/ci/ansi2json/converter.rb
@@ -104,23 +104,24 @@ module Gitlab
action = scanner[1]
timestamp = scanner[2]
section = scanner[3]
+ options = parse_section_options(scanner[4])
section_name = sanitize_section_name(section)
- if action == "start"
- handle_section_start(scanner, section_name, timestamp)
- elsif action == "end"
+ if action == 'start'
+ handle_section_start(scanner, section_name, timestamp, options)
+ elsif action == 'end'
handle_section_end(scanner, section_name, timestamp)
else
raise 'unsupported action'
end
end
- def handle_section_start(scanner, section, timestamp)
+ def handle_section_start(scanner, section, timestamp, options)
# We make a new line for new section
flush_current_line
- @state.open_section(section, timestamp)
+ @state.open_section(section, timestamp, options)
# we need to consume match after handling
# the open of section, as we want the section
@@ -157,6 +158,18 @@ module Gitlab
def sanitize_section_name(section)
section.to_s.downcase.gsub(/[^a-z0-9]/, '-')
end
+
+ def parse_section_options(raw_options)
+ return unless raw_options
+
+ # We need to remove the square brackets and split
+ # by comma to get a list of the options
+ options = raw_options[1...-1].split ','
+
+ # Now split each option by equals to separate
+ # each in the format [key, value]
+ options.to_h { |option| option.split '=' }
+ end
end
end
end
diff --git a/lib/gitlab/ci/ansi2json/line.rb b/lib/gitlab/ci/ansi2json/line.rb
index 21aa1f84353..b1dee0e1ecc 100644
--- a/lib/gitlab/ci/ansi2json/line.rb
+++ b/lib/gitlab/ci/ansi2json/line.rb
@@ -32,7 +32,7 @@ module Gitlab
end
attr_reader :offset, :sections, :segments, :current_segment,
- :section_header, :section_duration
+ :section_header, :section_duration, :section_options
def initialize(offset:, style:, sections: [])
@offset = offset
@@ -68,6 +68,10 @@ module Gitlab
@sections << section
end
+ def set_section_options(options)
+ @section_options = options
+ end
+
def set_as_section_header
@section_header = true
end
@@ -90,6 +94,7 @@ module Gitlab
result[:section] = sections.last if sections.any?
result[:section_header] = true if @section_header
result[:section_duration] = @section_duration if @section_duration
+ result[:section_options] = @section_options if @section_options
end
end
end
diff --git a/lib/gitlab/ci/ansi2json/state.rb b/lib/gitlab/ci/ansi2json/state.rb
index 38d36e6950c..b2b6ce649ed 100644
--- a/lib/gitlab/ci/ansi2json/state.rb
+++ b/lib/gitlab/ci/ansi2json/state.rb
@@ -26,10 +26,11 @@ module Gitlab
Base64.urlsafe_encode64(state.to_json)
end
- def open_section(section, timestamp)
+ def open_section(section, timestamp, options)
@open_sections[section] = timestamp
@current_line.add_section(section)
+ @current_line.set_section_options(options)
@current_line.set_as_section_header
end
diff --git a/lib/gitlab/ci/config/entry/product/matrix.rb b/lib/gitlab/ci/config/entry/product/matrix.rb
index 6af809d46c1..d4ee0978e1b 100644
--- a/lib/gitlab/ci/config/entry/product/matrix.rb
+++ b/lib/gitlab/ci/config/entry/product/matrix.rb
@@ -46,13 +46,11 @@ module Gitlab
end
end
- # rubocop:disable CodeReuse/ActiveRecord
def number_of_generated_jobs
value.sum do |config|
config.values.reduce(1) { |acc, values| acc * values.size }
end
end
- # rubocop:enable CodeReuse/ActiveRecord
end
end
end
diff --git a/lib/gitlab/ci/config/entry/product/variables.rb b/lib/gitlab/ci/config/entry/product/variables.rb
index ac4f70fb69e..2481989060e 100644
--- a/lib/gitlab/ci/config/entry/product/variables.rb
+++ b/lib/gitlab/ci/config/entry/product/variables.rb
@@ -14,7 +14,7 @@ module Gitlab
validations do
validates :config, variables: { array_values: true }
validates :config, length: {
- minimum: 2,
+ minimum: :minimum,
too_short: 'requires at least %{count} items'
}
end
@@ -28,6 +28,10 @@ module Gitlab
.map { |key, value| [key.to_s, Array(value).map(&:to_s)] }
.to_h
end
+
+ def minimum
+ ::Gitlab::Ci::Features.one_dimensional_matrix_enabled? ? 1 : 2
+ end
end
end
end
diff --git a/lib/gitlab/ci/features.rb b/lib/gitlab/ci/features.rb
index e770187b124..e14d56af978 100644
--- a/lib/gitlab/ci/features.rb
+++ b/lib/gitlab/ci/features.rb
@@ -10,10 +10,6 @@ module Gitlab
::Feature.enabled?(:ci_artifacts_exclude, default_enabled: true)
end
- def self.job_heartbeats_runner?(project)
- ::Feature.enabled?(:ci_job_heartbeats_runner, project, default_enabled: true)
- end
-
def self.instance_variables_ui_enabled?
::Feature.enabled?(:ci_instance_variables_ui, default_enabled: true)
end
@@ -35,10 +31,6 @@ module Gitlab
::Feature.enabled?(:ci_raise_job_rules_without_workflow_rules_warning, default_enabled: true)
end
- def self.bulk_insert_on_create?(project)
- ::Feature.enabled?(:ci_bulk_insert_on_create, project, default_enabled: true)
- end
-
# NOTE: The feature flag `disallow_to_create_merge_request_pipelines_in_target_project`
# is a safe switch to disable the feature for a parituclar project when something went wrong,
# therefore it's not supposed to be enabled by default.
@@ -54,10 +46,6 @@ module Gitlab
Feature.enabled?(:project_transactionless_destroy, project, default_enabled: false)
end
- def self.coverage_report_view?(project)
- ::Feature.enabled?(:coverage_report_view, project, default_enabled: true)
- end
-
def self.child_of_child_pipeline_enabled?(project)
::Feature.enabled?(:ci_child_of_child_pipeline, project, default_enabled: true)
end
@@ -72,7 +60,11 @@ module Gitlab
end
def self.new_artifact_file_reader_enabled?(project)
- ::Feature.enabled?(:ci_new_artifact_file_reader, project, default_enabled: false)
+ ::Feature.enabled?(:ci_new_artifact_file_reader, project, default_enabled: true)
+ end
+
+ def self.one_dimensional_matrix_enabled?
+ ::Feature.enabled?(:one_dimensional_matrix, default_enabled: false)
end
end
end
diff --git a/lib/gitlab/ci/pipeline/chain/command.rb b/lib/gitlab/ci/pipeline/chain/command.rb
index d1882059dd8..06096a33f27 100644
--- a/lib/gitlab/ci/pipeline/chain/command.rb
+++ b/lib/gitlab/ci/pipeline/chain/command.rb
@@ -16,7 +16,7 @@ module Gitlab
) do
include Gitlab::Utils::StrongMemoize
- def initialize(**params)
+ def initialize(params = {})
params.each do |key, value|
self[key] = value
end
diff --git a/lib/gitlab/ci/pipeline/chain/create.rb b/lib/gitlab/ci/pipeline/chain/create.rb
index 34649fe16f3..81ef3bb074d 100644
--- a/lib/gitlab/ci/pipeline/chain/create.rb
+++ b/lib/gitlab/ci/pipeline/chain/create.rb
@@ -8,7 +8,7 @@ module Gitlab
include Chain::Helpers
def perform!
- BulkInsertableAssociations.with_bulk_insert(enabled: ::Gitlab::Ci::Features.bulk_insert_on_create?(project)) do
+ BulkInsertableAssociations.with_bulk_insert do
pipeline.save!
end
rescue ActiveRecord::RecordInvalid => e
diff --git a/lib/gitlab/ci/runner/backoff.rb b/lib/gitlab/ci/runner/backoff.rb
new file mode 100644
index 00000000000..95d7719e9cb
--- /dev/null
+++ b/lib/gitlab/ci/runner/backoff.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Ci
+ module Runner
+ ##
+ # Runner Backoff class is an implementation of an exponential backoff
+ # used when a runner communicates with GitLab. We typically use it when a
+ # runner retries sending a build status after we created a build pending
+ # state.
+ #
+ # Backoff is calculated based on the backoff slot which is always a power
+ # of 2:
+ #
+ # 0s - 3s duration -> 1 second backoff
+ # 4s - 7s duration -> 2 seconds backoff
+ # 8s - 15s duration -> 4 seconds backoff
+ # 16s - 31s duration -> 8 seconds backoff
+ # 32s - 63s duration -> 16 seconds backoff
+ # 64s - 127s duration -> 32 seconds backoff
+ # 127s - 256s+ duration -> 64 seconds backoff
+ #
+ # It means that first 15 requests made by a runner will need to respect
+ # following backoffs:
+ #
+ # 0s -> 1 second backoff (backoff started, slot 0, 2^0 backoff)
+ # 1s -> 1 second backoff
+ # 2s -> 1 second backoff
+ # 3s -> 1 seconds backoff
+ # (slot 1 - 2^1 backoff)
+ # 4s -> 2 seconds backoff
+ # 6s -> 2 seconds backoff
+ # (slot 2 - 2^2 backoff)
+ # 8s -> 4 seconds backoff
+ # 12s -> 4 seconds backoff
+ # (slot 3 - 2^3 backoff)
+ # 16s -> 8 seconds backoff
+ # 24s -> 8 seconds backoff
+ # (slot 4 - 2^4 backoff)
+ # 32s -> 16 seconds backoff
+ # 48s -> 16 seconds backoff
+ # (slot 5 - 2^5 backoff)
+ # 64s -> 32 seconds backoff
+ # 96s -> 32 seconds backoff
+ # (slot 6 - 2^6 backoff)
+ # 128s -> 64 seconds backoff
+ #
+ # There is a cap on the backoff - it will never exceed 64 seconds.
+ #
+ class Backoff
+ def initialize(started)
+ @started = started
+
+ if duration < 0
+ raise ArgumentError, 'backoff duration negative'
+ end
+ end
+
+ def duration
+ (Time.current - @started).ceil
+ end
+
+ def slot
+ return 0 if duration < 2
+
+ Math.log(duration, 2).floor - 1
+ end
+
+ def to_seconds
+ 2**[slot, 6].min
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/templates/Jobs/Deploy.latest.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Deploy.latest.gitlab-ci.yml
index 829fd7a722f..8b921305c11 100644
--- a/lib/gitlab/ci/templates/Jobs/Deploy.latest.gitlab-ci.yml
+++ b/lib/gitlab/ci/templates/Jobs/Deploy.latest.gitlab-ci.yml
@@ -1,5 +1,5 @@
.auto-deploy:
- image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v1.0.3"
+ image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v2.0.0-beta.2"
dependencies: []
review:
@@ -91,7 +91,7 @@ canary:
- auto-deploy ensure_namespace
- auto-deploy initialize_tiller
- auto-deploy create_secret
- - auto-deploy deploy canary
+ - auto-deploy deploy canary 50
environment:
name: production
url: http://$CI_PROJECT_PATH_SLUG.$KUBE_INGRESS_BASE_DOMAIN
@@ -114,7 +114,6 @@ canary:
- auto-deploy create_secret
- auto-deploy deploy
- auto-deploy delete canary
- - auto-deploy delete rollout
- auto-deploy persist_environment_url
environment:
name: production
@@ -163,9 +162,7 @@ production_manual:
- auto-deploy ensure_namespace
- auto-deploy initialize_tiller
- auto-deploy create_secret
- - auto-deploy deploy rollout $ROLLOUT_PERCENTAGE
- - auto-deploy scale stable $((100-ROLLOUT_PERCENTAGE))
- - auto-deploy delete canary
+ - auto-deploy deploy canary $ROLLOUT_PERCENTAGE
- auto-deploy persist_environment_url
environment:
name: production
diff --git a/lib/gitlab/ci/templates/Jobs/Deploy/ECS.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Deploy/ECS.gitlab-ci.yml
index da474f8ac88..317e8bfab0e 100644
--- a/lib/gitlab/ci/templates/Jobs/Deploy/ECS.gitlab-ci.yml
+++ b/lib/gitlab/ci/templates/Jobs/Deploy/ECS.gitlab-ci.yml
@@ -10,6 +10,7 @@
.deploy_to_ecs:
image: 'registry.gitlab.com/gitlab-org/cloud-deploy/aws-ecs:latest'
+ dependencies: []
script:
- ecs update-task-definition
diff --git a/lib/gitlab/ci/trace.rb b/lib/gitlab/ci/trace.rb
index 348e5472cb4..6fd32b3f1a0 100644
--- a/lib/gitlab/ci/trace.rb
+++ b/lib/gitlab/ci/trace.rb
@@ -16,6 +16,7 @@ module Gitlab
ArchiveError = Class.new(StandardError)
AlreadyArchivedError = Class.new(StandardError)
+ LockedError = Class.new(StandardError)
attr_reader :job
@@ -130,6 +131,12 @@ module Gitlab
end
end
+ def lock(&block)
+ in_write_lock(&block)
+ rescue FailedToObtainLockError
+ raise LockedError, "build trace `#{job.id}` is locked"
+ end
+
private
def read_stream
diff --git a/lib/gitlab/ci/trace/checksum.rb b/lib/gitlab/ci/trace/checksum.rb
new file mode 100644
index 00000000000..b01136a6d24
--- /dev/null
+++ b/lib/gitlab/ci/trace/checksum.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Ci
+ class Trace
+ ##
+ # Trace::Checksum class is responsible for calculating a CRC32 checksum
+ # of an entire build trace using partial build trace chunks stored in a
+ # database.
+ #
+ # CRC32 checksum can be easily calculated by combining partial checksums
+ # in a right order.
+ #
+ # Then we compare CRC32 checksum provided by a GitLab Runner and expect
+ # it to be the same as the CRC32 checksum derived from partial chunks.
+ #
+ class Checksum
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :build
+
+ def initialize(build)
+ @build = build
+ end
+
+ def valid?
+ return false unless state_crc32.present?
+
+ state_crc32 == chunks_crc32
+ end
+
+ def state_crc32
+ strong_memoize(:crc32) { build.pending_state&.crc32 }
+ end
+
+ def chunks_crc32
+ trace_chunks.reduce(0) do |crc32, chunk|
+ Zlib.crc32_combine(crc32, chunk.crc32, chunk_size(chunk))
+ end
+ end
+
+ def last_chunk
+ strong_memoize(:last_chunk) { trace_chunks.max }
+ end
+
+ ##
+ # Trace chunks will be persisted in a database if an object store is
+ # not configured - in that case we do not want to load entire raw data
+ # of all the chunks into memory.
+ #
+ # We ignore `raw_data` attribute instead, and rely on internal build
+ # trace chunk database adapter to handle
+ # `ActiveModel::MissingAttributeError` exception.
+ #
+ # Alternative solution would be separating chunk data from chunk
+ # metadata on the database level too.
+ #
+ def trace_chunks
+ strong_memoize(:trace_chunks) do
+ build.trace_chunks.persisted
+ .select(::Ci::BuildTraceChunk.metadata_attributes)
+ end
+ end
+
+ private
+
+ def chunk_size(chunk)
+ if chunk == last_chunk
+ chunk.size
+ else
+ ::Ci::BuildTraceChunk::CHUNK_SIZE
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/trace/metrics.rb b/lib/gitlab/ci/trace/metrics.rb
index 82a7d5fb83c..51372871f39 100644
--- a/lib/gitlab/ci/trace/metrics.rb
+++ b/lib/gitlab/ci/trace/metrics.rb
@@ -7,7 +7,8 @@ module Gitlab
extend Gitlab::Utils::StrongMemoize
OPERATIONS = [:appended, :streamed, :chunked, :mutated, :overwrite,
- :accepted, :finalized, :discarded, :conflict].freeze
+ :accepted, :finalized, :discarded, :conflict, :locked,
+ :invalid].freeze
def increment_trace_operation(operation: :unknown)
unless OPERATIONS.include?(operation)
@@ -18,7 +19,7 @@ module Gitlab
end
def increment_trace_bytes(size)
- self.class.trace_bytes.increment(by: size.to_i)
+ self.class.trace_bytes.increment({}, size.to_i)
end
def self.trace_operations
diff --git a/lib/gitlab/ci/variables/collection/item.rb b/lib/gitlab/ci/variables/collection/item.rb
index a072036daa8..84a9280e507 100644
--- a/lib/gitlab/ci/variables/collection/item.rb
+++ b/lib/gitlab/ci/variables/collection/item.rb
@@ -36,9 +36,9 @@ module Gitlab
def self.fabricate(resource)
case resource
when Hash
- self.new(resource.symbolize_keys)
+ self.new(**resource.symbolize_keys)
when ::Ci::HasVariable
- self.new(resource.to_runner_variable)
+ self.new(**resource.to_runner_variable)
when self
resource.dup
else
diff --git a/lib/gitlab/ci/yaml_processor/result.rb b/lib/gitlab/ci/yaml_processor/result.rb
index 68f61e52df7..6c771b220ad 100644
--- a/lib/gitlab/ci/yaml_processor/result.rb
+++ b/lib/gitlab/ci/yaml_processor/result.rb
@@ -95,6 +95,10 @@ module Gitlab
}.compact }.compact
end
+ def merged_yaml
+ @ci_config&.to_hash&.to_yaml
+ end
+
private
def variables
diff --git a/lib/gitlab/cleanup/orphan_lfs_file_references.rb b/lib/gitlab/cleanup/orphan_lfs_file_references.rb
index 14eac474e27..a6638b2cbc8 100644
--- a/lib/gitlab/cleanup/orphan_lfs_file_references.rb
+++ b/lib/gitlab/cleanup/orphan_lfs_file_references.rb
@@ -19,6 +19,11 @@ module Gitlab
def run!
log_info("Looking for orphan LFS files for project #{project.name_with_namespace}")
+ if project.lfs_objects.empty?
+ log_info("Project #{project.name_with_namespace} is linked to 0 LFS objects. Nothing to do")
+ return
+ end
+
remove_orphan_references
end
diff --git a/lib/gitlab/code_navigation_path.rb b/lib/gitlab/code_navigation_path.rb
index 909d0536b5f..7d36f2f12cf 100644
--- a/lib/gitlab/code_navigation_path.rb
+++ b/lib/gitlab/code_navigation_path.rb
@@ -13,7 +13,6 @@ module Gitlab
end
def full_json_path_for(path)
- return unless Feature.enabled?(:code_navigation, project, default_enabled: true)
return unless build
raw_project_job_artifacts_path(project, build, path: "lsif/#{path}.json", file_type: :lsif)
diff --git a/lib/gitlab/config/entry/factory.rb b/lib/gitlab/config/entry/factory.rb
index 7c5ffaa7621..f76c98f7cbf 100644
--- a/lib/gitlab/config/entry/factory.rb
+++ b/lib/gitlab/config/entry/factory.rb
@@ -79,7 +79,7 @@ module Gitlab
end
def fabricate(entry_class, value = nil)
- entry_class.new(value, @metadata) do |node|
+ entry_class.new(value, **@metadata) do |node|
node.key = @attributes[:key]
node.parent = @attributes[:parent]
node.default = @attributes[:default]
diff --git a/lib/gitlab/config/entry/simplifiable.rb b/lib/gitlab/config/entry/simplifiable.rb
index 315f1947e2c..ee28891a174 100644
--- a/lib/gitlab/config/entry/simplifiable.rb
+++ b/lib/gitlab/config/entry/simplifiable.rb
@@ -19,7 +19,7 @@ module Gitlab
entry = self.class.entry_class(strategy)
- @subject = entry.new(config, metadata, &blk)
+ @subject = entry.new(config, **metadata, &blk)
super(@subject)
end
diff --git a/lib/gitlab/conflict/file_collection.rb b/lib/gitlab/conflict/file_collection.rb
index 53406af2c4e..047600af267 100644
--- a/lib/gitlab/conflict/file_collection.rb
+++ b/lib/gitlab/conflict/file_collection.rb
@@ -21,11 +21,13 @@ module Gitlab
def resolve(user, commit_message, files)
msg = commit_message || default_commit_message
resolution = Gitlab::Git::Conflict::Resolution.new(user, files, msg)
- args = {
+
+ resolver.resolve_conflicts(
+ @source_repo,
+ resolution,
source_branch: merge_request.source_branch,
target_branch: merge_request.target_branch
- }
- resolver.resolve_conflicts(@source_repo, resolution, args)
+ )
ensure
@merge_request.clear_memoized_shas
end
diff --git a/lib/gitlab/danger/commit_linter.rb b/lib/gitlab/danger/commit_linter.rb
index 954934518d7..7b01db125a9 100644
--- a/lib/gitlab/danger/commit_linter.rb
+++ b/lib/gitlab/danger/commit_linter.rb
@@ -10,7 +10,7 @@ module Gitlab
MAX_LINE_LENGTH = 72
MAX_CHANGED_FILES_IN_COMMIT = 3
MAX_CHANGED_LINES_IN_COMMIT = 30
- SHORT_REFERENCE_REGEX = %r{([\w\-\/]+)?(#|!|&|%)\d+\b}.freeze
+ SHORT_REFERENCE_REGEX = %r{([\w\-\/]+)?(?<!`)(#|!|&|%)\d+(?<!`)}.freeze
DEFAULT_SUBJECT_DESCRIPTION = 'commit subject'
WIP_PREFIX = 'WIP: '
PROBLEMS = {
@@ -118,7 +118,7 @@ module Gitlab
next unless line_too_long?(line)
- url_size = line.scan(%r((https?://\S+))).sum { |(url)| url.length } # rubocop:disable CodeReuse/ActiveRecord
+ url_size = line.scan(%r((https?://\S+))).sum { |(url)| url.length }
# If the line includes a URL, we'll allow it to exceed MAX_LINE_LENGTH characters, but
# only if the line _without_ the URL does not exceed this limit.
diff --git a/lib/gitlab/danger/helper.rb b/lib/gitlab/danger/helper.rb
index 3626ec5bf5b..d01455c9ec4 100644
--- a/lib/gitlab/danger/helper.rb
+++ b/lib/gitlab/danger/helper.rb
@@ -214,6 +214,12 @@ module Gitlab
title.gsub(DRAFT_REGEX, '').gsub(/`/, '\\\`')
end
+ def draft_mr?
+ return false unless gitlab_helper
+
+ DRAFT_REGEX.match?(gitlab_helper.mr_json['title'])
+ end
+
def security_mr?
return false unless gitlab_helper
diff --git a/lib/gitlab/danger/roulette.rb b/lib/gitlab/danger/roulette.rb
index a6866868e6c..e67e4a45bfe 100644
--- a/lib/gitlab/danger/roulette.rb
+++ b/lib/gitlab/danger/roulette.rb
@@ -146,13 +146,19 @@ module Gitlab
%i[reviewer traintainer maintainer].map do |role|
spin_role_for_category(team, role, project, category)
end
+ hungry_reviewers = reviewers.select { |member| member.hungry }
+ hungry_traintainers = traintainers.select { |member| member.hungry }
# TODO: take CODEOWNERS into account?
# https://gitlab.com/gitlab-org/gitlab/issues/26723
- # Make traintainers have triple the chance to be picked as a reviewer
random = new_random(mr_source_branch)
- reviewer = spin_for_person(reviewers + traintainers + traintainers, random: random, timezone_experiment: timezone_experiment)
+
+ # Make hungry traintainers have 4x the chance to be picked as a reviewer
+ # Make traintainers have 3x the chance to be picked as a reviewer
+ # Make hungry reviewers have 2x the chance to be picked as a reviewer
+ weighted_reviewers = reviewers + hungry_reviewers + traintainers + traintainers + traintainers + hungry_traintainers
+ reviewer = spin_for_person(weighted_reviewers, random: random, timezone_experiment: timezone_experiment)
maintainer = spin_for_person(maintainers, random: random, timezone_experiment: timezone_experiment)
Spin.new(category, reviewer, maintainer, false, timezone_experiment)
diff --git a/lib/gitlab/danger/teammate.rb b/lib/gitlab/danger/teammate.rb
index ebd96be40d7..4481977db15 100644
--- a/lib/gitlab/danger/teammate.rb
+++ b/lib/gitlab/danger/teammate.rb
@@ -3,7 +3,7 @@
module Gitlab
module Danger
class Teammate
- attr_reader :options, :username, :name, :role, :projects, :available, :tz_offset_hours
+ attr_reader :options, :username, :name, :role, :projects, :available, :hungry, :tz_offset_hours
# The options data are produced by https://gitlab.com/gitlab-org/gitlab-roulette/-/blob/master/lib/team_member.rb
def initialize(options = {})
@@ -14,6 +14,7 @@ module Gitlab
@role = options['role']
@projects = options['projects']
@available = options['available']
+ @hungry = options['hungry']
@tz_offset_hours = options['tz_offset_hours']
end
@@ -31,10 +32,8 @@ module Gitlab
projects&.has_key?(name)
end
- # Traintainers also count as reviewers
def reviewer?(project, category, labels)
- has_capability?(project, category, :reviewer, labels) ||
- traintainer?(project, category, labels)
+ has_capability?(project, category, :reviewer, labels)
end
def traintainer?(project, category, labels)
diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb
index accc6330253..d9c4b1cb280 100644
--- a/lib/gitlab/database.rb
+++ b/lib/gitlab/database.rb
@@ -92,10 +92,6 @@ module Gitlab
@version ||= database_version.match(/\A(?:PostgreSQL |)([^\s]+).*\z/)[1]
end
- def self.postgresql_9_or_less?
- version.to_f < 10
- end
-
def self.postgresql_minimum_supported_version?
version.to_f >= MINIMUM_POSTGRES_VERSION
end
@@ -127,28 +123,6 @@ module Gitlab
# ignore - happens when Rake tasks yet have to create a database, e.g. for testing
end
- # map some of the function names that changed between PostgreSQL 9 and 10
- # https://wiki.postgresql.org/wiki/New_in_postgres_10
- def self.pg_wal_lsn_diff
- Gitlab::Database.postgresql_9_or_less? ? 'pg_xlog_location_diff' : 'pg_wal_lsn_diff'
- end
-
- def self.pg_current_wal_insert_lsn
- Gitlab::Database.postgresql_9_or_less? ? 'pg_current_xlog_insert_location' : 'pg_current_wal_insert_lsn'
- end
-
- def self.pg_last_wal_receive_lsn
- Gitlab::Database.postgresql_9_or_less? ? 'pg_last_xlog_receive_location' : 'pg_last_wal_receive_lsn'
- end
-
- def self.pg_last_wal_replay_lsn
- Gitlab::Database.postgresql_9_or_less? ? 'pg_last_xlog_replay_location' : 'pg_last_wal_replay_lsn'
- end
-
- def self.pg_last_xact_replay_timestamp
- 'pg_last_xact_replay_timestamp'
- end
-
def self.nulls_last_order(field, direction = 'ASC')
Arel.sql("#{field} #{direction} NULLS LAST")
end
diff --git a/lib/gitlab/database/batch_count.rb b/lib/gitlab/database/batch_count.rb
index 1762b81b7d8..0de67ed8cf0 100644
--- a/lib/gitlab/database/batch_count.rb
+++ b/lib/gitlab/database/batch_count.rb
@@ -8,15 +8,20 @@
# In order to not use a possible complex time consuming query when calculating min and max for batch_distinct_count
# the start and finish can be sent specifically
#
+# Grouped relations can be used as well. However, the preferred batch count should be around 10K because group by count is more expensive.
+#
# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22705
#
# Examples:
# extend ::Gitlab::Database::BatchCount
# batch_count(User.active)
# batch_count(::Clusters::Cluster.aws_installed.enabled, :cluster_id)
+# batch_count(Namespace.group(:type))
# batch_distinct_count(::Project, :creator_id)
# batch_distinct_count(::Project.with_active_services.service_desk_enabled.where(time_period), start: ::User.minimum(:id), finish: ::User.maximum(:id))
+# batch_distinct_count(Project.group(:visibility_level), :creator_id)
# batch_sum(User, :sign_in_count)
+# batch_sum(Issue.group(:state_id), :weight))
module Gitlab
module Database
module BatchCount
@@ -77,12 +82,12 @@ module Gitlab
raise "Batch counting expects positive values only for #{@column}" if start < 0 || finish < 0
return FALLBACK if unwanted_configuration?(finish, batch_size, start)
- counter = 0
+ results = nil
batch_start = start
while batch_start <= finish
begin
- counter += batch_fetch(batch_start, batch_start + batch_size, mode)
+ results = merge_results(results, batch_fetch(batch_start, batch_start + batch_size, mode))
batch_start += batch_size
rescue ActiveRecord::QueryCanceled
# retry with a safe batch size & warmer cache
@@ -95,7 +100,17 @@ module Gitlab
sleep(SLEEP_TIME_IN_SECONDS)
end
- counter
+ results
+ end
+
+ def merge_results(results, object)
+ return object unless results
+
+ if object.is_a?(Hash)
+ results.merge!(object) { |_, a, b| a + b }
+ else
+ results + object
+ end
end
def batch_fetch(start, finish, mode)
@@ -118,11 +133,11 @@ module Gitlab
end
def actual_start(start)
- start || @relation.minimum(@column) || 0
+ start || @relation.unscope(:group, :having).minimum(@column) || 0
end
def actual_finish(finish)
- finish || @relation.maximum(@column) || 0
+ finish || @relation.unscope(:group, :having).maximum(@column) || 0
end
def check_mode!(mode)
diff --git a/lib/gitlab/database/concurrent_reindex.rb b/lib/gitlab/database/concurrent_reindex.rb
deleted file mode 100644
index 485ab35e55d..00000000000
--- a/lib/gitlab/database/concurrent_reindex.rb
+++ /dev/null
@@ -1,143 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
- module Database
- class ConcurrentReindex
- include Gitlab::Utils::StrongMemoize
- include MigrationHelpers
-
- ReindexError = Class.new(StandardError)
-
- PG_IDENTIFIER_LENGTH = 63
- TEMPORARY_INDEX_PREFIX = 'tmp_reindex_'
- REPLACED_INDEX_PREFIX = 'old_reindex_'
-
- attr_reader :index_name, :logger
-
- def initialize(index_name, logger:)
- @index_name = index_name
- @logger = logger
- end
-
- def execute
- raise ReindexError, "index #{index_name} does not exist" unless index_exists?
-
- raise ReindexError, 'UNIQUE indexes are currently not supported' if index_unique?
-
- logger.debug("dropping dangling index from previous run: #{replacement_index_name}")
- remove_replacement_index
-
- begin
- create_replacement_index
-
- unless replacement_index_valid?
- message = 'replacement index was created as INVALID'
- logger.error("#{message}, cleaning up")
- raise ReindexError, "failed to reindex #{index_name}: #{message}"
- end
-
- swap_replacement_index
- rescue Gitlab::Database::WithLockRetries::AttemptsExhaustedError => e
- logger.error('failed to obtain the required database locks to swap the indexes, cleaning up')
- raise ReindexError, e.message
- rescue ActiveRecord::ActiveRecordError, PG::Error => e
- logger.error("database error while attempting reindex of #{index_name}: #{e.message}")
- raise ReindexError, e.message
- ensure
- logger.info("dropping unneeded replacement index: #{replacement_index_name}")
- remove_replacement_index
- end
- end
-
- private
-
- def connection
- @connection ||= ActiveRecord::Base.connection
- end
-
- def replacement_index_name
- @replacement_index_name ||= constrained_index_name(TEMPORARY_INDEX_PREFIX)
- end
-
- def index
- strong_memoize(:index) do
- find_index(index_name)
- end
- end
-
- def index_exists?
- !index.nil?
- end
-
- def index_unique?
- index.indisunique
- end
-
- def constrained_index_name(prefix)
- "#{prefix}#{index_name}".slice(0, PG_IDENTIFIER_LENGTH)
- end
-
- def create_replacement_index
- create_replacement_index_statement = index.indexdef
- .sub(/CREATE INDEX/, 'CREATE INDEX CONCURRENTLY')
- .sub(/#{index_name}/, replacement_index_name)
-
- logger.info("creating replacement index #{replacement_index_name}")
- logger.debug("replacement index definition: #{create_replacement_index_statement}")
-
- disable_statement_timeout do
- connection.execute(create_replacement_index_statement)
- end
- end
-
- def replacement_index_valid?
- find_index(replacement_index_name).indisvalid
- end
-
- def find_index(index_name)
- record = connection.select_one(<<~SQL)
- SELECT
- pg_index.indisunique,
- pg_index.indisvalid,
- pg_indexes.indexdef
- FROM pg_index
- INNER JOIN pg_class ON pg_class.oid = pg_index.indexrelid
- INNER JOIN pg_namespace ON pg_class.relnamespace = pg_namespace.oid
- INNER JOIN pg_indexes ON pg_class.relname = pg_indexes.indexname
- WHERE pg_namespace.nspname = 'public'
- AND pg_class.relname = #{connection.quote(index_name)}
- SQL
-
- OpenStruct.new(record) if record
- end
-
- def swap_replacement_index
- replaced_index_name = constrained_index_name(REPLACED_INDEX_PREFIX)
-
- logger.info("swapping replacement index #{replacement_index_name} with #{index_name}")
-
- with_lock_retries do
- rename_index(index_name, replaced_index_name)
- rename_index(replacement_index_name, index_name)
- rename_index(replaced_index_name, replacement_index_name)
- end
- end
-
- def rename_index(old_index_name, new_index_name)
- connection.execute("ALTER INDEX #{old_index_name} RENAME TO #{new_index_name}")
- end
-
- def remove_replacement_index
- disable_statement_timeout do
- connection.execute("DROP INDEX CONCURRENTLY IF EXISTS #{replacement_index_name}")
- end
- end
-
- def with_lock_retries(&block)
- arguments = { klass: self.class, logger: logger }
-
- Gitlab::Database::WithLockRetries.new(arguments).run(raise_on_exhaustion: true, &block)
- end
- end
- end
-end
diff --git a/lib/gitlab/database/count/reltuples_count_strategy.rb b/lib/gitlab/database/count/reltuples_count_strategy.rb
index e226ed7613a..89190320cf9 100644
--- a/lib/gitlab/database/count/reltuples_count_strategy.rb
+++ b/lib/gitlab/database/count/reltuples_count_strategy.rb
@@ -74,8 +74,9 @@ module Gitlab
def get_statistics(table_names, check_statistics: true)
time = 6.hours.ago
- query = PgClass.joins("LEFT JOIN pg_stat_user_tables USING (relname)")
+ query = PgClass.joins("LEFT JOIN pg_stat_user_tables ON pg_stat_user_tables.relid = pg_class.oid")
.where(relname: table_names)
+ .where('schemaname = current_schema()')
.select('pg_class.relname AS table_name, reltuples::bigint AS estimate')
if check_statistics
diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb
index 723f0f6a308..4e2e1eaf21c 100644
--- a/lib/gitlab/database/migration_helpers.rb
+++ b/lib/gitlab/database/migration_helpers.rb
@@ -176,7 +176,7 @@ module Gitlab
name: name.presence || concurrent_foreign_key_name(source, column)
}
- if foreign_key_exists?(source, target, options)
+ if foreign_key_exists?(source, target, **options)
warning_message = "Foreign key not created because it exists already " \
"(this may be due to an aborted migration or similar): " \
"source: #{source}, target: #{target}, column: #{options[:column]}, "\
@@ -330,13 +330,13 @@ module Gitlab
# * +timing_configuration+ - [[ActiveSupport::Duration, ActiveSupport::Duration], ...] lock timeout for the block, sleep time before the next iteration, defaults to `Gitlab::Database::WithLockRetries::DEFAULT_TIMING_CONFIGURATION`
# * +logger+ - [Gitlab::JsonLogger]
# * +env+ - [Hash] custom environment hash, see the example with `DISABLE_LOCK_RETRIES`
- def with_lock_retries(**args, &block)
+ def with_lock_retries(*args, **kwargs, &block)
merged_args = {
klass: self.class,
logger: Gitlab::BackgroundMigration::Logger
- }.merge(args)
+ }.merge(kwargs)
- Gitlab::Database::WithLockRetries.new(merged_args).run(&block)
+ Gitlab::Database::WithLockRetries.new(**merged_args).run(&block)
end
def true_value
@@ -882,7 +882,7 @@ module Gitlab
# column.
opclasses[new] = opclasses.delete(old) if opclasses[old]
- options[:opclasses] = opclasses
+ options[:opclass] = opclasses
end
add_concurrent_index(table, new_columns, options)
@@ -994,10 +994,10 @@ into similar problems in the future (e.g. when new tables are created).
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}'
+ FROM pg_catalog.pg_indexes
+ WHERE schemaname = #{connection.quote(current_schema)}
+ AND tablename = #{connection.quote(table)}
+ AND indexname = #{connection.quote(name)}
SQL
connection.select_value(index_sql).to_i > 0
@@ -1053,11 +1053,15 @@ into similar problems in the future (e.g. when new tables are created).
# the table name in addition to using the constraint_name
check_sql = <<~SQL
SELECT COUNT(*)
- FROM pg_constraint
- JOIN pg_class ON pg_constraint.conrelid = pg_class.oid
- WHERE pg_constraint.contype = 'c'
- AND pg_constraint.conname = '#{constraint_name}'
- AND pg_class.relname = '#{table}'
+ FROM pg_catalog.pg_constraint con
+ INNER JOIN pg_catalog.pg_class rel
+ ON rel.oid = con.conrelid
+ INNER JOIN pg_catalog.pg_namespace nsp
+ ON nsp.oid = con.connamespace
+ WHERE con.contype = 'c'
+ AND con.conname = #{connection.quote(constraint_name)}
+ AND nsp.nspname = #{connection.quote(current_schema)}
+ AND rel.relname = #{connection.quote(table)}
SQL
connection.select_value(check_sql) > 0
@@ -1284,8 +1288,9 @@ into similar problems in the future (e.g. when new tables are created).
check_sql = <<~SQL
SELECT c.is_nullable
FROM information_schema.columns c
- WHERE c.table_name = '#{table}'
- AND c.column_name = '#{column}'
+ WHERE c.table_schema = #{connection.quote(current_schema)}
+ AND c.table_name = #{connection.quote(table)}
+ AND c.column_name = #{connection.quote(column)}
SQL
connection.select_value(check_sql) == 'YES'
diff --git a/lib/gitlab/database/partitioning/partition_creator.rb b/lib/gitlab/database/partitioning/partition_creator.rb
index 4c1b13fe3b5..547e0b9b957 100644
--- a/lib/gitlab/database/partitioning/partition_creator.rb
+++ b/lib/gitlab/database/partitioning/partition_creator.rb
@@ -72,10 +72,10 @@ module Gitlab
end
def with_lock_retries(&block)
- Gitlab::Database::WithLockRetries.new({
+ Gitlab::Database::WithLockRetries.new(
klass: self.class,
logger: Gitlab::AppLogger
- }).run(&block)
+ ).run(&block)
end
def connection
diff --git a/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table.rb b/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table.rb
index f9ad1e60776..17a42d997e6 100644
--- a/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table.rb
+++ b/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table.rb
@@ -11,8 +11,6 @@ module Gitlab
PAUSE_SECONDS = 0.25
def perform(start_id, stop_id, source_table, partitioned_table, source_column)
- return unless Feature.enabled?(:backfill_partitioned_audit_events, default_enabled: true)
-
if transaction_open?
raise "Aborting job to backfill partitioned #{source_table} table! Do not run this job in a transaction block!"
end
diff --git a/lib/gitlab/database/postgres_index.rb b/lib/gitlab/database/postgres_index.rb
new file mode 100644
index 00000000000..2a9f23f0098
--- /dev/null
+++ b/lib/gitlab/database/postgres_index.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ class PostgresIndex < ActiveRecord::Base
+ self.table_name = 'postgres_indexes'
+ self.primary_key = 'identifier'
+
+ scope :by_identifier, ->(identifier) do
+ raise ArgumentError, "Index name is not fully qualified with a schema: #{identifier}" unless identifier =~ /^\w+\.\w+$/
+
+ find(identifier)
+ end
+
+ # A 'regular' index is a non-unique index,
+ # that does not serve an exclusion constraint and
+ # is defined on a table that is not partitioned.
+ scope :regular, -> { where(unique: false, partitioned: false, exclusion: false)}
+
+ scope :random_few, ->(how_many) do
+ limit(how_many).order(Arel.sql('RANDOM()'))
+ end
+
+ scope :not_match, ->(regex) { where("name !~ ?", regex)}
+
+ def to_s
+ name
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/reindexing.rb b/lib/gitlab/database/reindexing.rb
new file mode 100644
index 00000000000..baffe28d9ed
--- /dev/null
+++ b/lib/gitlab/database/reindexing.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ module Reindexing
+ def self.perform(index_selector)
+ Array.wrap(index_selector).each do |index|
+ ReindexAction.keep_track_of(index) do
+ ConcurrentReindex.new(index).perform
+ end
+ end
+ end
+
+ def self.candidate_indexes
+ Gitlab::Database::PostgresIndex
+ .regular
+ .not_match("^#{ConcurrentReindex::TEMPORARY_INDEX_PREFIX}")
+ .not_match("^#{ConcurrentReindex::REPLACED_INDEX_PREFIX}")
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/reindexing/concurrent_reindex.rb b/lib/gitlab/database/reindexing/concurrent_reindex.rb
new file mode 100644
index 00000000000..89fab4a183c
--- /dev/null
+++ b/lib/gitlab/database/reindexing/concurrent_reindex.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ module Reindexing
+ class ConcurrentReindex
+ include Gitlab::Utils::StrongMemoize
+ include MigrationHelpers
+
+ ReindexError = Class.new(StandardError)
+
+ PG_IDENTIFIER_LENGTH = 63
+ TEMPORARY_INDEX_PREFIX = 'tmp_reindex_'
+ REPLACED_INDEX_PREFIX = 'old_reindex_'
+
+ attr_reader :index, :logger
+
+ def initialize(index, logger: Gitlab::AppLogger)
+ @index = index
+ @logger = logger
+ end
+
+ def perform
+ raise ReindexError, 'UNIQUE indexes are currently not supported' if index.unique?
+ raise ReindexError, 'partitioned indexes are currently not supported' if index.partitioned?
+ raise ReindexError, 'indexes serving an exclusion constraint are currently not supported' if index.exclusion?
+ raise ReindexError, 'index is a left-over temporary index from a previous reindexing run' if index.name.start_with?(TEMPORARY_INDEX_PREFIX, REPLACED_INDEX_PREFIX)
+
+ logger.info "Starting reindex of #{index}"
+
+ with_rebuilt_index do |replacement_index|
+ swap_index(replacement_index)
+ end
+ end
+
+ private
+
+ def with_rebuilt_index
+ if Gitlab::Database::PostgresIndex.find_by(schema: index.schema, name: replacement_index_name)
+ logger.debug("dropping dangling index from previous run (if it exists): #{replacement_index_name}")
+ remove_index(index.schema, replacement_index_name)
+ end
+
+ create_replacement_index_statement = index.definition
+ .sub(/CREATE INDEX #{index.name}/, "CREATE INDEX CONCURRENTLY #{replacement_index_name}")
+
+ logger.info("creating replacement index #{replacement_index_name}")
+ logger.debug("replacement index definition: #{create_replacement_index_statement}")
+
+ disable_statement_timeout do
+ connection.execute(create_replacement_index_statement)
+ end
+
+ replacement_index = Gitlab::Database::PostgresIndex.find_by(schema: index.schema, name: replacement_index_name)
+
+ unless replacement_index.valid_index?
+ message = 'replacement index was created as INVALID'
+ logger.error("#{message}, cleaning up")
+ raise ReindexError, "failed to reindex #{index}: #{message}"
+ end
+
+ yield replacement_index
+ ensure
+ begin
+ remove_index(index.schema, replacement_index_name)
+ rescue => e
+ logger.error(e)
+ end
+ end
+
+ def swap_index(replacement_index)
+ logger.info("swapping replacement index #{replacement_index} with #{index}")
+
+ with_lock_retries do
+ rename_index(index.schema, index.name, replaced_index_name)
+ rename_index(replacement_index.schema, replacement_index.name, index.name)
+ rename_index(index.schema, replaced_index_name, replacement_index.name)
+ end
+ end
+
+ def rename_index(schema, old_index_name, new_index_name)
+ connection.execute(<<~SQL)
+ ALTER INDEX #{quote_table_name(schema)}.#{quote_table_name(old_index_name)}
+ RENAME TO #{quote_table_name(new_index_name)}
+ SQL
+ end
+
+ def remove_index(schema, name)
+ logger.info("Removing index #{schema}.#{name}")
+
+ disable_statement_timeout do
+ connection.execute(<<~SQL)
+ DROP INDEX CONCURRENTLY
+ IF EXISTS #{quote_table_name(schema)}.#{quote_table_name(name)}
+ SQL
+ end
+ end
+
+ def replacement_index_name
+ @replacement_index_name ||= "#{TEMPORARY_INDEX_PREFIX}#{index.indexrelid}"
+ end
+
+ def replaced_index_name
+ @replaced_index_name ||= "#{REPLACED_INDEX_PREFIX}#{index.indexrelid}"
+ end
+
+ def with_lock_retries(&block)
+ arguments = { klass: self.class, logger: logger }
+
+ Gitlab::Database::WithLockRetries.new(**arguments).run(raise_on_exhaustion: true, &block)
+ end
+
+ delegate :execute, :quote_table_name, to: :connection
+ def connection
+ @connection ||= ActiveRecord::Base.connection
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/reindexing/reindex_action.rb b/lib/gitlab/database/reindexing/reindex_action.rb
new file mode 100644
index 00000000000..0928ef90e5d
--- /dev/null
+++ b/lib/gitlab/database/reindexing/reindex_action.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ module Reindexing
+ class ReindexAction < ActiveRecord::Base
+ self.table_name = 'postgres_reindex_actions'
+
+ enum state: { started: 0, finished: 1, failed: 2 }
+
+ def self.keep_track_of(index, &block)
+ action = create!(
+ index_identifier: index.identifier,
+ action_start: Time.zone.now,
+ ondisk_size_bytes_start: index.ondisk_size_bytes
+ )
+
+ yield
+
+ action.state = :finished
+ rescue
+ action.state = :failed
+ raise
+ ensure
+ index.reload # rubocop:disable Cop/ActiveRecordAssociationReload
+
+ action.action_end = Time.zone.now
+ action.ondisk_size_bytes_end = index.ondisk_size_bytes
+
+ action.save!
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/schema_helpers.rb b/lib/gitlab/database/schema_helpers.rb
index dda4d8eecdb..3d929c62933 100644
--- a/lib/gitlab/database/schema_helpers.rb
+++ b/lib/gitlab/database/schema_helpers.rb
@@ -32,11 +32,14 @@ module Gitlab
def trigger_exists?(table_name, name)
connection.select_value(<<~SQL)
SELECT 1
- FROM pg_trigger
- INNER JOIN pg_class
- ON pg_trigger.tgrelid = pg_class.oid
- WHERE pg_class.relname = '#{table_name}'
- AND pg_trigger.tgname = '#{name}'
+ FROM pg_catalog.pg_trigger trgr
+ INNER JOIN pg_catalog.pg_class rel
+ ON trgr.tgrelid = rel.oid
+ INNER JOIN pg_catalog.pg_namespace nsp
+ ON nsp.oid = rel.relnamespace
+ WHERE nsp.nspname = #{connection.quote(current_schema)}
+ AND rel.relname = #{connection.quote(table_name)}
+ AND trgr.tgname = #{connection.quote(name)}
SQL
end
@@ -68,10 +71,10 @@ module Gitlab
end
def with_lock_retries(&block)
- Gitlab::Database::WithLockRetries.new({
+ Gitlab::Database::WithLockRetries.new(
klass: self.class,
logger: Gitlab::BackgroundMigration::Logger
- }).run(&block)
+ ).run(&block)
end
def assert_not_in_transaction_block(scope:)
diff --git a/lib/gitlab/database/similarity_score.rb b/lib/gitlab/database/similarity_score.rb
index 2633c29438a..ff78fd0218c 100644
--- a/lib/gitlab/database/similarity_score.rb
+++ b/lib/gitlab/database/similarity_score.rb
@@ -6,6 +6,11 @@ module Gitlab
EMPTY_STRING = Arel.sql("''").freeze
EXPRESSION_ON_INVALID_INPUT = Arel::Nodes::NamedFunction.new('CAST', [Arel.sql('0').as('integer')]).freeze
DEFAULT_MULTIPLIER = 1
+ DISPLAY_NAME = self.name.underscore.freeze
+
+ # Adds a "magic" comment in the generated SQL expression in order to be able to tell if we're sorting by similarity.
+ # Example: /* gitlab/database/similarity_score */ SIMILARITY(COALESCE...
+ SIMILARITY_FUNCTION_CALL_WITH_ANNOTATION = "/* #{DISPLAY_NAME} */ SIMILARITY".freeze
# This method returns an Arel expression that can be used in an ActiveRecord query to order the resultset by similarity.
#
@@ -74,6 +79,10 @@ module Gitlab
end
end
+ def self.order_by_similarity?(arel_query)
+ arel_query.to_sql.include?(SIMILARITY_FUNCTION_CALL_WITH_ANNOTATION)
+ end
+
# (SIMILARITY(COALESCE(column, ''), 'search_string') * CAST(multiplier AS numeric))
def self.rule_to_arel(search, rule)
Arel::Nodes::Grouping.new(
@@ -91,7 +100,7 @@ module Gitlab
# SIMILARITY(COALESCE(column, ''), 'search_string')
def self.similarity_function_call(search, column)
- Arel::Nodes::NamedFunction.new('SIMILARITY', [column, Arel.sql(search)])
+ Arel::Nodes::NamedFunction.new(SIMILARITY_FUNCTION_CALL_WITH_ANNOTATION, [column, Arel.sql(search)])
end
# CAST(multiplier AS numeric)
diff --git a/lib/gitlab/database/with_lock_retries.rb b/lib/gitlab/database/with_lock_retries.rb
index a9c86e4e267..3fb52d786ad 100644
--- a/lib/gitlab/database/with_lock_retries.rb
+++ b/lib/gitlab/database/with_lock_retries.rb
@@ -95,7 +95,7 @@ module Gitlab
run_block_with_transaction
rescue ActiveRecord::LockWaitTimeout
if retry_with_lock_timeout?
- disable_idle_in_transaction_timeout
+ disable_idle_in_transaction_timeout if ActiveRecord::Base.connection.transaction_open?
wait_until_next_retry
reset_db_settings
@@ -149,7 +149,7 @@ module Gitlab
log(message: "Couldn't acquire lock to perform the migration", current_iteration: current_iteration)
log(message: "Executing the migration without lock timeout", current_iteration: current_iteration)
- execute("SET LOCAL lock_timeout TO '0'")
+ disable_lock_timeout if ActiveRecord::Base.connection.transaction_open?
run_block
@@ -184,6 +184,10 @@ module Gitlab
execute("SET LOCAL idle_in_transaction_session_timeout TO '0'")
end
+ def disable_lock_timeout
+ execute("SET LOCAL lock_timeout TO '0'")
+ end
+
def reset_db_settings
execute('RESET idle_in_transaction_session_timeout; RESET lock_timeout')
end
diff --git a/lib/gitlab/design_management/copy_design_collection_model_attributes.yml b/lib/gitlab/design_management/copy_design_collection_model_attributes.yml
new file mode 100644
index 00000000000..1d341e6520e
--- /dev/null
+++ b/lib/gitlab/design_management/copy_design_collection_model_attributes.yml
@@ -0,0 +1,43 @@
+# This file exists to lock the attributes of Design Management models
+# that get copied in `DesignManagement::CopyDesignCollection::CopyService`
+# to specific schemas.
+#
+# This allows us to perform sanity checks and alert when there are changes
+# to the schema by running expectations against the lists in this file
+# and the actual schema of the models in `copy_designs_service_spec.rb`.
+#
+# If you are here because you received a failed test in
+# `copy_designs_service_spec.rb`, you need to decide how to handle the
+# changes and whether the new attribute(s) should be included in the copy
+# or ignored.
+
+# COPY.
+# Add attributes that should be copied to the `{model}_attributes` lists:
+design_attributes:
+ - filename
+ - relative_position
+
+version_attributes:
+ - author_id
+ - created_at
+
+action_attributes: # (None)
+
+# IGNORE.
+# Add attributes that should not be copied to the `ignore_{model}_attributes` lists:
+ignore_design_attributes:
+ - id
+ - issue_id
+ - project_id
+
+ignore_version_attributes:
+ - id
+ - issue_id
+ - sha
+
+ignore_action_attributes:
+ - id
+ - design_id
+ - event
+ - image_v432x230
+ - version_id
diff --git a/lib/gitlab/diff/file_collection/merge_request_diff_base.rb b/lib/gitlab/diff/file_collection/merge_request_diff_base.rb
index d54e1aad19a..341572f9c94 100644
--- a/lib/gitlab/diff/file_collection/merge_request_diff_base.rb
+++ b/lib/gitlab/diff/file_collection/merge_request_diff_base.rb
@@ -11,7 +11,7 @@ module Gitlab
super(merge_request_diff,
project: merge_request_diff.project,
- diff_options: diff_options,
+ diff_options: merged_diff_options(diff_options),
diff_refs: merge_request_diff.diff_refs,
fallback_diff_refs: merge_request_diff.fallback_diff_refs)
end
@@ -64,6 +64,11 @@ module Gitlab
diff_stats_cache.read || super
end
end
+
+ def merged_diff_options(diff_options)
+ max_diff_options = ::Commit.max_diff_options(project: @merge_request_diff.project)
+ diff_options.present? ? diff_options.merge(max_diff_options) : max_diff_options
+ end
end
end
end
diff --git a/lib/gitlab/diff/highlight_cache.rb b/lib/gitlab/diff/highlight_cache.rb
index 0eb22e6b3cb..e873e9c17d5 100644
--- a/lib/gitlab/diff/highlight_cache.rb
+++ b/lib/gitlab/diff/highlight_cache.rb
@@ -20,10 +20,23 @@ module Gitlab
# - Assigns DiffFile#highlighted_diff_lines for cached files
#
def decorate(diff_file)
- if content = read_file(diff_file)
- diff_file.highlighted_diff_lines = content.map do |line|
- Gitlab::Diff::Line.safe_init_from_hash(line)
- end
+ content = read_file(diff_file)
+
+ return [] unless content
+
+ if content.empty? && recache_due_to_size?(diff_file)
+ # If the file is missing from the cache and there's reason to believe
+ # it is uncached due to a size issue around changing the values for
+ # max patch size, manually populate the hash and then set the value.
+ #
+ new_cache_content = {}
+ new_cache_content[diff_file.file_path] = diff_file.highlighted_diff_lines.map(&:to_hash)
+
+ write_to_redis_hash(new_cache_content)
+
+ set_highlighted_diff_lines(diff_file, read_file(diff_file))
+ else
+ set_highlighted_diff_lines(diff_file, content)
end
end
@@ -58,6 +71,28 @@ module Gitlab
private
+ def set_highlighted_diff_lines(diff_file, content)
+ diff_file.highlighted_diff_lines = content.map do |line|
+ Gitlab::Diff::Line.safe_init_from_hash(line)
+ end
+ end
+
+ def recache_due_to_size?(diff_file)
+ diff_file_class = diff_file.diff.class
+
+ current_patch_safe_limit_bytes = diff_file_class.patch_safe_limit_bytes
+ default_patch_safe_limit_bytes = diff_file_class.patch_safe_limit_bytes(diff_file_class::DEFAULT_MAX_PATCH_BYTES)
+
+ # If the diff is >= than the default limit, but less than the current
+ # limit, it is likely uncached due to having hit the default limit,
+ # making it eligible for recalculating.
+ #
+ diff_file.diff.diff_bytesize.between?(
+ default_patch_safe_limit_bytes,
+ current_patch_safe_limit_bytes
+ )
+ end
+
def cacheable_files
strong_memoize(:cacheable_files) do
diff_files.select { |file| cacheable?(file) && read_file(file).nil? }
diff --git a/lib/gitlab/exclusive_lease_helpers.rb b/lib/gitlab/exclusive_lease_helpers.rb
index 10762d83588..da5b0afad38 100644
--- a/lib/gitlab/exclusive_lease_helpers.rb
+++ b/lib/gitlab/exclusive_lease_helpers.rb
@@ -35,7 +35,7 @@ module Gitlab
lease.obtain(1 + retries)
- yield(lease.retried?)
+ yield(lease.retried?, lease)
ensure
lease&.cancel
end
diff --git a/lib/gitlab/experimentation.rb b/lib/gitlab/experimentation.rb
index dca60c93fb2..72d3da6f0f2 100644
--- a/lib/gitlab/experimentation.rb
+++ b/lib/gitlab/experimentation.rb
@@ -62,6 +62,12 @@ module Gitlab
},
invite_email: {
tracking_category: 'Growth::Acquisition::Experiment::InviteEmail'
+ },
+ invitation_reminders: {
+ tracking_category: 'Growth::Acquisition::Experiment::InvitationReminders'
+ },
+ group_only_trials: {
+ tracking_category: 'Growth::Conversion::Experiment::GroupOnlyTrials'
}
}.freeze
@@ -91,10 +97,17 @@ module Gitlab
}
end
+ def push_frontend_experiment(experiment_key)
+ var_name = experiment_key.to_s.camelize(:lower)
+ enabled = experiment_enabled?(experiment_key)
+
+ gon.push({ experiments: { var_name => enabled } }, true)
+ end
+
def experiment_enabled?(experiment_key)
return false if dnt_enabled?
- return true if Experimentation.enabled_for_user?(experiment_key, experimentation_subject_index)
+ return true if Experimentation.enabled_for_value?(experiment_key, experimentation_subject_index)
return true if forced_enabled?(experiment_key)
false
@@ -102,7 +115,7 @@ module Gitlab
def track_experiment_event(experiment_key, action, value = nil)
track_experiment_event_for(experiment_key, action, value) do |tracking_data|
- ::Gitlab::Tracking.event(tracking_data.delete(:category), tracking_data.delete(:action), tracking_data)
+ ::Gitlab::Tracking.event(tracking_data.delete(:category), tracking_data.delete(:action), **tracking_data)
end
end
@@ -183,9 +196,14 @@ module Gitlab
experiment.enabled? && experiment.enabled_for_environment?
end
- def enabled_for_user?(experiment_key, experimentation_subject_index)
+ def enabled_for_attribute?(experiment_key, attribute)
+ index = Digest::SHA1.hexdigest(attribute).hex % 100
+ enabled_for_value?(experiment_key, index)
+ end
+
+ def enabled_for_value?(experiment_key, experimentation_subject_index)
enabled?(experiment_key) &&
- experiment(experiment_key).enabled_for_experimentation_subject?(experimentation_subject_index)
+ experiment(experiment_key).enabled_for_index?(experimentation_subject_index)
end
end
@@ -200,10 +218,10 @@ module Gitlab
environment
end
- def enabled_for_experimentation_subject?(experimentation_subject_index)
- return false if experimentation_subject_index.blank?
+ def enabled_for_index?(index)
+ return false if index.blank?
- experimentation_subject_index <= experiment_percentage
+ index <= experiment_percentage
end
private
diff --git a/lib/gitlab/git/diff.rb b/lib/gitlab/git/diff.rb
index 09a49b6c1ca..78c47023c08 100644
--- a/lib/gitlab/git/diff.rb
+++ b/lib/gitlab/git/diff.rb
@@ -120,8 +120,8 @@ module Gitlab
# default.
#
# Patches surpassing this limit should still be persisted in the database.
- def patch_safe_limit_bytes
- patch_hard_limit_bytes / 10
+ def patch_safe_limit_bytes(limit = patch_hard_limit_bytes)
+ limit / 10
end
# Returns the limit for a single diff file (patch).
@@ -174,9 +174,13 @@ module Gitlab
@line_count ||= Util.count_lines(@diff)
end
+ def diff_bytesize
+ @diff_bytesize ||= @diff.bytesize
+ end
+
def too_large?
if @too_large.nil?
- @too_large = @diff.bytesize >= self.class.patch_hard_limit_bytes
+ @too_large = diff_bytesize >= self.class.patch_hard_limit_bytes
else
@too_large
end
@@ -194,7 +198,7 @@ module Gitlab
def collapsed?
return @collapsed if defined?(@collapsed)
- @collapsed = !expanded && @diff.bytesize >= self.class.patch_safe_limit_bytes
+ @collapsed = !expanded && diff_bytesize >= self.class.patch_safe_limit_bytes
end
def collapse!
diff --git a/lib/gitlab/git/diff_collection.rb b/lib/gitlab/git/diff_collection.rb
index e6121d688ba..2fa88973bae 100644
--- a/lib/gitlab/git/diff_collection.rb
+++ b/lib/gitlab/git/diff_collection.rb
@@ -7,19 +7,23 @@ module Gitlab
class DiffCollection
include Enumerable
- DEFAULT_LIMITS = { max_files: 100, max_lines: 5000 }.freeze
-
attr_reader :limits
delegate :max_files, :max_lines, :max_bytes, :safe_max_files, :safe_max_lines, :safe_max_bytes, to: :limits
+ def self.default_limits
+ { max_files: 100, max_lines: 5000 }
+ end
+
def self.limits(options = {})
limits = {}
- limits[:max_files] = options.fetch(:max_files, DEFAULT_LIMITS[:max_files])
- limits[:max_lines] = options.fetch(:max_lines, DEFAULT_LIMITS[:max_lines])
+ defaults = default_limits
+ limits[:max_files] = options.fetch(:max_files, defaults[:max_files])
+ limits[:max_lines] = options.fetch(:max_lines, defaults[:max_lines])
limits[:max_bytes] = limits[:max_files] * 5.kilobytes # Average 5 KB per file
- limits[:safe_max_files] = [limits[:max_files], DEFAULT_LIMITS[:max_files]].min
- limits[:safe_max_lines] = [limits[:max_lines], DEFAULT_LIMITS[:max_lines]].min
+
+ limits[:safe_max_files] = [limits[:max_files], defaults[:max_files]].min
+ limits[:safe_max_lines] = [limits[:max_lines], defaults[:max_lines]].min
limits[:safe_max_bytes] = limits[:safe_max_files] * 5.kilobytes # Average 5 KB per file
limits[:max_patch_bytes] = Gitlab::Git::Diff.patch_hard_limit_bytes
diff --git a/lib/gitlab/git/diff_stats_collection.rb b/lib/gitlab/git/diff_stats_collection.rb
index 7e49d79676e..e30ec836a49 100644
--- a/lib/gitlab/git/diff_stats_collection.rb
+++ b/lib/gitlab/git/diff_stats_collection.rb
@@ -22,8 +22,8 @@ module Gitlab
@collection.map(&:path)
end
- def real_size
- max_files = ::Commit.max_diff_options[:max_files]
+ def real_size(project: nil)
+ max_files = ::Commit.max_diff_options(project: project)[:max_files]
if paths.size > max_files
"#{max_files}+"
else
diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb
index b67b3a37440..d8c992155cb 100644
--- a/lib/gitlab/git_access.rb
+++ b/lib/gitlab/git_access.rb
@@ -504,7 +504,7 @@ module Gitlab
changes_size = 0
changes_list.each do |change|
- changes_size += repository.new_blobs(change[:newrev]).sum(&:size) # rubocop: disable CodeReuse/ActiveRecord
+ changes_size += repository.new_blobs(change[:newrev]).sum(&:size)
check_size_against_limit(changes_size)
end
diff --git a/lib/gitlab/git_access_snippet.rb b/lib/gitlab/git_access_snippet.rb
index ae83e45f2b3..b4ccca9df07 100644
--- a/lib/gitlab/git_access_snippet.rb
+++ b/lib/gitlab/git_access_snippet.rb
@@ -60,13 +60,17 @@ module Gitlab
def check_valid_actor!
# TODO: Investigate if expanding actor/authentication types are needed.
# https://gitlab.com/gitlab-org/gitlab/issues/202190
- if actor && !actor.is_a?(User) && !actor.instance_of?(Key)
+ if actor && !allowed_actor?
raise ForbiddenError, ERROR_MESSAGES[:authentication_mechanism]
end
super
end
+ def allowed_actor?
+ actor.is_a?(User) || actor.instance_of?(Key)
+ end
+
def project_snippet?
snippet.is_a?(ProjectSnippet)
end
@@ -138,3 +142,5 @@ module Gitlab
end
end
end
+
+Gitlab::GitAccessSnippet.prepend_if_ee('EE::Gitlab::GitAccessSnippet')
diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb
index 66517ecd743..d2ddc608d3a 100644
--- a/lib/gitlab/gon_helper.rb
+++ b/lib/gitlab/gon_helper.rb
@@ -45,10 +45,11 @@ module Gitlab
# made globally available to the frontend
push_frontend_feature_flag(:snippets_vue, default_enabled: true)
push_frontend_feature_flag(:monaco_blobs, default_enabled: true)
- push_frontend_feature_flag(:monaco_ci, default_enabled: false)
+ push_frontend_feature_flag(:monaco_ci, default_enabled: true)
push_frontend_feature_flag(:snippets_edit_vue, default_enabled: true)
push_frontend_feature_flag(:webperf_experiment, default_enabled: false)
push_frontend_feature_flag(:snippets_binary_blob, default_enabled: false)
+ push_frontend_feature_flag(:usage_data_api, default_enabled: false)
# Startup CSS feature is a special one as it can be enabled by means of cookies and params
gon.push({ features: { 'startupCss' => use_startup_css? } }, true)
diff --git a/lib/gitlab/graphql/global_id_compatibility.rb b/lib/gitlab/graphql/global_id_compatibility.rb
new file mode 100644
index 00000000000..a96e4c4b976
--- /dev/null
+++ b/lib/gitlab/graphql/global_id_compatibility.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Graphql
+ module GlobalIDCompatibility
+ # TODO: remove this module once the compatibility layer is no longer needed.
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ def coerce_global_id_arguments!(args)
+ global_id_arguments = self.class.arguments.values.select do |arg|
+ arg.type.is_a?(Class) && arg.type <= ::Types::GlobalIDType
+ end
+
+ global_id_arguments.each do |arg|
+ k = arg.keyword
+ args[k] &&= arg.type.coerce_isolated_input(args[k])
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/graphql/markdown_field.rb b/lib/gitlab/graphql/markdown_field.rb
index 7be6810f7ba..0b5bde8d8d9 100644
--- a/lib/gitlab/graphql/markdown_field.rb
+++ b/lib/gitlab/graphql/markdown_field.rb
@@ -12,13 +12,19 @@ module Gitlab
end
method_name = kwargs.delete(:method) || name.to_s.sub(/_html$/, '')
- kwargs[:resolve] = Gitlab::Graphql::MarkdownField::Resolver.new(method_name.to_sym).proc
+ resolver_method = "#{name}_resolver".to_sym
+ kwargs[:resolver_method] = resolver_method
kwargs[:description] ||= "The GitLab Flavored Markdown rendering of `#{method_name}`"
# Adding complexity to rendered notes since that could cause queries.
kwargs[:complexity] ||= 5
field name, GraphQL::STRING_TYPE, **kwargs
+
+ define_method resolver_method do
+ # We need to `dup` the context so the MarkdownHelper doesn't modify it
+ ::MarkupHelper.markdown_field(object, method_name.to_sym, context.to_h.dup)
+ end
end
end
end
diff --git a/lib/gitlab/graphql/markdown_field/resolver.rb b/lib/gitlab/graphql/markdown_field/resolver.rb
deleted file mode 100644
index 11a01b95ad1..00000000000
--- a/lib/gitlab/graphql/markdown_field/resolver.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
- module Graphql
- module MarkdownField
- class Resolver
- attr_reader :method_name
-
- def initialize(method_name)
- @method_name = method_name
- end
-
- def proc
- -> (object, _args, ctx) do
- # We need to `dup` the context so the MarkdownHelper doesn't modify it
- ::MarkupHelper.markdown_field(object, method_name, ctx.to_h.dup)
- end
- end
- end
- end
- end
-end
diff --git a/lib/gitlab/graphql/pagination/keyset/order_info.rb b/lib/gitlab/graphql/pagination/keyset/order_info.rb
index f54695ddb9a..577f59911f5 100644
--- a/lib/gitlab/graphql/pagination/keyset/order_info.rb
+++ b/lib/gitlab/graphql/pagination/keyset/order_info.rb
@@ -94,6 +94,8 @@ module Gitlab
[order_value.expr.expressions[0].name.to_s, order_value.direction, order_value.expr]
elsif ordering_by_similarity?(order_value)
['similarity', order_value.direction, order_value.expr]
+ elsif ordering_by_case?(order_value)
+ [order_value.expr.case.name.to_s, order_value.direction, order_value.expr]
else
[order_value.expr.name, order_value.direction, nil]
end
@@ -106,7 +108,12 @@ module Gitlab
# determine if ordering using SIMILARITY scoring based on Gitlab::Database::SimilarityScore
def ordering_by_similarity?(order_value)
- order_value.to_sql.match?(/SIMILARITY\(.+\*/)
+ Gitlab::Database::SimilarityScore.order_by_similarity?(order_value)
+ end
+
+ # determine if ordering using CASE
+ def ordering_by_case?(order_value)
+ order_value.expr.is_a?(Arel::Nodes::Case)
end
end
end
diff --git a/lib/gitlab/graphql/query_analyzers/logger_analyzer.rb b/lib/gitlab/graphql/query_analyzers/logger_analyzer.rb
index 6b6bb72eb31..1e568e9dcbc 100644
--- a/lib/gitlab/graphql/query_analyzers/logger_analyzer.rb
+++ b/lib/gitlab/graphql/query_analyzers/logger_analyzer.rb
@@ -6,6 +6,8 @@ module Gitlab
class LoggerAnalyzer
COMPLEXITY_ANALYZER = GraphQL::Analysis::QueryComplexity.new { |query, complexity_value| complexity_value }
DEPTH_ANALYZER = GraphQL::Analysis::QueryDepth.new { |query, depth_value| depth_value }
+ FIELD_USAGE_ANALYZER = GraphQL::Analysis::FieldUsage.new { |query, used_fields, used_deprecated_fields| [used_fields, used_deprecated_fields] }
+ ALL_ANALYZERS = [COMPLEXITY_ANALYZER, DEPTH_ANALYZER, FIELD_USAGE_ANALYZER].freeze
def analyze?(query)
Feature.enabled?(:graphql_logging, default_enabled: true)
@@ -29,12 +31,13 @@ module Gitlab
def final_value(memo)
return if memo.nil?
- analyzers = [COMPLEXITY_ANALYZER, DEPTH_ANALYZER]
- complexity, depth = GraphQL::Analysis.analyze_query(memo[:query], analyzers)
+ complexity, depth, field_usages = GraphQL::Analysis.analyze_query(memo[:query], ALL_ANALYZERS)
memo[:depth] = depth
memo[:complexity] = complexity
memo[:duration_s] = duration(memo[:time_started]).round(1)
+ memo[:used_fields] = field_usages.first
+ memo[:used_deprecated_fields] = field_usages.second
GraphqlLogger.info(memo.except!(:time_started, :query))
rescue => e
diff --git a/lib/gitlab/group_search_results.rb b/lib/gitlab/group_search_results.rb
index 0cc3de297ba..751dedf4323 100644
--- a/lib/gitlab/group_search_results.rb
+++ b/lib/gitlab/group_search_results.rb
@@ -38,3 +38,5 @@ module Gitlab
end
end
end
+
+Gitlab::GroupSearchResults.prepend_if_ee('EE::Gitlab::GroupSearchResults')
diff --git a/lib/gitlab/health_checks/unicorn_check.rb b/lib/gitlab/health_checks/unicorn_check.rb
index cdc6d2a7519..f0c6fdab600 100644
--- a/lib/gitlab/health_checks/unicorn_check.rb
+++ b/lib/gitlab/health_checks/unicorn_check.rb
@@ -22,7 +22,7 @@ module Gitlab
def check
return unless http_servers
- http_servers.sum(&:worker_processes) # rubocop: disable CodeReuse/ActiveRecord
+ http_servers.sum(&:worker_processes)
end
# Traversal of ObjectSpace is expensive, on fully loaded application
diff --git a/lib/gitlab/import_export/base/relation_factory.rb b/lib/gitlab/import_export/base/relation_factory.rb
index 05b69362976..00c6f570f4f 100644
--- a/lib/gitlab/import_export/base/relation_factory.rb
+++ b/lib/gitlab/import_export/base/relation_factory.rb
@@ -53,6 +53,7 @@ module Gitlab
@importable = importable
@imported_object_retries = 0
@relation_hash[importable_column_name] = @importable.id
+ @original_user = {}
# Remove excluded keys from relation_hash
# We don't do this in the parsed_relation_hash because of the 'transformed attributes'
@@ -112,6 +113,7 @@ module Gitlab
def update_user_references
self.class::USER_REFERENCES.each do |reference|
if @relation_hash[reference]
+ @original_user[reference] = @relation_hash[reference]
@relation_hash[reference] = @members_mapper.map[@relation_hash[reference]]
end
end
@@ -243,28 +245,20 @@ module Gitlab
# will be used. Otherwise, a note stating the original author name
# is left.
def set_note_author
- old_author_id = @relation_hash['author_id']
+ old_author_id = @original_user['author_id']
author = @relation_hash.delete('author')
- update_note_for_missing_author(author['name']) unless has_author?(old_author_id)
- end
-
- def has_author?(old_author_id)
- admin_user? && @members_mapper.include?(old_author_id)
+ unless @members_mapper.include?(old_author_id)
+ @relation_hash['note'] = "%{note}\n\n %{missing_author_note}" % {
+ note: @relation_hash['note'].presence || '*Blank note*',
+ missing_author_note: missing_author_note(@relation_hash['updated_at'], author['name'])
+ }
+ end
end
def missing_author_note(updated_at, author_name)
timestamp = updated_at.split('.').first
- "\n\n *By #{author_name} on #{timestamp} (imported from GitLab project)*"
- end
-
- def update_note_for_missing_author(author_name)
- @relation_hash['note'] = '*Blank note*' if @relation_hash['note'].blank?
- @relation_hash['note'] = "#{@relation_hash['note']}#{missing_author_note(@relation_hash['updated_at'], author_name)}"
- end
-
- def admin_user?
- @user.admin?
+ "*By #{author_name} on #{timestamp} (imported from GitLab)*"
end
def existing_object?
diff --git a/lib/gitlab/import_export/lfs_saver.rb b/lib/gitlab/import_export/lfs_saver.rb
index 515fd98630c..4964b8b16f4 100644
--- a/lib/gitlab/import_export/lfs_saver.rb
+++ b/lib/gitlab/import_export/lfs_saver.rb
@@ -21,10 +21,10 @@ module Gitlab
save_lfs_object(lfs_object)
end
- append_lfs_json_for_batch(batch) if write_lfs_json_enabled?
+ append_lfs_json_for_batch(batch)
end
- write_lfs_json if write_lfs_json_enabled?
+ write_lfs_json
true
rescue => e
@@ -35,10 +35,6 @@ module Gitlab
private
- def write_lfs_json_enabled?
- ::Feature.enabled?(:export_lfs_objects_projects, default_enabled: true)
- end
-
def save_lfs_object(lfs_object)
if lfs_object.local_store?
copy_file_for_lfs_object(lfs_object)
diff --git a/lib/gitlab/import_export/members_mapper.rb b/lib/gitlab/import_export/members_mapper.rb
index 31d1f7b48bd..6b37683ea68 100644
--- a/lib/gitlab/import_export/members_mapper.rb
+++ b/lib/gitlab/import_export/members_mapper.rb
@@ -34,8 +34,8 @@ module Gitlab
@user.id
end
- def include?(old_author_id)
- map.has_key?(old_author_id) && map[old_author_id] != default_user_id
+ def include?(old_user_id)
+ map.has_key?(old_user_id)
end
private
@@ -63,6 +63,8 @@ module Gitlab
end
def add_team_member(member, existing_user = nil)
+ return true if existing_user && @importable.members.exists?(user_id: existing_user.id)
+
member['user'] = existing_user
member_hash = member_hash(member)
diff --git a/lib/gitlab/instrumentation/redis.rb b/lib/gitlab/instrumentation/redis.rb
index 4a85a313fd7..d1ac6a55fb7 100644
--- a/lib/gitlab/instrumentation/redis.rb
+++ b/lib/gitlab/instrumentation/redis.rb
@@ -37,7 +37,7 @@ module Gitlab
%i[get_request_count query_time read_bytes write_bytes].each do |method|
define_method method do
- STORAGES.sum(&method) # rubocop:disable CodeReuse/ActiveRecord
+ STORAGES.sum(&method)
end
end
end
diff --git a/lib/gitlab/job_waiter.rb b/lib/gitlab/job_waiter.rb
index e7a8cc6305a..2cede524cac 100644
--- a/lib/gitlab/job_waiter.rb
+++ b/lib/gitlab/job_waiter.rb
@@ -23,7 +23,15 @@ module Gitlab
TIMEOUTS_METRIC = :gitlab_job_waiter_timeouts_total
def self.notify(key, jid)
- Gitlab::Redis::SharedState.with { |redis| redis.lpush(key, jid) }
+ Gitlab::Redis::SharedState.with do |redis|
+ # Use a Redis MULTI transaction to ensure we always set an expiry
+ redis.multi do |multi|
+ multi.lpush(key, jid)
+ # This TTL needs to be long enough to allow whichever Sidekiq job calls
+ # JobWaiter#wait to reach BLPOP.
+ multi.expire(key, 6.hours.to_i)
+ end
+ end
end
def self.key?(key)
@@ -52,10 +60,6 @@ module Gitlab
increment_counter(STARTED_METRIC)
Gitlab::Redis::SharedState.with do |redis|
- # Fallback key expiry: allow a long grace period to reduce the chance of
- # a job pushing to an expired key and recreating it
- redis.expire(key, [timeout * 2, 10.minutes.to_i].max)
-
while jobs_remaining > 0
# Redis will not take fractional seconds. Prefer waiting too long over
# not waiting long enough
@@ -75,9 +79,6 @@ module Gitlab
@finished << jid
@jobs_remaining -= 1
end
-
- # All jobs have finished, so expire the key immediately
- redis.expire(key, 0) if jobs_remaining == 0
end
finished
diff --git a/lib/gitlab/lfs/client.rb b/lib/gitlab/lfs/client.rb
index e4d600694c2..95217f86d01 100644
--- a/lib/gitlab/lfs/client.rb
+++ b/lib/gitlab/lfs/client.rb
@@ -6,6 +6,14 @@ module Gitlab
# * https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md
# * https://github.com/git-lfs/git-lfs/blob/master/docs/api/basic-transfers.md
class Client
+ GIT_LFS_CONTENT_TYPE = 'application/vnd.git-lfs+json'
+ GIT_LFS_USER_AGENT = "GitLab #{Gitlab::VERSION} LFS client"
+ DEFAULT_HEADERS = {
+ 'Accept' => GIT_LFS_CONTENT_TYPE,
+ 'Content-Type' => GIT_LFS_CONTENT_TYPE,
+ 'User-Agent' => GIT_LFS_USER_AGENT
+ }.freeze
+
attr_reader :base_url
def initialize(base_url, credentials:)
@@ -13,19 +21,19 @@ module Gitlab
@credentials = credentials
end
- def batch(operation, objects)
+ def batch!(operation, objects)
body = {
operation: operation,
transfers: ['basic'],
# We don't know `ref`, so can't send it
- objects: objects.map { |object| { oid: object.oid, size: object.size } }
+ objects: objects.as_json(only: [:oid, :size])
}
rsp = Gitlab::HTTP.post(
batch_url,
basic_auth: basic_auth,
body: body.to_json,
- headers: { 'Content-Type' => 'application/vnd.git-lfs+json' }
+ headers: build_request_headers
)
raise BatchSubmitError unless rsp.success?
@@ -40,14 +48,15 @@ module Gitlab
body
end
- def upload(object, upload_action, authenticated:)
+ def upload!(object, upload_action, authenticated:)
file = object.file.open
params = {
body_stream: file,
headers: {
'Content-Length' => object.size.to_s,
- 'Content-Type' => 'application/octet-stream'
+ 'Content-Type' => 'application/octet-stream',
+ 'User-Agent' => GIT_LFS_USER_AGENT
}.merge(upload_action['header'] || {})
}
@@ -60,8 +69,25 @@ module Gitlab
file&.close
end
+ def verify!(object, verify_action, authenticated:)
+ params = {
+ body: object.to_json(only: [:oid, :size]),
+ headers: build_request_headers(verify_action['header'])
+ }
+
+ params[:basic_auth] = basic_auth unless authenticated
+
+ rsp = Gitlab::HTTP.post(verify_action['href'], params)
+
+ raise ObjectVerifyError unless rsp.success?
+ end
+
private
+ def build_request_headers(extra_headers = nil)
+ DEFAULT_HEADERS.merge(extra_headers || {})
+ end
+
attr_reader :credentials
def batch_url
@@ -96,6 +122,12 @@ module Gitlab
"Failed to upload object"
end
end
+
+ class ObjectVerifyError < StandardError
+ def message
+ "Failed to verify object"
+ end
+ end
end
end
end
diff --git a/lib/gitlab/manifest_import/metadata.rb b/lib/gitlab/manifest_import/metadata.rb
new file mode 100644
index 00000000000..80dff075391
--- /dev/null
+++ b/lib/gitlab/manifest_import/metadata.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module ManifestImport
+ class Metadata
+ EXPIRY_TIME = 1.week
+
+ attr_reader :user, :fallback
+
+ def initialize(user, fallback: {})
+ @user = user
+ @fallback = fallback
+ end
+
+ def save(repositories, group_id)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.multi do
+ redis.set(key_for('repositories'), Gitlab::Json.dump(repositories), ex: EXPIRY_TIME)
+ redis.set(key_for('group_id'), group_id, ex: EXPIRY_TIME)
+ end
+ end
+ end
+
+ def repositories
+ redis_get('repositories').then do |repositories|
+ next unless repositories
+
+ Gitlab::Json.parse(repositories).map(&:symbolize_keys)
+ end || fallback[:manifest_import_repositories]
+ end
+
+ def group_id
+ redis_get('group_id')&.to_i || fallback[:manifest_import_group_id]
+ end
+
+ private
+
+ def key_for(field)
+ "manifest_import:metadata:user:#{user.id}:#{field}"
+ end
+
+ def redis_get(field)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.get(key_for(field))
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/dashboard/importers/prometheus_metrics.rb b/lib/gitlab/metrics/dashboard/importers/prometheus_metrics.rb
index d1490d5d9b6..8a176be30a2 100644
--- a/lib/gitlab/metrics/dashboard/importers/prometheus_metrics.rb
+++ b/lib/gitlab/metrics/dashboard/importers/prometheus_metrics.rb
@@ -13,11 +13,12 @@ module Gitlab
@dashboard_hash = dashboard_hash
@project = project
@dashboard_path = dashboard_path
+ @affected_environment_ids = []
end
def execute
import
- rescue ActiveRecord::RecordInvalid, ::Gitlab::Metrics::Dashboard::Transformers::TransformerError
+ rescue ActiveRecord::RecordInvalid, Dashboard::Transformers::Errors::BaseError
false
end
@@ -32,28 +33,51 @@ module Gitlab
def import
delete_stale_metrics
create_or_update_metrics
+ update_prometheus_environments
end
# rubocop: disable CodeReuse/ActiveRecord
def create_or_update_metrics
# TODO: use upsert and worker for callbacks?
+
+ affected_metric_ids = []
prometheus_metrics_attributes.each do |attributes|
- prometheus_metric = PrometheusMetric.find_or_initialize_by(attributes.slice(:identifier, :project))
+ prometheus_metric = PrometheusMetric.find_or_initialize_by(attributes.slice(:dashboard_path, :identifier, :project))
prometheus_metric.update!(attributes.slice(*ALLOWED_ATTRIBUTES))
+
+ affected_metric_ids << prometheus_metric.id
end
+
+ @affected_environment_ids += find_alerts(affected_metric_ids).get_environment_id
end
# rubocop: enable CodeReuse/ActiveRecord
def delete_stale_metrics
- identifiers = prometheus_metrics_attributes.map { |metric_attributes| metric_attributes[:identifier] }
+ identifiers_from_yml = prometheus_metrics_attributes.map { |metric_attributes| metric_attributes[:identifier] }
stale_metrics = PrometheusMetric.for_project(project)
.for_dashboard_path(dashboard_path)
.for_group(Enums::PrometheusMetric.groups[:custom])
- .not_identifier(identifiers)
+ .not_identifier(identifiers_from_yml)
+
+ return unless stale_metrics.exists?
+
+ delete_stale_alerts(stale_metrics)
+ stale_metrics.each_batch { |batch| batch.delete_all }
+ end
+
+ def delete_stale_alerts(stale_metrics)
+ stale_alerts = find_alerts(stale_metrics)
+
+ affected_environment_ids = stale_alerts.get_environment_id
+ return unless affected_environment_ids.present?
- # TODO: use destroy_all and worker for callbacks?
- stale_metrics.each(&:destroy)
+ @affected_environment_ids += affected_environment_ids
+ stale_alerts.each_batch { |batch| batch.delete_all }
+ end
+
+ def find_alerts(metrics)
+ Projects::Prometheus::AlertsFinder.new(project: project, metric: metrics).execute
end
def prometheus_metrics_attributes
@@ -65,6 +89,19 @@ module Gitlab
).execute
end
end
+
+ def update_prometheus_environments
+ affected_environments = ::Environment.for_id(@affected_environment_ids.flatten.uniq).for_project(project)
+
+ return unless affected_environments.exists?
+
+ affected_environments.each do |affected_environment|
+ ::Clusters::Applications::ScheduleUpdateService.new(
+ affected_environment.cluster_prometheus_adapter,
+ project
+ ).execute
+ end
+ end
end
end
end
diff --git a/lib/gitlab/metrics/dashboard/stages/custom_dashboard_metrics_inserter.rb b/lib/gitlab/metrics/dashboard/stages/custom_dashboard_metrics_inserter.rb
new file mode 100644
index 00000000000..5ed4466f440
--- /dev/null
+++ b/lib/gitlab/metrics/dashboard/stages/custom_dashboard_metrics_inserter.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Metrics
+ module Dashboard
+ module Stages
+ # Acts on metrics which have been ingested from source controlled dashboards
+ class CustomDashboardMetricsInserter < BaseStage
+ # For each metric in the dashboard config, attempts to
+ # find a corresponding database record. If found, includes
+ # the record's id in the dashboard config.
+ def transform!
+ database_metrics = ::PrometheusMetricsFinder.new(common: false, group: :custom, project: project).execute
+
+ for_metrics do |metric|
+ metric_record = database_metrics.find { |m| m.identifier == metric[:id] }
+ metric[:metric_id] = metric_record.id if metric_record
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/dashboard/stages/url_validator.rb b/lib/gitlab/metrics/dashboard/stages/url_validator.rb
index 9e2bb0d1a70..ad9d78133af 100644
--- a/lib/gitlab/metrics/dashboard/stages/url_validator.rb
+++ b/lib/gitlab/metrics/dashboard/stages/url_validator.rb
@@ -46,7 +46,7 @@ module Gitlab
links&.each do |link|
next unless link.is_a? Hash
- Gitlab::UrlBlocker.validate!(link[:url], blocker_args)
+ Gitlab::UrlBlocker.validate!(link[:url], **blocker_args)
rescue Gitlab::UrlBlocker::BlockedUrlError
link[:url] = ''
end
diff --git a/lib/gitlab/metrics/dashboard/transformers/errors.rb b/lib/gitlab/metrics/dashboard/transformers/errors.rb
index 4d94ab098ae..bc85dc4e131 100644
--- a/lib/gitlab/metrics/dashboard/transformers/errors.rb
+++ b/lib/gitlab/metrics/dashboard/transformers/errors.rb
@@ -4,10 +4,10 @@ module Gitlab
module Metrics
module Dashboard
module Transformers
- TransformerError = Class.new(StandardError)
-
module Errors
- class MissingAttribute < TransformerError
+ BaseError = Class.new(StandardError)
+
+ class MissingAttribute < BaseError
def initialize(attribute_name)
super("Missing attribute: '#{attribute_name}'")
end
diff --git a/lib/gitlab/metrics/requests_rack_middleware.rb b/lib/gitlab/metrics/requests_rack_middleware.rb
index 15db3999fa4..f8ba254c2a7 100644
--- a/lib/gitlab/metrics/requests_rack_middleware.rb
+++ b/lib/gitlab/metrics/requests_rack_middleware.rb
@@ -4,15 +4,13 @@ module Gitlab
module Metrics
class RequestsRackMiddleware
HTTP_METHODS = {
- "delete" => %w(200 202 204 303 400 401 403 404 410 422 500 503),
- "get" => %w(200 204 301 302 303 304 307 400 401 403 404 410 412 422 429 500 503),
- "head" => %w(200 204 301 302 303 304 400 401 403 404 410 429 500 503),
+ "delete" => %w(200 202 204 303 400 401 403 404 500 503),
+ "get" => %w(200 204 301 302 303 304 307 400 401 403 404 410 422 429 500 503),
+ "head" => %w(200 204 301 302 303 401 403 404 410 500),
"options" => %w(200 404),
- "patch" => %w(200 202 204 400 403 404 409 416 422 500),
- "post" => %w(200 201 202 204 301 302 303 304 400 401 403 404 406 409 410 412 413 415 422 429 500 503),
- "propfind" => %w(404),
- "put" => %w(200 202 204 400 401 403 404 405 406 409 410 415 422 500),
- "report" => %w(404)
+ "patch" => %w(200 202 204 400 403 404 409 416 500),
+ "post" => %w(200 201 202 204 301 302 303 304 400 401 403 404 406 409 410 412 422 429 500 503),
+ "put" => %w(200 202 204 400 401 403 404 405 406 409 410 422 500)
}.freeze
HEALTH_ENDPOINT = /^\/-\/(liveness|readiness|health|metrics)\/?$/.freeze
@@ -48,10 +46,12 @@ module Gitlab
def call(env)
method = env['REQUEST_METHOD'].downcase
+ method = 'INVALID' unless HTTP_METHODS.key?(method)
started = Time.now.to_f
+ health_endpoint = health_endpoint?(env['PATH_INFO'])
begin
- if health_endpoint?(env['PATH_INFO'])
+ if health_endpoint
RequestsRackMiddleware.http_health_requests_total.increment(method: method)
else
RequestsRackMiddleware.http_request_total.increment(method: method)
@@ -60,7 +60,10 @@ module Gitlab
status, headers, body = @app.call(env)
elapsed = Time.now.to_f - started
- RequestsRackMiddleware.http_request_duration_seconds.observe({ method: method, status: status.to_s }, elapsed)
+
+ unless health_endpoint
+ RequestsRackMiddleware.http_request_duration_seconds.observe({ method: method, status: status.to_s }, elapsed)
+ end
[status, headers, body]
rescue
diff --git a/lib/gitlab/metrics/samplers/unicorn_sampler.rb b/lib/gitlab/metrics/samplers/unicorn_sampler.rb
index 8c4d150adad..d7935d65e12 100644
--- a/lib/gitlab/metrics/samplers/unicorn_sampler.rb
+++ b/lib/gitlab/metrics/samplers/unicorn_sampler.rb
@@ -54,7 +54,7 @@ module Gitlab
end
def unicorn_workers_count
- http_servers.sum(&:worker_processes) # rubocop: disable CodeReuse/ActiveRecord
+ http_servers.sum(&:worker_processes)
end
# Traversal of ObjectSpace is expensive, on fully loaded application
diff --git a/lib/gitlab/middleware/multipart.rb b/lib/gitlab/middleware/multipart.rb
index 8e6ac7610f2..e7e18b3bb82 100644
--- a/lib/gitlab/middleware/multipart.rb
+++ b/lib/gitlab/middleware/multipart.rb
@@ -137,6 +137,7 @@ module Gitlab
# TODO this class is meant to replace Handler when the feature flag
# upload_middleware_jwt_params_handler is removed
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/233895#roll-out-steps
class HandlerForJWTParams < Handler
def with_open_files
@rewritten_fields.keys.each do |field|
diff --git a/lib/gitlab/pagination/offset_pagination.rb b/lib/gitlab/pagination/offset_pagination.rb
index 8796dd4d7ec..46c74b8fe3c 100644
--- a/lib/gitlab/pagination/offset_pagination.rb
+++ b/lib/gitlab/pagination/offset_pagination.rb
@@ -10,9 +10,9 @@ module Gitlab
@request_context = request_context
end
- def paginate(relation)
+ def paginate(relation, exclude_total_headers: false)
paginate_with_limit_optimization(add_default_order(relation)).tap do |data|
- add_pagination_headers(data)
+ add_pagination_headers(data, exclude_total_headers)
end
end
@@ -27,7 +27,7 @@ module Gitlab
end
return pagination_data unless pagination_data.is_a?(ActiveRecord::Relation)
- return pagination_data unless Feature.enabled?(:api_kaminari_count_with_limit)
+ return pagination_data unless Feature.enabled?(:api_kaminari_count_with_limit, type: :ops)
limited_total_count = pagination_data.total_count_with_limit
if limited_total_count > Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT
@@ -47,14 +47,14 @@ module Gitlab
relation
end
- def add_pagination_headers(paginated_data)
+ def add_pagination_headers(paginated_data, exclude_total_headers)
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)
+ return if exclude_total_headers || data_without_counts?(paginated_data)
header 'X-Total', paginated_data.total_count.to_s
header 'X-Total-Pages', total_pages(paginated_data).to_s
diff --git a/lib/gitlab/project_template.rb b/lib/gitlab/project_template.rb
index e6e599e079d..fa3af269bbf 100644
--- a/lib/gitlab/project_template.rb
+++ b/lib/gitlab/project_template.rb
@@ -52,6 +52,7 @@ module Gitlab
ProjectTemplate.new('gitbook', 'Pages/GitBook', _('Everything you need to create a GitLab Pages site using GitBook.'), 'https://gitlab.com/pages/gitbook', 'illustrations/logos/gitbook.svg'),
ProjectTemplate.new('hexo', 'Pages/Hexo', _('Everything you need to create a GitLab Pages site using Hexo.'), 'https://gitlab.com/pages/hexo', 'illustrations/logos/hexo.svg'),
ProjectTemplate.new('sse_middleman', 'Static Site Editor/Middleman', _('Middleman project with Static Site Editor support'), 'https://gitlab.com/gitlab-org/project-templates/static-site-editor-middleman'),
+ ProjectTemplate.new('gitpod_spring_petclinic', 'Gitpod/Spring Petclinic', _('A Gitpod configured Webapplication in Spring and Java'), 'https://gitlab.com/gitlab-org/project-templates/gitpod-spring-petclinic', 'illustrations/logos/gitpod.svg'),
ProjectTemplate.new('nfhugo', 'Netlify/Hugo', _('A Hugo site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features.'), 'https://gitlab.com/pages/nfhugo', 'illustrations/logos/netlify.svg'),
ProjectTemplate.new('nfjekyll', 'Netlify/Jekyll', _('A Jekyll site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features.'), 'https://gitlab.com/pages/nfjekyll', 'illustrations/logos/netlify.svg'),
ProjectTemplate.new('nfplainhtml', 'Netlify/Plain HTML', _('A plain HTML site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features.'), 'https://gitlab.com/pages/nfplain-html', 'illustrations/logos/netlify.svg'),
diff --git a/lib/gitlab/quick_actions/merge_request_actions.rb b/lib/gitlab/quick_actions/merge_request_actions.rb
index 98db8ff761e..c8c949a9363 100644
--- a/lib/gitlab/quick_actions/merge_request_actions.rb
+++ b/lib/gitlab/quick_actions/merge_request_actions.rb
@@ -121,6 +121,20 @@ module Gitlab
result[:message]
end
end
+
+ desc _('Approve a merge request')
+ explanation _('Approve the current merge request.')
+ types MergeRequest
+ condition do
+ quick_action_target.persisted? && quick_action_target.can_be_approved_by?(current_user)
+ end
+ command :approve do
+ success = MergeRequests::ApprovalService.new(quick_action_target.project, current_user).execute(quick_action_target)
+
+ next unless success
+
+ @execution_message[:approve] = _('Approved the current merge request.')
+ end
end
def merge_orchestration_service
diff --git a/lib/gitlab/redis/hll.rb b/lib/gitlab/redis/hll.rb
index ff5754675e2..496018c88cb 100644
--- a/lib/gitlab/redis/hll.rb
+++ b/lib/gitlab/redis/hll.rb
@@ -7,11 +7,11 @@ module Gitlab
KeyFormatError = Class.new(StandardError)
def self.count(params)
- self.new.count(params)
+ self.new.count(**params)
end
def self.add(params)
- self.new.add(params)
+ self.new.add(**params)
end
def count(keys:)
diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb
index 8e23ac6aca5..6511b84e947 100644
--- a/lib/gitlab/regex.rb
+++ b/lib/gitlab/regex.rb
@@ -61,6 +61,39 @@ module Gitlab
)\z}xi.freeze
end
+ def debian_package_name_regex
+ # See official parser
+ # https://git.dpkg.org/cgit/dpkg/dpkg.git/tree/lib/dpkg/parsehelp.c?id=9e0c88ec09475f4d1addde9cdba1ad7849720356#n122
+ # @debian_package_name_regex ||= %r{\A[a-z0-9][-+\._a-z0-9]*\z}i.freeze
+ # But we prefer a more strict version from Lintian
+ # https://salsa.debian.org/lintian/lintian/-/blob/5080c0068ffc4a9ddee92022a91d0c2ff53e56d1/lib/Lintian/Util.pm#L116
+ @debian_package_name_regex ||= %r{\A[a-z0-9][-+\.a-z0-9]+\z}.freeze
+ end
+
+ def debian_version_regex
+ # See official parser: https://git.dpkg.org/cgit/dpkg/dpkg.git/tree/lib/dpkg/parsehelp.c?id=9e0c88ec09475f4d1addde9cdba1ad7849720356#n205
+ @debian_version_regex ||= %r{
+ \A(?:
+ (?:([0-9]{1,9}):)? (?# epoch)
+ ([0-9][0-9a-z\.+~-]*) (?# version)
+ (?:(-[0-0a-z\.+~]+))? (?# revision)
+ )\z}xi.freeze
+ end
+
+ def debian_architecture_regex
+ # See official parser: https://git.dpkg.org/cgit/dpkg/dpkg.git/tree/lib/dpkg/arch.c?id=9e0c88ec09475f4d1addde9cdba1ad7849720356#n43
+ # But we limit to lower case
+ @debian_architecture_regex ||= %r{\A[a-z0-9][-a-z0-9]*\z}.freeze
+ end
+
+ def debian_distribution_regex
+ @debian_distribution_regex ||= %r{\A[a-z0-9][a-z0-9\.-]*\z}i.freeze
+ end
+
+ def debian_component_regex
+ @debian_component_regex ||= %r{#{debian_distribution_regex}}.freeze
+ end
+
def unbounded_semver_regex
# See the official regex: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
@@ -80,6 +113,11 @@ module Gitlab
@semver_regex ||= Regexp.new("\\A#{::Gitlab::Regex.unbounded_semver_regex.source}\\z", ::Gitlab::Regex.unbounded_semver_regex.options)
end
+ def prefixed_semver_regex
+ # identical to semver_regex, except starting with 'v'
+ @prefixed_semver_regex ||= Regexp.new("\\Av#{::Gitlab::Regex.unbounded_semver_regex.source}\\z", ::Gitlab::Regex.unbounded_semver_regex.options)
+ end
+
def go_package_regex
# A Go package name looks like a URL but is not; it:
# - Must not have a scheme, such as http:// or https://
@@ -103,6 +141,14 @@ module Gitlab
def generic_package_version_regex
/\A\d+\.\d+\.\d+\z/
end
+
+ def generic_package_name_regex
+ maven_file_name_regex
+ end
+
+ def generic_package_file_name_regex
+ generic_package_name_regex
+ end
end
extend self
@@ -211,8 +257,27 @@ module Gitlab
"Must start with a letter, and cannot end with '-'"
end
+ # The section start, e.g. section_start:12345678:NAME
+ def logs_section_prefix_regex
+ /section_((?:start)|(?:end)):(\d+):([a-zA-Z0-9_.-]+)/
+ end
+
+ # The optional section options, e.g. [collapsed=true]
+ def logs_section_options_regex
+ /(\[(?:\w+=\w+)(?:, ?(?:\w+=\w+))*\])?/
+ end
+
+ # The region end, always: \r\e\[0K
+ def logs_section_suffix_regex
+ /\r\033\[0K/
+ end
+
def build_trace_section_regex
- @build_trace_section_regexp ||= /section_((?:start)|(?:end)):(\d+):([a-zA-Z0-9_.-]+)\r\033\[0K/.freeze
+ @build_trace_section_regexp ||= %r{
+ #{logs_section_prefix_regex}
+ #{logs_section_options_regex}
+ #{logs_section_suffix_regex}
+ }x.freeze
end
def markdown_code_or_html_blocks
diff --git a/lib/gitlab/relative_positioning/item_context.rb b/lib/gitlab/relative_positioning/item_context.rb
index cd03a347355..8f5495ece5e 100644
--- a/lib/gitlab/relative_positioning/item_context.rb
+++ b/lib/gitlab/relative_positioning/item_context.rb
@@ -131,12 +131,12 @@ module Gitlab
def shift_left
move_sequence_before(true)
- object.reset
+ object.reset_relative_position
end
def shift_right
move_sequence_after(true)
- object.reset
+ object.reset_relative_position
end
def create_space_left
diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb
index 06d8dca2f70..1e78464ddf8 100644
--- a/lib/gitlab/search_results.rb
+++ b/lib/gitlab/search_results.rb
@@ -7,7 +7,7 @@ module Gitlab
DEFAULT_PAGE = 1
DEFAULT_PER_PAGE = 20
- attr_reader :current_user, :query, :filters
+ attr_reader :current_user, :query, :sort, :filters
# Limit search results by passed projects
# It allows us to search only for projects user has access to
@@ -19,31 +19,23 @@ module Gitlab
# query
attr_reader :default_project_filter
- def initialize(current_user, query, limit_projects = nil, default_project_filter: false, filters: {})
+ def initialize(current_user, query, limit_projects = nil, sort: nil, default_project_filter: false, filters: {})
@current_user = current_user
@query = query
@limit_projects = limit_projects || Project.all
@default_project_filter = default_project_filter
+ @sort = sort
@filters = filters
end
def objects(scope, page: nil, per_page: DEFAULT_PER_PAGE, without_count: true, preload_method: nil)
should_preload = preload_method.present?
- collection = case scope
- when 'projects'
- projects
- when 'issues'
- issues
- when 'merge_requests'
- merge_requests
- when 'milestones'
- milestones
- when 'users'
- users
- else
- should_preload = false
- Kaminari.paginate_array([])
- end
+ collection = collection_for(scope)
+
+ if collection.nil?
+ should_preload = false
+ collection = Kaminari.paginate_array([])
+ end
collection = collection.public_send(preload_method) if should_preload # rubocop:disable GitlabSecurity/PublicSend
collection = collection.page(page).per(per_page)
@@ -118,6 +110,34 @@ module Gitlab
private
+ def collection_for(scope)
+ case scope
+ when 'projects'
+ projects
+ when 'issues'
+ issues
+ when 'merge_requests'
+ merge_requests
+ when 'milestones'
+ milestones
+ when 'users'
+ users
+ end
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def apply_sort(scope)
+ case sort
+ when 'oldest'
+ scope.reorder('created_at ASC')
+ when 'newest'
+ scope.reorder('created_at DESC')
+ else
+ scope
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
def projects
limit_projects.search(query)
end
@@ -129,7 +149,7 @@ module Gitlab
issues = issues.where(project_id: project_ids_relation) # rubocop: disable CodeReuse/ActiveRecord
end
- issues
+ apply_sort(issues)
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -149,7 +169,7 @@ module Gitlab
merge_requests = merge_requests.in_projects(project_ids_relation)
end
- merge_requests
+ apply_sort(merge_requests)
end
def default_scope
@@ -193,6 +213,10 @@ module Gitlab
end
params[:state] = filters[:state] if filters.key?(:state)
+
+ if Feature.enabled?(:search_filter_by_confidential) && filters.key?(:confidential) && %w(yes no).include?(filters[:confidential])
+ params[:confidential] = filters[:confidential] == 'yes'
+ end
end
end
diff --git a/lib/gitlab/sidekiq_daemon/memory_killer.rb b/lib/gitlab/sidekiq_daemon/memory_killer.rb
index e1a87a77f04..8793a672693 100644
--- a/lib/gitlab/sidekiq_daemon/memory_killer.rb
+++ b/lib/gitlab/sidekiq_daemon/memory_killer.rb
@@ -231,7 +231,7 @@ module Gitlab
def rss_increase_by_jobs
Gitlab::SidekiqDaemon::Monitor.instance.jobs_mutex.synchronize do
- Gitlab::SidekiqDaemon::Monitor.instance.jobs.sum do |job| # rubocop:disable CodeReuse/ActiveRecord
+ Gitlab::SidekiqDaemon::Monitor.instance.jobs.sum do |job|
rss_increase_by_job(job)
end
end
diff --git a/lib/gitlab/static_site_editor/config/file_config.rb b/lib/gitlab/static_site_editor/config/file_config.rb
index f647c85e1c8..315c603c1dd 100644
--- a/lib/gitlab/static_site_editor/config/file_config.rb
+++ b/lib/gitlab/static_site_editor/config/file_config.rb
@@ -3,11 +3,38 @@
module Gitlab
module StaticSiteEditor
module Config
+ #
+ # Base GitLab Static Site Editor Configuration facade
+ #
class FileConfig
- def data
- {
- static_site_generator: 'middleman'
- }
+ ConfigError = Class.new(StandardError)
+
+ def initialize(yaml)
+ content_hash = content_hash(yaml)
+ @global = Entry::Global.new(content_hash)
+ @global.compose!
+ rescue Gitlab::Config::Loader::FormatError => e
+ raise FileConfig::ConfigError, e.message
+ end
+
+ def valid?
+ @global.valid?
+ end
+
+ def errors
+ @global.errors
+ end
+
+ def to_hash_with_defaults
+ # NOTE: The current approach of simply mapping all the descendents' keys and values ('config')
+ # into a flat hash may need to be enhanced as we add more complex, non-scalar entries.
+ @global.descendants.map { |descendant| [descendant.key, descendant.config] }.to_h
+ end
+
+ private
+
+ def content_hash(yaml)
+ Gitlab::Config::Loader::Yaml.new(yaml).load!
end
end
end
diff --git a/lib/gitlab/static_site_editor/config/file_config/entry/global.rb b/lib/gitlab/static_site_editor/config/file_config/entry/global.rb
new file mode 100644
index 00000000000..c295ccf1d11
--- /dev/null
+++ b/lib/gitlab/static_site_editor/config/file_config/entry/global.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module StaticSiteEditor
+ module Config
+ class FileConfig
+ module Entry
+ ##
+ # This class represents a global entry - root Entry for entire
+ # GitLab StaticSiteEditor Configuration file.
+ #
+ class Global < ::Gitlab::Config::Entry::Node
+ include ::Gitlab::Config::Entry::Configurable
+ include ::Gitlab::Config::Entry::Attributable
+
+ ALLOWED_KEYS = %i[
+ image_upload_path
+ mounts
+ static_site_generator
+ ].freeze
+
+ attributes ALLOWED_KEYS
+
+ validations do
+ validates :config, allowed_keys: ALLOWED_KEYS
+ end
+
+ entry :image_upload_path, Entry::ImageUploadPath,
+ description: 'Configuration of the Static Site Editor image upload path.'
+ entry :mounts, Entry::Mounts,
+ description: 'Configuration of the Static Site Editor mounts.'
+ entry :static_site_generator, Entry::StaticSiteGenerator,
+ description: 'Configuration of the Static Site Editor static site generator.'
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/static_site_editor/config/file_config/entry/image_upload_path.rb b/lib/gitlab/static_site_editor/config/file_config/entry/image_upload_path.rb
new file mode 100644
index 00000000000..6a2b9e10d33
--- /dev/null
+++ b/lib/gitlab/static_site_editor/config/file_config/entry/image_upload_path.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module StaticSiteEditor
+ module Config
+ class FileConfig
+ module Entry
+ ##
+ # Entry that represents the path to which images will be uploaded
+ #
+ class ImageUploadPath < ::Gitlab::Config::Entry::Node
+ include ::Gitlab::Config::Entry::Validatable
+
+ validations do
+ validates :config, type: String
+ end
+
+ def self.default
+ 'source/images'
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/static_site_editor/config/file_config/entry/mount.rb b/lib/gitlab/static_site_editor/config/file_config/entry/mount.rb
new file mode 100644
index 00000000000..b10956e17a5
--- /dev/null
+++ b/lib/gitlab/static_site_editor/config/file_config/entry/mount.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module StaticSiteEditor
+ module Config
+ class FileConfig
+ module Entry
+ ##
+ # Entry that represents the mappings of mounted source directories to target paths
+ #
+ class Mount < ::Gitlab::Config::Entry::Node
+ include ::Gitlab::Config::Entry::Validatable
+ include ::Gitlab::Config::Entry::Attributable
+
+ ALLOWED_KEYS = %i[source target].freeze
+
+ attributes ALLOWED_KEYS
+
+ validations do
+ validates :config, allowed_keys: ALLOWED_KEYS
+
+ validates :source, type: String, presence: true
+ validates :target, type: String, presence: true, allow_blank: true
+ end
+
+ def self.default
+ # NOTE: This is the default for middleman projects. Ideally, this would be determined
+ # based on the defaults for whatever `static_site_generator` is configured.
+ {
+ source: 'source',
+ target: ''
+ }
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/static_site_editor/config/file_config/entry/mounts.rb b/lib/gitlab/static_site_editor/config/file_config/entry/mounts.rb
new file mode 100644
index 00000000000..10bd377e419
--- /dev/null
+++ b/lib/gitlab/static_site_editor/config/file_config/entry/mounts.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module StaticSiteEditor
+ module Config
+ class FileConfig
+ module Entry
+ ##
+ # Entry that represents the mappings of mounted source directories to target paths
+ #
+ class Mounts < ::Gitlab::Config::Entry::Node
+ include ::Gitlab::Config::Entry::Configurable
+ include ::Gitlab::Config::Entry::Validatable
+
+ entry :mount, Entry::Mount, description: 'Configuration of a Static Site Editor mount.'
+
+ validations do
+ validates :config, type: Array, presence: true
+ end
+
+ def skip_config_hash_validation?
+ true
+ end
+
+ def self.default
+ [Entry::Mount.default]
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/static_site_editor/config/file_config/entry/static_site_generator.rb b/lib/gitlab/static_site_editor/config/file_config/entry/static_site_generator.rb
new file mode 100644
index 00000000000..593c0951f93
--- /dev/null
+++ b/lib/gitlab/static_site_editor/config/file_config/entry/static_site_generator.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module StaticSiteEditor
+ module Config
+ class FileConfig
+ module Entry
+ ##
+ # Entry that represents the static site generator tool/framework.
+ #
+ class StaticSiteGenerator < ::Gitlab::Config::Entry::Node
+ include ::Gitlab::Config::Entry::Validatable
+
+ validations do
+ validates :config, type: String, inclusion: { in: %w[middleman], message: "should be 'middleman'" }
+ end
+
+ def self.default
+ 'middleman'
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/static_site_editor/config/generated_config.rb b/lib/gitlab/static_site_editor/config/generated_config.rb
index f3dce74a32f..ff24ec69ab0 100644
--- a/lib/gitlab/static_site_editor/config/generated_config.rb
+++ b/lib/gitlab/static_site_editor/config/generated_config.rb
@@ -4,8 +4,6 @@ module Gitlab
module StaticSiteEditor
module Config
class GeneratedConfig
- SUPPORTED_EXTENSIONS = %w[.md].freeze
-
def initialize(repository, ref, path, return_url)
@repository = repository
@ref = ref
@@ -23,7 +21,7 @@ module Gitlab
project: project.path,
namespace: project.namespace.full_path,
return_url: sanitize_url(return_url),
- is_supported_content: supported_content?.to_s,
+ is_supported_content: supported_content?,
base_url: Gitlab::Routing.url_helpers.project_show_sse_path(project, full_path),
merge_requests_illustration_path: merge_requests_illustration_path
}
@@ -35,8 +33,12 @@ module Gitlab
delegate :project, to: :repository
+ def supported_extensions
+ %w[.md].freeze
+ end
+
def commit_id
- repository.commit(ref)&.id if ref
+ repository.commit(ref)&.id
end
def supported_content?
@@ -50,7 +52,7 @@ module Gitlab
def extension_supported?
return true if path.end_with?('.md.erb') && Feature.enabled?(:sse_erb_support, project)
- SUPPORTED_EXTENSIONS.any? { |ext| path.end_with?(ext) }
+ supported_extensions.any? { |ext| path.end_with?(ext) }
end
def file_exists?
diff --git a/lib/gitlab/themes.rb b/lib/gitlab/themes.rb
index 72783a2d682..16e7b8a7eca 100644
--- a/lib/gitlab/themes.rb
+++ b/lib/gitlab/themes.rb
@@ -10,21 +10,21 @@ module Gitlab
APPLICATION_DEFAULT = 1
# Struct class representing a single Theme
- Theme = Struct.new(:id, :name, :css_class)
+ Theme = Struct.new(:id, :name, :css_class, :css_filename)
# All available Themes
THEMES = [
- Theme.new(1, 'Indigo', 'ui-indigo'),
- Theme.new(6, 'Light Indigo', 'ui-light-indigo'),
- Theme.new(4, 'Blue', 'ui-blue'),
- Theme.new(7, 'Light Blue', 'ui-light-blue'),
- Theme.new(5, 'Green', 'ui-green'),
- Theme.new(8, 'Light Green', 'ui-light-green'),
- Theme.new(9, 'Red', 'ui-red'),
- Theme.new(10, 'Light Red', 'ui-light-red'),
- Theme.new(2, 'Dark', 'ui-dark'),
- Theme.new(3, 'Light', 'ui-light'),
- Theme.new(11, 'Dark Mode (alpha)', 'gl-dark')
+ Theme.new(1, 'Indigo', 'ui-indigo', 'theme_indigo'),
+ Theme.new(6, 'Light Indigo', 'ui-light-indigo', 'theme_light_indigo'),
+ Theme.new(4, 'Blue', 'ui-blue', 'theme_blue'),
+ Theme.new(7, 'Light Blue', 'ui-light-blue', 'theme_light_blue'),
+ Theme.new(5, 'Green', 'ui-green', 'theme_green'),
+ Theme.new(8, 'Light Green', 'ui-light-green', 'theme_light_green'),
+ Theme.new(9, 'Red', 'ui-red', 'theme_red'),
+ Theme.new(10, 'Light Red', 'ui-light-red', 'theme_light_red'),
+ Theme.new(2, 'Dark', 'ui-dark', 'theme_dark'),
+ Theme.new(3, 'Light', 'ui-light', 'theme_light'),
+ Theme.new(11, 'Dark Mode (alpha)', 'gl-dark', nil)
].freeze
# Convenience method to get a space-separated String of all the theme
diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb
index 89605ce5d07..fbfc7beed9a 100644
--- a/lib/gitlab/usage_data.rb
+++ b/lib/gitlab/usage_data.rb
@@ -138,8 +138,10 @@ module Gitlab
pages_domains: count(PagesDomain),
pool_repositories: count(PoolRepository),
projects: count(Project),
+ projects_creating_incidents: distinct_count(Issue.incident, :project_id),
projects_imported_from_github: count(Project.where(import_type: 'github')),
projects_with_repositories_enabled: count(ProjectFeature.where('repository_access_level > ?', ProjectFeature::DISABLED)),
+ projects_with_tracing_enabled: count(ProjectTracingSetting),
projects_with_error_tracking_enabled: count(::ErrorTracking::ProjectErrorTrackingSetting.where(enabled: true)),
projects_with_alerts_service_enabled: count(AlertsService.active),
projects_with_prometheus_alerts: distinct_count(PrometheusAlert, :project_id),
@@ -166,8 +168,7 @@ module Gitlab
user_preferences_usage,
ingress_modsecurity_usage,
container_expiration_policies_usage,
- service_desk_counts,
- snowplow_event_counts
+ service_desk_counts
).tap do |data|
data[:snippets] = data[:personal_snippets] + data[:project_snippets]
end
@@ -175,7 +176,7 @@ module Gitlab
end
# rubocop: enable Metrics/AbcSize
- def snowplow_event_counts(time_period: {})
+ def snowplow_event_counts(time_period)
return {} unless report_snowplow_events?
{
@@ -242,7 +243,8 @@ module Gitlab
signup_enabled: alt_usage_data(fallback: nil) { Gitlab::CurrentSettings.allow_signup? },
web_ide_clientside_preview_enabled: alt_usage_data(fallback: nil) { Gitlab::CurrentSettings.web_ide_clientside_preview_enabled? },
ingress_modsecurity_enabled: Feature.enabled?(:ingress_modsecurity),
- grafana_link_enabled: alt_usage_data(fallback: nil) { Gitlab::CurrentSettings.grafana_enabled? }
+ grafana_link_enabled: alt_usage_data(fallback: nil) { Gitlab::CurrentSettings.grafana_enabled? },
+ gitpod_enabled: alt_usage_data(fallback: nil) { Gitlab::CurrentSettings.gitpod_enabled? }
}
end
@@ -444,8 +446,11 @@ module Gitlab
# rubocop: enable UsageData/LargeTable
# rubocop: enable CodeReuse/ActiveRecord
+ # augmented in EE
def user_preferences_usage
- {} # augmented in EE
+ {
+ user_preferences_user_gitpod_enabled: count(UserPreference.with_user.gitpod_enabled.merge(User.active))
+ }
end
def merge_requests_users(time_period)
@@ -469,7 +474,7 @@ module Gitlab
end
def last_28_days_time_period(column: :created_at)
- { column => 28.days.ago..Time.current }
+ { column => 30.days.ago..2.days.ago }
end
# Source: https://gitlab.com/gitlab-data/analytics/blob/master/transform/snowflake-dbt/data/ping_metrics_to_stage_mapping_data.csv
@@ -541,6 +546,7 @@ module Gitlab
groups: distinct_count(::GroupMember.where(time_period), :user_id),
users_created: count(::User.where(time_period), start: user_minimum_id, finish: user_maximum_id),
omniauth_providers: filtered_omniauth_provider_names.reject { |name| name == 'group_saml' },
+ user_auth_by_provider: distinct_count_user_auth_by_provider(time_period),
projects_imported: {
gitlab_project: projects_imported_count('gitlab_project', time_period),
gitlab: projects_imported_count('gitlab', time_period),
@@ -555,7 +561,8 @@ module Gitlab
jira: distinct_count(::JiraImportState.where(time_period), :user_id),
fogbugz: projects_imported_count('fogbugz', time_period),
phabricator: projects_imported_count('phabricator', time_period)
- }
+ },
+ groups_imported: distinct_count(::GroupImportState.where(time_period), :user_id)
}
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -567,7 +574,8 @@ module Gitlab
clusters_applications_prometheus: cluster_applications_user_distinct_count(::Clusters::Applications::Prometheus, time_period),
operations_dashboard_default_dashboard: count(::User.active.with_dashboard('operations').where(time_period),
start: user_minimum_id,
- finish: user_maximum_id)
+ finish: user_maximum_id),
+ projects_with_tracing_enabled: distinct_count(::Project.with_tracing_enabled.where(time_period), :creator_id)
}
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -696,10 +704,10 @@ module Gitlab
counter = Gitlab::UsageDataCounters::EditorUniqueCounter
{
- action_monthly_active_users_web_ide_edit: redis_usage_data { counter.count_web_ide_edit_actions(date_range) },
- action_monthly_active_users_sfe_edit: redis_usage_data { counter.count_sfe_edit_actions(date_range) },
- action_monthly_active_users_snippet_editor_edit: redis_usage_data { counter.count_snippet_editor_edit_actions(date_range) },
- action_monthly_active_users_ide_edit: redis_usage_data { counter.count_edit_using_editor(date_range) }
+ action_monthly_active_users_web_ide_edit: redis_usage_data { counter.count_web_ide_edit_actions(**date_range) },
+ action_monthly_active_users_sfe_edit: redis_usage_data { counter.count_sfe_edit_actions(**date_range) },
+ action_monthly_active_users_snippet_editor_edit: redis_usage_data { counter.count_snippet_editor_edit_actions(**date_range) },
+ action_monthly_active_users_ide_edit: redis_usage_data { counter.count_edit_using_editor(**date_range) }
}
end
@@ -812,6 +820,7 @@ module Gitlab
clear_memoization(:approval_merge_request_rule_maximum_id)
clear_memoization(:project_minimum_id)
clear_memoization(:project_maximum_id)
+ clear_memoization(:auth_providers)
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -843,6 +852,39 @@ module Gitlab
def projects_imported_count(from, time_period)
distinct_count(::Project.imported_from(from).where(time_period), :creator_id) # rubocop: disable CodeReuse/ActiveRecord
end
+
+ # rubocop:disable CodeReuse/ActiveRecord
+ def distinct_count_user_auth_by_provider(time_period)
+ counts = auth_providers_except_ldap.each_with_object({}) do |provider, hash|
+ hash[provider] = distinct_count(
+ ::AuthenticationEvent.success.for_provider(provider).where(time_period), :user_id)
+ end
+
+ if any_ldap_auth_providers?
+ counts['ldap'] = distinct_count(
+ ::AuthenticationEvent.success.ldap.where(time_period), :user_id
+ )
+ end
+
+ counts
+ end
+ # rubocop:enable CodeReuse/ActiveRecord
+
+ # rubocop:disable UsageData/LargeTable
+ def auth_providers
+ strong_memoize(:auth_providers) do
+ ::AuthenticationEvent.providers
+ end
+ end
+ # rubocop:enable UsageData/LargeTable
+
+ def auth_providers_except_ldap
+ auth_providers.reject { |provider| provider.starts_with?('ldap') }
+ end
+
+ def any_ldap_auth_providers?
+ auth_providers.any? { |provider| provider.starts_with?('ldap') }
+ end
end
end
end
diff --git a/lib/gitlab/usage_data_counters/hll_redis_counter.rb b/lib/gitlab/usage_data_counters/hll_redis_counter.rb
index 53bf6daea4c..eb132ef0967 100644
--- a/lib/gitlab/usage_data_counters/hll_redis_counter.rb
+++ b/lib/gitlab/usage_data_counters/hll_redis_counter.rb
@@ -72,7 +72,8 @@ module Gitlab
events_names = events_for_category(category)
event_results = events_names.each_with_object({}) do |event, hash|
- hash[event] = unique_events(event_names: event, start_date: 7.days.ago.to_date, end_date: Date.current)
+ hash["#{event}_weekly"] = unique_events(event_names: event, start_date: 7.days.ago.to_date, end_date: Date.current)
+ hash["#{event}_monthly"] = unique_events(event_names: event, start_date: 4.weeks.ago.to_date, end_date: Date.current)
end
if eligible_for_totals?(events_names)
diff --git a/lib/gitlab/usage_data_counters/issue_activity_unique_counter.rb b/lib/gitlab/usage_data_counters/issue_activity_unique_counter.rb
index fc1b5a59487..b1ce46a1ff5 100644
--- a/lib/gitlab/usage_data_counters/issue_activity_unique_counter.rb
+++ b/lib/gitlab/usage_data_counters/issue_activity_unique_counter.rb
@@ -3,14 +3,26 @@
module Gitlab
module UsageDataCounters
module IssueActivityUniqueCounter
- ISSUE_TITLE_CHANGED = 'g_project_management_issue_title_changed'
- ISSUE_DESCRIPTION_CHANGED = 'g_project_management_issue_description_changed'
+ ISSUE_CATEGORY = 'issues_edit'
+
ISSUE_ASSIGNEE_CHANGED = 'g_project_management_issue_assignee_changed'
+ ISSUE_CREATED = 'g_project_management_issue_created'
+ ISSUE_CLOSED = 'g_project_management_issue_closed'
+ ISSUE_DESCRIPTION_CHANGED = 'g_project_management_issue_description_changed'
+ ISSUE_ITERATION_CHANGED = 'g_project_management_issue_iteration_changed'
+ ISSUE_LABEL_CHANGED = 'g_project_management_issue_label_changed'
ISSUE_MADE_CONFIDENTIAL = 'g_project_management_issue_made_confidential'
ISSUE_MADE_VISIBLE = 'g_project_management_issue_made_visible'
- ISSUE_CATEGORY = 'issues_edit'
+ ISSUE_MILESTONE_CHANGED = 'g_project_management_issue_milestone_changed'
+ ISSUE_REOPENED = 'g_project_management_issue_reopened'
+ ISSUE_TITLE_CHANGED = 'g_project_management_issue_title_changed'
+ ISSUE_WEIGHT_CHANGED = 'g_project_management_issue_weight_changed'
class << self
+ def track_issue_created_action(author:, time: Time.zone.now)
+ track_unique_action(ISSUE_CREATED, author, time)
+ end
+
def track_issue_title_changed_action(author:, time: Time.zone.now)
track_unique_action(ISSUE_TITLE_CHANGED, author, time)
end
@@ -31,6 +43,30 @@ module Gitlab
track_unique_action(ISSUE_MADE_VISIBLE, author, time)
end
+ def track_issue_closed_action(author:, time: Time.zone.now)
+ track_unique_action(ISSUE_CLOSED, author, time)
+ end
+
+ def track_issue_reopened_action(author:, time: Time.zone.now)
+ track_unique_action(ISSUE_REOPENED, author, time)
+ end
+
+ def track_issue_label_changed_action(author:, time: Time.zone.now)
+ track_unique_action(ISSUE_LABEL_CHANGED, author, time)
+ end
+
+ def track_issue_milestone_changed_action(author:, time: Time.zone.now)
+ track_unique_action(ISSUE_MILESTONE_CHANGED, author, time)
+ end
+
+ def track_issue_iteration_changed_action(author:, time: Time.zone.now)
+ track_unique_action(ISSUE_ITERATION_CHANGED, author, time)
+ end
+
+ def track_issue_weight_changed_action(author:, time: Time.zone.now)
+ track_unique_action(ISSUE_WEIGHT_CHANGED, author, time)
+ end
+
private
def track_unique_action(action, author, time)
diff --git a/lib/gitlab/usage_data_counters/known_events.yml b/lib/gitlab/usage_data_counters/known_events.yml
index 25e7f858bb1..9bbf804d87c 100644
--- a/lib/gitlab/usage_data_counters/known_events.yml
+++ b/lib/gitlab/usage_data_counters/known_events.yml
@@ -206,3 +206,31 @@
category: issues_edit
redis_slot: project_management
aggregation: daily
+- name: g_project_management_issue_created
+ category: issues_edit
+ redis_slot: project_management
+ aggregation: daily
+- name: g_project_management_issue_closed
+ category: issues_edit
+ redis_slot: project_management
+ aggregation: daily
+- name: g_project_management_issue_reopened
+ category: issues_edit
+ redis_slot: project_management
+ aggregation: daily
+- name: g_project_management_issue_label_changed
+ category: issues_edit
+ redis_slot: project_management
+ aggregation: daily
+- name: g_project_management_issue_milestone_changed
+ category: issues_edit
+ redis_slot: project_management
+ aggregation: daily
+- name: g_project_management_issue_iteration_changed
+ category: issues_edit
+ redis_slot: project_management
+ aggregation: daily
+- name: g_project_management_issue_weight_changed
+ category: issues_edit
+ redis_slot: project_management
+ aggregation: daily
diff --git a/lib/gitlab/usage_data_queries.rb b/lib/gitlab/usage_data_queries.rb
index bacd63ab282..c54e766230e 100644
--- a/lib/gitlab/usage_data_queries.rb
+++ b/lib/gitlab/usage_data_queries.rb
@@ -22,7 +22,7 @@ module Gitlab
end
def sum(relation, column, *rest)
- relation.select(relation.all.table[column].sum).to_sql # rubocop:disable CodeReuse/ActiveRecord
+ relation.select(relation.all.table[column].sum).to_sql
end
private
diff --git a/lib/gitlab/utils/usage_data.rb b/lib/gitlab/utils/usage_data.rb
index ca6a36c9cea..5267733d220 100644
--- a/lib/gitlab/utils/usage_data.rb
+++ b/lib/gitlab/utils/usage_data.rb
@@ -39,9 +39,9 @@ module Gitlab
FALLBACK = -1
- def count(relation, column = nil, batch: true, start: nil, finish: nil)
+ def count(relation, column = nil, batch: true, batch_size: nil, start: nil, finish: nil)
if batch
- Gitlab::Database::BatchCount.batch_count(relation, column, start: start, finish: finish)
+ Gitlab::Database::BatchCount.batch_count(relation, column, batch_size: batch_size, start: start, finish: finish)
else
relation.count
end
@@ -106,7 +106,6 @@ module Gitlab
# @param values [Array|String] the values counted
def track_usage_event(event_name, values)
return unless Feature.enabled?(:"usage_data_#{event_name}", default_enabled: true)
- return unless Gitlab::CurrentSettings.usage_ping_enabled?
Gitlab::UsageDataCounters::HLLRedisCounter.track_event(values, event_name.to_s)
end
diff --git a/lib/gitlab/webpack/manifest.rb b/lib/gitlab/webpack/manifest.rb
index d2c01bbd55e..6a8a7e24ebd 100644
--- a/lib/gitlab/webpack/manifest.rb
+++ b/lib/gitlab/webpack/manifest.rb
@@ -1,16 +1,33 @@
# frozen_string_literal: true
-require 'webpack/rails/manifest'
+require 'net/http'
+require 'uri'
module Gitlab
module Webpack
- class Manifest < ::Webpack::Rails::Manifest
- # Raised if a supplied asset does not exist in the webpack manifest
+ class Manifest
+ # Raised if we can't read our webpack manifest for whatever reason
+ class ManifestLoadError < StandardError
+ def initialize(message, orig)
+ super "#{message} (original error #{orig})"
+ end
+ end
+
+ # Raised if webpack couldn't build one of your entry points
+ class WebpackError < StandardError
+ def initialize(errors)
+ super "Error in webpack compile, details follow below:\n#{errors.join("\n\n")}"
+ end
+ end
+
+ # Raised if a supplied entry point does not exist in the webpack manifest
AssetMissingError = Class.new(StandardError)
class << self
+ include Gitlab::Utils::StrongMemoize
+
def entrypoint_paths(source)
- raise ::Webpack::Rails::Manifest::WebpackError, manifest["errors"] unless manifest_bundled?
+ raise WebpackError, manifest["errors"] unless manifest_bundled?
dll_assets = manifest.fetch("dllAssets", [])
entrypoint = manifest["entrypoints"][source]
@@ -21,9 +38,86 @@ module Gitlab
"/#{::Rails.configuration.webpack.public_path}/#{p}"
end
else
+ raise AssetMissingError, "Can't find asset '#{source}' in webpack manifest"
+ end
+ end
+
+ def asset_paths(source)
+ raise WebpackError, manifest["errors"] unless manifest_bundled?
+
+ paths = manifest["assetsByChunkName"][source]
+ if paths
+ # Can be either a string or an array of strings.
+ # Do not include source maps as they are not javascript
+ [paths].flatten.reject { |p| p =~ /.*\.map$/ }.map do |p|
+ "/#{::Rails.configuration.webpack.public_path}/#{p}"
+ end
+ else
raise AssetMissingError, "Can't find entry point '#{source}' in webpack manifest"
end
end
+
+ def clear_manifest!
+ clear_memoization(:manifest)
+ end
+
+ private
+
+ def manifest_bundled?
+ !manifest["errors"].any? { |error| error.include? "Module build failed" }
+ end
+
+ def manifest
+ if ::Rails.configuration.webpack.dev_server.enabled
+ # Don't cache if we're in dev server mode, manifest may change ...
+ load_manifest
+ else
+ # ... otherwise cache at class level, as JSON loading/parsing can be expensive
+ strong_memoize(:manifest) { load_manifest }
+ end
+ end
+
+ def load_manifest
+ data = if ::Rails.configuration.webpack.dev_server.enabled
+ load_dev_server_manifest
+ else
+ load_static_manifest
+ end
+
+ Gitlab::Json.parse(data)
+ end
+
+ def load_dev_server_manifest
+ host = ::Rails.configuration.webpack.dev_server.manifest_host
+ port = ::Rails.configuration.webpack.dev_server.manifest_port
+ uri = Addressable::URI.new(scheme: 'http', host: host, port: port, path: dev_server_path)
+
+ # localhost could be blocked via Gitlab::HTTP
+ response = HTTParty.get(uri.to_s, verify: false) # rubocop:disable Gitlab/HTTParty
+
+ return response.body if response.code == 200
+
+ raise "HTTP error #{response.code}"
+ rescue => e
+ raise ManifestLoadError.new("Could not load manifest from webpack-dev-server at #{uri} - is it running, and is stats-webpack-plugin loaded?", e)
+ end
+
+ def load_static_manifest
+ File.read(static_manifest_path)
+ rescue => e
+ raise ManifestLoadError.new("Could not load compiled manifest from #{static_manifest_path} - have you run `rake webpack:compile`?", e)
+ end
+
+ def static_manifest_path
+ ::Rails.root.join(
+ ::Rails.configuration.webpack.output_dir,
+ ::Rails.configuration.webpack.manifest_filename
+ )
+ end
+
+ def dev_server_path
+ "/#{::Rails.configuration.webpack.public_path}/#{::Rails.configuration.webpack.manifest_filename}"
+ end
end
end
end
diff --git a/lib/gitlab_danger.rb b/lib/gitlab_danger.rb
index a906beda80e..acce9a455bd 100644
--- a/lib/gitlab_danger.rb
+++ b/lib/gitlab_danger.rb
@@ -11,7 +11,7 @@ class GitlabDanger
karma
database
commit_messages
- telemetry
+ product_analytics
utility_css
pajamas
].freeze
diff --git a/lib/grafana/client.rb b/lib/grafana/client.rb
index b419f79bace..7c0e56b61c8 100644
--- a/lib/grafana/client.rb
+++ b/lib/grafana/client.rb
@@ -19,8 +19,8 @@ module Grafana
# @param name [String] Unique identifier for a Grafana datasource
def get_datasource(name:)
# CGI#escape formats strings such that the Grafana endpoint
- # will not recognize the dashboard name. Preferring URI#escape.
- http_get("#{@api_url}/api/datasources/name/#{URI.escape(name)}") # rubocop:disable Lint/UriEscapeUnescape
+ # will not recognize the dashboard name. Prefer Addressable::URI#encode_component.
+ http_get("#{@api_url}/api/datasources/name/#{Addressable::URI.encode_component(name)}")
end
# @param datasource_id [String] Grafana ID for the datasource
diff --git a/lib/pager_duty/validator/schemas/message.json b/lib/pager_duty/validator/schemas/message.json
new file mode 100644
index 00000000000..b1a3185cd1a
--- /dev/null
+++ b/lib/pager_duty/validator/schemas/message.json
@@ -0,0 +1,47 @@
+{
+ "type": "object",
+ "required": ["event", "incident"],
+ "properties": {
+ "event": { "type": "string" },
+ "incident": {
+ "type": "object",
+ "required": [
+ "html_url",
+ "incident_number",
+ "title",
+ "status",
+ "created_at",
+ "urgency",
+ "incident_key"
+ ],
+ "properties": {
+ "html_url": { "type": "string" },
+ "incindent_number": { "type": "integer" },
+ "title": { "type": "string" },
+ "status": { "type": "string" },
+ "created_at": { "type": "string" },
+ "urgency": { "type": "string", "enum": ["high", "low"] },
+ "incident_key": { "type": ["string", "null"] },
+ "assignments": {
+ "type": "array",
+ "items": {
+ "assignee": {
+ "type": "array",
+ "items": {
+ "summary": { "type": "string" },
+ "html_url": { "type": "string" }
+ }
+ }
+ }
+ },
+ "impacted_services": {
+ "type": "array",
+ "items": {
+ "summary": { "type": "string" },
+ "html_url": { "type": "string" }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lib/pager_duty/webhook_payload_parser.rb b/lib/pager_duty/webhook_payload_parser.rb
index 573fb36f0ca..11071926cf2 100644
--- a/lib/pager_duty/webhook_payload_parser.rb
+++ b/lib/pager_duty/webhook_payload_parser.rb
@@ -2,6 +2,8 @@
module PagerDuty
class WebhookPayloadParser
+ SCHEMA_PATH = File.join('lib', 'pager_duty', 'validator', 'schemas', 'message.json')
+
def initialize(payload)
@payload = payload
end
@@ -11,7 +13,7 @@ module PagerDuty
end
def call
- Array(payload['messages']).map { |msg| parse_message(msg) }
+ Array(payload['messages']).map { |msg| parse_message(msg) }.reject(&:empty?)
end
private
@@ -19,6 +21,8 @@ module PagerDuty
attr_reader :payload
def parse_message(message)
+ return {} unless valid_message?(message)
+
{
'event' => message['event'],
'incident' => parse_incident(message['incident'])
@@ -26,8 +30,6 @@ module PagerDuty
end
def parse_incident(incident)
- return {} if incident.blank?
-
{
'url' => incident['html_url'],
'incident_number' => incident['incident_number'],
@@ -62,5 +64,9 @@ module PagerDuty
def reject_empty(entities)
Array(entities).reject { |e| e['summary'].blank? && e['url'].blank? }
end
+
+ def valid_message?(message)
+ ::JSONSchemer.schema(Pathname.new(SCHEMA_PATH)).valid?(message)
+ end
end
end
diff --git a/lib/peek/views/detailed_view.rb b/lib/peek/views/detailed_view.rb
index 389f5301079..4cc2e85c7bb 100644
--- a/lib/peek/views/detailed_view.rb
+++ b/lib/peek/views/detailed_view.rb
@@ -23,7 +23,7 @@ module Peek
private
def duration
- detail_store.map { |entry| entry[:duration] }.sum * 1000 # rubocop:disable CodeReuse/ActiveRecord
+ detail_store.map { |entry| entry[:duration] }.sum * 1000
end
def calls
diff --git a/lib/safe_zip/extract.rb b/lib/safe_zip/extract.rb
index 679c021c730..ac33f0b3c2c 100644
--- a/lib/safe_zip/extract.rb
+++ b/lib/safe_zip/extract.rb
@@ -19,11 +19,7 @@ module SafeZip
def extract(opts = {})
params = SafeZip::ExtractParams.new(**opts)
- if Feature.enabled?(:safezip_use_rubyzip, default_enabled: true)
- extract_with_ruby_zip(params)
- else
- legacy_unsafe_extract_with_system_zip(params)
- end
+ extract_with_ruby_zip(params)
end
private
@@ -53,21 +49,5 @@ module SafeZip
.extract
end
end
-
- def legacy_unsafe_extract_with_system_zip(params)
- # Requires UnZip at least 6.00 Info-ZIP.
- # -n never overwrite existing files
- args = %W(unzip -n -qq #{archive_path})
-
- # We add * to end of directory, because we want to extract directory and all subdirectories
- args += params.directories_wildcard
-
- # Target directory where we extract
- args += %W(-d #{params.extract_path})
-
- unless system(*args)
- raise Error, 'archive failed to extract'
- end
- end
end
end
diff --git a/lib/sentry/client/issue.rb b/lib/sentry/client/issue.rb
index c5e9df9cd21..f714bda49fd 100644
--- a/lib/sentry/client/issue.rb
+++ b/lib/sentry/client/issue.rb
@@ -14,7 +14,7 @@ module Sentry
}.freeze
def list_issues(**keyword_args)
- response = get_issues(keyword_args)
+ response = get_issues(**keyword_args)
issues = response[:issues]
pagination = response[:pagination]
@@ -44,7 +44,7 @@ module Sentry
def get_issues(**keyword_args)
response = http_get(
api_urls.issues_url,
- query: list_issue_sentry_query(keyword_args)
+ query: list_issue_sentry_query(**keyword_args)
)
{
diff --git a/lib/tasks/gitlab/assets.rake b/lib/tasks/gitlab/assets.rake
index caa583fb3a9..ab2d77eeaf0 100644
--- a/lib/tasks/gitlab/assets.rake
+++ b/lib/tasks/gitlab/assets.rake
@@ -81,7 +81,7 @@ namespace :gitlab do
if head_assets_md5 != master_assets_md5 || !public_assets_webpack_dir_exists
FileUtils.rm_r(Tasks::Gitlab::Assets::PUBLIC_ASSETS_WEBPACK_DIR) if public_assets_webpack_dir_exists
- Rake::Task['webpack:compile'].invoke
+ system('yarn webpack')
end
end
diff --git a/lib/tasks/gitlab/backup.rake b/lib/tasks/gitlab/backup.rake
index 2a3713ed85c..de2dfca8c1b 100644
--- a/lib/tasks/gitlab/backup.rake
+++ b/lib/tasks/gitlab/backup.rake
@@ -107,7 +107,7 @@ namespace :gitlab do
puts "GITLAB_BACKUP_MAX_CONCURRENCY and GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY must have a value of at least 1".color(:red)
exit 1
else
- Backup::Repository.new(progress).dump(
+ Backup::Repositories.new(progress).dump(
max_concurrency: max_concurrency,
max_storage_concurrency: max_storage_concurrency
)
@@ -117,7 +117,7 @@ namespace :gitlab do
task restore: :gitlab_environment do
puts_time "Restoring repositories ...".color(:blue)
- Backup::Repository.new(progress).restore
+ Backup::Repositories.new(progress).restore
puts_time "done".color(:green)
end
end
diff --git a/lib/tasks/gitlab/db.rake b/lib/tasks/gitlab/db.rake
index 425f66918b0..8a1809f9dfc 100644
--- a/lib/tasks/gitlab/db.rake
+++ b/lib/tasks/gitlab/db.rake
@@ -35,6 +35,11 @@ namespace :gitlab do
# Truncate schema_migrations to ensure migrations re-run
connection.execute('TRUNCATE schema_migrations') if connection.table_exists? 'schema_migrations'
+ # Drop any views
+ connection.views.each do |view|
+ connection.execute("DROP VIEW IF EXISTS #{connection.quote_table_name(view)} CASCADE")
+ end
+
# Drop tables with cascade to avoid dependent table errors
# PG: http://www.postgresql.org/docs/current/static/ddl-depend.html
# Add `IF EXISTS` because cascade could have already deleted a table.
@@ -169,9 +174,21 @@ namespace :gitlab do
desc 'reindex a regular (non-unique) index without downtime to eliminate bloat'
task :reindex, [:index_name] => :environment do |_, args|
- raise ArgumentError, 'must give the index name to reindex' unless args[:index_name]
+ unless Feature.enabled?(:database_reindexing, type: :ops)
+ puts "This feature (database_reindexing) is currently disabled.".color(:yellow)
+ exit
+ end
+
+ indexes = if args[:index_name]
+ Gitlab::Database::PostgresIndex.by_identifier(args[:index_name])
+ else
+ Gitlab::Database::Reindexing.candidate_indexes.random_few(2)
+ end
- Gitlab::Database::ConcurrentReindex.new(args[:index_name], logger: Logger.new(STDOUT)).execute
+ Gitlab::Database::Reindexing.perform(indexes)
+ rescue => e
+ Gitlab::AppLogger.error(e)
+ raise
end
end
end
diff --git a/lib/uploaded_file.rb b/lib/uploaded_file.rb
index cd5943b552e..9b034d1c6c2 100644
--- a/lib/uploaded_file.rb
+++ b/lib/uploaded_file.rb
@@ -42,6 +42,9 @@ class UploadedFile
@remote_id = remote_id
end
+ # TODO this function is meant to replace .from_params when the feature flag
+ # upload_middleware_jwt_params_handler is removed
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/233895#roll-out-steps
def self.from_params_without_field(params, upload_paths)
path = params['path']
remote_id = params['remote_id']
@@ -68,6 +71,10 @@ class UploadedFile
)
end
+ # Deprecated. Don't use it.
+ # .from_params_without_field will replace this one
+ # See .from_params_without_field and
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/233895#roll-out-steps
def self.from_params(params, field, upload_paths, path_override = nil)
path = path_override || params["#{field}.path"]
remote_id = params["#{field}.remote_id"]
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index f301315746f..d4048b717ff 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -8,8 +8,6 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-09-17 11:26-0400\n"
-"PO-Revision-Date: 2020-09-17 11:26-0400\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
@@ -79,6 +77,16 @@ msgstr ""
msgid "\"el\" parameter is required for createInstance()"
msgstr ""
+msgid "%d Approval"
+msgid_plural "%d Approvals"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d Package"
+msgid_plural "%d Packages"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "%d Scanned URL"
msgid_plural "%d Scanned URLs"
msgstr[0] ""
@@ -157,6 +165,11 @@ msgid_plural "%d days"
msgstr[0] ""
msgstr[1] ""
+msgid "%d day until tags are automatically removed"
+msgid_plural "%d days until tags are automatically removed"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "%d error"
msgid_plural "%d errors"
msgstr[0] ""
@@ -252,6 +265,11 @@ msgid_plural "%d open issues"
msgstr[0] ""
msgstr[1] ""
+msgid "%d pending comment"
+msgid_plural "%d pending comments"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "%d personal project will be removed and cannot be restored."
msgid_plural "%d personal projects will be removed and cannot be restored."
msgstr[0] ""
@@ -267,6 +285,11 @@ msgid_plural "%d projects"
msgstr[0] ""
msgstr[1] ""
+msgid "%d project selected"
+msgid_plural "%d projects selected"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "%d request with warnings"
msgid_plural "%d requests with warnings"
msgstr[0] ""
@@ -287,6 +310,11 @@ msgid_plural "%d tags"
msgstr[0] ""
msgstr[1] ""
+msgid "%d tag per image name"
+msgid_plural "%d tags per image name"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "%d unassigned issue"
msgid_plural "%d unassigned issues"
msgstr[0] ""
@@ -401,11 +429,6 @@ msgid_plural "%{count} participants"
msgstr[0] ""
msgstr[1] ""
-msgid "%{count} pending comment"
-msgid_plural "%{count} pending comments"
-msgstr[0] ""
-msgstr[1] ""
-
msgid "%{count} related %{pluralized_subject}: %{links}"
msgstr ""
@@ -466,6 +489,9 @@ msgstr ""
msgid "%{group_name} uses group managed accounts. You need to create a new GitLab account which will be managed by %{group_name}."
msgstr ""
+msgid "%{group_name}&%{epic_iid} &middot; opened %{epic_created} by %{author}"
+msgstr ""
+
msgid "%{host} sign-in from new location"
msgstr ""
@@ -511,7 +537,7 @@ msgstr ""
msgid "%{labelStart}Namespace:%{labelEnd} %{namespace}"
msgstr ""
-msgid "%{labelStart}Report Type:%{labelEnd} %{reportType}"
+msgid "%{labelStart}Scan Type:%{labelEnd} %{reportType}"
msgstr ""
msgid "%{labelStart}Scanner:%{labelEnd} %{scanner}"
@@ -859,9 +885,6 @@ msgstr ""
msgid "&lt; 1 hour"
msgstr ""
-msgid "&lt;no name set&gt;"
-msgstr ""
-
msgid "&lt;no scopes selected&gt;"
msgstr ""
@@ -954,6 +977,9 @@ msgstr ""
msgid "+ %{numberOfHiddenAssignees} more"
msgstr ""
+msgid "+ %{numberOfHiddenReviewers} more"
+msgstr ""
+
msgid "+%d more"
msgid_plural "+%d more"
msgstr[0] ""
@@ -1014,6 +1040,11 @@ msgid_plural "%d Days"
msgstr[0] ""
msgstr[1] ""
+msgid "1 Issue"
+msgid_plural "%d Issues"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "1 closed issue"
msgid_plural "%{issues} closed issues"
msgstr[0] ""
@@ -1029,6 +1060,11 @@ msgid_plural "%d days"
msgstr[0] ""
msgstr[1] ""
+msgid "1 deploy key"
+msgid_plural "%d deploy keys"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "1 group"
msgid_plural "%d groups"
msgstr[0] ""
@@ -1146,6 +1182,9 @@ msgstr ""
msgid "A GitBook site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features."
msgstr ""
+msgid "A Gitpod configured Webapplication in Spring and Java"
+msgstr ""
+
msgid "A Hexo site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features."
msgstr ""
@@ -1317,9 +1356,15 @@ msgstr ""
msgid "Access expiration date"
msgstr ""
+msgid "Access expires"
+msgstr ""
+
msgid "Access forbidden. Check your access level."
msgstr ""
+msgid "Access granted"
+msgstr ""
+
msgid "Access requests"
msgstr ""
@@ -1329,6 +1374,9 @@ msgstr ""
msgid "Access to Pages websites are controlled based on the user's membership to a given project. By checking this box, users will be required to be logged in to have access to all Pages websites in your instance."
msgstr ""
+msgid "AccessDropdown|Deploy Keys"
+msgstr ""
+
msgid "AccessDropdown|Groups"
msgstr ""
@@ -1446,12 +1494,6 @@ msgstr ""
msgid "Active Sessions"
msgstr ""
-msgid "Active Users:"
-msgstr ""
-
-msgid "Active users"
-msgstr ""
-
msgid "Activity"
msgstr ""
@@ -2126,7 +2168,7 @@ msgstr ""
msgid "AdminUsers|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 %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd}, it cannot be undone or recovered."
msgstr ""
-msgid "AdminUsers|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."
+msgid "AdminUsers|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 %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd}, it cannot be undone or recovered."
msgstr ""
msgid "AdminUsers|You cannot remove your own admin rights."
@@ -2224,6 +2266,9 @@ msgstr ""
msgid "AlertManagement|Edit"
msgstr ""
+msgid "AlertManagement|Environment"
+msgstr ""
+
msgid "AlertManagement|Events"
msgstr ""
@@ -2494,6 +2539,9 @@ msgstr ""
msgid "All projects"
msgstr ""
+msgid "All projects selected"
+msgstr ""
+
msgid "All security scans are enabled because %{linkStart}Auto DevOps%{linkEnd} is enabled on this project"
msgstr ""
@@ -2638,6 +2686,9 @@ msgstr ""
msgid "An error has occurred"
msgstr ""
+msgid "An error occured while making the changes: %{error}"
+msgstr ""
+
msgid "An error occurred adding a draft to the thread."
msgstr ""
@@ -2665,12 +2716,6 @@ msgstr ""
msgid "An error occurred when toggling the notification subscription"
msgstr ""
-msgid "An error occurred when trying to resolve a comment. Please try again."
-msgstr ""
-
-msgid "An error occurred when trying to resolve a discussion. Please try again."
-msgstr ""
-
msgid "An error occurred when updating the issue weight"
msgstr ""
@@ -2686,12 +2731,6 @@ msgstr ""
msgid "An error occurred while checking group path. Please refresh and try again."
msgstr ""
-msgid "An error occurred while creating the issue. Please try again."
-msgstr ""
-
-msgid "An error occurred while creating the list. Please try again."
-msgstr ""
-
msgid "An error occurred while decoding the file."
msgstr ""
@@ -2764,18 +2803,12 @@ msgstr ""
msgid "An error occurred while fetching the Service Desk address."
msgstr ""
-msgid "An error occurred while fetching the board issues. Please reload the page."
-msgstr ""
-
msgid "An error occurred while fetching the board lists. Please reload the page."
msgstr ""
msgid "An error occurred while fetching the board lists. Please try again."
msgstr ""
-msgid "An error occurred while fetching the board swimlanes. Please reload the page."
-msgstr ""
-
msgid "An error occurred while fetching the builds."
msgstr ""
@@ -2887,9 +2920,6 @@ msgstr ""
msgid "An error occurred while moving the issue."
msgstr ""
-msgid "An error occurred while moving the issue. Please try again."
-msgstr ""
-
msgid "An error occurred while parsing recent searches"
msgstr ""
@@ -2932,9 +2962,6 @@ msgstr ""
msgid "An error occurred while saving assignees"
msgstr ""
-msgid "An error occurred while saving the template. Please check if the template exists."
-msgstr ""
-
msgid "An error occurred while searching for milestones"
msgstr ""
@@ -2959,9 +2986,6 @@ msgstr ""
msgid "An error occurred while updating the comment"
msgstr ""
-msgid "An error occurred while updating the list. Please try again."
-msgstr ""
-
msgid "An error occurred while validating group path"
msgstr ""
@@ -3013,6 +3037,9 @@ msgstr ""
msgid "An unknown error occurred while loading this graph."
msgstr ""
+msgid "An unknown error occurred."
+msgstr ""
+
msgid "Analytics"
msgstr ""
@@ -3046,10 +3073,10 @@ msgstr ""
msgid "Any"
msgstr ""
-msgid "Any Author"
+msgid "Any %{header}"
msgstr ""
-msgid "Any Status"
+msgid "Any Author"
msgstr ""
msgid "Any branch"
@@ -3327,6 +3354,9 @@ msgstr ""
msgid "Are you sure you want to delete this %{typeOfComment}?"
msgstr ""
+msgid "Are you sure you want to delete this SSH key?"
+msgstr ""
+
msgid "Are you sure you want to delete this board?"
msgstr ""
@@ -3446,12 +3476,6 @@ msgstr ""
msgid "Artifacts"
msgstr ""
-msgid "As U2F devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser."
-msgstr ""
-
-msgid "As WebAuthn devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a WebAuthn device. That way you'll always be able to log in - even when you're using an unsupported browser."
-msgstr ""
-
msgid "As we continue to build more features for SAST, we'd love your feedback on the SAST configuration feature in %{linkStart}this issue%{linkEnd}."
msgstr ""
@@ -3645,6 +3669,9 @@ msgstr ""
msgid "AuditLogs|Target"
msgstr ""
+msgid "AuditLogs|This month"
+msgstr ""
+
msgid "AuditLogs|User Events"
msgstr ""
@@ -3681,7 +3708,10 @@ msgstr ""
msgid "Authentication method updated"
msgstr ""
-msgid "Authentication via %{method} device failed."
+msgid "Authentication via U2F device failed."
+msgstr ""
+
+msgid "Authentication via WebAuthn device failed."
msgstr ""
msgid "Author"
@@ -3951,15 +3981,6 @@ msgstr ""
msgid "BambooService|You must set up automatic revision labeling and a repository trigger in Bamboo."
msgstr ""
-msgid "BatchComments|Delete all pending comments"
-msgstr ""
-
-msgid "BatchComments|Discard review?"
-msgstr ""
-
-msgid "BatchComments|You're about to discard your review which will delete all of your pending comments. The deleted comments %{strong_start}cannot%{strong_end} be restored."
-msgstr ""
-
msgid "Be careful. Changing the project's namespace can have unintended side effects."
msgstr ""
@@ -3981,6 +4002,9 @@ msgstr ""
msgid "Bi-weekly code coverage"
msgstr ""
+msgid "Billable Users:"
+msgstr ""
+
msgid "Billing"
msgstr ""
@@ -3990,7 +4014,7 @@ msgstr ""
msgid "BillingPlans|@%{user_name} you are currently using the %{plan_name} plan."
msgstr ""
-msgid "BillingPlans|Congratulations, your new trial is activated"
+msgid "BillingPlans|Congratulations, your free trial is activated."
msgstr ""
msgid "BillingPlans|If you would like to downgrade your plan please contact %{support_link_start}Customer Support%{support_link_end}."
@@ -4071,31 +4095,37 @@ msgstr ""
msgid "Blog"
msgstr ""
-msgid "Board name"
-msgstr ""
-
msgid "Board scope"
msgstr ""
msgid "Board scope affects which issues are displayed for anyone who visits this board"
msgstr ""
-msgid "BoardBlankState|Add default lists"
+msgid "Boards"
msgstr ""
-msgid "BoardBlankState|Add the following default lists to your Issue Board with one click:"
+msgid "Boards and Board Lists"
msgstr ""
-msgid "BoardBlankState|Nevermind, I'll use my own"
+msgid "Boards|An error occurred while creating the issue. Please try again."
msgstr ""
-msgid "BoardBlankState|Starting out with the default set of lists will get you right on the way to making the most of your board."
+msgid "Boards|An error occurred while creating the list. Please try again."
msgstr ""
-msgid "Boards"
+msgid "Boards|An error occurred while fetching issues. Please reload the page."
msgstr ""
-msgid "Boards and Board Lists"
+msgid "Boards|An error occurred while fetching the board issues. Please reload the page."
+msgstr ""
+
+msgid "Boards|An error occurred while fetching the board swimlanes. Please reload the page."
+msgstr ""
+
+msgid "Boards|An error occurred while moving the issue. Please try again."
+msgstr ""
+
+msgid "Boards|An error occurred while updating the list. Please try again."
msgstr ""
msgid "Boards|Collapse"
@@ -4461,9 +4491,6 @@ msgstr ""
msgid "Can't create snippet: %{err}"
msgstr ""
-msgid "Can't edit as source branch was deleted"
-msgstr ""
-
msgid "Can't fetch content for the blob: %{err}"
msgstr ""
@@ -4476,9 +4503,6 @@ msgstr ""
msgid "Can't load mermaid module: %{err}"
msgstr ""
-msgid "Can't remove group members without group managed account"
-msgstr ""
-
msgid "Can't scan the code?"
msgstr ""
@@ -4521,6 +4545,9 @@ msgstr ""
msgid "Cannot create the abuse report. This user has been blocked."
msgstr ""
+msgid "Cannot enable shared runners because parent group does not allow it"
+msgstr ""
+
msgid "Cannot have multiple Jira imports running at the same time"
msgstr ""
@@ -4626,6 +4653,15 @@ msgstr ""
msgid "Change your password or recover your current one"
msgstr ""
+msgid "ChangeReviewer|Reviewer changed from %{old} to %{new}"
+msgstr ""
+
+msgid "ChangeReviewer|Reviewer changed to %{new}"
+msgstr ""
+
+msgid "ChangeReviewer|Unassigned"
+msgstr ""
+
msgid "ChangeTypeActionLabel|Pick into branch"
msgstr ""
@@ -5073,6 +5109,9 @@ msgstr ""
msgid "CiVariables|Protected"
msgstr ""
+msgid "CiVariables|Remove variable"
+msgstr ""
+
msgid "CiVariables|Remove variable row"
msgstr ""
@@ -5286,6 +5325,27 @@ msgstr ""
msgid "Cluster type must be specificed for Stages::ClusterEndpointInserter"
msgstr ""
+msgid "ClusterAgents|Configuration"
+msgstr ""
+
+msgid "ClusterAgents|Connect your cluster with the GitLab Agent"
+msgstr ""
+
+msgid "ClusterAgents|Integrate Kubernetes with a GitLab Agent"
+msgstr ""
+
+msgid "ClusterAgents|Integrate with the GitLab Agent"
+msgstr ""
+
+msgid "ClusterAgents|Name"
+msgstr ""
+
+msgid "ClusterAgents|The GitLab Agent also requires %{linkStart}enabling the Agent Server%{linkEnd}"
+msgstr ""
+
+msgid "ClusterAgents|The GitLab Kubernetes Agent allows an Infrastructure as Code, GitOps approach to integrating Kubernetes clusters with GitLab. %{linkStart}Learn more.%{linkEnd}"
+msgstr ""
+
msgid "ClusterAgent|This feature is only available for premium plans"
msgstr ""
@@ -5355,10 +5415,10 @@ msgstr ""
msgid "ClusterIntegration|All installed applications and related resources"
msgstr ""
-msgid "ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster."
+msgid "ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster. %{linkStart}More information%{linkEnd}"
msgstr ""
-msgid "ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster. %{startLink}More information%{endLink}"
+msgid "ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster."
msgstr ""
msgid "ClusterIntegration|Alternatively, "
@@ -5379,6 +5439,9 @@ msgstr ""
msgid "ClusterIntegration|An error occurred while trying to fetch zone machine types: %{error}"
msgstr ""
+msgid "ClusterIntegration|An unknown error occurred while attempting to connect to Kubernetes."
+msgstr ""
+
msgid "ClusterIntegration|Any project namespaces"
msgstr ""
@@ -5394,6 +5457,9 @@ msgstr ""
msgid "ClusterIntegration|Authenticate with Amazon Web Services"
msgstr ""
+msgid "ClusterIntegration|Authentication Error"
+msgstr ""
+
msgid "ClusterIntegration|Base domain"
msgstr ""
@@ -5412,13 +5478,22 @@ msgstr ""
msgid "ClusterIntegration|Certificate Authority bundle (PEM format)"
msgstr ""
-msgid "ClusterIntegration|Choose the %{startLink}security group %{externalLinkIcon} %{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets."
+msgid "ClusterIntegration|Check your CA certificate"
+msgstr ""
+
+msgid "ClusterIntegration|Check your cluster status"
+msgstr ""
+
+msgid "ClusterIntegration|Check your token"
msgstr ""
-msgid "ClusterIntegration|Choose the %{startLink}subnets %{externalLinkIcon} %{endLink} in your VPC where your worker nodes will run."
+msgid "ClusterIntegration|Choose the %{linkStart}security group %{linkEnd} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets."
msgstr ""
-msgid "ClusterIntegration|Choose the worker node %{startLink}instance type %{externalLinkIcon} %{endLink}."
+msgid "ClusterIntegration|Choose the %{linkStart}subnets %{linkEnd} in your VPC where your worker nodes will run."
+msgstr ""
+
+msgid "ClusterIntegration|Choose the worker node %{linkStart}instance type%{linkEnd}."
msgstr ""
msgid "ClusterIntegration|Choose which applications to install on your Kubernetes cluster."
@@ -5430,10 +5505,7 @@ msgstr ""
msgid "ClusterIntegration|Clear cluster cache"
msgstr ""
-msgid "ClusterIntegration|Clear the local cache of namespace and service accounts. This is necessary if your integration has become out of sync. The cache is repopulated during the next CI job that requires namespace and service accounts."
-msgstr ""
-
-msgid "ClusterIntegration|Cluster being created"
+msgid "ClusterIntegration|Clear the local cache of namespace and service accounts."
msgstr ""
msgid "ClusterIntegration|Cluster management project (alpha)"
@@ -5445,15 +5517,21 @@ msgstr ""
msgid "ClusterIntegration|Cluster_applications artifact too big. Maximum allowable size: %{human_size}"
msgstr ""
-msgid "ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters."
+msgid "ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters. %{linkStart}More information%{linkEnd}"
msgstr ""
-msgid "ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters. %{linkStart}More information%{linkEnd}"
+msgid "ClusterIntegration|Clusters connected with a certificate"
+msgstr ""
+
+msgid "ClusterIntegration|Connect cluster with certificate"
msgstr ""
msgid "ClusterIntegration|Connect existing cluster"
msgstr ""
+msgid "ClusterIntegration|Connection Error"
+msgstr ""
+
msgid "ClusterIntegration|Copy API URL"
msgstr ""
@@ -5529,6 +5607,12 @@ msgstr ""
msgid "ClusterIntegration|Deletes all GitLab resources attached to this cluster during removal"
msgstr ""
+msgid "ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared."
+msgstr ""
+
+msgid "ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared. %{linkStart}More information%{linkEnd}"
+msgstr ""
+
msgid "ClusterIntegration|Did you know?"
msgstr ""
@@ -5598,6 +5682,9 @@ msgstr ""
msgid "ClusterIntegration|Fluentd is an open source data collector, which lets you unify the data collection and consumption for a better use and understanding of data. It requires at least one of the following logs to be successfully installed."
msgstr ""
+msgid "ClusterIntegration|GitLab Agent managed clusters"
+msgstr ""
+
msgid "ClusterIntegration|GitLab Container Network Policies"
msgstr ""
@@ -5610,6 +5697,12 @@ msgstr ""
msgid "ClusterIntegration|GitLab Runner connects to the repository and executes CI/CD jobs, pushing results back and deploying applications to production."
msgstr ""
+msgid "ClusterIntegration|GitLab failed to authenticate."
+msgstr ""
+
+msgid "ClusterIntegration|GitLab failed to connect to the cluster."
+msgstr ""
+
msgid "ClusterIntegration|GitLab-managed cluster"
msgstr ""
@@ -5631,6 +5724,9 @@ msgstr ""
msgid "ClusterIntegration|Group cluster"
msgstr ""
+msgid "ClusterIntegration|HTTP Error"
+msgstr ""
+
msgid "ClusterIntegration|Helm Tiller"
msgstr ""
@@ -5664,7 +5760,10 @@ msgstr ""
msgid "ClusterIntegration|Instance type"
msgstr ""
-msgid "ClusterIntegration|Integrate Kubernetes cluster automation"
+msgid "ClusterIntegration|Integrate Kubernetes with a cluster certificate"
+msgstr ""
+
+msgid "ClusterIntegration|Integrate with a cluster certificate"
msgstr ""
msgid "ClusterIntegration|Issuer Email"
@@ -5700,9 +5799,6 @@ msgstr ""
msgid "ClusterIntegration|Knative extends Kubernetes to provide a set of middleware components that are essential to build modern, source-centric, and container-based applications that can run anywhere: on premises, in the cloud, or even in a third-party data center."
msgstr ""
-msgid "ClusterIntegration|Kubernetes cluster"
-msgstr ""
-
msgid "ClusterIntegration|Kubernetes cluster is being created..."
msgstr ""
@@ -5715,9 +5811,6 @@ msgstr ""
msgid "ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way."
msgstr ""
-msgid "ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project"
-msgstr ""
-
msgid "ClusterIntegration|Kubernetes version"
msgstr ""
@@ -5730,7 +5823,7 @@ msgstr ""
msgid "ClusterIntegration|Learn more about %{help_link_start}zones%{help_link_end}."
msgstr ""
-msgid "ClusterIntegration|Learn more about %{startLink}Regions %{externalLinkIcon}%{endLink}."
+msgid "ClusterIntegration|Learn more about %{linkStart}Regions%{linkEnd}."
msgstr ""
msgid "ClusterIntegration|Learn more about Kubernetes"
@@ -5775,12 +5868,18 @@ msgstr ""
msgid "ClusterIntegration|Machine type"
msgstr ""
+msgid "ClusterIntegration|Make sure your API endpoint is correct"
+msgstr ""
+
msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create Kubernetes clusters"
msgstr ""
msgid "ClusterIntegration|Manage your Kubernetes cluster by visiting %{provider_link}"
msgstr ""
+msgid "ClusterIntegration|Namespace per environment"
+msgstr ""
+
msgid "ClusterIntegration|No IAM Roles found"
msgstr ""
@@ -5826,6 +5925,9 @@ msgstr ""
msgid "ClusterIntegration|No zones matched your search"
msgstr ""
+msgid "ClusterIntegration|Node calculations use the Kubernetes Metrics API. Make sure your cluster has metrics installed"
+msgstr ""
+
msgid "ClusterIntegration|Number of nodes"
msgstr ""
@@ -5868,6 +5970,9 @@ msgstr ""
msgid "ClusterIntegration|RBAC-enabled cluster"
msgstr ""
+msgid "ClusterIntegration|Read our %{linkStart}help page%{linkEnd} on Kubernetes cluster integration."
+msgstr ""
+
msgid "ClusterIntegration|Read our %{link_start}help page%{link_end} on Kubernetes cluster integration."
msgstr ""
@@ -5970,7 +6075,7 @@ msgstr ""
msgid "ClusterIntegration|Select a VPC to choose a subnet"
msgstr ""
-msgid "ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}."
+msgid "ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{linkStart}Amazon Web Services %{linkEnd}."
msgstr ""
msgid "ClusterIntegration|Select a network to choose a subnetwork"
@@ -6003,7 +6108,7 @@ msgstr ""
msgid "ClusterIntegration|Select project to choose zone"
msgstr ""
-msgid "ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}."
+msgid "ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{linkStart}Amazon Web Services%{linkEnd}."
msgstr ""
msgid "ClusterIntegration|Select zone"
@@ -6093,9 +6198,18 @@ msgstr ""
msgid "ClusterIntegration|There was a problem authenticating with your cluster. Please ensure your CA Certificate and Token are valid."
msgstr ""
+msgid "ClusterIntegration|There was an HTTP error when connecting to your 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|This is necessary if your integration has become out of sync. The cache is repopulated during the next CI job that requires namespace and service accounts."
+msgstr ""
+
+msgid "ClusterIntegration|This is necessary to clear existing environment-namespace associations from clusters previously managed by GitLab."
+msgstr ""
+
msgid "ClusterIntegration|This option will allow you to install applications on RBAC clusters."
msgstr ""
@@ -6120,9 +6234,21 @@ msgstr ""
msgid "ClusterIntegration|To use a new project, first create one on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}."
msgstr ""
+msgid "ClusterIntegration|Troubleshooting tips:"
+msgstr ""
+
+msgid "ClusterIntegration|Unable to Authenticate"
+msgstr ""
+
+msgid "ClusterIntegration|Unable to Connect"
+msgstr ""
+
msgid "ClusterIntegration|Uninstall %{appTitle}"
msgstr ""
+msgid "ClusterIntegration|Unknown Error"
+msgstr ""
+
msgid "ClusterIntegration|Update %{appTitle}"
msgstr ""
@@ -6183,7 +6309,7 @@ msgstr ""
msgid "ClusterIntegration|Your cluster API is unreachable. Please ensure your API URL is correct."
msgstr ""
-msgid "ClusterIntegration|Your service role is distinct from the provision role used when authenticating. It will allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}."
+msgid "ClusterIntegration|Your service role is distinct from the provision role used when authenticating. It will allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role, first create one on %{linkStart}Amazon Web Services%{linkEnd}."
msgstr ""
msgid "ClusterIntegration|Zone"
@@ -6303,6 +6429,9 @@ msgstr ""
msgid "Collapse"
msgstr ""
+msgid "Collapse all threads"
+msgstr ""
+
msgid "Collapse approvers"
msgstr ""
@@ -6617,6 +6746,9 @@ msgstr ""
msgid "Configure the way a user creates a new account."
msgstr ""
+msgid "Configure which lists are shown for anyone who visits this board"
+msgstr ""
+
msgid "Confirm"
msgstr ""
@@ -6842,6 +6974,9 @@ msgstr[1] ""
msgid "ContainerRegistry|Set cleanup policy"
msgstr ""
+msgid "ContainerRegistry|Some tags were not deleted"
+msgstr ""
+
msgid "ContainerRegistry|Something went wrong while fetching the cleanup policy."
msgstr ""
@@ -6881,6 +7016,9 @@ msgstr ""
msgid "ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}expire:%{italicEnd}"
msgstr ""
+msgid "ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}"
+msgstr ""
+
msgid "ContainerRegistry|The last tag related to this image was recently removed. This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. If you have any questions, contact your administrator."
msgstr ""
@@ -7121,6 +7259,9 @@ msgstr ""
msgid "Copy the code below to implement tracking in your application:"
msgstr ""
+msgid "Copy this value"
+msgstr ""
+
msgid "Copy to clipboard"
msgstr ""
@@ -7187,6 +7328,9 @@ msgstr ""
msgid "Could not find iteration"
msgstr ""
+msgid "Could not load instance counts. Please refresh the page to try again."
+msgstr ""
+
msgid "Could not remove the trigger."
msgstr ""
@@ -8076,6 +8220,9 @@ msgstr ""
msgid "Days to merge"
msgstr ""
+msgid "Dear Administrator,"
+msgstr ""
+
msgid "Debug"
msgstr ""
@@ -8448,9 +8595,6 @@ msgstr ""
msgid "Deploy to..."
msgstr ""
-msgid "DeployBoard|Matching on the %{appLabel} label has been removed for deploy boards. To see all instances on your board, you must update your chart and redeploy."
-msgstr ""
-
msgid "DeployFreeze|Freeze end"
msgstr ""
@@ -8790,6 +8934,9 @@ msgstr ""
msgid "DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again."
msgstr ""
+msgid "DesignManagement|There was an error moving your designs. Please upload your designs below."
+msgstr ""
+
msgid "DesignManagement|To upload designs, you'll need to enable LFS and have admin enable hashed storage. %{requirements_link_start}More information%{requirements_link_end}"
msgstr ""
@@ -8802,6 +8949,9 @@ msgstr ""
msgid "DesignManagement|Upload skipped."
msgstr ""
+msgid "DesignManagement|Your designs are being copied and are on their way… Please refresh to update."
+msgstr ""
+
msgid "DesignManagement|and %{moreCount} more."
msgstr ""
@@ -8859,6 +9009,9 @@ msgstr ""
msgid "Diffs|Something went wrong while fetching diff lines."
msgstr ""
+msgid "Direct member"
+msgstr ""
+
msgid "Direction"
msgstr ""
@@ -8907,9 +9060,6 @@ msgstr ""
msgid "Discard draft"
msgstr ""
-msgid "Discard review"
-msgstr ""
-
msgid "DiscordService|Discord Notifications"
msgstr ""
@@ -8978,10 +9128,10 @@ msgstr ""
msgid "Dismiss Merge Request promotion"
msgstr ""
-msgid "Dismiss Selected"
+msgid "Dismiss Value Stream Analytics introduction box"
msgstr ""
-msgid "Dismiss Value Stream Analytics introduction box"
+msgid "Dismiss selected"
msgstr ""
msgid "Dismiss trial promotion"
@@ -9032,9 +9182,6 @@ msgstr ""
msgid "Documents reindexed: %{processed_documents} (%{percentage}%%)"
msgstr ""
-msgid "Doing"
-msgstr ""
-
msgid "Domain"
msgstr ""
@@ -9230,10 +9377,10 @@ msgstr ""
msgid "Edit environment"
msgstr ""
-msgid "Edit file"
+msgid "Edit files in the editor and commit changes here"
msgstr ""
-msgid "Edit files in the editor and commit changes here"
+msgid "Edit fork in Web IDE"
msgstr ""
msgid "Edit group: %{group_name}"
@@ -9242,6 +9389,12 @@ msgstr ""
msgid "Edit identity for %{user_name}"
msgstr ""
+msgid "Edit in Web IDE"
+msgstr ""
+
+msgid "Edit in single-file editor"
+msgstr ""
+
msgid "Edit issues"
msgstr ""
@@ -9371,6 +9524,9 @@ msgstr ""
msgid "Emails sent from Service Desk will have this name"
msgstr ""
+msgid "Emails sent to %{email} will still be supported"
+msgstr ""
+
msgid "Emails separated by comma"
msgstr ""
@@ -9404,7 +9560,7 @@ msgstr ""
msgid "Enable"
msgstr ""
-msgid "Enable %{gitpod_link} integration to launch a development environment in your browser directly from GitLab."
+msgid "Enable %{link_start}Gitpod%{link_end} integration to launch a development environment in your browser directly from GitLab."
msgstr ""
msgid "Enable Auto DevOps"
@@ -10106,6 +10262,9 @@ msgstr ""
msgid "Error occurred when saving assignees"
msgstr ""
+msgid "Error occurred when saving reviewers"
+msgstr ""
+
msgid "Error occurred when toggling the notification subscription"
msgstr ""
@@ -10331,6 +10490,9 @@ msgstr ""
msgid "Except policy:"
msgstr ""
+msgid "Excess storage"
+msgstr ""
+
msgid "Excluding merge commits. Limited to %{limit} commits."
msgstr ""
@@ -10361,10 +10523,10 @@ msgstr ""
msgid "Expand all files"
msgstr ""
-msgid "Expand approvers"
+msgid "Expand all threads"
msgstr ""
-msgid "Expand file"
+msgid "Expand approvers"
msgstr ""
msgid "Expand milestones"
@@ -10580,6 +10742,9 @@ msgstr ""
msgid "Failed to load branches. Please try again."
msgstr ""
+msgid "Failed to load deploy keys."
+msgstr ""
+
msgid "Failed to load emoji list."
msgstr ""
@@ -10595,6 +10760,9 @@ msgstr ""
msgid "Failed to load groups & users."
msgstr ""
+msgid "Failed to load groups, users and deploy keys."
+msgstr ""
+
msgid "Failed to load labels. Please try again."
msgstr ""
@@ -10604,6 +10772,12 @@ msgstr ""
msgid "Failed to load related branches"
msgstr ""
+msgid "Failed to load sidebar confidential toggle"
+msgstr ""
+
+msgid "Failed to load sidebar lock status"
+msgstr ""
+
msgid "Failed to load stacktrace."
msgstr ""
@@ -10864,6 +11038,9 @@ msgstr ""
msgid "FeatureFlags|Loading feature flags"
msgstr ""
+msgid "FeatureFlags|Loading user lists"
+msgstr ""
+
msgid "FeatureFlags|More information"
msgstr ""
@@ -10882,7 +11059,7 @@ msgstr ""
msgid "FeatureFlags|New feature flag"
msgstr ""
-msgid "FeatureFlags|New list"
+msgid "FeatureFlags|New user list"
msgstr ""
msgid "FeatureFlags|Percent of users"
@@ -10921,6 +11098,9 @@ msgstr ""
msgid "FeatureFlags|There was an error fetching the feature flags."
msgstr ""
+msgid "FeatureFlags|There was an error fetching the user lists."
+msgstr ""
+
msgid "FeatureFlags|There was an error retrieving user lists"
msgstr ""
@@ -10936,6 +11116,9 @@ msgstr ""
msgid "FeatureFlags|User List"
msgstr ""
+msgid "FeatureFlags|User Lists"
+msgstr ""
+
msgid "FeatureFlag|List"
msgstr ""
@@ -11077,6 +11260,9 @@ msgstr ""
msgid "Filter by user"
msgstr ""
+msgid "Filter parameters are not valid. Make sure that the end date is after the start date."
+msgstr ""
+
msgid "Filter pipelines"
msgstr ""
@@ -11101,7 +11287,7 @@ msgstr ""
msgid "Find File"
msgstr ""
-msgid "Find bugs in your code with coverage-guided fuzzing"
+msgid "Find bugs in your code with coverage-guided fuzzing."
msgstr ""
msgid "Find by path"
@@ -11128,9 +11314,6 @@ msgstr ""
msgid "Finish editing this message first!"
msgstr ""
-msgid "Finish review"
-msgstr ""
-
msgid "Finish setting up your dedicated account for %{group_name}."
msgstr ""
@@ -11212,6 +11395,9 @@ msgstr ""
msgid "For more info, read the documentation."
msgstr ""
+msgid "For more information on how the number of active users is calculated, see the %{self_managed_subscriptions_doc_link} documentation."
+msgstr ""
+
msgid "For more information, go to the "
msgstr ""
@@ -11788,6 +11974,9 @@ msgstr ""
msgid "GitLab API"
msgstr ""
+msgid "GitLab Billing Team."
+msgstr ""
+
msgid "GitLab Group Runners can execute code for all the projects in this group."
msgstr ""
@@ -12508,9 +12697,6 @@ msgstr ""
msgid "GroupSAML|Valid SAML Response"
msgstr ""
-msgid "GroupSAML|With group managed accounts enabled, all the users without a group managed account will be excluded from the group."
-msgstr ""
-
msgid "GroupSAML|With prohibit outer forks flag enabled group members will be able to fork project only inside your group."
msgstr ""
@@ -12870,6 +13056,9 @@ msgid_plural "Hide charts"
msgstr[0] ""
msgstr[1] ""
+msgid "Hide comments on this file"
+msgstr ""
+
msgid "Hide details"
msgstr ""
@@ -13100,6 +13289,9 @@ msgstr ""
msgid "If enabled, access to projects will be validated on an external service using their classification label."
msgstr ""
+msgid "If the number of active users exceeds the user limit, you will be charged for the number of %{users_over_license_link} at your next license reconciliation."
+msgstr ""
+
msgid "If there is no previous license or if the previous license has expired, some GitLab functionality will be blocked until a new, valid license is uploaded."
msgstr ""
@@ -13506,6 +13698,9 @@ msgstr ""
msgid "Information about additional Pages templates and how to install them can be found in our %{pages_getting_started_guide}."
msgstr ""
+msgid "Inherited"
+msgstr ""
+
msgid "Inherited:"
msgstr ""
@@ -13586,6 +13781,24 @@ msgstr ""
msgid "Instance administrators group already exists"
msgstr ""
+msgid "InstanceStatistics|Groups"
+msgstr ""
+
+msgid "InstanceStatistics|Issues"
+msgstr ""
+
+msgid "InstanceStatistics|Merge Requests"
+msgstr ""
+
+msgid "InstanceStatistics|Pipelines"
+msgstr ""
+
+msgid "InstanceStatistics|Projects"
+msgstr ""
+
+msgid "InstanceStatistics|Users"
+msgstr ""
+
msgid "Integration"
msgstr ""
@@ -13631,6 +13844,15 @@ msgstr ""
msgid "Integrations|Includes commit title and branch"
msgstr ""
+msgid "Integrations|Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults."
+msgstr ""
+
+msgid "Integrations|Save settings?"
+msgstr ""
+
+msgid "Integrations|Saving will update the default settings for all projects that are not using custom settings."
+msgstr ""
+
msgid "Integrations|Standard"
msgstr ""
@@ -13763,6 +13985,9 @@ msgstr ""
msgid "Invitation"
msgstr ""
+msgid "Invitation declined"
+msgstr ""
+
msgid "Invite"
msgstr ""
@@ -13814,6 +14039,42 @@ msgstr ""
msgid "InviteMembersBanner|We noticed that you haven't invited anyone to this group. Invite your colleagues so you can discuss issues, collaborate on merge requests, and share your knowledge."
msgstr ""
+msgid "InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions"
+msgstr ""
+
+msgid "InviteMembersModal|Access expiration date (optional)"
+msgstr ""
+
+msgid "InviteMembersModal|Cancel"
+msgstr ""
+
+msgid "InviteMembersModal|Choose a role permission"
+msgstr ""
+
+msgid "InviteMembersModal|GitLab member or Email address"
+msgstr ""
+
+msgid "InviteMembersModal|Invite"
+msgstr ""
+
+msgid "InviteMembersModal|Invite team members"
+msgstr ""
+
+msgid "InviteMembersModal|Search for members to invite"
+msgstr ""
+
+msgid "InviteMembersModal|User not invited. Feature coming soon!"
+msgstr ""
+
+msgid "InviteMembersModal|Users were succesfully added"
+msgstr ""
+
+msgid "InviteMembersModal|You're inviting members to the %{group_name} group"
+msgstr ""
+
+msgid "InviteMembers|Invite team members"
+msgstr ""
+
msgid "Invited"
msgstr ""
@@ -13868,6 +14129,9 @@ msgstr ""
msgid "Issue cannot be found."
msgstr ""
+msgid "Issue created from vulnerability %{vulnerability_link}"
+msgstr ""
+
msgid "Issue events"
msgstr ""
@@ -14312,9 +14576,6 @@ msgstr ""
msgid "July"
msgstr ""
-msgid "Jump to first unresolved thread"
-msgstr ""
-
msgid "Jump to next unresolved thread"
msgstr ""
@@ -14399,6 +14660,9 @@ msgstr ""
msgid "LDAP Synchronization"
msgstr ""
+msgid "LDAP group settings"
+msgstr ""
+
msgid "LDAP settings"
msgstr ""
@@ -14408,6 +14672,9 @@ msgstr ""
msgid "LDAP sync in progress. This could take a few minutes. Refresh the page to see the changes."
msgstr ""
+msgid "LDAP synchronizations"
+msgstr ""
+
msgid "LFS"
msgstr ""
@@ -14488,9 +14755,6 @@ msgid_plural "Last %d days"
msgstr[0] ""
msgstr[1] ""
-msgid "Last %{days} days"
-msgstr ""
-
msgid "Last 2 weeks"
msgstr ""
@@ -14979,6 +15243,9 @@ msgstr ""
msgid "List of all merge commits"
msgstr ""
+msgid "List options"
+msgstr ""
+
msgid "List settings"
msgstr ""
@@ -14991,9 +15258,6 @@ msgstr ""
msgid "List your Bitbucket Server repositories"
msgstr ""
-msgid "Lists"
-msgstr ""
-
msgid "Live preview"
msgstr ""
@@ -15240,6 +15504,12 @@ msgstr ""
msgid "Mark as done"
msgstr ""
+msgid "Mark as draft"
+msgstr ""
+
+msgid "Mark as ready"
+msgstr ""
+
msgid "Mark as resolved"
msgstr ""
@@ -15363,6 +15633,9 @@ msgstr ""
msgid "Max access level"
msgstr ""
+msgid "Max role"
+msgstr ""
+
msgid "Max size 15 MB"
msgstr ""
@@ -15510,6 +15783,18 @@ msgstr ""
msgid "Members with access to %{strong_start}%{group_name}%{strong_end}"
msgstr ""
+msgid "Members|%{time} by %{user}"
+msgstr ""
+
+msgid "Members|Expired"
+msgstr ""
+
+msgid "Members|No expiration set"
+msgstr ""
+
+msgid "Members|in %{time}"
+msgstr ""
+
msgid "Memory Usage"
msgstr ""
@@ -15696,9 +15981,6 @@ msgstr ""
msgid "MergeRequests|Thread will be unresolved"
msgstr ""
-msgid "MergeRequests|Toggle comments for this file"
-msgstr ""
-
msgid "MergeRequests|View file @ %{commitId}"
msgstr ""
@@ -16660,6 +16942,9 @@ msgstr ""
msgid "NetworkPolicies|Name"
msgstr ""
+msgid "NetworkPolicies|Namespace"
+msgstr ""
+
msgid "NetworkPolicies|Network Policy"
msgstr ""
@@ -16726,6 +17011,9 @@ msgstr ""
msgid "NetworkPolicies|Traffic that does not match any rule will be blocked."
msgstr ""
+msgid "NetworkPolicies|Unable to parse policy"
+msgstr ""
+
msgid "NetworkPolicies|YAML editor"
msgstr ""
@@ -17121,6 +17409,9 @@ msgstr ""
msgid "No matching results for \"%{query}\""
msgstr ""
+msgid "No members found"
+msgstr ""
+
msgid "No merge requests found"
msgstr ""
@@ -17241,6 +17532,12 @@ msgstr ""
msgid "Not Implemented"
msgstr ""
+msgid "Not all browsers support U2F devices. Therefore, we require that you set up a two-factor authentication app first. That way you'll always be able to sign in - even when you're using an unsupported browser."
+msgstr ""
+
+msgid "Not all browsers support WebAuthn. Therefore, we require that you set up a two-factor authentication app first. That way you'll always be able to sign in - even from an unsupported browser."
+msgstr ""
+
msgid "Not all data has been processed yet, the accuracy of the chart for the selected timeframe is limited."
msgstr ""
@@ -17337,6 +17634,9 @@ msgstr ""
msgid "Notification settings saved"
msgstr ""
+msgid "NotificationEvent|Change reviewer merge request"
+msgstr ""
+
msgid "NotificationEvent|Close issue"
msgstr ""
@@ -17499,12 +17799,6 @@ msgstr ""
msgid "On track"
msgstr ""
-msgid "OnDemandScans|Attached branch"
-msgstr ""
-
-msgid "OnDemandScans|Attached branch is where the scan job runs."
-msgstr ""
-
msgid "OnDemandScans|Could not fetch scanner profiles. Please refresh the page, or try again later."
msgstr ""
@@ -17541,16 +17835,10 @@ msgstr ""
msgid "OnDemandScans|On-demand scans run outside the DevOps cycle and find vulnerabilities in your projects. %{learnMoreLinkStart}Learn more%{learnMoreLinkEnd}"
msgstr ""
-msgid "OnDemandScans|Only a passive scan can be performed on demand."
-msgstr ""
-
-msgid "OnDemandScans|Passive"
-msgstr ""
-
msgid "OnDemandScans|Run scan"
msgstr ""
-msgid "OnDemandScans|Scan mode"
+msgid "OnDemandScans|Scanner profile"
msgstr ""
msgid "OnDemandScans|Scanner settings"
@@ -17562,6 +17850,9 @@ msgstr ""
msgid "OnDemandScans|Select one of the existing profiles"
msgstr ""
+msgid "OnDemandScans|Site profile"
+msgstr ""
+
msgid "OnDemandScans|Site profiles"
msgstr ""
@@ -17666,9 +17957,6 @@ msgstr ""
msgid "Open Selection"
msgstr ""
-msgid "Open Web IDE"
-msgstr ""
-
msgid "Open comment type dropdown"
msgstr ""
@@ -17774,6 +18062,9 @@ msgstr ""
msgid "Origin"
msgstr ""
+msgid "Orphaned member"
+msgstr ""
+
msgid "Other Labels"
msgstr ""
@@ -17816,6 +18107,9 @@ msgstr ""
msgid "Overwrite diverged branches"
msgstr ""
+msgid "Owned by %{image_tag}"
+msgstr ""
+
msgid "Owned by anyone"
msgstr ""
@@ -17855,9 +18149,6 @@ msgstr ""
msgid "Package type must be PyPi"
msgstr ""
-msgid "Package was removed"
-msgstr ""
-
msgid "PackageRegistry|%{name} version %{version} was created %{datetime}"
msgstr ""
@@ -17999,12 +18290,18 @@ msgstr ""
msgid "PackageRegistry|NuGet Command"
msgstr ""
+msgid "PackageRegistry|Package Registry"
+msgstr ""
+
msgid "PackageRegistry|Pip Command"
msgstr ""
msgid "PackageRegistry|Pipeline %{link} triggered %{datetime} by %{author}"
msgstr ""
+msgid "PackageRegistry|Publish and share packages for a variety of common package managers. %{docLinkStart}More information%{docLinkEnd}"
+msgstr ""
+
msgid "PackageRegistry|Published to the %{project} Package Registry %{datetime}"
msgstr ""
@@ -18248,7 +18545,7 @@ msgstr ""
msgid "Paste issue link"
msgstr ""
-msgid "Paste your public SSH key, which is usually contained in the file '~/.ssh/id_ed25519.pub' or '~/.ssh/id_rsa.pub' and begins with 'ssh-ed25519' or 'ssh-rsa'. Don't use your private SSH key."
+msgid "Paste your public SSH key, which is usually contained in the file '~/.ssh/id_ed25519.pub' or '~/.ssh/id_rsa.pub' and begins with 'ssh-ed25519' or 'ssh-rsa'. Do not paste your private SSH key, as that can compromise your identity."
msgstr ""
msgid "Patch to apply"
@@ -18275,6 +18572,9 @@ msgstr ""
msgid "Pending"
msgstr ""
+msgid "Pending comments"
+msgstr ""
+
msgid "People without permission will never get a notification and won't be able to comment."
msgstr ""
@@ -18293,9 +18593,6 @@ msgstr ""
msgid "Perform common operations on GitLab project"
msgstr ""
-msgid "Performance and resource management"
-msgstr ""
-
msgid "Performance optimization"
msgstr ""
@@ -18473,6 +18770,9 @@ msgstr ""
msgid "Pipelines|Build with confidence"
msgstr ""
+msgid "Pipelines|By revoking a trigger you will break any processes making use of it. Are you sure?"
+msgstr ""
+
msgid "Pipelines|CI Lint"
msgstr ""
@@ -18485,6 +18785,15 @@ msgstr ""
msgid "Pipelines|Continuous Integration can help catch bugs by running your tests automatically, while Continuous Deployment can help you deliver code to your product environment."
msgstr ""
+msgid "Pipelines|Copy trigger token"
+msgstr ""
+
+msgid "Pipelines|Description"
+msgstr ""
+
+msgid "Pipelines|Edit"
+msgstr ""
+
msgid "Pipelines|Get started with Pipelines"
msgstr ""
@@ -18500,15 +18809,27 @@ msgstr ""
msgid "Pipelines|It is recommended the code is reviewed thoroughly before running this pipeline with the parent project's CI resource."
msgstr ""
+msgid "Pipelines|Last Used"
+msgstr ""
+
msgid "Pipelines|Loading Pipelines"
msgstr ""
msgid "Pipelines|More Information"
msgstr ""
+msgid "Pipelines|No triggers have been created yet. Add one using the form above."
+msgstr ""
+
+msgid "Pipelines|Owner"
+msgstr ""
+
msgid "Pipelines|Project cache successfully reset."
msgstr ""
+msgid "Pipelines|Revoke"
+msgstr ""
+
msgid "Pipelines|Run Pipeline"
msgstr ""
@@ -18533,6 +18854,15 @@ msgstr ""
msgid "Pipelines|This project is not currently set up to run pipelines."
msgstr ""
+msgid "Pipelines|Token"
+msgstr ""
+
+msgid "Pipelines|Trigger user has insufficient permissions to project"
+msgstr ""
+
+msgid "Pipelines|invalid"
+msgstr ""
+
msgid "Pipelines|parent"
msgstr ""
@@ -18791,6 +19121,9 @@ msgstr ""
msgid "Please provide attributes to update"
msgstr ""
+msgid "Please reach out if you have any questions and we'll be happy to assist."
+msgstr ""
+
msgid "Please refer to %{docs_url}"
msgstr ""
@@ -18821,6 +19154,9 @@ msgstr ""
msgid "Please set a new password before proceeding."
msgstr ""
+msgid "Please share your feedback about %{featureName} %{linkStart}in this issue%{linkEnd} to help us improve the experience."
+msgstr ""
+
msgid "Please solve the reCAPTCHA"
msgstr ""
@@ -19229,7 +19565,7 @@ msgstr ""
msgid "Profiles|Full name"
msgstr ""
-msgid "Profiles|Give your individual key a title. This will be publically visible."
+msgid "Profiles|Give your individual key a title."
msgstr ""
msgid "Profiles|Include private contributions on my profile"
@@ -19319,7 +19655,7 @@ msgstr ""
msgid "Profiles|The maximum file size allowed is 200KB."
msgstr ""
-msgid "Profiles|This doesn't look like a public SSH key, are you sure you want to add it?"
+msgid "Profiles|This doesn't look like a public SSH key, are you sure you want to add it? It will be publicly visible."
msgstr ""
msgid "Profiles|This email will be displayed on your public profile"
@@ -19985,6 +20321,9 @@ msgstr ""
msgid "ProjectTemplates|GitLab Cluster Management"
msgstr ""
+msgid "ProjectTemplates|Gitpod/Spring Petclinic"
+msgstr ""
+
msgid "ProjectTemplates|Go Micro"
msgstr ""
@@ -20594,6 +20933,9 @@ msgstr ""
msgid "Publish to status page"
msgstr ""
+msgid "Published"
+msgstr ""
+
msgid "Published on status page"
msgstr ""
@@ -20973,9 +21315,6 @@ msgstr ""
msgid "Release|Something went wrong while saving the release details"
msgstr ""
-msgid "Remediated: needs review"
-msgstr ""
-
msgid "Remediations"
msgstr ""
@@ -21374,6 +21713,9 @@ msgstr ""
msgid "RepositoriesAnalytics|Test Code Coverage"
msgstr ""
+msgid "RepositoriesAnalytics|There was an error fetching the projects."
+msgstr ""
+
msgid "Repository"
msgstr ""
@@ -21452,9 +21794,15 @@ msgstr ""
msgid "Request parameter %{param} is missing."
msgstr ""
+msgid "Request review from"
+msgstr ""
+
msgid "Request to link SAML account must be authorized"
msgstr ""
+msgid "Requested"
+msgstr ""
+
msgid "Requested %{time_ago}"
msgstr ""
@@ -21473,6 +21821,9 @@ msgstr ""
msgid "Requests to these domain(s)/address(es) on the local network will be allowed when local requests from hooks and services are not allowed. IP ranges such as 1:0:0:0:0:0:0:0/124 or 127.0.0.0/28 are supported. Domain wildcards are not supported currently. Use comma, semicolon, or newline to separate multiple entries. The allowlist can hold a maximum of 1000 entries. Domains should use IDNA encoding. Ex: example.com, 192.168.1.1, 127.0.0.0/28, xn--itlab-j1a.com."
msgstr ""
+msgid "Require admin approval for new sign-ups"
+msgstr ""
+
msgid "Require all users in this group to setup Two-factor authentication"
msgstr ""
@@ -21600,9 +21951,6 @@ msgstr ""
msgid "Resolved by %{name}"
msgstr ""
-msgid "Resolved by %{resolvedByName}"
-msgstr ""
-
msgid "Resolves IP addresses once and uses them to submit requests"
msgstr ""
@@ -21722,6 +22070,14 @@ msgstr ""
msgid "ReviewApp|Enable Review App"
msgstr ""
+msgid "Reviewer"
+msgid_plural "%d Reviewers"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Reviewer(s)"
+msgstr ""
+
msgid "Reviewing"
msgstr ""
@@ -21911,6 +22267,9 @@ msgstr ""
msgid "Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects."
msgstr ""
+msgid "SAML"
+msgstr ""
+
msgid "SAML SSO"
msgstr ""
@@ -22037,9 +22396,6 @@ msgstr ""
msgid "Scope"
msgstr ""
-msgid "Scope not supported with disabled 'users_search' feature!"
-msgstr ""
-
msgid "Scoped issue boards"
msgstr ""
@@ -22166,12 +22522,6 @@ msgstr ""
msgid "Search requirements"
msgstr ""
-msgid "Search results…"
-msgstr ""
-
-msgid "Search test cases"
-msgstr ""
-
msgid "Search users"
msgstr ""
@@ -22244,6 +22594,11 @@ msgid_plural "SearchResults|commits"
msgstr[0] ""
msgstr[1] ""
+msgid "SearchResults|epic"
+msgid_plural "SearchResults|epics"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "SearchResults|issue"
msgid_plural "SearchResults|issues"
msgstr[0] ""
@@ -22288,6 +22643,12 @@ msgstr ""
msgid "Seat Link is disabled, and cannot be configured through this form."
msgstr ""
+msgid "Seats usage data as of %{last_enqueue_time}"
+msgstr ""
+
+msgid "Seats usage data is updated every day at 12:00pm UTC"
+msgstr ""
+
msgid "Secondary"
msgstr ""
@@ -22339,6 +22700,9 @@ msgstr ""
msgid "SecurityConfiguration|Available for on-demand DAST"
msgstr ""
+msgid "SecurityConfiguration|By default, all analyzers are applied in order to cover all languages across your project, and only run if the language is detected in the Merge Request."
+msgstr ""
+
msgid "SecurityConfiguration|Configure"
msgstr ""
@@ -22369,16 +22733,19 @@ msgstr ""
msgid "SecurityConfiguration|Manage"
msgstr ""
+msgid "SecurityConfiguration|More information"
+msgstr ""
+
msgid "SecurityConfiguration|Not enabled"
msgstr ""
-msgid "SecurityConfiguration|SAST Configuration"
+msgid "SecurityConfiguration|SAST Analyzers"
msgstr ""
-msgid "SecurityConfiguration|Security Control"
+msgid "SecurityConfiguration|SAST Configuration"
msgstr ""
-msgid "SecurityConfiguration|See documentation"
+msgid "SecurityConfiguration|Security Control"
msgstr ""
msgid "SecurityConfiguration|Status"
@@ -22471,9 +22838,6 @@ msgstr ""
msgid "SecurityReports|Learn more about setting up your dashboard"
msgstr ""
-msgid "SecurityReports|Load more vulnerabilities"
-msgstr ""
-
msgid "SecurityReports|Monitor vulnerabilities in your code"
msgstr ""
@@ -22771,9 +23135,6 @@ msgstr ""
msgid "Select the environment scope for this feature flag."
msgstr ""
-msgid "Select timeframe"
-msgstr ""
-
msgid "Select timezone"
msgstr ""
@@ -23101,6 +23462,9 @@ msgstr ""
msgid "Set up a %{type} Runner manually"
msgstr ""
+msgid "Set up a hardware device as a second factor to sign in."
+msgstr ""
+
msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
msgstr ""
@@ -23260,6 +23624,9 @@ msgstr ""
msgid "Show comments"
msgstr ""
+msgid "Show comments on this file"
+msgstr ""
+
msgid "Show comments only"
msgstr ""
@@ -23305,6 +23672,12 @@ msgstr ""
msgid "Show parent subgroups"
msgstr ""
+msgid "Show the Closed list"
+msgstr ""
+
+msgid "Show the Open list"
+msgstr ""
+
msgid "Show whitespace changes"
msgstr ""
@@ -23630,9 +24003,6 @@ msgstr ""
msgid "Something went wrong while creating a requirement."
msgstr ""
-msgid "Something went wrong while creating a test case."
-msgstr ""
-
msgid "Something went wrong while deleting description changes. Please try again."
msgstr ""
@@ -23657,9 +24027,6 @@ msgstr ""
msgid "Something went wrong while fetching comments. Please try again."
msgstr ""
-msgid "Something went wrong while fetching count of test cases."
-msgstr ""
-
msgid "Something went wrong while fetching description changes. Please try again."
msgstr ""
@@ -23684,9 +24051,6 @@ msgstr ""
msgid "Something went wrong while fetching requirements list."
msgstr ""
-msgid "Something went wrong while fetching test cases list."
-msgstr ""
-
msgid "Something went wrong while fetching the environments for this merge request. Please try again."
msgstr ""
@@ -24110,9 +24474,6 @@ msgstr ""
msgid "Start and due date"
msgstr ""
-msgid "Start by choosing a group to see how your team is spending time. You can then drill down to the project level."
-msgstr ""
-
msgid "Start by choosing a group to start exploring the merge requests in that group. You can then proceed to filter by projects, labels, milestones and authors."
msgstr ""
@@ -24194,18 +24555,27 @@ msgstr ""
msgid "StaticSiteEditor|3. Assign a person to review and accept the merge request."
msgstr ""
+msgid "StaticSiteEditor|A link to view the merge request will appear once ready."
+msgstr ""
+
msgid "StaticSiteEditor|An error occurred while submitting your changes."
msgstr ""
msgid "StaticSiteEditor|Branch could not be created."
msgstr ""
+msgid "StaticSiteEditor|Copy update"
+msgstr ""
+
msgid "StaticSiteEditor|Could not commit the content changes."
msgstr ""
msgid "StaticSiteEditor|Could not create merge request."
msgstr ""
+msgid "StaticSiteEditor|Creating your merge request"
+msgstr ""
+
msgid "StaticSiteEditor|Incompatible file content"
msgstr ""
@@ -24227,6 +24597,9 @@ msgstr ""
msgid "StaticSiteEditor|View documentation"
msgstr ""
+msgid "StaticSiteEditor|You can set an assignee to get your changes reviewed and deployed once your merge request is created."
+msgstr ""
+
msgid "StaticSiteEditor|Your merge request has been created"
msgstr ""
@@ -24377,9 +24750,6 @@ msgstr ""
msgid "Submit search"
msgstr ""
-msgid "Submit test case"
-msgstr ""
-
msgid "Submit the current review."
msgstr ""
@@ -24923,6 +25293,27 @@ msgstr[1] ""
msgid "Test settings"
msgstr ""
+msgid "TestCases|New Test Case"
+msgstr ""
+
+msgid "TestCases|New test case"
+msgstr ""
+
+msgid "TestCases|Search test cases"
+msgstr ""
+
+msgid "TestCases|Something went wrong while creating a test case."
+msgstr ""
+
+msgid "TestCases|Something went wrong while fetching count of test cases."
+msgstr ""
+
+msgid "TestCases|Something went wrong while fetching test cases list."
+msgstr ""
+
+msgid "TestCases|Submit test case"
+msgstr ""
+
msgid "TestHooks|Ensure one of your projects has merge requests."
msgstr ""
@@ -24986,6 +25377,9 @@ msgstr ""
msgid "Thank you for signing up for your free trial! You will get additional instructions in your inbox shortly."
msgstr ""
+msgid "Thank you for your business."
+msgstr ""
+
msgid "Thank you for your feedback!"
msgstr ""
@@ -25042,10 +25436,10 @@ msgstr ""
msgid "The URL defined on the primary node that secondary nodes should use to contact it."
msgstr ""
-msgid "The URL to use for connecting to Elasticsearch. Use a comma-separated list to support clustering (e.g., \"http://localhost:9200, http://localhost:9201\")."
+msgid "The URL defined on the primary node that secondary nodes should use to contact it. %{linkStart}More information%{linkEnd}"
msgstr ""
-msgid "The Web IDE offers advanced syntax highlighting capabilities and more."
+msgid "The URL to use for connecting to Elasticsearch. Use a comma-separated list to support clustering (e.g., \"http://localhost:9200, http://localhost:9201\")."
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."
@@ -25737,6 +26131,9 @@ msgstr ""
msgid "This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention."
msgstr ""
+msgid "This action cannot be undone, and will permanently delete the %{key} SSH key"
+msgstr ""
+
msgid "This action cannot be undone. You will lose the project's repository and all content: issues, merge requests, etc."
msgstr ""
@@ -25857,9 +26254,6 @@ msgstr ""
msgid "This field is required."
msgstr ""
-msgid "This file is collapsed."
-msgstr ""
-
msgid "This group"
msgstr ""
@@ -26136,7 +26530,7 @@ msgstr ""
msgid "This user has previously committed to the %{name} project."
msgstr ""
-msgid "This user is a %{access} of the %{name} project."
+msgid "This user has the %{access} role in the %{name} project."
msgstr ""
msgid "This user is the author of this %{noteable}."
@@ -26172,6 +26566,9 @@ msgstr ""
msgid "Threat Monitoring"
msgstr ""
+msgid "ThreatMonitoring|All Environments"
+msgstr ""
+
msgid "ThreatMonitoring|Anomalous Requests"
msgstr ""
@@ -26199,9 +26596,6 @@ msgstr ""
msgid "ThreatMonitoring|Operations Per Second"
msgstr ""
-msgid "ThreatMonitoring|Overview"
-msgstr ""
-
msgid "ThreatMonitoring|Packet Activity"
msgstr ""
@@ -26220,6 +26614,9 @@ msgstr ""
msgid "ThreatMonitoring|Something went wrong, unable to fetch statistics"
msgstr ""
+msgid "ThreatMonitoring|Statistics"
+msgstr ""
+
msgid "ThreatMonitoring|The firewall is not installed or has been disabled. To view this data, ensure the web application firewall is installed and enabled for your cluster."
msgstr ""
@@ -26647,9 +27044,6 @@ msgstr ""
msgid "Toggle Sidebar"
msgstr ""
-msgid "Toggle all threads"
-msgstr ""
-
msgid "Toggle backtrace"
msgstr ""
@@ -26674,9 +27068,6 @@ msgstr ""
msgid "Toggle navigation"
msgstr ""
-msgid "Toggle project"
-msgstr ""
-
msgid "Toggle sidebar"
msgstr ""
@@ -26935,6 +27326,9 @@ msgstr ""
msgid "Tuesday"
msgstr ""
+msgid "Tuning settings"
+msgstr ""
+
msgid "Turn Off"
msgstr ""
@@ -27079,9 +27473,6 @@ msgstr ""
msgid "Unable to load the merge request widget. Try reloading the page."
msgstr ""
-msgid "Unable to resolve"
-msgstr ""
-
msgid "Unable to save iteration. Please try again"
msgstr ""
@@ -27535,9 +27926,6 @@ msgstr ""
msgid "Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab"
msgstr ""
-msgid "Use a hardware device to add the second factor of authentication."
-msgstr ""
-
msgid "Use an one time password authenticator on your mobile device or computer to enable two-factor authentication (2FA)."
msgstr ""
@@ -27811,6 +28199,9 @@ msgstr ""
msgid "Users"
msgstr ""
+msgid "Users in License"
+msgstr ""
+
msgid "Users in License:"
msgstr ""
@@ -28199,6 +28590,12 @@ msgstr ""
msgid "VulnerabilityManagement|Could not process %{issueReference}: %{errorMessage}."
msgstr ""
+msgid "VulnerabilityManagement|Detected"
+msgstr ""
+
+msgid "VulnerabilityManagement|Needs triage"
+msgstr ""
+
msgid "VulnerabilityManagement|Something went wrong while trying to delete the comment. Please try again later."
msgstr ""
@@ -28247,6 +28644,9 @@ msgstr ""
msgid "Vulnerability|%{scannerName} (version %{scannerVersion})"
msgstr ""
+msgid "Vulnerability|Activity"
+msgstr ""
+
msgid "Vulnerability|Class"
msgstr ""
@@ -28325,6 +28725,9 @@ msgstr ""
msgid "Warning: Displaying this diagram might cause performance issues on this page."
msgstr ""
+msgid "We are currently unable to fetch data for the pipeline header."
+msgstr ""
+
msgid "We are currently unable to fetch data for this graph."
msgstr ""
@@ -28364,6 +28767,12 @@ msgstr ""
msgid "We want to be sure it is you, please confirm you are not a robot."
msgstr ""
+msgid "We will notify %{inviter} that you declined their invitation to join GitLab. You will stop receiving reminders."
+msgstr ""
+
+msgid "We would like to inform you that your subscription GitLab Enterprise Edition %{plan_name} is nearing its user limit. You have %{active_user_count} active users, which is almost at the user limit of %{maximum_user_count}."
+msgstr ""
+
msgid "We've found no vulnerabilities"
msgstr ""
@@ -28523,15 +28932,9 @@ msgstr ""
msgid "Welcome to the guided GitLab tour"
msgstr ""
-msgid "Welcome to your Issue Board!"
-msgstr ""
-
msgid "Welcome to your issue board!"
msgstr ""
-msgid "We’ve been making changes to %{featureName} and we’d love your feedback %{linkStart}in this issue%{linkEnd} to help us improve the experience."
-msgstr ""
-
msgid "What are you searching for?"
msgstr ""
@@ -28550,6 +28953,9 @@ msgstr ""
msgid "When a runner is locked, it cannot be assigned to other projects"
msgstr ""
+msgid "When enabled, any user visiting %{host} and creating an account will have to be explicitly approved by the admin before they can login. This setting is effective only if sign-ups are enabled."
+msgstr ""
+
msgid "When enabled, any user visiting %{host} will be able to create an account."
msgstr ""
@@ -28816,6 +29222,9 @@ msgstr ""
msgid "Wrong extern UID provided. Make sure Auth0 is configured correctly."
msgstr ""
+msgid "YYYY-MM-DD"
+msgstr ""
+
msgid "Yes"
msgstr ""
@@ -28954,6 +29363,9 @@ msgstr ""
msgid "You can filter by 'days to merge' by clicking on the columns in the chart."
msgstr ""
+msgid "You can find more information about GitLab subscriptions in %{subscriptions_doc_link}."
+msgstr ""
+
msgid "You can generate an access token scoped to this project for each application to use the GitLab API."
msgstr ""
@@ -28978,6 +29390,9 @@ msgstr ""
msgid "You can notify the app / group or a project by sending them an email notification"
msgstr ""
+msgid "You can now close this window."
+msgstr ""
+
msgid "You can now export your security dashboard to a CSV report."
msgstr ""
@@ -29026,9 +29441,6 @@ msgstr ""
msgid "You can test your .gitlab-ci.yml in %{linkStart}CI Lint%{linkEnd}."
msgstr ""
-msgid "You can try again using %{begin_link}basic search%{end_link}"
-msgstr ""
-
msgid "You cannot access the raw file. Please wait a minute."
msgstr ""
@@ -29230,6 +29642,9 @@ msgstr ""
msgid "You reached %{usage_in_percent} of %{namespace_name}'s storage capacity (%{used_storage} of %{storage_limit})"
msgstr ""
+msgid "You successfully declined the invitation"
+msgstr ""
+
msgid "You tried to fork %{link_to_the_project} but it failed for the following reason:"
msgstr ""
@@ -29661,6 +30076,15 @@ msgstr ""
msgid "cannot be changed if a personal project has container registry tags."
msgstr ""
+msgid "cannot be changed if shared runners are enabled"
+msgstr ""
+
+msgid "cannot be enabled because parent group does not allow it"
+msgstr ""
+
+msgid "cannot be enabled because parent group has shared Runners disabled"
+msgstr ""
+
msgid "cannot be enabled unless all domains have TLS certificates"
msgstr ""
@@ -30042,6 +30466,9 @@ msgstr ""
msgid "failed to dismiss associated finding(id=%{finding_id}): %{message}"
msgstr ""
+msgid "failed to revert associated finding(id=%{finding_id}) to detected"
+msgstr ""
+
msgid "file"
msgid_plural "files"
msgstr[0] ""
@@ -30584,6 +31011,9 @@ msgstr ""
msgid "must be a root namespace"
msgstr ""
+msgid "must be a valid IPv4 or IPv6 address"
+msgstr ""
+
msgid "must be greater than start date"
msgstr ""
@@ -30620,6 +31050,9 @@ msgstr ""
msgid "no expiration"
msgstr ""
+msgid "no name set"
+msgstr ""
+
msgid "no one can merge"
msgstr ""
@@ -30924,9 +31357,6 @@ msgstr ""
msgid "toggle collapse"
msgstr ""
-msgid "toggle dropdown"
-msgstr ""
-
msgid "triggered"
msgstr ""
diff --git a/locale/unfound_translations.rb b/locale/unfound_translations.rb
index 6f7934b4d65..c76518b0f4f 100644
--- a/locale/unfound_translations.rb
+++ b/locale/unfound_translations.rb
@@ -16,3 +16,4 @@ N_('NotificationEvent|Merge merge request')
N_('NotificationEvent|Failed pipeline')
N_('NotificationEvent|Fixed pipeline')
N_('NotificationEvent|New release')
+N_('NotificationEvent|Change reviewer merge request')
diff --git a/package.json b/package.json
index ca76e4cab0b..402df614789 100644
--- a/package.json
+++ b/package.json
@@ -42,10 +42,11 @@
"@babel/plugin-syntax-import-meta": "^7.10.1",
"@babel/preset-env": "^7.10.1",
"@gitlab/at.js": "1.5.5",
- "@gitlab/svgs": "1.164.0",
- "@gitlab/ui": "21.3.1",
+ "@gitlab/svgs": "1.169.0",
+ "@gitlab/ui": "21.10.0",
"@gitlab/visual-review-tools": "1.6.1",
- "@rails/actioncable": "^6.0.3-1",
+ "@rails/actioncable": "^6.0.3-3",
+ "@rails/ujs": "^6.0.3-2",
"@sentry/browser": "^5.22.3",
"@sourcegraph/code-host-integration": "0.0.50",
"@toast-ui/editor": "^2.4.0",
@@ -92,14 +93,12 @@
"glob": "^7.1.6",
"graphql": "^14.7.0",
"graphql-tag": "^2.10.1",
- "gray-matter": "^4.0.2",
"immer": "^7.0.7",
"imports-loader": "^0.8.0",
"ipaddr.js": "^1.9.1",
"jed": "^1.1.1",
"jest-transform-graphql": "^2.1.0",
"jquery": "^3.5.0",
- "jquery-ujs": "1.2.2",
"jquery.caret": "^0.3.1",
"jquery.waitforimages": "^2.2.0",
"js-cookie": "^2.2.1",
@@ -145,11 +144,11 @@
"url-loader": "^3.0.0",
"uuid": "8.1.0",
"visibilityjs": "^1.2.4",
- "vue": "^2.6.10",
+ "vue": "^2.6.12",
"vue-apollo": "^3.0.3",
- "vue-loader": "^15.9.0",
- "vue-router": "^3.4.3",
- "vue-template-compiler": "^2.6.10",
+ "vue-loader": "^15.9.3",
+ "vue-router": "^3.4.5",
+ "vue-template-compiler": "^2.6.12",
"vue-virtual-scroll-list": "^1.4.4",
"vuedraggable": "^2.23.0",
"vuex": "^3.5.1",
@@ -205,7 +204,7 @@
"karma-webpack": "^4.0.2",
"markdownlint-cli": "0.23.2",
"md5": "^2.2.1",
- "node-sass": "^4.12.0",
+ "node-sass": "^4.14.1",
"nodemon": "^2.0.4",
"pixelmatch": "^4.0.2",
"postcss": "^7.0.14",
diff --git a/qa/Gemfile b/qa/Gemfile
index d946b22a0e0..f00f26a5482 100644
--- a/qa/Gemfile
+++ b/qa/Gemfile
@@ -1,7 +1,7 @@
source 'https://rubygems.org'
gem 'gitlab-qa'
-gem 'activesupport', '~> 6.0.3.1' # This should stay in sync with the root's Gemfile
+gem 'activesupport', '~> 6.0.3.3' # This should stay in sync with the root's Gemfile
gem 'capybara', '~> 3.29.0'
gem 'capybara-screenshot', '~> 1.0.23'
gem 'rake', '~> 12.3.3'
diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock
index 2356c90a0af..6cdedc3834d 100644
--- a/qa/Gemfile.lock
+++ b/qa/Gemfile.lock
@@ -2,7 +2,7 @@ GEM
remote: https://rubygems.org/
specs:
abstract_type (0.0.7)
- activesupport (6.0.3.1)
+ activesupport (6.0.3.3)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
minitest (~> 5.1)
@@ -38,7 +38,7 @@ GEM
concord (0.1.5)
adamantium (~> 0.2.0)
equalizer (~> 0.0.9)
- concurrent-ruby (1.1.6)
+ concurrent-ruby (1.1.7)
debase (0.2.4.1)
debase-ruby_core_source (>= 0.10.2)
debase-ruby_core_source (0.10.6)
@@ -52,7 +52,7 @@ GEM
http-accept (1.7.0)
http-cookie (1.0.3)
domain_name (~> 0.5)
- i18n (1.8.2)
+ i18n (1.8.5)
concurrent-ruby (~> 1.0)
ice_nine (0.11.2)
knapsack (1.17.1)
@@ -67,11 +67,11 @@ GEM
mime-types-data (3.2020.0425)
mini_mime (1.0.2)
mini_portile2 (2.4.0)
- minitest (5.14.1)
+ minitest (5.14.2)
netrc (0.11.0)
nokogiri (1.10.9)
mini_portile2 (~> 2.4.0)
- parallel (1.17.0)
+ parallel (1.19.2)
parallel_tests (2.29.0)
parallel
parser (2.7.1.4)
@@ -145,13 +145,13 @@ GEM
procto (~> 0.0.2)
xpath (3.2.0)
nokogiri (~> 1.8)
- zeitwerk (2.3.0)
+ zeitwerk (2.4.0)
PLATFORMS
ruby
DEPENDENCIES
- activesupport (~> 6.0.3.1)
+ activesupport (~> 6.0.3.3)
airborne (~> 0.3.4)
capybara (~> 3.29.0)
capybara-screenshot (~> 1.0.23)
diff --git a/qa/qa.rb b/qa/qa.rb
index f6e0ea5d615..d0edd1936e5 100644
--- a/qa/qa.rb
+++ b/qa/qa.rb
@@ -91,6 +91,7 @@ module QA
autoload :UserGPG, 'qa/resource/user_gpg'
autoload :Visibility, 'qa/resource/visibility'
autoload :ProjectSnippet, 'qa/resource/project_snippet'
+ autoload :Design, 'qa/resource/design'
module KubernetesCluster
autoload :Base, 'qa/resource/kubernetes_cluster/base'
@@ -260,6 +261,7 @@ module QA
module Pipeline
autoload :Index, 'qa/page/project/pipeline/index'
autoload :Show, 'qa/page/project/pipeline/show'
+ autoload :New, 'qa/page/project/pipeline/new'
end
module Tag
@@ -371,6 +373,7 @@ module QA
module Snippet
autoload :New, 'qa/page/project/snippet/new'
autoload :Show, 'qa/page/project/snippet/show'
+ autoload :Index, 'qa/page/project/snippet/index'
end
end
diff --git a/qa/qa/git/repository.rb b/qa/qa/git/repository.rb
index 035946b8471..512653e8c4a 100644
--- a/qa/qa/git/repository.rb
+++ b/qa/qa/git/repository.rb
@@ -59,6 +59,10 @@ module QA
self.username, self.password = default_credentials
end
+ def use_default_identity
+ configure_identity('GitLab QA', 'root@gitlab.com')
+ end
+
def clone(opts = '')
clone_result = run("git clone #{opts} #{uri} ./", max_attempts: 3)
return clone_result.response unless clone_result.success?
@@ -122,8 +126,12 @@ module QA
run("git rev-parse --abbrev-ref HEAD").to_s
end
- def push_changes(branch = 'master')
- run("git push #{uri} #{branch}", max_attempts: 3).to_s
+ def push_changes(branch = 'master', push_options: nil)
+ cmd = ['git push']
+ cmd << push_options_hash_to_string(push_options)
+ cmd << uri
+ cmd << branch
+ run(cmd.compact.join(' '), max_attempts: 3).to_s
end
def push_all_branches
@@ -205,6 +213,10 @@ module QA
run("cat #{file}").to_s
end
+ def delete_netrc
+ File.delete(netrc_file_path) if File.exist?(netrc_file_path)
+ end
+
private
attr_reader :uri, :username, :password, :known_hosts_file,
@@ -216,7 +228,7 @@ module QA
alias_method :to_s, :response
def success?
- exitstatus == 0
+ exitstatus == 0 && !response.include?('Error encountered')
end
end
@@ -293,6 +305,23 @@ module QA
@tmp_home_dir ||= File.join(Dir.tmpdir, "qa-netrc-credentials", $$.to_s)
end
+ def push_options_hash_to_string(opts)
+ return if opts.nil?
+
+ prefix = "-o merge_request"
+ opts.each_with_object([]) do |(key, value), options|
+ if value.is_a?(Array)
+ value.each do |item|
+ options << "#{prefix}.#{key}=\"#{item}\""
+ end
+ elsif value == true
+ options << "#{prefix}.#{key}"
+ else
+ options << "#{prefix}.#{key}=\"#{value}\""
+ end
+ end.join(' ')
+ end
+
def netrc_file_path
@netrc_file_path ||= File.join(tmp_home_dir, '.netrc')
end
diff --git a/qa/qa/page/component/design_management.rb b/qa/qa/page/component/design_management.rb
index a8a24bd3949..44d6b02ccd8 100644
--- a/qa/qa/page/component/design_management.rb
+++ b/qa/qa/page/component/design_management.rb
@@ -31,6 +31,16 @@ module QA
element :design_file_name
element :design_image
end
+
+ view 'app/assets/javascripts/design_management/pages/index.vue' do
+ element :archive_button
+ element :design_checkbox
+ element :design_dropzone_content
+ end
+
+ view 'app/assets/javascripts/design_management/components/delete_button.vue' do
+ element :confirm_archiving_button
+ end
end
end
@@ -52,12 +62,14 @@ module QA
# It accepts a `class:` option, but that only works for class attributes
# It doesn't work as a CSS selector.
# So instead we use the name attribute as a locator
- page.attach_file("design_file", design_file_path, make_visible: { display: 'block' })
+ within_element(:design_dropzone_content) do
+ page.attach_file("design_file", design_file_path, make_visible: { display: 'block' })
+ end
filename = ::File.basename(design_file_path)
found = wait_until(reload: false, sleep_interval: 1) do
- image = find_element(:design_image)
+ image = find_element(:design_image, filename: filename)
has_element?(:design_file_name, text: filename) &&
image["complete"] &&
@@ -71,11 +83,24 @@ module QA
click_element(:design_file_name, text: filename)
end
+ def select_design(filename)
+ click_element(:design_checkbox, design: filename)
+ end
+
+ def archive_selected_designs
+ click_element(:archive_button)
+ click_element(:confirm_archiving_button)
+ end
+
def has_annotation?(note)
within_element_by_index(:design_discussion_content, 0) do
has_element?(:note_content, text: note)
end
end
+
+ def has_design?(filename)
+ has_element?(:design_file_name, text: filename)
+ end
end
end
end
diff --git a/qa/qa/page/component/issuable/sidebar.rb b/qa/qa/page/component/issuable/sidebar.rb
index 4e94049efe7..82347ee209a 100644
--- a/qa/qa/page/component/issuable/sidebar.rb
+++ b/qa/qa/page/component/issuable/sidebar.rb
@@ -18,16 +18,29 @@ module QA
element :more_assignees_link
end
- base.view 'app/helpers/dropdowns_helper.rb' do
+ base.view 'app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue' do
+ element :labels_block
+ end
+
+ base.view 'app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue' do
+ element :selected_label_content
+ end
+
+ base.view 'app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue' do
+ element :labels_dropdown_content
+ end
+
+ base.view 'app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue' do
+ element :labels_edit_button
+ end
+
+ base.view 'app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue' do
element :dropdown_input_field
end
base.view 'app/views/shared/issuable/_sidebar.html.haml' do
element :assignee_block
- element :dropdown_menu_labels
- element :edit_labels_link
element :edit_milestone_link
- element :labels_block
element :milestone_block
element :milestone_link
end
@@ -64,7 +77,7 @@ module QA
def has_label?(label)
within_element(:labels_block) do
- !!has_element?(:label, label_name: label)
+ !!has_element?(:selected_label_content, label_name: label)
end
end
@@ -80,23 +93,25 @@ module QA
def select_labels_and_refresh(labels)
Support::Retrier.retry_until do
- click_element(:edit_labels_link)
- has_element?(:dropdown_menu_labels, text: labels.first)
+ click_element(:labels_edit_button)
+ has_element?(:labels_dropdown_content, text: labels.first)
end
labels.each do |label|
- within_element(:dropdown_menu_labels, text: label) do
+ within_element(:labels_dropdown_content) do
send_keys_to_element(:dropdown_input_field, [label, :enter])
end
end
- click_element(:edit_labels_link)
+ click_element(:labels_edit_button)
labels.each do |label|
has_element?(:labels_block, text: label, wait: 0)
end
refresh
+
+ wait_for_requests
end
def toggle_more_assignees_link
diff --git a/qa/qa/page/component/new_snippet.rb b/qa/qa/page/component/new_snippet.rb
index 3e5ae29177a..ff9ece9ad10 100644
--- a/qa/qa/page/component/new_snippet.rb
+++ b/qa/qa/page/component/new_snippet.rb
@@ -21,18 +21,11 @@ module QA
base.view 'app/assets/javascripts/snippets/components/snippet_blob_edit.vue' do
element :file_name_field
+ element :file_holder_container
end
- base.view 'app/views/shared/form_elements/_description.html.haml' do
- element :issuable_form_description
- end
-
- base.view 'app/views/shared/snippets/_form.html.haml' do
- element :snippet_description_field
- element :description_placeholder
- element :snippet_title_field
- element :file_name_field
- element :submit_button
+ base.view 'app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue' do
+ element :add_file_button
end
base.view 'app/views/shared/_zen.html.haml' do
@@ -54,12 +47,28 @@ module QA
choose visibility
end
- def fill_file_name(name)
- fill_element :file_name_field, name
+ def fill_file_name(name, file_number = nil)
+ if file_number
+ within_element_by_index(:file_holder_container, file_number - 1) do
+ fill_element(:file_name_field, name)
+ end
+ else
+ fill_element(:file_name_field, name)
+ end
+ end
+
+ def fill_file_content(content, file_number = nil)
+ if file_number
+ within_element_by_index(:file_holder_container, file_number - 1) do
+ text_area.set(content)
+ end
+ else
+ text_area.set content
+ end
end
- def fill_file_content(content)
- text_area.set content
+ def click_add_file
+ click_element(:add_file_button)
end
def click_create_snippet_button
diff --git a/qa/qa/page/component/note.rb b/qa/qa/page/component/note.rb
index 0e9cdd49519..e6defd2ec0c 100644
--- a/qa/qa/page/component/note.rb
+++ b/qa/qa/page/component/note.rb
@@ -9,9 +9,18 @@ module QA
def self.included(base)
super
+ base.view 'app/assets/javascripts/diffs/components/diff_file_header.vue' do
+ element :toggle_comments_button
+ end
+
+ base.view 'app/assets/javascripts/notes/components/discussion_actions.vue' do
+ element :discussion_reply_tab
+ element :resolve_discussion_button
+ end
+
base.view 'app/assets/javascripts/notes/components/comment_form.vue' do
element :note_dropdown
- element :discussion_option
+ element :discussion_menu_item
end
base.view 'app/assets/javascripts/notes/components/noteable_discussion.vue' do
@@ -23,39 +32,32 @@ module QA
end
base.view 'app/assets/javascripts/notes/components/note_form.vue' do
- element :reply_input
+ element :reply_field
element :reply_comment_button
end
- base.view 'app/assets/javascripts/notes/components/discussion_actions.vue' do
- element :discussion_reply_tab
- element :resolve_discussion_button
- end
-
base.view 'app/assets/javascripts/notes/components/toggle_replies_widget.vue' do
- element :expand_replies
- element :collapse_replies
+ element :expand_replies_button
+ element :collapse_replies_button
end
- base.view 'app/assets/javascripts/diffs/components/diff_file_header.vue' do
- element :toggle_comments_button
+ base.view 'app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue' do
+ element :skeleton_note_placeholder
end
end
- def start_discussion(text)
- fill_element :comment_input, text
- click_element :note_dropdown
- click_element :discussion_option
- click_element :comment_button
+ def collapse_replies
+ click_element :collapse_replies_button
end
- def toggle_comments(position)
- all_elements(:toggle_comments_button, minimum: position)[position - 1].click
+ def edit_comment(text)
+ click_element :note_edit_button
+ fill_element :reply_field, text
+ click_element :reply_comment_button
end
- def type_reply_to_discussion(position, reply_text)
- all_elements(:discussion_reply_tab, minimum: position)[position - 1].click
- fill_element :reply_input, reply_text
+ def expand_replies
+ click_element :expand_replies_button
end
def reply_to_discussion(position, reply_text)
@@ -69,18 +71,24 @@ module QA
end
end
- def collapse_replies
- click_element :collapse_replies
+ def start_discussion(text)
+ fill_element :comment_field, text
+ click_element :note_dropdown
+ click_element :discussion_menu_item
+ click_element :comment_button
end
- def expand_replies
- click_element :expand_replies
+ def toggle_comments(position)
+ all_elements(:toggle_comments_button, minimum: position)[position - 1].click
end
- def edit_comment(text)
- click_element :note_edit_button
- fill_element :reply_input, text
- click_element :reply_comment_button
+ def type_reply_to_discussion(position, reply_text)
+ all_elements(:discussion_reply_tab, minimum: position)[position - 1].click
+ fill_element :reply_field, reply_text
+ end
+
+ def wait_for_loading
+ has_no_element?(:skeleton_note_placeholer)
end
end
end
diff --git a/qa/qa/page/component/select2.rb b/qa/qa/page/component/select2.rb
index 761bbb17168..87aed0105aa 100644
--- a/qa/qa/page/component/select2.rb
+++ b/qa/qa/page/component/select2.rb
@@ -43,6 +43,8 @@ module QA
end
def wait_for_search_to_complete
+ Support::WaitForRequests.wait_for_requests
+
has_css?('.select2-active', wait: 1)
has_no_css?('.select2-active', wait: 30)
end
diff --git a/qa/qa/page/component/snippet.rb b/qa/qa/page/component/snippet.rb
index 2776b6c078e..7074d7e7649 100644
--- a/qa/qa/page/component/snippet.rb
+++ b/qa/qa/page/component/snippet.rb
@@ -98,15 +98,39 @@ module QA
end
end
- def has_file_name?(file_name)
- within_element(:file_title_content) do
- has_text?(file_name)
- end
- end
-
- def has_file_content?(file_content)
- within_element(:file_content) do
- has_text?(file_content)
+ def has_file_name?(file_name, file_number = nil)
+ if file_number
+ within_element_by_index(:file_title_content, file_number - 1) do
+ has_text?(file_name)
+ end
+ else
+ within_element(:file_title_content) do
+ has_text?(file_name)
+ end
+ end
+ end
+
+ def has_file_content?(file_content, file_number = nil)
+ if file_number
+ within_element_by_index(:file_content, file_number - 1) do
+ has_text?(file_content)
+ end
+ else
+ within_element(:file_content) do
+ has_text?(file_content)
+ end
+ end
+ end
+
+ def has_no_file_content?(file_content, file_number = nil)
+ if file_number
+ within_element_by_index(:file_content, file_number - 1) do
+ has_no_text?(file_content)
+ end
+ else
+ within_element(:file_content) do
+ has_no_text?(file_content)
+ end
end
end
diff --git a/qa/qa/page/dashboard/snippet/edit.rb b/qa/qa/page/dashboard/snippet/edit.rb
index 7802a680c25..31fc69a04cc 100644
--- a/qa/qa/page/dashboard/snippet/edit.rb
+++ b/qa/qa/page/dashboard/snippet/edit.rb
@@ -5,10 +5,6 @@ module QA
module Dashboard
module Snippet
class Edit < Page::Base
- view 'app/views/shared/snippets/_form.html.haml' do
- element :submit_button
- end
-
view 'app/assets/javascripts/snippets/components/edit.vue' do
element :submit_button
end
diff --git a/qa/qa/page/merge_request/show.rb b/qa/qa/page/merge_request/show.rb
index 0b80ba84fa4..164f25389c0 100644
--- a/qa/qa/page/merge_request/show.rb
+++ b/qa/qa/page/merge_request/show.rb
@@ -44,15 +44,23 @@ module QA
element :squash_checkbox
end
- view 'app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue' do
- element :skeleton_note
- end
-
view 'app/views/projects/merge_requests/show.html.haml' do
element :notes_tab
element :diffs_tab
end
+ view 'app/assets/javascripts/diffs/components/compare_dropdown_layout.vue' do
+ element :dropdown_content
+ end
+
+ view 'app/assets/javascripts/diffs/components/compare_versions.vue' do
+ element :target_version_dropdown
+ end
+
+ view 'app/assets/javascripts/diffs/components/diff_file_header.vue' do
+ element :file_name_content
+ end
+
view 'app/assets/javascripts/diffs/components/inline_diff_table_row.vue' do
element :new_diff_line
end
@@ -67,15 +75,13 @@ module QA
view 'app/assets/javascripts/batch_comments/components/review_bar.vue' do
element :review_bar
- element :discard_review
- element :modal_delete_pending_comments
end
view 'app/assets/javascripts/notes/components/note_form.vue' do
element :unresolve_review_discussion_checkbox
element :resolve_review_discussion_checkbox
- element :start_review
- element :comment_now
+ element :start_review_button
+ element :comment_now_button
end
view 'app/assets/javascripts/batch_comments/components/preview_dropdown.vue' do
@@ -83,46 +89,54 @@ module QA
end
def start_review
- click_element :start_review
+ click_element(:start_review_button)
# After clicking the button, wait for it to disappear
# before moving on to the next part of the test
- has_no_element? :start_review
+ has_no_element?(:start_review_button)
+ end
+
+ def click_target_version_dropdown
+ click_element(:target_version_dropdown)
end
def comment_now
- click_element :comment_now
+ click_element(:comment_now_button)
# After clicking the button, wait for it to disappear
# before moving on to the next part of the test
- has_no_element? :comment_now
+ has_no_element?(:comment_now_button)
+ end
+
+ def version_dropdown_content
+ find_element(:dropdown_content).text
end
def submit_pending_reviews
- within_element :review_bar do
- click_element :review_preview_toggle
- click_element :submit_review
+ within_element(:review_bar) do
+ click_element(:review_preview_toggle)
+ click_element(:submit_review)
# After clicking the button, wait for it to disappear
# before moving on to the next part of the test
- has_no_element? :submit_review
+ has_no_element?(:submit_review)
end
end
def discard_pending_reviews
- within_element :review_bar do
- click_element :discard_review
+ within_element(:review_bar) do
+ click_element(:discard_review)
end
- click_element :modal_delete_pending_comments
+ click_element(:modal_delete_pending_comments)
end
def resolve_review_discussion
- scroll_to_element :start_review
- check_element :resolve_review_discussion_checkbox
+ scroll_to_element(:start_review_button)
+ check_element(:resolve_review_discussion_checkbox)
end
def unresolve_review_discussion
- check_element :unresolve_review_discussion_checkbox
+ check_element(:unresolve_review_discussion_checkbox)
end
def add_comment_to_diff(text)
@@ -131,7 +145,7 @@ module QA
end
all_elements(:new_diff_line, minimum: 1).first.hover
click_element(:diff_comment)
- fill_element(:reply_input, text)
+ fill_element(:reply_field, text)
end
def click_discussions_tab
@@ -160,6 +174,10 @@ module QA
has_no_text?('Fast-forward merge is not possible')
end
+ def has_file?(file_name)
+ has_element?(:file_name_content, text: file_name)
+ end
+
def has_merge_button?
refresh
@@ -168,7 +186,7 @@ module QA
def has_pipeline_status?(text)
# Pipelines can be slow, so we wait a bit longer than the usual 10 seconds
- has_element?(:merge_request_pipeline_info_content, text: text, wait: 30)
+ has_element?(:merge_request_pipeline_info_content, text: text, wait: 60)
end
def has_title?(title)
@@ -190,7 +208,7 @@ module QA
!find_element(:squash_checkbox).disabled?
end
- click_element :squash_checkbox
+ click_element(:squash_checkbox)
end
def merge!
@@ -202,7 +220,7 @@ module QA
end
def merged?
- has_element?(:merged_status_content, text: 'The changes were merged into', wait: 30)
+ has_element?(:merged_status_content, text: 'The changes were merged into', wait: 60)
end
# Check if the MR is able to be merged
@@ -235,7 +253,7 @@ module QA
!find_element(:mr_rebase_button).disabled?
end
- click_element :mr_rebase_button
+ click_element(:mr_rebase_button)
success = wait_until do
has_text?('Fast-forward merge without a merge commit')
@@ -251,12 +269,12 @@ module QA
end
def view_email_patches
- click_element :download_dropdown
+ click_element(:download_dropdown)
visit_link_in_element(:download_email_patches)
end
def view_plain_diff
- click_element :download_dropdown
+ click_element(:download_dropdown)
visit_link_in_element(:download_plain_diff)
end
@@ -266,10 +284,6 @@ module QA
end
end
- def wait_for_loading
- has_no_element?(:skeleton_note)
- end
-
def click_open_in_web_ide
click_element(:open_in_web_ide_button)
wait_for_requests
diff --git a/qa/qa/page/profile/ssh_keys.rb b/qa/qa/page/profile/ssh_keys.rb
index 810877e21ad..d27e113832c 100644
--- a/qa/qa/page/profile/ssh_keys.rb
+++ b/qa/qa/page/profile/ssh_keys.rb
@@ -11,8 +11,9 @@ module QA
element :add_key_button
end
- view 'app/views/profiles/keys/_key_details.html.haml' do
- element :delete_key_button
+ view 'app/helpers/profiles_helper.rb' do
+ element :delete_ssh_key_button
+ element :ssh_key_delete_modal
end
view 'app/views/profiles/keys/_key_table.html.haml' do
@@ -38,8 +39,14 @@ module QA
def remove_key(title)
click_link(title)
- accept_alert do
- click_element(:delete_key_button)
+ click_element(:delete_ssh_key_button)
+
+ # Retrying due to https://gitlab.com/gitlab-org/gitlab/-/issues/255287
+ retry_on_exception do
+ wait_for_animated_element(:ssh_key_delete_modal)
+ within_element(:ssh_key_delete_modal) do
+ click_button('Delete')
+ end
end
end
diff --git a/qa/qa/page/project/issue/show.rb b/qa/qa/page/project/issue/show.rb
index 826acaa2e0a..a02617def9e 100644
--- a/qa/qa/page/project/issue/show.rb
+++ b/qa/qa/page/project/issue/show.rb
@@ -12,7 +12,7 @@ module QA
view 'app/assets/javascripts/notes/components/comment_form.vue' do
element :comment_button
- element :comment_input
+ element :comment_field
end
view 'app/assets/javascripts/notes/components/discussion_filter.vue' do
@@ -43,7 +43,7 @@ module QA
end
view 'app/assets/javascripts/related_issues/components/related_issuable_input.vue' do
- element :add_issue_input
+ element :add_issue_field
end
view 'app/assets/javascripts/related_issues/components/related_issues_block.vue' do
@@ -57,8 +57,8 @@ module QA
def relate_issue(issue)
click_element(:related_issues_plus_button)
- fill_element(:add_issue_input, issue.web_url)
- send_keys_to_element(:add_issue_input, :enter)
+ fill_element(:add_issue_field, issue.web_url)
+ send_keys_to_element(:add_issue_field, :enter)
end
def related_issuable_item
@@ -84,7 +84,7 @@ module QA
# attachment option should be an absolute path
def comment(text, attachment: nil, filter: :all_activities)
method("select_#{filter}_filter").call
- fill_element :comment_input, "#{text}\n"
+ fill_element :comment_field, "#{text}\n"
unless attachment.nil?
QA::Page::Component::Dropzone.new(self, '.new-note')
@@ -125,6 +125,8 @@ module QA
click_element(:title)
click_element :discussion_filter
find_element(:filter_options, text: text).click
+
+ wait_for_loading
end
end
end
diff --git a/qa/qa/page/project/operations/kubernetes/index.rb b/qa/qa/page/project/operations/kubernetes/index.rb
index 0c92f9a9f28..114e3ddd46a 100644
--- a/qa/qa/page/project/operations/kubernetes/index.rb
+++ b/qa/qa/page/project/operations/kubernetes/index.rb
@@ -7,11 +7,11 @@ module QA
module Kubernetes
class Index < Page::Base
view 'app/views/clusters/clusters/_empty_state.html.haml' do
- element :add_kubernetes_cluster_button, "link_to s_('ClusterIntegration|Add Kubernetes cluster')" # rubocop:disable QA/ElementWithPattern
+ element :add_kubernetes_cluster_button, "link_to s_('ClusterIntegration|Integrate with a cluster certificate')" # rubocop:disable QA/ElementWithPattern
end
def add_kubernetes_cluster
- click_on 'Add Kubernetes cluster'
+ click_on 'Connect cluster with certificate'
end
def has_cluster?(cluster)
diff --git a/qa/qa/page/project/packages/index.rb b/qa/qa/page/project/packages/index.rb
index 6d55d1d04b6..396d3373b8a 100644
--- a/qa/qa/page/project/packages/index.rb
+++ b/qa/qa/page/project/packages/index.rb
@@ -26,3 +26,5 @@ module QA
end
end
end
+
+QA::Page::Project::Packages::Index.prepend_if_ee('QA::EE::Page::Project::Packages::Index')
diff --git a/qa/qa/page/project/pipeline/index.rb b/qa/qa/page/project/pipeline/index.rb
index aa2ef2f058f..b2ecbb2e988 100644
--- a/qa/qa/page/project/pipeline/index.rb
+++ b/qa/qa/page/project/pipeline/index.rb
@@ -14,6 +14,10 @@ module QA
element :pipeline_retry_button
end
+ view 'app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue' do
+ element :run_pipeline_button
+ end
+
def click_on_latest_pipeline
all_elements(:pipeline_url_link, minimum: 1, wait: QA::Support::Repeater::DEFAULT_MAX_WAIT_TIME).first.click
end
@@ -40,6 +44,14 @@ module QA
wait_for_latest_pipeline_success
end
end
+
+ def has_pipeline?
+ has_element? :pipeline_url_link
+ end
+
+ def click_run_pipeline_button
+ click_element :run_pipeline_button, Page::Project::Pipeline::New
+ end
end
end
end
diff --git a/qa/qa/page/project/pipeline/new.rb b/qa/qa/page/project/pipeline/new.rb
new file mode 100644
index 00000000000..644a21b46e9
--- /dev/null
+++ b/qa/qa/page/project/pipeline/new.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module QA
+ module Page
+ module Project
+ module Pipeline
+ class New < QA::Page::Base
+ view 'app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue' do
+ element :run_pipeline_button, required: true
+ end
+
+ def click_run_pipeline_button
+ click_element :run_pipeline_button
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/project/show.rb b/qa/qa/page/project/show.rb
index c607b35005e..d81be2803bd 100644
--- a/qa/qa/page/project/show.rb
+++ b/qa/qa/page/project/show.rb
@@ -103,6 +103,8 @@ module QA
end
def click_commit(commit_msg)
+ wait_for_requests
+
within_element(:file_tree_table) do
click_on commit_msg
end
diff --git a/qa/qa/page/project/snippet/index.rb b/qa/qa/page/project/snippet/index.rb
new file mode 100644
index 00000000000..a221abc4196
--- /dev/null
+++ b/qa/qa/page/project/snippet/index.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module QA
+ module Page
+ module Project
+ module Snippet
+ class Index < Page::Base
+ include Page::Component::Snippet
+
+ view 'app/views/shared/snippets/_snippet.html.haml' do
+ element :snippet_link
+ end
+
+ def has_project_snippet?(title)
+ has_element?(:snippet_link, snippet_title: title)
+ end
+
+ def click_snippet_link(title)
+ within_element(:snippet_link, text: title) do
+ click_link(title)
+ end
+ end
+ end
+ end
+ end
+ end
+end
+
+QA::Page::Project::Snippet::Index.prepend_if_ee('QA::EE::Page::Project::Snippet::Index')
diff --git a/qa/qa/resource/api_fabricator.rb b/qa/qa/resource/api_fabricator.rb
index d0cbab70983..034feb4e90f 100644
--- a/qa/qa/resource/api_fabricator.rb
+++ b/qa/qa/resource/api_fabricator.rb
@@ -96,15 +96,38 @@ module QA
end
def api_post
- response = post(
- Runtime::API::Request.new(api_client, api_post_path).url,
- api_post_body)
+ if api_post_path == "/graphql"
+ graphql_response = post(
+ Runtime::API::Request.new(api_client, api_post_path).url,
+ query: api_post_body)
- unless response.code == HTTP_STATUS_CREATED
- raise ResourceFabricationFailedError, "Fabrication of #{self.class.name} using the API failed (#{response.code}) with `#{response}`."
+ flattened_response = flatten_hash(parse_body(graphql_response))
+
+ unless graphql_response.code == HTTP_STATUS_OK && flattened_response[:errors].empty?
+ raise ResourceFabricationFailedError, "Fabrication of #{self.class.name} using the API failed (#{graphql_response.code}) with `#{graphql_response}`."
+ end
+
+ flattened_response[:web_url] = flattened_response.delete(:webUrl)
+ flattened_response[:id] = flattened_response.fetch(:id).split('/')[-1]
+
+ process_api_response(flattened_response)
+ else
+ response = post(
+ Runtime::API::Request.new(api_client, api_post_path).url,
+ api_post_body)
+
+ unless response.code == HTTP_STATUS_CREATED
+ raise ResourceFabricationFailedError, "Fabrication of #{self.class.name} using the API failed (#{response.code}) with `#{response}`."
+ end
+
+ process_api_response(parse_body(response))
end
+ end
- process_api_response(parse_body(response))
+ def flatten_hash(param)
+ param.each_pair.reduce({}) do |a, (k, v)|
+ v.is_a?(Hash) ? a.merge(flatten_hash(v)) : a.merge(k.to_sym => v)
+ end
end
def api_delete
diff --git a/qa/qa/resource/design.rb b/qa/qa/resource/design.rb
new file mode 100644
index 00000000000..10d4f32d49d
--- /dev/null
+++ b/qa/qa/resource/design.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module QA
+ module Resource
+ class Design < Base
+ attribute :issue do
+ Issue.fabricate_via_api!
+ end
+
+ attribute :filepath do
+ ::File.absolute_path(::File.join('spec', 'fixtures', @filename))
+ end
+
+ attribute :id
+ attribute :filename
+
+ def initialize
+ @filename = 'banana_sample.gif'
+ end
+
+ # TODO This will be replaced as soon as file uploads over GraphQL are implemented
+ def fabricate!
+ issue.visit!
+
+ Page::Project::Issue::Show.perform do |issue|
+ issue.add_design(filepath)
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/resource/events/base.rb b/qa/qa/resource/events/base.rb
index 91ec0e59e37..4c5f54825b3 100644
--- a/qa/qa/resource/events/base.rb
+++ b/qa/qa/resource/events/base.rb
@@ -9,9 +9,12 @@ module QA
EventNotFoundError = Class.new(RuntimeError)
module Base
- def events(action: nil)
+ def events(action: nil, target_type: nil)
+ query = []
+ query << "action=#{CGI.escape(action)}" if action
+ query << "target_type=#{CGI.escape(target_type)}" if target_type
path = [api_get_events]
- path << "?action=#{CGI.escape(action)}" if action
+ path << "?#{query.join("&")}" unless query.empty?
parse_body(api_get_from("#{path.join}"))
end
diff --git a/qa/qa/resource/events/project.rb b/qa/qa/resource/events/project.rb
index 99c78254f42..8c97f66c663 100644
--- a/qa/qa/resource/events/project.rb
+++ b/qa/qa/resource/events/project.rb
@@ -6,6 +6,13 @@ module QA
module Project
include Events::Base
+ def wait_for_merge(title)
+ QA::Runtime::Logger.debug(%Q[#{self.class.name} - wait_for_merge with title "#{title}"])
+ wait_for_event do
+ events(action: 'accepted', target_type: 'merge_request').any? { |event| event[:target_title] == title }
+ end
+ end
+
def wait_for_push(commit_message)
QA::Runtime::Logger.debug(%Q[#{self.class.name} - wait_for_push with commit message "#{commit_message}"])
wait_for_event do
diff --git a/qa/qa/resource/merge_request.rb b/qa/qa/resource/merge_request.rb
index 6c0f4621dd9..dca8fb6dc6b 100644
--- a/qa/qa/resource/merge_request.rb
+++ b/qa/qa/resource/merge_request.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'securerandom'
+require 'active_support/core_ext/object/blank'
module QA
module Resource
@@ -17,7 +18,12 @@ module QA
:labels,
:file_name,
:file_content
- attr_writer :no_preparation
+ attr_writer :no_preparation,
+ :wait_for_merge
+
+ attribute :merge_when_pipeline_succeeds
+ attribute :merge_status
+ attribute :state
attribute :project do
Project.fabricate! do |resource|
@@ -58,6 +64,7 @@ module QA
@file_content = "File Added"
@target_new_branch = true
@no_preparation = false
+ @wait_for_merge = true
end
def fabricate!
@@ -80,10 +87,17 @@ module QA
end
def fabricate_via_api!
+ resource_web_url(api_get)
+ rescue ResourceNotFoundError
populate(:target, :source) unless @no_preparation
+
super
end
+ def api_merge_path
+ "/projects/#{project.id}/merge_requests/#{id}/merge"
+ end
+
def api_get_path
"/projects/#{project.id}/merge_requests/#{id}"
end
@@ -100,6 +114,36 @@ module QA
title: @title
}
end
+
+ def merge_via_api!
+ Support::Waiter.wait_until(sleep_interval: 1) do
+ QA::Runtime::Logger.debug("Waiting until merge request with id '#{id}' can be merged")
+
+ reload!.api_resource[:merge_status] == 'can_be_merged'
+ end
+
+ Support::Retrier.retry_on_exception do
+ response = put(Runtime::API::Request.new(api_client, api_merge_path).url)
+
+ unless response.code == HTTP_STATUS_OK
+ raise ResourceUpdateFailedError, "Could not merge. Request returned (#{response.code}): `#{response}`."
+ end
+
+ result = parse_body(response)
+
+ project.wait_for_merge(result[:title]) if @wait_for_merge
+
+ result
+ end
+ end
+
+ private
+
+ def transform_api_resource(api_resource)
+ raise ResourceNotFoundError if api_resource.blank?
+
+ super(api_resource)
+ end
end
end
end
diff --git a/qa/qa/resource/project.rb b/qa/qa/resource/project.rb
index 0025ebb2fd5..163c0b40bb5 100644
--- a/qa/qa/resource/project.rb
+++ b/qa/qa/resource/project.rb
@@ -110,6 +110,10 @@ module QA
response.any? { |file| file[:path] == file_path }
end
+ def has_branch?(branch)
+ has_branches?(Array(branch))
+ end
+
def has_branches?(branches)
branches.all? do |branch|
response = get(Runtime::API::Request.new(api_client, "#{api_repository_branches_path}/#{branch}").url)
@@ -140,6 +144,10 @@ module QA
"#{api_get_path}/members"
end
+ def api_merge_requests_path
+ "#{api_get_path}/merge_requests"
+ end
+
def api_runners_path
"#{api_get_path}/runners"
end
@@ -223,6 +231,14 @@ module QA
result[:import_status]
end
+ def merge_requests
+ parse_body(get(Runtime::API::Request.new(api_client, api_merge_requests_path).url))
+ end
+
+ def merge_request_with_title(title)
+ merge_requests.find { |mr| mr[:title] == title }
+ end
+
def runners(tag_list: nil)
response = if tag_list
get Runtime::API::Request.new(api_client, "#{api_runners_path}?tag_list=#{tag_list.compact.join(',')}").url
diff --git a/qa/qa/resource/repository/push.rb b/qa/qa/resource/repository/push.rb
index 1e5399fcc59..5266f8b9bea 100644
--- a/qa/qa/resource/repository/push.rb
+++ b/qa/qa/resource/repository/push.rb
@@ -11,7 +11,7 @@ module QA
:branch_name, :new_branch, :output, :repository_http_uri,
:repository_ssh_uri, :ssh_key, :user, :use_lfs, :tag_name
- attr_writer :remote_branch, :gpg_key_id
+ attr_writer :remote_branch, :gpg_key_id, :merge_request_push_options
def initialize
@file_name = "file-#{SecureRandom.hex(8)}.txt"
@@ -24,6 +24,7 @@ module QA
@use_lfs = false
@tag_name = nil
@gpg_key_id = nil
+ @merge_request_push_options = nil
end
def remote_branch
@@ -95,7 +96,7 @@ module QA
end
@output += commit_to repository
- @output += repository.push_changes("#{branch_name}:#{remote_branch}")
+ @output += repository.push_changes("#{branch_name}:#{remote_branch}", push_options: @merge_request_push_options)
end
repository.delete_ssh_key
diff --git a/qa/qa/resource/snippet.rb b/qa/qa/resource/snippet.rb
index 39be5e5cb7d..c4ea6447209 100644
--- a/qa/qa/resource/snippet.rb
+++ b/qa/qa/resource/snippet.rb
@@ -11,6 +11,11 @@ module QA
@visibility = 'Public'
@file_content = 'The snippet content'
@file_name = 'New snippet file name'
+ @files = []
+ end
+
+ def add_files
+ yield @files
end
def fabricate!
@@ -22,6 +27,12 @@ module QA
new_page.set_visibility(@visibility)
new_page.fill_file_name(@file_name)
new_page.fill_file_content(@file_content)
+
+ @files.each.with_index(2) do |file, i|
+ new_page.click_add_file
+ new_page.fill_file_name(file[:name], i)
+ new_page.fill_file_content(file[:content], i)
+ end
new_page.click_create_snippet_button
end
end
diff --git a/qa/qa/resource/ssh_key.rb b/qa/qa/resource/ssh_key.rb
index d4e394954ce..fcd0a479fec 100644
--- a/qa/qa/resource/ssh_key.rb
+++ b/qa/qa/resource/ssh_key.rb
@@ -76,6 +76,15 @@ module QA
parse_body(response)[:title].include?(title)
end
end
+
+ private
+
+ def api_get
+ with_paginated_response_body(Runtime::API::Request.new(api_client, '/user/keys', per_page: '100').url) do |page|
+ key = page.find { |key| key[:title] == title }
+ break process_api_response(key) if key
+ end
+ end
end
end
end
diff --git a/qa/qa/runtime/api/request.rb b/qa/qa/runtime/api/request.rb
index 724b499d32f..b58be354103 100644
--- a/qa/qa/runtime/api/request.rb
+++ b/qa/qa/runtime/api/request.rb
@@ -34,7 +34,11 @@ module QA
#
# Returns the relative path to the requested API resource
def request_path(path, version: API_VERSION, **query_string)
- full_path = ::File.join('/api', version, path)
+ full_path = if path == '/graphql'
+ ::File.join('/api', path)
+ else
+ ::File.join('/api', version, path)
+ end
if query_string.any?
full_path << (path.include?('?') ? '&' : '?')
diff --git a/qa/qa/runtime/feature.rb b/qa/qa/runtime/feature.rb
index 579c2293c51..a48bc216ac2 100644
--- a/qa/qa/runtime/feature.rb
+++ b/qa/qa/runtime/feature.rb
@@ -1,98 +1,125 @@
# frozen_string_literal: true
+require 'active_support/core_ext/object/blank'
+
module QA
module Runtime
- module Feature
- extend self
- extend Support::Api
+ class Feature
+ class << self
+ # Documentation: https://docs.gitlab.com/ee/api/features.html
- SetFeatureError = Class.new(RuntimeError)
- AuthorizationError = Class.new(RuntimeError)
+ include Support::Api
- def enable(key)
- QA::Runtime::Logger.info("Enabling feature: #{key}")
- set_feature(key, true)
- end
+ SetFeatureError = Class.new(RuntimeError)
+ AuthorizationError = Class.new(RuntimeError)
+ UnknownScopeError = Class.new(RuntimeError)
- def disable(key)
- QA::Runtime::Logger.info("Disabling feature: #{key}")
- set_feature(key, false)
- end
-
- def remove(key)
- request = Runtime::API::Request.new(api_client, "/features/#{key}")
- response = delete(request.url)
- unless response.code == QA::Support::Api::HTTP_STATUS_NO_CONTENT
- raise SetFeatureError, "Deleting feature flag #{key} failed with `#{response}`."
+ def remove(key)
+ request = Runtime::API::Request.new(api_client, "/features/#{key}")
+ response = delete(request.url)
+ unless response.code == QA::Support::Api::HTTP_STATUS_NO_CONTENT
+ raise SetFeatureError, "Deleting feature flag #{key} failed with `#{response}`."
+ end
end
- end
-
- def enable_and_verify(key)
- set_and_verify(key, enable: true)
- end
- def disable_and_verify(key)
- set_and_verify(key, enable: false)
- end
+ def enable(key, **scopes)
+ set_and_verify(key, enable: true, **scopes)
+ end
- def enabled?(key)
- feature = JSON.parse(get_features).find { |flag| flag["name"] == key }
- feature && feature["state"] == "on"
- end
+ def disable(key, **scopes)
+ set_and_verify(key, enable: false, **scopes)
+ end
- def get_features
- request = Runtime::API::Request.new(api_client, "/features")
- response = get(request.url)
- response.body
- end
+ def enabled?(key, **scopes)
+ feature = JSON.parse(get_features).find { |flag| flag['name'] == key.to_s }
+ feature && feature['state'] == 'on' || feature['state'] == 'conditional' && scopes.present? && enabled_scope?(feature['gates'], scopes)
+ end
- private
+ private
- def api_client
- @api_client ||= begin
- if Runtime::Env.admin_personal_access_token
- Runtime::API::Client.new(:gitlab, personal_access_token: Runtime::Env.admin_personal_access_token)
- else
- user = Resource::User.fabricate_via_api! do |user|
- user.username = Runtime::User.admin_username
- user.password = Runtime::User.admin_password
- end
+ def api_client
+ @api_client ||= Runtime::API::Client.as_admin
+ rescue Runtime::API::Client::AuthorizationError => e
+ raise AuthorizationError, "Administrator access is required to enable/disable feature flags. #{e.message}"
+ end
- unless user.admin?
- raise AuthorizationError, "Administrator access is required to enable/disable feature flags. User '#{user.username}' is not an administrator."
+ def enabled_scope?(gates, scopes)
+ scopes.each do |key, value|
+ case key
+ when :project, :group, :user
+ actors = gates.filter { |i| i['key'] == 'actors' }.first['value']
+ break actors.include?("#{key.to_s.capitalize}:#{value.id}")
+ when :feature_group
+ groups = gates.filter { |i| i['key'] == 'groups' }.first['value']
+ break groups.include?(value)
+ else
+ raise UnknownScopeError, "Unknown scope: #{key}"
end
-
- Runtime::API::Client.new(:gitlab, user: user)
end
end
- end
- # Change a feature flag and verify that the change was successful
- # Arguments:
- # key: The feature flag to set (as a string)
- # enable: `true` to enable the flag, `false` to disable it
- def set_and_verify(key, enable:)
- Support::Retrier.retry_on_exception(sleep_interval: 2) do
- enable ? enable(key) : disable(key)
+ def get_features
+ request = Runtime::API::Request.new(api_client, '/features')
+ response = get(request.url)
+ response.body
+ end
- is_enabled = nil
+ # Change a feature flag and verify that the change was successful
+ # Arguments:
+ # key: The feature flag to set (as a string)
+ # enable: `true` to enable the flag, `false` to disable it
+ # scopes: Any scope (user, project, group) to restrict the change to
+ def set_and_verify(key, enable:, **scopes)
+ msg = "#{enable ? 'En' : 'Dis'}abling feature: #{key}"
+ msg += " for scope \"#{scopes_to_s(scopes)}\"" if scopes.present?
+ QA::Runtime::Logger.info(msg)
- QA::Support::Waiter.wait_until(sleep_interval: 1) do
- is_enabled = enabled?(key)
- is_enabled == enable
- end
+ Support::Retrier.retry_on_exception(sleep_interval: 2) do
+ set_feature(key, enable, scopes)
+
+ is_enabled = nil
+
+ QA::Support::Waiter.wait_until(sleep_interval: 1) do
+ is_enabled = enabled?(key, scopes)
+ is_enabled == enable || !enable && scopes.present?
+ end
+
+ if is_enabled == enable
+ QA::Runtime::Logger.info("Successfully #{enable ? 'en' : 'dis'}abled and verified feature flag: #{key}")
+ else
+ raise SetFeatureError, "#{key} was not #{enable ? 'en' : 'dis'}abled!" if enable
- raise SetFeatureError, "#{key} was not #{enable ? 'enabled' : 'disabled'}!" unless is_enabled == enable
+ QA::Runtime::Logger.warn("Feature flag scope was removed but the flag is still enabled globally.")
+ end
+ end
+ end
- QA::Runtime::Logger.info("Successfully #{enable ? 'enabled' : 'disabled'} and verified feature flag: #{key}")
+ def set_feature(key, value, **scopes)
+ scopes[:project] = scopes[:project].full_path if scopes.key?(:project)
+ scopes[:group] = scopes[:group].full_path if scopes.key?(:group)
+ scopes[:user] = scopes[:user].username if scopes.key?(:user)
+ request = Runtime::API::Request.new(api_client, "/features/#{key}")
+ response = post(request.url, scopes.merge({ value: value }))
+ unless response.code == QA::Support::Api::HTTP_STATUS_CREATED
+ raise SetFeatureError, "Setting feature flag #{key} to #{value} failed with `#{response}`."
+ end
end
- end
- def set_feature(key, value)
- request = Runtime::API::Request.new(api_client, "/features/#{key}")
- response = post(request.url, { value: value })
- unless response.code == QA::Support::Api::HTTP_STATUS_CREATED
- raise SetFeatureError, "Setting feature flag #{key} to #{value} failed with `#{response}`."
+ def scopes_to_s(**scopes)
+ key = scopes.each_key.first
+ s = "#{key}: "
+ case key
+ when :project, :group
+ s += scopes[key].full_path
+ when :user
+ s += scopes[key].username
+ when :feature_group
+ s += scopes[key]
+ else
+ raise UnknownScopeError, "Unknown scope: #{key}"
+ end
+
+ s
end
end
end
diff --git a/qa/qa/specs/features/api/3_create/gitaly/distributed_reads_spec.rb b/qa/qa/specs/features/api/3_create/gitaly/distributed_reads_spec.rb
index 29f131ac322..c3cb503ed3f 100644
--- a/qa/qa/specs/features/api/3_create/gitaly/distributed_reads_spec.rb
+++ b/qa/qa/specs/features/api/3_create/gitaly/distributed_reads_spec.rb
@@ -17,12 +17,12 @@ module QA
end
before do
- Runtime::Feature.enable_and_verify('gitaly_distributed_reads')
+ Runtime::Feature.enable(:gitaly_distributed_reads)
praefect_manager.wait_for_replication(project.id)
end
after do
- Runtime::Feature.disable_and_verify('gitaly_distributed_reads')
+ Runtime::Feature.disable(:gitaly_distributed_reads)
end
it 'reads from each node', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/979' do
diff --git a/qa/qa/specs/features/api/3_create/merge_request/push_options_labels_spec.rb b/qa/qa/specs/features/api/3_create/merge_request/push_options_labels_spec.rb
new file mode 100644
index 00000000000..82a06780830
--- /dev/null
+++ b/qa/qa/specs/features/api/3_create/merge_request/push_options_labels_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+module QA
+ RSpec.describe 'Create' do
+ describe 'Merge request push options' do
+ # If run locally on GDK, push options need to be enabled on the host with the following command:
+ #
+ # git config --global receive.advertisepushoptions true
+
+ branch = "push-options-test-#{SecureRandom.hex(8)}"
+ title = "MR push options test #{SecureRandom.hex(8)}"
+ commit_message = 'Add README.md'
+
+ project = Resource::Project.fabricate_via_api! do |project|
+ project.name = 'merge-request-push-options'
+ project.initialize_with_readme = true
+ end
+
+ it 'sets labels', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1032' do
+ Resource::Repository::ProjectPush.fabricate! do |push|
+ push.project = project
+ push.commit_message = commit_message
+ push.branch_name = branch
+ push.merge_request_push_options = {
+ create: true,
+ title: title,
+ label: %w[one two three]
+ }
+ end
+
+ merge_request = project.merge_request_with_title(title)
+
+ expect(merge_request).not_to be_nil, "There was a problem creating the merge request"
+ expect(merge_request[:labels]).to include('one').and include('two').and include('three')
+ end
+
+ context 'when labels are set already' do
+ it 'removes them', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1033' do
+ Resource::Repository::ProjectPush.fabricate! do |push|
+ push.project = project
+ push.file_content = "Unlabel test #{SecureRandom.hex(8)}"
+ push.commit_message = commit_message
+ push.branch_name = branch
+ push.new_branch = false
+ push.merge_request_push_options = {
+ title: title,
+ unlabel: %w[one three]
+ }
+ end
+
+ merge_request = project.merge_request_with_title(title)
+
+ expect(merge_request).not_to be_nil, "There was a problem creating the merge request"
+
+ aggregate_failures do
+ expect(merge_request[:labels]).to include('two')
+ expect(merge_request[:labels]).not_to include('one')
+ expect(merge_request[:labels]).not_to include('three')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/api/3_create/merge_request/push_options_mwps_spec.rb b/qa/qa/specs/features/api/3_create/merge_request/push_options_mwps_spec.rb
new file mode 100644
index 00000000000..dde4708874d
--- /dev/null
+++ b/qa/qa/specs/features/api/3_create/merge_request/push_options_mwps_spec.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+module QA
+ RSpec.describe 'Create' do
+ describe 'Merge request push options' do
+ # If run locally on GDK, push options need to be enabled on the host with the following command:
+ #
+ # git config --global receive.advertisepushoptions true
+
+ let(:branch) { "push-options-test-#{SecureRandom.hex(8)}" }
+ let(:title) { "MR push options test #{SecureRandom.hex(8)}" }
+
+ let(:project) do
+ Resource::Project.fabricate_via_api! do |project|
+ project.name = 'merge-request-push-options'
+ project.initialize_with_readme = true
+ end
+ end
+
+ let!(:runner) do
+ Resource::Runner.fabricate! do |runner|
+ runner.project = project
+ runner.name = "runner-for-#{project.name}"
+ runner.tags = ["runner-for-#{project.name}"]
+ end
+ end
+
+ after do
+ runner.remove_via_api!
+ end
+
+ it 'sets merge when pipeline succeeds', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1037' do
+ Resource::Repository::Commit.fabricate_via_api! do |commit|
+ commit.project = project
+ commit.commit_message = 'Add .gitlab-ci.yml'
+ commit.add_files(
+ [
+ {
+ file_path: '.gitlab-ci.yml',
+ content: <<~YAML
+ no-op:
+ tags:
+ - "runner-for-#{project.name}"
+ script: sleep 999 # Leave the pipeline pending
+ YAML
+ }
+ ]
+ )
+ end
+
+ Resource::Repository::ProjectPush.fabricate! do |push|
+ push.project = project
+ push.branch_name = branch
+ push.merge_request_push_options = {
+ create: true,
+ merge_when_pipeline_succeeds: true,
+ title: title
+ }
+ end
+
+ merge_request = project.merge_request_with_title(title)
+
+ expect(merge_request).not_to be_nil, "There was a problem creating the merge request"
+
+ merge_request = Resource::MergeRequest.fabricate_via_api! do |mr|
+ mr.project = project
+ mr.id = merge_request[:iid]
+ end
+
+ expect(merge_request.state).to eq('opened')
+ expect(merge_request.merge_status).to eq('checking')
+ expect(merge_request.merge_when_pipeline_succeeds).to be true
+ end
+
+ it 'merges when pipeline succeeds', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1036' do
+ Resource::Repository::Commit.fabricate_via_api! do |commit|
+ commit.project = project
+ commit.commit_message = 'Add .gitlab-ci.yml'
+ commit.add_files(
+ [
+ {
+ file_path: '.gitlab-ci.yml',
+ content: <<~YAML
+ no-op:
+ tags:
+ - "runner-for-#{project.name}"
+ script: echo 'OK'
+ YAML
+ }
+ ]
+ )
+ end
+
+ Resource::Repository::ProjectPush.fabricate! do |push|
+ push.project = project
+ push.branch_name = branch
+ push.merge_request_push_options = {
+ create: true,
+ merge_when_pipeline_succeeds: true,
+ title: title
+ }
+ end
+
+ merge_request = project.merge_request_with_title(title)
+
+ expect(merge_request).not_to be_nil, "There was a problem creating the merge request"
+ expect(merge_request[:merge_when_pipeline_succeeds]).to be true
+
+ merge_request = Support::Waiter.wait_until(sleep_interval: 5) do
+ mr = Resource::MergeRequest.fabricate_via_api! do |mr|
+ mr.project = project
+ mr.id = merge_request[:iid]
+ end
+
+ next unless mr.state == 'merged'
+
+ mr
+ end
+
+ expect(merge_request.state).to eq('merged')
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/api/3_create/merge_request/push_options_remove_source_branch_spec.rb b/qa/qa/specs/features/api/3_create/merge_request/push_options_remove_source_branch_spec.rb
new file mode 100644
index 00000000000..d6bd668fa8a
--- /dev/null
+++ b/qa/qa/specs/features/api/3_create/merge_request/push_options_remove_source_branch_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module QA
+ RSpec.describe 'Create' do
+ describe 'Merge request push options' do
+ # If run locally on GDK, push options need to be enabled on the host with the following command:
+ #
+ # git config --global receive.advertisepushoptions true
+
+ let(:branch) { "push-options-test-#{SecureRandom.hex(8)}" }
+ let(:title) { "MR push options test #{SecureRandom.hex(8)}" }
+
+ let(:project) do
+ Resource::Project.fabricate_via_api! do |project|
+ project.name = 'merge-request-push-options'
+ project.initialize_with_readme = true
+ end
+ end
+
+ it 'removes the source branch', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1035' do
+ Resource::Repository::ProjectPush.fabricate! do |push|
+ push.project = project
+ push.branch_name = branch
+ push.merge_request_push_options = {
+ create: true,
+ remove_source_branch: true,
+ title: title
+ }
+ end
+
+ merge_request = project.merge_request_with_title(title)
+
+ expect(merge_request).not_to be_nil, "There was a problem creating the merge request"
+
+ merge_request = Resource::MergeRequest.fabricate_via_api! do |mr|
+ mr.project = project
+ mr.id = merge_request[:iid]
+ end.merge_via_api!
+
+ expect(merge_request[:state]).to eq('merged')
+ expect(project).not_to have_branch(branch)
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/api/3_create/merge_request/push_options_target_branch_spec.rb b/qa/qa/specs/features/api/3_create/merge_request/push_options_target_branch_spec.rb
new file mode 100644
index 00000000000..6072fd8c1a2
--- /dev/null
+++ b/qa/qa/specs/features/api/3_create/merge_request/push_options_target_branch_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module QA
+ RSpec.describe 'Create' do
+ describe 'Merge request push options' do
+ # If run locally on GDK, push options need to be enabled on the host with the following command:
+ #
+ # git config --global receive.advertisepushoptions true
+
+ let(:title) { "MR push options test #{SecureRandom.hex(8)}" }
+
+ let(:project) do
+ Resource::Project.fabricate_via_api! do |project|
+ project.name = 'merge-request-push-options'
+ project.initialize_with_readme = true
+ end
+ end
+
+ it 'sets a target branch', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1034' do
+ target_branch = "push-options-test-target-#{SecureRandom.hex(8)}"
+
+ Resource::Repository::ProjectPush.fabricate! do |push|
+ push.project = project
+ push.branch_name = target_branch
+ push.file_content = "Target branch test target branch #{SecureRandom.hex(8)}"
+ end
+
+ Resource::Repository::ProjectPush.fabricate! do |push|
+ push.project = project
+ push.branch_name = "push-options-test-#{SecureRandom.hex(8)}"
+ push.file_content = "Target branch test source branch #{SecureRandom.hex(8)}"
+ push.merge_request_push_options = {
+ create: true,
+ title: title,
+ target: target_branch
+ }
+ end
+
+ merge_request = project.merge_request_with_title(title)
+
+ expect(merge_request).not_to be_nil, "There was a problem creating the merge request"
+ expect(merge_request[:target_branch]).to eq(target_branch)
+
+ merge_request = Resource::MergeRequest.fabricate_via_api! do |mr|
+ mr.project = project
+ mr.id = merge_request[:iid]
+ end.merge_via_api!
+
+ expect(merge_request[:state]).to eq('merged')
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/api/3_create/merge_request/push_options_title_description_spec.rb b/qa/qa/specs/features/api/3_create/merge_request/push_options_title_description_spec.rb
new file mode 100644
index 00000000000..f49a8a229dc
--- /dev/null
+++ b/qa/qa/specs/features/api/3_create/merge_request/push_options_title_description_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module QA
+ RSpec.describe 'Create' do
+ describe 'Merge request push options' do
+ # If run locally on GDK, push options need to be enabled on the host with the following command:
+ #
+ # git config --global receive.advertisepushoptions true
+
+ let(:project) do
+ Resource::Project.fabricate_via_api! do |project|
+ project.name = 'merge-request-push-options'
+ project.initialize_with_readme = true
+ end
+ end
+
+ it 'sets title and description', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1038' do
+ description = "This is a test of MR push options"
+ title = "MR push options test #{SecureRandom.hex(8)}"
+
+ Resource::Repository::ProjectPush.fabricate! do |push|
+ push.project = project
+ push.branch_name = "push-options-test-#{SecureRandom.hex(8)}"
+ push.merge_request_push_options = {
+ create: true,
+ title: title,
+ description: description
+ }
+ end
+
+ merge_request = project.merge_request_with_title(title)
+
+ expect(merge_request).not_to be_nil, "There was a problem creating the merge request"
+
+ aggregate_failures do
+ expect(merge_request[:title]).to eq(title)
+ expect(merge_request[:description]).to eq(description)
+ end
+
+ merge_request = Resource::MergeRequest.fabricate_via_api! do |mr|
+ mr.project = project
+ mr.id = merge_request[:iid]
+ end.merge_via_api!
+
+ expect(merge_request[:state]).to eq('merged')
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/api/3_create/repository/default_branch_name_setting_spec.rb b/qa/qa/specs/features/api/3_create/repository/default_branch_name_setting_spec.rb
index af155b22618..f86bbee05c2 100644
--- a/qa/qa/specs/features/api/3_create/repository/default_branch_name_setting_spec.rb
+++ b/qa/qa/specs/features/api/3_create/repository/default_branch_name_setting_spec.rb
@@ -13,7 +13,7 @@ module QA
Runtime::ApplicationSettings.restore_application_settings(:default_branch_name)
end
- it 'sets the default branch name for a new project' do
+ it 'sets the default branch name for a new project', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1018' do
project = Resource::Project.fabricate_via_api! do |project|
project.name = "default-branch-name"
project.initialize_with_readme = true
@@ -32,7 +32,7 @@ module QA
end
end
- it 'allows a project to be created via the CLI with a different default branch name' do
+ it 'allows a project to be created via the CLI with a different default branch name', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1019' do
project_name = "default-branch-name-via-cli-#{SecureRandom.hex(8)}"
group = Resource::Group.fabricate_via_api!
diff --git a/qa/qa/specs/features/api/3_create/repository/project_archive_compare_spec.rb b/qa/qa/specs/features/api/3_create/repository/project_archive_compare_spec.rb
index 4bd99b4820e..548933d2cde 100644
--- a/qa/qa/specs/features/api/3_create/repository/project_archive_compare_spec.rb
+++ b/qa/qa/specs/features/api/3_create/repository/project_archive_compare_spec.rb
@@ -28,6 +28,13 @@ module QA
end
end
+ after do
+ # Delete the .netrc file created during this test so that subsequent tests don't try to use the logins
+ Git::Repository.perform do |repository|
+ repository.delete_netrc
+ end
+ end
+
it 'download archives of each user project then check they are different', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/427' do
archive_checksums = {}
diff --git a/qa/qa/specs/features/browser_ui/1_manage/login/log_in_with_2fa_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/login/log_in_with_2fa_spec.rb
index bd0d28c86be..e514507fcb6 100644
--- a/qa/qa/specs/features/browser_ui/1_manage/login/log_in_with_2fa_spec.rb
+++ b/qa/qa/specs/features/browser_ui/1_manage/login/log_in_with_2fa_spec.rb
@@ -18,7 +18,7 @@ module QA
QA::Resource::Group.fabricate_via_api! do |group|
group.sandbox = sandbox_group
group.api_client = owner_api_client
- group.name = 'group-with-2fa'
+ group.path = "group-with-2fa-#{SecureRandom.hex(8)}"
end
end
diff --git a/qa/qa/specs/features/browser_ui/2_plan/milestone/assign_milestone_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/milestone/assign_milestone_spec.rb
index b011978dce6..c908b1c46a1 100644
--- a/qa/qa/specs/features/browser_ui/2_plan/milestone/assign_milestone_spec.rb
+++ b/qa/qa/specs/features/browser_ui/2_plan/milestone/assign_milestone_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
- RSpec.describe 'Plan' do
+ RSpec.describe 'Plan', :reliable do
describe 'Milestones' do
include Support::Dates
diff --git a/qa/qa/specs/features/browser_ui/2_plan/milestone/create_group_milestone_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/milestone/create_group_milestone_spec.rb
index 5f7e28190b2..564cfbb8399 100644
--- a/qa/qa/specs/features/browser_ui/2_plan/milestone/create_group_milestone_spec.rb
+++ b/qa/qa/specs/features/browser_ui/2_plan/milestone/create_group_milestone_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
- RSpec.describe 'Plan' do
+ RSpec.describe 'Plan', :reliable do
describe 'Group milestone' do
include Support::Dates
diff --git a/qa/qa/specs/features/browser_ui/2_plan/milestone/create_project_milestone_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/milestone/create_project_milestone_spec.rb
index 489691b4d0c..99d547acb26 100644
--- a/qa/qa/specs/features/browser_ui/2_plan/milestone/create_project_milestone_spec.rb
+++ b/qa/qa/specs/features/browser_ui/2_plan/milestone/create_project_milestone_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
- RSpec.describe 'Plan' do
+ RSpec.describe 'Plan', :reliable do
describe 'Project milestone' do
include Support::Dates
diff --git a/qa/qa/specs/features/browser_ui/3_create/design_management/archive_design_content_spec.rb b/qa/qa/specs/features/browser_ui/3_create/design_management/archive_design_content_spec.rb
new file mode 100644
index 00000000000..7090427e5a4
--- /dev/null
+++ b/qa/qa/specs/features/browser_ui/3_create/design_management/archive_design_content_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module QA
+ RSpec.describe 'Create' do
+ context 'Design Management' do
+ let(:first_design) { Resource::Design.fabricate! }
+
+ let(:second_design) do
+ Resource::Design.fabricate! do |design|
+ design.issue = first_design.issue
+ design.filename = 'values.png'
+ end
+ end
+
+ let(:third_design) do
+ Resource::Design.fabricate! do |design|
+ design.issue = second_design.issue
+ design.filename = 'tanuki.jpg'
+ end
+ end
+
+ before do
+ Flow::Login.sign_in
+ end
+
+ it 'user archives a design', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/274' do
+ third_design.issue.visit!
+
+ Page::Project::Issue::Show.perform do |issue|
+ issue.select_design(third_design.filename)
+
+ issue.archive_selected_designs
+
+ expect(issue).not_to have_design(third_design.filename)
+ expect(issue).to have_design(first_design.filename)
+ expect(issue).to have_design(second_design.filename)
+ end
+
+ Page::Project::Issue::Show.perform do |issue|
+ issue.select_design(second_design.filename)
+ issue.select_design(first_design.filename)
+
+ issue.archive_selected_designs
+
+ expect(issue).not_to have_design(first_design.filename)
+ expect(issue).not_to have_design(second_design.filename)
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/browser_ui/3_create/merge_request/view_merge_request_merge_ref_diff_spec.rb b/qa/qa/specs/features/browser_ui/3_create/merge_request/view_merge_request_merge_ref_diff_spec.rb
new file mode 100644
index 00000000000..7844d0d7ccb
--- /dev/null
+++ b/qa/qa/specs/features/browser_ui/3_create/merge_request/view_merge_request_merge_ref_diff_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+module QA
+ RSpec.describe 'Create', :requires_admin do
+ describe 'View merge request merge-ref diff' do
+ let(:merge_request) do
+ Resource::MergeRequest.fabricate_via_api! do |merge_request|
+ merge_request.title = 'This is a merge request'
+ merge_request.description = '... for viewing merge-ref and merge-base diffs'
+ merge_request.file_content = 'This exists on the source branch only'
+ end
+ end
+
+ let(:new_file_name) { "added_file-#{SecureRandom.hex(8)}.txt" }
+
+ before do
+ commit_to_branch(merge_request.target_branch, new_file_name)
+ commit_to_branch(merge_request.source_branch, new_file_name)
+
+ Flow::Login.sign_in
+ end
+
+ context 'when the feature flag default_merge_ref_for_diffs is enabled' do
+ before do
+ Runtime::Feature.enable('default_merge_ref_for_diffs', project: merge_request.project)
+
+ merge_request.visit!
+ end
+
+ it 'views the merge-ref diff by default' do
+ Page::MergeRequest::Show.perform do |mr_page|
+ mr_page.click_diffs_tab
+ mr_page.click_target_version_dropdown
+
+ expect(mr_page.version_dropdown_content).to include('master (HEAD)')
+ expect(mr_page.version_dropdown_content).not_to include('master (base)')
+ expect(mr_page).to have_file(merge_request.file_name)
+ expect(mr_page).not_to have_file(new_file_name)
+ end
+ end
+ end
+
+ context 'when the feature flag default_merge_ref_for_diffs is disabled' do
+ before do
+ Runtime::Feature.disable('default_merge_ref_for_diffs', project: merge_request.project)
+
+ merge_request.visit!
+ end
+
+ it 'views the merge-base diff by default' do
+ Page::MergeRequest::Show.perform do |mr_page|
+ mr_page.click_diffs_tab
+ mr_page.click_target_version_dropdown
+
+ expect(mr_page.version_dropdown_content).to include('master (HEAD)')
+ expect(mr_page.version_dropdown_content).to include('master (base)')
+ expect(mr_page).to have_file(merge_request.file_name)
+ expect(mr_page).to have_file(new_file_name)
+ end
+ end
+ end
+
+ def commit_to_branch(branch, file)
+ Resource::Repository::Commit.fabricate_via_api! do |commit|
+ commit.project = merge_request.project
+ commit.branch = branch
+ commit.commit_message = "Add new file on #{branch}"
+ commit.add_files(
+ [
+ {
+ file_path: file,
+ content: "This exists on source and target branches"
+ }
+ ]
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/push_mirroring_lfs_over_http_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/push_mirroring_lfs_over_http_spec.rb
index f96b424d233..83945a09587 100644
--- a/qa/qa/specs/features/browser_ui/3_create/repository/push_mirroring_lfs_over_http_spec.rb
+++ b/qa/qa/specs/features/browser_ui/3_create/repository/push_mirroring_lfs_over_http_spec.rb
@@ -3,8 +3,8 @@
module QA
RSpec.describe 'Create' do
describe 'Push mirror a repository over HTTP' do
- it 'configures and syncs LFS objects for a (push) mirrored repository', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/414' do
- Runtime::Feature.enable_and_verify('push_mirror_syncs_lfs')
+ it 'configures and syncs LFS objects for a (push) mirrored repository', :requires_admin, testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/414' do
+ Runtime::Feature.enable(:push_mirror_syncs_lfs)
Runtime::Browser.visit(:gitlab, Page::Main::Login)
Page::Main::Login.perform(&:sign_in_using_credentials)
diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_personal_snippet_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_personal_snippet_spec.rb
index ef3b315506f..a85efe5829b 100644
--- a/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_personal_snippet_spec.rb
+++ b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_personal_snippet_spec.rb
@@ -36,6 +36,10 @@ module QA
Flow::Login.sign_in
end
+ after do
+ ssh_key.remove_via_api!
+ end
+
it 'clones, pushes, and pulls a snippet over HTTP, edits via UI', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/826' do
Resource::Repository::Push.fabricate! do |push|
push.repository_http_uri = repository_uri_http
diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb
index 34f6b464f29..9789f7a1716 100644
--- a/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb
+++ b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb
@@ -36,6 +36,10 @@ module QA
Flow::Login.sign_in
end
+ after do
+ ssh_key.remove_via_api!
+ end
+
it 'clones, pushes, and pulls a project snippet over HTTP, edits via UI', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/833' do
Resource::Repository::Push.fabricate! do |push|
push.repository_http_uri = repository_uri_http
diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/create_personal_snippet_with_multiple_files_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/create_personal_snippet_with_multiple_files_spec.rb
new file mode 100644
index 00000000000..376ce51273e
--- /dev/null
+++ b/qa/qa/specs/features/browser_ui/3_create/snippet/create_personal_snippet_with_multiple_files_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module QA
+ RSpec.describe 'Create', :requires_admin do
+ describe 'Multiple file snippet' do
+ before do
+ Runtime::Feature.enable('snippet_multiple_files')
+ end
+
+ after do
+ Runtime::Feature.disable('snippet_multiple_files')
+ end
+
+ it 'creates a personal snippet with multiple files', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/842' do
+ Flow::Login.sign_in
+
+ Page::Main::Menu.perform do |menu|
+ menu.go_to_more_dropdown_option(:snippets_link)
+ end
+
+ Resource::Snippet.fabricate_via_browser_ui! do |snippet|
+ snippet.title = 'Personal snippet with multiple files'
+ snippet.description = 'Snippet description'
+ snippet.visibility = 'Public'
+ snippet.file_name = 'First file name'
+ snippet.file_content = 'First file content'
+
+ snippet.add_files do |files|
+ files.append(name: 'Second file name', content: 'Second file content')
+ files.append(name: 'Third file name', content: 'Third file content')
+ end
+ end
+
+ Page::Dashboard::Snippet::Show.perform do |snippet|
+ expect(snippet).to have_snippet_title('Personal snippet with multiple files')
+ expect(snippet).to have_snippet_description('Snippet description')
+ expect(snippet).to have_visibility_type(/public/i)
+ expect(snippet).to have_file_name('First file name', 1)
+ expect(snippet).to have_file_content('First file content', 1)
+ expect(snippet).to have_file_name('Second file name', 2)
+ expect(snippet).to have_file_content('Second file content', 2)
+ expect(snippet).to have_file_name('Third file name', 3)
+ expect(snippet).to have_file_content('Third file content', 3)
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/create_project_snippet_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/create_project_snippet_spec.rb
index 0a8f6e13b2e..d80fc4c5b95 100644
--- a/qa/qa/specs/features/browser_ui/3_create/snippet/create_project_snippet_spec.rb
+++ b/qa/qa/specs/features/browser_ui/3_create/snippet/create_project_snippet_spec.rb
@@ -21,8 +21,8 @@ module QA
expect(snippet).to have_file_name('markdown_file.md')
expect(snippet).to have_file_content('Snippet heading')
expect(snippet).to have_file_content('Gitlab link')
- expect(snippet).not_to have_file_content('###')
- expect(snippet).not_to have_file_content('https://gitlab.com/')
+ expect(snippet).to have_no_file_content('###')
+ expect(snippet).to have_no_file_content('https://gitlab.com/')
end
end
end
diff --git a/qa/qa/specs/features/browser_ui/3_create/wiki/project_based_directory_management_spec.rb b/qa/qa/specs/features/browser_ui/3_create/wiki/project_based_directory_management_spec.rb
index e8053600930..4f1d9ac1696 100644
--- a/qa/qa/specs/features/browser_ui/3_create/wiki/project_based_directory_management_spec.rb
+++ b/qa/qa/specs/features/browser_ui/3_create/wiki/project_based_directory_management_spec.rb
@@ -4,7 +4,7 @@ module QA
RSpec.describe 'Create' do
context 'Wiki' do
let(:initial_wiki) { Resource::Wiki::ProjectPage.fabricate_via_api! }
- let(:new_path) { "a/new/path" }
+ let(:new_path) { "a/new/path-with-spaces" }
before do
Flow::Login.sign_in
@@ -23,7 +23,9 @@ module QA
Page::Project::Wiki::Edit.perform(&:click_save_changes)
Page::Project::Wiki::Show.perform do |wiki|
- expect(wiki).to have_directory(new_path)
+ expect(wiki).to have_directory('a')
+ expect(wiki).to have_directory('new')
+ expect(wiki).to have_directory('path with spaces')
end
end
end
diff --git a/qa/qa/specs/features/browser_ui/4_verify/pipeline/create_and_process_pipeline_spec.rb b/qa/qa/specs/features/browser_ui/4_verify/pipeline/create_and_process_pipeline_spec.rb
index 326647b25f7..8de739f1559 100644
--- a/qa/qa/specs/features/browser_ui/4_verify/pipeline/create_and_process_pipeline_spec.rb
+++ b/qa/qa/specs/features/browser_ui/4_verify/pipeline/create_and_process_pipeline_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
- RSpec.describe 'Verify', :docker, :runner do
+ RSpec.describe 'Verify', :runner do
describe 'Pipeline creation and processing' do
let(:executor) { "qa-runner-#{Time.now.to_i}" }
let(:max_wait) { 30 }
diff --git a/qa/qa/specs/features/browser_ui/4_verify/pipeline/run_pipeline_via_web_only_spec.rb b/qa/qa/specs/features/browser_ui/4_verify/pipeline/run_pipeline_via_web_only_spec.rb
new file mode 100644
index 00000000000..153ccafaa20
--- /dev/null
+++ b/qa/qa/specs/features/browser_ui/4_verify/pipeline/run_pipeline_via_web_only_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module QA
+ RSpec.describe 'Verify' do
+ describe 'Run pipeline', :requires_admin, :skip_live_env do
+ # [TODO]: Developer to remove :requires_admin and :skip_live_env once FF is removed in https://gitlab.com/gitlab-org/gitlab/-/issues/229632
+
+ context 'with web only rule' do
+ let(:feature_flag) { :new_pipeline_form }
+ let(:job_name) { 'test_job' }
+ let(:project) do
+ Resource::Project.fabricate_via_api! do |project|
+ project.name = 'web-only-pipeline'
+ end
+ end
+
+ let!(:ci_file) do
+ Resource::Repository::Commit.fabricate_via_api! do |commit|
+ commit.project = project
+ commit.commit_message = 'Add .gitlab-ci.yml'
+ commit.add_files(
+ [
+ {
+ file_path: '.gitlab-ci.yml',
+ content: <<~YAML
+ #{job_name}:
+ tags:
+ - #{project.name}
+ script: echo 'OK'
+ only:
+ - web
+ YAML
+ }
+ ]
+ )
+ end
+ end
+
+ before do
+ Runtime::Feature.enable(feature_flag) # [TODO]: Developer to remove when feature flag is removed
+ Flow::Login.sign_in
+ project.visit!
+ Page::Project::Menu.perform(&:click_ci_cd_pipelines)
+ end
+
+ after do
+ Runtime::Feature.disable(feature_flag) # [TODO]: Developer to remove when feature flag is removed
+ end
+
+ it 'can trigger pipeline', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/946' do
+ Page::Project::Pipeline::Index.perform do |index|
+ expect(index).not_to have_pipeline # should not auto trigger pipeline
+ index.click_run_pipeline_button
+ end
+
+ Page::Project::Pipeline::New.perform(&:click_run_pipeline_button)
+
+ Page::Project::Pipeline::Show.perform do |pipeline|
+ expect(pipeline).to have_job(job_name)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/browser_ui/4_verify/runner/register_runner_spec.rb b/qa/qa/specs/features/browser_ui/4_verify/runner/register_runner_spec.rb
index a296d60b27c..9ce87f353d0 100644
--- a/qa/qa/specs/features/browser_ui/4_verify/runner/register_runner_spec.rb
+++ b/qa/qa/specs/features/browser_ui/4_verify/runner/register_runner_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
- RSpec.describe 'Verify', :docker, :runner do
+ RSpec.describe 'Verify', :runner do
describe 'Runner registration' do
let(:executor) { "qa-runner-#{Time.now.to_i}" }
let!(:runner) do
diff --git a/qa/qa/specs/features/browser_ui/4_verify/testing/view_code_coverage_spec.rb b/qa/qa/specs/features/browser_ui/4_verify/testing/view_code_coverage_spec.rb
index f4edaaa84a8..5bfc88e45f2 100644
--- a/qa/qa/specs/features/browser_ui/4_verify/testing/view_code_coverage_spec.rb
+++ b/qa/qa/specs/features/browser_ui/4_verify/testing/view_code_coverage_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
- RSpec.describe 'Verify', :docker, :runner do
+ RSpec.describe 'Verify', :runner do
describe 'Code coverage statistics' do
let(:simplecov) { '\(\d+.\d+\%\) covered' }
let(:executor) { "qa-runner-#{Time.now.to_i}" }
diff --git a/qa/qa/specs/features/browser_ui/5_package/maven_repository_spec.rb b/qa/qa/specs/features/browser_ui/5_package/maven_repository_spec.rb
index a617f3b3e29..4ca356c9b65 100644
--- a/qa/qa/specs/features/browser_ui/5_package/maven_repository_spec.rb
+++ b/qa/qa/specs/features/browser_ui/5_package/maven_repository_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
- RSpec.describe 'Package', :docker, :orchestrated, :packages do
+ RSpec.describe 'Package', :orchestrated, :packages do
describe 'Maven Repository' do
include Runtime::Fixtures
diff --git a/qa/qa/specs/features/browser_ui/5_package/npm_registry_spec.rb b/qa/qa/specs/features/browser_ui/5_package/npm_registry_spec.rb
index e97ede35610..43c708093b3 100644
--- a/qa/qa/specs/features/browser_ui/5_package/npm_registry_spec.rb
+++ b/qa/qa/specs/features/browser_ui/5_package/npm_registry_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
- RSpec.describe 'Package', :docker, :orchestrated, :packages do
+ RSpec.describe 'Package', :orchestrated, :packages do
describe 'NPM registry' do
include Runtime::Fixtures
diff --git a/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb b/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb
index 18eb52830a2..abac4f2b91d 100644
--- a/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb
+++ b/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb
@@ -3,7 +3,7 @@
require 'digest/sha1'
module QA
- RSpec.describe 'Release', :docker, :runner do
+ RSpec.describe 'Release', :runner do
describe 'Git clone using a deploy key' do
before do
Flow::Login.sign_in
diff --git a/qa/qa/specs/features/browser_ui/6_release/pipeline/parent_child_pipelines_dependent_relationship_spec.rb b/qa/qa/specs/features/browser_ui/6_release/pipeline/parent_child_pipelines_dependent_relationship_spec.rb
index 47a1b3b5670..ece45d093a7 100644
--- a/qa/qa/specs/features/browser_ui/6_release/pipeline/parent_child_pipelines_dependent_relationship_spec.rb
+++ b/qa/qa/specs/features/browser_ui/6_release/pipeline/parent_child_pipelines_dependent_relationship_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
- RSpec.describe 'Release', :docker, :runner, :reliable do
+ RSpec.describe 'Release', :runner, :reliable do
describe 'Parent-child pipelines dependent relationship' do
let!(:project) do
Resource::Project.fabricate_via_api! do |project|
diff --git a/qa/qa/specs/features/browser_ui/6_release/pipeline/parent_child_pipelines_independent_relationship_spec.rb b/qa/qa/specs/features/browser_ui/6_release/pipeline/parent_child_pipelines_independent_relationship_spec.rb
index 9eb81244aa4..38cee0e62ca 100644
--- a/qa/qa/specs/features/browser_ui/6_release/pipeline/parent_child_pipelines_independent_relationship_spec.rb
+++ b/qa/qa/specs/features/browser_ui/6_release/pipeline/parent_child_pipelines_independent_relationship_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
- RSpec.describe 'Release', :docker, :runner, :reliable do
+ RSpec.describe 'Release', :runner, :reliable do
describe 'Parent-child pipelines independent relationship' do
let!(:project) do
Resource::Project.fabricate_via_api! do |project|
diff --git a/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb b/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb
index 53a1c8010af..6d31780f196 100644
--- a/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb
+++ b/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb
@@ -15,7 +15,7 @@ module QA
disable_optional_jobs(project)
end
- describe 'Auto DevOps support', :orchestrated, :kubernetes, quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/230927', type: :stale } do
+ describe 'Auto DevOps support', :orchestrated, :kubernetes, quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/251090', type: :stale } do
context 'when rbac is enabled' do
let(:cluster) { Service::KubernetesCluster.new.create! }
@@ -24,6 +24,8 @@ module QA
end
it 'runs auto devops', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/702' do
+ skip('Test requires tunnel: see https://gitlab.com/gitlab-org/gitlab/-/issues/251090')
+
Flow::Login.sign_in
# Set an application secret CI variable (prefixed with K8S_SECRET_)
diff --git a/qa/qa/specs/features/sanity/framework_spec.rb b/qa/qa/specs/features/sanity/framework_spec.rb
index 611c6c7b1ff..feec56478c0 100644
--- a/qa/qa/specs/features/sanity/framework_spec.rb
+++ b/qa/qa/specs/features/sanity/framework_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
- context 'Framework sanity checks', :orchestrated, :framework do
+ RSpec.describe 'Framework sanity checks', :orchestrated, :framework do
describe 'Passing orchestrated example' do
it 'succeeds' do
Runtime::Browser.visit(:gitlab, Page::Main::Login)
diff --git a/qa/qa/support/api.rb b/qa/qa/support/api.rb
index 08faacb6db3..5fc36b68e5c 100644
--- a/qa/qa/support/api.rb
+++ b/qa/qa/support/api.rb
@@ -40,7 +40,7 @@ module QA
return_response_or_raise(e)
end
- def put(url, payload)
+ def put(url, payload = nil)
RestClient::Request.execute(
method: :put,
url: url,
diff --git a/qa/qa/support/wait_for_requests.rb b/qa/qa/support/wait_for_requests.rb
index 943d7d510df..ebc473a7d86 100644
--- a/qa/qa/support/wait_for_requests.rb
+++ b/qa/qa/support/wait_for_requests.rb
@@ -9,18 +9,14 @@ module QA
def wait_for_requests(skip_finished_loading_check: false)
Waiter.wait_until(log: false) do
- finished_all_ajax_requests? && finished_all_axios_requests? && (!skip_finished_loading_check ? finished_loading?(wait: 1) : true)
+ finished_all_ajax_requests? && (!skip_finished_loading_check ? finished_loading?(wait: 1) : true)
end
- end
-
- def finished_all_axios_requests?
- Capybara.page.evaluate_script('window.pendingRequests || 0').zero? # rubocop:disable Style/NumericPredicate
+ rescue Repeater::WaitExceededError
+ raise $!, 'Page did not fully load. This could be due to an unending async request or loading icon.'
end
def finished_all_ajax_requests?
- return true if Capybara.page.evaluate_script('typeof jQuery === "undefined"')
-
- Capybara.page.evaluate_script('jQuery.active').zero? # rubocop:disable Style/NumericPredicate
+ Capybara.page.evaluate_script('window.pendingRequests || window.pendingRailsUJSRequests || 0').zero? # rubocop:disable Style/NumericPredicate
end
def finished_loading?(wait: DEFAULT_MAX_WAIT_TIME)
diff --git a/qa/spec/factory/resource/user_spec.rb b/qa/spec/factory/resource/user_spec.rb
index d59ee24c758..1adf3799b0e 100644
--- a/qa/spec/factory/resource/user_spec.rb
+++ b/qa/spec/factory/resource/user_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Resource::User do
+RSpec.describe QA::Resource::User do
describe "#fabricate_via_api!" do
response = Struct.new(:code, :body)
diff --git a/qa/spec/fixtures/tanuki.jpg b/qa/spec/fixtures/tanuki.jpg
new file mode 100644
index 00000000000..f0df472663e
--- /dev/null
+++ b/qa/spec/fixtures/tanuki.jpg
Binary files differ
diff --git a/qa/spec/fixtures/values.png b/qa/spec/fixtures/values.png
new file mode 100644
index 00000000000..9ecb6e7b778
--- /dev/null
+++ b/qa/spec/fixtures/values.png
Binary files differ
diff --git a/qa/spec/git/location_spec.rb b/qa/spec/git/location_spec.rb
index 0c57291666f..ee714206e4f 100644
--- a/qa/spec/git/location_spec.rb
+++ b/qa/spec/git/location_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Git::Location do
+RSpec.describe QA::Git::Location do
describe '.new' do
context 'when URI starts with ssh://' do
context 'when URI has port' do
diff --git a/qa/spec/git/repository_spec.rb b/qa/spec/git/repository_spec.rb
index 8355c77f493..2ac7a99d82f 100644
--- a/qa/spec/git/repository_spec.rb
+++ b/qa/spec/git/repository_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Git::Repository do
+RSpec.describe QA::Git::Repository do
include Helpers::StubENV
shared_context 'unresolvable git directory' do
@@ -117,6 +117,66 @@ describe QA::Git::Repository do
let(:call_method) { repository.push_changes(branch) }
end
end
+
+ context 'with push options' do
+ let(:command) { "git push #{push_options} #{repo_uri_with_credentials} #{branch}" }
+
+ context 'when set to create a merge request' do
+ it_behaves_like 'command with retries' do
+ let(:push_options) { '-o merge_request.create' }
+ let(:call_method) { repository.push_changes(push_options: { create: true }) }
+ end
+ end
+
+ context 'when set to merge when pipeline succeeds' do
+ it_behaves_like 'command with retries' do
+ let(:push_options) { '-o merge_request.merge_when_pipeline_succeeds' }
+ let(:call_method) { repository.push_changes(push_options: { merge_when_pipeline_succeeds: true }) }
+ end
+ end
+
+ context 'when set to remove source branch' do
+ it_behaves_like 'command with retries' do
+ let(:push_options) { '-o merge_request.remove_source_branch' }
+ let(:call_method) { repository.push_changes(push_options: { remove_source_branch: true }) }
+ end
+ end
+
+ context 'when title is given' do
+ it_behaves_like 'command with retries' do
+ let(:push_options) { '-o merge_request.title="Is A Title"' }
+ let(:call_method) { repository.push_changes(push_options: { title: 'Is A Title' }) }
+ end
+ end
+
+ context 'when description is given' do
+ it_behaves_like 'command with retries' do
+ let(:push_options) { '-o merge_request.description="Is A Description"' }
+ let(:call_method) { repository.push_changes(push_options: { description: 'Is A Description' }) }
+ end
+ end
+
+ context 'when target branch is given' do
+ it_behaves_like 'command with retries' do
+ let(:push_options) { '-o merge_request.target="is-a-target-branch"' }
+ let(:call_method) { repository.push_changes(push_options: { target: 'is-a-target-branch' }) }
+ end
+ end
+
+ context 'when a label is given' do
+ it_behaves_like 'command with retries' do
+ let(:push_options) { '-o merge_request.label="is-a-label"' }
+ let(:call_method) { repository.push_changes(push_options: { label: ['is-a-label'] }) }
+ end
+ end
+
+ context 'when two labels are given' do
+ it_behaves_like 'command with retries' do
+ let(:push_options) { '-o merge_request.label="is-a-label" -o merge_request.label="is-another-label"' }
+ let(:call_method) { repository.push_changes(push_options: { label: %w[is-a-label is-another-label] }) }
+ end
+ end
+ end
end
describe '#git_protocol=' do
diff --git a/qa/spec/page/base_spec.rb b/qa/spec/page/base_spec.rb
index 0cbb0a2b12e..52345876149 100644
--- a/qa/spec/page/base_spec.rb
+++ b/qa/spec/page/base_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Page::Base do
+RSpec.describe QA::Page::Base do
describe 'page helpers' do
it 'exposes helpful page helpers' do
expect(subject).to respond_to :refresh, :wait_until, :scroll_to
diff --git a/qa/spec/page/element_spec.rb b/qa/spec/page/element_spec.rb
index 3f64743ffac..fbf58b5e18a 100644
--- a/qa/spec/page/element_spec.rb
+++ b/qa/spec/page/element_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Page::Element do
+RSpec.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)
diff --git a/qa/spec/page/logging_spec.rb b/qa/spec/page/logging_spec.rb
index f2ce9fb2cf8..df3447770be 100644
--- a/qa/spec/page/logging_spec.rb
+++ b/qa/spec/page/logging_spec.rb
@@ -3,7 +3,7 @@
require 'capybara/dsl'
require 'logger'
-describe QA::Support::Page::Logging do
+RSpec.describe QA::Support::Page::Logging do
let(:page) { double.as_null_object }
before do
diff --git a/qa/spec/page/validator_spec.rb b/qa/spec/page/validator_spec.rb
index c727cfb686e..cfb36052294 100644
--- a/qa/spec/page/validator_spec.rb
+++ b/qa/spec/page/validator_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Page::Validator do
+RSpec.describe QA::Page::Validator do
describe '#constants' do
subject do
described_class.new(QA::Page::Project)
diff --git a/qa/spec/page/view_spec.rb b/qa/spec/page/view_spec.rb
index 3cb64dcd9c2..3342b387ed1 100644
--- a/qa/spec/page/view_spec.rb
+++ b/qa/spec/page/view_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Page::View do
+RSpec.describe QA::Page::View do
let(:element) do
double('element', name: :something, pattern: /some element/)
end
diff --git a/qa/spec/resource/api_fabricator_spec.rb b/qa/spec/resource/api_fabricator_spec.rb
index eb2bdd1be64..69a95c92332 100644
--- a/qa/spec/resource/api_fabricator_spec.rb
+++ b/qa/spec/resource/api_fabricator_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Resource::ApiFabricator do
+RSpec.describe QA::Resource::ApiFabricator do
let(:resource_without_api_support) do
Class.new do
def self.name
diff --git a/qa/spec/resource/base_spec.rb b/qa/spec/resource/base_spec.rb
index b23de19e1f8..c0bedf794be 100644
--- a/qa/spec/resource/base_spec.rb
+++ b/qa/spec/resource/base_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Resource::Base do
+RSpec.describe QA::Resource::Base do
include Helpers::StubENV
let(:resource) { spy('resource') }
diff --git a/qa/spec/resource/events/base_spec.rb b/qa/spec/resource/events/base_spec.rb
index 9cdf4785092..4df30a970fc 100644
--- a/qa/spec/resource/events/base_spec.rb
+++ b/qa/spec/resource/events/base_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Resource::Events::Base do
+RSpec.describe QA::Resource::Events::Base do
let(:resource) do
Class.new(QA::Resource::Base) do
def api_get_path
diff --git a/qa/spec/resource/events/project_spec.rb b/qa/spec/resource/events/project_spec.rb
index 98da87906fa..88d50749a0a 100644
--- a/qa/spec/resource/events/project_spec.rb
+++ b/qa/spec/resource/events/project_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Resource::Events::Project do
+RSpec.describe QA::Resource::Events::Project do
let(:resource) do
Class.new(QA::Resource::Base) do
def api_get_path
diff --git a/qa/spec/resource/repository/push_spec.rb b/qa/spec/resource/repository/push_spec.rb
index 2f9e4958ae1..2b9c90b3dac 100644
--- a/qa/spec/resource/repository/push_spec.rb
+++ b/qa/spec/resource/repository/push_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Resource::Repository::Push do
+RSpec.describe QA::Resource::Repository::Push do
describe '.files=' do
let(:files) do
[
diff --git a/qa/spec/resource/ssh_key_spec.rb b/qa/spec/resource/ssh_key_spec.rb
index b2b5ec070e1..fd0fda3c1b8 100644
--- a/qa/spec/resource/ssh_key_spec.rb
+++ b/qa/spec/resource/ssh_key_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Resource::SSHKey do
+RSpec.describe QA::Resource::SSHKey do
describe '#key' do
it 'generates a default key' do
expect(subject.key).to be_a(QA::Runtime::Key::RSA)
diff --git a/qa/spec/resource/user_spec.rb b/qa/spec/resource/user_spec.rb
index 5845f7996a3..e7397d9c0bf 100644
--- a/qa/spec/resource/user_spec.rb
+++ b/qa/spec/resource/user_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Resource::User do
+RSpec.describe QA::Resource::User do
let(:api_resource) do
{
name: "GitLab QA",
diff --git a/qa/spec/runtime/api/client_spec.rb b/qa/spec/runtime/api/client_spec.rb
index 6f7020d6595..dd139fda980 100644
--- a/qa/spec/runtime/api/client_spec.rb
+++ b/qa/spec/runtime/api/client_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Runtime::API::Client do
+RSpec.describe QA::Runtime::API::Client do
include Helpers::StubENV
describe 'initialization' do
diff --git a/qa/spec/runtime/api/request_spec.rb b/qa/spec/runtime/api/request_spec.rb
index 8354eff6234..93de2f4a87e 100644
--- a/qa/spec/runtime/api/request_spec.rb
+++ b/qa/spec/runtime/api/request_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Runtime::API::Request do
+RSpec.describe QA::Runtime::API::Request do
let(:client) { QA::Runtime::API::Client.new('http://example.com') }
let(:request) { described_class.new(client, '/users') }
diff --git a/qa/spec/runtime/application_settings_spec.rb b/qa/spec/runtime/application_settings_spec.rb
index e48214b22e6..5c4947f6f70 100644
--- a/qa/spec/runtime/application_settings_spec.rb
+++ b/qa/spec/runtime/application_settings_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Runtime::ApplicationSettings do
+RSpec.describe QA::Runtime::ApplicationSettings do
let(:api_client) { double('QA::Runtime::API::Client') }
let(:request) { Struct.new(:url).new('http://api') }
let(:get_response) { Struct.new(:body).new("{}") }
diff --git a/qa/spec/runtime/env_spec.rb b/qa/spec/runtime/env_spec.rb
index 0cfb9a70c88..bf6c01bf251 100644
--- a/qa/spec/runtime/env_spec.rb
+++ b/qa/spec/runtime/env_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Runtime::Env do
+RSpec.describe QA::Runtime::Env do
include Helpers::StubENV
shared_examples 'boolean method' do |**kwargs|
diff --git a/qa/spec/runtime/feature_spec.rb b/qa/spec/runtime/feature_spec.rb
index db3c2f65963..39c20dd3070 100644
--- a/qa/spec/runtime/feature_spec.rb
+++ b/qa/spec/runtime/feature_spec.rb
@@ -1,87 +1,215 @@
# frozen_string_literal: true
-describe QA::Runtime::Feature do
+RSpec.describe QA::Runtime::Feature do
let(:api_client) { double('QA::Runtime::API::Client') }
let(:request) { Struct.new(:url).new('http://api') }
let(:response_post) { Struct.new(:code).new(201) }
- let(:response_get) { Struct.new(:code, :body).new(200, '[{ "name": "a-flag", "state": "on" }]') }
before do
allow(described_class).to receive(:api_client).and_return(api_client)
end
- describe '.enable' do
- it 'enables a feature flag' do
- expect(QA::Runtime::API::Request)
- .to receive(:new)
- .with(api_client, "/features/a-flag")
- .and_return(request)
- expect(described_class)
- .to receive(:post)
- .with(request.url, { value: true })
- .and_return(response_post)
-
- subject.enable('a-flag')
- end
+ where(:feature_flag) do
+ ['a_flag', :a_flag]
end
- describe '.enable_and_verify' do
- it 'enables a feature flag' do
- allow(described_class).to receive(:get).and_return(response_get)
+ with_them do
+ shared_examples 'enables a feature flag' do
+ it 'enables a feature flag for a scope' do
+ allow(described_class).to receive(:get)
+ .and_return(Struct.new(:code, :body).new(200, '[{ "name": "a_flag", "state": "on" }]'))
- expect(QA::Runtime::API::Request).to receive(:new)
- .with(api_client, "/features/a-flag").and_return(request)
- expect(described_class).to receive(:post)
- .with(request.url, { value: true }).and_return(response_post)
- expect(QA::Runtime::API::Request).to receive(:new)
- .with(api_client, "/features").and_return(request)
+ expect(QA::Runtime::API::Request).to receive(:new)
+ .with(api_client, "/features/a_flag").and_return(request)
+ expect(described_class).to receive(:post)
+ .with(request.url, { value: true, scope => actor_name }).and_return(response_post)
+ expect(QA::Runtime::API::Request).to receive(:new)
+ .with(api_client, "/features").and_return(request)
+ expect(QA::Runtime::Logger).to receive(:info).with("Enabling feature: a_flag for scope \"#{scope}: #{actor_name}\"")
+ expect(QA::Runtime::Logger).to receive(:info).with("Successfully enabled and verified feature flag: a_flag")
- subject.enable_and_verify('a-flag')
+ described_class.enable(feature_flag, scope => actor)
+ end
end
- end
- describe '.disable' do
- it 'disables a feature flag' do
- expect(QA::Runtime::API::Request)
- .to receive(:new)
- .with(api_client, "/features/a-flag")
- .and_return(request)
- expect(described_class)
- .to receive(:post)
- .with(request.url, { value: false })
- .and_return(response_post)
-
- subject.disable('a-flag')
+ shared_examples 'disables a feature flag' do
+ it 'disables a feature flag for a scope' do
+ allow(described_class).to receive(:get)
+ .and_return(Struct.new(:code, :body).new(200, '[{ "name": "a_flag", "state": "off" }]'))
+
+ expect(QA::Runtime::API::Request).to receive(:new)
+ .with(api_client, "/features/a_flag").and_return(request)
+ expect(described_class).to receive(:post)
+ .with(request.url, { value: false, scope => actor_name }).and_return(response_post)
+ expect(QA::Runtime::API::Request).to receive(:new)
+ .with(api_client, "/features").and_return(request)
+ expect(QA::Runtime::Logger).to receive(:info).with("Disabling feature: a_flag for scope \"#{scope}: #{actor_name}\"")
+ expect(QA::Runtime::Logger).to receive(:info).with("Successfully disabled and verified feature flag: a_flag")
+
+ described_class.disable(feature_flag, scope => actor )
+ end
end
- end
- describe '.disable_and_verify' do
- it 'disables a feature flag' do
- allow(described_class).to receive(:get)
- .and_return(Struct.new(:code, :body).new(200, '[{ "name": "a-flag", "state": "off" }]'))
+ shared_examples 'checks a feature flag' do
+ context 'when the flag is enabled for a scope' do
+ it 'returns the feature flag state' do
+ expect(QA::Runtime::API::Request)
+ .to receive(:new)
+ .with(api_client, "/features")
+ .and_return(request)
+ expect(described_class)
+ .to receive(:get)
+ .and_return(Struct.new(:code, :body).new(200, %Q([{ "name": "a_flag", "state": "conditional", "gates": #{gates} }])))
+
+ expect(described_class.enabled?(feature_flag, scope => actor)).to be_truthy
+ end
+ end
+ end
- expect(QA::Runtime::API::Request).to receive(:new)
- .with(api_client, "/features/a-flag").and_return(request)
- expect(described_class).to receive(:post)
- .with(request.url, { value: false }).and_return(response_post)
- expect(QA::Runtime::API::Request).to receive(:new)
- .with(api_client, "/features").and_return(request)
+ describe '.enable' do
+ it 'enables a feature flag' do
+ allow(described_class).to receive(:get)
+ .and_return(Struct.new(:code, :body).new(200, '[{ "name": "a_flag", "state": "on" }]'))
- subject.disable_and_verify('a-flag')
+ expect(QA::Runtime::API::Request).to receive(:new)
+ .with(api_client, "/features/a_flag").and_return(request)
+ expect(described_class).to receive(:post)
+ .with(request.url, { value: true }).and_return(response_post)
+ expect(QA::Runtime::API::Request).to receive(:new)
+ .with(api_client, "/features").and_return(request)
+
+ described_class.enable(feature_flag)
+ end
+
+ context 'when a project scope is provided' do
+ it_behaves_like 'enables a feature flag' do
+ let(:scope) { :project }
+ let(:actor_name) { 'group-name/project-name' }
+ let(:actor) { Struct.new(:full_path).new(actor_name) }
+ end
+ end
+
+ context 'when a group scope is provided' do
+ it_behaves_like 'enables a feature flag' do
+ let(:scope) { :group }
+ let(:actor_name) { 'group-name' }
+ let(:actor) { Struct.new(:full_path).new(actor_name) }
+ end
+ end
+
+ context 'when a user scope is provided' do
+ it_behaves_like 'enables a feature flag' do
+ let(:scope) { :user }
+ let(:actor_name) { 'user-name' }
+ let(:actor) { Struct.new(:username).new(actor_name) }
+ end
+ end
+
+ context 'when a feature group scope is provided' do
+ it_behaves_like 'enables a feature flag' do
+ let(:scope) { :feature_group }
+ let(:actor_name) { 'foo' }
+ let(:actor) { "foo" }
+ end
+ end
end
- end
- describe '.enabled?' do
- it 'returns a feature flag state' do
- expect(QA::Runtime::API::Request)
- .to receive(:new)
- .with(api_client, "/features")
- .and_return(request)
- expect(described_class)
- .to receive(:get)
- .and_return(response_get)
-
- expect(subject.enabled?('a-flag')).to be_truthy
+ describe '.disable' do
+ it 'disables a feature flag' do
+ allow(described_class).to receive(:get)
+ .and_return(Struct.new(:code, :body).new(200, '[{ "name": "a_flag", "state": "off" }]'))
+
+ expect(QA::Runtime::API::Request).to receive(:new)
+ .with(api_client, "/features/a_flag").and_return(request)
+ expect(described_class).to receive(:post)
+ .with(request.url, { value: false }).and_return(response_post)
+ expect(QA::Runtime::API::Request).to receive(:new)
+ .with(api_client, "/features").and_return(request)
+
+ described_class.disable(feature_flag)
+ end
+
+ context 'when a project scope is provided' do
+ it_behaves_like 'disables a feature flag' do
+ let(:scope) { :project }
+ let(:actor_name) { 'group-name/project-name' }
+ let(:actor) { Struct.new(:full_path).new(actor_name) }
+ end
+ end
+
+ context 'when a group scope is provided' do
+ it_behaves_like 'disables a feature flag' do
+ let(:scope) { :group }
+ let(:actor_name) { 'group-name' }
+ let(:actor) { Struct.new(:full_path).new(actor_name) }
+ end
+ end
+
+ context 'when a user scope is provided' do
+ it_behaves_like 'disables a feature flag' do
+ let(:scope) { :user }
+ let(:actor_name) { 'user-name' }
+ let(:actor) { Struct.new(:username).new(actor_name) }
+ end
+ end
+
+ context 'when a feature group scope is provided' do
+ it_behaves_like 'disables a feature flag' do
+ let(:scope) { :feature_group }
+ let(:actor_name) { 'foo' }
+ let(:actor) { "foo" }
+ end
+ end
+ end
+
+ describe '.enabled?' do
+ it 'returns a feature flag state' do
+ expect(QA::Runtime::API::Request)
+ .to receive(:new)
+ .with(api_client, "/features")
+ .and_return(request)
+ expect(described_class)
+ .to receive(:get)
+ .and_return(Struct.new(:code, :body).new(200, '[{ "name": "a_flag", "state": "on" }]'))
+
+ expect(described_class.enabled?(feature_flag)).to be_truthy
+ end
+
+ context 'when a project scope is provided' do
+ it_behaves_like 'checks a feature flag' do
+ let(:scope) { :project }
+ let(:actor_name) { 'group-name/project-name' }
+ let(:actor) { Struct.new(:full_path, :id).new(actor_name, 270) }
+ let(:gates) { %q([{"key": "actors", "value": ["Project:270"]}]) }
+ end
+ end
+
+ context 'when a group scope is provided' do
+ it_behaves_like 'checks a feature flag' do
+ let(:scope) { :group }
+ let(:actor_name) { 'group-name' }
+ let(:actor) { Struct.new(:full_path, :id).new(actor_name, 33) }
+ let(:gates) { %q([{"key": "actors", "value": ["Group:33"]}]) }
+ end
+ end
+
+ context 'when a user scope is provided' do
+ it_behaves_like 'checks a feature flag' do
+ let(:scope) { :user }
+ let(:actor_name) { 'user-name' }
+ let(:actor) { Struct.new(:full_path, :id).new(actor_name, 13) }
+ let(:gates) { %q([{"key": "actors", "value": ["User:13"]}]) }
+ end
+ end
+
+ context 'when a feature group scope is provided' do
+ it_behaves_like 'checks a feature flag' do
+ let(:scope) { :feature_group }
+ let(:actor_name) { 'foo' }
+ let(:actor) { "foo" }
+ let(:gates) { %q([{"key": "groups", "value": ["foo"]}]) }
+ end
+ end
end
end
end
diff --git a/qa/spec/runtime/key/ecdsa_spec.rb b/qa/spec/runtime/key/ecdsa_spec.rb
index 3f9718e62c5..499233df618 100644
--- a/qa/spec/runtime/key/ecdsa_spec.rb
+++ b/qa/spec/runtime/key/ecdsa_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Runtime::Key::ECDSA do
+RSpec.describe QA::Runtime::Key::ECDSA do
describe '#public_key' do
[256, 384, 521].each do |bits|
it "generates a public #{bits}-bits ECDSA key" do
diff --git a/qa/spec/runtime/key/ed25519_spec.rb b/qa/spec/runtime/key/ed25519_spec.rb
index 08f232260af..e63c7f5deae 100644
--- a/qa/spec/runtime/key/ed25519_spec.rb
+++ b/qa/spec/runtime/key/ed25519_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Runtime::Key::ED25519 do
+RSpec.describe QA::Runtime::Key::ED25519 do
describe '#public_key' do
subject { described_class.new.public_key }
diff --git a/qa/spec/runtime/key/rsa_spec.rb b/qa/spec/runtime/key/rsa_spec.rb
index fcb52f541bf..5b5d8a13fa1 100644
--- a/qa/spec/runtime/key/rsa_spec.rb
+++ b/qa/spec/runtime/key/rsa_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Runtime::Key::RSA do
+RSpec.describe QA::Runtime::Key::RSA do
describe '#public_key' do
subject { described_class.new.public_key }
diff --git a/qa/spec/runtime/logger_spec.rb b/qa/spec/runtime/logger_spec.rb
index 44be3381bff..a888bf1452b 100644
--- a/qa/spec/runtime/logger_spec.rb
+++ b/qa/spec/runtime/logger_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Runtime::Logger do
+RSpec.describe QA::Runtime::Logger do
before do
logger = Logger.new $stdout
logger.level = ::Logger::DEBUG
diff --git a/qa/spec/runtime/namespace_spec.rb b/qa/spec/runtime/namespace_spec.rb
index d24fa509f30..92836862864 100644
--- a/qa/spec/runtime/namespace_spec.rb
+++ b/qa/spec/runtime/namespace_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Runtime::Namespace do
+RSpec.describe QA::Runtime::Namespace do
include Helpers::StubENV
describe '.name' do
diff --git a/qa/spec/runtime/release_spec.rb b/qa/spec/runtime/release_spec.rb
index b5a7dd5269d..b4e278fb546 100644
--- a/qa/spec/runtime/release_spec.rb
+++ b/qa/spec/runtime/release_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Runtime::Release do
+RSpec.describe QA::Runtime::Release do
context 'when release version has extension strategy' do
let(:strategy) { spy('strategy') }
diff --git a/qa/spec/runtime/scenario_spec.rb b/qa/spec/runtime/scenario_spec.rb
index 30ada4529ed..175973b6795 100644
--- a/qa/spec/runtime/scenario_spec.rb
+++ b/qa/spec/runtime/scenario_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Runtime::Scenario do
+RSpec.describe QA::Runtime::Scenario do
subject do
Module.new.extend(described_class)
end
diff --git a/qa/spec/scenario/actable_spec.rb b/qa/spec/scenario/actable_spec.rb
index 589d0c61993..36e9f3de961 100644
--- a/qa/spec/scenario/actable_spec.rb
+++ b/qa/spec/scenario/actable_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Scenario::Actable do
+RSpec.describe QA::Scenario::Actable do
subject do
Class.new do
include QA::Scenario::Actable
diff --git a/qa/spec/scenario/bootable_spec.rb b/qa/spec/scenario/bootable_spec.rb
index e8accb45518..8a96e9bebbf 100644
--- a/qa/spec/scenario/bootable_spec.rb
+++ b/qa/spec/scenario/bootable_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Scenario::Bootable do
+RSpec.describe QA::Scenario::Bootable do
subject do
Class.new(QA::Scenario::Template)
.include(described_class)
diff --git a/qa/spec/scenario/template_spec.rb b/qa/spec/scenario/template_spec.rb
index 65793734548..f07d817ea16 100644
--- a/qa/spec/scenario/template_spec.rb
+++ b/qa/spec/scenario/template_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Scenario::Template do
+RSpec.describe QA::Scenario::Template do
let(:feature) { spy('Runtime::Feature') }
let(:release) { spy('Runtime::Release') }
diff --git a/qa/spec/scenario/test/instance/airgapped_spec.rb b/qa/spec/scenario/test/instance/airgapped_spec.rb
index 0c4167eafff..5e319ba4bbb 100644
--- a/qa/spec/scenario/test/instance/airgapped_spec.rb
+++ b/qa/spec/scenario/test/instance/airgapped_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Scenario::Test::Instance::Airgapped do
+RSpec.describe QA::Scenario::Test::Instance::Airgapped do
describe '#perform' do
it_behaves_like 'a QA scenario class' do
end
diff --git a/qa/spec/scenario/test/instance/all_spec.rb b/qa/spec/scenario/test/instance/all_spec.rb
index 8acd56914c5..875df9a32f5 100644
--- a/qa/spec/scenario/test/instance/all_spec.rb
+++ b/qa/spec/scenario/test/instance/all_spec.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
-describe QA::Scenario::Test::Instance::All do
+RSpec.describe QA::Scenario::Test::Instance::All do
it_behaves_like 'a QA scenario class'
end
diff --git a/qa/spec/scenario/test/instance/smoke_spec.rb b/qa/spec/scenario/test/instance/smoke_spec.rb
index 6cc71699be9..09d0df2c479 100644
--- a/qa/spec/scenario/test/instance/smoke_spec.rb
+++ b/qa/spec/scenario/test/instance/smoke_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Scenario::Test::Instance::Smoke do
+RSpec.describe QA::Scenario::Test::Instance::Smoke do
it_behaves_like 'a QA scenario class' do
let(:tags) { [:smoke] }
end
diff --git a/qa/spec/scenario/test/integration/github_spec.rb b/qa/spec/scenario/test/integration/github_spec.rb
index b2d577bd552..b68b06a7b9f 100644
--- a/qa/spec/scenario/test/integration/github_spec.rb
+++ b/qa/spec/scenario/test/integration/github_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Scenario::Test::Integration::Github do
+RSpec.describe QA::Scenario::Test::Integration::Github do
describe '#perform' do
let(:env) { spy('Runtime::Env') }
diff --git a/qa/spec/scenario/test/integration/instance_saml_spec.rb b/qa/spec/scenario/test/integration/instance_saml_spec.rb
index 15f15b2e643..20e860d3e4b 100644
--- a/qa/spec/scenario/test/integration/instance_saml_spec.rb
+++ b/qa/spec/scenario/test/integration/instance_saml_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Scenario::Test::Integration::InstanceSAML do
+RSpec.describe QA::Scenario::Test::Integration::InstanceSAML do
describe '#perform' do
it_behaves_like 'a QA scenario class' do
let(:tags) { [:instance_saml] }
diff --git a/qa/spec/scenario/test/integration/kubernetes_spec.rb b/qa/spec/scenario/test/integration/kubernetes_spec.rb
index 51ee7b9acff..d5885b97343 100644
--- a/qa/spec/scenario/test/integration/kubernetes_spec.rb
+++ b/qa/spec/scenario/test/integration/kubernetes_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Scenario::Test::Integration::Kubernetes do
+RSpec.describe QA::Scenario::Test::Integration::Kubernetes do
describe '#perform' do
it_behaves_like 'a QA scenario class' do
let(:tags) { [:kubernetes] }
diff --git a/qa/spec/scenario/test/integration/ldap_spec.rb b/qa/spec/scenario/test/integration/ldap_spec.rb
index c493cde6c7a..c53302d9bd3 100644
--- a/qa/spec/scenario/test/integration/ldap_spec.rb
+++ b/qa/spec/scenario/test/integration/ldap_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Scenario::Test::Integration::LDAPNoTLS do
+RSpec.describe QA::Scenario::Test::Integration::LDAPNoTLS do
describe '#perform' do
it_behaves_like 'a QA scenario class' do
let(:tags) { [:ldap_no_tls] }
@@ -8,7 +8,7 @@ describe QA::Scenario::Test::Integration::LDAPNoTLS do
end
end
-describe QA::Scenario::Test::Integration::LDAPNoServer do
+RSpec.describe QA::Scenario::Test::Integration::LDAPNoServer do
describe '#perform' do
it_behaves_like 'a QA scenario class' do
let(:tags) { [:ldap_no_server] }
@@ -16,7 +16,7 @@ describe QA::Scenario::Test::Integration::LDAPNoServer do
end
end
-describe QA::Scenario::Test::Integration::LDAPTLS do
+RSpec.describe QA::Scenario::Test::Integration::LDAPTLS do
describe '#perform' do
it_behaves_like 'a QA scenario class' do
let(:tags) { [:ldap_tls] }
diff --git a/qa/spec/scenario/test/integration/mattermost_spec.rb b/qa/spec/scenario/test/integration/mattermost_spec.rb
index 7e4eb6284e8..9532ec35b95 100644
--- a/qa/spec/scenario/test/integration/mattermost_spec.rb
+++ b/qa/spec/scenario/test/integration/mattermost_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Scenario::Test::Integration::Mattermost do
+RSpec.describe QA::Scenario::Test::Integration::Mattermost do
describe '#perform' do
it_behaves_like 'a QA scenario class' do
let(:args) { %w[gitlab_address mattermost_address] }
diff --git a/qa/spec/scenario/test/integration/object_storage_spec.rb b/qa/spec/scenario/test/integration/object_storage_spec.rb
index 8b4367bee32..235dd495687 100644
--- a/qa/spec/scenario/test/integration/object_storage_spec.rb
+++ b/qa/spec/scenario/test/integration/object_storage_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Scenario::Test::Integration::ObjectStorage do
+RSpec.describe QA::Scenario::Test::Integration::ObjectStorage do
describe '#perform' do
it_behaves_like 'a QA scenario class' do
let(:tags) { [:object_storage] }
diff --git a/qa/spec/scenario/test/sanity/framework_spec.rb b/qa/spec/scenario/test/sanity/framework_spec.rb
index a63c59e2995..5ae8b123ec2 100644
--- a/qa/spec/scenario/test/sanity/framework_spec.rb
+++ b/qa/spec/scenario/test/sanity/framework_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Scenario::Test::Sanity::Framework do
+RSpec.describe QA::Scenario::Test::Sanity::Framework do
it_behaves_like 'a QA scenario class' do
let(:tags) { [:framework] }
end
diff --git a/qa/spec/scenario/test/sanity/selectors_spec.rb b/qa/spec/scenario/test/sanity/selectors_spec.rb
index e18babaed63..2a68dd23219 100644
--- a/qa/spec/scenario/test/sanity/selectors_spec.rb
+++ b/qa/spec/scenario/test/sanity/selectors_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Scenario::Test::Sanity::Selectors do
+RSpec.describe QA::Scenario::Test::Sanity::Selectors do
let(:validator) { spy('validator') }
before do
diff --git a/qa/spec/specs/helpers/quarantine_spec.rb b/qa/spec/specs/helpers/quarantine_spec.rb
index 9686a9771c4..b0b7d4e6804 100644
--- a/qa/spec/specs/helpers/quarantine_spec.rb
+++ b/qa/spec/specs/helpers/quarantine_spec.rb
@@ -36,7 +36,7 @@ RSpec.configure do |c|
end
end
-describe QA::Specs::Helpers::Quarantine do
+RSpec.describe QA::Specs::Helpers::Quarantine do
describe '.skip_or_run_quarantined_contexts' do
context 'with no tag focused' do
before do
diff --git a/qa/spec/specs/parallel_runner_spec.rb b/qa/spec/specs/parallel_runner_spec.rb
index 67d94a1f648..c2d28bf81fb 100644
--- a/qa/spec/specs/parallel_runner_spec.rb
+++ b/qa/spec/specs/parallel_runner_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-describe QA::Specs::ParallelRunner do
+RSpec.describe QA::Specs::ParallelRunner do
include Helpers::StubENV
before do
diff --git a/qa/spec/specs/runner_spec.rb b/qa/spec/specs/runner_spec.rb
index 361588fa14f..8171cfcb3e6 100644
--- a/qa/spec/specs/runner_spec.rb
+++ b/qa/spec/specs/runner_spec.rb
@@ -2,7 +2,7 @@
require 'active_support/core_ext/hash'
-describe QA::Specs::Runner do
+RSpec.describe QA::Specs::Runner do
shared_examples 'excludes orchestrated' do
it 'excludes the orchestrated tag and includes default args' do
expect_rspec_runner_arguments(['--tag', '~orchestrated', *described_class::DEFAULT_TEST_PATH_ARGS])
diff --git a/qa/spec/support/repeater_spec.rb b/qa/spec/support/repeater_spec.rb
index b5d5058ef49..18ccbf250cb 100644
--- a/qa/spec/support/repeater_spec.rb
+++ b/qa/spec/support/repeater_spec.rb
@@ -4,7 +4,7 @@ require 'logger'
require 'timecop'
require 'active_support/core_ext/integer/time'
-describe QA::Support::Repeater do
+RSpec.describe QA::Support::Repeater do
before do
logger = ::Logger.new $stdout
logger.level = ::Logger::DEBUG
diff --git a/qa/spec/support/retrier_spec.rb b/qa/spec/support/retrier_spec.rb
index ef1d53e6b65..6f052519516 100644
--- a/qa/spec/support/retrier_spec.rb
+++ b/qa/spec/support/retrier_spec.rb
@@ -3,7 +3,7 @@
require 'logger'
require 'timecop'
-describe QA::Support::Retrier do
+RSpec.describe QA::Support::Retrier do
before do
logger = ::Logger.new $stdout
logger.level = ::Logger::DEBUG
diff --git a/qa/spec/support/wait_for_requests_spec.rb b/qa/spec/support/wait_for_requests_spec.rb
index 79ee3eb5099..47c35addd9f 100644
--- a/qa/spec/support/wait_for_requests_spec.rb
+++ b/qa/spec/support/wait_for_requests_spec.rb
@@ -1,9 +1,8 @@
# frozen_string_literal: true
-describe QA::Support::WaitForRequests do
+RSpec.describe QA::Support::WaitForRequests do
describe '.wait_for_requests' do
before do
- allow(subject).to receive(:finished_all_axios_requests?).and_return(true)
allow(subject).to receive(:finished_all_ajax_requests?).and_return(true)
allow(subject).to receive(:finished_loading?).and_return(true)
end
diff --git a/qa/spec/support/waiter_spec.rb b/qa/spec/support/waiter_spec.rb
index 35f1e01289a..5b0c2c95d0d 100644
--- a/qa/spec/support/waiter_spec.rb
+++ b/qa/spec/support/waiter_spec.rb
@@ -2,7 +2,7 @@
require 'logger'
-describe QA::Support::Waiter do
+RSpec.describe QA::Support::Waiter do
before do
logger = ::Logger.new $stdout
logger.level = ::Logger::DEBUG
diff --git a/rubocop/cop/code_reuse/active_record.rb b/rubocop/cop/code_reuse/active_record.rb
index 8c77c292315..f6b5d66647d 100644
--- a/rubocop/cop/code_reuse/active_record.rb
+++ b/rubocop/cop/code_reuse/active_record.rb
@@ -5,20 +5,20 @@ require_relative '../../code_reuse_helpers'
module RuboCop
module Cop
module CodeReuse
- # Cop that blacklists the use of ActiveRecord methods outside of models.
+ # Cop that denies the use of ActiveRecord methods outside of models.
class ActiveRecord < RuboCop::Cop::Cop
include CodeReuseHelpers
MSG = 'This method can only be used inside an ActiveRecord model: ' \
'https://gitlab.com/gitlab-org/gitlab-foss/issues/49653'
- # Various methods from ActiveRecord::Querying that are blacklisted. We
+ # Various methods from ActiveRecord::Querying that are denied. We
# exclude some generic ones such as `any?` and `first`, as these may
# lead to too many false positives, since `Array` also supports these
# methods.
#
- # The keys of this Hash are the blacklisted method names. The values are
- # booleans that indicate if the method should only be blacklisted if any
+ # The keys of this Hash are the denied method names. The values are
+ # booleans that indicate if the method should only be denied if any
# arguments are provided.
NOT_ALLOWED = {
average: true,
@@ -57,7 +57,6 @@ module RuboCop
references: true,
reorder: true,
rewhere: true,
- sum: false,
take: false,
take!: false,
unscope: false,
@@ -65,9 +64,9 @@ module RuboCop
with: true
}.freeze
- # Directories that allow the use of the blacklisted methods. These
+ # Directories that allow the use of the denied methods. These
# directories are checked relative to both . and ee/
- WHITELISTED_DIRECTORIES = %w[
+ ALLOWED_DIRECTORIES = %w[
app/models
config
danger
@@ -88,7 +87,7 @@ module RuboCop
].freeze
def on_send(node)
- return if in_whitelisted_directory?(node)
+ return if in_allowed_directory?(node)
receiver = node.children[0]
send_name = node.children[1]
@@ -105,12 +104,12 @@ module RuboCop
end
end
- # Returns true if the node resides in one of the whitelisted
+ # Returns true if the node resides in one of the allowed
# directories.
- def in_whitelisted_directory?(node)
+ def in_allowed_directory?(node)
path = file_path_for_node(node)
- WHITELISTED_DIRECTORIES.any? do |directory|
+ ALLOWED_DIRECTORIES.any? do |directory|
path.start_with?(
File.join(rails_root, directory),
File.join(rails_root, 'ee', directory)
@@ -119,12 +118,12 @@ module RuboCop
end
# We can not auto correct code like this, as it requires manual
- # refactoring. Instead, we'll just whitelist the surrounding scope.
+ # refactoring. Instead, we'll just allow the surrounding scope.
#
# Despite this method's presence, you should not use it. This method
- # exists to make it possible to whitelist large chunks of offenses we
+ # exists to make it possible to allow large chunks of offenses we
# can't fix in the short term. If you are writing new code, follow the
- # code reuse guidelines, instead of whitelisting any new offenses.
+ # code reuse guidelines, instead of allowing any new offenses.
def autocorrect(node)
scope = surrounding_scope_of(node)
indent = indentation_of(scope)
@@ -132,7 +131,7 @@ module RuboCop
lambda do |corrector|
# This prevents us from inserting the same enable/disable comment
# for a method or block that has multiple offenses.
- next if whitelisted_scopes.include?(scope)
+ next if allowed_scopes.include?(scope)
corrector.insert_before(
scope.source_range,
@@ -144,7 +143,7 @@ module RuboCop
"\n#{indent}# rubocop: enable #{cop_name}"
)
- whitelisted_scopes << scope
+ allowed_scopes << scope
end
end
@@ -160,8 +159,8 @@ module RuboCop
end
end
- def whitelisted_scopes
- @whitelisted_scopes ||= Set.new
+ def allowed_scopes
+ @allowed_scopes ||= Set.new
end
end
end
diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh
index 1243609dc24..e95f20bc26c 100644
--- a/scripts/prepare_build.sh
+++ b/scripts/prepare_build.sh
@@ -2,7 +2,7 @@
export SETUP_DB=${SETUP_DB:-true}
export USE_BUNDLE_INSTALL=${USE_BUNDLE_INSTALL:-true}
-export BUNDLE_INSTALL_FLAGS=${BUNDLE_INSTALL_FLAGS:-"--without=production --without=development --jobs=$(nproc) --path=vendor --retry=3 --quiet"}
+export BUNDLE_INSTALL_FLAGS=${BUNDLE_INSTALL_FLAGS:-"--without=production development --jobs=$(nproc) --path=vendor --retry=3 --quiet"}
if [ "$USE_BUNDLE_INSTALL" != "false" ]; then
bundle --version
@@ -14,10 +14,6 @@ if [ "$USE_BUNDLE_INSTALL" != "false" ]; then
run_timed_command "bundle pristine pg"
fi
-# Only install knapsack after bundle install! Otherwise oddly some native
-# gems could not be found under some circumstance. No idea why, hours wasted.
-run_timed_command "gem install knapsack --no-document"
-
cp config/gitlab.yml.example config/gitlab.yml
sed -i 's/bin_path: \/usr\/bin\/git/bin_path: \/usr\/local\/bin\/git/' config/gitlab.yml
diff --git a/scripts/review_apps/review-apps.sh b/scripts/review_apps/review-apps.sh
index 862c3b4bb62..1a1cfbbd5f6 100755
--- a/scripts/review_apps/review-apps.sh
+++ b/scripts/review_apps/review-apps.sh
@@ -48,7 +48,13 @@ function delete_release() {
return
fi
- helm_delete_release "${namespace}" "${release}"
+ # Check if helm release exists before attempting to delete
+ # There may be situation where k8s resources exist, but helm release does not,
+ # for example, following a failed helm install.
+ # In such cases, we still want to continue to clean up k8s resources.
+ if deploy_exists "${namespace}" "${release}"; then
+ helm_delete_release "${namespace}" "${release}"
+ fi
kubectl_cleanup_release "${namespace}" "${release}"
}
diff --git a/spec/bin/feature_flag_spec.rb b/spec/bin/feature_flag_spec.rb
index 41117880f95..185a03fc587 100644
--- a/spec/bin/feature_flag_spec.rb
+++ b/spec/bin/feature_flag_spec.rb
@@ -240,5 +240,18 @@ RSpec.describe 'bin/feature-flag' do
end
end
end
+
+ describe '.read_ee_only' do
+ where(:type, :is_ee_only) do
+ :development | false
+ :licensed | true
+ end
+
+ with_them do
+ let(:options) { OpenStruct.new(name: 'foo', type: type) }
+
+ it { expect(described_class.read_ee_only(options)).to eq(is_ee_only) }
+ end
+ end
end
end
diff --git a/spec/channels/application_cable/connection_spec.rb b/spec/channels/application_cable/connection_spec.rb
index e5f7ea1103c..7d60548f780 100644
--- a/spec/channels/application_cable/connection_spec.rb
+++ b/spec/channels/application_cable/connection_spec.rb
@@ -5,27 +5,39 @@ require 'spec_helper'
RSpec.describe ApplicationCable::Connection, :clean_gitlab_redis_shared_state do
let(:session_id) { Rack::Session::SessionId.new('6919a6f1bb119dd7396fadc38fd18d0d') }
- before do
- Gitlab::Redis::SharedState.with do |redis|
- redis.set("session:gitlab:#{session_id.private_id}", Marshal.dump(session_hash))
+ context 'when session cookie is set' do
+ before do
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.set("session:gitlab:#{session_id.private_id}", Marshal.dump(session_hash))
+ end
+
+ cookies[Gitlab::Application.config.session_options[:key]] = session_id.public_id
end
- cookies[Gitlab::Application.config.session_options[:key]] = session_id.public_id
- end
+ context 'when user is logged in' do
+ let(:user) { create(:user) }
+ let(:session_hash) { { 'warden.user.user.key' => [[user.id], user.encrypted_password[0, 29]] } }
+
+ it 'sets current_user' do
+ connect
+
+ expect(connection.current_user).to eq(user)
+ end
- context 'when user is logged in' do
- let(:user) { create(:user) }
- let(:session_hash) { { 'warden.user.user.key' => [[user.id], user.encrypted_password[0, 29]] } }
+ context 'with a stale password' do
+ let(:partial_password_hash) { build(:user, password: 'some_old_password').encrypted_password[0, 29] }
+ let(:session_hash) { { 'warden.user.user.key' => [[user.id], partial_password_hash] } }
- it 'sets current_user' do
- connect
+ it 'sets current_user to nil' do
+ connect
- expect(connection.current_user).to eq(user)
+ expect(connection.current_user).to be_nil
+ end
+ end
end
- context 'with a stale password' do
- let(:partial_password_hash) { build(:user, password: 'some_old_password').encrypted_password[0, 29] }
- let(:session_hash) { { 'warden.user.user.key' => [[user.id], partial_password_hash] } }
+ context 'when user is not logged in' do
+ let(:session_hash) { {} }
it 'sets current_user to nil' do
connect
@@ -35,10 +47,18 @@ RSpec.describe ApplicationCable::Connection, :clean_gitlab_redis_shared_state do
end
end
- context 'when user is not logged in' do
- let(:session_hash) { {} }
+ context 'when session cookie is not set' do
+ it 'sets current_user to nil' do
+ connect
+
+ expect(connection.current_user).to be_nil
+ end
+ end
+ context 'when session cookie is an empty string' do
it 'sets current_user to nil' do
+ cookies[Gitlab::Application.config.session_options[:key]] = ''
+
connect
expect(connection.current_user).to be_nil
diff --git a/spec/controllers/admin/application_settings_controller_spec.rb b/spec/controllers/admin/application_settings_controller_spec.rb
index 4f223811be8..f71f859a704 100644
--- a/spec/controllers/admin/application_settings_controller_spec.rb
+++ b/spec/controllers/admin/application_settings_controller_spec.rb
@@ -15,6 +15,37 @@ RSpec.describe Admin::ApplicationSettingsController do
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
end
+ describe 'GET #integrations' do
+ before do
+ sign_in(admin)
+ end
+
+ context 'when GitLab.com' do
+ before do
+ allow(::Gitlab).to receive(:com?) { true }
+ end
+
+ it 'returns 404' do
+ get :integrations
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when not GitLab.com' do
+ before do
+ allow(::Gitlab).to receive(:com?) { false }
+ end
+
+ it 'renders correct template' do
+ get :integrations
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template('admin/application_settings/integrations')
+ end
+ end
+ end
+
describe 'GET #usage_data with no access' do
before do
stub_usage_data_connections
@@ -56,6 +87,13 @@ RSpec.describe Admin::ApplicationSettingsController do
sign_in(admin)
end
+ it 'updates the require_admin_approval_after_user_signup setting' do
+ put :update, params: { application_setting: { require_admin_approval_after_user_signup: true } }
+
+ expect(response).to redirect_to(general_admin_application_settings_path)
+ expect(ApplicationSetting.current.require_admin_approval_after_user_signup).to eq(true)
+ end
+
it 'updates the password_authentication_enabled_for_git setting' do
put :update, params: { application_setting: { password_authentication_enabled_for_git: "0" } }
diff --git a/spec/controllers/admin/clusters_controller_spec.rb b/spec/controllers/admin/clusters_controller_spec.rb
index d2a569a9d48..69bdc79c5f5 100644
--- a/spec/controllers/admin/clusters_controller_spec.rb
+++ b/spec/controllers/admin/clusters_controller_spec.rb
@@ -416,6 +416,7 @@ RSpec.describe Admin::ClustersController do
expect(cluster).to be_user
expect(cluster).to be_kubernetes
expect(cluster).to be_platform_kubernetes_rbac
+ expect(cluster).to be_namespace_per_environment
end
end
end
@@ -585,6 +586,7 @@ RSpec.describe Admin::ClustersController do
enabled: false,
name: 'my-new-cluster-name',
managed: false,
+ namespace_per_environment: false,
base_domain: domain
}
}
@@ -599,6 +601,7 @@ RSpec.describe Admin::ClustersController do
expect(cluster.enabled).to be_falsey
expect(cluster.name).to eq('my-new-cluster-name')
expect(cluster).not_to be_managed
+ expect(cluster).not_to be_namespace_per_environment
expect(cluster.domain).to eq('test-domain.com')
end
@@ -624,6 +627,7 @@ RSpec.describe Admin::ClustersController do
enabled: false,
name: 'my-new-cluster-name',
managed: false,
+ namespace_per_environment: false,
domain: domain
}
}
@@ -637,6 +641,7 @@ RSpec.describe Admin::ClustersController do
expect(cluster.enabled).to be_falsey
expect(cluster.name).to eq('my-new-cluster-name')
expect(cluster).not_to be_managed
+ expect(cluster).not_to be_namespace_per_environment
end
end
diff --git a/spec/controllers/admin/integrations_controller_spec.rb b/spec/controllers/admin/integrations_controller_spec.rb
index 4b1806a43d2..1a13d016b73 100644
--- a/spec/controllers/admin/integrations_controller_spec.rb
+++ b/spec/controllers/admin/integrations_controller_spec.rb
@@ -20,6 +20,18 @@ RSpec.describe Admin::IntegrationsController do
end
end
end
+
+ context 'when GitLab.com' do
+ before do
+ allow(::Gitlab).to receive(:com?) { true }
+ end
+
+ it 'returns 404' do
+ get :edit, params: { id: Service.available_services_names.sample }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
end
describe '#update' do
@@ -43,7 +55,7 @@ RSpec.describe Admin::IntegrationsController do
end
it 'calls to PropagateIntegrationWorker' do
- expect(PropagateIntegrationWorker).to have_received(:perform_async).with(integration.id, false)
+ expect(PropagateIntegrationWorker).to have_received(:perform_async).with(integration.id)
end
end
diff --git a/spec/controllers/admin/runners_controller_spec.rb b/spec/controllers/admin/runners_controller_spec.rb
index 013eee19409..3fffc50475c 100644
--- a/spec/controllers/admin/runners_controller_spec.rb
+++ b/spec/controllers/admin/runners_controller_spec.rb
@@ -151,4 +151,21 @@ RSpec.describe Admin::RunnersController do
expect(runner.active).to eq(false)
end
end
+
+ describe 'GET #runner_setup_scripts' do
+ it 'renders the setup scripts' do
+ get :runner_setup_scripts, params: { os: 'linux', arch: 'amd64' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to have_key("install")
+ expect(json_response).to have_key("register")
+ end
+
+ it 'renders errors if they occur' do
+ get :runner_setup_scripts, params: { os: 'foo', arch: 'bar' }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to have_key("errors")
+ end
+ end
end
diff --git a/spec/controllers/admin/sessions_controller_spec.rb b/spec/controllers/admin/sessions_controller_spec.rb
index 35982e57034..5fa7a7f278d 100644
--- a/spec/controllers/admin/sessions_controller_spec.rb
+++ b/spec/controllers/admin/sessions_controller_spec.rb
@@ -109,7 +109,7 @@ RSpec.describe Admin::SessionsController, :do_not_mock_admin_mode do
# triggering the auth form will request admin mode
get :new
- Timecop.freeze(Gitlab::Auth::CurrentUserMode::ADMIN_MODE_REQUESTED_GRACE_PERIOD.from_now) do
+ travel_to(Gitlab::Auth::CurrentUserMode::ADMIN_MODE_REQUESTED_GRACE_PERIOD.from_now) do
post :create, params: { user: { password: user.password } }
expect(response).to redirect_to(new_admin_session_path)
diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb
index 6301da74f4a..0ee773f291c 100644
--- a/spec/controllers/admin/users_controller_spec.rb
+++ b/spec/controllers/admin/users_controller_spec.rb
@@ -23,6 +23,12 @@ RSpec.describe Admin::UsersController do
expect(assigns(:users)).to eq([admin])
end
+
+ it 'eager loads authorized projects association' do
+ get :index
+
+ expect(assigns(:users).first.association(:authorized_projects)).to be_loaded
+ end
end
describe 'GET :id' do
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index 188a4cb04af..94e110325f9 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -416,13 +416,13 @@ RSpec.describe ApplicationController do
end
it 'returns false if the grace period has expired' do
- Timecop.freeze(3.hours.from_now) do
+ travel_to(3.hours.from_now) do
expect(subject).to be_falsey
end
end
it 'returns true if the grace period is still active' do
- Timecop.freeze(1.hour.from_now) do
+ travel_to(1.hour.from_now) do
expect(subject).to be_truthy
end
end
diff --git a/spec/controllers/boards/lists_controller_spec.rb b/spec/controllers/boards/lists_controller_spec.rb
index c72d9e5053a..9b09f46d17e 100644
--- a/spec/controllers/boards/lists_controller_spec.rb
+++ b/spec/controllers/boards/lists_controller_spec.rb
@@ -260,6 +260,17 @@ RSpec.describe Boards::ListsController do
end
end
+ context 'with an error service response' do
+ it 'returns an unprocessable entity response' do
+ allow(Boards::Lists::DestroyService).to receive(:new)
+ .and_return(double(execute: ServiceResponse.error(message: 'error')))
+
+ remove_board_list user: user, board: board, list: planning
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ end
+ end
+
def remove_board_list(user:, board:, list:)
sign_in(user)
diff --git a/spec/controllers/concerns/controller_with_feature_category/config_spec.rb b/spec/controllers/concerns/controller_with_feature_category/config_spec.rb
deleted file mode 100644
index 9b8ffd2baab..00000000000
--- a/spec/controllers/concerns/controller_with_feature_category/config_spec.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-
-require "fast_spec_helper"
-require "rspec-parameterized"
-require_relative "../../../../app/controllers/concerns/controller_with_feature_category/config"
-
-RSpec.describe ControllerWithFeatureCategory::Config do
- describe "#matches?" do
- using RSpec::Parameterized::TableSyntax
-
- where(:only_actions, :except_actions, :if_proc, :unless_proc, :test_action, :expected) do
- nil | nil | nil | nil | "action" | true
- [:included] | nil | nil | nil | "action" | false
- [:included] | nil | nil | nil | "included" | true
- nil | [:excluded] | nil | nil | "excluded" | false
- nil | nil | true | nil | "action" | true
- [:included] | nil | true | nil | "action" | false
- [:included] | nil | true | nil | "included" | true
- nil | [:excluded] | true | nil | "excluded" | false
- nil | nil | false | nil | "action" | false
- [:included] | nil | false | nil | "action" | false
- [:included] | nil | false | nil | "included" | false
- nil | [:excluded] | false | nil | "excluded" | false
- nil | nil | nil | true | "action" | false
- [:included] | nil | nil | true | "action" | false
- [:included] | nil | nil | true | "included" | false
- nil | [:excluded] | nil | true | "excluded" | false
- nil | nil | nil | false | "action" | true
- [:included] | nil | nil | false | "action" | false
- [:included] | nil | nil | false | "included" | true
- nil | [:excluded] | nil | false | "excluded" | false
- nil | nil | true | false | "action" | true
- [:included] | nil | true | false | "action" | false
- [:included] | nil | true | false | "included" | true
- nil | [:excluded] | true | false | "excluded" | false
- nil | nil | false | true | "action" | false
- [:included] | nil | false | true | "action" | false
- [:included] | nil | false | true | "included" | false
- nil | [:excluded] | false | true | "excluded" | false
- end
-
- with_them do
- let(:config) do
- if_to_proc = if_proc.nil? ? nil : -> (_) { if_proc }
- unless_to_proc = unless_proc.nil? ? nil : -> (_) { unless_proc }
-
- described_class.new(:category, only_actions, except_actions, if_to_proc, unless_to_proc)
- end
-
- specify { expect(config.matches?(test_action)).to be(expected) }
- end
- end
-end
diff --git a/spec/controllers/concerns/controller_with_feature_category_spec.rb b/spec/controllers/concerns/controller_with_feature_category_spec.rb
index e603a7d14c4..55e84755f5c 100644
--- a/spec/controllers/concerns/controller_with_feature_category_spec.rb
+++ b/spec/controllers/concerns/controller_with_feature_category_spec.rb
@@ -2,7 +2,6 @@
require 'fast_spec_helper'
require_relative "../../../app/controllers/concerns/controller_with_feature_category"
-require_relative "../../../app/controllers/concerns/controller_with_feature_category/config"
RSpec.describe ControllerWithFeatureCategory do
describe ".feature_category_for_action" do
@@ -14,17 +13,15 @@ RSpec.describe ControllerWithFeatureCategory do
let(:controller) do
Class.new(base_controller) do
- feature_category :baz
- feature_category :foo, except: %w(update edit)
- feature_category :bar, only: %w(index show)
- feature_category :quux, only: %w(destroy)
- feature_category :quuz, only: %w(destroy)
+ feature_category :foo, %w(update edit)
+ feature_category :bar, %w(index show)
+ feature_category :quux, %w(destroy)
end
end
let(:subclass) do
Class.new(controller) do
- feature_category :qux, only: %w(index)
+ feature_category :baz, %w(subclass_index)
end
end
@@ -33,34 +30,31 @@ RSpec.describe ControllerWithFeatureCategory do
end
it "returns the expected category", :aggregate_failures do
- expect(controller.feature_category_for_action("update")).to eq(:baz)
- expect(controller.feature_category_for_action("hello")).to eq(:foo)
+ expect(controller.feature_category_for_action("update")).to eq(:foo)
expect(controller.feature_category_for_action("index")).to eq(:bar)
+ expect(controller.feature_category_for_action("destroy")).to eq(:quux)
end
- it "returns the closest match for categories defined in subclasses" do
- expect(subclass.feature_category_for_action("index")).to eq(:qux)
- expect(subclass.feature_category_for_action("show")).to eq(:bar)
+ it "returns the expected category for categories defined in subclasses" do
+ expect(subclass.feature_category_for_action("subclass_index")).to eq(:baz)
end
- it "returns the last defined feature category when multiple match" do
- expect(controller.feature_category_for_action("destroy")).to eq(:quuz)
- end
-
- it "raises an error when using including and excluding the same action" do
+ it "raises an error when defining for the controller and for individual actions" do
expect do
Class.new(base_controller) do
- feature_category :hello, only: [:world], except: [:world]
+ feature_category :hello
+ feature_category :goodbye, [:world]
end
- end.to raise_error(%r(cannot configure both `only` and `except`))
+ end.to raise_error(ArgumentError, "hello is defined for all actions, but other categories are set")
end
- it "raises an error when using unknown arguments" do
+ it "raises an error when multiple calls define the same action" do
expect do
Class.new(base_controller) do
- feature_category :hello, hello: :world
+ feature_category :hello, [:world]
+ feature_category :goodbye, ["world"]
end
- end.to raise_error(%r(unknown arguments))
+ end.to raise_error(ArgumentError, "Actions have multiple feature categories: world")
end
end
end
diff --git a/spec/controllers/concerns/redis_tracking_spec.rb b/spec/controllers/concerns/redis_tracking_spec.rb
index 3795fca5576..1b41e1820b1 100644
--- a/spec/controllers/concerns/redis_tracking_spec.rb
+++ b/spec/controllers/concerns/redis_tracking_spec.rb
@@ -36,21 +36,9 @@ RSpec.describe RedisTracking do
end
end
- context 'with usage ping disabled' do
- it 'does not track the event' do
- stub_feature_flags(feature => true)
- allow(Gitlab::CurrentSettings).to receive(:usage_ping_enabled?).and_return(false)
-
- expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
-
- get :index
- end
- end
-
- context 'with feature enabled and usage ping enabled' do
+ context 'with feature enabled' do
before do
stub_feature_flags(feature => true)
- allow(Gitlab::CurrentSettings).to receive(:usage_ping_enabled?).and_return(true)
end
context 'when user is logged in' do
diff --git a/spec/controllers/dashboard_controller_spec.rb b/spec/controllers/dashboard_controller_spec.rb
index c838affa239..9b78f841cce 100644
--- a/spec/controllers/dashboard_controller_spec.rb
+++ b/spec/controllers/dashboard_controller_spec.rb
@@ -15,6 +15,16 @@ RSpec.describe DashboardController do
describe 'GET issues' do
it_behaves_like 'issuables list meta-data', :issue, :issues
it_behaves_like 'issuables requiring filter', :issues
+
+ it 'lists only incidents and issues' do
+ issue = create(:incident, project: project, author: user)
+ incident = create(:incident, project: project, author: user)
+ create(:quality_test_case, project: project, author: user)
+
+ get :issues, params: { author_id: user.id }
+
+ expect(assigns(:issues)).to match_array([issue, incident])
+ end
end
describe 'GET merge requests' do
diff --git a/spec/controllers/every_controller_spec.rb b/spec/controllers/every_controller_spec.rb
index 4785ee9ed8f..d333c98ccf7 100644
--- a/spec/controllers/every_controller_spec.rb
+++ b/spec/controllers/every_controller_spec.rb
@@ -17,20 +17,27 @@ RSpec.describe "Every controller" do
.compact
.select { |route| route[:controller].present? && route[:action].present? }
.map { |route| [constantize_controller(route[:controller]), route[:action]] }
- .reject { |route| route.first.nil? || !route.first.include?(ControllerWithFeatureCategory) }
+ .select { |(controller, action)| controller&.include?(ControllerWithFeatureCategory) }
+ .reject { |(controller, action)| controller == Devise::UnlocksController }
end
let_it_be(:routes_without_category) do
controller_actions.map do |controller, action|
- "#{controller}##{action}" unless controller.feature_category_for_action(action)
+ next if controller.feature_category_for_action(action)
+ next unless controller.to_s.start_with?('B', 'C', 'D', 'E', 'F', 'Projects::MergeRequestsController')
+
+ "#{controller}##{action}"
end.compact
end
it "has feature categories" do
- pending("We'll work on defining categories for all controllers: "\
- "https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/463")
+ routes_without_category.map { |x| x.split('#') }.group_by(&:first).each do |controller, actions|
+ puts controller
+ puts actions.map { |x| ":#{x.last}" }.sort.join(', ')
+ puts ''
+ end
- expect(routes_without_category).to be_empty, "#{routes_without_category.first(10)} did not have a category"
+ expect(routes_without_category).to be_empty, "#{routes_without_category} did not have a category"
end
it "completed controllers don't get new routes without categories" do
@@ -74,9 +81,9 @@ RSpec.describe "Every controller" do
end
def actions_defined_in_feature_category_config(controller)
- feature_category_configs = controller.send(:class_attributes)[:feature_category_config]
- feature_category_configs.map do |config|
- Array(config.send(:only)) + Array(config.send(:except))
- end.flatten.uniq.map(&:to_s)
+ controller.send(:class_attributes)[:feature_category_config]
+ .values
+ .flatten
+ .map(&:to_s)
end
end
diff --git a/spec/controllers/groups/clusters_controller_spec.rb b/spec/controllers/groups/clusters_controller_spec.rb
index 81d5bc7770f..140b7b0f2a8 100644
--- a/spec/controllers/groups/clusters_controller_spec.rb
+++ b/spec/controllers/groups/clusters_controller_spec.rb
@@ -271,6 +271,7 @@ RSpec.describe Groups::ClustersController do
expect(cluster).to be_kubernetes
expect(cluster.provider_gcp).to be_legacy_abac
expect(cluster).to be_managed
+ expect(cluster).to be_namespace_per_environment
end
context 'when legacy_abac param is false' do
@@ -358,6 +359,7 @@ RSpec.describe Groups::ClustersController do
expect(cluster).to be_user
expect(cluster).to be_kubernetes
expect(cluster).to be_managed
+ expect(cluster).to be_namespace_per_environment
end
end
@@ -387,6 +389,7 @@ RSpec.describe Groups::ClustersController do
expect(cluster).to be_user
expect(cluster).to be_kubernetes
expect(cluster).to be_platform_kubernetes_rbac
+ expect(cluster).to be_namespace_per_environment
end
end
@@ -716,6 +719,7 @@ RSpec.describe Groups::ClustersController do
enabled: false,
name: 'my-new-cluster-name',
managed: false,
+ namespace_per_environment: false,
domain: domain
}
}
@@ -729,6 +733,7 @@ RSpec.describe Groups::ClustersController do
expect(cluster.enabled).to be_falsey
expect(cluster.name).to eq('my-new-cluster-name')
expect(cluster).not_to be_managed
+ expect(cluster).not_to be_namespace_per_environment
end
end
diff --git a/spec/controllers/groups/group_links_controller_spec.rb b/spec/controllers/groups/group_links_controller_spec.rb
index 07299382230..d179e268748 100644
--- a/spec/controllers/groups/group_links_controller_spec.rb
+++ b/spec/controllers/groups/group_links_controller_spec.rb
@@ -136,10 +136,15 @@ RSpec.describe Groups::GroupLinksController do
let(:expiry_date) { 1.month.from_now.to_date }
subject do
- post(:update, params: { group_id: shared_group,
- id: link.id,
- group_link: { group_access: Gitlab::Access::GUEST,
- expires_at: expiry_date } })
+ post(
+ :update,
+ params: {
+ group_id: shared_group,
+ id: link.id,
+ group_link: { group_access: Gitlab::Access::GUEST, expires_at: expiry_date }
+ },
+ format: :json
+ )
end
context 'when user has admin access to the shared group' do
@@ -160,6 +165,26 @@ RSpec.describe Groups::GroupLinksController do
expect(link.expires_at).to eq(expiry_date)
end
+ context 'when `expires_at` is set' do
+ it 'returns correct json response' do
+ travel_to Time.now.utc.beginning_of_day
+
+ subject
+
+ expect(json_response).to eq({ "expires_in" => "about 1 month", "expires_soon" => false })
+ end
+ end
+
+ context 'when `expires_at` is not set' do
+ let(:expiry_date) { nil }
+
+ it 'returns empty json response' do
+ subject
+
+ expect(json_response).to be_empty
+ end
+ end
+
it 'updates project permissions' do
expect { subject }.to change { group_member.can?(:create_release, project) }.from(true).to(false)
end
diff --git a/spec/controllers/groups/labels_controller_spec.rb b/spec/controllers/groups/labels_controller_spec.rb
index 20ee19b01d1..33041f1af9f 100644
--- a/spec/controllers/groups/labels_controller_spec.rb
+++ b/spec/controllers/groups/labels_controller_spec.rb
@@ -9,6 +9,8 @@ RSpec.describe Groups::LabelsController do
before do
group.add_owner(user)
+ # by default FFs are enabled in specs so we turn it off
+ stub_feature_flags(show_inherited_labels: false)
sign_in(user)
end
@@ -32,11 +34,41 @@ RSpec.describe Groups::LabelsController do
subgroup.add_owner(user)
end
- it 'returns ancestor group labels' do
- get :index, params: { group_id: subgroup, include_ancestor_groups: true, only_group_labels: true }, format: :json
+ RSpec.shared_examples 'returns ancestor group labels' do
+ it 'returns ancestor group labels' do
+ get :index, params: params, format: :json
- label_ids = json_response.map {|label| label['title']}
- expect(label_ids).to match_array([group_label_1.title, subgroup_label_1.title])
+ label_ids = json_response.map {|label| label['title']}
+ expect(label_ids).to match_array([group_label_1.title, subgroup_label_1.title])
+ end
+ end
+
+ context 'when include_ancestor_groups true' do
+ let(:params) { { group_id: subgroup, include_ancestor_groups: true, only_group_labels: true } }
+
+ it_behaves_like 'returns ancestor group labels'
+ end
+
+ context 'when include_ancestor_groups false' do
+ let(:params) { { group_id: subgroup, only_group_labels: true } }
+
+ it 'does not return ancestor group labels', :aggregate_failures do
+ get :index, params: params, format: :json
+
+ label_ids = json_response.map {|label| label['title']}
+ expect(label_ids).to match_array([subgroup_label_1.title])
+ expect(label_ids).not_to include([group_label_1.title])
+ end
+ end
+
+ context 'when show_inherited_labels enabled' do
+ let(:params) { { group_id: subgroup } }
+
+ before do
+ stub_feature_flags(show_inherited_labels: true)
+ end
+
+ it_behaves_like 'returns ancestor group labels'
end
end
@@ -56,4 +88,43 @@ RSpec.describe Groups::LabelsController do
expect(response).to have_gitlab_http_status(:ok)
end
end
+
+ describe 'DELETE #destroy' do
+ context 'when current user has ability to destroy the label' do
+ before do
+ sign_in(user)
+ end
+
+ it 'removes the label' do
+ label = create(:group_label, group: group)
+ delete :destroy, params: { group_id: group.to_param, id: label.to_param }
+
+ expect { label.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ context 'when label is succesfuly destroyed' do
+ it 'redirects to the group labels page' do
+ label = create(:group_label, group: group)
+ delete :destroy, params: { group_id: group.to_param, id: label.to_param }
+
+ expect(response).to redirect_to(group_labels_path)
+ end
+ end
+ end
+
+ context 'when current_user does not have ability to destroy the label' do
+ let(:another_user) { create(:user) }
+
+ before do
+ sign_in(another_user)
+ end
+
+ it 'responds with status 404' do
+ label = create(:group_label, group: group)
+ delete :destroy, params: { group_id: group.to_param, id: label.to_param }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
end
diff --git a/spec/controllers/groups/milestones_controller_spec.rb b/spec/controllers/groups/milestones_controller_spec.rb
index 5c7b88a218a..2c85fe482e2 100644
--- a/spec/controllers/groups/milestones_controller_spec.rb
+++ b/spec/controllers/groups/milestones_controller_spec.rb
@@ -9,7 +9,6 @@ RSpec.describe Groups::MilestonesController do
let(:user) { create(:user) }
let(:title) { '肯定不是中文的问题' }
let(:milestone) { create(:milestone, project: project) }
- let(:milestone_path) { group_milestone_path(group, milestone.safe_title, title: milestone.title) }
let(:milestone_params) do
{
@@ -25,6 +24,12 @@ RSpec.describe Groups::MilestonesController do
project.add_maintainer(user)
end
+ it_behaves_like 'milestone tabs' do
+ let(:milestone) { create(:milestone, group: group) }
+ let(:milestone_path) { group_milestone_path(group, milestone.iid) }
+ let(:request_params) { { group_id: group, id: milestone.iid } }
+ end
+
describe '#index' do
describe 'as HTML' do
render_views
diff --git a/spec/controllers/groups/registry/repositories_controller_spec.rb b/spec/controllers/groups/registry/repositories_controller_spec.rb
index ddac8fc5002..ae982b02a4f 100644
--- a/spec/controllers/groups/registry/repositories_controller_spec.rb
+++ b/spec/controllers/groups/registry/repositories_controller_spec.rb
@@ -87,7 +87,7 @@ RSpec.describe Groups::Registry::RepositoriesController do
it_behaves_like 'with name parameter'
- it_behaves_like 'a gitlab tracking event', described_class.name, 'list_repositories'
+ it_behaves_like 'a package tracking event', described_class.name, 'list_repositories'
context 'with project in subgroup' do
let_it_be(:test_group) { create(:group, parent: group ) }
diff --git a/spec/controllers/groups/settings/ci_cd_controller_spec.rb b/spec/controllers/groups/settings/ci_cd_controller_spec.rb
index f11bb66caab..880d5fe8951 100644
--- a/spec/controllers/groups/settings/ci_cd_controller_spec.rb
+++ b/spec/controllers/groups/settings/ci_cd_controller_spec.rb
@@ -225,4 +225,25 @@ RSpec.describe Groups::Settings::CiCdController do
end
end
end
+
+ describe 'GET #runner_setup_scripts' do
+ before do
+ group.add_owner(user)
+ end
+
+ it 'renders the setup scripts' do
+ get :runner_setup_scripts, params: { os: 'linux', arch: 'amd64', group_id: group }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to have_key("install")
+ expect(json_response).to have_key("register")
+ end
+
+ it 'renders errors if they occur' do
+ get :runner_setup_scripts, params: { os: 'foo', arch: 'bar', group_id: group }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to have_key("errors")
+ end
+ end
end
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index 35d8c0b7c6d..90f348a3421 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -2,18 +2,18 @@
require 'spec_helper'
-RSpec.describe GroupsController do
+RSpec.describe GroupsController, factory_default: :keep do
include ExternalAuthorizationServiceHelpers
- let(:user) { create(:user) }
- let(:admin) { create(:admin) }
- let(:group) { create(:group, :public) }
- let(:project) { create(:project, namespace: group) }
- let!(:group_member) { create(:group_member, group: group, user: user) }
- let!(:owner) { group.add_owner(create(:user)).user }
- let!(:maintainer) { group.add_maintainer(create(:user)).user }
- let!(:developer) { group.add_developer(create(:user)).user }
- let!(:guest) { group.add_guest(create(:user)).user }
+ let_it_be_with_refind(:group) { create_default(:group, :public) }
+ let_it_be_with_refind(:project) { create(:project, namespace: group) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:group_member) { create(:group_member, group: group, user: user) }
+ let_it_be(:owner) { group.add_owner(create(:user)).user }
+ let_it_be(:maintainer) { group.add_maintainer(create(:user)).user }
+ let_it_be(:developer) { group.add_developer(create(:user)).user }
+ let_it_be(:guest) { group.add_guest(create(:user)).user }
shared_examples 'member with ability to create subgroups' do
it 'renders the new page' do
@@ -57,7 +57,6 @@ RSpec.describe GroupsController do
describe 'GET #show' do
before do
sign_in(user)
- project
end
let(:format) { :html }
@@ -82,7 +81,6 @@ RSpec.describe GroupsController do
describe 'GET #details' do
before do
sign_in(user)
- project
end
let(:format) { :html }
@@ -131,12 +129,9 @@ RSpec.describe GroupsController do
end
describe 'GET #activity' do
- render_views
-
context 'as json' do
before do
sign_in(user)
- project
end
it 'includes events from all projects in group and subgroups', :sidekiq_might_not_need_inline do
@@ -157,10 +152,6 @@ RSpec.describe GroupsController do
end
context 'when user has no permission to see the event' do
- let(:user) { create(:user) }
- let(:group) { create(:group) }
- let(:project) { create(:project, group: group) }
-
let(:project_with_restricted_access) do
create(:project, :public, issues_access_level: ProjectFeature::PRIVATE, group: group)
end
@@ -398,8 +389,8 @@ RSpec.describe GroupsController do
end
describe 'GET #issues', :sidekiq_might_not_need_inline do
- let(:issue_1) { create(:issue, project: project, title: 'foo') }
- let(:issue_2) { create(:issue, project: project, title: 'bar') }
+ let_it_be(:issue_1) { create(:issue, project: project, title: 'foo') }
+ let_it_be(:issue_2) { create(:issue, project: project, title: 'bar') }
before do
create_list(:award_emoji, 3, awardable: issue_2)
@@ -409,6 +400,15 @@ RSpec.describe GroupsController do
sign_in(user)
end
+ it 'lists only incidents and issues' do
+ incident = create(:incident, project: project)
+ create(:quality_test_case, project: project)
+
+ get :issues, params: { id: group.to_param }
+
+ expect(assigns(:issues)).to match_array([issue_1, issue_2, incident])
+ end
+
context 'sorting by votes' do
it 'sorts most popular issues' do
get :issues, params: { id: group.to_param, sort: 'upvotes_desc' }
@@ -552,8 +552,6 @@ RSpec.describe GroupsController do
end
context 'when there is a conflicting group path' do
- render_views
-
let!(:conflict_group) { create(:group, path: SecureRandom.hex(12) ) }
let!(:old_name) { group.name }
@@ -794,6 +792,7 @@ RSpec.describe GroupsController do
context 'when transferring to a subgroup goes right' do
let(:new_parent_group) { create(:group, :public) }
+ let(: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) }
@@ -805,11 +804,8 @@ RSpec.describe GroupsController do
}
end
- it 'returns a notice' do
+ it 'returns a notice and redirects to the new path' do
expect(flash[:notice]).to eq("Group '#{group.name}' was successfully transferred.")
- end
-
- it 'redirects to the new path' do
expect(response).to redirect_to("/#{new_parent_group.path}/#{group.path}")
end
end
@@ -826,17 +822,15 @@ RSpec.describe GroupsController do
}
end
- it 'returns a notice' do
+ it 'returns a notice and redirects to the new path' do
expect(flash[:notice]).to eq("Group '#{group.name}' was successfully transferred.")
- end
-
- it 'redirects 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) { 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) }
@@ -850,17 +844,15 @@ RSpec.describe GroupsController do
}
end
- it 'returns an alert' do
+ it 'returns an alert and redirects to the current path' do
expect(flash[:alert]).to eq "Transfer failed: namespace directory cannot be moved"
- end
-
- it 'redirects to the current path' do
expect(response).to redirect_to(edit_group_path(group))
end
end
context 'when the user is not allowed to transfer the group' do
let(:new_parent_group) { create(:group, :public) }
+ let(: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) }
@@ -879,6 +871,7 @@ RSpec.describe GroupsController do
context 'transferring when a project has container images' do
let(:group) { create(:group, :public, :nested) }
+ let(:project) { create(:project, namespace: group) }
let!(:group_member) { create(:group_member, :owner, group: group, user: user) }
before do
@@ -979,6 +972,8 @@ RSpec.describe GroupsController do
end
context 'when there is no file available to download' do
+ let(:admin) { create(:admin) }
+
before do
sign_in(admin)
end
@@ -1149,9 +1144,7 @@ RSpec.describe GroupsController do
describe "GET #activity as JSON" do
include DesignManagementTestHelpers
- render_views
- let(:project) { create(:project, :public, group: group) }
let(:other_project) { create(:project, :public, group: group) }
def get_activity
diff --git a/spec/controllers/import/bulk_imports_controller_spec.rb b/spec/controllers/import/bulk_imports_controller_spec.rb
new file mode 100644
index 00000000000..9fe15162158
--- /dev/null
+++ b/spec/controllers/import/bulk_imports_controller_spec.rb
@@ -0,0 +1,137 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Import::BulkImportsController do
+ let_it_be(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ context 'when user is signed in' do
+ context 'when bulk_import feature flag is enabled' do
+ before do
+ stub_feature_flags(bulk_import: true)
+ end
+
+ describe 'POST configure' do
+ context 'when no params are passed in' do
+ it 'clears out existing session' do
+ post :configure
+
+ expect(session[:bulk_import_gitlab_access_token]).to be_nil
+ expect(session[:bulk_import_gitlab_url]).to be_nil
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(response).to redirect_to(status_import_bulk_import_url)
+ end
+ end
+
+ it 'sets the session variables' do
+ token = 'token'
+ url = 'https://gitlab.example'
+
+ post :configure, params: { bulk_import_gitlab_access_token: token, bulk_import_gitlab_url: url }
+
+ expect(session[:bulk_import_gitlab_access_token]).to eq(token)
+ expect(session[:bulk_import_gitlab_url]).to eq(url)
+ expect(response).to have_gitlab_http_status(:found)
+ expect(response).to redirect_to(status_import_bulk_import_url)
+ end
+
+ it 'strips access token with spaces' do
+ token = 'token'
+
+ post :configure, params: { bulk_import_gitlab_access_token: " #{token} " }
+
+ expect(session[:bulk_import_gitlab_access_token]).to eq(token)
+ expect(controller).to redirect_to(status_import_bulk_import_url)
+ end
+ end
+
+ describe 'GET status' do
+ context 'when host url is local or not http' do
+ %w[https://localhost:3000 http://192.168.0.1 ftp://testing].each do |url|
+ before do
+ stub_application_setting(allow_local_requests_from_web_hooks_and_services: false)
+
+ session[:bulk_import_gitlab_access_token] = 'test'
+ session[:bulk_import_gitlab_url] = url
+ end
+
+ it 'denies network request' do
+ get :status
+
+ expect(controller).to redirect_to(new_group_path)
+ expect(flash[:alert]).to eq('Specified URL cannot be used: "Only allowed schemes are http, https"')
+ end
+ end
+
+ context 'when local requests are allowed' do
+ %w[https://localhost:3000 http://192.168.0.1].each do |url|
+ before do
+ stub_application_setting(allow_local_requests_from_web_hooks_and_services: true)
+
+ session[:bulk_import_gitlab_access_token] = 'test'
+ session[:bulk_import_gitlab_url] = url
+ end
+
+ it 'allows network request' do
+ get :status
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+ end
+ end
+ end
+
+ context 'when gitlab_api_imports feature flag is disabled' do
+ before do
+ stub_feature_flags(bulk_import: false)
+ end
+
+ context 'POST configure' do
+ it 'returns 404' do
+ post :configure
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'GET status' do
+ it 'returns 404' do
+ get :status
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+
+ context 'when user is signed out' do
+ before do
+ sign_out(user)
+ end
+
+ context 'POST configure' do
+ it 'redirects to sign in page' do
+ post :configure
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(response).to redirect_to(new_user_session_path)
+ end
+ end
+
+ context 'GET status' do
+ it 'redirects to sign in page' do
+ get :status
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(response).to redirect_to(new_user_session_path)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/import/manifest_controller_spec.rb b/spec/controllers/import/manifest_controller_spec.rb
index ec8bd45b65c..6b21b45e698 100644
--- a/spec/controllers/import/manifest_controller_spec.rb
+++ b/spec/controllers/import/manifest_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Import::ManifestController do
+RSpec.describe Import::ManifestController, :clean_gitlab_redis_shared_state do
include ImportSpecHelper
let_it_be(:user) { create(:user) }
@@ -16,42 +16,93 @@ RSpec.describe Import::ManifestController do
sign_in(user)
end
- def assign_session_group
- session[:manifest_import_repositories] = []
- session[:manifest_import_group_id] = group.id
+ describe 'POST upload' do
+ context 'with a valid manifest' do
+ it 'saves the manifest and redirects to the status page', :aggregate_failures do
+ post :upload, params: {
+ group_id: group.id,
+ manifest: fixture_file_upload('spec/fixtures/aosp_manifest.xml')
+ }
+
+ metadata = Gitlab::ManifestImport::Metadata.new(user)
+
+ expect(metadata.group_id).to eq(group.id)
+ expect(metadata.repositories.size).to eq(660)
+ expect(metadata.repositories.first).to include(name: 'platform/build', path: 'build/make')
+
+ expect(response).to redirect_to(status_import_manifest_path)
+ end
+ end
+
+ context 'with an invalid manifest' do
+ it 'displays an error' do
+ post :upload, params: {
+ group_id: group.id,
+ manifest: fixture_file_upload('spec/fixtures/invalid_manifest.xml')
+ }
+
+ expect(assigns(:errors)).to be_present
+ end
+ end
+
+ context 'when the user cannot create projects in the group' do
+ it 'displays an error' do
+ sign_in(create(:user))
+
+ post :upload, params: {
+ group_id: group.id,
+ manifest: fixture_file_upload('spec/fixtures/aosp_manifest.xml')
+ }
+
+ expect(assigns(:errors)).to be_present
+ end
+ end
end
describe 'GET status' do
- let(:repo1) { OpenStruct.new(id: 'test1', url: 'http://demo.host/test1') }
- let(:repo2) { OpenStruct.new(id: 'test2', url: 'http://demo.host/test2') }
+ let(:repo1) { { id: 'test1', url: 'http://demo.host/test1' } }
+ let(:repo2) { { id: 'test2', url: 'http://demo.host/test2' } }
let(:repos) { [repo1, repo2] }
- before do
- assign_session_group
+ shared_examples 'status action' do
+ it "returns variables for json request" do
+ project = create(:project, import_type: 'manifest', creator_id: user.id)
- session[:manifest_import_repositories] = repos
- end
+ get :status, format: :json
- it "returns variables for json request" do
- project = create(:project, import_type: 'manifest', creator_id: user.id)
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.dig("imported_projects", 0, "id")).to eq(project.id)
+ expect(json_response.dig("provider_repos", 0, "id")).to eq(repo1[:id])
+ expect(json_response.dig("provider_repos", 1, "id")).to eq(repo2[:id])
+ expect(json_response.dig("namespaces", 0, "id")).to eq(group.id)
+ end
- get :status, format: :json
+ it "does not show already added project" do
+ project = create(:project, import_type: 'manifest', namespace: user.namespace, import_status: :finished, import_url: repo1[:url])
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response.dig("imported_projects", 0, "id")).to eq(project.id)
- expect(json_response.dig("provider_repos", 0, "id")).to eq(repo1.id)
- expect(json_response.dig("provider_repos", 1, "id")).to eq(repo2.id)
- expect(json_response.dig("namespaces", 0, "id")).to eq(group.id)
+ get :status, format: :json
+
+ expect(json_response.dig("imported_projects", 0, "id")).to eq(project.id)
+ expect(json_response.dig("provider_repos").length).to eq(1)
+ expect(json_response.dig("provider_repos", 0, "id")).not_to eq(repo1[:id])
+ end
end
- it "does not show already added project" do
- project = create(:project, import_type: 'manifest', namespace: user.namespace, import_status: :finished, import_url: repo1.url)
+ context 'when the data is stored via Gitlab::ManifestImport::Metadata' do
+ before do
+ Gitlab::ManifestImport::Metadata.new(user).save(repos, group.id)
+ end
+
+ include_examples 'status action'
+ end
- get :status, format: :json
+ context 'when the data is stored in the user session' do
+ before do
+ session[:manifest_import_repositories] = repos
+ session[:manifest_import_group_id] = group.id
+ end
- expect(json_response.dig("imported_projects", 0, "id")).to eq(project.id)
- expect(json_response.dig("provider_repos").length).to eq(1)
- expect(json_response.dig("provider_repos", 0, "id")).not_to eq(repo1.id)
+ include_examples 'status action'
end
end
end
diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb
index a083cfac981..75a972d2f95 100644
--- a/spec/controllers/invites_controller_spec.rb
+++ b/spec/controllers/invites_controller_spec.rb
@@ -17,8 +17,53 @@ RSpec.describe InvitesController, :snowplow do
}
end
- before do
- controller.instance_variable_set(:@member, member)
+ shared_examples 'invalid token' do
+ context 'when invite token is not valid' do
+ let(:params) { { id: '_bogus_token_' } }
+
+ it 'renders the 404 page' do
+ request
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ shared_examples "tracks the 'accepted' event for the invitation reminders experiment" do
+ before do
+ stub_experiment(invitation_reminders: true)
+ allow(Gitlab::Experimentation).to receive(:enabled_for_attribute?).with(:invitation_reminders, member.invite_email).and_return(experimental_group)
+ end
+
+ context 'when in the control group' do
+ let(:experimental_group) { false }
+
+ it "tracks the 'accepted' event" do
+ request
+
+ expect_snowplow_event(
+ category: 'Growth::Acquisition::Experiment::InvitationReminders',
+ label: md5_member_global_id,
+ property: 'control_group',
+ action: 'accepted'
+ )
+ end
+ end
+
+ context 'when in the experimental group' do
+ let(:experimental_group) { true }
+
+ it "tracks the 'accepted' event" do
+ request
+
+ expect_snowplow_event(
+ category: 'Growth::Acquisition::Experiment::InvitationReminders',
+ label: md5_member_global_id,
+ property: 'experimental_group',
+ action: 'accepted'
+ )
+ end
+ end
end
describe 'GET #show' do
@@ -39,7 +84,7 @@ RSpec.describe InvitesController, :snowplow do
end
it 'forces re-confirmation if email does not match signed in user' do
- member.invite_email = 'bogus@email.com'
+ member.update!(invite_email: 'bogus@email.com')
expect do
request
@@ -64,8 +109,8 @@ RSpec.describe InvitesController, :snowplow do
it 'tracks the user as experiment group' do
request
- expect_snowplow_event(snowplow_event.merge(action: 'opened'))
- expect_snowplow_event(snowplow_event.merge(action: 'accepted'))
+ expect_snowplow_event(**snowplow_event.merge(action: 'opened'))
+ expect_snowplow_event(**snowplow_event.merge(action: 'accepted'))
end
end
@@ -76,10 +121,13 @@ RSpec.describe InvitesController, :snowplow do
it 'tracks the user as control group' do
request
- expect_snowplow_event(snowplow_event.merge(action: 'opened'))
- expect_snowplow_event(snowplow_event.merge(action: 'accepted'))
+ expect_snowplow_event(**snowplow_event.merge(action: 'opened'))
+ expect_snowplow_event(**snowplow_event.merge(action: 'accepted'))
end
end
+
+ it_behaves_like "tracks the 'accepted' event for the invitation reminders experiment"
+ it_behaves_like 'invalid token'
end
context 'when not logged in' do
@@ -125,7 +173,7 @@ RSpec.describe InvitesController, :snowplow do
it 'tracks the user as experiment group' do
request
- expect_snowplow_event(snowplow_event.merge(action: 'accepted'))
+ expect_snowplow_event(**snowplow_event.merge(action: 'accepted'))
end
end
@@ -136,8 +184,31 @@ RSpec.describe InvitesController, :snowplow do
it 'tracks the user as control group' do
request
- expect_snowplow_event(snowplow_event.merge(action: 'accepted'))
+ expect_snowplow_event(**snowplow_event.merge(action: 'accepted'))
end
end
+
+ it_behaves_like "tracks the 'accepted' event for the invitation reminders experiment"
+ it_behaves_like 'invalid token'
+ end
+
+ describe 'POST #decline for link in UI' do
+ before do
+ sign_in(user)
+ end
+
+ subject(:request) { post :decline, params: params }
+
+ it_behaves_like 'invalid token'
+ end
+
+ describe 'GET #decline for link in email' do
+ before do
+ sign_in(user)
+ end
+
+ subject(:request) { get :decline, params: params }
+
+ it_behaves_like 'invalid token'
end
end
diff --git a/spec/controllers/projects/clusters_controller_spec.rb b/spec/controllers/projects/clusters_controller_spec.rb
index 51a451570c5..52cd6869b04 100644
--- a/spec/controllers/projects/clusters_controller_spec.rb
+++ b/spec/controllers/projects/clusters_controller_spec.rb
@@ -251,6 +251,7 @@ RSpec.describe Projects::ClustersController do
cluster: {
name: 'new-cluster',
managed: '1',
+ namespace_per_environment: '0',
provider_gcp_attributes: {
gcp_project_id: 'gcp-project-12345',
legacy_abac: legacy_abac_param
@@ -278,6 +279,7 @@ RSpec.describe Projects::ClustersController do
expect(project.clusters.first).to be_kubernetes
expect(project.clusters.first.provider_gcp).to be_legacy_abac
expect(project.clusters.first.managed?).to be_truthy
+ expect(project.clusters.first.namespace_per_environment?).to be_falsy
end
context 'when legacy_abac param is false' do
@@ -369,6 +371,7 @@ RSpec.describe Projects::ClustersController do
expect(project.clusters.first).to be_user
expect(project.clusters.first).to be_kubernetes
+ expect(project.clusters.first).to be_namespace_per_environment
end
end
@@ -400,6 +403,7 @@ RSpec.describe Projects::ClustersController do
expect(cluster).to be_user
expect(cluster).to be_kubernetes
expect(cluster).to be_platform_kubernetes_rbac
+ expect(cluster).to be_namespace_per_environment
end
end
@@ -726,6 +730,7 @@ RSpec.describe Projects::ClustersController do
enabled: false,
name: 'my-new-cluster-name',
managed: false,
+ namespace_per_environment: false,
platform_kubernetes_attributes: {
namespace: 'my-namespace'
}
@@ -742,6 +747,7 @@ RSpec.describe Projects::ClustersController do
expect(cluster.enabled).to be_falsey
expect(cluster.name).to eq('my-new-cluster-name')
expect(cluster).not_to be_managed
+ expect(cluster).not_to be_namespace_per_environment
expect(cluster.platform_kubernetes.namespace).to eq('my-namespace')
end
diff --git a/spec/controllers/projects/group_links_controller_spec.rb b/spec/controllers/projects/group_links_controller_spec.rb
index 762ef795f6e..3baadde46dc 100644
--- a/spec/controllers/projects/group_links_controller_spec.rb
+++ b/spec/controllers/projects/group_links_controller_spec.rb
@@ -3,10 +3,10 @@
require 'spec_helper'
RSpec.describe Projects::GroupLinksController do
- let(:group) { create(:group, :private) }
- let(:group2) { create(:group, :private) }
- let(:project) { create(:project, :private, group: group2) }
- let(:user) { create(:user) }
+ let_it_be(:group) { create(:group, :private) }
+ let_it_be(:group2) { create(:group, :private) }
+ let_it_be(:project) { create(:project, :private, group: group2) }
+ let_it_be(:user) { create(:user) }
before do
project.add_maintainer(user)
@@ -142,4 +142,47 @@ RSpec.describe Projects::GroupLinksController do
end
end
end
+
+ describe '#update' do
+ let_it_be(:link) do
+ create(
+ :project_group_link,
+ {
+ project: project,
+ group: group
+ }
+ )
+ end
+
+ let(:expiry_date) { 1.month.from_now.to_date }
+
+ before do
+ travel_to Time.now.utc.beginning_of_day
+
+ put(
+ :update,
+ params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ id: link.id,
+ group_link: { group_access: Gitlab::Access::GUEST, expires_at: expiry_date }
+ },
+ format: :json
+ )
+ end
+
+ context 'when `expires_at` is set' do
+ it 'returns correct json response' do
+ expect(json_response).to eq({ "expires_in" => "about 1 month", "expires_soon" => false })
+ end
+ end
+
+ context 'when `expires_at` is not set' do
+ let(:expiry_date) { nil }
+
+ it 'returns empty json response' do
+ expect(json_response).to be_empty
+ end
+ end
+ end
end
diff --git a/spec/controllers/projects/import/jira_controller_spec.rb b/spec/controllers/projects/import/jira_controller_spec.rb
index b82735a56b3..37a7fce0c23 100644
--- a/spec/controllers/projects/import/jira_controller_spec.rb
+++ b/spec/controllers/projects/import/jira_controller_spec.rb
@@ -12,7 +12,6 @@ RSpec.describe Projects::Import::JiraController do
def ensure_correct_config
sign_in(user)
project.add_maintainer(user)
- stub_feature_flags(jira_issue_import: true)
stub_jira_service_test
end
@@ -77,7 +76,6 @@ RSpec.describe Projects::Import::JiraController do
before do
sign_in(user)
project.add_maintainer(user)
- stub_feature_flags(jira_issue_import: true)
end
context 'when Jira service is not enabled for the project' do
diff --git a/spec/controllers/projects/incidents_controller_spec.rb b/spec/controllers/projects/incidents_controller_spec.rb
index 2baae0661cb..1b47f9f6abf 100644
--- a/spec/controllers/projects/incidents_controller_spec.rb
+++ b/spec/controllers/projects/incidents_controller_spec.rb
@@ -3,44 +3,127 @@
require 'spec_helper'
RSpec.describe Projects::IncidentsController do
- let_it_be(:project) { create(:project) }
+ let_it_be_with_refind(:project) { create(:project) }
let_it_be(:developer) { create(:user) }
let_it_be(:guest) { create(:user) }
+ let_it_be(:anonymous) { nil }
before_all do
- project.add_developer(developer)
project.add_guest(guest)
+ project.add_developer(developer)
+ end
+
+ before do
+ sign_in(user) if user
+ end
+
+ subject { make_request }
+
+ shared_examples 'not found' do
+ include_examples 'returning response status', :not_found
+ end
+
+ shared_examples 'login required' do
+ it 'redirects to the login page' do
+ subject
+
+ expect(response).to redirect_to(new_user_session_path)
+ end
end
describe 'GET #index' do
def make_request
- get :index, params: { namespace_id: project.namespace, project_id: project }
+ get :index, params: project_params
end
- it 'shows the page for user with developer role' do
- sign_in(developer)
- make_request
+ let(:user) { developer }
+
+ it 'shows the page' do
+ subject
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:index)
end
context 'when user is unauthorized' do
- it 'redirects to the login page' do
- sign_out(developer)
- make_request
+ let(:user) { anonymous }
+
+ it_behaves_like 'login required'
+ end
+
+ context 'when user is a guest' do
+ let(:user) { guest }
+
+ it 'shows the page' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:index)
+ end
+ end
+ end
+
+ describe 'GET #show' do
+ def make_request
+ get :show, params: project_params(id: resource)
+ end
+
+ let_it_be(:resource) { create(:incident, project: project) }
+ let(:user) { developer }
+
+ it 'renders incident page' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:show)
- expect(response).to redirect_to(new_user_session_path)
+ expect(assigns(:incident)).to be_present
+ expect(assigns(:incident).author.association(:status)).to be_loaded
+ expect(assigns(:issue)).to be_present
+ expect(assigns(:noteable)).to eq(assigns(:incident))
+ end
+
+ context 'with feature flag disabled' do
+ before do
+ stub_feature_flags(issues_incident_details: false)
end
+
+ it_behaves_like 'not found'
+ end
+
+ context 'with non existing id' do
+ let(:resource) { non_existing_record_id }
+
+ it_behaves_like 'not found'
+ end
+
+ context 'for issue' do
+ let_it_be(:resource) { create(:issue, project: project) }
+
+ it_behaves_like 'not found'
end
context 'when user is a guest' do
- it 'shows 404' do
- sign_in(guest)
- make_request
+ let(:user) { guest }
+
+ it 'shows the page' do
+ subject
- expect(response).to have_gitlab_http_status(:not_found)
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:show)
end
end
+
+ context 'when unauthorized' do
+ let(:user) { anonymous }
+
+ it_behaves_like 'login required'
+ end
+ end
+
+ private
+
+ def project_params(opts = {})
+ opts.reverse_merge(namespace_id: project.namespace, project_id: project)
end
end
diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb
index 94cce1964ca..d7c22d46e90 100644
--- a/spec/controllers/projects/jobs_controller_spec.rb
+++ b/spec/controllers/projects/jobs_controller_spec.rb
@@ -121,13 +121,6 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do
expect(response).to have_gitlab_http_status(:ok)
expect(assigns(:build).id).to eq(job.id)
end
-
- it 'has the correct build collection' do
- builds = assigns(:builds).map(&:id)
-
- expect(builds).to include(job.id, second_job.id)
- expect(builds).not_to include(third_job.id)
- end
end
context 'when job does not exist' do
diff --git a/spec/controllers/projects/labels_controller_spec.rb b/spec/controllers/projects/labels_controller_spec.rb
index f213d104747..8a3c55033cb 100644
--- a/spec/controllers/projects/labels_controller_spec.rb
+++ b/spec/controllers/projects/labels_controller_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
RSpec.describe Projects::LabelsController do
- let(:group) { create(:group) }
- let(:project) { create(:project, namespace: group) }
- let(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project, reload: true) { create(:project, namespace: group) }
+ let_it_be(:user) { create(:user) }
before do
project.add_maintainer(user)
@@ -14,16 +14,21 @@ RSpec.describe Projects::LabelsController do
end
describe 'GET #index' do
- let!(:label_1) { create(:label, project: project, priority: 1, title: 'Label 1') }
- let!(:label_2) { create(:label, project: project, priority: 3, title: 'Label 2') }
- let!(:label_3) { create(:label, project: project, priority: 1, title: 'Label 3') }
- let!(:label_4) { create(:label, project: project, title: 'Label 4') }
- let!(:label_5) { create(:label, project: project, title: 'Label 5') }
-
- let!(:group_label_1) { create(:group_label, group: group, title: 'Group Label 1') }
- let!(:group_label_2) { create(:group_label, group: group, title: 'Group Label 2') }
- let!(:group_label_3) { create(:group_label, group: group, title: 'Group Label 3') }
- let!(:group_label_4) { create(:group_label, group: group, title: 'Group Label 4') }
+ let_it_be(:label_1) { create(:label, project: project, priority: 1, title: 'Label 1') }
+ let_it_be(:label_2) { create(:label, project: project, priority: 3, title: 'Label 2') }
+ let_it_be(:label_3) { create(:label, project: project, priority: 1, title: 'Label 3') }
+ let_it_be(:label_4) { create(:label, project: project, title: 'Label 4') }
+ let_it_be(:label_5) { create(:label, project: project, title: 'Label 5') }
+
+ let_it_be(:group_label_1) { create(:group_label, group: group, title: 'Group Label 1') }
+ let_it_be(:group_label_2) { create(:group_label, group: group, title: 'Group Label 2') }
+ let_it_be(:group_label_3) { create(:group_label, group: group, title: 'Group Label 3') }
+ let_it_be(:group_label_4) { create(:group_label, group: group, title: 'Group Label 4') }
+
+ let_it_be(:group_labels) { [group_label_3, group_label_4]}
+ let_it_be(:project_labels) { [label_4, label_5]}
+ let_it_be(:group_priority_labels) { [group_label_1, group_label_2]}
+ let_it_be(:project_priority_labels) { [label_1, label_2, label_3]}
before do
create(:label_priority, project: project, label: group_label_1, priority: 3)
@@ -68,6 +73,60 @@ RSpec.describe Projects::LabelsController do
end
end
+ context 'with subgroups' do
+ let_it_be(:subgroup) { create(:group, parent: group) }
+ let_it_be(:subgroup_label_1) { create(:group_label, group: subgroup, title: 'subgroup_label_1') }
+ let_it_be(:subgroup_label_2) { create(:group_label, group: subgroup, title: 'subgroup_label_2') }
+
+ before do
+ project.update!(namespace: subgroup)
+ subgroup.add_owner(user)
+ create(:label_priority, project: project, label: subgroup_label_2, priority: 1)
+ end
+
+ RSpec.shared_examples 'returns ancestor group labels' do
+ it 'returns ancestor group labels', :aggregate_failures do
+ get :index, params: params
+
+ expect(assigns(:labels)).to match_array([subgroup_label_1] + group_labels + project_labels)
+ expect(assigns(:prioritized_labels)).to match_array([subgroup_label_2] + group_priority_labels + project_priority_labels)
+ end
+ end
+
+ context 'when show_inherited_labels disabled' do
+ before do
+ stub_feature_flags(show_inherited_labels: false)
+ end
+
+ context 'when include_ancestor_groups false' do
+ let(:params) { { namespace_id: project.namespace.to_param, project_id: project } }
+
+ it 'does not return ancestor group labels', :aggregate_failures do
+ get :index, params: params
+
+ expect(assigns(:labels)).to match_array([subgroup_label_1] + project_labels)
+ expect(assigns(:prioritized_labels)).to match_array([subgroup_label_2] + project_priority_labels)
+ end
+ end
+
+ context 'when include_ancestor_groups true' do
+ let(:params) { { namespace_id: project.namespace.to_param, project_id: project, include_ancestor_groups: true } }
+
+ it_behaves_like 'returns ancestor group labels'
+ end
+ end
+
+ context 'when show_inherited_labels enabled' do
+ let(:params) { { namespace_id: project.namespace.to_param, project_id: project } }
+
+ before do
+ stub_feature_flags(show_inherited_labels: true)
+ end
+
+ it_behaves_like 'returns ancestor group labels'
+ end
+ end
+
def list_labels
get :index, params: { namespace_id: project.namespace.to_param, project_id: project }
end
@@ -75,7 +134,7 @@ RSpec.describe Projects::LabelsController do
describe 'POST #generate' do
context 'personal project' do
- let(:personal_project) { create(:project, namespace: user.namespace) }
+ let_it_be(:personal_project) { create(:project, namespace: user.namespace) }
it 'creates labels' do
post :generate, params: { namespace_id: personal_project.namespace.to_param, project_id: personal_project }
@@ -116,8 +175,8 @@ RSpec.describe Projects::LabelsController do
end
describe 'POST #promote' do
- let!(:promoted_label_name) { "Promoted Label" }
- let!(:label_1) { create(:label, title: promoted_label_name, project: project) }
+ let_it_be(:promoted_label_name) { "Promoted Label" }
+ let_it_be(:label_1) { create(:label, title: promoted_label_name, project: project) }
context 'not group reporters' do
it 'denies access' do
@@ -196,7 +255,7 @@ RSpec.describe Projects::LabelsController do
end
context 'when requesting a redirected path' do
- let!(:redirect_route) { project.redirect_routes.create(path: project.full_path + 'old') }
+ let_it_be(:redirect_route) { project.redirect_routes.create(path: project.full_path + 'old') }
it 'redirects to the canonical path' do
get :index, params: { namespace_id: project.namespace, project_id: project.to_param + 'old' }
@@ -242,7 +301,7 @@ RSpec.describe Projects::LabelsController do
end
context 'when requesting a redirected path' do
- let!(:redirect_route) { project.redirect_routes.create(path: project.full_path + 'old') }
+ let_it_be(:redirect_route) { project.redirect_routes.create(path: project.full_path + 'old') }
it 'returns not found' do
post :generate, params: { namespace_id: project.namespace, project_id: project.to_param + 'old' }
diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb
index fa32d32f552..9e5d41b1075 100644
--- a/spec/controllers/projects/milestones_controller_spec.rb
+++ b/spec/controllers/projects/milestones_controller_spec.rb
@@ -17,7 +17,9 @@ RSpec.describe Projects::MilestonesController do
controller.instance_variable_set(:@project, project)
end
- it_behaves_like 'milestone tabs'
+ it_behaves_like 'milestone tabs' do
+ let(:request_params) { { namespace_id: project.namespace, project_id: project, id: milestone.iid } }
+ end
describe "#show" do
render_views
diff --git a/spec/controllers/projects/registry/tags_controller_spec.rb b/spec/controllers/projects/registry/tags_controller_spec.rb
index 6adee35b60a..59df9e78a3c 100644
--- a/spec/controllers/projects/registry/tags_controller_spec.rb
+++ b/spec/controllers/projects/registry/tags_controller_spec.rb
@@ -109,7 +109,7 @@ RSpec.describe Projects::Registry::TagsController do
it 'tracks the event' do
expect_delete_tags(%w[test.])
- expect(controller).to receive(:track_event).with(:delete_tag)
+ expect(controller).to receive(:track_event).with(:delete_tag, {})
destroy_tag('test.')
end
diff --git a/spec/controllers/projects/releases/evidences_controller_spec.rb b/spec/controllers/projects/releases/evidences_controller_spec.rb
index d5a9665d6a5..0ec4cdf2a31 100644
--- a/spec/controllers/projects/releases/evidences_controller_spec.rb
+++ b/spec/controllers/projects/releases/evidences_controller_spec.rb
@@ -113,18 +113,6 @@ RSpec.describe Projects::Releases::EvidencesController do
it_behaves_like 'does not show the issue in evidence'
- context 'when the issue is confidential' do
- let(:issue) { create(:issue, :confidential, project: project) }
-
- it_behaves_like 'does not show the issue in evidence'
- end
-
- context 'when the user is the author of the confidential issue' do
- let(:issue) { create(:issue, :confidential, project: project, author: user) }
-
- it_behaves_like 'does not show the issue in evidence'
- end
-
context 'when project is private' do
let(:project) { create(:project, :repository, :private) }
@@ -143,32 +131,16 @@ RSpec.describe Projects::Releases::EvidencesController do
it_behaves_like 'does not show the issue in evidence'
- context 'when the issue is confidential' do
- let(:issue) { create(:issue, :confidential, project: project) }
-
- it_behaves_like 'does not show the issue in evidence'
- end
-
- context 'when the user is the author of the confidential issue' do
- let(:issue) { create(:issue, :confidential, project: project, author: user) }
-
- it_behaves_like 'does not show the issue in evidence'
- end
-
context 'when project is private' do
let(:project) { create(:project, :repository, :private) }
- it 'returns evidence ' do
- subject
-
- expect(json_response).to eq(evidence.summary)
- end
+ it_behaves_like 'does not show the issue in evidence'
end
context 'when project restricts the visibility of issues to project members only' do
let(:project) { create(:project, :repository, :issues_private) }
- it_behaves_like 'evidence not found'
+ it_behaves_like 'does not show the issue in evidence'
end
end
diff --git a/spec/controllers/projects/runners_controller_spec.rb b/spec/controllers/projects/runners_controller_spec.rb
index 66f20bd50c4..2443a823070 100644
--- a/spec/controllers/projects/runners_controller_spec.rb
+++ b/spec/controllers/projects/runners_controller_spec.rb
@@ -73,4 +73,45 @@ RSpec.describe Projects::RunnersController do
expect(runner.active).to eq(false)
end
end
+
+ describe '#toggle_shared_runners' do
+ let(:group) { create(:group) }
+ let(:project) { create(:project, group: group) }
+
+ it 'toggles shared_runners_enabled when the group allows shared runners' do
+ project.update!(shared_runners_enabled: true)
+
+ post :toggle_shared_runners, params: params
+
+ project.reload
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(project.shared_runners_enabled).to eq(false)
+ end
+
+ it 'toggles shared_runners_enabled when the group disallows shared runners but allows overrides' do
+ group.update!(shared_runners_enabled: false, allow_descendants_override_disabled_shared_runners: true)
+ project.update!(shared_runners_enabled: false)
+
+ post :toggle_shared_runners, params: params
+
+ project.reload
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(project.shared_runners_enabled).to eq(true)
+ end
+
+ it 'does not enable if the group disallows shared runners' do
+ group.update!(shared_runners_enabled: false, allow_descendants_override_disabled_shared_runners: false)
+ project.update!(shared_runners_enabled: false)
+
+ post :toggle_shared_runners, params: params
+
+ project.reload
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(project.shared_runners_enabled).to eq(false)
+ expect(flash[:alert]).to eq("Cannot enable shared runners because parent group does not allow it")
+ end
+ end
end
diff --git a/spec/controllers/projects/serverless/functions_controller_spec.rb b/spec/controllers/projects/serverless/functions_controller_spec.rb
index 7f558ad9231..75135839a06 100644
--- a/spec/controllers/projects/serverless/functions_controller_spec.rb
+++ b/spec/controllers/projects/serverless/functions_controller_spec.rb
@@ -206,7 +206,7 @@ RSpec.describe Projects::Serverless::FunctionsController do
context 'on Knative 0.5.0' do
before do
- prepare_knative_stubs(knative_05_service(knative_stub_options))
+ prepare_knative_stubs(knative_05_service(**knative_stub_options))
end
include_examples 'GET #show with valid data'
@@ -214,7 +214,7 @@ RSpec.describe Projects::Serverless::FunctionsController do
context 'on Knative 0.6.0' do
before do
- prepare_knative_stubs(knative_06_service(knative_stub_options))
+ prepare_knative_stubs(knative_06_service(**knative_stub_options))
end
include_examples 'GET #show with valid data'
@@ -222,7 +222,7 @@ RSpec.describe Projects::Serverless::FunctionsController do
context 'on Knative 0.7.0' do
before do
- prepare_knative_stubs(knative_07_service(knative_stub_options))
+ prepare_knative_stubs(knative_07_service(**knative_stub_options))
end
include_examples 'GET #show with valid data'
@@ -230,7 +230,7 @@ RSpec.describe Projects::Serverless::FunctionsController do
context 'on Knative 0.9.0' do
before do
- prepare_knative_stubs(knative_09_service(knative_stub_options))
+ prepare_knative_stubs(knative_09_service(**knative_stub_options))
end
include_examples 'GET #show with valid data'
@@ -275,7 +275,7 @@ RSpec.describe Projects::Serverless::FunctionsController do
context 'on Knative 0.5.0' do
before do
- prepare_knative_stubs(knative_05_service(knative_stub_options))
+ prepare_knative_stubs(knative_05_service(**knative_stub_options))
end
include_examples 'GET #index with data'
@@ -283,7 +283,7 @@ RSpec.describe Projects::Serverless::FunctionsController do
context 'on Knative 0.6.0' do
before do
- prepare_knative_stubs(knative_06_service(knative_stub_options))
+ prepare_knative_stubs(knative_06_service(**knative_stub_options))
end
include_examples 'GET #index with data'
@@ -291,7 +291,7 @@ RSpec.describe Projects::Serverless::FunctionsController do
context 'on Knative 0.7.0' do
before do
- prepare_knative_stubs(knative_07_service(knative_stub_options))
+ prepare_knative_stubs(knative_07_service(**knative_stub_options))
end
include_examples 'GET #index with data'
@@ -299,7 +299,7 @@ RSpec.describe Projects::Serverless::FunctionsController do
context 'on Knative 0.9.0' do
before do
- prepare_knative_stubs(knative_09_service(knative_stub_options))
+ prepare_knative_stubs(knative_09_service(**knative_stub_options))
end
include_examples 'GET #index with data'
diff --git a/spec/controllers/projects/settings/access_tokens_controller_spec.rb b/spec/controllers/projects/settings/access_tokens_controller_spec.rb
index 4743ab2b7c1..f293027a69a 100644
--- a/spec/controllers/projects/settings/access_tokens_controller_spec.rb
+++ b/spec/controllers/projects/settings/access_tokens_controller_spec.rb
@@ -14,28 +14,21 @@ RSpec.describe Projects::Settings::AccessTokensController do
sign_in(user)
end
- shared_examples 'feature unavailability' do
- context 'when flag is disabled' do
- before do
- stub_feature_flags(resource_access_token: false)
- end
+ shared_examples 'feature unavailable' do
+ let_it_be(:project) { create(:project) }
- it { is_expected.to have_gitlab_http_status(:not_found) }
+ before do
+ allow(Gitlab).to receive(:com?).and_return(false)
+ project.add_developer(user)
end
- context 'when environment is Gitlab.com' do
- before do
- allow(Gitlab).to receive(:com?).and_return(true)
- end
-
- it { is_expected.to have_gitlab_http_status(:not_found) }
- end
+ it { is_expected.to have_gitlab_http_status(:not_found) }
end
describe '#index' do
subject { get :index, params: { namespace_id: project.namespace, project_id: project } }
- it_behaves_like 'feature unavailability'
+ it_behaves_like 'feature unavailable'
context 'when feature is available' do
let_it_be(:bot_user) { create(:user, :project_bot) }
@@ -84,7 +77,7 @@ RSpec.describe Projects::Settings::AccessTokensController do
let_it_be(:access_token_params) { {} }
- it_behaves_like 'feature unavailability'
+ it_behaves_like 'feature unavailable'
context 'when feature is available' do
let_it_be(:access_token_params) { { name: 'Nerd bot', scopes: ["api"], expires_at: 1.month.since.to_date } }
@@ -148,7 +141,7 @@ RSpec.describe Projects::Settings::AccessTokensController do
project.add_maintainer(bot_user)
end
- it_behaves_like 'feature unavailability'
+ it_behaves_like 'feature unavailable'
context 'when feature is available' do
before do
@@ -185,6 +178,5 @@ RSpec.describe Projects::Settings::AccessTokensController do
def enable_feature
allow(Gitlab).to receive(:com?).and_return(false)
- stub_feature_flags(resource_access_token: true)
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 8498ff49826..9e26eca88f2 100644
--- a/spec/controllers/projects/settings/ci_cd_controller_spec.rb
+++ b/spec/controllers/projects/settings/ci_cd_controller_spec.rb
@@ -266,4 +266,21 @@ RSpec.describe Projects::Settings::CiCdController do
end
end
end
+
+ describe 'GET #runner_setup_scripts' do
+ it 'renders the setup scripts' do
+ get :runner_setup_scripts, params: { os: 'linux', arch: 'amd64', namespace_id: project.namespace, project_id: project }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to have_key("install")
+ expect(json_response).to have_key("register")
+ end
+
+ it 'renders errors if they occur' do
+ get :runner_setup_scripts, params: { os: 'foo', arch: 'bar', namespace_id: project.namespace, project_id: project }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to have_key("errors")
+ end
+ end
end
diff --git a/spec/controllers/projects/static_site_editor_controller_spec.rb b/spec/controllers/projects/static_site_editor_controller_spec.rb
index 7883c7e6f81..1cd32177e17 100644
--- a/spec/controllers/projects/static_site_editor_controller_spec.rb
+++ b/spec/controllers/projects/static_site_editor_controller_spec.rb
@@ -5,9 +5,11 @@ require 'spec_helper'
RSpec.describe Projects::StaticSiteEditorController do
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be(:user) { create(:user) }
- let(:data) { instance_double(Hash) }
+ let(:data) { { key: 'value' } }
describe 'GET show' do
+ render_views
+
let(:default_params) do
{
namespace_id: project.namespace,
@@ -50,41 +52,78 @@ RSpec.describe Projects::StaticSiteEditorController do
end
end
- %w[developer maintainer].each do |role|
- context "as #{role}" do
- before_all do
- project.add_role(user, role)
+ context "as developer" do
+ before do
+ project.add_role(user, 'developer')
+ sign_in(user)
+ get :show, params: default_params
+ end
+
+ it 'renders the edit page' do
+ expect(response).to render_template(:show)
+ end
+
+ it 'assigns ref and path variables' do
+ expect(assigns(:ref)).to eq('master')
+ expect(assigns(:path)).to eq('README.md')
+ end
+
+ context 'when combination of ref and path is incorrect' do
+ let(:default_params) { super().merge(id: 'unknown') }
+
+ it 'responds with 404 page' do
+ expect(response).to have_gitlab_http_status(:not_found)
end
+ end
+
+ context 'when invalid config file' do
+ let(:service_response) { ServiceResponse.error(message: 'invalid') }
- before do
- sign_in(user)
- get :show, params: default_params
+ it 'redirects to project page and flashes error message' do
+ expect(response).to redirect_to(project_path(project))
+ expect(response).to set_flash[:alert].to('invalid')
end
+ end
- it 'renders the edit page' do
- expect(response).to render_template(:show)
+ context 'with a service response payload containing multiple data types' do
+ let(:data) do
+ {
+ a_string: 'string',
+ an_array: [
+ {
+ foo: 'bar'
+ }
+ ],
+ an_integer: 123,
+ a_hash: {
+ a_deeper_hash: {
+ foo: 'bar'
+ }
+ },
+ a_boolean: true
+ }
end
- it 'assigns a required variables' do
- expect(assigns(:data)).to eq(data)
- expect(assigns(:ref)).to eq('master')
- expect(assigns(:path)).to eq('README.md')
+ let(:assigns_data) { assigns(:data) }
+
+ it 'leaves data values which are strings as strings' do
+ expect(assigns_data[:a_string]).to eq('string')
end
- context 'when combination of ref and path is incorrect' do
- let(:default_params) { super().merge(id: 'unknown') }
+ it 'leaves data values which are integers as integers' do
+ expect(assigns_data[:an_integer]).to eq(123)
+ end
- it 'responds with 404 page' do
- expect(response).to have_gitlab_http_status(:not_found)
- end
+ it 'serializes data values which are booleans to JSON' do
+ expect(assigns_data[:a_boolean]).to eq('true')
end
- context 'when invalid config file' do
- let(:service_response) { ServiceResponse.error(message: 'invalid') }
+ it 'serializes data values which are arrays to JSON' do
+ expect(assigns_data[:an_array]).to eq('[{"foo":"bar"}]')
+ end
- it 'returns 422' do
- expect(response).to have_gitlab_http_status(:unprocessable_entity)
- end
+ it 'serializes data values which are hashes to JSON' do
+ expect(assigns_data[:a_hash]).to eq('{"a_deeper_hash":{"foo":"bar"}}')
end
end
end
diff --git a/spec/controllers/projects/tags_controller_spec.rb b/spec/controllers/projects/tags_controller_spec.rb
index d213d003bed..57760088183 100644
--- a/spec/controllers/projects/tags_controller_spec.rb
+++ b/spec/controllers/projects/tags_controller_spec.rb
@@ -131,4 +131,25 @@ RSpec.describe Projects::TagsController do
end
end
end
+
+ describe 'DELETE #destroy' do
+ let(:tag) { project.repository.add_tag(user, 'fake-tag', 'master') }
+ let(:request) do
+ delete(:destroy, params: { id: tag.name, namespace_id: project.namespace.to_param, project_id: project })
+ end
+
+ before do
+ project.add_developer(user)
+ sign_in(user)
+ end
+
+ it 'deletes tag' do
+ request
+
+ expect(response).to be_successful
+ expect(response.body).to include("Tag was removed")
+
+ expect(project.repository.find_tag(tag.name)).not_to be_present
+ end
+ end
end
diff --git a/spec/controllers/runner_setup_controller_spec.rb b/spec/controllers/runner_setup_controller_spec.rb
new file mode 100644
index 00000000000..0b237500907
--- /dev/null
+++ b/spec/controllers/runner_setup_controller_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RunnerSetupController do
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ describe 'GET #platforms' do
+ it 'renders the platforms' do
+ get :platforms
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to have_key("windows")
+ expect(json_response).to have_key("kubernetes")
+ end
+ end
+end
diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb
index 688539f2a03..47d234df22b 100644
--- a/spec/controllers/sessions_controller_spec.rb
+++ b/spec/controllers/sessions_controller_spec.rb
@@ -78,6 +78,9 @@ RSpec.describe SessionsController do
end
context 'when using standard authentications' do
+ let(:user) { create(:user) }
+ let(:post_action) { post(:create, params: { user: { login: user.username, password: user.password } }) }
+
context 'invalid password' do
it 'does not authenticate user' do
post(:create, params: { user: { login: 'invalid', password: 'invalid' } })
@@ -87,6 +90,26 @@ RSpec.describe SessionsController do
end
end
+ context 'a blocked user' do
+ it 'does not authenticate the user' do
+ user.block!
+ post_action
+
+ expect(@request.env['warden']).not_to be_authenticated
+ expect(flash[:alert]).to include('Your account has been blocked')
+ end
+ end
+
+ context 'an internal user' do
+ it 'does not authenticate the user' do
+ user.ghost!
+ post_action
+
+ expect(@request.env['warden']).not_to be_authenticated
+ expect(flash[:alert]).to include('Your account does not have the required permission to login')
+ end
+ end
+
context 'when using valid password', :clean_gitlab_redis_shared_state do
let(:user) { create(:user) }
let(:user_params) { { login: user.username, password: user.password } }
diff --git a/spec/factories/alert_management/alerts.rb b/spec/factories/alert_management/alerts.rb
index d931947fff1..d0546657ccf 100644
--- a/spec/factories/alert_management/alerts.rb
+++ b/spec/factories/alert_management/alerts.rb
@@ -100,7 +100,7 @@ FactoryBot.define do
end
trait :prometheus do
- monitoring_tool { Gitlab::AlertManagement::AlertParams::MONITORING_TOOLS[:prometheus] }
+ monitoring_tool { Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus] }
payload do
{
annotations: {
@@ -123,5 +123,17 @@ FactoryBot.define do
with_description
low
end
+
+ trait :from_payload do
+ after(:build) do |alert|
+ alert_params = ::Gitlab::AlertManagement::Payload.parse(
+ alert.project,
+ alert.payload,
+ monitoring_tool: alert.monitoring_tool
+ ).alert_params
+
+ alert.assign_attributes(alert_params)
+ end
+ end
end
end
diff --git a/spec/factories/alert_management/http_integrations.rb b/spec/factories/alert_management/http_integrations.rb
new file mode 100644
index 00000000000..9311cb3e114
--- /dev/null
+++ b/spec/factories/alert_management/http_integrations.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :alert_management_http_integration, class: 'AlertManagement::HttpIntegration' do
+ project
+ active { true }
+ name { 'DataDog' }
+ endpoint_identifier { SecureRandom.hex(4) }
+
+ trait :inactive do
+ active { false }
+ end
+ end
+end
diff --git a/spec/factories/alerting/alert.rb b/spec/factories/alerting/alert.rb
deleted file mode 100644
index 285bb14efa2..00000000000
--- a/spec/factories/alerting/alert.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-FactoryBot.define do
- factory :alerting_alert, class: 'Gitlab::Alerting::Alert' do
- project
- payload { {} }
-
- transient do
- metric_id { nil }
-
- after(:build) do |alert, evaluator|
- unless alert.payload.key?('startsAt')
- alert.payload['startsAt'] = Time.now.rfc3339
- end
-
- if metric_id = evaluator.metric_id
- alert.payload['labels'] ||= {}
- alert.payload['labels']['gitlab_alert_id'] = metric_id.to_s
- end
- end
- end
-
- skip_create
- end
-end
diff --git a/spec/factories/authentication_event.rb b/spec/factories/authentication_event.rb
new file mode 100644
index 00000000000..ff539c6f5c4
--- /dev/null
+++ b/spec/factories/authentication_event.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :authentication_event do
+ user
+ provider { :standard }
+ user_name { 'Jane Doe' }
+ ip_address { '127.0.0.1' }
+ result { :failed }
+ end
+end
diff --git a/spec/factories/ci/build_pending_states.rb b/spec/factories/ci/build_pending_states.rb
index 765b7f005b9..eddd74b1068 100644
--- a/spec/factories/ci/build_pending_states.rb
+++ b/spec/factories/ci/build_pending_states.rb
@@ -3,7 +3,7 @@
FactoryBot.define do
factory :ci_build_pending_state, class: 'Ci::BuildPendingState' do
build factory: :ci_build
- trace_checksum { 'crc32:12345678' }
+ trace_checksum { 'crc32:bc614e' }
state { 'success' }
end
end
diff --git a/spec/factories/ci/build_trace_chunks.rb b/spec/factories/ci/build_trace_chunks.rb
index 7c348f4b7e4..d996b41b648 100644
--- a/spec/factories/ci/build_trace_chunks.rb
+++ b/spec/factories/ci/build_trace_chunks.rb
@@ -53,5 +53,18 @@ FactoryBot.define do
trait :fog_without_data do
data_store { :fog }
end
+
+ trait :persisted do
+ data_store { :database}
+
+ transient do
+ initial_data { 'test data' }
+ end
+
+ after(:build) do |chunk, evaluator|
+ Ci::BuildTraceChunks::Database.new.set_data(chunk, evaluator.initial_data)
+ chunk.checksum = chunk.class.crc32(evaluator.initial_data)
+ end
+ end
end
end
diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb
index 6174bfbfbb7..5fec6dd0d78 100644
--- a/spec/factories/ci/pipelines.rb
+++ b/spec/factories/ci/pipelines.rb
@@ -23,7 +23,7 @@ FactoryBot.define do
factory :ci_pipeline do
transient { ci_ref_presence { true } }
- after(:build) do |pipeline, evaluator|
+ before(:create) do |pipeline, evaluator|
pipeline.ensure_ci_ref! if evaluator.ci_ref_presence && pipeline.ci_ref_id.nil?
end
diff --git a/spec/factories/design_management/designs.rb b/spec/factories/design_management/designs.rb
index 66c33c9ece0..38d0545483c 100644
--- a/spec/factories/design_management/designs.rb
+++ b/spec/factories/design_management/designs.rb
@@ -75,7 +75,7 @@ FactoryBot.define do
end
# Use this trait if you want versions in a particular history, but don't
- # want to pay for gitlay calls.
+ # want to pay for gitaly calls.
trait :with_versions do
transient do
deleted { false }
diff --git a/spec/factories/events.rb b/spec/factories/events.rb
index ecbda5fbfd3..6c9f1ba0137 100644
--- a/spec/factories/events.rb
+++ b/spec/factories/events.rb
@@ -18,6 +18,7 @@ FactoryBot.define do
trait(:destroyed) { action { :destroyed } }
trait(:expired) { action { :expired } }
trait(:archived) { action { :archived } }
+ trait(:approved) { action { :approved } }
factory :closed_issue_event do
action { :closed }
@@ -55,6 +56,16 @@ FactoryBot.define do
action { :created }
target { design }
end
+
+ factory :project_created_event do
+ project factory: :project
+ action { :created }
+ end
+
+ factory :project_imported_event do
+ project factory: [:project, :with_import_url]
+ action { :created }
+ end
end
factory :push_event, class: 'PushEvent' do
diff --git a/spec/factories/group_import_states.rb b/spec/factories/group_import_states.rb
index 0b491d444fa..47d4b480b12 100644
--- a/spec/factories/group_import_states.rb
+++ b/spec/factories/group_import_states.rb
@@ -3,6 +3,7 @@
FactoryBot.define do
factory :group_import_state, class: 'GroupImportState', traits: %i[created] do
association :group, factory: :group
+ association :user, factory: :user
trait :created do
status { 0 }
diff --git a/spec/factories/instance_statistics/measurement.rb b/spec/factories/instance_statistics/measurement.rb
index fb180c23214..f9398cd3061 100644
--- a/spec/factories/instance_statistics/measurement.rb
+++ b/spec/factories/instance_statistics/measurement.rb
@@ -13,5 +13,13 @@ FactoryBot.define do
trait :group_count do
identifier { :groups }
end
+
+ trait :pipelines_succeeded_count do
+ identifier { :pipelines_succeeded }
+ end
+
+ trait :pipelines_skipped_count do
+ identifier { :pipelines_skipped }
+ end
end
end
diff --git a/spec/factories/issue_email_participants.rb b/spec/factories/issue_email_participants.rb
new file mode 100644
index 00000000000..730e224b01e
--- /dev/null
+++ b/spec/factories/issue_email_participants.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :issue_email_participant do
+ issue
+ email { generate(:email) }
+ end
+end
diff --git a/spec/factories/merge_request_diffs.rb b/spec/factories/merge_request_diffs.rb
index fdb7f52f3bd..481cabdae6d 100644
--- a/spec/factories/merge_request_diffs.rb
+++ b/spec/factories/merge_request_diffs.rb
@@ -2,13 +2,7 @@
FactoryBot.define do
factory :merge_request_diff do
- merge_request do
- build(:merge_request) do |merge_request|
- # MergeRequest should not create a MergeRequestDiff in the callback
- allow(merge_request).to receive(:ensure_merge_request_diff)
- end
- end
-
+ association :merge_request, factory: :merge_request_without_merge_request_diff
state { :collected }
commits_count { 1 }
diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb
index 6836d5d71f0..67f547dfbbb 100644
--- a/spec/factories/merge_requests.rb
+++ b/spec/factories/merge_requests.rb
@@ -286,5 +286,7 @@ FactoryBot.define do
merge_request.update!(labels: evaluator.labels)
end
end
+
+ factory :merge_request_without_merge_request_diff, class: 'MergeRequestWithoutMergeRequestDiff'
end
end
diff --git a/spec/factories/namespaces.rb b/spec/factories/namespaces.rb
index 0dcec086da9..0ec977b8234 100644
--- a/spec/factories/namespaces.rb
+++ b/spec/factories/namespaces.rb
@@ -63,5 +63,13 @@ FactoryBot.define do
)
end
end
+
+ trait :shared_runners_disabled do
+ shared_runners_enabled { false }
+ end
+
+ trait :allow_descendants_override_disabled_shared_runners do
+ allow_descendants_override_disabled_shared_runners { true }
+ end
end
end
diff --git a/spec/factories/packages.rb b/spec/factories/packages.rb
index 52b2a32cd3b..3562f57654e 100644
--- a/spec/factories/packages.rb
+++ b/spec/factories/packages.rb
@@ -91,6 +91,12 @@ FactoryBot.define do
end
end
+ factory :golang_package do
+ sequence(:name) { |n| "golang.org/x/pkg-#{n}"}
+ sequence(:version) { |n| "v1.0.#{n}" }
+ package_type { :golang }
+ end
+
factory :conan_package do
conan_metadatum
@@ -141,160 +147,6 @@ FactoryBot.define do
package
end
- factory :package_file, class: 'Packages::PackageFile' do
- package
-
- file_name { 'somefile.txt' }
-
- transient do
- file_fixture { 'spec/fixtures/packages/conan/recipe_files/conanfile.py' }
- end
-
- after(:build) do |package_file, evaluator|
- package_file.file = fixture_file_upload(evaluator.file_fixture)
- end
-
- factory :conan_package_file do
- package { create(:conan_package, without_package_files: true) }
-
- transient do
- without_loaded_metadatum { false }
- end
-
- trait(:conan_recipe_file) do
- after :create do |package_file, evaluator|
- unless evaluator.without_loaded_metadatum
- create :conan_file_metadatum, :recipe_file, package_file: package_file
- end
- end
-
- file_fixture { 'spec/fixtures/packages/conan/recipe_files/conanfile.py' }
- file_name { 'conanfile.py' }
- file_sha1 { 'be93151dc23ac34a82752444556fe79b32c7a1ad' }
- file_md5 { '12345abcde' }
- size { 400.kilobytes }
- end
-
- trait(:conan_recipe_manifest) do
- after :create do |package_file, evaluator|
- unless evaluator.without_loaded_metadatum
- create :conan_file_metadatum, :recipe_file, package_file: package_file
- end
- end
-
- file_fixture { 'spec/fixtures/packages/conan/recipe_files/conanmanifest.txt' }
- file_name { 'conanmanifest.txt' }
- file_sha1 { 'be93151dc23ac34a82752444556fe79b32c7a1ad' }
- file_md5 { '12345abcde' }
- size { 400.kilobytes }
- end
-
- trait(:conan_package_manifest) do
- after :create do |package_file, evaluator|
- unless evaluator.without_loaded_metadatum
- create :conan_file_metadatum, :package_file, package_file: package_file
- end
- end
-
- file_fixture { 'spec/fixtures/packages/conan/package_files/conanmanifest.txt' }
- file_name { 'conanmanifest.txt' }
- file_sha1 { 'be93151dc23ac34a82752444556fe79b32c7a1ad' }
- file_md5 { '12345abcde' }
- size { 400.kilobytes }
- end
-
- trait(:conan_package_info) do
- after :create do |package_file, evaluator|
- unless evaluator.without_loaded_metadatum
- create :conan_file_metadatum, :package_file, package_file: package_file
- end
- end
-
- file_fixture { 'spec/fixtures/packages/conan/package_files/conaninfo.txt' }
- file_name { 'conaninfo.txt' }
- file_sha1 { 'be93151dc23ac34a82752444556fe79b32c7a1ad' }
- file_md5 { '12345abcde' }
- size { 400.kilobytes }
- end
-
- trait(:conan_package) do
- after :create do |package_file, evaluator|
- unless evaluator.without_loaded_metadatum
- create :conan_file_metadatum, :package_file, package_file: package_file
- end
- end
-
- file_fixture { 'spec/fixtures/packages/conan/package_files/conan_package.tgz' }
- file_name { 'conan_package.tgz' }
- file_sha1 { 'be93151dc23ac34a82752444556fe79b32c7a1ad' }
- file_md5 { '12345abcde' }
- size { 400.kilobytes }
- end
- end
-
- trait(:jar) do
- file_fixture { 'spec/fixtures/packages/maven/my-app-1.0-20180724.124855-1.jar' }
- file_name { 'my-app-1.0-20180724.124855-1.jar' }
- file_sha1 { '4f0bfa298744d505383fbb57c554d4f5c12d88b3' }
- size { 100.kilobytes }
- end
-
- trait(:pom) do
- file_fixture { 'spec/fixtures/packages/maven/my-app-1.0-20180724.124855-1.pom' }
- file_name { 'my-app-1.0-20180724.124855-1.pom' }
- file_sha1 { '19c975abd49e5102ca6c74a619f21e0cf0351c57' }
- size { 200.kilobytes }
- end
-
- trait(:xml) do
- file_fixture { 'spec/fixtures/packages/maven/maven-metadata.xml' }
- file_name { 'maven-metadata.xml' }
- file_sha1 { '42b1bdc80de64953b6876f5a8c644f20204011b0' }
- size { 300.kilobytes }
- end
-
- trait(:npm) do
- file_fixture { 'spec/fixtures/packages/npm/foo-1.0.1.tgz' }
- file_name { 'foo-1.0.1.tgz' }
- file_sha1 { 'be93151dc23ac34a82752444556fe79b32c7a1ad' }
- verified_at { Date.current }
- verification_checksum { '4437b5775e61455588a7e5187a2e5c58c680694260bbe5501c235ec690d17f83' }
- size { 400.kilobytes }
- end
-
- trait(:nuget) do
- package
- file_fixture { 'spec/fixtures/packages/nuget/package.nupkg' }
- file_name { 'package.nupkg' }
- file_sha1 { '5fe852b2a6abd96c22c11fa1ff2fb19d9ce58b57' }
- size { 300.kilobytes }
- end
-
- trait(:pypi) do
- package
- file_fixture { 'spec/fixtures/packages/pypi/sample-project.tar.gz' }
- file_name { 'sample-project-1.0.0.tar.gz' }
- file_sha1 { '2c0cfbed075d3fae226f051f0cc771b533e01aff' }
- file_md5 { '0a7392d24f42f83068fa3767c5310052' }
- file_sha256 { '440e5e148a25331bbd7991575f7d54933c0ebf6cc735a18ee5066ac1381bb590' }
- size { 1149.bytes }
- end
-
- trait(:object_storage) do
- file_store { Packages::PackageFileUploader::Store::REMOTE }
- end
-
- trait(:checksummed) do
- verification_checksum { 'abc' }
- end
-
- trait(:checksum_failure) do
- verification_failure { 'Could not calculate the checksum' }
- end
-
- factory :package_file_with_file, traits: [:jar]
- end
-
factory :maven_metadatum, class: 'Packages::Maven::Metadatum' do
association :package, package_type: :maven
path { 'my/company/app/my-app/1.0-SNAPSHOT' }
diff --git a/spec/factories/packages/package_file.rb b/spec/factories/packages/package_file.rb
new file mode 100644
index 00000000000..bcca48fb086
--- /dev/null
+++ b/spec/factories/packages/package_file.rb
@@ -0,0 +1,165 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :package_file, class: 'Packages::PackageFile' do
+ package
+
+ file_name { 'somefile.txt' }
+
+ transient do
+ file_fixture { 'spec/fixtures/packages/conan/recipe_files/conanfile.py' }
+ end
+
+ after(:build) do |package_file, evaluator|
+ package_file.file = fixture_file_upload(evaluator.file_fixture)
+ end
+
+ factory :conan_package_file do
+ package { create(:conan_package, without_package_files: true) }
+
+ transient do
+ without_loaded_metadatum { false }
+ end
+
+ trait(:conan_recipe_file) do
+ after :create do |package_file, evaluator|
+ unless evaluator.without_loaded_metadatum
+ create :conan_file_metadatum, :recipe_file, package_file: package_file
+ end
+ end
+
+ file_fixture { 'spec/fixtures/packages/conan/recipe_files/conanfile.py' }
+ file_name { 'conanfile.py' }
+ file_sha1 { 'be93151dc23ac34a82752444556fe79b32c7a1ad' }
+ file_md5 { '12345abcde' }
+ size { 400.kilobytes }
+ end
+
+ trait(:conan_recipe_manifest) do
+ after :create do |package_file, evaluator|
+ unless evaluator.without_loaded_metadatum
+ create :conan_file_metadatum, :recipe_file, package_file: package_file
+ end
+ end
+
+ file_fixture { 'spec/fixtures/packages/conan/recipe_files/conanmanifest.txt' }
+ file_name { 'conanmanifest.txt' }
+ file_sha1 { 'be93151dc23ac34a82752444556fe79b32c7a1ad' }
+ file_md5 { '12345abcde' }
+ size { 400.kilobytes }
+ end
+
+ trait(:conan_package_manifest) do
+ after :create do |package_file, evaluator|
+ unless evaluator.without_loaded_metadatum
+ create :conan_file_metadatum, :package_file, package_file: package_file
+ end
+ end
+
+ file_fixture { 'spec/fixtures/packages/conan/package_files/conanmanifest.txt' }
+ file_name { 'conanmanifest.txt' }
+ file_sha1 { 'be93151dc23ac34a82752444556fe79b32c7a1ad' }
+ file_md5 { '12345abcde' }
+ size { 400.kilobytes }
+ end
+
+ trait(:conan_package_info) do
+ after :create do |package_file, evaluator|
+ unless evaluator.without_loaded_metadatum
+ create :conan_file_metadatum, :package_file, package_file: package_file
+ end
+ end
+
+ file_fixture { 'spec/fixtures/packages/conan/package_files/conaninfo.txt' }
+ file_name { 'conaninfo.txt' }
+ file_sha1 { 'be93151dc23ac34a82752444556fe79b32c7a1ad' }
+ file_md5 { '12345abcde' }
+ size { 400.kilobytes }
+ end
+
+ trait(:conan_package) do
+ after :create do |package_file, evaluator|
+ unless evaluator.without_loaded_metadatum
+ create :conan_file_metadatum, :package_file, package_file: package_file
+ end
+ end
+
+ file_fixture { 'spec/fixtures/packages/conan/package_files/conan_package.tgz' }
+ file_name { 'conan_package.tgz' }
+ file_sha1 { 'be93151dc23ac34a82752444556fe79b32c7a1ad' }
+ file_md5 { '12345abcde' }
+ size { 400.kilobytes }
+ end
+ end
+
+ trait(:jar) do
+ file_fixture { 'spec/fixtures/packages/maven/my-app-1.0-20180724.124855-1.jar' }
+ file_name { 'my-app-1.0-20180724.124855-1.jar' }
+ file_sha1 { '4f0bfa298744d505383fbb57c554d4f5c12d88b3' }
+ size { 100.kilobytes }
+ end
+
+ trait(:pom) do
+ file_fixture { 'spec/fixtures/packages/maven/my-app-1.0-20180724.124855-1.pom' }
+ file_name { 'my-app-1.0-20180724.124855-1.pom' }
+ file_sha1 { '19c975abd49e5102ca6c74a619f21e0cf0351c57' }
+ size { 200.kilobytes }
+ end
+
+ trait(:xml) do
+ file_fixture { 'spec/fixtures/packages/maven/maven-metadata.xml' }
+ file_name { 'maven-metadata.xml' }
+ file_sha1 { '42b1bdc80de64953b6876f5a8c644f20204011b0' }
+ size { 300.kilobytes }
+ end
+
+ trait(:npm) do
+ file_fixture { 'spec/fixtures/packages/npm/foo-1.0.1.tgz' }
+ file_name { 'foo-1.0.1.tgz' }
+ file_sha1 { 'be93151dc23ac34a82752444556fe79b32c7a1ad' }
+ verified_at { Date.current }
+ verification_checksum { '4437b5775e61455588a7e5187a2e5c58c680694260bbe5501c235ec690d17f83' }
+ size { 400.kilobytes }
+ end
+
+ trait(:nuget) do
+ package
+ file_fixture { 'spec/fixtures/packages/nuget/package.nupkg' }
+ file_name { 'package.nupkg' }
+ file_sha1 { '5fe852b2a6abd96c22c11fa1ff2fb19d9ce58b57' }
+ size { 300.kilobytes }
+ end
+
+ trait(:pypi) do
+ package
+ file_fixture { 'spec/fixtures/packages/pypi/sample-project.tar.gz' }
+ file_name { 'sample-project-1.0.0.tar.gz' }
+ file_sha1 { '2c0cfbed075d3fae226f051f0cc771b533e01aff' }
+ file_md5 { '0a7392d24f42f83068fa3767c5310052' }
+ file_sha256 { '440e5e148a25331bbd7991575f7d54933c0ebf6cc735a18ee5066ac1381bb590' }
+ size { 1149.bytes }
+ end
+
+ trait(:generic) do
+ package
+ file_fixture { 'spec/fixtures/packages/generic/myfile.tar.gz' }
+ file_name { "#{package.name}.tar.gz" }
+ file_sha256 { '440e5e148a25331bbd7991575f7d54933c0ebf6cc735a18ee5066ac1381bb590' }
+ size { 1149.bytes }
+ end
+
+ trait(:object_storage) do
+ file_store { Packages::PackageFileUploader::Store::REMOTE }
+ end
+
+ trait(:checksummed) do
+ verification_checksum { 'abc' }
+ end
+
+ trait(:checksum_failure) do
+ verification_failure { 'Could not calculate the checksum' }
+ end
+
+ factory :package_file_with_file, traits: [:jar]
+ end
+end
diff --git a/spec/factories/pages_deployments.rb b/spec/factories/pages_deployments.rb
index 1bea003d683..20b8e4782df 100644
--- a/spec/factories/pages_deployments.rb
+++ b/spec/factories/pages_deployments.rb
@@ -4,9 +4,12 @@ FactoryBot.define do
factory :pages_deployment, class: 'PagesDeployment' do
project
file_store { ObjectStorage::SUPPORTED_STORES.first }
- size { 1.megabytes }
- # TODO: replace with proper file uploaded in https://gitlab.com/gitlab-org/gitlab/-/issues/245295
- file { "dummy string" }
+ after(:build) do |deployment, _evaluator|
+ deployment.file = fixture_file_upload(
+ Rails.root.join("spec/fixtures/pages.zip")
+ )
+ deployment.size = deployment.file.size
+ end
end
end
diff --git a/spec/factories/project_tracing_settings.rb b/spec/factories/project_tracing_settings.rb
new file mode 100644
index 00000000000..05c1529c18e
--- /dev/null
+++ b/spec/factories/project_tracing_settings.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :project_tracing_setting do
+ project
+ external_url { 'https://example.com' }
+ end
+end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index 147413557d6..091c9d5a245 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -285,6 +285,12 @@ FactoryBot.define do
end
end
+ trait :with_import_url do
+ import_finished
+
+ import_url { generate(:url) }
+ end
+
trait(:wiki_enabled) { wiki_access_level { ProjectFeature::ENABLED } }
trait(:wiki_disabled) { wiki_access_level { ProjectFeature::DISABLED } }
trait(:wiki_private) { wiki_access_level { ProjectFeature::PRIVATE } }
diff --git a/spec/factories/prometheus_metrics.rb b/spec/factories/prometheus_metrics.rb
index 83e3845f1c3..503d392a524 100644
--- a/spec/factories/prometheus_metrics.rb
+++ b/spec/factories/prometheus_metrics.rb
@@ -9,6 +9,7 @@ FactoryBot.define do
group { :business }
project
legend { 'legend' }
+ dashboard_path { '.gitlab/dashboards/dashboard_path.yml'}
trait :common do
common { true }
diff --git a/spec/factories/services.rb b/spec/factories/services.rb
index 9056fd97f13..13997080817 100644
--- a/spec/factories/services.rb
+++ b/spec/factories/services.rb
@@ -81,7 +81,7 @@ FactoryBot.define do
project_key { nil }
end
- after(:build) do |service, evaluator|
+ before(:create) do |service, evaluator|
if evaluator.create_data
create(:jira_tracker_data, service: service,
url: evaluator.url, api_url: evaluator.api_url, jira_issue_transition_id: evaluator.jira_issue_transition_id,
@@ -130,7 +130,7 @@ FactoryBot.define do
new_issue_url { 'http://new-issue.example.com' }
end
- after(:build) do |service, evaluator|
+ before(:create) do |service, evaluator|
if evaluator.create_data
create(:issue_tracker_data, service: service,
project_url: evaluator.project_url, issues_url: evaluator.issues_url, new_issue_url: evaluator.new_issue_url
@@ -151,7 +151,7 @@ FactoryBot.define do
project_identifier_code { 'PRJ-1' }
end
- after(:build) do |service, evaluator|
+ before(:create) do |service, evaluator|
create(:open_project_tracker_data, service: service,
url: evaluator.url, api_url: evaluator.api_url, token: evaluator.token,
closed_status_id: evaluator.closed_status_id, project_identifier_code: evaluator.project_identifier_code
diff --git a/spec/factories/terraform/state.rb b/spec/factories/terraform/state.rb
index 9decc89ef39..d80c1315e28 100644
--- a/spec/factories/terraform/state.rb
+++ b/spec/factories/terraform/state.rb
@@ -17,16 +17,6 @@ FactoryBot.define do
locked_by_user { create(:user) }
end
- trait(:checksummed) do
- with_file
- verification_checksum { 'abc' }
- end
-
- trait(:checksum_failure) do
- with_file
- verification_failure { 'Could not calculate the checksum' }
- end
-
trait :with_version do
after(:create) do |state|
create(:terraform_state_version, :with_file, terraform_state: state)
diff --git a/spec/factories/terraform/state_version.rb b/spec/factories/terraform/state_version.rb
index d1bd78215e3..b45bd01fd3c 100644
--- a/spec/factories/terraform/state_version.rb
+++ b/spec/factories/terraform/state_version.rb
@@ -7,5 +7,13 @@ FactoryBot.define do
sequence(:version)
file { fixture_file_upload('spec/fixtures/terraform/terraform.tfstate', 'application/json') }
+
+ trait(:checksummed) do
+ verification_checksum { 'abc' }
+ end
+
+ trait(:checksum_failure) do
+ verification_failure { 'Could not calculate the checksum' }
+ end
end
end
diff --git a/spec/factories/todos.rb b/spec/factories/todos.rb
index 0b5d00cff67..97a1265c46a 100644
--- a/spec/factories/todos.rb
+++ b/spec/factories/todos.rb
@@ -12,6 +12,10 @@ FactoryBot.define do
action { Todo::ASSIGNED }
end
+ trait :review_requested do
+ action { Todo::REVIEW_REQUESTED }
+ end
+
trait :mentioned do
action { Todo::MENTIONED }
end
diff --git a/spec/factories/usage_data.rb b/spec/factories/usage_data.rb
index 5b20205a235..2f7c6fde3e4 100644
--- a/spec/factories/usage_data.rb
+++ b/spec/factories/usage_data.rb
@@ -50,6 +50,9 @@ FactoryBot.define do
create(:protected_branch, project: projects[0])
create(:protected_branch, name: 'main', project: projects[0])
+ # Tracing
+ create(:project_tracing_setting, project: projects[0])
+
# Incident Labeled Issues
incident_label = create(:label, :incident, project: projects[0])
create(:labeled_issue, project: projects[0], labels: [incident_label])
@@ -97,16 +100,19 @@ FactoryBot.define do
create(:grafana_integration, project: projects[1], enabled: true)
create(:grafana_integration, project: projects[2], enabled: false)
- create(:package, project: projects[0])
- create(:package, project: projects[0])
- create(:package, project: projects[1])
+ create(:package, project: projects[0], created_at: 3.days.ago)
+ create(:package, project: projects[0], created_at: 3.days.ago)
+ create(:package, project: projects[1], created_at: 3.days.ago)
create(:package, created_at: 2.months.ago, project: projects[1])
+ # User Preferences
+ create(:user_preference, gitpod_enabled: true)
+
ProjectFeature.first.update_attribute('repository_access_level', 0)
# Create fresh & a month (28-days SMAU) old data
env = create(:environment, project: projects[3])
- [2, 29].each do |n|
+ [3, 31].each do |n|
deployment_options = { created_at: n.days.ago, project: env.project, environment: env }
create(:deployment, :failed, deployment_options)
create(:deployment, :success, deployment_options)
diff --git a/spec/factories/wiki_pages.rb b/spec/factories/wiki_pages.rb
index cc866d336a4..3397277839e 100644
--- a/spec/factories/wiki_pages.rb
+++ b/spec/factories/wiki_pages.rb
@@ -9,7 +9,7 @@ FactoryBot.define do
content { 'Content for wiki page' }
format { :markdown }
message { nil }
- project { association(:project, :wiki_repo) }
+ project { association(:project) }
container { project }
wiki { association(:wiki, container: container) }
page { OpenStruct.new(url_path: title) }
@@ -18,6 +18,7 @@ FactoryBot.define do
initialize_with do
new(wiki, page).tap do |page|
page.attributes = {
+ slug: title&.tr(' ', '-'),
title: title,
content: content,
format: format
diff --git a/spec/factories/wikis.rb b/spec/factories/wikis.rb
index 96578fdcee6..86d98bfd756 100644
--- a/spec/factories/wikis.rb
+++ b/spec/factories/wikis.rb
@@ -3,8 +3,8 @@
FactoryBot.define do
factory :wiki do
transient do
- container { association(:project, :wiki_repo) }
- user { association(:user) }
+ container { association(:project) }
+ user { container.default_owner || association(:user) }
end
initialize_with { Wiki.for_container(container, user) }
@@ -12,7 +12,7 @@ FactoryBot.define do
factory :project_wiki do
transient do
- project { association(:project, :wiki_repo) }
+ project { association(:project) }
end
container { project }
diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb
index 38f0b813183..e1df1c69351 100644
--- a/spec/features/admin/admin_settings_spec.rb
+++ b/spec/features/admin/admin_settings_spec.rb
@@ -130,6 +130,38 @@ RSpec.describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_n
expect(user_internal_regex['placeholder']).to eq 'Regex pattern'
end
+ context 'Change Sign-up restrictions' do
+ context 'Require Admin approval for new signup setting' do
+ context 'when feature is enabled' do
+ before do
+ stub_feature_flags(admin_approval_for_new_user_signups: true)
+ end
+
+ it 'changes the setting' do
+ page.within('.as-signup') do
+ check 'Require admin approval for new sign-ups'
+ click_button 'Save changes'
+ end
+
+ expect(current_settings.require_admin_approval_after_user_signup).to be_truthy
+ expect(page).to have_content "Application settings saved successfully"
+ end
+ end
+
+ context 'when feature is disabled' do
+ before do
+ stub_feature_flags(admin_approval_for_new_user_signups: false)
+ end
+
+ it 'does not show the the setting' do
+ page.within('.as-signup') do
+ expect(page).not_to have_selector('.application_setting_require_admin_approval_after_user_signup')
+ end
+ end
+ end
+ end
+ end
+
it 'Change Sign-in restrictions' do
page.within('.as-signin') do
fill_in 'Home page URL', with: 'https://about.gitlab.com/'
diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb
index a37210d2acc..8b7750ab4ec 100644
--- a/spec/features/admin/admin_users_spec.rb
+++ b/spec/features/admin/admin_users_spec.rb
@@ -31,6 +31,7 @@ RSpec.describe "Admin::Users" do
expect(page).to have_content(current_user.last_activity_on.strftime("%e %b, %Y"))
expect(page).to have_content(user.email)
expect(page).to have_content(user.name)
+ expect(page).to have_content('Projects')
expect(page).to have_button('Block')
expect(page).to have_button('Deactivate')
expect(page).to have_button('Delete user')
@@ -48,6 +49,19 @@ RSpec.describe "Admin::Users" do
end
end
+ context 'user project count' do
+ before do
+ project = create(:project)
+ project.add_maintainer(current_user)
+ end
+
+ it 'displays count of users projects' do
+ visit admin_users_path
+
+ expect(page.find("[data-testid='user-project-count-#{current_user.id}']").text).to eq("1")
+ end
+ end
+
describe 'search and sort' do
before do
create(:user, name: 'Foo Bar', last_activity_on: 3.days.ago)
@@ -606,7 +620,7 @@ RSpec.describe "Admin::Users" do
end
end
- describe 'show user keys' do
+ describe 'show user keys', :js do
let!(:key1) do
create(:key, user: user, title: "ssh-rsa Key1", key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC4FIEBXGi4bPU8kzxMefudPIJ08/gNprdNTaO9BR/ndy3+58s2HCTw2xCHcsuBmq+TsAqgEidVq4skpqoTMB+Uot5Uzp9z4764rc48dZiI661izoREoKnuRQSsRqUTHg5wrLzwxlQbl1MVfRWQpqiz/5KjBC7yLEb9AbusjnWBk8wvC1bQPQ1uLAauEA7d836tgaIsym9BrLsMVnR4P1boWD3Xp1B1T/ImJwAGHvRmP/ycIqmKdSpMdJXwxcb40efWVj0Ibbe7ii9eeoLdHACqevUZi6fwfbymdow+FeqlkPoHyGg3Cu4vD/D8+8cRc7mE/zGCWcQ15Var83Tczour Key1")
end
@@ -629,7 +643,11 @@ RSpec.describe "Admin::Users" do
expect(page).to have_content(key2.title)
expect(page).to have_content(key2.key)
- click_link 'Remove'
+ click_button 'Delete'
+
+ page.within('.modal') do
+ page.click_button('Delete')
+ end
expect(page).not_to have_content(key2.title)
end
diff --git a/spec/features/admin/admin_uses_repository_checks_spec.rb b/spec/features/admin/admin_uses_repository_checks_spec.rb
index b8851c28531..44642983a36 100644
--- a/spec/features/admin/admin_uses_repository_checks_spec.rb
+++ b/spec/features/admin/admin_uses_repository_checks_spec.rb
@@ -46,7 +46,7 @@ RSpec.describe 'Admin uses repository checks', :request_store, :clean_gitlab_red
)
visit_admin_project_page(project)
- page.within('.alert') do
+ page.within('.gl-alert') do
expect(page.text).to match(/Last repository check \(just now\) failed/)
end
end
diff --git a/spec/features/admin/clusters/eks_spec.rb b/spec/features/admin/clusters/eks_spec.rb
index ef49aebc7c5..ad7122bf182 100644
--- a/spec/features/admin/clusters/eks_spec.rb
+++ b/spec/features/admin/clusters/eks_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe 'Instance-level AWS EKS Cluster', :js do
before do
visit admin_clusters_path
- click_link 'Add Kubernetes cluster'
+ click_link 'Integrate with a cluster certificate'
end
context 'when user creates a cluster on AWS EKS' do
diff --git a/spec/features/admin/dashboard_spec.rb b/spec/features/admin/dashboard_spec.rb
index 4ffa5e3be0b..acb8fb54e11 100644
--- a/spec/features/admin/dashboard_spec.rb
+++ b/spec/features/admin/dashboard_spec.rb
@@ -28,11 +28,9 @@ RSpec.describe 'admin visits dashboard' do
describe 'Users statistic' do
let_it_be(:users_statistics) { create(:users_statistics) }
+ let_it_be(:users_count_label) { Gitlab.ee? ? 'Billable users 71' : 'Active users 71' }
it 'shows correct amounts of users', :aggregate_failures do
- expected_active_users_text = Gitlab.ee? ? 'Active users (Billable users) 71' : 'Active users 71'
-
- sign_in(create(:admin))
visit admin_dashboard_stats_path
expect(page).to have_content('Users without a Group and Project 23')
@@ -42,9 +40,9 @@ RSpec.describe 'admin visits dashboard' do
expect(page).to have_content('Users with highest role Maintainer 6')
expect(page).to have_content('Users with highest role Owner 5')
expect(page).to have_content('Bots 2')
- expect(page).to have_content(expected_active_users_text)
expect(page).to have_content('Blocked users 7')
expect(page).to have_content('Total users 78')
+ expect(page).to have_content(users_count_label)
end
end
end
diff --git a/spec/features/boards/add_issues_modal_spec.rb b/spec/features/boards/add_issues_modal_spec.rb
index d432825e113..d4a26ba0098 100644
--- a/spec/features/boards/add_issues_modal_spec.rb
+++ b/spec/features/boards/add_issues_modal_spec.rb
@@ -79,7 +79,7 @@ RSpec.describe 'Issue Boards add issue modal', :js do
it 'loads issues' do
page.within('.add-issues-modal') do
- page.within('.nav-links') do
+ page.within('.gl-tabs') do
expect(page).to have_content('2')
end
@@ -146,7 +146,7 @@ RSpec.describe 'Issue Boards add issue modal', :js do
page.within('.add-issues-modal') do
first('.board-card .board-card-number').click
- page.within('.nav-links') do
+ page.within('.gl-tabs') do
expect(page).to have_content('Selected issues 1')
end
end
diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb
index e36378bd34e..eb8c1826dfd 100644
--- a/spec/features/boards/boards_spec.rb
+++ b/spec/features/boards/boards_spec.rb
@@ -24,33 +24,11 @@ RSpec.describe 'Issue Boards', :js do
context 'no lists' do
before do
visit project_board_path(project, board)
- wait_for_requests
- expect(page).to have_selector('.board', count: 3)
- end
-
- it 'shows blank state' do
- expect(page).to have_content('Welcome to your Issue Board!')
- end
-
- it 'shows tooltip on add issues button' do
- button = page.find('.filter-dropdown-container button', text: 'Add issues')
-
- expect(button[:"data-original-title"]).to eq("Please add a list to your board first")
- end
-
- it 'hides the blank state when clicking nevermind button' do
- page.within(find('.board-blank-state')) do
- click_button("Nevermind, I'll use my own")
- end
- expect(page).to have_selector('.board', count: 2)
end
it 'creates default lists' do
lists = ['Open', 'To Do', 'Doing', 'Closed']
- page.within(find('.board-blank-state')) do
- click_button('Add default lists')
- end
wait_for_requests
expect(page).to have_selector('.board', count: 4)
diff --git a/spec/features/calendar_spec.rb b/spec/features/calendar_spec.rb
index 346f305f0d0..5f58fa420fb 100644
--- a/spec/features/calendar_spec.rb
+++ b/spec/features/calendar_spec.rb
@@ -180,7 +180,7 @@ RSpec.describe 'Contributions Calendar', :js do
before do
push_code_contribution
- Timecop.freeze(Date.yesterday) do
+ travel_to(Date.yesterday) do
Issues::CreateService.new(contributed_project, user, issue_params).execute
end
end
diff --git a/spec/features/clusters/cluster_detail_page_spec.rb b/spec/features/clusters/cluster_detail_page_spec.rb
index 4f7f62d00a5..31d6bcda9e8 100644
--- a/spec/features/clusters/cluster_detail_page_spec.rb
+++ b/spec/features/clusters/cluster_detail_page_spec.rb
@@ -87,6 +87,7 @@ RSpec.describe 'Clusterable > Show page' do
within('#advanced-settings-section') do
expect(page).to have_content('Google Kubernetes Engine')
expect(page).to have_content('Manage your Kubernetes cluster by visiting')
+ expect_common_advanced_options
end
end
end
@@ -117,6 +118,7 @@ RSpec.describe 'Clusterable > Show page' do
within('#advanced-settings-section') do
expect(page).not_to have_content('Google Kubernetes Engine')
expect(page).not_to have_content('Manage your Kubernetes cluster by visiting')
+ expect_common_advanced_options
end
end
end
@@ -176,4 +178,14 @@ RSpec.describe 'Clusterable > Show page' do
let(:cluster) { create(:cluster, :provided_by_user, :instance) }
end
end
+
+ private
+
+ def expect_common_advanced_options
+ aggregate_failures do
+ expect(page).to have_content('Cluster management project')
+ expect(page).to have_content('Clear cluster cache')
+ expect(page).to have_content('Remove Kubernetes cluster integration')
+ end
+ end
end
diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb
index e66a40720da..97ee891dbb8 100644
--- a/spec/features/commits_spec.rb
+++ b/spec/features/commits_spec.rb
@@ -140,6 +140,7 @@ RSpec.describe 'Commits' do
context 'when accessing internal project with disallowed access', :js do
before do
+ stub_feature_flags(graphql_pipeline_header: false)
project.update(
visibility_level: Gitlab::VisibilityLevel::INTERNAL,
public_builds: false)
diff --git a/spec/features/dashboard/todos/todos_filtering_spec.rb b/spec/features/dashboard/todos/todos_filtering_spec.rb
index f60b07c976e..b1464af4194 100644
--- a/spec/features/dashboard/todos/todos_filtering_spec.rb
+++ b/spec/features/dashboard/todos/todos_filtering_spec.rb
@@ -130,6 +130,7 @@ RSpec.describe 'Dashboard > User filters todos', :js do
before do
create(:todo, :build_failed, user: user_1, author: user_2, project: project_1)
create(:todo, :marked, user: user_1, author: user_2, project: project_1, target: issue1)
+ create(:todo, :review_requested, user: user_1, author: user_2, project: project_1, target: issue1)
end
it 'filters by Assigned' do
@@ -138,6 +139,12 @@ RSpec.describe 'Dashboard > User filters todos', :js do
expect_to_see_action(:assigned)
end
+ it 'filters by Review Requested' do
+ filter_action('Review requested')
+
+ expect_to_see_action(:review_requested)
+ end
+
it 'filters by Mentioned' do
filter_action('Mentioned')
@@ -168,6 +175,7 @@ RSpec.describe 'Dashboard > User filters todos', :js do
def expect_to_see_action(action_name)
action_names = {
assigned: ' assigned you ',
+ review_requested: ' requested a review of ',
mentioned: ' mentioned ',
marked: ' added a todo for ',
build_failed: ' build failed for '
diff --git a/spec/features/dashboard/todos/todos_spec.rb b/spec/features/dashboard/todos/todos_spec.rb
index cf773d2caed..7243b5d3483 100644
--- a/spec/features/dashboard/todos/todos_spec.rb
+++ b/spec/features/dashboard/todos/todos_spec.rb
@@ -197,6 +197,21 @@ RSpec.describe 'Dashboard Todos' do
end
end
end
+
+ context 'review request todo' do
+ let(:merge_request) { create(:merge_request, title: "Fixes issue") }
+
+ before do
+ create(:todo, :review_requested, user: user, project: project, target: merge_request, author: user)
+ visit dashboard_todos_path
+ end
+
+ it 'shows you set yourself as an reviewer message' do
+ page.within('.js-todos-all') do
+ expect(page).to have_content("You requested a review of merge request #{merge_request.to_reference} \"Fixes issue\" at #{project.namespace.owner_name} / #{project.name} from yourself")
+ end
+ end
+ end
end
context 'User has done todos', :js do
diff --git a/spec/features/expand_collapse_diffs_spec.rb b/spec/features/expand_collapse_diffs_spec.rb
index e705f2916da..49343cc7a57 100644
--- a/spec/features/expand_collapse_diffs_spec.rb
+++ b/spec/features/expand_collapse_diffs_spec.rb
@@ -7,6 +7,7 @@ RSpec.describe 'Expand and collapse diffs', :js do
let(:project) { create(:project, :repository) }
before do
+ stub_feature_flags(increased_diff_limits: false)
sign_in(create(:admin))
# Ensure that undiffable.md is in .gitattributes
diff --git a/spec/features/groups/clusters/eks_spec.rb b/spec/features/groups/clusters/eks_spec.rb
index 5a62741250a..c361c502cbb 100644
--- a/spec/features/groups/clusters/eks_spec.rb
+++ b/spec/features/groups/clusters/eks_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe 'Group AWS EKS Cluster', :js do
before do
visit group_clusters_path(group)
- click_link 'Add Kubernetes cluster'
+ click_link 'Integrate with a cluster certificate'
end
context 'when user creates a cluster on AWS EKS' do
diff --git a/spec/features/groups/clusters/user_spec.rb b/spec/features/groups/clusters/user_spec.rb
index 90253451d6b..97f8864aab2 100644
--- a/spec/features/groups/clusters/user_spec.rb
+++ b/spec/features/groups/clusters/user_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe 'User Cluster', :js do
before do
visit group_clusters_path(group)
- click_link 'Add Kubernetes cluster'
+ click_link 'Integrate with a cluster certificate'
click_link 'Connect existing cluster'
end
@@ -66,6 +66,10 @@ RSpec.describe 'User Cluster', :js do
expect(page.find_field('cluster[platform_kubernetes_attributes][authorization_type]', disabled: true)).to be_checked
end
end
+
+ it 'user sees namespace per environment is enabled by default' do
+ expect(page).to have_checked_field('Namespace per environment')
+ end
end
context 'when user filled form with invalid parameters' do
@@ -125,7 +129,7 @@ RSpec.describe 'User Cluster', :js do
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')
+ expect(page).to have_link('Integrate with a cluster certificate')
end
end
end
diff --git a/spec/features/groups/members/manage_groups_spec.rb b/spec/features/groups/members/manage_groups_spec.rb
index e3bbbd4d73b..8a7d997d5ac 100644
--- a/spec/features/groups/members/manage_groups_spec.rb
+++ b/spec/features/groups/members/manage_groups_spec.rb
@@ -6,62 +6,115 @@ RSpec.describe 'Groups > Members > Manage groups', :js do
include Select2Helper
include Spec::Support::Helpers::Features::ListRowsHelpers
- let(:user) { create(:user) }
- let(:shared_with_group) { create(:group) }
- let(:shared_group) { create(:group) }
+ let_it_be(:user) { create(:user) }
before do
stub_feature_flags(vue_group_members_list: false)
- shared_group.add_owner(user)
sign_in(user)
end
- it 'add group to group' do
- visit group_group_members_path(shared_group)
+ context 'when group link does not exist' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:group_to_add) { create(:group) }
- add_group(shared_with_group.id, 'Reporter')
+ before do
+ group.add_owner(user)
+ visit group_group_members_path(group)
+ end
- click_groups_tab
+ it 'add group to group' do
+ add_group(group_to_add.id, 'Reporter')
- page.within(first_row) do
- expect(page).to have_content(shared_with_group.name)
- expect(page).to have_content('Reporter')
+ click_groups_tab
+
+ page.within(first_row) do
+ expect(page).to have_content(group_to_add.name)
+ expect(page).to have_content('Reporter')
+ end
end
end
- it 'remove group from group' do
- create(:group_group_link, shared_group: shared_group,
- shared_with_group: shared_with_group, group_access: ::Gitlab::Access::DEVELOPER)
+ context 'when group link exists' do
+ let_it_be(:shared_with_group) { create(:group) }
+ let_it_be(:shared_group) { create(:group) }
- visit group_group_members_path(shared_group)
+ let(:additional_link_attrs) { {} }
- click_groups_tab
+ let_it_be(:group_link, refind: true) do
+ create(
+ :group_group_link,
+ shared_group: shared_group,
+ shared_with_group: shared_with_group,
+ group_access: ::Gitlab::Access::DEVELOPER
+ )
+ end
- expect(page).to have_content(shared_with_group.name)
+ before do
+ travel_to Time.now.utc.beginning_of_day
+ group_link.update!(additional_link_attrs)
- accept_confirm do
- find(:css, '#tab-groups li', text: shared_with_group.name).find(:css, 'a.btn-remove').click
+ shared_group.add_owner(user)
+ visit group_group_members_path(shared_group)
end
- expect(page).not_to have_content(shared_with_group.name)
- end
+ it 'remove group from group' do
+ click_groups_tab
+
+ expect(page).to have_content(shared_with_group.name)
+
+ accept_confirm do
+ find(:css, '#tab-groups li', text: shared_with_group.name).find(:css, 'a.btn-remove').click
+ end
+
+ expect(page).not_to have_content(shared_with_group.name)
+ end
- it 'update group to owner level' do
- create(:group_group_link, shared_group: shared_group,
- shared_with_group: shared_with_group, group_access: ::Gitlab::Access::DEVELOPER)
+ it 'update group to owner level' do
+ click_groups_tab
- visit group_group_members_path(shared_group)
+ page.within(first_row) do
+ click_button('Developer')
+ click_link('Maintainer')
- click_groups_tab
+ wait_for_requests
- page.within(first_row) do
- click_button('Developer')
- click_link('Maintainer')
+ expect(page).to have_button('Maintainer')
+ end
+ end
+
+ it 'updates expiry date' do
+ click_groups_tab
+
+ expires_at_field = "member_expires_at_#{shared_with_group.id}"
+ fill_in "member_expires_at_#{shared_with_group.id}", with: 3.days.from_now.to_date
+ find_field(expires_at_field).native.send_keys :enter
wait_for_requests
- expect(page).to have_button('Maintainer')
+ page.within(find('li.group_member')) do
+ expect(page).to have_content('Expires in 3 days')
+ end
+ end
+
+ context 'when expiry date is set' do
+ let(:additional_link_attrs) { { expires_at: 3.days.from_now.to_date } }
+
+ it 'clears expiry date' do
+ click_groups_tab
+
+ page.within(find('li.group_member')) do
+ expect(page).to have_content('Expires in 3 days')
+
+ page.within(find('.js-edit-member-form')) do
+ find('.js-clear-input').click
+ end
+
+ wait_for_requests
+
+ expect(page).not_to have_content('Expires in')
+ end
+ end
end
end
diff --git a/spec/features/groups/navbar_spec.rb b/spec/features/groups/navbar_spec.rb
index 60f1c404e78..e81f2370d10 100644
--- a/spec/features/groups/navbar_spec.rb
+++ b/spec/features/groups/navbar_spec.rb
@@ -72,4 +72,12 @@ RSpec.describe 'Group navbar' do
it_behaves_like 'verified navigation bar'
end
+
+ context 'when invite team members is not available' do
+ it 'does not display the js-invite-members-trigger' do
+ visit group_path(group)
+
+ expect(page).not_to have_selector('.js-invite-members-trigger')
+ end
+ end
end
diff --git a/spec/features/groups/packages_spec.rb b/spec/features/groups/packages_spec.rb
index d81e4aa70cf..60e0c08b3d4 100644
--- a/spec/features/groups/packages_spec.rb
+++ b/spec/features/groups/packages_spec.rb
@@ -48,7 +48,7 @@ RSpec.describe 'Group Packages' do
it 'allows you to navigate to the project page' do
page.within('[data-qa-selector="packages-table"]') do
- click_link project.name
+ find('[data-qa-selector="package-path"]', text: project.name).click
end
expect(page).to have_current_path(project_path(project))
diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb
index ec30f34199d..304573ecd6e 100644
--- a/spec/features/groups/show_spec.rb
+++ b/spec/features/groups/show_spec.rb
@@ -184,4 +184,17 @@ RSpec.describe 'Group show page' do
expect(page).to have_selector('.notifications-btn.disabled', visible: true)
end
end
+
+ context 'page og:description' do
+ let(:group) { create(:group, description: '**Lorem** _ipsum_ dolor sit [amet](https://example.com)') }
+ let(:maintainer) { create(:user) }
+
+ before do
+ group.add_maintainer(maintainer)
+ sign_in(maintainer)
+ visit path
+ end
+
+ it_behaves_like 'page meta description', 'Lorem ipsum dolor sit amet'
+ end
end
diff --git a/spec/features/incidents/incident_details_spec.rb b/spec/features/incidents/incident_details_spec.rb
new file mode 100644
index 00000000000..6db767dbddb
--- /dev/null
+++ b/spec/features/incidents/incident_details_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Incident details', :js do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:incident) { create(:incident, project: project, author: developer) }
+
+ before_all do
+ project.add_developer(developer)
+ end
+
+ before do
+ sign_in(developer)
+
+ visit project_issues_incident_path(project, incident)
+ wait_for_requests
+ end
+
+ context 'when a developer+ displays the incident' do
+ it 'shows the incident' do
+ page.within('.issuable-details') do
+ expect(find('h2')).to have_content(incident.title)
+ end
+ end
+
+ it 'does not show design management' do
+ expect(page).not_to have_selector('.js-design-management')
+ end
+ end
+end
diff --git a/spec/features/invites_spec.rb b/spec/features/invites_spec.rb
index 3954de56eea..8a4c4cc1eca 100644
--- a/spec/features/invites_spec.rb
+++ b/spec/features/invites_spec.rb
@@ -81,10 +81,10 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
end
end
- context 'when inviting a user using their email address' do
+ context 'when inviting a user' do
let(:new_user) { build_stubbed(:user) }
let(:invite_email) { new_user.email }
- let(:group_invite) { create(:group_member, :invited, group: group, invite_email: invite_email) }
+ let(:group_invite) { create(:group_member, :invited, group: group, invite_email: invite_email, created_by: owner) }
let!(:project_invite) { create(:project_member, :invited, project: project, invite_email: invite_email) }
context 'when user has not signed in yet' do
@@ -210,30 +210,43 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
context 'when declining the invitation' do
let(:send_email_confirmation) { true }
- context 'when signed in' do
- before do
- sign_in(user)
- visit invite_path(group_invite.raw_invite_token)
+ context 'as an existing user' do
+ let(:group_invite) { create(:group_member, user: user, group: group, created_by: owner) }
+
+ context 'when signed in' do
+ before do
+ sign_in(user)
+ visit decline_invite_path(group_invite.raw_invite_token)
+ end
+
+ it 'declines application and redirects to dashboard' do
+ expect(current_path).to eq(dashboard_projects_path)
+ expect(page).to have_content('You have declined the invitation to join group Owned.')
+ expect { group_invite.reload }.to raise_error ActiveRecord::RecordNotFound
+ end
end
- it 'declines application and redirects to dashboard' do
- page.click_link 'Decline'
+ context 'when signed out' do
+ before do
+ visit decline_invite_path(group_invite.raw_invite_token)
+ end
- expect(current_path).to eq(dashboard_projects_path)
- expect(page).to have_content('You have declined the invitation to join group Owned.')
- expect { group_invite.reload }.to raise_error ActiveRecord::RecordNotFound
+ 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.')
+ expect { group_invite.reload }.to raise_error ActiveRecord::RecordNotFound
+ end
end
end
- context 'when signed out' do
+ context 'as a non-existing user' do
before do
visit decline_invite_path(group_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.')
+ it 'declines application and shows a decline page' do
+ expect(current_path).to eq(decline_invite_path(group_invite.raw_invite_token))
+ expect(page).to have_content('You successfully declined the invitation')
expect { group_invite.reload }.to raise_error ActiveRecord::RecordNotFound
end
end
diff --git a/spec/features/issuables/close_reopen_report_toggle_spec.rb b/spec/features/issuables/close_reopen_report_toggle_spec.rb
index 5ea89a7984f..6e99cfb3293 100644
--- a/spec/features/issuables/close_reopen_report_toggle_spec.rb
+++ b/spec/features/issuables/close_reopen_report_toggle_spec.rb
@@ -23,7 +23,15 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do
expect(container).to have_content("Close #{human_model_name}")
expect(container).to have_content('Report abuse')
expect(container).to have_content("Report #{human_model_name.pluralize} that are abusive, inappropriate or spam.")
- expect(container).to have_selector('.close-item.droplab-item-selected')
+
+ if issuable.is_a?(MergeRequest)
+ page.within('.js-issuable-close-dropdown') do
+ expect(page).to have_link('Close merge request')
+ end
+ else
+ expect(container).to have_selector('.close-item.droplab-item-selected')
+ end
+
expect(container).to have_selector('.report-item')
expect(container).not_to have_selector('.report-item.droplab-item-selected')
expect(container).not_to have_selector('.reopen-item')
@@ -123,7 +131,7 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do
it 'shows only the `Edit` button' do
expect(page).to have_link('Edit')
- expect(page).not_to have_link('Report abuse')
+ expect(page).to have_link('Report abuse')
expect(page).not_to have_button('Close merge request')
expect(page).not_to have_button('Reopen merge request')
end
diff --git a/spec/features/issuables/markdown_references/internal_references_spec.rb b/spec/features/issuables/markdown_references/internal_references_spec.rb
index aceaea8d2ed..07d4271eed7 100644
--- a/spec/features/issuables/markdown_references/internal_references_spec.rb
+++ b/spec/features/issuables/markdown_references/internal_references_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe "Internal references", :js do
add_note("##{public_project_issue.to_reference(private_project)}")
end
- context "when user doesn't have access to private project" do
+ context "when user doesn't have access to private project", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/257832' do
before do
sign_in(public_project_user)
@@ -52,7 +52,7 @@ RSpec.describe "Internal references", :js do
visit(project_issue_path(public_project, public_project_issue))
end
- it "doesn't show any references" do
+ it "doesn't show any references", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/257832' do
page.within(".issue-details") do
expect(page).not_to have_content("#merge-requests .merge-requests-title")
end
@@ -94,7 +94,7 @@ RSpec.describe "Internal references", :js do
add_note("##{public_project_merge_request.to_reference(private_project)}")
end
- context "when user doesn't have access to private project" do
+ context "when user doesn't have access to private project", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/257832' do
before do
sign_in(public_project_user)
@@ -121,7 +121,7 @@ RSpec.describe "Internal references", :js do
visit(project_merge_request_path(public_project, public_project_merge_request))
end
- it "doesn't show any references" do
+ it "doesn't show any references", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/257832' do
page.within(".merge-request-details") do
expect(page).not_to have_content("#merge-requests .merge-requests-title")
end
diff --git a/spec/features/issuables/merge_request_discussion_lock_spec.rb b/spec/features/issuables/merge_request_discussion_lock_spec.rb
new file mode 100644
index 00000000000..4e0265839f6
--- /dev/null
+++ b/spec/features/issuables/merge_request_discussion_lock_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+# TODO use shared examples to merge this spec with discussion_lock_spec.rb
+# https://gitlab.com/gitlab-org/gitlab/-/issues/255910
+
+require 'spec_helper'
+
+RSpec.describe 'Merge Request Discussion Lock', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :public, :repository) }
+ let(:merge_request) { create(:merge_request, source_project: project, author: user) }
+
+ before do
+ sign_in(user)
+ end
+
+ context 'when a user is a team member' do
+ before do
+ project.add_developer(user)
+ end
+
+ context 'when the discussion is unlocked' do
+ it 'the user can lock the merge_request' do
+ visit project_merge_request_path(merge_request.project, merge_request)
+
+ expect(find('.issuable-sidebar')).to have_content('Unlocked')
+
+ page.within('.issuable-sidebar') do
+ find('.lock-edit').click
+ click_button('Lock')
+ end
+
+ expect(find('[data-testid="lock-status"]')).to have_content('Locked')
+ end
+ end
+
+ context 'when the discussion is locked' do
+ before do
+ merge_request.update_attribute(:discussion_locked, true)
+ visit project_merge_request_path(merge_request.project, merge_request)
+ end
+
+ it 'the user can unlock the merge_request' do
+ expect(find('.issuable-sidebar')).to have_content('Locked')
+
+ page.within('.issuable-sidebar') do
+ find('.lock-edit').click
+ click_button('Unlock')
+ end
+
+ expect(find('[data-testid="lock-status"]')).to have_content('Unlocked')
+ end
+ end
+ end
+
+ context 'when a user is not a team member' do
+ context 'when the discussion is unlocked' do
+ before do
+ visit project_merge_request_path(merge_request.project, merge_request)
+ end
+
+ it 'the user can not lock the merge_request' do
+ expect(find('.issuable-sidebar')).to have_content('Unlocked')
+ expect(find('.issuable-sidebar')).not_to have_selector('.lock-edit')
+ end
+ end
+
+ context 'when the discussion is locked' do
+ before do
+ merge_request.update_attribute(:discussion_locked, true)
+ visit project_merge_request_path(merge_request.project, merge_request)
+ end
+
+ it 'the user can not unlock the merge_request' do
+ expect(find('.issuable-sidebar')).to have_content('Locked')
+ expect(find('.issuable-sidebar')).not_to have_selector('.lock-edit')
+ end
+ end
+ end
+end
diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb
index 0165fba9ace..ff78b9e608f 100644
--- a/spec/features/issues/gfm_autocomplete_spec.rb
+++ b/spec/features/issues/gfm_autocomplete_spec.rb
@@ -6,7 +6,9 @@ RSpec.describe 'GFM autocomplete', :js do
let_it_be(:user_xss_title) { 'eve <img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;' }
let_it_be(:user_xss) { create(:user, name: user_xss_title, username: 'xss.user') }
let_it_be(:user) { create(:user, name: '💃speciąl someone💃', username: 'someone.special') }
- let_it_be(:project) { create(:project) }
+ let_it_be(:group) { create(:group, name: 'Ancestor') }
+ let_it_be(:child_group) { create(:group, parent: group, name: 'My group') }
+ let_it_be(:project) { create(:project, group: child_group) }
let_it_be(:label) { create(:label, project: project, title: 'special+') }
let(:issue) { create(:issue, project: project) }
@@ -530,7 +532,7 @@ RSpec.describe 'GFM autocomplete', :js do
expect(page).to have_selector('.tribute-container', visible: true)
- expect(find('.tribute-container ul', visible: true).text).to have_content(user_xss.username)
+ expect(find('.tribute-container ul', visible: true)).to have_text(user_xss.username)
end
it 'selects the first item for assignee dropdowns' do
@@ -558,6 +560,24 @@ RSpec.describe 'GFM autocomplete', :js do
expect(find('.tribute-container ul', visible: true)).to have_content(user.name)
end
+ context 'when autocompleting for groups' do
+ it 'shows the group when searching for the name of the group' do
+ page.within '.timeline-content-form' do
+ find('#note-body').native.send_keys('@mygroup')
+ end
+
+ expect(find('.tribute-container ul', visible: true)).to have_text('My group')
+ end
+
+ it 'does not show the group when searching for the name of the parent of the group' do
+ page.within '.timeline-content-form' do
+ find('#note-body').native.send_keys('@ancestor')
+ end
+
+ expect(find('.tribute-container ul', visible: true)).not_to have_text('My group')
+ end
+ end
+
context 'if a selected value has special characters' do
it 'wraps the result in double quotes' do
note = find('#note-body')
diff --git a/spec/features/issues/user_sees_live_update_spec.rb b/spec/features/issues/user_sees_live_update_spec.rb
index c9b751715bc..d27cdb774a5 100644
--- a/spec/features/issues/user_sees_live_update_spec.rb
+++ b/spec/features/issues/user_sees_live_update_spec.rb
@@ -39,7 +39,7 @@ RSpec.describe 'Issues > User sees live update', :js do
expect(page).to have_css('.sidebar-item-warning-message')
within('.sidebar-item-warning-message') do
- find('.btn-close').click
+ find('[data-testid="confidential-toggle"]').click
end
wait_for_requests
diff --git a/spec/features/issues/user_views_issue_spec.rb b/spec/features/issues/user_views_issue_spec.rb
index 3f18764aa58..9b1c8be1513 100644
--- a/spec/features/issues/user_views_issue_spec.rb
+++ b/spec/features/issues/user_views_issue_spec.rb
@@ -5,7 +5,7 @@ require "spec_helper"
RSpec.describe "User views issue" do
let_it_be(:project) { create(:project_empty_repo, :public) }
let_it_be(:user) { create(:user) }
- let_it_be(:issue) { create(:issue, project: project, description: "# Description header", author: user) }
+ let_it_be(:issue) { create(:issue, project: project, description: "# Description header\n\n**Lorem** _ipsum_ dolor sit [amet](https://example.com)", author: user) }
let_it_be(:note) { create(:note, noteable: issue, project: project, author: user) }
before_all do
@@ -20,6 +20,8 @@ RSpec.describe "User views issue" do
it { expect(page).to have_header_with_correct_id_and_link(1, "Description header", "description-header") }
+ it_behaves_like 'page meta description', ' Description header Lorem ipsum dolor sit amet'
+
it 'shows the merge request and issue actions', :aggregate_failures do
expect(page).to have_link('New issue')
expect(page).to have_button('Create merge request')
diff --git a/spec/features/merge_request/batch_comments_spec.rb b/spec/features/merge_request/batch_comments_spec.rb
index 40f6482c948..c8fc23bebf9 100644
--- a/spec/features/merge_request/batch_comments_spec.rb
+++ b/spec/features/merge_request/batch_comments_spec.rb
@@ -41,7 +41,6 @@ RSpec.describe 'Merge request > Batch comments', :js do
write_comment
page.within('.review-bar-content') do
- click_button 'Finish review'
click_button 'Submit review'
end
@@ -64,18 +63,6 @@ RSpec.describe 'Merge request > Batch comments', :js do
expect(page).to have_selector('.note:not(.draft-note)', text: 'Line is wrong')
end
- it 'discards review' do
- write_comment
-
- click_button 'Discard review'
-
- click_button 'Delete all pending comments'
-
- wait_for_requests
-
- expect(page).not_to have_selector('.draft-note-component')
- end
-
it 'deletes draft note' do
write_comment
@@ -149,7 +136,6 @@ RSpec.describe 'Merge request > Batch comments', :js do
write_reply_to_discussion(resolve: true)
page.within('.review-bar-content') do
- click_button 'Finish review'
click_button 'Submit review'
end
@@ -192,7 +178,6 @@ RSpec.describe 'Merge request > Batch comments', :js do
write_reply_to_discussion(button_text: 'Start a review', unresolve: true)
page.within('.review-bar-content') do
- click_button 'Finish review'
click_button 'Submit review'
end
diff --git a/spec/features/merge_request/maintainer_edits_fork_spec.rb b/spec/features/merge_request/maintainer_edits_fork_spec.rb
index 0e65cb358da..a98bfd1c8a4 100644
--- a/spec/features/merge_request/maintainer_edits_fork_spec.rb
+++ b/spec/features/merge_request/maintainer_edits_fork_spec.rb
@@ -26,7 +26,12 @@ RSpec.describe 'a maintainer edits files on a source-branch of an MR from a fork
visit project_merge_request_path(target_project, merge_request)
click_link 'Changes'
wait_for_requests
- first('.js-file-title').find('.js-edit-blob').click
+
+ page.within(first('.js-file-title')) do
+ find('.js-diff-more-actions').click
+ find('.js-edit-blob').click
+ end
+
wait_for_requests
end
diff --git a/spec/features/merge_request/user_comments_on_diff_spec.rb b/spec/features/merge_request/user_comments_on_diff_spec.rb
index 3a199951b56..ad1ad067935 100644
--- a/spec/features/merge_request/user_comments_on_diff_spec.rb
+++ b/spec/features/merge_request/user_comments_on_diff_spec.rb
@@ -34,7 +34,8 @@ RSpec.describe 'User comments on a diff', :js do
page.within('.diff-files-holder > div:nth-child(3)') do
expect(page).to have_content('Line is wrong')
- find('.js-btn-vue-toggle-comments').click
+ find('.js-diff-more-actions').click
+ click_button 'Hide comments on this file'
expect(page).not_to have_content('Line is wrong')
end
@@ -67,7 +68,8 @@ RSpec.describe 'User comments on a diff', :js do
# Hide the comment.
page.within('.diff-files-holder > div:nth-child(3)') do
- find('.js-btn-vue-toggle-comments').click
+ find('.js-diff-more-actions').click
+ click_button 'Hide comments on this file'
expect(page).not_to have_content('Line is wrong')
end
@@ -80,7 +82,8 @@ RSpec.describe 'User comments on a diff', :js do
# Show the comment.
page.within('.diff-files-holder > div:nth-child(3)') do
- find('.js-btn-vue-toggle-comments').click
+ find('.js-diff-more-actions').click
+ click_button 'Show comments on this file'
end
# Now both the comments should be shown.
diff --git a/spec/features/merge_request/user_edits_mr_spec.rb b/spec/features/merge_request/user_edits_mr_spec.rb
index 397ca70f4a1..817b4e0b48e 100644
--- a/spec/features/merge_request/user_edits_mr_spec.rb
+++ b/spec/features/merge_request/user_edits_mr_spec.rb
@@ -21,24 +21,6 @@ RSpec.describe 'Merge request > User edits MR' do
it_behaves_like 'an editable merge request'
end
- context 'when merge_request_reviewers is turned on' do
- before do
- stub_feature_flags(merge_request_reviewers: true)
- end
-
- context 'non-fork merge request' do
- include_context 'merge request edit context'
- it_behaves_like 'an editable merge request with reviewers'
- end
-
- context 'for a forked project' do
- let(:source_project) { fork_project(target_project, nil, repository: true) }
-
- include_context 'merge request edit context'
- it_behaves_like 'an editable merge request with reviewers'
- end
- end
-
context 'when merge_request_reviewers is turned off' do
before do
stub_feature_flags(merge_request_reviewers: false)
diff --git a/spec/features/merge_request/user_expands_diff_spec.rb b/spec/features/merge_request/user_expands_diff_spec.rb
index 0e39cce13a1..0cdc87de761 100644
--- a/spec/features/merge_request/user_expands_diff_spec.rb
+++ b/spec/features/merge_request/user_expands_diff_spec.rb
@@ -7,6 +7,7 @@ RSpec.describe 'User expands diff', :js do
let(:merge_request) { create(:merge_request, source_branch: 'expand-collapse-files', source_project: project, target_project: project) }
before do
+ stub_feature_flags(increased_diff_limits: false)
visit(diffs_project_merge_request_path(project, merge_request))
wait_for_requests
@@ -14,11 +15,11 @@ RSpec.describe 'User expands diff', :js do
it 'allows user to expand diff' do
page.within find('[id="19763941ab80e8c09871c0a425f0560d9053bcb3"]') do
- find('[data-testid="expandButton"]').click
+ click_link 'Click to expand it.'
wait_for_requests
- expect(page).not_to have_content('Expand File')
+ expect(page).not_to have_content('Click to expand it.')
expect(page).to have_selector('.code')
end
end
diff --git a/spec/features/merge_request/user_marks_merge_request_as_draft_spec.rb b/spec/features/merge_request/user_marks_merge_request_as_draft_spec.rb
new file mode 100644
index 00000000000..f5bca7cf015
--- /dev/null
+++ b/spec/features/merge_request/user_marks_merge_request_as_draft_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Merge request > User marks merge request as draft', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :public, :repository) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
+ before do
+ project.add_maintainer(user)
+
+ sign_in(user)
+
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'toggles draft status' do
+ click_link 'Mark as draft'
+
+ expect(page).to have_content("Draft: #{merge_request.title}")
+
+ page.within('.detail-page-header-actions') do
+ click_link 'Mark as ready'
+ end
+
+ expect(page).to have_content(merge_request.title)
+ end
+end
diff --git a/spec/features/merge_request/user_reopens_merge_request_spec.rb b/spec/features/merge_request/user_reopens_merge_request_spec.rb
index 7866ece84ac..4a05a3be59a 100644
--- a/spec/features/merge_request/user_reopens_merge_request_spec.rb
+++ b/spec/features/merge_request/user_reopens_merge_request_spec.rb
@@ -15,7 +15,11 @@ RSpec.describe 'User reopens a merge requests', :js do
end
it 'reopens a merge request' do
- click_button('Reopen merge request', match: :first)
+ find('.js-issuable-close-dropdown .dropdown-toggle').click
+
+ click_link('Reopen merge request', match: :first)
+
+ wait_for_requests
page.within('.status-box') do
expect(page).to have_content('Open')
diff --git a/spec/features/merge_request/user_resolves_wip_mr_spec.rb b/spec/features/merge_request/user_resolves_wip_mr_spec.rb
index a9d4c4df507..b67167252e1 100644
--- a/spec/features/merge_request/user_resolves_wip_mr_spec.rb
+++ b/spec/features/merge_request/user_resolves_wip_mr_spec.rb
@@ -35,7 +35,9 @@ RSpec.describe 'Merge request > User resolves Work in Progress', :js do
expect(page.find('.ci-widget-content')).to have_content("Pipeline ##{pipeline.id}")
expect(page).to have_content "This merge request is still a work in progress."
- click_button('Mark as ready')
+ page.within('.mr-state-widget') do
+ click_button('Mark as ready')
+ end
wait_for_requests
diff --git a/spec/features/merge_request/user_sees_diff_spec.rb b/spec/features/merge_request/user_sees_diff_spec.rb
index 7a3a14e61e3..a7713ed9964 100644
--- a/spec/features/merge_request/user_sees_diff_spec.rb
+++ b/spec/features/merge_request/user_sees_diff_spec.rb
@@ -63,7 +63,7 @@ RSpec.describe 'Merge request > User sees diff', :js do
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")
+ expect(page).to have_selector("[id=\"#{changelog_id}\"] .js-edit-blob", visible: false)
end
end
@@ -73,6 +73,7 @@ RSpec.describe 'Merge request > User sees diff', :js do
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-diff-more-actions").click
find("[id=\"#{changelog_id}\"] .js-edit-blob").click
expect(page).to have_selector('.js-fork-suggestion-button', count: 1)
diff --git a/spec/features/merge_request/user_sees_page_metadata_spec.rb b/spec/features/merge_request/user_sees_page_metadata_spec.rb
new file mode 100644
index 00000000000..7b3e07152a0
--- /dev/null
+++ b/spec/features/merge_request/user_sees_page_metadata_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Merge request > User sees page metadata' do
+ let(:merge_request) { create(:merge_request, description: '**Lorem** _ipsum_ dolor sit [amet](https://example.com)') }
+ let(:project) { merge_request.target_project }
+ let(:user) { project.creator }
+
+ before do
+ project.add_maintainer(user)
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it_behaves_like 'page meta description', 'Lorem ipsum dolor sit amet'
+end
diff --git a/spec/features/merge_request/user_sees_pipelines_spec.rb b/spec/features/merge_request/user_sees_pipelines_spec.rb
index 8e15ba6cf8d..107fc002ebd 100644
--- a/spec/features/merge_request/user_sees_pipelines_spec.rb
+++ b/spec/features/merge_request/user_sees_pipelines_spec.rb
@@ -50,7 +50,7 @@ RSpec.describe 'Merge request > User sees pipelines', :js do
wait_for_requests
- expect(page.find('.js-run-mr-pipeline')).to have_text('Run Pipeline')
+ expect(page.find('[data-testid="run_pipeline_button"]')).to have_text('Run Pipeline')
end
end
@@ -66,7 +66,7 @@ RSpec.describe 'Merge request > User sees pipelines', :js do
wait_for_requests
- expect(page.find('.js-run-mr-pipeline')).to have_text('Run Pipeline')
+ expect(page.find('[data-testid="run_pipeline_button"]')).to have_text('Run Pipeline')
end
end
end
diff --git a/spec/features/merge_request/user_sees_suggest_pipeline_spec.rb b/spec/features/merge_request/user_sees_suggest_pipeline_spec.rb
new file mode 100644
index 00000000000..93807512d9c
--- /dev/null
+++ b/spec/features/merge_request/user_sees_suggest_pipeline_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Merge request > User sees suggest pipeline', :js do
+ let(:merge_request) { create(:merge_request) }
+ let(:project) { merge_request.source_project }
+ let(:user) { project.creator }
+
+ before do
+ stub_application_setting(auto_devops_enabled: false)
+ stub_experiment(suggest_pipeline: true)
+ stub_experiment_for_user(suggest_pipeline: true)
+ project.add_maintainer(user)
+ sign_in(user)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'shows the suggest pipeline widget and then allows dismissal correctly' do
+ expect(page).to have_content('Are you adding technical debt or code vulnerabilities?')
+
+ page.within '.mr-pipeline-suggest' do
+ find('[data-testid="close"]').click
+ end
+
+ wait_for_requests
+
+ expect(page).not_to have_content('Are you adding technical debt or code vulnerabilities?')
+
+ # Reload so we know the user callout was registered
+ visit page.current_url
+
+ expect(page).not_to have_content('Are you adding technical debt or code vulnerabilities?')
+ end
+end
diff --git a/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb
index 39495832547..abdbd3a6f8a 100644
--- a/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb
+++ b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb
@@ -119,7 +119,8 @@ RSpec.describe 'User comments on a diff', :js do
it 'can add and remove suggestions from a batch' do
files.each_with_index do |file, index|
page.within("[id='#{file[:hash]}']") do
- find("button[title='Show full file']").click
+ find('.js-diff-more-actions').click
+ click_button 'Show full file'
wait_for_requests
click_diff_line(find("[id='#{file[:line_code]}']"))
@@ -130,7 +131,9 @@ RSpec.describe 'User comments on a diff', :js do
wait_for_requests
end
end
+ end
+ files.each_with_index do |file, index|
page.within("[id='#{file[:hash]}']") do
expect(page).not_to have_content('Applied')
diff --git a/spec/features/merge_request/user_views_open_merge_request_spec.rb b/spec/features/merge_request/user_views_open_merge_request_spec.rb
index 448844ae57d..e8998f9457a 100644
--- a/spec/features/merge_request/user_views_open_merge_request_spec.rb
+++ b/spec/features/merge_request/user_views_open_merge_request_spec.rb
@@ -22,7 +22,24 @@ RSpec.describe 'User views an open merge request' do
# returns the whole document, not the node's actual parent element
expect(find(:xpath, "#{node.path}/..").text).to eq(merge_request.description[2..-1])
- expect(page).to have_content(merge_request.title).and have_content(merge_request.description)
+ expect(page).to have_content(merge_request.title)
+ end
+
+ it 'has reviewers in sidebar' do
+ expect(page).to have_css('.reviewer')
+ end
+ end
+
+ context 'when merge_request_reviewers is turned off' do
+ let(:project) { create(:project, :public, :repository) }
+
+ before do
+ stub_feature_flags(merge_request_reviewers: false)
+ visit(merge_request_path(merge_request))
+ end
+
+ it 'has reviewers in sidebar' do
+ expect(page).not_to have_css('.reviewer')
end
end
diff --git a/spec/features/milestones/user_views_milestone_spec.rb b/spec/features/milestones/user_views_milestone_spec.rb
index 420f8d49483..9c19f842427 100644
--- a/spec/features/milestones/user_views_milestone_spec.rb
+++ b/spec/features/milestones/user_views_milestone_spec.rb
@@ -4,15 +4,27 @@ require 'spec_helper'
RSpec.describe "User views milestone" do
let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project) }
- let_it_be(:milestone) { create(:milestone, project: project) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, :repository, group: group) }
+ let_it_be(:milestone) { create(:milestone, project: project, description: '**Lorem** _ipsum_ dolor sit [amet](https://example.com)') }
let_it_be(:labels) { create_list(:label, 2, project: project) }
- before do
+ before_all do
project.add_developer(user)
+ end
+
+ before do
sign_in(user)
end
+ context 'page description' do
+ before do
+ visit(project_milestone_path(project, milestone))
+ end
+
+ it_behaves_like 'page meta description', 'Lorem ipsum dolor sit amet'
+ end
+
it "avoids N+1 database queries" do
issue_params = { project: project, assignees: [user], author: user, milestone: milestone, labels: labels }.freeze
@@ -25,7 +37,7 @@ RSpec.describe "User views milestone" do
expect { visit_milestone }.not_to exceed_query_limit(control)
end
- context 'limiting milestone issues' do
+ context 'issues list', :js do
before_all do
2.times do
create(:issue, milestone: milestone, project: project)
@@ -34,6 +46,28 @@ RSpec.describe "User views milestone" do
end
end
+ context 'for a project milestone' do
+ it 'does not show the project name' do
+ visit(project_milestone_path(project, milestone))
+
+ wait_for_requests
+
+ expect(page.find('#tab-issues')).not_to have_text(project.name)
+ end
+ end
+
+ context 'for a group milestone' do
+ let(:group_milestone) { create(:milestone, group: group) }
+
+ it 'shows the project name' do
+ create(:issue, project: project, milestone: group_milestone)
+
+ visit(group_milestone_path(group, group_milestone))
+
+ expect(page.find('#tab-issues')).to have_text(project.name)
+ end
+ end
+
context 'when issues on milestone are over DISPLAY_ISSUES_LIMIT' do
it "limits issues to display and shows warning" do
stub_const('Milestoneish::DISPLAY_ISSUES_LIMIT', 3)
@@ -56,6 +90,40 @@ RSpec.describe "User views milestone" do
end
end
+ context 'merge requests list', :js do
+ context 'for a project milestone' do
+ it 'does not show the project name' do
+ create(:merge_request, source_project: project, milestone: milestone)
+
+ visit(project_milestone_path(project, milestone))
+
+ within('.js-milestone-tabs') do
+ click_link('Merge Requests')
+ end
+
+ wait_for_requests
+
+ expect(page.find('#tab-merge-requests')).not_to have_text(project.name)
+ end
+ end
+
+ context 'for a group milestone' do
+ let(:group_milestone) { create(:milestone, group: group) }
+
+ it 'shows the project name' do
+ create(:merge_request, source_project: project, milestone: group_milestone)
+
+ visit(group_milestone_path(group, group_milestone))
+
+ within('.js-milestone-tabs') do
+ click_link('Merge Requests')
+ end
+
+ expect(page.find('#tab-merge-requests')).to have_text(project.name)
+ end
+ end
+ end
+
private
def visit_milestone
diff --git a/spec/features/milestones/user_views_milestones_spec.rb b/spec/features/milestones/user_views_milestones_spec.rb
index 3f606577121..f8b4b802a60 100644
--- a/spec/features/milestones/user_views_milestones_spec.rb
+++ b/spec/features/milestones/user_views_milestones_spec.rb
@@ -21,7 +21,7 @@ RSpec.describe "User views milestones" do
.and have_content("Merge Requests")
end
- context "with issues" do
+ context "with issues", :js do
let_it_be(:issue) { create(:issue, project: project, milestone: milestone) }
let_it_be(:closed_issue) { create(:closed_issue, project: project, milestone: milestone) }
@@ -33,7 +33,6 @@ RSpec.describe "User views milestones" do
.and have_selector("#tab-issues li.issuable-row", count: 2)
.and have_content(issue.title)
.and have_content(closed_issue.title)
- .and have_selector("#tab-merge-requests")
end
end
diff --git a/spec/features/operations_sidebar_link_spec.rb b/spec/features/operations_sidebar_link_spec.rb
new file mode 100644
index 00000000000..32e2833dafb
--- /dev/null
+++ b/spec/features/operations_sidebar_link_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Operations dropdown sidebar' do
+ let_it_be(:project) { create(:project, :repository) }
+ let(:user) { create(:user) }
+
+ before do
+ project.add_role(user, role)
+ sign_in(user)
+ visit project_issues_path(project)
+ end
+
+ context 'user has guest role' do
+ let(:role) { :guest }
+
+ it 'has the correct `Operations` menu items' do
+ expect(page).to have_link(title: 'Incidents', href: project_incidents_path(project))
+
+ expect(page).not_to have_link(title: 'Metrics', href: project_metrics_dashboard_path(project))
+ expect(page).not_to have_link(title: 'Alerts', href: project_alert_management_index_path(project))
+ expect(page).not_to have_link(title: 'Environments', href: project_environments_path(project))
+ expect(page).not_to have_link(title: 'Error Tracking', href: project_error_tracking_index_path(project))
+ expect(page).not_to have_link(title: 'Product Analytics', href: project_product_analytics_path(project))
+ expect(page).not_to have_link(title: 'Serverless', href: project_serverless_functions_path(project))
+ expect(page).not_to have_link(title: 'Logs', href: project_logs_path(project))
+ expect(page).not_to have_link(title: 'Kubernetes', href: project_clusters_path(project))
+ end
+ end
+
+ context 'user has reporter role' do
+ let(:role) { :reporter }
+
+ it 'has the correct `Operations` menu items' do
+ expect(page).to have_link(title: 'Metrics', href: project_metrics_dashboard_path(project))
+ expect(page).to have_link(title: 'Incidents', href: project_incidents_path(project))
+ expect(page).to have_link(title: 'Environments', href: project_environments_path(project))
+ expect(page).to have_link(title: 'Error Tracking', href: project_error_tracking_index_path(project))
+ expect(page).to have_link(title: 'Product Analytics', href: project_product_analytics_path(project))
+
+ expect(page).not_to have_link(title: 'Alerts', href: project_alert_management_index_path(project))
+ expect(page).not_to have_link(title: 'Serverless', href: project_serverless_functions_path(project))
+ expect(page).not_to have_link(title: 'Logs', href: project_logs_path(project))
+ expect(page).not_to have_link(title: 'Kubernetes', href: project_clusters_path(project))
+ end
+ end
+
+ context 'user has developer role' do
+ let(:role) { :developer }
+
+ it 'has the correct `Operations` menu items' do
+ expect(page).to have_link(title: 'Metrics', href: project_metrics_dashboard_path(project))
+ expect(page).to have_link(title: 'Alerts', href: project_alert_management_index_path(project))
+ expect(page).to have_link(title: 'Incidents', href: project_incidents_path(project))
+ expect(page).to have_link(title: 'Environments', href: project_environments_path(project))
+ expect(page).to have_link(title: 'Error Tracking', href: project_error_tracking_index_path(project))
+ expect(page).to have_link(title: 'Product Analytics', href: project_product_analytics_path(project))
+ expect(page).to have_link(title: 'Logs', href: project_logs_path(project))
+
+ expect(page).not_to have_link(title: 'Serverless', href: project_serverless_functions_path(project))
+ expect(page).not_to have_link(title: 'Kubernetes', href: project_clusters_path(project))
+ end
+ end
+
+ context 'user has maintainer role' do
+ let(:role) { :maintainer }
+
+ it 'has the correct `Operations` menu items' do
+ expect(page).to have_link(title: 'Metrics', href: project_metrics_dashboard_path(project))
+ expect(page).to have_link(title: 'Alerts', href: project_alert_management_index_path(project))
+ expect(page).to have_link(title: 'Incidents', href: project_incidents_path(project))
+ expect(page).to have_link(title: 'Environments', href: project_environments_path(project))
+ expect(page).to have_link(title: 'Error Tracking', href: project_error_tracking_index_path(project))
+ expect(page).to have_link(title: 'Product Analytics', href: project_product_analytics_path(project))
+ expect(page).to have_link(title: 'Serverless', href: project_serverless_functions_path(project))
+ expect(page).to have_link(title: 'Logs', href: project_logs_path(project))
+ expect(page).to have_link(title: 'Kubernetes', href: project_clusters_path(project))
+ end
+ end
+end
diff --git a/spec/features/profiles/keys_spec.rb b/spec/features/profiles/keys_spec.rb
index b5e784a749f..23bbe9c1587 100644
--- a/spec/features/profiles/keys_spec.rb
+++ b/spec/features/profiles/keys_spec.rb
@@ -71,21 +71,35 @@ RSpec.describe 'Profile > SSH Keys' do
expect(page).to have_content(key.title)
end
- it 'User removes a key via the key index' do
- create(:key, user: user)
- visit profile_keys_path
+ describe 'User removes a key', :js do
+ shared_examples 'removes key' do
+ it 'removes key' do
+ visit path
+ click_button('Delete')
- click_link('Remove')
+ page.within('.modal') do
+ page.click_button('Delete')
+ end
- expect(page).to have_content('Your SSH keys (0)')
- end
+ expect(page).to have_content('Your SSH keys (0)')
+ end
+ end
- it 'User removes a key via its details page' do
- key = create(:key, user: user)
- visit profile_key_path(key)
+ context 'via the key index' do
+ before do
+ create(:key, user: user)
+ end
+
+ let(:path) { profile_keys_path }
- click_link('Remove')
+ it_behaves_like 'removes key'
+ end
- expect(page).to have_content('Your SSH keys (0)')
+ context 'via its details page' do
+ let(:key) { create(:key, user: user) }
+ let(:path) { profile_keys_path(key) }
+
+ it_behaves_like 'removes key'
+ end
end
end
diff --git a/spec/features/projects/activity/user_sees_design_comment_spec.rb b/spec/features/projects/activity/user_sees_design_comment_spec.rb
index e60deba65f0..3a8e2790858 100644
--- a/spec/features/projects/activity/user_sees_design_comment_spec.rb
+++ b/spec/features/projects/activity/user_sees_design_comment_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe 'Projects > Activity > User sees design comment', :js do
let_it_be(:design) { create(:design, issue: issue) }
let(:design_activity) do
- "#{commenter.name} #{commenter.to_reference} commented on design"
+ "#{commenter.name} #{commenter.to_reference} commented on design #{design.to_reference}"
end
let(:issue_activity) do
diff --git a/spec/features/projects/blobs/edit_spec.rb b/spec/features/projects/blobs/edit_spec.rb
index 5aca994f53e..90d9263b515 100644
--- a/spec/features/projects/blobs/edit_spec.rb
+++ b/spec/features/projects/blobs/edit_spec.rb
@@ -20,9 +20,14 @@ RSpec.describe 'Editing file blob', :js do
sign_in(user)
end
- def edit_and_commit(commit_changes: true)
+ def edit_and_commit(commit_changes: true, is_diff: false)
wait_for_requests
- find('.js-edit-blob').click
+
+ if is_diff
+ first('.js-diff-more-actions').click
+ end
+
+ first('.js-edit-blob').click
fill_editor(content: 'class NextFeature\\nend\\n')
if commit_changes
@@ -38,7 +43,7 @@ RSpec.describe 'Editing file blob', :js do
context 'from MR diff' do
before do
visit diffs_project_merge_request_path(project, merge_request)
- edit_and_commit
+ edit_and_commit(is_diff: true)
end
it 'returns me to the mr' do
diff --git a/spec/features/projects/blobs/user_follows_pipeline_suggest_nudge_spec.rb b/spec/features/projects/blobs/user_follows_pipeline_suggest_nudge_spec.rb
index 8b43687c71c..023e00a3e02 100644
--- a/spec/features/projects/blobs/user_follows_pipeline_suggest_nudge_spec.rb
+++ b/spec/features/projects/blobs/user_follows_pipeline_suggest_nudge_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe 'User follows pipeline suggest nudge spec when feature is enabled
describe 'viewing the new blob page' do
before do
- stub_feature_flags(suggest_pipeline: true)
+ stub_experiment_for_user(suggest_pipeline: true)
sign_in(user)
end
diff --git a/spec/features/projects/branches_spec.rb b/spec/features/projects/branches_spec.rb
index 0e2444c5434..4224fdbc1fc 100644
--- a/spec/features/projects/branches_spec.rb
+++ b/spec/features/projects/branches_spec.rb
@@ -21,11 +21,11 @@ RSpec.describe 'Branches' do
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}") }
+ travel_to((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}") }
+ travel_to((threshold - i).ago) { create_file(message: "a commit in active-#{i}", branch_name: "active-#{i}") }
end
end
diff --git a/spec/features/projects/clusters/eks_spec.rb b/spec/features/projects/clusters/eks_spec.rb
index c5feef6c6f3..9f3f331cfab 100644
--- a/spec/features/projects/clusters/eks_spec.rb
+++ b/spec/features/projects/clusters/eks_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe 'AWS EKS Cluster', :js do
before do
visit project_clusters_path(project)
- click_link 'Add Kubernetes cluster'
+ click_link 'Integrate with a cluster certificate'
end
context 'when user creates a cluster on AWS EKS' do
diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb
index 04339d20d77..a0519d88532 100644
--- a/spec/features/projects/clusters/gcp_spec.rb
+++ b/spec/features/projects/clusters/gcp_spec.rb
@@ -33,7 +33,7 @@ RSpec.describe 'Gcp Cluster', :js, :do_not_mock_admin_mode do
before do
visit project_clusters_path(project)
- click_link 'Add Kubernetes cluster'
+ click_link 'Integrate with a cluster certificate'
click_link 'Create new cluster'
click_link 'Google GKE'
end
@@ -143,7 +143,7 @@ RSpec.describe 'Gcp Cluster', :js, :do_not_mock_admin_mode do
before do
visit project_clusters_path(project)
- click_link 'Add Kubernetes cluster'
+ click_link 'Connect cluster with certificate'
click_link 'Connect existing cluster'
end
@@ -162,7 +162,7 @@ RSpec.describe 'Gcp Cluster', :js, :do_not_mock_admin_mode do
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')
+ expect(page).to have_link('Integrate with a cluster certificate')
end
end
end
@@ -178,7 +178,7 @@ RSpec.describe 'Gcp Cluster', :js, :do_not_mock_admin_mode do
end
it 'user sees offer on cluster create page' do
- click_link 'Add Kubernetes cluster'
+ click_link 'Integrate with a cluster certificate'
expect(page).to have_css('.gcp-signup-offer')
end
@@ -192,10 +192,10 @@ RSpec.describe 'Gcp Cluster', :js, :do_not_mock_admin_mode do
it 'user does not see offer after dismissing' do
expect(page).to have_css('.gcp-signup-offer')
- find('.gcp-signup-offer .close').click
+ find('.gcp-signup-offer .js-close').click
wait_for_requests
- click_link 'Add Kubernetes cluster'
+ click_link 'Integrate with a cluster certificate'
expect(page).not_to have_css('.gcp-signup-offer')
end
diff --git a/spec/features/projects/clusters/user_spec.rb b/spec/features/projects/clusters/user_spec.rb
index 9d0dc65093e..748eba558aa 100644
--- a/spec/features/projects/clusters/user_spec.rb
+++ b/spec/features/projects/clusters/user_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe 'User Cluster', :js do
before do
visit project_clusters_path(project)
- click_link 'Add Kubernetes cluster'
+ click_link 'Integrate with a cluster certificate'
click_link 'Connect existing cluster'
end
@@ -52,6 +52,10 @@ RSpec.describe 'User Cluster', :js do
it 'user sees RBAC is enabled by default' do
expect(page).to have_checked_field('RBAC-enabled cluster')
end
+
+ it 'user sees namespace per environment is enabled by default' do
+ expect(page).to have_checked_field('Namespace per environment')
+ end
end
context 'when user filled form with invalid parameters' do
@@ -112,7 +116,7 @@ RSpec.describe 'User Cluster', :js do
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')
+ expect(page).to have_link('Integrate with a cluster certificate')
end
end
end
diff --git a/spec/features/projects/clusters_spec.rb b/spec/features/projects/clusters_spec.rb
index d674fbc457e..6c6e65005f6 100644
--- a/spec/features/projects/clusters_spec.rb
+++ b/spec/features/projects/clusters_spec.rb
@@ -11,7 +11,6 @@ RSpec.describe 'Clusters', :js do
before do
project.add_maintainer(user)
gitlab_sign_in(user)
- stub_feature_flags(clusters_list_redesign: false)
end
context 'when user does not have a cluster and visits cluster index page' do
@@ -20,7 +19,7 @@ RSpec.describe 'Clusters', :js do
end
it 'sees empty state' do
- expect(page).to have_link('Add Kubernetes cluster')
+ expect(page).to have_link('Integrate with a cluster certificate')
expect(page).to have_selector('.empty-state')
end
end
@@ -42,7 +41,7 @@ RSpec.describe 'Clusters', :js do
context 'when user filled form with environment scope' do
before do
- click_link 'Add Kubernetes cluster'
+ click_link 'Connect cluster with certificate'
click_link 'Connect existing cluster'
fill_in 'cluster_name', with: 'staging-cluster'
fill_in 'cluster_environment_scope', with: 'staging/*'
@@ -71,7 +70,7 @@ RSpec.describe 'Clusters', :js do
context 'when user updates duplicated environment scope' do
before do
- click_link 'Add Kubernetes cluster'
+ click_link 'Connect cluster with certificate'
click_link 'Connect existing cluster'
fill_in 'cluster_name', with: 'staging-cluster'
fill_in 'cluster_environment_scope', with: '*'
@@ -117,7 +116,7 @@ RSpec.describe 'Clusters', :js do
context 'when user filled form with environment scope' do
before do
- click_link 'Add Kubernetes cluster'
+ click_link 'Connect cluster with certificate'
click_link 'Create new cluster'
click_link 'Google GKE'
@@ -162,7 +161,7 @@ RSpec.describe 'Clusters', :js do
context 'when user updates duplicated environment scope' do
before do
- click_link 'Add Kubernetes cluster'
+ click_link 'Connect cluster with certificate'
click_link 'Create new cluster'
click_link 'Google GKE'
@@ -196,8 +195,7 @@ RSpec.describe 'Clusters', :js do
end
it 'user sees a table with one cluster' do
- # One is the header row, the other the cluster row
- expect(page).to have_selector('.gl-responsive-table-row', count: 2)
+ expect(page).to have_selector('[data-testid="cluster_list_table"] tbody tr', count: 1)
end
context 'when user clicks on a cluster' do
@@ -216,7 +214,7 @@ RSpec.describe 'Clusters', :js do
before do
visit project_clusters_path(project)
- click_link 'Add Kubernetes cluster'
+ click_link 'Integrate with a cluster certificate'
click_link 'Create new cluster'
end
diff --git a/spec/features/projects/commit/user_comments_on_commit_spec.rb b/spec/features/projects/commit/user_comments_on_commit_spec.rb
index 87a022d74a3..0fa4975bb25 100644
--- a/spec/features/projects/commit/user_comments_on_commit_spec.rb
+++ b/spec/features/projects/commit/user_comments_on_commit_spec.rb
@@ -6,19 +6,22 @@ RSpec.describe "User comments on commit", :js do
include Spec::Support::Helpers::Features::NotesHelpers
include RepoHelpers
- let(:project) { create(:project, :repository) }
- let(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
let(:comment_text) { "XML attached" }
- before do
- sign_in(user)
+ before_all do
project.add_developer(user)
+ end
- visit(project_commit_path(project, sample_commit.id))
+ before do
+ sign_in(user)
end
context "when adding new comment" do
it "adds comment" do
+ visit(project_commit_path(project, sample_commit.id))
+
emoji_code = ":+1:"
page.within(".js-main-target-form") do
@@ -57,6 +60,8 @@ RSpec.describe "User comments on commit", :js do
context "when editing comment" do
before do
+ visit(project_commit_path(project, sample_commit.id))
+
add_note(comment_text)
end
@@ -87,6 +92,8 @@ RSpec.describe "User comments on commit", :js do
context "when deleting comment" do
before do
+ visit(project_commit_path(project, sample_commit.id))
+
add_note(comment_text)
end
@@ -108,4 +115,35 @@ RSpec.describe "User comments on commit", :js do
expect(page).not_to have_css(".note")
end
end
+
+ context 'when checking task lists' do
+ let(:note_with_task) do
+ <<-EOT.strip_heredoc
+
+ - [ ] Task 1
+ EOT
+ end
+
+ before do
+ create(:note_on_commit, project: project, commit_id: sample_commit.id, note: note_with_task, author: user)
+ create(:note_on_commit, project: project, commit_id: sample_commit.id, note: note_with_task, author: user)
+
+ visit(project_commit_path(project, sample_commit.id))
+ end
+
+ it 'allows the tasks to be checked' do
+ expect(page).to have_selector('li.task-list-item', count: 2)
+ expect(page).to have_selector('li.task-list-item input[checked]', count: 0)
+
+ all('.task-list-item-checkbox').each do |checkbox|
+ checkbox.click
+ end
+ wait_for_requests
+
+ visit(project_commit_path(project, sample_commit.id))
+
+ expect(page).to have_selector('li.task-list-item', count: 2)
+ expect(page).to have_selector('li.task-list-item input[checked]', count: 2)
+ end
+ end
end
diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb
index 7f2ef61bcbe..8c032660726 100644
--- a/spec/features/projects/environments/environments_spec.rb
+++ b/spec/features/projects/environments/environments_spec.rb
@@ -372,7 +372,7 @@ RSpec.describe 'Environments page', :js do
let(:role) { :developer }
it 'developer creates a new environment with a valid name' do
- within(".top-area") { click_link 'New environment' }
+ within(".environments-section") { click_link 'New environment' }
fill_in('Name', with: 'production')
click_on 'Save'
@@ -380,7 +380,7 @@ RSpec.describe 'Environments page', :js do
end
it 'developer creates a new environmetn with invalid name' do
- within(".top-area") { click_link 'New environment' }
+ within(".environments-section") { click_link 'New environment' }
fill_in('Name', with: 'name,with,commas')
click_on 'Save'
diff --git a/spec/features/projects/issues/design_management/user_links_to_designs_in_issue_spec.rb b/spec/features/projects/issues/design_management/user_links_to_designs_in_issue_spec.rb
index 8d5e99d7e2b..78fb470d4ea 100644
--- a/spec/features/projects/issues/design_management/user_links_to_designs_in_issue_spec.rb
+++ b/spec/features/projects/issues/design_management/user_links_to_designs_in_issue_spec.rb
@@ -90,34 +90,5 @@ RSpec.describe 'viewing issues with design references' do
expect(page).not_to have_link(design_ref_b)
end
end
-
- context 'design management is enabled, but the filter is disabled globally' do
- before do
- enable_design_management
- stub_feature_flags(
- Banzai::Filter::DesignReferenceFilter::FEATURE_FLAG => false
- )
- end
-
- it 'processes design tab links successfully, and design references as issue references', :aggregate_failures do
- visit_page_with_design_references
-
- expect(page).to have_text('The designs I mentioned')
- expect(page).to have_link(design_tab_ref)
- expect(page).to have_link(issue_ref)
- expect(page).not_to have_link(design_ref_a)
- expect(page).not_to have_link(design_ref_b)
- end
- end
-
- context 'design management is enabled, and the filter is enabled for the current project' do
- before do
- stub_feature_flags(
- Banzai::Filter::DesignReferenceFilter::FEATURE_FLAG => public_project
- )
- end
-
- it_behaves_like 'successful use of design link references'
- end
end
end
diff --git a/spec/features/projects/issues/viewing_relocated_issues_spec.rb b/spec/features/projects/issues/viewing_relocated_issues_spec.rb
new file mode 100644
index 00000000000..10d5ad1747c
--- /dev/null
+++ b/spec/features/projects/issues/viewing_relocated_issues_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'issues canonical link' do
+ include Spec::Support::Helpers::Features::CanonicalLinkHelpers
+
+ let_it_be(:original_project) { create(:project, :public) }
+ let_it_be(:original_issue) { create(:issue, project: original_project) }
+ let_it_be(:canonical_issue) { create(:issue) }
+ let_it_be(:canonical_url) { issue_url(canonical_issue, Gitlab::Application.routes.default_url_options) }
+
+ it "doesn't show the canonical URL" do
+ visit(issue_path(original_issue))
+
+ expect(page).not_to have_any_canonical_links
+ end
+
+ context 'when the issue was moved' do
+ it 'shows the canonical URL' do
+ original_issue.moved_to = canonical_issue
+ original_issue.save!
+
+ visit(issue_path(original_issue))
+
+ expect(page).to have_canonical_link(canonical_url)
+ end
+ end
+
+ context 'when the issue was duplicated' do
+ it 'shows the canonical URL' do
+ original_issue.duplicated_to = canonical_issue
+ original_issue.save!
+
+ visit(issue_path(original_issue))
+
+ expect(page).to have_canonical_link(canonical_url)
+ end
+ end
+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 2ee6bc103e9..d652f6715db 100644
--- a/spec/features/projects/members/groups_with_access_list_spec.rb
+++ b/spec/features/projects/members/groups_with_access_list_spec.rb
@@ -3,20 +3,23 @@
require 'spec_helper'
RSpec.describe 'Projects > Members > Groups with access list', :js do
- let(:user) { create(:user) }
- let(:group) { create(:group, :public) }
- let(:project) { create(:project, :public) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group, :public) }
+ let_it_be(:project) { create(:project, :public) }
+
+ let(:additional_link_attrs) { {} }
+ let!(:group_link) { create(:project_group_link, project: project, group: group, **additional_link_attrs) }
before do
- project.add_maintainer(user)
- @group_link = create(:project_group_link, project: project, group: group)
+ travel_to Time.now.utc.beginning_of_day
+ project.add_maintainer(user)
sign_in(user)
visit project_project_members_path(project)
end
it 'updates group access level' do
- click_button @group_link.human_access
+ click_button group_link.human_access
page.within '.dropdown-menu' do
click_link 'Guest'
@@ -30,14 +33,32 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do
end
it 'updates expiry date' do
- tomorrow = Date.today + 3
+ expires_at_field = "member_expires_at_#{group.id}"
+ fill_in expires_at_field, with: 3.days.from_now.to_date
- fill_in "member_expires_at_#{group.id}", with: tomorrow.strftime("%F")
- find('body').click
+ find_field(expires_at_field).native.send_keys :enter
wait_for_requests
page.within(find('li.group_member')) do
- expect(page).to have_content('Expires in')
+ expect(page).to have_content('Expires in 3 days')
+ end
+ end
+
+ context 'when link has expiry date set' do
+ let(:additional_link_attrs) { { expires_at: 3.days.from_now.to_date } }
+
+ it 'clears expiry date' do
+ page.within(find('li.group_member')) do
+ expect(page).to have_content('Expires in 3 days')
+
+ page.within(find('.js-edit-member-form')) do
+ find('.js-clear-input').click
+ end
+
+ wait_for_requests
+
+ expect(page).not_to have_content('Expires in')
+ end
end
end
diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb
index f59dc5dd074..c8c3a52d427 100644
--- a/spec/features/projects/pipelines/pipeline_spec.rb
+++ b/spec/features/projects/pipelines/pipeline_spec.rb
@@ -172,10 +172,17 @@ RSpec.describe 'Pipeline', :js do
end
end
- it_behaves_like 'showing user status' do
- let(:user_with_status) { pipeline.user }
+ describe 'pipelines details view' do
+ let!(:status) { create(:user_status, user: pipeline.user, emoji: 'smirk', message: 'Authoring this object') }
- subject { visit project_pipeline_path(project, pipeline) }
+ it 'pipeline header shows the user status and emoji' do
+ visit project_pipeline_path(project, pipeline)
+
+ within '[data-testid="pipeline-header-content"]' do
+ expect(page).to have_selector("[data-testid='#{status.message}']")
+ expect(page).to have_selector("[data-name='#{status.emoji}']")
+ end
+ end
end
describe 'pipeline graph' do
@@ -400,7 +407,7 @@ RSpec.describe 'Pipeline', :js do
context 'when retrying' do
before do
- find('[data-testid="retryButton"]').click
+ find('[data-testid="retryPipeline"]').click
end
it 'does not show a "Retry" button', :sidekiq_might_not_need_inline do
@@ -902,7 +909,7 @@ RSpec.describe 'Pipeline', :js do
context 'when retrying' do
before do
- find('[data-testid="retryButton"]').click
+ find('[data-testid="retryPipeline"]').click
end
it 'does not show a "Retry" button', :sidekiq_might_not_need_inline do
diff --git a/spec/features/projects/releases/user_creates_release_spec.rb b/spec/features/projects/releases/user_creates_release_spec.rb
index 5d05a7e4c91..a440a4211e3 100644
--- a/spec/features/projects/releases/user_creates_release_spec.rb
+++ b/spec/features/projects/releases/user_creates_release_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe 'User creates release', :js do
project.add_developer(user)
- gitlab_sign_in(user)
+ sign_in(user)
visit new_page_url
@@ -108,6 +108,24 @@ RSpec.describe 'User creates release', :js do
end
end
+ context 'when the release notes "Preview" tab is clicked' do
+ before do
+ find_field('Release notes').click
+
+ fill_release_notes('**some** _markdown_ [content](https://example.com)')
+
+ click_on 'Preview'
+
+ wait_for_all_requests
+ end
+
+ it 'renders a preview of the release notes markdown' do
+ within('[data-testid="release-notes"]') do
+ expect(page).to have_text('some markdown content')
+ end
+ end
+ end
+
def fill_out_form_and_submit
fill_tag_name(tag_name)
diff --git a/spec/features/projects/releases/user_views_edit_release_spec.rb b/spec/features/projects/releases/user_views_edit_release_spec.rb
index 4ed1be6db6b..976840ee4e7 100644
--- a/spec/features/projects/releases/user_views_edit_release_spec.rb
+++ b/spec/features/projects/releases/user_views_edit_release_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe 'User edits Release', :js do
project.add_developer(user)
- gitlab_sign_in(user)
+ sign_in(user)
visit edit_project_release_path(project, release)
diff --git a/spec/features/projects/releases/user_views_release_spec.rb b/spec/features/projects/releases/user_views_release_spec.rb
index c82588746a8..4410f345e56 100644
--- a/spec/features/projects/releases/user_views_release_spec.rb
+++ b/spec/features/projects/releases/user_views_release_spec.rb
@@ -4,17 +4,25 @@ require 'spec_helper'
RSpec.describe 'User views Release', :js do
let(:project) { create(:project, :repository) }
- let(:release) { create(:release, project: project, name: 'The first release' ) }
let(:user) { create(:user) }
+ let(:release) do
+ create(:release,
+ project: project,
+ name: 'The first release',
+ description: '**Lorem** _ipsum_ dolor sit [amet](https://example.com)')
+ end
+
before do
project.add_developer(user)
- gitlab_sign_in(user)
+ sign_in(user)
visit project_release_path(project, release)
end
+ it_behaves_like 'page meta description', 'Lorem ipsum dolor sit amet'
+
it 'renders the breadcrumbs' do
within('.breadcrumbs') do
expect(page).to have_content("#{project.creator.name} #{project.name} Releases #{release.name}")
@@ -31,7 +39,7 @@ RSpec.describe 'User views Release', :js do
expect(page).to have_content(release.name)
expect(page).to have_content(release.tag)
expect(page).to have_content(release.commit.short_id)
- expect(page).to have_content(release.description)
+ expect(page).to have_content('Lorem ipsum dolor sit amet')
end
end
end
diff --git a/spec/features/projects/releases/user_views_releases_spec.rb b/spec/features/projects/releases/user_views_releases_spec.rb
index 993d3371904..3bf472e82ec 100644
--- a/spec/features/projects/releases/user_views_releases_spec.rb
+++ b/spec/features/projects/releases/user_views_releases_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe 'User views releases', :js do
shared_examples 'releases page' do
context('when the user is a maintainer') do
before do
- gitlab_sign_in(maintainer)
+ sign_in(maintainer)
end
it 'sees the release' do
@@ -110,7 +110,7 @@ RSpec.describe 'User views releases', :js do
context('when the user is a guest') do
before do
- gitlab_sign_in(guest)
+ sign_in(guest)
end
it 'renders release info except for Git-related data' do
diff --git a/spec/features/projects/show/user_manages_notifications_spec.rb b/spec/features/projects/show/user_manages_notifications_spec.rb
index 9d9a75c22be..d444ea27d35 100644
--- a/spec/features/projects/show/user_manages_notifications_spec.rb
+++ b/spec/features/projects/show/user_manages_notifications_spec.rb
@@ -18,7 +18,9 @@ RSpec.describe 'Projects > Show > User manages notifications', :js do
click_notifications_button
click_link 'On mention'
- wait_for_requests
+ page.within('.notification-dropdown') do
+ expect(page).not_to have_css('.gl-spinner')
+ end
click_notifications_button
expect(find('.update-notification.is-active')).to have_content('On mention')
@@ -30,7 +32,9 @@ RSpec.describe 'Projects > Show > User manages notifications', :js do
click_notifications_button
click_link 'Disabled'
- wait_for_requests
+ page.within('.notification-dropdown') do
+ expect(page).not_to have_css('.gl-spinner')
+ end
expect(page).to have_css('.notifications-icon[data-testid="notifications-off-icon"]')
end
diff --git a/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb b/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb
index 81736fefae9..afa9de5ce86 100644
--- a/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb
+++ b/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb
@@ -226,7 +226,7 @@ RSpec.describe 'Projects > Show > User sees setup shortcut buttons' do
expect(project.repository.gitlab_ci_yml).to be_nil
page.within('.project-buttons') do
- expect(page).to have_link('Set up CI/CD', href: presenter.add_ci_yml_ide_path)
+ expect(page).to have_link('Set up CI/CD', href: presenter.add_ci_yml_path)
end
end
diff --git a/spec/features/projects/tree/tree_show_spec.rb b/spec/features/projects/tree/tree_show_spec.rb
index bd2af66710a..ca9e0a23888 100644
--- a/spec/features/projects/tree/tree_show_spec.rb
+++ b/spec/features/projects/tree/tree_show_spec.rb
@@ -69,7 +69,7 @@ RSpec.describe 'Projects tree', :js do
# Check last commit
expect(find('.commit-content').text).to include(message)
- expect(find('.commit-sha-group').text).to eq(short_newrev)
+ expect(find('.js-commit-sha-group').text).to eq(short_newrev)
end
end
diff --git a/spec/features/projects/user_sees_sidebar_spec.rb b/spec/features/projects/user_sees_sidebar_spec.rb
index 50d7b353c46..040e8741b6e 100644
--- a/spec/features/projects/user_sees_sidebar_spec.rb
+++ b/spec/features/projects/user_sees_sidebar_spec.rb
@@ -145,11 +145,11 @@ RSpec.describe 'Projects > User sees sidebar' do
expect(page).to have_content 'Project'
expect(page).to have_content 'Issues'
expect(page).to have_content 'Wiki'
+ expect(page).to have_content 'Operations'
expect(page).not_to have_content 'Repository'
expect(page).not_to have_content 'CI / CD'
expect(page).not_to have_content 'Merge Requests'
- expect(page).not_to have_content 'Operations'
end
end
diff --git a/spec/features/projects/wiki/markdown_preview_spec.rb b/spec/features/projects/wiki/markdown_preview_spec.rb
deleted file mode 100644
index 8f2fb9e827c..00000000000
--- a/spec/features/projects/wiki/markdown_preview_spec.rb
+++ /dev/null
@@ -1,168 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Projects > Wiki > User previews markdown changes', :js do
- let_it_be(:user) { create(:user) }
- let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
- let(:wiki_page) { create(:wiki_page, wiki: project.wiki, title: 'home', content: '[some link](other-page)') }
- let(:wiki_content) do
- <<-HEREDOC
-Some text so key event for [ does not trigger an incorrect replacement.
-[regular link](regular)
-[relative link 1](../relative)
-[relative link 2](./relative)
-[relative link 3](./e/f/relative)
-[spaced link](title with spaces)
- HEREDOC
- end
-
- before do
- project.add_maintainer(user)
-
- sign_in(user)
- end
-
- context "while creating a new wiki page" do
- context "when there are no spaces or hyphens in the page name" do
- it "rewrites relative links as expected" do
- create_wiki_page('a/b/c/d', content: wiki_content)
-
- expect(page).to have_content("regular link")
-
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/regular\">regular link</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a/b/relative\">relative link 1</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a/b/c/relative\">relative link 2</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a/b/c/e/f/relative\">relative link 3</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/title%20with%20spaces\">spaced link</a>")
- end
- end
-
- context "when there are spaces in the page name" do
- it "rewrites relative links as expected" do
- create_wiki_page('a page/b page/c page/d page', content: wiki_content)
-
- expect(page).to have_content("regular link")
-
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/regular\">regular link</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a-page/b-page/relative\">relative link 1</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a-page/b-page/c-page/relative\">relative link 2</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/title%20with%20spaces\">spaced link</a>")
- end
- end
-
- context "when there are hyphens in the page name" do
- it "rewrites relative links as expected" do
- create_wiki_page('a-page/b-page/c-page/d-page', content: wiki_content)
-
- expect(page).to have_content("regular link")
-
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/regular\">regular link</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a-page/b-page/relative\">relative link 1</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a-page/b-page/c-page/relative\">relative link 2</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/title%20with%20spaces\">spaced link</a>")
- end
- end
- end
-
- context "while editing a wiki page" do
- context "when there are no spaces or hyphens in the page name" do
- it "rewrites relative links as expected" do
- create_wiki_page('a/b/c/d')
- click_link 'Edit'
-
- fill_in :wiki_content, with: wiki_content
- click_on "Preview"
-
- expect(page).to have_content("regular link")
-
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/regular\">regular link</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a/b/relative\">relative link 1</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a/b/c/relative\">relative link 2</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a/b/c/e/f/relative\">relative link 3</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/title%20with%20spaces\">spaced link</a>")
- end
- end
-
- context "when there are spaces in the page name" do
- it "rewrites relative links as expected" do
- create_wiki_page('a page/b page/c page/d page')
- click_link 'Edit'
-
- fill_in :wiki_content, with: wiki_content
- click_on "Preview"
-
- expect(page).to have_content("regular link")
-
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/regular\">regular link</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a-page/b-page/relative\">relative link 1</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a-page/b-page/c-page/relative\">relative link 2</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/title%20with%20spaces\">spaced link</a>")
- end
- end
-
- context "when there are hyphens in the page name" do
- it "rewrites relative links as expected" do
- create_wiki_page('a-page/b-page/c-page/d-page')
- click_link 'Edit'
-
- fill_in :wiki_content, with: wiki_content
- click_on "Preview"
-
- expect(page).to have_content("regular link")
-
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/regular\">regular link</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a-page/b-page/relative\">relative link 1</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a-page/b-page/c-page/relative\">relative link 2</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>")
- expect(page.html).to include("<a href=\"/#{project.full_path}/-/wikis/title%20with%20spaces\">spaced link</a>")
- end
- end
-
- context 'when rendering the preview' do
- it 'renders content with CommonMark' do
- create_wiki_page('a-page/b-page/c-page/common-mark')
- click_link 'Edit'
-
- fill_in :wiki_content, with: "1. one\n - sublist\n"
- click_on "Preview"
-
- # the above generates two separate lists (not embedded) in CommonMark
- expect(page).to have_content("sublist")
- expect(page).not_to have_xpath("//ol//li//ul")
- end
- end
- end
-
- it "does not linkify double brackets inside code blocks as expected" do
- wiki_content = <<-HEREDOC
- `[[do_not_linkify]]`
- ```
- [[also_do_not_linkify]]
- ```
- HEREDOC
-
- create_wiki_page('linkify_test', wiki_content)
-
- expect(page).to have_content("do_not_linkify")
-
- expect(page.html).to include('[[do_not_linkify]]')
- expect(page.html).to include('[[also_do_not_linkify]]')
- end
-
- private
-
- def create_wiki_page(path, content = 'content')
- visit project_wiki_path(project, wiki_page)
-
- click_link 'New page'
-
- fill_in :wiki_title, with: path
- fill_in :wiki_content, with: content
-
- click_button 'Create page'
- end
-end
diff --git a/spec/features/projects/wiki/shortcuts_spec.rb b/spec/features/projects/wiki/shortcuts_spec.rb
deleted file mode 100644
index 170e7afb51f..00000000000
--- a/spec/features/projects/wiki/shortcuts_spec.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Wiki shortcuts', :js do
- let(:user) { create(:user) }
- let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
- let(:wiki_page) { create(:wiki_page, wiki: project.wiki, title: 'home', content: 'Home page') }
-
- before do
- sign_in(user)
- visit project_wiki_path(project, wiki_page)
- end
-
- it 'Visit edit wiki page using "e" keyboard shortcut' do
- find('body').native.send_key('e')
-
- expect(find('.wiki-page-title')).to have_content('Edit Page')
- end
-end
diff --git a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb
deleted file mode 100644
index eba1b63765a..00000000000
--- a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb
+++ /dev/null
@@ -1,360 +0,0 @@
-# frozen_string_literal: true
-
-require "spec_helper"
-
-RSpec.describe "User creates wiki page" do
- include WikiHelpers
-
- let(:user) { create(:user) }
- let(:wiki) { ProjectWiki.new(project, user) }
- let(:project) { create(:project) }
-
- before do
- project.add_maintainer(user)
-
- sign_in(user)
- end
-
- context "when wiki is empty" do
- before do |example|
- visit(project_wikis_path(project))
-
- wait_for_svg_to_be_loaded(example)
-
- click_link "Create your first page"
- end
-
- context "in a user namespace" do
- let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
-
- it "shows validation error message" do
- page.within(".wiki-form") do
- fill_in(:wiki_content, with: "")
-
- click_on("Create page")
- end
-
- expect(page).to have_content("The form contains the following error:").and have_content("Content can't be blank")
-
- page.within(".wiki-form") do
- fill_in(:wiki_content, with: "[link test](test)")
-
- click_on("Create page")
- end
-
- expect(page).to have_content("Home").and have_content("link test")
-
- click_link("link test")
-
- expect(page).to have_content("Create New Page")
- end
-
- it "shows non-escaped link in the pages list" do
- fill_in(:wiki_title, with: "one/two/three-test")
-
- 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(page).to have_xpath("//a[@href='/#{project.full_path}/-/wikis/one/two/three-test']")
- end
-
- it "has `Create home` as a commit message", :js do
- wait_for_requests
-
- expect(page).to have_field("wiki[message]", with: "Create home")
- end
-
- it "creates a page from the home page" do
- fill_in(:wiki_content, with: "[test](test)\n[GitLab API doc](api)\n[Rake tasks](raketasks)\n# Wiki header\n")
- fill_in(:wiki_message, with: "Adding links to wiki")
-
- page.within(".wiki-form") do
- click_button("Create page")
- end
-
- expect(current_path).to eq(project_wiki_path(project, "home"))
- expect(page).to have_content("test GitLab API doc Rake tasks Wiki header")
- .and have_content("Home")
- .and have_content("Last edited by #{user.name}")
- .and have_header_with_correct_id_and_link(1, "Wiki header", "wiki-header")
-
- click_link("test")
-
- expect(current_path).to eq(project_wiki_path(project, "test"))
-
- page.within(:css, ".nav-text") do
- expect(page).to have_content("Create New Page")
- end
-
- click_link("Home")
-
- expect(current_path).to eq(project_wiki_path(project, "home"))
-
- click_link("GitLab API")
-
- expect(current_path).to eq(project_wiki_path(project, "api"))
-
- page.within(:css, ".nav-text") do
- expect(page).to have_content("Create")
- end
-
- click_link("Home")
-
- expect(current_path).to eq(project_wiki_path(project, "home"))
-
- click_link("Rake tasks")
-
- expect(current_path).to eq(project_wiki_path(project, "raketasks"))
-
- page.within(:css, ".nav-text") do
- expect(page).to have_content("Create")
- end
- end
-
- it "creates ASCII wiki with LaTeX blocks", :js do
- stub_application_setting(plantuml_url: "http://localhost", plantuml_enabled: true)
-
- ascii_content = <<~MD
- :stem: latexmath
-
- [stem]
- ++++
- \\sqrt{4} = 2
- ++++
-
- another part
-
- [latexmath]
- ++++
- \\beta_x \\gamma
- ++++
-
- stem:[2+2] is 4
- MD
-
- find("#wiki_format option[value=asciidoc]").select_option
-
- fill_in(:wiki_content, with: ascii_content)
-
- page.within(".wiki-form") do
- click_button("Create page")
- end
-
- page.within ".md" do
- expect(page).to have_selector(".katex", count: 3).and have_content("2+2 is 4")
- end
- end
-
- it 'creates a wiki page with Org markup', :aggregate_failures do
- org_content = <<~ORG
- * Heading
- ** Subheading
- [[home][Link to Home]]
- ORG
-
- page.within('.wiki-form') do
- find('#wiki_format option[value=org]').select_option
- fill_in(:wiki_content, with: org_content)
- click_button('Create page')
- end
-
- expect(page).to have_selector('h1', text: 'Heading')
- expect(page).to have_selector('h2', text: 'Subheading')
- expect(page).to have_link('Link to Home', href: "/#{project.full_path}/-/wikis/home")
- end
-
- it_behaves_like 'wiki file attachments'
- end
-
- context "in a group namespace", :js do
- let(:project) { create(:project, :wiki_repo, namespace: create(:group, :public)) }
-
- it "has `Create home` as a commit message" do
- wait_for_requests
-
- expect(page).to have_field("wiki[message]", with: "Create home")
- end
-
- it "creates a page from the home page" do
- page.within(".wiki-form") do
- fill_in(:wiki_content, with: "My awesome wiki!")
-
- click_button("Create page")
- end
-
- expect(page).to have_content("Home")
- .and have_content("Last edited by #{user.name}")
- .and have_content("My awesome wiki!")
- end
- end
- end
-
- context "when wiki is not empty", :js do
- before do
- create(:wiki_page, wiki: wiki, title: 'home', content: 'Home page')
-
- visit(project_wikis_path(project))
- end
-
- context "in a user namespace" do
- let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
-
- context "via the `new wiki page` page" do
- it "creates a page with a single word" do
- click_link("New page")
-
- page.within(".wiki-form") do
- fill_in(:wiki_title, with: "foo")
- fill_in(:wiki_content, with: "My awesome wiki!")
- end
-
- # Commit message field should have correct value.
- expect(page).to have_field("wiki[message]", with: "Create foo")
-
- click_button("Create page")
-
- expect(page).to have_content("foo")
- .and have_content("Last edited by #{user.name}")
- .and have_content("My awesome wiki!")
- end
-
- it "creates a page with spaces in the name" do
- click_link("New page")
-
- page.within(".wiki-form") do
- fill_in(:wiki_title, with: "Spaces in the name")
- fill_in(:wiki_content, with: "My awesome wiki!")
- end
-
- # Commit message field should have correct value.
- expect(page).to have_field("wiki[message]", with: "Create Spaces in the name")
-
- click_button("Create page")
-
- expect(page).to have_content("Spaces in the name")
- .and have_content("Last edited by #{user.name}")
- .and have_content("My awesome wiki!")
- end
-
- it "creates a page with hyphens in the name" do
- click_link("New page")
-
- page.within(".wiki-form") do
- fill_in(:wiki_title, with: "hyphens-in-the-name")
- fill_in(:wiki_content, with: "My awesome wiki!")
- end
-
- # Commit message field should have correct value.
- expect(page).to have_field("wiki[message]", with: "Create hyphens in the name")
-
- page.within(".wiki-form") do
- fill_in(:wiki_content, with: "My awesome wiki!")
-
- click_button("Create page")
- end
-
- expect(page).to have_content("hyphens in the name")
- .and have_content("Last edited by #{user.name}")
- .and have_content("My awesome wiki!")
- end
- end
-
- it "shows the emoji autocompletion dropdown" do
- click_link("New page")
-
- page.within(".wiki-form") do
- find("#wiki_content").native.send_keys("")
-
- fill_in(:wiki_content, with: ":")
- end
-
- expect(page).to have_selector(".atwho-view")
- end
- end
-
- context "in a group namespace" do
- let(:project) { create(:project, :wiki_repo, namespace: create(:group, :public)) }
-
- context "via the `new wiki page` page" do
- it "creates a page" do
- click_link("New page")
-
- page.within(".wiki-form") do
- fill_in(:wiki_title, with: "foo")
- fill_in(:wiki_content, with: "My awesome wiki!")
- end
-
- # Commit message field should have correct value.
- expect(page).to have_field("wiki[message]", with: "Create foo")
-
- click_button("Create page")
-
- expect(page).to have_content("foo")
- .and have_content("Last edited by #{user.name}")
- .and have_content("My awesome wiki!")
- end
- end
- end
- end
-
- describe 'sidebar feature' do
- context 'when there are some existing pages' do
- before do
- create(:wiki_page, wiki: wiki, title: 'home', content: 'home')
- create(:wiki_page, wiki: wiki, title: 'another', content: 'another')
- end
-
- it 'renders a default sidebar when there is no customized sidebar' do
- visit(project_wikis_path(project))
-
- expect(page).to have_content('another')
- expect(page).not_to have_link('View All Pages')
- end
-
- context 'when there is a customized sidebar' do
- before do
- create(:wiki_page, wiki: wiki, title: '_sidebar', content: 'My customized sidebar')
- end
-
- it 'renders my customized sidebar instead of the default one' do
- visit(project_wikis_path(project))
-
- expect(page).to have_content('My customized sidebar')
- expect(page).not_to have_content('Another')
- end
- end
- end
-
- context 'when there are 15 existing pages' do
- before do
- (1..5).each { |i| create(:wiki_page, wiki: wiki, title: "my page #{i}") }
- (6..10).each { |i| create(:wiki_page, wiki: wiki, title: "parent/my page #{i}") }
- (11..15).each { |i| create(:wiki_page, wiki: wiki, title: "grandparent/parent/my page #{i}") }
- end
-
- it 'shows all pages in the sidebar' do
- visit(project_wikis_path(project))
-
- (1..15).each { |i| expect(page).to have_content("my page #{i}") }
- expect(page).not_to have_link('View All Pages')
- end
-
- context 'when there are more than 15 existing pages' do
- before do
- create(:wiki_page, wiki: wiki, title: 'my page 16')
- end
-
- it 'shows the first 15 pages in the sidebar' do
- visit(project_wikis_path(project))
-
- expect(page).to have_text('my page', count: 15)
- expect(page).to have_link('View All Pages')
- end
- end
- end
- end
-end
diff --git a/spec/features/projects/wiki/user_deletes_wiki_page_spec.rb b/spec/features/projects/wiki/user_deletes_wiki_page_spec.rb
deleted file mode 100644
index a5d865d581b..00000000000
--- a/spec/features/projects/wiki/user_deletes_wiki_page_spec.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'User deletes wiki page', :js do
- let(:user) { create(:user) }
- let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
- let(:wiki_page) { create(:wiki_page, wiki: project.wiki) }
-
- before do
- sign_in(user)
- visit(project_wiki_path(project, wiki_page))
- end
-
- it 'deletes a page' do
- click_on('Edit')
- click_on('Delete')
- find('.modal-footer .btn-danger').click
-
- expect(page).to have_content('Page was successfully deleted')
- end
-end
diff --git a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb
deleted file mode 100644
index fdab63a56b8..00000000000
--- a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb
+++ /dev/null
@@ -1,263 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'User updates wiki page' do
- include WikiHelpers
-
- let(:user) { create(:user) }
-
- before do
- project.add_maintainer(user)
- sign_in(user)
- end
-
- context 'when wiki is empty' do
- before do |example|
- visit(project_wikis_path(project))
-
- wait_for_svg_to_be_loaded(example)
-
- click_link "Create your first page"
- end
-
- context 'in a user namespace' do
- let(:project) { create(:project, :wiki_repo) }
-
- it 'redirects back to the home edit page' do
- page.within(:css, '.wiki-form .form-actions') do
- click_on('Cancel')
- end
-
- expect(current_path).to eq wiki_path(project.wiki)
- end
-
- it 'updates a page that has a path', :js do
- fill_in(:wiki_title, with: 'one/two/three-test')
-
- 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
-
- it_behaves_like 'wiki file attachments'
- 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, title: 'home', content: 'Home page') }
-
- before do
- visit(project_wikis_path(project))
-
- click_link('Edit')
- end
-
- context 'in a user namespace' do
- let(:project) { create(:project, :wiki_repo) }
-
- it 'updates a page', :js do
- # Commit message field should have correct value.
- expect(page).to have_field('wiki[message]', with: 'Update home')
-
- 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
-
- it 'updates the commit message as the title is changed', :js do
- fill_in(:wiki_title, with: '& < > \ \ { } &')
-
- expect(page).to have_field('wiki[message]', with: 'Update & < > \ \ { } &')
- end
-
- it 'correctly escapes the commit message entities', :js do
- fill_in(:wiki_title, with: 'Wiki title')
-
- expect(page).to have_field('wiki[message]', with: 'Update Wiki title')
- end
-
- it 'shows a validation error message' do
- 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
-
- it 'shows the emoji autocompletion dropdown', :js do
- find('#wiki_content').native.send_keys('')
- fill_in(:wiki_content, with: ':')
-
- expect(page).to have_selector('.atwho-view')
- end
-
- it 'shows the error message' do
- wiki_page.update(content: 'Update')
-
- click_button('Save changes')
-
- expect(page).to have_content('Someone edited the page the same time you did.')
- end
-
- it 'updates a page' do
- fill_in('Content', with: 'Updated Wiki Content')
- click_on('Save changes')
-
- expect(page).to have_content('Updated Wiki Content')
- end
-
- it 'cancels editing of a page' do
- page.within(:css, '.wiki-form .form-actions') do
- click_on('Cancel')
- end
-
- expect(current_path).to eq(project_wiki_path(project, wiki_page))
- end
-
- it_behaves_like 'wiki file attachments'
- end
-
- context 'in a group namespace' do
- let(:project) { create(:project, :wiki_repo, namespace: create(:group, :public)) }
-
- it 'updates a page', :js do
- # Commit message field should have correct value.
- expect(page).to have_field('wiki[message]', with: 'Update home')
-
- 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
-
- it_behaves_like 'wiki file attachments'
- end
- end
-
- context 'when the page is in a subdir' do
- let!(:project) { create(:project, :wiki_repo) }
- 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, title: page_dir, content: 'Home page') }
-
- before do
- visit(project_wiki_edit_path(project, wiki_page))
- end
-
- it 'moves the page to the root folder' do
- fill_in(:wiki_title, with: "/#{page_name}")
-
- click_button('Save changes')
-
- expect(current_path).to eq(project_wiki_path(project, page_name))
- end
-
- it 'moves the page to other dir' do
- new_page_dir = "foo1/bar1/#{page_name}"
-
- 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 'remains in the same place if title has not changed' do
- original_path = project_wiki_path(project, wiki_page)
-
- fill_in(:wiki_title, with: page_name)
-
- click_button('Save changes')
-
- expect(current_path).to eq(original_path)
- end
-
- it 'can be moved to a different dir with a different name' do
- new_page_dir = "foo1/bar1/new_page_name"
-
- 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 'can be renamed and moved to the root folder' do
- new_name = 'new_page_name'
-
- fill_in(:wiki_title, with: "/#{new_name}")
-
- click_button('Save changes')
-
- expect(current_path).to eq(project_wiki_path(project, new_name))
- end
-
- it 'squishes the title before creating the page' do
- new_page_dir = " foo1 / bar1 / #{page_name} "
-
- fill_in(:wiki_title, with: new_page_dir)
-
- click_button('Save changes')
-
- expect(current_path).to eq(project_wiki_path(project, "foo1/bar1/#{page_name}"))
- end
-
- it_behaves_like 'wiki file attachments'
- end
-
- context 'when an existing page exceeds the content size limit' do
- let_it_be(:project) { create(:project, :wiki_repo) }
- let!(:wiki_page) { create(:wiki_page, wiki: project.wiki, content: "one\ntwo\nthree") }
-
- before do
- stub_application_setting(wiki_page_max_content_bytes: 10)
-
- visit wiki_page_path(wiki_page.wiki, wiki_page, action: :edit)
- end
-
- it 'allows changing the title if the content does not change' do
- fill_in 'Title', with: 'new title'
- click_on 'Save changes'
-
- expect(page).to have_content('Wiki was successfully updated.')
- end
-
- it 'shows a validation error when trying to change the content' do
- fill_in 'Content', with: 'new content'
- click_on 'Save changes'
-
- expect(page).to have_content('The form contains the following error:')
- expect(page).to have_content('Content is too long (11 Bytes). The maximum size is 10 Bytes.')
- end
- end
-end
diff --git a/spec/features/projects/wiki/user_views_wiki_empty_spec.rb b/spec/features/projects/wiki/user_views_wiki_empty_spec.rb
index 0af40a2d760..1f460f39267 100644
--- a/spec/features/projects/wiki/user_views_wiki_empty_spec.rb
+++ b/spec/features/projects/wiki/user_views_wiki_empty_spec.rb
@@ -2,108 +2,86 @@
require 'spec_helper'
-RSpec.describe 'User views empty wiki' do
- let(:user) { create(:user) }
- let(:confluence_link) { 'Enable the Confluence Wiki integration' }
- let(:element) { page.find('.row.empty-state') }
-
- shared_examples 'empty wiki and accessible issues' do
- it 'show "issue tracker" message' do
- visit(project_wikis_path(project))
-
- expect(element).to have_content('This project has no wiki pages')
- expect(element).to have_content('You must be a project member')
- expect(element).to have_content('improve the wiki for this project')
- expect(element).to have_link("issue tracker", href: project_issues_path(project))
- expect(element).to have_link("Suggest wiki improvement", href: new_project_issue_path(project))
- expect(element).to have_no_link(confluence_link)
- end
- end
-
- shared_examples 'empty wiki and non-accessible issues' do
- it 'does not show "issue tracker" message' do
- visit(project_wikis_path(project))
+RSpec.describe 'Project > User views empty wiki' do
+ let_it_be(:user) { create(:user) }
- expect(element).to have_content('This project has no wiki pages')
- expect(element).to have_content('You must be a project member')
- expect(element).to have_no_link('Suggest wiki improvement')
- expect(element).to have_no_link(confluence_link)
- end
- end
+ let(:wiki) { create(:project_wiki, project: project) }
- context 'when user is logged out and issue tracker is public' do
- let(:project) { create(:project, :public, :wiki_repo) }
+ it_behaves_like 'User views empty wiki' do
+ context 'when project is public' do
+ let(:project) { create(:project, :public) }
- it_behaves_like 'empty wiki and accessible issues'
- end
+ it_behaves_like 'empty wiki message', issuable: true
- context 'when user is logged in and not a member' do
- let(:project) { create(:project, :public, :wiki_repo) }
+ context 'when issue tracker is private' do
+ let(:project) { create(:project, :public, :issues_private) }
- before do
- sign_in(user)
- end
+ it_behaves_like 'empty wiki message', issuable: false
+ end
- it_behaves_like 'empty wiki and accessible issues'
- end
+ context 'when issue tracker is disabled' do
+ let(:project) { create(:project, :public, :issues_disabled) }
- context 'when issue tracker is private' do
- let(:project) { create(:project, :public, :wiki_repo, :issues_private) }
+ it_behaves_like 'empty wiki message', issuable: false
+ end
- it_behaves_like 'empty wiki and non-accessible issues'
- end
+ context 'and user is logged in' do
+ before do
+ sign_in(user)
+ end
- context 'when issue tracker is disabled' do
- let(:project) { create(:project, :public, :wiki_repo, :issues_disabled) }
+ context 'and user is not a member' do
+ it_behaves_like 'empty wiki message', issuable: true
+ end
- it_behaves_like 'empty wiki and non-accessible issues'
- end
+ context 'and user is a member' do
+ before do
+ project.add_developer(user)
+ end
- context 'when user is logged in and a member' do
- let(:project) { create(:project, :public) }
-
- before do
- sign_in(user)
- project.add_developer(user)
+ it_behaves_like 'empty wiki message', writable: true, issuable: true
+ end
+ end
end
- it 'shows "create first page" message' do
- visit(project_wikis_path(project))
-
- expect(element).to have_content('your project', count: 2)
+ context 'when project is private' do
+ let(:project) { create(:project, :private) }
- element.click_link 'Create your first page'
+ it_behaves_like 'wiki is not found'
- expect(page).to have_button('Create page')
- end
+ context 'and user is logged in' do
+ before do
+ sign_in(user)
+ end
- it 'does not show the "enable confluence" button' do
- visit(project_wikis_path(project))
+ context 'and user is not a member' do
+ it_behaves_like 'wiki is not found'
+ end
- expect(element).to have_no_link(confluence_link)
- end
- end
+ context 'and user is a member' do
+ before do
+ project.add_developer(user)
+ end
- context 'when user is logged in and an admin' do
- let(:project) { create(:project, :public, :wiki_repo) }
+ it_behaves_like 'empty wiki message', writable: true, issuable: true
+ end
- before do
- sign_in(user)
- project.add_maintainer(user)
- end
-
- it 'shows the "enable confluence" button' do
- visit(project_wikis_path(project))
-
- expect(element).to have_link(confluence_link)
- end
+ context 'and user is a maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
- it 'does not show "enable confluence" button if confluence is already enabled' do
- create(:confluence_service, project: project)
+ it_behaves_like 'empty wiki message', writable: true, issuable: true, confluence: true
- visit(project_wikis_path(project))
+ context 'and Confluence is already enabled' do
+ before do
+ create(:confluence_service, project: project)
+ end
- expect(element).to have_no_link(confluence_link)
+ it_behaves_like 'empty wiki message', writable: true, issuable: true, confluence: false
+ end
+ end
+ end
end
end
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
deleted file mode 100644
index e93689af0aa..00000000000
--- a/spec/features/projects/wiki/user_views_wiki_page_spec.rb
+++ /dev/null
@@ -1,276 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'User views a wiki page' do
- include WikiHelpers
-
- let(:user) { create(:user) }
- let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
- let(:path) { 'image.png' }
- let(:wiki) { project.wiki }
- let(:wiki_page) do
- create(:wiki_page,
- wiki: wiki,
- title: 'home', content: "Look at this [image](#{path})\n\n ![alt text](#{path})")
- end
-
- before do
- project.add_maintainer(user)
- sign_in(user)
- end
-
- context 'when wiki is empty', :js do
- before do
- visit project_wikis_path(project)
-
- wait_for_svg_to_be_loaded
-
- click_link "Create your first page"
-
- fill_in(:wiki_title, with: 'one/two/three-test')
-
- page.within('.wiki-form') do
- fill_in(:wiki_content, with: 'wiki content')
- click_on('Create page')
- end
-
- expect(page).to have_content('Wiki was successfully updated.')
- end
-
- it 'shows the history of a page that has a path' do
- expect(current_path).to include('one/two/three-test')
-
- first(:link, text: 'three').click
- click_on('Page history')
-
- expect(current_path).to include('one/two/three-test')
-
- page.within(:css, '.nav-text') do
- expect(page).to have_content('History')
- end
- end
-
- it 'shows an old version of a page' do
- 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('Wiki was successfully updated.')
-
- click_on('Page history')
-
- within('.nav-text') do
- expect(page).to have_content('History')
- end
-
- within('.wiki-history') do
- expect(page).to have_css('a[href*="?version_id"]', count: 4)
- end
- end
- 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
-
- context 'shows a file stored in a page' do
- let(:path) { upload_file_to_wiki(project, user, 'dk.png') }
-
- it do
- expect(page).to have_xpath("//img[@data-src='#{wiki.wiki_base_path}/#{path}']")
- expect(page).to have_link('image', href: "#{wiki.wiki_base_path}/#{path}")
-
- click_on('image')
-
- expect(current_path).to match("wikis/#{path}")
- expect(page).not_to have_xpath('/html') # Page should render the image which means there is no html involved
- end
- end
-
- it 'shows the creation page if file does not exist' do
- expect(page).to have_link('image', href: "#{wiki.wiki_base_path}/#{path}")
-
- click_on('image')
-
- expect(current_path).to match("wikis/#{path}")
- expect(page).to have_content('Create New Page')
- end
- end
-
- context 'when a page has history' do
- before do
- wiki_page.update(message: 'updated home', content: 'updated [some link](other-page)') # rubocop:disable Rails/SaveBang
- end
-
- it 'shows the page history' do
- visit(project_wiki_path(project, wiki_page))
-
- expect(page).to have_selector('a.btn', text: 'Edit')
-
- 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')
- 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
-
- context 'show the diff' do
- def expect_diff_links(commit)
- diff_path = wiki_page_path(wiki, wiki_page, version_id: commit, action: :diff)
-
- expect(page).to have_link('Hide whitespace changes', href: "#{diff_path}&w=1")
- expect(page).to have_link('Inline', href: "#{diff_path}&view=inline")
- expect(page).to have_link('Side-by-side', href: "#{diff_path}&view=parallel")
- expect(page).to have_link("View page @ #{commit.short_id}", href: wiki_page_path(wiki, wiki_page, version_id: commit))
- expect(page).to have_css('.diff-file[data-blob-diff-path="%s"]' % diff_path)
- end
-
- it 'links to the correct diffs' do
- visit project_wiki_history_path(project, wiki_page)
-
- commit1 = wiki.commit('HEAD^')
- commit2 = wiki.commit
-
- expect(page).to have_link('created page: home', href: wiki_page_path(wiki, wiki_page, version_id: commit1, action: :diff))
- expect(page).to have_link('updated home', href: wiki_page_path(wiki, wiki_page, version_id: commit2, action: :diff))
- end
-
- it 'between the current and the previous version of a page' do
- commit = wiki.commit
- visit wiki_page_path(wiki, wiki_page, version_id: commit, action: :diff)
-
- expect(page).to have_content('by John Doe')
- expect(page).to have_content('updated home')
- expect(page).to have_content('Showing 1 changed file with 1 addition and 3 deletions')
- expect(page).to have_content('some link')
-
- expect_diff_links(commit)
- end
-
- it 'between two old versions of a page' do
- wiki_page.update(message: 'latest home change', content: 'updated [another link](other-page)') # rubocop:disable Rails/SaveBang:
- commit = wiki.commit('HEAD^')
- visit wiki_page_path(wiki, wiki_page, version_id: commit, action: :diff)
-
- expect(page).to have_content('by John Doe')
- expect(page).to have_content('updated home')
- expect(page).to have_content('Showing 1 changed file with 1 addition and 3 deletions')
- expect(page).to have_content('some link')
- expect(page).not_to have_content('latest home change')
- expect(page).not_to have_content('another link')
-
- expect_diff_links(commit)
- end
-
- it 'for the oldest version of a page' do
- commit = wiki.commit('HEAD^')
- visit wiki_page_path(wiki, wiki_page, version_id: commit, action: :diff)
-
- expect(page).to have_content('by John Doe')
- expect(page).to have_content('created page: home')
- expect(page).to have_content('Showing 1 changed file with 4 additions and 0 deletions')
- expect(page).to have_content('Look at this')
-
- expect_diff_links(commit)
- end
- end
- end
-
- context 'when a page has special characters in its title' do
- let(:title) { '<foo> !@#$%^&*()[]{}=_+\'"\\|<>? <bar>' }
-
- before do
- wiki_page.update(title: title ) # rubocop:disable Rails/SaveBang
- end
-
- it 'preserves the special characters' do
- visit(project_wiki_path(project, wiki_page))
-
- expect(page).to have_css('.wiki-page-title', text: title)
- expect(page).to have_css('.wiki-pages li', text: title)
- end
- end
-
- context 'when a page has XSS in its title or content' do
- let(:title) { '<script>alert("title")<script>' }
-
- before do
- wiki_page.update(title: title, content: 'foo <script>alert("content")</script> bar') # rubocop:disable Rails/SaveBang
- end
-
- it 'safely displays the page' do
- visit(project_wiki_path(project, wiki_page))
-
- expect(page).to have_css('.wiki-page-title', text: title)
- expect(page).to have_content('foo bar')
- end
- end
-
- context 'when a page has XSS in its message' do
- before do
- wiki_page.update(message: '<script>alert(true)<script>', content: 'XSS update') # rubocop:disable Rails/SaveBang
- end
-
- it 'safely displays the message' do
- visit(project_wiki_history_path(project, wiki_page))
-
- expect(page).to have_content('<script>alert(true)<script>')
- end
- end
-
- context 'when page has invalid content encoding' do
- let(:content) { (+'whatever').force_encoding('ISO-8859-1') }
-
- before do
- allow(Gitlab::EncodingHelper).to receive(:encode!).and_return(content)
-
- visit(project_wiki_path(project, wiki_page))
- end
-
- it 'does not show "Edit" button' do
- expect(page).not_to have_selector('a.btn', text: 'Edit')
- end
-
- it 'shows error' do
- page.within(:css, '.flash-notice') do
- expect(page).to have_content('The content of this page is not encoded in UTF-8. Edits can only be made via the Git repository.')
- end
- end
- end
-
- it 'opens a default wiki page', :js do
- visit project_path(project)
-
- find('.shortcuts-wiki').click
-
- wait_for_svg_to_be_loaded
-
- click_link "Create your first page"
-
- expect(page).to have_content('Create New Page')
- end
-end
diff --git a/spec/features/projects/wiki/user_views_wiki_pages_spec.rb b/spec/features/projects/wiki/user_views_wiki_pages_spec.rb
deleted file mode 100644
index 4f29ae0cc8a..00000000000
--- a/spec/features/projects/wiki/user_views_wiki_pages_spec.rb
+++ /dev/null
@@ -1,91 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'User views wiki pages' do
- include WikiHelpers
-
- let(:user) { create(:user) }
- let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
-
- let!(:wiki_page1) do
- create(:wiki_page, wiki: project.wiki, title: '3 home', content: '3')
- end
-
- let!(:wiki_page2) do
- create(:wiki_page, wiki: project.wiki, title: '1 home', content: '1')
- end
-
- let!(:wiki_page3) do
- create(:wiki_page, wiki: project.wiki, title: '2 home', content: '2')
- end
-
- let(:pages) do
- page.find('.wiki-pages-list').all('li').map { |li| li.find('a') }
- end
-
- before do
- project.add_maintainer(user)
- sign_in(user)
- visit(project_wikis_pages_path(project))
- end
-
- context 'ordered by title' do
- let(:pages_ordered_by_title) { [wiki_page2, wiki_page3, wiki_page1] }
-
- context 'asc' do
- it 'pages are displayed in direct order' do
- pages.each.with_index do |page_title, index|
- expect(page_title.text).to eq(pages_ordered_by_title[index].title)
- end
- end
- end
-
- context 'desc' do
- before do
- page.within('.wiki-sort-dropdown') do
- page.find('.rspec-reverse-sort').click
- end
- end
-
- it 'pages are displayed in reversed order' do
- pages.reverse_each.with_index do |page_title, index|
- expect(page_title.text).to eq(pages_ordered_by_title[index].title)
- end
- end
- end
- end
-
- context 'ordered by created_at' do
- let(:pages_ordered_by_created_at) { [wiki_page1, wiki_page2, wiki_page3] }
-
- before do
- page.within('.wiki-sort-dropdown') do
- click_button('Title')
- click_link('Created date')
- end
- end
-
- context 'asc' do
- it 'pages are displayed in direct order' do
- pages.each.with_index do |page_title, index|
- expect(page_title.text).to eq(pages_ordered_by_created_at[index].title)
- end
- end
- end
-
- context 'desc' do
- before do
- page.within('.wiki-sort-dropdown') do
- page.find('.rspec-reverse-sort').click
- end
- end
-
- it 'pages are displayed in reversed order' do
- pages.reverse_each.with_index do |page_title, index|
- expect(page_title.text).to eq(pages_ordered_by_created_at[index].title)
- end
- end
- end
- end
-end
diff --git a/spec/features/projects/wiki/users_views_asciidoc_page_with_includes_spec.rb b/spec/features/projects/wiki/users_views_asciidoc_page_with_includes_spec.rb
deleted file mode 100644
index 5c45e34595f..00000000000
--- a/spec/features/projects/wiki/users_views_asciidoc_page_with_includes_spec.rb
+++ /dev/null
@@ -1,79 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'User views AsciiDoc page with includes', :js do
- let_it_be(:user) { create(:user) }
- let_it_be(:wiki_content_selector) { '[data-qa-selector=wiki_page_content]' }
- let(:project) { create(:project, :public, :wiki_repo) }
- let!(:included_wiki_page) { create_wiki_page('included_page', content: 'Content from the included page')}
- let!(:wiki_page) { create_wiki_page('home', content: "Content from the main page.\ninclude::included_page.asciidoc[]") }
-
- def create_wiki_page(title, content:)
- attrs = {
- title: title,
- content: content,
- format: :asciidoc
- }
-
- create(:wiki_page, wiki: project.wiki, **attrs)
- end
-
- before do
- sign_in(user)
- end
-
- context 'when the file being included exists' do
- it 'includes the file contents' do
- visit(project_wiki_path(project, wiki_page))
-
- page.within(:css, wiki_content_selector) do
- expect(page).to have_content('Content from the main page. Content from the included page')
- end
- end
-
- context 'when there are multiple versions of the wiki pages' do
- before do
- included_wiki_page.update(message: 'updated included file', content: 'Updated content from the included page')
- wiki_page.update(message: 'updated wiki page', content: "Updated content from the main page.\ninclude::included_page.asciidoc[]")
- end
-
- let(:latest_version_id) { wiki_page.versions.first.id }
- let(:oldest_version_id) { wiki_page.versions.last.id }
-
- context 'viewing the latest version' do
- it 'includes the latest content' do
- visit(project_wiki_path(project, wiki_page, version_id: latest_version_id))
-
- page.within(:css, wiki_content_selector) do
- expect(page).to have_content('Updated content from the main page. Updated content from the included page')
- end
- end
- end
-
- context 'viewing the original version' do
- it 'includes the content from the original version' do
- visit(project_wiki_path(project, wiki_page, version_id: oldest_version_id))
-
- page.within(:css, wiki_content_selector) do
- expect(page).to have_content('Content from the main page. Content from the included page')
- end
- end
- end
- end
- end
-
- context 'when the file being included does not exist' do
- before do
- included_wiki_page.delete
- end
-
- it 'outputs an error' do
- visit(project_wiki_path(project, wiki_page))
-
- page.within(:css, wiki_content_selector) do
- expect(page).to have_content('Content from the main page. [ERROR: include::included_page.asciidoc[] - unresolved directive]')
- end
- end
- end
-end
diff --git a/spec/features/projects/wikis_spec.rb b/spec/features/projects/wikis_spec.rb
new file mode 100644
index 00000000000..1c66ad81145
--- /dev/null
+++ b/spec/features/projects/wikis_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe 'Project wikis' do
+ let_it_be(:user) { create(:user) }
+
+ let(:wiki) { create(:project_wiki, user: user, project: project) }
+ let(:project) { create(:project, namespace: user.namespace, creator: user) }
+
+ it_behaves_like 'User creates wiki page'
+ it_behaves_like 'User deletes wiki page'
+ it_behaves_like 'User previews wiki changes'
+ it_behaves_like 'User updates wiki page'
+ it_behaves_like 'User uses wiki shortcuts'
+ it_behaves_like 'User views AsciiDoc page with includes'
+ it_behaves_like 'User views a wiki page'
+ it_behaves_like 'User views wiki pages'
+ it_behaves_like 'User views wiki sidebar'
+end
diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb
index 970500985ae..6c1e1eab968 100644
--- a/spec/features/projects_spec.rb
+++ b/spec/features/projects_spec.rb
@@ -99,6 +99,15 @@ RSpec.describe 'Project' do
expect(page).to have_css('.home-panel-description .is-expanded')
end
end
+
+ context 'page description' do
+ before do
+ project.update_attribute(:description, '**Lorem** _ipsum_ dolor sit [amet](https://example.com)')
+ visit path
+ end
+
+ it_behaves_like 'page meta description', 'Lorem ipsum dolor sit amet'
+ end
end
describe 'project topics' do
diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb
index f0707610c3f..d8d9ccb5c6f 100644
--- a/spec/features/protected_branches_spec.rb
+++ b/spec/features/protected_branches_spec.rb
@@ -9,6 +9,10 @@ RSpec.describe 'Protected Branches', :js do
let(:admin) { create(:admin) }
let(:project) { create(:project, :repository) }
+ before do
+ stub_feature_flags(deploy_keys_on_protected_branches: false)
+ end
+
context 'logged in as developer' do
before do
project.add_developer(user)
@@ -163,4 +167,14 @@ RSpec.describe 'Protected Branches', :js do
include_examples "protected branches > access control > CE"
end
end
+
+ context 'when the users for protected branches feature is off' do
+ before do
+ stub_licensed_features(protected_refs_for_users: false)
+ end
+
+ include_examples 'when the deploy_keys_on_protected_branches FF is turned on' do
+ let(:all_dropdown_sections) { %w(Roles Deploy\ Keys) }
+ end
+ end
end
diff --git a/spec/features/search/user_searches_for_code_spec.rb b/spec/features/search/user_searches_for_code_spec.rb
index 227e75088d2..a88043c98ac 100644
--- a/spec/features/search/user_searches_for_code_spec.rb
+++ b/spec/features/search/user_searches_for_code_spec.rb
@@ -21,6 +21,7 @@ RSpec.describe 'User searches for code' do
expect(page).to have_selector('.results', text: 'application.js')
expect(page).to have_selector('.file-content .code')
expect(page).to have_selector("span.line[lang='javascript']")
+ expect(page).to have_link('application.js', href: /master\/files\/js\/application.js/)
end
context 'when on a project page', :js do
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 cfda25b9ab4..5cbfacf4e48 100644
--- a/spec/features/search/user_uses_header_search_field_spec.rb
+++ b/spec/features/search/user_uses_header_search_field_spec.rb
@@ -30,6 +30,8 @@ RSpec.describe 'User uses header search field', :js do
before do
find('#search')
find('body').native.send_keys('s')
+
+ wait_for_all_requests
end
it 'shows the category search dropdown' do
@@ -89,9 +91,7 @@ RSpec.describe 'User uses header search field', :js do
context 'when entering text into the search field' do
it 'does not display the category search dropdown' do
- page.within('.search-input-wrap') do
- fill_in('search', with: scope_name.first(4))
- end
+ fill_in_search(scope_name.first(4))
expect(page).not_to have_selector('.dropdown-header', text: /#{scope_name}/i)
end
@@ -105,9 +105,7 @@ RSpec.describe 'User uses header search field', :js do
end
it 'displays search options' do
- page.within('.search-input-wrap') do
- fill_in('search', with: 'test')
- end
+ fill_in_search('test')
expect(page).to have_selector(scoped_search_link('test'))
end
@@ -140,9 +138,7 @@ RSpec.describe 'User uses header search field', :js do
end
it 'displays search options' do
- page.within('.search-input-wrap') do
- fill_in('search', with: 'test')
- end
+ fill_in_search('test')
expect(page).to have_selector(scoped_search_link('test'))
expect(page).to have_selector(scoped_search_link('test', group_id: group.id))
@@ -157,9 +153,7 @@ RSpec.describe 'User uses header search field', :js do
end
it 'displays search options' do
- page.within('.search-input-wrap') do
- fill_in('search', with: 'test')
- end
+ fill_in_search('test')
expect(page).to have_selector(scoped_search_link('test'))
expect(page).not_to have_selector(scoped_search_link('test', group_id: project.namespace_id))
@@ -182,9 +176,7 @@ RSpec.describe 'User uses header search field', :js do
end
it 'displays search options' do
- page.within('.search-input-wrap') do
- fill_in('search', with: 'test')
- end
+ fill_in_search('test')
expect(page).to have_selector(scoped_search_link('test'))
expect(page).to have_selector(scoped_search_link('test', group_id: group.id))
@@ -208,9 +200,7 @@ RSpec.describe 'User uses header search field', :js do
end
it 'displays search options' do
- page.within('.search-input-wrap') do
- fill_in('search', with: 'test')
- end
+ fill_in_search('test')
expect(page).to have_selector(scoped_search_link('test'))
expect(page).to have_selector(scoped_search_link('test', group_id: subgroup.id))
diff --git a/spec/features/static_site_editor_spec.rb b/spec/features/static_site_editor_spec.rb
index b67e47b6ac4..03085917d67 100644
--- a/spec/features/static_site_editor_spec.rb
+++ b/spec/features/static_site_editor_spec.rb
@@ -6,18 +6,71 @@ RSpec.describe 'Static Site Editor' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public, :repository) }
+ let(:sse_path) { project_show_sse_path(project, 'master/README.md') }
+
+ before_all do
+ project.add_developer(user)
+ end
+
before do
- project.add_maintainer(user)
sign_in(user)
+ end
+
+ context "when no config file is present" do
+ before do
+ visit sse_path
+ end
- visit project_show_sse_path(project, 'master/README.md')
+ it 'renders SSE page with all generated config values and default config file values' do
+ node = page.find('#static-site-editor')
+
+ # assert generated config values are present
+ expect(node['data-base-url']).to eq("/#{project.full_path}/-/sse/master%2FREADME.md")
+ expect(node['data-branch']).to eq('master')
+ expect(node['data-commit-id']).to match(/\A[0-9a-f]{40}\z/)
+ expect(node['data-is-supported-content']).to eq('true')
+ expect(node['data-merge-requests-illustration-path'])
+ .to match(%r{/assets/illustrations/merge_requests-.*\.svg})
+ expect(node['data-namespace']).to eq(project.namespace.full_path)
+ expect(node['data-project']).to eq(project.path)
+ expect(node['data-project-id']).to eq(project.id.to_s)
+
+ # assert default config file values are present
+ expect(node['data-image-upload-path']).to eq('source/images')
+ expect(node['data-mounts']).to eq('[{"source":"source","target":""}]')
+ expect(node['data-static-site-generator']).to eq('middleman')
+ end
end
- it 'renders Static Site Editor page with generated and file attributes' do
- # assert generated config value is present
- expect(page).to have_css('#static-site-editor[data-branch="master"]')
+ context "when a config file is present" do
+ let(:config_file_yml) do
+ <<~YAML
+ image_upload_path: custom-image-upload-path
+ mounts:
+ - source: source1
+ target: ""
+ - source: source2
+ target: target2
+ static_site_generator: middleman
+ YAML
+ end
+
+ before do
+ allow_next_instance_of(Repository) do |repository|
+ allow(repository).to receive(:blob_data_at).and_return(config_file_yml)
+ end
+
+ visit sse_path
+ end
+
+ it 'renders Static Site Editor page values read from config file' do
+ node = page.find('#static-site-editor')
- # assert file config value is present
- expect(page).to have_css('#static-site-editor[data-static-site-generator="middleman"]')
+ # assert user-specified config file values are present
+ expected_mounts = '[{"source":"source1","target":""},{"source":"source2","target":"target2"}]'
+ expect(node['data-image-upload-path']).to eq('custom-image-upload-path')
+ expect(node['data-mounts']).to eq(expected_mounts)
+ expect(node['data-static-site-generator']).to eq('middleman')
+ end
end
end
diff --git a/spec/features/tags/developer_deletes_tag_spec.rb b/spec/features/tags/developer_deletes_tag_spec.rb
index de9296bc08e..7c4c6f54685 100644
--- a/spec/features/tags/developer_deletes_tag_spec.rb
+++ b/spec/features/tags/developer_deletes_tag_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Developer deletes tag' do
+RSpec.describe 'Developer deletes tag', :js do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, :repository, namespace: group) }
@@ -13,11 +13,12 @@ RSpec.describe 'Developer deletes tag' do
visit project_tags_path(project)
end
- context 'from the tags list page', :js do
+ context 'from the tags list page' do
it 'deletes the tag' do
expect(page).to have_content 'v1.1.0'
- delete_tag 'v1.1.0'
+ container = page.find('.content .flex-row', text: 'v1.1.0')
+ delete_tag container
expect(page).not_to have_content 'v1.1.0'
end
@@ -29,15 +30,15 @@ RSpec.describe 'Developer deletes tag' do
expect(current_path).to eq(
project_tag_path(project, 'v1.0.0'))
- click_on 'Delete tag'
+ container = page.find('.nav-controls')
+ delete_tag container
- expect(current_path).to eq(
- project_tags_path(project))
+ expect(current_path).to eq("#{project_tags_path(project)}/")
expect(page).not_to have_content 'v1.0.0'
end
end
- context 'when pre-receive hook fails', :js do
+ context 'when pre-receive hook fails' do
before do
allow_next_instance_of(Gitlab::GitalyClient::OperationService) do |instance|
allow(instance).to receive(:rm_tag)
@@ -46,15 +47,17 @@ RSpec.describe 'Developer deletes tag' do
end
it 'shows the error message' do
- delete_tag 'v1.1.0'
+ container = page.find('.content .flex-row', text: 'v1.1.0')
+ delete_tag container
expect(page).to have_content('Do not delete tags')
end
end
- def delete_tag(tag)
- page.within('.content') do
- accept_confirm { find("li > .row-fixed-content.controls a.btn-remove[href='/#{project.full_path}/-/tags/#{tag}']").click }
- end
+ def delete_tag(container)
+ container.find('.js-remove-tag').click
+
+ page.within('.modal') { click_button('Delete tag') }
+ wait_for_requests
end
end
diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb
index a9cfe794177..0f8daaf8e15 100644
--- a/spec/features/task_lists_spec.rb
+++ b/spec/features/task_lists_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Task Lists' do
+RSpec.describe 'Task Lists', :js do
include Warden::Test::Helpers
let_it_be(:project) { create(:project, :public, :repository) }
@@ -38,41 +38,7 @@ RSpec.describe 'Task Lists' do
MARKDOWN
end
- let(:nested_tasks_markdown) do
- <<-EOT.strip_heredoc
- - [ ] Task a
- - [x] Task a.1
- - [ ] Task a.2
- - [ ] Task b
-
- 1. [ ] Task 1
- 1. [ ] Task 1.1
- 1. [x] Task 1.2
- EOT
- end
-
- let(:commented_tasks_markdown) do
- <<-EOT.strip_heredoc
- <!--
- - [ ] a
- -->
-
- - [ ] b
- EOT
- end
-
- let(:summary_no_blank_line_markdown) do
- <<-EOT.strip_heredoc
- <details>
- <summary>No blank line after summary element breaks task list</summary>
- 1. [ ] People Ops: do such and such
- </details>
-
- * [ ] Task 1
- EOT
- end
-
- before(:all) do
+ before_all do
project.add_maintainer(user)
project.add_guest(user2)
end
@@ -86,7 +52,7 @@ RSpec.describe 'Task Lists' do
end
describe 'for Issues' do
- describe 'multiple tasks', :js do
+ describe 'multiple tasks' do
let!(:issue) { create(:issue, description: markdown, author: user, project: project) }
it 'renders' do
@@ -127,7 +93,7 @@ RSpec.describe 'Task Lists' do
end
end
- describe 'single incomplete task', :js do
+ describe 'single incomplete task' do
let!(:issue) { create(:issue, description: singleIncompleteMarkdown, author: user, project: project) }
it 'renders' do
@@ -146,7 +112,7 @@ RSpec.describe 'Task Lists' do
end
end
- describe 'single complete task', :js do
+ describe 'single complete task' do
let!(:issue) { create(:issue, description: singleCompleteMarkdown, author: user, project: project) }
it 'renders' do
@@ -175,7 +141,7 @@ RSpec.describe 'Task Lists' do
project: project, author: user)
end
- it 'renders for note body', :js do
+ it 'renders for note body' do
visit_issue(project, issue)
expect(page).to have_selector('.note ul.task-list', count: 1)
@@ -183,14 +149,14 @@ RSpec.describe 'Task Lists' do
expect(page).to have_selector('.note ul input[checked]', count: 2)
end
- it 'contains the required selectors', :js do
+ it 'contains the required selectors' do
visit_issue(project, issue)
expect(page).to have_selector('.note .js-task-list-container')
expect(page).to have_selector('.note .js-task-list-container .task-list .task-list-item .task-list-item-checkbox')
end
- it 'is only editable by author', :js do
+ it 'is only editable by author' do
visit_issue(project, issue)
expect(page).to have_selector('.js-task-list-container')
@@ -209,7 +175,7 @@ RSpec.describe 'Task Lists' do
project: project, author: user)
end
- it 'renders for note body', :js do
+ it 'renders for note body' do
visit_issue(project, issue)
expect(page).to have_selector('.note ul.task-list', count: 1)
@@ -224,7 +190,7 @@ RSpec.describe 'Task Lists' do
project: project, author: user)
end
- it 'renders for note body', :js do
+ it 'renders for note body' do
visit_issue(project, issue)
expect(page).to have_selector('.note ul.task-list', count: 1)
@@ -240,7 +206,7 @@ RSpec.describe 'Task Lists' do
end
shared_examples 'multiple tasks' do
- it 'renders for description', :js do
+ it 'renders for description' do
visit_merge_request(project, merge)
wait_for_requests
@@ -249,7 +215,7 @@ RSpec.describe 'Task Lists' do
expect(page).to have_selector('ul input[checked]', count: 2)
end
- it 'contains the required selectors', :js do
+ it 'contains the required selectors' do
visit_merge_request(project, merge)
wait_for_requests
@@ -261,7 +227,7 @@ RSpec.describe 'Task Lists' do
expect(page).to have_selector('form.js-issuable-update')
end
- it 'is only editable by author', :js do
+ it 'is only editable by author' do
visit_merge_request(project, merge)
wait_for_requests
@@ -300,7 +266,7 @@ RSpec.describe 'Task Lists' do
describe 'single incomplete task' do
let!(:merge) { create(:merge_request, :simple, description: singleIncompleteMarkdown, author: user, source_project: project) }
- it 'renders for description', :js do
+ it 'renders for description' do
visit_merge_request(project, merge)
wait_for_requests
@@ -319,7 +285,7 @@ RSpec.describe 'Task Lists' do
describe 'single complete task' do
let!(:merge) { create(:merge_request, :simple, description: singleCompleteMarkdown, author: user, source_project: project) }
- it 'renders for description', :js do
+ it 'renders for description' do
visit_merge_request(project, merge)
wait_for_requests
@@ -337,7 +303,17 @@ RSpec.describe 'Task Lists' do
end
describe 'markdown task edge cases' do
- describe 'commented tasks', :js do
+ describe 'commented tasks' do
+ let(:commented_tasks_markdown) do
+ <<-EOT.strip_heredoc
+ <!--
+ - [ ] a
+ -->
+
+ - [ ] b
+ EOT
+ end
+
let!(:issue) { create(:issue, description: commented_tasks_markdown, author: user, project: project) }
it 'renders' do
@@ -360,7 +336,18 @@ RSpec.describe 'Task Lists' do
end
end
- describe 'summary with no blank line', :js do
+ describe 'summary with no blank line' do
+ let(:summary_no_blank_line_markdown) do
+ <<-EOT.strip_heredoc
+ <details>
+ <summary>No blank line after summary element breaks task list</summary>
+ 1. [ ] People Ops: do such and such
+ </details>
+
+ * [ ] Task 1
+ EOT
+ end
+
let!(:issue) { create(:issue, description: summary_no_blank_line_markdown, author: user, project: project) }
it 'renders' do
@@ -382,5 +369,31 @@ RSpec.describe 'Task Lists' do
expect(page).to have_selector('ul input[checked]', count: 1)
end
end
+
+ describe 'markdown starting with new line character' do
+ let(:markdown_starting_with_new_line) do
+ <<-EOT.strip_heredoc
+
+ - [ ] Task 1
+ EOT
+ end
+
+ let(:merge_request) { create(:merge_request, description: markdown_starting_with_new_line, author: user, source_project: project) }
+
+ it 'allows the task to be checked' do
+ visit project_merge_request_path(project, merge_request)
+ wait_for_requests
+
+ expect(page).to have_selector('ul input[checked]', count: 0)
+
+ find('.task-list-item-checkbox').click
+ wait_for_requests
+
+ visit project_merge_request_path(project, merge_request)
+ wait_for_requests
+
+ expect(page).to have_selector('ul input[checked]', count: 1)
+ end
+ end
end
end
diff --git a/spec/features/triggers_spec.rb b/spec/features/triggers_spec.rb
index 4be27673adf..6fa805d8c74 100644
--- a/spec/features/triggers_spec.rb
+++ b/spec/features/triggers_spec.rb
@@ -19,114 +19,132 @@ RSpec.describe 'Triggers', :js do
visit project_settings_ci_cd_path(@project)
end
- describe 'create trigger workflow' do
- it 'prevents adding new trigger with no description' do
- fill_in 'trigger_description', with: ''
- click_button 'Add trigger'
-
- # See if input has error due to empty value
- expect(page.find('form.gl-show-field-errors .gl-field-error')).to be_visible
- end
+ shared_examples 'triggers page' do
+ describe 'create trigger workflow' do
+ it 'prevents adding new trigger with no description' do
+ fill_in 'trigger_description', with: ''
+ click_button 'Add trigger'
+
+ # See if input has error due to empty value
+ expect(page.find('form.gl-show-field-errors .gl-field-error')).to be_visible
+ end
- it 'adds new trigger with description' do
- fill_in 'trigger_description', with: 'trigger desc'
- click_button 'Add trigger'
+ it 'adds new trigger with description' do
+ fill_in 'trigger_description', with: 'trigger desc'
+ click_button 'Add trigger'
- # See if "trigger creation successful" message displayed and description and owner are correct
- expect(page.find('.flash-notice')).to have_content 'Trigger was created successfully.'
- expect(page.find('.triggers-list')).to have_content 'trigger desc'
- expect(page.find('.triggers-list .trigger-owner')).to have_content user.name
+ aggregate_failures 'display creation notice and trigger is created' do
+ expect(page.find('.flash-notice')).to have_content 'Trigger was created successfully.'
+ expect(page.find('.triggers-list')).to have_content 'trigger desc'
+ expect(page.find('.triggers-list .trigger-owner')).to have_content user.name
+ end
+ end
end
- end
-
- describe 'edit trigger workflow' do
- let(:new_trigger_title) { 'new trigger' }
- it 'click on edit trigger opens edit trigger page' do
- create(:ci_trigger, owner: user, project: @project, description: trigger_title)
- visit project_settings_ci_cd_path(@project)
+ describe 'edit trigger workflow' do
+ let(:new_trigger_title) { 'new trigger' }
- # See if edit page has correct descrption
- find('a[title="Edit"]').send_keys(:return)
- expect(page.find('#trigger_description').value).to have_content 'trigger desc'
- end
+ it 'click on edit trigger opens edit trigger page' do
+ create(:ci_trigger, owner: user, project: @project, description: trigger_title)
+ visit project_settings_ci_cd_path(@project)
- it 'edit trigger and save' do
- create(:ci_trigger, owner: user, project: @project, description: trigger_title)
- visit project_settings_ci_cd_path(@project)
+ # See if edit page has correct descrption
+ find('a[title="Edit"]').send_keys(:return)
+ expect(page.find('#trigger_description').value).to have_content 'trigger desc'
+ end
- # See if edit page opens, then fill in new description and save
- find('a[title="Edit"]').send_keys(:return)
- fill_in 'trigger_description', with: new_trigger_title
- click_button 'Save trigger'
+ it 'edit trigger and save' do
+ create(:ci_trigger, owner: user, project: @project, description: trigger_title)
+ visit project_settings_ci_cd_path(@project)
- # See if "trigger updated successfully" message displayed and description and owner are correct
- expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.'
- expect(page.find('.triggers-list')).to have_content new_trigger_title
- expect(page.find('.triggers-list .trigger-owner')).to have_content user.name
- end
- end
+ # See if edit page opens, then fill in new description and save
+ find('a[title="Edit"]').send_keys(:return)
+ fill_in 'trigger_description', with: new_trigger_title
+ click_button 'Save trigger'
- describe 'trigger "Revoke" workflow' do
- before do
- create(:ci_trigger, owner: user2, project: @project, description: trigger_title)
- visit project_settings_ci_cd_path(@project)
+ aggregate_failures 'display update notice and trigger is updated' do
+ expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.'
+ expect(page.find('.triggers-list')).to have_content new_trigger_title
+ expect(page.find('.triggers-list .trigger-owner')).to have_content user.name
+ end
+ end
end
- it 'button "Revoke" has correct alert' do
- expected_alert = 'By revoking a trigger you will break any processes making use of it. Are you sure?'
- expect(page.find('a.btn-trigger-revoke')['data-confirm']).to eq expected_alert
- end
+ describe 'trigger "Revoke" workflow' do
+ before do
+ create(:ci_trigger, owner: user2, project: @project, description: trigger_title)
+ visit project_settings_ci_cd_path(@project)
+ end
- it 'revoke trigger' do
- # See if "Revoke" on trigger works post trigger creation
- page.accept_confirm do
- find('a.btn-trigger-revoke').send_keys(:return)
+ it 'button "Revoke" has correct alert' do
+ expected_alert = 'By revoking a trigger you will break any processes making use of it. Are you sure?'
+ expect(page.find('[data-testid="trigger_revoke_button"]')['data-confirm']).to eq expected_alert
end
- expect(page.find('.flash-notice')).to have_content 'Trigger removed'
- expect(page).to have_selector('p.settings-message.text-center.gl-mb-3')
- end
- end
+ it 'revoke trigger' do
+ # See if "Revoke" on trigger works post trigger creation
+ page.accept_confirm do
+ find('[data-testid="trigger_revoke_button"]').send_keys(:return)
+ end
- describe 'show triggers workflow' do
- it 'contains trigger description placeholder' do
- expect(page.find('#trigger_description')['placeholder']).to eq 'Trigger description'
+ aggregate_failures 'trigger is removed' do
+ expect(page.find('.flash-notice')).to have_content 'Trigger removed'
+ expect(page).to have_css('[data-testid="no_triggers_content"]')
+ end
+ end
end
- it 'show "invalid" badge for trigger with owner having insufficient permissions' do
- create(:ci_trigger, owner: guest_user, project: @project, description: trigger_title)
- visit project_settings_ci_cd_path(@project)
+ describe 'show triggers workflow' do
+ it 'contains trigger description placeholder' do
+ expect(page.find('#trigger_description')['placeholder']).to eq 'Trigger description'
+ end
- expect(page.find('.triggers-list')).to have_content 'invalid'
- expect(page.find('.triggers-list')).not_to have_selector('a[title="Edit"]')
- end
+ it 'show "invalid" badge for trigger with owner having insufficient permissions' do
+ create(:ci_trigger, owner: guest_user, project: @project, description: trigger_title)
+ visit project_settings_ci_cd_path(@project)
+
+ aggregate_failures 'has invalid badge and no edit link' do
+ expect(page.find('.triggers-list')).to have_content 'invalid'
+ expect(page.find('.triggers-list')).not_to have_selector('a[title="Edit"]')
+ end
+ end
- it 'do not show "Edit" or full token for not owned trigger' do
- # Create trigger with user different from current_user
- create(:ci_trigger, owner: user2, project: @project, description: trigger_title)
- visit project_settings_ci_cd_path(@project)
+ it 'do not show "Edit" or full token for not owned trigger' do
+ # Create trigger with user different from current_user
+ create(:ci_trigger, owner: user2, project: @project, description: trigger_title)
+ visit project_settings_ci_cd_path(@project)
+
+ aggregate_failures 'shows truncated token, no clipboard button and no edit link' do
+ expect(page.find('.triggers-list')).to have_content(@project.triggers.first.token[0..3])
+ expect(page.find('.triggers-list')).not_to have_selector('[data-testid="clipboard-btn"]')
+ expect(page.find('.triggers-list .trigger-owner')).not_to have_content user.name
+ expect(page.find('.triggers-list')).not_to have_selector('a[title="Edit"]')
+ end
+ end
- # See if trigger not owned by current_user shows only first few token chars and doesn't have copy-to-clipboard button
- expect(page.find('.triggers-list')).to have_content(@project.triggers.first.token[0..3])
- expect(page.find('.triggers-list')).not_to have_selector('button.btn-clipboard')
+ it 'show "Edit" and full token for owned trigger' do
+ create(:ci_trigger, owner: user, project: @project, description: trigger_title)
+ visit project_settings_ci_cd_path(@project)
- # See if trigger owner name doesn't match with current_user and trigger is non-editable
- expect(page.find('.triggers-list .trigger-owner')).not_to have_content user.name
- expect(page.find('.triggers-list')).not_to have_selector('a[title="Edit"]')
+ aggregate_failures 'shows full token, clipboard button and edit link' do
+ expect(page.find('.triggers-list')).to have_content @project.triggers.first.token
+ expect(page.find('.triggers-list')).to have_selector('[data-testid="clipboard-btn"]')
+ expect(page.find('.triggers-list .trigger-owner')).to have_content user.name
+ expect(page.find('.triggers-list')).to have_selector('a[title="Edit"]')
+ end
+ end
end
+ end
- it 'show "Edit" and full token for owned trigger' do
- create(:ci_trigger, owner: user, project: @project, description: trigger_title)
- visit project_settings_ci_cd_path(@project)
-
- # See if trigger shows full token and has copy-to-clipboard button
- expect(page.find('.triggers-list')).to have_content @project.triggers.first.token
- expect(page.find('.triggers-list')).to have_selector('button.btn-clipboard')
+ context 'when ci_pipeline_triggers_settings_vue_ui is enabled' do
+ it_behaves_like 'triggers page'
+ end
- # See if trigger owner name matches with current_user and is editable
- expect(page.find('.triggers-list .trigger-owner')).to have_content user.name
- expect(page.find('.triggers-list')).to have_selector('a[title="Edit"]')
+ context 'when ci_pipeline_triggers_settings_vue_ui is disabled' do
+ before do
+ stub_feature_flags(ci_pipeline_triggers_settings_vue_ui: false)
end
+
+ it_behaves_like 'triggers page'
end
end
diff --git a/spec/features/users/show_spec.rb b/spec/features/users/show_spec.rb
index dd5c2442d00..b3c8cf8d326 100644
--- a/spec/features/users/show_spec.rb
+++ b/spec/features/users/show_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe 'User page' do
include ExternalAuthorizationServiceHelpers
- let(:user) { create(:user) }
+ let(:user) { create(:user, bio: '**Lorem** _ipsum_ dolor sit [amet](https://example.com)') }
context 'with public profile' do
it 'shows all the tabs' do
@@ -174,4 +174,12 @@ RSpec.describe 'User page' do
end
end
end
+
+ context 'page description' do
+ before do
+ visit(user_path(user))
+ end
+
+ it_behaves_like 'page meta description', 'Lorem ipsum dolor sit amet'
+ end
end
diff --git a/spec/features/users/terms_spec.rb b/spec/features/users/terms_spec.rb
index 5275845fe5b..7500f2fe59a 100644
--- a/spec/features/users/terms_spec.rb
+++ b/spec/features/users/terms_spec.rb
@@ -26,6 +26,21 @@ RSpec.describe 'Users > Terms' do
expect(page).not_to have_content('Continue')
end
+ context 'when user is a project bot' do
+ let(:project_bot) { create(:user, :project_bot) }
+
+ before do
+ enforce_terms
+ end
+
+ it 'auto accepts the terms' do
+ visit terms_path
+
+ expect(page).not_to have_content('Accept terms')
+ expect(project_bot.terms_accepted?).to be(true)
+ end
+ end
+
context 'when signed in' do
let(:user) { create(:user) }
diff --git a/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb b/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb
index 196fde5efe0..65f6dc0ba74 100644
--- a/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb
+++ b/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb
@@ -122,7 +122,7 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do
end
context 'with unsaved merge request' do
- let(:merge_request) { build(:merge_request) }
+ let(:merge_request) { build(:merge_request, source_project: create(:project, :repository)) }
let!(:pipeline) do
create(:ci_empty_pipeline, project: project,
diff --git a/spec/finders/group_labels_finder_spec.rb b/spec/finders/group_labels_finder_spec.rb
deleted file mode 100644
index d65a8fb4fed..00000000000
--- a/spec/finders/group_labels_finder_spec.rb
+++ /dev/null
@@ -1,42 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe GroupLabelsFinder, '#execute' do
- let!(:group) { create(:group) }
- let!(:user) { create(:user) }
- let!(:label1) { create(:group_label, title: 'Foo', description: 'Lorem ipsum', group: group) }
- let!(:label2) { create(:group_label, title: 'Bar', description: 'Fusce consequat', group: group) }
-
- it 'returns all group labels sorted by name if no params' do
- result = described_class.new(user, group).execute
-
- expect(result.to_a).to match_array([label2, label1])
- end
-
- it 'returns all group labels sorted by name desc' do
- result = described_class.new(user, group, sort: 'name_desc').execute
-
- expect(result.to_a).to match_array([label2, label1])
- end
-
- it 'returns group labels that match search' do
- result = described_class.new(user, group, search: 'Foo').execute
-
- expect(result.to_a).to match_array([label1])
- end
-
- it 'returns group labels user subscribed to' do
- label2.subscribe(user)
-
- result = described_class.new(user, group, subscribed: 'true').execute
-
- expect(result.to_a).to match_array([label2])
- end
-
- it 'returns second page of labels' do
- result = described_class.new(user, group, page: '2').execute
-
- expect(result.to_a).to match_array([])
- end
-end
diff --git a/spec/finders/groups_finder_spec.rb b/spec/finders/groups_finder_spec.rb
index 48e4c5dadc9..c9e9328794e 100644
--- a/spec/finders/groups_finder_spec.rb
+++ b/spec/finders/groups_finder_spec.rb
@@ -161,5 +161,61 @@ RSpec.describe GroupsFinder do
end
end
end
+
+ context 'with include parent group descendants' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:parent_group) { create(:group, :public) }
+ let_it_be(:public_subgroup) { create(:group, :public, parent: parent_group) }
+ let_it_be(:internal_sub_subgroup) { create(:group, :internal, parent: public_subgroup) }
+ let_it_be(:private_sub_subgroup) { create(:group, :private, parent: public_subgroup) }
+ let_it_be(:public_sub_subgroup) { create(:group, :public, parent: public_subgroup) }
+ let(:params) { { include_parent_descendants: true, parent: parent_group } }
+
+ context 'with nil parent' do
+ it 'returns all accessible groups' do
+ params[:parent] = nil
+ expect(described_class.new(user, params).execute).to contain_exactly(
+ parent_group,
+ public_subgroup,
+ internal_sub_subgroup,
+ public_sub_subgroup
+ )
+ end
+ end
+
+ context 'without a user' do
+ it 'only returns the group public descendants' do
+ expect(described_class.new(nil, params).execute).to contain_exactly(
+ public_subgroup,
+ public_sub_subgroup
+ )
+ end
+ end
+
+ context 'when a user is present' do
+ it 'returns the group public and internal descendants' do
+ expect(described_class.new(user, params).execute).to contain_exactly(
+ public_subgroup,
+ public_sub_subgroup,
+ internal_sub_subgroup
+ )
+ end
+ end
+
+ context 'when a parent group member is present' do
+ before do
+ parent_group.add_developer(user)
+ end
+
+ it 'returns all group descendants' do
+ expect(described_class.new(user, params).execute).to contain_exactly(
+ public_subgroup,
+ public_sub_subgroup,
+ internal_sub_subgroup,
+ private_sub_subgroup
+ )
+ end
+ end
+ end
end
end
diff --git a/spec/finders/merge_requests/by_approvals_finder_spec.rb b/spec/finders/merge_requests/by_approvals_finder_spec.rb
new file mode 100644
index 00000000000..0e1856879f1
--- /dev/null
+++ b/spec/finders/merge_requests/by_approvals_finder_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe MergeRequests::ByApprovalsFinder do
+ let_it_be(:first_user) { create(:user) }
+ let_it_be(:second_user) { create(:user) }
+ let(:third_user) { create(:user) }
+
+ let_it_be(:merge_request_without_approvals) { create(:merge_request) }
+ let_it_be(:merge_request_with_first_user_approval) do
+ create(:merge_request).tap do |mr|
+ create(:approval, merge_request: mr, user: first_user)
+ end
+ end
+ let_it_be(:merge_request_with_both_approvals) do
+ create(:merge_request).tap do |mr|
+ create(:approval, merge_request: mr, user: first_user)
+ create(:approval, merge_request: mr, user: second_user)
+ end
+ end
+
+ def merge_requests(ids: nil, names: [])
+ described_class.new(names, ids).execute(MergeRequest.all)
+ end
+
+ context 'filter by no approvals' do
+ it 'returns merge requests without approvals' do
+ expected_result = [merge_request_without_approvals]
+
+ expect(merge_requests(ids: 'None')).to match_array(expected_result)
+ expect(merge_requests(names: ['None'])).to match_array(expected_result)
+ end
+ end
+
+ context 'filter by any approvals' do
+ it 'returns merge requests approved by at least one user' do
+ expected_result = [merge_request_with_first_user_approval, merge_request_with_both_approvals]
+
+ expect(merge_requests(ids: 'Any')).to match_array(expected_result)
+ expect(merge_requests(names: ['Any'])).to match_array(expected_result)
+ end
+ end
+
+ context 'filter by specific user approval' do
+ it 'returns merge requests approved by specific user' do
+ expected_result = [merge_request_with_first_user_approval, merge_request_with_both_approvals]
+
+ expect(merge_requests(ids: [first_user.id])).to match_array(expected_result)
+ expect(merge_requests(names: [first_user.username])).to match_array(expected_result)
+ end
+ end
+
+ context 'filter by multiple user approval' do
+ it 'returns merge requests approved by both users' do
+ expected_result = [merge_request_with_both_approvals]
+
+ expect(merge_requests(ids: [first_user.id, second_user.id])).to match_array(expected_result)
+ expect(merge_requests(names: [first_user.username, second_user.username])).to match_array(expected_result)
+ end
+
+ context 'limiting max conditional elements' do
+ it 'returns merge requests approved by both users, considering limit of 2 being defined' do
+ stub_const('MergeRequests::ByApprovalsFinder::MAX_FILTER_ELEMENTS', 2)
+
+ expected_result = [merge_request_with_both_approvals]
+
+ expect(merge_requests(ids: [first_user.id, second_user.id, third_user.id])).to match_array(expected_result)
+ expect(merge_requests(names: [first_user.username, second_user.username, third_user.username])).to match_array(expected_result)
+ end
+ end
+ end
+
+ context 'with empty params' do
+ it 'returns all merge requests' do
+ expected_result = [merge_request_without_approvals, merge_request_with_first_user_approval, merge_request_with_both_approvals]
+
+ expect(merge_requests(ids: [])).to match_array(expected_result)
+ expect(merge_requests(names: [])).to match_array(expected_result)
+ end
+ end
+end
diff --git a/spec/finders/packages/generic/package_finder_spec.rb b/spec/finders/packages/generic/package_finder_spec.rb
new file mode 100644
index 00000000000..ed34268e7a9
--- /dev/null
+++ b/spec/finders/packages/generic/package_finder_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Packages::Generic::PackageFinder do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:package) { create(:generic_package, project: project) }
+
+ describe '#execute!' do
+ subject(:finder) { described_class.new(project) }
+
+ it 'finds package by name and version' do
+ found_package = finder.execute!(package.name, package.version)
+
+ expect(found_package).to eq(package)
+ end
+
+ it 'ignores packages with same name but different version' do
+ create(:generic_package, project: project, name: package.name, version: '3.1.4')
+
+ found_package = finder.execute!(package.name, package.version)
+
+ expect(found_package).to eq(package)
+ end
+
+ it 'raises ActiveRecord::RecordNotFound if package is not found' do
+ expect { finder.execute!(package.name, '3.1.4') }
+ .to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+end
diff --git a/spec/fixtures/api/schemas/entities/group_group_link.json b/spec/fixtures/api/schemas/entities/group_group_link.json
index 4c9aae140d2..dda94dc3362 100644
--- a/spec/fixtures/api/schemas/entities/group_group_link.json
+++ b/spec/fixtures/api/schemas/entities/group_group_link.json
@@ -1,10 +1,12 @@
{
"type": "object",
- "required": ["id", "created_at", "expires_at", "access_level"],
+ "required": ["id", "created_at", "expires_at", "can_update", "can_remove", "access_level"],
"properties": {
"id": { "type": "integer" },
"created_at": { "type": "date-time" },
"expires_at": { "type": ["date-time", "null"] },
+ "can_update": { "type": "boolean" },
+ "can_remove": { "type": "boolean" },
"access_level": {
"type": "object",
"required": ["integer_value", "string_value"],
diff --git a/spec/fixtures/api/schemas/entities/merge_request_basic.json b/spec/fixtures/api/schemas/entities/merge_request_basic.json
index 3c19528d71b..b061176f6a7 100644
--- a/spec/fixtures/api/schemas/entities/merge_request_basic.json
+++ b/spec/fixtures/api/schemas/entities/merge_request_basic.json
@@ -1,6 +1,7 @@
{
"type": "object",
"properties" : {
+ "title": { "type": "string" },
"state": { "type": "string" },
"merge_status": { "type": "string" },
"source_branch_exists": { "type": "boolean" },
diff --git a/spec/fixtures/api/schemas/entities/test_case.json b/spec/fixtures/api/schemas/entities/test_case.json
index 0dd3c5d472f..d731d7eed0a 100644
--- a/spec/fixtures/api/schemas/entities/test_case.json
+++ b/spec/fixtures/api/schemas/entities/test_case.json
@@ -8,6 +8,7 @@
"status": { "type": "string" },
"name": { "type": "string" },
"classname": { "type": "string" },
+ "file": { "type": ["string", "null"] },
"execution_time": { "type": "float" },
"system_output": { "type": ["string", "null"] },
"stack_trace": { "type": ["string", "null"] },
diff --git a/spec/fixtures/api/schemas/entities/trigger.json b/spec/fixtures/api/schemas/entities/trigger.json
new file mode 100644
index 00000000000..5c46142673f
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/trigger.json
@@ -0,0 +1,39 @@
+{
+ "type": "object",
+ "required": [
+ "description",
+ "owner",
+ "last_used",
+ "has_token_exposed",
+ "token",
+ "can_access_project"
+ ],
+ "properties": {
+ "description": {
+ "type": ["string", "null"]
+ },
+ "owner": {
+ "type": "object",
+ "$ref": "user.json"
+ },
+ "last_used": {
+ "type": ["datetime", "null"]
+ },
+ "token": {
+ "type": "string"
+ },
+ "has_token_exposed": {
+ "type": "boolean"
+ },
+ "can_access_project": {
+ "type": "boolean"
+ },
+ "edit_project_trigger_path": {
+ "type": "string"
+ },
+ "project_trigger_path": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/feature_flags_client_token.json b/spec/fixtures/api/schemas/feature_flags_client_token.json
new file mode 100644
index 00000000000..115db422d12
--- /dev/null
+++ b/spec/fixtures/api/schemas/feature_flags_client_token.json
@@ -0,0 +1,10 @@
+{
+ "type": "object",
+ "required" : [
+ "token"
+ ],
+ "properties" : {
+ "token": { "type": ["string"] }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/group_member.json b/spec/fixtures/api/schemas/group_member.json
index 035c862d229..eefd4aeb219 100644
--- a/spec/fixtures/api/schemas/group_member.json
+++ b/spec/fixtures/api/schemas/group_member.json
@@ -62,7 +62,18 @@
"avatar_url": { "type": ["string", "null"] },
"web_url": { "type": "string" },
"blocked": { "type": "boolean" },
- "two_factor_enabled": { "type": "boolean" }
+ "two_factor_enabled": { "type": "boolean" },
+ "status": {
+ "type": "object",
+ "required": [
+ "emoji",
+ "message_html"
+ ],
+ "properties": {
+ "emoji": { "type": "string" },
+ "message_html": { "type": "string" }
+ }
+ }
}
},
"invite": {
diff --git a/spec/fixtures/api/schemas/public_api/v4/packages/package.json b/spec/fixtures/api/schemas/public_api/v4/packages/package.json
index 757e5fd26b6..08909efd10c 100644
--- a/spec/fixtures/api/schemas/public_api/v4/packages/package.json
+++ b/spec/fixtures/api/schemas/public_api/v4/packages/package.json
@@ -11,6 +11,9 @@
"name": {
"type": "string"
},
+ "conan_package_name": {
+ "type": "string"
+ },
"version": {
"type": "string"
},
diff --git a/spec/fixtures/api/schemas/registry/repository.json b/spec/fixtures/api/schemas/registry/repository.json
index 1f84e787b19..18d2c68ac2f 100644
--- a/spec/fixtures/api/schemas/registry/repository.json
+++ b/spec/fixtures/api/schemas/registry/repository.json
@@ -20,6 +20,9 @@
"created_at": {
"type": "date-time"
},
+ "cleanup_policy_started_at": {
+ "type": "date-time"
+ },
"tags_path": {
"type": "string"
},
diff --git a/spec/fixtures/api/schemas/unleash/unleash.json b/spec/fixtures/api/schemas/unleash/unleash.json
new file mode 100644
index 00000000000..6eaf316bb11
--- /dev/null
+++ b/spec/fixtures/api/schemas/unleash/unleash.json
@@ -0,0 +1,20 @@
+{
+ "additionalProperties": false,
+ "properties": {
+ "features": {
+ "items": {
+ "$ref": "unleash_feature.json"
+ },
+ "minItems": 0,
+ "type": "array"
+ },
+ "version": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "version",
+ "features"
+ ],
+ "type": "object"
+}
diff --git a/spec/fixtures/api/schemas/unleash/unleash_feature.json b/spec/fixtures/api/schemas/unleash/unleash_feature.json
new file mode 100644
index 00000000000..71d375a5371
--- /dev/null
+++ b/spec/fixtures/api/schemas/unleash/unleash_feature.json
@@ -0,0 +1,27 @@
+{
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "name",
+ "enabled",
+ "strategies"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "description": {
+ "type": "string"
+ },
+ "strategies": {
+ "items": {
+ "$ref": "unleash_strategy.json"
+ },
+ "minItems": 1,
+ "type": "array"
+ }
+ }
+}
diff --git a/spec/fixtures/api/schemas/unleash/unleash_strategy.json b/spec/fixtures/api/schemas/unleash/unleash_strategy.json
new file mode 100644
index 00000000000..7b48038ad15
--- /dev/null
+++ b/spec/fixtures/api/schemas/unleash/unleash_strategy.json
@@ -0,0 +1,24 @@
+{
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "parameters": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "groupId": {
+ "type": "string"
+ },
+ "percentage": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+}
diff --git a/spec/fixtures/invalid_manifest.xml b/spec/fixtures/invalid_manifest.xml
new file mode 100644
index 00000000000..5357329784c
--- /dev/null
+++ b/spec/fixtures/invalid_manifest.xml
@@ -0,0 +1,4 @@
+<manifest>
+ <remote review="invalid-url" />
+ <project name="platform/build"/>
+</manifest>
diff --git a/spec/fixtures/lib/backup/design_repo.bundle b/spec/fixtures/lib/backup/design_repo.bundle
new file mode 100644
index 00000000000..3ed4ad6ab8b
--- /dev/null
+++ b/spec/fixtures/lib/backup/design_repo.bundle
Binary files differ
diff --git a/spec/fixtures/lib/backup/project_repo.bundle b/spec/fixtures/lib/backup/project_repo.bundle
new file mode 100644
index 00000000000..44d4fc56d51
--- /dev/null
+++ b/spec/fixtures/lib/backup/project_repo.bundle
Binary files differ
diff --git a/spec/fixtures/lib/backup/wiki_repo.bundle b/spec/fixtures/lib/backup/wiki_repo.bundle
new file mode 100644
index 00000000000..bcc08dcbe8e
--- /dev/null
+++ b/spec/fixtures/lib/backup/wiki_repo.bundle
Binary files differ
diff --git a/spec/fixtures/packages/debian/libsample0_1.2.3~alpha2-1_amd64.deb b/spec/fixtures/packages/debian/libsample0_1.2.3~alpha2-1_amd64.deb
new file mode 100644
index 00000000000..c6cac69265a
--- /dev/null
+++ b/spec/fixtures/packages/debian/libsample0_1.2.3~alpha2-1_amd64.deb
@@ -0,0 +1 @@
+empty
diff --git a/spec/fixtures/packages/generic/myfile.tar.gz b/spec/fixtures/packages/generic/myfile.tar.gz
new file mode 100644
index 00000000000..c71b1fef23d
--- /dev/null
+++ b/spec/fixtures/packages/generic/myfile.tar.gz
Binary files differ
diff --git a/spec/frontend/alert_handler_spec.js b/spec/frontend/alert_handler_spec.js
index ba2f4f24aa5..0cee28112a8 100644
--- a/spec/frontend/alert_handler_spec.js
+++ b/spec/frontend/alert_handler_spec.js
@@ -2,18 +2,26 @@ import { setHTMLFixture } from 'helpers/fixtures';
import initAlertHandler from '~/alert_handler';
describe('Alert Handler', () => {
- const ALERT_SELECTOR = 'gl-alert';
- const CLOSE_SELECTOR = 'gl-alert-dismiss';
- const ALERT_HTML = `<div class="${ALERT_SELECTOR}"><button class="${CLOSE_SELECTOR}">Dismiss</button></div>`;
+ const ALERT_CLASS = 'gl-alert';
+ const BANNER_CLASS = 'gl-banner';
+ const DISMISS_CLASS = 'gl-alert-dismiss';
+ const DISMISS_LABEL = 'Dismiss';
- const findFirstAlert = () => document.querySelector(`.${ALERT_SELECTOR}`);
- const findAllAlerts = () => document.querySelectorAll(`.${ALERT_SELECTOR}`);
- const findFirstCloseButton = () => document.querySelector(`.${CLOSE_SELECTOR}`);
+ const generateHtml = parentClass =>
+ `<div class="${parentClass}">
+ <button aria-label="${DISMISS_LABEL}">Dismiss</button>
+ </div>`;
+
+ const findFirstAlert = () => document.querySelector(`.${ALERT_CLASS}`);
+ const findFirstBanner = () => document.querySelector(`.${BANNER_CLASS}`);
+ const findAllAlerts = () => document.querySelectorAll(`.${ALERT_CLASS}`);
+ const findFirstDismissButton = () => document.querySelector(`[aria-label="${DISMISS_LABEL}"]`);
+ const findFirstDismissButtonByClass = () => document.querySelector(`.${DISMISS_CLASS}`);
describe('initAlertHandler', () => {
describe('with one alert', () => {
beforeEach(() => {
- setHTMLFixture(ALERT_HTML);
+ setHTMLFixture(generateHtml(ALERT_CLASS));
initAlertHandler();
});
@@ -22,14 +30,14 @@ describe('Alert Handler', () => {
});
it('should dismiss the alert on click', () => {
- findFirstCloseButton().click();
+ findFirstDismissButton().click();
expect(findFirstAlert()).not.toExist();
});
});
describe('with two alerts', () => {
beforeEach(() => {
- setHTMLFixture(ALERT_HTML + ALERT_HTML);
+ setHTMLFixture(generateHtml(ALERT_CLASS) + generateHtml(ALERT_CLASS));
initAlertHandler();
});
@@ -38,9 +46,46 @@ describe('Alert Handler', () => {
});
it('should dismiss only one alert on click', () => {
- findFirstCloseButton().click();
+ findFirstDismissButton().click();
expect(findAllAlerts()).toHaveLength(1);
});
});
+
+ describe('with a dismissible banner', () => {
+ beforeEach(() => {
+ setHTMLFixture(generateHtml(BANNER_CLASS));
+ initAlertHandler();
+ });
+
+ it('should render the banner', () => {
+ expect(findFirstBanner()).toExist();
+ });
+
+ it('should dismiss the banner on click', () => {
+ findFirstDismissButton().click();
+ expect(findFirstBanner()).not.toExist();
+ });
+ });
+
+ // Dismiss buttons *should* have the correct aria labels, but some of them won't
+ // because legacy code isn't always a11y compliant.
+ // This tests that the fallback for the incorrectly labelled buttons works.
+ describe('with a mislabelled dismiss button', () => {
+ beforeEach(() => {
+ setHTMLFixture(`<div class="${ALERT_CLASS}">
+ <button class="${DISMISS_CLASS}">Dismiss</button>
+ </div>`);
+ initAlertHandler();
+ });
+
+ it('should render the banner', () => {
+ expect(findFirstAlert()).toExist();
+ });
+
+ it('should dismiss the banner on click', () => {
+ findFirstDismissButtonByClass().click();
+ expect(findFirstAlert()).not.toExist();
+ });
+ });
});
});
diff --git a/spec/frontend/alert_management/components/alert_details_spec.js b/spec/frontend/alert_management/components/alert_details_spec.js
index 8aa26dbca3b..910bb31b573 100644
--- a/spec/frontend/alert_management/components/alert_details_spec.js
+++ b/spec/frontend/alert_management/components/alert_details_spec.js
@@ -2,8 +2,10 @@ import { mount, shallowMount } from '@vue/test-utils';
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import AlertDetails from '~/alert_management/components/alert_details.vue';
+import AlertSummaryRow from '~/alert_management/components/alert_summary_row.vue';
import createIssueMutation from '~/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql';
import { joinPaths } from '~/lib/utils/url_utility';
import {
@@ -24,31 +26,36 @@ describe('AlertDetails', () => {
const $router = { replace: jest.fn() };
function mountComponent({ data, loading = false, mountMethod = shallowMount, stubs = {} } = {}) {
- wrapper = mountMethod(AlertDetails, {
- provide: {
- alertId: 'alertId',
- projectPath,
- projectIssuesPath,
- projectId,
- },
- data() {
- return { alert: { ...mockAlert }, sidebarStatus: false, ...data };
- },
- mocks: {
- $apollo: {
- mutate: jest.fn(),
- queries: {
- alert: {
- loading,
+ wrapper = extendedWrapper(
+ mountMethod(AlertDetails, {
+ provide: {
+ alertId: 'alertId',
+ projectPath,
+ projectIssuesPath,
+ projectId,
+ },
+ data() {
+ return { alert: { ...mockAlert }, sidebarStatus: false, ...data };
+ },
+ mocks: {
+ $apollo: {
+ mutate: jest.fn(),
+ queries: {
+ alert: {
+ loading,
+ },
+ sidebarStatus: {},
},
- sidebarStatus: {},
},
+ $router,
+ $route: { params: {} },
},
- $router,
- $route: { params: {} },
- },
- stubs,
- });
+ stubs: {
+ ...stubs,
+ AlertSummaryRow,
+ },
+ }),
+ );
}
beforeEach(() => {
@@ -62,9 +69,10 @@ describe('AlertDetails', () => {
mock.restore();
});
- const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]');
- const findViewIncidentBtn = () => wrapper.find('[data-testid="viewIncidentBtn"]');
- const findIncidentCreationAlert = () => wrapper.find('[data-testid="incidentCreationError"]');
+ const findCreateIncidentBtn = () => wrapper.findByTestId('createIncidentBtn');
+ const findViewIncidentBtn = () => wrapper.findByTestId('viewIncidentBtn');
+ const findIncidentCreationAlert = () => wrapper.findByTestId('incidentCreationError');
+ const findEnvironmentLink = () => wrapper.findByTestId('environmentUrl');
const findDetailsTable = () => wrapper.find(AlertDetailsTable);
describe('Alert details', () => {
@@ -74,7 +82,7 @@ describe('AlertDetails', () => {
});
it('shows an empty state', () => {
- expect(wrapper.find('[data-testid="alertDetailsTabs"]').exists()).toBe(false);
+ expect(wrapper.findByTestId('alertDetailsTabs').exists()).toBe(false);
});
});
@@ -84,28 +92,26 @@ describe('AlertDetails', () => {
});
it('renders a tab with overview information', () => {
- expect(wrapper.find('[data-testid="overview"]').exists()).toBe(true);
+ expect(wrapper.findByTestId('overview').exists()).toBe(true);
});
it('renders a tab with an activity feed', () => {
- expect(wrapper.find('[data-testid="activity"]').exists()).toBe(true);
+ expect(wrapper.findByTestId('activity').exists()).toBe(true);
});
it('renders severity', () => {
- expect(wrapper.find('[data-testid="severity"]').text()).toBe(
+ expect(wrapper.findByTestId('severity').text()).toBe(
ALERTS_SEVERITY_LABELS[mockAlert.severity],
);
});
it('renders a title', () => {
- expect(wrapper.find('[data-testid="title"]').text()).toBe(mockAlert.title);
+ expect(wrapper.findByTestId('title').text()).toBe(mockAlert.title);
});
it('renders a start time', () => {
- expect(wrapper.find('[data-testid="startTimeItem"]').exists()).toBe(true);
- expect(wrapper.find('[data-testid="startTimeItem"]').props().time).toBe(
- mockAlert.startedAt,
- );
+ expect(wrapper.findByTestId('startTimeItem').exists()).toBe(true);
+ expect(wrapper.findByTestId('startTimeItem').props('time')).toBe(mockAlert.startedAt);
});
});
@@ -114,6 +120,8 @@ describe('AlertDetails', () => {
field | data | isShown
${'eventCount'} | ${1} | ${true}
${'eventCount'} | ${undefined} | ${false}
+ ${'environment'} | ${undefined} | ${false}
+ ${'environment'} | ${'Production'} | ${true}
${'monitoringTool'} | ${'New Relic'} | ${true}
${'monitoringTool'} | ${undefined} | ${false}
${'service'} | ${'Prometheus'} | ${true}
@@ -126,15 +134,29 @@ describe('AlertDetails', () => {
});
it(`${field} is ${isShown ? 'displayed' : 'hidden'} correctly`, () => {
+ const element = wrapper.findByTestId(field);
if (isShown) {
- expect(wrapper.find(`[data-testid="${field}"]`).text()).toBe(data.toString());
+ expect(element.text()).toContain(data.toString());
} else {
- expect(wrapper.find(`[data-testid="${field}"]`).exists()).toBe(false);
+ expect(wrapper.findByTestId(field).exists()).toBe(false);
}
});
});
});
+ describe('environment URL fields', () => {
+ it('should show the environment URL when available', () => {
+ const environment = 'Production';
+ const environmentUrl = 'fake/url';
+ mountComponent({
+ data: { alert: { ...mockAlert, environment, environmentUrl } },
+ });
+
+ expect(findEnvironmentLink().text()).toBe(environment);
+ expect(findEnvironmentLink().attributes('href')).toBe(environmentUrl);
+ });
+ });
+
describe('Create incident from alert', () => {
it('should display "View incident" button that links the incident page when incident exists', () => {
const issueIid = '3';
@@ -222,7 +244,7 @@ describe('AlertDetails', () => {
mountComponent({
data: { errored: true, sidebarErrorMessage: '<span data-testid="htmlError" />' },
});
- expect(wrapper.find('[data-testid="htmlError"]').exists()).toBe(true);
+ expect(wrapper.findByTestId('htmlError').exists()).toBe(true);
});
it('does not display an error when dismissed', () => {
@@ -232,7 +254,7 @@ describe('AlertDetails', () => {
});
describe('header', () => {
- const findHeader = () => wrapper.find('[data-testid="alert-header"]');
+ const findHeader = () => wrapper.findByTestId('alert-header');
const stubs = { TimeAgoTooltip: { template: '<span>now</span>' } };
describe('individual header fields', () => {
diff --git a/spec/frontend/alert_management/components/alert_management_table_spec.js b/spec/frontend/alert_management/components/alert_management_table_spec.js
index bcad415eb19..3aa67614369 100644
--- a/spec/frontend/alert_management/components/alert_management_table_spec.js
+++ b/spec/frontend/alert_management/components/alert_management_table_spec.js
@@ -3,8 +3,8 @@ import {
GlTable,
GlAlert,
GlLoadingIcon,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
GlTabs,
GlTab,
@@ -34,12 +34,12 @@ describe('AlertManagementTable', () => {
const findAlerts = () => wrapper.findAll('table tbody tr');
const findAlert = () => wrapper.find(GlAlert);
const findLoader = () => wrapper.find(GlLoadingIcon);
- const findStatusDropdown = () => wrapper.find(GlDeprecatedDropdown);
+ const findStatusDropdown = () => wrapper.find(GlDropdown);
const findStatusFilterTabs = () => wrapper.findAll(GlTab);
const findStatusTabs = () => wrapper.find(GlTabs);
const findStatusFilterBadge = () => wrapper.findAll(GlBadge);
const findDateFields = () => wrapper.findAll(TimeAgo);
- const findFirstStatusOption = () => findStatusDropdown().find(GlDeprecatedDropdownItem);
+ const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem);
const findPagination = () => wrapper.find(GlPagination);
const findSearch = () => wrapper.find(GlSearchBoxByType);
const findSeverityColumnHeader = () =>
@@ -295,10 +295,30 @@ describe('AlertManagementTable', () => {
loading: false,
});
+ expect(visitUrl).not.toHaveBeenCalled();
+
findAlerts()
.at(0)
.trigger('click');
- expect(visitUrl).toHaveBeenCalledWith('/1527542/details');
+ expect(visitUrl).toHaveBeenCalledWith('/1527542/details', false);
+ });
+
+ it('navigates to the detail page in new tab when alert row is clicked with the metaKey', () => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
+ loading: false,
+ });
+
+ expect(visitUrl).not.toHaveBeenCalled();
+
+ findAlerts()
+ .at(0)
+ .trigger('click', {
+ metaKey: true,
+ });
+
+ expect(visitUrl).toHaveBeenCalledWith('/1527542/details', true);
});
describe('alert issue links', () => {
diff --git a/spec/frontend/alert_management/components/alert_summary_row_spec.js b/spec/frontend/alert_management/components/alert_summary_row_spec.js
new file mode 100644
index 00000000000..47c715c089a
--- /dev/null
+++ b/spec/frontend/alert_management/components/alert_summary_row_spec.js
@@ -0,0 +1,40 @@
+import { shallowMount } from '@vue/test-utils';
+import AlertSummaryRow from '~/alert_management/components/alert_summary_row.vue';
+
+const label = 'a label';
+const value = 'a value';
+
+describe('AlertSummaryRow', () => {
+ let wrapper;
+
+ function mountComponent({ mountMethod = shallowMount, props, defaultSlot } = {}) {
+ wrapper = mountMethod(AlertSummaryRow, {
+ propsData: props,
+ scopedSlots: {
+ default: defaultSlot,
+ },
+ });
+ }
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ describe('Alert Summary Row', () => {
+ beforeEach(() => {
+ mountComponent({
+ props: {
+ label,
+ },
+ defaultSlot: `<span class="value">${value}</span>`,
+ });
+ });
+
+ it('should display a label and a value', () => {
+ expect(wrapper.text()).toBe(`${label} ${value}`);
+ });
+ });
+});
diff --git a/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js b/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js
index 4c9db02eff4..1d87301aac9 100644
--- a/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js
+++ b/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js
@@ -1,7 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
-import { GlDeprecatedDropdownItem } from '@gitlab/ui';
+import { GlDropdownItem } from '@gitlab/ui';
import SidebarAssignee from '~/alert_management/components/sidebar/sidebar_assignee.vue';
import SidebarAssignees from '~/alert_management/components/sidebar/sidebar_assignees.vue';
import AlertSetAssignees from '~/alert_management/graphql/mutations/alert_set_assignees.mutation.graphql';
@@ -106,7 +106,7 @@ describe('Alert Details Sidebar Assignees', () => {
it('renders a unassigned option', async () => {
wrapper.setData({ isDropdownSearching: false });
await wrapper.vm.$nextTick();
- expect(wrapper.find(GlDeprecatedDropdownItem).text()).toBe('Unassigned');
+ expect(wrapper.find(GlDropdownItem).text()).toBe('Unassigned');
});
it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => {
diff --git a/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js b/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js
index a8fe40687e1..e144d473c12 100644
--- a/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js
+++ b/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils';
-import { GlDeprecatedDropdown, GlDeprecatedDropdownItem, GlLoadingIcon } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import { trackAlertStatusUpdateOptions } from '~/alert_management/constants';
import AlertSidebarStatus from '~/alert_management/components/sidebar/sidebar_status.vue';
import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql';
@@ -10,9 +10,10 @@ const mockAlert = mockAlerts[0];
describe('Alert Details Sidebar Status', () => {
let wrapper;
- const findStatusDropdown = () => wrapper.find(GlDeprecatedDropdown);
- const findStatusDropdownItem = () => wrapper.find(GlDeprecatedDropdownItem);
+ const findStatusDropdown = () => wrapper.find(GlDropdown);
+ const findStatusDropdownItem = () => wrapper.find(GlDropdownItem);
const findStatusLoadingIcon = () => wrapper.find(GlLoadingIcon);
+ const findStatusDropdownHeader = () => wrapper.find('[data-testid="dropdown-header"]');
function mountComponent({ data, sidebarCollapsed = true, loading = false, stubs = {} } = {}) {
wrapper = mount(AlertSidebarStatus, {
@@ -56,11 +57,7 @@ describe('Alert Details Sidebar Status', () => {
});
it('displays the dropdown status header', () => {
- expect(
- findStatusDropdown()
- .find('.dropdown-title')
- .exists(),
- ).toBe(true);
+ expect(findStatusDropdownHeader().exists()).toBe(true);
});
describe('updating the alert status', () => {
diff --git a/spec/frontend/alert_management/components/system_notes/alert_management_system_note_spec.js b/spec/frontend/alert_management/components/system_notes/alert_management_system_note_spec.js
index 8dd663e55d9..65cfc600d76 100644
--- a/spec/frontend/alert_management/components/system_notes/alert_management_system_note_spec.js
+++ b/spec/frontend/alert_management/components/system_notes/alert_management_system_note_spec.js
@@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
import SystemNote from '~/alert_management/components/system_notes/system_note.vue';
import mockAlerts from '../../mocks/alerts.json';
@@ -19,6 +20,7 @@ describe('Alert Details System Note', () => {
afterEach(() => {
if (wrapper) {
wrapper.destroy();
+ wrapper = null;
}
});
@@ -29,10 +31,10 @@ describe('Alert Details System Note', () => {
it('renders the correct system note', () => {
const noteId = wrapper.find('.note-wrapper').attributes('id');
- const iconRoute = wrapper.find('use').attributes('href');
+ const iconName = wrapper.find(GlIcon).attributes('name');
expect(noteId).toBe('note_1628');
- expect(iconRoute.includes('user')).toBe(true);
+ expect(iconName).toBe(mockAlert.notes.nodes[0].systemNoteIconName);
});
});
});
diff --git a/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap b/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap
index 16e92bf505a..545be94dcaa 100644
--- a/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap
+++ b/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap
@@ -26,7 +26,7 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`]
</gl-form-group-stub>
<gl-form-group-stub label=\\"Authorization key\\" label-for=\\"authorization-key\\" label-class=\\"label-bold\\">
<gl-form-input-group-stub value=\\"abcedfg123\\" predefinedoptions=\\"[object Object]\\" id=\\"authorization-key\\" readonly=\\"\\" class=\\"gl-mb-2\\"></gl-form-input-group-stub>
- <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub>
+ <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub>
<gl-modal-stub modalid=\\"authKeyModal\\" titletag=\\"h4\\" modalclass=\\"\\" size=\\"md\\" title=\\"Reset key\\" ok-title=\\"Reset key\\" ok-variant=\\"danger\\">
Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.
</gl-modal-stub>
@@ -34,16 +34,14 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`]
<gl-form-group-stub label=\\"Alert test payload\\" label-for=\\"alert-json\\" label-class=\\"label-bold\\">
<gl-form-textarea-stub noresize=\\"true\\" id=\\"alert-json\\" disabled=\\"true\\" state=\\"true\\" placeholder=\\"Enter test alert JSON....\\" rows=\\"6\\" max-rows=\\"10\\"></gl-form-textarea-stub>
</gl-form-group-stub>
- <div class=\\"gl-display-flex gl-justify-content-end\\">
- <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\">Test alert payload</gl-button-stub>
- </div>
+ <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\">Test alert payload</gl-button-stub>
<div class=\\"footer-block row-content-block gl-display-flex gl-justify-content-space-between\\">
- <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\">
- Cancel
- </gl-button-stub>
- <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\">
+ <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\">
Save changes
</gl-button-stub>
+ <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\">
+ Cancel
+ </gl-button-stub>
</div>
</gl-form-stub>
</div>"
diff --git a/spec/frontend/analytics/instance_statistics/components/app_spec.js b/spec/frontend/analytics/instance_statistics/components/app_spec.js
new file mode 100644
index 00000000000..242621dc40c
--- /dev/null
+++ b/spec/frontend/analytics/instance_statistics/components/app_spec.js
@@ -0,0 +1,24 @@
+import { shallowMount } from '@vue/test-utils';
+import InstanceStatisticsApp from '~/analytics/instance_statistics/components/app.vue';
+import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue';
+
+describe('InstanceStatisticsApp', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(InstanceStatisticsApp);
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('displays the instance counts component', () => {
+ expect(wrapper.find(InstanceCounts).exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js b/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js
new file mode 100644
index 00000000000..2274f4c3fde
--- /dev/null
+++ b/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js
@@ -0,0 +1,54 @@
+import { shallowMount } from '@vue/test-utils';
+import InstanceCounts from '~/analytics/instance_statistics/components/instance_counts.vue';
+import MetricCard from '~/analytics/shared/components/metric_card.vue';
+import countsMockData from '../mock_data';
+
+describe('InstanceCounts', () => {
+ let wrapper;
+
+ const createComponent = ({ loading = false, data = {} } = {}) => {
+ const $apollo = {
+ queries: {
+ counts: {
+ loading,
+ },
+ },
+ };
+
+ wrapper = shallowMount(InstanceCounts, {
+ mocks: { $apollo },
+ data() {
+ return {
+ ...data,
+ };
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findMetricCard = () => wrapper.find(MetricCard);
+
+ describe('while loading', () => {
+ beforeEach(() => {
+ createComponent({ loading: true });
+ });
+
+ it('displays the metric card with isLoading=true', () => {
+ expect(findMetricCard().props('isLoading')).toBe(true);
+ });
+ });
+
+ describe('with data', () => {
+ beforeEach(() => {
+ createComponent({ data: { counts: countsMockData } });
+ });
+
+ it('passes the counts data to the metric card', () => {
+ expect(findMetricCard().props('metrics')).toEqual(countsMockData);
+ });
+ });
+});
diff --git a/spec/frontend/analytics/instance_statistics/mock_data.js b/spec/frontend/analytics/instance_statistics/mock_data.js
new file mode 100644
index 00000000000..9fabf3a4c65
--- /dev/null
+++ b/spec/frontend/analytics/instance_statistics/mock_data.js
@@ -0,0 +1,4 @@
+export default [
+ { key: 'projects', value: 10, label: 'Projects' },
+ { key: 'groups', value: 20, label: 'Group' },
+];
diff --git a/spec/frontend/analytics/shared/components/metric_card_spec.js b/spec/frontend/analytics/shared/components/metric_card_spec.js
new file mode 100644
index 00000000000..e89d499ed9b
--- /dev/null
+++ b/spec/frontend/analytics/shared/components/metric_card_spec.js
@@ -0,0 +1,129 @@
+import { mount } from '@vue/test-utils';
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import MetricCard from '~/analytics/shared/components/metric_card.vue';
+
+const metrics = [
+ { key: 'first_metric', value: 10, label: 'First metric', unit: 'days', link: 'some_link' },
+ { key: 'second_metric', value: 20, label: 'Yet another metric' },
+ { key: 'third_metric', value: null, label: 'Null metric without value', unit: 'parsecs' },
+ { key: 'fourth_metric', value: '-', label: 'Metric without value', unit: 'parsecs' },
+];
+
+const defaultProps = {
+ title: 'My fancy title',
+ isLoading: false,
+ metrics,
+};
+
+describe('MetricCard', () => {
+ let wrapper;
+
+ const factory = (props = defaultProps) => {
+ wrapper = mount(MetricCard, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findTitle = () => wrapper.find({ ref: 'title' });
+ const findLoadingIndicator = () => wrapper.find(GlSkeletonLoading);
+ const findMetricsWrapper = () => wrapper.find({ ref: 'metricsWrapper' });
+ const findMetricItem = () => wrapper.findAll({ ref: 'metricItem' });
+ const findTooltip = () => wrapper.find('[data-testid="tooltip"]');
+
+ describe('template', () => {
+ it('renders the title', () => {
+ factory();
+
+ expect(findTitle().text()).toContain('My fancy title');
+ });
+
+ describe('when isLoading is true', () => {
+ beforeEach(() => {
+ factory({ isLoading: true });
+ });
+
+ it('displays a loading indicator', () => {
+ expect(findLoadingIndicator().exists()).toBe(true);
+ });
+
+ it('does not display the metrics container', () => {
+ expect(findMetricsWrapper().exists()).toBe(false);
+ });
+ });
+
+ describe('when isLoading is false', () => {
+ beforeEach(() => {
+ factory({ isLoading: false });
+ });
+
+ it('does not display a loading indicator', () => {
+ expect(findLoadingIndicator().exists()).toBe(false);
+ });
+
+ it('displays the metrics container', () => {
+ expect(findMetricsWrapper().exists()).toBe(true);
+ });
+
+ it('renders two metrics', () => {
+ expect(findMetricItem()).toHaveLength(metrics.length);
+ });
+
+ describe('with tooltip text', () => {
+ const tooltipText = 'This is a tooltip';
+ const tooltipMetric = {
+ key: 'fifth_metric',
+ value: '-',
+ label: 'Metric with tooltip',
+ unit: 'parsecs',
+ tooltipText,
+ };
+
+ beforeEach(() => {
+ factory({
+ isLoading: false,
+ metrics: [tooltipMetric],
+ });
+ });
+
+ it('will render a tooltip', () => {
+ const tt = getBinding(findTooltip().element, 'gl-tooltip');
+ expect(tt.value.title).toEqual(tooltipText);
+ });
+ });
+
+ describe.each`
+ columnIndex | label | value | unit | link
+ ${0} | ${'First metric'} | ${10} | ${' days'} | ${'some_link'}
+ ${1} | ${'Yet another metric'} | ${20} | ${''} | ${null}
+ ${2} | ${'Null metric without value'} | ${'-'} | ${''} | ${null}
+ ${3} | ${'Metric without value'} | ${'-'} | ${''} | ${null}
+ `('metric columns', ({ columnIndex, label, value, unit, link }) => {
+ it(`renders ${value}${unit} ${label} with URL ${link}`, () => {
+ const allMetricItems = findMetricItem();
+ const metricItem = allMetricItems.at(columnIndex);
+ const text = metricItem.text();
+
+ expect(text).toContain(`${value}${unit}`);
+ expect(text).toContain(label);
+
+ if (link) {
+ expect(metricItem.find('a').attributes('href')).toBe(link);
+ } else {
+ expect(metricItem.find('a').exists()).toBe(false);
+ }
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js
index 3ae0d06162d..f7c6290ce1c 100644
--- a/spec/frontend/api_spec.js
+++ b/spec/frontend/api_spec.js
@@ -1152,4 +1152,44 @@ describe('Api', () => {
});
});
});
+
+ describe('trackRedisHllUserEvent', () => {
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/usage_data/increment_unique_users`;
+
+ const event = 'dummy_event';
+ const postData = { event };
+ const headers = {
+ 'Content-Type': 'application/json',
+ };
+
+ describe('when usage data increment unique users is called with feature flag disabled', () => {
+ beforeEach(() => {
+ gon.features = { ...gon.features, usageDataApi: false };
+ });
+
+ it('returns null', () => {
+ jest.spyOn(axios, 'post');
+ mock.onPost(expectedUrl).replyOnce(httpStatus.OK, true);
+
+ expect(axios.post).toHaveBeenCalledTimes(0);
+ expect(Api.trackRedisHllUserEvent(event)).toEqual(null);
+ });
+ });
+
+ describe('when usage data increment unique users is called', () => {
+ beforeEach(() => {
+ gon.features = { ...gon.features, usageDataApi: true };
+ });
+
+ it('resolves the Promise', () => {
+ jest.spyOn(axios, 'post');
+ mock.onPost(expectedUrl, { event }).replyOnce(httpStatus.OK, true);
+
+ return Api.trackRedisHllUserEvent(event).then(({ data }) => {
+ expect(data).toEqual(true);
+ expect(axios.post).toHaveBeenCalledWith(expectedUrl, postData, { headers });
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js
index f0ed18248f0..7fd6a9e7b87 100644
--- a/spec/frontend/awards_handler_spec.js
+++ b/spec/frontend/awards_handler_spec.js
@@ -309,6 +309,30 @@ describe('AwardsHandler', () => {
expect($('[data-name=alien]').is(':visible')).toBe(true);
expect($('.js-emoji-menu-search').val()).toBe('');
});
+
+ it('should fuzzy filter the emoji', async () => {
+ await openAndWaitForEmojiMenu();
+
+ awardsHandler.searchEmojis('sgls');
+
+ expect($('[data-name=angel]').is(':visible')).toBe(false);
+ expect($('[data-name=anger]').is(':visible')).toBe(false);
+ expect($('[data-name=sunglasses]').is(':visible')).toBe(true);
+ });
+
+ it('should filter by emoji description', async () => {
+ await openAndWaitForEmojiMenu();
+
+ awardsHandler.searchEmojis('baby');
+ expect($('[data-name=angel]').is(':visible')).toBe(true);
+ });
+
+ it('should filter by emoji unicode value', async () => {
+ await openAndWaitForEmojiMenu();
+
+ awardsHandler.searchEmojis('👼');
+ expect($('[data-name=angel]').is(':visible')).toBe(true);
+ });
});
describe('emoji menu', () => {
diff --git a/spec/frontend/batch_comments/components/preview_item_spec.js b/spec/frontend/batch_comments/components/preview_item_spec.js
index 2b63ece28ba..8ddad3dacfe 100644
--- a/spec/frontend/batch_comments/components/preview_item_spec.js
+++ b/spec/frontend/batch_comments/components/preview_item_spec.js
@@ -43,22 +43,6 @@ describe('Batch comments draft preview item component', () => {
);
});
- it('adds is last class', () => {
- createComponent(true);
-
- expect(vm.$el.classList).toContain('is-last');
- });
-
- it('scrolls to draft on click', () => {
- createComponent();
-
- jest.spyOn(vm.$store, 'dispatch').mockImplementation();
-
- vm.$el.click();
-
- expect(vm.$store.dispatch).toHaveBeenCalledWith('batchComments/scrollToDraft', vm.draft);
- });
-
describe('for file', () => {
it('renders file path', () => {
createComponent(false, { file_path: 'index.js', file_hash: 'abc', position: {} });
diff --git a/spec/frontend/batch_comments/components/publish_button_spec.js b/spec/frontend/batch_comments/components/publish_button_spec.js
index 4362f62c7f8..4032713150c 100644
--- a/spec/frontend/batch_comments/components/publish_button_spec.js
+++ b/spec/frontend/batch_comments/components/publish_button_spec.js
@@ -29,17 +29,6 @@ describe('Batch comments publish button component', () => {
expect(vm.$store.dispatch).toHaveBeenCalledWith('batchComments/publishReview', undefined);
});
- it('dispatches toggleReviewDropdown when shouldPublish is false on click', () => {
- vm.shouldPublish = false;
-
- vm.$el.click();
-
- expect(vm.$store.dispatch).toHaveBeenCalledWith(
- 'batchComments/toggleReviewDropdown',
- undefined,
- );
- });
-
it('sets loading when isPublishing is true', done => {
vm.$store.state.batchComments.isPublishing = true;
diff --git a/spec/frontend/batch_comments/components/publish_dropdown_spec.js b/spec/frontend/batch_comments/components/publish_dropdown_spec.js
index fb3c532174d..f235867f002 100644
--- a/spec/frontend/batch_comments/components/publish_dropdown_spec.js
+++ b/spec/frontend/batch_comments/components/publish_dropdown_spec.js
@@ -1,96 +1,39 @@
-import Vue from 'vue';
-import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import PreviewDropdown from '~/batch_comments/components/preview_dropdown.vue';
import { createStore } from '~/mr_notes/stores';
import '~/behaviors/markdown/render_gfm';
import { createDraft } from '../mock_data';
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
describe('Batch comments publish dropdown component', () => {
- let vm;
- let Component;
+ let wrapper;
- function createComponent(extendStore = () => {}) {
+ function createComponent() {
const store = createStore();
store.state.batchComments.drafts.push(createDraft(), { ...createDraft(), id: 2 });
- extendStore(store);
-
- vm = mountComponentWithStore(Component, { store });
+ wrapper = shallowMount(PreviewDropdown, {
+ store,
+ });
}
- beforeAll(() => {
- Component = Vue.extend(PreviewDropdown);
- });
-
afterEach(() => {
- vm.$destroy();
- });
-
- it('toggles dropdown when clicking button', done => {
- createComponent();
-
- jest.spyOn(vm.$store, 'dispatch');
-
- vm.$el.querySelector('.review-preview-dropdown-toggle').click();
-
- expect(vm.$store.dispatch).toHaveBeenCalledWith(
- 'batchComments/toggleReviewDropdown',
- expect.anything(),
- );
-
- setImmediate(() => {
- expect(vm.$el.classList).toContain('show');
-
- done();
- });
- });
-
- it('toggles dropdown when clicking body', () => {
- createComponent();
-
- vm.$store.state.batchComments.showPreviewDropdown = true;
-
- jest.spyOn(vm.$store, 'dispatch').mockImplementation();
-
- document.body.click();
-
- expect(vm.$store.dispatch).toHaveBeenCalledWith(
- 'batchComments/toggleReviewDropdown',
- undefined,
- );
+ wrapper.destroy();
});
it('renders list of drafts', () => {
- createComponent(store => {
- Object.assign(store.state.notes, {
- isNotesFetched: true,
- });
- });
-
- expect(vm.$el.querySelectorAll('.dropdown-content li').length).toBe(2);
- });
-
- it('adds is-last class to last item', () => {
- createComponent(store => {
- Object.assign(store.state.notes, {
- isNotesFetched: true,
- });
- });
-
- expect(vm.$el.querySelectorAll('.dropdown-content li')[1].querySelector('.is-last')).not.toBe(
- null,
- );
- });
-
- it('renders draft count in dropdown title', () => {
createComponent();
- expect(vm.$el.querySelector('.dropdown-title').textContent).toContain('2 pending comments');
+ expect(wrapper.findAll(GlDropdownItem).length).toBe(2);
});
- it('renders publish button in footer', () => {
+ it('renders draft count in dropdown title', () => {
createComponent();
- expect(vm.$el.querySelector('.dropdown-footer .js-publish-draft-button')).not.toBe(null);
+ expect(wrapper.find(GlDropdown).props('headerText')).toEqual('2 pending comments');
});
});
diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
index a6942115649..e66f36aa3a2 100644
--- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
+++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
@@ -199,42 +199,6 @@ describe('Batch comments store actions', () => {
});
});
- describe('discardReview', () => {
- it('commits mutations', done => {
- const getters = {
- getNotesData: { draftsDiscardPath: TEST_HOST },
- };
- const commit = jest.fn();
- mock.onAny().reply(200);
-
- actions
- .discardReview({ getters, commit })
- .then(() => {
- expect(commit.mock.calls[0]).toEqual(['REQUEST_DISCARD_REVIEW']);
- expect(commit.mock.calls[1]).toEqual(['RECEIVE_DISCARD_REVIEW_SUCCESS']);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('commits error mutations', done => {
- const getters = {
- getNotesData: { draftsDiscardPath: TEST_HOST },
- };
- const commit = jest.fn();
- mock.onAny().reply(500);
-
- actions
- .discardReview({ getters, commit })
- .then(() => {
- expect(commit.mock.calls[0]).toEqual(['REQUEST_DISCARD_REVIEW']);
- expect(commit.mock.calls[1]).toEqual(['RECEIVE_DISCARD_REVIEW_ERROR']);
- })
- .then(done)
- .catch(done.fail);
- });
- });
-
describe('updateDraft', () => {
let getters;
@@ -284,56 +248,6 @@ describe('Batch comments store actions', () => {
});
});
- describe('toggleReviewDropdown', () => {
- it('dispatches openReviewDropdown', done => {
- testAction(
- actions.toggleReviewDropdown,
- null,
- { showPreviewDropdown: false },
- [],
- [{ type: 'openReviewDropdown' }],
- done,
- );
- });
-
- it('dispatches closeReviewDropdown when showPreviewDropdown is true', done => {
- testAction(
- actions.toggleReviewDropdown,
- null,
- { showPreviewDropdown: true },
- [],
- [{ type: 'closeReviewDropdown' }],
- done,
- );
- });
- });
-
- describe('openReviewDropdown', () => {
- it('commits OPEN_REVIEW_DROPDOWN', done => {
- testAction(
- actions.openReviewDropdown,
- null,
- null,
- [{ type: 'OPEN_REVIEW_DROPDOWN' }],
- [],
- done,
- );
- });
- });
-
- describe('closeReviewDropdown', () => {
- it('commits CLOSE_REVIEW_DROPDOWN', done => {
- testAction(
- actions.closeReviewDropdown,
- null,
- null,
- [{ type: 'CLOSE_REVIEW_DROPDOWN' }],
- [],
- done,
- );
- });
- });
-
describe('expandAllDiscussions', () => {
it('dispatches expandDiscussion for all drafts', done => {
const state = {
@@ -383,9 +297,7 @@ describe('Batch comments store actions', () => {
actions.scrollToDraft({ dispatch, rootGetters }, draft);
- expect(dispatch.mock.calls[0]).toEqual(['closeReviewDropdown']);
-
- expect(dispatch.mock.calls[1]).toEqual([
+ expect(dispatch.mock.calls[0]).toEqual([
'expandDiscussion',
{ discussionId: '1' },
{ root: true },
diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js
index a86726269ef..1406f66fd10 100644
--- a/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js
+++ b/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js
@@ -89,42 +89,6 @@ describe('Batch comments mutations', () => {
});
});
- describe(types.REQUEST_DISCARD_REVIEW, () => {
- it('sets isDiscarding to true', () => {
- mutations[types.REQUEST_DISCARD_REVIEW](state);
-
- expect(state.isDiscarding).toBe(true);
- });
- });
-
- describe(types.RECEIVE_DISCARD_REVIEW_SUCCESS, () => {
- it('emptys drafts array', () => {
- state.drafts.push('test');
-
- mutations[types.RECEIVE_DISCARD_REVIEW_SUCCESS](state);
-
- expect(state.drafts).toEqual([]);
- });
-
- it('sets isDiscarding to false', () => {
- state.isDiscarding = true;
-
- mutations[types.RECEIVE_DISCARD_REVIEW_SUCCESS](state);
-
- expect(state.isDiscarding).toBe(false);
- });
- });
-
- describe(types.RECEIVE_DISCARD_REVIEW_ERROR, () => {
- it('updates isDiscarding to false', () => {
- state.isDiscarding = true;
-
- mutations[types.RECEIVE_DISCARD_REVIEW_ERROR](state);
-
- expect(state.isDiscarding).toBe(false);
- });
- });
-
describe(types.RECEIVE_DRAFT_UPDATE_SUCCESS, () => {
it('updates draft in store', () => {
state.drafts.push({ id: 1 });
@@ -140,20 +104,4 @@ describe('Batch comments mutations', () => {
]);
});
});
-
- describe(types.OPEN_REVIEW_DROPDOWN, () => {
- it('sets showPreviewDropdown to true', () => {
- mutations[types.OPEN_REVIEW_DROPDOWN](state);
-
- expect(state.showPreviewDropdown).toBe(true);
- });
- });
-
- describe(types.CLOSE_REVIEW_DROPDOWN, () => {
- it('sets showPreviewDropdown to false', () => {
- mutations[types.CLOSE_REVIEW_DROPDOWN](state);
-
- expect(state.showPreviewDropdown).toBe(false);
- });
- });
});
diff --git a/spec/frontend/behaviors/load_startup_css_spec.js b/spec/frontend/behaviors/load_startup_css_spec.js
new file mode 100644
index 00000000000..81222ac5aaa
--- /dev/null
+++ b/spec/frontend/behaviors/load_startup_css_spec.js
@@ -0,0 +1,44 @@
+import { setHTMLFixture } from 'helpers/fixtures';
+import { loadStartupCSS } from '~/behaviors/load_startup_css';
+
+describe('behaviors/load_startup_css', () => {
+ let loadListener;
+
+ const setupListeners = () => {
+ document
+ .querySelectorAll('link')
+ .forEach(x => x.addEventListener('load', () => loadListener(x)));
+ };
+
+ beforeEach(() => {
+ loadListener = jest.fn();
+
+ setHTMLFixture(`
+ <meta charset="utf-8" />
+ <link media="print" src="./lorem-print.css" />
+ <link media="print" src="./ipsum-print.css" />
+ <link media="all" src="./dolar-all.css" />
+ `);
+
+ setupListeners();
+
+ loadStartupCSS();
+ });
+
+ it('does nothing at first', () => {
+ expect(loadListener).not.toHaveBeenCalled();
+ });
+
+ describe('on window load', () => {
+ beforeEach(() => {
+ window.dispatchEvent(new Event('load'));
+ });
+
+ it('dispatches load to the print links', () => {
+ expect(loadListener.mock.calls.map(([el]) => el.getAttribute('src'))).toEqual([
+ './lorem-print.css',
+ './ipsum-print.css',
+ ]);
+ });
+ });
+});
diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap
index 0f5b3cd3f5e..53815820bbe 100644
--- a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap
+++ b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap
@@ -27,8 +27,10 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = `
</small>
<clipboard-button-stub
+ category="tertiary"
cssclass="btn-clipboard btn-transparent lh-100 position-static"
gfm="\`foo/bar/dummy.md\`"
+ size="medium"
text="foo/bar/dummy.md"
title="Copy file path"
tooltipplacement="top"
diff --git a/spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js b/spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js
deleted file mode 100644
index 8dc71f99010..00000000000
--- a/spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import waitForPromises from 'helpers/wait_for_promises';
-import { shallowMount } from '@vue/test-utils';
-import { GlButton, GlAlert } from '@gitlab/ui';
-import axios from '~/lib/utils/axios_utils';
-import WebIdeAlert from '~/blob/suggest_web_ide_ci/components/web_ide_alert.vue';
-
-const dismissEndpoint = '/-/user_callouts';
-const featureId = 'web_ide_alert_dismissed';
-const editPath = 'edit/master/-/.gitlab-ci.yml';
-
-describe('WebIdeAlert', () => {
- let wrapper;
- let mock;
-
- const findButton = () => wrapper.find(GlButton);
- const findAlert = () => wrapper.find(GlAlert);
- const dismissAlert = alertWrapper => alertWrapper.vm.$emit('dismiss');
- const getPostPayload = () => JSON.parse(mock.history.post[0].data);
-
- const createComponent = () => {
- wrapper = shallowMount(WebIdeAlert, {
- propsData: {
- dismissEndpoint,
- featureId,
- editPath,
- },
- });
- };
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
-
- mock.onPost(dismissEndpoint).reply(200);
-
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
-
- mock.restore();
- });
-
- describe('with defaults', () => {
- it('displays alert correctly', () => {
- expect(findAlert().exists()).toBe(true);
- });
-
- it('web ide button link has correct path', () => {
- expect(findButton().attributes('href')).toBe(editPath);
- });
-
- it('dismisses alert correctly', async () => {
- const alertWrapper = findAlert();
-
- dismissAlert(alertWrapper);
-
- await waitForPromises();
-
- expect(alertWrapper.exists()).toBe(false);
- expect(mock.history.post).toHaveLength(1);
- expect(getPostPayload()).toEqual({ feature_name: featureId });
- });
- });
-});
diff --git a/spec/frontend/boards/board_blank_state_spec.js b/spec/frontend/boards/board_blank_state_spec.js
deleted file mode 100644
index 3ffdda52f58..00000000000
--- a/spec/frontend/boards/board_blank_state_spec.js
+++ /dev/null
@@ -1,95 +0,0 @@
-import Vue from 'vue';
-import boardsStore from '~/boards/stores/boards_store';
-import BoardBlankState from '~/boards/components/board_blank_state.vue';
-
-describe('Boards blank state', () => {
- let vm;
- let fail = false;
-
- beforeEach(done => {
- const Comp = Vue.extend(BoardBlankState);
-
- boardsStore.create();
-
- jest.spyOn(boardsStore, 'addList').mockImplementation();
- jest.spyOn(boardsStore, 'removeList').mockImplementation();
- jest.spyOn(boardsStore, 'generateDefaultLists').mockImplementation(
- () =>
- new Promise((resolve, reject) => {
- if (fail) {
- reject();
- } else {
- resolve({
- data: [
- {
- id: 1,
- title: 'To Do',
- label: { id: 1 },
- },
- {
- id: 2,
- title: 'Doing',
- label: { id: 2 },
- },
- ],
- });
- }
- }),
- );
-
- vm = new Comp();
-
- setImmediate(() => {
- vm.$mount();
- done();
- });
- });
-
- it('renders pre-defined labels', () => {
- expect(vm.$el.querySelectorAll('.board-blank-state-list li').length).toBe(2);
-
- expect(vm.$el.querySelectorAll('.board-blank-state-list li')[0].textContent.trim()).toEqual(
- 'To Do',
- );
-
- expect(vm.$el.querySelectorAll('.board-blank-state-list li')[1].textContent.trim()).toEqual(
- 'Doing',
- );
- });
-
- it('clears blank state', done => {
- vm.$el.querySelector('.btn-default').click();
-
- setImmediate(() => {
- expect(boardsStore.welcomeIsHidden()).toBeTruthy();
-
- done();
- });
- });
-
- it('creates pre-defined labels', done => {
- vm.$el.querySelector('.btn-success').click();
-
- setImmediate(() => {
- expect(boardsStore.addList).toHaveBeenCalledTimes(2);
- expect(boardsStore.addList).toHaveBeenCalledWith(expect.objectContaining({ title: 'To Do' }));
-
- expect(boardsStore.addList).toHaveBeenCalledWith(expect.objectContaining({ title: 'Doing' }));
-
- done();
- });
- });
-
- it('resets the store if request fails', done => {
- fail = true;
-
- vm.$el.querySelector('.btn-success').click();
-
- setImmediate(() => {
- expect(boardsStore.welcomeIsHidden()).toBeFalsy();
- expect(boardsStore.removeList).toHaveBeenCalledWith(undefined, 'label');
-
- done();
- });
- });
-});
diff --git a/spec/frontend/boards/boards_store_spec.js b/spec/frontend/boards/boards_store_spec.js
index 41971137b95..e7c1cf79fdc 100644
--- a/spec/frontend/boards/boards_store_spec.js
+++ b/spec/frontend/boards/boards_store_spec.js
@@ -1,7 +1,7 @@
import AxiosMockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import axios from '~/lib/utils/axios_utils';
-import boardsStore from '~/boards/stores/boards_store';
+import boardsStore, { gqlClient } from '~/boards/stores/boards_store';
import eventHub from '~/boards/eventhub';
import { listObj, listObjDuplicate } from './mock_data';
@@ -503,11 +503,15 @@ describe('boardsStore', () => {
beforeEach(() => {
requestSpy = jest.fn();
axiosMock.onPut(url).replyOnce(config => requestSpy(config));
+ jest.spyOn(gqlClient, 'mutate').mockReturnValue(Promise.resolve({}));
});
it('makes a request to update the board', () => {
requestSpy.mockReturnValue([200, dummyResponse]);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
+ const expectedResponse = [
+ expect.objectContaining({ data: dummyResponse }),
+ expect.objectContaining({}),
+ ];
return expect(
boardsStore.createBoard({
@@ -555,11 +559,12 @@ describe('boardsStore', () => {
beforeEach(() => {
requestSpy = jest.fn();
axiosMock.onPost(url).replyOnce(config => requestSpy(config));
+ jest.spyOn(gqlClient, 'mutate').mockReturnValue(Promise.resolve({}));
});
it('makes a request to create a new board', () => {
requestSpy.mockReturnValue([200, dummyResponse]);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
+ const expectedResponse = dummyResponse;
return expect(boardsStore.createBoard(board))
.resolves.toEqual(expectedResponse)
@@ -740,14 +745,6 @@ describe('boardsStore', () => {
expect(boardsStore.shouldAddBlankState()).toBe(true);
});
- it('adds the blank state', () => {
- boardsStore.addBlankState();
-
- const list = boardsStore.findList('type', 'blank', 'blank');
-
- expect(list).toBeDefined();
- });
-
it('removes list from state', () => {
boardsStore.addList(listObj);
diff --git a/spec/frontend/boards/components/board_configuration_options_spec.js b/spec/frontend/boards/components/board_configuration_options_spec.js
new file mode 100644
index 00000000000..e9a1cb6a4e8
--- /dev/null
+++ b/spec/frontend/boards/components/board_configuration_options_spec.js
@@ -0,0 +1,59 @@
+import { shallowMount } from '@vue/test-utils';
+import BoardConfigurationOptions from '~/boards/components/board_configuration_options.vue';
+
+describe('BoardConfigurationOptions', () => {
+ let wrapper;
+ const board = { hide_backlog_list: false, hide_closed_list: false };
+
+ const defaultProps = {
+ currentBoard: board,
+ board,
+ isNewForm: false,
+ };
+
+ const createComponent = () => {
+ wrapper = shallowMount(BoardConfigurationOptions, {
+ propsData: { ...defaultProps },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const backlogListCheckbox = el => el.find('[data-testid="backlog-list-checkbox"]');
+ const closedListCheckbox = el => el.find('[data-testid="closed-list-checkbox"]');
+
+ const checkboxAssert = (backlogCheckbox, closedCheckbox) => {
+ expect(backlogListCheckbox(wrapper).attributes('checked')).toEqual(
+ backlogCheckbox ? undefined : 'true',
+ );
+ expect(closedListCheckbox(wrapper).attributes('checked')).toEqual(
+ closedCheckbox ? undefined : 'true',
+ );
+ };
+
+ it.each`
+ backlogCheckboxValue | closedCheckboxValue
+ ${true} | ${true}
+ ${true} | ${false}
+ ${false} | ${true}
+ ${false} | ${false}
+ `(
+ 'renders two checkbox when one is $backlogCheckboxValue and other is $closedCheckboxValue',
+ async ({ backlogCheckboxValue, closedCheckboxValue }) => {
+ await wrapper.setData({
+ hideBacklogList: backlogCheckboxValue,
+ hideClosedList: closedCheckboxValue,
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ checkboxAssert(backlogCheckboxValue, closedCheckboxValue);
+ });
+ },
+ );
+});
diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js
index df117d06cdf..09e38001e2e 100644
--- a/spec/frontend/boards/components/board_content_spec.js
+++ b/spec/frontend/boards/components/board_content_spec.js
@@ -23,9 +23,6 @@ describe('BoardContent', () => {
return new Vuex.Store({
getters,
state,
- actions: {
- fetchIssuesForAllLists: () => {},
- },
});
};
diff --git a/spec/frontend/boards/components/sidebar/board_editable_item_spec.js b/spec/frontend/boards/components/sidebar/board_editable_item_spec.js
index 1dbcbd06407..e7139ceaa93 100644
--- a/spec/frontend/boards/components/sidebar/board_editable_item_spec.js
+++ b/spec/frontend/boards/components/sidebar/board_editable_item_spec.js
@@ -96,12 +96,22 @@ describe('boards sidebar remove issue', () => {
expect(findExpanded().isVisible()).toBe(false);
});
- it('emits changed event', async () => {
+ it('emits close event', async () => {
document.body.click();
await wrapper.vm.$nextTick();
- expect(wrapper.emitted().changed[1][0]).toBe(false);
+ expect(wrapper.emitted().close.length).toBe(1);
});
});
+
+ it('emits open when edit button is clicked and edit is initailized to false', async () => {
+ createComponent({ canUpdate: true });
+
+ findEditButton().vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted().open.length).toBe(1);
+ });
});
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index bdbcd435708..6415a5a5d3a 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -6,12 +6,13 @@ import {
mockIssueWithModel,
mockIssue2WithModel,
rawIssue,
+ mockIssues,
} from '../mock_data';
import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
import { inactiveId, ListType } from '~/boards/constants';
import issueMoveListMutation from '~/boards/queries/issue_move_list.mutation.graphql';
-import { fullBoardId } from '~/boards/boards_util';
+import { fullBoardId, formatListIssues } from '~/boards/boards_util';
const expectNotImplemented = action => {
it('is not implemented', () => {
@@ -237,6 +238,77 @@ describe('deleteList', () => {
expectNotImplemented(actions.deleteList);
});
+describe('fetchIssuesForList', () => {
+ const listId = mockLists[0].id;
+
+ const state = {
+ endpoints: {
+ fullPath: 'gitlab-org',
+ boardId: 1,
+ },
+ filterParams: {},
+ boardType: 'group',
+ };
+
+ const queryResponse = {
+ data: {
+ group: {
+ board: {
+ lists: {
+ nodes: [
+ {
+ id: listId,
+ issues: {
+ nodes: mockIssues,
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ };
+
+ const formattedIssues = formatListIssues(queryResponse.data.group.board.lists);
+
+ it('should commit mutation RECEIVE_ISSUES_FOR_LIST_SUCCESS on success', done => {
+ jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
+
+ testAction(
+ actions.fetchIssuesForList,
+ listId,
+ state,
+ [
+ {
+ type: types.RECEIVE_ISSUES_FOR_LIST_SUCCESS,
+ payload: { listIssues: formattedIssues, listId },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+
+ it('should commit mutation RECEIVE_ISSUES_FOR_LIST_FAILURE on failure', done => {
+ jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject());
+
+ testAction(
+ actions.fetchIssuesForList,
+ listId,
+ state,
+ [{ type: types.RECEIVE_ISSUES_FOR_LIST_FAILURE, payload: listId }],
+ [],
+ done,
+ );
+ });
+});
+
+describe('resetIssues', () => {
+ it('commits RESET_ISSUES mutation', () => {
+ return testAction(actions.resetIssues, {}, {}, [{ type: types.RESET_ISSUES }], []);
+ });
+});
+
describe('moveIssue', () => {
const listIssues = {
'gid://gitlab/List/1': [436, 437],
diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js
index a13a99a507e..c80537bf168 100644
--- a/spec/frontend/boards/stores/mutations_spec.js
+++ b/spec/frontend/boards/stores/mutations_spec.js
@@ -145,6 +145,23 @@ describe('Board Store Mutations', () => {
expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_ERROR);
});
+ describe('RESET_ISSUES', () => {
+ it('should remove issues from issuesByListId state', () => {
+ const issuesByListId = {
+ 'gid://gitlab/List/1': [mockIssue.id],
+ };
+
+ state = {
+ ...state,
+ issuesByListId,
+ };
+
+ mutations[types.RESET_ISSUES](state);
+
+ expect(state.issuesByListId).toEqual({ 'gid://gitlab/List/1': [] });
+ });
+ });
+
describe('RECEIVE_ISSUES_FOR_LIST_SUCCESS', () => {
it('updates issuesByListId and issues on state', () => {
const listIssues = {
@@ -156,7 +173,6 @@ describe('Board Store Mutations', () => {
state = {
...state,
- isLoadingIssues: true,
issuesByListId: {},
issues: {},
boardLists: mockListsWithModel,
@@ -172,16 +188,6 @@ describe('Board Store Mutations', () => {
});
});
- describe('REQUEST_ISSUES_FOR_ALL_LISTS', () => {
- it('sets isLoadingIssues to true', () => {
- expect(state.isLoadingIssues).toBe(false);
-
- mutations.REQUEST_ISSUES_FOR_ALL_LISTS(state);
-
- expect(state.isLoadingIssues).toBe(true);
- });
- });
-
describe('RECEIVE_ISSUES_FOR_LIST_FAILURE', () => {
it('sets error message', () => {
state = {
@@ -200,51 +206,10 @@ describe('Board Store Mutations', () => {
});
});
- describe('RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS', () => {
- it('sets isLoadingIssues to false and updates issuesByListId object', () => {
- const listIssues = {
- 'gid://gitlab/List/1': [mockIssue.id],
- };
- const issues = {
- '1': mockIssue,
- };
-
- state = {
- ...state,
- isLoadingIssues: true,
- issuesByListId: {},
- issues: {},
- };
-
- mutations.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS(state, { listData: listIssues, issues });
-
- expect(state.isLoadingIssues).toBe(false);
- expect(state.issuesByListId).toEqual(listIssues);
- expect(state.issues).toEqual(issues);
- });
- });
-
describe('REQUEST_ADD_ISSUE', () => {
expectNotImplemented(mutations.REQUEST_ADD_ISSUE);
});
- describe('RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE', () => {
- it('sets isLoadingIssues to false and sets error message', () => {
- state = {
- ...state,
- isLoadingIssues: true,
- error: undefined,
- };
-
- mutations.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE(state);
-
- expect(state.isLoadingIssues).toBe(false);
- expect(state.error).toEqual(
- 'An error occurred while fetching the board issues. Please reload the page.',
- );
- });
- });
-
describe('UPDATE_ISSUE_BY_ID', () => {
const issueId = '1';
const prop = 'id';
@@ -254,7 +219,6 @@ describe('Board Store Mutations', () => {
beforeEach(() => {
state = {
...state,
- isLoadingIssues: true,
error: undefined,
issues: {
...issue,
diff --git a/spec/frontend/ci_settings_pipeline_triggers/components/triggers_list_spec.js b/spec/frontend/ci_settings_pipeline_triggers/components/triggers_list_spec.js
new file mode 100644
index 00000000000..e07afb5d736
--- /dev/null
+++ b/spec/frontend/ci_settings_pipeline_triggers/components/triggers_list_spec.js
@@ -0,0 +1,102 @@
+import { mount } from '@vue/test-utils';
+import { GlTable, GlBadge } from '@gitlab/ui';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+import TriggersList from '~/ci_settings_pipeline_triggers/components/triggers_list.vue';
+import { triggers } from '../mock_data';
+
+describe('TriggersList', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = mount(TriggersList, {
+ propsData: { triggers, ...props },
+ });
+ };
+
+ const findTable = () => wrapper.find(GlTable);
+ const findHeaderAt = i => wrapper.findAll('thead th').at(i);
+ const findRows = () => wrapper.findAll('tbody tr');
+ const findRowAt = i => findRows().at(i);
+ const findCell = (i, col) =>
+ findRowAt(i)
+ .findAll('td')
+ .at(col);
+ const findClipboardBtn = i => findCell(i, 0).find(ClipboardButton);
+ const findInvalidBadge = i => findCell(i, 0).find(GlBadge);
+ const findEditBtn = i => findRowAt(i).find('[data-testid="edit-btn"]');
+ const findRevokeBtn = i => findRowAt(i).find('[data-testid="trigger_revoke_button"]');
+
+ beforeEach(() => {
+ createComponent();
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('displays a table with expected headers', () => {
+ const headers = ['Token', 'Description', 'Owner', 'Last Used', ''];
+ headers.forEach((header, i) => {
+ expect(findHeaderAt(i).text()).toBe(header);
+ });
+ });
+
+ it('displays a table with rows', () => {
+ expect(findRows()).toHaveLength(triggers.length);
+
+ const [trigger] = triggers;
+
+ expect(findCell(0, 0).text()).toBe(trigger.token);
+ expect(findCell(0, 1).text()).toBe(trigger.description);
+ expect(findCell(0, 2).text()).toContain(trigger.owner.name);
+ });
+
+ it('displays a "copy to cliboard" button for exposed tokens', () => {
+ expect(findClipboardBtn(0).exists()).toBe(true);
+ expect(findClipboardBtn(0).props('text')).toBe(triggers[0].token);
+
+ expect(findClipboardBtn(1).exists()).toBe(false);
+ });
+
+ it('displays an "invalid" label for tokens without access', () => {
+ expect(findInvalidBadge(0).exists()).toBe(false);
+
+ expect(findInvalidBadge(1).exists()).toBe(true);
+ });
+
+ it('displays a time ago label when last used', () => {
+ expect(findCell(0, 3).text()).toBe('Never');
+
+ expect(
+ findCell(1, 3)
+ .find(TimeAgoTooltip)
+ .props('time'),
+ ).toBe(triggers[1].lastUsed);
+ });
+
+ it('displays actions in a rows', () => {
+ const [data] = triggers;
+
+ expect(findEditBtn(0).attributes('href')).toBe(data.editProjectTriggerPath);
+
+ expect(findRevokeBtn(0).attributes('href')).toBe(data.projectTriggerPath);
+ expect(findRevokeBtn(0).attributes('data-method')).toBe('delete');
+ expect(findRevokeBtn(0).attributes('data-confirm')).toBeTruthy();
+ });
+
+ describe('when there are no triggers set', () => {
+ beforeEach(() => {
+ createComponent({ triggers: [] });
+ });
+
+ it('does not display a table', () => {
+ expect(findTable().exists()).toBe(false);
+ });
+
+ it('displays a message', () => {
+ expect(wrapper.text()).toBe(
+ 'No triggers have been created yet. Add one using the form above.',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/ci_settings_pipeline_triggers/mock_data.js b/spec/frontend/ci_settings_pipeline_triggers/mock_data.js
new file mode 100644
index 00000000000..6813e941e03
--- /dev/null
+++ b/spec/frontend/ci_settings_pipeline_triggers/mock_data.js
@@ -0,0 +1,30 @@
+export const triggers = [
+ {
+ hasTokenExposed: true,
+ token: '0000',
+ description: 'My trigger',
+ owner: {
+ name: 'My User',
+ username: 'user1',
+ path: '/user1',
+ },
+ lastUsed: null,
+ canAccessProject: true,
+ editProjectTriggerPath: '/triggers/1/edit',
+ projectTriggerPath: '/trigger/1',
+ },
+ {
+ hasTokenExposed: false,
+ token: '1111',
+ description: "Anothe user's trigger",
+ owner: {
+ name: 'Someone else',
+ username: 'user2',
+ path: '/user2',
+ },
+ lastUsed: '2020-09-10T08:26:47.410Z',
+ canAccessProject: false,
+ editProjectTriggerPath: '/triggers/1/edit',
+ projectTriggerPath: '/trigger/1',
+ },
+];
diff --git a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js
index ab32fb12058..5c2d096418d 100644
--- a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js
+++ b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js
@@ -1,6 +1,6 @@
import Vuex from 'vuex';
import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
-import { GlButton, GlFormCombobox } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { AWS_ACCESS_KEY_ID } from '~/ci_variable_list/constants';
import CiVariableModal from '~/ci_variable_list/components/ci_variable_modal.vue';
import createStore from '~/ci_variable_list/store';
@@ -18,7 +18,6 @@ describe('Ci variable modal', () => {
store = createStore();
wrapper = method(CiVariableModal, {
attachToDocument: true,
- provide: { glFeatures: { ciKeyAutocomplete: true } },
stubs: {
GlModal: ModalStub,
},
@@ -42,27 +41,6 @@ describe('Ci variable modal', () => {
wrapper.destroy();
});
- describe('Feature flag', () => {
- describe('when off', () => {
- beforeEach(() => {
- createComponent(shallowMount, { provide: { glFeatures: { ciKeyAutocomplete: false } } });
- });
-
- it('does not render the autocomplete dropdown', () => {
- expect(wrapper.find(GlFormCombobox).exists()).toBe(false);
- });
- });
-
- describe('when on', () => {
- beforeEach(() => {
- createComponent(shallowMount);
- });
- it('renders the autocomplete dropdown', () => {
- expect(wrapper.find(GlFormCombobox).exists()).toBe(true);
- });
- });
- });
-
describe('Basic interactions', () => {
beforeEach(() => {
createComponent(shallowMount);
diff --git a/spec/frontend/clusters/components/fluentd_output_settings_spec.js b/spec/frontend/clusters/components/fluentd_output_settings_spec.js
index c263679a45c..25db8785edc 100644
--- a/spec/frontend/clusters/components/fluentd_output_settings_spec.js
+++ b/spec/frontend/clusters/components/fluentd_output_settings_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlAlert, GlDeprecatedDropdown, GlFormCheckbox } from '@gitlab/ui';
+import { GlAlert, GlDropdown, GlFormCheckbox } from '@gitlab/ui';
import FluentdOutputSettings from '~/clusters/components/fluentd_output_settings.vue';
import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants';
import eventHub from '~/clusters/event_hub';
@@ -36,7 +36,7 @@ describe('FluentdOutputSettings', () => {
};
const findSaveButton = () => wrapper.find({ ref: 'saveBtn' });
const findCancelButton = () => wrapper.find({ ref: 'cancelBtn' });
- const findProtocolDropdown = () => wrapper.find(GlDeprecatedDropdown);
+ const findProtocolDropdown = () => wrapper.find(GlDropdown);
const findCheckbox = name =>
wrapper.findAll(GlFormCheckbox).wrappers.find(x => x.text() === name);
const findHost = () => wrapper.find('#fluentd-host');
diff --git a/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js b/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js
index 3a9a608b2e2..1f07a0b7908 100644
--- a/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js
+++ b/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlAlert, GlToggle, GlDeprecatedDropdown } from '@gitlab/ui';
+import { GlAlert, GlToggle, GlDropdown } from '@gitlab/ui';
import IngressModsecuritySettings from '~/clusters/components/ingress_modsecurity_settings.vue';
import { APPLICATION_STATUS, INGRESS } from '~/clusters/constants';
import eventHub from '~/clusters/event_hub';
@@ -28,10 +28,12 @@ describe('IngressModsecuritySettings', () => {
});
};
- const findSaveButton = () => wrapper.find('.btn-success');
- const findCancelButton = () => wrapper.find('[variant="secondary"]');
+ const findSaveButton = () =>
+ wrapper.find('[data-qa-selector="save_ingress_modsecurity_settings"]');
+ const findCancelButton = () =>
+ wrapper.find('[data-qa-selector="cancel_ingress_modsecurity_settings"]');
const findModSecurityToggle = () => wrapper.find(GlToggle);
- const findModSecurityDropdown = () => wrapper.find(GlDeprecatedDropdown);
+ const findModSecurityDropdown = () => wrapper.find(GlDropdown);
describe('when ingress is installed', () => {
beforeEach(() => {
diff --git a/spec/frontend/clusters/components/knative_domain_editor_spec.js b/spec/frontend/clusters/components/knative_domain_editor_spec.js
index 11ebe1b5d61..b7f76211fd6 100644
--- a/spec/frontend/clusters/components/knative_domain_editor_spec.js
+++ b/spec/frontend/clusters/components/knative_domain_editor_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedDropdownItem, GlButton } from '@gitlab/ui';
+import { GlDropdownItem, GlButton } from '@gitlab/ui';
import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue';
import { APPLICATION_STATUS } from '~/clusters/constants';
@@ -112,7 +112,7 @@ describe('KnativeDomainEditor', () => {
createComponent({ knative: { ...knative, availableDomains: [newDomain] } });
jest.spyOn(wrapper.vm, 'selectDomain');
- wrapper.find(GlDeprecatedDropdownItem).vm.$emit('click');
+ wrapper.find(GlDropdownItem).vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.selectDomain).toHaveBeenCalledWith(newDomain);
diff --git a/spec/frontend/clusters/services/crossplane_provider_stack_spec.js b/spec/frontend/clusters/services/crossplane_provider_stack_spec.js
index 57c538d2650..3e5f8de8e7b 100644
--- a/spec/frontend/clusters/services/crossplane_provider_stack_spec.js
+++ b/spec/frontend/clusters/services/crossplane_provider_stack_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedDropdownItem, GlIcon } from '@gitlab/ui';
+import { GlDropdownItem, GlIcon } from '@gitlab/ui';
import CrossplaneProviderStack from '~/clusters/components/crossplane_provider_stack.vue';
describe('CrossplaneProviderStack component', () => {
@@ -37,7 +37,7 @@ describe('CrossplaneProviderStack component', () => {
createComponent({ crossplane });
});
- const findDropdownElements = () => wrapper.findAll(GlDeprecatedDropdownItem);
+ const findDropdownElements = () => wrapper.findAll(GlDropdownItem);
const findFirstDropdownElement = () => findDropdownElements().at(0);
afterEach(() => {
diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js
index 628c35ae839..34d99473eb7 100644
--- a/spec/frontend/clusters_list/components/clusters_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_spec.js
@@ -164,18 +164,18 @@ describe('Clusters', () => {
});
it.each`
- nodeSize | lineNumber
- ${'Unknown'} | ${0}
- ${'1'} | ${1}
- ${'2'} | ${2}
- ${'1'} | ${3}
- ${'1'} | ${4}
- ${'Unknown'} | ${5}
- `('renders node size for each cluster', ({ nodeSize, lineNumber }) => {
+ nodeText | lineNumber
+ ${'Unable to Authenticate'} | ${0}
+ ${'1'} | ${1}
+ ${'2'} | ${2}
+ ${'1'} | ${3}
+ ${'1'} | ${4}
+ ${'Unknown Error'} | ${5}
+ `('renders node size for each cluster', ({ nodeText, lineNumber }) => {
const sizes = findTable().findAll('td:nth-child(3)');
const size = sizes.at(lineNumber);
- expect(size.text()).toBe(nodeSize);
+ expect(size.text()).toContain(nodeText);
expect(size.find(GlSkeletonLoading).exists()).toBe(false);
});
});
diff --git a/spec/frontend/clusters_list/components/node_error_help_text_spec.js b/spec/frontend/clusters_list/components/node_error_help_text_spec.js
new file mode 100644
index 00000000000..4d157b3a8ab
--- /dev/null
+++ b/spec/frontend/clusters_list/components/node_error_help_text_spec.js
@@ -0,0 +1,33 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlPopover } from '@gitlab/ui';
+import NodeErrorHelpText from '~/clusters_list/components/node_error_help_text.vue';
+
+describe('NodeErrorHelpText', () => {
+ let wrapper;
+
+ const createWrapper = propsData => {
+ wrapper = shallowMount(NodeErrorHelpText, { propsData, stubs: { GlPopover } });
+ return wrapper.vm.$nextTick();
+ };
+
+ const findPopover = () => wrapper.find(GlPopover);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it.each`
+ errorType | wrapperText | popoverText
+ ${'authentication_error'} | ${'Unable to Authenticate'} | ${'GitLab failed to authenticate'}
+ ${'connection_error'} | ${'Unable to Connect'} | ${'GitLab failed to connect to the cluster'}
+ ${'http_error'} | ${'Unable to Connect'} | ${'There was an HTTP error when connecting to your cluster'}
+ ${'default'} | ${'Unknown Error'} | ${'An unknown error occurred while attempting to connect to Kubernetes.'}
+ ${'unknown_error_type'} | ${'Unknown Error'} | ${'An unknown error occurred while attempting to connect to Kubernetes.'}
+ ${null} | ${'Unknown Error'} | ${'An unknown error occurred while attempting to connect to Kubernetes.'}
+ `('displays error text', ({ errorType, wrapperText, popoverText }) => {
+ return createWrapper({ errorType, popoverId: 'id' }).then(() => {
+ expect(wrapper.text()).toContain(wrapperText);
+ expect(findPopover().text()).toContain(popoverText);
+ });
+ });
+});
diff --git a/spec/frontend/clusters_list/mock_data.js b/spec/frontend/clusters_list/mock_data.js
index 48af3b91c94..ed32655d10e 100644
--- a/spec/frontend/clusters_list/mock_data.js
+++ b/spec/frontend/clusters_list/mock_data.js
@@ -6,6 +6,11 @@ export const clusterList = [
provider_type: 'gcp',
status: 'creating',
nodes: null,
+ kubernetes_errors: {
+ connection_error: 'authentication_error',
+ node_connection_error: 'connection_error',
+ metrics_connection_error: 'http_error',
+ },
},
{
name: 'My Cluster 2',
@@ -19,6 +24,7 @@ export const clusterList = [
usage: { cpu: '246155922n', memory: '1255212Ki' },
},
],
+ kubernetes_errors: {},
},
{
name: 'My Cluster 3',
@@ -36,6 +42,7 @@ export const clusterList = [
usage: { cpu: '307051934n', memory: '1379136Ki' },
},
],
+ kubernetes_errors: {},
},
{
name: 'My Cluster 4',
@@ -48,6 +55,7 @@ export const clusterList = [
usage: { cpu: '1missingCpuUnit', memory: '1missingMemoryUnit' },
},
],
+ kubernetes_errors: {},
},
{
name: 'My Cluster 5',
@@ -59,12 +67,14 @@ export const clusterList = [
status: { allocatable: { cpu: '1missingCpuUnit', memory: '1missingMemoryUnit' } },
},
],
+ kubernetes_errors: {},
},
{
name: 'My Cluster 6',
environment_scope: '*',
cluster_type: 'project_type',
status: 'cleanup_ongoing',
+ kubernetes_errors: {},
},
];
diff --git a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap
index 745a163951a..62b751ec59b 100644
--- a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap
+++ b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap
@@ -56,6 +56,7 @@ exports[`Code navigation popover component renders popover 1`] = `
class="popover-body border-top"
>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="w-100"
data-testid="go-to-definition-btn"
diff --git a/spec/frontend/commit/pipelines/pipelines_spec.js b/spec/frontend/commit/pipelines/pipelines_spec.js
index fdf3c2e85f3..a196b66daa0 100644
--- a/spec/frontend/commit/pipelines/pipelines_spec.js
+++ b/spec/frontend/commit/pipelines/pipelines_spec.js
@@ -21,6 +21,10 @@ describe('Pipelines table in Commits and Merge requests', () => {
preloadFixtures(jsonFixtureName);
+ const findRunPipelineBtn = () => vm.$el.querySelector('[data-testid="run_pipeline_button"]');
+ const findRunPipelineBtnMobile = () =>
+ vm.$el.querySelector('[data-testid="run_pipeline_button_mobile"]');
+
beforeEach(() => {
mock = new MockAdapter(axios);
@@ -131,7 +135,8 @@ describe('Pipelines table in Commits and Merge requests', () => {
vm = mountComponent(PipelinesTable, { ...props });
setImmediate(() => {
- expect(vm.$el.querySelector('.js-run-mr-pipeline')).not.toBeNull();
+ expect(findRunPipelineBtn()).not.toBeNull();
+ expect(findRunPipelineBtnMobile()).not.toBeNull();
done();
});
});
@@ -147,7 +152,8 @@ describe('Pipelines table in Commits and Merge requests', () => {
vm = mountComponent(PipelinesTable, { ...props });
setImmediate(() => {
- expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull();
+ expect(findRunPipelineBtn()).toBeNull();
+ expect(findRunPipelineBtnMobile()).toBeNull();
done();
});
});
@@ -157,7 +163,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
const findModal = () =>
document.querySelector('#create-pipeline-for-fork-merge-request-modal');
- beforeEach(() => {
+ beforeEach(done => {
pipelineCopy.flags.detached_merge_request_pipeline = true;
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
@@ -168,23 +174,46 @@ describe('Pipelines table in Commits and Merge requests', () => {
projectId: '5',
mergeRequestId: 3,
});
- });
- it('updates the loading state', done => {
jest.spyOn(Api, 'postMergeRequestPipeline').mockReturnValue(Promise.resolve());
setImmediate(() => {
- vm.$el.querySelector('.js-run-mr-pipeline').click();
+ done();
+ });
+ });
- vm.$nextTick(() => {
- expect(findModal()).toBeNull();
- expect(vm.state.isRunningMergeRequestPipeline).toBe(true);
+ it('on desktop, shows a loading button', done => {
+ findRunPipelineBtn().click();
- setImmediate(() => {
- expect(vm.state.isRunningMergeRequestPipeline).toBe(false);
+ vm.$nextTick(() => {
+ expect(findModal()).toBeNull();
- done();
- });
+ expect(findRunPipelineBtn().disabled).toBe(true);
+ expect(findRunPipelineBtn().querySelector('.gl-spinner')).not.toBeNull();
+
+ setImmediate(() => {
+ expect(findRunPipelineBtn().disabled).toBe(false);
+ expect(findRunPipelineBtn().querySelector('.gl-spinner')).toBeNull();
+
+ done();
+ });
+ });
+ });
+
+ it('on mobile, shows a loading button', done => {
+ findRunPipelineBtnMobile().click();
+
+ vm.$nextTick(() => {
+ expect(findModal()).toBeNull();
+
+ expect(findModal()).toBeNull();
+ expect(findRunPipelineBtn().querySelector('.gl-spinner')).not.toBeNull();
+
+ setImmediate(() => {
+ expect(findRunPipelineBtn().disabled).toBe(false);
+ expect(findRunPipelineBtn().querySelector('.gl-spinner')).toBeNull();
+
+ done();
});
});
});
@@ -194,7 +223,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
const findModal = () =>
document.querySelector('#create-pipeline-for-fork-merge-request-modal');
- beforeEach(() => {
+ beforeEach(done => {
pipelineCopy.flags.detached_merge_request_pipeline = true;
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
@@ -207,18 +236,29 @@ describe('Pipelines table in Commits and Merge requests', () => {
sourceProjectFullPath: 'test/parent-project',
targetProjectFullPath: 'test/fork-project',
});
- });
- it('shows a security warning modal', done => {
jest.spyOn(Api, 'postMergeRequestPipeline').mockReturnValue(Promise.resolve());
setImmediate(() => {
- vm.$el.querySelector('.js-run-mr-pipeline').click();
+ done();
+ });
+ });
- vm.$nextTick(() => {
- expect(findModal()).not.toBeNull();
- done();
- });
+ it('on desktop, shows a security warning modal', done => {
+ findRunPipelineBtn().click();
+
+ vm.$nextTick(() => {
+ expect(findModal()).not.toBeNull();
+ done();
+ });
+ });
+
+ it('on mobile, shows a security warning modal', done => {
+ findRunPipelineBtnMobile().click();
+
+ vm.$nextTick(() => {
+ expect(findModal()).not.toBeNull();
+ done();
});
});
});
diff --git a/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js b/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js
index 4bf3ac430f5..e0913fe2e88 100644
--- a/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js
@@ -12,6 +12,7 @@ describe('CreateEksCluster', () => {
let vm;
let state;
const gitlabManagedClusterHelpPath = 'gitlab-managed-cluster-help-path';
+ const namespacePerEnvironmentHelpPath = 'namespace-per-environment-help-path';
const accountAndExternalIdsHelpPath = 'account-and-external-id-help-path';
const createRoleArnHelpPath = 'role-arn-help-path';
const kubernetesIntegrationHelpPath = 'kubernetes-integration';
@@ -26,6 +27,7 @@ describe('CreateEksCluster', () => {
vm = shallowMount(CreateEksCluster, {
propsData: {
gitlabManagedClusterHelpPath,
+ namespacePerEnvironmentHelpPath,
accountAndExternalIdsHelpPath,
createRoleArnHelpPath,
externalLinkIcon,
@@ -53,6 +55,12 @@ describe('CreateEksCluster', () => {
);
});
+ it('help url for namespace per environment cluster documentation', () => {
+ expect(vm.find(EksClusterConfigurationForm).props('namespacePerEnvironmentHelpPath')).toBe(
+ namespacePerEnvironmentHelpPath,
+ );
+ });
+
it('help url for gitlab managed cluster documentation', () => {
expect(vm.find(EksClusterConfigurationForm).props('kubernetesIntegrationHelpPath')).toBe(
kubernetesIntegrationHelpPath,
diff --git a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
index d7dd7072f67..2600415fc9f 100644
--- a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
@@ -169,6 +169,7 @@ describe('EksClusterConfigurationForm', () => {
store,
propsData: {
gitlabManagedClusterHelpPath: '',
+ namespacePerEnvironmentHelpPath: '',
kubernetesIntegrationHelpPath: '',
externalLinkIcon: '',
},
diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
index ed753888790..f929216689a 100644
--- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
@@ -14,6 +14,7 @@ import {
SET_ROLE,
SET_SECURITY_GROUP,
SET_GITLAB_MANAGED_CLUSTER,
+ SET_NAMESPACE_PER_ENVIRONMENT,
SET_INSTANCE_TYPE,
SET_NODE_COUNT,
REQUEST_CREATE_ROLE,
@@ -40,6 +41,7 @@ describe('EKS Cluster Store Actions', () => {
let instanceType;
let nodeCount;
let gitlabManagedCluster;
+ let namespacePerEnvironment;
let mock;
let state;
let newClusterUrl;
@@ -57,6 +59,7 @@ describe('EKS Cluster Store Actions', () => {
instanceType = 'small-1';
nodeCount = '5';
gitlabManagedCluster = true;
+ namespacePerEnvironment = true;
newClusterUrl = '/clusters/1';
@@ -76,19 +79,20 @@ describe('EKS Cluster Store Actions', () => {
});
it.each`
- action | mutation | payload | payloadDescription
- ${'setClusterName'} | ${SET_CLUSTER_NAME} | ${{ clusterName }} | ${'cluster name'}
- ${'setEnvironmentScope'} | ${SET_ENVIRONMENT_SCOPE} | ${{ environmentScope }} | ${'environment scope'}
- ${'setKubernetesVersion'} | ${SET_KUBERNETES_VERSION} | ${{ kubernetesVersion }} | ${'kubernetes version'}
- ${'setRole'} | ${SET_ROLE} | ${{ role }} | ${'role'}
- ${'setRegion'} | ${SET_REGION} | ${{ region }} | ${'region'}
- ${'setKeyPair'} | ${SET_KEY_PAIR} | ${{ keyPair }} | ${'key pair'}
- ${'setVpc'} | ${SET_VPC} | ${{ vpc }} | ${'vpc'}
- ${'setSubnet'} | ${SET_SUBNET} | ${{ subnet }} | ${'subnet'}
- ${'setSecurityGroup'} | ${SET_SECURITY_GROUP} | ${{ securityGroup }} | ${'securityGroup'}
- ${'setInstanceType'} | ${SET_INSTANCE_TYPE} | ${{ instanceType }} | ${'instance type'}
- ${'setNodeCount'} | ${SET_NODE_COUNT} | ${{ nodeCount }} | ${'node count'}
- ${'setGitlabManagedCluster'} | ${SET_GITLAB_MANAGED_CLUSTER} | ${gitlabManagedCluster} | ${'gitlab managed cluster'}
+ action | mutation | payload | payloadDescription
+ ${'setClusterName'} | ${SET_CLUSTER_NAME} | ${{ clusterName }} | ${'cluster name'}
+ ${'setEnvironmentScope'} | ${SET_ENVIRONMENT_SCOPE} | ${{ environmentScope }} | ${'environment scope'}
+ ${'setKubernetesVersion'} | ${SET_KUBERNETES_VERSION} | ${{ kubernetesVersion }} | ${'kubernetes version'}
+ ${'setRole'} | ${SET_ROLE} | ${{ role }} | ${'role'}
+ ${'setRegion'} | ${SET_REGION} | ${{ region }} | ${'region'}
+ ${'setKeyPair'} | ${SET_KEY_PAIR} | ${{ keyPair }} | ${'key pair'}
+ ${'setVpc'} | ${SET_VPC} | ${{ vpc }} | ${'vpc'}
+ ${'setSubnet'} | ${SET_SUBNET} | ${{ subnet }} | ${'subnet'}
+ ${'setSecurityGroup'} | ${SET_SECURITY_GROUP} | ${{ securityGroup }} | ${'securityGroup'}
+ ${'setInstanceType'} | ${SET_INSTANCE_TYPE} | ${{ instanceType }} | ${'instance type'}
+ ${'setNodeCount'} | ${SET_NODE_COUNT} | ${{ nodeCount }} | ${'node count'}
+ ${'setGitlabManagedCluster'} | ${SET_GITLAB_MANAGED_CLUSTER} | ${gitlabManagedCluster} | ${'gitlab managed cluster'}
+ ${'setNamespacePerEnvironment'} | ${SET_NAMESPACE_PER_ENVIRONMENT} | ${namespacePerEnvironment} | ${'namespace per environment'}
`(`$action commits $mutation with $payloadDescription payload`, data => {
const { action, mutation, payload } = data;
@@ -179,6 +183,7 @@ describe('EKS Cluster Store Actions', () => {
name: clusterName,
environment_scope: environmentScope,
managed: gitlabManagedCluster,
+ namespace_per_environment: namespacePerEnvironment,
provider_aws_attributes: {
kubernetes_version: kubernetesVersion,
region,
@@ -204,6 +209,7 @@ describe('EKS Cluster Store Actions', () => {
selectedInstanceType: instanceType,
nodeCount,
gitlabManagedCluster,
+ namespacePerEnvironment,
});
});
diff --git a/spec/frontend/design_management/components/design_todo_button_spec.js b/spec/frontend/design_management/components/design_todo_button_spec.js
index 451c23f0fea..9ebc6ca26a2 100644
--- a/spec/frontend/design_management/components/design_todo_button_spec.js
+++ b/spec/frontend/design_management/components/design_todo_button_spec.js
@@ -111,7 +111,7 @@ describe('Design management design todo button', () => {
});
it('renders correct button text', () => {
- expect(wrapper.text()).toBe('Add a To-Do');
+ expect(wrapper.text()).toBe('Add a To Do');
});
describe('when clicked', () => {
diff --git a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap
index 822df1f6472..de276bd300b 100644
--- a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap
+++ b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap
@@ -24,6 +24,7 @@ exports[`Design management list item component with notes renders item with mult
<img
alt="test"
class="gl-display-block gl-mx-auto gl-max-w-full mh-100 design-img"
+ data-qa-filename="test"
data-qa-selector="design_image"
src=""
/>
@@ -94,6 +95,7 @@ exports[`Design management list item component with notes renders item with sing
<img
alt="test"
class="gl-display-block gl-mx-auto gl-max-w-full mh-100 design-img"
+ data-qa-filename="test"
data-qa-selector="design_image"
src=""
/>
diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap
index a7d6145285c..5eb86d4f9cb 100644
--- a/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap
+++ b/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap
@@ -4,15 +4,16 @@ exports[`Design management pagination component hides components when designs ar
exports[`Design management pagination component renders navigation buttons 1`] = `
<div
- class="d-flex align-items-center"
+ class="gl-display-flex gl-align-items-center"
>
0 of 2
<gl-button-group-stub
- class="ml-3 mr-3"
+ class="gl-mx-5"
>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="js-previous-design"
disabled="true"
@@ -23,6 +24,7 @@ exports[`Design management pagination component renders navigation buttons 1`] =
/>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="js-next-design"
icon="angle-right"
diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap
index b286a74ebb8..723ac0491a7 100644
--- a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap
@@ -19,16 +19,16 @@ exports[`Design management toolbar component renders design and updated data 1`]
</a>
<div
- class="overflow-hidden d-flex align-items-center"
+ class="gl-overflow-hidden gl-display-flex gl-align-items-center"
>
<h2
- class="m-0 str-truncated-100 gl-font-base"
+ class="gl-m-0 str-truncated-100 gl-font-base"
>
test.jpg
</h2>
<small
- class="text-secondary"
+ class="gl-text-gray-500"
>
Updated 1 hour ago by Test Name
</small>
@@ -36,11 +36,12 @@ exports[`Design management toolbar component renders design and updated data 1`]
</div>
<design-navigation-stub
- class="ml-auto flex-shrink-0"
+ class="gl-ml-auto gl-flex-shrink-0"
id="1"
/>
<gl-button-stub
+ buttontextclasses=""
category="primary"
href="/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d"
icon="download"
diff --git a/spec/frontend/design_management/components/toolbar/design_navigation_spec.js b/spec/frontend/design_management/components/toolbar/design_navigation_spec.js
index 1c6588a9628..1d9b9c002f9 100644
--- a/spec/frontend/design_management/components/toolbar/design_navigation_spec.js
+++ b/spec/frontend/design_management/components/toolbar/design_navigation_spec.js
@@ -43,7 +43,7 @@ describe('Design management pagination component', () => {
it('renders navigation buttons', () => {
wrapper.setData({
- designs: [{ id: '1' }, { id: '2' }],
+ designCollection: { designs: [{ id: '1' }, { id: '2' }] },
});
return wrapper.vm.$nextTick().then(() => {
@@ -54,7 +54,7 @@ describe('Design management pagination component', () => {
describe('keyboard buttons navigation', () => {
beforeEach(() => {
wrapper.setData({
- designs: [{ filename: '1' }, { filename: '2' }, { filename: '3' }],
+ designCollection: { designs: [{ filename: '1' }, { filename: '2' }, { filename: '3' }] },
});
});
diff --git a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap
index 3d7939df28e..eaa7460ae15 100644
--- a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap
+++ b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap
@@ -5,6 +5,7 @@ exports[`Design management upload button component renders inverted upload desig
isinverted="true"
>
<gl-button-stub
+ buttontextclasses=""
category="primary"
icon=""
size="small"
@@ -30,6 +31,7 @@ exports[`Design management upload button component renders inverted upload desig
exports[`Design management upload button component renders loading icon 1`] = `
<div>
<gl-button-stub
+ buttontextclasses=""
category="primary"
disabled="true"
icon=""
@@ -62,6 +64,7 @@ exports[`Design management upload button component renders loading icon 1`] = `
exports[`Design management upload button component renders upload design button 1`] = `
<div>
<gl-button-stub
+ buttontextclasses=""
category="primary"
icon=""
size="small"
diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js
index 1c7806c292f..a8b335c2c46 100644
--- a/spec/frontend/design_management/mock_data/apollo_mock.js
+++ b/spec/frontend/design_management/mock_data/apollo_mock.js
@@ -4,6 +4,7 @@ export const designListQueryResponse = {
id: '1',
issue: {
designCollection: {
+ copyState: 'READY',
designs: {
nodes: [
{
diff --git a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
index b80b7fdb43e..7ab2c02c786 100644
--- a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
@@ -19,6 +19,7 @@ exports[`Design management index page designs does not render toolbar when there
>
<design-dropzone-stub
class="design-list-item design-list-item-new"
+ data-qa-selector="design_dropzone_content"
hasdesigns="true"
/>
</li>
@@ -110,6 +111,7 @@ exports[`Design management index page designs renders designs list and header wi
class="qa-selector-toolbar gl-display-flex gl-align-items-center"
>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="gl-mr-4 js-select-all"
icon=""
@@ -126,6 +128,7 @@ exports[`Design management index page designs renders designs list and header wi
buttonclass="gl-mr-3"
buttonsize="small"
buttonvariant="warning"
+ data-qa-selector="archive_button"
>
Archive selected
@@ -150,6 +153,7 @@ exports[`Design management index page designs renders designs list and header wi
>
<design-dropzone-stub
class="design-list-item design-list-item-new"
+ data-qa-selector="design_dropzone_content"
hasdesigns="true"
/>
</li>
@@ -171,6 +175,8 @@ exports[`Design management index page designs renders designs list and header wi
<input
class="design-checkbox"
+ data-qa-design="design-1-name"
+ data-qa-selector="design_checkbox"
type="checkbox"
/>
</li>
@@ -192,6 +198,8 @@ exports[`Design management index page designs renders designs list and header wi
<input
class="design-checkbox"
+ data-qa-design="design-2-name"
+ data-qa-selector="design_checkbox"
type="checkbox"
/>
</li>
@@ -213,6 +221,8 @@ exports[`Design management index page designs renders designs list and header wi
<input
class="design-checkbox"
+ data-qa-design="design-3-name"
+ data-qa-selector="design_checkbox"
type="checkbox"
/>
</li>
@@ -298,6 +308,7 @@ exports[`Design management index page when has no designs renders design dropzon
>
<design-dropzone-stub
class=""
+ data-qa-selector="design_dropzone_content"
/>
</li>
</ol>
diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
index c849e4d4ed6..8546f9fbf51 100644
--- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
@@ -67,6 +67,7 @@ exports[`Design management design index page renders design index 1`] = `
/>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4"
data-testid="resolved-comments"
diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js
index 661717d29a3..55ccb668e81 100644
--- a/spec/frontend/design_management/pages/index_spec.js
+++ b/spec/frontend/design_management/pages/index_spec.js
@@ -92,6 +92,8 @@ describe('Design management index page', () => {
const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox');
const findSelectAllButton = () => wrapper.find('.js-select-all');
const findToolbar = () => wrapper.find('.qa-selector-toolbar');
+ const findDesignCollectionIsCopying = () =>
+ wrapper.find('[data-testid="design-collection-is-copying"');
const findDeleteButton = () => wrapper.find(DeleteButton);
const findDropzone = () => wrapper.findAll(DesignDropzone).at(0);
const dropzoneClasses = () => findDropzone().classes();
@@ -99,6 +101,7 @@ describe('Design management index page', () => {
const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1);
const findDesignsWrapper = () => wrapper.find('[data-testid="designs-root"]');
const findDesigns = () => wrapper.findAll(Design);
+ const draggableAttributes = () => wrapper.find(VueDraggable).vm.$attrs;
async function moveDesigns(localWrapper) {
await jest.runOnlyPendingTimers();
@@ -115,8 +118,8 @@ describe('Design management index page', () => {
function createComponent({
loading = false,
- designs = [],
allVersions = [],
+ designCollection = { designs: mockDesigns, copyState: 'READY' },
createDesign = true,
stubs = {},
mockMutate = jest.fn().mockResolvedValue(),
@@ -124,7 +127,7 @@ describe('Design management index page', () => {
mutate = mockMutate;
const $apollo = {
queries: {
- designs: {
+ designCollection: {
loading,
},
permissions: {
@@ -137,8 +140,8 @@ describe('Design management index page', () => {
wrapper = shallowMount(Index, {
data() {
return {
- designs,
allVersions,
+ designCollection,
permissions: {
createDesign,
},
@@ -200,13 +203,13 @@ describe('Design management index page', () => {
});
it('renders a toolbar with buttons when there are designs', () => {
- createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
+ createComponent({ allVersions: [mockVersion] });
expect(findToolbar().exists()).toBe(true);
});
it('renders designs list and header with upload button', () => {
- createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
+ createComponent({ allVersions: [mockVersion] });
expect(wrapper.element).toMatchSnapshot();
});
@@ -236,7 +239,7 @@ describe('Design management index page', () => {
describe('when has no designs', () => {
beforeEach(() => {
- createComponent();
+ createComponent({ designCollection: { designs: [], copyState: 'READY' } });
});
it('renders design dropzone', () =>
@@ -259,6 +262,21 @@ describe('Design management index page', () => {
}));
});
+ describe('handling design collection copy state', () => {
+ it.each`
+ copyState | isRendered | description
+ ${'IN_PROGRESS'} | ${true} | ${'renders'}
+ ${'READY'} | ${false} | ${'does not render'}
+ ${'ERROR'} | ${false} | ${'does not render'}
+ `(
+ '$description the copying message if design collection copyState is $copyState',
+ ({ copyState, isRendered }) => {
+ createComponent({ designCollection: { designs: [], copyState } });
+ expect(findDesignCollectionIsCopying().exists()).toBe(isRendered);
+ },
+ );
+ });
+
describe('uploading designs', () => {
it('calls mutation on upload', () => {
createComponent({ stubs: { GlEmptyState } });
@@ -282,6 +300,10 @@ describe('Design management index page', () => {
{
__typename: 'Design',
id: expect.anything(),
+ currentUserTodos: {
+ __typename: 'TodoConnection',
+ nodes: [],
+ },
image: '',
imageV432x230: '',
filename: 'test',
@@ -531,13 +553,16 @@ describe('Design management index page', () => {
});
it('on latest version when has no designs toolbar buttons are invisible', () => {
- createComponent({ designs: [], allVersions: [mockVersion] });
+ createComponent({
+ designCollection: { designs: [], copyState: 'READY' },
+ allVersions: [mockVersion],
+ });
expect(findToolbar().isVisible()).toBe(false);
});
describe('on non-latest version', () => {
beforeEach(() => {
- createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
+ createComponent({ allVersions: [mockVersion] });
});
it('does not render design checkboxes', async () => {
@@ -628,7 +653,6 @@ describe('Design management index page', () => {
it('ensures fullscreen layout is not applied', () => {
createComponent(true);
- wrapper.vm.$router.push('/');
expect(mockPageEl.classList.remove).toHaveBeenCalledTimes(1);
expect(mockPageEl.classList.remove).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
});
@@ -676,6 +700,20 @@ describe('Design management index page', () => {
).toBe('2');
});
+ it('prevents reordering when reorderDesigns mutation is in progress', async () => {
+ createComponentWithApollo({});
+
+ await moveDesigns(wrapper);
+
+ expect(draggableAttributes().disabled).toBe(true);
+
+ await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises)
+ await wrapper.vm.$nextTick(); // kick off the DOM update
+ await wrapper.vm.$nextTick(); // kick off the DOM update for finally block
+
+ expect(draggableAttributes().disabled).toBe(false);
+ });
+
it('displays flash if mutation had a recoverable error', async () => {
createComponentWithApollo({
moveHandler: jest.fn().mockResolvedValue(moveDesignMutationResponseWithErrors),
diff --git a/spec/frontend/design_management/router_spec.js b/spec/frontend/design_management/router_spec.js
index d4cb9f75a77..fac4f7d368d 100644
--- a/spec/frontend/design_management/router_spec.js
+++ b/spec/frontend/design_management/router_spec.js
@@ -25,7 +25,7 @@ function factory(routeArg) {
mocks: {
$apollo: {
queries: {
- designs: { loading: true },
+ designCollection: { loading: true },
design: { loading: true },
permissions: { loading: true },
},
diff --git a/spec/frontend/design_management/utils/design_management_utils_spec.js b/spec/frontend/design_management/utils/design_management_utils_spec.js
index 7e857d08d25..232cfa2f4ca 100644
--- a/spec/frontend/design_management/utils/design_management_utils_spec.js
+++ b/spec/frontend/design_management/utils/design_management_utils_spec.js
@@ -93,6 +93,10 @@ describe('optimistic responses', () => {
fullPath: '',
notesCount: 0,
event: 'NONE',
+ currentUserTodos: {
+ __typename: 'TodoConnection',
+ nodes: [],
+ },
diffRefs: { __typename: 'DiffRefs', baseSha: '', startSha: '', headSha: '' },
discussions: { __typename: 'DesignDiscussion', nodes: [] },
versions: {
diff --git a/spec/frontend/diff_comments_store_spec.js b/spec/frontend/diff_comments_store_spec.js
deleted file mode 100644
index 6f25c9dd3bc..00000000000
--- a/spec/frontend/diff_comments_store_spec.js
+++ /dev/null
@@ -1,136 +0,0 @@
-/* global CommentsStore */
-
-import '~/diff_notes/models/discussion';
-import '~/diff_notes/models/note';
-import '~/diff_notes/stores/comments';
-
-function createDiscussion(noteId = 1, resolved = true) {
- CommentsStore.create({
- discussionId: 'a',
- noteId,
- canResolve: true,
- resolved,
- resolvedBy: 'test',
- authorName: 'test',
- authorAvatar: 'test',
- noteTruncated: 'test...',
- });
-}
-
-beforeEach(() => {
- CommentsStore.state = {};
-});
-
-describe('New discussion', () => {
- it('creates new discussion', () => {
- expect(Object.keys(CommentsStore.state).length).toBe(0);
- createDiscussion();
-
- expect(Object.keys(CommentsStore.state).length).toBe(1);
- });
-
- it('creates new note in discussion', () => {
- createDiscussion();
- createDiscussion(2);
-
- const discussion = CommentsStore.state.a;
-
- expect(Object.keys(discussion.notes).length).toBe(2);
- });
-});
-
-describe('Get note', () => {
- beforeEach(() => {
- createDiscussion();
- });
-
- it('gets note by ID', () => {
- const note = CommentsStore.get('a', 1);
-
- expect(note).toBeDefined();
- expect(note.id).toBe(1);
- });
-});
-
-describe('Delete discussion', () => {
- beforeEach(() => {
- createDiscussion();
- });
-
- it('deletes discussion by ID', () => {
- CommentsStore.delete('a', 1);
-
- expect(Object.keys(CommentsStore.state).length).toBe(0);
- });
-
- it('deletes discussion when no more notes', () => {
- createDiscussion();
- createDiscussion(2);
-
- expect(Object.keys(CommentsStore.state).length).toBe(1);
- expect(Object.keys(CommentsStore.state.a.notes).length).toBe(2);
-
- CommentsStore.delete('a', 1);
- CommentsStore.delete('a', 2);
-
- expect(Object.keys(CommentsStore.state).length).toBe(0);
- });
-});
-
-describe('Update note', () => {
- beforeEach(() => {
- createDiscussion();
- });
-
- it('updates note to be unresolved', () => {
- CommentsStore.update('a', 1, false, 'test');
-
- const note = CommentsStore.get('a', 1);
-
- expect(note.resolved).toBe(false);
- });
-});
-
-describe('Discussion resolved', () => {
- beforeEach(() => {
- createDiscussion();
- });
-
- it('is resolved with single note', () => {
- const discussion = CommentsStore.state.a;
-
- expect(discussion.isResolved()).toBe(true);
- });
-
- it('is unresolved with 2 notes', () => {
- const discussion = CommentsStore.state.a;
- createDiscussion(2, false);
-
- expect(discussion.isResolved()).toBe(false);
- });
-
- it('is resolved with 2 notes', () => {
- const discussion = CommentsStore.state.a;
- createDiscussion(2);
-
- expect(discussion.isResolved()).toBe(true);
- });
-
- it('resolve all notes', () => {
- const discussion = CommentsStore.state.a;
- createDiscussion(2, false);
-
- discussion.resolveAllNotes();
-
- expect(discussion.isResolved()).toBe(true);
- });
-
- it('unresolve all notes', () => {
- const discussion = CommentsStore.state.a;
- createDiscussion(2);
-
- discussion.unResolveAllNotes();
-
- expect(discussion.isResolved()).toBe(false);
- });
-});
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index cd3a6aa0e28..86560470ada 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -699,7 +699,7 @@ describe('diffs/components/app', () => {
describe('collapsed files', () => {
it('should render the collapsed files warning if there are any collapsed files', () => {
createComponent({}, ({ state }) => {
- state.diffs.diffFiles = [{ viewer: { collapsed: true } }];
+ state.diffs.diffFiles = [{ viewer: { automaticallyCollapsed: true } }];
});
expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true);
@@ -707,7 +707,7 @@ describe('diffs/components/app', () => {
it('should not render the collapsed files warning if the user has dismissed the alert already', async () => {
createComponent({}, ({ state }) => {
- state.diffs.diffFiles = [{ viewer: { collapsed: true } }];
+ state.diffs.diffFiles = [{ viewer: { automaticallyCollapsed: true } }];
});
expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true);
diff --git a/spec/frontend/diffs/components/collapsed_files_warning_spec.js b/spec/frontend/diffs/components/collapsed_files_warning_spec.js
index 670eab5472f..7bbffb7a1cd 100644
--- a/spec/frontend/diffs/components/collapsed_files_warning_spec.js
+++ b/spec/frontend/diffs/components/collapsed_files_warning_spec.js
@@ -50,7 +50,7 @@ describe('CollapsedFilesWarning', () => {
({ limited, containerClasses }) => {
createComponent({ limited });
- expect(wrapper.classes()).toEqual(containerClasses);
+ expect(wrapper.classes()).toEqual(['col-12'].concat(containerClasses));
},
);
diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js
index c48445790f7..9e4fcddd1b4 100644
--- a/spec/frontend/diffs/components/commit_item_spec.js
+++ b/spec/frontend/diffs/components/commit_item_spec.js
@@ -25,7 +25,7 @@ describe('diffs/components/commit_item', () => {
const getTitleElement = () => wrapper.find('.commit-row-message.item-title');
const getDescElement = () => wrapper.find('pre.commit-row-description');
const getDescExpandElement = () => wrapper.find('.commit-content .js-toggle-button');
- const getShaElement = () => wrapper.find('.commit-sha-group');
+ const getShaElement = () => wrapper.find('[data-testid="commit-sha-group"]');
const getAvatarElement = () => wrapper.find('.user-avatar-link');
const getCommitterElement = () => wrapper.find('.committer');
const getCommitActionsElement = () => wrapper.find('.commit-actions');
@@ -84,8 +84,8 @@ describe('diffs/components/commit_item', () => {
it('renders commit sha', () => {
const shaElement = getShaElement();
- const labelElement = shaElement.find('.label');
- const buttonElement = shaElement.find('button');
+ const labelElement = shaElement.find('[data-testid="commit-sha-group"] button');
+ const buttonElement = shaElement.find('button.input-group-text');
expect(labelElement.text()).toEqual(commit.short_id);
expect(buttonElement.props('text')).toBe(commit.id);
diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js
index a0cad32b9fb..3a236228c40 100644
--- a/spec/frontend/diffs/components/diff_file_header_spec.js
+++ b/spec/frontend/diffs/components/diff_file_header_spec.js
@@ -1,9 +1,7 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
-import { GlIcon } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
-import EditButton from '~/diffs/components/edit_button.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import diffDiscussionsMockData from '../mock_data/diff_discussions';
import { truncateSha } from '~/lib/utils/text_utility';
@@ -76,15 +74,7 @@ describe('DiffFileHeader component', () => {
const findReplacedFileButton = () => wrapper.find({ ref: 'replacedFileButton' });
const findViewFileButton = () => wrapper.find({ ref: 'viewButton' });
const findCollapseIcon = () => wrapper.find({ ref: 'collapseIcon' });
-
- const findIconByName = iconName => {
- const icons = wrapper.findAll(GlIcon).filter(w => w.props('name') === iconName);
- if (icons.length === 0) return icons;
- if (icons.length > 1) {
- throw new Error(`Multiple icons found for ${iconName}`);
- }
- return icons.at(0);
- };
+ const findEditButton = () => wrapper.find({ ref: 'editButton' });
const createComponent = props => {
mockStoreConfig = cloneDeep(defaultMockStoreConfig);
@@ -203,16 +193,6 @@ describe('DiffFileHeader component', () => {
describe('for any file', () => {
const otherModes = Object.keys(diffViewerModes).filter(m => m !== 'mode_changed');
- it('when edit button emits showForkMessage event it is re-emitted', () => {
- createComponent({
- addMergeRequestButtons: true,
- });
- wrapper.find(EditButton).vm.$emit('showForkMessage');
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted().showForkMessage).toBeDefined();
- });
- });
-
it('for mode_changed file mode displays mode changes', () => {
createComponent({
diffFile: {
@@ -271,16 +251,16 @@ describe('DiffFileHeader component', () => {
});
it('should not render edit button', () => {
createComponent({ addMergeRequestButtons: false });
- expect(wrapper.find(EditButton).exists()).toBe(false);
+ expect(findEditButton().exists()).toBe(false);
});
});
describe('when addMergeRequestButtons is true', () => {
describe('without discussions', () => {
- it('renders a disabled toggle discussions button', () => {
+ it('does not render a toggle discussions button', () => {
diffHasDiscussionsResultMock.mockReturnValue(false);
createComponent({ addMergeRequestButtons: true });
- expect(findToggleDiscussionsButton().attributes('disabled')).toBe('true');
+ expect(findToggleDiscussionsButton().exists()).toBe(false);
});
});
@@ -288,7 +268,7 @@ describe('DiffFileHeader component', () => {
it('dispatches toggleFileDiscussionWrappers when user clicks on toggle discussions button', () => {
diffHasDiscussionsResultMock.mockReturnValue(true);
createComponent({ addMergeRequestButtons: true });
- expect(findToggleDiscussionsButton().attributes('disabled')).toBeFalsy();
+ expect(findToggleDiscussionsButton().exists()).toBe(true);
findToggleDiscussionsButton().vm.$emit('click');
expect(
mockStoreConfig.modules.diffs.actions.toggleFileDiscussionWrappers,
@@ -300,7 +280,7 @@ describe('DiffFileHeader component', () => {
createComponent({
addMergeRequestButtons: true,
});
- expect(wrapper.find(EditButton).exists()).toBe(true);
+ expect(findEditButton().exists()).toBe(true);
});
describe('view on environment button', () => {
@@ -334,7 +314,7 @@ describe('DiffFileHeader component', () => {
});
it('should not render edit button', () => {
- expect(wrapper.find(EditButton).exists()).toBe(false);
+ expect(findEditButton().exists()).toBe(false);
});
});
describe('with file blob', () => {
@@ -345,7 +325,7 @@ describe('DiffFileHeader component', () => {
addMergeRequestButtons: true,
});
expect(findViewFileButton().attributes('href')).toBe(viewPath);
- expect(findViewFileButton().attributes('title')).toEqual(
+ expect(findViewFileButton().text()).toEqual(
`View file @ ${diffFile.content_sha.substr(0, 8)}`,
);
});
@@ -375,21 +355,6 @@ describe('DiffFileHeader component', () => {
addMergeRequestButtons: true,
};
- it.each`
- iconName | isShowingFullFile
- ${'doc-expand'} | ${false}
- ${'doc-changes'} | ${true}
- `(
- 'shows $iconName when isShowingFullFile set to $isShowingFullFile',
- ({ iconName, isShowingFullFile }) => {
- createComponent({
- ...fullyNotExpandedFileProps,
- diffFile: { ...fullyNotExpandedFileProps.diffFile, isShowingFullFile },
- });
- expect(findIconByName(iconName).exists()).toBe(true);
- },
- );
-
it('renders expand to full file button if not showing full file already', () => {
createComponent(fullyNotExpandedFileProps);
expect(findExpandButton().exists()).toBe(true);
@@ -455,7 +420,7 @@ describe('DiffFileHeader component', () => {
it('does not show edit button', () => {
createComponent({ diffFile: { ...diffFile, deleted_file: true } });
- expect(wrapper.find(EditButton).exists()).toBe(false);
+ expect(findEditButton().exists()).toBe(false);
});
});
diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js
index 79f0f6bc327..a6f0d2bf11d 100644
--- a/spec/frontend/diffs/components/diff_file_spec.js
+++ b/spec/frontend/diffs/components/diff_file_spec.js
@@ -37,7 +37,7 @@ describe('DiffFile', () => {
expect(el.querySelectorAll('.diff-content.hidden').length).toEqual(0);
expect(el.querySelector('.js-file-title')).toBeDefined();
- expect(el.querySelector('.btn-clipboard')).toBeDefined();
+ expect(el.querySelector('[data-testid="diff-file-copy-clipboard"]')).toBeDefined();
expect(el.querySelector('.file-title-name').innerText.indexOf(file_path)).toBeGreaterThan(-1);
expect(el.querySelector('.js-syntax-highlight')).toBeDefined();
@@ -47,7 +47,7 @@ describe('DiffFile', () => {
.then(() => {
expect(el.querySelectorAll('.line_content').length).toBe(8);
expect(el.querySelectorAll('.js-line-expansion-content').length).toBe(1);
- triggerEvent('.btn-clipboard');
+ triggerEvent('[data-testid="diff-file-copy-clipboard"]');
})
.then(done)
.catch(done.fail);
@@ -56,11 +56,11 @@ describe('DiffFile', () => {
it('should track a click event on copy to clip board button', done => {
const el = vm.$el;
- expect(el.querySelector('.btn-clipboard')).toBeDefined();
+ expect(el.querySelector('[data-testid="diff-file-copy-clipboard"]')).toBeDefined();
vm.file.renderIt = true;
vm.$nextTick()
.then(() => {
- triggerEvent('.btn-clipboard');
+ triggerEvent('[data-testid="diff-file-copy-clipboard"]');
expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_copy_file_button', {
label: 'diff_copy_file_path_button',
@@ -90,8 +90,8 @@ describe('DiffFile', () => {
vm.isCollapsed = true;
vm.$nextTick(() => {
- expect(vm.$el.innerText).toContain('This file is collapsed.');
- expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy();
+ expect(vm.$el.innerText).toContain('This diff is collapsed');
+ expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
done();
});
@@ -102,8 +102,8 @@ describe('DiffFile', () => {
vm.isCollapsed = true;
vm.$nextTick(() => {
- expect(vm.$el.innerText).toContain('This file is collapsed.');
- expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy();
+ expect(vm.$el.innerText).toContain('This diff is collapsed');
+ expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
done();
});
@@ -121,8 +121,8 @@ describe('DiffFile', () => {
vm.isCollapsed = true;
vm.$nextTick(() => {
- expect(vm.$el.innerText).toContain('This file is collapsed.');
- expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy();
+ expect(vm.$el.innerText).toContain('This diff is collapsed');
+ expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
done();
});
@@ -135,7 +135,7 @@ describe('DiffFile', () => {
vm.file.viewer.name = diffViewerModes.renamed;
vm.$nextTick(() => {
- expect(vm.$el.innerText).not.toContain('This file is collapsed.');
+ expect(vm.$el.innerText).not.toContain('This diff is collapsed');
done();
});
@@ -148,7 +148,7 @@ describe('DiffFile', () => {
vm.file.viewer.name = diffViewerModes.mode_changed;
vm.$nextTick(() => {
- expect(vm.$el.innerText).not.toContain('This file is collapsed.');
+ expect(vm.$el.innerText).not.toContain('This diff is collapsed');
done();
});
@@ -181,7 +181,7 @@ describe('DiffFile', () => {
});
it('updates local state when changing file state', done => {
- vm.file.viewer.collapsed = true;
+ vm.file.viewer.automaticallyCollapsed = true;
vm.$nextTick(() => {
expect(vm.isCollapsed).toBe(true);
diff --git a/spec/frontend/diffs/components/diff_row_utils_spec.js b/spec/frontend/diffs/components/diff_row_utils_spec.js
new file mode 100644
index 00000000000..394b6cb1914
--- /dev/null
+++ b/spec/frontend/diffs/components/diff_row_utils_spec.js
@@ -0,0 +1,203 @@
+import * as utils from '~/diffs/components/diff_row_utils';
+import {
+ MATCH_LINE_TYPE,
+ CONTEXT_LINE_TYPE,
+ OLD_NO_NEW_LINE_TYPE,
+ NEW_NO_NEW_LINE_TYPE,
+ EMPTY_CELL_TYPE,
+} from '~/diffs/constants';
+
+const LINE_CODE = 'abc123';
+
+describe('isHighlighted', () => {
+ it('should return true if line is highlighted', () => {
+ const state = { diffs: { highlightedRow: LINE_CODE } };
+ const line = { line_code: LINE_CODE };
+ const isCommented = false;
+ expect(utils.isHighlighted(state, line, isCommented)).toBe(true);
+ });
+
+ it('should return false if line is not highlighted', () => {
+ const state = { diffs: { highlightedRow: 'xxx' } };
+ const line = { line_code: LINE_CODE };
+ const isCommented = false;
+ expect(utils.isHighlighted(state, line, isCommented)).toBe(false);
+ });
+
+ it('should return true if isCommented is true', () => {
+ const state = { diffs: { highlightedRow: 'xxx' } };
+ const line = { line_code: LINE_CODE };
+ const isCommented = true;
+ expect(utils.isHighlighted(state, line, isCommented)).toBe(true);
+ });
+});
+
+describe('isContextLine', () => {
+ it('return true if line type is context', () => {
+ expect(utils.isContextLine(CONTEXT_LINE_TYPE)).toBe(true);
+ });
+
+ it('return false if line type is not context', () => {
+ expect(utils.isContextLine('xxx')).toBe(false);
+ });
+});
+
+describe('isMatchLine', () => {
+ it('return true if line type is match', () => {
+ expect(utils.isMatchLine(MATCH_LINE_TYPE)).toBe(true);
+ });
+
+ it('return false if line type is not match', () => {
+ expect(utils.isMatchLine('xxx')).toBe(false);
+ });
+});
+
+describe('isMetaLine', () => {
+ it.each`
+ type | expectation
+ ${OLD_NO_NEW_LINE_TYPE} | ${true}
+ ${NEW_NO_NEW_LINE_TYPE} | ${true}
+ ${EMPTY_CELL_TYPE} | ${true}
+ ${'xxx'} | ${false}
+ `('should return $expectation if type is $type', ({ type, expectation }) => {
+ expect(utils.isMetaLine(type)).toBe(expectation);
+ });
+});
+
+describe('shouldRenderCommentButton', () => {
+ it('should return false if comment button is not rendered', () => {
+ expect(utils.shouldRenderCommentButton(true, false)).toBe(false);
+ });
+
+ it('should return false if not logged in', () => {
+ expect(utils.shouldRenderCommentButton(false, true)).toBe(false);
+ });
+
+ it('should return true logged in and rendered', () => {
+ expect(utils.shouldRenderCommentButton(true, true)).toBe(true);
+ });
+});
+
+describe('hasDiscussions', () => {
+ it('should return false if line is undefined', () => {
+ expect(utils.hasDiscussions()).toBe(false);
+ });
+
+ it('should return false if discussions is undefined', () => {
+ expect(utils.hasDiscussions({})).toBe(false);
+ });
+
+ it('should return false if discussions has legnth of 0', () => {
+ expect(utils.hasDiscussions({ discussions: [] })).toBe(false);
+ });
+
+ it('should return true if discussions has legnth > 0', () => {
+ expect(utils.hasDiscussions({ discussions: [1] })).toBe(true);
+ });
+});
+
+describe('lineHref', () => {
+ it(`should return #${LINE_CODE}`, () => {
+ expect(utils.lineHref({ line_code: LINE_CODE })).toEqual(`#${LINE_CODE}`);
+ });
+
+ it(`should return '#' if line is undefined`, () => {
+ expect(utils.lineHref()).toEqual('#');
+ });
+
+ it(`should return '#' if line_code is undefined`, () => {
+ expect(utils.lineHref({})).toEqual('#');
+ });
+});
+
+describe('lineCode', () => {
+ it(`should return undefined if line_code is undefined`, () => {
+ expect(utils.lineCode()).toEqual(undefined);
+ expect(utils.lineCode({ left: {} })).toEqual(undefined);
+ expect(utils.lineCode({ right: {} })).toEqual(undefined);
+ });
+
+ it(`should return ${LINE_CODE}`, () => {
+ expect(utils.lineCode({ line_code: LINE_CODE })).toEqual(LINE_CODE);
+ expect(utils.lineCode({ left: { line_code: LINE_CODE } })).toEqual(LINE_CODE);
+ expect(utils.lineCode({ right: { line_code: LINE_CODE } })).toEqual(LINE_CODE);
+ });
+});
+
+describe('classNameMapCell', () => {
+ it.each`
+ line | hll | loggedIn | hovered | expectation
+ ${undefined} | ${true} | ${true} | ${true} | ${[]}
+ ${{ type: 'new' }} | ${false} | ${false} | ${false} | ${['new', { hll: false, 'is-over': false }]}
+ ${{ type: 'new' }} | ${true} | ${true} | ${false} | ${['new', { hll: true, 'is-over': false }]}
+ ${{ type: 'new' }} | ${true} | ${false} | ${true} | ${['new', { hll: true, 'is-over': false }]}
+ ${{ type: 'new' }} | ${true} | ${true} | ${true} | ${['new', { hll: true, 'is-over': true }]}
+ `('should return $expectation', ({ line, hll, loggedIn, hovered, expectation }) => {
+ const classes = utils.classNameMapCell(line, hll, loggedIn, hovered);
+ expect(classes).toEqual(expectation);
+ });
+});
+
+describe('addCommentTooltip', () => {
+ const brokenSymLinkTooltip =
+ 'Commenting on symbolic links that replace or are replaced by files is currently not supported.';
+ const brokenRealTooltip =
+ 'Commenting on files that replace or are replaced by symbolic links is currently not supported.';
+ it('should return default tooltip', () => {
+ expect(utils.addCommentTooltip()).toBeUndefined();
+ });
+
+ it('should return broken symlink tooltip', () => {
+ expect(utils.addCommentTooltip({ commentsDisabled: { wasSymbolic: true } })).toEqual(
+ brokenSymLinkTooltip,
+ );
+ expect(utils.addCommentTooltip({ commentsDisabled: { isSymbolic: true } })).toEqual(
+ brokenSymLinkTooltip,
+ );
+ });
+
+ it('should return broken real tooltip', () => {
+ expect(utils.addCommentTooltip({ commentsDisabled: { wasReal: true } })).toEqual(
+ brokenRealTooltip,
+ );
+ expect(utils.addCommentTooltip({ commentsDisabled: { isReal: true } })).toEqual(
+ brokenRealTooltip,
+ );
+ });
+});
+
+describe('parallelViewLeftLineType', () => {
+ it(`should return ${OLD_NO_NEW_LINE_TYPE}`, () => {
+ expect(utils.parallelViewLeftLineType({ right: { type: NEW_NO_NEW_LINE_TYPE } })).toEqual(
+ OLD_NO_NEW_LINE_TYPE,
+ );
+ });
+
+ it(`should return 'new'`, () => {
+ expect(utils.parallelViewLeftLineType({ left: { type: 'new' } })).toContain('new');
+ });
+
+ it(`should return ${EMPTY_CELL_TYPE}`, () => {
+ expect(utils.parallelViewLeftLineType({})).toContain(EMPTY_CELL_TYPE);
+ });
+
+ it(`should return hll:true`, () => {
+ expect(utils.parallelViewLeftLineType({}, true)[1]).toEqual({ hll: true });
+ });
+});
+
+describe('shouldShowCommentButton', () => {
+ it.each`
+ hover | context | meta | discussions | expectation
+ ${true} | ${false} | ${false} | ${false} | ${true}
+ ${false} | ${false} | ${false} | ${false} | ${false}
+ ${true} | ${true} | ${false} | ${false} | ${false}
+ ${true} | ${true} | ${true} | ${false} | ${false}
+ ${true} | ${true} | ${true} | ${true} | ${false}
+ `(
+ 'should return $expectation when hover is $hover',
+ ({ hover, context, meta, discussions, expectation }) => {
+ expect(utils.shouldShowCommentButton(hover, context, meta, discussions)).toBe(expectation);
+ },
+ );
+});
diff --git a/spec/frontend/diffs/components/diff_table_cell_spec.js b/spec/frontend/diffs/components/diff_table_cell_spec.js
deleted file mode 100644
index 02f5c27eecb..00000000000
--- a/spec/frontend/diffs/components/diff_table_cell_spec.js
+++ /dev/null
@@ -1,279 +0,0 @@
-import { createLocalVue, shallowMount } from '@vue/test-utils';
-import Vuex from 'vuex';
-import { TEST_HOST } from 'helpers/test_constants';
-import DiffTableCell from '~/diffs/components/diff_table_cell.vue';
-import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue';
-import { LINE_POSITION_RIGHT } from '~/diffs/constants';
-import { createStore } from '~/mr_notes/stores';
-import discussionsMockData from '../mock_data/diff_discussions';
-import diffFileMockData from '../mock_data/diff_file';
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-const TEST_USER_ID = 'abc123';
-const TEST_USER = { id: TEST_USER_ID };
-const TEST_LINE_NUMBER = 1;
-const TEST_LINE_CODE = 'LC_42';
-const TEST_FILE_HASH = diffFileMockData.file_hash;
-
-describe('DiffTableCell', () => {
- const symlinkishFileTooltip =
- 'Commenting on symbolic links that replace or are replaced by files is currently not supported.';
- const realishFileTooltip =
- 'Commenting on files that replace or are replaced by symbolic links is currently not supported.';
- const otherFileTooltip = 'Add a comment to this line';
-
- let wrapper;
- let line;
- let store;
-
- beforeEach(() => {
- store = createStore();
- store.state.notes.userData = TEST_USER;
-
- line = {
- line_code: TEST_LINE_CODE,
- type: 'new',
- old_line: null,
- new_line: 1,
- discussions: [{ ...discussionsMockData }],
- discussionsExpanded: true,
- text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
- rich_text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
- meta_data: null,
- };
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- const setWindowLocation = value => {
- Object.defineProperty(window, 'location', {
- writable: true,
- value,
- });
- };
-
- const createComponent = (props = {}) => {
- wrapper = shallowMount(DiffTableCell, {
- localVue,
- store,
- propsData: {
- line,
- fileHash: TEST_FILE_HASH,
- contextLinesPath: '/context/lines/path',
- isHighlighted: false,
- ...props,
- },
- });
- };
-
- const findTd = () => wrapper.find({ ref: 'td' });
- const findNoteButton = () => wrapper.find({ ref: 'addDiffNoteButton' });
- const findLineNumber = () => wrapper.find({ ref: 'lineNumberRef' });
- const findTooltip = () => wrapper.find({ ref: 'addNoteTooltip' });
- const findAvatars = () => wrapper.find(DiffGutterAvatars);
-
- describe('td', () => {
- it('highlights when isHighlighted true', () => {
- createComponent({ isHighlighted: true });
-
- expect(findTd().classes()).toContain('hll');
- });
-
- it('does not highlight when isHighlighted false', () => {
- createComponent({ isHighlighted: false });
-
- expect(findTd().classes()).not.toContain('hll');
- });
- });
-
- describe('comment button', () => {
- it.each`
- showCommentButton | userData | query | mergeRefHeadComments | expectation
- ${true} | ${TEST_USER} | ${'diff_head=false'} | ${false} | ${true}
- ${true} | ${TEST_USER} | ${'diff_head=true'} | ${true} | ${true}
- ${true} | ${TEST_USER} | ${'diff_head=true'} | ${false} | ${false}
- ${false} | ${TEST_USER} | ${'diff_head=true'} | ${true} | ${false}
- ${false} | ${TEST_USER} | ${'bogus'} | ${true} | ${false}
- ${true} | ${null} | ${''} | ${true} | ${false}
- `(
- 'exists is $expectation - with showCommentButton ($showCommentButton) userData ($userData) query ($query)',
- ({ showCommentButton, userData, query, mergeRefHeadComments, expectation }) => {
- store.state.notes.userData = userData;
- gon.features = { mergeRefHeadComments };
- setWindowLocation({ href: `${TEST_HOST}?${query}` });
- createComponent({ showCommentButton });
-
- wrapper.setData({ isCommentButtonRendered: showCommentButton });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findNoteButton().exists()).toBe(expectation);
- });
- },
- );
-
- it.each`
- isHover | otherProps | discussions | expectation
- ${true} | ${{}} | ${[]} | ${true}
- ${false} | ${{}} | ${[]} | ${false}
- ${true} | ${{ line: { ...line, type: 'context' } }} | ${[]} | ${false}
- ${true} | ${{ line: { ...line, type: 'old-nonewline' } }} | ${[]} | ${false}
- ${true} | ${{}} | ${[{}]} | ${false}
- `(
- 'visible is $expectation - with isHover ($isHover), discussions ($discussions), otherProps ($otherProps)',
- ({ isHover, otherProps, discussions, expectation }) => {
- line.discussions = discussions;
- createComponent({
- showCommentButton: true,
- isHover,
- ...otherProps,
- });
-
- wrapper.setData({
- isCommentButtonRendered: true,
- });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findNoteButton().isVisible()).toBe(expectation);
- });
- },
- );
-
- it.each`
- disabled | commentsDisabled
- ${'disabled'} | ${true}
- ${undefined} | ${false}
- `(
- 'has attribute disabled=$disabled when the outer component has prop commentsDisabled=$commentsDisabled',
- ({ disabled, commentsDisabled }) => {
- line.commentsDisabled = commentsDisabled;
-
- createComponent({
- showCommentButton: true,
- isHover: true,
- });
-
- wrapper.setData({ isCommentButtonRendered: true });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findNoteButton().attributes('disabled')).toBe(disabled);
- });
- },
- );
-
- it.each`
- tooltip | commentsDisabled
- ${symlinkishFileTooltip} | ${{ wasSymbolic: true }}
- ${symlinkishFileTooltip} | ${{ isSymbolic: true }}
- ${realishFileTooltip} | ${{ wasReal: true }}
- ${realishFileTooltip} | ${{ isReal: true }}
- ${otherFileTooltip} | ${false}
- `(
- 'has the correct tooltip when commentsDisabled=$commentsDisabled',
- ({ tooltip, commentsDisabled }) => {
- line.commentsDisabled = commentsDisabled;
-
- createComponent({
- showCommentButton: true,
- isHover: true,
- });
-
- wrapper.setData({ isCommentButtonRendered: true });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(findTooltip().attributes('title')).toBe(tooltip);
- });
- },
- );
- });
-
- describe('line number', () => {
- describe('without lineNumber prop', () => {
- it('does not render', () => {
- createComponent({ lineType: 'old' });
-
- expect(findLineNumber().exists()).toBe(false);
- });
- });
-
- describe('with lineNumber prop', () => {
- describe.each`
- lineProps | expectedHref | expectedClickArg
- ${{ line_code: TEST_LINE_CODE }} | ${`#${TEST_LINE_CODE}`} | ${TEST_LINE_CODE}
- ${{ line_code: undefined }} | ${'#'} | ${undefined}
- ${{ line_code: undefined, left: { line_code: TEST_LINE_CODE } }} | ${'#'} | ${TEST_LINE_CODE}
- ${{ line_code: undefined, right: { line_code: TEST_LINE_CODE } }} | ${'#'} | ${TEST_LINE_CODE}
- `('with line ($lineProps)', ({ lineProps, expectedHref, expectedClickArg }) => {
- beforeEach(() => {
- jest.spyOn(store, 'dispatch').mockImplementation();
- Object.assign(line, lineProps);
- createComponent({ lineNumber: TEST_LINE_NUMBER });
- });
-
- it('renders', () => {
- expect(findLineNumber().exists()).toBe(true);
- expect(findLineNumber().attributes()).toEqual({
- href: expectedHref,
- 'data-linenumber': TEST_LINE_NUMBER.toString(),
- });
- });
-
- it('on click, dispatches setHighlightedRow', () => {
- expect(store.dispatch).not.toHaveBeenCalled();
-
- findLineNumber().trigger('click');
-
- expect(store.dispatch).toHaveBeenCalledWith('diffs/setHighlightedRow', expectedClickArg);
- });
- });
- });
- });
-
- describe('diff-gutter-avatars', () => {
- describe('with showCommentButton', () => {
- beforeEach(() => {
- jest.spyOn(store, 'dispatch').mockImplementation();
-
- createComponent({ showCommentButton: true });
- });
-
- it('renders', () => {
- expect(findAvatars().props()).toEqual({
- discussions: line.discussions,
- discussionsExpanded: line.discussionsExpanded,
- });
- });
-
- it('toggles line discussion', () => {
- expect(store.dispatch).not.toHaveBeenCalled();
-
- findAvatars().vm.$emit('toggleLineDiscussions');
-
- expect(store.dispatch).toHaveBeenCalledWith('diffs/toggleLineDiscussions', {
- lineCode: TEST_LINE_CODE,
- fileHash: TEST_FILE_HASH,
- expanded: !line.discussionsExpanded,
- });
- });
- });
-
- it.each`
- props | lineProps | expectation
- ${{ showCommentButton: true }} | ${{}} | ${true}
- ${{ showCommentButton: false }} | ${{}} | ${false}
- ${{ showCommentButton: true, linePosition: LINE_POSITION_RIGHT }} | ${{ type: null }} | ${false}
- ${{ showCommentButton: true }} | ${{ discussions: [] }} | ${false}
- `(
- 'exists is $expectation - with props ($props), line ($lineProps)',
- ({ props, lineProps, expectation }) => {
- Object.assign(line, lineProps);
- createComponent(props);
-
- expect(findAvatars().exists()).toBe(expectation);
- },
- );
- });
-});
diff --git a/spec/frontend/diffs/components/edit_button_spec.js b/spec/frontend/diffs/components/edit_button_spec.js
deleted file mode 100644
index 71512c1c4af..00000000000
--- a/spec/frontend/diffs/components/edit_button_spec.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedButton } from '@gitlab/ui';
-import EditButton from '~/diffs/components/edit_button.vue';
-
-const editPath = 'test-path';
-
-describe('EditButton', () => {
- let wrapper;
-
- const createComponent = (props = {}) => {
- wrapper = shallowMount(EditButton, {
- propsData: { ...props },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('has correct href attribute', () => {
- createComponent({
- editPath,
- canCurrentUserFork: false,
- });
-
- expect(wrapper.find(GlDeprecatedButton).attributes('href')).toBe(editPath);
- });
-
- it('emits a show fork message event if current user can fork', () => {
- createComponent({
- editPath,
- canCurrentUserFork: true,
- });
- wrapper.find(GlDeprecatedButton).trigger('click');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('showForkMessage')).toBeTruthy();
- });
- });
-
- it('doesnt emit a show fork message event if current user cannot fork', () => {
- createComponent({
- editPath,
- canCurrentUserFork: false,
- });
- wrapper.find(GlDeprecatedButton).trigger('click');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('showForkMessage')).toBeFalsy();
- });
- });
-
- it('doesnt emit a show fork message event if current user can modify blob', () => {
- createComponent({
- editPath,
- canCurrentUserFork: true,
- canModifyBlob: true,
- });
- wrapper.find(GlDeprecatedButton).trigger('click');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('showForkMessage')).toBeFalsy();
- });
- });
-
- it('disables button if editPath is empty', () => {
- createComponent({
- editPath: '',
- canCurrentUserFork: true,
- canModifyBlob: true,
- });
-
- expect(wrapper.find(GlDeprecatedButton).attributes('disabled')).toBe('true');
- });
-});
diff --git a/spec/frontend/diffs/mock_data/diff_file.js b/spec/frontend/diffs/mock_data/diff_file.js
index c2a4424ee95..babaaa21dab 100644
--- a/spec/frontend/diffs/mock_data/diff_file.js
+++ b/spec/frontend/diffs/mock_data/diff_file.js
@@ -27,7 +27,7 @@ export default {
viewer: {
name: 'text',
error: null,
- collapsed: false,
+ automaticallyCollapsed: false,
},
added_lines: 2,
removed_lines: 0,
diff --git a/spec/frontend/diffs/mock_data/diff_file_unreadable.js b/spec/frontend/diffs/mock_data/diff_file_unreadable.js
index 8c2df45988e..fca81faabf6 100644
--- a/spec/frontend/diffs/mock_data/diff_file_unreadable.js
+++ b/spec/frontend/diffs/mock_data/diff_file_unreadable.js
@@ -26,7 +26,7 @@ export default {
viewer: {
name: 'text',
error: null,
- collapsed: false,
+ automaticallyCollapsed: false,
},
added_lines: 0,
removed_lines: 0,
diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js
index 4f647b0cd41..c3e4ee9c531 100644
--- a/spec/frontend/diffs/store/actions_spec.js
+++ b/spec/frontend/diffs/store/actions_spec.js
@@ -483,14 +483,14 @@ describe('DiffsStoreActions', () => {
id: 1,
renderIt: false,
viewer: {
- collapsed: false,
+ automaticallyCollapsed: false,
},
},
{
id: 2,
renderIt: false,
viewer: {
- collapsed: false,
+ automaticallyCollapsed: false,
},
},
],
@@ -967,7 +967,7 @@ describe('DiffsStoreActions', () => {
{
file_hash: 'HASH',
viewer: {
- collapsed,
+ automaticallyCollapsed: collapsed,
},
renderIt,
},
@@ -1167,7 +1167,7 @@ describe('DiffsStoreActions', () => {
file_hash: 'testhash',
alternate_viewer: { name: updatedViewerName },
};
- const updatedViewer = { name: updatedViewerName, collapsed: false };
+ const updatedViewer = { name: updatedViewerName, automaticallyCollapsed: false };
const testData = [{ rich_text: 'test' }, { rich_text: 'file2' }];
let renamedFile;
let mock;
diff --git a/spec/frontend/diffs/store/getters_spec.js b/spec/frontend/diffs/store/getters_spec.js
index dac5be2d656..0083f1d8b44 100644
--- a/spec/frontend/diffs/store/getters_spec.js
+++ b/spec/frontend/diffs/store/getters_spec.js
@@ -51,13 +51,19 @@ describe('Diffs Module Getters', () => {
describe('hasCollapsedFile', () => {
it('returns true when all files are collapsed', () => {
- localState.diffFiles = [{ viewer: { collapsed: true } }, { viewer: { collapsed: true } }];
+ localState.diffFiles = [
+ { viewer: { automaticallyCollapsed: true } },
+ { viewer: { automaticallyCollapsed: true } },
+ ];
expect(getters.hasCollapsedFile(localState)).toEqual(true);
});
it('returns true when at least one file is collapsed', () => {
- localState.diffFiles = [{ viewer: { collapsed: false } }, { viewer: { collapsed: true } }];
+ localState.diffFiles = [
+ { viewer: { automaticallyCollapsed: false } },
+ { viewer: { automaticallyCollapsed: true } },
+ ];
expect(getters.hasCollapsedFile(localState)).toEqual(true);
});
@@ -139,50 +145,74 @@ describe('Diffs Module Getters', () => {
describe('diffHasExpandedDiscussions', () => {
it('returns true when one of the discussions is expanded', () => {
- discussionMock1.expanded = false;
+ const diffFile = {
+ parallel_diff_lines: [],
+ highlighted_diff_lines: [
+ {
+ discussions: [discussionMock, discussionMock],
+ discussionsExpanded: true,
+ },
+ ],
+ };
- expect(
- getters.diffHasExpandedDiscussions(localState, {
- getDiffFileDiscussions: () => [discussionMock, discussionMock],
- })(diffFileMock),
- ).toEqual(true);
+ expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(true);
});
it('returns false when there are no discussions', () => {
- expect(
- getters.diffHasExpandedDiscussions(localState, { getDiffFileDiscussions: () => [] })(
- diffFileMock,
- ),
- ).toEqual(false);
+ const diffFile = {
+ parallel_diff_lines: [],
+ highlighted_diff_lines: [
+ {
+ discussions: [],
+ discussionsExpanded: true,
+ },
+ ],
+ };
+ expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(false);
});
it('returns false when no discussion is expanded', () => {
- discussionMock.expanded = false;
- discussionMock1.expanded = false;
+ const diffFile = {
+ parallel_diff_lines: [],
+ highlighted_diff_lines: [
+ {
+ discussions: [discussionMock, discussionMock],
+ discussionsExpanded: false,
+ },
+ ],
+ };
- expect(
- getters.diffHasExpandedDiscussions(localState, {
- getDiffFileDiscussions: () => [discussionMock, discussionMock1],
- })(diffFileMock),
- ).toEqual(false);
+ expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(false);
});
});
describe('diffHasDiscussions', () => {
it('returns true when getDiffFileDiscussions returns discussions', () => {
- expect(
- getters.diffHasDiscussions(localState, {
- getDiffFileDiscussions: () => [discussionMock],
- })(diffFileMock),
- ).toEqual(true);
+ const diffFile = {
+ parallel_diff_lines: [],
+ highlighted_diff_lines: [
+ {
+ discussions: [discussionMock, discussionMock],
+ discussionsExpanded: false,
+ },
+ ],
+ };
+
+ expect(getters.diffHasDiscussions(localState)(diffFile)).toEqual(true);
});
it('returns false when getDiffFileDiscussions returns no discussions', () => {
- expect(
- getters.diffHasDiscussions(localState, {
- getDiffFileDiscussions: () => [],
- })(diffFileMock),
- ).toEqual(false);
+ const diffFile = {
+ parallel_diff_lines: [],
+ highlighted_diff_lines: [
+ {
+ discussions: [],
+ discussionsExpanded: false,
+ },
+ ],
+ };
+
+ expect(getters.diffHasDiscussions(localState)(diffFile)).toEqual(false);
});
});
diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js
index e1d855ae0cf..a84ad63c695 100644
--- a/spec/frontend/diffs/store/mutations_spec.js
+++ b/spec/frontend/diffs/store/mutations_spec.js
@@ -130,14 +130,14 @@ describe('DiffsStoreMutations', () => {
it('should change the collapsed prop from diffFiles', () => {
const diffFile = {
viewer: {
- collapsed: true,
+ automaticallyCollapsed: true,
},
};
const state = { expandAllFiles: true, diffFiles: [diffFile] };
mutations[types.EXPAND_ALL_FILES](state);
- expect(state.diffFiles[0].viewer.collapsed).toEqual(false);
+ expect(state.diffFiles[0].viewer.automaticallyCollapsed).toEqual(false);
});
});
@@ -933,12 +933,12 @@ describe('DiffsStoreMutations', () => {
describe('SET_FILE_COLLAPSED', () => {
it('sets collapsed', () => {
const state = {
- diffFiles: [{ file_path: 'test', viewer: { collapsed: false } }],
+ diffFiles: [{ file_path: 'test', viewer: { automaticallyCollapsed: false } }],
};
mutations[types.SET_FILE_COLLAPSED](state, { filePath: 'test', collapsed: true });
- expect(state.diffFiles[0].viewer.collapsed).toBe(true);
+ expect(state.diffFiles[0].viewer.automaticallyCollapsed).toBe(true);
});
});
diff --git a/spec/frontend/emoji/emoji_spec.js b/spec/frontend/emoji/emoji_spec.js
index 53c6d0835bc..2f174c45ad7 100644
--- a/spec/frontend/emoji/emoji_spec.js
+++ b/spec/frontend/emoji/emoji_spec.js
@@ -1,7 +1,7 @@
import MockAdapter from 'axios-mock-adapter';
import { trimText } from 'helpers/text_helper';
import axios from '~/lib/utils/axios_utils';
-import { initEmojiMap, glEmojiTag, EMOJI_VERSION } from '~/emoji';
+import { initEmojiMap, glEmojiTag, searchEmoji, EMOJI_VERSION } from '~/emoji';
import isEmojiUnicodeSupported, {
isFlagEmoji,
isRainbowFlagEmoji,
@@ -31,25 +31,35 @@ const emptySupportMap = {
};
const emojiFixtureMap = {
+ atom: {
+ name: 'atom',
+ moji: '⚛',
+ description: 'atom symbol',
+ unicodeVersion: '4.1',
+ },
bomb: {
name: 'bomb',
moji: '💣',
unicodeVersion: '6.0',
+ description: 'bomb',
},
construction_worker_tone5: {
name: 'construction_worker_tone5',
moji: '👷🏿',
unicodeVersion: '8.0',
+ description: 'construction worker tone 5',
},
five: {
name: 'five',
moji: '5️⃣',
unicodeVersion: '3.0',
+ description: 'keycap digit five',
},
grey_question: {
name: 'grey_question',
moji: '❔',
unicodeVersion: '6.0',
+ description: 'white question mark ornament',
},
};
@@ -57,8 +67,15 @@ describe('gl_emoji', () => {
let mock;
beforeEach(() => {
+ const emojiData = Object.fromEntries(
+ Object.values(emojiFixtureMap).map(m => {
+ const { name: n, moji: e, unicodeVersion: u, category: c, description: d } = m;
+ return [n, { c, e, d, u }];
+ }),
+ );
+
mock = new MockAdapter(axios);
- mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200);
+ mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, JSON.stringify(emojiData));
return initEmojiMap().catch(() => {});
});
@@ -378,4 +395,24 @@ describe('gl_emoji', () => {
expect(isSupported).toBeFalsy();
});
});
+
+ describe('searchEmoji', () => {
+ const { atom, grey_question } = emojiFixtureMap;
+ const contains = (e, term) =>
+ expect(searchEmoji(term).map(({ name }) => name)).toContain(e.name);
+
+ it('should match by full name', () => contains(grey_question, 'grey_question'));
+ it('should match by full alias', () => contains(atom, 'atom_symbol'));
+ it('should match by full description', () => contains(grey_question, 'ornament'));
+
+ it('should match by partial name', () => contains(grey_question, 'question'));
+ it('should match by partial alias', () => contains(atom, '_symbol'));
+ it('should match by partial description', () => contains(grey_question, 'ment'));
+
+ it('should fuzzy match by name', () => contains(grey_question, 'greion'));
+ it('should fuzzy match by alias', () => contains(atom, 'atobol'));
+ it('should fuzzy match by description', () => contains(grey_question, 'ornt'));
+
+ it('should match by character', () => contains(grey_question, '❔'));
+ });
});
diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js
index 35ca323f5a9..c958fb7ce03 100644
--- a/spec/frontend/environment.js
+++ b/spec/frontend/environment.js
@@ -1,4 +1,4 @@
-/* eslint-disable import/no-commonjs */
+/* eslint-disable import/no-commonjs, max-classes-per-file */
const path = require('path');
const { ErrorWithStack } = require('jest-util');
@@ -58,6 +58,14 @@ class CustomEnvironment extends JSDOMEnvironment {
measure: () => null,
getEntriesByName: () => [],
});
+
+ this.global.PerformanceObserver = class {
+ /* eslint-disable no-useless-constructor, no-unused-vars, no-empty-function, class-methods-use-this */
+ constructor(callback) {}
+ disconnect() {}
+ observe(element, initObject) {}
+ /* eslint-enable no-useless-constructor, no-unused-vars, no-empty-function, class-methods-use-this */
+ };
}
async teardown() {
diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js
index fe32bf918dd..22b066fae41 100644
--- a/spec/frontend/environments/environments_app_spec.js
+++ b/spec/frontend/environments/environments_app_spec.js
@@ -40,6 +40,9 @@ describe('Environment', () => {
return axios.waitForAll();
};
+ const findEnvironmentsTabAvailable = () => wrapper.find('.js-environments-tab-available > a');
+ const findEnvironmentsTabStopped = () => wrapper.find('.js-environments-tab-stopped > a');
+
beforeEach(() => {
mock = new MockAdapter(axios);
});
@@ -108,9 +111,16 @@ describe('Environment', () => {
it('should make an API request when using tabs', () => {
jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {});
- wrapper.find('.js-environments-tab-stopped').trigger('click');
+ findEnvironmentsTabStopped().trigger('click');
expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: 'stopped', page: '1' });
});
+
+ it('should not make the same API request when clicking on the current scope tab', () => {
+ // component starts at available
+ jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {});
+ findEnvironmentsTabAvailable().trigger('click');
+ expect(wrapper.vm.updateContent).toHaveBeenCalledTimes(0);
+ });
});
});
});
diff --git a/spec/frontend/environments/folder/environments_folder_view_spec.js b/spec/frontend/environments/folder/environments_folder_view_spec.js
index f33c8de0094..14c710dd7ba 100644
--- a/spec/frontend/environments/folder/environments_folder_view_spec.js
+++ b/spec/frontend/environments/folder/environments_folder_view_spec.js
@@ -46,9 +46,10 @@ describe('Environments Folder View', () => {
wrapper = mount(EnvironmentsFolderViewComponent, { propsData: mockData });
};
- const findEnvironmentsTabAvailable = () => wrapper.find('.js-environments-tab-available');
+ const findEnvironmentsTabAvailable = () =>
+ wrapper.find('[data-testid="environments-tab-available"]');
- const findEnvironmentsTabStopped = () => wrapper.find('.js-environments-tab-stopped');
+ const findEnvironmentsTabStopped = () => wrapper.find('[data-testid="environments-tab-stopped"]');
beforeEach(() => {
mock = new MockAdapter(axios);
diff --git a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js
index 21edcb7235a..f4a765a3d73 100644
--- a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js
+++ b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js
@@ -1,7 +1,6 @@
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
-import { GlFormInput } from '@gitlab/ui';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import { GlFormInput, GlButton } from '@gitlab/ui';
import ErrorTrackingForm from '~/error_tracking_settings/components/error_tracking_form.vue';
import createStore from '~/error_tracking_settings/store';
import { defaultProps } from '../mock';
@@ -43,7 +42,7 @@ describe('error tracking settings form', () => {
.attributes('id'),
).toBe('error-tracking-token');
- expect(wrapper.findAll(LoadingButton).exists()).toBe(true);
+ expect(wrapper.findAll(GlButton).exists()).toBe(true);
});
it('is rendered with labels and placeholders', () => {
@@ -72,9 +71,10 @@ describe('error tracking settings form', () => {
});
it('shows loading spinner', () => {
- const { label, loading } = wrapper.find(LoadingButton).props();
- expect(loading).toBe(true);
- expect(label).toBe('Connecting');
+ const buttonEl = wrapper.find(GlButton);
+
+ expect(buttonEl.props('loading')).toBe(true);
+ expect(buttonEl.text()).toBe('Connecting');
});
});
diff --git a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js
new file mode 100644
index 00000000000..47f786827f1
--- /dev/null
+++ b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js
@@ -0,0 +1,159 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlModal, GlSprintf } from '@gitlab/ui';
+import Component from '~/feature_flags/components/configure_feature_flags_modal.vue';
+import Callout from '~/vue_shared/components/callout.vue';
+
+describe('Configure Feature Flags Modal', () => {
+ const mockEvent = { preventDefault: jest.fn() };
+ const provide = {
+ projectName: 'fakeProjectName',
+ featureFlagsHelpPagePath: '/help/path',
+ };
+
+ const propsData = {
+ helpClientLibrariesPath: '/help/path/#flags',
+ helpClientExamplePath: '/feature-flags#clientexample',
+ apiUrl: '/api/url',
+ instanceId: 'instance-id-token',
+ isRotating: false,
+ hasRotateError: false,
+ canUserRotateToken: true,
+ };
+
+ let wrapper;
+ const factory = (props = {}, { mountFn = shallowMount, ...options } = {}) => {
+ wrapper = mountFn(Component, {
+ provide,
+ stubs: { GlSprintf },
+ propsData: {
+ ...propsData,
+ ...props,
+ },
+ ...options,
+ });
+ };
+
+ const findGlModal = () => wrapper.find(GlModal);
+ const findPrimaryAction = () => findGlModal().props('actionPrimary');
+ const findProjectNameInput = () => wrapper.find('#project_name_verification');
+ const findDangerCallout = () =>
+ wrapper.findAll(Callout).filter(c => c.props('category') === 'danger');
+
+ describe('idle', () => {
+ afterEach(() => wrapper.destroy());
+ beforeEach(factory);
+
+ it('should have Primary and Cancel actions', () => {
+ expect(findGlModal().props('actionCancel').text).toBe('Close');
+ expect(findPrimaryAction().text).toBe('Regenerate instance ID');
+ });
+
+ it('should default disable the primary action', async () => {
+ const [{ disabled }] = findPrimaryAction().attributes;
+ expect(disabled).toBe(true);
+ });
+
+ it('should emit a `token` event when clicking on the Primary action', async () => {
+ findGlModal().vm.$emit('primary', mockEvent);
+ await wrapper.vm.$nextTick();
+ expect(wrapper.emitted('token')).toEqual([[]]);
+ expect(mockEvent.preventDefault).toHaveBeenCalled();
+ });
+
+ it('should clear the project name input after generating the token', async () => {
+ findProjectNameInput().vm.$emit('input', provide.projectName);
+ findGlModal().vm.$emit('primary', mockEvent);
+ await wrapper.vm.$nextTick();
+ expect(findProjectNameInput().attributes('value')).toBe('');
+ });
+
+ it('should provide an input for filling the project name', () => {
+ expect(findProjectNameInput().exists()).toBe(true);
+ expect(findProjectNameInput().attributes('value')).toBe('');
+ });
+
+ it('should display an help text', () => {
+ const help = wrapper.find('p');
+ expect(help.text()).toMatch(/More Information/);
+ });
+
+ it('should have links to the documentation', () => {
+ expect(wrapper.find('[data-testid="help-link"]').attributes('href')).toBe(
+ provide.featureFlagsHelpPagePath,
+ );
+ expect(wrapper.find('[data-testid="help-client-link"]').attributes('href')).toBe(
+ propsData.helpClientLibrariesPath,
+ );
+ });
+
+ it('should display one and only one danger callout', () => {
+ const dangerCallout = findDangerCallout();
+ expect(dangerCallout.length).toBe(1);
+ expect(dangerCallout.at(0).props('message')).toMatch(/Regenerating the instance ID/);
+ });
+
+ it('should display a message asking to fill the project name', () => {
+ expect(wrapper.find('[data-testid="prevent-accident-text"]').text()).toMatch(
+ provide.projectName,
+ );
+ });
+
+ it('should display the api URL in an input box', () => {
+ const input = wrapper.find('#api_url');
+ expect(input.element.value).toBe('/api/url');
+ });
+
+ it('should display the instance ID in an input box', () => {
+ const input = wrapper.find('#instance_id');
+ expect(input.element.value).toBe('instance-id-token');
+ });
+ });
+
+ describe('verified', () => {
+ afterEach(() => wrapper.destroy());
+ beforeEach(factory);
+
+ it('should enable the primary action', async () => {
+ findProjectNameInput().vm.$emit('input', provide.projectName);
+ await wrapper.vm.$nextTick();
+ const [{ disabled }] = findPrimaryAction().attributes;
+ expect(disabled).toBe(false);
+ });
+ });
+
+ describe('cannot rotate token', () => {
+ afterEach(() => wrapper.destroy());
+ beforeEach(factory.bind(null, { canUserRotateToken: false }));
+
+ it('should not display the primary action', async () => {
+ expect(findPrimaryAction()).toBe(null);
+ });
+
+ it('shold not display regenerating instance ID', async () => {
+ expect(findDangerCallout().exists()).toBe(false);
+ });
+
+ it('should disable the project name input', async () => {
+ expect(findProjectNameInput().exists()).toBe(false);
+ });
+ });
+
+ describe('has rotate error', () => {
+ afterEach(() => wrapper.destroy());
+ beforeEach(factory.bind(null, { hasRotateError: false }));
+
+ it('should display an error', async () => {
+ expect(wrapper.find('.text-danger')).toExist();
+ expect(wrapper.find('[name="warning"]')).toExist();
+ });
+ });
+
+ describe('is rotating', () => {
+ afterEach(() => wrapper.destroy());
+ beforeEach(factory.bind(null, { isRotating: true }));
+
+ it('should disable the project name input', async () => {
+ expect(findProjectNameInput().attributes('disabled')).toBeTruthy();
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js
new file mode 100644
index 00000000000..f2e587bb8d9
--- /dev/null
+++ b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js
@@ -0,0 +1,197 @@
+import Vuex from 'vuex';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import { GlToggle, GlAlert } from '@gitlab/ui';
+import { TEST_HOST } from 'spec/test_constants';
+import { mockTracking } from 'helpers/tracking_helper';
+import { LEGACY_FLAG, NEW_VERSION_FLAG, NEW_FLAG_ALERT } from '~/feature_flags/constants';
+import Form from '~/feature_flags/components/form.vue';
+import editModule from '~/feature_flags/store/modules/edit';
+import EditFeatureFlag from '~/feature_flags/components/edit_feature_flag.vue';
+import axios from '~/lib/utils/axios_utils';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const userCalloutId = 'feature_flags_new_version';
+const userCalloutsPath = `${TEST_HOST}/user_callouts`;
+
+describe('Edit feature flag form', () => {
+ let wrapper;
+ let mock;
+
+ const store = new Vuex.Store({
+ modules: {
+ edit: editModule,
+ },
+ });
+
+ const factory = (opts = {}) => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ wrapper = shallowMount(EditFeatureFlag, {
+ localVue,
+ propsData: {
+ endpoint: `${TEST_HOST}/feature_flags.json`,
+ path: '/feature_flags',
+ environmentsEndpoint: 'environments.json',
+ projectId: '8',
+ featureFlagIssuesEndpoint: `${TEST_HOST}/feature_flags/5/issues`,
+ showUserCallout: true,
+ userCalloutId,
+ userCalloutsPath,
+ },
+ store,
+ provide: {
+ glFeatures: {
+ featureFlagsNewVersion: true,
+ },
+ },
+ ...opts,
+ });
+ };
+
+ beforeEach(done => {
+ mock = new MockAdapter(axios);
+ mock.onGet(`${TEST_HOST}/feature_flags.json`).replyOnce(200, {
+ id: 21,
+ iid: 5,
+ active: true,
+ created_at: '2019-01-17T17:27:39.778Z',
+ updated_at: '2019-01-17T17:27:39.778Z',
+ name: 'feature_flag',
+ description: '',
+ version: LEGACY_FLAG,
+ edit_path: '/h5bp/html5-boilerplate/-/feature_flags/21/edit',
+ destroy_path: '/h5bp/html5-boilerplate/-/feature_flags/21',
+ scopes: [
+ {
+ id: 21,
+ active: false,
+ environment_scope: '*',
+ created_at: '2019-01-17T17:27:39.778Z',
+ updated_at: '2019-01-17T17:27:39.778Z',
+ },
+ ],
+ });
+ factory();
+ setImmediate(() => done());
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+ });
+
+ const findAlert = () => wrapper.find(GlAlert);
+
+ it('should display the iid', () => {
+ expect(wrapper.find('h3').text()).toContain('^5');
+ });
+
+ it('should render the toggle', () => {
+ expect(wrapper.find(GlToggle).exists()).toBe(true);
+ });
+
+ it('should set the value of the toggle to whether or not the flag is active', () => {
+ expect(wrapper.find(GlToggle).props('value')).toBe(true);
+ });
+
+ it('should not alert users that feature flags are changing soon', () => {
+ expect(findAlert().text()).toContain('GitLab is moving to a new way of managing feature flags');
+ });
+
+ describe('with error', () => {
+ it('should render the error', () => {
+ store.dispatch('edit/receiveUpdateFeatureFlagError', { message: ['The name is required'] });
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find('.alert-danger').exists()).toEqual(true);
+ expect(wrapper.find('.alert-danger').text()).toContain('The name is required');
+ });
+ });
+ });
+
+ describe('without error', () => {
+ it('renders form title', () => {
+ expect(wrapper.text()).toContain('^5 feature_flag');
+ });
+
+ it('should render feature flag form', () => {
+ expect(wrapper.find(Form).exists()).toEqual(true);
+ });
+
+ it('should set the version of the form from the feature flag', () => {
+ expect(wrapper.find(Form).props('version')).toBe(LEGACY_FLAG);
+
+ mock.resetHandlers();
+
+ mock.onGet(`${TEST_HOST}/feature_flags.json`).replyOnce(200, {
+ id: 21,
+ iid: 5,
+ active: true,
+ created_at: '2019-01-17T17:27:39.778Z',
+ updated_at: '2019-01-17T17:27:39.778Z',
+ name: 'feature_flag',
+ description: '',
+ version: NEW_VERSION_FLAG,
+ edit_path: '/h5bp/html5-boilerplate/-/feature_flags/21/edit',
+ destroy_path: '/h5bp/html5-boilerplate/-/feature_flags/21',
+ strategies: [],
+ });
+
+ factory();
+
+ return axios.waitForAll().then(() => {
+ expect(wrapper.find(Form).props('version')).toBe(NEW_VERSION_FLAG);
+ });
+ });
+
+ it('renders the related issues widget', () => {
+ const expected = `${TEST_HOST}/feature_flags/5/issues`;
+
+ expect(wrapper.find(Form).props('featureFlagIssuesEndpoint')).toBe(expected);
+ });
+
+ it('should track when the toggle is clicked', () => {
+ const toggle = wrapper.find(GlToggle);
+ const spy = mockTracking('_category_', toggle.element, jest.spyOn);
+
+ toggle.trigger('click');
+
+ expect(spy).toHaveBeenCalledWith('_category_', 'click_button', {
+ label: 'feature_flag_toggle',
+ });
+ });
+ });
+
+ describe('without new version flags', () => {
+ beforeEach(() => factory({ provide: { glFeatures: { featureFlagsNewVersion: false } } }));
+
+ it('should alert users that feature flags are changing soon', () => {
+ expect(findAlert().text()).toBe(NEW_FLAG_ALERT);
+ });
+ });
+
+ describe('dismissing new version alert', () => {
+ beforeEach(() => {
+ factory({ provide: { glFeatures: { featureFlagsNewVersion: false } } });
+ mock.onPost(userCalloutsPath, { feature_name: userCalloutId }).reply(200);
+ findAlert().vm.$emit('dismiss');
+ return wrapper.vm.$nextTick();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should hide the alert', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('should send the dismissal event', () => {
+ expect(mock.history.post.length).toBe(1);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/environments_dropdown_spec.js b/spec/frontend/feature_flags/components/environments_dropdown_spec.js
new file mode 100644
index 00000000000..2aa75ef6652
--- /dev/null
+++ b/spec/frontend/feature_flags/components/environments_dropdown_spec.js
@@ -0,0 +1,145 @@
+import MockAdapter from 'axios-mock-adapter';
+import { shallowMount } from '@vue/test-utils';
+import { GlLoadingIcon, GlDeprecatedButton, GlSearchBoxByType } from '@gitlab/ui';
+import { TEST_HOST } from 'spec/test_constants';
+import waitForPromises from 'helpers/wait_for_promises';
+import EnvironmentsDropdown from '~/feature_flags/components/environments_dropdown.vue';
+import axios from '~/lib/utils/axios_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
+
+describe('Feature flags > Environments dropdown ', () => {
+ let wrapper;
+ let mock;
+ const results = ['production', 'staging'];
+ const factory = props => {
+ wrapper = shallowMount(EnvironmentsDropdown, {
+ propsData: {
+ endpoint: `${TEST_HOST}/environments.json'`,
+ ...props,
+ },
+ });
+ };
+
+ const findEnvironmentSearchInput = () => wrapper.find(GlSearchBoxByType);
+ const findDropdownMenu = () => wrapper.find('.dropdown-menu');
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+ });
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ describe('without value', () => {
+ it('renders the placeholder', () => {
+ factory();
+ expect(findEnvironmentSearchInput().vm.$attrs.placeholder).toBe('Search an environment spec');
+ });
+ });
+
+ describe('with value', () => {
+ it('sets filter to equal the value', () => {
+ factory({ value: 'production' });
+ expect(findEnvironmentSearchInput().props('value')).toBe('production');
+ });
+ });
+
+ describe('on focus', () => {
+ it('sets results with the received data', async () => {
+ mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results);
+ factory();
+ findEnvironmentSearchInput().vm.$emit('focus');
+ await waitForPromises();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('.dropdown-content > ul').exists()).toBe(true);
+ expect(wrapper.findAll('.dropdown-content > ul > li').exists()).toBe(true);
+ });
+ });
+
+ describe('on keyup', () => {
+ it('sets results with the received data', async () => {
+ mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results);
+ factory();
+ findEnvironmentSearchInput().vm.$emit('keyup');
+ await waitForPromises();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('.dropdown-content > ul').exists()).toBe(true);
+ expect(wrapper.findAll('.dropdown-content > ul > li').exists()).toBe(true);
+ });
+ });
+
+ describe('on input change', () => {
+ describe('on success', () => {
+ beforeEach(async () => {
+ mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results);
+ factory();
+ findEnvironmentSearchInput().vm.$emit('focus');
+ findEnvironmentSearchInput().vm.$emit('input', 'production');
+ await waitForPromises();
+ await wrapper.vm.$nextTick();
+ });
+
+ it('sets filter value', () => {
+ expect(findEnvironmentSearchInput().props('value')).toBe('production');
+ });
+
+ describe('with received data', () => {
+ it('sets is loading to false', () => {
+ expect(wrapper.vm.isLoading).toBe(false);
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ });
+
+ it('shows the suggestions', () => {
+ expect(findDropdownMenu().exists()).toBe(true);
+ });
+
+ it('emits event when a suggestion is clicked', async () => {
+ const button = wrapper
+ .findAll(GlDeprecatedButton)
+ .filter(b => b.text() === 'production')
+ .at(0);
+ button.vm.$emit('click');
+ await wrapper.vm.$nextTick();
+ expect(wrapper.emitted('selectEnvironment')).toEqual([['production']]);
+ });
+ });
+
+ describe('on click clear button', () => {
+ beforeEach(async () => {
+ wrapper.find(GlDeprecatedButton).vm.$emit('click');
+ await wrapper.vm.$nextTick();
+ });
+
+ it('resets filter value', () => {
+ expect(findEnvironmentSearchInput().props('value')).toBe('');
+ });
+
+ it('closes list of suggestions', () => {
+ expect(wrapper.vm.showSuggestions).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe('on click create button', () => {
+ beforeEach(async () => {
+ mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, []);
+ factory();
+ findEnvironmentSearchInput().vm.$emit('focus');
+ findEnvironmentSearchInput().vm.$emit('input', 'production');
+ await waitForPromises();
+ await wrapper.vm.$nextTick();
+ });
+
+ it('emits create event', async () => {
+ wrapper
+ .findAll(GlDeprecatedButton)
+ .at(0)
+ .vm.$emit('click');
+ await wrapper.vm.$nextTick();
+ expect(wrapper.emitted('createClicked')).toEqual([['production']]);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/feature_flags_spec.js b/spec/frontend/feature_flags/components/feature_flags_spec.js
new file mode 100644
index 00000000000..5ff39937113
--- /dev/null
+++ b/spec/frontend/feature_flags/components/feature_flags_spec.js
@@ -0,0 +1,343 @@
+import { shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
+import { TEST_HOST } from 'spec/test_constants';
+import Api from '~/api';
+import { createStore } from '~/feature_flags/store';
+import FeatureFlagsTab from '~/feature_flags/components/feature_flags_tab.vue';
+import FeatureFlagsComponent from '~/feature_flags/components/feature_flags.vue';
+import FeatureFlagsTable from '~/feature_flags/components/feature_flags_table.vue';
+import UserListsTable from '~/feature_flags/components/user_lists_table.vue';
+import ConfigureFeatureFlagsModal from '~/feature_flags/components/configure_feature_flags_modal.vue';
+import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '~/feature_flags/constants';
+import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+import axios from '~/lib/utils/axios_utils';
+import { getRequestData, userList } from '../mock_data';
+
+describe('Feature flags', () => {
+ const mockData = {
+ endpoint: `${TEST_HOST}/endpoint.json`,
+ csrfToken: 'testToken',
+ featureFlagsClientLibrariesHelpPagePath: '/help/feature-flags#unleash-clients',
+ featureFlagsClientExampleHelpPagePath: '/help/feature-flags#client-example',
+ unleashApiUrl: `${TEST_HOST}/api/unleash`,
+ unleashApiInstanceId: 'oP6sCNRqtRHmpy1gw2-F',
+ canUserConfigure: true,
+ canUserRotateToken: true,
+ newFeatureFlagPath: 'feature-flags/new',
+ newUserListPath: '/user-list/new',
+ projectId: '8',
+ };
+
+ let wrapper;
+ let mock;
+ let store;
+
+ const factory = (propsData = mockData, fn = shallowMount) => {
+ store = createStore();
+ wrapper = fn(FeatureFlagsComponent, {
+ store,
+ propsData,
+ provide: {
+ projectName: 'fakeProjectName',
+ errorStateSvgPath: '/assets/illustrations/feature_flag.svg',
+ featureFlagsHelpPagePath: '/help/feature-flags',
+ },
+ stubs: {
+ FeatureFlagsTab,
+ },
+ });
+ };
+
+ const configureButton = () => wrapper.find('[data-testid="ff-configure-button"]');
+ const newButton = () => wrapper.find('[data-testid="ff-new-button"]');
+ const newUserListButton = () => wrapper.find('[data-testid="ff-new-list-button"]');
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ jest.spyOn(Api, 'fetchFeatureFlagUserLists').mockResolvedValue({
+ data: [userList],
+ headers: {
+ 'x-next-page': '2',
+ 'x-page': '1',
+ 'X-Per-Page': '8',
+ 'X-Prev-Page': '',
+ 'X-TOTAL': '40',
+ 'X-Total-Pages': '5',
+ },
+ });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('without permissions', () => {
+ const propsData = {
+ endpoint: `${TEST_HOST}/endpoint.json`,
+ csrfToken: 'testToken',
+ errorStateSvgPath: '/assets/illustrations/feature_flag.svg',
+ featureFlagsHelpPagePath: '/help/feature-flags',
+ canUserConfigure: false,
+ canUserRotateToken: false,
+ featureFlagsClientLibrariesHelpPagePath: '/help/feature-flags#unleash-clients',
+ featureFlagsClientExampleHelpPagePath: '/help/feature-flags#client-example',
+ unleashApiUrl: `${TEST_HOST}/api/unleash`,
+ unleashApiInstanceId: 'oP6sCNRqtRHmpy1gw2-F',
+ projectId: '8',
+ };
+
+ beforeEach(done => {
+ mock
+ .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
+ .reply(200, getRequestData, {});
+
+ factory(propsData);
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('does not render configure button', () => {
+ expect(configureButton().exists()).toBe(false);
+ });
+
+ it('does not render new feature flag button', () => {
+ expect(newButton().exists()).toBe(false);
+ });
+
+ it('does not render new user list button', () => {
+ expect(newUserListButton().exists()).toBe(false);
+ });
+ });
+
+ describe('loading state', () => {
+ it('renders a loading icon', () => {
+ mock
+ .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
+ .replyOnce(200, getRequestData, {});
+
+ factory();
+
+ const loadingElement = wrapper.find(GlLoadingIcon);
+
+ expect(loadingElement.exists()).toBe(true);
+ expect(loadingElement.props('label')).toEqual('Loading feature flags');
+ });
+ });
+
+ describe('successful request', () => {
+ describe('without feature flags', () => {
+ let emptyState;
+
+ beforeEach(async () => {
+ mock.onGet(mockData.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }).reply(
+ 200,
+ {
+ feature_flags: [],
+ count: {
+ all: 0,
+ enabled: 0,
+ disabled: 0,
+ },
+ },
+ {},
+ );
+
+ factory();
+ await wrapper.vm.$nextTick();
+
+ emptyState = wrapper.find(GlEmptyState);
+ });
+
+ it('should render the empty state', async () => {
+ await axios.waitForAll();
+ emptyState = wrapper.find(GlEmptyState);
+ expect(emptyState.exists()).toBe(true);
+ });
+
+ it('renders configure button', () => {
+ expect(configureButton().exists()).toBe(true);
+ });
+
+ it('renders new feature flag button', () => {
+ expect(newButton().exists()).toBe(true);
+ });
+
+ it('renders new user list button', () => {
+ expect(newUserListButton().exists()).toBe(true);
+ expect(newUserListButton().attributes('href')).toBe('/user-list/new');
+ });
+
+ describe('in feature flags tab', () => {
+ it('renders generic title', () => {
+ expect(emptyState.props('title')).toEqual('Get started with feature flags');
+ });
+ });
+ });
+
+ describe('with paginated feature flags', () => {
+ beforeEach(done => {
+ mock
+ .onGet(mockData.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
+ .replyOnce(200, getRequestData, {
+ 'x-next-page': '2',
+ 'x-page': '1',
+ 'X-Per-Page': '2',
+ 'X-Prev-Page': '',
+ 'X-TOTAL': '37',
+ 'X-Total-Pages': '5',
+ });
+
+ factory();
+ jest.spyOn(store, 'dispatch');
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('should render a table with feature flags', () => {
+ const table = wrapper.find(FeatureFlagsTable);
+ expect(table.exists()).toBe(true);
+ expect(table.props(FEATURE_FLAG_SCOPE)).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: getRequestData.feature_flags[0].name,
+ description: getRequestData.feature_flags[0].description,
+ }),
+ ]),
+ );
+ });
+
+ it('should toggle a flag when receiving the toggle-flag event', () => {
+ const table = wrapper.find(FeatureFlagsTable);
+
+ const [flag] = table.props(FEATURE_FLAG_SCOPE);
+ table.vm.$emit('toggle-flag', flag);
+
+ expect(store.dispatch).toHaveBeenCalledWith('index/toggleFeatureFlag', flag);
+ });
+
+ it('renders configure button', () => {
+ expect(configureButton().exists()).toBe(true);
+ });
+
+ it('renders new feature flag button', () => {
+ expect(newButton().exists()).toBe(true);
+ });
+
+ it('renders new user list button', () => {
+ expect(newUserListButton().exists()).toBe(true);
+ expect(newUserListButton().attributes('href')).toBe('/user-list/new');
+ });
+
+ describe('pagination', () => {
+ it('should render pagination', () => {
+ expect(wrapper.find(TablePagination).exists()).toBe(true);
+ });
+
+ it('should make an API request when page is clicked', () => {
+ jest.spyOn(wrapper.vm, 'updateFeatureFlagOptions');
+ wrapper.find(TablePagination).vm.change(4);
+
+ expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({
+ scope: FEATURE_FLAG_SCOPE,
+ page: '4',
+ });
+ });
+
+ it('should make an API request when using tabs', () => {
+ jest.spyOn(wrapper.vm, 'updateFeatureFlagOptions');
+ wrapper.find('[data-testid="user-lists-tab"]').vm.$emit('changeTab');
+
+ expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({
+ scope: USER_LIST_SCOPE,
+ page: '1',
+ });
+ });
+ });
+ });
+
+ describe('in user lists tab', () => {
+ beforeEach(done => {
+ factory();
+
+ setImmediate(() => {
+ done();
+ });
+ });
+ beforeEach(() => {
+ wrapper.find('[data-testid="user-lists-tab"]').vm.$emit('changeTab');
+ return wrapper.vm.$nextTick();
+ });
+
+ it('should display the user list table', () => {
+ expect(wrapper.find(UserListsTable).exists()).toBe(true);
+ });
+
+ it('should set the user lists to display', () => {
+ expect(wrapper.find(UserListsTable).props('userLists')).toEqual([userList]);
+ });
+ });
+ });
+
+ describe('unsuccessful request', () => {
+ beforeEach(done => {
+ mock
+ .onGet(mockData.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
+ .replyOnce(500, {});
+ Api.fetchFeatureFlagUserLists.mockRejectedValueOnce();
+
+ factory();
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('should render error state', () => {
+ const emptyState = wrapper.find(GlEmptyState);
+ expect(emptyState.props('title')).toEqual('There was an error fetching the feature flags.');
+ expect(emptyState.props('description')).toEqual(
+ 'Try again in a few moments or contact your support team.',
+ );
+ });
+
+ it('renders configure button', () => {
+ expect(configureButton().exists()).toBe(true);
+ });
+
+ it('renders new feature flag button', () => {
+ expect(newButton().exists()).toBe(true);
+ });
+
+ it('renders new user list button', () => {
+ expect(newUserListButton().exists()).toBe(true);
+ expect(newUserListButton().attributes('href')).toBe('/user-list/new');
+ });
+ });
+
+ describe('rotate instance id', () => {
+ beforeEach(done => {
+ mock
+ .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
+ .reply(200, getRequestData, {});
+ factory();
+
+ setImmediate(() => {
+ done();
+ });
+ });
+
+ it('should fire the rotate action when a `token` event is received', () => {
+ const actionSpy = jest.spyOn(wrapper.vm, 'rotateInstanceId');
+ const modal = wrapper.find(ConfigureFeatureFlagsModal);
+ modal.vm.$emit('token');
+
+ expect(actionSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/feature_flags_tab_spec.js b/spec/frontend/feature_flags/components/feature_flags_tab_spec.js
new file mode 100644
index 00000000000..bc90c5ceb2d
--- /dev/null
+++ b/spec/frontend/feature_flags/components/feature_flags_tab_spec.js
@@ -0,0 +1,168 @@
+import { mount } from '@vue/test-utils';
+import { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTabs } from '@gitlab/ui';
+import FeatureFlagsTab from '~/feature_flags/components/feature_flags_tab.vue';
+
+const DEFAULT_PROPS = {
+ title: 'test',
+ count: 5,
+ alerts: ['an alert', 'another alert'],
+ isLoading: false,
+ loadingLabel: 'test loading',
+ errorState: false,
+ errorTitle: 'test title',
+ emptyState: true,
+ emptyTitle: 'test empty',
+};
+
+const DEFAULT_PROVIDE = {
+ errorStateSvgPath: '/error.svg',
+ featureFlagsHelpPagePath: '/help/page/path',
+};
+
+describe('feature_flags/components/feature_flags_tab.vue', () => {
+ let wrapper;
+
+ const factory = (props = {}) =>
+ mount(
+ {
+ components: {
+ GlTabs,
+ FeatureFlagsTab,
+ },
+ render(h) {
+ return h(GlTabs, [
+ h(FeatureFlagsTab, { props: this.$attrs, on: this.$listeners }, this.$slots.default),
+ ]);
+ },
+ },
+ {
+ propsData: {
+ ...DEFAULT_PROPS,
+ ...props,
+ },
+ provide: DEFAULT_PROVIDE,
+ slots: {
+ default: '<p data-testid="test-slot">testing</p>',
+ },
+ },
+ );
+
+ afterEach(() => {
+ if (wrapper?.destroy) {
+ wrapper.destroy();
+ }
+
+ wrapper = null;
+ });
+
+ describe('alerts', () => {
+ let alerts;
+
+ beforeEach(() => {
+ wrapper = factory();
+ alerts = wrapper.findAll(GlAlert);
+ });
+
+ it('should show any alerts', () => {
+ expect(alerts).toHaveLength(DEFAULT_PROPS.alerts.length);
+ alerts.wrappers.forEach((alert, i) => expect(alert.text()).toBe(DEFAULT_PROPS.alerts[i]));
+ });
+
+ it('should emit a dismiss event for a dismissed alert', () => {
+ alerts.at(0).vm.$emit('dismiss');
+
+ expect(wrapper.find(FeatureFlagsTab).emitted('dismissAlert')).toEqual([[0]]);
+ });
+ });
+
+ describe('loading', () => {
+ beforeEach(() => {
+ wrapper = factory({ isLoading: true });
+ });
+
+ it('should show a loading icon and nothing else', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ expect(wrapper.findAll(GlEmptyState)).toHaveLength(0);
+ });
+ });
+
+ describe('error', () => {
+ let emptyState;
+
+ beforeEach(() => {
+ wrapper = factory({ errorState: true });
+ emptyState = wrapper.find(GlEmptyState);
+ });
+
+ it('should show an error state if there has been an error', () => {
+ expect(emptyState.text()).toContain(DEFAULT_PROPS.errorTitle);
+ expect(emptyState.text()).toContain(
+ 'Try again in a few moments or contact your support team.',
+ );
+ expect(emptyState.props('svgPath')).toBe(DEFAULT_PROVIDE.errorStateSvgPath);
+ });
+ });
+
+ describe('empty', () => {
+ let emptyState;
+ let emptyStateLink;
+
+ beforeEach(() => {
+ wrapper = factory({ emptyState: true });
+ emptyState = wrapper.find(GlEmptyState);
+ emptyStateLink = emptyState.find(GlLink);
+ });
+
+ it('should show an empty state if it is empty', () => {
+ expect(emptyState.text()).toContain(DEFAULT_PROPS.emptyTitle);
+ expect(emptyState.text()).toContain(
+ 'Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.',
+ );
+ expect(emptyState.props('svgPath')).toBe(DEFAULT_PROVIDE.errorStateSvgPath);
+ expect(emptyStateLink.attributes('href')).toBe(DEFAULT_PROVIDE.featureFlagsHelpPagePath);
+ expect(emptyStateLink.text()).toBe('More information');
+ });
+ });
+
+ describe('slot', () => {
+ let slot;
+
+ beforeEach(async () => {
+ wrapper = factory();
+ await wrapper.vm.$nextTick();
+
+ slot = wrapper.find('[data-testid="test-slot"]');
+ });
+
+ it('should display the passed slot', () => {
+ expect(slot.exists()).toBe(true);
+ expect(slot.text()).toBe('testing');
+ });
+ });
+
+ describe('count', () => {
+ it('should display a count if there is one', async () => {
+ wrapper = factory();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlBadge).text()).toBe(DEFAULT_PROPS.count.toString());
+ });
+ it('should display 0 if there is no count', async () => {
+ wrapper = factory({ count: undefined });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlBadge).text()).toBe('0');
+ });
+ });
+
+ describe('title', () => {
+ it('should show the title', async () => {
+ wrapper = factory();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('[data-testid="feature-flags-tab-title"]').text()).toBe(
+ DEFAULT_PROPS.title,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/feature_flags_table_spec.js b/spec/frontend/feature_flags/components/feature_flags_table_spec.js
new file mode 100644
index 00000000000..c59ecbf3b06
--- /dev/null
+++ b/spec/frontend/feature_flags/components/feature_flags_table_spec.js
@@ -0,0 +1,262 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlToggle, GlBadge } from '@gitlab/ui';
+import { trimText } from 'helpers/text_helper';
+import { mockTracking } from 'helpers/tracking_helper';
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+ NEW_VERSION_FLAG,
+ LEGACY_FLAG,
+ DEFAULT_PERCENT_ROLLOUT,
+} from '~/feature_flags/constants';
+import FeatureFlagsTable from '~/feature_flags/components/feature_flags_table.vue';
+
+const getDefaultProps = () => ({
+ featureFlags: [
+ {
+ id: 1,
+ iid: 1,
+ active: true,
+ name: 'flag name',
+ description: 'flag description',
+ destroy_path: 'destroy/path',
+ edit_path: 'edit/path',
+ version: LEGACY_FLAG,
+ scopes: [
+ {
+ id: 1,
+ active: true,
+ environmentScope: 'scope',
+ canUpdate: true,
+ protected: false,
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ shouldBeDestroyed: false,
+ },
+ ],
+ },
+ ],
+ csrfToken: 'fakeToken',
+});
+
+describe('Feature flag table', () => {
+ let wrapper;
+ let props;
+
+ const createWrapper = (propsData, opts = {}) => {
+ wrapper = shallowMount(FeatureFlagsTable, {
+ propsData,
+ ...opts,
+ });
+ };
+
+ beforeEach(() => {
+ props = getDefaultProps();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('with an active scope and a standard rollout strategy', () => {
+ beforeEach(() => {
+ createWrapper(props);
+ });
+
+ it('Should render a table', () => {
+ expect(wrapper.classes('table-holder')).toBe(true);
+ });
+
+ it('Should render rows', () => {
+ expect(wrapper.find('.gl-responsive-table-row').exists()).toBe(true);
+ });
+
+ it('should render an ID column', () => {
+ expect(wrapper.find('.js-feature-flag-id').exists()).toBe(true);
+ expect(trimText(wrapper.find('.js-feature-flag-id').text())).toEqual('^1');
+ });
+
+ it('Should render a status column', () => {
+ const badge = wrapper.find('[data-testid="feature-flag-status-badge"]');
+
+ expect(badge.exists()).toBe(true);
+ expect(trimText(badge.text())).toEqual('Active');
+ });
+
+ it('Should render a feature flag column', () => {
+ expect(wrapper.find('.js-feature-flag-title').exists()).toBe(true);
+ expect(trimText(wrapper.find('.feature-flag-name').text())).toEqual('flag name');
+
+ expect(trimText(wrapper.find('.feature-flag-description').text())).toEqual(
+ 'flag description',
+ );
+ });
+
+ it('should render an environments specs column', () => {
+ const envColumn = wrapper.find('.js-feature-flag-environments');
+
+ expect(envColumn).toBeDefined();
+ expect(trimText(envColumn.text())).toBe('scope');
+ });
+
+ it('should render an environments specs badge with active class', () => {
+ const envColumn = wrapper.find('.js-feature-flag-environments');
+
+ expect(trimText(envColumn.find(GlBadge).text())).toBe('scope');
+ });
+
+ it('should render an actions column', () => {
+ expect(wrapper.find('.table-action-buttons').exists()).toBe(true);
+ expect(wrapper.find('.js-feature-flag-delete-button').exists()).toBe(true);
+ expect(wrapper.find('.js-feature-flag-edit-button').exists()).toBe(true);
+ expect(wrapper.find('.js-feature-flag-edit-button').attributes('href')).toEqual('edit/path');
+ });
+ });
+
+ describe('when active and with an update toggle', () => {
+ let toggle;
+ let spy;
+
+ beforeEach(() => {
+ props.featureFlags[0].update_path = props.featureFlags[0].destroy_path;
+ createWrapper(props);
+ toggle = wrapper.find(GlToggle);
+ spy = mockTracking('_category_', toggle.element, jest.spyOn);
+ });
+
+ it('should have a toggle', () => {
+ expect(toggle.exists()).toBe(true);
+ expect(toggle.props('value')).toBe(true);
+ });
+
+ it('should trigger a toggle event', () => {
+ toggle.vm.$emit('change');
+ const flag = { ...props.featureFlags[0], active: !props.featureFlags[0].active };
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('toggle-flag')).toEqual([[flag]]);
+ });
+ });
+
+ it('should track a click', () => {
+ toggle.trigger('click');
+
+ expect(spy).toHaveBeenCalledWith('_category_', 'click_button', {
+ label: 'feature_flag_toggle',
+ });
+ });
+ });
+
+ describe('with an active scope and a percentage rollout strategy', () => {
+ beforeEach(() => {
+ props.featureFlags[0].scopes[0].rolloutStrategy = ROLLOUT_STRATEGY_PERCENT_ROLLOUT;
+ props.featureFlags[0].scopes[0].rolloutPercentage = '54';
+ createWrapper(props);
+ });
+
+ it('should render an environments specs badge with percentage', () => {
+ const envColumn = wrapper.find('.js-feature-flag-environments');
+
+ expect(trimText(envColumn.find(GlBadge).text())).toBe('scope: 54%');
+ });
+ });
+
+ describe('with an inactive scope', () => {
+ beforeEach(() => {
+ props.featureFlags[0].scopes[0].active = false;
+ createWrapper(props);
+ });
+
+ it('should render an environments specs badge with inactive class', () => {
+ const envColumn = wrapper.find('.js-feature-flag-environments');
+
+ expect(trimText(envColumn.find(GlBadge).text())).toBe('scope');
+ });
+ });
+
+ describe('with a new version flag', () => {
+ let badges;
+
+ beforeEach(() => {
+ const newVersionProps = {
+ ...props,
+ featureFlags: [
+ {
+ id: 1,
+ iid: 1,
+ active: true,
+ name: 'flag name',
+ description: 'flag description',
+ destroy_path: 'destroy/path',
+ edit_path: 'edit/path',
+ version: NEW_VERSION_FLAG,
+ scopes: [],
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ scopes: [{ environment_scope: '*' }],
+ },
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '50' },
+ scopes: [{ environment_scope: 'production' }, { environment_scope: 'staging' }],
+ },
+ {
+ name: ROLLOUT_STRATEGY_USER_ID,
+ parameters: { userIds: '1,2,3,4' },
+ scopes: [{ environment_scope: 'review/*' }],
+ },
+ {
+ name: ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+ parameters: {},
+ user_list: { name: 'test list' },
+ scopes: [{ environment_scope: '*' }],
+ },
+ ],
+ },
+ ],
+ };
+ createWrapper(newVersionProps, { provide: { glFeatures: { featureFlagsNewVersion: true } } });
+
+ badges = wrapper.findAll('[data-testid="strategy-badge"]');
+ });
+
+ it('shows All Environments if the environment scope is *', () => {
+ expect(badges.at(0).text()).toContain('All Environments');
+ });
+
+ it('shows the environment scope if another is set', () => {
+ expect(badges.at(1).text()).toContain('production');
+ expect(badges.at(1).text()).toContain('staging');
+ expect(badges.at(2).text()).toContain('review/*');
+ });
+
+ it('shows All Users for the default strategy', () => {
+ expect(badges.at(0).text()).toContain('All Users');
+ });
+
+ it('shows the percent for a percent rollout', () => {
+ expect(badges.at(1).text()).toContain('Percent of users - 50%');
+ });
+
+ it('shows the number of users for users with ID', () => {
+ expect(badges.at(2).text()).toContain('User IDs - 4 users');
+ });
+
+ it('shows the name of a user list for user list', () => {
+ expect(badges.at(3).text()).toContain('User List - test list');
+ });
+ });
+
+ it('renders a feature flag without an iid', () => {
+ delete props.featureFlags[0].iid;
+ createWrapper(props);
+
+ expect(wrapper.find('.js-feature-flag-id').exists()).toBe(true);
+ expect(trimText(wrapper.find('.js-feature-flag-id').text())).toBe('');
+ });
+});
diff --git a/spec/frontend/feature_flags/components/form_spec.js b/spec/frontend/feature_flags/components/form_spec.js
new file mode 100644
index 00000000000..451bb4176ef
--- /dev/null
+++ b/spec/frontend/feature_flags/components/form_spec.js
@@ -0,0 +1,485 @@
+import { uniqueId } from 'lodash';
+import { shallowMount } from '@vue/test-utils';
+import { GlFormTextarea, GlFormCheckbox, GlButton } from '@gitlab/ui';
+import Api from '~/api';
+import Form from '~/feature_flags/components/form.vue';
+import EnvironmentsDropdown from '~/feature_flags/components/environments_dropdown.vue';
+import Strategy from '~/feature_flags/components/strategy.vue';
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ INTERNAL_ID_PREFIX,
+ DEFAULT_PERCENT_ROLLOUT,
+ LEGACY_FLAG,
+ NEW_VERSION_FLAG,
+} from '~/feature_flags/constants';
+import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue';
+import ToggleButton from '~/vue_shared/components/toggle_button.vue';
+import { featureFlag, userList, allUsersStrategy } from '../mock_data';
+
+jest.mock('~/api.js');
+
+describe('feature flag form', () => {
+ let wrapper;
+ const requiredProps = {
+ cancelPath: 'feature_flags',
+ submitText: 'Create',
+ environmentsEndpoint: '/environments.json',
+ projectId: '1',
+ };
+
+ const factory = (props = {}) => {
+ wrapper = shallowMount(Form, {
+ propsData: props,
+ provide: {
+ glFeatures: {
+ featureFlagPermissions: true,
+ featureFlagsNewVersion: true,
+ },
+ },
+ });
+ };
+
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [] });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should render provided submitText', () => {
+ factory(requiredProps);
+
+ expect(wrapper.find('.js-ff-submit').text()).toEqual(requiredProps.submitText);
+ });
+
+ it('should render provided cancelPath', () => {
+ factory(requiredProps);
+
+ expect(wrapper.find('.js-ff-cancel').attributes('href')).toEqual(requiredProps.cancelPath);
+ });
+
+ it('does not render the related issues widget without the featureFlagIssuesEndpoint', () => {
+ factory(requiredProps);
+
+ expect(wrapper.find(RelatedIssuesRoot).exists()).toBe(false);
+ });
+
+ it('renders the related issues widget when the featureFlagIssuesEndpoint is provided', () => {
+ factory({
+ ...requiredProps,
+ featureFlagIssuesEndpoint: '/some/endpoint',
+ });
+
+ expect(wrapper.find(RelatedIssuesRoot).exists()).toBe(true);
+ });
+
+ describe('without provided data', () => {
+ beforeEach(() => {
+ factory(requiredProps);
+ });
+
+ it('should render name input text', () => {
+ expect(wrapper.find('#feature-flag-name').exists()).toBe(true);
+ });
+
+ it('should render description textarea', () => {
+ expect(wrapper.find('#feature-flag-description').exists()).toBe(true);
+ });
+
+ describe('scopes', () => {
+ it('should render scopes table', () => {
+ expect(wrapper.find('.js-scopes-table').exists()).toBe(true);
+ });
+
+ it('should render scopes table with a new row ', () => {
+ expect(wrapper.find('.js-add-new-scope').exists()).toBe(true);
+ });
+
+ describe('status toggle', () => {
+ describe('without filled text input', () => {
+ it('should add a new scope with the text value empty and the status', () => {
+ wrapper.find(ToggleButton).vm.$emit('change', true);
+
+ expect(wrapper.vm.formScopes).toHaveLength(1);
+ expect(wrapper.vm.formScopes[0].active).toEqual(true);
+ expect(wrapper.vm.formScopes[0].environmentScope).toEqual('');
+
+ expect(wrapper.vm.newScope).toEqual('');
+ });
+ });
+
+ it('should be disabled if the feature flag is not active', done => {
+ wrapper.setProps({ active: false });
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.find(ToggleButton).props('disabledInput')).toBe(true);
+ done();
+ });
+ });
+ });
+ });
+ });
+
+ describe('with provided data', () => {
+ beforeEach(() => {
+ factory({
+ ...requiredProps,
+ name: featureFlag.name,
+ description: featureFlag.description,
+ active: true,
+ version: LEGACY_FLAG,
+ scopes: [
+ {
+ id: 1,
+ active: true,
+ environmentScope: 'scope',
+ canUpdate: true,
+ protected: false,
+ rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ rolloutPercentage: '54',
+ rolloutUserIds: '123',
+ shouldIncludeUserIds: true,
+ },
+ {
+ id: 2,
+ active: true,
+ environmentScope: 'scope',
+ canUpdate: false,
+ protected: true,
+ rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ rolloutPercentage: '54',
+ rolloutUserIds: '123',
+ shouldIncludeUserIds: true,
+ },
+ ],
+ });
+ });
+
+ describe('scopes', () => {
+ it('should be possible to remove a scope', () => {
+ expect(wrapper.find('.js-feature-flag-delete').exists()).toEqual(true);
+ });
+
+ it('renders empty row to add a new scope', () => {
+ expect(wrapper.find('.js-add-new-scope').exists()).toEqual(true);
+ });
+
+ it('renders the user id checkbox', () => {
+ expect(wrapper.find(GlFormCheckbox).exists()).toBe(true);
+ });
+
+ it('renders the user id text area', () => {
+ expect(wrapper.find(GlFormTextarea).exists()).toBe(true);
+
+ expect(wrapper.find(GlFormTextarea).vm.value).toBe('123');
+ });
+
+ describe('update scope', () => {
+ describe('on click on toggle', () => {
+ it('should update the scope', () => {
+ wrapper.find(ToggleButton).vm.$emit('change', false);
+
+ expect(wrapper.vm.formScopes[0].active).toBe(false);
+ });
+
+ it('should be disabled if the feature flag is not active', done => {
+ wrapper.setProps({ active: false });
+
+ wrapper.vm.$nextTick(() => {
+ expect(wrapper.find(ToggleButton).props('disabledInput')).toBe(true);
+ done();
+ });
+ });
+ });
+ describe('on strategy change', () => {
+ it('should not include user IDs if All Users is selected', () => {
+ const scope = wrapper.find({ ref: 'scopeRow' });
+ scope.find('select').setValue(ROLLOUT_STRATEGY_ALL_USERS);
+ return wrapper.vm.$nextTick().then(() => {
+ expect(scope.find('#rollout-user-id-0').exists()).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe('deleting an existing scope', () => {
+ beforeEach(() => {
+ wrapper.find('.js-delete-scope').vm.$emit('click');
+ });
+
+ it('should add `shouldBeDestroyed` key the clicked scope', () => {
+ expect(wrapper.vm.formScopes[0].shouldBeDestroyed).toBe(true);
+ });
+
+ it('should not render deleted scopes', () => {
+ expect(wrapper.vm.filteredScopes).toEqual([expect.objectContaining({ id: 2 })]);
+ });
+ });
+
+ describe('deleting a new scope', () => {
+ it('should remove the scope from formScopes', () => {
+ factory({
+ ...requiredProps,
+ name: 'feature_flag_1',
+ description: 'this is a feature flag',
+ scopes: [
+ {
+ environmentScope: 'new_scope',
+ active: false,
+ id: uniqueId(INTERNAL_ID_PREFIX),
+ canUpdate: true,
+ protected: false,
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ },
+ ],
+ },
+ ],
+ });
+
+ wrapper.find('.js-delete-scope').vm.$emit('click');
+
+ expect(wrapper.vm.formScopes).toEqual([]);
+ });
+ });
+
+ describe('with * scope', () => {
+ beforeEach(() => {
+ factory({
+ ...requiredProps,
+ name: 'feature_flag_1',
+ description: 'this is a feature flag',
+ scopes: [
+ {
+ environmentScope: '*',
+ active: false,
+ canUpdate: false,
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ },
+ ],
+ });
+ });
+
+ it('renders read only name', () => {
+ expect(wrapper.find('.js-scope-all').exists()).toEqual(true);
+ });
+ });
+
+ describe('without permission to update', () => {
+ it('should have the flag name input disabled', () => {
+ const input = wrapper.find('#feature-flag-name');
+
+ expect(input.element.disabled).toBe(true);
+ });
+
+ it('should have the flag discription text area disabled', () => {
+ const textarea = wrapper.find('#feature-flag-description');
+
+ expect(textarea.element.disabled).toBe(true);
+ });
+
+ it('should have the scope that cannot be updated be disabled', () => {
+ const row = wrapper.findAll('.gl-responsive-table-row').at(2);
+
+ expect(row.find(EnvironmentsDropdown).vm.disabled).toBe(true);
+ expect(row.find(ToggleButton).vm.disabledInput).toBe(true);
+ expect(row.find('.js-delete-scope').exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('on submit', () => {
+ const selectFirstRolloutStrategyOption = dropdownIndex => {
+ wrapper
+ .findAll('select.js-rollout-strategy')
+ .at(dropdownIndex)
+ .findAll('option')
+ .at(1)
+ .setSelected();
+ };
+
+ beforeEach(() => {
+ factory({
+ ...requiredProps,
+ name: 'feature_flag_1',
+ active: true,
+ description: 'this is a feature flag',
+ scopes: [
+ {
+ id: 1,
+ environmentScope: 'production',
+ canUpdate: true,
+ protected: true,
+ active: false,
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ rolloutUserIds: '',
+ },
+ ],
+ });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('should emit handleSubmit with the updated data', () => {
+ wrapper.find('#feature-flag-name').setValue('feature_flag_2');
+
+ return wrapper.vm
+ .$nextTick()
+ .then(() => {
+ wrapper
+ .find('.js-new-scope-name')
+ .find(EnvironmentsDropdown)
+ .vm.$emit('selectEnvironment', 'review');
+
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ wrapper
+ .find('.js-add-new-scope')
+ .find(ToggleButton)
+ .vm.$emit('change', true);
+ })
+ .then(() => {
+ wrapper.find(ToggleButton).vm.$emit('change', true);
+ return wrapper.vm.$nextTick();
+ })
+
+ .then(() => {
+ selectFirstRolloutStrategyOption(0);
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ selectFirstRolloutStrategyOption(2);
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ wrapper.find('.js-rollout-percentage').setValue('55');
+
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ wrapper.find({ ref: 'submitButton' }).vm.$emit('click');
+
+ const data = wrapper.emitted().handleSubmit[0][0];
+
+ expect(data.name).toEqual('feature_flag_2');
+ expect(data.description).toEqual('this is a feature flag');
+ expect(data.active).toBe(true);
+
+ expect(data.scopes).toEqual([
+ {
+ id: 1,
+ active: true,
+ environmentScope: 'production',
+ canUpdate: true,
+ protected: true,
+ rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ rolloutPercentage: '55',
+ rolloutUserIds: '',
+ shouldIncludeUserIds: false,
+ },
+ {
+ id: expect.any(String),
+ active: false,
+ environmentScope: 'review',
+ canUpdate: true,
+ protected: false,
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ rolloutUserIds: '',
+ },
+ {
+ id: expect.any(String),
+ active: true,
+ environmentScope: '',
+ canUpdate: true,
+ protected: false,
+ rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ rolloutUserIds: '',
+ shouldIncludeUserIds: false,
+ },
+ ]);
+ });
+ });
+ });
+ });
+
+ describe('with strategies', () => {
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [userList] });
+ factory({
+ ...requiredProps,
+ name: featureFlag.name,
+ description: featureFlag.description,
+ active: true,
+ version: NEW_VERSION_FLAG,
+ strategies: [
+ {
+ type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '30' },
+ scopes: [],
+ },
+ {
+ type: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ scopes: [{ environment_scope: 'review/*' }],
+ },
+ ],
+ });
+ });
+
+ it('should request the user lists on mount', () => {
+ return wrapper.vm.$nextTick(() => {
+ expect(Api.fetchFeatureFlagUserLists).toHaveBeenCalledWith('1');
+ });
+ });
+
+ it('should show the strategy component', () => {
+ const strategy = wrapper.find(Strategy);
+ expect(strategy.exists()).toBe(true);
+ expect(strategy.props('strategy')).toEqual({
+ type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '30' },
+ scopes: [],
+ });
+ });
+
+ it('should show one strategy component per strategy', () => {
+ expect(wrapper.findAll(Strategy)).toHaveLength(2);
+ });
+
+ it('adds an all users strategy when clicking the Add button', () => {
+ wrapper.find(GlButton).vm.$emit('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ const strategies = wrapper.findAll(Strategy);
+
+ expect(strategies).toHaveLength(3);
+ expect(strategies.at(2).props('strategy')).toEqual(allUsersStrategy);
+ });
+ });
+
+ it('should remove a strategy on delete', () => {
+ const strategy = {
+ type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '30' },
+ scopes: [],
+ };
+ wrapper.find(Strategy).vm.$emit('delete');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.findAll(Strategy)).toHaveLength(1);
+ expect(wrapper.find(Strategy).props('strategy')).not.toEqual(strategy);
+ });
+ });
+
+ it('should provide the user lists to the strategy', () => {
+ expect(wrapper.find(Strategy).props('userLists')).toEqual([userList]);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js b/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js
new file mode 100644
index 00000000000..10e9ed4d3bf
--- /dev/null
+++ b/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js
@@ -0,0 +1,103 @@
+import { shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui';
+import NewEnvironmentsDropdown from '~/feature_flags/components/new_environments_dropdown.vue';
+import axios from '~/lib/utils/axios_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
+
+const TEST_HOST = '/test';
+const TEST_SEARCH = 'production';
+
+describe('New Environments Dropdown', () => {
+ let wrapper;
+ let axiosMock;
+
+ beforeEach(() => {
+ axiosMock = new MockAdapter(axios);
+ wrapper = shallowMount(NewEnvironmentsDropdown, { propsData: { endpoint: TEST_HOST } });
+ });
+
+ afterEach(() => {
+ axiosMock.restore();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ describe('before results', () => {
+ it('should show a loading icon', () => {
+ axiosMock.onGet(TEST_HOST).reply(() => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+ wrapper.find(GlSearchBoxByType).vm.$emit('focus');
+ return axios.waitForAll();
+ });
+
+ it('should not show any dropdown items', () => {
+ axiosMock.onGet(TEST_HOST).reply(() => {
+ expect(wrapper.findAll(GlDropdownItem)).toHaveLength(0);
+ });
+ wrapper.find(GlSearchBoxByType).vm.$emit('focus');
+ return axios.waitForAll();
+ });
+ });
+
+ describe('with empty results', () => {
+ let item;
+ beforeEach(() => {
+ axiosMock.onGet(TEST_HOST).reply(200, []);
+ wrapper.find(GlSearchBoxByType).vm.$emit('focus');
+ wrapper.find(GlSearchBoxByType).vm.$emit('input', TEST_SEARCH);
+ return axios
+ .waitForAll()
+ .then(() => wrapper.vm.$nextTick())
+ .then(() => {
+ item = wrapper.find(GlDropdownItem);
+ });
+ });
+
+ it('should display a Create item label', () => {
+ expect(item.text()).toBe('Create production');
+ });
+
+ it('should display that no matching items are found', () => {
+ expect(wrapper.find({ ref: 'noResults' }).exists()).toBe(true);
+ });
+
+ it('should emit a new scope when selected', () => {
+ item.vm.$emit('click');
+ expect(wrapper.emitted('add')).toEqual([[TEST_SEARCH]]);
+ });
+ });
+
+ describe('with results', () => {
+ let items;
+ beforeEach(() => {
+ axiosMock.onGet(TEST_HOST).reply(httpStatusCodes.OK, ['prod', 'production']);
+ wrapper.find(GlSearchBoxByType).vm.$emit('focus');
+ wrapper.find(GlSearchBoxByType).vm.$emit('input', 'prod');
+ return axios.waitForAll().then(() => {
+ items = wrapper.findAll(GlDropdownItem);
+ });
+ });
+
+ it('should display one item per result', () => {
+ expect(items).toHaveLength(2);
+ });
+
+ it('should emit an add if an item is clicked', () => {
+ items.at(0).vm.$emit('click');
+ expect(wrapper.emitted('add')).toEqual([['prod']]);
+ });
+
+ it('should not display a create label', () => {
+ items = items.filter(i => i.text().startsWith('Create'));
+ expect(items).toHaveLength(0);
+ });
+
+ it('should not display a message about no results', () => {
+ expect(wrapper.find({ ref: 'noResults' }).exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/new_feature_flag_spec.js b/spec/frontend/feature_flags/components/new_feature_flag_spec.js
new file mode 100644
index 00000000000..284ba09d7fd
--- /dev/null
+++ b/spec/frontend/feature_flags/components/new_feature_flag_spec.js
@@ -0,0 +1,145 @@
+import Vuex from 'vuex';
+import { shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import { GlAlert } from '@gitlab/ui';
+import { TEST_HOST } from 'spec/test_constants';
+import Form from '~/feature_flags/components/form.vue';
+import newModule from '~/feature_flags/store/modules/new';
+import NewFeatureFlag from '~/feature_flags/components/new_feature_flag.vue';
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ DEFAULT_PERCENT_ROLLOUT,
+ NEW_FLAG_ALERT,
+} from '~/feature_flags/constants';
+import axios from '~/lib/utils/axios_utils';
+import { allUsersStrategy } from '../mock_data';
+
+const userCalloutId = 'feature_flags_new_version';
+const userCalloutsPath = `${TEST_HOST}/user_callouts`;
+
+describe('New feature flag form', () => {
+ let wrapper;
+
+ const store = new Vuex.Store({
+ modules: {
+ new: newModule,
+ },
+ });
+
+ const factory = (opts = {}) => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ wrapper = shallowMount(NewFeatureFlag, {
+ propsData: {
+ endpoint: `${TEST_HOST}/feature_flags.json`,
+ path: '/feature_flags',
+ environmentsEndpoint: 'environments.json',
+ projectId: '8',
+ showUserCallout: true,
+ userCalloutId,
+ userCalloutsPath,
+ },
+ store,
+ provide: {
+ glFeatures: {
+ featureFlagsNewVersion: true,
+ },
+ },
+ ...opts,
+ });
+ };
+
+ beforeEach(() => {
+ factory();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findAlert = () => wrapper.find(GlAlert);
+
+ describe('with error', () => {
+ it('should render the error', () => {
+ store.dispatch('new/receiveCreateFeatureFlagError', { message: ['The name is required'] });
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find('.alert').exists()).toEqual(true);
+ expect(wrapper.find('.alert').text()).toContain('The name is required');
+ });
+ });
+ });
+
+ it('renders form title', () => {
+ expect(wrapper.find('h3').text()).toEqual('New feature flag');
+ });
+
+ it('should render feature flag form', () => {
+ expect(wrapper.find(Form).exists()).toEqual(true);
+ });
+
+ it('does not render the related issues widget', () => {
+ expect(wrapper.find(Form).props('featureFlagIssuesEndpoint')).toBe('');
+ });
+
+ it('should render default * row', () => {
+ const defaultScope = {
+ id: expect.any(String),
+ environmentScope: '*',
+ active: true,
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ rolloutUserIds: '',
+ };
+ expect(wrapper.vm.scopes).toEqual([defaultScope]);
+
+ expect(wrapper.find(Form).props('scopes')).toContainEqual(defaultScope);
+ });
+
+ it('should not alert users that feature flags are changing soon', () => {
+ expect(wrapper.find(GlAlert).exists()).toBe(false);
+ });
+
+ it('should pass in the project ID', () => {
+ expect(wrapper.find(Form).props('projectId')).toBe('8');
+ });
+
+ it('has an all users strategy by default', () => {
+ const strategies = wrapper.find(Form).props('strategies');
+
+ expect(strategies).toEqual([allUsersStrategy]);
+ });
+
+ describe('without new version flags', () => {
+ beforeEach(() => factory({ provide: { glFeatures: { featureFlagsNewVersion: false } } }));
+
+ it('should alert users that feature flags are changing soon', () => {
+ expect(findAlert().text()).toBe(NEW_FLAG_ALERT);
+ });
+ });
+
+ describe('dismissing new version alert', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onPost(userCalloutsPath, { feature_name: userCalloutId }).reply(200);
+ factory({ provide: { glFeatures: { featureFlagsNewVersion: false } } });
+ findAlert().vm.$emit('dismiss');
+ return wrapper.vm.$nextTick();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should hide the alert', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('should send the dismissal event', () => {
+ expect(mock.history.post.length).toBe(1);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/strategy_spec.js b/spec/frontend/feature_flags/components/strategy_spec.js
new file mode 100644
index 00000000000..8436f1cbe97
--- /dev/null
+++ b/spec/frontend/feature_flags/components/strategy_spec.js
@@ -0,0 +1,320 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlFormSelect, GlFormTextarea, GlFormInput, GlLink, GlToken, GlButton } from '@gitlab/ui';
+import {
+ PERCENT_ROLLOUT_GROUP_ID,
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+} from '~/feature_flags/constants';
+import Strategy from '~/feature_flags/components/strategy.vue';
+import NewEnvironmentsDropdown from '~/feature_flags/components/new_environments_dropdown.vue';
+
+import { userList } from '../mock_data';
+
+const provide = {
+ strategyTypeDocsPagePath: 'link-to-strategy-docs',
+ environmentsScopeDocsPath: 'link-scope-docs',
+};
+
+describe('Feature flags strategy', () => {
+ let wrapper;
+
+ const findStrategy = () => wrapper.find('[data-testid="strategy"]');
+ const findDocsLinks = () => wrapper.findAll(GlLink);
+
+ const factory = (
+ opts = {
+ propsData: {
+ strategy: {},
+ index: 0,
+ endpoint: '',
+ userLists: [userList],
+ },
+ provide,
+ },
+ ) => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ wrapper = shallowMount(Strategy, opts);
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ describe('helper links', () => {
+ const propsData = { strategy: {}, index: 0, endpoint: '', userLists: [userList] };
+ factory({ propsData, provide });
+
+ it('should display 2 helper links', () => {
+ const links = findDocsLinks();
+ expect(links.exists()).toBe(true);
+ expect(links.at(0).attributes('href')).toContain('docs');
+ expect(links.at(1).attributes('href')).toContain('docs');
+ });
+ });
+
+ describe.each`
+ name | parameter | value | newValue | input
+ ${ROLLOUT_STRATEGY_ALL_USERS} | ${null} | ${null} | ${null} | ${null}
+ ${ROLLOUT_STRATEGY_PERCENT_ROLLOUT} | ${'percentage'} | ${'50'} | ${'20'} | ${GlFormInput}
+ ${ROLLOUT_STRATEGY_USER_ID} | ${'userIds'} | ${'1,2'} | ${'1,2,3'} | ${GlFormTextarea}
+ `('with strategy $name', ({ name, parameter, value, newValue, input }) => {
+ let propsData;
+ let strategy;
+ beforeEach(() => {
+ const parameters = {};
+ if (parameter !== null) {
+ parameters[parameter] = value;
+ }
+ strategy = { name, parameters };
+ propsData = { strategy, index: 0, endpoint: '' };
+ factory({ propsData, provide });
+ });
+
+ it('should set the select to match the strategy name', () => {
+ expect(wrapper.find(GlFormSelect).attributes('value')).toBe(name);
+ });
+
+ it('should not show inputs for other parameters', () => {
+ [GlFormTextarea, GlFormInput, GlFormSelect]
+ .filter(component => component !== input)
+ .map(component => findStrategy().findAll(component))
+ .forEach(inputWrapper => expect(inputWrapper).toHaveLength(0));
+ });
+
+ if (parameter !== null) {
+ it(`should show the input for ${parameter} with the correct value`, () => {
+ const inputWrapper = findStrategy().find(input);
+ expect(inputWrapper.exists()).toBe(true);
+ expect(inputWrapper.attributes('value')).toBe(value);
+ });
+
+ it(`should emit a change event when altering ${parameter}`, () => {
+ const inputWrapper = findStrategy().find(input);
+ inputWrapper.vm.$emit('input', newValue);
+ expect(wrapper.emitted('change')).toEqual([
+ [{ name, parameters: expect.objectContaining({ [parameter]: newValue }), scopes: [] }],
+ ]);
+ });
+ }
+ });
+
+ describe('with strategy gitlabUserList', () => {
+ let propsData;
+ let strategy;
+ beforeEach(() => {
+ strategy = { name: ROLLOUT_STRATEGY_GITLAB_USER_LIST, userListId: '2', parameters: {} };
+ propsData = { strategy, index: 0, endpoint: '', userLists: [userList] };
+ factory({ propsData, provide });
+ });
+
+ it('should set the select to match the strategy name', () => {
+ expect(wrapper.find(GlFormSelect).attributes('value')).toBe(
+ ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+ );
+ });
+
+ it('should not show inputs for other parameters', () => {
+ expect(
+ findStrategy()
+ .find(GlFormTextarea)
+ .exists(),
+ ).toBe(false);
+ expect(
+ findStrategy()
+ .find(GlFormInput)
+ .exists(),
+ ).toBe(false);
+ });
+
+ it('should show the input for userListId with the correct value', () => {
+ const inputWrapper = findStrategy().find(GlFormSelect);
+ expect(inputWrapper.exists()).toBe(true);
+ expect(inputWrapper.attributes('value')).toBe('2');
+ });
+
+ it('should emit a change event when altering the userListId', () => {
+ const inputWrapper = findStrategy().find(GlFormSelect);
+ inputWrapper.vm.$emit('input', '3');
+ inputWrapper.vm.$emit('change', '3');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('change')).toEqual([
+ [
+ {
+ name: ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+ userListId: '3',
+ scopes: [],
+ parameters: {},
+ },
+ ],
+ ]);
+ });
+ });
+ });
+
+ describe('with a strategy', () => {
+ describe('with a single environment scope defined', () => {
+ let strategy;
+
+ beforeEach(() => {
+ strategy = {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '50' },
+ scopes: [{ environmentScope: 'production' }],
+ };
+ const propsData = { strategy, index: 0, endpoint: '' };
+ factory({ propsData, provide });
+ });
+
+ it('should revert to all-environments scope when last scope is removed', () => {
+ const token = wrapper.find(GlToken);
+ token.vm.$emit('close');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.findAll(GlToken)).toHaveLength(0);
+ expect(wrapper.emitted('change')).toEqual([
+ [
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID },
+ scopes: [{ environmentScope: '*' }],
+ },
+ ],
+ ]);
+ });
+ });
+ });
+
+ describe('with an all-environments scope defined', () => {
+ let strategy;
+
+ beforeEach(() => {
+ strategy = {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '50' },
+ scopes: [{ environmentScope: '*' }],
+ };
+ const propsData = { strategy, index: 0, endpoint: '' };
+ factory({ propsData, provide });
+ });
+
+ it('should change the parameters if a different strategy is chosen', () => {
+ const select = wrapper.find(GlFormSelect);
+ select.vm.$emit('input', ROLLOUT_STRATEGY_ALL_USERS);
+ select.vm.$emit('change', ROLLOUT_STRATEGY_ALL_USERS);
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(GlFormInput).exists()).toBe(false);
+ expect(wrapper.emitted('change')).toEqual([
+ [
+ {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ scopes: [{ environmentScope: '*' }],
+ },
+ ],
+ ]);
+ });
+ });
+
+ it('should display selected scopes', () => {
+ const dropdown = wrapper.find(NewEnvironmentsDropdown);
+ dropdown.vm.$emit('add', 'production');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.findAll(GlToken)).toHaveLength(1);
+ expect(wrapper.find(GlToken).text()).toBe('production');
+ });
+ });
+
+ it('should display all selected scopes', () => {
+ const dropdown = wrapper.find(NewEnvironmentsDropdown);
+ dropdown.vm.$emit('add', 'production');
+ dropdown.vm.$emit('add', 'staging');
+ return wrapper.vm.$nextTick().then(() => {
+ const tokens = wrapper.findAll(GlToken);
+ expect(tokens).toHaveLength(2);
+ expect(tokens.at(0).text()).toBe('production');
+ expect(tokens.at(1).text()).toBe('staging');
+ });
+ });
+
+ it('should emit selected scopes', () => {
+ const dropdown = wrapper.find(NewEnvironmentsDropdown);
+ dropdown.vm.$emit('add', 'production');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('change')).toEqual([
+ [
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID },
+ scopes: [
+ { environmentScope: '*', shouldBeDestroyed: true },
+ { environmentScope: 'production' },
+ ],
+ },
+ ],
+ ]);
+ });
+ });
+
+ it('should emit a delete if the delete button is clicked', () => {
+ wrapper.find(GlButton).vm.$emit('click');
+ expect(wrapper.emitted('delete')).toEqual([[]]);
+ });
+ });
+
+ describe('without scopes defined', () => {
+ beforeEach(() => {
+ const strategy = {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '50' },
+ scopes: [],
+ };
+ const propsData = { strategy, index: 0, endpoint: '' };
+ factory({ propsData, provide });
+ });
+
+ it('should display selected scopes', () => {
+ const dropdown = wrapper.find(NewEnvironmentsDropdown);
+ dropdown.vm.$emit('add', 'production');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.findAll(GlToken)).toHaveLength(1);
+ expect(wrapper.find(GlToken).text()).toBe('production');
+ });
+ });
+
+ it('should display all selected scopes', () => {
+ const dropdown = wrapper.find(NewEnvironmentsDropdown);
+ dropdown.vm.$emit('add', 'production');
+ dropdown.vm.$emit('add', 'staging');
+ return wrapper.vm.$nextTick().then(() => {
+ const tokens = wrapper.findAll(GlToken);
+ expect(tokens).toHaveLength(2);
+ expect(tokens.at(0).text()).toBe('production');
+ expect(tokens.at(1).text()).toBe('staging');
+ });
+ });
+
+ it('should emit selected scopes', () => {
+ const dropdown = wrapper.find(NewEnvironmentsDropdown);
+ dropdown.vm.$emit('add', 'production');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('change')).toEqual([
+ [
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID },
+ scopes: [{ environmentScope: 'production' }],
+ },
+ ],
+ ]);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/user_lists_table_spec.js b/spec/frontend/feature_flags/components/user_lists_table_spec.js
new file mode 100644
index 00000000000..d6ced3be168
--- /dev/null
+++ b/spec/frontend/feature_flags/components/user_lists_table_spec.js
@@ -0,0 +1,98 @@
+import { mount } from '@vue/test-utils';
+import * as timeago from 'timeago.js';
+import { GlModal } from '@gitlab/ui';
+import UserListsTable from '~/feature_flags/components/user_lists_table.vue';
+import { userList } from '../mock_data';
+
+jest.mock('timeago.js', () => ({
+ format: jest.fn().mockReturnValue('2 weeks ago'),
+ register: jest.fn(),
+}));
+
+describe('User Lists Table', () => {
+ let wrapper;
+ let userLists;
+
+ beforeEach(() => {
+ userLists = new Array(5).fill(userList).map((x, i) => ({ ...x, id: i }));
+ wrapper = mount(UserListsTable, {
+ propsData: { userLists },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should display the details of a user list', () => {
+ expect(wrapper.find('[data-testid="ffUserListName"]').text()).toBe(userList.name);
+ expect(wrapper.find('[data-testid="ffUserListIds"]').text()).toBe(
+ userList.user_xids.replace(/,/g, ', '),
+ );
+ expect(wrapper.find('[data-testid="ffUserListTimestamp"]').text()).toBe('created 2 weeks ago');
+ expect(timeago.format).toHaveBeenCalledWith(userList.created_at);
+ });
+
+ it('should set the title for a tooltip on the created stamp', () => {
+ expect(wrapper.find('[data-testid="ffUserListTimestamp"]').attributes('title')).toBe(
+ 'Feb 4, 2020 8:13am GMT+0000',
+ );
+ });
+
+ it('should display a user list entry per user list', () => {
+ const lists = wrapper.findAll('[data-testid="ffUserList"]');
+ expect(lists).toHaveLength(5);
+ lists.wrappers.forEach(list => {
+ expect(list.find('[data-testid="ffUserListName"]').exists()).toBe(true);
+ expect(list.find('[data-testid="ffUserListIds"]').exists()).toBe(true);
+ expect(list.find('[data-testid="ffUserListTimestamp"]').exists()).toBe(true);
+ });
+ });
+
+ describe('edit button', () => {
+ it('should link to the path for the user list', () => {
+ expect(wrapper.find('[data-testid="edit-user-list"]').attributes('href')).toBe(userList.path);
+ });
+ });
+
+ describe('delete button', () => {
+ it('should display the confirmation modal', () => {
+ const modal = wrapper.find(GlModal);
+
+ wrapper.find('[data-testid="delete-user-list"]').trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(modal.text()).toContain(`Delete ${userList.name}?`);
+ expect(modal.text()).toContain(`User list ${userList.name} will be removed.`);
+ });
+ });
+ });
+
+ describe('confirmation modal', () => {
+ let modal;
+
+ beforeEach(() => {
+ modal = wrapper.find(GlModal);
+
+ wrapper.find('button').trigger('click');
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('should emit delete with list on confirmation', () => {
+ modal.find('[data-testid="modal-confirm"]').trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('delete')).toEqual([[userLists[0]]]);
+ });
+ });
+
+ it('should not emit delete with list when not confirmed', () => {
+ modal.find('button').trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('delete')).toBeUndefined();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/mock_data.js b/spec/frontend/feature_flags/mock_data.js
new file mode 100644
index 00000000000..47e4957f208
--- /dev/null
+++ b/spec/frontend/feature_flags/mock_data.js
@@ -0,0 +1,109 @@
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+} from '~/feature_flags/constants';
+
+export const featureFlag = {
+ id: 1,
+ active: true,
+ created_at: '2018-12-12T22:07:31.401Z',
+ updated_at: '2018-12-12T22:07:31.401Z',
+ name: 'test flag',
+ description: 'flag for tests',
+ destroy_path: 'feature_flags/1',
+ update_path: 'feature_flags/1',
+ edit_path: 'feature_flags/1/edit',
+ scopes: [
+ {
+ id: 1,
+ active: true,
+ environment_scope: '*',
+ can_update: true,
+ protected: false,
+ created_at: '2019-01-14T06:41:40.987Z',
+ updated_at: '2019-01-14T06:41:40.987Z',
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ },
+ ],
+ },
+ {
+ id: 2,
+ active: false,
+ environment_scope: 'production',
+ can_update: true,
+ protected: false,
+ created_at: '2019-01-14T06:41:40.987Z',
+ updated_at: '2019-01-14T06:41:40.987Z',
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ },
+ ],
+ },
+ {
+ id: 3,
+ active: false,
+ environment_scope: 'review/*',
+ can_update: true,
+ protected: false,
+ created_at: '2019-01-14T06:41:40.987Z',
+ updated_at: '2019-01-14T06:41:40.987Z',
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ },
+ ],
+ },
+ {
+ id: 4,
+ active: true,
+ environment_scope: 'development',
+ can_update: true,
+ protected: false,
+ created_at: '2019-01-14T06:41:40.987Z',
+ updated_at: '2019-01-14T06:41:40.987Z',
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: {
+ percentage: '86',
+ },
+ },
+ ],
+ },
+ ],
+};
+
+export const getRequestData = {
+ feature_flags: [featureFlag],
+ count: {
+ all: 1,
+ disabled: 1,
+ enabled: 0,
+ },
+};
+
+export const rotateData = { token: 'oP6sCNRqtRHmpy1gw2-F' };
+
+export const userList = {
+ name: 'test_users',
+ user_xids: 'user3,user4,user5',
+ id: 2,
+ iid: 2,
+ project_id: 1,
+ created_at: '2020-02-04T08:13:10.507Z',
+ updated_at: '2020-02-04T08:13:10.507Z',
+ path: '/path/to/user/list',
+ edit_path: '/path/to/user/list/edit',
+};
+
+export const allUsersStrategy = {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ scopes: [],
+};
diff --git a/spec/frontend/feature_flags/store/edit/actions_spec.js b/spec/frontend/feature_flags/store/edit/actions_spec.js
new file mode 100644
index 00000000000..4f20b9713bf
--- /dev/null
+++ b/spec/frontend/feature_flags/store/edit/actions_spec.js
@@ -0,0 +1,334 @@
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import { TEST_HOST } from 'spec/test_constants';
+import {
+ setEndpoint,
+ setPath,
+ updateFeatureFlag,
+ requestUpdateFeatureFlag,
+ receiveUpdateFeatureFlagSuccess,
+ receiveUpdateFeatureFlagError,
+ fetchFeatureFlag,
+ requestFeatureFlag,
+ receiveFeatureFlagSuccess,
+ receiveFeatureFlagError,
+ toggleActive,
+} from '~/feature_flags/store/modules/edit/actions';
+import state from '~/feature_flags/store/modules/edit/state';
+import {
+ mapStrategiesToRails,
+ mapFromScopesViewModel,
+} from '~/feature_flags/store/modules/helpers';
+import {
+ NEW_VERSION_FLAG,
+ LEGACY_FLAG,
+ ROLLOUT_STRATEGY_ALL_USERS,
+} from '~/feature_flags/constants';
+import * as types from '~/feature_flags/store/modules/edit/mutation_types';
+import axios from '~/lib/utils/axios_utils';
+
+jest.mock('~/lib/utils/url_utility');
+
+describe('Feature flags Edit Module actions', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = state();
+ });
+
+ describe('setEndpoint', () => {
+ it('should commit SET_ENDPOINT mutation', done => {
+ testAction(
+ setEndpoint,
+ 'feature_flags.json',
+ mockedState,
+ [{ type: types.SET_ENDPOINT, payload: 'feature_flags.json' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setPath', () => {
+ it('should commit SET_PATH mutation', done => {
+ testAction(
+ setPath,
+ '/feature_flags',
+ mockedState,
+ [{ type: types.SET_PATH, payload: '/feature_flags' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('updateFeatureFlag', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('success', () => {
+ it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagSuccess ', done => {
+ const featureFlag = {
+ name: 'feature_flag',
+ description: 'feature flag',
+ scopes: [
+ {
+ id: '1',
+ environmentScope: '*',
+ active: true,
+ shouldBeDestroyed: false,
+ canUpdate: true,
+ protected: false,
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ },
+ ],
+ version: LEGACY_FLAG,
+ active: true,
+ };
+ mock.onPut(mockedState.endpoint, mapFromScopesViewModel(featureFlag)).replyOnce(200);
+
+ testAction(
+ updateFeatureFlag,
+ featureFlag,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestUpdateFeatureFlag',
+ },
+ {
+ type: 'receiveUpdateFeatureFlagSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ it('handles new version flags as well', done => {
+ const featureFlag = {
+ name: 'name',
+ description: 'description',
+ active: true,
+ version: NEW_VERSION_FLAG,
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ id: 1,
+ scopes: [{ id: 1, environmentScope: 'environmentScope', shouldBeDestroyed: false }],
+ shouldBeDestroyed: false,
+ },
+ ],
+ };
+ mock.onPut(mockedState.endpoint, mapStrategiesToRails(featureFlag)).replyOnce(200);
+
+ testAction(
+ updateFeatureFlag,
+ featureFlag,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestUpdateFeatureFlag',
+ },
+ {
+ type: 'receiveUpdateFeatureFlagSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagError ', done => {
+ mock.onPut(`${TEST_HOST}/endpoint.json`).replyOnce(500, { message: [] });
+
+ testAction(
+ updateFeatureFlag,
+ {
+ name: 'feature_flag',
+ description: 'feature flag',
+ scopes: [{ environment_scope: '*', active: true }],
+ },
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestUpdateFeatureFlag',
+ },
+ {
+ type: 'receiveUpdateFeatureFlagError',
+ payload: { message: [] },
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('requestUpdateFeatureFlag', () => {
+ it('should commit REQUEST_UPDATE_FEATURE_FLAG mutation', done => {
+ testAction(
+ requestUpdateFeatureFlag,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_UPDATE_FEATURE_FLAG }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveUpdateFeatureFlagSuccess', () => {
+ it('should commit RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS mutation', done => {
+ testAction(
+ receiveUpdateFeatureFlagSuccess,
+ null,
+ mockedState,
+ [
+ {
+ type: types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveUpdateFeatureFlagError', () => {
+ it('should commit RECEIVE_UPDATE_FEATURE_FLAG_ERROR mutation', done => {
+ testAction(
+ receiveUpdateFeatureFlagError,
+ 'There was an error',
+ mockedState,
+ [{ type: types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, payload: 'There was an error' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchFeatureFlag', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('success', () => {
+ it('dispatches requestFeatureFlag and receiveFeatureFlagSuccess ', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { id: 1 });
+
+ testAction(
+ fetchFeatureFlag,
+ { id: 1 },
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestFeatureFlag',
+ },
+ {
+ type: 'receiveFeatureFlagSuccess',
+ payload: { id: 1 },
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ it('dispatches requestFeatureFlag and receiveUpdateFeatureFlagError ', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {});
+
+ testAction(
+ fetchFeatureFlag,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestFeatureFlag',
+ },
+ {
+ type: 'receiveFeatureFlagError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('requestFeatureFlag', () => {
+ it('should commit REQUEST_FEATURE_FLAG mutation', done => {
+ testAction(
+ requestFeatureFlag,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_FEATURE_FLAG }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveFeatureFlagSuccess', () => {
+ it('should commit RECEIVE_FEATURE_FLAG_SUCCESS mutation', done => {
+ testAction(
+ receiveFeatureFlagSuccess,
+ { id: 1 },
+ mockedState,
+ [{ type: types.RECEIVE_FEATURE_FLAG_SUCCESS, payload: { id: 1 } }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveFeatureFlagError', () => {
+ it('should commit RECEIVE_FEATURE_FLAG_ERROR mutation', done => {
+ testAction(
+ receiveFeatureFlagError,
+ null,
+ mockedState,
+ [
+ {
+ type: types.RECEIVE_FEATURE_FLAG_ERROR,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggelActive', () => {
+ it('should commit TOGGLE_ACTIVE mutation', done => {
+ testAction(
+ toggleActive,
+ true,
+ mockedState,
+ [{ type: types.TOGGLE_ACTIVE, payload: true }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/store/edit/mutations_spec.js b/spec/frontend/feature_flags/store/edit/mutations_spec.js
new file mode 100644
index 00000000000..21d4e962b48
--- /dev/null
+++ b/spec/frontend/feature_flags/store/edit/mutations_spec.js
@@ -0,0 +1,150 @@
+import state from '~/feature_flags/store/modules/edit/state';
+import mutations from '~/feature_flags/store/modules/edit/mutations';
+import * as types from '~/feature_flags/store/modules/edit/mutation_types';
+
+describe('Feature flags Edit Module Mutations', () => {
+ let stateCopy;
+
+ beforeEach(() => {
+ stateCopy = state();
+ });
+
+ describe('SET_ENDPOINT', () => {
+ it('should set endpoint', () => {
+ mutations[types.SET_ENDPOINT](stateCopy, 'feature_flags.json');
+
+ expect(stateCopy.endpoint).toEqual('feature_flags.json');
+ });
+ });
+
+ describe('SET_PATH', () => {
+ it('should set provided options', () => {
+ mutations[types.SET_PATH](stateCopy, 'feature_flags');
+
+ expect(stateCopy.path).toEqual('feature_flags');
+ });
+ });
+
+ describe('REQUEST_FEATURE_FLAG', () => {
+ it('should set isLoading to true', () => {
+ mutations[types.REQUEST_FEATURE_FLAG](stateCopy);
+
+ expect(stateCopy.isLoading).toEqual(true);
+ });
+
+ it('should set error to an empty array', () => {
+ mutations[types.REQUEST_FEATURE_FLAG](stateCopy);
+
+ expect(stateCopy.error).toEqual([]);
+ });
+ });
+
+ describe('RECEIVE_FEATURE_FLAG_SUCCESS', () => {
+ const data = {
+ name: '*',
+ description: 'All environments',
+ scopes: [{ id: 1 }],
+ iid: 5,
+ version: 'new_version_flag',
+ strategies: [
+ { id: 1, scopes: [{ environment_scope: '*' }], name: 'default', parameters: {} },
+ ],
+ };
+
+ beforeEach(() => {
+ mutations[types.RECEIVE_FEATURE_FLAG_SUCCESS](stateCopy, data);
+ });
+
+ it('should set isLoading to false', () => {
+ expect(stateCopy.isLoading).toEqual(false);
+ });
+
+ it('should set hasError to false', () => {
+ expect(stateCopy.hasError).toEqual(false);
+ });
+
+ it('should set name with the provided one', () => {
+ expect(stateCopy.name).toEqual(data.name);
+ });
+
+ it('should set description with the provided one', () => {
+ expect(stateCopy.description).toEqual(data.description);
+ });
+
+ it('should set scope with the provided one', () => {
+ expect(stateCopy.scope).toEqual(data.scope);
+ });
+
+ it('should set the iid to the provided one', () => {
+ expect(stateCopy.iid).toEqual(data.iid);
+ });
+
+ it('should set the version to the provided one', () => {
+ expect(stateCopy.version).toBe('new_version_flag');
+ });
+
+ it('should set the strategies to the provided one', () => {
+ expect(stateCopy.strategies).toEqual([
+ {
+ id: 1,
+ scopes: [{ environmentScope: '*', shouldBeDestroyed: false }],
+ name: 'default',
+ parameters: {},
+ shouldBeDestroyed: false,
+ },
+ ]);
+ });
+ });
+
+ describe('RECEIVE_FEATURE_FLAG_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_FEATURE_FLAG_ERROR](stateCopy);
+ });
+
+ it('should set isLoading to false', () => {
+ expect(stateCopy.isLoading).toEqual(false);
+ });
+
+ it('should set hasError to true', () => {
+ expect(stateCopy.hasError).toEqual(true);
+ });
+ });
+
+ describe('REQUEST_UPDATE_FEATURE_FLAG', () => {
+ beforeEach(() => {
+ mutations[types.REQUEST_UPDATE_FEATURE_FLAG](stateCopy);
+ });
+
+ it('should set isSendingRequest to true', () => {
+ expect(stateCopy.isSendingRequest).toEqual(true);
+ });
+
+ it('should set error to an empty array', () => {
+ expect(stateCopy.error).toEqual([]);
+ });
+ });
+
+ describe('RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS', () => {
+ it('should set isSendingRequest to false', () => {
+ mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](stateCopy);
+
+ expect(stateCopy.isSendingRequest).toEqual(false);
+ });
+ });
+
+ describe('RECEIVE_UPDATE_FEATURE_FLAG_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](stateCopy, {
+ message: ['Name is required'],
+ });
+ });
+
+ it('should set isSendingRequest to false', () => {
+ expect(stateCopy.isSendingRequest).toEqual(false);
+ });
+
+ it('should set error to the given message', () => {
+ expect(stateCopy.error).toEqual(['Name is required']);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/store/helpers_spec.js b/spec/frontend/feature_flags/store/helpers_spec.js
new file mode 100644
index 00000000000..0bc15ab70aa
--- /dev/null
+++ b/spec/frontend/feature_flags/store/helpers_spec.js
@@ -0,0 +1,514 @@
+import { uniqueId } from 'lodash';
+import {
+ mapToScopesViewModel,
+ mapFromScopesViewModel,
+ createNewEnvironmentScope,
+ mapStrategiesToViewModel,
+ mapStrategiesToRails,
+} from '~/feature_flags/store/modules/helpers';
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ PERCENT_ROLLOUT_GROUP_ID,
+ INTERNAL_ID_PREFIX,
+ DEFAULT_PERCENT_ROLLOUT,
+ LEGACY_FLAG,
+ NEW_VERSION_FLAG,
+} from '~/feature_flags/constants';
+
+describe('feature flags helpers spec', () => {
+ describe('mapToScopesViewModel', () => {
+ it('converts the data object from the Rails API into something more usable by Vue', () => {
+ const input = [
+ {
+ id: 3,
+ environment_scope: 'environment_scope',
+ active: true,
+ can_update: true,
+ protected: true,
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: {
+ percentage: '56',
+ },
+ },
+ {
+ name: ROLLOUT_STRATEGY_USER_ID,
+ parameters: {
+ userIds: '123,234',
+ },
+ },
+ ],
+
+ _destroy: true,
+ },
+ ];
+
+ const expected = [
+ expect.objectContaining({
+ id: 3,
+ environmentScope: 'environment_scope',
+ active: true,
+ canUpdate: true,
+ protected: true,
+ rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ rolloutPercentage: '56',
+ rolloutUserIds: '123, 234',
+ shouldBeDestroyed: true,
+ }),
+ ];
+
+ const actual = mapToScopesViewModel(input);
+
+ expect(actual).toEqual(expected);
+ });
+
+ it('returns Boolean properties even when their Rails counterparts were not provided (are `undefined`)', () => {
+ const input = [
+ {
+ id: 3,
+ environment_scope: 'environment_scope',
+ },
+ ];
+
+ const [result] = mapToScopesViewModel(input);
+
+ expect(result).toEqual(
+ expect.objectContaining({
+ active: false,
+ canUpdate: false,
+ protected: false,
+ shouldBeDestroyed: false,
+ }),
+ );
+ });
+
+ it('returns an empty array if null or undefined is provided as a parameter', () => {
+ expect(mapToScopesViewModel(null)).toEqual([]);
+ expect(mapToScopesViewModel(undefined)).toEqual([]);
+ });
+
+ describe('with user IDs per environment', () => {
+ let oldGon;
+
+ beforeEach(() => {
+ oldGon = window.gon;
+ window.gon = { features: { featureFlagsUsersPerEnvironment: true } };
+ });
+
+ afterEach(() => {
+ window.gon = oldGon;
+ });
+
+ it('sets the user IDs as a comma separated string', () => {
+ const input = [
+ {
+ id: 3,
+ environment_scope: 'environment_scope',
+ active: true,
+ can_update: true,
+ protected: true,
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: {
+ percentage: '56',
+ },
+ },
+ {
+ name: ROLLOUT_STRATEGY_USER_ID,
+ parameters: {
+ userIds: '123,234',
+ },
+ },
+ ],
+
+ _destroy: true,
+ },
+ ];
+
+ const expected = [
+ {
+ id: 3,
+ environmentScope: 'environment_scope',
+ active: true,
+ canUpdate: true,
+ protected: true,
+ rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ rolloutPercentage: '56',
+ rolloutUserIds: '123, 234',
+ shouldBeDestroyed: true,
+ shouldIncludeUserIds: true,
+ },
+ ];
+
+ const actual = mapToScopesViewModel(input);
+
+ expect(actual).toEqual(expected);
+ });
+ });
+ });
+
+ describe('mapFromScopesViewModel', () => {
+ it('converts the object emitted from the Vue component into an object than is in the right format to be submitted to the Rails API', () => {
+ const input = {
+ name: 'name',
+ description: 'description',
+ active: true,
+ scopes: [
+ {
+ id: 4,
+ environmentScope: 'environmentScope',
+ active: true,
+ canUpdate: true,
+ protected: true,
+ shouldBeDestroyed: true,
+ shouldIncludeUserIds: true,
+ rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ rolloutPercentage: '48',
+ rolloutUserIds: '123, 234',
+ },
+ ],
+ };
+
+ const expected = {
+ operations_feature_flag: {
+ name: 'name',
+ description: 'description',
+ active: true,
+ version: LEGACY_FLAG,
+ scopes_attributes: [
+ {
+ id: 4,
+ environment_scope: 'environmentScope',
+ active: true,
+ can_update: true,
+ protected: true,
+ _destroy: true,
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: {
+ groupId: PERCENT_ROLLOUT_GROUP_ID,
+ percentage: '48',
+ },
+ },
+ {
+ name: ROLLOUT_STRATEGY_USER_ID,
+ parameters: {
+ userIds: '123,234',
+ },
+ },
+ ],
+ },
+ ],
+ },
+ };
+
+ const actual = mapFromScopesViewModel(input);
+
+ expect(actual).toEqual(expected);
+ });
+
+ it('should strip out internal IDs', () => {
+ const input = {
+ scopes: [{ id: 3 }, { id: uniqueId(INTERNAL_ID_PREFIX) }],
+ };
+
+ const result = mapFromScopesViewModel(input);
+ const [realId, internalId] = result.operations_feature_flag.scopes_attributes;
+
+ expect(realId.id).toBe(3);
+ expect(internalId.id).toBeUndefined();
+ });
+
+ it('returns scopes_attributes as [] if param.scopes is null or undefined', () => {
+ let {
+ operations_feature_flag: { scopes_attributes: actualScopes },
+ } = mapFromScopesViewModel({ scopes: null });
+
+ expect(actualScopes).toEqual([]);
+
+ ({
+ operations_feature_flag: { scopes_attributes: actualScopes },
+ } = mapFromScopesViewModel({ scopes: undefined }));
+
+ expect(actualScopes).toEqual([]);
+ });
+ describe('with user IDs per environment', () => {
+ it('sets the user IDs as a comma separated string', () => {
+ const input = {
+ name: 'name',
+ description: 'description',
+ active: true,
+ scopes: [
+ {
+ id: 4,
+ environmentScope: 'environmentScope',
+ active: true,
+ canUpdate: true,
+ protected: true,
+ shouldBeDestroyed: true,
+ rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ rolloutPercentage: '48',
+ rolloutUserIds: '123, 234',
+ shouldIncludeUserIds: true,
+ },
+ ],
+ };
+
+ const expected = {
+ operations_feature_flag: {
+ name: 'name',
+ description: 'description',
+ version: LEGACY_FLAG,
+ active: true,
+ scopes_attributes: [
+ {
+ id: 4,
+ environment_scope: 'environmentScope',
+ active: true,
+ can_update: true,
+ protected: true,
+ _destroy: true,
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ parameters: {
+ groupId: PERCENT_ROLLOUT_GROUP_ID,
+ percentage: '48',
+ },
+ },
+ {
+ name: ROLLOUT_STRATEGY_USER_ID,
+ parameters: {
+ userIds: '123,234',
+ },
+ },
+ ],
+ },
+ ],
+ },
+ };
+
+ const actual = mapFromScopesViewModel(input);
+
+ expect(actual).toEqual(expected);
+ });
+ });
+ });
+
+ describe('createNewEnvironmentScope', () => {
+ it('should return a new environment scope object populated with the default options', () => {
+ const expected = {
+ environmentScope: '',
+ active: false,
+ id: expect.stringContaining(INTERNAL_ID_PREFIX),
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ rolloutUserIds: '',
+ };
+
+ const actual = createNewEnvironmentScope();
+
+ expect(actual).toEqual(expected);
+ });
+
+ it('should return a new environment scope object with overrides applied', () => {
+ const overrides = {
+ environmentScope: 'environmentScope',
+ active: true,
+ };
+
+ const expected = {
+ environmentScope: 'environmentScope',
+ active: true,
+ id: expect.stringContaining(INTERNAL_ID_PREFIX),
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ rolloutUserIds: '',
+ };
+
+ const actual = createNewEnvironmentScope(overrides);
+
+ expect(actual).toEqual(expected);
+ });
+
+ it('sets canUpdate and protected when called with featureFlagPermissions=true', () => {
+ expect(createNewEnvironmentScope({}, true)).toEqual(
+ expect.objectContaining({
+ canUpdate: true,
+ protected: false,
+ }),
+ );
+ });
+ });
+
+ describe('mapStrategiesToViewModel', () => {
+ it('should map rails casing to view model casing', () => {
+ expect(
+ mapStrategiesToViewModel([
+ {
+ id: '1',
+ name: 'default',
+ parameters: {},
+ scopes: [
+ {
+ environment_scope: '*',
+ id: '1',
+ },
+ ],
+ },
+ ]),
+ ).toEqual([
+ {
+ id: '1',
+ name: 'default',
+ parameters: {},
+ shouldBeDestroyed: false,
+ scopes: [
+ {
+ shouldBeDestroyed: false,
+ environmentScope: '*',
+ id: '1',
+ },
+ ],
+ },
+ ]);
+ });
+
+ it('inserts spaces between user ids', () => {
+ const strategy = mapStrategiesToViewModel([
+ {
+ id: '1',
+ name: 'userWithId',
+ parameters: { userIds: 'user1,user2,user3' },
+ scopes: [],
+ },
+ ])[0];
+
+ expect(strategy.parameters).toEqual({ userIds: 'user1, user2, user3' });
+ });
+ });
+
+ describe('mapStrategiesToRails', () => {
+ it('should map rails casing to view model casing', () => {
+ expect(
+ mapStrategiesToRails({
+ name: 'test',
+ description: 'test description',
+ version: NEW_VERSION_FLAG,
+ active: true,
+ strategies: [
+ {
+ id: '1',
+ name: 'default',
+ parameters: {},
+ shouldBeDestroyed: true,
+ scopes: [
+ {
+ environmentScope: '*',
+ id: '1',
+ shouldBeDestroyed: true,
+ },
+ ],
+ },
+ ],
+ }),
+ ).toEqual({
+ operations_feature_flag: {
+ name: 'test',
+ description: 'test description',
+ version: NEW_VERSION_FLAG,
+ active: true,
+ strategies_attributes: [
+ {
+ id: '1',
+ name: 'default',
+ parameters: {},
+ _destroy: true,
+ scopes_attributes: [
+ {
+ environment_scope: '*',
+ id: '1',
+ _destroy: true,
+ },
+ ],
+ },
+ ],
+ },
+ });
+ });
+
+ it('should insert a default * scope if there are none', () => {
+ expect(
+ mapStrategiesToRails({
+ name: 'test',
+ description: 'test description',
+ version: NEW_VERSION_FLAG,
+ active: true,
+ strategies: [
+ {
+ id: '1',
+ name: 'default',
+ parameters: {},
+ scopes: [],
+ },
+ ],
+ }),
+ ).toEqual({
+ operations_feature_flag: {
+ name: 'test',
+ description: 'test description',
+ version: NEW_VERSION_FLAG,
+ active: true,
+ strategies_attributes: [
+ {
+ id: '1',
+ name: 'default',
+ parameters: {},
+ scopes_attributes: [
+ {
+ environment_scope: '*',
+ },
+ ],
+ },
+ ],
+ },
+ });
+ });
+
+ it('removes white space between user ids', () => {
+ const result = mapStrategiesToRails({
+ name: 'test',
+ version: NEW_VERSION_FLAG,
+ active: true,
+ strategies: [
+ {
+ id: '1',
+ name: 'userWithId',
+ parameters: { userIds: 'user1, user2, user3' },
+ scopes: [],
+ },
+ ],
+ });
+
+ const strategyAttrs = result.operations_feature_flag.strategies_attributes[0];
+
+ expect(strategyAttrs.parameters).toEqual({ userIds: 'user1,user2,user3' });
+ });
+
+ it('preserves the value of active', () => {
+ const result = mapStrategiesToRails({
+ name: 'test',
+ version: NEW_VERSION_FLAG,
+ active: false,
+ strategies: [],
+ });
+
+ expect(result.operations_feature_flag.active).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/store/index/actions_spec.js b/spec/frontend/feature_flags/store/index/actions_spec.js
new file mode 100644
index 00000000000..0ada84aed33
--- /dev/null
+++ b/spec/frontend/feature_flags/store/index/actions_spec.js
@@ -0,0 +1,605 @@
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import { TEST_HOST } from 'spec/test_constants';
+import Api from '~/api';
+import {
+ requestFeatureFlags,
+ receiveFeatureFlagsSuccess,
+ receiveFeatureFlagsError,
+ fetchFeatureFlags,
+ setFeatureFlagsEndpoint,
+ setFeatureFlagsOptions,
+ setInstanceIdEndpoint,
+ setInstanceId,
+ rotateInstanceId,
+ requestRotateInstanceId,
+ receiveRotateInstanceIdSuccess,
+ receiveRotateInstanceIdError,
+ toggleFeatureFlag,
+ updateFeatureFlag,
+ receiveUpdateFeatureFlagSuccess,
+ receiveUpdateFeatureFlagError,
+ requestUserLists,
+ receiveUserListsSuccess,
+ receiveUserListsError,
+ fetchUserLists,
+ deleteUserList,
+ receiveDeleteUserListError,
+ clearAlert,
+} from '~/feature_flags/store/modules/index/actions';
+import { mapToScopesViewModel } from '~/feature_flags/store/modules/helpers';
+import state from '~/feature_flags/store/modules/index/state';
+import * as types from '~/feature_flags/store/modules/index/mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import { getRequestData, rotateData, featureFlag, userList } from '../../mock_data';
+
+jest.mock('~/api.js');
+
+describe('Feature flags actions', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = state();
+ });
+
+ describe('setFeatureFlagsEndpoint', () => {
+ it('should commit SET_FEATURE_FLAGS_ENDPOINT mutation', done => {
+ testAction(
+ setFeatureFlagsEndpoint,
+ 'feature_flags.json',
+ mockedState,
+ [{ type: types.SET_FEATURE_FLAGS_ENDPOINT, payload: 'feature_flags.json' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setFeatureFlagsOptions', () => {
+ it('should commit SET_FEATURE_FLAGS_OPTIONS mutation', done => {
+ testAction(
+ setFeatureFlagsOptions,
+ { page: '1', scope: 'all' },
+ mockedState,
+ [{ type: types.SET_FEATURE_FLAGS_OPTIONS, payload: { page: '1', scope: 'all' } }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setInstanceIdEndpoint', () => {
+ it('should commit SET_INSTANCE_ID_ENDPOINT mutation', done => {
+ testAction(
+ setInstanceIdEndpoint,
+ 'instance_id.json',
+ mockedState,
+ [{ type: types.SET_INSTANCE_ID_ENDPOINT, payload: 'instance_id.json' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setInstanceId', () => {
+ it('should commit SET_INSTANCE_ID mutation', done => {
+ testAction(
+ setInstanceId,
+ 'test_instance_id',
+ mockedState,
+ [{ type: types.SET_INSTANCE_ID, payload: 'test_instance_id' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchFeatureFlags', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('success', () => {
+ it('dispatches requestFeatureFlags and receiveFeatureFlagsSuccess ', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, getRequestData, {});
+
+ testAction(
+ fetchFeatureFlags,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestFeatureFlags',
+ },
+ {
+ payload: { data: getRequestData, headers: {} },
+ type: 'receiveFeatureFlagsSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ it('dispatches requestFeatureFlags and receiveFeatureFlagsError ', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {});
+
+ testAction(
+ fetchFeatureFlags,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestFeatureFlags',
+ },
+ {
+ type: 'receiveFeatureFlagsError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('requestFeatureFlags', () => {
+ it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', done => {
+ testAction(
+ requestFeatureFlags,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_FEATURE_FLAGS }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveFeatureFlagsSuccess', () => {
+ it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', done => {
+ testAction(
+ receiveFeatureFlagsSuccess,
+ { data: getRequestData, headers: {} },
+ mockedState,
+ [
+ {
+ type: types.RECEIVE_FEATURE_FLAGS_SUCCESS,
+ payload: { data: getRequestData, headers: {} },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveFeatureFlagsError', () => {
+ it('should commit RECEIVE_FEATURE_FLAGS_ERROR mutation', done => {
+ testAction(
+ receiveFeatureFlagsError,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_FEATURE_FLAGS_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchUserLists', () => {
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [userList], headers: {} });
+ });
+
+ describe('success', () => {
+ it('dispatches requestUserLists and receiveUserListsSuccess ', done => {
+ testAction(
+ fetchUserLists,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestUserLists',
+ },
+ {
+ payload: { data: [userList], headers: {} },
+ type: 'receiveUserListsSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ it('dispatches requestUserLists and receiveUserListsError ', done => {
+ Api.fetchFeatureFlagUserLists.mockRejectedValue();
+
+ testAction(
+ fetchUserLists,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestUserLists',
+ },
+ {
+ type: 'receiveUserListsError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('requestUserLists', () => {
+ it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', done => {
+ testAction(
+ requestUserLists,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_USER_LISTS }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveUserListsSuccess', () => {
+ it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', done => {
+ testAction(
+ receiveUserListsSuccess,
+ { data: [userList], headers: {} },
+ mockedState,
+ [
+ {
+ type: types.RECEIVE_USER_LISTS_SUCCESS,
+ payload: { data: [userList], headers: {} },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveUserListsError', () => {
+ it('should commit RECEIVE_USER_LISTS_ERROR mutation', done => {
+ testAction(
+ receiveUserListsError,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_USER_LISTS_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('rotateInstanceId', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.rotateEndpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('success', () => {
+ it('dispatches requestRotateInstanceId and receiveRotateInstanceIdSuccess ', done => {
+ mock.onPost(`${TEST_HOST}/endpoint.json`).replyOnce(200, rotateData, {});
+
+ testAction(
+ rotateInstanceId,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestRotateInstanceId',
+ },
+ {
+ payload: { data: rotateData, headers: {} },
+ type: 'receiveRotateInstanceIdSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ it('dispatches requestRotateInstanceId and receiveRotateInstanceIdError ', done => {
+ mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {});
+
+ testAction(
+ rotateInstanceId,
+ null,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestRotateInstanceId',
+ },
+ {
+ type: 'receiveRotateInstanceIdError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('requestRotateInstanceId', () => {
+ it('should commit REQUEST_ROTATE_INSTANCE_ID mutation', done => {
+ testAction(
+ requestRotateInstanceId,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_ROTATE_INSTANCE_ID }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveRotateInstanceIdSuccess', () => {
+ it('should commit RECEIVE_ROTATE_INSTANCE_ID_SUCCESS mutation', done => {
+ testAction(
+ receiveRotateInstanceIdSuccess,
+ { data: rotateData, headers: {} },
+ mockedState,
+ [
+ {
+ type: types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS,
+ payload: { data: rotateData, headers: {} },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveRotateInstanceIdError', () => {
+ it('should commit RECEIVE_ROTATE_INSTANCE_ID_ERROR mutation', done => {
+ testAction(
+ receiveRotateInstanceIdError,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_ROTATE_INSTANCE_ID_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggleFeatureFlag', () => {
+ let mock;
+
+ beforeEach(() => {
+ mockedState.featureFlags = getRequestData.feature_flags.map(flag => ({
+ ...flag,
+ scopes: mapToScopesViewModel(flag.scopes || []),
+ }));
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+ describe('success', () => {
+ it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', done => {
+ mock.onPut(featureFlag.update_path).replyOnce(200, featureFlag, {});
+
+ testAction(
+ toggleFeatureFlag,
+ featureFlag,
+ mockedState,
+ [],
+ [
+ {
+ type: 'updateFeatureFlag',
+ payload: featureFlag,
+ },
+ {
+ payload: featureFlag,
+ type: 'receiveUpdateFeatureFlagSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ describe('error', () => {
+ it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', done => {
+ mock.onPut(featureFlag.update_path).replyOnce(500);
+
+ testAction(
+ toggleFeatureFlag,
+ featureFlag,
+ mockedState,
+ [],
+ [
+ {
+ type: 'updateFeatureFlag',
+ payload: featureFlag,
+ },
+ {
+ payload: featureFlag.id,
+ type: 'receiveUpdateFeatureFlagError',
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+ describe('updateFeatureFlag', () => {
+ beforeEach(() => {
+ mockedState.featureFlags = getRequestData.feature_flags.map(f => ({
+ ...f,
+ scopes: mapToScopesViewModel(f.scopes || []),
+ }));
+ });
+
+ it('commits UPDATE_FEATURE_FLAG with the given flag', done => {
+ testAction(
+ updateFeatureFlag,
+ featureFlag,
+ mockedState,
+ [
+ {
+ type: 'UPDATE_FEATURE_FLAG',
+ payload: featureFlag,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+ describe('receiveUpdateFeatureFlagSuccess', () => {
+ beforeEach(() => {
+ mockedState.featureFlags = getRequestData.feature_flags.map(f => ({
+ ...f,
+ scopes: mapToScopesViewModel(f.scopes || []),
+ }));
+ });
+
+ it('commits RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS with the given flag', done => {
+ testAction(
+ receiveUpdateFeatureFlagSuccess,
+ featureFlag,
+ mockedState,
+ [
+ {
+ type: 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS',
+ payload: featureFlag,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+ describe('receiveUpdateFeatureFlagError', () => {
+ beforeEach(() => {
+ mockedState.featureFlags = getRequestData.feature_flags.map(f => ({
+ ...f,
+ scopes: mapToScopesViewModel(f.scopes || []),
+ }));
+ });
+
+ it('commits RECEIVE_UPDATE_FEATURE_FLAG_ERROR with the given flag id', done => {
+ testAction(
+ receiveUpdateFeatureFlagError,
+ featureFlag.id,
+ mockedState,
+ [
+ {
+ type: 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR',
+ payload: featureFlag.id,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+ describe('deleteUserList', () => {
+ beforeEach(() => {
+ mockedState.userLists = [userList];
+ });
+
+ describe('success', () => {
+ beforeEach(() => {
+ Api.deleteFeatureFlagUserList.mockResolvedValue();
+ });
+
+ it('should refresh the user lists', done => {
+ testAction(
+ deleteUserList,
+ userList,
+ mockedState,
+ [],
+ [{ type: 'requestDeleteUserList', payload: userList }, { type: 'fetchUserLists' }],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ Api.deleteFeatureFlagUserList.mockRejectedValue({ response: { data: 'some error' } });
+ });
+
+ it('should dispatch receiveDeleteUserListError', done => {
+ testAction(
+ deleteUserList,
+ userList,
+ mockedState,
+ [],
+ [
+ { type: 'requestDeleteUserList', payload: userList },
+ {
+ type: 'receiveDeleteUserListError',
+ payload: { list: userList, error: 'some error' },
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('receiveDeleteUserListError', () => {
+ it('should commit RECEIVE_DELETE_USER_LIST_ERROR with the given list', done => {
+ testAction(
+ receiveDeleteUserListError,
+ { list: userList, error: 'mock error' },
+ mockedState,
+ [
+ {
+ type: 'RECEIVE_DELETE_USER_LIST_ERROR',
+ payload: { list: userList, error: 'mock error' },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('clearAlert', () => {
+ it('should commit RECEIVE_CLEAR_ALERT', done => {
+ const alertIndex = 3;
+
+ testAction(
+ clearAlert,
+ alertIndex,
+ mockedState,
+ [{ type: 'RECEIVE_CLEAR_ALERT', payload: alertIndex }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/store/index/mutations_spec.js b/spec/frontend/feature_flags/store/index/mutations_spec.js
new file mode 100644
index 00000000000..5e236fe2222
--- /dev/null
+++ b/spec/frontend/feature_flags/store/index/mutations_spec.js
@@ -0,0 +1,332 @@
+import state from '~/feature_flags/store/modules/index/state';
+import mutations from '~/feature_flags/store/modules/index/mutations';
+import * as types from '~/feature_flags/store/modules/index/mutation_types';
+import { mapToScopesViewModel } from '~/feature_flags/store/modules/helpers';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { getRequestData, rotateData, featureFlag, userList } from '../../mock_data';
+
+describe('Feature flags store Mutations', () => {
+ let stateCopy;
+
+ beforeEach(() => {
+ stateCopy = state();
+ });
+
+ describe('SET_FEATURE_FLAGS_ENDPOINT', () => {
+ it('should set endpoint', () => {
+ mutations[types.SET_FEATURE_FLAGS_ENDPOINT](stateCopy, 'feature_flags.json');
+
+ expect(stateCopy.endpoint).toEqual('feature_flags.json');
+ });
+ });
+
+ describe('SET_FEATURE_FLAGS_OPTIONS', () => {
+ it('should set provided options', () => {
+ mutations[types.SET_FEATURE_FLAGS_OPTIONS](stateCopy, { page: '1', scope: 'all' });
+
+ expect(stateCopy.options).toEqual({ page: '1', scope: 'all' });
+ });
+ });
+
+ describe('SET_INSTANCE_ID_ENDPOINT', () => {
+ it('should set provided endpoint', () => {
+ mutations[types.SET_INSTANCE_ID_ENDPOINT](stateCopy, 'rotate_token.json');
+
+ expect(stateCopy.rotateEndpoint).toEqual('rotate_token.json');
+ });
+ });
+
+ describe('SET_INSTANCE_ID', () => {
+ it('should set provided token', () => {
+ mutations[types.SET_INSTANCE_ID](stateCopy, rotateData.token);
+
+ expect(stateCopy.instanceId).toEqual(rotateData.token);
+ });
+ });
+
+ describe('REQUEST_FEATURE_FLAGS', () => {
+ it('should set isLoading to true', () => {
+ mutations[types.REQUEST_FEATURE_FLAGS](stateCopy);
+
+ expect(stateCopy.isLoading).toEqual(true);
+ });
+ });
+
+ describe('RECEIVE_FEATURE_FLAGS_SUCCESS', () => {
+ const headers = {
+ 'x-next-page': '2',
+ 'x-page': '1',
+ 'X-Per-Page': '2',
+ 'X-Prev-Page': '',
+ 'X-TOTAL': '37',
+ 'X-Total-Pages': '5',
+ };
+
+ beforeEach(() => {
+ mutations[types.RECEIVE_FEATURE_FLAGS_SUCCESS](stateCopy, { data: getRequestData, headers });
+ });
+
+ it('should set isLoading to false', () => {
+ expect(stateCopy.isLoading).toEqual(false);
+ });
+
+ it('should set hasError to false', () => {
+ expect(stateCopy.hasError).toEqual(false);
+ });
+
+ it('should set featureFlags with the transformed data', () => {
+ const expected = getRequestData.feature_flags.map(flag => ({
+ ...flag,
+ scopes: mapToScopesViewModel(flag.scopes || []),
+ }));
+
+ expect(stateCopy.featureFlags).toEqual(expected);
+ });
+
+ it('should set count with the given data', () => {
+ expect(stateCopy.count.featureFlags).toEqual(37);
+ });
+
+ it('should set pagination', () => {
+ expect(stateCopy.pageInfo.featureFlags).toEqual(
+ parseIntPagination(normalizeHeaders(headers)),
+ );
+ });
+ });
+
+ describe('RECEIVE_FEATURE_FLAGS_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_FEATURE_FLAGS_ERROR](stateCopy);
+ });
+
+ it('should set isLoading to false', () => {
+ expect(stateCopy.isLoading).toEqual(false);
+ });
+
+ it('should set hasError to true', () => {
+ expect(stateCopy.hasError).toEqual(true);
+ });
+ });
+
+ describe('REQUEST_USER_LISTS', () => {
+ it('sets isLoading to true', () => {
+ mutations[types.REQUEST_USER_LISTS](stateCopy);
+ expect(stateCopy.isLoading).toBe(true);
+ });
+ });
+
+ describe('RECEIVE_USER_LISTS_SUCCESS', () => {
+ const headers = {
+ 'x-next-page': '2',
+ 'x-page': '1',
+ 'X-Per-Page': '2',
+ 'X-Prev-Page': '',
+ 'X-TOTAL': '37',
+ 'X-Total-Pages': '5',
+ };
+
+ beforeEach(() => {
+ mutations[types.RECEIVE_USER_LISTS_SUCCESS](stateCopy, { data: [userList], headers });
+ });
+
+ it('sets isLoading to false', () => {
+ expect(stateCopy.isLoading).toBe(false);
+ });
+
+ it('sets userLists to the received userLists', () => {
+ expect(stateCopy.userLists).toEqual([userList]);
+ });
+
+ it('sets pagination info for user lits', () => {
+ expect(stateCopy.pageInfo.userLists).toEqual(parseIntPagination(normalizeHeaders(headers)));
+ });
+
+ it('sets the count for user lists', () => {
+ expect(stateCopy.count.userLists).toBe(parseInt(headers['X-TOTAL'], 10));
+ });
+ });
+
+ describe('RECEIVE_USER_LISTS_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_USER_LISTS_ERROR](stateCopy);
+ });
+
+ it('should set isLoading to false', () => {
+ expect(stateCopy.isLoading).toEqual(false);
+ });
+
+ it('should set hasError to true', () => {
+ expect(stateCopy.hasError).toEqual(true);
+ });
+ });
+
+ describe('REQUEST_ROTATE_INSTANCE_ID', () => {
+ beforeEach(() => {
+ mutations[types.REQUEST_ROTATE_INSTANCE_ID](stateCopy);
+ });
+
+ it('should set isRotating to true', () => {
+ expect(stateCopy.isRotating).toBe(true);
+ });
+
+ it('should set hasRotateError to false', () => {
+ expect(stateCopy.hasRotateError).toBe(false);
+ });
+ });
+
+ describe('RECEIVE_ROTATE_INSTANCE_ID_SUCCESS', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS](stateCopy, { data: rotateData });
+ });
+
+ it('should set the instance id to the received data', () => {
+ expect(stateCopy.instanceId).toBe(rotateData.token);
+ });
+
+ it('should set isRotating to false', () => {
+ expect(stateCopy.isRotating).toBe(false);
+ });
+
+ it('should set hasRotateError to false', () => {
+ expect(stateCopy.hasRotateError).toBe(false);
+ });
+ });
+
+ describe('RECEIVE_ROTATE_INSTANCE_ID_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_ROTATE_INSTANCE_ID_ERROR](stateCopy);
+ });
+
+ it('should set isRotating to false', () => {
+ expect(stateCopy.isRotating).toBe(false);
+ });
+
+ it('should set hasRotateError to true', () => {
+ expect(stateCopy.hasRotateError).toBe(true);
+ });
+ });
+
+ describe('UPDATE_FEATURE_FLAG', () => {
+ beforeEach(() => {
+ stateCopy.featureFlags = getRequestData.feature_flags.map(flag => ({
+ ...flag,
+ scopes: mapToScopesViewModel(flag.scopes || []),
+ }));
+ stateCopy.count = { featureFlags: 1, userLists: 0 };
+
+ mutations[types.UPDATE_FEATURE_FLAG](stateCopy, {
+ ...featureFlag,
+ scopes: mapToScopesViewModel(featureFlag.scopes || []),
+ active: false,
+ });
+ });
+
+ it('should update the flag with the matching ID', () => {
+ expect(stateCopy.featureFlags).toEqual([
+ {
+ ...featureFlag,
+ scopes: mapToScopesViewModel(featureFlag.scopes || []),
+ active: false,
+ },
+ ]);
+ });
+ });
+
+ describe('RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS', () => {
+ const runUpdate = (stateCount, flagState, featureFlagUpdateParams) => {
+ stateCopy.featureFlags = getRequestData.feature_flags.map(flag => ({
+ ...flag,
+ ...flagState,
+ scopes: mapToScopesViewModel(flag.scopes || []),
+ }));
+ stateCopy.count.featureFlags = stateCount;
+
+ mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](stateCopy, {
+ ...featureFlag,
+ ...featureFlagUpdateParams,
+ });
+ };
+
+ it('updates the flag with the matching ID', () => {
+ runUpdate({ all: 1, enabled: 1, disabled: 0 }, { active: true }, { active: false });
+
+ expect(stateCopy.featureFlags).toEqual([
+ {
+ ...featureFlag,
+ scopes: mapToScopesViewModel(featureFlag.scopes || []),
+ active: false,
+ },
+ ]);
+ });
+ });
+
+ describe('RECEIVE_UPDATE_FEATURE_FLAG_ERROR', () => {
+ beforeEach(() => {
+ stateCopy.featureFlags = getRequestData.feature_flags.map(flag => ({
+ ...flag,
+ scopes: mapToScopesViewModel(flag.scopes || []),
+ }));
+ stateCopy.count = { enabled: 1, disabled: 0 };
+
+ mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](stateCopy, featureFlag.id);
+ });
+
+ it('should update the flag with the matching ID, toggling active', () => {
+ expect(stateCopy.featureFlags).toEqual([
+ {
+ ...featureFlag,
+ scopes: mapToScopesViewModel(featureFlag.scopes || []),
+ active: false,
+ },
+ ]);
+ });
+ });
+
+ describe('REQUEST_DELETE_USER_LIST', () => {
+ beforeEach(() => {
+ stateCopy.userLists = [userList];
+ mutations[types.REQUEST_DELETE_USER_LIST](stateCopy, userList);
+ });
+
+ it('should remove the deleted list', () => {
+ expect(stateCopy.userLists).not.toContain(userList);
+ });
+ });
+
+ describe('RECEIVE_DELETE_USER_LIST_ERROR', () => {
+ beforeEach(() => {
+ stateCopy.userLists = [];
+ mutations[types.RECEIVE_DELETE_USER_LIST_ERROR](stateCopy, {
+ list: userList,
+ error: 'some error',
+ });
+ });
+
+ it('should set isLoading to false and hasError to false', () => {
+ expect(stateCopy.isLoading).toBe(false);
+ expect(stateCopy.hasError).toBe(false);
+ });
+
+ it('should add the user list back to the list of user lists', () => {
+ expect(stateCopy.userLists).toContain(userList);
+ });
+ });
+
+ describe('RECEIVE_CLEAR_ALERT', () => {
+ it('clears the alert', () => {
+ stateCopy.alerts = ['a server error'];
+
+ mutations[types.RECEIVE_CLEAR_ALERT](stateCopy, 0);
+
+ expect(stateCopy.alerts).toEqual([]);
+ });
+
+ it('clears the alert at the specified index', () => {
+ stateCopy.alerts = ['a server error', 'another error', 'final error'];
+
+ mutations[types.RECEIVE_CLEAR_ALERT](stateCopy, 1);
+
+ expect(stateCopy.alerts).toEqual(['a server error', 'final error']);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/store/new/actions_spec.js b/spec/frontend/feature_flags/store/new/actions_spec.js
new file mode 100644
index 00000000000..cfcddd9451f
--- /dev/null
+++ b/spec/frontend/feature_flags/store/new/actions_spec.js
@@ -0,0 +1,223 @@
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import { TEST_HOST } from 'spec/test_constants';
+import {
+ setEndpoint,
+ setPath,
+ createFeatureFlag,
+ requestCreateFeatureFlag,
+ receiveCreateFeatureFlagSuccess,
+ receiveCreateFeatureFlagError,
+} from '~/feature_flags/store/modules/new/actions';
+import state from '~/feature_flags/store/modules/new/state';
+import * as types from '~/feature_flags/store/modules/new/mutation_types';
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ LEGACY_FLAG,
+ NEW_VERSION_FLAG,
+} from '~/feature_flags/constants';
+import {
+ mapFromScopesViewModel,
+ mapStrategiesToRails,
+} from '~/feature_flags/store/modules/helpers';
+import axios from '~/lib/utils/axios_utils';
+
+jest.mock('~/lib/utils/url_utility');
+
+describe('Feature flags New Module Actions', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = state();
+ });
+
+ describe('setEndpoint', () => {
+ it('should commit SET_ENDPOINT mutation', done => {
+ testAction(
+ setEndpoint,
+ 'feature_flags.json',
+ mockedState,
+ [{ type: types.SET_ENDPOINT, payload: 'feature_flags.json' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setPath', () => {
+ it('should commit SET_PATH mutation', done => {
+ testAction(
+ setPath,
+ '/feature_flags',
+ mockedState,
+ [{ type: types.SET_PATH, payload: '/feature_flags' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('createFeatureFlag', () => {
+ let mock;
+
+ const actionParams = {
+ name: 'name',
+ description: 'description',
+ active: true,
+ version: LEGACY_FLAG,
+ scopes: [
+ {
+ id: 1,
+ environmentScope: 'environmentScope',
+ active: true,
+ canUpdate: true,
+ protected: true,
+ shouldBeDestroyed: false,
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ },
+ ],
+ };
+
+ beforeEach(() => {
+ mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('success', () => {
+ it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagSuccess ', done => {
+ const convertedActionParams = mapFromScopesViewModel(actionParams);
+
+ mock.onPost(`${TEST_HOST}/endpoint.json`, convertedActionParams).replyOnce(200);
+
+ testAction(
+ createFeatureFlag,
+ actionParams,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestCreateFeatureFlag',
+ },
+ {
+ type: 'receiveCreateFeatureFlagSuccess',
+ },
+ ],
+ done,
+ );
+ });
+
+ it('sends strategies for new style feature flags', done => {
+ const newVersionFlagParams = {
+ name: 'name',
+ description: 'description',
+ active: true,
+ version: NEW_VERSION_FLAG,
+ strategies: [
+ {
+ name: ROLLOUT_STRATEGY_ALL_USERS,
+ parameters: {},
+ id: 1,
+ scopes: [{ id: 1, environmentScope: 'environmentScope', shouldBeDestroyed: false }],
+ shouldBeDestroyed: false,
+ },
+ ],
+ };
+ mock
+ .onPost(`${TEST_HOST}/endpoint.json`, mapStrategiesToRails(newVersionFlagParams))
+ .replyOnce(200);
+
+ testAction(
+ createFeatureFlag,
+ newVersionFlagParams,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestCreateFeatureFlag',
+ },
+ {
+ type: 'receiveCreateFeatureFlagSuccess',
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('error', () => {
+ it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagError ', done => {
+ const convertedActionParams = mapFromScopesViewModel(actionParams);
+
+ mock
+ .onPost(`${TEST_HOST}/endpoint.json`, convertedActionParams)
+ .replyOnce(500, { message: [] });
+
+ testAction(
+ createFeatureFlag,
+ actionParams,
+ mockedState,
+ [],
+ [
+ {
+ type: 'requestCreateFeatureFlag',
+ },
+ {
+ type: 'receiveCreateFeatureFlagError',
+ payload: { message: [] },
+ },
+ ],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('requestCreateFeatureFlag', () => {
+ it('should commit REQUEST_CREATE_FEATURE_FLAG mutation', done => {
+ testAction(
+ requestCreateFeatureFlag,
+ null,
+ mockedState,
+ [{ type: types.REQUEST_CREATE_FEATURE_FLAG }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveCreateFeatureFlagSuccess', () => {
+ it('should commit RECEIVE_CREATE_FEATURE_FLAG_SUCCESS mutation', done => {
+ testAction(
+ receiveCreateFeatureFlagSuccess,
+ null,
+ mockedState,
+ [
+ {
+ type: types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveCreateFeatureFlagError', () => {
+ it('should commit RECEIVE_CREATE_FEATURE_FLAG_ERROR mutation', done => {
+ testAction(
+ receiveCreateFeatureFlagError,
+ 'There was an error',
+ mockedState,
+ [{ type: types.RECEIVE_CREATE_FEATURE_FLAG_ERROR, payload: 'There was an error' }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/store/new/mutations_spec.js b/spec/frontend/feature_flags/store/new/mutations_spec.js
new file mode 100644
index 00000000000..95eba96ed72
--- /dev/null
+++ b/spec/frontend/feature_flags/store/new/mutations_spec.js
@@ -0,0 +1,65 @@
+import state from '~/feature_flags/store/modules/new/state';
+import mutations from '~/feature_flags/store/modules/new/mutations';
+import * as types from '~/feature_flags/store/modules/new/mutation_types';
+
+describe('Feature flags New Module Mutations', () => {
+ let stateCopy;
+
+ beforeEach(() => {
+ stateCopy = state();
+ });
+
+ describe('SET_ENDPOINT', () => {
+ it('should set endpoint', () => {
+ mutations[types.SET_ENDPOINT](stateCopy, 'feature_flags.json');
+
+ expect(stateCopy.endpoint).toEqual('feature_flags.json');
+ });
+ });
+
+ describe('SET_PATH', () => {
+ it('should set provided options', () => {
+ mutations[types.SET_PATH](stateCopy, 'feature_flags');
+
+ expect(stateCopy.path).toEqual('feature_flags');
+ });
+ });
+
+ describe('REQUEST_CREATE_FEATURE_FLAG', () => {
+ it('should set isSendingRequest to true', () => {
+ mutations[types.REQUEST_CREATE_FEATURE_FLAG](stateCopy);
+
+ expect(stateCopy.isSendingRequest).toEqual(true);
+ });
+
+ it('should set error to an empty array', () => {
+ mutations[types.REQUEST_CREATE_FEATURE_FLAG](stateCopy);
+
+ expect(stateCopy.error).toEqual([]);
+ });
+ });
+
+ describe('RECEIVE_CREATE_FEATURE_FLAG_SUCCESS', () => {
+ it('should set isSendingRequest to false', () => {
+ mutations[types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS](stateCopy);
+
+ expect(stateCopy.isSendingRequest).toEqual(false);
+ });
+ });
+
+ describe('RECEIVE_CREATE_FEATURE_FLAG_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_CREATE_FEATURE_FLAG_ERROR](stateCopy, {
+ message: ['Name is required'],
+ });
+ });
+
+ it('should set isSendingRequest to false', () => {
+ expect(stateCopy.isSendingRequest).toEqual(false);
+ });
+
+ it('should set hasError to true', () => {
+ expect(stateCopy.error).toEqual(['Name is required']);
+ });
+ });
+});
diff --git a/spec/frontend/fixtures/releases.rb b/spec/frontend/fixtures/releases.rb
new file mode 100644
index 00000000000..bda62f4850a
--- /dev/null
+++ b/spec/frontend/fixtures/releases.rb
@@ -0,0 +1,136 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Releases (JavaScript fixtures)' do
+ include ApiHelpers
+ include JavaScriptFixturesHelpers
+
+ let_it_be(:admin) { create(:admin, username: 'administrator', email: 'admin@example.gitlab.com') }
+ let_it_be(:namespace) { create(:namespace, path: 'releases-namespace') }
+ let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'releases-project') }
+
+ let_it_be(:milestone_12_3) do
+ create(:milestone,
+ id: 123,
+ project: project,
+ title: '12.3',
+ description: 'The 12.3 milestone',
+ start_date: Time.zone.parse('2018-12-10'),
+ due_date: Time.zone.parse('2019-01-10'))
+ end
+
+ let_it_be(:milestone_12_4) do
+ create(:milestone,
+ id: 124,
+ project: project,
+ title: '12.4',
+ description: 'The 12.4 milestone',
+ start_date: Time.zone.parse('2019-01-10'),
+ due_date: Time.zone.parse('2019-02-10'))
+ end
+
+ let_it_be(:open_issues_12_3) do
+ create_list(:issue, 2, milestone: milestone_12_3, project: project)
+ end
+
+ let_it_be(:closed_issues_12_3) do
+ create_list(:issue, 3, :closed, milestone: milestone_12_3, project: project)
+ end
+
+ let_it_be(:open_issues_12_4) do
+ create_list(:issue, 3, milestone: milestone_12_4, project: project)
+ end
+
+ let_it_be(:closed_issues_12_4) do
+ create_list(:issue, 1, :closed, milestone: milestone_12_4, project: project)
+ end
+
+ let_it_be(:release) do
+ create(:release,
+ milestones: [milestone_12_3, milestone_12_4],
+ project: project,
+ tag: 'v1.1',
+ name: 'The first release',
+ author: admin,
+ description: 'Best. Release. **Ever.** :rocket:',
+ created_at: Time.zone.parse('2018-12-3'),
+ released_at: Time.zone.parse('2018-12-10'))
+ end
+
+ let_it_be(:evidence) do
+ create(:evidence,
+ release: release,
+ collected_at: Time.zone.parse('2018-12-03'))
+ end
+
+ let_it_be(:other_link) do
+ create(:release_link,
+ id: 10,
+ release: release,
+ name: 'linux-amd64 binaries',
+ filepath: '/binaries/linux-amd64',
+ url: 'https://downloads.example.com/bin/gitlab-linux-amd64')
+ end
+
+ let_it_be(:runbook_link) do
+ create(:release_link,
+ id: 11,
+ release: release,
+ name: 'Runbook',
+ url: "#{release.project.web_url}/runbook",
+ link_type: :runbook)
+ end
+
+ let_it_be(:package_link) do
+ create(:release_link,
+ id: 12,
+ release: release,
+ name: 'Package',
+ url: 'https://example.com/package',
+ link_type: :package)
+ end
+
+ let_it_be(:image_link) do
+ create(:release_link,
+ id: 13,
+ release: release,
+ name: 'Image',
+ url: 'https://example.com/image',
+ link_type: :image)
+ end
+
+ after(:all) do
+ remove_repository(project)
+ end
+
+ describe API::Releases, type: :request do
+ before(:all) do
+ clean_frontend_fixtures('api/releases/')
+ end
+
+ it 'api/releases/release.json' do
+ get api("/projects/#{project.id}/releases/#{release.tag}", admin)
+
+ expect(response).to be_successful
+ end
+ end
+
+ graphql_query_path = 'releases/queries/all_releases.query.graphql'
+
+ describe "~/#{graphql_query_path}", type: :request do
+ include GraphqlHelpers
+
+ before(:all) do
+ clean_frontend_fixtures('graphql/releases/')
+ end
+
+ it "graphql/#{graphql_query_path}.json" do
+ query = File.read(File.join(Rails.root, '/app/assets/javascripts', graphql_query_path))
+
+ post_graphql(query, current_user: admin, variables: { fullPath: project.full_path })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+end
diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js
index 6c40b1ba3a7..38a0da95080 100644
--- a/spec/frontend/gfm_auto_complete_spec.js
+++ b/spec/frontend/gfm_auto_complete_spec.js
@@ -7,15 +7,228 @@ import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete
import { TEST_HOST } from 'helpers/test_constants';
import { getJSONFixture } from 'helpers/fixtures';
+import waitForPromises from 'jest/helpers/wait_for_promises';
+
+import MockAdapter from 'axios-mock-adapter';
+import AjaxCache from '~/lib/utils/ajax_cache';
+import axios from '~/lib/utils/axios_utils';
+
const labelsFixture = getJSONFixture('autocomplete_sources/labels.json');
describe('GfmAutoComplete', () => {
- const gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
- fetchData: () => {},
- });
+ const fetchDataMock = { fetchData: jest.fn() };
+ let gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call(fetchDataMock);
let atwhoInstance;
let sorterValue;
+ let filterValue;
+
+ describe('.typesWithBackendFiltering', () => {
+ it('should contain vulnerabilities', () => {
+ expect(GfmAutoComplete.typesWithBackendFiltering).toContain('vulnerabilities');
+ });
+ });
+
+ describe('DefaultOptions.filter', () => {
+ let items;
+
+ beforeEach(() => {
+ jest.spyOn(fetchDataMock, 'fetchData');
+ jest.spyOn($.fn.atwho.default.callbacks, 'filter').mockImplementation(() => {});
+ });
+
+ describe('assets loading', () => {
+ beforeEach(() => {
+ atwhoInstance = { setting: {}, $inputor: 'inputor', at: '+' };
+ items = ['loading'];
+
+ filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, '', items);
+ });
+
+ it('should call the fetchData function without query', () => {
+ expect(fetchDataMock.fetchData).toHaveBeenCalledWith('inputor', '+');
+ });
+
+ it('should not call the default atwho filter', () => {
+ expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled();
+ });
+
+ it('should return the passed unfiltered items', () => {
+ expect(filterValue).toEqual(items);
+ });
+ });
+
+ describe('backend filtering', () => {
+ beforeEach(() => {
+ atwhoInstance = { setting: {}, $inputor: 'inputor', at: '+' };
+ items = [];
+ });
+
+ describe('when previous query is different from current one', () => {
+ beforeEach(() => {
+ gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
+ previousQuery: 'oldquery',
+ ...fetchDataMock,
+ });
+ filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, 'newquery', items);
+ });
+
+ it('should call the fetchData function with query', () => {
+ expect(fetchDataMock.fetchData).toHaveBeenCalledWith('inputor', '+', 'newquery');
+ });
+
+ it('should not call the default atwho filter', () => {
+ expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled();
+ });
+
+ it('should return the passed unfiltered items', () => {
+ expect(filterValue).toEqual(items);
+ });
+ });
+
+ describe('when previous query is not different from current one', () => {
+ beforeEach(() => {
+ gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
+ previousQuery: 'oldquery',
+ ...fetchDataMock,
+ });
+ filterValue = gfmAutoCompleteCallbacks.filter.call(
+ atwhoInstance,
+ 'oldquery',
+ items,
+ 'searchKey',
+ );
+ });
+
+ it('should not call the fetchData function', () => {
+ expect(fetchDataMock.fetchData).not.toHaveBeenCalled();
+ });
+
+ it('should call the default atwho filter', () => {
+ expect($.fn.atwho.default.callbacks.filter).toHaveBeenCalledWith(
+ 'oldquery',
+ items,
+ 'searchKey',
+ );
+ });
+ });
+ });
+ });
+
+ describe('fetchData', () => {
+ const { fetchData } = GfmAutoComplete.prototype;
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ jest.spyOn(axios, 'get');
+ jest.spyOn(AjaxCache, 'retrieve');
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('already loading data', () => {
+ beforeEach(() => {
+ const context = {
+ isLoadingData: { '+': true },
+ dataSources: {},
+ cachedData: {},
+ };
+ fetchData.call(context, {}, '+', '');
+ });
+
+ it('should not call either axios nor AjaxCache', () => {
+ expect(axios.get).not.toHaveBeenCalled();
+ expect(AjaxCache.retrieve).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('backend filtering', () => {
+ describe('data is not in cache', () => {
+ let context;
+
+ beforeEach(() => {
+ context = {
+ isLoadingData: { '+': false },
+ dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' },
+ cachedData: {},
+ };
+ });
+
+ it('should call axios with query', () => {
+ fetchData.call(context, {}, '+', 'query');
+
+ expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', {
+ params: { search: 'query' },
+ });
+ });
+
+ it.each([200, 500])('should set the loading state', async responseStatus => {
+ mock.onGet('vulnerabilities_autocomplete_url').replyOnce(responseStatus);
+
+ fetchData.call(context, {}, '+', 'query');
+
+ expect(context.isLoadingData['+']).toBe(true);
+
+ await waitForPromises();
+
+ expect(context.isLoadingData['+']).toBe(false);
+ });
+ });
+
+ describe('data is in cache', () => {
+ beforeEach(() => {
+ const context = {
+ isLoadingData: { '+': false },
+ dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' },
+ cachedData: { '+': [{}] },
+ };
+ fetchData.call(context, {}, '+', 'query');
+ });
+
+ it('should anyway call axios with query ignoring cache', () => {
+ expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', {
+ params: { search: 'query' },
+ });
+ });
+ });
+ });
+
+ describe('frontend filtering', () => {
+ describe('data is not in cache', () => {
+ beforeEach(() => {
+ const context = {
+ isLoadingData: { '#': false },
+ dataSources: { issues: 'issues_autocomplete_url' },
+ cachedData: {},
+ };
+ fetchData.call(context, {}, '#', 'query');
+ });
+
+ it('should call AjaxCache', () => {
+ expect(AjaxCache.retrieve).toHaveBeenCalledWith('issues_autocomplete_url', true);
+ });
+ });
+
+ describe('data is in cache', () => {
+ beforeEach(() => {
+ const context = {
+ isLoadingData: { '#': false },
+ dataSources: { issues: 'issues_autocomplete_url' },
+ cachedData: { '#': [{}] },
+ loadData: () => {},
+ };
+ fetchData.call(context, {}, '#', 'query');
+ });
+
+ it('should not call AjaxCache', () => {
+ expect(AjaxCache.retrieve).not.toHaveBeenCalled();
+ });
+ });
+ });
+ });
describe('DefaultOptions.sorter', () => {
describe('assets loading', () => {
diff --git a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
index 0befe1aa192..e880f585daa 100644
--- a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
+++ b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
@@ -17,6 +17,7 @@ exports[`grafana integration component default state to match the default snapsh
</h3>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="js-settings-toggle"
icon=""
@@ -92,20 +93,17 @@ exports[`grafana integration component default state to match the default snapsh
</p>
</gl-form-group-stub>
- <div
- class="gl-display-flex gl-justify-content-end"
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ icon=""
+ size="medium"
+ variant="success"
>
- <gl-button-stub
- category="primary"
- icon=""
- size="medium"
- variant="success"
- >
-
- Save Changes
- </gl-button-stub>
- </div>
+ Save Changes
+
+ </gl-button-stub>
</form>
</div>
</section>
diff --git a/spec/frontend/groups/components/item_actions_spec.js b/spec/frontend/groups/components/item_actions_spec.js
index f5df8c180d5..d4aa29eaadd 100644
--- a/spec/frontend/groups/components/item_actions_spec.js
+++ b/spec/frontend/groups/components/item_actions_spec.js
@@ -1,84 +1,87 @@
-import Vue from 'vue';
-
-import mountComponent from 'helpers/vue_mount_component_helper';
-import itemActionsComponent from '~/groups/components/item_actions.vue';
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
+import ItemActions from '~/groups/components/item_actions.vue';
import eventHub from '~/groups/event_hub';
import { mockParentGroupItem, mockChildren } from '../mock_data';
-const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => {
- const Component = Vue.extend(itemActionsComponent);
+describe('ItemActions', () => {
+ let wrapper;
+ const parentGroup = mockChildren[0];
- return mountComponent(Component, {
- group,
+ const defaultProps = {
+ group: mockParentGroupItem,
parentGroup,
- });
-};
-
-describe('ItemActionsComponent', () => {
- let vm;
+ };
- beforeEach(() => {
- vm = createComponent();
- });
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(ItemActions, {
+ propsData: { ...defaultProps, ...props },
+ });
+ };
afterEach(() => {
- vm.$destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
- describe('methods', () => {
- describe('onLeaveGroup', () => {
- it('emits `showLeaveGroupModal` event with `group` and `parentGroup` props', () => {
- jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
- vm.onLeaveGroup();
-
- expect(eventHub.$emit).toHaveBeenCalledWith(
- 'showLeaveGroupModal',
- vm.group,
- vm.parentGroup,
- );
- });
- });
- });
+ const findEditGroupBtn = () => wrapper.find('[data-testid="edit-group-btn"]');
+ const findEditGroupIcon = () => findEditGroupBtn().find(GlIcon);
+ const findLeaveGroupBtn = () => wrapper.find('[data-testid="leave-group-btn"]');
+ const findLeaveGroupIcon = () => findLeaveGroupBtn().find(GlIcon);
describe('template', () => {
- it('should render component template correctly', () => {
- expect(vm.$el.classList.contains('controls')).toBeTruthy();
- });
+ it('renders component template correctly', () => {
+ createComponent();
- it('should render Edit Group button with correct attribute values', () => {
- const group = { ...mockParentGroupItem };
- group.canEdit = true;
- const newVm = createComponent(group);
+ expect(wrapper.classes()).toContain('controls');
+ });
- const editBtn = newVm.$el.querySelector('a.edit-group');
+ it('renders "Edit group" button with correct attribute values', () => {
+ const group = {
+ ...mockParentGroupItem,
+ canEdit: true,
+ };
+
+ createComponent({ group });
+
+ expect(findEditGroupBtn().exists()).toBe(true);
+ expect(findEditGroupBtn().classes()).toContain('no-expand');
+ expect(findEditGroupBtn().attributes('href')).toBe(group.editPath);
+ expect(findEditGroupBtn().attributes('aria-label')).toBe('Edit group');
+ expect(findEditGroupBtn().attributes('data-original-title')).toBe('Edit group');
+ expect(findEditGroupIcon().exists()).toBe(true);
+ expect(findEditGroupIcon().props('name')).toBe('settings');
+ });
- expect(editBtn).toBeDefined();
- expect(editBtn.classList.contains('no-expand')).toBeTruthy();
- expect(editBtn.getAttribute('href')).toBe(group.editPath);
- expect(editBtn.getAttribute('aria-label')).toBe('Edit group');
- expect(editBtn.dataset.originalTitle).toBe('Edit group');
- expect(editBtn.querySelectorAll('svg').length).not.toBe(0);
- expect(editBtn.querySelector('svg').getAttribute('data-testid')).toBe('settings-icon');
+ describe('`canLeave` is true', () => {
+ const group = {
+ ...mockParentGroupItem,
+ canLeave: true,
+ };
- newVm.$destroy();
- });
+ beforeEach(() => {
+ createComponent({ group });
+ });
- it('should render Leave Group button with correct attribute values', () => {
- const group = { ...mockParentGroupItem };
- group.canLeave = true;
- const newVm = createComponent(group);
+ it('renders "Leave this group" button with correct attribute values', () => {
+ expect(findLeaveGroupBtn().exists()).toBe(true);
+ expect(findLeaveGroupBtn().classes()).toContain('no-expand');
+ expect(findLeaveGroupBtn().attributes('href')).toBe(group.leavePath);
+ expect(findLeaveGroupBtn().attributes('aria-label')).toBe('Leave this group');
+ expect(findLeaveGroupBtn().attributes('data-original-title')).toBe('Leave this group');
+ expect(findLeaveGroupIcon().exists()).toBe(true);
+ expect(findLeaveGroupIcon().props('name')).toBe('leave');
+ });
- const leaveBtn = newVm.$el.querySelector('a.leave-group');
+ it('emits event on "Leave this group" button click', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
- expect(leaveBtn).toBeDefined();
- expect(leaveBtn.classList.contains('no-expand')).toBeTruthy();
- 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.querySelectorAll('svg').length).not.toBe(0);
- expect(leaveBtn.querySelector('svg').getAttribute('data-testid')).toBe('leave-icon');
+ findLeaveGroupBtn().trigger('click');
- newVm.$destroy();
+ expect(eventHub.$emit).toHaveBeenCalledWith('showLeaveGroupModal', group, parentGroup);
+ });
});
});
});
diff --git a/spec/frontend/groups/components/item_caret_spec.js b/spec/frontend/groups/components/item_caret_spec.js
index 4ff7482414c..b2915607a06 100644
--- a/spec/frontend/groups/components/item_caret_spec.js
+++ b/spec/frontend/groups/components/item_caret_spec.js
@@ -1,38 +1,48 @@
-import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
+import ItemCaret from '~/groups/components/item_caret.vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import itemCaretComponent from '~/groups/components/item_caret.vue';
+describe('ItemCaret', () => {
+ let wrapper;
-const createComponent = (isGroupOpen = false) => {
- const Component = Vue.extend(itemCaretComponent);
+ const defaultProps = {
+ isGroupOpen: false,
+ };
- return mountComponent(Component, {
- isGroupOpen,
- });
-};
-
-describe('ItemCaretComponent', () => {
- let vm;
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(ItemCaret, {
+ propsData: { ...defaultProps, ...props },
+ });
+ };
afterEach(() => {
- vm.$destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
+ const findAllGlIcons = () => wrapper.findAll(GlIcon);
+ const findGlIcon = () => wrapper.find(GlIcon);
+
describe('template', () => {
- it('should render component template correctly', () => {
- vm = createComponent();
- expect(vm.$el.classList.contains('folder-caret')).toBeTruthy();
- expect(vm.$el.querySelectorAll('svg').length).toBe(1);
- });
+ it('renders component template correctly', () => {
+ createComponent();
- it('should render caret down icon if `isGroupOpen` prop is `true`', () => {
- vm = createComponent(true);
- expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('angle-down-icon');
+ expect(wrapper.classes()).toContain('folder-caret');
+ expect(findAllGlIcons()).toHaveLength(1);
});
- it('should render caret right icon if `isGroupOpen` prop is `false`', () => {
- vm = createComponent();
- expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('angle-right-icon');
+ it.each`
+ isGroupOpen | icon
+ ${true} | ${'angle-down'}
+ ${false} | ${'angle-right'}
+ `('renders "$icon" icon when `isGroupOpen` is $isGroupOpen', ({ isGroupOpen, icon }) => {
+ createComponent({
+ isGroupOpen,
+ });
+
+ expect(findGlIcon().props('name')).toBe(icon);
});
});
});
diff --git a/spec/frontend/groups/components/item_stats_spec.js b/spec/frontend/groups/components/item_stats_spec.js
index 771643609ec..d8c88a608ac 100644
--- a/spec/frontend/groups/components/item_stats_spec.js
+++ b/spec/frontend/groups/components/item_stats_spec.js
@@ -1,119 +1,50 @@
-import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import ItemStats from '~/groups/components/item_stats.vue';
+import ItemStatsValue from '~/groups/components/item_stats_value.vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import itemStatsComponent from '~/groups/components/item_stats.vue';
-import {
- mockParentGroupItem,
- ITEM_TYPE,
- VISIBILITY_TYPE_ICON,
- GROUP_VISIBILITY_TYPE,
- PROJECT_VISIBILITY_TYPE,
-} from '../mock_data';
+import { mockParentGroupItem, ITEM_TYPE } from '../mock_data';
-const createComponent = (item = mockParentGroupItem) => {
- const Component = Vue.extend(itemStatsComponent);
+describe('ItemStats', () => {
+ let wrapper;
- return mountComponent(Component, {
- item,
- });
-};
-
-describe('ItemStatsComponent', () => {
- describe('computed', () => {
- describe('visibilityIcon', () => {
- it('should return icon class based on `item.visibility` value', () => {
- Object.keys(VISIBILITY_TYPE_ICON).forEach(visibility => {
- const item = { ...mockParentGroupItem, visibility };
- const vm = createComponent(item);
+ const defaultProps = {
+ item: mockParentGroupItem,
+ };
- expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]);
- vm.$destroy();
- });
- });
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(ItemStats, {
+ propsData: { ...defaultProps, ...props },
});
+ };
- describe('visibilityTooltip', () => {
- it('should return tooltip string for Group based on `item.visibility` value', () => {
- Object.keys(GROUP_VISIBILITY_TYPE).forEach(visibility => {
- const item = { ...mockParentGroupItem, visibility, type: ITEM_TYPE.GROUP };
- const vm = createComponent(item);
-
- expect(vm.visibilityTooltip).toBe(GROUP_VISIBILITY_TYPE[visibility]);
- vm.$destroy();
- });
- });
-
- it('should return tooltip string for Project based on `item.visibility` value', () => {
- Object.keys(PROJECT_VISIBILITY_TYPE).forEach(visibility => {
- const item = { ...mockParentGroupItem, visibility, type: ITEM_TYPE.PROJECT };
- const vm = createComponent(item);
-
- expect(vm.visibilityTooltip).toBe(PROJECT_VISIBILITY_TYPE[visibility]);
- vm.$destroy();
- });
- });
- });
-
- describe('isProject', () => {
- it('should return boolean value representing whether `item.type` is Project or not', () => {
- let item;
- let vm;
-
- item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT };
- vm = createComponent(item);
-
- expect(vm.isProject).toBeTruthy();
- vm.$destroy();
-
- item = { ...mockParentGroupItem, type: ITEM_TYPE.GROUP };
- vm = createComponent(item);
-
- expect(vm.isProject).toBeFalsy();
- vm.$destroy();
- });
- });
-
- describe('isGroup', () => {
- it('should return boolean value representing whether `item.type` is Group or not', () => {
- let item;
- let vm;
-
- item = { ...mockParentGroupItem, type: ITEM_TYPE.GROUP };
- vm = createComponent(item);
-
- expect(vm.isGroup).toBeTruthy();
- vm.$destroy();
-
- item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT };
- vm = createComponent(item);
-
- expect(vm.isGroup).toBeFalsy();
- vm.$destroy();
- });
- });
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
+ const findItemStatsValue = () => wrapper.find(ItemStatsValue);
+
describe('template', () => {
it('renders component container element correctly', () => {
- const vm = createComponent();
+ createComponent();
- expect(vm.$el.classList.contains('stats')).toBeTruthy();
-
- vm.$destroy();
+ expect(wrapper.classes()).toContain('stats');
});
it('renders start count and last updated information for project item correctly', () => {
- const item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT, starCount: 4 };
- const vm = createComponent(item);
-
- const projectStarIconEl = vm.$el.querySelector('.project-stars');
+ const item = {
+ ...mockParentGroupItem,
+ type: ITEM_TYPE.PROJECT,
+ starCount: 4,
+ };
- expect(projectStarIconEl).not.toBeNull();
- expect(projectStarIconEl.querySelectorAll('svg').length).toBeGreaterThan(0);
- expect(projectStarIconEl.querySelectorAll('.stat-value').length).toBeGreaterThan(0);
- expect(vm.$el.querySelectorAll('.last-updated').length).toBeGreaterThan(0);
+ createComponent({ item });
- vm.$destroy();
+ expect(findItemStatsValue().exists()).toBe(true);
+ expect(findItemStatsValue().props('cssClass')).toBe('project-stars');
+ expect(wrapper.contains('.last-updated')).toBe(true);
});
});
});
diff --git a/spec/frontend/groups/components/item_stats_value_spec.js b/spec/frontend/groups/components/item_stats_value_spec.js
index 11246390444..6f018aa79a0 100644
--- a/spec/frontend/groups/components/item_stats_value_spec.js
+++ b/spec/frontend/groups/components/item_stats_value_spec.js
@@ -1,82 +1,67 @@
-import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
+import ItemStatsValue from '~/groups/components/item_stats_value.vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import itemStatsValueComponent from '~/groups/components/item_stats_value.vue';
+describe('ItemStatsValue', () => {
+ let wrapper;
-const createComponent = ({ title, cssClass, iconName, tooltipPlacement, value }) => {
- const Component = Vue.extend(itemStatsValueComponent);
+ const defaultProps = {
+ title: 'Subgroups',
+ cssClass: 'number-subgroups',
+ iconName: 'folder',
+ tooltipPlacement: 'left',
+ };
- return mountComponent(Component, {
- title,
- cssClass,
- iconName,
- tooltipPlacement,
- value,
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(ItemStatsValue, {
+ propsData: { ...defaultProps, ...props },
+ });
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
-};
-describe('ItemStatsValueComponent', () => {
- describe('computed', () => {
- let vm;
- const itemConfig = {
- title: 'Subgroups',
- cssClass: 'number-subgroups',
- iconName: 'folder',
- tooltipPlacement: 'left',
- };
+ const findGlIcon = () => wrapper.find(GlIcon);
+ const findStatValue = () => wrapper.find('[data-testid="itemStatValue"]');
- describe('isValuePresent', () => {
- it('returns true if non-empty `value` is present', () => {
- vm = createComponent({ ...itemConfig, value: 10 });
+ describe('template', () => {
+ describe('when `value` is not provided', () => {
+ it('does not render value count', () => {
+ createComponent();
- expect(vm.isValuePresent).toBeTruthy();
+ expect(findStatValue().exists()).toBe(false);
});
+ });
- it('returns false if empty `value` is present', () => {
- vm = createComponent(itemConfig);
-
- expect(vm.isValuePresent).toBeFalsy();
+ describe('when `value` is provided', () => {
+ beforeEach(() => {
+ createComponent({
+ value: 10,
+ });
});
- afterEach(() => {
- vm.$destroy();
+ it('renders component element correctly', () => {
+ expect(wrapper.classes()).toContain('number-subgroups');
});
- });
- });
- describe('template', () => {
- let vm;
- beforeEach(() => {
- vm = createComponent({
- title: 'Subgroups',
- cssClass: 'number-subgroups',
- iconName: 'folder',
- tooltipPlacement: 'left',
- value: 10,
+ it('renders element tooltip correctly', () => {
+ expect(wrapper.attributes('data-original-title')).toBe('Subgroups');
+ expect(wrapper.attributes('data-placement')).toBe('left');
});
- });
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders component element correctly', () => {
- expect(vm.$el.classList.contains('number-subgroups')).toBeTruthy();
- expect(vm.$el.querySelectorAll('svg').length).toBeGreaterThan(0);
- expect(vm.$el.querySelectorAll('.stat-value').length).toBeGreaterThan(0);
- });
-
- 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').getAttribute('data-testid')).toBe('folder-icon');
- });
+ it('renders element icon correctly', () => {
+ expect(findGlIcon().exists()).toBe(true);
+ expect(findGlIcon().props('name')).toBe('folder');
+ });
- it('renders value count correctly', () => {
- expect(vm.$el.querySelector('.stat-value').innerText.trim()).toContain('10');
+ it('renders value count correctly', () => {
+ expect(findStatValue().classes()).toContain('stat-value');
+ expect(findStatValue().text()).toBe('10');
+ });
});
});
});
diff --git a/spec/frontend/groups/components/item_type_icon_spec.js b/spec/frontend/groups/components/item_type_icon_spec.js
index 477c413ddcd..5e7056be218 100644
--- a/spec/frontend/groups/components/item_type_icon_spec.js
+++ b/spec/frontend/groups/components/item_type_icon_spec.js
@@ -1,53 +1,53 @@
-import Vue from 'vue';
-
-import mountComponent from 'helpers/vue_mount_component_helper';
-import itemTypeIconComponent from '~/groups/components/item_type_icon.vue';
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
+import ItemTypeIcon from '~/groups/components/item_type_icon.vue';
import { ITEM_TYPE } from '../mock_data';
-const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => {
- const Component = Vue.extend(itemTypeIconComponent);
-
- return mountComponent(Component, {
- itemType,
- isGroupOpen,
- });
-};
+describe('ItemTypeIcon', () => {
+ let wrapper;
-describe('ItemTypeIconComponent', () => {
- describe('template', () => {
- it('should render component template correctly', () => {
- const vm = createComponent();
+ const defaultProps = {
+ itemType: ITEM_TYPE.GROUP,
+ isGroupOpen: false,
+ };
- expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy();
- vm.$destroy();
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(ItemTypeIcon, {
+ propsData: { ...defaultProps, ...props },
});
+ };
- it('should render folder open or close icon based `isGroupOpen` prop value', () => {
- let vm;
-
- vm = createComponent(ITEM_TYPE.GROUP, true);
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
- expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('folder-open-icon');
- vm.$destroy();
+ const findGlIcon = () => wrapper.find(GlIcon);
- vm = createComponent(ITEM_TYPE.GROUP);
+ describe('template', () => {
+ it('renders component template correctly', () => {
+ createComponent();
- expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('folder-o-icon');
- vm.$destroy();
+ expect(wrapper.classes()).toContain('item-type-icon');
});
- it('should render bookmark icon based on `isProject` prop value', () => {
- let vm;
-
- vm = createComponent(ITEM_TYPE.PROJECT);
-
- expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('bookmark-icon');
- vm.$destroy();
-
- vm = createComponent(ITEM_TYPE.GROUP);
-
- expect(vm.$el.querySelector('svg').getAttribute('data-testid')).not.toBe('bookmark-icon');
- vm.$destroy();
- });
+ it.each`
+ type | isGroupOpen | icon
+ ${ITEM_TYPE.GROUP} | ${true} | ${'folder-open'}
+ ${ITEM_TYPE.GROUP} | ${false} | ${'folder-o'}
+ ${ITEM_TYPE.PROJECT} | ${true} | ${'bookmark'}
+ ${ITEM_TYPE.PROJECT} | ${false} | ${'bookmark'}
+ `(
+ 'shows "$icon" icon when `itemType` is "$type" and `isGroupOpen` is $isGroupOpen',
+ ({ type, isGroupOpen, icon }) => {
+ createComponent({
+ itemType: type,
+ isGroupOpen,
+ });
+ expect(findGlIcon().props('name')).toBe(icon);
+ },
+ );
});
});
diff --git a/spec/frontend/groups/members/index_spec.js b/spec/frontend/groups/members/index_spec.js
index 70fce0d60fb..95a111ef5da 100644
--- a/spec/frontend/groups/members/index_spec.js
+++ b/spec/frontend/groups/members/index_spec.js
@@ -1,5 +1,5 @@
import { createWrapper } from '@vue/test-utils';
-import initGroupMembersApp from '~/groups/members';
+import { initGroupMembersApp } from '~/groups/members';
import GroupMembersApp from '~/groups/members/components/app.vue';
import { membersJsonString, membersParsed } from './mock_data';
@@ -9,7 +9,7 @@ describe('initGroupMembersApp', () => {
let wrapper;
const setup = () => {
- vm = initGroupMembersApp(el);
+ vm = initGroupMembersApp(el, ['account']);
wrapper = createWrapper(vm);
};
@@ -63,4 +63,10 @@ describe('initGroupMembersApp', () => {
expect(vm.$store.state.members).toEqual(membersParsed);
});
+
+ it('sets `tableFields` in Vuex store', () => {
+ setup();
+
+ expect(vm.$store.state.tableFields).toEqual(['account']);
+ });
});
diff --git a/spec/frontend/helpers/experimentation_helper.js b/spec/frontend/helpers/experimentation_helper.js
new file mode 100644
index 00000000000..c08c25155e8
--- /dev/null
+++ b/spec/frontend/helpers/experimentation_helper.js
@@ -0,0 +1,14 @@
+import { merge } from 'lodash';
+
+export function withGonExperiment(experimentKey, value = true) {
+ let origGon;
+
+ beforeEach(() => {
+ origGon = window.gon;
+ window.gon = merge({}, window.gon || {}, { experiments: { [experimentKey]: value } });
+ });
+
+ afterEach(() => {
+ window.gon = origGon;
+ });
+}
diff --git a/spec/frontend/helpers/keep_alive_component_helper.js b/spec/frontend/helpers/keep_alive_component_helper.js
new file mode 100644
index 00000000000..54f40bf9093
--- /dev/null
+++ b/spec/frontend/helpers/keep_alive_component_helper.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+
+export function keepAlive(KeptAliveComponent) {
+ return Vue.extend({
+ components: {
+ KeptAliveComponent,
+ },
+ data() {
+ return {
+ view: 'KeptAliveComponent',
+ };
+ },
+ methods: {
+ async activate() {
+ this.view = 'KeptAliveComponent';
+ await this.$nextTick();
+ },
+ async deactivate() {
+ this.view = 'div';
+ await this.$nextTick();
+ },
+ async reactivate() {
+ await this.deactivate();
+ await this.activate();
+ },
+ },
+ template: `<keep-alive><component :is="view"></component></keep-alive>`,
+ });
+}
diff --git a/spec/frontend/helpers/keep_alive_component_helper_spec.js b/spec/frontend/helpers/keep_alive_component_helper_spec.js
new file mode 100644
index 00000000000..dcccc14f396
--- /dev/null
+++ b/spec/frontend/helpers/keep_alive_component_helper_spec.js
@@ -0,0 +1,32 @@
+import { mount } from '@vue/test-utils';
+import { keepAlive } from './keep_alive_component_helper';
+
+const component = {
+ template: '<div>Test Component</div>',
+};
+
+describe('keepAlive', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = mount(keepAlive(component));
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('converts a component to a keep-alive component', async () => {
+ const { element } = wrapper.find(component);
+
+ await wrapper.vm.deactivate();
+ expect(wrapper.find(component).exists()).toBe(false);
+
+ await wrapper.vm.activate();
+
+ // assert that when the component is destroyed and re-rendered, the
+ // newly rendered component has the reference to the old component
+ // (i.e. the old component was deactivated and activated)
+ expect(wrapper.find(component).element).toBe(element);
+ });
+});
diff --git a/spec/frontend/helpers/local_storage_helper.js b/spec/frontend/helpers/local_storage_helper.js
index cd39b660bfd..0318b80aaef 100644
--- a/spec/frontend/helpers/local_storage_helper.js
+++ b/spec/frontend/helpers/local_storage_helper.js
@@ -35,7 +35,7 @@ export const createLocalStorageSpy = () => {
clear: jest.fn(() => {
storage = {};
}),
- getItem: jest.fn(key => storage[key]),
+ getItem: jest.fn(key => (key in storage ? storage[key] : null)),
setItem: jest.fn((key, value) => {
storage[key] = value;
}),
diff --git a/spec/frontend/helpers/local_storage_helper_spec.js b/spec/frontend/helpers/local_storage_helper_spec.js
index 6b44ea3a4c3..5d9961e7631 100644
--- a/spec/frontend/helpers/local_storage_helper_spec.js
+++ b/spec/frontend/helpers/local_storage_helper_spec.js
@@ -18,11 +18,11 @@ describe('localStorage helper', () => {
localStorage.removeItem('test', 'testing');
- expect(localStorage.getItem('test')).toBeUndefined();
+ expect(localStorage.getItem('test')).toBe(null);
expect(localStorage.getItem('test2')).toBe('testing');
localStorage.clear();
- expect(localStorage.getItem('test2')).toBeUndefined();
+ expect(localStorage.getItem('test2')).toBe(null);
});
});
diff --git a/spec/frontend/helpers/vue_test_utils_helper.js b/spec/frontend/helpers/vue_test_utils_helper.js
index 68326e37ae7..ead898f04d3 100644
--- a/spec/frontend/helpers/vue_test_utils_helper.js
+++ b/spec/frontend/helpers/vue_test_utils_helper.js
@@ -33,3 +33,10 @@ export const waitForMutation = (store, expectedMutationType) =>
}
});
});
+
+export const extendedWrapper = wrapper =>
+ Object.defineProperty(wrapper, 'findByTestId', {
+ value(id) {
+ return this.find(`[data-testid="${id}"]`);
+ },
+ });
diff --git a/spec/frontend/helpers/wait_for_text.js b/spec/frontend/helpers/wait_for_text.js
new file mode 100644
index 00000000000..6bed8a90a98
--- /dev/null
+++ b/spec/frontend/helpers/wait_for_text.js
@@ -0,0 +1,3 @@
+import { findByText } from '@testing-library/dom';
+
+export const waitForText = async (text, container = document) => findByText(container, text);
diff --git a/spec/frontend/ide/components/commit_sidebar/actions_spec.js b/spec/frontend/ide/components/commit_sidebar/actions_spec.js
index a303e2b9bee..0003e13c92f 100644
--- a/spec/frontend/ide/components/commit_sidebar/actions_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/actions_spec.js
@@ -83,12 +83,12 @@ describe('IDE commit sidebar actions', () => {
});
});
- describe('commitToCurrentBranchText', () => {
+ describe('currentBranchText', () => {
it('escapes current branch', () => {
const injectedSrc = '<img src="x" />';
createComponent({ currentBranchId: injectedSrc });
- expect(vm.commitToCurrentBranchText).not.toContain(injectedSrc);
+ expect(vm.currentBranchText).not.toContain(injectedSrc);
});
});
diff --git a/spec/frontend/ide/components/ide_review_spec.js b/spec/frontend/ide/components/ide_review_spec.js
index c9ac2ac423d..bcc98669427 100644
--- a/spec/frontend/ide/components/ide_review_spec.js
+++ b/spec/frontend/ide/components/ide_review_spec.js
@@ -1,14 +1,19 @@
import Vue from 'vue';
+import Vuex from 'vuex';
+import { createLocalVue, mount } from '@vue/test-utils';
import IdeReview from '~/ide/components/ide_review.vue';
+import EditorModeDropdown from '~/ide/components/editor_mode_dropdown.vue';
import { createStore } from '~/ide/stores';
-import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import { trimText } from '../../helpers/text_helper';
+import { keepAlive } from '../../helpers/keep_alive_component_helper';
import { file } from '../helpers';
import { projectData } from '../mock_data';
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
describe('IDE review mode', () => {
- const Component = Vue.extend(IdeReview);
- let vm;
+ let wrapper;
let store;
beforeEach(() => {
@@ -21,15 +26,53 @@ describe('IDE review mode', () => {
loading: false,
});
- vm = createComponentWithStore(Component, store).$mount();
+ wrapper = mount(keepAlive(IdeReview), {
+ store,
+ localVue,
+ });
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
it('renders list of files', () => {
- expect(vm.$el.textContent).toContain('fileName');
+ expect(wrapper.text()).toContain('fileName');
+ });
+
+ describe('activated', () => {
+ let inititializeSpy;
+
+ beforeEach(async () => {
+ inititializeSpy = jest.spyOn(wrapper.find(IdeReview).vm, 'initialize');
+ store.state.viewer = 'editor';
+
+ await wrapper.vm.reactivate();
+ });
+
+ it('re initializes the component', () => {
+ expect(inititializeSpy).toHaveBeenCalled();
+ });
+
+ it('updates viewer to "diff" by default', () => {
+ expect(store.state.viewer).toBe('diff');
+ });
+
+ describe('merge request is defined', () => {
+ beforeEach(async () => {
+ store.state.currentMergeRequestId = '1';
+ store.state.projects.abcproject.mergeRequests['1'] = {
+ iid: 123,
+ web_url: 'testing123',
+ };
+
+ await wrapper.vm.reactivate();
+ });
+
+ it('updates viewer to "mrdiff"', async () => {
+ expect(store.state.viewer).toBe('mrdiff');
+ });
+ });
});
describe('merge request', () => {
@@ -40,32 +83,27 @@ describe('IDE review mode', () => {
web_url: 'testing123',
};
- return vm.$nextTick();
+ return wrapper.vm.$nextTick();
});
it('renders edit dropdown', () => {
- expect(vm.$el.querySelector('.btn')).not.toBe(null);
+ expect(wrapper.find(EditorModeDropdown).exists()).toBe(true);
});
- it('renders merge request link & IID', () => {
+ it('renders merge request link & IID', async () => {
store.state.viewer = 'mrdiff';
- return vm.$nextTick(() => {
- const link = vm.$el.querySelector('.ide-review-sub-header');
+ await wrapper.vm.$nextTick();
- expect(link.querySelector('a').getAttribute('href')).toBe('testing123');
- expect(trimText(link.textContent)).toBe('Merge request (!123)');
- });
+ expect(trimText(wrapper.text())).toContain('Merge request (!123)');
});
- it('changes text to latest changes when viewer is not mrdiff', () => {
+ it('changes text to latest changes when viewer is not mrdiff', async () => {
store.state.viewer = 'diff';
- return vm.$nextTick(() => {
- expect(trimText(vm.$el.querySelector('.ide-review-sub-header').textContent)).toBe(
- 'Latest changes',
- );
- });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain('Latest changes');
});
});
});
diff --git a/spec/frontend/ide/components/ide_side_bar_spec.js b/spec/frontend/ide/components/ide_side_bar_spec.js
index 67257b40879..86e4e8d8f89 100644
--- a/spec/frontend/ide/components/ide_side_bar_spec.js
+++ b/spec/frontend/ide/components/ide_side_bar_spec.js
@@ -1,57 +1,88 @@
-import Vue from 'vue';
-import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { mount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { GlSkeletonLoading } from '@gitlab/ui';
import { createStore } from '~/ide/stores';
-import ideSidebar from '~/ide/components/ide_side_bar.vue';
+import IdeSidebar from '~/ide/components/ide_side_bar.vue';
+import IdeTree from '~/ide/components/ide_tree.vue';
+import RepoCommitSection from '~/ide/components/repo_commit_section.vue';
import { leftSidebarViews } from '~/ide/constants';
import { projectData } from '../mock_data';
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
describe('IdeSidebar', () => {
- let vm;
+ let wrapper;
let store;
- beforeEach(() => {
+ function createComponent() {
store = createStore();
- const Component = Vue.extend(ideSidebar);
-
store.state.currentProjectId = 'abcproject';
store.state.projects.abcproject = projectData;
- vm = createComponentWithStore(Component, store).$mount();
- });
+ return mount(IdeSidebar, {
+ store,
+ localVue,
+ });
+ }
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
+ wrapper = null;
});
it('renders a sidebar', () => {
- expect(vm.$el.querySelector('.multi-file-commit-panel-inner')).not.toBeNull();
+ wrapper = createComponent();
+
+ expect(wrapper.find('[data-testid="ide-side-bar-inner"]').exists()).toBe(true);
});
- it('renders loading icon component', done => {
- vm.$store.state.loading = true;
+ it('renders loading components', async () => {
+ wrapper = createComponent();
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
- expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
+ store.state.loading = true;
- done();
- });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(3);
});
describe('activityBarComponent', () => {
it('renders tree component', () => {
- expect(vm.$el.querySelector('.ide-file-list')).not.toBeNull();
+ wrapper = createComponent();
+
+ expect(wrapper.find(IdeTree).exists()).toBe(true);
});
- it('renders commit component', done => {
- vm.$store.state.currentActivityView = leftSidebarViews.commit.name;
+ it('renders commit component', async () => {
+ wrapper = createComponent();
+
+ store.state.currentActivityView = leftSidebarViews.commit.name;
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.multi-file-commit-panel-section')).not.toBeNull();
+ await wrapper.vm.$nextTick();
- done();
- });
+ expect(wrapper.find(RepoCommitSection).exists()).toBe(true);
});
});
+
+ it('keeps the current activity view components alive', async () => {
+ wrapper = createComponent();
+
+ const ideTreeComponent = wrapper.find(IdeTree).element;
+
+ store.state.currentActivityView = leftSidebarViews.commit.name;
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(IdeTree).exists()).toBe(false);
+ expect(wrapper.find(RepoCommitSection).exists()).toBe(true);
+
+ store.state.currentActivityView = leftSidebarViews.edit.name;
+
+ await wrapper.vm.$nextTick();
+
+ // reference to the elements remains the same, meaning the components were kept alive
+ expect(wrapper.find(IdeTree).element).toEqual(ideTreeComponent);
+ });
});
diff --git a/spec/frontend/ide/components/ide_tree_list_spec.js b/spec/frontend/ide/components/ide_tree_list_spec.js
index 4593ef6049b..dd57a5c5f4d 100644
--- a/spec/frontend/ide/components/ide_tree_list_spec.js
+++ b/spec/frontend/ide/components/ide_tree_list_spec.js
@@ -38,15 +38,9 @@ describe('IDE tree list', () => {
beforeEach(() => {
bootstrapWithTree();
- jest.spyOn(vm, 'updateViewer');
-
vm.$mount();
});
- it('updates viewer on mount', () => {
- expect(vm.updateViewer).toHaveBeenCalledWith('edit');
- });
-
it('renders loading indicator', done => {
store.state.trees['abcproject/master'].loading = true;
@@ -67,8 +61,6 @@ describe('IDE tree list', () => {
beforeEach(() => {
bootstrapWithTree(emptyBranchTree);
- jest.spyOn(vm, 'updateViewer');
-
vm.$mount();
});
diff --git a/spec/frontend/ide/components/ide_tree_spec.js b/spec/frontend/ide/components/ide_tree_spec.js
index 899daa0bf57..ad00dec2e48 100644
--- a/spec/frontend/ide/components/ide_tree_spec.js
+++ b/spec/frontend/ide/components/ide_tree_spec.js
@@ -1,19 +1,22 @@
import Vue from 'vue';
+import Vuex from 'vuex';
+import { mount, createLocalVue } from '@vue/test-utils';
import IdeTree from '~/ide/components/ide_tree.vue';
import { createStore } from '~/ide/stores';
-import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { keepAlive } from '../../helpers/keep_alive_component_helper';
import { file } from '../helpers';
import { projectData } from '../mock_data';
-describe('IdeRepoTree', () => {
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('IdeTree', () => {
let store;
- let vm;
+ let wrapper;
beforeEach(() => {
store = createStore();
- const IdeRepoTree = Vue.extend(IdeTree);
-
store.state.currentProjectId = 'abcproject';
store.state.currentBranchId = 'master';
store.state.projects.abcproject = { ...projectData };
@@ -22,14 +25,36 @@ describe('IdeRepoTree', () => {
loading: false,
});
- vm = createComponentWithStore(IdeRepoTree, store).$mount();
+ wrapper = mount(keepAlive(IdeTree), {
+ store,
+ localVue,
+ });
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
it('renders list of files', () => {
- expect(vm.$el.textContent).toContain('fileName');
+ expect(wrapper.text()).toContain('fileName');
+ });
+
+ describe('activated', () => {
+ let inititializeSpy;
+
+ beforeEach(async () => {
+ inititializeSpy = jest.spyOn(wrapper.find(IdeTree).vm, 'initialize');
+ store.state.viewer = 'diff';
+
+ await wrapper.vm.reactivate();
+ });
+
+ it('re initializes the component', () => {
+ expect(inititializeSpy).toHaveBeenCalled();
+ });
+
+ it('updates viewer to "editor" by default', () => {
+ expect(store.state.viewer).toBe('editor');
+ });
});
});
diff --git a/spec/frontend/ide/components/repo_commit_section_spec.js b/spec/frontend/ide/components/repo_commit_section_spec.js
index 3b837622720..096079308cd 100644
--- a/spec/frontend/ide/components/repo_commit_section_spec.js
+++ b/spec/frontend/ide/components/repo_commit_section_spec.js
@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils';
import { createStore } from '~/ide/stores';
import { createRouter } from '~/ide/ide_router';
+import { keepAlive } from '../../helpers/keep_alive_component_helper';
import RepoCommitSection from '~/ide/components/repo_commit_section.vue';
import EmptyState from '~/ide/components/commit_sidebar/empty_state.vue';
import { stageKeys } from '~/ide/constants';
@@ -14,7 +15,7 @@ describe('RepoCommitSection', () => {
let store;
function createComponent() {
- wrapper = mount(RepoCommitSection, { store });
+ wrapper = mount(keepAlive(RepoCommitSection), { store });
}
function setupDefaultState() {
@@ -64,6 +65,7 @@ describe('RepoCommitSection', () => {
afterEach(() => {
wrapper.destroy();
+ wrapper = null;
});
describe('empty state', () => {
@@ -168,4 +170,21 @@ describe('RepoCommitSection', () => {
expect(wrapper.find(EmptyState).exists()).toBe(false);
});
});
+
+ describe('activated', () => {
+ let inititializeSpy;
+
+ beforeEach(async () => {
+ createComponent();
+
+ inititializeSpy = jest.spyOn(wrapper.find(RepoCommitSection).vm, 'initialize');
+ store.state.viewer = 'diff';
+
+ await wrapper.vm.reactivate();
+ });
+
+ it('re initializes the component', () => {
+ expect(inititializeSpy).toHaveBeenCalled();
+ });
+ });
});
diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js
index 307806e0a8a..1b98d488854 100644
--- a/spec/frontend/incidents/components/incidents_list_spec.js
+++ b/spec/frontend/incidents/components/incidents_list_spec.js
@@ -5,7 +5,6 @@ import {
GlTable,
GlAvatar,
GlPagination,
- GlSearchBoxByType,
GlTab,
GlTabs,
GlBadge,
@@ -15,13 +14,24 @@ import { visitUrl, joinPaths, mergeUrlParams } from '~/lib/utils/url_utility';
import IncidentsList from '~/incidents/components/incidents_list.vue';
import SeverityToken from '~/sidebar/components/severity/severity.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { I18N, INCIDENT_STATUS_TABS } from '~/incidents/constants';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
+import {
+ I18N,
+ INCIDENT_STATUS_TABS,
+ TH_CREATED_AT_TEST_ID,
+ TH_SEVERITY_TEST_ID,
+ TH_PUBLISHED_TEST_ID,
+} from '~/incidents/constants';
import mockIncidents from '../mocks/incidents.json';
+import mockFilters from '../mocks/incidents_filter.json';
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn().mockName('visitUrlMock'),
- joinPaths: jest.fn().mockName('joinPaths'),
- mergeUrlParams: jest.fn().mockName('mergeUrlParams'),
+ joinPaths: jest.fn(),
+ mergeUrlParams: jest.fn(),
+ setUrlParams: jest.fn(),
+ updateHistory: jest.fn(),
}));
describe('Incidents List', () => {
@@ -41,9 +51,7 @@ describe('Incidents List', () => {
const findAlert = () => wrapper.find(GlAlert);
const findLoader = () => wrapper.find(GlLoadingIcon);
const findTimeAgo = () => wrapper.findAll(TimeAgoTooltip);
- const findDateColumnHeader = () =>
- wrapper.find('[data-testid="incident-management-created-at-sort"]');
- const findSearch = () => wrapper.find(GlSearchBoxByType);
+ const findSearch = () => wrapper.find(FilteredSearchBar);
const findAssingees = () => wrapper.findAll('[data-testid="incident-assignees"]');
const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]');
const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']");
@@ -73,9 +81,13 @@ describe('Incidents List', () => {
newIssuePath,
incidentTemplateName,
incidentType,
- issuePath: '/project/isssues',
+ issuePath: '/project/issues',
publishedAvailable: true,
emptyListSvgPath,
+ textQuery: '',
+ authorUsernamesQuery: '',
+ assigneeUsernamesQuery: '',
+ issuesIncidentDetails: false,
},
stubs: {
GlButton: true,
@@ -171,13 +183,6 @@ describe('Incidents List', () => {
expect(src).toBe(avatarUrl);
});
- it('contains a link to the issue details', () => {
- findTableRows()
- .at(0)
- .trigger('click');
- expect(visitUrl).toHaveBeenCalledWith(joinPaths(`/project/isssues/`, mockIncidents[0].iid));
- });
-
it('renders a closed icon for closed incidents', () => {
expect(findClosedIcon().length).toBe(
mockIncidents.filter(({ state }) => state === 'closed').length,
@@ -188,6 +193,30 @@ describe('Incidents List', () => {
it('renders severity per row', () => {
expect(findSeverity().length).toBe(mockIncidents.length);
});
+
+ it('contains a link to the issue details page', () => {
+ findTableRows()
+ .at(0)
+ .trigger('click');
+ expect(visitUrl).toHaveBeenCalledWith(joinPaths(`/project/issues/`, mockIncidents[0].iid));
+ });
+
+ it('contains a link to the incident details page', async () => {
+ beforeEach(() =>
+ mountComponent({
+ data: { incidents: { list: mockIncidents }, incidentsCount: {} },
+ loading: false,
+ provide: { glFeatures: { issuesIncidentDetails: true } },
+ }),
+ );
+
+ findTableRows()
+ .at(0)
+ .trigger('click');
+ expect(visitUrl).toHaveBeenCalledWith(
+ joinPaths(`/project/issues/incident`, mockIncidents[0].iid),
+ );
+ });
});
describe('Create Incident', () => {
@@ -207,11 +236,10 @@ describe('Incidents List', () => {
);
});
- it('sets button loading on click', () => {
+ it('sets button loading on click', async () => {
findCreateIncidentBtn().vm.$emit('click');
- return wrapper.vm.$nextTick().then(() => {
- expect(findCreateIncidentBtn().attributes('loading')).toBe('true');
- });
+ await wrapper.vm.$nextTick();
+ expect(findCreateIncidentBtn().attributes('loading')).toBe('true');
});
it("doesn't show the button when list is empty", () => {
@@ -243,51 +271,47 @@ describe('Incidents List', () => {
});
describe('prevPage', () => {
- it('returns prevPage button', () => {
+ it('returns prevPage button', async () => {
findPagination().vm.$emit('input', 3);
- return wrapper.vm.$nextTick(() => {
- expect(
- findPagination()
- .findAll('.page-item')
- .at(0)
- .text(),
- ).toBe('Prev');
- });
+ await wrapper.vm.$nextTick();
+ expect(
+ findPagination()
+ .findAll('.page-item')
+ .at(0)
+ .text(),
+ ).toBe('Prev');
});
- it('returns prevPage number', () => {
+ it('returns prevPage number', async () => {
findPagination().vm.$emit('input', 3);
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.prevPage).toBe(2);
- });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.prevPage).toBe(2);
});
- it('returns 0 when it is the first page', () => {
+ it('returns 0 when it is the first page', async () => {
findPagination().vm.$emit('input', 1);
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.prevPage).toBe(0);
- });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.prevPage).toBe(0);
});
});
describe('nextPage', () => {
- it('returns nextPage button', () => {
+ it('returns nextPage button', async () => {
findPagination().vm.$emit('input', 3);
- return wrapper.vm.$nextTick(() => {
- expect(
- findPagination()
- .findAll('.page-item')
- .at(1)
- .text(),
- ).toBe('Next');
- });
+ await wrapper.vm.$nextTick();
+ expect(
+ findPagination()
+ .findAll('.page-item')
+ .at(1)
+ .text(),
+ ).toBe('Next');
});
- it('returns nextPage number', () => {
+ it('returns nextPage number', async () => {
mountComponent({
data: {
incidents: {
@@ -301,21 +325,19 @@ describe('Incidents List', () => {
});
findPagination().vm.$emit('input', 1);
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.nextPage).toBe(2);
- });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.nextPage).toBe(2);
});
- it('returns `null` when currentPage is already last page', () => {
+ it('returns `null` when currentPage is already last page', async () => {
findStatusTabs().vm.$emit('input', 1);
findPagination().vm.$emit('input', 1);
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.nextPage).toBeNull();
- });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.nextPage).toBeNull();
});
});
- describe('Search', () => {
+ describe('Filtered search component', () => {
beforeEach(() => {
mountComponent({
data: {
@@ -331,15 +353,62 @@ describe('Incidents List', () => {
});
it('renders the search component for incidents', () => {
- expect(findSearch().exists()).toBe(true);
+ expect(findSearch().props('searchInputPlaceholder')).toBe('Search or filter results…');
+ expect(findSearch().props('tokens')).toEqual([
+ {
+ type: 'author_username',
+ icon: 'user',
+ title: 'Author',
+ unique: true,
+ symbol: '@',
+ token: AuthorToken,
+ operators: [{ value: '=', description: 'is', default: 'true' }],
+ fetchPath: '/project/path',
+ fetchAuthors: expect.any(Function),
+ },
+ {
+ type: 'assignee_username',
+ icon: 'user',
+ title: 'Assignees',
+ unique: true,
+ symbol: '@',
+ token: AuthorToken,
+ operators: [{ value: '=', description: 'is', default: 'true' }],
+ fetchPath: '/project/path',
+ fetchAuthors: expect.any(Function),
+ },
+ ]);
+ expect(findSearch().props('recentSearchesStorageKey')).toBe('incidents');
+ });
+
+ it('returns correctly applied filter search values', async () => {
+ const searchTerm = 'foo';
+ wrapper.setData({
+ searchTerm,
+ });
+
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.filteredSearchValue).toEqual([searchTerm]);
});
- it('sets the `searchTerm` graphql variable', () => {
- const SEARCH_TERM = 'Simple Incident';
+ it('updates props tied to getIncidents GraphQL query', () => {
+ wrapper.vm.handleFilterIncidents(mockFilters);
- findSearch().vm.$emit('input', SEARCH_TERM);
+ expect(wrapper.vm.authorUsername).toBe('root');
+ expect(wrapper.vm.assigneeUsernames).toEqual('root2');
+ expect(wrapper.vm.searchTerm).toBe(mockFilters[2].value.data);
+ });
- expect(wrapper.vm.$data.searchTerm).toBe(SEARCH_TERM);
+ it('updates props `searchTerm` and `authorUsername` with empty values when passed filters param is empty', () => {
+ wrapper.setData({
+ authorUsername: 'foo',
+ searchTerm: 'bar',
+ });
+
+ wrapper.vm.handleFilterIncidents([]);
+
+ expect(wrapper.vm.authorUsername).toBe('');
+ expect(wrapper.vm.searchTerm).toBe('');
});
});
@@ -383,13 +452,25 @@ describe('Incidents List', () => {
});
});
- it('updates sort with new direction and column key', () => {
- expect(findDateColumnHeader().attributes('aria-sort')).toBe('descending');
+ const descSort = 'descending';
+ const ascSort = 'ascending';
+ const noneSort = 'none';
- findDateColumnHeader().trigger('click');
- return wrapper.vm.$nextTick(() => {
- expect(findDateColumnHeader().attributes('aria-sort')).toBe('ascending');
- });
+ it.each`
+ selector | initialSort | firstSort | nextSort
+ ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort}
+ ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
+ ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
+ `('updates sort with new direction', async ({ selector, initialSort, firstSort, nextSort }) => {
+ const [[attr, value]] = Object.entries(selector);
+ const columnHeader = () => wrapper.find(`[${attr}="${value}"]`);
+ expect(columnHeader().attributes('aria-sort')).toBe(initialSort);
+ columnHeader().trigger('click');
+ await wrapper.vm.$nextTick();
+ expect(columnHeader().attributes('aria-sort')).toBe(firstSort);
+ columnHeader().trigger('click');
+ await wrapper.vm.$nextTick();
+ expect(columnHeader().attributes('aria-sort')).toBe(nextSort);
});
});
});
diff --git a/spec/frontend/incidents/mocks/incidents_filter.json b/spec/frontend/incidents/mocks/incidents_filter.json
new file mode 100644
index 00000000000..9f54e259b1d
--- /dev/null
+++ b/spec/frontend/incidents/mocks/incidents_filter.json
@@ -0,0 +1,14 @@
+ [
+ {
+ "type": "assignee_username",
+ "value": { "data": "root2" }
+ },
+ {
+ "type": "author_username",
+ "value": { "data": "root" }
+ },
+ {
+ "type": "filtered-search-term",
+ "value": { "data": "bar" }
+ }
+ ] \ No newline at end of file
diff --git a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap
index cab2165b5db..e4620590e62 100644
--- a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap
+++ b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap
@@ -93,23 +93,20 @@ exports[`Alert integration settings form default state should match the default
</gl-form-checkbox-stub>
</gl-form-group-stub>
- <div
- class="gl-display-flex gl-justify-content-end"
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ class="js-no-auto-disable"
+ data-qa-selector="save_changes_button"
+ icon=""
+ size="medium"
+ type="submit"
+ variant="success"
>
- <gl-button-stub
- category="primary"
- class="js-no-auto-disable"
- data-qa-selector="save_changes_button"
- icon=""
- size="medium"
- type="submit"
- variant="success"
- >
-
- Save changes
- </gl-button-stub>
- </div>
+ Save changes
+
+ </gl-button-stub>
</form>
</div>
`;
diff --git a/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap
index 3ad4c13382d..53c3e131466 100644
--- a/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap
+++ b/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap
@@ -18,6 +18,7 @@ exports[`IncidentsSettingTabs should render the component 1`] = `
</h4>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="js-settings-toggle"
icon=""
diff --git a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap
index 78bb238fcb6..ea2c512bf40 100644
--- a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap
+++ b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap
@@ -42,24 +42,21 @@ exports[`Alert integration settings form should match the default snapshot 1`] =
/>
</div>
- <div
- class="gl-display-flex gl-justify-content-end"
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ class="gl-mt-3"
+ data-testid="webhook-reset-btn"
+ icon=""
+ role="button"
+ size="medium"
+ tabindex="0"
+ variant="default"
>
- <gl-button-stub
- category="primary"
- class="gl-mt-3"
- data-testid="webhook-reset-btn"
- icon=""
- role="button"
- size="medium"
- tabindex="0"
- variant="default"
- >
-
- Reset webhook URL
- </gl-button-stub>
- </div>
+ Reset webhook URL
+
+ </gl-button-stub>
<gl-modal-stub
modalclass=""
@@ -76,22 +73,19 @@ exports[`Alert integration settings form should match the default snapshot 1`] =
</gl-modal-stub>
</gl-form-group-stub>
- <div
- class="gl-display-flex gl-justify-content-end"
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ class="js-no-auto-disable"
+ icon=""
+ size="medium"
+ type="submit"
+ variant="success"
>
- <gl-button-stub
- category="primary"
- class="js-no-auto-disable"
- icon=""
- size="medium"
- type="submit"
- variant="success"
- >
-
- Save changes
- </gl-button-stub>
- </div>
+ Save changes
+
+ </gl-button-stub>
</form>
</div>
`;
diff --git a/spec/frontend/integrations/edit/components/confirmation_modal_spec.js b/spec/frontend/integrations/edit/components/confirmation_modal_spec.js
new file mode 100644
index 00000000000..02f311f579f
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/confirmation_modal_spec.js
@@ -0,0 +1,51 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlModal } from '@gitlab/ui';
+import { createStore } from '~/integrations/edit/store';
+
+import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue';
+
+describe('ConfirmationModal', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(ConfirmationModal, {
+ store: createStore(),
+ });
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ const findGlModal = () => wrapper.find(GlModal);
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders GlModal with correct copy', () => {
+ expect(findGlModal().exists()).toBe(true);
+ expect(findGlModal().attributes('title')).toBe('Save settings?');
+ expect(findGlModal().text()).toContain(
+ 'Saving will update the default settings for all projects that are not using custom settings.',
+ );
+ expect(findGlModal().text()).toContain(
+ 'Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults.',
+ );
+ });
+
+ it('emits `submit` event when `primary` event is emitted on GlModal', async () => {
+ expect(wrapper.emitted().submit).toBeUndefined();
+
+ findGlModal().vm.$emit('primary');
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted().submit).toHaveLength(1);
+ });
+ });
+});
diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js
index eeb5d21d62c..efcc727277a 100644
--- a/spec/frontend/integrations/edit/components/integration_form_spec.js
+++ b/spec/frontend/integrations/edit/components/integration_form_spec.js
@@ -4,6 +4,7 @@ import { createStore } from '~/integrations/edit/store';
import IntegrationForm from '~/integrations/edit/components/integration_form.vue';
import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue';
import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue';
+import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue';
import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue';
import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
@@ -22,6 +23,7 @@ describe('IntegrationForm', () => {
stubs: {
OverrideDropdown,
ActiveCheckbox,
+ ConfirmationModal,
JiraTriggerFields,
TriggerFields,
},
@@ -40,6 +42,7 @@ describe('IntegrationForm', () => {
const findOverrideDropdown = () => wrapper.find(OverrideDropdown);
const findActiveCheckbox = () => wrapper.find(ActiveCheckbox);
+ const findConfirmationModal = () => wrapper.find(ConfirmationModal);
const findJiraTriggerFields = () => wrapper.find(JiraTriggerFields);
const findJiraIssuesFields = () => wrapper.find(JiraIssuesFields);
const findTriggerFields = () => wrapper.find(TriggerFields);
@@ -63,6 +66,26 @@ describe('IntegrationForm', () => {
});
});
+ describe('integrationLevel is instance', () => {
+ it('renders ConfirmationModal', () => {
+ createComponent({
+ integrationLevel: 'instance',
+ });
+
+ expect(findConfirmationModal().exists()).toBe(true);
+ });
+ });
+
+ describe('integrationLevel is not instance', () => {
+ it('does not render ConfirmationModal', () => {
+ createComponent({
+ integrationLevel: 'project',
+ });
+
+ expect(findConfirmationModal().exists()).toBe(false);
+ });
+ });
+
describe('type is "slack"', () => {
beforeEach(() => {
createComponent({ type: 'slack' });
diff --git a/spec/frontend/integrations/edit/mock_data.js b/spec/frontend/integrations/edit/mock_data.js
index 821972b7698..27ba0768331 100644
--- a/spec/frontend/integrations/edit/mock_data.js
+++ b/spec/frontend/integrations/edit/mock_data.js
@@ -2,6 +2,7 @@ export const mockIntegrationProps = {
id: 25,
initialActivated: true,
showActive: true,
+ editable: true,
triggerFieldsProps: {
initialTriggerCommit: false,
initialTriggerMergeRequest: false,
diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js
new file mode 100644
index 00000000000..0be0fbbde2d
--- /dev/null
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -0,0 +1,115 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDropdown, GlDropdownItem, GlDatepicker, GlSprintf, GlLink } from '@gitlab/ui';
+import Api from '~/api';
+import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
+
+const groupId = '1';
+const groupName = 'testgroup';
+const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 };
+const defaultAccessLevel = '10';
+const helpLink = 'https://example.com';
+
+const createComponent = () => {
+ return shallowMount(InviteMembersModal, {
+ propsData: {
+ groupId,
+ groupName,
+ accessLevels,
+ defaultAccessLevel,
+ helpLink,
+ },
+ stubs: {
+ GlSprintf,
+ 'gl-modal': '<div><slot name="modal-footer"></slot><slot></slot></div>',
+ },
+ });
+};
+
+describe('InviteMembersModal', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findDropdown = () => wrapper.find(GlDropdown);
+ const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
+ const findDatepicker = () => wrapper.find(GlDatepicker);
+ const findLink = () => wrapper.find(GlLink);
+ const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
+ const findInviteButton = () => wrapper.find({ ref: 'inviteButton' });
+
+ describe('rendering the modal', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('renders the modal with the correct title', () => {
+ expect(wrapper.attributes('title')).toBe('Invite team members');
+ });
+
+ it('renders the Cancel button text correctly', () => {
+ expect(findCancelButton().text()).toBe('Cancel');
+ });
+
+ it('renders the Invite button text correctly', () => {
+ expect(findInviteButton().text()).toBe('Invite');
+ });
+
+ describe('rendering the access levels dropdown', () => {
+ it('sets the default dropdown text to the default access level name', () => {
+ expect(findDropdown().attributes('text')).toBe('Guest');
+ });
+
+ it('renders dropdown items for each accessLevel', () => {
+ expect(findDropdownItems()).toHaveLength(5);
+ });
+ });
+
+ describe('rendering the help link', () => {
+ it('renders the correct link', () => {
+ expect(findLink().attributes('href')).toBe(helpLink);
+ });
+ });
+
+ describe('rendering the access expiration date field', () => {
+ it('renders the datepicker', () => {
+ expect(findDatepicker()).toExist();
+ });
+ });
+ });
+
+ describe('submitting the invite form', () => {
+ const postData = {
+ user_id: '1',
+ access_level: '10',
+ expires_at: new Date(),
+ format: 'json',
+ };
+
+ beforeEach(() => {
+ wrapper = createComponent();
+
+ jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData });
+ wrapper.vm.$toast = { show: jest.fn() };
+
+ wrapper.vm.submitForm(postData);
+ });
+
+ it('calls Api inviteGroupMember with the correct params', () => {
+ expect(Api.inviteGroupMember).toHaveBeenCalledWith(groupId, postData);
+ });
+
+ describe('when the invite was sent successfully', () => {
+ const toastMessageSuccessful = 'Users were succesfully added';
+
+ it('displays the successful toastMessage', () => {
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
+ toastMessageSuccessful,
+ wrapper.vm.toastOptions,
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js
new file mode 100644
index 00000000000..450d37a9748
--- /dev/null
+++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js
@@ -0,0 +1,58 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon, GlLink } from '@gitlab/ui';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+
+const displayText = 'Invite team members';
+const icon = 'plus';
+
+const createComponent = (props = {}) => {
+ return shallowMount(InviteMembersTrigger, {
+ propsData: {
+ displayText,
+ ...props,
+ },
+ });
+};
+
+describe('InviteMembersTrigger', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('displayText', () => {
+ const findLink = () => wrapper.find(GlLink);
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('includes the correct displayText for the link', () => {
+ expect(findLink().text()).toBe(displayText);
+ });
+ });
+
+ describe('icon', () => {
+ const findIcon = () => wrapper.find(GlIcon);
+
+ it('includes the correct icon when an icon is sent', () => {
+ wrapper = createComponent({ icon });
+
+ expect(findIcon().attributes('name')).toBe(icon);
+ });
+
+ it('does not include an icon when icon is not sent', () => {
+ wrapper = createComponent();
+
+ expect(findIcon().exists()).toBe(false);
+ });
+
+ it('does not include an icon when empty string is sent', () => {
+ wrapper = createComponent({ icon: '' });
+
+ expect(findIcon().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js
index bfbe4ec8e70..17a195df494 100644
--- a/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js
+++ b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js
@@ -48,7 +48,10 @@ describe('AddIssuableForm', () => {
const input = findFormInput(wrapper);
if (input) input.blur();
- wrapper.destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
describe('with data', () => {
diff --git a/spec/frontend/issuable/related_issues/components/issue_token_spec.js b/spec/frontend/issuable/related_issues/components/issue_token_spec.js
index 553721fa783..f2cb9042ba6 100644
--- a/spec/frontend/issuable/related_issues/components/issue_token_spec.js
+++ b/spec/frontend/issuable/related_issues/components/issue_token_spec.js
@@ -1,241 +1,146 @@
-import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
import { PathIdSeparator } from '~/related_issues/constants';
-import issueToken from '~/related_issues/components/issue_token.vue';
+import IssueToken from '~/related_issues/components/issue_token.vue';
describe('IssueToken', () => {
const idKey = 200;
const displayReference = 'foo/bar#123';
- const title = 'some title';
- const pathIdSeparator = PathIdSeparator.Issue;
const eventNamespace = 'pendingIssuable';
- let IssueToken;
- let vm;
+ const path = '/foo/bar/issues/123';
+ const pathIdSeparator = PathIdSeparator.Issue;
+ const title = 'some title';
- beforeEach(() => {
- IssueToken = Vue.extend(issueToken);
- });
+ let wrapper;
+
+ const defaultProps = {
+ idKey,
+ displayReference,
+ pathIdSeparator,
+ };
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(IssueToken, {
+ propsData: { ...defaultProps, ...props },
+ });
+ };
afterEach(() => {
- if (vm) {
- vm.$destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
}
});
+ const findLink = () => wrapper.find({ ref: 'link' });
+ const findReference = () => wrapper.find({ ref: 'reference' });
+ const findReferenceIcon = () => wrapper.find('[data-testid="referenceIcon"]');
+ const findRemoveBtn = () => wrapper.find('[data-testid="removeBtn"]');
+ const findTitle = () => wrapper.find({ ref: 'title' });
+
describe('with reference supplied', () => {
beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- },
- }).$mount();
+ createComponent();
});
it('shows reference', () => {
- expect(vm.$el.textContent.trim()).toEqual(displayReference);
+ expect(wrapper.text()).toContain(displayReference);
});
it('does not link without path specified', () => {
- expect(vm.$refs.link.tagName.toLowerCase()).toEqual('span');
- expect(vm.$refs.link.getAttribute('href')).toBeNull();
+ expect(findLink().element.tagName).toBe('SPAN');
+ expect(findLink().attributes('href')).toBeUndefined();
});
});
describe('with reference and title supplied', () => {
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- title,
- },
- }).$mount();
- });
-
it('shows reference and title', () => {
- expect(vm.$refs.reference.textContent.trim()).toEqual(displayReference);
- expect(vm.$refs.title.textContent.trim()).toEqual(title);
- });
- });
-
- describe('with path supplied', () => {
- const path = '/foo/bar/issues/123';
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- title,
- path,
- },
- }).$mount();
- });
+ createComponent({
+ title,
+ });
- it('links reference and title', () => {
- expect(vm.$refs.link.getAttribute('href')).toEqual(path);
+ expect(findReference().text()).toBe(displayReference);
+ expect(findTitle().text()).toBe(title);
});
});
- describe('with state supplied', () => {
- describe("`state: 'opened'`", () => {
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- state: 'opened',
- },
- }).$mount();
+ describe('with path and title supplied', () => {
+ it('links reference and title', () => {
+ createComponent({
+ path,
+ title,
});
- it('shows green circle icon', () => {
- expect(vm.$el.querySelector('.issue-token-state-icon-open.fa.fa-circle-o')).toBeDefined();
- });
- });
-
- describe("`state: 'reopened'`", () => {
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- state: 'reopened',
- },
- }).$mount();
- });
-
- it('shows green circle icon', () => {
- expect(vm.$el.querySelector('.issue-token-state-icon-open.fa.fa-circle-o')).toBeDefined();
- });
+ expect(findLink().attributes('href')).toBe(path);
});
+ });
- describe("`state: 'closed'`", () => {
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- state: 'closed',
- },
- }).$mount();
+ describe('with state supplied', () => {
+ it.each`
+ state | icon | cssClass
+ ${'opened'} | ${'issue-open-m'} | ${'issue-token-state-icon-open'}
+ ${'reopened'} | ${'issue-open-m'} | ${'issue-token-state-icon-open'}
+ ${'closed'} | ${'issue-close'} | ${'issue-token-state-icon-closed'}
+ `('shows "$icon" icon when "$state"', ({ state, icon, cssClass }) => {
+ createComponent({
+ path,
+ state,
});
- it('shows red minus icon', () => {
- expect(vm.$el.querySelector('.issue-token-state-icon-closed.fa.fa-minus')).toBeDefined();
- });
+ expect(findReferenceIcon().props('name')).toBe(icon);
+ expect(findReferenceIcon().classes()).toContain(cssClass);
});
});
describe('with reference, title, state', () => {
const state = 'opened';
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- title,
- state,
- },
- }).$mount();
- });
it('shows reference, title, and state', () => {
- const stateIcon = vm.$refs.reference.querySelector('svg');
+ createComponent({
+ title,
+ state,
+ });
- expect(stateIcon.getAttribute('aria-label')).toEqual(state);
- expect(vm.$refs.reference.textContent.trim()).toEqual(displayReference);
- expect(vm.$refs.title.textContent.trim()).toEqual(title);
+ expect(findReferenceIcon().attributes('aria-label')).toBe(state);
+ expect(findReference().text()).toBe(displayReference);
+ expect(findTitle().text()).toBe(title);
});
});
describe('with canRemove', () => {
describe('`canRemove: false` (default)', () => {
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- },
- }).$mount();
- });
-
it('does not have remove button', () => {
- expect(vm.$el.querySelector('.issue-token-remove-button')).toBeNull();
+ createComponent();
+
+ expect(findRemoveBtn().exists()).toBe(false);
});
});
describe('`canRemove: true`', () => {
beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- canRemove: true,
- },
- }).$mount();
+ createComponent({
+ eventNamespace,
+ canRemove: true,
+ });
});
it('has remove button', () => {
- expect(vm.$el.querySelector('.issue-token-remove-button')).toBeDefined();
+ expect(findRemoveBtn().exists()).toBe(true);
});
- });
- });
-
- describe('methods', () => {
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- },
- }).$mount();
- });
- it('when getting checked', () => {
- jest.spyOn(vm, '$emit').mockImplementation(() => {});
- vm.onRemoveRequest();
+ it('emits event when clicked', () => {
+ findRemoveBtn().trigger('click');
- expect(vm.$emit).toHaveBeenCalledWith('pendingIssuableRemoveRequest', vm.idKey);
- });
- });
+ const emitted = wrapper.emitted(`${eventNamespace}RemoveRequest`);
- describe('tooltip', () => {
- beforeEach(() => {
- vm = new IssueToken({
- propsData: {
- idKey,
- eventNamespace,
- displayReference,
- pathIdSeparator,
- canRemove: true,
- },
- }).$mount();
- });
-
- it('should not be escaped', () => {
- const { originalTitle } = vm.$refs.removeButton.dataset;
+ expect(emitted).toHaveLength(1);
+ expect(emitted[0]).toEqual([idKey]);
+ });
- expect(originalTitle).toEqual(`Remove ${displayReference}`);
+ it('tooltip should not be escaped', () => {
+ expect(findRemoveBtn().attributes('data-original-title')).toBe(
+ `Remove ${displayReference}`,
+ );
+ });
});
});
});
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
index 0f88e4d71fe..b758b85beef 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
@@ -18,7 +18,10 @@ describe('RelatedIssuesBlock', () => {
const findIssueCountBadgeAddButton = () => wrapper.find(GlButton);
afterEach(() => {
- wrapper.destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
describe('with defaults', () => {
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
index 6cf0b9d21ea..39bc244297b 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
@@ -14,7 +14,10 @@ describe('RelatedIssuesList', () => {
let wrapper;
afterEach(() => {
- wrapper.destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
describe('with defaults', () => {
diff --git a/spec/frontend/issuable_create/components/issuable_form_spec.js b/spec/frontend/issuable_create/components/issuable_form_spec.js
index e2c6b4d9521..e489d1dae3e 100644
--- a/spec/frontend/issuable_create/components/issuable_form_spec.js
+++ b/spec/frontend/issuable_create/components/issuable_form_spec.js
@@ -79,6 +79,7 @@ describe('IssuableForm', () => {
markdownDocsPath: wrapper.vm.descriptionHelpPath,
addSpacingClasses: false,
showSuggestPopover: true,
+ textareaValue: '',
});
expect(descriptionFieldEl.find('textarea').exists()).toBe(true);
expect(descriptionFieldEl.find('textarea').attributes('placeholder')).toBe(
diff --git a/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js b/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js
index 8d50df5e406..766a27015bb 100644
--- a/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js
+++ b/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js
@@ -9,6 +9,7 @@ describe('Highlight Bar', () => {
let wrapper;
const alert = {
+ iid: 1,
startedAt: '2020-05-29T10:39:22Z',
detailsUrl: 'http://127.0.0.1:3000/root/unique-alerts/-/alert_management/1/details',
eventCount: 1,
@@ -39,7 +40,8 @@ describe('Highlight Bar', () => {
it('renders a link to the alert page', () => {
expect(findLink().exists()).toBe(true);
expect(findLink().attributes('href')).toBe(alert.detailsUrl);
- expect(findLink().text()).toContain(alert.title);
+ expect(findLink().attributes('title')).toBe(alert.title);
+ expect(findLink().text()).toBe(`#${alert.iid}`);
});
it('renders formatted start time of the alert', () => {
diff --git a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js b/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js
index a51b497cd79..6babba37b57 100644
--- a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js
+++ b/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js
@@ -79,7 +79,7 @@ describe('Incident Tabs component', () => {
it('renders the alert details table with the correct props', () => {
const alert = { iid: mockAlert.iid };
- expect(findAlertDetailsComponent().props('alert')).toEqual(alert);
+ expect(findAlertDetailsComponent().props('alert')).toMatchObject(alert);
expect(findAlertDetailsComponent().props('loading')).toBe(true);
});
diff --git a/spec/frontend/issue_show/issue_spec.js b/spec/frontend/issue_show/issue_spec.js
index befb670c6cd..c0175e774a2 100644
--- a/spec/frontend/issue_show/issue_spec.js
+++ b/spec/frontend/issue_show/issue_spec.js
@@ -14,12 +14,8 @@ useMockIntersectionObserver();
jest.mock('~/lib/utils/poll');
const setupHTML = initialData => {
- document.body.innerHTML = `
- <div id="js-issuable-app"></div>
- <script id="js-issuable-app-initial-data" type="application/json">
- ${JSON.stringify(initialData)}
- </script>
- `;
+ document.body.innerHTML = `<div id="js-issuable-app"></div>`;
+ document.getElementById('js-issuable-app').dataset.initial = JSON.stringify(initialData);
};
describe('Issue show index', () => {
diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js
index 294f88bbc74..e50d304bb08 100644
--- a/spec/frontend/jobs/store/utils_spec.js
+++ b/spec/frontend/jobs/store/utils_spec.js
@@ -35,6 +35,14 @@ describe('Jobs Store Utils', () => {
lines: [],
});
});
+
+ it('pre-closes a section when specified in options', () => {
+ const headerLine = { content: [{ text: 'foo' }], section_options: { collapsed: 'true' } };
+
+ const parsedHeaderLine = parseHeaderLine(headerLine, 2);
+
+ expect(parsedHeaderLine.isClosed).toBe(true);
+ });
});
describe('parseLine', () => {
diff --git a/spec/frontend/lib/dompurify_spec.js b/spec/frontend/lib/dompurify_spec.js
new file mode 100644
index 00000000000..ee1971a4931
--- /dev/null
+++ b/spec/frontend/lib/dompurify_spec.js
@@ -0,0 +1,98 @@
+import { sanitize } from '~/lib/dompurify';
+
+// GDK
+const rootGon = {
+ sprite_file_icons: '/assets/icons-123a.svg',
+ sprite_icons: '/assets/icons-456b.svg',
+};
+
+// Production
+const absoluteGon = {
+ sprite_file_icons: `${window.location.protocol}//${window.location.hostname}/assets/icons-123a.svg`,
+ sprite_icons: `${window.location.protocol}//${window.location.hostname}/assets/icons-456b.svg`,
+};
+
+const expectedSanitized = '<svg><use></use></svg>';
+
+const safeUrls = {
+ root: Object.values(rootGon).map(url => `${url}#ellipsis_h`),
+ absolute: Object.values(absoluteGon).map(url => `${url}#ellipsis_h`),
+};
+
+const unsafeUrls = [
+ '/an/evil/url',
+ '../../../evil/url',
+ 'https://evil.url/assets/icons-123a.svg',
+ 'https://evil.url/assets/icons-456b.svg',
+ `https://evil.url/${rootGon.sprite_icons}`,
+ `https://evil.url/${rootGon.sprite_file_icons}`,
+ `https://evil.url/${absoluteGon.sprite_icons}`,
+ `https://evil.url/${absoluteGon.sprite_file_icons}`,
+];
+
+describe('~/lib/dompurify', () => {
+ let originalGon;
+
+ it('uses local configuration when given', () => {
+ // As dompurify uses a "Persistent Configuration", it might
+ // ignore config, this check verifies we respect
+ // https://github.com/cure53/DOMPurify#persistent-configuration
+ expect(sanitize('<br>', { ALLOWED_TAGS: [] })).toBe('');
+ expect(sanitize('<strong></strong>', { ALLOWED_TAGS: [] })).toBe('');
+ });
+
+ describe.each`
+ type | gon
+ ${'root'} | ${rootGon}
+ ${'absolute'} | ${absoluteGon}
+ `('when gon contains $type icon urls', ({ type, gon }) => {
+ beforeAll(() => {
+ originalGon = window.gon;
+ window.gon = gon;
+ });
+
+ afterAll(() => {
+ window.gon = originalGon;
+ });
+
+ it('allows no href attrs', () => {
+ const htmlHref = `<svg><use></use></svg>`;
+ expect(sanitize(htmlHref)).toBe(htmlHref);
+ });
+
+ it.each(safeUrls[type])('allows safe URL %s', url => {
+ const htmlHref = `<svg><use href="${url}"></use></svg>`;
+ expect(sanitize(htmlHref)).toBe(htmlHref);
+
+ const htmlXlink = `<svg><use xlink:href="${url}"></use></svg>`;
+ expect(sanitize(htmlXlink)).toBe(htmlXlink);
+ });
+
+ it.each(unsafeUrls)('sanitizes unsafe URL %s', url => {
+ const htmlHref = `<svg><use href="${url}"></use></svg>`;
+ const htmlXlink = `<svg><use xlink:href="${url}"></use></svg>`;
+
+ expect(sanitize(htmlHref)).toBe(expectedSanitized);
+ expect(sanitize(htmlXlink)).toBe(expectedSanitized);
+ });
+ });
+
+ describe('when gon does not contain icon urls', () => {
+ beforeAll(() => {
+ originalGon = window.gon;
+ window.gon = {};
+ });
+
+ afterAll(() => {
+ window.gon = originalGon;
+ });
+
+ it.each([...safeUrls.root, ...safeUrls.absolute, ...unsafeUrls])('sanitizes URL %s', url => {
+ const htmlHref = `<svg><use href="${url}"></use></svg>`;
+ const htmlXlink = `<svg><use xlink:href="${url}"></use></svg>`;
+
+ expect(sanitize(htmlHref)).toBe(expectedSanitized);
+ expect(sanitize(htmlXlink)).toBe(expectedSanitized);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/axios_startup_calls_spec.js b/spec/frontend/lib/utils/axios_startup_calls_spec.js
index e804cae7914..e12bf725560 100644
--- a/spec/frontend/lib/utils/axios_startup_calls_spec.js
+++ b/spec/frontend/lib/utils/axios_startup_calls_spec.js
@@ -111,21 +111,44 @@ describe('setupAxiosStartupCalls', () => {
});
});
- it('removes GitLab Base URL from startup call', async () => {
- const oldGon = window.gon;
- window.gon = { gitlab_url: 'https://example.org/gitlab' };
-
- window.gl.startup_calls = {
- '/startup': {
- fetchCall: mockFetchCall(200),
- },
- };
- setupAxiosStartupCalls(axios);
+ describe('startup call', () => {
+ let oldGon;
+
+ beforeEach(() => {
+ oldGon = window.gon;
+ window.gon = { gitlab_url: 'https://example.org/gitlab' };
+ });
+
+ afterEach(() => {
+ window.gon = oldGon;
+ });
- const { data } = await axios.get('https://example.org/gitlab/startup');
+ it('removes GitLab Base URL from startup call', async () => {
+ window.gl.startup_calls = {
+ '/startup': {
+ fetchCall: mockFetchCall(200),
+ },
+ };
+ setupAxiosStartupCalls(axios);
- expect(data).toEqual(STARTUP_JS_RESPONSE);
+ const { data } = await axios.get('https://example.org/gitlab/startup');
- window.gon = oldGon;
+ expect(data).toEqual(STARTUP_JS_RESPONSE);
+ });
+
+ it('sorts the params in the requested API url', async () => {
+ window.gl.startup_calls = {
+ '/startup?alpha=true&bravo=true': {
+ fetchCall: mockFetchCall(200),
+ },
+ };
+ setupAxiosStartupCalls(axios);
+
+ // Use a full url instead of passing options = { params: { ... } } to axios.get
+ // to ensure the params are listed in the specified order.
+ const { data } = await axios.get('https://example.org/gitlab/startup?bravo=true&alpha=true');
+
+ expect(data).toEqual(STARTUP_JS_RESPONSE);
+ });
});
});
diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js
index 5b1fdea058b..a7973d66b50 100644
--- a/spec/frontend/lib/utils/datetime_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime_utility_spec.js
@@ -667,3 +667,26 @@ describe('differenceInMilliseconds', () => {
expect(datetimeUtility.differenceInMilliseconds(startDate, endDate)).toBe(expected);
});
});
+
+describe('dateAtFirstDayOfMonth', () => {
+ const date = new Date('2019-07-16T12:00:00.000Z');
+
+ it('returns the date at the first day of the month', () => {
+ const startDate = datetimeUtility.dateAtFirstDayOfMonth(date);
+ const expectedStartDate = new Date('2019-07-01T12:00:00.000Z');
+
+ expect(startDate).toStrictEqual(expectedStartDate);
+ });
+});
+
+describe('datesMatch', () => {
+ const date = new Date('2019-07-17T00:00:00.000Z');
+
+ it.each`
+ date1 | date2 | expected
+ ${date} | ${new Date('2019-07-17T00:00:00.000Z')} | ${true}
+ ${date} | ${new Date('2019-07-17T12:00:00.000Z')} | ${false}
+ `('returns $expected for $date1 matches $date2', ({ date1, date2, expected }) => {
+ expect(datetimeUtility.datesMatch(date1, date2)).toBe(expected);
+ });
+});
diff --git a/spec/frontend/lib/utils/experimentation_spec.js b/spec/frontend/lib/utils/experimentation_spec.js
new file mode 100644
index 00000000000..2c5d2f89297
--- /dev/null
+++ b/spec/frontend/lib/utils/experimentation_spec.js
@@ -0,0 +1,20 @@
+import * as experimentUtils from '~/lib/utils/experimentation';
+
+const TEST_KEY = 'abc';
+
+describe('experiment Utilities', () => {
+ describe('isExperimentEnabled', () => {
+ it.each`
+ experiments | value
+ ${{ [TEST_KEY]: true }} | ${true}
+ ${{ [TEST_KEY]: false }} | ${false}
+ ${{ def: true }} | ${false}
+ ${{}} | ${false}
+ ${null} | ${false}
+ `('returns correct value of $value for experiments=$experiments', ({ experiments, value }) => {
+ window.gon = { experiments };
+
+ expect(experimentUtils.isExperimentEnabled(TEST_KEY)).toEqual(value);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js
index 869ae274a3f..2afc1694281 100644
--- a/spec/frontend/lib/utils/url_utility_spec.js
+++ b/spec/frontend/lib/utils/url_utility_spec.js
@@ -664,6 +664,19 @@ describe('URL utility', () => {
});
});
+ describe('cleanLeadingSeparator', () => {
+ it.each`
+ path | expected
+ ${'/foo/bar'} | ${'foo/bar'}
+ ${'foo/bar'} | ${'foo/bar'}
+ ${'//foo/bar'} | ${'foo/bar'}
+ ${'/./foo/bar'} | ${'./foo/bar'}
+ ${''} | ${''}
+ `('$path becomes $expected', ({ path, expected }) => {
+ expect(urlUtils.cleanLeadingSeparator(path)).toBe(expected);
+ });
+ });
+
describe('joinPaths', () => {
it.each`
paths | expected
@@ -688,6 +701,18 @@ describe('URL utility', () => {
});
});
+ describe('stripFinalUrlSegment', () => {
+ it.each`
+ path | expected
+ ${'http://fake.domain/twitter/typeahead-js/-/tags/v0.11.0'} | ${'http://fake.domain/twitter/typeahead-js/-/tags/'}
+ ${'http://fake.domain/bar/cool/-/nested/content'} | ${'http://fake.domain/bar/cool/-/nested/'}
+ ${'http://fake.domain/bar/cool?q="search"'} | ${'http://fake.domain/bar/'}
+ ${'http://fake.domain/bar/cool#link-to-something'} | ${'http://fake.domain/bar/'}
+ `('stripFinalUrlSegment $path => $expected', ({ path, expected }) => {
+ expect(urlUtils.stripFinalUrlSegment(path)).toBe(expected);
+ });
+ });
+
describe('escapeFileUrl', () => {
it('encodes URL excluding the slashes', () => {
expect(urlUtils.escapeFileUrl('/foo-bar/file.md')).toBe('/foo-bar/file.md');
@@ -787,4 +812,36 @@ describe('URL utility', () => {
expect(urlUtils.getHTTPProtocol(url)).toBe(expectation);
});
});
+
+ describe('stripPathTail', () => {
+ it.each`
+ path | expected
+ ${''} | ${''}
+ ${'index.html'} | ${''}
+ ${'/'} | ${'/'}
+ ${'/foo/bar'} | ${'/foo/'}
+ ${'/foo/bar/'} | ${'/foo/bar/'}
+ ${'/foo/bar/index.html'} | ${'/foo/bar/'}
+ `('strips the filename from $path => $expected', ({ path, expected }) => {
+ expect(urlUtils.stripPathTail(path)).toBe(expected);
+ });
+ });
+
+ describe('getURLOrigin', () => {
+ it('when no url passed, returns correct origin from window location', () => {
+ const origin = 'https://foo.bar';
+
+ setWindowLocation({ origin });
+ expect(urlUtils.getURLOrigin()).toBe(origin);
+ });
+
+ it.each`
+ url | expectation
+ ${'not-a-url'} | ${null}
+ ${'wss://example.com'} | ${'wss://example.com'}
+ ${'https://foo.bar/foo/bar'} | ${'https://foo.bar'}
+ `('returns correct origin for $url', ({ url, expectation }) => {
+ expect(urlUtils.getURLOrigin(url)).toBe(expectation);
+ });
+ });
});
diff --git a/spec/frontend/merge_request_spec.js b/spec/frontend/merge_request_spec.js
index 16f04d032fd..37509f77f71 100644
--- a/spec/frontend/merge_request_spec.js
+++ b/spec/frontend/merge_request_spec.js
@@ -3,8 +3,6 @@ import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'spec/test_constants';
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';
describe('MergeRequest', () => {
const test = {};
@@ -112,66 +110,7 @@ describe('MergeRequest', () => {
});
});
- describe('class constructor', () => {
- beforeEach(() => {
- jest.spyOn($, 'ajax').mockImplementation();
- });
-
- it('calls .initCloseReopenReport', () => {
- jest.spyOn(IssuablesHelper, 'initCloseReopenReport').mockImplementation(() => {});
-
- new MergeRequest(); // eslint-disable-line no-new
-
- expect(IssuablesHelper.initCloseReopenReport).toHaveBeenCalled();
- });
-
- it('calls .initDroplab', () => {
- const container = {
- querySelector: jest.fn().mockName('container.querySelector'),
- };
- const dropdownTrigger = {};
- const dropdownList = {};
- const button = {};
-
- jest.spyOn(CloseReopenReportToggle.prototype, 'initDroplab').mockImplementation(() => {});
- jest.spyOn(document, 'querySelector').mockReturnValue(container);
-
- container.querySelector
- .mockReturnValueOnce(dropdownTrigger)
- .mockReturnValueOnce(dropdownList)
- .mockReturnValueOnce(button);
-
- new MergeRequest(); // eslint-disable-line no-new
-
- expect(document.querySelector).toHaveBeenCalledWith('.js-issuable-close-dropdown');
- expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-toggle');
- expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-menu');
- expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-button');
- expect(CloseReopenReportToggle.prototype.initDroplab).toHaveBeenCalled();
- });
- });
-
describe('hideCloseButton', () => {
- describe('merge request of another user', () => {
- beforeEach(() => {
- loadFixtures('merge_requests/merge_request_with_task_list.html');
- test.el = document.querySelector('.js-issuable-actions');
- new MergeRequest(); // eslint-disable-line no-new
- MergeRequest.hideCloseButton();
- });
-
- it('hides the dropdown close item and selects the next item', () => {
- const closeItem = test.el.querySelector('li.close-item');
- const smallCloseItem = test.el.querySelector('.js-close-item');
- const reportItem = test.el.querySelector('li.report-item');
-
- expect(closeItem).toHaveClass('hidden');
- expect(smallCloseItem).toHaveClass('hidden');
- expect(reportItem).toHaveClass('droplab-item-selected');
- expect(reportItem).not.toHaveClass('hidden');
- });
- });
-
describe('merge request of current_user', () => {
beforeEach(() => {
loadFixtures('merge_requests/merge_request_of_current_user.html');
@@ -180,10 +119,8 @@ describe('MergeRequest', () => {
});
it('hides the close button', () => {
- const closeButton = test.el.querySelector('.btn-close');
const smallCloseItem = test.el.querySelector('.js-close-item');
- expect(closeButton).toHaveClass('hidden');
expect(smallCloseItem).toHaveClass('hidden');
});
});
diff --git a/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap
index c30fb572826..9b2aa3a5b5b 100644
--- a/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap
+++ b/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap
@@ -1,79 +1,146 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`GroupEmptyState Renders an empty state for BAD_QUERY 1`] = `
-<gl-empty-state-stub
- compact="true"
- primarybuttonlink="/path/to/settings"
- primarybuttontext="Verify configuration"
- svgpath="/path/to/empty-group-illustration.svg"
- title="Query cannot be processed"
-/>
+exports[`GroupEmptyState given state BAD_QUERY passes the expected props to GlEmptyState 1`] = `
+Object {
+ "compact": true,
+ "description": null,
+ "primaryButtonLink": "/path/to/settings",
+ "primaryButtonText": "Verify configuration",
+ "secondaryButtonLink": null,
+ "secondaryButtonText": null,
+ "svgHeight": null,
+ "svgPath": "/path/to/empty-group-illustration.svg",
+ "title": "Query cannot be processed",
+}
`;
-exports[`GroupEmptyState Renders an empty state for BAD_QUERY 2`] = `"The Prometheus server responded with \\"bad request\\". Please check your queries are correct and are supported in your Prometheus version. <a href=\\"/path/to/docs\\">More information</a>"`;
+exports[`GroupEmptyState given state BAD_QUERY renders the slotted content 1`] = `
+<div>
+ <div>
+ The Prometheus server responded with "bad request". Please check your queries are correct and are supported in your Prometheus version.
+ <a
+ href="/path/to/docs"
+ >
+ More information
+ </a>
+ </div>
+</div>
+`;
-exports[`GroupEmptyState Renders an empty state for CONNECTION_FAILED 1`] = `
-<gl-empty-state-stub
- compact="true"
- description="We couldn't reach the Prometheus server. Either the server no longer exists or the configuration details need updating."
- primarybuttonlink="/path/to/settings"
- primarybuttontext="Verify configuration"
- svgpath="/path/to/empty-group-illustration.svg"
- title="Connection failed"
-/>
+exports[`GroupEmptyState given state CONNECTION_FAILED passes the expected props to GlEmptyState 1`] = `
+Object {
+ "compact": true,
+ "description": "We couldn't reach the Prometheus server. Either the server no longer exists or the configuration details need updating.",
+ "primaryButtonLink": "/path/to/settings",
+ "primaryButtonText": "Verify configuration",
+ "secondaryButtonLink": null,
+ "secondaryButtonText": null,
+ "svgHeight": null,
+ "svgPath": "/path/to/empty-group-illustration.svg",
+ "title": "Connection failed",
+}
`;
-exports[`GroupEmptyState Renders an empty state for CONNECTION_FAILED 2`] = `undefined`;
+exports[`GroupEmptyState given state CONNECTION_FAILED renders the slotted content 1`] = `<div />`;
-exports[`GroupEmptyState Renders an empty state for FOO STATE 1`] = `
-<gl-empty-state-stub
- compact="true"
- description="An error occurred while loading the data. Please try again."
- svgpath="/path/to/empty-group-illustration.svg"
- title="An error has occurred"
-/>
+exports[`GroupEmptyState given state FOO STATE passes the expected props to GlEmptyState 1`] = `
+Object {
+ "compact": true,
+ "description": "An error occurred while loading the data. Please try again.",
+ "primaryButtonLink": null,
+ "primaryButtonText": null,
+ "secondaryButtonLink": null,
+ "secondaryButtonText": null,
+ "svgHeight": null,
+ "svgPath": "/path/to/empty-group-illustration.svg",
+ "title": "An error has occurred",
+}
`;
-exports[`GroupEmptyState Renders an empty state for FOO STATE 2`] = `undefined`;
+exports[`GroupEmptyState given state FOO STATE renders the slotted content 1`] = `<div />`;
-exports[`GroupEmptyState Renders an empty state for LOADING 1`] = `
-<gl-empty-state-stub
- compact="true"
- description="Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available."
- svgpath="/path/to/empty-group-illustration.svg"
- title="Waiting for performance data"
-/>
+exports[`GroupEmptyState given state LOADING passes the expected props to GlEmptyState 1`] = `
+Object {
+ "compact": true,
+ "description": "Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available.",
+ "primaryButtonLink": null,
+ "primaryButtonText": null,
+ "secondaryButtonLink": null,
+ "secondaryButtonText": null,
+ "svgHeight": null,
+ "svgPath": "/path/to/empty-group-illustration.svg",
+ "title": "Waiting for performance data",
+}
`;
-exports[`GroupEmptyState Renders an empty state for LOADING 2`] = `undefined`;
+exports[`GroupEmptyState given state LOADING renders the slotted content 1`] = `<div />`;
-exports[`GroupEmptyState Renders an empty state for NO_DATA 1`] = `
-<gl-empty-state-stub
- compact="true"
- svgpath="/path/to/empty-group-illustration.svg"
- title="No data to display"
-/>
+exports[`GroupEmptyState given state NO_DATA passes the expected props to GlEmptyState 1`] = `
+Object {
+ "compact": true,
+ "description": null,
+ "primaryButtonLink": null,
+ "primaryButtonText": null,
+ "secondaryButtonLink": null,
+ "secondaryButtonText": null,
+ "svgHeight": null,
+ "svgPath": "/path/to/empty-group-illustration.svg",
+ "title": "No data to display",
+}
`;
-exports[`GroupEmptyState Renders an empty state for NO_DATA 2`] = `"The data source is connected, but there is no data to display. <a href=\\"/path/to/docs\\">More information</a>"`;
+exports[`GroupEmptyState given state NO_DATA renders the slotted content 1`] = `
+<div>
+ <div>
+ The data source is connected, but there is no data to display.
+ <a
+ href="/path/to/docs"
+ >
+ More information
+ </a>
+ </div>
+</div>
+`;
-exports[`GroupEmptyState Renders an empty state for TIMEOUT 1`] = `
-<gl-empty-state-stub
- compact="true"
- svgpath="/path/to/empty-group-illustration.svg"
- title="Connection timed out"
-/>
+exports[`GroupEmptyState given state TIMEOUT passes the expected props to GlEmptyState 1`] = `
+Object {
+ "compact": true,
+ "description": null,
+ "primaryButtonLink": null,
+ "primaryButtonText": null,
+ "secondaryButtonLink": null,
+ "secondaryButtonText": null,
+ "svgHeight": null,
+ "svgPath": "/path/to/empty-group-illustration.svg",
+ "title": "Connection timed out",
+}
`;
-exports[`GroupEmptyState Renders an empty state for TIMEOUT 2`] = `"Charts can't be displayed as the request for data has timed out. <a href=\\"/path/to/docs\\">More information</a>"`;
+exports[`GroupEmptyState given state TIMEOUT renders the slotted content 1`] = `
+<div>
+ <div>
+ Charts can't be displayed as the request for data has timed out.
+ <a
+ href="/path/to/docs"
+ >
+ More information
+ </a>
+ </div>
+</div>
+`;
-exports[`GroupEmptyState Renders an empty state for UNKNOWN_ERROR 1`] = `
-<gl-empty-state-stub
- compact="true"
- description="An error occurred while loading the data. Please try again."
- svgpath="/path/to/empty-group-illustration.svg"
- title="An error has occurred"
-/>
+exports[`GroupEmptyState given state UNKNOWN_ERROR passes the expected props to GlEmptyState 1`] = `
+Object {
+ "compact": true,
+ "description": "An error occurred while loading the data. Please try again.",
+ "primaryButtonLink": null,
+ "primaryButtonText": null,
+ "secondaryButtonLink": null,
+ "secondaryButtonText": null,
+ "svgHeight": null,
+ "svgPath": "/path/to/empty-group-illustration.svg",
+ "title": "An error has occurred",
+}
`;
-exports[`GroupEmptyState Renders an empty state for UNKNOWN_ERROR 2`] = `undefined`;
+exports[`GroupEmptyState given state UNKNOWN_ERROR renders the slotted content 1`] = `<div />`;
diff --git a/spec/frontend/monitoring/components/group_empty_state_spec.js b/spec/frontend/monitoring/components/group_empty_state_spec.js
index 90bd6f67196..3b94c4c6806 100644
--- a/spec/frontend/monitoring/components/group_empty_state_spec.js
+++ b/spec/frontend/monitoring/components/group_empty_state_spec.js
@@ -1,7 +1,13 @@
+import { GlEmptyState } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import GroupEmptyState from '~/monitoring/components/group_empty_state.vue';
import { metricStates } from '~/monitoring/constants';
+const MockGlEmptyState = {
+ props: GlEmptyState.props,
+ template: '<div><slot name="description"></slot></div>',
+};
+
function createComponent(props) {
return shallowMount(GroupEmptyState, {
propsData: {
@@ -10,11 +16,20 @@ function createComponent(props) {
settingsPath: '/path/to/settings',
svgPath: '/path/to/empty-group-illustration.svg',
},
+ stubs: {
+ GlEmptyState: MockGlEmptyState,
+ },
});
}
describe('GroupEmptyState', () => {
- const supportedStates = [
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe.each([
metricStates.NO_DATA,
metricStates.TIMEOUT,
metricStates.CONNECTION_FAILED,
@@ -22,13 +37,17 @@ describe('GroupEmptyState', () => {
metricStates.LOADING,
metricStates.UNKNOWN_ERROR,
'FOO STATE', // does not fail with unknown states
- ];
+ ])('given state %s', selectedState => {
+ beforeEach(() => {
+ wrapper = createComponent({ selectedState });
+ });
- it.each(supportedStates)('Renders an empty state for %s', selectedState => {
- const wrapper = createComponent({ selectedState });
+ it('renders the slotted content', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
- expect(wrapper.element).toMatchSnapshot();
- // slot is not rendered by the stub, test it separately
- expect(wrapper.vm.currentState.slottedDescription).toMatchSnapshot();
+ it('passes the expected props to GlEmptyState', () => {
+ expect(wrapper.find(MockGlEmptyState).props()).toMatchSnapshot();
+ });
});
});
diff --git a/spec/frontend/notes/components/discussion_counter_spec.js b/spec/frontend/notes/components/discussion_counter_spec.js
index affd6c1d1d2..d82590c7e9e 100644
--- a/spec/frontend/notes/components/discussion_counter_spec.js
+++ b/spec/frontend/notes/components/discussion_counter_spec.js
@@ -1,6 +1,6 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { GlIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import notesModule from '~/notes/stores/modules';
import DiscussionCounter from '~/notes/components/discussion_counter.vue';
import { noteableDataMock, discussionMock, notesDataMock, userDataMock } from '../mock_data';
@@ -9,6 +9,7 @@ import * as types from '~/notes/stores/mutation_types';
describe('DiscussionCounter component', () => {
let store;
let wrapper;
+ let setExpandDiscussionsFn;
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -16,6 +17,7 @@ describe('DiscussionCounter component', () => {
beforeEach(() => {
window.mrTabs = {};
const { state, getters, mutations, actions } = notesModule();
+ setExpandDiscussionsFn = jest.fn().mockImplementation(actions.setExpandDiscussions);
store = new Vuex.Store({
state: {
@@ -24,7 +26,10 @@ describe('DiscussionCounter component', () => {
},
getters,
mutations,
- actions,
+ actions: {
+ ...actions,
+ setExpandDiscussions: setExpandDiscussionsFn,
+ },
});
store.dispatch('setNoteableData', {
...noteableDataMock,
@@ -84,7 +89,7 @@ describe('DiscussionCounter component', () => {
wrapper = shallowMount(DiscussionCounter, { store, localVue });
expect(wrapper.find(`.is-active`).exists()).toBe(isActive);
- expect(wrapper.findAll('[role="group"').length).toBe(groupLength);
+ expect(wrapper.findAll(GlButton)).toHaveLength(groupLength);
});
});
@@ -103,23 +108,22 @@ describe('DiscussionCounter component', () => {
it('calls button handler when clicked', () => {
updateStoreWithExpanded(true);
- wrapper.setMethods({ handleExpandDiscussions: jest.fn() });
- toggleAllButton.trigger('click');
+ toggleAllButton.vm.$emit('click');
- expect(wrapper.vm.handleExpandDiscussions).toHaveBeenCalledTimes(1);
+ expect(setExpandDiscussionsFn).toHaveBeenCalledTimes(1);
});
it('collapses all discussions if expanded', () => {
updateStoreWithExpanded(true);
expect(wrapper.vm.allExpanded).toBe(true);
- expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-up');
+ expect(toggleAllButton.props('icon')).toBe('angle-up');
- toggleAllButton.trigger('click');
+ toggleAllButton.vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.allExpanded).toBe(false);
- expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-down');
+ expect(toggleAllButton.props('icon')).toBe('angle-down');
});
});
@@ -127,13 +131,13 @@ describe('DiscussionCounter component', () => {
updateStoreWithExpanded(false);
expect(wrapper.vm.allExpanded).toBe(false);
- expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-down');
+ expect(toggleAllButton.props('icon')).toBe('angle-down');
- toggleAllButton.trigger('click');
+ toggleAllButton.vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.allExpanded).toBe(true);
- expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-up');
+ expect(toggleAllButton.props('icon')).toBe('angle-up');
});
});
});
diff --git a/spec/frontend/notes/components/discussion_filter_spec.js b/spec/frontend/notes/components/discussion_filter_spec.js
index 91ff796b9de..e3e3518fd31 100644
--- a/spec/frontend/notes/components/discussion_filter_spec.js
+++ b/spec/frontend/notes/components/discussion_filter_spec.js
@@ -74,13 +74,15 @@ describe('DiscussionFilter component', () => {
});
it('renders the all filters', () => {
- expect(wrapper.findAll('.dropdown-menu li').length).toBe(discussionFiltersMock.length);
+ expect(wrapper.findAll('.discussion-filter-container .dropdown-item').length).toBe(
+ discussionFiltersMock.length,
+ );
});
it('renders the default selected item', () => {
expect(
wrapper
- .find('#discussion-filter-dropdown')
+ .find('#discussion-filter-dropdown .dropdown-item')
.text()
.trim(),
).toBe(discussionFiltersMock[0].title);
@@ -88,7 +90,7 @@ describe('DiscussionFilter component', () => {
it('updates to the selected item', () => {
const filterItem = wrapper.find(
- `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"] button`,
+ `.discussion-filter-container .dropdown-item[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"]`,
);
filterItem.trigger('click');
@@ -98,7 +100,9 @@ describe('DiscussionFilter component', () => {
it('only updates when selected filter changes', () => {
wrapper
- .find(`.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] button`)
+ .find(
+ `.discussion-filter-container .dropdown-item[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"]`,
+ )
.trigger('click');
expect(filterDiscussion).not.toHaveBeenCalled();
@@ -106,7 +110,7 @@ describe('DiscussionFilter component', () => {
it('disables commenting when "Show history only" filter is applied', () => {
const filterItem = wrapper.find(
- `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"] button`,
+ `.discussion-filter-container .dropdown-item[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"]`,
);
filterItem.trigger('click');
@@ -115,7 +119,7 @@ describe('DiscussionFilter component', () => {
it('enables commenting when "Show history only" filter is not applied', () => {
const filterItem = wrapper.find(
- `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] button`,
+ `.discussion-filter-container .dropdown-item[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"]`,
);
filterItem.trigger('click');
@@ -124,10 +128,10 @@ describe('DiscussionFilter component', () => {
it('renders a dropdown divider for the default filter', () => {
const defaultFilter = wrapper.findAll(
- `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] > *`,
+ `.discussion-filter-container .dropdown-item-wrapper > *`,
);
- expect(defaultFilter.at(defaultFilter.length - 1).classes('dropdown-divider')).toBe(true);
+ expect(defaultFilter.at(1).classes('gl-new-dropdown-divider')).toBe(true);
});
describe('Merge request tabs', () => {
diff --git a/spec/frontend/notes/components/sort_discussion_spec.js b/spec/frontend/notes/components/sort_discussion_spec.js
index 575f1057db2..49b85d60a27 100644
--- a/spec/frontend/notes/components/sort_discussion_spec.js
+++ b/spec/frontend/notes/components/sort_discussion_spec.js
@@ -55,7 +55,7 @@ describe('Sort Discussion component', () => {
it('calls the right actions', () => {
createComponent();
- wrapper.find('.js-newest-first').trigger('click');
+ wrapper.find('.js-newest-first').vm.$emit('click');
expect(store.dispatch).toHaveBeenCalledWith('setDiscussionSortDirection', DESC);
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'change_discussion_sort_direction', {
@@ -67,7 +67,7 @@ describe('Sort Discussion component', () => {
it('shows the "Oldest First" as the dropdown', () => {
createComponent();
- expect(wrapper.find('.js-dropdown-text').text()).toBe('Oldest first');
+ expect(wrapper.find('.js-dropdown-text').props('text')).toBe('Oldest first');
});
});
@@ -79,7 +79,7 @@ describe('Sort Discussion component', () => {
describe('when the dropdown item is clicked', () => {
it('calls the right actions', () => {
- wrapper.find('.js-oldest-first').trigger('click');
+ wrapper.find('.js-oldest-first').vm.$emit('click');
expect(store.dispatch).toHaveBeenCalledWith('setDiscussionSortDirection', ASC);
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'change_discussion_sort_direction', {
@@ -87,13 +87,13 @@ describe('Sort Discussion component', () => {
});
});
- it('applies the active class to the correct button in the dropdown', () => {
- expect(wrapper.find('.js-newest-first').classes()).toContain('is-active');
+ it('sets is-checked to true on the active button in the dropdown', () => {
+ expect(wrapper.find('.js-newest-first').props('isChecked')).toBe(true);
});
});
it('shows the "Newest First" as the dropdown', () => {
- expect(wrapper.find('.js-dropdown-text').text()).toBe('Newest first');
+ expect(wrapper.find('.js-dropdown-text').props('text')).toBe('Newest first');
});
});
});
diff --git a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap
index 4d9e0af1545..d317264bdae 100644
--- a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap
+++ b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap
@@ -2,151 +2,163 @@
exports[`PackageTitle renders with tags 1`] = `
<div
- class="gl-display-flex gl-justify-content-space-between gl-py-3"
+ class="gl-display-flex gl-flex-direction-column"
data-qa-selector="package_title"
>
<div
- class="gl-flex-direction-column"
+ class="gl-display-flex gl-justify-content-space-between gl-py-3"
>
<div
- class="gl-display-flex"
+ class="gl-flex-direction-column"
>
- <!---->
-
<div
- class="gl-display-flex gl-flex-direction-column"
+ class="gl-display-flex"
>
- <h1
- class="gl-font-size-h1 gl-mt-3 gl-mb-2"
- data-testid="title"
- >
- Test package
- </h1>
+ <!---->
<div
- class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1"
+ class="gl-display-flex gl-flex-direction-column"
>
- <gl-icon-stub
- class="gl-mr-3"
- name="eye"
- size="16"
- />
+ <h1
+ class="gl-font-size-h1 gl-mt-3 gl-mb-2"
+ data-testid="title"
+ >
+ Test package
+ </h1>
- <gl-sprintf-stub
- message="v%{version} published %{timeAgo}"
- />
+ <div
+ class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1"
+ >
+ <gl-icon-stub
+ class="gl-mr-3"
+ name="eye"
+ size="16"
+ />
+
+ <gl-sprintf-stub
+ message="v%{version} published %{timeAgo}"
+ />
+ </div>
</div>
</div>
- </div>
-
- <div
- class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"
- >
- <div
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <metadata-item-stub
- data-testid="package-type"
- icon="package"
- link=""
- size="s"
- text="maven"
- />
- </div>
- <div
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <metadata-item-stub
- data-testid="package-size"
- icon="disk"
- link=""
- size="s"
- text="300 bytes"
- />
- </div>
+
<div
- class="gl-display-flex gl-align-items-center gl-mr-5"
+ class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"
>
- <package-tags-stub
- hidelabel="true"
- tagdisplaylimit="2"
- tags="[object Object],[object Object],[object Object],[object Object]"
- />
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-5"
+ >
+ <metadata-item-stub
+ data-testid="package-type"
+ icon="package"
+ link=""
+ size="s"
+ text="maven"
+ />
+ </div>
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-5"
+ >
+ <metadata-item-stub
+ data-testid="package-size"
+ icon="disk"
+ link=""
+ size="s"
+ text="300 bytes"
+ />
+ </div>
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-5"
+ >
+ <package-tags-stub
+ hidelabel="true"
+ tagdisplaylimit="2"
+ tags="[object Object],[object Object],[object Object],[object Object]"
+ />
+ </div>
</div>
</div>
+
+ <!---->
</div>
- <!---->
+ <p />
</div>
`;
exports[`PackageTitle renders without tags 1`] = `
<div
- class="gl-display-flex gl-justify-content-space-between gl-py-3"
+ class="gl-display-flex gl-flex-direction-column"
data-qa-selector="package_title"
>
<div
- class="gl-flex-direction-column"
+ class="gl-display-flex gl-justify-content-space-between gl-py-3"
>
<div
- class="gl-display-flex"
+ class="gl-flex-direction-column"
>
- <!---->
-
<div
- class="gl-display-flex gl-flex-direction-column"
+ class="gl-display-flex"
>
- <h1
- class="gl-font-size-h1 gl-mt-3 gl-mb-2"
- data-testid="title"
- >
- Test package
- </h1>
+ <!---->
<div
- class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1"
+ class="gl-display-flex gl-flex-direction-column"
>
- <gl-icon-stub
- class="gl-mr-3"
- name="eye"
- size="16"
- />
+ <h1
+ class="gl-font-size-h1 gl-mt-3 gl-mb-2"
+ data-testid="title"
+ >
+ Test package
+ </h1>
- <gl-sprintf-stub
- message="v%{version} published %{timeAgo}"
- />
+ <div
+ class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1"
+ >
+ <gl-icon-stub
+ class="gl-mr-3"
+ name="eye"
+ size="16"
+ />
+
+ <gl-sprintf-stub
+ message="v%{version} published %{timeAgo}"
+ />
+ </div>
</div>
</div>
- </div>
-
- <div
- class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"
- >
- <div
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <metadata-item-stub
- data-testid="package-type"
- icon="package"
- link=""
- size="s"
- text="maven"
- />
- </div>
+
<div
- class="gl-display-flex gl-align-items-center gl-mr-5"
+ class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3"
>
- <metadata-item-stub
- data-testid="package-size"
- icon="disk"
- link=""
- size="s"
- text="300 bytes"
- />
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-5"
+ >
+ <metadata-item-stub
+ data-testid="package-type"
+ icon="package"
+ link=""
+ size="s"
+ text="maven"
+ />
+ </div>
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-5"
+ >
+ <metadata-item-stub
+ data-testid="package-size"
+ icon="disk"
+ link=""
+ size="s"
+ text="300 bytes"
+ />
+ </div>
</div>
</div>
+
+ <!---->
</div>
- <!---->
+ <p />
</div>
`;
diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js
index 0e95ee4cfd3..06e5950eb5d 100644
--- a/spec/frontend/packages/details/store/getters_spec.js
+++ b/spec/frontend/packages/details/store/getters_spec.js
@@ -69,7 +69,7 @@ describe('Getters PackageDetails Store', () => {
const nugetInstallationCommandStr = `nuget install ${nugetPackage.name} -Source "GitLab"`;
const nugetSetupCommandStr = `nuget source Add -Name "GitLab" -Source "${registryUrl}" -UserName <your_username> -Password <your_token>`;
- const pypiPipCommandStr = `pip install ${pypiPackage.name} --index-url ${registryUrl}`;
+ const pypiPipCommandStr = `pip install ${pypiPackage.name} --extra-index-url ${registryUrl}`;
const composerRegistryIncludeStr = '{"type":"composer","url":"foo"}';
const composerPackageIncludeStr = JSON.stringify({
[packageWithoutBuildInfo.name]: packageWithoutBuildInfo.version,
diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
index 6ff9376565a..794e583a487 100644
--- a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
+++ b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
@@ -1,457 +1,463 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`packages_list_app renders 1`] = `
-<b-tabs-stub
- activenavitemclass="gl-tab-nav-item-active gl-tab-nav-item-active-indigo"
- class="gl-tabs"
- contentclass=",gl-tab-content"
- navclass="gl-tabs-nav"
- nofade="true"
- nonavstyle="true"
- tag="div"
->
- <template>
-
- <b-tab-stub
- tag="div"
- title="All"
- titlelinkclass="gl-tab-nav-item"
- >
- <template>
- <div>
- <section
- class="row empty-state text-center"
- >
- <div
- class="col-12"
+<div>
+ <package-title-stub
+ packagehelpurl="foo"
+ />
+
+ <b-tabs-stub
+ activenavitemclass="gl-tab-nav-item-active gl-tab-nav-item-active-indigo"
+ class="gl-tabs"
+ contentclass=",gl-tab-content"
+ navclass="gl-tabs-nav"
+ nofade="true"
+ nonavstyle="true"
+ tag="div"
+ >
+ <template>
+
+ <b-tab-stub
+ tag="div"
+ title="All"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
>
<div
- class="svg-250 svg-content"
+ class="col-12"
>
- <img
- alt="There are no packages yet"
- class="gl-max-w-full"
- src="helpSvg"
- />
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
</div>
- </div>
-
- <div
- class="col-12"
- >
+
<div
- class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ class="col-12"
>
- <h1
- class="h4"
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
- There are no packages yet
- </h1>
-
- <p>
- Learn how to
- <b-link-stub
- class="gl-link"
- event="click"
- href="helpUrl"
- routertag="a"
- target="_blank"
+ <h1
+ class="h4"
>
- publish and share your packages
- </b-link-stub>
- with GitLab.
- </p>
-
- <div>
- <!---->
+ There are no packages yet
+ </h1>
- <!---->
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
+
+ <div>
+ <!---->
+
+ <!---->
+ </div>
</div>
</div>
- </div>
- </section>
- </div>
- </template>
- </b-tab-stub>
- <b-tab-stub
- tag="div"
- title="Composer"
- titlelinkclass="gl-tab-nav-item"
- >
- <template>
- <div>
- <section
- class="row empty-state text-center"
- >
- <div
- class="col-12"
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="Composer"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
>
<div
- class="svg-250 svg-content"
+ class="col-12"
>
- <img
- alt="There are no Composer packages yet"
- class="gl-max-w-full"
- src="helpSvg"
- />
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no Composer packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
</div>
- </div>
-
- <div
- class="col-12"
- >
+
<div
- class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ class="col-12"
>
- <h1
- class="h4"
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
- There are no Composer packages yet
- </h1>
-
- <p>
- Learn how to
- <b-link-stub
- class="gl-link"
- event="click"
- href="helpUrl"
- routertag="a"
- target="_blank"
+ <h1
+ class="h4"
>
- publish and share your packages
- </b-link-stub>
- with GitLab.
- </p>
-
- <div>
- <!---->
+ There are no Composer packages yet
+ </h1>
- <!---->
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
+
+ <div>
+ <!---->
+
+ <!---->
+ </div>
</div>
</div>
- </div>
- </section>
- </div>
- </template>
- </b-tab-stub>
- <b-tab-stub
- tag="div"
- title="Conan"
- titlelinkclass="gl-tab-nav-item"
- >
- <template>
- <div>
- <section
- class="row empty-state text-center"
- >
- <div
- class="col-12"
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="Conan"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
>
<div
- class="svg-250 svg-content"
+ class="col-12"
>
- <img
- alt="There are no Conan packages yet"
- class="gl-max-w-full"
- src="helpSvg"
- />
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no Conan packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
</div>
- </div>
-
- <div
- class="col-12"
- >
+
<div
- class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ class="col-12"
>
- <h1
- class="h4"
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
- There are no Conan packages yet
- </h1>
-
- <p>
- Learn how to
- <b-link-stub
- class="gl-link"
- event="click"
- href="helpUrl"
- routertag="a"
- target="_blank"
+ <h1
+ class="h4"
>
- publish and share your packages
- </b-link-stub>
- with GitLab.
- </p>
-
- <div>
- <!---->
+ There are no Conan packages yet
+ </h1>
- <!---->
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
+
+ <div>
+ <!---->
+
+ <!---->
+ </div>
</div>
</div>
- </div>
- </section>
- </div>
- </template>
- </b-tab-stub>
- <b-tab-stub
- tag="div"
- title="Maven"
- titlelinkclass="gl-tab-nav-item"
- >
- <template>
- <div>
- <section
- class="row empty-state text-center"
- >
- <div
- class="col-12"
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="Maven"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
>
<div
- class="svg-250 svg-content"
+ class="col-12"
>
- <img
- alt="There are no Maven packages yet"
- class="gl-max-w-full"
- src="helpSvg"
- />
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no Maven packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
</div>
- </div>
-
- <div
- class="col-12"
- >
+
<div
- class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ class="col-12"
>
- <h1
- class="h4"
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
- There are no Maven packages yet
- </h1>
-
- <p>
- Learn how to
- <b-link-stub
- class="gl-link"
- event="click"
- href="helpUrl"
- routertag="a"
- target="_blank"
+ <h1
+ class="h4"
>
- publish and share your packages
- </b-link-stub>
- with GitLab.
- </p>
-
- <div>
- <!---->
+ There are no Maven packages yet
+ </h1>
+
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
- <!---->
+ <div>
+ <!---->
+
+ <!---->
+ </div>
</div>
</div>
- </div>
- </section>
- </div>
- </template>
- </b-tab-stub>
- <b-tab-stub
- tag="div"
- title="NPM"
- titlelinkclass="gl-tab-nav-item"
- >
- <template>
- <div>
- <section
- class="row empty-state text-center"
- >
- <div
- class="col-12"
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="NPM"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
>
<div
- class="svg-250 svg-content"
+ class="col-12"
>
- <img
- alt="There are no NPM packages yet"
- class="gl-max-w-full"
- src="helpSvg"
- />
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no NPM packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
</div>
- </div>
-
- <div
- class="col-12"
- >
+
<div
- class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ class="col-12"
>
- <h1
- class="h4"
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
- There are no NPM packages yet
- </h1>
-
- <p>
- Learn how to
- <b-link-stub
- class="gl-link"
- event="click"
- href="helpUrl"
- routertag="a"
- target="_blank"
+ <h1
+ class="h4"
>
- publish and share your packages
- </b-link-stub>
- with GitLab.
- </p>
-
- <div>
- <!---->
+ There are no NPM packages yet
+ </h1>
+
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
- <!---->
+ <div>
+ <!---->
+
+ <!---->
+ </div>
</div>
</div>
- </div>
- </section>
- </div>
- </template>
- </b-tab-stub>
- <b-tab-stub
- tag="div"
- title="NuGet"
- titlelinkclass="gl-tab-nav-item"
- >
- <template>
- <div>
- <section
- class="row empty-state text-center"
- >
- <div
- class="col-12"
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="NuGet"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
>
<div
- class="svg-250 svg-content"
+ class="col-12"
>
- <img
- alt="There are no NuGet packages yet"
- class="gl-max-w-full"
- src="helpSvg"
- />
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no NuGet packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
</div>
- </div>
-
- <div
- class="col-12"
- >
+
<div
- class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ class="col-12"
>
- <h1
- class="h4"
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
- There are no NuGet packages yet
- </h1>
-
- <p>
- Learn how to
- <b-link-stub
- class="gl-link"
- event="click"
- href="helpUrl"
- routertag="a"
- target="_blank"
+ <h1
+ class="h4"
>
- publish and share your packages
- </b-link-stub>
- with GitLab.
- </p>
-
- <div>
- <!---->
+ There are no NuGet packages yet
+ </h1>
- <!---->
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
+
+ <div>
+ <!---->
+
+ <!---->
+ </div>
</div>
</div>
- </div>
- </section>
- </div>
- </template>
- </b-tab-stub>
- <b-tab-stub
- tag="div"
- title="PyPi"
- titlelinkclass="gl-tab-nav-item"
- >
- <template>
- <div>
- <section
- class="row empty-state text-center"
- >
- <div
- class="col-12"
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="PyPi"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
>
<div
- class="svg-250 svg-content"
+ class="col-12"
>
- <img
- alt="There are no PyPi packages yet"
- class="gl-max-w-full"
- src="helpSvg"
- />
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no PyPi packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
</div>
- </div>
-
- <div
- class="col-12"
- >
+
<div
- class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ class="col-12"
>
- <h1
- class="h4"
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
- There are no PyPi packages yet
- </h1>
-
- <p>
- Learn how to
- <b-link-stub
- class="gl-link"
- event="click"
- href="helpUrl"
- routertag="a"
- target="_blank"
+ <h1
+ class="h4"
>
- publish and share your packages
- </b-link-stub>
- with GitLab.
- </p>
-
- <div>
- <!---->
+ There are no PyPi packages yet
+ </h1>
+
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
- <!---->
+ <div>
+ <!---->
+
+ <!---->
+ </div>
</div>
</div>
- </div>
- </section>
- </div>
- </template>
- </b-tab-stub>
-
- <!---->
- </template>
- <template>
- <div
- class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end"
- >
- <package-filter-stub
- class="mr-1"
- />
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
- <package-sort-stub />
- </div>
- </template>
-</b-tabs-stub>
+ <!---->
+ </template>
+ <template>
+ <div
+ class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end"
+ >
+ <package-filter-stub
+ class="gl-mr-2"
+ />
+
+ <package-sort-stub />
+ </div>
+ </template>
+ </b-tabs-stub>
+</div>
`;
diff --git a/spec/frontend/packages/list/components/packages_list_app_spec.js b/spec/frontend/packages/list/components/packages_list_app_spec.js
index 19ff4290f50..217096f822a 100644
--- a/spec/frontend/packages/list/components/packages_list_app_spec.js
+++ b/spec/frontend/packages/list/components/packages_list_app_spec.js
@@ -36,6 +36,7 @@ describe('packages_list_app', () => {
resourceId: 'project_id',
emptyListIllustration: 'helpSvg',
emptyListHelpUrl,
+ packageHelpUrl: 'foo',
},
filterQuery,
},
diff --git a/spec/frontend/packages/list/components/packages_title_spec.js b/spec/frontend/packages/list/components/packages_title_spec.js
new file mode 100644
index 00000000000..5e9ebd8ecb0
--- /dev/null
+++ b/spec/frontend/packages/list/components/packages_title_spec.js
@@ -0,0 +1,71 @@
+import { shallowMount } from '@vue/test-utils';
+import PackageTitle from '~/packages/list/components/package_title.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '~/packages/list//constants';
+
+describe('PackageTitle', () => {
+ let wrapper;
+ let store;
+
+ const findTitleArea = () => wrapper.find(TitleArea);
+ const findMetadataItem = () => wrapper.find(MetadataItem);
+
+ const mountComponent = (propsData = { packageHelpUrl: 'foo' }) => {
+ wrapper = shallowMount(PackageTitle, {
+ store,
+ propsData,
+ stubs: {
+ TitleArea,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('title area', () => {
+ it('exists', () => {
+ mountComponent();
+
+ expect(findTitleArea().exists()).toBe(true);
+ });
+
+ it('has the correct props', () => {
+ mountComponent();
+
+ expect(findTitleArea().props()).toMatchObject({
+ title: LIST_TITLE_TEXT,
+ infoMessages: [{ text: LIST_INTRO_TEXT, link: 'foo' }],
+ });
+ });
+ });
+
+ describe.each`
+ packagesCount | exist | text
+ ${null} | ${false} | ${''}
+ ${undefined} | ${false} | ${''}
+ ${0} | ${true} | ${'0 Packages'}
+ ${1} | ${true} | ${'1 Package'}
+ ${2} | ${true} | ${'2 Packages'}
+ `('when packagesCount is $packagesCount metadata item', ({ packagesCount, exist, text }) => {
+ beforeEach(() => {
+ mountComponent({ packagesCount, packageHelpUrl: 'foo' });
+ });
+
+ it(`is ${exist} that it exists`, () => {
+ expect(findMetadataItem().exists()).toBe(exist);
+ });
+
+ if (exist) {
+ it('has the correct props', () => {
+ expect(findMetadataItem().props()).toMatchObject({
+ icon: 'package',
+ text,
+ });
+ });
+ }
+ });
+});
diff --git a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
index 6aaefed92d0..5faae5690db 100644
--- a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
+++ b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
@@ -52,27 +52,6 @@ exports[`packages_list_row renders 1`] = `
<!---->
<div
- class="gl-display-flex gl-align-items-center"
- >
- <gl-icon-stub
- class="gl-ml-3 gl-mr-2 gl-min-w-0"
- name="review-list"
- size="16"
- />
-
- <gl-link-stub
- class="gl-text-body gl-min-w-0"
- data-testid="packages-row-project"
- href="/foo/bar/baz"
- >
- <gl-truncate-stub
- position="end"
- text="foo/bar/baz"
- />
- </gl-link-stub>
- </div>
-
- <div
class="d-flex align-items-center"
data-testid="package-type"
>
@@ -86,6 +65,10 @@ exports[`packages_list_row renders 1`] = `
Maven
</span>
</div>
+
+ <package-path-stub
+ path="foo/bar/baz"
+ />
</div>
</div>
</div>
@@ -118,6 +101,7 @@ exports[`packages_list_row renders 1`] = `
>
<gl-button-stub
aria-label="Remove package"
+ buttontextclasses=""
category="primary"
data-testid="action-delete"
icon="remove"
diff --git a/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap
index 9a0c52cee47..acdf7c49ebd 100644
--- a/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap
+++ b/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap
@@ -32,7 +32,8 @@ exports[`publish_method renders 1`] = `
</gl-link-stub>
<clipboard-button-stub
- cssclass="gl-border-0 gl-py-0 gl-px-2"
+ category="tertiary"
+ size="small"
text="sha-baz"
title="Copy commit SHA"
tooltipplacement="top"
diff --git a/spec/frontend/packages/shared/components/package_list_row_spec.js b/spec/frontend/packages/shared/components/package_list_row_spec.js
index f4eabf7bb67..0d0ea4e2122 100644
--- a/spec/frontend/packages/shared/components/package_list_row_spec.js
+++ b/spec/frontend/packages/shared/components/package_list_row_spec.js
@@ -1,6 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import PackagesListRow from '~/packages/shared/components/package_list_row.vue';
import PackageTags from '~/packages/shared/components/package_tags.vue';
+import PackagePath from '~/packages/shared/components/package_path.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import { packageList } from '../../mock_data';
@@ -11,7 +12,7 @@ describe('packages_list_row', () => {
const [packageWithoutTags, packageWithTags] = packageList;
const findPackageTags = () => wrapper.find(PackageTags);
- const findProjectLink = () => wrapper.find('[data-testid="packages-row-project"]');
+ const findPackagePath = () => wrapper.find(PackagePath);
const findDeleteButton = () => wrapper.find('[data-testid="action-delete"]');
const findPackageType = () => wrapper.find('[data-testid="package-type"]');
@@ -63,8 +64,9 @@ describe('packages_list_row', () => {
mountComponent({ isGroup: true });
});
- it('has project field', () => {
- expect(findProjectLink().exists()).toBe(true);
+ it('has a package path component', () => {
+ expect(findPackagePath().exists()).toBe(true);
+ expect(findPackagePath().props()).toMatchObject({ path: 'foo/bar/baz' });
});
});
diff --git a/spec/frontend/packages/shared/components/package_path_spec.js b/spec/frontend/packages/shared/components/package_path_spec.js
new file mode 100644
index 00000000000..40d455ac77c
--- /dev/null
+++ b/spec/frontend/packages/shared/components/package_path_spec.js
@@ -0,0 +1,86 @@
+import { shallowMount } from '@vue/test-utils';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import PackagePath from '~/packages/shared/components/package_path.vue';
+
+describe('PackagePath', () => {
+ let wrapper;
+
+ const mountComponent = (propsData = { path: 'foo' }) => {
+ wrapper = shallowMount(PackagePath, {
+ propsData,
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ const BASE_ICON = 'base-icon';
+ const ROOT_LINK = 'root-link';
+ const ROOT_CHEVRON = 'root-chevron';
+ const ELLIPSIS_ICON = 'ellipsis-icon';
+ const ELLIPSIS_CHEVRON = 'ellipsis-chevron';
+ const LEAF_LINK = 'leaf-link';
+
+ const findItem = name => wrapper.find(`[data-testid="${name}"]`);
+ const findTooltip = w => getBinding(w.element, 'gl-tooltip');
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe.each`
+ path | rootUrl | shouldExist | shouldNotExist
+ ${'foo/bar'} | ${'/foo/bar'} | ${[]} | ${[ROOT_CHEVRON, ELLIPSIS_ICON, ELLIPSIS_CHEVRON, LEAF_LINK]}
+ ${'foo/bar/baz'} | ${'/foo/bar'} | ${[ROOT_CHEVRON, LEAF_LINK]} | ${[ELLIPSIS_ICON, ELLIPSIS_CHEVRON]}
+ ${'foo/bar/baz/baz2'} | ${'/foo/bar'} | ${[ROOT_CHEVRON, LEAF_LINK, ELLIPSIS_ICON, ELLIPSIS_CHEVRON]} | ${[]}
+ ${'foo/bar/baz/baz2/bar2'} | ${'/foo/bar'} | ${[ROOT_CHEVRON, LEAF_LINK, ELLIPSIS_ICON, ELLIPSIS_CHEVRON]} | ${[]}
+ `('given path $path', ({ path, shouldExist, shouldNotExist, rootUrl }) => {
+ const pathPieces = path.split('/').slice(1);
+ const hasTooltip = shouldExist.includes(ELLIPSIS_ICON);
+
+ beforeEach(() => {
+ mountComponent({ path });
+ });
+
+ it('should have a base icon', () => {
+ expect(findItem(BASE_ICON).exists()).toBe(true);
+ });
+
+ it('should have a root link', () => {
+ const root = findItem(ROOT_LINK);
+ expect(root.exists()).toBe(true);
+ expect(root.attributes('href')).toBe(rootUrl);
+ });
+
+ if (hasTooltip) {
+ it('should have a tooltip', () => {
+ const tooltip = findTooltip(findItem(ELLIPSIS_ICON));
+ expect(tooltip).toBeDefined();
+ expect(tooltip.value).toMatchObject({
+ title: path,
+ });
+ });
+ }
+
+ if (shouldExist.length) {
+ it.each(shouldExist)(`should have %s`, element => {
+ expect(findItem(element).exists()).toBe(true);
+ });
+ }
+
+ if (shouldNotExist.length) {
+ it.each(shouldNotExist)(`should not have %s`, element => {
+ expect(findItem(element).exists()).toBe(false);
+ });
+ }
+
+ if (shouldExist.includes(LEAF_LINK)) {
+ it('the last link should be the last piece of the path', () => {
+ const leaf = findItem(LEAF_LINK);
+ expect(leaf.attributes('href')).toBe(`/${path}`);
+ expect(leaf.text()).toBe(pathPieces[pathPieces.length - 1]);
+ });
+ }
+ });
+});
diff --git a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap
index 2fbc700d4f5..ddeaa2a79db 100644
--- a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap
+++ b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap
@@ -39,6 +39,7 @@ exports[`User Operation confirmation modal renders modal with form included 1`]
/>
</form>
<gl-button-stub
+ buttontextclasses=""
category="primary"
icon=""
size="medium"
@@ -48,6 +49,7 @@ exports[`User Operation confirmation modal renders modal with form included 1`]
</gl-button-stub>
<gl-button-stub
+ buttontextclasses=""
category="primary"
disabled="true"
icon=""
@@ -60,6 +62,7 @@ exports[`User Operation confirmation modal renders modal with form included 1`]
</gl-button-stub>
<gl-button-stub
+ buttontextclasses=""
category="primary"
disabled="true"
icon=""
diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
index 5a61f9fca69..5da998d9d2d 100644
--- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
+++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
@@ -1,23 +1,18 @@
import Vue from 'vue';
import Cookies from 'js-cookie';
import PipelineSchedulesCallout from '~/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue';
-import '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg';
-
-jest.mock(
- '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg',
- () => '<svg></svg>',
-);
const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout);
const cookieKey = 'pipeline_schedules_callout_dismissed';
const docsUrl = 'help/ci/scheduled_pipelines';
+const imageUrl = 'pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg';
describe('Pipeline Schedule Callout', () => {
let calloutComponent;
beforeEach(() => {
setFixtures(`
- <div id='pipeline-schedules-callout' data-docs-url=${docsUrl}></div>
+ <div id='pipeline-schedules-callout' data-docs-url=${docsUrl} data-image-url=${imageUrl}></div>
`);
});
@@ -30,13 +25,13 @@ describe('Pipeline Schedule Callout', () => {
expect(calloutComponent).toBeDefined();
});
- it('correctly sets illustrationSvg', () => {
- expect(calloutComponent.illustrationSvg).toContain('<svg');
- });
-
it('correctly sets docsUrl', () => {
expect(calloutComponent.docsUrl).toContain(docsUrl);
});
+
+ it('correctly sets imageUrl', () => {
+ expect(calloutComponent.imageUrl).toContain(imageUrl);
+ });
});
describe(`when ${cookieKey} cookie is set`, () => {
@@ -68,8 +63,8 @@ describe('Pipeline Schedule Callout', () => {
expect(calloutComponent.$el.querySelector('.bordered-box')).not.toBeNull();
});
- it('renders the callout svg', () => {
- expect(calloutComponent.$el.outerHTML).toContain('<svg');
+ it('renders the callout img', () => {
+ expect(calloutComponent.$el.outerHTML).toContain('<img');
});
it('renders the callout title', () => {
diff --git a/spec/frontend/pipeline_new/mock_data.js b/spec/frontend/pipeline_new/mock_data.js
index 55286e0ec7e..cdbd6d4437e 100644
--- a/spec/frontend/pipeline_new/mock_data.js
+++ b/spec/frontend/pipeline_new/mock_data.js
@@ -14,9 +14,9 @@ export const mockProjectId = '21';
export const mockPostParams = {
ref: 'tag-1',
- variables: [
- { key: 'test_var', value: 'test_var_val', variable_type: 'env_var' },
- { key: 'test_file', value: 'test_file_val', variable_type: 'file' },
+ variables_attributes: [
+ { key: 'test_var', secret_value: 'test_var_val', variable_type: 'env_var' },
+ { key: 'test_file', secret_value: 'test_file_val', variable_type: 'file' },
],
};
diff --git a/spec/frontend/pipelines/components/dag/dag_spec.js b/spec/frontend/pipelines/components/dag/dag_spec.js
index 989f6c17197..08a43199594 100644
--- a/spec/frontend/pipelines/components/dag/dag_spec.js
+++ b/spec/frontend/pipelines/components/dag/dag_spec.js
@@ -4,13 +4,8 @@ import Dag from '~/pipelines/components/dag/dag.vue';
import DagGraph from '~/pipelines/components/dag/dag_graph.vue';
import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue';
-import {
- ADD_NOTE,
- REMOVE_NOTE,
- REPLACE_NOTES,
- PARSE_FAILURE,
- UNSUPPORTED_DATA,
-} from '~/pipelines/components/dag//constants';
+import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from '~/pipelines/components/dag/constants';
+import { PARSE_FAILURE, UNSUPPORTED_DATA } from '~/pipelines/constants';
import {
mockParsedGraphQLNodes,
tooSmallGraph,
diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js
index d977db58a0e..062c9759a65 100644
--- a/spec/frontend/pipelines/graph/graph_component_spec.js
+++ b/spec/frontend/pipelines/graph/graph_component_spec.js
@@ -3,23 +3,27 @@ import { mount } from '@vue/test-utils';
import { setHTMLFixture } from 'helpers/fixtures';
import PipelineStore from '~/pipelines/stores/pipeline_store';
import graphComponent from '~/pipelines/components/graph/graph_component.vue';
-import stageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
+import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import linkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import graphJSON from './mock_data';
import linkedPipelineJSON from './linked_pipelines_mock_data';
import PipelinesMediator from '~/pipelines/pipeline_details_mediator';
describe('graph component', () => {
- const store = new PipelineStore();
- store.storePipeline(linkedPipelineJSON);
- const mediator = new PipelinesMediator({ endpoint: '' });
-
+ let store;
+ let mediator;
let wrapper;
const findExpandPipelineBtn = () => wrapper.find('[data-testid="expandPipelineButton"]');
const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expandPipelineButton"]');
+ const findStageColumns = () => wrapper.findAll(StageColumnComponent);
+ const findStageColumnAt = i => findStageColumns().at(i);
beforeEach(() => {
+ mediator = new PipelinesMediator({ endpoint: '' });
+ store = new PipelineStore();
+ store.storePipeline(linkedPipelineJSON);
+
setHTMLFixture('<div class="layout-page"></div>');
});
@@ -43,7 +47,7 @@ describe('graph component', () => {
});
describe('with data', () => {
- it('should render the graph', () => {
+ beforeEach(() => {
wrapper = mount(graphComponent, {
propsData: {
isLoading: false,
@@ -51,26 +55,17 @@ describe('graph component', () => {
mediator,
},
});
+ });
+ it('renders the graph', () => {
expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true);
-
- expect(wrapper.find(stageColumnComponent).classes()).toContain('no-margin');
-
- expect(
- wrapper
- .findAll(stageColumnComponent)
- .at(1)
- .classes(),
- ).toContain('left-margin');
-
- expect(wrapper.find('.stage-column:nth-child(2) .build:nth-child(1)').classes()).toContain(
- 'left-connector',
- );
-
expect(wrapper.find('.loading-icon').exists()).toBe(false);
-
expect(wrapper.find('.stage-column-list').exists()).toBe(true);
});
+
+ it('renders columns in the graph', () => {
+ expect(findStageColumns()).toHaveLength(graphJSON.details.stages.length);
+ });
});
describe('when linked pipelines are present', () => {
@@ -93,26 +88,26 @@ describe('graph component', () => {
expect(wrapper.find('.fa-spinner').exists()).toBe(false);
});
- it('should include the stage column list', () => {
- expect(wrapper.find(stageColumnComponent).exists()).toBe(true);
- });
-
- it('should include the no-margin class on the first child if there is only one job', () => {
- const firstStageColumnElement = wrapper.find(stageColumnComponent);
-
- expect(firstStageColumnElement.classes()).toContain('no-margin');
+ it('should include the stage column', () => {
+ expect(findStageColumnAt(0).exists()).toBe(true);
});
- it('should include the has-only-one-job class on the first child', () => {
- const firstStageColumnElement = wrapper.find('.stage-column-list .stage-column');
-
- expect(firstStageColumnElement.classes()).toContain('has-only-one-job');
+ it('stage column should have no-margin, gl-mr-26, has-only-one-job classes if there is only one job', () => {
+ expect(findStageColumnAt(0).classes()).toEqual(
+ expect.arrayContaining(['no-margin', 'gl-mr-26', 'has-only-one-job']),
+ );
});
it('should include the left-margin class on the second child', () => {
- const firstStageColumnElement = wrapper.find('.stage-column-list .stage-column:last-child');
+ expect(findStageColumnAt(1).classes('left-margin')).toBe(true);
+ });
- expect(firstStageColumnElement.classes()).toContain('left-margin');
+ it('should include the left-connector class in the build of the second child', () => {
+ expect(
+ findStageColumnAt(1)
+ .find('.build:nth-child(1)')
+ .classes('left-connector'),
+ ).toBe(true);
});
it('should include the js-has-linked-pipelines flag', () => {
@@ -134,12 +129,7 @@ describe('graph component', () => {
describe('stageConnectorClass', () => {
it('it returns left-margin when there is a triggerer', () => {
- expect(
- wrapper
- .findAll(stageColumnComponent)
- .at(1)
- .classes(),
- ).toContain('left-margin');
+ expect(findStageColumnAt(1).classes('left-margin')).toBe(true);
});
});
});
@@ -248,6 +238,16 @@ describe('graph component', () => {
.catch(done.fail);
});
});
+
+ describe('when column requests a refresh', () => {
+ beforeEach(() => {
+ findStageColumnAt(0).vm.$emit('refreshPipelineGraph');
+ });
+
+ it('refreshPipelineGraph is emitted', () => {
+ expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1);
+ });
+ });
});
});
});
@@ -268,7 +268,7 @@ describe('graph component', () => {
it('should include the first column with a no margin', () => {
const firstColumn = wrapper.find('.stage-column');
- expect(firstColumn.classes()).toContain('no-margin');
+ expect(firstColumn.classes('no-margin')).toBe(true);
});
it('should not render a linked pipelines column', () => {
@@ -278,16 +278,11 @@ describe('graph component', () => {
describe('stageConnectorClass', () => {
it('it returns no-margin when no triggerer and there is one job', () => {
- expect(wrapper.find(stageColumnComponent).classes()).toContain('no-margin');
+ expect(findStageColumnAt(0).classes('no-margin')).toBe(true);
});
it('it returns left-margin when no triggerer and not the first stage', () => {
- expect(
- wrapper
- .findAll(stageColumnComponent)
- .at(1)
- .classes(),
- ).toContain('left-margin');
+ expect(findStageColumnAt(1).classes('left-margin')).toBe(true);
});
});
});
@@ -302,12 +297,9 @@ describe('graph component', () => {
},
});
- expect(
- wrapper
- .find('.stage-column:nth-child(2) .stage-name')
- .text()
- .trim(),
- ).toEqual('Deploy &lt;img src=x onerror=alert(document.domain)&gt;');
+ expect(findStageColumnAt(1).props('title')).toEqual(
+ 'Deploy &lt;img src=x onerror=alert(document.domain)&gt;',
+ );
});
});
});
diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js
index 5388d624d3c..2e10b0f068c 100644
--- a/spec/frontend/pipelines/header_component_spec.js
+++ b/spec/frontend/pipelines/header_component_spec.js
@@ -1,115 +1,164 @@
import { shallowMount } from '@vue/test-utils';
-import { GlModal } from '@gitlab/ui';
+import { GlModal, GlLoadingIcon } from '@gitlab/ui';
+import MockAdapter from 'axios-mock-adapter';
+import {
+ mockCancelledPipelineHeader,
+ mockFailedPipelineHeader,
+ mockRunningPipelineHeader,
+ mockSuccessfulPipelineHeader,
+} from './mock_data';
+import axios from '~/lib/utils/axios_utils';
import HeaderComponent from '~/pipelines/components/header_component.vue';
-import CiHeader from '~/vue_shared/components/header_ci_component.vue';
-import eventHub from '~/pipelines/event_hub';
describe('Pipeline details header', () => {
let wrapper;
let glModalDirective;
-
- const threeWeeksAgo = new Date();
- threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
+ let mockAxios;
const findDeleteModal = () => wrapper.find(GlModal);
-
- const defaultProps = {
- pipeline: {
- details: {
- status: {
- group: 'failed',
- icon: 'status_failed',
- label: 'failed',
- text: 'failed',
- details_path: 'path',
- },
- },
- id: 123,
- created_at: threeWeeksAgo.toISOString(),
- user: {
- web_url: 'path',
- name: 'Foo',
- username: 'foobar',
- email: 'foo@bar.com',
- avatar_url: 'link',
- },
- retry_path: 'retry',
- cancel_path: 'cancel',
- delete_path: 'delete',
+ const findRetryButton = () => wrapper.find('[data-testid="retryPipeline"]');
+ const findCancelButton = () => wrapper.find('[data-testid="cancelPipeline"]');
+ const findDeleteButton = () => wrapper.find('[data-testid="deletePipeline"]');
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+
+ const defaultProvideOptions = {
+ pipelineId: 14,
+ pipelineIid: 1,
+ paths: {
+ retry: '/retry',
+ cancel: '/cancel',
+ delete: '/delete',
+ fullProject: '/namespace/my-project',
},
- isLoading: false,
};
- const createComponent = (props = {}) => {
+ const createComponent = (pipelineMock = mockRunningPipelineHeader, { isLoading } = false) => {
glModalDirective = jest.fn();
- wrapper = shallowMount(HeaderComponent, {
- propsData: {
- ...props,
+ const $apollo = {
+ queries: {
+ pipeline: {
+ loading: isLoading,
+ stopPolling: jest.fn(),
+ startPolling: jest.fn(),
+ },
+ },
+ };
+
+ return shallowMount(HeaderComponent, {
+ data() {
+ return {
+ pipeline: pipelineMock,
+ };
+ },
+ provide: {
+ ...defaultProvideOptions,
},
directives: {
glModal: {
- bind(el, { value }) {
+ bind(_, { value }) {
glModalDirective(value);
},
},
},
+ mocks: { $apollo },
});
};
beforeEach(() => {
- jest.spyOn(eventHub, '$emit');
-
- createComponent(defaultProps);
+ mockAxios = new MockAdapter(axios);
+ mockAxios.onGet('*').replyOnce(200);
});
afterEach(() => {
- eventHub.$off();
-
wrapper.destroy();
wrapper = null;
+
+ mockAxios.restore();
});
- it('should render provided pipeline info', () => {
- expect(wrapper.find(CiHeader).props()).toMatchObject({
- status: defaultProps.pipeline.details.status,
- itemId: defaultProps.pipeline.id,
- time: defaultProps.pipeline.created_at,
- user: defaultProps.pipeline.user,
+ describe('initial loading', () => {
+ beforeEach(() => {
+ wrapper = createComponent(null, { isLoading: true });
});
- });
- describe('action buttons', () => {
- it('should not trigger eventHub when nothing happens', () => {
- expect(eventHub.$emit).not.toHaveBeenCalled();
+ it('shows a loading state while graphQL is fetching initial data', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
});
+ });
+
+ describe('visible state', () => {
+ it.each`
+ state | pipelineData | retryValue | cancelValue
+ ${'cancelled'} | ${mockCancelledPipelineHeader} | ${true} | ${false}
+ ${'failed'} | ${mockFailedPipelineHeader} | ${true} | ${false}
+ ${'running'} | ${mockRunningPipelineHeader} | ${false} | ${true}
+ ${'successful'} | ${mockSuccessfulPipelineHeader} | ${false} | ${false}
+ `(
+ 'with a $state pipeline, it will show actions: retry $retryValue and cancel $cancelValue',
+ ({ pipelineData, retryValue, cancelValue }) => {
+ wrapper = createComponent(pipelineData);
+
+ expect(findRetryButton().exists()).toBe(retryValue);
+ expect(findCancelButton().exists()).toBe(cancelValue);
+ },
+ );
+ });
- it('should call postAction when retry button action is clicked', () => {
- wrapper.find('[data-testid="retryButton"]').vm.$emit('click');
+ describe('actions', () => {
+ describe('Retry action', () => {
+ beforeEach(() => {
+ wrapper = createComponent(mockCancelledPipelineHeader);
+ });
- expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'retry');
- });
+ it('should call axios with the right path when retry button is clicked', async () => {
+ jest.spyOn(axios, 'post');
+ findRetryButton().vm.$emit('click');
- it('should call postAction when cancel button action is clicked', () => {
- wrapper.find('[data-testid="cancelPipeline"]').vm.$emit('click');
+ await wrapper.vm.$nextTick();
- expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'cancel');
+ expect(axios.post).toHaveBeenCalledWith(defaultProvideOptions.paths.retry);
+ });
});
- it('does not show delete modal', () => {
- expect(findDeleteModal()).not.toBeVisible();
+ describe('Cancel action', () => {
+ beforeEach(() => {
+ wrapper = createComponent(mockRunningPipelineHeader);
+ });
+
+ it('should call axios with the right path when cancel button is clicked', async () => {
+ jest.spyOn(axios, 'post');
+ findCancelButton().vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(axios.post).toHaveBeenCalledWith(defaultProvideOptions.paths.cancel);
+ });
});
- describe('when delete button action is clicked', () => {
- it('displays delete modal', () => {
+ describe('Delete action', () => {
+ beforeEach(() => {
+ wrapper = createComponent(mockFailedPipelineHeader);
+ });
+
+ it('displays delete modal when clicking on delete and does not call the delete action', async () => {
+ jest.spyOn(axios, 'delete');
+ findDeleteButton().vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
expect(findDeleteModal().props('modalId')).toBe(wrapper.vm.$options.DELETE_MODAL_ID);
expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.$options.DELETE_MODAL_ID);
+ expect(axios.delete).not.toHaveBeenCalled();
});
- it('should call delete when modal is submitted', () => {
+ it('should call delete path when modal is submitted', async () => {
+ jest.spyOn(axios, 'delete');
findDeleteModal().vm.$emit('ok');
- expect(eventHub.$emit).toHaveBeenCalledWith('headerDeleteAction', 'delete');
+ await wrapper.vm.$nextTick();
+
+ expect(axios.delete).toHaveBeenCalledWith(defaultProvideOptions.paths.delete);
});
});
});
diff --git a/spec/frontend/pipelines/legacy_header_component_spec.js b/spec/frontend/pipelines/legacy_header_component_spec.js
new file mode 100644
index 00000000000..fb7feb8898a
--- /dev/null
+++ b/spec/frontend/pipelines/legacy_header_component_spec.js
@@ -0,0 +1,116 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlModal } from '@gitlab/ui';
+import LegacyHeaderComponent from '~/pipelines/components/legacy_header_component.vue';
+import CiHeader from '~/vue_shared/components/header_ci_component.vue';
+import eventHub from '~/pipelines/event_hub';
+
+describe('Pipeline details header', () => {
+ let wrapper;
+ let glModalDirective;
+
+ const threeWeeksAgo = new Date();
+ threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
+
+ const findDeleteModal = () => wrapper.find(GlModal);
+
+ const defaultProps = {
+ pipeline: {
+ details: {
+ status: {
+ group: 'failed',
+ icon: 'status_failed',
+ label: 'failed',
+ text: 'failed',
+ details_path: 'path',
+ },
+ },
+ id: 123,
+ created_at: threeWeeksAgo.toISOString(),
+ user: {
+ web_url: 'path',
+ name: 'Foo',
+ username: 'foobar',
+ email: 'foo@bar.com',
+ avatar_url: 'link',
+ },
+ retry_path: 'retry',
+ cancel_path: 'cancel',
+ delete_path: 'delete',
+ },
+ isLoading: false,
+ };
+
+ const createComponent = (props = {}) => {
+ glModalDirective = jest.fn();
+
+ wrapper = shallowMount(LegacyHeaderComponent, {
+ propsData: {
+ ...props,
+ },
+ directives: {
+ glModal: {
+ bind(el, { value }) {
+ glModalDirective(value);
+ },
+ },
+ },
+ });
+ };
+
+ beforeEach(() => {
+ jest.spyOn(eventHub, '$emit');
+
+ createComponent(defaultProps);
+ });
+
+ afterEach(() => {
+ eventHub.$off();
+
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('should render provided pipeline info', () => {
+ expect(wrapper.find(CiHeader).props()).toMatchObject({
+ status: defaultProps.pipeline.details.status,
+ itemId: defaultProps.pipeline.id,
+ time: defaultProps.pipeline.created_at,
+ user: defaultProps.pipeline.user,
+ });
+ });
+
+ describe('action buttons', () => {
+ it('should not trigger eventHub when nothing happens', () => {
+ expect(eventHub.$emit).not.toHaveBeenCalled();
+ });
+
+ it('should call postAction when retry button action is clicked', () => {
+ wrapper.find('[data-testid="retryButton"]').vm.$emit('click');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'retry');
+ });
+
+ it('should call postAction when cancel button action is clicked', () => {
+ wrapper.find('[data-testid="cancelPipeline"]').vm.$emit('click');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'cancel');
+ });
+
+ it('does not show delete modal', () => {
+ expect(findDeleteModal()).not.toBeVisible();
+ });
+
+ describe('when delete button action is clicked', () => {
+ it('displays delete modal', () => {
+ expect(findDeleteModal().props('modalId')).toBe(wrapper.vm.$options.DELETE_MODAL_ID);
+ expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.$options.DELETE_MODAL_ID);
+ });
+
+ it('should call delete when modal is submitted', () => {
+ findDeleteModal().vm.$emit('ok');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('headerDeleteAction', 'delete');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js
index e63efc543f1..2afdbb05107 100644
--- a/spec/frontend/pipelines/mock_data.js
+++ b/spec/frontend/pipelines/mock_data.js
@@ -1,3 +1,7 @@
+const PIPELINE_RUNNING = 'RUNNING';
+const PIPELINE_CANCELED = 'CANCELED';
+const PIPELINE_FAILED = 'FAILED';
+
export const pipelineWithStages = {
id: 20333396,
user: {
@@ -320,6 +324,80 @@ export const pipelineWithStages = {
triggered: [],
};
+const threeWeeksAgo = new Date();
+threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
+
+export const mockPipelineHeader = {
+ detailedStatus: {},
+ id: 123,
+ userPermissions: {
+ destroyPipeline: true,
+ },
+ createdAt: threeWeeksAgo.toISOString(),
+ user: {
+ name: 'Foo',
+ username: 'foobar',
+ email: 'foo@bar.com',
+ avatarUrl: 'link',
+ },
+};
+
+export const mockFailedPipelineHeader = {
+ ...mockPipelineHeader,
+ status: PIPELINE_FAILED,
+ retryable: true,
+ cancelable: false,
+ detailedStatus: {
+ group: 'failed',
+ icon: 'status_failed',
+ label: 'failed',
+ text: 'failed',
+ detailsPath: 'path',
+ },
+};
+
+export const mockRunningPipelineHeader = {
+ ...mockPipelineHeader,
+ status: PIPELINE_RUNNING,
+ retryable: false,
+ cancelable: true,
+ detailedStatus: {
+ group: 'running',
+ icon: 'status_running',
+ label: 'running',
+ text: 'running',
+ detailsPath: 'path',
+ },
+};
+
+export const mockCancelledPipelineHeader = {
+ ...mockPipelineHeader,
+ status: PIPELINE_CANCELED,
+ retryable: true,
+ cancelable: false,
+ detailedStatus: {
+ group: 'cancelled',
+ icon: 'status_cancelled',
+ label: 'cancelled',
+ text: 'cancelled',
+ detailsPath: 'path',
+ },
+};
+
+export const mockSuccessfulPipelineHeader = {
+ ...mockPipelineHeader,
+ status: 'SUCCESS',
+ retryable: false,
+ cancelable: false,
+ detailedStatus: {
+ group: 'success',
+ icon: 'status_success',
+ label: 'success',
+ text: 'success',
+ detailsPath: 'path',
+ },
+};
+
export const stageReply = {
name: 'deploy',
title: 'deploy: running',
diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js
index b0ad6bbd228..1298a2a1524 100644
--- a/spec/frontend/pipelines/pipelines_spec.js
+++ b/spec/frontend/pipelines/pipelines_spec.js
@@ -1,9 +1,17 @@
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
-import { GlFilteredSearch } from '@gitlab/ui';
+import { GlFilteredSearch, GlButton, GlLoadingIcon } from '@gitlab/ui';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
+import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
+import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+
+import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue';
+import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue';
+import BlankState from '~/pipelines/components/pipelines_list/blank_state.vue';
+import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue';
+
import PipelinesComponent from '~/pipelines/components/pipelines_list/pipelines.vue';
import Store from '~/pipelines/stores/pipelines_store';
import { pipelineWithStages, stageReply, users, mockSearch, branches } from './mock_data';
@@ -49,6 +57,20 @@ describe('Pipelines', () => {
};
const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
+ const findByTestId = id => wrapper.find(`[data-testid="${id}"]`);
+ const findNavigationTabs = () => wrapper.find(NavigationTabs);
+ const findNavigationControls = () => wrapper.find(NavigationControls);
+ const findTab = tab => findByTestId(`pipelines-tab-${tab}`);
+
+ const findRunPipelineButton = () => findByTestId('run-pipeline-button');
+ const findCiLintButton = () => findByTestId('ci-lint-button');
+ const findCleanCacheButton = () => findByTestId('clear-cache-button');
+
+ const findEmptyState = () => wrapper.find(EmptyState);
+ const findBlankState = () => wrapper.find(BlankState);
+ const findStagesDropdown = () => wrapper.find('.js-builds-dropdown-button');
+
+ const findTablePagination = () => wrapper.find(TablePagination);
const createComponent = (props = defaultProps, methods) => {
wrapper = mount(PipelinesComponent, {
@@ -87,19 +109,19 @@ describe('Pipelines', () => {
});
it('renders tabs', () => {
- expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ expect(findTab('all').text()).toContain('All');
});
it('renders Run Pipeline link', () => {
- expect(wrapper.find('.js-run-pipeline').attributes('href')).toBe(paths.newPipelinePath);
+ expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath);
});
it('renders CI Lint link', () => {
- expect(wrapper.find('.js-ci-lint').attributes('href')).toBe(paths.ciLintPath);
+ expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath);
});
it('renders Clear Runner Cache button', () => {
- expect(wrapper.find('.js-clear-cache').text()).toBe('Clear Runner Caches');
+ expect(findCleanCacheButton().text()).toBe('Clear Runner Caches');
});
it('renders pipelines table', () => {
@@ -127,23 +149,31 @@ describe('Pipelines', () => {
});
it('renders tabs', () => {
- expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ expect(findTab('all').text()).toContain('All');
});
it('renders Run Pipeline link', () => {
- expect(wrapper.find('.js-run-pipeline').attributes('href')).toEqual(paths.newPipelinePath);
+ expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath);
});
it('renders CI Lint link', () => {
- expect(wrapper.find('.js-ci-lint').attributes('href')).toEqual(paths.ciLintPath);
+ expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath);
});
it('renders Clear Runner Cache button', () => {
- expect(wrapper.find('.js-clear-cache').text()).toEqual('Clear Runner Caches');
+ expect(findCleanCacheButton().text()).toBe('Clear Runner Caches');
});
it('renders tab empty state', () => {
- expect(wrapper.find('.empty-state h4').text()).toEqual('There are currently no pipelines.');
+ expect(findBlankState().text()).toBe('There are currently no pipelines.');
+ });
+
+ it('renders tab empty state finished scope', () => {
+ wrapper.vm.scope = 'finished';
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findBlankState().text()).toBe('There are currently no finished pipelines.');
+ });
});
});
@@ -165,18 +195,23 @@ describe('Pipelines', () => {
});
it('renders empty state', () => {
- expect(wrapper.find('.js-empty-state h4').text()).toEqual('Build with confidence');
-
- expect(wrapper.find('.js-get-started-pipelines').attributes('href')).toEqual(
- paths.helpPagePath,
- );
+ expect(
+ findEmptyState()
+ .find('h4')
+ .text(),
+ ).toBe('Build with confidence');
+ expect(
+ findEmptyState()
+ .find(GlButton)
+ .attributes('href'),
+ ).toBe(paths.helpPagePath);
});
it('does not render tabs nor buttons', () => {
- expect(wrapper.find('.js-pipelines-tab-all').exists()).toBeFalsy();
- expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy();
- expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy();
- expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy();
+ expect(findTab('all').exists()).toBe(false);
+ expect(findRunPipelineButton().exists()).toBeFalsy();
+ expect(findCiLintButton().exists()).toBeFalsy();
+ expect(findCleanCacheButton().exists()).toBeFalsy();
});
});
@@ -189,20 +224,18 @@ describe('Pipelines', () => {
});
it('renders tabs', () => {
- expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ expect(findTab('all').text()).toContain('All');
});
it('renders buttons', () => {
- expect(wrapper.find('.js-run-pipeline').attributes('href')).toEqual(paths.newPipelinePath);
+ expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath);
- expect(wrapper.find('.js-ci-lint').attributes('href')).toEqual(paths.ciLintPath);
- expect(wrapper.find('.js-clear-cache').text()).toEqual('Clear Runner Caches');
+ expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath);
+ expect(findCleanCacheButton().text()).toBe('Clear Runner Caches');
});
it('renders error state', () => {
- expect(wrapper.find('.empty-state').text()).toContain(
- 'There was an error fetching the pipelines.',
- );
+ expect(findBlankState().text()).toContain('There was an error fetching the pipelines.');
});
});
});
@@ -218,13 +251,13 @@ describe('Pipelines', () => {
});
it('renders tabs', () => {
- expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ expect(findTab('all').text()).toContain('All');
});
it('does not render buttons', () => {
- expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy();
- expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy();
- expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy();
+ expect(findRunPipelineButton().exists()).toBeFalsy();
+ expect(findCiLintButton().exists()).toBeFalsy();
+ expect(findCleanCacheButton().exists()).toBeFalsy();
});
it('renders pipelines table', () => {
@@ -252,17 +285,17 @@ describe('Pipelines', () => {
});
it('renders tabs', () => {
- expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ expect(findTab('all').text()).toContain('All');
});
it('does not render buttons', () => {
- expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy();
- expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy();
- expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy();
+ expect(findRunPipelineButton().exists()).toBeFalsy();
+ expect(findCiLintButton().exists()).toBeFalsy();
+ expect(findCleanCacheButton().exists()).toBeFalsy();
});
it('renders tab empty state', () => {
- expect(wrapper.find('.empty-state h4').text()).toEqual('There are currently no pipelines.');
+ expect(wrapper.find('.empty-state h4').text()).toBe('There are currently no pipelines.');
});
});
@@ -284,18 +317,22 @@ describe('Pipelines', () => {
});
it('renders empty state without button to set CI', () => {
- expect(wrapper.find('.js-empty-state').text()).toEqual(
+ expect(findEmptyState().text()).toBe(
'This project is not currently set up to run pipelines.',
);
- expect(wrapper.find('.js-get-started-pipelines').exists()).toBeFalsy();
+ expect(
+ findEmptyState()
+ .find(GlButton)
+ .exists(),
+ ).toBeFalsy();
});
it('does not render tabs or buttons', () => {
- expect(wrapper.find('.js-pipelines-tab-all').exists()).toBeFalsy();
- expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy();
- expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy();
- expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy();
+ expect(findTab('all').exists()).toBe(false);
+ expect(findRunPipelineButton().exists()).toBeFalsy();
+ expect(findCiLintButton().exists()).toBeFalsy();
+ expect(findCleanCacheButton().exists()).toBeFalsy();
});
});
@@ -309,13 +346,13 @@ describe('Pipelines', () => {
});
it('renders tabs', () => {
- expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
+ expect(findTab('all').text()).toContain('All');
});
it('does not renders buttons', () => {
- expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy();
- expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy();
- expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy();
+ expect(findRunPipelineButton().exists()).toBeFalsy();
+ expect(findCiLintButton().exists()).toBeFalsy();
+ expect(findCleanCacheButton().exists()).toBeFalsy();
});
it('renders error state', () => {
@@ -342,14 +379,20 @@ describe('Pipelines', () => {
);
});
- it('should render navigation tabs', () => {
- expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All');
-
- expect(wrapper.find('.js-pipelines-tab-finished').text()).toContain('Finished');
-
- expect(wrapper.find('.js-pipelines-tab-branches').text()).toContain('Branches');
+ it('should set up navigation tabs', () => {
+ expect(findNavigationTabs().props('tabs')).toEqual([
+ { name: 'All', scope: 'all', count: '3', isActive: true },
+ { name: 'Finished', scope: 'finished', count: undefined, isActive: false },
+ { name: 'Branches', scope: 'branches', isActive: false },
+ { name: 'Tags', scope: 'tags', isActive: false },
+ ]);
+ });
- expect(wrapper.find('.js-pipelines-tab-tags').text()).toContain('Tags');
+ it('should render navigation tabs', () => {
+ expect(findTab('all').html()).toContain('All');
+ expect(findTab('finished').text()).toContain('Finished');
+ expect(findTab('branches').text()).toContain('Branches');
+ expect(findTab('tags').text()).toContain('Tags');
});
it('should make an API request when using tabs', () => {
@@ -362,7 +405,7 @@ describe('Pipelines', () => {
);
return waitForPromises().then(() => {
- wrapper.find('.js-pipelines-tab-finished').trigger('click');
+ findTab('finished').trigger('click');
expect(updateContentMock).toHaveBeenCalledWith({ scope: 'finished', page: '1' });
});
@@ -401,133 +444,172 @@ describe('Pipelines', () => {
});
});
- describe('methods', () => {
+ describe('User Interaction', () => {
+ let updateContentMock;
+
beforeEach(() => {
jest.spyOn(window.history, 'pushState').mockImplementation(() => null);
});
- describe('onChangeTab', () => {
- it('should set page to 1', () => {
- const updateContentMock = jest.fn(() => {});
- createComponent(
- { hasGitlabCi: true, canCreatePipeline: true, ...paths },
- {
- updateContent: updateContentMock,
- },
- );
+ beforeEach(() => {
+ mock.onGet(paths.endpoint).reply(200, pipelines);
+ createComponent();
- wrapper.vm.onChangeTab('running');
+ updateContentMock = jest.spyOn(wrapper.vm, 'updateContent');
+
+ return waitForPromises();
+ });
+
+ describe('when user changes tabs', () => {
+ it('should set page to 1', () => {
+ findNavigationTabs().vm.$emit('onChangeTab', 'running');
expect(updateContentMock).toHaveBeenCalledWith({ scope: 'running', page: '1' });
});
});
- describe('onChangePage', () => {
+ describe('when user changes page', () => {
it('should update page and keep scope', () => {
- const updateContentMock = jest.fn(() => {});
- createComponent(
- { hasGitlabCi: true, canCreatePipeline: true, ...paths },
- {
- updateContent: updateContentMock,
- },
- );
-
- wrapper.vm.onChangePage(4);
+ findTablePagination().vm.change(4);
expect(updateContentMock).toHaveBeenCalledWith({ scope: wrapper.vm.scope, page: '4' });
});
});
- });
- describe('computed properties', () => {
- beforeEach(() => {
- createComponent();
- });
+ describe('updates results when a staged is clicked', () => {
+ beforeEach(() => {
+ const copyPipeline = { ...pipelineWithStages };
+ copyPipeline.id += 1;
+ mock
+ .onGet('twitter/flight/pipelines.json')
+ .reply(
+ 200,
+ {
+ pipelines: [pipelineWithStages],
+ count: {
+ all: 1,
+ finished: 1,
+ pending: 0,
+ running: 0,
+ },
+ },
+ {
+ 'POLL-INTERVAL': 100,
+ },
+ )
+ .onGet(pipelineWithStages.details.stages[0].dropdown_path)
+ .reply(200, stageReply);
- describe('tabs', () => {
- it('returns default tabs', () => {
- expect(wrapper.vm.tabs).toEqual([
- { name: 'All', scope: 'all', count: undefined, isActive: true },
- { name: 'Finished', scope: 'finished', count: undefined, isActive: false },
- { name: 'Branches', scope: 'branches', isActive: false },
- { name: 'Tags', scope: 'tags', isActive: false },
- ]);
+ createComponent();
});
- });
- describe('emptyTabMessage', () => {
- it('returns message with finished scope', () => {
- wrapper.vm.scope = 'finished';
+ describe('when a request is being made', () => {
+ it('stops polling, cancels the request, & restarts polling', () => {
+ const stopMock = jest.spyOn(wrapper.vm.poll, 'stop');
+ const restartMock = jest.spyOn(wrapper.vm.poll, 'restart');
+ const cancelMock = jest.spyOn(wrapper.vm.service.cancelationSource, 'cancel');
+ mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines);
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.emptyTabMessage).toEqual('There are currently no finished pipelines.');
+ return waitForPromises()
+ .then(() => {
+ wrapper.vm.isMakingRequest = true;
+ findStagesDropdown().trigger('click');
+ })
+ .then(() => {
+ expect(cancelMock).toHaveBeenCalled();
+ expect(stopMock).toHaveBeenCalled();
+ expect(restartMock).toHaveBeenCalled();
+ });
});
});
- it('returns message without scope when scope is `all`', () => {
- expect(wrapper.vm.emptyTabMessage).toEqual('There are currently no pipelines.');
+ describe('when no request is being made', () => {
+ it('stops polling & restarts polling', () => {
+ const stopMock = jest.spyOn(wrapper.vm.poll, 'stop');
+ const restartMock = jest.spyOn(wrapper.vm.poll, 'restart');
+ mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines);
+
+ return waitForPromises()
+ .then(() => {
+ findStagesDropdown().trigger('click');
+ expect(stopMock).toHaveBeenCalled();
+ })
+ .then(() => {
+ expect(restartMock).toHaveBeenCalled();
+ });
+ });
});
});
+ });
- describe('stateToRender', () => {
- it('returns loading state when the app is loading', () => {
- expect(wrapper.vm.stateToRender).toEqual('loading');
+ describe('Rendered content', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('displays different content', () => {
+ it('shows loading state when the app is loading', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
- it('returns error state when app has error', () => {
+ it('shows error state when app has error', () => {
wrapper.vm.hasError = true;
wrapper.vm.isLoading = false;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.stateToRender).toEqual('error');
+ expect(findBlankState().props('message')).toBe(
+ 'There was an error fetching the pipelines. Try again in a few moments or contact your support team.',
+ );
});
});
- it('returns table list when app has pipelines', () => {
+ it('shows table list when app has pipelines', () => {
wrapper.vm.isLoading = false;
wrapper.vm.hasError = false;
wrapper.vm.state.pipelines = pipelines.pipelines;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.stateToRender).toEqual('tableList');
+ expect(wrapper.find(PipelinesTableComponent).exists()).toBe(true);
});
});
- it('returns empty tab when app does not have pipelines but project has pipelines', () => {
+ it('shows empty tab when app does not have pipelines but project has pipelines', () => {
wrapper.vm.state.count.all = 10;
wrapper.vm.isLoading = false;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.stateToRender).toEqual('emptyTab');
+ expect(findBlankState().exists()).toBe(true);
+ expect(findBlankState().props('message')).toBe('There are currently no pipelines.');
});
});
- it('returns empty tab when project has CI', () => {
+ it('shows empty tab when project has CI', () => {
wrapper.vm.isLoading = false;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.stateToRender).toEqual('emptyTab');
+ expect(findBlankState().exists()).toBe(true);
+ expect(findBlankState().props('message')).toBe('There are currently no pipelines.');
});
});
- it('returns empty state when project does not have pipelines nor CI', () => {
+ it('shows empty state when project does not have pipelines nor CI', () => {
createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths });
wrapper.vm.isLoading = false;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.stateToRender).toEqual('emptyState');
+ expect(wrapper.find(EmptyState).exists()).toBe(true);
});
});
});
- describe('shouldRenderTabs', () => {
+ describe('displays tabs', () => {
it('returns true when state is loading & has already made the first request', () => {
wrapper.vm.isLoading = true;
wrapper.vm.hasMadeRequest = true;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.shouldRenderTabs).toEqual(true);
+ expect(findNavigationTabs().exists()).toBe(true);
});
});
@@ -537,7 +619,7 @@ describe('Pipelines', () => {
wrapper.vm.hasMadeRequest = true;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.shouldRenderTabs).toEqual(true);
+ expect(findNavigationTabs().exists()).toBe(true);
});
});
@@ -547,7 +629,7 @@ describe('Pipelines', () => {
wrapper.vm.hasMadeRequest = true;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.shouldRenderTabs).toEqual(true);
+ expect(findNavigationTabs().exists()).toBe(true);
});
});
@@ -557,7 +639,7 @@ describe('Pipelines', () => {
wrapper.vm.hasMadeRequest = true;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.shouldRenderTabs).toEqual(true);
+ expect(findNavigationTabs().exists()).toBe(true);
});
});
@@ -565,7 +647,7 @@ describe('Pipelines', () => {
wrapper.vm.hasMadeRequest = false;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.shouldRenderTabs).toEqual(false);
+ expect(findNavigationTabs().exists()).toBe(false);
});
});
@@ -576,17 +658,17 @@ describe('Pipelines', () => {
wrapper.vm.hasMadeRequest = true;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.shouldRenderTabs).toEqual(false);
+ expect(findNavigationTabs().exists()).toBe(false);
});
});
});
- describe('shouldRenderButtons', () => {
+ describe('displays buttons', () => {
it('returns true when it has paths & has made the first request', () => {
wrapper.vm.hasMadeRequest = true;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.shouldRenderButtons).toEqual(true);
+ expect(findNavigationControls().exists()).toBe(true);
});
});
@@ -594,77 +676,12 @@ describe('Pipelines', () => {
wrapper.vm.hasMadeRequest = false;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.shouldRenderButtons).toEqual(false);
+ expect(findNavigationControls().exists()).toBe(false);
});
});
});
});
- describe('updates results when a staged is clicked', () => {
- beforeEach(() => {
- const copyPipeline = { ...pipelineWithStages };
- copyPipeline.id += 1;
- mock
- .onGet('twitter/flight/pipelines.json')
- .reply(
- 200,
- {
- pipelines: [pipelineWithStages],
- count: {
- all: 1,
- finished: 1,
- pending: 0,
- running: 0,
- },
- },
- {
- 'POLL-INTERVAL': 100,
- },
- )
- .onGet(pipelineWithStages.details.stages[0].dropdown_path)
- .reply(200, stageReply);
-
- createComponent();
- });
-
- describe('when a request is being made', () => {
- it('stops polling, cancels the request, & restarts polling', () => {
- const stopMock = jest.spyOn(wrapper.vm.poll, 'stop');
- const restartMock = jest.spyOn(wrapper.vm.poll, 'restart');
- const cancelMock = jest.spyOn(wrapper.vm.service.cancelationSource, 'cancel');
- mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines);
-
- return waitForPromises()
- .then(() => {
- wrapper.vm.isMakingRequest = true;
- wrapper.find('.js-builds-dropdown-button').trigger('click');
- })
- .then(() => {
- expect(cancelMock).toHaveBeenCalled();
- expect(stopMock).toHaveBeenCalled();
- expect(restartMock).toHaveBeenCalled();
- });
- });
- });
-
- describe('when no request is being made', () => {
- it('stops polling & restarts polling', () => {
- const stopMock = jest.spyOn(wrapper.vm.poll, 'stop');
- const restartMock = jest.spyOn(wrapper.vm.poll, 'restart');
- mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines);
-
- return waitForPromises()
- .then(() => {
- wrapper.find('.js-builds-dropdown-button').trigger('click');
- expect(stopMock).toHaveBeenCalled();
- })
- .then(() => {
- expect(restartMock).toHaveBeenCalled();
- });
- });
- });
- });
-
describe('Pipeline filters', () => {
let updateContentMock;
diff --git a/spec/frontend/pipelines/test_reports/mock_data.js b/spec/frontend/pipelines/test_reports/mock_data.js
index 1d03f0b655f..872cb5c87be 100644
--- a/spec/frontend/pipelines/test_reports/mock_data.js
+++ b/spec/frontend/pipelines/test_reports/mock_data.js
@@ -9,4 +9,20 @@ export default [
status: TestStatus.SKIPPED,
system_output: null,
},
+ {
+ classname: 'spec.test_spec',
+ execution_time: 0,
+ name: 'Test#error text',
+ stack_trace: null,
+ status: TestStatus.ERROR,
+ system_output: null,
+ },
+ {
+ classname: 'spec.test_spec',
+ execution_time: 0,
+ name: 'Test#unknown text',
+ stack_trace: null,
+ status: TestStatus.UNKNOWN,
+ system_output: null,
+ },
];
diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
index 2feb6aa5799..af2150be7a0 100644
--- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
@@ -61,18 +61,17 @@ describe('Test reports suite table', () => {
expect(allCaseRows().length).toBe(testCases.length);
});
- it('renders the correct icon for each status', () => {
- const failedTest = testCases.findIndex(x => x.status === TestStatus.FAILED);
- const skippedTest = testCases.findIndex(x => x.status === TestStatus.SKIPPED);
- const successTest = testCases.findIndex(x => x.status === TestStatus.SUCCESS);
+ it.each([
+ TestStatus.ERROR,
+ TestStatus.FAILED,
+ TestStatus.SKIPPED,
+ TestStatus.SUCCESS,
+ 'unknown',
+ ])('renders the correct icon for test case with %s status', status => {
+ const test = testCases.findIndex(x => x.status === status);
+ const row = findCaseRowAtIndex(test);
- const failedRow = findCaseRowAtIndex(failedTest);
- const skippedRow = findCaseRowAtIndex(skippedTest);
- const successRow = findCaseRowAtIndex(successTest);
-
- expect(findIconForRow(failedRow, TestStatus.FAILED).exists()).toBe(true);
- expect(findIconForRow(skippedRow, TestStatus.SKIPPED).exists()).toBe(true);
- expect(findIconForRow(successRow, TestStatus.SUCCESS).exists()).toBe(true);
+ expect(findIconForRow(row, status).exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/project_find_file_spec.js b/spec/frontend/project_find_file_spec.js
index 757a02a04a3..6a50f68a4e9 100644
--- a/spec/frontend/project_find_file_spec.js
+++ b/spec/frontend/project_find_file_spec.js
@@ -1,11 +1,12 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
import { TEST_HOST } from 'helpers/test_constants';
-import { sanitize } from 'dompurify';
+import { sanitize } from '~/lib/dompurify';
import ProjectFindFile from '~/project_find_file';
import axios from '~/lib/utils/axios_utils';
-jest.mock('dompurify', () => ({
+jest.mock('~/lib/dompurify', () => ({
+ addHook: jest.fn(),
sanitize: jest.fn(val => val),
}));
diff --git a/spec/frontend/projects/commit_box/info/load_branches_spec.js b/spec/frontend/projects/commit_box/info/load_branches_spec.js
new file mode 100644
index 00000000000..ebd4ee45dab
--- /dev/null
+++ b/spec/frontend/projects/commit_box/info/load_branches_spec.js
@@ -0,0 +1,68 @@
+import axios from 'axios';
+import waitForPromises from 'helpers/wait_for_promises';
+import MockAdapter from 'axios-mock-adapter';
+import { loadBranches } from '~/projects/commit_box/info/load_branches';
+
+const mockCommitPath = '/commit/abcd/branches';
+const mockBranchesRes =
+ '<a href="/-/commits/master">master</a><span><a href="/-/commits/my-branch">my-branch</a></span>';
+
+describe('~/projects/commit_box/info/load_branches', () => {
+ let mock;
+ let el;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet(mockCommitPath).reply(200, mockBranchesRes);
+
+ el = document.createElement('div');
+ el.dataset.commitPath = mockCommitPath;
+ el.innerHTML = '<div class="commit-info branches"><span class="spinner"/></div>';
+ });
+
+ it('loads and renders branches info', async () => {
+ loadBranches(el);
+ await waitForPromises();
+
+ expect(el.innerHTML).toBe(`<div class="commit-info branches">${mockBranchesRes}</div>`);
+ });
+
+ it('does not load when no container is provided', async () => {
+ loadBranches(null);
+ await waitForPromises();
+
+ expect(mock.history.get).toHaveLength(0);
+ });
+
+ describe('when braches request returns unsafe content', () => {
+ beforeEach(() => {
+ mock
+ .onGet(mockCommitPath)
+ .reply(200, '<a onload="alert(\'xss!\');" href="/-/commits/master">master</a>');
+ });
+
+ it('displays sanitized html', async () => {
+ loadBranches(el);
+ await waitForPromises();
+
+ expect(el.innerHTML).toBe(
+ '<div class="commit-info branches"><a href="/-/commits/master">master</a></div>',
+ );
+ });
+ });
+
+ describe('when braches request fails', () => {
+ beforeEach(() => {
+ mock.onGet(mockCommitPath).reply(500, 'Error!');
+ });
+
+ it('attempts to load and renders an error', async () => {
+ loadBranches(el);
+ await waitForPromises();
+
+ expect(el.innerHTML).toBe(
+ '<div class="commit-info branches">Failed to load branches. Please try again.</div>',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
index 455467e7b29..a0fd6012546 100644
--- a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
+++ b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
@@ -17,6 +17,7 @@ exports[`Project remove modal initialized matches the snapshot 1`] = `
/>
<gl-button-stub
+ buttontextclasses=""
category="primary"
icon=""
role="button"
diff --git a/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap b/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap
index 692b8f6cf52..4630415f61c 100644
--- a/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap
+++ b/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap
@@ -18,6 +18,7 @@ exports[`Project remove modal intialized matches the snapshot 1`] = `
/>
<gl-button-stub
+ buttontextclasses=""
category="primary"
icon=""
role="button"
@@ -84,6 +85,7 @@ exports[`Project remove modal intialized matches the snapshot 1`] = `
<template>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="js-modal-action-cancel"
icon=""
@@ -98,6 +100,7 @@ exports[`Project remove modal intialized matches the snapshot 1`] = `
<!---->
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="js-modal-action-primary"
disabled="true"
diff --git a/spec/frontend/projects/settings/access_dropdown_spec.js b/spec/frontend/projects/settings/access_dropdown_spec.js
index 3b375c5610f..41b9c0c3763 100644
--- a/spec/frontend/projects/settings/access_dropdown_spec.js
+++ b/spec/frontend/projects/settings/access_dropdown_spec.js
@@ -14,6 +14,7 @@ describe('AccessDropdown', () => {
`);
const $dropdown = $('#dummy-dropdown');
$dropdown.data('defaultLabel', defaultLabel);
+ gon.features = { deployKeysOnProtectedBranches: true };
const options = {
$dropdown,
accessLevelsData: {
@@ -37,6 +38,9 @@ describe('AccessDropdown', () => {
{ type: LEVEL_TYPES.GROUP },
{ type: LEVEL_TYPES.GROUP },
{ type: LEVEL_TYPES.GROUP },
+ { type: LEVEL_TYPES.DEPLOY_KEY },
+ { type: LEVEL_TYPES.DEPLOY_KEY },
+ { type: LEVEL_TYPES.DEPLOY_KEY },
];
beforeEach(() => {
@@ -49,7 +53,7 @@ describe('AccessDropdown', () => {
const label = dropdown.toggleLabel();
- expect(label).toBe('1 role, 2 users, 3 groups');
+ expect(label).toBe('1 role, 2 users, 3 deploy keys, 3 groups');
expect($dropdownToggleText).not.toHaveClass('is-default');
});
@@ -122,6 +126,21 @@ describe('AccessDropdown', () => {
expect($dropdownToggleText).not.toHaveClass('is-default');
});
});
+
+ describe('with users and deploy keys', () => {
+ beforeEach(() => {
+ const selectedTypes = [LEVEL_TYPES.DEPLOY_KEY, LEVEL_TYPES.USER];
+ dropdown.setSelectedItems(dummyItems.filter(item => selectedTypes.includes(item.type)));
+ $dropdownToggleText.addClass('is-default');
+ });
+
+ it('displays number of deploy keys', () => {
+ const label = dropdown.toggleLabel();
+
+ expect(label).toBe('2 users, 3 deploy keys');
+ expect($dropdownToggleText).not.toHaveClass('is-default');
+ });
+ });
});
describe('userRowHtml', () => {
diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
index 0f3b699f6b2..62aeb4ddee5 100644
--- a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
+++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
@@ -218,9 +218,7 @@ describe('ServiceDeskRoot', () => {
.$nextTick()
.then(waitForPromises)
.then(() => {
- expect(wrapper.html()).toContain(
- 'An error occurred while saving the template. Please check if the template exists.',
- );
+ expect(wrapper.html()).toContain('An error occured while making the changes:');
});
});
});
diff --git a/spec/frontend/registry/explorer/components/details_page/partial_cleanup_alert_spec.js b/spec/frontend/registry/explorer/components/details_page/partial_cleanup_alert_spec.js
new file mode 100644
index 00000000000..17821d8be31
--- /dev/null
+++ b/spec/frontend/registry/explorer/components/details_page/partial_cleanup_alert_spec.js
@@ -0,0 +1,71 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlAlert, GlSprintf } from '@gitlab/ui';
+import component from '~/registry/explorer/components/details_page/partial_cleanup_alert.vue';
+import { DELETE_ALERT_TITLE, DELETE_ALERT_LINK_TEXT } from '~/registry/explorer/constants';
+
+describe('Partial Cleanup alert', () => {
+ let wrapper;
+
+ const findAlert = () => wrapper.find(GlAlert);
+ const findRunLink = () => wrapper.find('[data-testid="run-link"');
+ const findHelpLink = () => wrapper.find('[data-testid="help-link"');
+
+ const mountComponent = () => {
+ wrapper = shallowMount(component, {
+ stubs: { GlSprintf },
+ propsData: {
+ runCleanupPoliciesHelpPagePath: 'foo',
+ cleanupPoliciesHelpPagePath: 'bar',
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it(`gl-alert has the correct properties`, () => {
+ mountComponent();
+
+ expect(findAlert().props()).toMatchObject({
+ title: DELETE_ALERT_TITLE,
+ variant: 'warning',
+ });
+ });
+
+ it('has the right text', () => {
+ mountComponent();
+
+ expect(wrapper.text()).toMatchInterpolatedText(DELETE_ALERT_LINK_TEXT);
+ });
+
+ it('contains run link', () => {
+ mountComponent();
+
+ const link = findRunLink();
+ expect(link.exists()).toBe(true);
+ expect(link.attributes()).toMatchObject({
+ href: 'foo',
+ target: '_blank',
+ });
+ });
+
+ it('contains help link', () => {
+ mountComponent();
+
+ const link = findHelpLink();
+ expect(link.exists()).toBe(true);
+ expect(link.attributes()).toMatchObject({
+ href: 'bar',
+ target: '_blank',
+ });
+ });
+
+ it('GlAlert dismiss event triggers a dismiss event', () => {
+ mountComponent();
+
+ findAlert().vm.$emit('dismiss');
+ expect(wrapper.emitted('dismiss')).toEqual([[]]);
+ });
+});
diff --git a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js
index 7a27f8fa431..3c997093d46 100644
--- a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js
+++ b/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlSprintf, GlLink } from '@gitlab/ui';
+import { GlSprintf } from '@gitlab/ui';
import Component from '~/registry/explorer/components/list_page/registry_header.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import {
@@ -19,12 +19,8 @@ describe('registry_header', () => {
const findTitleArea = () => wrapper.find(TitleArea);
const findCommandsSlot = () => wrapper.find('[data-testid="commands-slot"]');
- const findInfoArea = () => wrapper.find('[data-testid="info-area"]');
- const findIntroText = () => wrapper.find('[data-testid="default-intro"]');
const findImagesCountSubHeader = () => wrapper.find('[data-testid="images-count"]');
const findExpirationPolicySubHeader = () => wrapper.find('[data-testid="expiration-policy"]');
- const findDisabledExpirationPolicyMessage = () =>
- wrapper.find('[data-testid="expiration-disabled-message"]');
const mountComponent = (propsData, slots) => {
wrapper = shallowMount(Component, {
@@ -123,44 +119,18 @@ describe('registry_header', () => {
});
});
- describe('info area', () => {
- it('exists', () => {
- mountComponent();
-
- expect(findInfoArea().exists()).toBe(true);
- });
-
+ describe('info messages', () => {
describe('default message', () => {
- beforeEach(() => {
- return mountComponent({ helpPagePath: 'bar' });
- });
-
- it('exists', () => {
- expect(findIntroText().exists()).toBe(true);
- });
-
- it('has the correct copy', () => {
- expect(findIntroText().text()).toMatchInterpolatedText(LIST_INTRO_TEXT);
- });
+ it('is correctly bound to title_area props', () => {
+ mountComponent({ helpPagePath: 'foo' });
- it('has the correct link', () => {
- expect(
- findIntroText()
- .find(GlLink)
- .attributes('href'),
- ).toBe('bar');
+ expect(findTitleArea().props('infoMessages')).toEqual([
+ { text: LIST_INTRO_TEXT, link: 'foo' },
+ ]);
});
});
describe('expiration policy info message', () => {
- describe('when there are no images', () => {
- it('is hidden', () => {
- mountComponent();
-
- expect(findDisabledExpirationPolicyMessage().exists()).toBe(false);
- });
- });
-
describe('when there are images', () => {
describe('when expiration policy is disabled', () => {
beforeEach(() => {
@@ -170,43 +140,27 @@ describe('registry_header', () => {
imagesCount: 1,
});
});
- it('message exist', () => {
- expect(findDisabledExpirationPolicyMessage().exists()).toBe(true);
- });
- it('has the correct copy', () => {
- expect(findDisabledExpirationPolicyMessage().text()).toMatchInterpolatedText(
- EXPIRATION_POLICY_DISABLED_MESSAGE,
- );
- });
- it('has the correct link', () => {
- expect(
- findDisabledExpirationPolicyMessage()
- .find(GlLink)
- .attributes('href'),
- ).toBe('foo');
+ it('the prop is correctly bound', () => {
+ expect(findTitleArea().props('infoMessages')).toEqual([
+ { text: LIST_INTRO_TEXT, link: '' },
+ { text: EXPIRATION_POLICY_DISABLED_MESSAGE, link: 'foo' },
+ ]);
});
});
- describe('when expiration policy is enabled', () => {
+ describe.each`
+ desc | props
+ ${'when there are no images'} | ${{ expirationPolicy: { enabled: false }, imagesCount: 0 }}
+ ${'when expiration policy is enabled'} | ${{ expirationPolicy: { enabled: true }, imagesCount: 1 }}
+ ${'when the expiration policy is completely disabled'} | ${{ expirationPolicy: { enabled: false }, imagesCount: 1, hideExpirationPolicyData: true }}
+ `('$desc', ({ props }) => {
it('message does not exist', () => {
- mountComponent({
- expirationPolicy: { enabled: true },
- imagesCount: 1,
- });
-
- expect(findDisabledExpirationPolicyMessage().exists()).toBe(false);
- });
- });
- describe('when the expiration policy is completely disabled', () => {
- it('message does not exist', () => {
- mountComponent({
- expirationPolicy: { enabled: true },
- imagesCount: 1,
- hideExpirationPolicyData: true,
- });
+ mountComponent(props);
- expect(findDisabledExpirationPolicyMessage().exists()).toBe(false);
+ expect(findTitleArea().props('infoMessages')).toEqual([
+ { text: LIST_INTRO_TEXT, link: '' },
+ ]);
});
});
});
diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js
index 66e8a4aea0d..86b52c4f06a 100644
--- a/spec/frontend/registry/explorer/pages/details_spec.js
+++ b/spec/frontend/registry/explorer/pages/details_spec.js
@@ -3,6 +3,7 @@ import { GlPagination } from '@gitlab/ui';
import Tracking from '~/tracking';
import component from '~/registry/explorer/pages/details.vue';
import DeleteAlert from '~/registry/explorer/components/details_page/delete_alert.vue';
+import PartialCleanupAlert from '~/registry/explorer/components/details_page/partial_cleanup_alert.vue';
import DetailsHeader from '~/registry/explorer/components/details_page/details_header.vue';
import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue';
import TagsList from '~/registry/explorer/components/details_page/tags_list.vue';
@@ -30,8 +31,10 @@ describe('Details Page', () => {
const findDeleteAlert = () => wrapper.find(DeleteAlert);
const findDetailsHeader = () => wrapper.find(DetailsHeader);
const findEmptyTagsState = () => wrapper.find(EmptyTagsState);
+ const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert);
- const routeId = window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar' }));
+ const routeIdGenerator = override =>
+ window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar', ...override }));
const tagsArrayToSelectedTags = tags =>
tags.reduce((acc, c) => {
@@ -39,7 +42,7 @@ describe('Details Page', () => {
return acc;
}, {});
- const mountComponent = options => {
+ const mountComponent = ({ options, routeParams } = {}) => {
wrapper = shallowMount(component, {
store,
stubs: {
@@ -48,7 +51,7 @@ describe('Details Page', () => {
mocks: {
$route: {
params: {
- id: routeId,
+ id: routeIdGenerator(routeParams),
},
},
},
@@ -224,7 +227,7 @@ describe('Details Page', () => {
findDeleteModal().vm.$emit('confirmDelete');
expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTag', {
tag: store.state.tags[0],
- params: routeId,
+ params: routeIdGenerator(),
});
});
});
@@ -239,7 +242,7 @@ describe('Details Page', () => {
findDeleteModal().vm.$emit('confirmDelete');
expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTags', {
ids: store.state.tags.map(t => t.name),
- params: routeId,
+ params: routeIdGenerator(),
});
});
});
@@ -273,11 +276,57 @@ describe('Details Page', () => {
it('has the correct props', () => {
store.commit(SET_INITIAL_STATE, { ...config });
mountComponent({
- data: () => ({
- deleteAlertType,
- }),
+ options: {
+ data: () => ({
+ deleteAlertType,
+ }),
+ },
});
expect(findDeleteAlert().props()).toEqual({ ...config, deleteAlertType });
});
});
+
+ describe('Partial Cleanup Alert', () => {
+ const config = {
+ runCleanupPoliciesHelpPagePath: 'foo',
+ cleanupPoliciesHelpPagePath: 'bar',
+ };
+
+ describe('when expiration_policy_started is not null', () => {
+ const routeParams = { cleanup_policy_started_at: Date.now().toString() };
+
+ it('exists', () => {
+ mountComponent({ routeParams });
+
+ expect(findPartialCleanupAlert().exists()).toBe(true);
+ });
+
+ it('has the correct props', () => {
+ store.commit(SET_INITIAL_STATE, { ...config });
+
+ mountComponent({ routeParams });
+
+ expect(findPartialCleanupAlert().props()).toEqual({ ...config });
+ });
+
+ it('dismiss hides the component', async () => {
+ mountComponent({ routeParams });
+
+ expect(findPartialCleanupAlert().exists()).toBe(true);
+ findPartialCleanupAlert().vm.$emit('dismiss');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findPartialCleanupAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('when expiration_policy_started is null', () => {
+ it('the component is hidden', () => {
+ mountComponent();
+
+ expect(findPartialCleanupAlert().exists()).toBe(false);
+ });
+ });
+ });
});
diff --git a/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap b/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap
deleted file mode 100644
index 11393c89d06..00000000000
--- a/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap
+++ /dev/null
@@ -1,7 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Registry Settings App renders 1`] = `
-<div>
- <settings-form-stub />
-</div>
-`;
diff --git a/spec/frontend/registry/settings/components/registry_settings_app_spec.js b/spec/frontend/registry/settings/components/registry_settings_app_spec.js
index 9551ee72e51..01d6852e1e5 100644
--- a/spec/frontend/registry/settings/components/registry_settings_app_spec.js
+++ b/spec/frontend/registry/settings/components/registry_settings_app_spec.js
@@ -1,28 +1,35 @@
-import { shallowMount } from '@vue/test-utils';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import createMockApollo from 'jest/helpers/mock_apollo_helper';
import component from '~/registry/settings/components/registry_settings_app.vue';
+import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.graphql';
import SettingsForm from '~/registry/settings/components/settings_form.vue';
-import { createStore } from '~/registry/settings/store/';
-import { SET_SETTINGS, SET_INITIAL_STATE } from '~/registry/settings/store/mutation_types';
import { FETCH_SETTINGS_ERROR_MESSAGE } from '~/registry/shared/constants';
import {
UNAVAILABLE_FEATURE_INTRO_TEXT,
UNAVAILABLE_USER_FEATURE_TEXT,
} from '~/registry/settings/constants';
-import { stringifiedFormOptions } from '../../shared/mock_data';
+import { expirationPolicyPayload } from '../mock_data';
+
+const localVue = createLocalVue();
describe('Registry Settings App', () => {
let wrapper;
- let store;
+ let fakeApollo;
+
+ const defaultProvidedValues = {
+ projectPath: 'path',
+ isAdmin: false,
+ adminSettingsPath: 'settingsPath',
+ enableHistoricEntries: false,
+ };
const findSettingsComponent = () => wrapper.find(SettingsForm);
const findAlert = () => wrapper.find(GlAlert);
- const mountComponent = ({ dispatchMock = 'mockResolvedValue' } = {}) => {
- const dispatchSpy = jest.spyOn(store, 'dispatch');
- dispatchSpy[dispatchMock]();
-
+ const mountComponent = (provide = defaultProvidedValues, config) => {
wrapper = shallowMount(component, {
stubs: {
GlSprintf,
@@ -32,71 +39,72 @@ describe('Registry Settings App', () => {
show: jest.fn(),
},
},
- store,
+ provide,
+ ...config,
});
};
- beforeEach(() => {
- store = createStore();
- });
+ const mountComponentWithApollo = ({ provide = defaultProvidedValues, resolver } = {}) => {
+ localVue.use(VueApollo);
+
+ const requestHandlers = [[expirationPolicyQuery, resolver]];
+
+ fakeApollo = createMockApollo(requestHandlers);
+ mountComponent(provide, {
+ localVue,
+ apolloProvider: fakeApollo,
+ });
+
+ return requestHandlers.map(request => request[1]);
+ };
afterEach(() => {
wrapper.destroy();
});
- it('renders', () => {
- store.commit(SET_SETTINGS, { foo: 'bar' });
- mountComponent();
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('call the store function to load the data on mount', () => {
- mountComponent();
- expect(store.dispatch).toHaveBeenCalledWith('fetchSettings');
- });
+ it('renders the setting form', async () => {
+ const requests = mountComponentWithApollo({
+ resolver: jest.fn().mockResolvedValue(expirationPolicyPayload()),
+ });
+ await Promise.all(requests);
- it('renders the setting form', () => {
- store.commit(SET_SETTINGS, { foo: 'bar' });
- mountComponent();
expect(findSettingsComponent().exists()).toBe(true);
});
describe('the form is disabled', () => {
- beforeEach(() => {
- store.commit(SET_SETTINGS, undefined);
+ it('the form is hidden', () => {
mountComponent();
- });
- it('the form is hidden', () => {
expect(findSettingsComponent().exists()).toBe(false);
});
it('shows an alert', () => {
+ mountComponent();
+
const text = findAlert().text();
expect(text).toContain(UNAVAILABLE_FEATURE_INTRO_TEXT);
expect(text).toContain(UNAVAILABLE_USER_FEATURE_TEXT);
});
describe('an admin is visiting the page', () => {
- beforeEach(() => {
- store.commit(SET_INITIAL_STATE, {
- ...stringifiedFormOptions,
- isAdmin: true,
- adminSettingsPath: 'foo',
- });
- });
-
it('shows the admin part of the alert message', () => {
+ mountComponent({ ...defaultProvidedValues, isAdmin: true });
+
const sprintf = findAlert().find(GlSprintf);
expect(sprintf.text()).toBe('administration settings');
- expect(sprintf.find(GlLink).attributes('href')).toBe('foo');
+ expect(sprintf.find(GlLink).attributes('href')).toBe(
+ defaultProvidedValues.adminSettingsPath,
+ );
});
});
});
describe('fetchSettingsError', () => {
beforeEach(() => {
- mountComponent({ dispatchMock: 'mockRejectedValue' });
+ const requests = mountComponentWithApollo({
+ resolver: jest.fn().mockRejectedValue(new Error('GraphQL error')),
+ });
+ return Promise.all(requests);
});
it('the form is hidden', () => {
diff --git a/spec/frontend/registry/settings/components/settings_form_spec.js b/spec/frontend/registry/settings/components/settings_form_spec.js
index 6f9518808db..77fd71a22fc 100644
--- a/spec/frontend/registry/settings/components/settings_form_spec.js
+++ b/spec/frontend/registry/settings/components/settings_form_spec.js
@@ -1,30 +1,37 @@
-import { shallowMount } from '@vue/test-utils';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'jest/helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import Tracking from '~/tracking';
import component from '~/registry/settings/components/settings_form.vue';
import expirationPolicyFields from '~/registry/shared/components/expiration_policy_fields.vue';
-import { createStore } from '~/registry/settings/store/';
+import updateContainerExpirationPolicyMutation from '~/registry/settings/graphql/mutations/update_container_expiration_policy.graphql';
+import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.graphql';
import {
UPDATE_SETTINGS_ERROR_MESSAGE,
UPDATE_SETTINGS_SUCCESS_MESSAGE,
} from '~/registry/shared/constants';
-import { stringifiedFormOptions } from '../../shared/mock_data';
+import { GlCard, GlLoadingIcon } from '../../shared/stubs';
+import { expirationPolicyPayload, expirationPolicyMutationPayload } from '../mock_data';
+
+const localVue = createLocalVue();
describe('Settings Form', () => {
let wrapper;
- let store;
- let dispatchSpy;
-
- const GlLoadingIcon = { name: 'gl-loading-icon-stub', template: '<svg></svg>' };
- const GlCard = {
- name: 'gl-card-stub',
- template: `
- <div>
- <slot name="header"></slot>
- <slot></slot>
- <slot name="footer"></slot>
- </div>
- `,
+ let fakeApollo;
+
+ const defaultProvidedValues = {
+ projectPath: 'path',
+ };
+
+ const {
+ data: {
+ project: { containerExpirationPolicy },
+ },
+ } = expirationPolicyPayload();
+
+ const defaultProps = {
+ value: { ...containerExpirationPolicy },
};
const trackingPayload = {
@@ -35,14 +42,21 @@ describe('Settings Form', () => {
const findFields = () => wrapper.find(expirationPolicyFields);
const findCancelButton = () => wrapper.find({ ref: 'cancel-button' });
const findSaveButton = () => wrapper.find({ ref: 'save-button' });
- const findLoadingIcon = (parent = wrapper) => parent.find(GlLoadingIcon);
- const mountComponent = (data = {}) => {
+ const mountComponent = ({
+ props = defaultProps,
+ data,
+ config,
+ provide = defaultProvidedValues,
+ mocks,
+ } = {}) => {
wrapper = shallowMount(component, {
stubs: {
GlCard,
GlLoadingIcon,
},
+ propsData: { ...props },
+ provide,
data() {
return {
...data,
@@ -52,15 +66,42 @@ describe('Settings Form', () => {
$toast: {
show: jest.fn(),
},
+ ...mocks,
},
- store,
+ ...config,
});
};
+ const mountComponentWithApollo = ({ provide = defaultProvidedValues, resolver } = {}) => {
+ localVue.use(VueApollo);
+
+ const requestHandlers = [
+ [updateContainerExpirationPolicyMutation, resolver],
+ [expirationPolicyQuery, jest.fn().mockResolvedValue(expirationPolicyPayload())],
+ ];
+
+ fakeApollo = createMockApollo(requestHandlers);
+
+ fakeApollo.defaultClient.cache.writeQuery({
+ query: expirationPolicyQuery,
+ variables: {
+ projectPath: provide.projectPath,
+ },
+ ...expirationPolicyPayload(),
+ });
+
+ mountComponent({
+ provide,
+ config: {
+ localVue,
+ apolloProvider: fakeApollo,
+ },
+ });
+
+ return requestHandlers.map(resolvers => resolvers[1]);
+ };
+
beforeEach(() => {
- store = createStore();
- store.dispatch('setInitialState', stringifiedFormOptions);
- dispatchSpy = jest.spyOn(store, 'dispatch');
jest.spyOn(Tracking, 'event');
});
@@ -72,12 +113,12 @@ describe('Settings Form', () => {
it('v-model change update the settings property', () => {
mountComponent();
findFields().vm.$emit('input', { newValue: 'foo' });
- expect(dispatchSpy).toHaveBeenCalledWith('updateSettings', { settings: 'foo' });
+ expect(wrapper.emitted('input')).toEqual([['foo']]);
});
it('v-model change update the api error property', () => {
const apiErrors = { baz: 'bar' };
- mountComponent({ apiErrors });
+ mountComponent({ data: { apiErrors } });
expect(findFields().props('apiErrors')).toEqual(apiErrors);
findFields().vm.$emit('input', { newValue: 'foo', modified: 'baz' });
expect(findFields().props('apiErrors')).toEqual({});
@@ -85,19 +126,14 @@ describe('Settings Form', () => {
});
describe('form', () => {
- let form;
- beforeEach(() => {
- mountComponent();
- form = findForm();
- dispatchSpy.mockReturnValue();
- });
-
describe('form reset event', () => {
beforeEach(() => {
- form.trigger('reset');
+ mountComponent();
+
+ findForm().trigger('reset');
});
it('calls the appropriate function', () => {
- expect(dispatchSpy).toHaveBeenCalledWith('resetSettings');
+ expect(wrapper.emitted('reset')).toEqual([[]]);
});
it('tracks the reset event', () => {
@@ -108,54 +144,96 @@ describe('Settings Form', () => {
describe('form submit event ', () => {
it('save has type submit', () => {
mountComponent();
+
expect(findSaveButton().attributes('type')).toBe('submit');
});
- it('dispatches the saveSettings action', () => {
- dispatchSpy.mockResolvedValue();
- form.trigger('submit');
- expect(dispatchSpy).toHaveBeenCalledWith('saveSettings');
+ it('dispatches the correct apollo mutation', async () => {
+ const [expirationPolicyMutationResolver] = mountComponentWithApollo({
+ resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()),
+ });
+
+ findForm().trigger('submit');
+ await expirationPolicyMutationResolver();
+ expect(expirationPolicyMutationResolver).toHaveBeenCalled();
});
it('tracks the submit event', () => {
- dispatchSpy.mockResolvedValue();
- form.trigger('submit');
+ mountComponentWithApollo({
+ resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()),
+ });
+
+ findForm().trigger('submit');
+
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'submit_form', trackingPayload);
});
it('show a success toast when submit succeed', async () => {
- dispatchSpy.mockResolvedValue();
- form.trigger('submit');
- await waitForPromises();
+ const handlers = mountComponentWithApollo({
+ resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()),
+ });
+
+ findForm().trigger('submit');
+ await Promise.all(handlers);
+ await wrapper.vm.$nextTick();
+
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE, {
type: 'success',
});
});
describe('when submit fails', () => {
- it('shows an error', async () => {
- dispatchSpy.mockRejectedValue({ response: {} });
- form.trigger('submit');
- await waitForPromises();
- expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE, {
- type: 'error',
+ describe('user recoverable errors', () => {
+ it('when there is an error is shown in a toast', async () => {
+ const handlers = mountComponentWithApollo({
+ resolver: jest
+ .fn()
+ .mockResolvedValue(expirationPolicyMutationPayload({ errors: ['foo'] })),
+ });
+
+ findForm().trigger('submit');
+ await Promise.all(handlers);
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('foo', {
+ type: 'error',
+ });
});
});
+ describe('global errors', () => {
+ it('shows an error', async () => {
+ const handlers = mountComponentWithApollo({
+ resolver: jest.fn().mockRejectedValue(expirationPolicyMutationPayload()),
+ });
+
+ findForm().trigger('submit');
+ await Promise.all(handlers);
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
- it('parses the error messages', async () => {
- dispatchSpy.mockRejectedValue({
- response: {
- data: {
- message: {
- foo: 'bar',
- 'container_expiration_policy.name': ['baz'],
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE, {
+ type: 'error',
+ });
+ });
+
+ it('parses the error messages', async () => {
+ const mutate = jest.fn().mockRejectedValue({
+ graphQLErrors: [
+ {
+ extensions: {
+ problems: [{ path: ['name'], message: 'baz' }],
+ },
},
- },
- },
+ ],
+ });
+ mountComponent({ mocks: { $apollo: { mutate } } });
+
+ findForm().trigger('submit');
+ await waitForPromises();
+ await wrapper.vm.$nextTick();
+
+ expect(findFields().props('apiErrors')).toEqual({ name: 'baz' });
});
- form.trigger('submit');
- await waitForPromises();
- expect(findFields().props('apiErrors')).toEqual({ name: 'baz' });
});
});
});
@@ -163,51 +241,78 @@ describe('Settings Form', () => {
describe('form actions', () => {
describe('cancel button', () => {
- beforeEach(() => {
- store.commit('SET_SETTINGS', { foo: 'bar' });
+ it('has type reset', () => {
mountComponent();
- });
- it('has type reset', () => {
expect(findCancelButton().attributes('type')).toBe('reset');
});
- it('is disabled when isEdited is false', () =>
- wrapper.vm.$nextTick().then(() => {
- expect(findCancelButton().attributes('disabled')).toBe('true');
- }));
-
- it('is disabled isLoading is true', () => {
- store.commit('TOGGLE_LOADING');
- store.commit('UPDATE_SETTINGS', { settings: { foo: 'baz' } });
- return wrapper.vm.$nextTick().then(() => {
- expect(findCancelButton().attributes('disabled')).toBe('true');
- store.commit('TOGGLE_LOADING');
- });
- });
+ it.each`
+ isLoading | isEdited | mutationLoading | isDisabled
+ ${true} | ${true} | ${true} | ${true}
+ ${false} | ${true} | ${true} | ${true}
+ ${false} | ${false} | ${true} | ${true}
+ ${true} | ${false} | ${false} | ${true}
+ ${false} | ${false} | ${false} | ${true}
+ ${false} | ${true} | ${false} | ${false}
+ `(
+ 'when isLoading is $isLoading and isEdited is $isEdited and mutationLoading is $mutationLoading is $isDisabled that the is disabled',
+ ({ isEdited, isLoading, mutationLoading, isDisabled }) => {
+ mountComponent({
+ props: { ...defaultProps, isEdited, isLoading },
+ data: { mutationLoading },
+ });
- it('is enabled when isLoading is false and isEdited is true', () => {
- store.commit('UPDATE_SETTINGS', { settings: { foo: 'baz' } });
- return wrapper.vm.$nextTick().then(() => {
- expect(findCancelButton().attributes('disabled')).toBe(undefined);
- });
- });
+ const expectation = isDisabled ? 'true' : undefined;
+ expect(findCancelButton().attributes('disabled')).toBe(expectation);
+ },
+ );
});
- describe('when isLoading is true', () => {
- beforeEach(() => {
- store.commit('TOGGLE_LOADING');
+ describe('submit button', () => {
+ it('has type submit', () => {
mountComponent();
- });
- afterEach(() => {
- store.commit('TOGGLE_LOADING');
- });
- it('submit button is disabled and shows a spinner', () => {
- const button = findSaveButton();
- expect(button.attributes('disabled')).toBeTruthy();
- expect(findLoadingIcon(button).exists()).toBe(true);
+ expect(findSaveButton().attributes('type')).toBe('submit');
});
+ it.each`
+ isLoading | fieldsAreValid | mutationLoading | isDisabled
+ ${true} | ${true} | ${true} | ${true}
+ ${false} | ${true} | ${true} | ${true}
+ ${false} | ${false} | ${true} | ${true}
+ ${true} | ${false} | ${false} | ${true}
+ ${false} | ${false} | ${false} | ${true}
+ ${false} | ${true} | ${false} | ${false}
+ `(
+ 'when isLoading is $isLoading and fieldsAreValid is $fieldsAreValid and mutationLoading is $mutationLoading is $isDisabled that the is disabled',
+ ({ fieldsAreValid, isLoading, mutationLoading, isDisabled }) => {
+ mountComponent({
+ props: { ...defaultProps, isLoading },
+ data: { mutationLoading, fieldsAreValid },
+ });
+
+ const expectation = isDisabled ? 'true' : undefined;
+ expect(findSaveButton().attributes('disabled')).toBe(expectation);
+ },
+ );
+
+ it.each`
+ isLoading | mutationLoading | showLoading
+ ${true} | ${true} | ${true}
+ ${true} | ${false} | ${true}
+ ${false} | ${true} | ${true}
+ ${false} | ${false} | ${false}
+ `(
+ 'when isLoading is $isLoading and mutationLoading is $mutationLoading is $showLoading that the loading icon is shown',
+ ({ isLoading, mutationLoading, showLoading }) => {
+ mountComponent({
+ props: { ...defaultProps, isLoading },
+ data: { mutationLoading },
+ });
+
+ expect(findSaveButton().props('loading')).toBe(showLoading);
+ },
+ );
});
});
});
diff --git a/spec/frontend/registry/settings/graphql/cache_updated_spec.js b/spec/frontend/registry/settings/graphql/cache_updated_spec.js
new file mode 100644
index 00000000000..e5f69a08285
--- /dev/null
+++ b/spec/frontend/registry/settings/graphql/cache_updated_spec.js
@@ -0,0 +1,56 @@
+import { updateContainerExpirationPolicy } from '~/registry/settings/graphql/utils/cache_update';
+import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.graphql';
+
+describe('Registry settings cache update', () => {
+ let client;
+
+ const payload = {
+ data: {
+ updateContainerExpirationPolicy: {
+ containerExpirationPolicy: {
+ enabled: true,
+ },
+ },
+ },
+ };
+
+ const cacheMock = {
+ project: {
+ containerExpirationPolicy: {
+ enabled: false,
+ },
+ },
+ };
+
+ const queryAndVariables = {
+ query: expirationPolicyQuery,
+ variables: { projectPath: 'foo' },
+ };
+
+ beforeEach(() => {
+ client = {
+ readQuery: jest.fn().mockReturnValue(cacheMock),
+ writeQuery: jest.fn(),
+ };
+ });
+ describe('Registry settings cache update', () => {
+ it('calls readQuery', () => {
+ updateContainerExpirationPolicy('foo')(client, payload);
+ expect(client.readQuery).toHaveBeenCalledWith(queryAndVariables);
+ });
+
+ it('writes the correct result in the cache', () => {
+ updateContainerExpirationPolicy('foo')(client, payload);
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ ...queryAndVariables,
+ data: {
+ project: {
+ containerExpirationPolicy: {
+ enabled: true,
+ },
+ },
+ },
+ });
+ });
+ });
+});
diff --git a/spec/frontend/registry/settings/mock_data.js b/spec/frontend/registry/settings/mock_data.js
new file mode 100644
index 00000000000..6a936785b7c
--- /dev/null
+++ b/spec/frontend/registry/settings/mock_data.js
@@ -0,0 +1,32 @@
+export const expirationPolicyPayload = override => ({
+ data: {
+ project: {
+ containerExpirationPolicy: {
+ cadence: 'EVERY_DAY',
+ enabled: true,
+ keepN: 'TEN_TAGS',
+ nameRegex: 'asdasdssssdfdf',
+ nameRegexKeep: 'sss',
+ olderThan: 'FOURTEEN_DAYS',
+ ...override,
+ },
+ },
+ },
+});
+
+export const expirationPolicyMutationPayload = ({ override, errors = [] } = {}) => ({
+ data: {
+ updateContainerExpirationPolicy: {
+ containerExpirationPolicy: {
+ cadence: 'EVERY_DAY',
+ enabled: true,
+ keepN: 'TEN_TAGS',
+ nameRegex: 'asdasdssssdfdf',
+ nameRegexKeep: 'sss',
+ olderThan: 'FOURTEEN_DAYS',
+ ...override,
+ },
+ errors,
+ },
+ },
+});
diff --git a/spec/frontend/registry/settings/store/actions_spec.js b/spec/frontend/registry/settings/store/actions_spec.js
deleted file mode 100644
index 51b89f96ef2..00000000000
--- a/spec/frontend/registry/settings/store/actions_spec.js
+++ /dev/null
@@ -1,90 +0,0 @@
-import testAction from 'helpers/vuex_action_helper';
-import Api from '~/api';
-import * as actions from '~/registry/settings/store/actions';
-import * as types from '~/registry/settings/store/mutation_types';
-
-describe('Actions Registry Store', () => {
- describe.each`
- actionName | mutationName | payload
- ${'setInitialState'} | ${types.SET_INITIAL_STATE} | ${'foo'}
- ${'updateSettings'} | ${types.UPDATE_SETTINGS} | ${'foo'}
- ${'toggleLoading'} | ${types.TOGGLE_LOADING} | ${undefined}
- ${'resetSettings'} | ${types.RESET_SETTINGS} | ${undefined}
- `(
- '$actionName invokes $mutationName with payload $payload',
- ({ actionName, mutationName, payload }) => {
- it('should set state', done => {
- testAction(actions[actionName], payload, {}, [{ type: mutationName, payload }], [], done);
- });
- },
- );
-
- describe('receiveSettingsSuccess', () => {
- it('calls SET_SETTINGS', () => {
- testAction(
- actions.receiveSettingsSuccess,
- 'foo',
- {},
- [{ type: types.SET_SETTINGS, payload: 'foo' }],
- [],
- );
- });
- });
-
- describe('fetchSettings', () => {
- const state = {
- projectId: 'bar',
- };
-
- const payload = {
- data: {
- container_expiration_policy: 'foo',
- },
- };
-
- it('should fetch the data from the API', done => {
- Api.project = jest.fn().mockResolvedValue(payload);
- testAction(
- actions.fetchSettings,
- null,
- state,
- [],
- [
- { type: 'toggleLoading' },
- { type: 'receiveSettingsSuccess', payload: payload.data.container_expiration_policy },
- { type: 'toggleLoading' },
- ],
- done,
- );
- });
- });
-
- describe('saveSettings', () => {
- const state = {
- projectId: 'bar',
- settings: 'baz',
- };
-
- const payload = {
- data: {
- tag_expiration_policies: 'foo',
- },
- };
-
- it('should fetch the data from the API', done => {
- Api.updateProject = jest.fn().mockResolvedValue(payload);
- testAction(
- actions.saveSettings,
- null,
- state,
- [],
- [
- { type: 'toggleLoading' },
- { type: 'receiveSettingsSuccess', payload: payload.data.container_expiration_policy },
- { type: 'toggleLoading' },
- ],
- done,
- );
- });
- });
-});
diff --git a/spec/frontend/registry/settings/store/getters_spec.js b/spec/frontend/registry/settings/store/getters_spec.js
deleted file mode 100644
index b781d09466c..00000000000
--- a/spec/frontend/registry/settings/store/getters_spec.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import * as getters from '~/registry/settings/store/getters';
-import * as utils from '~/registry/shared/utils';
-import { formOptions } from '../../shared/mock_data';
-
-describe('Getters registry settings store', () => {
- const settings = {
- enabled: true,
- cadence: 'foo',
- keep_n: 'bar',
- older_than: 'baz',
- name_regex: 'name-foo',
- name_regex_keep: 'name-keep-bar',
- };
-
- describe.each`
- getter | variable | formOption
- ${'getCadence'} | ${'cadence'} | ${'cadence'}
- ${'getKeepN'} | ${'keep_n'} | ${'keepN'}
- ${'getOlderThan'} | ${'older_than'} | ${'olderThan'}
- `('Options getter', ({ getter, variable, formOption }) => {
- beforeEach(() => {
- utils.findDefaultOption = jest.fn();
- });
-
- it(`${getter} returns ${variable} when ${variable} exists in settings`, () => {
- expect(getters[getter]({ settings })).toBe(settings[variable]);
- });
-
- it(`${getter} calls findDefaultOption when ${variable} does not exists in settings`, () => {
- getters[getter]({ settings: {}, formOptions });
- expect(utils.findDefaultOption).toHaveBeenCalledWith(formOptions[formOption]);
- });
- });
-
- describe('getSettings', () => {
- it('returns the content of settings', () => {
- const computedGetters = {
- getCadence: settings.cadence,
- getOlderThan: settings.older_than,
- getKeepN: settings.keep_n,
- };
- expect(getters.getSettings({ settings }, computedGetters)).toEqual(settings);
- });
- });
-
- describe('getIsEdited', () => {
- it('returns false when original is equal to settings', () => {
- const same = { foo: 'bar' };
- expect(getters.getIsEdited({ original: same, settings: same })).toBe(false);
- });
-
- it('returns true when original is different from settings', () => {
- expect(getters.getIsEdited({ original: { foo: 'bar' }, settings: { foo: 'baz' } })).toBe(
- true,
- );
- });
- });
-
- describe('getIsDisabled', () => {
- it.each`
- original | enableHistoricEntries | result
- ${undefined} | ${false} | ${true}
- ${{ foo: 'bar' }} | ${undefined} | ${false}
- ${{}} | ${false} | ${false}
- `(
- 'returns $result when original is $original and enableHistoricEntries is $enableHistoricEntries',
- ({ original, enableHistoricEntries, result }) => {
- expect(getters.getIsDisabled({ original, enableHistoricEntries })).toBe(result);
- },
- );
- });
-});
diff --git a/spec/frontend/registry/settings/store/mutations_spec.js b/spec/frontend/registry/settings/store/mutations_spec.js
deleted file mode 100644
index 1d85e38eb36..00000000000
--- a/spec/frontend/registry/settings/store/mutations_spec.js
+++ /dev/null
@@ -1,80 +0,0 @@
-import mutations from '~/registry/settings/store/mutations';
-import * as types from '~/registry/settings/store/mutation_types';
-import createState from '~/registry/settings/store/state';
-import { formOptions, stringifiedFormOptions } from '../../shared/mock_data';
-
-describe('Mutations Registry Store', () => {
- let mockState;
-
- beforeEach(() => {
- mockState = createState();
- });
-
- describe('SET_INITIAL_STATE', () => {
- it('should set the initial state', () => {
- const payload = {
- projectId: 'foo',
- enableHistoricEntries: false,
- adminSettingsPath: 'foo',
- isAdmin: true,
- };
- const expectedState = { ...mockState, ...payload, formOptions };
- mutations[types.SET_INITIAL_STATE](mockState, {
- ...payload,
- ...stringifiedFormOptions,
- });
-
- expect(mockState).toEqual(expectedState);
- });
- });
-
- describe('UPDATE_SETTINGS', () => {
- it('should update the settings', () => {
- mockState.settings = { foo: 'bar' };
- const payload = { foo: 'baz' };
- const expectedState = { ...mockState, settings: payload };
- mutations[types.UPDATE_SETTINGS](mockState, { settings: payload });
- expect(mockState.settings).toEqual(expectedState.settings);
- });
- });
-
- describe('SET_SETTINGS', () => {
- it('should set the settings and original', () => {
- const payload = { foo: 'baz' };
- const expectedState = { ...mockState, settings: payload };
- mutations[types.SET_SETTINGS](mockState, payload);
- expect(mockState.settings).toEqual(expectedState.settings);
- expect(mockState.original).toEqual(expectedState.settings);
- });
-
- it('should keep the default state when settings is not present', () => {
- const originalSettings = { ...mockState.settings };
- mutations[types.SET_SETTINGS](mockState);
- expect(mockState.settings).toEqual(originalSettings);
- expect(mockState.original).toEqual(undefined);
- });
- });
-
- describe('RESET_SETTINGS', () => {
- it('should copy original over settings', () => {
- mockState.settings = { foo: 'bar' };
- mockState.original = { foo: 'baz' };
- mutations[types.RESET_SETTINGS](mockState);
- expect(mockState.settings).toEqual(mockState.original);
- });
-
- it('if original is undefined it should initialize to empty object', () => {
- mockState.settings = { foo: 'bar' };
- mockState.original = undefined;
- mutations[types.RESET_SETTINGS](mockState);
- expect(mockState.settings).toEqual({});
- });
- });
-
- describe('TOGGLE_LOADING', () => {
- it('should toggle the loading', () => {
- mutations[types.TOGGLE_LOADING](mockState);
- expect(mockState.isLoading).toEqual(true);
- });
- });
-});
diff --git a/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap b/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap
new file mode 100644
index 00000000000..032007bba51
--- /dev/null
+++ b/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap
@@ -0,0 +1,101 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Utils formOptionsGenerator returns an object containing cadence 1`] = `
+Array [
+ Object {
+ "default": true,
+ "key": "EVERY_DAY",
+ "label": "Every day",
+ },
+ Object {
+ "default": false,
+ "key": "EVERY_WEEK",
+ "label": "Every week",
+ },
+ Object {
+ "default": false,
+ "key": "EVERY_TWO_WEEKS",
+ "label": "Every two weeks",
+ },
+ Object {
+ "default": false,
+ "key": "EVERY_MONTH",
+ "label": "Every month",
+ },
+ Object {
+ "default": false,
+ "key": "EVERY_THREE_MONTHS",
+ "label": "Every three months",
+ },
+]
+`;
+
+exports[`Utils formOptionsGenerator returns an object containing keepN 1`] = `
+Array [
+ Object {
+ "default": false,
+ "key": "ONE_TAG",
+ "label": "1 tag per image name",
+ "variable": 1,
+ },
+ Object {
+ "default": false,
+ "key": "FIVE_TAGS",
+ "label": "5 tags per image name",
+ "variable": 5,
+ },
+ Object {
+ "default": true,
+ "key": "TEN_TAGS",
+ "label": "10 tags per image name",
+ "variable": 10,
+ },
+ Object {
+ "default": false,
+ "key": "TWENTY_FIVE_TAGS",
+ "label": "25 tags per image name",
+ "variable": 25,
+ },
+ Object {
+ "default": false,
+ "key": "FIFTY_TAGS",
+ "label": "50 tags per image name",
+ "variable": 50,
+ },
+ Object {
+ "default": false,
+ "key": "ONE_HUNDRED_TAGS",
+ "label": "100 tags per image name",
+ "variable": 100,
+ },
+]
+`;
+
+exports[`Utils formOptionsGenerator returns an object containing olderThan 1`] = `
+Array [
+ Object {
+ "default": false,
+ "key": "SEVEN_DAYS",
+ "label": "7 days until tags are automatically removed",
+ "variable": 7,
+ },
+ Object {
+ "default": false,
+ "key": "FOURTEEN_DAYS",
+ "label": "14 days until tags are automatically removed",
+ "variable": 14,
+ },
+ Object {
+ "default": false,
+ "key": "THIRTY_DAYS",
+ "label": "30 days until tags are automatically removed",
+ "variable": 30,
+ },
+ Object {
+ "default": true,
+ "key": "NINETY_DAYS",
+ "label": "90 days until tags are automatically removed",
+ "variable": 90,
+ },
+]
+`;
diff --git a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js
index ee765ffd1c0..bee9bca5369 100644
--- a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js
+++ b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js
@@ -40,13 +40,13 @@ describe('Expiration Policy Form', () => {
});
describe.each`
- elementName | modelName | value | disabledByToggle
- ${'toggle'} | ${'enabled'} | ${true} | ${'not disabled'}
- ${'interval'} | ${'older_than'} | ${'foo'} | ${'disabled'}
- ${'schedule'} | ${'cadence'} | ${'foo'} | ${'disabled'}
- ${'latest'} | ${'keep_n'} | ${'foo'} | ${'disabled'}
- ${'name-matching'} | ${'name_regex'} | ${'foo'} | ${'disabled'}
- ${'keep-name'} | ${'name_regex_keep'} | ${'bar'} | ${'disabled'}
+ elementName | modelName | value | disabledByToggle
+ ${'toggle'} | ${'enabled'} | ${true} | ${'not disabled'}
+ ${'interval'} | ${'olderThan'} | ${'foo'} | ${'disabled'}
+ ${'schedule'} | ${'cadence'} | ${'foo'} | ${'disabled'}
+ ${'latest'} | ${'keepN'} | ${'foo'} | ${'disabled'}
+ ${'name-matching'} | ${'nameRegex'} | ${'foo'} | ${'disabled'}
+ ${'keep-name'} | ${'nameRegexKeep'} | ${'bar'} | ${'disabled'}
`(
`${FORM_ELEMENTS_ID_PREFIX}-$elementName form element`,
({ elementName, modelName, value, disabledByToggle }) => {
@@ -128,9 +128,9 @@ describe('Expiration Policy Form', () => {
});
describe.each`
- modelName | elementName
- ${'name_regex'} | ${'name-matching'}
- ${'name_regex_keep'} | ${'keep-name'}
+ modelName | elementName
+ ${'nameRegex'} | ${'name-matching'}
+ ${'nameRegexKeep'} | ${'keep-name'}
`('regex textarea validation', ({ modelName, elementName }) => {
const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(',');
diff --git a/spec/frontend/registry/shared/stubs.js b/spec/frontend/registry/shared/stubs.js
new file mode 100644
index 00000000000..f6b88d70e49
--- /dev/null
+++ b/spec/frontend/registry/shared/stubs.js
@@ -0,0 +1,11 @@
+export const GlLoadingIcon = { name: 'gl-loading-icon-stub', template: '<svg></svg>' };
+export const GlCard = {
+ name: 'gl-card-stub',
+ template: `
+<div>
+ <slot name="header"></slot>
+ <slot></slot>
+ <slot name="footer"></slot>
+</div>
+`,
+};
diff --git a/spec/frontend/registry/shared/utils_spec.js b/spec/frontend/registry/shared/utils_spec.js
new file mode 100644
index 00000000000..edb0c3261be
--- /dev/null
+++ b/spec/frontend/registry/shared/utils_spec.js
@@ -0,0 +1,37 @@
+import {
+ formOptionsGenerator,
+ optionLabelGenerator,
+ olderThanTranslationGenerator,
+} from '~/registry/shared/utils';
+
+describe('Utils', () => {
+ describe('optionLabelGenerator', () => {
+ it('returns an array with a set label', () => {
+ const result = optionLabelGenerator(
+ [{ variable: 1 }, { variable: 2 }],
+ olderThanTranslationGenerator,
+ );
+ expect(result).toEqual([
+ { variable: 1, label: '1 day until tags are automatically removed' },
+ { variable: 2, label: '2 days until tags are automatically removed' },
+ ]);
+ });
+ });
+
+ describe('formOptionsGenerator', () => {
+ it('returns an object containing olderThan', () => {
+ expect(formOptionsGenerator().olderThan).toBeDefined();
+ expect(formOptionsGenerator().olderThan).toMatchSnapshot();
+ });
+
+ it('returns an object containing cadence', () => {
+ expect(formOptionsGenerator().cadence).toBeDefined();
+ expect(formOptionsGenerator().cadence).toMatchSnapshot();
+ });
+
+ it('returns an object containing keepN', () => {
+ expect(formOptionsGenerator().keepN).toBeDefined();
+ expect(formOptionsGenerator().keepN).toMatchSnapshot();
+ });
+ });
+});
diff --git a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js b/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js
index 1b938c93df8..db33a9cdce1 100644
--- a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js
+++ b/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js
@@ -19,9 +19,8 @@ describe('RelatedMergeRequests', () => {
mockData = getJSONFixture(FIXTURE_PATH);
// put the fixture in DOM as the component expects
- document.body.innerHTML = `<div id="js-issuable-app-initial-data">${JSON.stringify(
- mockData,
- )}</div>`;
+ document.body.innerHTML = `<div id="js-issuable-app"></div>`;
+ document.getElementById('js-issuable-app').dataset.initial = JSON.stringify(mockData);
mock = new MockAdapter(axios);
mock.onGet(`${API_ENDPOINT}?per_page=100`).reply(200, mockData, { 'x-total': 2 });
diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap
index f56e296d106..84247e2a5a0 100644
--- a/spec/frontend/releases/__snapshots__/util_spec.js.snap
+++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap
@@ -5,109 +5,123 @@ Object {
"data": Array [
Object {
"_links": Object {
- "editUrl": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10/edit",
- "issuesUrl": null,
- "mergeRequestsUrl": null,
- "self": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10",
- "selfUrl": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10",
+ "editUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/edit",
+ "issuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=opened",
+ "mergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=opened",
+ "self": "http://localhost/releases-namespace/releases-project/-/releases/v1.1",
+ "selfUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1",
},
"assets": Object {
- "count": 7,
+ "count": 8,
"links": Array [
Object {
- "directAssetUrl": "http://0.0.0.0:3000/root/release-test/-/releases/v5.32/permanent/path/to/runbook",
+ "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-3",
"external": true,
- "id": "gid://gitlab/Releases::Link/69",
- "linkType": "other",
- "name": "An example link",
- "url": "https://example.com/link",
+ "id": "gid://gitlab/Releases::Link/13",
+ "linkType": "image",
+ "name": "Image",
+ "url": "https://example.com/image",
},
Object {
- "directAssetUrl": "https://example.com/package",
+ "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-2",
"external": true,
- "id": "gid://gitlab/Releases::Link/68",
+ "id": "gid://gitlab/Releases::Link/12",
"linkType": "package",
- "name": "An example package link",
+ "name": "Package",
"url": "https://example.com/package",
},
Object {
- "directAssetUrl": "https://example.com/image",
+ "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-1",
+ "external": false,
+ "id": "gid://gitlab/Releases::Link/11",
+ "linkType": "runbook",
+ "name": "Runbook",
+ "url": "http://localhost/releases-namespace/releases-project/runbook",
+ },
+ Object {
+ "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/linux-amd64",
"external": true,
- "id": "gid://gitlab/Releases::Link/67",
- "linkType": "image",
- "name": "An example image",
- "url": "https://example.com/image",
+ "id": "gid://gitlab/Releases::Link/10",
+ "linkType": "other",
+ "name": "linux-amd64 binaries",
+ "url": "https://downloads.example.com/bin/gitlab-linux-amd64",
},
],
"sources": Array [
Object {
"format": "zip",
- "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.zip",
+ "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.zip",
},
Object {
"format": "tar.gz",
- "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.gz",
+ "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar.gz",
},
Object {
"format": "tar.bz2",
- "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.bz2",
+ "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar.bz2",
},
Object {
"format": "tar",
- "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar",
+ "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar",
},
],
},
"author": Object {
- "avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png",
- "username": "root",
- "webUrl": "http://0.0.0.0:3000/root",
+ "avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon",
+ "username": "administrator",
+ "webUrl": "http://localhost/administrator",
},
"commit": Object {
- "shortId": "92e7ea2e",
- "title": "Testing a change.",
+ "shortId": "b83d6e39",
+ "title": "Merge branch 'branch-merged' into 'master'",
},
- "commitPath": "http://0.0.0.0:3000/root/release-test/-/commit/92e7ea2ee4496fe0d00ff69830ba0564d3d1e5a7",
- "descriptionHtml": "<p data-sourcepos=\\"1:1-1:24\\" dir=\\"auto\\">This is version <strong>1.0</strong>!</p>",
+ "commitPath": "http://localhost/releases-namespace/releases-project/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0",
+ "descriptionHtml": "<p data-sourcepos=\\"1:1-1:33\\" dir=\\"auto\\">Best. Release. <strong>Ever.</strong> <gl-emoji title=\\"rocket\\" data-name=\\"rocket\\" data-unicode-version=\\"6.0\\">🚀</gl-emoji></p>",
"evidences": Array [
Object {
- "collectedAt": "2020-08-21T20:15:19Z",
- "filepath": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10/evidences/34.json",
- "sha": "22bde8e8b93d870a29ddc339287a1fbb598f45d1396d",
+ "collectedAt": "2018-12-03T00:00:00Z",
+ "filepath": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/evidences/1.json",
+ "sha": "760d6cdfb0879c3ffedec13af470e0f71cf52c6cde4d",
},
],
"milestones": Array [
Object {
- "description": "",
- "id": "gid://gitlab/Milestone/60",
+ "description": "The 12.4 milestone",
+ "id": "gid://gitlab/Milestone/124",
"issueStats": Object {
- "closed": 0,
- "total": 0,
+ "closed": 1,
+ "total": 4,
},
"stats": undefined,
"title": "12.4",
"webPath": undefined,
- "webUrl": "/root/release-test/-/milestones/2",
+ "webUrl": "/releases-namespace/releases-project/-/milestones/2",
},
Object {
- "description": "Milestone 12.3",
- "id": "gid://gitlab/Milestone/59",
+ "description": "The 12.3 milestone",
+ "id": "gid://gitlab/Milestone/123",
"issueStats": Object {
- "closed": 1,
- "total": 2,
+ "closed": 3,
+ "total": 5,
},
"stats": undefined,
"title": "12.3",
"webPath": undefined,
- "webUrl": "/root/release-test/-/milestones/1",
+ "webUrl": "/releases-namespace/releases-project/-/milestones/1",
},
],
- "name": "Release 1.0",
- "releasedAt": "2020-08-21T20:15:18Z",
- "tagName": "v5.10",
- "tagPath": "/root/release-test/-/tags/v5.10",
- "upcomingRelease": false,
+ "name": "The first release",
+ "releasedAt": "2018-12-10T00:00:00Z",
+ "tagName": "v1.1",
+ "tagPath": "/releases-namespace/releases-project/-/tags/v1.1",
+ "upcomingRelease": true,
},
],
+ "paginationInfo": Object {
+ "endCursor": "eyJpZCI6IjEiLCJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyJ9",
+ "hasNextPage": false,
+ "hasPreviousPage": false,
+ "startCursor": "eyJpZCI6IjEiLCJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyJ9",
+ },
}
`;
diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js
index e9727801c1a..3367ca8ba3a 100644
--- a/spec/frontend/releases/components/app_edit_new_spec.js
+++ b/spec/frontend/releases/components/app_edit_new_spec.js
@@ -3,12 +3,15 @@ import { mount } from '@vue/test-utils';
import { merge } from 'lodash';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
+import { getJSONFixture } from 'helpers/fixtures';
import ReleaseEditNewApp from '~/releases/components/app_edit_new.vue';
-import { release as originalRelease, milestones as originalMilestones } from '../mock_data';
import * as commonUtils from '~/lib/utils/common_utils';
import { BACK_URL_PARAM } from '~/releases/constants';
import AssetLinksForm from '~/releases/components/asset_links_form.vue';
+const originalRelease = getJSONFixture('api/releases/release.json');
+const originalMilestones = originalRelease.milestones;
+
describe('Release edit/new component', () => {
let wrapper;
let release;
diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js
index bcb87509cc3..9f1577c2f1e 100644
--- a/spec/frontend/releases/components/app_index_spec.js
+++ b/spec/frontend/releases/components/app_index_spec.js
@@ -2,27 +2,33 @@ import { range as rge } from 'lodash';
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
+import { getJSONFixture } from 'helpers/fixtures';
import ReleasesApp from '~/releases/components/app_index.vue';
import createStore from '~/releases/stores';
import createListModule from '~/releases/stores/modules/list';
import api from '~/api';
-import {
- pageInfoHeadersWithoutPagination,
- pageInfoHeadersWithPagination,
- release2 as release,
- releases,
-} from '../mock_data';
+import { pageInfoHeadersWithoutPagination, pageInfoHeadersWithPagination } from '../mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+import ReleasesPagination from '~/releases/components/releases_pagination.vue';
+
+jest.mock('~/lib/utils/common_utils', () => ({
+ ...jest.requireActual('~/lib/utils/common_utils'),
+ getParameterByName: jest.fn().mockImplementation(paramName => {
+ return `${paramName}_param_value`;
+ }),
+}));
const localVue = createLocalVue();
localVue.use(Vuex);
+const release = getJSONFixture('api/releases/release.json');
+const releases = [release];
+
describe('Releases App ', () => {
let wrapper;
let fetchReleaseSpy;
- const releasesPagination = rge(21).map(index => ({
+ const paginatedReleases = rge(21).map(index => ({
...convertObjectPropsToCamelCase(release, { deep: true }),
tagName: `${index}.00`,
}));
@@ -70,9 +76,13 @@ describe('Releases App ', () => {
createComponent();
});
- it('calls fetchRelease with the page parameter', () => {
+ it('calls fetchRelease with the page, before, and after parameters', () => {
expect(fetchReleaseSpy).toHaveBeenCalledTimes(1);
- expect(fetchReleaseSpy).toHaveBeenCalledWith(expect.anything(), { page: null });
+ expect(fetchReleaseSpy).toHaveBeenCalledWith(expect.anything(), {
+ page: 'page_param_value',
+ before: 'before_param_value',
+ after: 'after_param_value',
+ });
});
});
@@ -91,7 +101,7 @@ describe('Releases App ', () => {
expect(wrapper.contains('.js-loading')).toBe(true);
expect(wrapper.contains('.js-empty-state')).toBe(false);
expect(wrapper.contains('.js-success-state')).toBe(false);
- expect(wrapper.contains(TablePagination)).toBe(false);
+ expect(wrapper.contains(ReleasesPagination)).toBe(false);
});
});
@@ -108,7 +118,7 @@ describe('Releases App ', () => {
expect(wrapper.contains('.js-loading')).toBe(false);
expect(wrapper.contains('.js-empty-state')).toBe(false);
expect(wrapper.contains('.js-success-state')).toBe(true);
- expect(wrapper.contains(TablePagination)).toBe(true);
+ expect(wrapper.contains(ReleasesPagination)).toBe(true);
});
});
@@ -116,7 +126,7 @@ describe('Releases App ', () => {
beforeEach(() => {
jest
.spyOn(api, 'releases')
- .mockResolvedValue({ data: releasesPagination, headers: pageInfoHeadersWithPagination });
+ .mockResolvedValue({ data: paginatedReleases, headers: pageInfoHeadersWithPagination });
createComponent();
});
@@ -125,7 +135,7 @@ describe('Releases App ', () => {
expect(wrapper.contains('.js-loading')).toBe(false);
expect(wrapper.contains('.js-empty-state')).toBe(false);
expect(wrapper.contains('.js-success-state')).toBe(true);
- expect(wrapper.contains(TablePagination)).toBe(true);
+ expect(wrapper.contains(ReleasesPagination)).toBe(true);
});
});
@@ -154,7 +164,7 @@ describe('Releases App ', () => {
const newReleasePath = 'path/to/new/release';
beforeEach(() => {
- createComponent({ ...defaultInitialState, newReleasePath });
+ createComponent({ newReleasePath });
});
it('renders the "New release" button', () => {
@@ -174,4 +184,27 @@ describe('Releases App ', () => {
});
});
});
+
+ describe('when the back button is pressed', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(api, 'releases')
+ .mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination });
+
+ createComponent();
+
+ fetchReleaseSpy.mockClear();
+
+ window.dispatchEvent(new PopStateEvent('popstate'));
+ });
+
+ it('calls fetchRelease with the page parameter', () => {
+ expect(fetchReleaseSpy).toHaveBeenCalledTimes(1);
+ expect(fetchReleaseSpy).toHaveBeenCalledWith(expect.anything(), {
+ page: 'page_param_value',
+ before: 'before_param_value',
+ after: 'after_param_value',
+ });
+ });
+ });
});
diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js
index 502a1053663..181fa0150f1 100644
--- a/spec/frontend/releases/components/app_show_spec.js
+++ b/spec/frontend/releases/components/app_show_spec.js
@@ -1,11 +1,13 @@
import Vuex from 'vuex';
import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { getJSONFixture } from 'helpers/fixtures';
import ReleaseShowApp from '~/releases/components/app_show.vue';
-import { release as originalRelease } from '../mock_data';
+import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue';
import ReleaseBlock from '~/releases/components/release_block.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+const originalRelease = getJSONFixture('api/releases/release.json');
+
describe('Release show component', () => {
let wrapper;
let release;
@@ -33,7 +35,7 @@ describe('Release show component', () => {
wrapper = shallowMount(ReleaseShowApp, { store });
};
- const findLoadingSkeleton = () => wrapper.find(GlSkeletonLoading);
+ const findLoadingSkeleton = () => wrapper.find(ReleaseSkeletonLoader);
const findReleaseBlock = () => wrapper.find(ReleaseBlock);
it('calls fetchRelease when the component is created', () => {
diff --git a/spec/frontend/releases/components/asset_links_form_spec.js b/spec/frontend/releases/components/asset_links_form_spec.js
index 582c0b32716..e5b8ed267a0 100644
--- a/spec/frontend/releases/components/asset_links_form_spec.js
+++ b/spec/frontend/releases/components/asset_links_form_spec.js
@@ -1,7 +1,7 @@
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
+import { getJSONFixture } from 'helpers/fixtures';
import AssetLinksForm from '~/releases/components/asset_links_form.vue';
-import { release as originalRelease } from '../mock_data';
import * as commonUtils from '~/lib/utils/common_utils';
import { ENTER_KEY } from '~/lib/utils/keys';
import { ASSET_LINK_TYPE, DEFAULT_ASSET_LINK_TYPE } from '~/releases/constants';
@@ -9,6 +9,8 @@ import { ASSET_LINK_TYPE, DEFAULT_ASSET_LINK_TYPE } from '~/releases/constants';
const localVue = createLocalVue();
localVue.use(Vuex);
+const originalRelease = getJSONFixture('api/releases/release.json');
+
describe('Release edit component', () => {
let wrapper;
let release;
@@ -223,10 +225,18 @@ describe('Release edit component', () => {
});
});
- it('selects the default asset type if no type was provided by the backend', () => {
- const selected = wrapper.find({ ref: 'typeSelect' }).element.value;
+ describe('when no link type was provided by the backend', () => {
+ beforeEach(() => {
+ delete release.assets.links[0].linkType;
+
+ factory({ release });
+ });
+
+ it('selects the default asset type', () => {
+ const selected = wrapper.find({ ref: 'typeSelect' }).element.value;
- expect(selected).toBe(DEFAULT_ASSET_LINK_TYPE);
+ expect(selected).toBe(DEFAULT_ASSET_LINK_TYPE);
+ });
});
});
diff --git a/spec/frontend/releases/components/evidence_block_spec.js b/spec/frontend/releases/components/evidence_block_spec.js
index ba60a79e464..b8c78f90fc2 100644
--- a/spec/frontend/releases/components/evidence_block_spec.js
+++ b/spec/frontend/releases/components/evidence_block_spec.js
@@ -1,11 +1,13 @@
import { mount } from '@vue/test-utils';
import { GlLink, GlIcon } from '@gitlab/ui';
+import { getJSONFixture } from 'helpers/fixtures';
import { truncateSha } from '~/lib/utils/text_utility';
-import { release as originalRelease } from '../mock_data';
import EvidenceBlock from '~/releases/components/evidence_block.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+const originalRelease = getJSONFixture('api/releases/release.json');
+
describe('Evidence Block', () => {
let wrapper;
let release;
@@ -35,7 +37,7 @@ describe('Evidence Block', () => {
});
it('renders the title for the dowload link', () => {
- expect(wrapper.find(GlLink).text()).toBe('v1.1.2-evidences-1.json');
+ expect(wrapper.find(GlLink).text()).toBe(`v1.1-evidences-1.json`);
});
it('renders the correct hover text for the download', () => {
@@ -43,7 +45,7 @@ describe('Evidence Block', () => {
});
it('renders the correct file link for download', () => {
- expect(wrapper.find(GlLink).attributes().download).toBe('v1.1.2-evidences-1.json');
+ expect(wrapper.find(GlLink).attributes().download).toBe(`v1.1-evidences-1.json`);
});
describe('sha text', () => {
diff --git a/spec/frontend/releases/components/release_block_assets_spec.js b/spec/frontend/releases/components/release_block_assets_spec.js
index 3453ecbf8ab..adccd9d87ef 100644
--- a/spec/frontend/releases/components/release_block_assets_spec.js
+++ b/spec/frontend/releases/components/release_block_assets_spec.js
@@ -1,10 +1,12 @@
import { mount } from '@vue/test-utils';
import { GlCollapse } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper';
-import { cloneDeep } from 'lodash';
+import { getJSONFixture } from 'helpers/fixtures';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import ReleaseBlockAssets from '~/releases/components/release_block_assets.vue';
import { ASSET_LINK_TYPE } from '~/releases/constants';
-import { assets } from '../mock_data';
+
+const { assets } = getJSONFixture('api/releases/release.json');
describe('Release block assets', () => {
let wrapper;
@@ -31,7 +33,7 @@ describe('Release block assets', () => {
wrapper.findAll('h5').filter(h5 => h5.text() === sections[type]);
beforeEach(() => {
- defaultProps = { assets: cloneDeep(assets) };
+ defaultProps = { assets: convertObjectPropsToCamelCase(assets, { deep: true }) };
});
describe('with default props', () => {
@@ -43,7 +45,7 @@ describe('Release block assets', () => {
const accordionButton = findAccordionButton();
expect(accordionButton.exists()).toBe(true);
- expect(trimText(accordionButton.text())).toBe('Assets 5');
+ expect(trimText(accordionButton.text())).toBe('Assets 8');
});
it('renders the accordion as expanded by default', () => {
diff --git a/spec/frontend/releases/components/release_block_footer_spec.js b/spec/frontend/releases/components/release_block_footer_spec.js
index bde01cc0e00..f1c0c24f8ca 100644
--- a/spec/frontend/releases/components/release_block_footer_spec.js
+++ b/spec/frontend/releases/components/release_block_footer_spec.js
@@ -1,11 +1,13 @@
import { mount } from '@vue/test-utils';
import { GlLink, GlIcon } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper';
+import { getJSONFixture } from 'helpers/fixtures';
import { cloneDeep } from 'lodash';
import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue';
-import { release as originalRelease } from '../mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+const originalRelease = getJSONFixture('api/releases/release.json');
+
const mockFutureDate = new Date(9999, 0, 0).toISOString();
let mockIsFutureRelease = false;
diff --git a/spec/frontend/releases/components/release_block_header_spec.js b/spec/frontend/releases/components/release_block_header_spec.js
index 9c6cbc86d3c..f2159871395 100644
--- a/spec/frontend/releases/components/release_block_header_spec.js
+++ b/spec/frontend/releases/components/release_block_header_spec.js
@@ -1,11 +1,13 @@
import { shallowMount } from '@vue/test-utils';
import { merge } from 'lodash';
import { GlLink } from '@gitlab/ui';
+import { getJSONFixture } from 'helpers/fixtures';
import ReleaseBlockHeader from '~/releases/components/release_block_header.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { release as originalRelease } from '../mock_data';
import { BACK_URL_PARAM } from '~/releases/constants';
+const originalRelease = getJSONFixture('api/releases/release.json');
+
describe('Release block header', () => {
let wrapper;
let release;
@@ -49,7 +51,7 @@ describe('Release block header', () => {
});
it('renders the title as text', () => {
- expect(findHeader().text()).toBe(release.name);
+ expect(findHeader().text()).toContain(release.name);
expect(findHeaderLink().exists()).toBe(false);
});
});
diff --git a/spec/frontend/releases/components/release_block_metadata_spec.js b/spec/frontend/releases/components/release_block_metadata_spec.js
index 6f184e45600..9038553fc8e 100644
--- a/spec/frontend/releases/components/release_block_metadata_spec.js
+++ b/spec/frontend/releases/components/release_block_metadata_spec.js
@@ -1,10 +1,12 @@
import { mount } from '@vue/test-utils';
import { trimText } from 'helpers/text_helper';
+import { getJSONFixture } from 'helpers/fixtures';
import { cloneDeep } from 'lodash';
import ReleaseBlockMetadata from '~/releases/components/release_block_metadata.vue';
-import { release as originalRelease } from '../mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+const originalRelease = getJSONFixture('api/releases/release.json');
+
const mockFutureDate = new Date(9999, 0, 0).toISOString();
let mockIsFutureRelease = false;
diff --git a/spec/frontend/releases/components/release_block_milestone_info_spec.js b/spec/frontend/releases/components/release_block_milestone_info_spec.js
index 0e79c45b337..45f4eaa01a9 100644
--- a/spec/frontend/releases/components/release_block_milestone_info_spec.js
+++ b/spec/frontend/releases/components/release_block_milestone_info_spec.js
@@ -1,11 +1,13 @@
import { mount } from '@vue/test-utils';
import { GlProgressBar, GlLink, GlBadge, GlButton } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper';
+import { getJSONFixture } from 'helpers/fixtures';
import ReleaseBlockMilestoneInfo from '~/releases/components/release_block_milestone_info.vue';
-import { milestones as originalMilestones } from '../mock_data';
import { MAX_MILESTONES_TO_DISPLAY } from '~/releases/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+const { milestones: originalMilestones } = getJSONFixture('api/releases/release.json');
+
describe('Release block milestone info', () => {
let wrapper;
let milestones;
@@ -35,7 +37,7 @@ describe('Release block milestone info', () => {
beforeEach(() => factory({ milestones }));
it('renders the correct percentage', () => {
- expect(milestoneProgressBarContainer().text()).toContain('41% complete');
+ expect(milestoneProgressBarContainer().text()).toContain('44% complete');
});
it('renders a progress bar that displays the correct percentage', () => {
@@ -44,14 +46,24 @@ describe('Release block milestone info', () => {
expect(progressBar.exists()).toBe(true);
expect(progressBar.attributes()).toEqual(
expect.objectContaining({
- value: '22',
- max: '54',
+ value: '4',
+ max: '9',
}),
);
});
it('renders a list of links to all associated milestones', () => {
- expect(trimText(milestoneListContainer().text())).toContain('Milestones 13.6 • 13.5');
+ // The API currently returns the milestones in a non-deterministic order,
+ // which causes the frontend fixture used by this test to return the
+ // milestones in one order locally and a different order in the CI pipeline.
+ // This is a bug and is tracked here: https://gitlab.com/gitlab-org/gitlab/-/issues/259012
+ // When this bug is fixed this expectation should be updated to
+ // assert the expected order.
+ const containerText = trimText(milestoneListContainer().text());
+ expect(
+ containerText.includes('Milestones 12.4 • 12.3') ||
+ containerText.includes('Milestones 12.3 • 12.4'),
+ ).toBe(true);
milestones.forEach((m, i) => {
const milestoneLink = milestoneListContainer()
@@ -65,7 +77,7 @@ describe('Release block milestone info', () => {
});
it('renders the "Issues" section with a total count of issues associated to the milestone(s)', () => {
- const totalIssueCount = 54;
+ const totalIssueCount = 9;
const issuesContainerText = trimText(issuesContainer().text());
expect(issuesContainerText).toContain(`Issues ${totalIssueCount}`);
@@ -73,7 +85,7 @@ describe('Release block milestone info', () => {
const badge = issuesContainer().find(GlBadge);
expect(badge.text()).toBe(totalIssueCount.toString());
- expect(issuesContainerText).toContain('Open: 32 • Closed: 22');
+ expect(issuesContainerText).toContain('Open: 5 • Closed: 4');
});
});
diff --git a/spec/frontend/releases/components/release_block_spec.js b/spec/frontend/releases/components/release_block_spec.js
index a7f1388664b..af5e538b95e 100644
--- a/spec/frontend/releases/components/release_block_spec.js
+++ b/spec/frontend/releases/components/release_block_spec.js
@@ -1,15 +1,17 @@
import $ from 'jquery';
import { mount } from '@vue/test-utils';
import { GlIcon } from '@gitlab/ui';
+import { getJSONFixture } from 'helpers/fixtures';
import EvidenceBlock from '~/releases/components/evidence_block.vue';
import ReleaseBlock from '~/releases/components/release_block.vue';
import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import { release as originalRelease } from '../mock_data';
import * as commonUtils from '~/lib/utils/common_utils';
import { BACK_URL_PARAM } from '~/releases/constants';
import * as urlUtility from '~/lib/utils/url_utility';
+const originalRelease = getJSONFixture('api/releases/release.json');
+
describe('Release block', () => {
let wrapper;
let release;
@@ -46,7 +48,7 @@ describe('Release block', () => {
beforeEach(() => factory(release));
it("renders the block with an id equal to the release's tag name", () => {
- expect(wrapper.attributes().id).toBe('v0.3');
+ expect(wrapper.attributes().id).toBe(release.tagName);
});
it(`renders an edit button that links to the "Edit release" page with a "${BACK_URL_PARAM}" parameter`, () => {
@@ -107,7 +109,7 @@ describe('Release block', () => {
});
it('does not render external label when link is not external', () => {
- expect(wrapper.find('.js-assets-list li:nth-child(2) a').text()).not.toContain(
+ expect(wrapper.find('.js-assets-list li:nth-child(3) a').text()).not.toContain(
'external source',
);
});
diff --git a/spec/frontend/releases/components/release_skeleton_loader_spec.js b/spec/frontend/releases/components/release_skeleton_loader_spec.js
new file mode 100644
index 00000000000..7fbf864568a
--- /dev/null
+++ b/spec/frontend/releases/components/release_skeleton_loader_spec.js
@@ -0,0 +1,15 @@
+import { mount } from '@vue/test-utils';
+import { GlSkeletonLoader } from '@gitlab/ui';
+import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue';
+
+describe('release_skeleton_loader.vue', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = mount(ReleaseSkeletonLoader);
+ });
+
+ it('renders a GlSkeletonLoader', () => {
+ expect(wrapper.find(GlSkeletonLoader).exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/releases/components/releases_pagination_graphql_spec.js b/spec/frontend/releases/components/releases_pagination_graphql_spec.js
index b01a28eb6c3..bba5e532e5e 100644
--- a/spec/frontend/releases/components/releases_pagination_graphql_spec.js
+++ b/spec/frontend/releases/components/releases_pagination_graphql_spec.js
@@ -29,7 +29,7 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => {
listModule.state.graphQlPageInfo = pageInfo;
- listModule.actions.fetchReleasesGraphQl = jest.fn();
+ listModule.actions.fetchReleases = jest.fn();
wrapper = mount(ReleasesPaginationGraphql, {
store: createStore({
@@ -141,8 +141,8 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => {
findNextButton().trigger('click');
});
- it('calls fetchReleasesGraphQl with the correct after cursor', () => {
- expect(listModule.actions.fetchReleasesGraphQl.mock.calls).toEqual([
+ it('calls fetchReleases with the correct after cursor', () => {
+ expect(listModule.actions.fetchReleases.mock.calls).toEqual([
[expect.anything(), { after: cursors.endCursor }],
]);
});
@@ -159,8 +159,8 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => {
findPrevButton().trigger('click');
});
- it('calls fetchReleasesGraphQl with the correct before cursor', () => {
- expect(listModule.actions.fetchReleasesGraphQl.mock.calls).toEqual([
+ it('calls fetchReleases with the correct before cursor', () => {
+ expect(listModule.actions.fetchReleases.mock.calls).toEqual([
[expect.anything(), { before: cursors.startCursor }],
]);
});
diff --git a/spec/frontend/releases/components/releases_pagination_rest_spec.js b/spec/frontend/releases/components/releases_pagination_rest_spec.js
index 4fd3e085fc9..59c0c31413a 100644
--- a/spec/frontend/releases/components/releases_pagination_rest_spec.js
+++ b/spec/frontend/releases/components/releases_pagination_rest_spec.js
@@ -20,9 +20,9 @@ describe('~/releases/components/releases_pagination_rest.vue', () => {
const createComponent = pageInfo => {
listModule = createListModule({ projectId });
- listModule.state.pageInfo = pageInfo;
+ listModule.state.restPageInfo = pageInfo;
- listModule.actions.fetchReleasesRest = jest.fn();
+ listModule.actions.fetchReleases = jest.fn();
wrapper = mount(ReleasesPaginationRest, {
store: createStore({
@@ -57,8 +57,8 @@ describe('~/releases/components/releases_pagination_rest.vue', () => {
findGlPagination().vm.$emit('input', newPage);
});
- it('calls fetchReleasesRest with the correct page', () => {
- expect(listModule.actions.fetchReleasesRest.mock.calls).toEqual([
+ it('calls fetchReleases with the correct page', () => {
+ expect(listModule.actions.fetchReleases.mock.calls).toEqual([
[expect.anything(), { page: newPage }],
]);
});
diff --git a/spec/frontend/releases/mock_data.js b/spec/frontend/releases/mock_data.js
index 58cd69a2f6a..c89182faa44 100644
--- a/spec/frontend/releases/mock_data.js
+++ b/spec/frontend/releases/mock_data.js
@@ -1,139 +1,3 @@
-import { ASSET_LINK_TYPE } from '~/releases/constants';
-
-export const milestones = [
- {
- id: 50,
- iid: 2,
- project_id: 18,
- title: '13.6',
- description: 'The 13.6 milestone!',
- state: 'active',
- created_at: '2019-08-27T17:22:38.280Z',
- updated_at: '2019-08-27T17:22:38.280Z',
- due_date: '2019-09-19',
- start_date: '2019-08-31',
- web_url: 'http://0.0.0.0:3001/root/release-test/-/milestones/2',
- issue_stats: {
- total: 33,
- closed: 19,
- },
- },
- {
- id: 49,
- iid: 1,
- project_id: 18,
- title: '13.5',
- description: 'The 13.5 milestone!',
- state: 'active',
- created_at: '2019-08-26T17:55:48.643Z',
- updated_at: '2019-08-26T17:55:48.643Z',
- due_date: '2019-10-11',
- start_date: '2019-08-19',
- web_url: 'http://0.0.0.0:3001/root/release-test/-/milestones/1',
- issue_stats: {
- total: 21,
- closed: 3,
- },
- },
-];
-
-export const release = {
- name: 'New release',
- tag_name: 'v0.3',
- tag_path: '/root/release-test/-/tags/v0.3',
- description: 'A super nice release!',
- description_html: '<p data-sourcepos="1:1-1:21" dir="auto">A super nice release!</p>',
- created_at: '2019-08-26T17:54:04.952Z',
- released_at: '2019-08-26T17:54:04.807Z',
- author: {
- id: 1,
- name: 'Administrator',
- username: 'root',
- state: 'active',
- avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- web_url: 'http://0.0.0.0:3001/root',
- },
- commit: {
- id: 'c22b0728d1b465f82898c884d32b01aa642f96c1',
- short_id: 'c22b0728',
- created_at: '2019-08-26T17:47:07.000Z',
- parent_ids: [],
- title: 'Initial commit',
- message: 'Initial commit',
- author_name: 'Administrator',
- author_email: 'admin@example.com',
- authored_date: '2019-08-26T17:47:07.000Z',
- committer_name: 'Administrator',
- committer_email: 'admin@example.com',
- committed_date: '2019-08-26T17:47:07.000Z',
- },
- commit_path: '/root/release-test/commit/c22b0728d1b465f82898c884d32b01aa642f96c1',
- upcoming_release: false,
- milestones,
- evidences: [
- {
- filepath:
- 'https://20592.qa-tunnel.gitlab.info/root/test-deployments/-/releases/v1.1.2/evidences/1.json',
- sha: 'fb3a125fd69a0e5048ebfb0ba43eb32ce4911520dd8d',
- collected_at: '2018-10-19 15:43:20 +0200',
- },
- {
- filepath:
- 'https://20592.qa-tunnel.gitlab.info/root/test-deployments/-/releases/v1.1.2/evidences/2.json',
- sha: '6ebd17a66e6a861175735416e49cf677678029805712dd71bb805c609e2d9108',
- collected_at: '2018-10-19 15:43:20 +0200',
- },
- {
- filepath:
- 'https://20592.qa-tunnel.gitlab.info/root/test-deployments/-/releases/v1.1.2/evidences/3.json',
- sha: '2f65beaf275c3cb4b4e24fb01d481cc475d69c957830833f15338384816b5cba',
- collected_at: '2018-10-19 15:43:20 +0200',
- },
- ],
- assets: {
- count: 5,
- sources: [
- {
- format: 'zip',
- url: 'http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.zip',
- },
- {
- format: 'tar.gz',
- url: 'http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.gz',
- },
- {
- format: 'tar.bz2',
- url: 'http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.bz2',
- },
- {
- format: 'tar',
- url: 'http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar',
- },
- ],
- links: [
- {
- id: 1,
- name: 'my link',
- url: 'https://google.com',
- direct_asset_url: 'https://redirected.google.com',
- external: true,
- },
- {
- id: 2,
- name: 'my second link',
- url:
- 'https://gitlab.com/gitlab-org/gitlab-foss/-/jobs/artifacts/v11.6.0-rc4/download?job=rspec-mysql+41%2F50',
- direct_asset_url: 'https://redirected.google.com',
- external: false,
- },
- ],
- },
- _links: {
- self: 'http://0.0.0.0:3001/root/release-test/-/releases/v0.3',
- edit_url: 'http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit',
- },
-};
-
export const pageInfoHeadersWithoutPagination = {
'X-NEXT-PAGE': '',
'X-PAGE': '1',
@@ -151,202 +15,3 @@ export const pageInfoHeadersWithPagination = {
'X-TOTAL': '21',
'X-TOTAL-PAGES': '2',
};
-
-export const assets = {
- count: 5,
- sources: [
- {
- format: 'zip',
- url: 'https://example.gitlab.com/path/to/zip',
- },
- ],
- links: [
- {
- linkType: ASSET_LINK_TYPE.IMAGE,
- url: 'https://example.gitlab.com/path/to/image',
- directAssetUrl: 'https://example.gitlab.com/path/to/image',
- name: 'Example image link',
- },
- {
- linkType: ASSET_LINK_TYPE.PACKAGE,
- url: 'https://example.gitlab.com/path/to/package',
- directAssetUrl: 'https://example.gitlab.com/path/to/package',
- name: 'Example package link',
- },
- {
- linkType: ASSET_LINK_TYPE.RUNBOOK,
- url: 'https://example.gitlab.com/path/to/runbook',
- directAssetUrl: 'https://example.gitlab.com/path/to/runbook',
- name: 'Example runbook link',
- },
- {
- linkType: ASSET_LINK_TYPE.OTHER,
- url: 'https://example.gitlab.com/path/to/link',
- directAssetUrl: 'https://example.gitlab.com/path/to/link',
- name: 'Example link',
- },
- ],
-};
-
-export const release2 = {
- name: 'Bionic Beaver',
- tag_name: '18.04',
- description: '## changelog\n\n* line 1\n* line2',
- description_html: '<div><h2>changelog</h2><ul><li>line1</li<li>line 2</li></ul></div>',
- author_name: 'Release bot',
- author_email: 'release-bot@example.com',
- created_at: '2012-05-28T05:00:00-07:00',
- commit: {
- id: '2695effb5807a22ff3d138d593fd856244e155e7',
- short_id: '2695effb',
- title: 'Initial commit',
- created_at: '2017-07-26T11:08:53.000+02:00',
- parent_ids: ['2a4b78934375d7f53875269ffd4f45fd83a84ebe'],
- message: 'Initial commit',
- author: {
- avatar_url: 'uploads/-/system/user/avatar/johndoe/avatar.png',
- id: 482476,
- name: 'John Doe',
- path: '/johndoe',
- state: 'active',
- status_tooltip_html: null,
- username: 'johndoe',
- web_url: 'https://gitlab.com/johndoe',
- },
- authored_date: '2012-05-28T04:42:42-07:00',
- committer_name: 'Jack Smith',
- committer_email: 'jack@example.com',
- committed_date: '2012-05-28T04:42:42-07:00',
- },
- assets,
-};
-
-export const releases = [release, release2];
-
-export const graphqlReleasesResponse = {
- data: {
- project: {
- releases: {
- count: 39,
- nodes: [
- {
- name: 'Release 1.0',
- tagName: 'v5.10',
- tagPath: '/root/release-test/-/tags/v5.10',
- descriptionHtml:
- '<p data-sourcepos="1:1-1:24" dir="auto">This is version <strong>1.0</strong>!</p>',
- releasedAt: '2020-08-21T20:15:18Z',
- upcomingRelease: false,
- assets: {
- count: 7,
- sources: {
- nodes: [
- {
- format: 'zip',
- url:
- 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.zip',
- },
- {
- format: 'tar.gz',
- url:
- 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.gz',
- },
- {
- format: 'tar.bz2',
- url:
- 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.bz2',
- },
- {
- format: 'tar',
- url:
- 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar',
- },
- ],
- },
- links: {
- nodes: [
- {
- id: 'gid://gitlab/Releases::Link/69',
- name: 'An example link',
- url: 'https://example.com/link',
- directAssetUrl:
- 'http://0.0.0.0:3000/root/release-test/-/releases/v5.32/permanent/path/to/runbook',
- linkType: 'OTHER',
- external: true,
- },
- {
- id: 'gid://gitlab/Releases::Link/68',
- name: 'An example package link',
- url: 'https://example.com/package',
- directAssetUrl: 'https://example.com/package',
- linkType: 'PACKAGE',
- external: true,
- },
- {
- id: 'gid://gitlab/Releases::Link/67',
- name: 'An example image',
- url: 'https://example.com/image',
- directAssetUrl: 'https://example.com/image',
- linkType: 'IMAGE',
- external: true,
- },
- ],
- },
- },
- evidences: {
- nodes: [
- {
- filepath:
- 'http://0.0.0.0:3000/root/release-test/-/releases/v5.10/evidences/34.json',
- collectedAt: '2020-08-21T20:15:19Z',
- sha: '22bde8e8b93d870a29ddc339287a1fbb598f45d1396d',
- },
- ],
- },
- links: {
- editUrl: 'http://0.0.0.0:3000/root/release-test/-/releases/v5.10/edit',
- issuesUrl: null,
- mergeRequestsUrl: null,
- selfUrl: 'http://0.0.0.0:3000/root/release-test/-/releases/v5.10',
- },
- commit: {
- sha: '92e7ea2ee4496fe0d00ff69830ba0564d3d1e5a7',
- webUrl:
- 'http://0.0.0.0:3000/root/release-test/-/commit/92e7ea2ee4496fe0d00ff69830ba0564d3d1e5a7',
- title: 'Testing a change.',
- },
- author: {
- webUrl: 'http://0.0.0.0:3000/root',
- avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png',
- username: 'root',
- },
- milestones: {
- nodes: [
- {
- id: 'gid://gitlab/Milestone/60',
- title: '12.4',
- description: '',
- webPath: '/root/release-test/-/milestones/2',
- stats: {
- totalIssuesCount: 0,
- closedIssuesCount: 0,
- },
- },
- {
- id: 'gid://gitlab/Milestone/59',
- title: '12.3',
- description: 'Milestone 12.3',
- webPath: '/root/release-test/-/milestones/1',
- stats: {
- totalIssuesCount: 2,
- closedIssuesCount: 1,
- },
- },
- ],
- },
- },
- ],
- },
- },
- },
-};
diff --git a/spec/frontend/releases/stores/getters_spec.js b/spec/frontend/releases/stores/getters_spec.js
new file mode 100644
index 00000000000..01e10567cf0
--- /dev/null
+++ b/spec/frontend/releases/stores/getters_spec.js
@@ -0,0 +1,22 @@
+import * as getters from '~/releases/stores/getters';
+
+describe('~/releases/stores/getters.js', () => {
+ it.each`
+ graphqlReleaseData | graphqlReleasesPage | graphqlMilestoneStats | result
+ ${false} | ${false} | ${false} | ${false}
+ ${false} | ${false} | ${true} | ${false}
+ ${false} | ${true} | ${false} | ${false}
+ ${false} | ${true} | ${true} | ${false}
+ ${true} | ${false} | ${false} | ${false}
+ ${true} | ${false} | ${true} | ${false}
+ ${true} | ${true} | ${false} | ${false}
+ ${true} | ${true} | ${true} | ${true}
+ `(
+ 'returns $result with feature flag values graphqlReleaseData=$graphqlReleaseData, graphqlReleasesPage=$graphqlReleasesPage, and graphqlMilestoneStats=$graphqlMilestoneStats',
+ ({ result: expectedResult, ...featureFlags }) => {
+ const actualResult = getters.useGraphQLEndpoint({ featureFlags });
+
+ expect(actualResult).toBe(expectedResult);
+ },
+ );
+});
diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js
index 1b2a705e8f4..955c761d35a 100644
--- a/spec/frontend/releases/stores/modules/detail/actions_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js
@@ -1,10 +1,10 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
+import { getJSONFixture } from 'helpers/fixtures';
import { cloneDeep } from 'lodash';
import * as actions from '~/releases/stores/modules/detail/actions';
import * as types from '~/releases/stores/modules/detail/mutation_types';
-import { release as originalRelease } from '../../../mock_data';
import createState from '~/releases/stores/modules/detail/state';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
@@ -21,6 +21,8 @@ jest.mock('~/lib/utils/url_utility', () => ({
joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths,
}));
+const originalRelease = getJSONFixture('api/releases/release.json');
+
describe('Release detail actions', () => {
let state;
let release;
@@ -207,6 +209,15 @@ describe('Release detail actions', () => {
});
});
+ describe('updateReleaseGroupMilestones', () => {
+ it(`commits ${types.UPDATE_RELEASE_GROUP_MILESTONES} with the updated release group milestones`, () => {
+ const newReleaseGroupMilestones = ['v0.0', 'v0.1'];
+ return testAction(actions.updateReleaseGroupMilestones, newReleaseGroupMilestones, state, [
+ { type: types.UPDATE_RELEASE_GROUP_MILESTONES, payload: newReleaseGroupMilestones },
+ ]);
+ });
+ });
+
describe('addEmptyAssetLink', () => {
it(`commits ${types.ADD_EMPTY_ASSET_LINK}`, () => {
return testAction(actions.addEmptyAssetLink, undefined, state, [
diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js
index cd7c6b7d275..f3e84262754 100644
--- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js
@@ -1,10 +1,12 @@
+import { getJSONFixture } from 'helpers/fixtures';
import createState from '~/releases/stores/modules/detail/state';
import mutations from '~/releases/stores/modules/detail/mutations';
import * as types from '~/releases/stores/modules/detail/mutation_types';
-import { release as originalRelease } from '../../../mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { ASSET_LINK_TYPE, DEFAULT_ASSET_LINK_TYPE } from '~/releases/constants';
+const originalRelease = getJSONFixture('api/releases/release.json');
+
describe('Release detail mutations', () => {
let state;
let release;
@@ -30,6 +32,7 @@ describe('Release detail mutations', () => {
name: '',
description: '',
milestones: [],
+ groupMilestones: [],
assets: {
links: [],
},
@@ -112,6 +115,26 @@ describe('Release detail mutations', () => {
});
});
+ describe(`${types.UPDATE_RELEASE_MILESTONES}`, () => {
+ it("updates the release's milestones", () => {
+ state.release = release;
+ const newReleaseMilestones = ['v0.0', 'v0.1'];
+ mutations[types.UPDATE_RELEASE_MILESTONES](state, newReleaseMilestones);
+
+ expect(state.release.milestones).toBe(newReleaseMilestones);
+ });
+ });
+
+ describe(`${types.UPDATE_RELEASE_GROUP_MILESTONES}`, () => {
+ it("updates the release's group milestones", () => {
+ state.release = release;
+ const newReleaseGroupMilestones = ['v0.0', 'v0.1'];
+ mutations[types.UPDATE_RELEASE_GROUP_MILESTONES](state, newReleaseGroupMilestones);
+
+ expect(state.release.groupMilestones).toBe(newReleaseGroupMilestones);
+ });
+ });
+
describe(`${types.REQUEST_SAVE_RELEASE}`, () => {
it('set state.isUpdatingRelease to true', () => {
mutations[types.REQUEST_SAVE_RELEASE](state);
diff --git a/spec/frontend/releases/stores/modules/list/actions_spec.js b/spec/frontend/releases/stores/modules/list/actions_spec.js
index 95e30659d6c..2068d7fee78 100644
--- a/spec/frontend/releases/stores/modules/list/actions_spec.js
+++ b/spec/frontend/releases/stores/modules/list/actions_spec.js
@@ -1,31 +1,42 @@
import { cloneDeep } from 'lodash';
import testAction from 'helpers/vuex_action_helper';
+import { getJSONFixture } from 'helpers/fixtures';
import {
- requestReleases,
fetchReleases,
- receiveReleasesSuccess,
+ fetchReleasesGraphQl,
+ fetchReleasesRest,
receiveReleasesError,
} from '~/releases/stores/modules/list/actions';
import createState from '~/releases/stores/modules/list/state';
import * as types from '~/releases/stores/modules/list/mutation_types';
import api from '~/api';
import { gqClient, convertGraphQLResponse } from '~/releases/util';
-import { parseIntPagination, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import {
- pageInfoHeadersWithoutPagination,
- releases as originalReleases,
- graphqlReleasesResponse as originalGraphqlReleasesResponse,
-} from '../../../mock_data';
+ normalizeHeaders,
+ parseIntPagination,
+ convertObjectPropsToCamelCase,
+} from '~/lib/utils/common_utils';
+import { pageInfoHeadersWithoutPagination } from '../../../mock_data';
import allReleasesQuery from '~/releases/queries/all_releases.query.graphql';
+import { PAGE_SIZE } from '~/releases/constants';
+
+const originalRelease = getJSONFixture('api/releases/release.json');
+const originalReleases = [originalRelease];
+
+const originalGraphqlReleasesResponse = getJSONFixture(
+ 'graphql/releases/queries/all_releases.query.graphql.json',
+);
describe('Releases State actions', () => {
let mockedState;
- let pageInfo;
let releases;
let graphqlReleasesResponse;
const projectPath = 'root/test-project';
const projectId = 19;
+ const before = 'testBeforeCursor';
+ const after = 'testAfterCursor';
+ const page = 2;
beforeEach(() => {
mockedState = {
@@ -33,178 +44,261 @@ describe('Releases State actions', () => {
projectId,
projectPath,
}),
- featureFlags: {
- graphqlReleaseData: true,
- graphqlReleasesPage: true,
- graphqlMilestoneStats: true,
- },
};
- pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination);
releases = convertObjectPropsToCamelCase(originalReleases, { deep: true });
graphqlReleasesResponse = cloneDeep(originalGraphqlReleasesResponse);
});
- describe('requestReleases', () => {
- it('should commit REQUEST_RELEASES mutation', done => {
- testAction(requestReleases, null, mockedState, [{ type: types.REQUEST_RELEASES }], [], done);
+ describe('when all the necessary GraphQL feature flags are enabled', () => {
+ beforeEach(() => {
+ mockedState.useGraphQLEndpoint = true;
+ });
+
+ describe('fetchReleases', () => {
+ it('dispatches fetchReleasesGraphQl with before and after parameters', () => {
+ return testAction(
+ fetchReleases,
+ { before, after, page },
+ mockedState,
+ [],
+ [
+ {
+ type: 'fetchReleasesGraphQl',
+ payload: { before, after },
+ },
+ ],
+ );
+ });
});
});
- describe('fetchReleases', () => {
- describe('success', () => {
- it('dispatches requestReleases and receiveReleasesSuccess', done => {
- jest.spyOn(gqClient, 'query').mockImplementation(({ query, variables }) => {
- expect(query).toBe(allReleasesQuery);
- expect(variables).toEqual({
- fullPath: projectPath,
+ describe('when at least one of the GraphQL feature flags is disabled', () => {
+ beforeEach(() => {
+ mockedState.useGraphQLEndpoint = false;
+ });
+
+ describe('fetchReleases', () => {
+ it('dispatches fetchReleasesRest with a page parameter', () => {
+ return testAction(
+ fetchReleases,
+ { before, after, page },
+ mockedState,
+ [],
+ [
+ {
+ type: 'fetchReleasesRest',
+ payload: { page },
+ },
+ ],
+ );
+ });
+ });
+ });
+
+ describe('fetchReleasesGraphQl', () => {
+ describe('GraphQL query variables', () => {
+ let vuexParams;
+
+ beforeEach(() => {
+ jest.spyOn(gqClient, 'query');
+
+ vuexParams = { dispatch: jest.fn(), commit: jest.fn(), state: mockedState };
+ });
+
+ describe('when neither a before nor an after parameter is provided', () => {
+ beforeEach(() => {
+ fetchReleasesGraphQl(vuexParams, { before: undefined, after: undefined });
+ });
+
+ it('makes a GraphQl query with a first variable', () => {
+ expect(gqClient.query).toHaveBeenCalledWith({
+ query: allReleasesQuery,
+ variables: { fullPath: projectPath, first: PAGE_SIZE },
});
- return Promise.resolve(graphqlReleasesResponse);
});
+ });
- testAction(
- fetchReleases,
+ describe('when only a before parameter is provided', () => {
+ beforeEach(() => {
+ fetchReleasesGraphQl(vuexParams, { before, after: undefined });
+ });
+
+ it('makes a GraphQl query with last and before variables', () => {
+ expect(gqClient.query).toHaveBeenCalledWith({
+ query: allReleasesQuery,
+ variables: { fullPath: projectPath, last: PAGE_SIZE, before },
+ });
+ });
+ });
+
+ describe('when only an after parameter is provided', () => {
+ beforeEach(() => {
+ fetchReleasesGraphQl(vuexParams, { before: undefined, after });
+ });
+
+ it('makes a GraphQl query with first and after variables', () => {
+ expect(gqClient.query).toHaveBeenCalledWith({
+ query: allReleasesQuery,
+ variables: { fullPath: projectPath, first: PAGE_SIZE, after },
+ });
+ });
+ });
+
+ describe('when both before and after parameters are provided', () => {
+ it('throws an error', () => {
+ const callFetchReleasesGraphQl = () => {
+ fetchReleasesGraphQl(vuexParams, { before, after });
+ };
+
+ expect(callFetchReleasesGraphQl).toThrowError(
+ 'Both a `before` and an `after` parameter were provided to fetchReleasesGraphQl. These parameters cannot be used together.',
+ );
+ });
+ });
+ });
+
+ describe('when the request is successful', () => {
+ beforeEach(() => {
+ jest.spyOn(gqClient, 'query').mockResolvedValue(graphqlReleasesResponse);
+ });
+
+ it(`commits ${types.REQUEST_RELEASES} and ${types.RECEIVE_RELEASES_SUCCESS}`, () => {
+ const convertedResponse = convertGraphQLResponse(graphqlReleasesResponse);
+
+ return testAction(
+ fetchReleasesGraphQl,
{},
mockedState,
- [],
[
{
- type: 'requestReleases',
+ type: types.REQUEST_RELEASES,
},
{
- payload: convertGraphQLResponse(graphqlReleasesResponse),
- type: 'receiveReleasesSuccess',
+ type: types.RECEIVE_RELEASES_SUCCESS,
+ payload: {
+ data: convertedResponse.data,
+ graphQlPageInfo: convertedResponse.paginationInfo,
+ },
},
],
- done,
+ [],
);
});
});
- describe('error', () => {
- it('dispatches requestReleases and receiveReleasesError', done => {
- jest.spyOn(gqClient, 'query').mockRejectedValue();
+ describe('when the request fails', () => {
+ beforeEach(() => {
+ jest.spyOn(gqClient, 'query').mockRejectedValue(new Error('Something went wrong!'));
+ });
- testAction(
- fetchReleases,
+ it(`commits ${types.REQUEST_RELEASES} and dispatch receiveReleasesError`, () => {
+ return testAction(
+ fetchReleasesGraphQl,
{},
mockedState,
- [],
[
{
- type: 'requestReleases',
+ type: types.REQUEST_RELEASES,
},
+ ],
+ [
{
type: 'receiveReleasesError',
},
],
- done,
);
});
});
+ });
+
+ describe('fetchReleasesRest', () => {
+ describe('REST query parameters', () => {
+ let vuexParams;
- describe('when the graphqlReleaseData feature flag is disabled', () => {
beforeEach(() => {
- mockedState.featureFlags.graphqlReleasesPage = false;
- });
+ jest
+ .spyOn(api, 'releases')
+ .mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination });
- describe('success', () => {
- it('dispatches requestReleases and receiveReleasesSuccess', done => {
- jest.spyOn(api, 'releases').mockImplementation((id, options) => {
- expect(id).toBe(projectId);
- expect(options.page).toBe('1');
- return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination });
- });
+ vuexParams = { dispatch: jest.fn(), commit: jest.fn(), state: mockedState };
+ });
- testAction(
- fetchReleases,
- {},
- mockedState,
- [],
- [
- {
- type: 'requestReleases',
- },
- {
- payload: { data: releases, headers: pageInfoHeadersWithoutPagination },
- type: 'receiveReleasesSuccess',
- },
- ],
- done,
- );
+ describe('when a page parameter is provided', () => {
+ beforeEach(() => {
+ fetchReleasesRest(vuexParams, { page: 2 });
});
- it('dispatches requestReleases and receiveReleasesSuccess on page two', done => {
- jest.spyOn(api, 'releases').mockImplementation((_, options) => {
- expect(options.page).toBe('2');
- return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination });
- });
-
- testAction(
- fetchReleases,
- { page: '2' },
- mockedState,
- [],
- [
- {
- type: 'requestReleases',
- },
- {
- payload: { data: releases, headers: pageInfoHeadersWithoutPagination },
- type: 'receiveReleasesSuccess',
- },
- ],
- done,
- );
+ it('makes a REST query with a page query parameter', () => {
+ expect(api.releases).toHaveBeenCalledWith(projectId, { page });
});
});
+ });
- describe('error', () => {
- it('dispatches requestReleases and receiveReleasesError', done => {
- jest.spyOn(api, 'releases').mockReturnValue(Promise.reject());
+ describe('when the request is successful', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(api, 'releases')
+ .mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination });
+ });
- testAction(
- fetchReleases,
- {},
- mockedState,
- [],
- [
- {
- type: 'requestReleases',
- },
- {
- type: 'receiveReleasesError',
+ it(`commits ${types.REQUEST_RELEASES} and ${types.RECEIVE_RELEASES_SUCCESS}`, () => {
+ return testAction(
+ fetchReleasesRest,
+ {},
+ mockedState,
+ [
+ {
+ type: types.REQUEST_RELEASES,
+ },
+ {
+ type: types.RECEIVE_RELEASES_SUCCESS,
+ payload: {
+ data: convertObjectPropsToCamelCase(releases, { deep: true }),
+ restPageInfo: parseIntPagination(
+ normalizeHeaders(pageInfoHeadersWithoutPagination),
+ ),
},
- ],
- done,
- );
- });
+ },
+ ],
+ [],
+ );
});
});
- });
- describe('receiveReleasesSuccess', () => {
- it('should commit RECEIVE_RELEASES_SUCCESS mutation', done => {
- testAction(
- receiveReleasesSuccess,
- { data: releases, headers: pageInfoHeadersWithoutPagination },
- mockedState,
- [{ type: types.RECEIVE_RELEASES_SUCCESS, payload: { pageInfo, data: releases } }],
- [],
- done,
- );
+ describe('when the request fails', () => {
+ beforeEach(() => {
+ jest.spyOn(api, 'releases').mockRejectedValue(new Error('Something went wrong!'));
+ });
+
+ it(`commits ${types.REQUEST_RELEASES} and dispatch receiveReleasesError`, () => {
+ return testAction(
+ fetchReleasesRest,
+ {},
+ mockedState,
+ [
+ {
+ type: types.REQUEST_RELEASES,
+ },
+ ],
+ [
+ {
+ type: 'receiveReleasesError',
+ },
+ ],
+ );
+ });
});
});
describe('receiveReleasesError', () => {
- it('should commit RECEIVE_RELEASES_ERROR mutation', done => {
- testAction(
+ it('should commit RECEIVE_RELEASES_ERROR mutation', () => {
+ return testAction(
receiveReleasesError,
null,
mockedState,
[{ type: types.RECEIVE_RELEASES_ERROR }],
[],
- done,
);
});
});
diff --git a/spec/frontend/releases/stores/modules/list/mutations_spec.js b/spec/frontend/releases/stores/modules/list/mutations_spec.js
index 27ad05846e7..914f69ec194 100644
--- a/spec/frontend/releases/stores/modules/list/mutations_spec.js
+++ b/spec/frontend/releases/stores/modules/list/mutations_spec.js
@@ -1,16 +1,29 @@
+import { getJSONFixture } from 'helpers/fixtures';
import createState from '~/releases/stores/modules/list/state';
import mutations from '~/releases/stores/modules/list/mutations';
import * as types from '~/releases/stores/modules/list/mutation_types';
-import { parseIntPagination } from '~/lib/utils/common_utils';
-import { pageInfoHeadersWithoutPagination, releases } from '../../../mock_data';
+import { parseIntPagination, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { pageInfoHeadersWithoutPagination } from '../../../mock_data';
+import { convertGraphQLResponse } from '~/releases/util';
+
+const originalRelease = getJSONFixture('api/releases/release.json');
+const originalReleases = [originalRelease];
+
+const graphqlReleasesResponse = getJSONFixture(
+ 'graphql/releases/queries/all_releases.query.graphql.json',
+);
describe('Releases Store Mutations', () => {
let stateCopy;
- let pageInfo;
+ let restPageInfo;
+ let graphQlPageInfo;
+ let releases;
beforeEach(() => {
stateCopy = createState({});
- pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination);
+ restPageInfo = parseIntPagination(pageInfoHeadersWithoutPagination);
+ graphQlPageInfo = convertGraphQLResponse(graphqlReleasesResponse).paginationInfo;
+ releases = convertObjectPropsToCamelCase(originalReleases, { deep: true });
});
describe('REQUEST_RELEASES', () => {
@@ -23,7 +36,11 @@ describe('Releases Store Mutations', () => {
describe('RECEIVE_RELEASES_SUCCESS', () => {
beforeEach(() => {
- mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { pageInfo, data: releases });
+ mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, {
+ restPageInfo,
+ graphQlPageInfo,
+ data: releases,
+ });
});
it('sets is loading to false', () => {
@@ -38,18 +55,29 @@ describe('Releases Store Mutations', () => {
expect(stateCopy.releases).toEqual(releases);
});
- it('sets pageInfo', () => {
- expect(stateCopy.pageInfo).toEqual(pageInfo);
+ it('sets restPageInfo', () => {
+ expect(stateCopy.restPageInfo).toEqual(restPageInfo);
+ });
+
+ it('sets graphQlPageInfo', () => {
+ expect(stateCopy.graphQlPageInfo).toEqual(graphQlPageInfo);
});
});
describe('RECEIVE_RELEASES_ERROR', () => {
it('resets data', () => {
+ mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, {
+ restPageInfo,
+ graphQlPageInfo,
+ data: releases,
+ });
+
mutations[types.RECEIVE_RELEASES_ERROR](stateCopy);
expect(stateCopy.isLoading).toEqual(false);
expect(stateCopy.releases).toEqual([]);
- expect(stateCopy.pageInfo).toEqual({});
+ expect(stateCopy.restPageInfo).toEqual({});
+ expect(stateCopy.graphQlPageInfo).toEqual({});
});
});
});
diff --git a/spec/frontend/releases/util_spec.js b/spec/frontend/releases/util_spec.js
index f40e5729188..a9d0b61695d 100644
--- a/spec/frontend/releases/util_spec.js
+++ b/spec/frontend/releases/util_spec.js
@@ -1,6 +1,10 @@
import { cloneDeep } from 'lodash';
+import { getJSONFixture } from 'helpers/fixtures';
import { releaseToApiJson, apiJsonToRelease, convertGraphQLResponse } from '~/releases/util';
-import { graphqlReleasesResponse as originalGraphqlReleasesResponse } from './mock_data';
+
+const originalGraphqlReleasesResponse = getJSONFixture(
+ 'graphql/releases/queries/all_releases.query.graphql.json',
+);
describe('releases/util.js', () => {
describe('releaseToApiJson', () => {
diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
index cf2e6b00800..aaa8bf168f2 100644
--- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
+++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
@@ -77,24 +77,31 @@ exports[`Repository last commit component renders commit widget 1`] = `
</gl-link-stub>
</div>
- <div
- class="commit-sha-group d-flex"
+ <gl-button-group-stub
+ class="gl-ml-4 js-commit-sha-group"
>
- <div
- class="label label-monospace monospace"
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ class="gl-font-monospace"
+ data-testid="last-commit-id-label"
+ icon=""
+ label="true"
+ size="medium"
+ variant="default"
>
-
- 12345678
-
- </div>
+ 12345678
+ </gl-button-stub>
<clipboard-button-stub
- cssclass="btn-default"
+ category="secondary"
+ class="input-group-text"
+ size="medium"
text="123456789"
title="Copy commit SHA"
- tooltipplacement="bottom"
+ tooltipplacement="top"
/>
- </div>
+ </gl-button-group-stub>
</div>
</div>
</div>
@@ -181,24 +188,31 @@ exports[`Repository last commit component renders the signature HTML as returned
</gl-link-stub>
</div>
- <div
- class="commit-sha-group d-flex"
+ <gl-button-group-stub
+ class="gl-ml-4 js-commit-sha-group"
>
- <div
- class="label label-monospace monospace"
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ class="gl-font-monospace"
+ data-testid="last-commit-id-label"
+ icon=""
+ label="true"
+ size="medium"
+ variant="default"
>
-
- 12345678
-
- </div>
+ 12345678
+ </gl-button-stub>
<clipboard-button-stub
- cssclass="btn-default"
+ category="secondary"
+ class="input-group-text"
+ size="medium"
text="123456789"
title="Copy commit SHA"
- tooltipplacement="bottom"
+ tooltipplacement="top"
/>
- </div>
+ </gl-button-group-stub>
</div>
</div>
</div>
diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js
index c14a7f0e061..ccba0982c26 100644
--- a/spec/frontend/repository/components/last_commit_spec.js
+++ b/spec/frontend/repository/components/last_commit_spec.js
@@ -78,7 +78,7 @@ describe('Repository last commit component', () => {
factory();
return vm.vm.$nextTick(() => {
- expect(vm.find('.label-monospace').text()).toEqual('12345678');
+ expect(vm.find('[data-testid="last-commit-id-label"]').text()).toEqual('12345678');
});
});
diff --git a/spec/frontend/repository/log_tree_spec.js b/spec/frontend/repository/log_tree_spec.js
index 954424b5c8a..ddc95feccd6 100644
--- a/spec/frontend/repository/log_tree_spec.js
+++ b/spec/frontend/repository/log_tree_spec.js
@@ -84,6 +84,14 @@ describe('fetchLogsTree', () => {
expect(axios.get.mock.calls.length).toEqual(1);
}));
+ it('calls axios for each path', () =>
+ Promise.all([
+ fetchLogsTree(client, '', '0', resolver),
+ fetchLogsTree(client, '/test', '0', resolver),
+ ]).then(() => {
+ expect(axios.get.mock.calls.length).toEqual(2);
+ }));
+
it('calls entry resolver', () =>
fetchLogsTree(client, '', '0', resolver).then(() => {
expect(resolver.resolve).toHaveBeenCalledWith(
diff --git a/spec/frontend/right_sidebar_spec.js b/spec/frontend/right_sidebar_spec.js
index d80d80152a5..3490a99afb4 100644
--- a/spec/frontend/right_sidebar_spec.js
+++ b/spec/frontend/right_sidebar_spec.js
@@ -6,7 +6,9 @@ import Sidebar from '~/right_sidebar';
let $aside = null;
let $toggle = null;
-let $icon = null;
+let $toggleContainer = null;
+let $expandIcon = null;
+let $collapseIcon = null;
let $page = null;
let $labelsIcon = null;
@@ -15,10 +17,11 @@ const assertSidebarState = state => {
const shouldBeCollapsed = state === 'collapsed';
expect($aside.hasClass('right-sidebar-expanded')).toBe(shouldBeExpanded);
expect($page.hasClass('right-sidebar-expanded')).toBe(shouldBeExpanded);
- expect($icon.hasClass('fa-angle-double-right')).toBe(shouldBeExpanded);
+ expect($toggleContainer.data('is-expanded')).toBe(shouldBeExpanded);
+ expect($expandIcon.hasClass('hidden')).toBe(shouldBeExpanded);
expect($aside.hasClass('right-sidebar-collapsed')).toBe(shouldBeCollapsed);
expect($page.hasClass('right-sidebar-collapsed')).toBe(shouldBeCollapsed);
- expect($icon.hasClass('fa-angle-double-left')).toBe(shouldBeCollapsed);
+ expect($collapseIcon.hasClass('hidden')).toBe(shouldBeCollapsed);
};
describe('RightSidebar', () => {
@@ -33,7 +36,9 @@ describe('RightSidebar', () => {
new Sidebar(); // eslint-disable-line no-new
$aside = $('.right-sidebar');
$page = $('.layout-page');
- $icon = $aside.find('i');
+ $toggleContainer = $('.js-sidebar-toggle-container');
+ $expandIcon = $aside.find('.js-sidebar-expand');
+ $collapseIcon = $aside.find('.js-sidebar-collapse');
$toggle = $aside.find('.js-sidebar-toggle');
$labelsIcon = $aside.find('.sidebar-collapsed-icon');
});
diff --git a/spec/frontend/search/components/dropdown_filter_spec.js b/spec/frontend/search/components/dropdown_filter_spec.js
new file mode 100644
index 00000000000..ffac038e1c5
--- /dev/null
+++ b/spec/frontend/search/components/dropdown_filter_spec.js
@@ -0,0 +1,108 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import DropdownFilter from '~/search/components/dropdown_filter.vue';
+import {
+ FILTER_STATES,
+ FILTER_STATES_BY_SCOPE,
+ FILTER_HEADER,
+ SCOPES,
+} from '~/search/state_filter/constants';
+import * as urlUtils from '~/lib/utils/url_utility';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ visitUrl: jest.fn(),
+ setUrlParams: jest.fn(),
+}));
+
+function createComponent(props = { scope: 'issues' }) {
+ return shallowMount(DropdownFilter, {
+ propsData: {
+ filtersArray: FILTER_STATES_BY_SCOPE.issues,
+ filters: FILTER_STATES,
+ header: FILTER_HEADER,
+ param: 'state',
+ supportedScopes: Object.values(SCOPES),
+ ...props,
+ },
+ });
+}
+
+describe('DropdownFilter', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findGlDropdown = () => wrapper.find(GlDropdown);
+ const findGlDropdownItems = () => findGlDropdown().findAll(GlDropdownItem);
+ const findDropdownItemsText = () => findGlDropdownItems().wrappers.map(w => w.text());
+ const firstDropDownItem = () => findGlDropdownItems().at(0);
+
+ describe('template', () => {
+ describe.each`
+ scope | showDropdown
+ ${'issues'} | ${true}
+ ${'merge_requests'} | ${true}
+ ${'projects'} | ${false}
+ ${'milestones'} | ${false}
+ ${'users'} | ${false}
+ ${'notes'} | ${false}
+ ${'wiki_blobs'} | ${false}
+ ${'blobs'} | ${false}
+ `(`dropdown`, ({ scope, showDropdown }) => {
+ beforeEach(() => {
+ wrapper = createComponent({ scope });
+ });
+
+ it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => {
+ expect(findGlDropdown().exists()).toBe(showDropdown);
+ });
+ });
+
+ describe.each`
+ initialFilter | label
+ ${FILTER_STATES.ANY.value} | ${`Any ${FILTER_HEADER}`}
+ ${FILTER_STATES.OPEN.value} | ${FILTER_STATES.OPEN.label}
+ ${FILTER_STATES.CLOSED.value} | ${FILTER_STATES.CLOSED.label}
+ `(`filter text`, ({ initialFilter, label }) => {
+ describe(`when initialFilter is ${initialFilter}`, () => {
+ beforeEach(() => {
+ wrapper = createComponent({ scope: 'issues', initialFilter });
+ });
+
+ it(`sets dropdown label to ${label}`, () => {
+ expect(findGlDropdown().attributes('text')).toBe(label);
+ });
+ });
+ });
+
+ describe('Filter options', () => {
+ it('renders a dropdown item for each filterOption', () => {
+ expect(findDropdownItemsText()).toStrictEqual(
+ FILTER_STATES_BY_SCOPE[SCOPES.ISSUES].map(v => {
+ return v.label;
+ }),
+ );
+ });
+
+ it('clicking a dropdown item calls setUrlParams', () => {
+ const state = FILTER_STATES[Object.keys(FILTER_STATES)[0]].value;
+ firstDropDownItem().vm.$emit('click');
+
+ expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ state });
+ });
+
+ it('clicking a dropdown item calls visitUrl', () => {
+ firstDropDownItem().vm.$emit('click');
+
+ expect(urlUtils.visitUrl).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/search/components/state_filter_spec.js b/spec/frontend/search/components/state_filter_spec.js
deleted file mode 100644
index 26344f2b592..00000000000
--- a/spec/frontend/search/components/state_filter_spec.js
+++ /dev/null
@@ -1,104 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import StateFilter from '~/search/state_filter/components/state_filter.vue';
-import {
- FILTER_STATES,
- SCOPES,
- FILTER_STATES_BY_SCOPE,
- FILTER_TEXT,
-} from '~/search/state_filter/constants';
-import * as urlUtils from '~/lib/utils/url_utility';
-
-jest.mock('~/lib/utils/url_utility', () => ({
- visitUrl: jest.fn(),
- setUrlParams: jest.fn(),
-}));
-
-function createComponent(props = { scope: 'issues' }) {
- return shallowMount(StateFilter, {
- propsData: {
- ...props,
- },
- });
-}
-
-describe('StateFilter', () => {
- let wrapper;
-
- beforeEach(() => {
- wrapper = createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- const findGlDropdown = () => wrapper.find(GlDropdown);
- const findGlDropdownItems = () => findGlDropdown().findAll(GlDropdownItem);
- const findDropdownItemsText = () => findGlDropdownItems().wrappers.map(w => w.text());
- const firstDropDownItem = () => findGlDropdownItems().at(0);
-
- describe('template', () => {
- describe.each`
- scope | showStateDropdown
- ${'issues'} | ${true}
- ${'merge_requests'} | ${true}
- ${'projects'} | ${false}
- ${'milestones'} | ${false}
- ${'users'} | ${false}
- ${'notes'} | ${false}
- ${'wiki_blobs'} | ${false}
- ${'blobs'} | ${false}
- `(`state dropdown`, ({ scope, showStateDropdown }) => {
- beforeEach(() => {
- wrapper = createComponent({ scope });
- });
-
- it(`does${showStateDropdown ? '' : ' not'} render when scope is ${scope}`, () => {
- expect(findGlDropdown().exists()).toBe(showStateDropdown);
- });
- });
-
- describe.each`
- state | label
- ${FILTER_STATES.ANY.value} | ${FILTER_TEXT}
- ${FILTER_STATES.OPEN.value} | ${FILTER_STATES.OPEN.label}
- ${FILTER_STATES.CLOSED.value} | ${FILTER_STATES.CLOSED.label}
- ${FILTER_STATES.MERGED.value} | ${FILTER_STATES.MERGED.label}
- `(`filter text`, ({ state, label }) => {
- describe(`when state is ${state}`, () => {
- beforeEach(() => {
- wrapper = createComponent({ scope: 'issues', state });
- });
-
- it(`sets dropdown label to ${label}`, () => {
- expect(findGlDropdown().attributes('text')).toBe(label);
- });
- });
- });
-
- describe('Filter options', () => {
- it('renders a dropdown item for each filterOption', () => {
- expect(findDropdownItemsText()).toStrictEqual(
- FILTER_STATES_BY_SCOPE[SCOPES.ISSUES].map(v => {
- return v.label;
- }),
- );
- });
-
- it('clicking a dropdown item calls setUrlParams', () => {
- const state = FILTER_STATES[Object.keys(FILTER_STATES)[0]].value;
- firstDropDownItem().vm.$emit('click');
-
- expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ state });
- });
-
- it('clicking a dropdown item calls visitUrl', () => {
- firstDropDownItem().vm.$emit('click');
-
- expect(urlUtils.visitUrl).toHaveBeenCalled();
- });
- });
- });
-});
diff --git a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap
index f4ac2f57261..02d5ca6bdb3 100644
--- a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap
+++ b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap
@@ -15,13 +15,16 @@ exports[`self monitor component When the self monitor project has not been creat
</h4>
- <gl-deprecated-button-stub
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
class="js-settings-toggle"
- size="md"
- variant="secondary"
+ icon=""
+ size="medium"
+ variant="default"
>
Expand
- </gl-deprecated-button-stub>
+ </gl-button-stub>
<p
class="js-section-sub-header"
@@ -56,6 +59,7 @@ exports[`self monitor component When the self monitor project has not been creat
<gl-modal-stub
cancel-title="Cancel"
+ category="primary"
modalclass=""
modalid="delete-self-monitor-modal"
ok-title="Delete project"
diff --git a/spec/frontend/self_monitor/components/self_monitor_form_spec.js b/spec/frontend/self_monitor/components/self_monitor_form_spec.js
index ec5f7b0a394..618cc16cdf4 100644
--- a/spec/frontend/self_monitor/components/self_monitor_form_spec.js
+++ b/spec/frontend/self_monitor/components/self_monitor_form_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedButton } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants';
import SelfMonitor from '~/self_monitor/components/self_monitor_form.vue';
import { createStore } from '~/self_monitor/store';
@@ -42,7 +42,7 @@ describe('self monitor component', () => {
it('renders as an expand button by default', () => {
wrapper = shallowMount(SelfMonitor, { store });
- const button = wrapper.find(GlDeprecatedButton);
+ const button = wrapper.find(GlButton);
expect(button.text()).toBe('Expand');
});
diff --git a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap
index 22689080063..6b3d65ff037 100644
--- a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap
+++ b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap
@@ -11,7 +11,7 @@ exports[`EmptyStateComponent should render content 1`] = `
<p>In order to start using functions as a service, you must first install Knative on your Kubernetes cluster. <gl-link-stub href=\\"/help\\">More information</gl-link-stub>
</p>
<div>
- <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" href=\\"/clusters\\">Install Knative</gl-button-stub>
+ <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" href=\\"/clusters\\">Install Knative</gl-button-stub>
<!---->
</div>
</div>
diff --git a/spec/frontend/serverless/components/missing_prometheus_spec.js b/spec/frontend/serverless/components/missing_prometheus_spec.js
index 9ca4a45dd5f..0bd2e96a068 100644
--- a/spec/frontend/serverless/components/missing_prometheus_spec.js
+++ b/spec/frontend/serverless/components/missing_prometheus_spec.js
@@ -1,4 +1,4 @@
-import { GlDeprecatedButton } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { createStore } from '~/serverless/store';
import missingPrometheusComponent from '~/serverless/components/missing_prometheus.vue';
@@ -24,7 +24,7 @@ describe('missingPrometheusComponent', () => {
'Function invocation metrics require Prometheus to be installed first.',
);
- expect(wrapper.find(GlDeprecatedButton).attributes('variant')).toBe('success');
+ expect(wrapper.find(GlButton).attributes('variant')).toBe('success');
});
it('should render no prometheus data message', () => {
diff --git a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js
index 2f11c6a07c2..8c868205295 100644
--- a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js
+++ b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js
@@ -1,5 +1,4 @@
import { shallowMount } from '@vue/test-utils';
-import { GlLoadingIcon } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import EditFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue';
import eventHub from '~/sidebar/event_hub';
@@ -56,11 +55,11 @@ describe('Edit Form Buttons', () => {
});
it('disables the toggle button', () => {
- expect(findConfidentialToggle().attributes('disabled')).toBe('disabled');
+ expect(findConfidentialToggle().props('disabled')).toBe(true);
});
- it('finds the GlLoadingIcon', () => {
- expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ it('sets loading on the toggle button', () => {
+ expect(findConfidentialToggle().props('loading')).toBe(true);
});
});
@@ -99,7 +98,7 @@ describe('Edit Form Buttons', () => {
describe('when succeeds', () => {
beforeEach(() => {
createComponent({ data: { isLoading: false }, props: { confidential: true } });
- findConfidentialToggle().trigger('click');
+ findConfidentialToggle().vm.$emit('click', new Event('click'));
});
it('dispatches the correct action', () => {
@@ -109,9 +108,9 @@ describe('Edit Form Buttons', () => {
});
});
- it('resets loading', () => {
+ it('resets loading on the toggle button', () => {
return waitForPromises().then(() => {
- expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ expect(findConfidentialToggle().props('loading')).toBe(false);
});
});
@@ -135,7 +134,7 @@ describe('Edit Form Buttons', () => {
props: { confidential: true },
resolved: false,
});
- findConfidentialToggle().trigger('click');
+ findConfidentialToggle().vm.$emit('click', new Event('click'));
});
it('calls flash with the correct message', () => {
diff --git a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js
index de1da3456f8..913646c8f8d 100644
--- a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js
+++ b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js
@@ -1,5 +1,4 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
import EditFormButtons from '~/sidebar/components/lock/edit_form_buttons.vue';
import eventHub from '~/sidebar/event_hub';
import { deprecatedCreateFlash as flash } from '~/flash';
@@ -22,7 +21,6 @@ describe('EditFormButtons', () => {
};
const findLockToggle = () => wrapper.find('[data-testid="lock-toggle"]');
- const findGlLoadingIcon = () => wrapper.find(GlLoadingIcon);
const createComponent = ({ props = {}, data = {}, resolved = true }) => {
store = issuableType === ISSUABLE_TYPE_ISSUE ? createStore() : createMrStore();
@@ -33,7 +31,7 @@ describe('EditFormButtons', () => {
jest.spyOn(store, 'dispatch').mockRejectedValue();
}
- wrapper = shallowMount(EditFormButtons, {
+ wrapper = mount(EditFormButtons, {
store,
provide: {
fullPath: '',
@@ -78,8 +76,8 @@ describe('EditFormButtons', () => {
expect(findLockToggle().attributes('disabled')).toBe('disabled');
});
- it('displays the GlLoadingIcon', () => {
- expect(findGlLoadingIcon().exists()).toBe(true);
+ it('sets loading on the toggle button', () => {
+ expect(findLockToggle().props('loading')).toBe(true);
});
});
@@ -121,7 +119,7 @@ describe('EditFormButtons', () => {
it('resets loading', async () => {
await wrapper.vm.$nextTick().then(() => {
- expect(findGlLoadingIcon().exists()).toBe(false);
+ expect(findLockToggle().props('loading')).toBe(false);
});
});
@@ -156,7 +154,7 @@ describe('EditFormButtons', () => {
it('resets loading', async () => {
await wrapper.vm.$nextTick().then(() => {
- expect(findGlLoadingIcon().exists()).toBe(false);
+ expect(findLockToggle().props('loading')).toBe(false);
});
});
diff --git a/spec/frontend/sidebar/reviewer_title_spec.js b/spec/frontend/sidebar/reviewer_title_spec.js
new file mode 100644
index 00000000000..eae266688d5
--- /dev/null
+++ b/spec/frontend/sidebar/reviewer_title_spec.js
@@ -0,0 +1,116 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { mockTracking, triggerEvent } from 'helpers/tracking_helper';
+import Component from '~/sidebar/components/reviewers/reviewer_title.vue';
+
+describe('ReviewerTitle component', () => {
+ let wrapper;
+
+ const createComponent = props => {
+ return shallowMount(Component, {
+ propsData: {
+ numberOfReviewers: 0,
+ editable: false,
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('reviewer title', () => {
+ it('renders reviewer', () => {
+ wrapper = createComponent({
+ numberOfReviewers: 1,
+ editable: false,
+ });
+
+ expect(wrapper.vm.$el.innerText.trim()).toEqual('Reviewer');
+ });
+
+ it('renders 2 reviewers', () => {
+ wrapper = createComponent({
+ numberOfReviewers: 2,
+ editable: false,
+ });
+
+ expect(wrapper.vm.$el.innerText.trim()).toEqual('2 Reviewers');
+ });
+ });
+
+ describe('gutter toggle', () => {
+ it('does not show toggle by default', () => {
+ wrapper = createComponent({
+ numberOfReviewers: 2,
+ editable: false,
+ });
+
+ expect(wrapper.vm.$el.querySelector('.gutter-toggle')).toBeNull();
+ });
+
+ it('shows toggle when showToggle is true', () => {
+ wrapper = createComponent({
+ numberOfReviewers: 2,
+ editable: false,
+ showToggle: true,
+ });
+
+ expect(wrapper.vm.$el.querySelector('.gutter-toggle')).toEqual(expect.any(Object));
+ });
+ });
+
+ it('does not render spinner by default', () => {
+ wrapper = createComponent({
+ numberOfReviewers: 0,
+ editable: false,
+ });
+
+ expect(wrapper.find(GlLoadingIcon).exists()).toBeFalsy();
+ });
+
+ it('renders spinner when loading', () => {
+ wrapper = createComponent({
+ loading: true,
+ numberOfReviewers: 0,
+ editable: false,
+ });
+
+ expect(wrapper.find(GlLoadingIcon).exists()).toBeTruthy();
+ });
+
+ it('does not render edit link when not editable', () => {
+ wrapper = createComponent({
+ numberOfReviewers: 0,
+ editable: false,
+ });
+
+ expect(wrapper.vm.$el.querySelector('.edit-link')).toBeNull();
+ });
+
+ it('renders edit link when editable', () => {
+ wrapper = createComponent({
+ numberOfReviewers: 0,
+ editable: true,
+ });
+
+ expect(wrapper.vm.$el.querySelector('.edit-link')).not.toBeNull();
+ });
+
+ it('tracks the event when edit is clicked', () => {
+ wrapper = createComponent({
+ numberOfReviewers: 0,
+ editable: true,
+ });
+
+ const spy = mockTracking('_category_', wrapper.element, jest.spyOn);
+ triggerEvent('.js-sidebar-dropdown-toggle');
+
+ expect(spy).toHaveBeenCalledWith('_category_', 'click_edit_button', {
+ label: 'right_sidebar',
+ property: 'reviewer',
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/reviewers_spec.js b/spec/frontend/sidebar/reviewers_spec.js
new file mode 100644
index 00000000000..effcac266f0
--- /dev/null
+++ b/spec/frontend/sidebar/reviewers_spec.js
@@ -0,0 +1,169 @@
+import { mount } from '@vue/test-utils';
+import { trimText } from 'helpers/text_helper';
+import { GlIcon } from '@gitlab/ui';
+import Reviewer from '~/sidebar/components/reviewers/reviewers.vue';
+import UsersMock from './mock_data';
+import UsersMockHelper from '../helpers/user_mock_data_helper';
+
+describe('Reviewer component', () => {
+ const getDefaultProps = () => ({
+ rootPath: 'http://localhost:3000',
+ users: [],
+ editable: false,
+ });
+ let wrapper;
+
+ const createWrapper = (propsData = getDefaultProps()) => {
+ wrapper = mount(Reviewer, {
+ propsData,
+ });
+ };
+
+ const findCollapsedChildren = () => wrapper.findAll('.sidebar-collapsed-icon > *');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('No reviewers/users', () => {
+ it('displays no reviewer icon when collapsed', () => {
+ createWrapper();
+ const collapsedChildren = findCollapsedChildren();
+ const userIcon = collapsedChildren.at(0).find(GlIcon);
+
+ expect(collapsedChildren.length).toBe(1);
+ expect(collapsedChildren.at(0).attributes('aria-label')).toBe('None');
+ expect(userIcon.exists()).toBe(true);
+ expect(userIcon.props('name')).toBe('user');
+ });
+ });
+
+ describe('One reviewer/user', () => {
+ it('displays one reviewer icon when collapsed', () => {
+ createWrapper({
+ ...getDefaultProps(),
+ users: [UsersMock.user],
+ });
+
+ const collapsedChildren = findCollapsedChildren();
+ const reviewer = collapsedChildren.at(0);
+
+ expect(collapsedChildren.length).toBe(1);
+ expect(reviewer.find('.avatar').attributes('src')).toBe(UsersMock.user.avatar);
+ expect(reviewer.find('.avatar').attributes('alt')).toBe(`${UsersMock.user.name}'s avatar`);
+
+ expect(trimText(reviewer.find('.author').text())).toBe(UsersMock.user.name);
+ });
+ });
+
+ describe('Two or more reviewers/users', () => {
+ it('displays two reviewer icons when collapsed', () => {
+ const users = UsersMockHelper.createNumberRandomUsers(2);
+ createWrapper({
+ ...getDefaultProps(),
+ users,
+ });
+
+ const collapsedChildren = findCollapsedChildren();
+
+ expect(collapsedChildren.length).toBe(2);
+
+ const first = collapsedChildren.at(0);
+
+ expect(first.find('.avatar').attributes('src')).toBe(users[0].avatar_url);
+ expect(first.find('.avatar').attributes('alt')).toBe(`${users[0].name}'s avatar`);
+
+ expect(trimText(first.find('.author').text())).toBe(users[0].name);
+
+ const second = collapsedChildren.at(1);
+
+ expect(second.find('.avatar').attributes('src')).toBe(users[1].avatar_url);
+ expect(second.find('.avatar').attributes('alt')).toBe(`${users[1].name}'s avatar`);
+
+ expect(trimText(second.find('.author').text())).toBe(users[1].name);
+ });
+
+ it('displays one reviewer icon and counter when collapsed', () => {
+ const users = UsersMockHelper.createNumberRandomUsers(3);
+ createWrapper({
+ ...getDefaultProps(),
+ users,
+ });
+
+ const collapsedChildren = findCollapsedChildren();
+
+ expect(collapsedChildren.length).toBe(2);
+
+ const first = collapsedChildren.at(0);
+
+ expect(first.find('.avatar').attributes('src')).toBe(users[0].avatar_url);
+ expect(first.find('.avatar').attributes('alt')).toBe(`${users[0].name}'s avatar`);
+
+ expect(trimText(first.find('.author').text())).toBe(users[0].name);
+
+ const second = collapsedChildren.at(1);
+
+ expect(trimText(second.find('.avatar-counter').text())).toBe('+2');
+ });
+
+ it('Shows two reviewers', () => {
+ const users = UsersMockHelper.createNumberRandomUsers(2);
+ createWrapper({
+ ...getDefaultProps(),
+ users,
+ editable: true,
+ });
+
+ expect(wrapper.findAll('.user-item').length).toBe(users.length);
+ expect(wrapper.find('.user-list-more').exists()).toBe(false);
+ });
+
+ it('shows sorted reviewer where "can merge" users are sorted first', () => {
+ const users = UsersMockHelper.createNumberRandomUsers(3);
+ users[0].can_merge = false;
+ users[1].can_merge = false;
+ users[2].can_merge = true;
+
+ createWrapper({
+ ...getDefaultProps(),
+ users,
+ editable: true,
+ });
+
+ expect(wrapper.vm.sortedReviewers[0].can_merge).toBe(true);
+ });
+
+ it('passes the sorted reviewers to the uncollapsed-reviewer-list', () => {
+ const users = UsersMockHelper.createNumberRandomUsers(3);
+ users[0].can_merge = false;
+ users[1].can_merge = false;
+ users[2].can_merge = true;
+
+ createWrapper({
+ ...getDefaultProps(),
+ users,
+ });
+
+ const userItems = wrapper.findAll('.user-list .user-item a');
+
+ expect(userItems.length).toBe(3);
+ expect(userItems.at(0).attributes('title')).toBe(users[2].name);
+ });
+
+ it('passes the sorted reviewers to the collapsed-reviewer-list', () => {
+ const users = UsersMockHelper.createNumberRandomUsers(3);
+ users[0].can_merge = false;
+ users[1].can_merge = false;
+ users[2].can_merge = true;
+
+ createWrapper({
+ ...getDefaultProps(),
+ users,
+ });
+
+ const collapsedButton = wrapper.find('.sidebar-collapsed-user button');
+
+ expect(trimText(collapsedButton.text())).toBe(users[2].name);
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js
index 29333a344e1..9d59dc750fb 100644
--- a/spec/frontend/sidebar/sidebar_labels_spec.js
+++ b/spec/frontend/sidebar/sidebar_labels_spec.js
@@ -114,7 +114,7 @@ describe('sidebar labels', () => {
const expected = {
[defaultProps.issuableType]: {
- label_ids: [27, 28, 40],
+ label_ids: [27, 28, 29, 40],
},
};
diff --git a/spec/frontend/snippet/snippet_edit_spec.js b/spec/frontend/snippet/snippet_edit_spec.js
index 7c12c0cac03..42a55ac0d3e 100644
--- a/spec/frontend/snippet/snippet_edit_spec.js
+++ b/spec/frontend/snippet/snippet_edit_spec.js
@@ -5,6 +5,7 @@ import initSnippet from '~/snippet/snippet_bundle';
jest.mock('~/snippet/snippet_bundle');
jest.mock('~/snippets');
+jest.mock('~/gl_form');
describe('Snippet edit form initialization', () => {
const setFF = flag => {
diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap
index 1cf1ee74ddf..e742a6b9eaf 100644
--- a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap
+++ b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap
@@ -3,6 +3,7 @@
exports[`Snippet Blob Edit component with loaded blob matches snapshot 1`] = `
<div
class="file-holder snippet"
+ data-qa-selector="file_holder_container"
>
<blob-header-edit-stub
candelete="true"
diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js
index b6abb9f389a..c1fad8cebe6 100644
--- a/spec/frontend/snippets/components/edit_spec.js
+++ b/spec/frontend/snippets/components/edit_spec.js
@@ -148,17 +148,17 @@ describe('Snippet Edit app', () => {
// Ideally we wouldn't call this method directly, but we don't have a way to trigger
// apollo responses yet.
- const loadSnippet = (...edges) => {
- if (edges.length) {
+ const loadSnippet = (...nodes) => {
+ if (nodes.length) {
wrapper.setData({
- snippet: edges[0],
+ snippet: nodes[0],
});
}
wrapper.vm.onSnippetFetch({
data: {
snippets: {
- edges,
+ nodes,
},
},
});
diff --git a/spec/frontend/snippets/components/snippet_blob_view_spec.js b/spec/frontend/snippets/components/snippet_blob_view_spec.js
index 9c4b2734a3f..1ccecd7b5ba 100644
--- a/spec/frontend/snippets/components/snippet_blob_view_spec.js
+++ b/spec/frontend/snippets/components/snippet_blob_view_spec.js
@@ -140,10 +140,10 @@ describe('Blob Embeddable', () => {
async ({ snippetBlobs, currentBlob, expectedContent }) => {
const apolloData = {
snippets: {
- edges: [
+ nodes: [
{
- node: {
- blobs: snippetBlobs,
+ blobs: {
+ nodes: snippetBlobs,
},
},
],
diff --git a/spec/frontend/static_site_editor/mock_data.js b/spec/frontend/static_site_editor/mock_data.js
index d861f6c9cd7..0f2456cd9ea 100644
--- a/spec/frontend/static_site_editor/mock_data.js
+++ b/spec/frontend/static_site_editor/mock_data.js
@@ -23,7 +23,10 @@ export const username = 'gitlabuser';
export const projectId = '123456';
export const returnUrl = 'https://www.gitlab.com';
export const sourcePath = 'foobar.md.html';
-
+export const mergeRequestMeta = {
+ title: `Update ${sourcePath} file`,
+ description: 'Copy update',
+};
export const savedContentMeta = {
branch: {
label: 'foobar',
diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js
index 41f8a1075c0..10d34d9651c 100644
--- a/spec/frontend/static_site_editor/pages/home_spec.js
+++ b/spec/frontend/static_site_editor/pages/home_spec.js
@@ -1,4 +1,3 @@
-import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import Home from '~/static_site_editor/pages/home.vue';
@@ -7,6 +6,7 @@ import EditArea from '~/static_site_editor/components/edit_area.vue';
import InvalidContentMessage from '~/static_site_editor/components/invalid_content_message.vue';
import SubmitChangesError from '~/static_site_editor/components/submit_changes_error.vue';
import submitContentChangesMutation from '~/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql';
+import hasSubmittedChangesMutation from '~/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql';
import { SUCCESS_ROUTE } from '~/static_site_editor/router/constants';
import { TRACKING_ACTION_INITIALIZE_EDITOR } from '~/static_site_editor/constants';
@@ -17,6 +17,7 @@ import {
sourceContentTitle as title,
sourcePath,
username,
+ mergeRequestMeta,
savedContentMeta,
submitChangesError,
trackingCategory,
@@ -24,8 +25,6 @@ import {
const localVue = createLocalVue();
-localVue.use(Vuex);
-
describe('static_site_editor/pages/home', () => {
let wrapper;
let store;
@@ -33,6 +32,19 @@ describe('static_site_editor/pages/home', () => {
let $router;
let mutateMock;
let trackingSpy;
+ const defaultAppData = {
+ isSupportedContent: true,
+ hasSubmittedChanges: false,
+ returnUrl,
+ project,
+ username,
+ sourcePath,
+ };
+ const hasSubmittedChangesMutationPayload = {
+ data: {
+ appData: { ...defaultAppData, hasSubmittedChanges: true },
+ },
+ };
const buildApollo = (queries = {}) => {
mutateMock = jest.fn();
@@ -64,7 +76,7 @@ describe('static_site_editor/pages/home', () => {
},
data() {
return {
- appData: { isSupportedContent: true, returnUrl, project, username, sourcePath },
+ appData: { ...defaultAppData },
sourceContent: { title, content },
...data,
};
@@ -152,8 +164,14 @@ describe('static_site_editor/pages/home', () => {
});
describe('when submitting changes fails', () => {
+ const setupMutateMock = () => {
+ mutateMock
+ .mockResolvedValueOnce(hasSubmittedChangesMutationPayload)
+ .mockRejectedValueOnce(new Error(submitChangesError));
+ };
+
beforeEach(() => {
- mutateMock.mockRejectedValue(new Error(submitChangesError));
+ setupMutateMock();
buildWrapper();
findEditArea().vm.$emit('submit', { content });
@@ -166,6 +184,8 @@ describe('static_site_editor/pages/home', () => {
});
it('retries submitting changes when retry button is clicked', () => {
+ setupMutateMock();
+
findSubmitChangesError().vm.$emit('retry');
expect(mutateMock).toHaveBeenCalled();
@@ -190,7 +210,11 @@ describe('static_site_editor/pages/home', () => {
const newContent = `new ${content}`;
beforeEach(() => {
- mutateMock.mockResolvedValueOnce({ data: { submitContentChanges: savedContentMeta } });
+ mutateMock.mockResolvedValueOnce(hasSubmittedChangesMutationPayload).mockResolvedValueOnce({
+ data: {
+ submitContentChanges: savedContentMeta,
+ },
+ });
buildWrapper();
findEditArea().vm.$emit('submit', { content: newContent });
@@ -198,8 +222,19 @@ describe('static_site_editor/pages/home', () => {
return wrapper.vm.$nextTick();
});
+ it('dispatches hasSubmittedChanges mutation', () => {
+ expect(mutateMock).toHaveBeenNthCalledWith(1, {
+ mutation: hasSubmittedChangesMutation,
+ variables: {
+ input: {
+ hasSubmittedChanges: true,
+ },
+ },
+ });
+ });
+
it('dispatches submitContentChanges mutation', () => {
- expect(mutateMock).toHaveBeenCalledWith({
+ expect(mutateMock).toHaveBeenNthCalledWith(2, {
mutation: submitContentChangesMutation,
variables: {
input: {
@@ -207,6 +242,8 @@ describe('static_site_editor/pages/home', () => {
project,
sourcePath,
username,
+ images: undefined,
+ mergeRequestMeta,
},
},
});
diff --git a/spec/frontend/static_site_editor/pages/success_spec.js b/spec/frontend/static_site_editor/pages/success_spec.js
index 3e19e2413e7..3fc69dc4586 100644
--- a/spec/frontend/static_site_editor/pages/success_spec.js
+++ b/spec/frontend/static_site_editor/pages/success_spec.js
@@ -1,10 +1,10 @@
import { shallowMount } from '@vue/test-utils';
-import { GlEmptyState, GlButton } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import Success from '~/static_site_editor/pages/success.vue';
import { savedContentMeta, returnUrl, sourcePath } from '../mock_data';
import { HOME_ROUTE } from '~/static_site_editor/router/constants';
-describe('static_site_editor/pages/success', () => {
+describe('~/static_site_editor/pages/success.vue', () => {
const mergeRequestsIllustrationPath = 'illustrations/merge_requests.svg';
let wrapper;
let router;
@@ -15,14 +15,15 @@ describe('static_site_editor/pages/success', () => {
};
};
- const buildWrapper = (data = {}) => {
+ const buildWrapper = (data = {}, appData = {}) => {
wrapper = shallowMount(Success, {
mocks: {
$router: router,
},
stubs: {
- GlEmptyState,
GlButton,
+ GlEmptyState,
+ GlLoadingIcon,
},
propsData: {
mergeRequestsIllustrationPath,
@@ -33,6 +34,8 @@ describe('static_site_editor/pages/success', () => {
appData: {
returnUrl,
sourcePath,
+ hasSubmittedChanges: true,
+ ...appData,
},
...data,
};
@@ -40,8 +43,9 @@ describe('static_site_editor/pages/success', () => {
});
};
- const findEmptyState = () => wrapper.find(GlEmptyState);
const findReturnUrlButton = () => wrapper.find(GlButton);
+ const findEmptyState = () => wrapper.find(GlEmptyState);
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
beforeEach(() => {
buildRouter();
@@ -52,50 +56,76 @@ describe('static_site_editor/pages/success', () => {
wrapper = null;
});
- it('renders empty state with a link to the created merge request', () => {
- buildWrapper();
+ describe('when savedContentMeta is valid', () => {
+ it('renders empty state with a link to the created merge request', () => {
+ buildWrapper();
+
+ expect(findEmptyState().exists()).toBe(true);
+ expect(findEmptyState().props()).toMatchObject({
+ primaryButtonText: 'View merge request',
+ primaryButtonLink: savedContentMeta.mergeRequest.url,
+ title: 'Your merge request has been created',
+ svgPath: mergeRequestsIllustrationPath,
+ svgHeight: 146,
+ });
+ });
- expect(findEmptyState().exists()).toBe(true);
- expect(findEmptyState().props()).toMatchObject({
- primaryButtonText: 'View merge request',
- primaryButtonLink: savedContentMeta.mergeRequest.url,
- title: 'Your merge request has been created',
- svgPath: mergeRequestsIllustrationPath,
+ it('displays merge request instructions in the empty state', () => {
+ buildWrapper();
+
+ expect(findEmptyState().text()).toContain(
+ 'To see your changes live you will need to do the following things:',
+ );
+ expect(findEmptyState().text()).toContain('1. Add a clear title to describe the change.');
+ expect(findEmptyState().text()).toContain(
+ '2. Add a description to explain why the change is being made.',
+ );
+ expect(findEmptyState().text()).toContain(
+ '3. Assign a person to review and accept the merge request.',
+ );
});
- });
- it('displays merge request instructions in the empty state', () => {
- buildWrapper();
-
- expect(findEmptyState().text()).toContain(
- 'To see your changes live you will need to do the following things:',
- );
- expect(findEmptyState().text()).toContain('1. Add a clear title to describe the change.');
- expect(findEmptyState().text()).toContain(
- '2. Add a description to explain why the change is being made.',
- );
- expect(findEmptyState().text()).toContain(
- '3. Assign a person to review and accept the merge request.',
- );
- });
+ it('displays return to site button', () => {
+ buildWrapper();
+
+ expect(findReturnUrlButton().text()).toBe('Return to site');
+ expect(findReturnUrlButton().attributes().href).toBe(returnUrl);
+ });
- it('displays return to site button', () => {
- buildWrapper();
+ it('displays source path', () => {
+ buildWrapper();
- expect(findReturnUrlButton().text()).toBe('Return to site');
- expect(findReturnUrlButton().attributes().href).toBe(returnUrl);
+ expect(wrapper.text()).toContain(`Update ${sourcePath} file`);
+ });
});
- it('displays source path', () => {
- buildWrapper();
+ describe('when savedContentMeta is invalid', () => {
+ it('renders empty state with a loader', () => {
+ buildWrapper({ savedContentMeta: null });
- expect(wrapper.text()).toContain(`Update ${sourcePath} file`);
- });
+ expect(findEmptyState().exists()).toBe(true);
+ expect(findEmptyState().props()).toMatchObject({
+ title: 'Creating your merge request',
+ svgPath: mergeRequestsIllustrationPath,
+ });
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
- it('redirects to the HOME route when content has not been submitted', () => {
- buildWrapper({ savedContentMeta: null });
+ it('displays helper info in the empty state', () => {
+ buildWrapper({ savedContentMeta: null });
- expect(router.push).toHaveBeenCalledWith(HOME_ROUTE);
- expect(wrapper.html()).toBe('');
+ expect(findEmptyState().text()).toContain(
+ 'You can set an assignee to get your changes reviewed and deployed once your merge request is created',
+ );
+ expect(findEmptyState().text()).toContain(
+ 'A link to view the merge request will appear once ready',
+ );
+ });
+
+ it('redirects to the HOME route when content has not been submitted', () => {
+ buildWrapper({ savedContentMeta: null }, { hasSubmittedChanges: false });
+
+ expect(router.push).toHaveBeenCalledWith(HOME_ROUTE);
+ });
});
});
diff --git a/spec/frontend/static_site_editor/services/front_matterify_spec.js b/spec/frontend/static_site_editor/services/front_matterify_spec.js
new file mode 100644
index 00000000000..dbaedc30849
--- /dev/null
+++ b/spec/frontend/static_site_editor/services/front_matterify_spec.js
@@ -0,0 +1,47 @@
+import {
+ sourceContentYAML as content,
+ sourceContentHeaderObjYAML as yamlFrontMatterObj,
+ sourceContentSpacing as spacing,
+ sourceContentBody as body,
+} from '../mock_data';
+
+import { frontMatterify, stringify } from '~/static_site_editor/services/front_matterify';
+
+describe('static_site_editor/services/front_matterify', () => {
+ const frontMatterifiedContent = {
+ source: content,
+ matter: yamlFrontMatterObj,
+ spacing,
+ content: body,
+ delimiter: '---',
+ type: 'yaml',
+ };
+ const frontMatterifiedBody = {
+ source: body,
+ matter: null,
+ spacing: null,
+ content: body,
+ delimiter: null,
+ type: null,
+ };
+
+ describe('frontMatterify', () => {
+ it.each`
+ frontMatterified | target
+ ${frontMatterify(content)} | ${frontMatterifiedContent}
+ ${frontMatterify(body)} | ${frontMatterifiedBody}
+ `('returns $target from $frontMatterified', ({ frontMatterified, target }) => {
+ expect(frontMatterified).toEqual(target);
+ });
+ });
+
+ describe('stringify', () => {
+ it.each`
+ stringified | target
+ ${stringify(frontMatterifiedContent)} | ${content}
+ ${stringify(frontMatterifiedBody)} | ${body}
+ `('returns $target from $stringified', ({ stringified, target }) => {
+ expect(stringified).toBe(target);
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js b/spec/frontend/static_site_editor/services/submit_content_changes_spec.js
index d464e6b1895..5018da7300b 100644
--- a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js
+++ b/spec/frontend/static_site_editor/services/submit_content_changes_spec.js
@@ -19,6 +19,7 @@ import {
commitBranchResponse,
commitMultipleResponse,
createMergeRequestResponse,
+ mergeRequestMeta,
sourcePath,
sourceContentYAML as content,
trackingCategory,
@@ -28,11 +29,20 @@ import {
jest.mock('~/static_site_editor/services/generate_branch_name');
describe('submitContentChanges', () => {
- const mergeRequestTitle = `Update ${sourcePath} file`;
const branch = 'branch-name';
let trackingSpy;
let origPage;
+ const buildPayload = (overrides = {}) => ({
+ username,
+ projectId,
+ sourcePath,
+ content,
+ images,
+ mergeRequestMeta,
+ ...overrides,
+ });
+
beforeEach(() => {
jest.spyOn(Api, 'createBranch').mockResolvedValue({ data: commitBranchResponse });
jest.spyOn(Api, 'commitMultiple').mockResolvedValue({ data: commitMultipleResponse });
@@ -53,7 +63,7 @@ describe('submitContentChanges', () => {
});
it('creates a branch named after the username and target branch', () => {
- return submitContentChanges({ username, projectId }).then(() => {
+ return submitContentChanges(buildPayload()).then(() => {
expect(Api.createBranch).toHaveBeenCalledWith(projectId, {
ref: DEFAULT_TARGET_BRANCH,
branch,
@@ -64,16 +74,16 @@ describe('submitContentChanges', () => {
it('notifies error when branch could not be created', () => {
Api.createBranch.mockRejectedValueOnce();
- return expect(submitContentChanges({ username, projectId })).rejects.toThrow(
+ return expect(submitContentChanges(buildPayload())).rejects.toThrow(
SUBMIT_CHANGES_BRANCH_ERROR,
);
});
it('commits the content changes to the branch when creating branch succeeds', () => {
- return submitContentChanges({ username, projectId, sourcePath, content, images }).then(() => {
+ return submitContentChanges(buildPayload()).then(() => {
expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, {
branch,
- commit_message: mergeRequestTitle,
+ commit_message: mergeRequestMeta.title,
actions: [
{
action: 'update',
@@ -93,16 +103,11 @@ describe('submitContentChanges', () => {
it('does not commit an image if it has been removed from the content', () => {
const contentWithoutImages = '## Content without images';
- return submitContentChanges({
- username,
- projectId,
- sourcePath,
- content: contentWithoutImages,
- images,
- }).then(() => {
+ const payload = buildPayload({ content: contentWithoutImages });
+ return submitContentChanges(payload).then(() => {
expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, {
branch,
- commit_message: mergeRequestTitle,
+ commit_message: mergeRequestMeta.title,
actions: [
{
action: 'update',
@@ -117,17 +122,19 @@ describe('submitContentChanges', () => {
it('notifies error when content could not be committed', () => {
Api.commitMultiple.mockRejectedValueOnce();
- return expect(submitContentChanges({ username, projectId, images })).rejects.toThrow(
+ return expect(submitContentChanges(buildPayload())).rejects.toThrow(
SUBMIT_CHANGES_COMMIT_ERROR,
);
});
- it('creates a merge request when commiting changes succeeds', () => {
- return submitContentChanges({ username, projectId, sourcePath, content, images }).then(() => {
+ it('creates a merge request when committing changes succeeds', () => {
+ return submitContentChanges(buildPayload()).then(() => {
+ const { title, description } = mergeRequestMeta;
expect(Api.createProjectMergeRequest).toHaveBeenCalledWith(
projectId,
convertObjectPropsToSnakeCase({
- title: mergeRequestTitle,
+ title,
+ description,
targetBranch: DEFAULT_TARGET_BRANCH,
sourceBranch: branch,
}),
@@ -138,7 +145,7 @@ describe('submitContentChanges', () => {
it('notifies error when merge request could not be created', () => {
Api.createProjectMergeRequest.mockRejectedValueOnce();
- return expect(submitContentChanges({ username, projectId, images })).rejects.toThrow(
+ return expect(submitContentChanges(buildPayload())).rejects.toThrow(
SUBMIT_CHANGES_MERGE_REQUEST_ERROR,
);
});
@@ -147,11 +154,9 @@ describe('submitContentChanges', () => {
let result;
beforeEach(() => {
- return submitContentChanges({ username, projectId, sourcePath, content, images }).then(
- _result => {
- result = _result;
- },
- );
+ return submitContentChanges(buildPayload()).then(_result => {
+ result = _result;
+ });
});
it('returns the branch name', () => {
@@ -179,7 +184,7 @@ describe('submitContentChanges', () => {
describe('sends the correct tracking event', () => {
beforeEach(() => {
- return submitContentChanges({ username, projectId, sourcePath, content, images });
+ return submitContentChanges(buildPayload());
});
it('for committing changes', () => {
diff --git a/spec/frontend/static_site_editor/services/templater_spec.js b/spec/frontend/static_site_editor/services/templater_spec.js
index 1e7ae872b7e..cb3a0a0c106 100644
--- a/spec/frontend/static_site_editor/services/templater_spec.js
+++ b/spec/frontend/static_site_editor/services/templater_spec.js
@@ -39,6 +39,10 @@ Below this line is a codeblock of the same HTML that should be ignored and prese
<p>Some paragraph...</p>
</div>
\`\`\`
+
+Below this line is a iframe that should be ignored and preserved
+
+<iframe></iframe>
`;
const sourceTemplated = `Below this line is a simple ERB (single-line erb block) example.
@@ -87,6 +91,10 @@ Below this line is a codeblock of the same HTML that should be ignored and prese
<p>Some paragraph...</p>
</div>
\`\`\`
+
+Below this line is a iframe that should be ignored and preserved
+
+<iframe></iframe>
`;
it.each`
diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js
index 544c19da57b..eebec7de9d4 100644
--- a/spec/frontend/test_setup.js
+++ b/spec/frontend/test_setup.js
@@ -1,4 +1,6 @@
import Vue from 'vue';
+import 'jquery';
+
import * as jqueryMatchers from 'custom-jquery-matchers';
import { config as testUtilsConfig } from '@vue/test-utils';
import Translate from '~/vue_shared/translate';
@@ -9,7 +11,6 @@ import customMatchers from './matchers';
import './helpers/dom_shims';
import './helpers/jquery';
-import '~/commons/jquery';
import '~/commons/bootstrap';
process.on('unhandledRejection', global.promiseRejectionHandler);
diff --git a/spec/frontend/user_lists/components/add_user_modal_spec.js b/spec/frontend/user_lists/components/add_user_modal_spec.js
new file mode 100644
index 00000000000..82ce195d7cd
--- /dev/null
+++ b/spec/frontend/user_lists/components/add_user_modal_spec.js
@@ -0,0 +1,50 @@
+import { mount } from '@vue/test-utils';
+import AddUserModal from '~/user_lists/components/add_user_modal.vue';
+
+describe('Add User Modal', () => {
+ let wrapper;
+
+ const click = testId => wrapper.find(`[data-testid="${testId}"]`).trigger('click');
+
+ beforeEach(() => {
+ wrapper = mount(AddUserModal, {
+ propsData: { visible: true },
+ });
+ });
+
+ it('should explain the format of user IDs to enter', () => {
+ expect(wrapper.find('[data-testid="add-userids-description"]').text()).toContain(
+ 'Enter a comma separated list of user IDs',
+ );
+ });
+
+ describe('events', () => {
+ beforeEach(() => {
+ wrapper.find('#add-user-ids').setValue('1, 2, 3, 4');
+ });
+
+ it('should emit the users entered when Add Users is clicked', () => {
+ click('confirm-add-user-ids');
+ expect(wrapper.emitted('addUsers')).toContainEqual(['1, 2, 3, 4']);
+ });
+
+ it('should clear the input after emitting', async () => {
+ click('confirm-add-user-ids');
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('#add-user-ids').element.value).toBe('');
+ });
+
+ it('should not emit the users entered if cancel is clicked', () => {
+ click('cancel-add-user-ids');
+ expect(wrapper.emitted('addUsers')).toBeUndefined();
+ });
+
+ it('should clear the input after cancelling', async () => {
+ click('cancel-add-user-ids');
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('#add-user-ids').element.value).toBe('');
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/components/edit_user_list_spec.js b/spec/frontend/user_lists/components/edit_user_list_spec.js
new file mode 100644
index 00000000000..51a38e12916
--- /dev/null
+++ b/spec/frontend/user_lists/components/edit_user_list_spec.js
@@ -0,0 +1,150 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { createLocalVue, mount } from '@vue/test-utils';
+import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import waitForPromises from 'helpers/wait_for_promises';
+import Api from '~/api';
+import createStore from '~/user_lists/store/edit';
+import EditUserList from '~/user_lists/components/edit_user_list.vue';
+import UserListForm from '~/user_lists/components/user_list_form.vue';
+import { userList } from '../../feature_flags/mock_data';
+import { redirectTo } from '~/lib/utils/url_utility';
+
+jest.mock('~/api');
+jest.mock('~/lib/utils/url_utility');
+
+const localVue = createLocalVue(Vue);
+localVue.use(Vuex);
+
+describe('user_lists/components/edit_user_list', () => {
+ let wrapper;
+
+ const setInputValue = value => wrapper.find('[data-testid="user-list-name"]').setValue(value);
+
+ const click = button => wrapper.find(`[data-testid="${button}"]`).trigger('click');
+ const clickSave = () => click('save-user-list');
+
+ const destroy = () => wrapper?.destroy();
+
+ const factory = () => {
+ destroy();
+
+ wrapper = mount(EditUserList, {
+ localVue,
+ store: createStore({ projectId: '1', userListIid: '2' }),
+ provide: {
+ userListsDocsPath: '/docs/user_lists',
+ },
+ });
+ };
+
+ afterEach(() => {
+ destroy();
+ });
+
+ describe('loading', () => {
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserList.mockReturnValue(new Promise(() => {}));
+ factory();
+ });
+
+ it('should show a loading icon', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
+
+ describe('loading error', () => {
+ const message = 'error creating list';
+ let alert;
+
+ beforeEach(async () => {
+ Api.fetchFeatureFlagUserList.mockRejectedValue({ message });
+ factory();
+ await waitForPromises();
+
+ alert = wrapper.find(GlAlert);
+ });
+
+ it('should show a flash with the error respopnse', () => {
+ expect(alert.text()).toContain(message);
+ });
+
+ it('should not be dismissible', async () => {
+ expect(alert.props('dismissible')).toBe(false);
+ });
+
+ it('should not show a user list form', () => {
+ expect(wrapper.find(UserListForm).exists()).toBe(false);
+ });
+ });
+
+ describe('update', () => {
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserList.mockResolvedValue({ data: userList });
+ factory();
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('should link to the documentation', () => {
+ const link = wrapper.find('[data-testid="user-list-docs-link"]');
+ expect(link.attributes('href')).toBe('/docs/user_lists');
+ });
+
+ it('should link the cancel button to the user list details path', () => {
+ const link = wrapper.find('[data-testid="user-list-cancel"]');
+ expect(link.attributes('href')).toBe(userList.path);
+ });
+
+ it('should show the user list name in the title', () => {
+ expect(wrapper.find('[data-testid="user-list-title"]').text()).toBe(`Edit ${userList.name}`);
+ });
+
+ describe('success', () => {
+ beforeEach(() => {
+ Api.updateFeatureFlagUserList.mockResolvedValue({ data: userList });
+ setInputValue('test');
+ clickSave();
+ return wrapper.vm.$nextTick();
+ });
+
+ it('should create a user list with the entered name', () => {
+ expect(Api.updateFeatureFlagUserList).toHaveBeenCalledWith('1', {
+ name: 'test',
+ iid: userList.iid,
+ });
+ });
+
+ it('should redirect to the feature flag details page', () => {
+ expect(redirectTo).toHaveBeenCalledWith(userList.path);
+ });
+ });
+
+ describe('error', () => {
+ let alert;
+ let message;
+
+ beforeEach(async () => {
+ message = 'error creating list';
+ Api.updateFeatureFlagUserList.mockRejectedValue({ message });
+ setInputValue('test');
+ clickSave();
+ await waitForPromises();
+
+ alert = wrapper.find(GlAlert);
+ });
+
+ it('should show a flash with the error respopnse', () => {
+ expect(alert.text()).toContain(message);
+ });
+
+ it('should dismiss the error if dismiss is clicked', async () => {
+ alert.find('button').trigger('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(alert.exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/components/new_user_list_spec.js b/spec/frontend/user_lists/components/new_user_list_spec.js
new file mode 100644
index 00000000000..62fb0ca0859
--- /dev/null
+++ b/spec/frontend/user_lists/components/new_user_list_spec.js
@@ -0,0 +1,93 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { GlAlert } from '@gitlab/ui';
+import waitForPromises from 'helpers/wait_for_promises';
+import Api from '~/api';
+import createStore from '~/user_lists/store/new';
+import NewUserList from '~/user_lists/components/new_user_list.vue';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { userList } from '../../feature_flags/mock_data';
+
+jest.mock('~/api');
+jest.mock('~/lib/utils/url_utility');
+
+const localVue = createLocalVue(Vue);
+localVue.use(Vuex);
+
+describe('user_lists/components/new_user_list', () => {
+ let wrapper;
+
+ const setInputValue = value => wrapper.find('[data-testid="user-list-name"]').setValue(value);
+
+ const click = button => wrapper.find(`[data-testid="${button}"]`).trigger('click');
+
+ beforeEach(() => {
+ wrapper = mount(NewUserList, {
+ localVue,
+ store: createStore({ projectId: '1' }),
+ provide: {
+ featureFlagsPath: '/feature_flags',
+ userListsDocsPath: '/docs/user_lists',
+ },
+ });
+ });
+
+ it('should link to the documentation', () => {
+ const link = wrapper.find('[data-testid="user-list-docs-link"]');
+ expect(link.attributes('href')).toBe('/docs/user_lists');
+ });
+
+ it('should link the cancel buton back to feature flags', () => {
+ const cancel = wrapper.find('[data-testid="user-list-cancel"');
+ expect(cancel.attributes('href')).toBe('/feature_flags');
+ });
+
+ describe('create', () => {
+ describe('success', () => {
+ beforeEach(() => {
+ Api.createFeatureFlagUserList.mockResolvedValue({ data: userList });
+ setInputValue('test');
+ click('save-user-list');
+ return wrapper.vm.$nextTick();
+ });
+
+ it('should create a user list with the entered name', () => {
+ expect(Api.createFeatureFlagUserList).toHaveBeenCalledWith('1', {
+ name: 'test',
+ user_xids: '',
+ });
+ });
+
+ it('should redirect to the feature flag details page', () => {
+ expect(redirectTo).toHaveBeenCalledWith(userList.path);
+ });
+ });
+
+ describe('error', () => {
+ let alert;
+
+ beforeEach(async () => {
+ Api.createFeatureFlagUserList.mockRejectedValue({ message: 'error creating list' });
+ setInputValue('test');
+ click('save-user-list');
+
+ await waitForPromises();
+
+ alert = wrapper.find(GlAlert);
+ });
+
+ it('should show a flash with the error respopnse', () => {
+ expect(alert.text()).toContain('error creating list');
+ });
+
+ it('should dismiss the error when the dismiss button is clicked', async () => {
+ alert.find('button').trigger('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(alert.exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/components/user_list_form_spec.js b/spec/frontend/user_lists/components/user_list_form_spec.js
new file mode 100644
index 00000000000..42f7659600e
--- /dev/null
+++ b/spec/frontend/user_lists/components/user_list_form_spec.js
@@ -0,0 +1,40 @@
+import { mount } from '@vue/test-utils';
+import Form from '~/user_lists/components/user_list_form.vue';
+import { userList } from '../../feature_flags/mock_data';
+
+describe('user_lists/components/user_list_form', () => {
+ let wrapper;
+ let input;
+
+ beforeEach(() => {
+ wrapper = mount(Form, {
+ propsData: {
+ cancelPath: '/cancel',
+ saveButtonLabel: 'Save',
+ userListsDocsPath: '/docs',
+ userList,
+ },
+ });
+
+ input = wrapper.find('[data-testid="user-list-name"]');
+ });
+
+ it('should set the name to the name of the given user list', () => {
+ expect(input.element.value).toBe(userList.name);
+ });
+
+ it('should link to the user lists docs', () => {
+ expect(wrapper.find('[data-testid="user-list-docs-link"]').attributes('href')).toBe('/docs');
+ });
+
+ it('should emit an updated user list when save is clicked', () => {
+ input.setValue('test');
+ wrapper.find('[data-testid="save-user-list"]').trigger('click');
+
+ expect(wrapper.emitted('submit')).toEqual([[{ ...userList, name: 'test' }]]);
+ });
+
+ it('should set the cancel button to the passed url', () => {
+ expect(wrapper.find('[data-testid="user-list-cancel"]').attributes('href')).toBe('/cancel');
+ });
+});
diff --git a/spec/frontend/user_lists/components/user_list_spec.js b/spec/frontend/user_lists/components/user_list_spec.js
new file mode 100644
index 00000000000..5f9b7967846
--- /dev/null
+++ b/spec/frontend/user_lists/components/user_list_spec.js
@@ -0,0 +1,196 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { mount } from '@vue/test-utils';
+import { uniq } from 'lodash';
+import { GlAlert, GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
+import Api from '~/api';
+import { parseUserIds, stringifyUserIds } from '~/user_lists/store/utils';
+import createStore from '~/user_lists/store/show';
+import UserList from '~/user_lists/components/user_list.vue';
+import { userList } from '../../feature_flags/mock_data';
+
+jest.mock('~/api');
+
+Vue.use(Vuex);
+
+describe('User List', () => {
+ let wrapper;
+
+ const click = testId => wrapper.find(`[data-testid="${testId}"]`).trigger('click');
+
+ const findUserIds = () => wrapper.findAll('[data-testid="user-id"]');
+
+ const destroy = () => wrapper?.destroy();
+
+ const factory = () => {
+ destroy();
+
+ wrapper = mount(UserList, {
+ store: createStore({ projectId: '1', userListIid: '2' }),
+ propsData: {
+ emptyStatePath: '/empty_state.svg',
+ },
+ });
+ };
+
+ describe('loading', () => {
+ let resolveFn;
+
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserList.mockReturnValue(
+ new Promise(resolve => {
+ resolveFn = resolve;
+ }),
+ );
+ factory();
+ });
+
+ afterEach(() => {
+ resolveFn();
+ });
+
+ it('shows a loading icon', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
+
+ describe('success', () => {
+ let userIds;
+
+ beforeEach(() => {
+ userIds = parseUserIds(userList.user_xids);
+ Api.fetchFeatureFlagUserList.mockResolvedValueOnce({ data: userList });
+ factory();
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('requests the user list on mount', () => {
+ expect(Api.fetchFeatureFlagUserList).toHaveBeenCalledWith('1', '2');
+ });
+
+ it('shows the list name', () => {
+ expect(wrapper.find('h3').text()).toBe(userList.name);
+ });
+
+ it('shows an add users button', () => {
+ expect(wrapper.find('[data-testid="add-users"]').text()).toBe('Add Users');
+ });
+
+ it('shows an edit list button', () => {
+ expect(wrapper.find('[data-testid="edit-user-list"]').text()).toBe('Edit');
+ });
+
+ it('shows a row for every id', () => {
+ expect(wrapper.findAll('[data-testid="user-id-row"]')).toHaveLength(userIds.length);
+ });
+
+ it('shows one id on each row', () => {
+ findUserIds().wrappers.forEach((w, i) => expect(w.text()).toBe(userIds[i]));
+ });
+
+ it('shows a delete button for every row', () => {
+ expect(wrapper.findAll('[data-testid="delete-user-id"]')).toHaveLength(userIds.length);
+ });
+
+ describe('adding users', () => {
+ const newIds = ['user3', 'user4', 'user5', 'test', 'example', 'foo'];
+ let receivedUserIds;
+ let parsedReceivedUserIds;
+
+ beforeEach(async () => {
+ Api.updateFeatureFlagUserList.mockResolvedValue(userList);
+ click('add-users');
+ await wrapper.vm.$nextTick();
+ wrapper.find('#add-user-ids').setValue(`${stringifyUserIds(newIds)},`);
+ click('confirm-add-user-ids');
+ await wrapper.vm.$nextTick();
+ [[, { user_xids: receivedUserIds }]] = Api.updateFeatureFlagUserList.mock.calls;
+ parsedReceivedUserIds = parseUserIds(receivedUserIds);
+ });
+
+ it('should add user IDs to the user list', () => {
+ newIds.forEach(id => expect(receivedUserIds).toContain(id));
+ });
+
+ it('should not remove existing user ids', () => {
+ userIds.forEach(id => expect(receivedUserIds).toContain(id));
+ });
+
+ it('should not submit empty IDs', () => {
+ parsedReceivedUserIds.forEach(id => expect(id).not.toBe(''));
+ });
+
+ it('should not create duplicate entries', () => {
+ expect(uniq(parsedReceivedUserIds)).toEqual(parsedReceivedUserIds);
+ });
+
+ it('should display the new IDs', () => {
+ const userIdWrappers = findUserIds();
+ newIds.forEach(id => {
+ const userIdWrapper = userIdWrappers.wrappers.find(w => w.text() === id);
+ expect(userIdWrapper.exists()).toBe(true);
+ });
+ });
+ });
+
+ describe('deleting users', () => {
+ let receivedUserIds;
+
+ beforeEach(async () => {
+ Api.updateFeatureFlagUserList.mockResolvedValue(userList);
+ click('delete-user-id');
+ await wrapper.vm.$nextTick();
+ [[, { user_xids: receivedUserIds }]] = Api.updateFeatureFlagUserList.mock.calls;
+ });
+
+ it('should remove the ID clicked', () => {
+ expect(receivedUserIds).not.toContain(userIds[0]);
+ });
+
+ it('should not display the deleted user', () => {
+ const userIdWrappers = findUserIds();
+ const userIdWrapper = userIdWrappers.wrappers.find(w => w.text() === userIds[0]);
+ expect(userIdWrapper).toBeUndefined();
+ });
+ });
+ });
+
+ describe('error', () => {
+ const findAlert = () => wrapper.find(GlAlert);
+
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserList.mockRejectedValue();
+ factory();
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('displays the alert message', () => {
+ const alert = findAlert();
+ expect(alert.text()).toBe('Something went wrong on our end. Please try again!');
+ });
+
+ it('can dismiss the alert', async () => {
+ const alert = findAlert();
+ alert.find('button').trigger('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(alert.exists()).toBe(false);
+ });
+ });
+
+ describe('empty list', () => {
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserList.mockResolvedValueOnce({ data: { ...userList, user_xids: '' } });
+ factory();
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('displays an empty state', () => {
+ expect(wrapper.find(GlEmptyState).exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/store/edit/actions_spec.js b/spec/frontend/user_lists/store/edit/actions_spec.js
new file mode 100644
index 00000000000..7f0fb8e5401
--- /dev/null
+++ b/spec/frontend/user_lists/store/edit/actions_spec.js
@@ -0,0 +1,121 @@
+import testAction from 'helpers/vuex_action_helper';
+import Api from '~/api';
+import createState from '~/user_lists/store/edit/state';
+import * as types from '~/user_lists/store/edit/mutation_types';
+import * as actions from '~/user_lists/store/edit/actions';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { userList } from '../../../feature_flags/mock_data';
+
+jest.mock('~/api');
+jest.mock('~/lib/utils/url_utility');
+
+describe('User Lists Edit Actions', () => {
+ let state;
+
+ beforeEach(() => {
+ state = createState({ projectId: '1', userListIid: '2' });
+ });
+
+ describe('fetchUserList', () => {
+ describe('success', () => {
+ beforeEach(() => {
+ Api.fetchFeatureFlagUserList.mockResolvedValue({ data: userList });
+ });
+
+ it('should commit RECEIVE_USER_LIST_SUCCESS', () => {
+ return testAction(
+ actions.fetchUserList,
+ undefined,
+ state,
+ [
+ { type: types.REQUEST_USER_LIST },
+ { type: types.RECEIVE_USER_LIST_SUCCESS, payload: userList },
+ ],
+ [],
+ () => expect(Api.fetchFeatureFlagUserList).toHaveBeenCalledWith('1', '2'),
+ );
+ });
+ });
+
+ describe('error', () => {
+ let error;
+ beforeEach(() => {
+ error = { response: { data: { message: ['error'] } } };
+ Api.fetchFeatureFlagUserList.mockRejectedValue(error);
+ });
+
+ it('should commit RECEIVE_USER_LIST_ERROR', () => {
+ return testAction(
+ actions.fetchUserList,
+ undefined,
+ state,
+ [
+ { type: types.REQUEST_USER_LIST },
+ { type: types.RECEIVE_USER_LIST_ERROR, payload: ['error'] },
+ ],
+ [],
+ () => expect(Api.fetchFeatureFlagUserList).toHaveBeenCalledWith('1', '2'),
+ );
+ });
+ });
+ });
+
+ describe('dismissErrorAlert', () => {
+ it('should commit DISMISS_ERROR_ALERT', () => {
+ return testAction(actions.dismissErrorAlert, undefined, state, [
+ { type: types.DISMISS_ERROR_ALERT },
+ ]);
+ });
+ });
+
+ describe('updateUserList', () => {
+ let updatedList;
+
+ beforeEach(() => {
+ updatedList = {
+ ...userList,
+ name: 'new',
+ };
+ });
+ describe('success', () => {
+ beforeEach(() => {
+ Api.updateFeatureFlagUserList.mockResolvedValue({ data: userList });
+ state.userList = userList;
+ });
+
+ it('should commit RECEIVE_USER_LIST_SUCCESS', () => {
+ return testAction(actions.updateUserList, updatedList, state, [], [], () => {
+ expect(Api.updateFeatureFlagUserList).toHaveBeenCalledWith('1', {
+ name: updatedList.name,
+ iid: updatedList.iid,
+ });
+ expect(redirectTo).toHaveBeenCalledWith(userList.path);
+ });
+ });
+ });
+
+ describe('error', () => {
+ let error;
+
+ beforeEach(() => {
+ error = { message: 'error' };
+ Api.updateFeatureFlagUserList.mockRejectedValue(error);
+ });
+
+ it('should commit RECEIVE_USER_LIST_ERROR', () => {
+ return testAction(
+ actions.updateUserList,
+ updatedList,
+ state,
+ [{ type: types.RECEIVE_USER_LIST_ERROR, payload: ['error'] }],
+ [],
+ () =>
+ expect(Api.updateFeatureFlagUserList).toHaveBeenCalledWith('1', {
+ name: updatedList.name,
+ iid: updatedList.iid,
+ }),
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/store/edit/mutations_spec.js b/spec/frontend/user_lists/store/edit/mutations_spec.js
new file mode 100644
index 00000000000..3d4d2a59717
--- /dev/null
+++ b/spec/frontend/user_lists/store/edit/mutations_spec.js
@@ -0,0 +1,61 @@
+import statuses from '~/user_lists/constants/edit';
+import createState from '~/user_lists/store/edit/state';
+import * as types from '~/user_lists/store/edit/mutation_types';
+import mutations from '~/user_lists/store/edit/mutations';
+import { userList } from '../../../feature_flags/mock_data';
+
+describe('User List Edit Mutations', () => {
+ let state;
+
+ beforeEach(() => {
+ state = createState({ projectId: '1', userListIid: '2' });
+ });
+
+ describe(types.REQUEST_USER_LIST, () => {
+ beforeEach(() => {
+ mutations[types.REQUEST_USER_LIST](state);
+ });
+
+ it('sets the state to loading', () => {
+ expect(state.status).toBe(statuses.LOADING);
+ });
+ });
+
+ describe(types.RECEIVE_USER_LIST_SUCCESS, () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_USER_LIST_SUCCESS](state, userList);
+ });
+
+ it('sets the state to success', () => {
+ expect(state.status).toBe(statuses.SUCCESS);
+ });
+
+ it('sets the user list to the one received', () => {
+ expect(state.userList).toEqual(userList);
+ });
+ });
+
+ describe(types.RECIEVE_USER_LIST_ERROR, () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_USER_LIST_ERROR](state, ['network error']);
+ });
+
+ it('sets the state to error', () => {
+ expect(state.status).toBe(statuses.ERROR);
+ });
+
+ it('sets the error message to the recieved one', () => {
+ expect(state.errorMessage).toEqual(['network error']);
+ });
+ });
+
+ describe(types.DISMISS_ERROR_ALERT, () => {
+ beforeEach(() => {
+ mutations[types.DISMISS_ERROR_ALERT](state);
+ });
+
+ it('sets the state to error dismissed', () => {
+ expect(state.status).toBe(statuses.UNSYNCED);
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/store/new/actions_spec.js b/spec/frontend/user_lists/store/new/actions_spec.js
new file mode 100644
index 00000000000..9cc6212a125
--- /dev/null
+++ b/spec/frontend/user_lists/store/new/actions_spec.js
@@ -0,0 +1,69 @@
+import testAction from 'helpers/vuex_action_helper';
+import Api from '~/api';
+import createState from '~/user_lists/store/new/state';
+import * as types from '~/user_lists/store/new/mutation_types';
+import * as actions from '~/user_lists/store/new/actions';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { userList } from '../../../feature_flags/mock_data';
+
+jest.mock('~/api');
+jest.mock('~/lib/utils/url_utility');
+
+describe('User Lists Edit Actions', () => {
+ let state;
+
+ beforeEach(() => {
+ state = createState({ projectId: '1' });
+ });
+
+ describe('dismissErrorAlert', () => {
+ it('should commit DISMISS_ERROR_ALERT', () => {
+ return testAction(actions.dismissErrorAlert, undefined, state, [
+ { type: types.DISMISS_ERROR_ALERT },
+ ]);
+ });
+ });
+
+ describe('createUserList', () => {
+ let createdList;
+
+ beforeEach(() => {
+ createdList = {
+ ...userList,
+ name: 'new',
+ };
+ });
+ describe('success', () => {
+ beforeEach(() => {
+ Api.createFeatureFlagUserList.mockResolvedValue({ data: userList });
+ });
+
+ it('should redirect to the user list page', () => {
+ return testAction(actions.createUserList, createdList, state, [], [], () => {
+ expect(Api.createFeatureFlagUserList).toHaveBeenCalledWith('1', createdList);
+ expect(redirectTo).toHaveBeenCalledWith(userList.path);
+ });
+ });
+ });
+
+ describe('error', () => {
+ let error;
+
+ beforeEach(() => {
+ error = { message: 'error' };
+ Api.createFeatureFlagUserList.mockRejectedValue(error);
+ });
+
+ it('should commit RECEIVE_USER_LIST_ERROR', () => {
+ return testAction(
+ actions.createUserList,
+ createdList,
+ state,
+ [{ type: types.RECEIVE_CREATE_USER_LIST_ERROR, payload: ['error'] }],
+ [],
+ () => expect(Api.createFeatureFlagUserList).toHaveBeenCalledWith('1', createdList),
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/store/new/mutations_spec.js b/spec/frontend/user_lists/store/new/mutations_spec.js
new file mode 100644
index 00000000000..89e8a83eb25
--- /dev/null
+++ b/spec/frontend/user_lists/store/new/mutations_spec.js
@@ -0,0 +1,38 @@
+import createState from '~/user_lists/store/new/state';
+import * as types from '~/user_lists/store/new/mutation_types';
+import mutations from '~/user_lists/store/new/mutations';
+
+describe('User List Edit Mutations', () => {
+ let state;
+
+ beforeEach(() => {
+ state = createState({ projectId: '1' });
+ });
+
+ describe(types.RECIEVE_USER_LIST_ERROR, () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_CREATE_USER_LIST_ERROR](state, ['network error']);
+ });
+
+ it('sets the error message to the recieved one', () => {
+ expect(state.errorMessage).toEqual(['network error']);
+ });
+
+ it('sets the error message to the recevied API message if present', () => {
+ const message = ['name is blank', 'name is too short'];
+
+ mutations[types.RECEIVE_CREATE_USER_LIST_ERROR](state, message);
+ expect(state.errorMessage).toEqual(message);
+ });
+ });
+
+ describe(types.DISMISS_ERROR_ALERT, () => {
+ beforeEach(() => {
+ mutations[types.DISMISS_ERROR_ALERT](state);
+ });
+
+ it('clears the error message', () => {
+ expect(state.errorMessage).toBe('');
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/store/show/actions_spec.js b/spec/frontend/user_lists/store/show/actions_spec.js
new file mode 100644
index 00000000000..25a6b9ec0e4
--- /dev/null
+++ b/spec/frontend/user_lists/store/show/actions_spec.js
@@ -0,0 +1,117 @@
+import testAction from 'helpers/vuex_action_helper';
+import { userList } from 'jest/feature_flags/mock_data';
+import Api from '~/api';
+import { stringifyUserIds } from '~/user_lists/store/utils';
+import createState from '~/user_lists/store/show/state';
+import * as types from '~/user_lists/store/show/mutation_types';
+import * as actions from '~/user_lists/store/show/actions';
+
+jest.mock('~/api');
+
+describe('User Lists Show Actions', () => {
+ let mockState;
+
+ beforeEach(() => {
+ mockState = createState({ projectId: '1', userListIid: '2' });
+ });
+
+ describe('fetchUserList', () => {
+ it('commits REQUEST_USER_LIST and RECEIVE_USER_LIST_SUCCESS on success', () => {
+ Api.fetchFeatureFlagUserList.mockResolvedValue({ data: userList });
+ return testAction(
+ actions.fetchUserList,
+ undefined,
+ mockState,
+ [
+ { type: types.REQUEST_USER_LIST },
+ { type: types.RECEIVE_USER_LIST_SUCCESS, payload: userList },
+ ],
+ [],
+ () => expect(Api.fetchFeatureFlagUserList).toHaveBeenCalledWith('1', '2'),
+ );
+ });
+
+ it('commits REQUEST_USER_LIST and RECEIVE_USER_LIST_ERROR on error', () => {
+ Api.fetchFeatureFlagUserList.mockRejectedValue({ message: 'fail' });
+ return testAction(
+ actions.fetchUserList,
+ undefined,
+ mockState,
+ [{ type: types.REQUEST_USER_LIST }, { type: types.RECEIVE_USER_LIST_ERROR }],
+ [],
+ );
+ });
+ });
+
+ describe('dismissErrorAlert', () => {
+ it('commits DISMISS_ERROR_ALERT', () => {
+ return testAction(
+ actions.dismissErrorAlert,
+ undefined,
+ mockState,
+ [{ type: types.DISMISS_ERROR_ALERT }],
+ [],
+ );
+ });
+ });
+
+ describe('addUserIds', () => {
+ it('adds the given IDs and tries to update the user list', () => {
+ return testAction(
+ actions.addUserIds,
+ '1,2,3',
+ mockState,
+ [{ type: types.ADD_USER_IDS, payload: '1,2,3' }],
+ [{ type: 'updateUserList' }],
+ );
+ });
+ });
+
+ describe('removeUserId', () => {
+ it('removes the given ID and tries to update the user list', () => {
+ return testAction(
+ actions.removeUserId,
+ 'user3',
+ mockState,
+ [{ type: types.REMOVE_USER_ID, payload: 'user3' }],
+ [{ type: 'updateUserList' }],
+ );
+ });
+ });
+
+ describe('updateUserList', () => {
+ beforeEach(() => {
+ mockState.userList = userList;
+ mockState.userIds = ['user1', 'user2', 'user3'];
+ });
+
+ it('commits REQUEST_USER_LIST and RECEIVE_USER_LIST_SUCCESS on success', () => {
+ Api.updateFeatureFlagUserList.mockResolvedValue({ data: userList });
+ return testAction(
+ actions.updateUserList,
+ undefined,
+ mockState,
+ [
+ { type: types.REQUEST_USER_LIST },
+ { type: types.RECEIVE_USER_LIST_SUCCESS, payload: userList },
+ ],
+ [],
+ () =>
+ expect(Api.updateFeatureFlagUserList).toHaveBeenCalledWith('1', {
+ ...userList,
+ user_xids: stringifyUserIds(mockState.userIds),
+ }),
+ );
+ });
+ it('commits REQUEST_USER_LIST and RECEIVE_USER_LIST_ERROR on error', () => {
+ Api.updateFeatureFlagUserList.mockRejectedValue({ message: 'fail' });
+ return testAction(
+ actions.updateUserList,
+ undefined,
+ mockState,
+ [{ type: types.REQUEST_USER_LIST }, { type: types.RECEIVE_USER_LIST_ERROR }],
+ [],
+ );
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/store/show/mutations_spec.js b/spec/frontend/user_lists/store/show/mutations_spec.js
new file mode 100644
index 00000000000..364cc6a0225
--- /dev/null
+++ b/spec/frontend/user_lists/store/show/mutations_spec.js
@@ -0,0 +1,86 @@
+import { uniq } from 'lodash';
+import { userList } from 'jest/feature_flags/mock_data';
+import createState from '~/user_lists/store/show/state';
+import mutations from '~/user_lists/store/show/mutations';
+import { states } from '~/user_lists/constants/show';
+import * as types from '~/user_lists/store/show/mutation_types';
+
+describe('User Lists Show Mutations', () => {
+ let mockState;
+
+ beforeEach(() => {
+ mockState = createState({ projectId: '1', userListIid: '2' });
+ });
+
+ describe(types.REQUEST_USER_LIST, () => {
+ it('puts us in the loading state', () => {
+ mutations[types.REQUEST_USER_LIST](mockState);
+
+ expect(mockState.state).toBe(states.LOADING);
+ });
+ });
+
+ describe(types.RECEIVE_USER_LIST_SUCCESS, () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_USER_LIST_SUCCESS](mockState, userList);
+ });
+
+ it('sets the state to LOADED', () => {
+ expect(mockState.state).toBe(states.SUCCESS);
+ });
+
+ it('sets the active user list', () => {
+ expect(mockState.userList).toEqual(userList);
+ });
+
+ it('splits the user IDs into an Array', () => {
+ expect(mockState.userIds).toEqual(userList.user_xids.split(','));
+ });
+
+ it('sets user IDs to an empty Array if an empty string is received', () => {
+ mutations[types.RECEIVE_USER_LIST_SUCCESS](mockState, { ...userList, user_xids: '' });
+ expect(mockState.userIds).toEqual([]);
+ });
+ });
+ describe(types.RECEIVE_USER_LIST_ERROR, () => {
+ it('sets the state to error', () => {
+ mutations[types.RECEIVE_USER_LIST_ERROR](mockState);
+ expect(mockState.state).toBe(states.ERROR);
+ });
+ });
+ describe(types.ADD_USER_IDS, () => {
+ const newIds = ['user3', 'test1', '1', '3', ''];
+
+ beforeEach(() => {
+ mutations[types.RECEIVE_USER_LIST_SUCCESS](mockState, userList);
+ mutations[types.ADD_USER_IDS](mockState, newIds.join(', '));
+ });
+
+ it('adds the new IDs to the state unless empty', () => {
+ newIds.filter(id => id).forEach(id => expect(mockState.userIds).toContain(id));
+ });
+
+ it('does not add duplicate IDs to the state', () => {
+ expect(mockState.userIds).toEqual(uniq(mockState.userIds));
+ });
+ });
+ describe(types.REMOVE_USER_ID, () => {
+ let userIds;
+ let removedId;
+
+ beforeEach(() => {
+ mutations[types.RECEIVE_USER_LIST_SUCCESS](mockState, userList);
+ userIds = mockState.userIds;
+ removedId = 'user3';
+ mutations[types.REMOVE_USER_ID](mockState, removedId);
+ });
+
+ it('should remove the given id', () => {
+ expect(mockState).not.toContain(removedId);
+ });
+
+ it('should leave the rest of the IDs alone', () => {
+ userIds.filter(id => id !== removedId).forEach(id => expect(mockState.userIds).toContain(id));
+ });
+ });
+});
diff --git a/spec/frontend/user_lists/store/utils_spec.js b/spec/frontend/user_lists/store/utils_spec.js
new file mode 100644
index 00000000000..9547b463eec
--- /dev/null
+++ b/spec/frontend/user_lists/store/utils_spec.js
@@ -0,0 +1,23 @@
+import { parseUserIds, stringifyUserIds } from '~/user_lists/store/utils';
+
+describe('User List Store Utils', () => {
+ describe('parseUserIds', () => {
+ it('should split comma-seperated user IDs into an array', () => {
+ expect(parseUserIds('1,2,3')).toEqual(['1', '2', '3']);
+ });
+
+ it('should filter whitespace before the comma', () => {
+ expect(parseUserIds('1\t,2 ,3')).toEqual(['1', '2', '3']);
+ });
+
+ it('should filter whitespace after the comma', () => {
+ expect(parseUserIds('1,\t2, 3')).toEqual(['1', '2', '3']);
+ });
+ });
+
+ describe('stringifyUserIds', () => {
+ it('should convert a list of user IDs into a comma-separated string', () => {
+ expect(stringifyUserIds(['1', '2', '3'])).toBe('1,2,3');
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
index caea9a757ae..015f8bbac51 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
@@ -130,7 +130,7 @@ describe('MRWidgetHeader', () => {
});
it('renders clipboard button', () => {
- expect(vm.$el.querySelector('.btn-clipboard')).not.toEqual(null);
+ expect(vm.$el.querySelector('[data-testid="mr-widget-copy-clipboard"]')).not.toEqual(null);
});
it('renders target branch', () => {
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js
index 6ec30493f8b..9923434a7dd 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js
@@ -6,6 +6,10 @@ import component from '~/vue_merge_request_widget/components/states/mr_widget_re
describe('Merge request widget rebase component', () => {
let Component;
let vm;
+
+ const findRebaseMessageEl = () => vm.$el.querySelector('[data-testid="rebase-message"]');
+ const findRebaseMessageElText = () => findRebaseMessageEl().textContent.trim();
+
beforeEach(() => {
Component = Vue.extend(component);
});
@@ -21,9 +25,7 @@ describe('Merge request widget rebase component', () => {
service: {},
});
- expect(
- vm.$el.querySelector('.rebase-state-find-class-convention span').textContent.trim(),
- ).toContain('Rebase in progress');
+ expect(findRebaseMessageElText()).toContain('Rebase in progress');
});
});
@@ -39,9 +41,7 @@ describe('Merge request widget rebase component', () => {
});
it('it should render rebase button and warning message', () => {
- const text = vm.$el
- .querySelector('.rebase-state-find-class-convention span')
- .textContent.trim();
+ const text = findRebaseMessageElText();
expect(text).toContain('Fast-forward merge is not possible.');
expect(text.replace(/\s\s+/g, ' ')).toContain(
@@ -53,9 +53,7 @@ describe('Merge request widget rebase component', () => {
vm.rebasingError = 'Something went wrong!';
Vue.nextTick(() => {
- expect(
- vm.$el.querySelector('.rebase-state-find-class-convention span').textContent.trim(),
- ).toContain('Something went wrong!');
+ expect(findRebaseMessageElText()).toContain('Something went wrong!');
done();
});
});
@@ -72,9 +70,7 @@ describe('Merge request widget rebase component', () => {
service: {},
});
- const text = vm.$el
- .querySelector('.rebase-state-find-class-convention span')
- .textContent.trim();
+ const text = findRebaseMessageElText();
expect(text).toContain('Fast-forward merge is not possible.');
expect(text).toContain('Rebase the source branch onto');
@@ -93,7 +89,7 @@ describe('Merge request widget rebase component', () => {
service: {},
});
- const elem = vm.$el.querySelector('.rebase-state-find-class-convention span');
+ const elem = findRebaseMessageEl();
expect(elem.innerHTML).toContain(
`Fast-forward merge is not possible. Rebase the source branch onto <span class="label-branch">${targetBranch}</span> to allow this merge request to be merged.`,
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js
index 98af44b0975..aae9b8660e2 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js
@@ -1,12 +1,12 @@
import { shallowMount } from '@vue/test-utils';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import AutoMergeFailedComponent from '~/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue';
import eventHub from '~/vue_merge_request_widget/event_hub';
describe('MRWidgetAutoMergeFailed', () => {
let wrapper;
const mergeError = 'This is the merge error';
- const findButton = () => wrapper.find('button');
+ const findButton = () => wrapper.find(GlButton);
const createComponent = (props = {}) => {
wrapper = shallowMount(AutoMergeFailedComponent, {
@@ -38,17 +38,13 @@ describe('MRWidgetAutoMergeFailed', () => {
it('emits event and shows loading icon when button is clicked', () => {
jest.spyOn(eventHub, '$emit');
- findButton().trigger('click');
+ findButton().vm.$emit('click');
expect(eventHub.$emit.mock.calls[0][0]).toBe('MRWidgetUpdateRequested');
return wrapper.vm.$nextTick(() => {
- expect(findButton().attributes('disabled')).toEqual('disabled');
- expect(
- findButton()
- .find(GlLoadingIcon)
- .exists(),
- ).toBe(true);
+ expect(findButton().attributes('disabled')).toBe('true');
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
index 5eb24315ca6..9057ffaea45 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
@@ -101,8 +101,6 @@ describe('ReadyToMerge', () => {
expect(vm.isMakingRequest).toBeFalsy();
expect(vm.isMergingImmediately).toBeFalsy();
expect(vm.commitMessage).toBe(vm.mr.commitMessage);
- expect(vm.successSvg).toBeDefined();
- expect(vm.warningSvg).toBeDefined();
});
});
@@ -494,19 +492,6 @@ describe('ReadyToMerge', () => {
});
});
- it('hides close button', done => {
- jest.spyOn(vm.service, 'poll').mockReturnValue(returnPromise('merged'));
- jest.spyOn(vm, 'initiateRemoveSourceBranchPolling').mockImplementation(() => {});
-
- vm.handleMergePolling(() => {}, () => {});
-
- setImmediate(() => {
- expect(document.querySelector('.btn-close').classList.contains('hidden')).toBeTruthy();
-
- done();
- });
- });
-
it('updates merge request count badge', done => {
jest.spyOn(vm.service, 'poll').mockReturnValue(returnPromise('merged'));
jest.spyOn(vm, 'initiateRemoveSourceBranchPolling').mockImplementation(() => {});
diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js
index 1711efb5512..13c0665f929 100644
--- a/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js
+++ b/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js
@@ -31,10 +31,7 @@ describe('DeploymentAction component', () => {
wrapper.destroy();
}
- wrapper = mount(DeploymentActions, {
- ...options,
- provide: { glFeatures: { deployFromFooter: true } },
- });
+ wrapper = mount(DeploymentActions, options);
};
const findStopButton = () => wrapper.find('.js-stop-env');
diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_spec.js
index ce395de3b5d..17d7fcc4bff 100644
--- a/spec/frontend/vue_mr_widget/deployment/deployment_spec.js
+++ b/spec/frontend/vue_mr_widget/deployment/deployment_spec.js
@@ -19,10 +19,7 @@ describe('Deployment component', () => {
if (wrapper && wrapper.destroy) {
wrapper.destroy();
}
- wrapper = mount(DeploymentComponent, {
- ...options,
- provide: { glFeatures: { deployFromFooter: true } },
- });
+ wrapper = mount(DeploymentComponent, options);
};
beforeEach(() => {
diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
index a2ade44b7c4..5fe8ff58d31 100644
--- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
+++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import mountComponent from 'helpers/vue_mount_component_helper';
+import { withGonExperiment } from 'helpers/experimentation_helper';
import axios from '~/lib/utils/axios_utils';
import mrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
import eventHub from '~/vue_merge_request_widget/event_hub';
@@ -812,43 +813,61 @@ describe('mrWidgetOptions', () => {
});
});
- describe('given suggestPipeline feature flag is enabled', () => {
+ describe('suggestPipeline Experiment', () => {
beforeEach(() => {
mock.onAny().reply(200);
// This is needed because some grandchildren Bootstrap components throw warnings
// https://gitlab.com/gitlab-org/gitlab/issues/208458
jest.spyOn(console, 'warn').mockImplementation();
+ });
- gon.features = { suggestPipeline: true };
+ describe('given experiment is enabled', () => {
+ withGonExperiment('suggestPipeline');
- createComponent();
+ beforeEach(() => {
+ createComponent();
- vm.mr.hasCI = false;
- });
+ vm.mr.hasCI = false;
+ });
- it('should suggest pipelines when none exist', () => {
- expect(findSuggestPipeline()).toEqual(expect.any(Element));
- });
+ it('should suggest pipelines when none exist', () => {
+ expect(findSuggestPipeline()).toEqual(expect.any(Element));
+ });
- it.each([
- { isDismissedSuggestPipeline: true },
- { mergeRequestAddCiConfigPath: null },
- { hasCI: true },
- ])('with %s, should not suggest pipeline', async obj => {
- Object.assign(vm.mr, obj);
+ it.each([
+ { isDismissedSuggestPipeline: true },
+ { mergeRequestAddCiConfigPath: null },
+ { hasCI: true },
+ ])('with %s, should not suggest pipeline', async obj => {
+ Object.assign(vm.mr, obj);
- await vm.$nextTick();
+ await vm.$nextTick();
- expect(findSuggestPipeline()).toBeNull();
+ expect(findSuggestPipeline()).toBeNull();
+ });
+
+ it('should allow dismiss of the suggest pipeline message', async () => {
+ findSuggestPipelineButton().click();
+
+ await vm.$nextTick();
+
+ expect(findSuggestPipeline()).toBeNull();
+ });
});
- it('should allow dismiss of the suggest pipeline message', async () => {
- findSuggestPipelineButton().click();
+ describe('given suggestPipeline experiment is not enabled', () => {
+ withGonExperiment('suggestPipeline', false);
- await vm.$nextTick();
+ beforeEach(() => {
+ createComponent();
- expect(findSuggestPipeline()).toBeNull();
+ vm.mr.hasCI = false;
+ });
+
+ it('should not suggest pipelines when none exist', () => {
+ expect(findSuggestPipeline()).toBeNull();
+ });
});
});
});
diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
index dfd114a2d1c..ec4a81054db 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
@@ -39,6 +39,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
tag="div"
>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="d-inline-flex"
data-clipboard-text="ssh://foo.bar"
@@ -80,6 +81,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
tag="div"
>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="d-inline-flex"
data-clipboard-text="http://foo.bar"
diff --git a/spec/frontend/vue_shared/components/alert_detail_table_spec.js b/spec/frontend/vue_shared/components/alert_detail_table_spec.js
deleted file mode 100644
index 9c38ccad8a7..00000000000
--- a/spec/frontend/vue_shared/components/alert_detail_table_spec.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import { mount } from '@vue/test-utils';
-import { GlTable, GlLoadingIcon } from '@gitlab/ui';
-import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
-
-const mockAlert = {
- iid: '1527542',
- title: 'SyntaxError: Invalid or unexpected token',
- severity: 'CRITICAL',
- eventCount: 7,
- createdAt: '2020-04-17T23:18:14.996Z',
- startedAt: '2020-04-17T23:18:14.996Z',
- endedAt: '2020-04-17T23:18:14.996Z',
- status: 'TRIGGERED',
- assignees: { nodes: [] },
- notes: { nodes: [] },
- todos: { nodes: [] },
-};
-
-describe('AlertDetails', () => {
- let wrapper;
-
- function mountComponent(propsData = {}) {
- wrapper = mount(AlertDetailsTable, {
- propsData: {
- alert: mockAlert,
- loading: false,
- ...propsData,
- },
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- const findTableComponent = () => wrapper.find(GlTable);
-
- describe('Alert details', () => {
- describe('empty state', () => {
- beforeEach(() => {
- mountComponent({ alert: null });
- });
-
- it('shows an empty state when no alert is provided', () => {
- expect(wrapper.text()).toContain('No alert data to display.');
- });
- });
-
- describe('loading state', () => {
- beforeEach(() => {
- mountComponent({ loading: true });
- });
-
- it('displays a loading state when loading', () => {
- expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
- });
- });
-
- describe('with table data', () => {
- beforeEach(() => {
- mountComponent();
- });
-
- it('renders a table', () => {
- expect(findTableComponent().exists()).toBe(true);
- });
-
- it('renders a cell based on alert data', () => {
- expect(findTableComponent().text()).toContain('SyntaxError: Invalid or unexpected token');
- });
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/alert_details_table_spec.js b/spec/frontend/vue_shared/components/alert_details_table_spec.js
new file mode 100644
index 00000000000..dbdb7705d3c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/alert_details_table_spec.js
@@ -0,0 +1,99 @@
+import { GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
+
+const mockAlert = {
+ iid: '1527542',
+ title: 'SyntaxError: Invalid or unexpected token',
+ severity: 'CRITICAL',
+ eventCount: 7,
+ createdAt: '2020-04-17T23:18:14.996Z',
+ startedAt: '2020-04-17T23:18:14.996Z',
+ endedAt: '2020-04-17T23:18:14.996Z',
+ status: 'TRIGGERED',
+ assignees: { nodes: [] },
+ notes: { nodes: [] },
+ todos: { nodes: [] },
+ __typename: 'AlertManagementAlert',
+};
+
+describe('AlertDetails', () => {
+ let wrapper;
+
+ function mountComponent(propsData = {}) {
+ wrapper = mount(AlertDetailsTable, {
+ propsData: {
+ alert: mockAlert,
+ loading: false,
+ ...propsData,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findTableComponent = () => wrapper.find(GlTable);
+ const findTableKeys = () => findTableComponent().findAll('tbody td:first-child');
+ const findTableField = (fields, fieldName) => fields.filter(row => row.text() === fieldName);
+
+ describe('Alert details', () => {
+ describe('empty state', () => {
+ beforeEach(() => {
+ mountComponent({ alert: null });
+ });
+
+ it('shows an empty state when no alert is provided', () => {
+ expect(wrapper.text()).toContain('No alert data to display.');
+ });
+ });
+
+ describe('loading state', () => {
+ beforeEach(() => {
+ mountComponent({ loading: true });
+ });
+
+ it('displays a loading state when loading', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
+
+ describe('with table data', () => {
+ const environment = 'myEnvironment';
+ const environmentUrl = 'fake/url';
+ beforeEach(() => {
+ mountComponent({ alert: { ...mockAlert, environment, environmentUrl } });
+ });
+
+ it('renders a table', () => {
+ expect(findTableComponent().exists()).toBe(true);
+ });
+
+ it('renders a cell based on alert data', () => {
+ expect(findTableComponent().text()).toContain('SyntaxError: Invalid or unexpected token');
+ });
+
+ it('should show allowed alert fields', () => {
+ const fields = findTableKeys();
+
+ expect(findTableField(fields, 'Iid').exists()).toBe(true);
+ expect(findTableField(fields, 'Title').exists()).toBe(true);
+ expect(findTableField(fields, 'Severity').exists()).toBe(true);
+ expect(findTableField(fields, 'Status').exists()).toBe(true);
+ expect(findTableField(fields, 'Environment').exists()).toBe(true);
+ });
+
+ it('should not show disallowed alert fields', () => {
+ const fields = findTableKeys();
+
+ expect(findTableField(fields, 'Typename').exists()).toBe(false);
+ expect(findTableField(fields, 'Todos').exists()).toBe(false);
+ expect(findTableField(fields, 'Notes').exists()).toBe(false);
+ expect(findTableField(fields, 'Assignees').exists()).toBe(false);
+ expect(findTableField(fields, 'EnvironmentUrl').exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/clipboard_button_spec.js b/spec/frontend/vue_shared/components/clipboard_button_spec.js
index 7f0b7ba8cf8..51a2653befc 100644
--- a/spec/frontend/vue_shared/components/clipboard_button_spec.js
+++ b/spec/frontend/vue_shared/components/clipboard_button_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedButton, GlIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
describe('clipboard button', () => {
@@ -26,9 +26,8 @@ describe('clipboard button', () => {
});
it('renders a button for clipboard', () => {
- expect(wrapper.find(GlDeprecatedButton).exists()).toBe(true);
+ expect(wrapper.find(GlButton).exists()).toBe(true);
expect(wrapper.attributes('data-clipboard-text')).toBe('copy me');
- expect(wrapper.find(GlIcon).props('name')).toBe('copy-to-clipboard');
});
it('should have a tooltip with default values', () => {
diff --git a/spec/frontend/vue_shared/components/confirm_modal_spec.js b/spec/frontend/vue_shared/components/confirm_modal_spec.js
index 5d92af64de0..8456ca9d125 100644
--- a/spec/frontend/vue_shared/components/confirm_modal_spec.js
+++ b/spec/frontend/vue_shared/components/confirm_modal_spec.js
@@ -86,6 +86,22 @@ describe('vue_shared/components/confirm_modal', () => {
expect(findForm().element.submit).not.toHaveBeenCalled();
});
+ describe('with handleSubmit prop', () => {
+ const handleSubmit = jest.fn();
+ beforeEach(() => {
+ createComponent({ handleSubmit });
+ findModal().vm.$emit('primary');
+ });
+
+ it('will call handleSubmit', () => {
+ expect(handleSubmit).toHaveBeenCalled();
+ });
+
+ it('does not submit the form', () => {
+ expect(findForm().element.submit).not.toHaveBeenCalled();
+ });
+ });
+
describe('when modal submitted', () => {
beforeEach(() => {
findModal().vm.$emit('primary');
diff --git a/spec/frontend/vue_shared/components/local_storage_sync_spec.js b/spec/frontend/vue_shared/components/local_storage_sync_spec.js
index 5470171a21e..3ff4c0917f2 100644
--- a/spec/frontend/vue_shared/components/local_storage_sync_spec.js
+++ b/spec/frontend/vue_shared/components/local_storage_sync_spec.js
@@ -12,7 +12,9 @@ describe('Local Storage Sync', () => {
};
afterEach(() => {
- wrapper.destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ }
wrapper = null;
localStorage.clear();
});
@@ -45,23 +47,23 @@ describe('Local Storage Sync', () => {
expect(wrapper.emitted('input')).toBeFalsy();
});
- it('saves updated value to localStorage', () => {
- createComponent({
- props: {
- storageKey,
- value: 'ascending',
- },
- });
-
- const newValue = 'descending';
- wrapper.setProps({
- value: newValue,
- });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(localStorage.getItem(storageKey)).toBe(newValue);
- });
- });
+ it.each('foo', 3, true, ['foo', 'bar'], { foo: 'bar' })(
+ 'saves updated value to localStorage',
+ newValue => {
+ createComponent({
+ props: {
+ storageKey,
+ value: 'initial',
+ },
+ });
+
+ wrapper.setProps({ value: newValue });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(localStorage.getItem(storageKey)).toBe(String(newValue));
+ });
+ },
+ );
it('does not save default value', () => {
const value = 'ascending';
@@ -125,4 +127,88 @@ describe('Local Storage Sync', () => {
});
});
});
+
+ describe('with "asJson" prop set to "true"', () => {
+ const storageKey = 'testStorageKey';
+
+ describe.each`
+ value | serializedValue
+ ${null} | ${'null'}
+ ${''} | ${'""'}
+ ${true} | ${'true'}
+ ${false} | ${'false'}
+ ${42} | ${'42'}
+ ${'42'} | ${'"42"'}
+ ${'{ foo: '} | ${'"{ foo: "'}
+ ${['test']} | ${'["test"]'}
+ ${{ foo: 'bar' }} | ${'{"foo":"bar"}'}
+ `('given $value', ({ value, serializedValue }) => {
+ describe('is a new value', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ storageKey,
+ value: 'initial',
+ asJson: true,
+ },
+ });
+
+ wrapper.setProps({ value });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('serializes the value correctly to localStorage', () => {
+ expect(localStorage.getItem(storageKey)).toBe(serializedValue);
+ });
+ });
+
+ describe('is already stored', () => {
+ beforeEach(() => {
+ localStorage.setItem(storageKey, serializedValue);
+
+ createComponent({
+ props: {
+ storageKey,
+ value: 'initial',
+ asJson: true,
+ },
+ });
+ });
+
+ it('emits an input event with the deserialized value', () => {
+ expect(wrapper.emitted('input')).toEqual([[value]]);
+ });
+ });
+ });
+
+ describe('with bad JSON in storage', () => {
+ const badJSON = '{ badJSON';
+
+ beforeEach(() => {
+ jest.spyOn(console, 'warn').mockImplementation();
+ localStorage.setItem(storageKey, badJSON);
+
+ createComponent({
+ props: {
+ storageKey,
+ value: 'initial',
+ asJson: true,
+ },
+ });
+ });
+
+ it('should console warn', () => {
+ // eslint-disable-next-line no-console
+ expect(console.warn).toHaveBeenCalledWith(
+ `[gitlab] Failed to deserialize value from localStorage (key=${storageKey})`,
+ badJSON,
+ );
+ });
+
+ it('should not emit an input event', () => {
+ expect(wrapper.emitted('input')).toBeUndefined();
+ });
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap b/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap
index cdd7a3ccaf0..b8a9143bc79 100644
--- a/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap
+++ b/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap
@@ -10,6 +10,7 @@ exports[`Suggestion Diff component matches snapshot 1`] = `
helppagepath="path_to_docs"
isapplyingbatch="true"
isbatched="true"
+ suggestionscount="0"
/>
<table
diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js
index 3da0a35f05a..f1ead33ec68 100644
--- a/spec/frontend/vue_shared/components/markdown/field_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/field_spec.js
@@ -7,6 +7,7 @@ import axios from '~/lib/utils/axios_utils';
const markdownPreviewPath = `${TEST_HOST}/preview`;
const markdownDocsPath = `${TEST_HOST}/docs`;
+const textareaValue = 'testing\n123';
function assertMarkdownTabs(isWrite, writeLink, previewLink, wrapper) {
expect(writeLink.element.parentNode.classList.contains('active')).toBe(isWrite);
@@ -20,23 +21,11 @@ function createComponent() {
markdownDocsPath,
markdownPreviewPath,
isSubmitting: false,
+ textareaValue,
},
slots: {
- textarea: '<textarea>testing\n123</textarea>',
+ textarea: `<textarea>${textareaValue}</textarea>`,
},
- template: `
- <field-component
- markdown-preview-path="${markdownPreviewPath}"
- markdown-docs-path="${markdownDocsPath}"
- :isSubmitting="false"
- >
- <textarea
- slot="textarea"
- v-model="text">
- <slot>this is a test</slot>
- </textarea>
- </field-component>
- `,
});
return wrapper;
}
diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
index a521668b15c..b19e74b5b11 100644
--- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
@@ -57,7 +57,9 @@ describe('Suggestion Diff component', () => {
});
it('renders apply suggestion and add to batch buttons', () => {
- createComponent();
+ createComponent({
+ suggestionsCount: 2,
+ });
const applyBtn = findApplyButton();
const addToBatchBtn = findAddToBatchButton();
@@ -104,7 +106,9 @@ describe('Suggestion Diff component', () => {
describe('when add to batch is clicked', () => {
it('emits addToBatch', () => {
- createComponent();
+ createComponent({
+ suggestionsCount: 2,
+ });
findAddToBatchButton().vm.$emit('click');
diff --git a/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js
new file mode 100644
index 00000000000..d6f5773295c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js
@@ -0,0 +1,46 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { getByText as getByTextHelper } from '@testing-library/dom';
+import { GlAvatarLink } from '@gitlab/ui';
+import { group as member } from '../mock_data';
+import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue';
+
+describe('MemberList', () => {
+ let wrapper;
+
+ const group = member.sharedWithGroup;
+
+ const createComponent = (propsData = {}) => {
+ wrapper = mount(GroupAvatar, {
+ propsData: {
+ member,
+ ...propsData,
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(getByTextHelper(wrapper.element, text, options));
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders link to group', () => {
+ const link = wrapper.find(GlAvatarLink);
+
+ expect(link.exists()).toBe(true);
+ expect(link.attributes('href')).toBe(group.webUrl);
+ });
+
+ it("renders group's full name", () => {
+ expect(getByText(group.fullName).exists()).toBe(true);
+ });
+
+ it("renders group's avatar", () => {
+ expect(wrapper.find('img').attributes('src')).toBe(group.avatarUrl);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js
new file mode 100644
index 00000000000..7948da7eb40
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js
@@ -0,0 +1,38 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { getByText as getByTextHelper } from '@testing-library/dom';
+import { invite as member } from '../mock_data';
+import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue';
+
+describe('MemberList', () => {
+ let wrapper;
+
+ const { invite } = member;
+
+ const createComponent = (propsData = {}) => {
+ wrapper = mount(InviteAvatar, {
+ propsData: {
+ member,
+ ...propsData,
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(getByTextHelper(wrapper.element, text, options));
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders email as name', () => {
+ expect(getByText(invite.email).exists()).toBe(true);
+ });
+
+ it('renders avatar', () => {
+ expect(wrapper.find('img').attributes('src')).toBe(invite.avatarUrl);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js
new file mode 100644
index 00000000000..6c0ba8afede
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js
@@ -0,0 +1,85 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { within } from '@testing-library/dom';
+import { GlAvatarLink, GlBadge } from '@gitlab/ui';
+import { member as memberMock, orphanedMember } from '../mock_data';
+import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue';
+
+describe('UserAvatar', () => {
+ let wrapper;
+
+ const { user } = memberMock;
+
+ const createComponent = (propsData = {}) => {
+ wrapper = mount(UserAvatar, {
+ propsData: {
+ member: memberMock,
+ isCurrentUser: false,
+ ...propsData,
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(within(wrapper.element).findByText(text, options));
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it("renders link to user's profile", () => {
+ createComponent();
+
+ const link = wrapper.find(GlAvatarLink);
+
+ expect(link.exists()).toBe(true);
+ expect(link.attributes()).toMatchObject({
+ href: user.webUrl,
+ 'data-user-id': `${user.id}`,
+ 'data-username': user.username,
+ });
+ });
+
+ it("renders user's name", () => {
+ createComponent();
+
+ expect(getByText(user.name).exists()).toBe(true);
+ });
+
+ it("renders user's username", () => {
+ createComponent();
+
+ expect(getByText(`@${user.username}`).exists()).toBe(true);
+ });
+
+ it("renders user's avatar", () => {
+ createComponent();
+
+ expect(wrapper.find('img').attributes('src')).toBe(user.avatarUrl);
+ });
+
+ describe('when user property does not exist', () => {
+ it('displays an orphaned user', () => {
+ createComponent({ member: orphanedMember });
+
+ expect(getByText('Orphaned member').exists()).toBe(true);
+ });
+ });
+
+ describe('badges', () => {
+ it.each`
+ member | badgeText
+ ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${'Blocked'}
+ ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${'2FA'}
+ `('renders the "$badgeText" badge', ({ member, badgeText }) => {
+ createComponent({ member });
+
+ expect(wrapper.find(GlBadge).text()).toBe(badgeText);
+ });
+
+ it('renders the "It\'s you" badge when member is current user', () => {
+ createComponent({ isCurrentUser: true });
+
+ expect(getByText("It's you").exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/mock_data.js b/spec/frontend/vue_shared/components/members/mock_data.js
new file mode 100644
index 00000000000..3195f04f202
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/mock_data.js
@@ -0,0 +1,61 @@
+export const member = {
+ requestedAt: null,
+ canUpdate: false,
+ canRemove: false,
+ canOverride: false,
+ accessLevel: { integerValue: 50, stringValue: 'Owner' },
+ source: {
+ id: 178,
+ name: 'Foo Bar',
+ webUrl: 'https://gitlab.com/groups/foo-bar',
+ },
+ user: {
+ id: 123,
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'https://gitlab.com/root',
+ avatarUrl: 'https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80&d=identicon',
+ blocked: false,
+ twoFactorEnabled: false,
+ },
+ id: 238,
+ createdAt: '2020-07-17T16:22:46.923Z',
+ expiresAt: null,
+ usingLicense: false,
+ groupSso: false,
+ groupManagedAccount: false,
+};
+
+export const group = {
+ accessLevel: { integerValue: 10, stringValue: 'Guest' },
+ sharedWithGroup: {
+ id: 24,
+ name: 'Commit451',
+ avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png?width=40',
+ fullPath: 'parent-group/commit451',
+ fullName: 'Parent group / Commit451',
+ webUrl: 'https://gitlab.com/groups/parent-group/commit451',
+ },
+ id: 3,
+ createdAt: '2020-08-06T15:31:07.662Z',
+ expiresAt: null,
+};
+
+const { user, ...memberNoUser } = member;
+export const invite = {
+ ...memberNoUser,
+ invite: {
+ email: 'jewel@hudsonwalter.biz',
+ avatarUrl: 'https://www.gravatar.com/avatar/cbab7510da7eec2f60f638261b05436d?s=80&d=identicon',
+ canResend: true,
+ },
+};
+
+export const orphanedMember = memberNoUser;
+
+export const accessRequest = {
+ ...member,
+ requestedAt: '2020-07-17T16:22:46.923Z',
+};
+
+export const members = [member];
diff --git a/spec/frontend/vue_shared/components/members/table/created_at_spec.js b/spec/frontend/vue_shared/components/members/table/created_at_spec.js
new file mode 100644
index 00000000000..cf3821baf44
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/created_at_spec.js
@@ -0,0 +1,61 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { within } from '@testing-library/dom';
+import { useFakeDate } from 'helpers/fake_date';
+import CreatedAt from '~/vue_shared/components/members/table/created_at.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+describe('CreatedAt', () => {
+ // March 15th, 2020
+ useFakeDate(2020, 2, 15);
+
+ const date = '2020-03-01T00:00:00.000';
+ const dateTimeAgo = '2 weeks ago';
+
+ let wrapper;
+
+ const createComponent = propsData => {
+ wrapper = mount(CreatedAt, {
+ propsData: {
+ date,
+ ...propsData,
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(within(wrapper.element).getByText(text, options));
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('created at text', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('displays created at text', () => {
+ expect(getByText(dateTimeAgo).exists()).toBe(true);
+ });
+
+ it('uses `TimeAgoTooltip` component to display tooltip', () => {
+ expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true);
+ });
+ });
+
+ describe('when `createdBy` prop is provided', () => {
+ it('displays a link to the user that created the member', () => {
+ createComponent({
+ createdBy: {
+ name: 'Administrator',
+ webUrl: 'https://gitlab.com/root',
+ },
+ });
+
+ const link = getByText('Administrator');
+
+ expect(link.exists()).toBe(true);
+ expect(link.attributes('href')).toBe('https://gitlab.com/root');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/table/expires_at_spec.js b/spec/frontend/vue_shared/components/members/table/expires_at_spec.js
new file mode 100644
index 00000000000..95ae251b0fd
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/expires_at_spec.js
@@ -0,0 +1,86 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { within } from '@testing-library/dom';
+import { useFakeDate } from 'helpers/fake_date';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue';
+
+describe('ExpiresAt', () => {
+ // March 15th, 2020
+ useFakeDate(2020, 2, 15);
+
+ let wrapper;
+
+ const createComponent = propsData => {
+ wrapper = mount(ExpiresAt, {
+ propsData,
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(within(wrapper.element).getByText(text, options));
+
+ const getTooltipDirective = elementWrapper => getBinding(elementWrapper.element, 'gl-tooltip');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when no expiration date is set', () => {
+ it('displays "No expiration set"', () => {
+ createComponent({ date: null });
+
+ expect(getByText('No expiration set').exists()).toBe(true);
+ });
+ });
+
+ describe('when expiration date is in the past', () => {
+ let expiredText;
+
+ beforeEach(() => {
+ createComponent({ date: '2019-03-15T00:00:00.000' });
+
+ expiredText = getByText('Expired');
+ });
+
+ it('displays "Expired"', () => {
+ expect(expiredText.exists()).toBe(true);
+ expect(expiredText.classes()).toContain('gl-text-red-500');
+ });
+
+ it('displays tooltip with formatted date', () => {
+ const tooltipDirective = getTooltipDirective(expiredText);
+
+ expect(tooltipDirective).not.toBeUndefined();
+ expect(expiredText.attributes('title')).toBe('Mar 15, 2019 12:00am GMT+0000');
+ });
+ });
+
+ describe('when expiration date is in the future', () => {
+ it.each`
+ date | expected | warningColor
+ ${'2020-03-23T00:00:00.000'} | ${'in 8 days'} | ${false}
+ ${'2020-03-20T00:00:00.000'} | ${'in 5 days'} | ${true}
+ ${'2020-03-16T00:00:00.000'} | ${'in 1 day'} | ${true}
+ ${'2020-03-15T05:00:00.000'} | ${'in about 5 hours'} | ${true}
+ ${'2020-03-15T01:00:00.000'} | ${'in about 1 hour'} | ${true}
+ ${'2020-03-15T00:30:00.000'} | ${'in 30 minutes'} | ${true}
+ ${'2020-03-15T00:01:15.000'} | ${'in 1 minute'} | ${true}
+ ${'2020-03-15T00:00:15.000'} | ${'in less than a minute'} | ${true}
+ `('displays "$expected"', ({ date, expected, warningColor }) => {
+ createComponent({ date });
+
+ const expiredText = getByText(expected);
+
+ expect(expiredText.exists()).toBe(true);
+
+ if (warningColor) {
+ expect(expiredText.classes()).toContain('gl-text-orange-500');
+ } else {
+ expect(expiredText.classes()).not.toContain('gl-text-orange-500');
+ }
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js b/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js
new file mode 100644
index 00000000000..a171dd830c1
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js
@@ -0,0 +1,39 @@
+import { shallowMount } from '@vue/test-utils';
+import { MEMBER_TYPES } from '~/vue_shared/components/members/constants';
+import { member as memberMock, group, invite, accessRequest } from '../mock_data';
+import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue';
+import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue';
+import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue';
+import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue';
+
+describe('MemberList', () => {
+ let wrapper;
+
+ const createComponent = propsData => {
+ wrapper = shallowMount(MemberAvatar, {
+ propsData: {
+ isCurrentUser: false,
+ ...propsData,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ test.each`
+ memberType | member | expectedComponent | expectedComponentName
+ ${MEMBER_TYPES.user} | ${memberMock} | ${UserAvatar} | ${'UserAvatar'}
+ ${MEMBER_TYPES.group} | ${group} | ${GroupAvatar} | ${'GroupAvatar'}
+ ${MEMBER_TYPES.invite} | ${invite} | ${InviteAvatar} | ${'InviteAvatar'}
+ ${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${UserAvatar} | ${'UserAvatar'}
+ `(
+ 'renders $expectedComponentName when `memberType` is $memberType',
+ ({ memberType, member, expectedComponent }) => {
+ createComponent({ memberType, member });
+
+ expect(wrapper.find(expectedComponent).exists()).toBe(true);
+ },
+ );
+});
diff --git a/spec/frontend/vue_shared/components/members/table/member_source_spec.js b/spec/frontend/vue_shared/components/members/table/member_source_spec.js
new file mode 100644
index 00000000000..8b914d76674
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/member_source_spec.js
@@ -0,0 +1,71 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { getByText as getByTextHelper } from '@testing-library/dom';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import MemberSource from '~/vue_shared/components/members/table/member_source.vue';
+
+describe('MemberSource', () => {
+ let wrapper;
+
+ const createComponent = propsData => {
+ wrapper = mount(MemberSource, {
+ propsData: {
+ memberSource: {
+ id: 102,
+ name: 'Foo bar',
+ webUrl: 'https://gitlab.com/groups/foo-bar',
+ },
+ ...propsData,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(getByTextHelper(wrapper.element, text, options));
+
+ const getTooltipDirective = elementWrapper => getBinding(elementWrapper.element, 'gl-tooltip');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('direct member', () => {
+ it('displays "Direct member"', () => {
+ createComponent({
+ isDirectMember: true,
+ });
+
+ expect(getByText('Direct member').exists()).toBe(true);
+ });
+ });
+
+ describe('inherited member', () => {
+ let sourceGroupLink;
+
+ beforeEach(() => {
+ createComponent({
+ isDirectMember: false,
+ });
+
+ sourceGroupLink = getByText('Foo bar');
+ });
+
+ it('displays a link to source group', () => {
+ createComponent({
+ isDirectMember: false,
+ });
+
+ expect(sourceGroupLink.exists()).toBe(true);
+ expect(sourceGroupLink.attributes('href')).toBe('https://gitlab.com/groups/foo-bar');
+ });
+
+ it('displays tooltip with "Inherited"', () => {
+ const tooltipDirective = getTooltipDirective(sourceGroupLink);
+
+ expect(tooltipDirective).not.toBeUndefined();
+ expect(sourceGroupLink.attributes('title')).toBe('Inherited');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js
new file mode 100644
index 00000000000..960d9bc164c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js
@@ -0,0 +1,130 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { MEMBER_TYPES } from '~/vue_shared/components/members/constants';
+import { member as memberMock, group, invite, accessRequest } from '../mock_data';
+import MembersTableCell from '~/vue_shared/components/members/table/members_table_cell.vue';
+
+describe('MemberList', () => {
+ const WrappedComponent = {
+ props: {
+ memberType: {
+ type: String,
+ required: true,
+ },
+ isDirectMember: {
+ type: Boolean,
+ required: true,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ render(createElement) {
+ return createElement('div', this.memberType);
+ },
+ };
+
+ const localVue = createLocalVue();
+ localVue.use(Vuex);
+ localVue.component('wrapped-component', WrappedComponent);
+
+ const createStore = (state = {}) => {
+ return new Vuex.Store({
+ state: {
+ sourceId: 1,
+ currentUserId: 1,
+ ...state,
+ },
+ });
+ };
+
+ let wrapper;
+
+ const createComponent = (propsData, state = {}) => {
+ wrapper = mount(MembersTableCell, {
+ localVue,
+ propsData,
+ store: createStore(state),
+ scopedSlots: {
+ default: `
+ <wrapped-component
+ :member-type="props.memberType"
+ :is-direct-member="props.isDirectMember"
+ :is-current-user="props.isCurrentUser"
+ />
+ `,
+ },
+ });
+ };
+
+ const findWrappedComponent = () => wrapper.find(WrappedComponent);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ test.each`
+ member | expectedMemberType
+ ${memberMock} | ${MEMBER_TYPES.user}
+ ${group} | ${MEMBER_TYPES.group}
+ ${invite} | ${MEMBER_TYPES.invite}
+ ${accessRequest} | ${MEMBER_TYPES.accessRequest}
+ `(
+ 'sets scoped slot prop `memberType` to $expectedMemberType',
+ ({ member, expectedMemberType }) => {
+ createComponent({ member });
+
+ expect(findWrappedComponent().props('memberType')).toBe(expectedMemberType);
+ },
+ );
+
+ describe('isDirectMember', () => {
+ it('returns `true` when member source has same ID as `sourceId`', () => {
+ createComponent({
+ member: {
+ ...memberMock,
+ source: {
+ ...memberMock.source,
+ id: 1,
+ },
+ },
+ });
+
+ expect(findWrappedComponent().props('isDirectMember')).toBe(true);
+ });
+
+ it('returns `false` when member is inherited', () => {
+ createComponent({
+ member: memberMock,
+ });
+
+ expect(findWrappedComponent().props('isDirectMember')).toBe(false);
+ });
+ });
+
+ describe('isCurrentUser', () => {
+ it('returns `true` when `member.user` has the same ID as `currentUserId`', () => {
+ createComponent({
+ member: {
+ ...memberMock,
+ user: {
+ ...memberMock.user,
+ id: 1,
+ },
+ },
+ });
+
+ expect(findWrappedComponent().props('isCurrentUser')).toBe(true);
+ });
+
+ it('returns `false` when `member.user` does not have the same ID as `currentUserId`', () => {
+ createComponent({
+ member: memberMock,
+ });
+
+ expect(findWrappedComponent().props('isCurrentUser')).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/table/members_table_spec.js b/spec/frontend/vue_shared/components/members/table/members_table_spec.js
new file mode 100644
index 00000000000..4979a7096ac
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/members_table_spec.js
@@ -0,0 +1,104 @@
+import { mount, createLocalVue, createWrapper } from '@vue/test-utils';
+import Vuex from 'vuex';
+import {
+ getByText as getByTextHelper,
+ getByTestId as getByTestIdHelper,
+} from '@testing-library/dom';
+import MembersTable from '~/vue_shared/components/members/table/members_table.vue';
+import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue';
+import MemberSource from '~/vue_shared/components/members/table/member_source.vue';
+import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue';
+import CreatedAt from '~/vue_shared/components/members/table/created_at.vue';
+import * as initUserPopovers from '~/user_popovers';
+import { member as memberMock, invite, accessRequest } from '../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('MemberList', () => {
+ let wrapper;
+
+ const createStore = (state = {}) => {
+ return new Vuex.Store({
+ state: {
+ members: [],
+ tableFields: [],
+ ...state,
+ },
+ });
+ };
+
+ const createComponent = state => {
+ wrapper = mount(MembersTable, {
+ localVue,
+ store: createStore(state),
+ stubs: ['member-avatar', 'member-source', 'expires-at', 'created-at'],
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(getByTextHelper(wrapper.element, text, options));
+
+ const getByTestId = (id, options) =>
+ createWrapper(getByTestIdHelper(wrapper.element, id, options));
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('fields', () => {
+ it.each`
+ field | label | member | expectedComponent
+ ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar}
+ ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource}
+ ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt}
+ ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt}
+ ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt}
+ ${'expires'} | ${'Access expires'} | ${memberMock} | ${ExpiresAt}
+ ${'maxRole'} | ${'Max role'} | ${memberMock} | ${null}
+ ${'expiration'} | ${'Expiration'} | ${memberMock} | ${null}
+ `('renders the $label field', ({ field, label, member, expectedComponent }) => {
+ createComponent({
+ members: [member],
+ tableFields: [field],
+ });
+
+ expect(getByText(label, { selector: '[role="columnheader"]' }).exists()).toBe(true);
+
+ if (expectedComponent) {
+ expect(
+ wrapper
+ .find(`[data-label="${label}"][role="cell"]`)
+ .find(expectedComponent)
+ .exists(),
+ ).toBe(true);
+ }
+ });
+
+ it('renders "Actions" field for screen readers', () => {
+ createComponent({ tableFields: ['actions'] });
+
+ const actionField = getByTestId('col-actions');
+
+ expect(actionField.exists()).toBe(true);
+ expect(actionField.classes('gl-sr-only')).toBe(true);
+ });
+ });
+
+ describe('when `members` is an empty array', () => {
+ it('displays a "No members found" message', () => {
+ createComponent();
+
+ expect(getByText('No members found').exists()).toBe(true);
+ });
+ });
+
+ it('initializes user popovers when mounted', () => {
+ const initUserPopoversMock = jest.spyOn(initUserPopovers, 'default');
+
+ createComponent();
+
+ expect(initUserPopoversMock).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/utils_spec.js b/spec/frontend/vue_shared/components/members/utils_spec.js
new file mode 100644
index 00000000000..f183abc08d6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/utils_spec.js
@@ -0,0 +1,29 @@
+import { generateBadges } from '~/vue_shared/components/members/utils';
+import { member as memberMock } from './mock_data';
+
+describe('Members Utils', () => {
+ describe('generateBadges', () => {
+ it('has correct properties for each badge', () => {
+ const badges = generateBadges(memberMock, true);
+
+ badges.forEach(badge => {
+ expect(badge).toEqual(
+ expect.objectContaining({
+ show: expect.any(Boolean),
+ text: expect.any(String),
+ variant: expect.stringMatching(/muted|neutral|info|success|danger|warning/),
+ }),
+ );
+ });
+ });
+
+ it.each`
+ member | expected
+ ${memberMock} | ${{ show: true, text: "It's you", variant: 'success' }}
+ ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${{ show: true, text: 'Blocked', variant: 'danger' }}
+ ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${{ show: true, text: '2FA', variant: 'info' }}
+ `('returns expected output for "$expected.text" badge', ({ member, expected }) => {
+ expect(generateBadges(member, true)).toContainEqual(expect.objectContaining(expected));
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
index 16094a42668..27276faf333 100644
--- a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
+++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
@@ -38,7 +38,8 @@ exports[`Package code instruction single line to match the default snapshot 1`]
data-testid="instruction-button"
>
<button
- class="btn input-group-text btn-secondary btn-md btn-default"
+ aria-label="Copy this value"
+ class="btn input-group-text btn-default btn-md gl-button btn-default-secondary btn-icon"
data-clipboard-text="npm i @my-package"
title="Copy npm install command"
type="button"
@@ -53,6 +54,8 @@ exports[`Package code instruction single line to match the default snapshot 1`]
href="#copy-to-clipboard"
/>
</svg>
+
+ <!---->
</button>
</span>
</div>
diff --git a/spec/frontend/vue_shared/components/registry/title_area_spec.js b/spec/frontend/vue_shared/components/registry/title_area_spec.js
index 6740d6097a4..a513f178f45 100644
--- a/spec/frontend/vue_shared/components/registry/title_area_spec.js
+++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js
@@ -1,4 +1,4 @@
-import { GlAvatar } from '@gitlab/ui';
+import { GlAvatar, GlSprintf, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import component from '~/vue_shared/components/registry/title_area.vue';
@@ -10,10 +10,12 @@ describe('title area', () => {
const findMetadataSlot = name => wrapper.find(`[data-testid="${name}"]`);
const findTitle = () => wrapper.find('[data-testid="title"]');
const findAvatar = () => wrapper.find(GlAvatar);
+ const findInfoMessages = () => wrapper.findAll('[data-testid="info-message"]');
const mountComponent = ({ propsData = { title: 'foo' }, slots } = {}) => {
wrapper = shallowMount(component, {
propsData,
+ stubs: { GlSprintf },
slots: {
'sub-header': '<div data-testid="sub-header" />',
'right-actions': '<div data-testid="right-actions" />',
@@ -95,4 +97,33 @@ describe('title area', () => {
});
});
});
+
+ describe('info-messages', () => {
+ it('shows a message when the props contains one', () => {
+ mountComponent({ propsData: { infoMessages: [{ text: 'foo foo bar bar' }] } });
+
+ const messages = findInfoMessages();
+ expect(messages).toHaveLength(1);
+ expect(messages.at(0).text()).toBe('foo foo bar bar');
+ });
+
+ it('shows a link when the props contains one', () => {
+ mountComponent({
+ propsData: {
+ infoMessages: [{ text: 'foo %{docLinkStart}link%{docLinkEnd}', link: 'bar' }],
+ },
+ });
+
+ const message = findInfoMessages().at(0);
+
+ expect(message.find(GlLink).attributes('href')).toBe('bar');
+ expect(message.text()).toBe('foo link');
+ });
+
+ it('multiple messages generates multiple spans', () => {
+ mountComponent({ propsData: { infoMessages: [{ text: 'foo' }, { text: 'bar' }] } });
+
+ expect(findInfoMessages()).toHaveLength(2);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js
index 16f60b5ff21..81d31a284df 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js
@@ -9,9 +9,11 @@ import {
} from '~/vue_shared/components/rich_content_editor/services/editor_service';
import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer';
import buildCustomRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer';
+import sanitizeHTML from '~/vue_shared/components/rich_content_editor/services/sanitize_html';
jest.mock('~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer');
jest.mock('~/vue_shared/components/rich_content_editor/services/build_custom_renderer');
+jest.mock('~/vue_shared/components/rich_content_editor/services/sanitize_html');
describe('Editor Service', () => {
let mockInstance;
@@ -143,5 +145,14 @@ describe('Editor Service', () => {
getEditorOptions(externalOptions);
expect(buildCustomRenderer).toHaveBeenCalledWith(externalOptions.customRenderers);
});
+
+ it('uses the internal sanitizeHTML service for HTML sanitization', () => {
+ const options = getEditorOptions();
+ const html = '<div></div>';
+
+ options.customHTMLSanitizer(html);
+
+ expect(sanitizeHTML).toHaveBeenCalledWith(html);
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js
index a6c712eeb31..b31684a400e 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js
@@ -1,22 +1,21 @@
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_html_block';
import { buildUneditableHtmlAsTextTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
-import { normalTextNode } from './mock_data';
+describe('rich_content_editor/services/renderers/render_html_block', () => {
+ const htmlBlockNode = {
+ literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>',
+ type: 'htmlBlock',
+ };
-const htmlBlockNode = {
- firstChild: null,
- literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>',
- type: 'htmlBlock',
-};
-
-describe('Render HTML renderer', () => {
describe('canRender', () => {
- it('should return true when the argument is an html block', () => {
- expect(renderer.canRender(htmlBlockNode)).toBe(true);
- });
-
- it('should return false when the argument is not an html block', () => {
- expect(renderer.canRender(normalTextNode)).toBe(false);
+ it.each`
+ input | result
+ ${htmlBlockNode} | ${true}
+ ${{ literal: '<iframe></iframe>', type: 'htmlBlock' }} | ${true}
+ ${{ literal: '<iframe src="https://www.youtube.com"></iframe>', type: 'htmlBlock' }} | ${false}
+ ${{ literal: '<iframe></iframe>', type: 'text' }} | ${false}
+ `('returns $result when input=$input', ({ input, result }) => {
+ expect(renderer.canRender(input)).toBe(result);
});
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js
new file mode 100644
index 00000000000..f2182ef60d7
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js
@@ -0,0 +1,11 @@
+import sanitizeHTML from '~/vue_shared/components/rich_content_editor/services/sanitize_html';
+
+describe('rich_content_editor/services/sanitize_html', () => {
+ it.each`
+ input | result
+ ${'<iframe src="https://www.youtube.com"></iframe>'} | ${'<iframe src="https://www.youtube.com"></iframe>'}
+ ${'<iframe src="https://gitlab.com"></iframe>'} | ${''}
+ `('removes iframes if the iframe source origin is not allowed', ({ input, result }) => {
+ expect(sanitizeHTML(input)).toBe(result);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
index 589be0ad7a4..a9350bc059d 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
@@ -69,6 +69,16 @@ describe('DropdownContentsLabelsView', () => {
expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
});
+ it('returns matching labels with fuzzy filtering', () => {
+ wrapper.setData({
+ searchKey: 'bg',
+ });
+
+ expect(wrapper.vm.visibleLabels.length).toBe(2);
+ expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
+ expect(wrapper.vm.visibleLabels[1].title).toBe('Boog');
+ });
+
it('returns all labels when `searchKey` is empty', () => {
wrapper.setData({
searchKey: '',
@@ -133,6 +143,19 @@ describe('DropdownContentsLabelsView', () => {
expect(wrapper.vm.currentHighlightItem).toBe(2);
});
+ it('resets the search text when the Enter key is pressed', () => {
+ wrapper.setData({
+ currentHighlightItem: 1,
+ searchKey: 'bug',
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: ENTER_KEY_CODE,
+ });
+
+ expect(wrapper.vm.searchKey).toBe('');
+ });
+
it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => {
jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
wrapper.setData({
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
index e1008d13fc2..9697d6c30f2 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
@@ -24,6 +24,13 @@ export const mockLabels = [
color: '#FF0000',
textColor: '#FFFFFF',
},
+ {
+ id: 29,
+ title: 'Boog',
+ description: 'Label for bugs',
+ color: '#FF0000',
+ textColor: '#FFFFFF',
+ },
];
export const mockConfig = {
diff --git a/spec/frontend/vue_shared/components/todo_button_spec.js b/spec/frontend/vue_shared/components/todo_button_spec.js
index 482b5de11f6..1f8a214d632 100644
--- a/spec/frontend/vue_shared/components/todo_button_spec.js
+++ b/spec/frontend/vue_shared/components/todo_button_spec.js
@@ -33,7 +33,7 @@ describe('Todo Button', () => {
it.each`
label | isTodo
${'Mark as done'} | ${true}
- ${'Add a To-Do'} | ${false}
+ ${'Add a To Do'} | ${false}
`('sets correct label when isTodo is $isTodo', ({ label, isTodo }) => {
createComponent({ isTodo });
diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js
index 57f511903d9..5532a27b767 100644
--- a/spec/frontend/vue_shared/components/web_ide_link_spec.js
+++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js
@@ -60,6 +60,7 @@ describe('Web IDE link component', () => {
it.each`
props | expectedActions
${{}} | ${[ACTION_WEB_IDE]}
+ ${{ webIdeIsFork: true }} | ${[{ ...ACTION_WEB_IDE, text: 'Edit fork in Web IDE' }]}
${{ needsToFork: true }} | ${[ACTION_WEB_IDE_FORK]}
${{ showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: true }} | ${[ACTION_GITPOD]}
${{ showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: false }} | ${[ACTION_GITPOD_ENABLE]}
diff --git a/spec/frontend/vue_shared/droplab_dropdown_button_spec.js b/spec/frontend/vue_shared/droplab_dropdown_button_spec.js
deleted file mode 100644
index e57c730ecee..00000000000
--- a/spec/frontend/vue_shared/droplab_dropdown_button_spec.js
+++ /dev/null
@@ -1,132 +0,0 @@
-import { mount } from '@vue/test-utils';
-
-import DroplabDropdownButton from '~/vue_shared/components/droplab_dropdown_button.vue';
-
-const mockActions = [
- {
- title: 'Foo',
- description: 'Some foo action',
- },
- {
- title: 'Bar',
- description: 'Some bar action',
- },
-];
-
-const createComponent = ({
- size = '',
- dropdownClass = '',
- actions = mockActions,
- defaultAction = 0,
-}) =>
- mount(DroplabDropdownButton, {
- propsData: {
- size,
- dropdownClass,
- actions,
- defaultAction,
- },
- });
-
-describe('DroplabDropdownButton', () => {
- let wrapper;
-
- beforeEach(() => {
- wrapper = createComponent({});
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('data', () => {
- it('contains `selectedAction` representing value of `defaultAction` prop', () => {
- expect(wrapper.vm.selectedAction).toBe(0);
- });
- });
-
- describe('computed', () => {
- describe('selectedActionTitle', () => {
- it('returns string containing title of selected action', () => {
- wrapper.setData({ selectedAction: 0 });
-
- expect(wrapper.vm.selectedActionTitle).toBe(mockActions[0].title);
-
- wrapper.setData({ selectedAction: 1 });
-
- expect(wrapper.vm.selectedActionTitle).toBe(mockActions[1].title);
- });
- });
-
- describe('buttonSizeClass', () => {
- it('returns string containing button sizing class based on `size` prop', done => {
- const wrapperWithSize = createComponent({
- size: 'sm',
- });
-
- wrapperWithSize.vm.$nextTick(() => {
- expect(wrapperWithSize.vm.buttonSizeClass).toBe('btn-sm');
-
- done();
- wrapperWithSize.destroy();
- });
- });
- });
- });
-
- describe('methods', () => {
- describe('handlePrimaryActionClick', () => {
- it('emits `onActionClick` event on component with selectedAction object as param', () => {
- jest.spyOn(wrapper.vm, '$emit');
-
- wrapper.setData({ selectedAction: 0 });
- wrapper.vm.handlePrimaryActionClick();
-
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('onActionClick', mockActions[0]);
- });
- });
-
- describe('handleActionClick', () => {
- it('emits `onActionSelect` event on component with selectedAction index as param', () => {
- jest.spyOn(wrapper.vm, '$emit');
-
- wrapper.vm.handleActionClick(1);
-
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('onActionSelect', 1);
- });
- });
- });
-
- describe('template', () => {
- it('renders default action button', () => {
- const defaultButton = wrapper.findAll('.btn').at(0);
-
- expect(defaultButton.text()).toBe(mockActions[0].title);
- });
-
- it('renders dropdown button', () => {
- const dropdownButton = wrapper.findAll('.dropdown-toggle').at(0);
-
- expect(dropdownButton.isVisible()).toBe(true);
- });
-
- it('renders dropdown actions', () => {
- const dropdownActions = wrapper.findAll('.dropdown-menu li button');
-
- Array(dropdownActions.length)
- .fill()
- .forEach((_, index) => {
- const actionContent = dropdownActions.at(index).find('.description');
-
- expect(actionContent.find('strong').text()).toBe(mockActions[index].title);
- expect(actionContent.find('p').text()).toBe(mockActions[index].description);
- });
- });
-
- it('renders divider between dropdown actions', () => {
- const dropdownDivider = wrapper.find('.dropdown-menu .divider');
-
- expect(dropdownDivider.isVisible()).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/whats_new/components/app_spec.js b/spec/frontend/whats_new/components/app_spec.js
index 59d05f68fdd..157faa90efa 100644
--- a/spec/frontend/whats_new/components/app_spec.js
+++ b/spec/frontend/whats_new/components/app_spec.js
@@ -1,6 +1,7 @@
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlDrawer } from '@gitlab/ui';
+import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
import App from '~/whats_new/components/app.vue';
const localVue = createLocalVue();
@@ -11,7 +12,8 @@ describe('App', () => {
let store;
let actions;
let state;
- let propsData = { features: '[ {"title":"Whats New Drawer"} ]' };
+ let propsData = { features: '[ {"title":"Whats New Drawer"} ]', storageKey: 'storage-key' };
+ let trackingSpy;
const buildWrapper = () => {
actions = {
@@ -36,11 +38,16 @@ describe('App', () => {
};
beforeEach(() => {
+ document.body.dataset.page = 'test-page';
+ document.body.dataset.namespaceId = 'namespace-840';
+
+ trackingSpy = mockTracking('_category_', null, jest.spyOn);
buildWrapper();
});
afterEach(() => {
wrapper.destroy();
+ unmockTracking();
});
const getDrawer = () => wrapper.find(GlDrawer);
@@ -50,7 +57,11 @@ describe('App', () => {
});
it('dispatches openDrawer when mounted', () => {
- expect(actions.openDrawer).toHaveBeenCalled();
+ expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'storage-key');
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', {
+ label: 'namespace_id',
+ value: 'namespace-840',
+ });
});
it('dispatches closeDrawer when clicking close', () => {
@@ -71,9 +82,30 @@ describe('App', () => {
});
it('handles bad json argument gracefully', () => {
- propsData = { features: 'this is not json' };
+ propsData = { features: 'this is not json', storageKey: 'storage-key' };
buildWrapper();
expect(getDrawer().exists()).toBe(true);
});
+
+ it('send an event when feature item is clicked', () => {
+ propsData = {
+ features: '[ {"title":"Whats New Drawer", "url": "www.url.com"} ]',
+ storageKey: 'storage-key',
+ };
+ buildWrapper();
+ trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
+
+ const link = wrapper.find('[data-testid="whats-new-title-link"]');
+ triggerEvent(link.element);
+
+ expect(trackingSpy.mock.calls[2]).toMatchObject([
+ '_category_',
+ 'click_whats_new_item',
+ {
+ label: 'Whats New Drawer',
+ property: 'www.url.com',
+ },
+ ]);
+ });
});
diff --git a/spec/frontend/whats_new/store/actions_spec.js b/spec/frontend/whats_new/store/actions_spec.js
index d95453c9175..6f550222074 100644
--- a/spec/frontend/whats_new/store/actions_spec.js
+++ b/spec/frontend/whats_new/store/actions_spec.js
@@ -1,11 +1,16 @@
import testAction from 'helpers/vuex_action_helper';
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import actions from '~/whats_new/store/actions';
import * as types from '~/whats_new/store/mutation_types';
describe('whats new actions', () => {
describe('openDrawer', () => {
+ useLocalStorageSpy();
+
it('should commit openDrawer', () => {
- testAction(actions.openDrawer, {}, {}, [{ type: types.OPEN_DRAWER }]);
+ testAction(actions.openDrawer, 'storage-key', {}, [{ type: types.OPEN_DRAWER }]);
+
+ expect(window.localStorage.setItem).toHaveBeenCalledWith('storage-key', 'false');
});
});
diff --git a/spec/frontend_integration/.eslintrc.yml b/spec/frontend_integration/.eslintrc.yml
index 26b6f935ffb..2460e218f59 100644
--- a/spec/frontend_integration/.eslintrc.yml
+++ b/spec/frontend_integration/.eslintrc.yml
@@ -4,3 +4,5 @@ settings:
import/resolver:
jest:
jestConfigFile: 'jest.config.integration.js'
+globals:
+ mockServer: false
diff --git a/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap b/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap
index 6beb5eab6db..6c120898f01 100644
--- a/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap
+++ b/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap
@@ -20,6 +20,7 @@ exports[`WebIDE runs 1`] = `
>
<div
class="multi-file-commit-panel-inner"
+ data-testid="ide-side-bar-inner"
>
<div
class="multi-file-loading-container"
diff --git a/spec/frontend_integration/ide/ide_helper.js b/spec/frontend_integration/ide/ide_helper.js
new file mode 100644
index 00000000000..a43695fea8f
--- /dev/null
+++ b/spec/frontend_integration/ide/ide_helper.js
@@ -0,0 +1,102 @@
+import { findAllByText, fireEvent, getByLabelText, screen } from '@testing-library/dom';
+
+const isFileRowOpen = row => row.matches('.is-open');
+
+const getLeftSidebar = () => screen.getByTestId('left-sidebar');
+
+const clickOnLeftSidebarTab = name => {
+ const sidebar = getLeftSidebar();
+
+ const button = getByLabelText(sidebar, name);
+
+ button.click();
+};
+
+const findMonacoEditor = () =>
+ screen.findByLabelText(/Editor content;/).then(x => x.closest('.monaco-editor'));
+
+const findAndSetEditorValue = async value => {
+ const editor = await findMonacoEditor();
+ const uri = editor.getAttribute('data-uri');
+
+ window.monaco.editor.getModel(uri).setValue(value);
+};
+
+const findTreeBody = () => screen.findByTestId('ide-tree-body', {}, { timeout: 5000 });
+
+const findFileRowContainer = (row = null) =>
+ row ? Promise.resolve(row.parentElement) : findTreeBody();
+
+const findFileChild = async (row, name, index = 0) => {
+ const container = await findFileRowContainer(row);
+ const children = await findAllByText(container, name, { selector: '.file-row-name' });
+
+ return children.map(x => x.closest('.file-row')).find(x => x.dataset.level === index.toString());
+};
+
+const openFileRow = row => {
+ if (!row || isFileRowOpen(row)) {
+ return;
+ }
+
+ row.click();
+};
+
+const findAndTraverseToPath = async (path, index = 0, row = null) => {
+ if (!path) {
+ return row;
+ }
+
+ const [name, ...restOfPath] = path.split('/');
+
+ openFileRow(row);
+
+ const child = await findFileChild(row, name, index);
+
+ return findAndTraverseToPath(restOfPath.join('/'), index + 1, child);
+};
+
+const clickFileRowAction = (row, name) => {
+ fireEvent.mouseOver(row);
+
+ const dropdownButton = getByLabelText(row, 'Create new file or directory');
+ dropdownButton.click();
+
+ const dropdownAction = getByLabelText(dropdownButton.parentNode, name);
+ dropdownAction.click();
+};
+
+const findAndSetFileName = async value => {
+ const nameField = await screen.findByTestId('file-name-field');
+ fireEvent.input(nameField, { target: { value } });
+
+ const createButton = screen.getByText('Create file');
+ createButton.click();
+};
+
+export const createFile = async (path, content) => {
+ const parentPath = path
+ .split('/')
+ .slice(0, -1)
+ .join('/');
+
+ const parentRow = await findAndTraverseToPath(parentPath);
+ clickFileRowAction(parentRow, 'New file');
+
+ await findAndSetFileName(path);
+ await findAndSetEditorValue(content);
+};
+
+export const deleteFile = async path => {
+ const row = await findAndTraverseToPath(path);
+ clickFileRowAction(row, 'Delete');
+};
+
+export const commit = async () => {
+ clickOnLeftSidebarTab('Commit');
+ screen.getByTestId('begin-commit-button').click();
+
+ await screen.findByLabelText(/Commit to .+ branch/).then(x => x.click());
+
+ screen.getByText('Commit').click();
+};
diff --git a/spec/frontend_integration/ide/ide_integration_spec.js b/spec/frontend_integration/ide/ide_integration_spec.js
index 91d89c26ec1..c4d0c4df8de 100644
--- a/spec/frontend_integration/ide/ide_integration_spec.js
+++ b/spec/frontend_integration/ide/ide_integration_spec.js
@@ -1,17 +1,10 @@
-/**
- * WARNING: WIP
- *
- * Please do not copy from this spec or use it as an example for anything.
- *
- * This is in place to iteratively set up the frontend integration testing environment
- * and will be improved upon in a later iteration.
- *
- * See https://gitlab.com/gitlab-org/gitlab/-/issues/208800 for more information.
- */
import { TEST_HOST } from 'helpers/test_constants';
+import { waitForText } from 'helpers/wait_for_text';
import { useOverclockTimers } from 'test_helpers/utils/overclock_timers';
+import { createCommitId } from 'test_helpers/factories/commit_id';
import { initIde } from '~/ide';
import extendStore from '~/ide/stores/extend';
+import * as ideHelper from './ide_helper';
const TEST_DATASET = {
emptyStateSvgPath: '/test/empty_state.svg',
@@ -59,4 +52,38 @@ describe('WebIDE', () => {
expect(root).toMatchSnapshot();
});
+
+ it('user commits changes', async () => {
+ createComponent();
+
+ await ideHelper.createFile('foo/bar/test.txt', 'Lorem ipsum dolar sit');
+ await ideHelper.deleteFile('foo/bar/.gitkeep');
+ await ideHelper.commit();
+
+ const commitId = createCommitId(1);
+ const commitShortId = commitId.slice(0, 8);
+
+ await waitForText('All changes are committed');
+ await waitForText(commitShortId);
+
+ expect(mockServer.db.branches.findBy({ name: 'master' }).commit).toMatchObject({
+ short_id: commitShortId,
+ id: commitId,
+ message: 'Update foo/bar/test.txt\nDeleted foo/bar/.gitkeep',
+ __actions: [
+ {
+ action: 'create',
+ content: 'Lorem ipsum dolar sit\n',
+ encoding: 'text',
+ file_path: 'foo/bar/test.txt',
+ last_commit_id: '',
+ },
+ {
+ action: 'delete',
+ encoding: 'text',
+ file_path: 'foo/bar/.gitkeep',
+ },
+ ],
+ });
+ });
});
diff --git a/spec/frontend_integration/test_helpers/setup/setup_mock_server.js b/spec/frontend_integration/test_helpers/setup/setup_mock_server.js
index 343aeebf88e..43a21deed25 100644
--- a/spec/frontend_integration/test_helpers/setup/setup_mock_server.js
+++ b/spec/frontend_integration/test_helpers/setup/setup_mock_server.js
@@ -1,13 +1,12 @@
import { createMockServer } from '../mock_server';
beforeEach(() => {
+ if (global.mockServer) {
+ global.mockServer.shutdown();
+ }
+
const server = createMockServer();
server.logging = false;
global.mockServer = server;
});
-
-afterEach(() => {
- global.mockServer.shutdown();
- global.mockServer = null;
-});
diff --git a/spec/graphql/features/feature_flag_spec.rb b/spec/graphql/features/feature_flag_spec.rb
index b484663d675..9ebc6e595a6 100644
--- a/spec/graphql/features/feature_flag_spec.rb
+++ b/spec/graphql/features/feature_flag_spec.rb
@@ -12,6 +12,10 @@ RSpec.describe 'Graphql Field feature flags' do
let(:query_string) { '{ item { name } }' }
let(:result) { execute_query(query_type)['data'] }
+ before do
+ skip_feature_flags_yaml_validation
+ end
+
subject { result }
describe 'Feature flagged field' do
diff --git a/spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb b/spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb
index 76854be2daa..c5637d43382 100644
--- a/spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb
+++ b/spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb
@@ -5,9 +5,11 @@ require 'spec_helper'
RSpec.describe Resolvers::Admin::Analytics::InstanceStatistics::MeasurementsResolver do
include GraphqlHelpers
+ let_it_be(:admin_user) { create(:user, :admin) }
+ let(:current_user) { admin_user }
+
describe '#resolve' do
let_it_be(:user) { create(:user) }
- let_it_be(:admin_user) { create(:user, :admin) }
let_it_be(:project_measurement_new) { create(:instance_statistics_measurement, :project_count, recorded_at: 2.days.ago) }
let_it_be(:project_measurement_old) { create(:instance_statistics_measurement, :project_count, recorded_at: 10.days.ago) }
@@ -39,6 +41,37 @@ RSpec.describe Resolvers::Admin::Analytics::InstanceStatistics::MeasurementsReso
end
end
end
+
+ context 'when requesting pipeline counts by pipeline status' do
+ let_it_be(:pipelines_succeeded_measurement) { create(:instance_statistics_measurement, :pipelines_succeeded_count, recorded_at: 2.days.ago) }
+ let_it_be(:pipelines_skipped_measurement) { create(:instance_statistics_measurement, :pipelines_skipped_count, recorded_at: 2.days.ago) }
+
+ subject { resolve_measurements({ identifier: identifier }, { current_user: current_user }) }
+
+ context 'filter for pipelines_succeeded' do
+ let(:identifier) { 'pipelines_succeeded' }
+
+ it { is_expected.to eq([pipelines_succeeded_measurement]) }
+ end
+
+ context 'filter for pipelines_skipped' do
+ let(:identifier) { 'pipelines_skipped' }
+
+ it { is_expected.to eq([pipelines_skipped_measurement]) }
+ end
+
+ context 'filter for pipelines_failed' do
+ let(:identifier) { 'pipelines_failed' }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'filter for pipelines_canceled' do
+ let(:identifier) { 'pipelines_canceled' }
+
+ it { is_expected.to be_empty }
+ end
+ end
end
def resolve_measurements(args = {}, context = {})
diff --git a/spec/graphql/resolvers/board_resolver_spec.rb b/spec/graphql/resolvers/board_resolver_spec.rb
new file mode 100644
index 00000000000..c70c696005f
--- /dev/null
+++ b/spec/graphql/resolvers/board_resolver_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::BoardResolver do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+
+ let(:dummy_gid) { 'gid://gitlab/Board/1' }
+
+ shared_examples_for 'group and project boards resolver' do
+ it 'does not create a default board' do
+ expect(resolve_board(id: dummy_gid)).to eq nil
+ end
+
+ it 'calls Boards::ListService' do
+ expect_next_instance_of(Boards::ListService) do |service|
+ expect(service).to receive(:execute).and_return([])
+ end
+
+ resolve_board(id: dummy_gid)
+ end
+
+ it 'requires an ID' do
+ expect do
+ resolve(described_class, obj: board_parent, args: {}, ctx: { current_user: user })
+ end.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
+ end
+
+ context 'when querying for a single board' do
+ let(:board1) { create(:board, name: 'One', resource_parent: board_parent) }
+
+ it 'returns specified board' do
+ expect(resolve_board(id: global_id_of(board1))).to eq board1
+ end
+
+ it 'returns nil if board not found' do
+ outside_parent = create(board_parent.class.underscore.to_sym) # rubocop:disable Rails/SaveBang
+ outside_board = create(:board, name: 'outside board', resource_parent: outside_parent)
+
+ expect(resolve_board(id: global_id_of(outside_board))).to eq nil
+ end
+ end
+ end
+
+ describe '#resolve' do
+ context 'when there is no parent' do
+ let(:board_parent) { nil }
+
+ it 'returns nil if parent is nil' do
+ expect(resolve_board(id: dummy_gid)).to eq(nil)
+ end
+ end
+
+ context 'when project boards' do
+ let(:board_parent) { create(:project, :public, creator_id: user.id, namespace: user.namespace ) }
+
+ it_behaves_like 'group and project boards resolver'
+ end
+
+ context 'when group boards' do
+ let(:board_parent) { create(:group) }
+
+ it_behaves_like 'group and project boards resolver'
+ end
+ end
+
+ def resolve_board(id:)
+ resolve(described_class, obj: board_parent, args: { id: id }, ctx: { current_user: user })
+ end
+end
diff --git a/spec/graphql/resolvers/concerns/looks_ahead_spec.rb b/spec/graphql/resolvers/concerns/looks_ahead_spec.rb
index f13823085b8..ebea9e5522b 100644
--- a/spec/graphql/resolvers/concerns/looks_ahead_spec.rb
+++ b/spec/graphql/resolvers/concerns/looks_ahead_spec.rb
@@ -117,20 +117,6 @@ RSpec.describe LooksAhead do
query.result
end
- context 'the feature flag is off' do
- before do
- stub_feature_flags(described_class::FEATURE_FLAG => false)
- end
-
- it_behaves_like 'a working query on the test schema'
-
- it 'does not preload labels on issues' do
- expect(the_user.issues).not_to receive(:preload).with(:labels)
-
- query.result
- end
- end
-
it 'issues fewer queries than the naive approach' do
the_user.reload # ensure no attributes are loaded before we begin
naive = <<-GQL
diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb
index db5d009f0e7..3a6507f906c 100644
--- a/spec/graphql/resolvers/issues_resolver_spec.rb
+++ b/spec/graphql/resolvers/issues_resolver_spec.rb
@@ -46,6 +46,13 @@ RSpec.describe Resolvers::IssuesResolver do
expect(resolve_issues(assignee_username: assignee.username)).to contain_exactly(issue2)
end
+ it 'filters by two assignees' do
+ assignee2 = create(:user)
+ issue2.update!(assignees: [assignee, assignee2])
+
+ expect(resolve_issues(assignee_id: [assignee.id, assignee2.id])).to contain_exactly(issue2)
+ end
+
it 'filters by assignee_id' do
expect(resolve_issues(assignee_id: assignee.id)).to contain_exactly(issue2)
end
@@ -58,6 +65,10 @@ RSpec.describe Resolvers::IssuesResolver do
expect(resolve_issues(assignee_id: IssuableFinder::Params::FILTER_NONE)).to contain_exactly(issue1)
end
+ it 'filters by author' do
+ expect(resolve_issues(author_username: issue1.author.username)).to contain_exactly(issue1, issue2)
+ end
+
it 'filters by labels' do
expect(resolve_issues(label_name: [label1.title])).to contain_exactly(issue1, issue2)
expect(resolve_issues(label_name: [label1.title, label2.title])).to contain_exactly(issue2)
@@ -219,6 +230,21 @@ RSpec.describe Resolvers::IssuesResolver do
expect(resolve_issues(sort: :milestone_due_desc).items).to eq([milestone_issue3, milestone_issue2, milestone_issue1])
end
end
+
+ context 'when sorting by severity' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:issue_high_severity) { create_issue_with_severity(project, severity: :high) }
+ let_it_be(:issue_low_severity) { create_issue_with_severity(project, severity: :low) }
+ let_it_be(:issue_no_severity) { create(:incident, project: project) }
+
+ it 'sorts issues ascending' do
+ expect(resolve_issues(sort: :severity_asc)).to eq([issue_no_severity, issue_low_severity, issue_high_severity])
+ end
+
+ it 'sorts issues descending' do
+ expect(resolve_issues(sort: :severity_desc)).to eq([issue_high_severity, issue_low_severity, issue_no_severity])
+ end
+ end
end
it 'returns issues user can see' do
@@ -304,6 +330,13 @@ RSpec.describe Resolvers::IssuesResolver do
expect(field.to_graphql.complexity.call({}, { labelName: 'foo' }, 1)).to eq 8
end
+ def create_issue_with_severity(project, severity:)
+ issue = create(:incident, project: project)
+ create(:issuable_severity, issue: issue, severity: severity)
+
+ issue
+ end
+
def resolve_issues(args = {}, context = { current_user: current_user })
resolve(described_class, obj: project, args: args, ctx: context)
end
diff --git a/spec/graphql/resolvers/projects_resolver_spec.rb b/spec/graphql/resolvers/projects_resolver_spec.rb
index d22ffeed740..07069bb1b06 100644
--- a/spec/graphql/resolvers/projects_resolver_spec.rb
+++ b/spec/graphql/resolvers/projects_resolver_spec.rb
@@ -8,10 +8,15 @@ RSpec.describe Resolvers::ProjectsResolver do
describe '#resolve' do
subject { resolve(described_class, obj: nil, args: filters, ctx: { current_user: current_user }) }
+ let_it_be(:group) { create(:group, name: 'public-group') }
+ let_it_be(:private_group) { create(:group, name: 'private-group') }
let_it_be(:project) { create(:project, :public) }
let_it_be(:other_project) { create(:project, :public) }
+ let_it_be(:group_project) { create(:project, :public, group: group) }
let_it_be(:private_project) { create(:project, :private) }
let_it_be(:other_private_project) { create(:project, :private) }
+ let_it_be(:other_private_project) { create(:project, :private) }
+ let_it_be(:private_group_project) { create(:project, :private, group: private_group) }
let_it_be(:user) { create(:user) }
@@ -20,6 +25,7 @@ RSpec.describe Resolvers::ProjectsResolver do
before_all do
project.add_developer(user)
private_project.add_developer(user)
+ private_group.add_developer(user)
end
context 'when user is not logged in' do
@@ -27,7 +33,7 @@ RSpec.describe Resolvers::ProjectsResolver do
context 'when no filters are applied' do
it 'returns all public projects' do
- is_expected.to contain_exactly(project, other_project)
+ is_expected.to contain_exactly(project, other_project, group_project)
end
context 'when search filter is provided' do
@@ -45,6 +51,22 @@ RSpec.describe Resolvers::ProjectsResolver do
is_expected.to be_empty
end
end
+
+ context 'when searchNamespaces filter is provided' do
+ let(:filters) { { search: 'group', search_namespaces: true } }
+
+ it 'returns projects in a matching namespace' do
+ is_expected.to contain_exactly(group_project)
+ end
+ end
+
+ context 'when searchNamespaces filter false' do
+ let(:filters) { { search: 'group', search_namespaces: false } }
+
+ it 'returns ignores namespace matches' do
+ is_expected.to be_empty
+ end
+ end
end
end
@@ -53,7 +75,7 @@ RSpec.describe Resolvers::ProjectsResolver do
context 'when no filters are applied' do
it 'returns all visible projects for the user' do
- is_expected.to contain_exactly(project, other_project, private_project)
+ is_expected.to contain_exactly(project, other_project, group_project, private_project, private_group_project)
end
context 'when search filter is provided' do
@@ -68,7 +90,23 @@ RSpec.describe Resolvers::ProjectsResolver do
let(:filters) { { membership: true } }
it 'returns projects that user is member of' do
- is_expected.to contain_exactly(project, private_project)
+ is_expected.to contain_exactly(project, private_project, private_group_project)
+ end
+ end
+
+ context 'when searchNamespaces filter is provided' do
+ let(:filters) { { search: 'group', search_namespaces: true } }
+
+ it 'returns projects from matching group' do
+ is_expected.to contain_exactly(group_project, private_group_project)
+ end
+ end
+
+ context 'when searchNamespaces filter false' do
+ let(:filters) { { search: 'group', search_namespaces: false } }
+
+ it 'returns ignores namespace matches' do
+ is_expected.to be_empty
end
end
diff --git a/spec/graphql/resolvers/snippets/blobs_resolver_spec.rb b/spec/graphql/resolvers/snippets/blobs_resolver_spec.rb
new file mode 100644
index 00000000000..fdbd87c32be
--- /dev/null
+++ b/spec/graphql/resolvers/snippets/blobs_resolver_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::Snippets::BlobsResolver do
+ include GraphqlHelpers
+
+ describe '#resolve' do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:snippet) { create(:personal_snippet, :private, :repository, author: current_user) }
+
+ context 'when user is not authorized' do
+ let(:other_user) { create(:user) }
+
+ it 'raises an error' do
+ expect do
+ resolve_blobs(snippet, user: other_user)
+ end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when using no filter' do
+ it 'returns all snippet blobs' do
+ expect(resolve_blobs(snippet).map(&:path)).to contain_exactly(*snippet.list_files)
+ end
+ end
+
+ context 'when using filters' do
+ context 'when paths is a single string' do
+ it 'returns an array of files' do
+ path = 'CHANGELOG'
+
+ expect(resolve_blobs(snippet, args: { paths: path }).first.path).to eq(path)
+ end
+ end
+
+ context 'when paths is an array of string' do
+ it 'returns an array of files' do
+ paths = ['CHANGELOG', 'README.md']
+
+ expect(resolve_blobs(snippet, args: { paths: paths }).map(&:path)).to contain_exactly(*paths)
+ end
+ end
+ end
+ end
+
+ def resolve_blobs(snippet, user: current_user, args: {})
+ resolve(described_class, args: args, ctx: { current_user: user }, obj: snippet)
+ end
+end
diff --git a/spec/graphql/resolvers/terraform/states_resolver_spec.rb b/spec/graphql/resolvers/terraform/states_resolver_spec.rb
new file mode 100644
index 00000000000..64b515528cd
--- /dev/null
+++ b/spec/graphql/resolvers/terraform/states_resolver_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::Terraform::StatesResolver do
+ include GraphqlHelpers
+
+ it { expect(described_class.type).to eq(Types::Terraform::StateType) }
+ it { expect(described_class.null).to be_truthy }
+
+ describe '#resolve' do
+ let_it_be(:project) { create(:project) }
+
+ let_it_be(:production_state) { create(:terraform_state, project: project) }
+ let_it_be(:staging_state) { create(:terraform_state, project: project) }
+ let_it_be(:other_state) { create(:terraform_state) }
+
+ let(:ctx) { Hash(current_user: user) }
+ let(:user) { create(:user, developer_projects: [project]) }
+
+ subject { resolve(described_class, obj: project, ctx: ctx) }
+
+ it 'returns states associated with the agent' do
+ expect(subject).to contain_exactly(production_state, staging_state)
+ end
+
+ context 'user does not have permission' do
+ let(:user) { create(:user) }
+
+ it { is_expected.to be_empty }
+ end
+ end
+end
diff --git a/spec/graphql/types/base_field_spec.rb b/spec/graphql/types/base_field_spec.rb
index bcfbd7f2480..d61ea6aa6e9 100644
--- a/spec/graphql/types/base_field_spec.rb
+++ b/spec/graphql/types/base_field_spec.rb
@@ -126,6 +126,10 @@ RSpec.describe Types::BaseField do
let(:field) { described_class.new(name: 'test', type: GraphQL::STRING_TYPE, feature_flag: flag, null: false) }
let(:context) { {} }
+ before do
+ skip_feature_flags_yaml_validation
+ end
+
it 'returns false if the feature is not enabled' do
stub_feature_flags(flag => false)
diff --git a/spec/graphql/types/design_management/design_collection_copy_state_enum_spec.rb b/spec/graphql/types/design_management/design_collection_copy_state_enum_spec.rb
new file mode 100644
index 00000000000..f536d91aeda
--- /dev/null
+++ b/spec/graphql/types/design_management/design_collection_copy_state_enum_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['DesignCollectionCopyState'] do
+ it { expect(described_class.graphql_name).to eq('DesignCollectionCopyState') }
+
+ it 'exposes the correct event states' do
+ expect(described_class.values.keys).to match_array(%w(READY IN_PROGRESS ERROR))
+ end
+end
diff --git a/spec/graphql/types/design_management/design_collection_type_spec.rb b/spec/graphql/types/design_management/design_collection_type_spec.rb
index 6b1d3a87c2d..83208705249 100644
--- a/spec/graphql/types/design_management/design_collection_type_spec.rb
+++ b/spec/graphql/types/design_management/design_collection_type_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe GitlabSchema.types['DesignCollection'] do
it { expect(described_class).to require_graphql_authorizations(:read_design) }
it 'has the expected fields' do
- expected_fields = %i[project issue designs versions version designAtVersion design]
+ expected_fields = %i[project issue designs versions version designAtVersion design copyState]
expect(described_class).to have_graphql_fields(*expected_fields)
end
diff --git a/spec/graphql/types/global_id_type_spec.rb b/spec/graphql/types/global_id_type_spec.rb
index 2a7b26f66b0..7589b0e285e 100644
--- a/spec/graphql/types/global_id_type_spec.rb
+++ b/spec/graphql/types/global_id_type_spec.rb
@@ -99,8 +99,6 @@ RSpec.describe Types::GlobalIDType do
end
describe 'compatibility' do
- # Simplified schema to test compatibility
-
def query(doc, vars)
GraphQL::Query.new(schema, document: doc, context: {}, variables: vars)
end
@@ -112,6 +110,7 @@ RSpec.describe Types::GlobalIDType do
all_types = [::GraphQL::ID_TYPE, ::Types::GlobalIDType, ::Types::GlobalIDType[::Project]]
shared_examples 'a working query' do
+ # Simplified schema to test compatibility
let!(:schema) do
# capture values so they can be closed over
arg_type = argument_type
@@ -135,10 +134,21 @@ RSpec.describe Types::GlobalIDType do
argument :id, arg_type, required: true
end
+ # This is needed so that all types are always registered as input types
+ field :echo, String, null: true do
+ argument :id, ::GraphQL::ID_TYPE, required: false
+ argument :gid, ::Types::GlobalIDType, required: false
+ argument :pid, ::Types::GlobalIDType[::Project], required: false
+ end
+
def project_by_id(id:)
gid = ::Types::GlobalIDType[::Project].coerce_isolated_input(id)
gid.model_class.find(gid.model_id)
end
+
+ def echo(id: nil, gid: nil, pid: nil)
+ "id: #{id}, gid: #{gid}, pid: #{pid}"
+ end
end)
end
end
@@ -152,7 +162,7 @@ RSpec.describe Types::GlobalIDType do
end
end
- context 'when the argument is declared as ID' do
+ context 'when the client declares the argument as ID the actual argument can be any type' do
let(:document) do
<<-GRAPHQL
query($projectId: ID!){
@@ -163,16 +173,16 @@ RSpec.describe Types::GlobalIDType do
GRAPHQL
end
- let(:argument_type) { ::GraphQL::ID_TYPE }
-
- where(:result_type) { all_types }
+ where(:result_type, :argument_type) do
+ all_types.flat_map { |arg_type| all_types.zip([arg_type].cycle) }
+ end
with_them do
it_behaves_like 'a working query'
end
end
- context 'when the argument is declared as GlobalID' do
+ context 'when the client passes the argument as GlobalID' do
let(:document) do
<<-GRAPHQL
query($projectId: GlobalID!) {
@@ -192,7 +202,7 @@ RSpec.describe Types::GlobalIDType do
end
end
- context 'when the argument is declared as ProjectID' do
+ context 'when the client passes the argument as ProjectID' do
let(:document) do
<<-GRAPHQL
query($projectId: ProjectID!) {
diff --git a/spec/graphql/types/issue_sort_enum_spec.rb b/spec/graphql/types/issue_sort_enum_spec.rb
index 9313d3aee84..4433709d193 100644
--- a/spec/graphql/types/issue_sort_enum_spec.rb
+++ b/spec/graphql/types/issue_sort_enum_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe GitlabSchema.types['IssueSort'] do
it 'exposes all the existing issue sort values' do
expect(described_class.values.keys).to include(
- *%w[DUE_DATE_ASC DUE_DATE_DESC RELATIVE_POSITION_ASC]
+ *%w[DUE_DATE_ASC DUE_DATE_DESC RELATIVE_POSITION_ASC SEVERITY_ASC SEVERITY_DESC]
)
end
end
diff --git a/spec/graphql/types/package_type_enum_spec.rb b/spec/graphql/types/package_type_enum_spec.rb
index 80a20a68bc2..638f8ccbaee 100644
--- a/spec/graphql/types/package_type_enum_spec.rb
+++ b/spec/graphql/types/package_type_enum_spec.rb
@@ -4,6 +4,6 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['PackageTypeEnum'] do
it 'exposes all package types' do
- expect(described_class.values.keys).to contain_exactly(*%w[MAVEN NPM CONAN NUGET PYPI COMPOSER GENERIC])
+ expect(described_class.values.keys).to contain_exactly(*%w[MAVEN NPM CONAN NUGET PYPI COMPOSER GENERIC GOLANG])
end
end
diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb
index 44a89bfa35e..8aa9e1138cc 100644
--- a/spec/graphql/types/project_type_spec.rb
+++ b/spec/graphql/types/project_type_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe GitlabSchema.types['Project'] do
environment boards jira_import_status jira_imports services releases release
alert_management_alerts alert_management_alert alert_management_alert_status_counts
container_expiration_policy service_desk_enabled service_desk_address
- issue_status_counts
+ issue_status_counts terraform_states
]
expect(described_class).to include_graphql_fields(*expected_fields)
@@ -154,5 +154,12 @@ RSpec.describe GitlabSchema.types['Project'] do
it { is_expected.to have_graphql_type(Types::ContainerExpirationPolicyType) }
end
+ describe 'terraform states field' do
+ subject { described_class.fields['terraformStates'] }
+
+ it { is_expected.to have_graphql_type(Types::Terraform::StateType.connection_type) }
+ it { is_expected.to have_graphql_resolver(Resolvers::Terraform::StatesResolver) }
+ end
+
it_behaves_like 'a GraphQL type with labels'
end
diff --git a/spec/graphql/types/snippet_type_spec.rb b/spec/graphql/types/snippet_type_spec.rb
index 86af69f1294..2ea215450cb 100644
--- a/spec/graphql/types/snippet_type_spec.rb
+++ b/spec/graphql/types/snippet_type_spec.rb
@@ -16,6 +16,15 @@ RSpec.describe GitlabSchema.types['Snippet'] do
expect(described_class).to have_graphql_fields(*expected_fields)
end
+ describe 'blobs field' do
+ subject { described_class.fields['blobs'] }
+
+ it 'returns blobs' do
+ is_expected.to have_graphql_type(Types::Snippets::BlobType.connection_type)
+ is_expected.to have_graphql_resolver(Resolvers::Snippets::BlobsResolver)
+ end
+ end
+
context 'when restricted visibility level is set to public' do
let_it_be(:snippet) { create(:personal_snippet, :repository, :public, author: user) }
@@ -142,9 +151,30 @@ RSpec.describe GitlabSchema.types['Snippet'] do
describe '#blobs' do
let_it_be(:snippet) { create(:personal_snippet, :public, author: user) }
- let(:query_blobs) { subject.dig('data', 'snippets', 'edges')[0]['node']['blobs'] }
+ let(:query_blobs) { subject.dig('data', 'snippets', 'edges')[0].dig('node', 'blobs', 'edges') }
+ let(:paths) { [] }
+ let(:query) do
+ %(
+ {
+ snippets {
+ edges {
+ node {
+ blobs(paths: #{paths}) {
+ edges {
+ node {
+ name
+ path
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ )
+ end
- subject { GitlabSchema.execute(snippet_query_for(field: 'blobs'), context: { current_user: user }).as_json }
+ subject { GitlabSchema.execute(query, context: { current_user: user }).as_json }
shared_examples 'an array' do
it 'returns an array of snippet blobs' do
@@ -158,8 +188,8 @@ RSpec.describe GitlabSchema.types['Snippet'] do
it_behaves_like 'an array'
it 'contains the first blob from the snippet' do
- expect(query_blobs.first['name']).to eq blob.name
- expect(query_blobs.first['path']).to eq blob.path
+ expect(query_blobs.first['node']['name']).to eq blob.name
+ expect(query_blobs.first['node']['path']).to eq blob.path
end
end
@@ -170,10 +200,22 @@ RSpec.describe GitlabSchema.types['Snippet'] do
it_behaves_like 'an array'
it 'contains all the blobs from the repository' do
- resulting_blobs_names = query_blobs.map { |b| b['name'] }
+ resulting_blobs_names = query_blobs.map { |b| b['node']['name'] }
expect(resulting_blobs_names).to match_array(blobs.map(&:name))
end
+
+ context 'when specific path is set' do
+ let(:paths) { ['CHANGELOG'] }
+
+ it_behaves_like 'an array'
+
+ it 'returns specific files' do
+ resulting_blobs_names = query_blobs.map { |b| b['node']['name'] }
+
+ expect(resulting_blobs_names).to match(paths)
+ end
+ end
end
end
diff --git a/spec/graphql/types/terraform/state_type_spec.rb b/spec/graphql/types/terraform/state_type_spec.rb
new file mode 100644
index 00000000000..51508208046
--- /dev/null
+++ b/spec/graphql/types/terraform/state_type_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['TerraformState'] do
+ it { expect(described_class.graphql_name).to eq('TerraformState') }
+ it { expect(described_class).to require_graphql_authorizations(:read_terraform_state) }
+
+ describe 'fields' do
+ let(:fields) { %i[id name locked_by_user locked_at created_at updated_at] }
+
+ it { expect(described_class).to have_graphql_fields(fields) }
+
+ it { expect(described_class.fields['id'].type).to be_non_null }
+ it { expect(described_class.fields['name'].type).to be_non_null }
+ it { expect(described_class.fields['lockedByUser'].type).not_to be_non_null }
+ it { expect(described_class.fields['lockedAt'].type).not_to be_non_null }
+ it { expect(described_class.fields['createdAt'].type).to be_non_null }
+ it { expect(described_class.fields['updatedAt'].type).to be_non_null }
+ end
+end
diff --git a/spec/haml_lint/linter/documentation_links_spec.rb b/spec/haml_lint/linter/documentation_links_spec.rb
index 68de8317b82..5de455b6e8c 100644
--- a/spec/haml_lint/linter/documentation_links_spec.rb
+++ b/spec/haml_lint/linter/documentation_links_spec.rb
@@ -8,75 +8,85 @@ require Rails.root.join('haml_lint/linter/documentation_links')
RSpec.describe HamlLint::Linter::DocumentationLinks do
include_context 'linter'
- context 'when link_to points to the existing file path' do
- let(:haml) { "= link_to 'Description', help_page_path('README.md')" }
+ shared_examples 'link validation rules' do |link_pattern|
+ context 'when link_to points to the existing file path' do
+ let(:haml) { "= link_to 'Description', #{link_pattern}('README.md')" }
- it { is_expected.not_to report_lint }
- end
+ it { is_expected.not_to report_lint }
+ end
- context 'when link_to points to the existing file with valid anchor' do
- let(:haml) { "= link_to 'Description', help_page_path('README.md', anchor: 'overview'), target: '_blank'" }
+ context 'when link_to points to the existing file with valid anchor' do
+ let(:haml) { "= link_to 'Description', #{link_pattern}('README.md', anchor: 'overview'), target: '_blank'" }
- it { is_expected.not_to report_lint }
- end
+ it { is_expected.not_to report_lint }
+ end
- context 'when link_to points to the existing file path without .md extension' do
- let(:haml) { "= link_to 'Description', help_page_path('README')" }
+ context 'when link_to points to the existing file path without .md extension' do
+ let(:haml) { "= link_to 'Description', #{link_pattern}('README')" }
- it { is_expected.not_to report_lint }
- end
+ it { is_expected.not_to report_lint }
+ end
- context 'when anchor is not correct' do
- let(:haml) { "= link_to 'Description', help_page_path('README.md', anchor: 'wrong')" }
+ context 'when anchor is not correct' do
+ let(:haml) { "= link_to 'Description', #{link_pattern}('README.md', anchor: 'wrong')" }
- it { is_expected.to report_lint }
+ it { is_expected.to report_lint }
- context 'when help_page_path has multiple options' do
- let(:haml) { "= link_to 'Description', help_page_path('README.md', key: :value, anchor: 'wrong')" }
+ context "when #{link_pattern} has multiple options" do
+ let(:haml) { "= link_to 'Description', #{link_pattern}('README.md', key: :value, anchor: 'wrong')" }
+
+ it { is_expected.to report_lint }
+ end
+ end
+
+ context 'when file path is wrong' do
+ let(:haml) { "= link_to 'Description', #{link_pattern}('wrong.md'), target: '_blank'" }
it { is_expected.to report_lint }
end
- end
- context 'when file path is wrong' do
- let(:haml) { "= link_to 'Description', help_page_path('wrong.md'), target: '_blank'" }
+ context 'when link with wrong file path is assigned to a variable' do
+ let(:haml) { "- my_link = link_to 'Description', #{link_pattern}('wrong.md')" }
- it { is_expected.to report_lint }
- end
+ it { is_expected.to report_lint }
+ end
- context 'when link with wrong file path is assigned to a variable' do
- let(:haml) { "- my_link = link_to 'Description', help_page_path('wrong.md')" }
+ context 'when it is a broken code' do
+ let(:haml) { "= I am broken! ]]]]" }
- it { is_expected.to report_lint }
- end
+ it { is_expected.not_to report_lint }
+ end
- context 'when it is a broken code' do
- let(:haml) { "= I am broken! ]]]]" }
+ context 'when anchor belongs to a different element' do
+ let(:haml) { "= link_to 'Description', #{link_pattern}('README.md'), target: (anchor: 'blank')" }
- it { is_expected.not_to report_lint }
- end
+ it { is_expected.not_to report_lint }
+ end
- context 'when anchor belongs to a different element' do
- let(:haml) { "= link_to 'Description', help_page_path('README.md'), target: (anchor: 'blank')" }
+ context "when a simple #{link_pattern}" do
+ let(:haml) { "- url = #{link_pattern}('wrong.md')" }
- it { is_expected.not_to report_lint }
- end
+ it { is_expected.to report_lint }
+ end
- context 'when a simple help_page_path' do
- let(:haml) { "- url = help_page_path('wrong.md')" }
+ context 'when link is not a string' do
+ let(:haml) { "- url = #{link_pattern}(help_url)" }
- it { is_expected.to report_lint }
- end
+ it { is_expected.not_to report_lint }
+ end
- context 'when link is not a string' do
- let(:haml) { "- url = help_page_path(help_url)" }
+ context 'when link is a part of the tag' do
+ let(:haml) { ".data-form{ data: { url: #{link_pattern}('wrong.md') } }" }
- it { is_expected.not_to report_lint }
+ it { is_expected.to report_lint }
+ end
end
- context 'when link is a part of the tag' do
- let(:haml) { ".data-form{ data: { url: help_page_path('wrong.md') } }" }
+ context 'help_page_path' do
+ it_behaves_like 'link validation rules', 'help_page_path'
+ end
- it { is_expected.to report_lint }
+ context 'help_page_url' do
+ it_behaves_like 'link validation rules', 'help_page_url'
end
end
diff --git a/spec/helpers/analytics/unique_visits_helper_spec.rb b/spec/helpers/analytics/unique_visits_helper_spec.rb
index ff9769078c4..ff363e81ac7 100644
--- a/spec/helpers/analytics/unique_visits_helper_spec.rb
+++ b/spec/helpers/analytics/unique_visits_helper_spec.rb
@@ -22,15 +22,6 @@ RSpec.describe Analytics::UniqueVisitsHelper do
helper.track_visit(target_id)
end
- it 'does not track visits if usage ping is disabled' do
- sign_in(current_user)
- expect(Gitlab::CurrentSettings).to receive(:usage_ping_enabled?).and_return(false)
-
- expect_any_instance_of(Gitlab::Analytics::UniqueVisits).not_to receive(:track_visit)
-
- helper.track_visit(target_id)
- end
-
it 'does not track visit if user is not logged in' do
expect_any_instance_of(Gitlab::Analytics::UniqueVisits).not_to receive(:track_visit)
diff --git a/spec/helpers/boards_helper_spec.rb b/spec/helpers/boards_helper_spec.rb
index a805b96a8cc..6bdd6fb6813 100644
--- a/spec/helpers/boards_helper_spec.rb
+++ b/spec/helpers/boards_helper_spec.rb
@@ -43,6 +43,7 @@ RSpec.describe BoardsHelper do
allow(helper).to receive(:current_user) { user }
allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, board).and_return(true)
+ allow(helper).to receive(:can?).with(user, :admin_issue, board).and_return(true)
end
it 'returns a board_lists_path as lists_endpoint' do
@@ -52,6 +53,10 @@ RSpec.describe BoardsHelper do
it 'returns board type as parent' do
expect(helper.board_data[:parent]).to eq('project')
end
+
+ it 'returns can_update for user permissions on the board' do
+ expect(helper.board_data[:can_update]).to eq('true')
+ end
end
describe '#current_board_json' do
diff --git a/spec/helpers/clusters_helper_spec.rb b/spec/helpers/clusters_helper_spec.rb
index 6164f3b5e8d..6b08b6515cf 100644
--- a/spec/helpers/clusters_helper_spec.rb
+++ b/spec/helpers/clusters_helper_spec.rb
@@ -59,6 +59,24 @@ RSpec.describe ClustersHelper do
end
end
+ describe '#js_cluster_agents_list_data' do
+ let_it_be(:project) { build(:project, :repository) }
+
+ subject { helper.js_cluster_agents_list_data(project) }
+
+ it 'displays project default branch' do
+ expect(subject[:default_branch_name]).to eq(project.default_branch)
+ end
+
+ it 'displays image path' do
+ expect(subject[:empty_state_image]).to match(%r(/illustrations/logos/clusters_empty|svg))
+ end
+
+ it 'displays project path' do
+ expect(subject[:project_path]).to eq(project.full_path)
+ end
+ end
+
describe '#js_clusters_list_data' do
subject { helper.js_clusters_list_data('/path') }
@@ -89,32 +107,6 @@ RSpec.describe ClustersHelper do
end
end
- describe '#provider_icon' do
- it 'will return GCP logo with gcp argument' do
- logo = helper.provider_icon('gcp')
-
- expect(logo).to match(%r(img alt="Google GKE" data-src="|/illustrations/logos/google_gke|svg))
- end
-
- it 'will return AWS logo with aws argument' do
- logo = helper.provider_icon('aws')
-
- expect(logo).to match(%r(img alt="Amazon EKS" data-src="|/illustrations/logos/amazon_eks|svg))
- end
-
- it 'will return default logo with unknown provider' do
- logo = helper.provider_icon('unknown')
-
- expect(logo).to match(%r(img alt="Kubernetes Cluster" data-src="|/illustrations/logos/kubernetes|svg))
- end
-
- it 'will return default logo when provider is empty' do
- logo = helper.provider_icon
-
- expect(logo).to match(%r(img alt="Kubernetes Cluster" data-src="|/illustrations/logos/kubernetes|svg))
- end
- end
-
describe '#cluster_type_label' do
subject { helper.cluster_type_label(cluster_type) }
diff --git a/spec/helpers/emails_helper_spec.rb b/spec/helpers/emails_helper_spec.rb
index 96ac4015c77..ef8b342a3f6 100644
--- a/spec/helpers/emails_helper_spec.rb
+++ b/spec/helpers/emails_helper_spec.rb
@@ -361,4 +361,116 @@ RSpec.describe EmailsHelper do
end
end
end
+
+ describe '#change_reviewer_notification_text' do
+ let(:mary) { build(:user, name: 'Mary') }
+ let(:john) { build(:user, name: 'John') }
+ let(:ted) { build(:user, name: 'Ted') }
+
+ context 'to new reviewers only' do
+ let(:previous_reviewers) { [] }
+ let(:new_reviewers) { [john] }
+
+ context 'with no html tag' do
+ let(:expected_output) do
+ 'Reviewer changed to John'
+ end
+
+ it 'returns the expected output' do
+ expect(change_reviewer_notification_text(new_reviewers, previous_reviewers)).to eq(expected_output)
+ end
+ end
+
+ context 'with <strong> tag' do
+ let(:expected_output) do
+ 'Reviewer changed to <strong>John</strong>'
+ end
+
+ it 'returns the expected output' do
+ expect(change_reviewer_notification_text(new_reviewers, previous_reviewers, :strong)).to eq(expected_output)
+ end
+ end
+ end
+
+ context 'from previous reviewers to new reviewers' do
+ let(:previous_reviewers) { [john, mary] }
+ let(:new_reviewers) { [ted] }
+
+ context 'with no html tag' do
+ let(:expected_output) do
+ 'Reviewer changed from John and Mary to Ted'
+ end
+
+ it 'returns the expected output' do
+ expect(change_reviewer_notification_text(new_reviewers, previous_reviewers)).to eq(expected_output)
+ end
+ end
+
+ context 'with <strong> tag' do
+ let(:expected_output) do
+ 'Reviewer changed from <strong>John and Mary</strong> to <strong>Ted</strong>'
+ end
+
+ it 'returns the expected output' do
+ expect(change_reviewer_notification_text(new_reviewers, previous_reviewers, :strong)).to eq(expected_output)
+ end
+ end
+ end
+
+ context 'from previous reviewers to no reviewers' do
+ let(:previous_reviewers) { [john, mary] }
+ let(:new_reviewers) { [] }
+
+ context 'with no html tag' do
+ let(:expected_output) do
+ 'Reviewer changed from John and Mary to Unassigned'
+ end
+
+ it 'returns the expected output' do
+ expect(change_reviewer_notification_text(new_reviewers, previous_reviewers)).to eq(expected_output)
+ end
+ end
+
+ context 'with <strong> tag' do
+ let(:expected_output) do
+ 'Reviewer changed from <strong>John and Mary</strong> to <strong>Unassigned</strong>'
+ end
+
+ it 'returns the expected output' do
+ expect(change_reviewer_notification_text(new_reviewers, previous_reviewers, :strong)).to eq(expected_output)
+ end
+ end
+ end
+
+ context "with a <script> tag in user's name" do
+ let(:previous_reviewers) { [] }
+ let(:new_reviewers) { [fishy_user] }
+ let(:fishy_user) { build(:user, name: "<script>alert('hi')</script>") }
+
+ let(:expected_output) do
+ 'Reviewer changed to <strong>&lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;</strong>'
+ end
+
+ it 'escapes the html tag' do
+ expect(change_reviewer_notification_text(new_reviewers, previous_reviewers, :strong)).to eq(expected_output)
+ end
+ end
+
+ context "with url in user's name" do
+ subject(:email_helper) { Object.new.extend(described_class) }
+
+ let(:previous_reviewers) { [] }
+ let(:new_reviewers) { [fishy_user] }
+ let(:fishy_user) { build(:user, name: "example.com") }
+
+ let(:expected_output) do
+ 'Reviewer changed to example_com'
+ end
+
+ it "sanitizes user's name" do
+ expect(email_helper).to receive(:sanitize_name).and_call_original
+ expect(email_helper.change_reviewer_notification_text(new_reviewers, previous_reviewers)).to eq(expected_output)
+ end
+ end
+ end
end
diff --git a/spec/helpers/feature_flags_helper_spec.rb b/spec/helpers/feature_flags_helper_spec.rb
new file mode 100644
index 00000000000..9a080736595
--- /dev/null
+++ b/spec/helpers/feature_flags_helper_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe FeatureFlagsHelper do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:feature_flag) { create(:operations_feature_flag, project: project) }
+ let_it_be(:user) { create(:user) }
+
+ describe '#unleash_api_url' do
+ subject { helper.unleash_api_url(project) }
+
+ it { is_expected.to end_with("/api/v4/feature_flags/unleash/#{project.id}") }
+ end
+
+ describe '#unleash_api_instance_id' do
+ subject { helper.unleash_api_instance_id(project) }
+
+ it { is_expected.not_to be_empty }
+ end
+end
diff --git a/spec/helpers/gitlab_routing_helper_spec.rb b/spec/helpers/gitlab_routing_helper_spec.rb
index 1ad7c7bb9ff..0088f739879 100644
--- a/spec/helpers/gitlab_routing_helper_spec.rb
+++ b/spec/helpers/gitlab_routing_helper_spec.rb
@@ -187,7 +187,7 @@ RSpec.describe GitlabRoutingHelper do
let(:ref) { 'test-ref' }
let(:args) { {} }
- subject { gitlab_raw_snippet_blob_path(snippet, blob.path, ref, args) }
+ subject { gitlab_raw_snippet_blob_path(snippet, blob.path, ref, **args) }
it_behaves_like 'snippet blob raw path'
@@ -222,7 +222,7 @@ RSpec.describe GitlabRoutingHelper do
let(:ref) { 'snippet-test-ref' }
let(:args) { {} }
- subject { gitlab_raw_snippet_blob_url(snippet, blob.path, ref, args) }
+ subject { gitlab_raw_snippet_blob_url(snippet, blob.path, ref, **args) }
it_behaves_like 'snippet blob raw url'
diff --git a/spec/helpers/groups/group_members_helper_spec.rb b/spec/helpers/groups/group_members_helper_spec.rb
index a25bf1c4157..abf621aee10 100644
--- a/spec/helpers/groups/group_members_helper_spec.rb
+++ b/spec/helpers/groups/group_members_helper_spec.rb
@@ -5,6 +5,8 @@ require "spec_helper"
RSpec.describe Groups::GroupMembersHelper do
include MembersPresentation
+ let_it_be(:current_user) { create(:user) }
+
describe '.group_member_select_options' do
let(:group) { create(:group) }
@@ -20,6 +22,10 @@ RSpec.describe Groups::GroupMembersHelper do
describe '#linked_groups_data_json' do
include_context 'group_group_link'
+ before do
+ allow(helper).to receive(:current_user).and_return(current_user)
+ end
+
it 'matches json schema' do
json = helper.linked_groups_data_json(shared_group.shared_with_group_links)
@@ -28,7 +34,6 @@ RSpec.describe Groups::GroupMembersHelper do
end
describe '#members_data_json' do
- let(:current_user) { create(:user) }
let(:group) { create(:group) }
before do
@@ -48,6 +53,14 @@ RSpec.describe Groups::GroupMembersHelper do
let(:group_member) { create(:group_member, group: group, created_by: current_user) }
it_behaves_like 'group_members.json'
+
+ context 'with user status set' do
+ let(:user) { create(:user) }
+ let!(:status) { create(:user_status, user: user) }
+ let(:group_member) { create(:group_member, group: group, user: user, created_by: current_user) }
+
+ it_behaves_like 'group_members.json'
+ end
end
context 'for an invited group member' do
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index 89a2a92ea57..821440db551 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -306,6 +306,38 @@ RSpec.describe IssuablesHelper do
end
end
+ describe '#reviewer_sidebar_data' do
+ let(:user) { create(:user) }
+
+ subject { helper.reviewer_sidebar_data(user, merge_request: merge_request) }
+
+ context 'without merge_request' do
+ let(:merge_request) { nil }
+
+ it 'returns hash of reviewer data' do
+ is_expected.to eql({
+ avatar_url: user.avatar_url,
+ name: user.name,
+ username: user.username
+ })
+ end
+ end
+
+ context 'with merge_request' do
+ let(:merge_request) { build(:merge_request) }
+
+ where(can_merge: [true, false])
+
+ with_them do
+ before do
+ allow(merge_request).to receive(:can_be_merged_by?).and_return(can_merge)
+ end
+
+ it { is_expected.to include({ can_merge: can_merge })}
+ end
+ end
+ end
+
describe '#issuable_squash_option?' do
using RSpec::Parameterized::TableSyntax
diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb
index 3f84eeb12c2..1ed61bd3144 100644
--- a/spec/helpers/issues_helper_spec.rb
+++ b/spec/helpers/issues_helper_spec.rb
@@ -233,4 +233,25 @@ RSpec.describe IssuesHelper do
expect(helper.show_moved_service_desk_issue_warning?(new_issue)).to be(false)
end
end
+
+ describe '#use_startup_call' do
+ it "returns false when a query param is present" do
+ allow(controller.request).to receive(:query_parameters).and_return({ foo: 'bar' })
+
+ expect(helper.use_startup_call?).to eq(false)
+ end
+
+ it "returns false when user has stored sort preference" do
+ controller.instance_variable_set(:@sort, 'updated_asc')
+
+ expect(helper.use_startup_call?).to eq(false)
+ end
+
+ it 'returns true when request.query_parameters is empty with default sorting preference' do
+ controller.instance_variable_set(:@sort, 'created_date')
+ allow(controller.request).to receive(:query_parameters).and_return({})
+
+ expect(helper.use_startup_call?).to eq(true)
+ end
+ end
end
diff --git a/spec/helpers/projects/incidents_helper_spec.rb b/spec/helpers/projects/incidents_helper_spec.rb
index 0affa67a902..68a5ce4eb91 100644
--- a/spec/helpers/projects/incidents_helper_spec.rb
+++ b/spec/helpers/projects/incidents_helper_spec.rb
@@ -9,9 +9,16 @@ RSpec.describe Projects::IncidentsHelper do
let(:project_path) { project.full_path }
let(:new_issue_path) { new_project_issue_path(project) }
let(:issue_path) { project_issues_path(project) }
+ let(:params) do
+ {
+ search: 'search text',
+ author_username: 'root',
+ assignee_username: 'max.power'
+ }
+ end
describe '#incidents_data' do
- subject(:data) { helper.incidents_data(project) }
+ subject(:data) { helper.incidents_data(project, params) }
it 'returns frontend configuration' do
expect(data).to match(
@@ -20,7 +27,10 @@ RSpec.describe Projects::IncidentsHelper do
'incident-template-name' => 'incident',
'incident-type' => 'incident',
'issue-path' => issue_path,
- 'empty-list-svg-path' => match_asset_path('/assets/illustrations/incident-empty-state.svg')
+ 'empty-list-svg-path' => match_asset_path('/assets/illustrations/incident-empty-state.svg'),
+ 'text-query': 'search text',
+ 'author-usernames-query': 'root',
+ 'assignee-usernames-query': 'max.power'
)
end
end
diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb
index 2b345ff3ae6..f081cf225b1 100644
--- a/spec/helpers/projects_helper_spec.rb
+++ b/spec/helpers/projects_helper_spec.rb
@@ -5,16 +5,15 @@ require 'spec_helper'
RSpec.describe ProjectsHelper do
include ProjectForksHelper
- let_it_be(:project) { create(:project) }
+ let_it_be_with_reload(:project) { create(:project) }
+ let_it_be_with_refind(:project_with_repo) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
- describe '#project_incident_management_setting' do
- let(:project) { create(:project) }
-
- before do
- helper.instance_variable_set(:@project, project)
- end
+ before do
+ helper.instance_variable_set(:@project, project)
+ end
+ describe '#project_incident_management_setting' do
context 'when incident_management_setting exists' do
let(:project_incident_management_setting) do
create(:project_incident_management_setting, project: project)
@@ -40,20 +39,14 @@ RSpec.describe ProjectsHelper do
end
describe '#error_tracking_setting_project_json' do
- let(:project) { create(:project) }
-
context 'error tracking setting does not exist' do
- before do
- helper.instance_variable_set(:@project, project)
- end
-
it 'returns nil' do
expect(helper.error_tracking_setting_project_json).to be_nil
end
end
context 'error tracking setting exists' do
- let!(:error_tracking_setting) { create(:project_error_tracking_setting, project: project) }
+ let_it_be(:error_tracking_setting) { create(:project_error_tracking_setting, project: project) }
context 'api_url present' do
let(:json) do
@@ -65,24 +58,16 @@ RSpec.describe ProjectsHelper do
}.to_json
end
- before do
- helper.instance_variable_set(:@project, project)
- end
-
it 'returns error tracking json' do
expect(helper.error_tracking_setting_project_json).to eq(json)
end
end
context 'api_url not present' do
- before do
+ it 'returns nil' do
project.error_tracking_setting.api_url = nil
project.error_tracking_setting.enabled = false
- helper.instance_variable_set(:@project, project)
- end
-
- it 'returns nil' do
expect(helper.error_tracking_setting_project_json).to be_nil
end
end
@@ -98,8 +83,7 @@ RSpec.describe ProjectsHelper do
end
describe "can_change_visibility_level?" do
- let(:project) { create(:project) }
- let(:user) { create(:project_member, :reporter, user: create(:user), project: project).user }
+ let_it_be(:user) { create(:project_member, :reporter, user: create(:user), project: project).user }
let(:forked_project) { fork_project(project, user) }
it "returns false if there are no appropriate permissions" do
@@ -142,8 +126,7 @@ RSpec.describe ProjectsHelper do
end
describe '#can_disable_emails?' do
- let(:project) { create(:project) }
- let(:user) { create(:project_member, :maintainer, user: create(:user), project: project).user }
+ let_it_be(:user) { create(:project_member, :maintainer, user: create(:user), project: project).user }
it 'returns true for the project owner' do
allow(helper).to receive(:can?).with(project.owner, :set_emails_disabled, project) { true }
@@ -166,11 +149,7 @@ RSpec.describe ProjectsHelper do
end
describe "readme_cache_key" do
- let(:project) { create(:project, :repository) }
-
- before do
- helper.instance_variable_set(:@project, project)
- end
+ let(:project) { project_with_repo }
it "returns a valid cach key" do
expect(helper.send(:readme_cache_key)).to eq("#{project.full_path}-#{project.commit.id}-readme")
@@ -184,8 +163,7 @@ RSpec.describe ProjectsHelper do
end
describe "#project_list_cache_key", :clean_gitlab_redis_shared_state do
- let(:project) { create(:project, :repository) }
- let(:user) { create(:user) }
+ let(:project) { project_with_repo }
before do
allow(helper).to receive(:current_user).and_return(user)
@@ -249,8 +227,6 @@ RSpec.describe ProjectsHelper do
describe '#load_pipeline_status' do
it 'loads the pipeline status in batch' do
- project = build(:project)
-
helper.load_pipeline_status([project])
# Skip lazy loading of the `pipeline_status` attribute
pipeline_status = project.instance_variable_get('@pipeline_status')
@@ -260,8 +236,6 @@ RSpec.describe ProjectsHelper do
end
describe '#show_no_ssh_key_message?' do
- let(:user) { create(:user) }
-
before do
allow(helper).to receive(:current_user).and_return(user)
end
@@ -282,8 +256,6 @@ RSpec.describe ProjectsHelper do
end
describe '#show_no_password_message?' do
- let(:user) { create(:user) }
-
before do
allow(helper).to receive(:current_user).and_return(user)
end
@@ -424,7 +396,6 @@ RSpec.describe ProjectsHelper do
before do
allow(helper).to receive(:current_user).and_return(user)
- helper.instance_variable_set(:@project, project)
end
context 'when there is no current_user' do
@@ -444,9 +415,6 @@ RSpec.describe ProjectsHelper do
end
describe '#get_project_nav_tabs' do
- let_it_be(:user) { create(:user) }
- let(:project) { create(:project) }
-
before do
allow(helper).to receive(:can?) { true }
end
@@ -524,7 +492,14 @@ RSpec.describe ProjectsHelper do
subject { helper.send(:can_view_operations_tab?, user, project) }
- [:read_environment, :read_cluster, :metrics_dashboard].each do |ability|
+ [
+ :metrics_dashboard,
+ :read_alert_management_alert,
+ :read_environment,
+ :read_issue,
+ :read_sentry_issue,
+ :read_cluster
+ ].each do |ability|
it 'includes operations tab' do
allow(helper).to receive(:can?).and_return(false)
allow(helper).to receive(:can?).with(user, ability, project).and_return(true)
@@ -536,7 +511,6 @@ RSpec.describe ProjectsHelper do
describe '#show_projects' do
let(:projects) do
- create(:project)
Project.all
end
@@ -561,8 +535,8 @@ RSpec.describe ProjectsHelper do
end
end
- describe('#push_to_create_project_command') do
- let(:user) { create(:user, username: 'john') }
+ describe '#push_to_create_project_command' do
+ let(:user) { build_stubbed(: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' }
@@ -578,8 +552,6 @@ RSpec.describe ProjectsHelper do
end
describe '#any_projects?' do
- let!(:project) { create(:project) }
-
it 'returns true when projects will be returned' do
expect(helper.any_projects?(Project.all)).to eq(true)
end
@@ -609,7 +581,7 @@ RSpec.describe ProjectsHelper do
end
describe '#git_user_name' do
- let(:user) { double(:user, name: 'John "A" Doe53') }
+ let(:user) { build_stubbed(:user, name: 'John "A" Doe53') }
before do
allow(helper).to receive(:current_user).and_return(user)
@@ -632,8 +604,6 @@ RSpec.describe ProjectsHelper do
end
context 'user logged in' do
- let(:user) { create(:user) }
-
before do
allow(helper).to receive(:current_user).and_return(user)
end
@@ -658,7 +628,6 @@ RSpec.describe ProjectsHelper do
end
describe 'show_xcode_link' do
- let!(:project) { create(:project) }
let(:mac_ua) { 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.139 Safari/537.36' }
let(:ios_ua) { 'Mozilla/5.0 (iPad; CPU OS 5_1_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/5.1 Mobile/9B206 Safari/7534.48.3' }
@@ -799,7 +768,7 @@ RSpec.describe ProjectsHelper do
describe '#show_auto_devops_implicitly_enabled_banner?' do
using RSpec::Parameterized::TableSyntax
- let(:user) { create(:user) }
+ let_it_be_with_reload(:project_with_auto_devops) { create(:project, :repository, :auto_devops) }
let(:feature_visibilities) do
{
@@ -873,9 +842,9 @@ RSpec.describe ProjectsHelper do
with_them do
let(:project) do
if project_setting.nil?
- create(:project, :repository)
+ project_with_repo
else
- create(:project, :repository, :auto_devops)
+ project_with_auto_devops
end
end
@@ -896,14 +865,8 @@ RSpec.describe ProjectsHelper do
end
describe '#can_import_members?' do
- let(:project) { create(:project) }
- let(:user) { create(:user) }
let(:owner) { project.owner }
- before do
- helper.instance_variable_set(:@project, project)
- end
-
it 'returns false if user cannot admin_project_member' do
allow(helper).to receive(:current_user) { user }
expect(helper.can_import_members?).to eq false
@@ -916,12 +879,6 @@ RSpec.describe ProjectsHelper do
end
describe '#metrics_external_dashboard_url' do
- let(:project) { create(:project) }
-
- before do
- helper.instance_variable_set(:@project, project)
- end
-
context 'metrics_setting exists' do
it 'returns external_dashboard_url' do
metrics_setting = create(:project_metrics_setting, project: project)
@@ -938,12 +895,6 @@ RSpec.describe ProjectsHelper do
end
describe '#grafana_integration_url' do
- let(:project) { create(:project) }
-
- before do
- helper.instance_variable_set(:@project, project)
- end
-
subject { helper.grafana_integration_url }
it { is_expected.to eq(nil) }
@@ -956,12 +907,6 @@ RSpec.describe ProjectsHelper do
end
describe '#grafana_integration_token' do
- let(:project) { create(:project) }
-
- before do
- helper.instance_variable_set(:@project, project)
- end
-
subject { helper.grafana_integration_masked_token }
it { is_expected.to eq(nil) }
@@ -974,12 +919,6 @@ RSpec.describe ProjectsHelper do
end
describe '#grafana_integration_enabled?' do
- let(:project) { create(:project) }
-
- before do
- helper.instance_variable_set(:@project, project)
- end
-
subject { helper.grafana_integration_enabled? }
it { is_expected.to eq(nil) }
@@ -992,7 +931,6 @@ RSpec.describe ProjectsHelper do
end
describe '#project_license_name(project)', :request_store do
- let_it_be(:project) { create(:project) }
let_it_be(:repository) { project.repository }
subject { project_license_name(project) }
diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb
index 594c5c11994..341606635c0 100644
--- a/spec/helpers/search_helper_spec.rb
+++ b/spec/helpers/search_helper_spec.rb
@@ -357,14 +357,6 @@ RSpec.describe SearchHelper do
describe '#show_user_search_tab?' do
subject { show_user_search_tab? }
- context 'when users_search feature is disabled' do
- before do
- stub_feature_flags(users_search: false)
- end
-
- it { is_expected.to eq(false) }
- end
-
context 'when project search' do
before do
@project = :some_project
@@ -399,4 +391,25 @@ RSpec.describe SearchHelper do
end
end
end
+
+ describe '#repository_ref' do
+ let_it_be(:project) { create(:project, :repository) }
+ let(:params) { { repository_ref: 'the-repository-ref-param' } }
+
+ subject { repository_ref(project) }
+
+ it { is_expected.to eq('the-repository-ref-param') }
+
+ context 'when the param :repository_ref is not set' do
+ let(:params) { { repository_ref: nil } }
+
+ it { is_expected.to eq(project.default_branch) }
+ end
+
+ context 'when the repository_ref param is a number' do
+ let(:params) { { repository_ref: 111111 } }
+
+ it { is_expected.to eq('111111') }
+ end
+ end
end
diff --git a/spec/helpers/tree_helper_spec.rb b/spec/helpers/tree_helper_spec.rb
index 97b6802dde9..d347d8e6bfa 100644
--- a/spec/helpers/tree_helper_spec.rb
+++ b/spec/helpers/tree_helper_spec.rb
@@ -178,7 +178,7 @@ RSpec.describe TreeHelper do
it 'returns a list of attributes related to the project' do
expect(subject).to include(
- ide_base_path: project.full_path,
+ web_ide_url_data: { path: project.full_path, is_fork: false },
needs_to_fork: false,
show_web_ide_button: true,
show_gitpod_button: false,
@@ -200,9 +200,9 @@ RSpec.describe TreeHelper do
allow(helper).to receive(:current_user).and_return(user)
end
- it 'includes ide_base_path: forked_project.full_path' do
+ it 'includes web_ide_url_data: forked_project.full_path' do
expect(subject).to include(
- ide_base_path: forked_project.full_path
+ web_ide_url_data: { path: forked_project.full_path, is_fork: true }
)
end
end
@@ -216,9 +216,9 @@ RSpec.describe TreeHelper do
allow(helper).to receive(:current_user).and_return(user)
end
- it 'includes ide_base_path: project.full_path' do
+ it 'includes web_ide_url_data: project.full_path' do
expect(subject).to include(
- ide_base_path: project.full_path
+ web_ide_url_data: { path: project.full_path, is_fork: false }
)
end
end
diff --git a/spec/helpers/whats_new_helper_spec.rb b/spec/helpers/whats_new_helper_spec.rb
index db880163454..7d8fb236b1e 100644
--- a/spec/helpers/whats_new_helper_spec.rb
+++ b/spec/helpers/whats_new_helper_spec.rb
@@ -3,6 +3,52 @@
require 'spec_helper'
RSpec.describe WhatsNewHelper do
+ describe '#whats_new_storage_key' do
+ subject { helper.whats_new_storage_key }
+
+ before do
+ allow(helper).to receive(:whats_new_most_recent_release_items).and_return(json)
+ end
+
+ context 'when recent release items exist' do
+ let(:json) { [{ release: 84.0 }].to_json }
+
+ it { is_expected.to eq('display-whats-new-notification-84.0') }
+
+ context 'when the release items are missing the release key' do
+ let(:json) { [{ title: 'bells!' }].to_json }
+
+ it { is_expected.to eq('display-whats-new-notification') }
+ end
+ end
+
+ context 'when recent release items do NOT exist' do
+ let(:json) { WhatsNewHelper::EMPTY_JSON }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe '#whats_new_most_recent_release_items_count' do
+ subject { helper.whats_new_most_recent_release_items_count }
+
+ before do
+ allow(helper).to receive(:whats_new_most_recent_release_items).and_return(json)
+ end
+
+ context 'when recent release items exist' do
+ let(:json) { [:bells, :and, :whistles].to_json }
+
+ it { is_expected.to eq(3) }
+ end
+
+ context 'when recent release items do NOT exist' do
+ let(:json) { WhatsNewHelper::EMPTY_JSON }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
describe '#whats_new_most_recent_release_items' do
let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) }
diff --git a/spec/lib/api/github/entities_spec.rb b/spec/lib/api/github/entities_spec.rb
new file mode 100644
index 00000000000..00ea60c5d65
--- /dev/null
+++ b/spec/lib/api/github/entities_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Github::Entities do
+ describe API::Github::Entities::User do
+ let(:user) { create(:user, username: username) }
+ let(:username) { 'name_of_user' }
+ let(:gitlab_protocol_and_host) { "#{Gitlab.config.gitlab.protocol}://#{Gitlab.config.gitlab.host}" }
+ let(:expected_user_url) { "#{gitlab_protocol_and_host}/#{username}" }
+ let(:entity) { described_class.new(user) }
+
+ subject { entity.as_json }
+
+ specify :aggregate_failure do
+ expect(subject[:id]).to eq user.id
+ expect(subject[:login]).to eq 'name_of_user'
+ expect(subject[:url]).to eq expected_user_url
+ expect(subject[:html_url]).to eq expected_user_url
+ expect(subject[:avatar_url]).to include('https://www.gravatar.com/avatar')
+ end
+
+ context 'with avatar' do
+ let(:user) { create(:user, :with_avatar, username: username) }
+
+ specify do
+ expect(subject[:avatar_url]).to include("#{gitlab_protocol_and_host}/uploads/-/system/user/avatar/")
+ end
+ end
+ end
+end
diff --git a/spec/lib/api/helpers/packages/dependency_proxy_helpers_spec.rb b/spec/lib/api/helpers/packages/dependency_proxy_helpers_spec.rb
index ccf96bcbad6..6d06fc3618d 100644
--- a/spec/lib/api/helpers/packages/dependency_proxy_helpers_spec.rb
+++ b/spec/lib/api/helpers/packages/dependency_proxy_helpers_spec.rb
@@ -24,6 +24,7 @@ RSpec.describe API::Helpers::Packages::DependencyProxyHelpers do
shared_examples 'executing redirect' do
it 'redirects to package registry' do
+ expect(helper).to receive(:track_event).with('npm_request_forward').once
expect(helper).to receive(:registry_url).once
expect(helper).to receive(:redirect).once
expect(helper).to receive(:fallback).never
@@ -63,6 +64,7 @@ RSpec.describe API::Helpers::Packages::DependencyProxyHelpers do
let(:package_type) { pkg_type }
it 'raises an error' do
+ allow(helper).to receive(:track_event)
expect { subject }.to raise_error(ArgumentError, "Can't build registry_url for package_type #{package_type}")
end
end
diff --git a/spec/lib/api/helpers_spec.rb b/spec/lib/api/helpers_spec.rb
index 51a45dff6a4..8e738af0fa3 100644
--- a/spec/lib/api/helpers_spec.rb
+++ b/spec/lib/api/helpers_spec.rb
@@ -191,41 +191,32 @@ RSpec.describe API::Helpers do
describe '#increment_unique_values' do
let(:value) { '9f302fea-f828-4ca9-aef4-e10bd723c0b3' }
- let(:event_name) { 'my_event' }
+ let(:event_name) { 'g_compliance_dashboard' }
let(:unknown_event) { 'unknown' }
let(:feature) { "usage_data_#{event_name}" }
+ before do
+ skip_feature_flags_yaml_validation
+ end
+
context 'with feature enabled' do
before do
stub_feature_flags(feature => true)
end
it 'tracks redis hll event' do
- stub_application_setting(usage_ping_enabled: true)
-
expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(value, event_name)
subject.increment_unique_values(event_name, value)
end
- it 'does not track event usage ping is not enabled' do
- stub_application_setting(usage_ping_enabled: false)
- expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
-
- subject.increment_unique_values(event_name, value)
- end
-
it 'logs an exception for unknown event' do
- stub_application_setting(usage_ping_enabled: true)
-
expect(Gitlab::AppLogger).to receive(:warn).with("Redis tracking event failed for event: #{unknown_event}, message: Unknown event #{unknown_event}")
subject.increment_unique_values(unknown_event, value)
end
it 'does not track event for nil values' do
- stub_application_setting(usage_ping_enabled: true)
-
expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
subject.increment_unique_values(unknown_event, nil)
diff --git a/spec/lib/backup/files_spec.rb b/spec/lib/backup/files_spec.rb
index c2dbaac7f15..45cc73974d6 100644
--- a/spec/lib/backup/files_spec.rb
+++ b/spec/lib/backup/files_spec.rb
@@ -30,7 +30,7 @@ RSpec.describe Backup::Files do
let(:timestamp) { Time.utc(2017, 3, 22) }
around do |example|
- Timecop.freeze(timestamp) { example.run }
+ travel_to(timestamp) { example.run }
end
describe 'folders with permission' do
diff --git a/spec/lib/backup/repositories_spec.rb b/spec/lib/backup/repositories_spec.rb
new file mode 100644
index 00000000000..540c64e74ca
--- /dev/null
+++ b/spec/lib/backup/repositories_spec.rb
@@ -0,0 +1,238 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Backup::Repositories do
+ let(:progress) { StringIO.new }
+
+ subject { described_class.new(progress) }
+
+ before do
+ allow(progress).to receive(:puts)
+ allow(progress).to receive(:print)
+
+ allow_next_instance_of(described_class) do |instance|
+ allow(instance).to receive(:progress).and_return(progress)
+ end
+ end
+
+ describe '#dump' do
+ let_it_be(:projects) { create_list(:project, 5, :repository) }
+
+ RSpec.shared_examples 'creates repository bundles' do
+ specify :aggregate_failures do
+ # Add data to the wiki and design repositories, so they will be included in the dump.
+ create(:wiki_page, container: project)
+ create(:design, :with_file, issue: create(:issue, project: project))
+
+ subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
+
+ expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.bundle'))
+ expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.wiki' + '.bundle'))
+ expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.design' + '.bundle'))
+ end
+ end
+
+ context 'hashed storage' do
+ let_it_be(:project) { create(:project, :repository) }
+
+ it_behaves_like 'creates repository bundles'
+ end
+
+ context 'legacy storage' do
+ let_it_be(:project) { create(:project, :repository, :legacy_storage) }
+
+ it_behaves_like 'creates repository bundles'
+ end
+
+ context 'no concurrency' do
+ it 'creates the expected number of threads' do
+ expect(Thread).not_to receive(:new)
+
+ projects.each do |project|
+ expect(subject).to receive(:dump_project).with(project).and_call_original
+ end
+
+ subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
+ end
+
+ describe 'command failure' do
+ it 'dump_project raises an error' do
+ allow(subject).to receive(:dump_project).and_raise(IOError)
+
+ expect { subject.dump(max_concurrency: 1, max_storage_concurrency: 1) }.to raise_error(IOError)
+ end
+
+ it 'project query raises an error' do
+ allow(Project).to receive_message_chain(:includes, :find_each).and_raise(ActiveRecord::StatementTimeout)
+
+ expect { subject.dump(max_concurrency: 1, max_storage_concurrency: 1) }.to raise_error(ActiveRecord::StatementTimeout)
+ end
+ end
+
+ it 'avoids N+1 database queries' do
+ control_count = ActiveRecord::QueryRecorder.new do
+ subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
+ end.count
+
+ create_list(:project, 2, :repository)
+
+ expect do
+ subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
+ end.not_to exceed_query_limit(control_count)
+ end
+ end
+
+ [4, 10].each do |max_storage_concurrency|
+ context "max_storage_concurrency #{max_storage_concurrency}", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/241701' do
+ let(:storage_keys) { %w[default test_second_storage] }
+
+ before do
+ allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(storage_keys)
+ end
+
+ it 'creates the expected number of threads' do
+ expect(Thread).to receive(:new)
+ .exactly(storage_keys.length * (max_storage_concurrency + 1)).times
+ .and_call_original
+
+ projects.each do |project|
+ expect(subject).to receive(:dump_project).with(project).and_call_original
+ end
+
+ subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency)
+ end
+
+ it 'creates the expected number of threads with extra max concurrency' do
+ expect(Thread).to receive(:new)
+ .exactly(storage_keys.length * (max_storage_concurrency + 1)).times
+ .and_call_original
+
+ projects.each do |project|
+ expect(subject).to receive(:dump_project).with(project).and_call_original
+ end
+
+ subject.dump(max_concurrency: 3, max_storage_concurrency: max_storage_concurrency)
+ end
+
+ describe 'command failure' do
+ it 'dump_project raises an error' do
+ allow(subject).to receive(:dump_project)
+ .and_raise(IOError)
+
+ expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(IOError)
+ end
+
+ it 'project query raises an error' do
+ allow(Project).to receive_message_chain(:for_repository_storage, :includes, :find_each).and_raise(ActiveRecord::StatementTimeout)
+
+ expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(ActiveRecord::StatementTimeout)
+ end
+
+ context 'misconfigured storages' do
+ let(:storage_keys) { %w[test_second_storage] }
+
+ it 'raises an error' do
+ expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(Backup::Error, 'repositories.storages in gitlab.yml is misconfigured')
+ end
+ end
+ end
+
+ it 'avoids N+1 database queries' do
+ control_count = ActiveRecord::QueryRecorder.new do
+ subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency)
+ end.count
+
+ create_list(:project, 2, :repository)
+
+ expect do
+ subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency)
+ end.not_to exceed_query_limit(control_count)
+ end
+ end
+ end
+ end
+
+ describe '#restore' do
+ let_it_be(:project) { create(:project) }
+
+ it 'restores repositories from bundles', :aggregate_failures do
+ next_path_to_bundle = [
+ Rails.root.join('spec/fixtures/lib/backup/project_repo.bundle'),
+ Rails.root.join('spec/fixtures/lib/backup/wiki_repo.bundle'),
+ Rails.root.join('spec/fixtures/lib/backup/design_repo.bundle')
+ ].to_enum
+
+ allow_next_instance_of(described_class::BackupRestore) do |backup_restore|
+ allow(backup_restore).to receive(:path_to_bundle).and_return(next_path_to_bundle.next)
+ end
+
+ subject.restore
+
+ collect_commit_shas = -> (repo) { repo.commits('master', limit: 10).map(&:sha) }
+
+ expect(collect_commit_shas.call(project.repository)).to eq(['393a7d860a5a4c3cc736d7eb00604e3472bb95ec'])
+ expect(collect_commit_shas.call(project.wiki.repository)).to eq(['c74b9948d0088d703ee1fafeddd9ed9add2901ea'])
+ expect(collect_commit_shas.call(project.design_repository)).to eq(['c3cd4d7bd73a51a0f22045c3a4c871c435dc959d'])
+ end
+
+ describe 'command failure' do
+ before do
+ expect(Project).to receive(:find_each).and_yield(project)
+
+ allow_next_instance_of(DesignManagement::Repository) do |repository|
+ allow(repository).to receive(:create_repository) { raise 'Fail in tests' }
+ end
+ allow_next_instance_of(Repository) do |repository|
+ allow(repository).to receive(:create_repository) { raise 'Fail in tests' }
+ end
+ end
+
+ context 'hashed storage' do
+ it 'shows the appropriate error' do
+ subject.restore
+
+ expect(progress).to have_received(:puts).with("[Failed] restoring #{project.full_path} (#{project.disk_path})")
+ end
+ end
+
+ context 'legacy storage' do
+ let_it_be(:project) { create(:project, :legacy_storage) }
+
+ it 'shows the appropriate error' do
+ subject.restore
+
+ expect(progress).to have_received(:puts).with("[Failed] restoring #{project.full_path} (#{project.disk_path})")
+ end
+ end
+ end
+
+ context 'restoring object pools' do
+ it 'schedules restoring of the pool', :sidekiq_might_not_need_inline do
+ pool_repository = create(:pool_repository, :failed)
+ pool_repository.delete_object_pool
+
+ subject.restore
+
+ pool_repository.reload
+ expect(pool_repository).not_to be_failed
+ expect(pool_repository.object_pool.exists?).to be(true)
+ end
+ end
+
+ it 'cleans existing repositories' do
+ expect_next_instance_of(DesignManagement::Repository) do |repository|
+ expect(repository).to receive(:remove)
+ end
+ expect(Repository).to receive(:new).twice.and_wrap_original do |method, *original_args|
+ repository = method.call(*original_args)
+
+ expect(repository).to receive(:remove)
+
+ repository
+ end
+
+ subject.restore
+ end
+ end
+end
diff --git a/spec/lib/backup/repository_spec.rb b/spec/lib/backup/repository_spec.rb
deleted file mode 100644
index 718f38f9452..00000000000
--- a/spec/lib/backup/repository_spec.rb
+++ /dev/null
@@ -1,232 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Backup::Repository do
- let_it_be(:project) { create(:project, :wiki_repo) }
-
- let(:progress) { StringIO.new }
-
- subject { described_class.new(progress) }
-
- before do
- allow(progress).to receive(:puts)
- allow(progress).to receive(:print)
- allow(FileUtils).to receive(:mv).and_return(true)
-
- allow_next_instance_of(described_class) do |instance|
- allow(instance).to receive(:progress).and_return(progress)
- end
- end
-
- describe '#dump' do
- before do
- allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(storage_keys)
- end
-
- let_it_be(:projects) { create_list(:project, 5, :wiki_repo) + [project] }
-
- let(:storage_keys) { %w[default test_second_storage] }
-
- context 'no concurrency' do
- it 'creates the expected number of threads' do
- expect(Thread).not_to receive(:new)
-
- projects.each do |project|
- expect(subject).to receive(:dump_project).with(project).and_call_original
- end
-
- subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
- end
-
- describe 'command failure' do
- it 'dump_project raises an error' do
- allow(subject).to receive(:dump_project).and_raise(IOError)
-
- expect { subject.dump(max_concurrency: 1, max_storage_concurrency: 1) }.to raise_error(IOError)
- end
-
- it 'project query raises an error' do
- allow(Project).to receive_message_chain(:includes, :find_each).and_raise(ActiveRecord::StatementTimeout)
-
- expect { subject.dump(max_concurrency: 1, max_storage_concurrency: 1) }.to raise_error(ActiveRecord::StatementTimeout)
- end
- end
-
- it 'avoids N+1 database queries' do
- control_count = ActiveRecord::QueryRecorder.new do
- subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
- end.count
-
- create_list(:project, 2, :wiki_repo)
-
- expect do
- subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
- end.not_to exceed_query_limit(control_count)
- end
- end
-
- [4, 10].each do |max_storage_concurrency|
- context "max_storage_concurrency #{max_storage_concurrency}", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/241701' do
- it 'creates the expected number of threads' do
- expect(Thread).to receive(:new)
- .exactly(storage_keys.length * (max_storage_concurrency + 1)).times
- .and_call_original
-
- projects.each do |project|
- expect(subject).to receive(:dump_project).with(project).and_call_original
- end
-
- subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency)
- end
-
- it 'creates the expected number of threads with extra max concurrency' do
- expect(Thread).to receive(:new)
- .exactly(storage_keys.length * (max_storage_concurrency + 1)).times
- .and_call_original
-
- projects.each do |project|
- expect(subject).to receive(:dump_project).with(project).and_call_original
- end
-
- subject.dump(max_concurrency: 3, max_storage_concurrency: max_storage_concurrency)
- end
-
- describe 'command failure' do
- it 'dump_project raises an error' do
- allow(subject).to receive(:dump_project)
- .and_raise(IOError)
-
- expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(IOError)
- end
-
- it 'project query raises an error' do
- allow(Project).to receive_message_chain(:for_repository_storage, :includes, :find_each).and_raise(ActiveRecord::StatementTimeout)
-
- expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(ActiveRecord::StatementTimeout)
- end
-
- context 'misconfigured storages' do
- let(:storage_keys) { %w[test_second_storage] }
-
- it 'raises an error' do
- expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(Backup::Error, 'repositories.storages in gitlab.yml is misconfigured')
- end
- end
- end
-
- it 'avoids N+1 database queries' do
- control_count = ActiveRecord::QueryRecorder.new do
- subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency)
- end.count
-
- create_list(:project, 2, :wiki_repo)
-
- expect do
- subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency)
- end.not_to exceed_query_limit(control_count)
- end
- end
- end
- end
-
- describe '#restore' do
- let(:timestamp) { Time.utc(2017, 3, 22) }
- let(:temp_dirs) do
- Gitlab.config.repositories.storages.map do |name, storage|
- Gitlab::GitalyClient::StorageSettings.allow_disk_access do
- File.join(storage.legacy_disk_path, '..', 'repositories.old.' + timestamp.to_i.to_s)
- end
- 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 us to set expectations on the project directly
- expect(Project).to receive(:find_each).and_yield(project)
- expect(project.repository).to receive(:create_repository) { raise 'Fail in tests' }
- end
-
- context 'hashed storage' do
- it 'shows the appropriate error' do
- subject.restore
-
- expect(progress).to have_received(:puts).with("[Failed] restoring #{project.full_path} repository")
- 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("[Failed] restoring #{project.full_path} repository")
- end
- end
- end
-
- context 'restoring object pools' do
- it 'schedules restoring of the pool', :sidekiq_might_not_need_inline do
- pool_repository = create(:pool_repository, :failed)
- pool_repository.delete_object_pool
-
- subject.restore
-
- pool_repository.reload
- expect(pool_repository).not_to be_failed
- expect(pool_repository.object_pool.exists?).to be(true)
- end
- end
-
- it 'cleans existing repositories' do
- wiki_repository_spy = spy(:wiki)
-
- allow_next_instance_of(ProjectWiki) do |project_wiki|
- allow(project_wiki).to receive(:repository).and_return(wiki_repository_spy)
- end
-
- expect_next_instance_of(Repository) do |repo|
- expect(repo).to receive(:remove)
- end
-
- subject.restore
-
- expect(wiki_repository_spy).to have_received(:remove)
- end
- end
-
- describe '#empty_repo?' do
- context 'for a wiki' do
- let(:wiki) { create(:project_wiki) }
-
- it 'invalidates the emptiness cache' do
- expect(wiki.repository).to receive(:expire_emptiness_caches).once
-
- subject.send(:empty_repo?, wiki)
- end
-
- context 'wiki repo has content' do
- let!(:wiki_page) { create(:wiki_page, wiki: wiki) }
-
- it 'returns true, regardless of bad cache value' do
- expect(subject.send(:empty_repo?, wiki)).to be(false)
- end
- end
-
- context 'wiki repo does not have content' do
- it 'returns true, regardless of bad cache value' do
- expect(subject.send(:empty_repo?, wiki)).to be_truthy
- end
- end
- end
- end
-end
diff --git a/spec/lib/banzai/filter/design_reference_filter_spec.rb b/spec/lib/banzai/filter/design_reference_filter_spec.rb
index 1b558754932..847c398964a 100644
--- a/spec/lib/banzai/filter/design_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/design_reference_filter_spec.rb
@@ -74,26 +74,6 @@ RSpec.describe Banzai::Filter::DesignReferenceFilter do
it_behaves_like 'a no-op filter'
end
-
- context 'design reference filter is not enabled' do
- before do
- stub_feature_flags(described_class::FEATURE_FLAG => false)
- end
-
- it_behaves_like 'a no-op filter'
-
- it 'issues no queries' do
- expect { process(input_text) }.not_to exceed_query_limit(0)
- end
- end
-
- context 'the filter is enabled for the context project' do
- before do
- stub_feature_flags(described_class::FEATURE_FLAG => project)
- end
-
- it_behaves_like 'a good link reference'
- 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 e7b6c910b8a..35ef2abfa63 100644
--- a/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/external_issue_reference_filter_spec.rb
@@ -5,6 +5,8 @@ require 'spec_helper'
RSpec.describe Banzai::Filter::ExternalIssueReferenceFilter do
include FilterSpecHelper
+ let_it_be_with_refind(:project) { create(:project) }
+
shared_examples_for "external issue tracker" do
it_behaves_like 'a reference containing an element node'
@@ -116,7 +118,7 @@ RSpec.describe Banzai::Filter::ExternalIssueReferenceFilter do
end
context "redmine project" do
- let(:project) { create(:redmine_project) }
+ let_it_be(:service) { create(:redmine_service, project: project) }
before do
project.update!(issues_enabled: false)
@@ -138,7 +140,7 @@ RSpec.describe Banzai::Filter::ExternalIssueReferenceFilter do
end
context "youtrack project" do
- let(:project) { create(:youtrack_project) }
+ let_it_be(:service) { create(:youtrack_service, project: project) }
before do
project.update!(issues_enabled: false)
@@ -181,7 +183,7 @@ RSpec.describe Banzai::Filter::ExternalIssueReferenceFilter do
end
context "jira project" do
- let(:project) { create(:jira_project) }
+ let_it_be(:service) { create(:jira_service, project: project) }
let(:reference) { issue.to_reference }
context "with right markdown" do
@@ -210,7 +212,7 @@ RSpec.describe Banzai::Filter::ExternalIssueReferenceFilter do
end
context "ewm project" do
- let_it_be(:project) { create(:ewm_project) }
+ let_it_be(:service) { create(:ewm_service, project: project) }
before do
project.update!(issues_enabled: false)
diff --git a/spec/lib/banzai/filter/inline_grafana_metrics_filter_spec.rb b/spec/lib/banzai/filter/inline_grafana_metrics_filter_spec.rb
index 8bdb24ab08c..d29af311ee5 100644
--- a/spec/lib/banzai/filter/inline_grafana_metrics_filter_spec.rb
+++ b/spec/lib/banzai/filter/inline_grafana_metrics_filter_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe Banzai::Filter::InlineGrafanaMetricsFilter do
it_behaves_like 'a metrics embed filter'
around do |example|
- Timecop.freeze(Time.utc(2019, 3, 17, 13, 10)) { example.run }
+ travel_to(Time.utc(2019, 3, 17, 13, 10)) { example.run }
end
context 'when grafana is not configured' do
diff --git a/spec/lib/banzai/filter/issue_reference_filter_spec.rb b/spec/lib/banzai/filter/issue_reference_filter_spec.rb
index 447802d18a7..4b8b575c1f0 100644
--- a/spec/lib/banzai/filter/issue_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/issue_reference_filter_spec.rb
@@ -296,6 +296,12 @@ RSpec.describe Banzai::Filter::IssueReferenceFilter do
.to eq reference
end
+ it 'link with trailing slash' do
+ doc = reference_filter("Fixed (#{issue_url + "/"}.)")
+
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(issue.to_reference(project))}</a>\.\)})
+ end
+
it 'links with adjacent text' do
doc = reference_filter("Fixed (#{reference}.)")
diff --git a/spec/lib/banzai/filter/milestone_reference_filter_spec.rb b/spec/lib/banzai/filter/milestone_reference_filter_spec.rb
index 62b1711ee57..276fa7952be 100644
--- a/spec/lib/banzai/filter/milestone_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/milestone_reference_filter_spec.rb
@@ -5,9 +5,11 @@ require 'spec_helper'
RSpec.describe Banzai::Filter::MilestoneReferenceFilter do
include FilterSpecHelper
- let(:parent_group) { create(:group, :public) }
- let(:group) { create(:group, :public, parent: parent_group) }
- let(:project) { create(:project, :public, group: group) }
+ let_it_be(:parent_group) { create(:group, :public) }
+ let_it_be(:group) { create(:group, :public, parent: parent_group) }
+ let_it_be(:project) { create(:project, :public, group: group) }
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:another_project) { create(:project, :public, namespace: namespace) }
it 'requires project context' do
expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
@@ -188,11 +190,9 @@ RSpec.describe Banzai::Filter::MilestoneReferenceFilter do
end
shared_examples 'cross-project / cross-namespace complete reference' do
- let(:namespace) { create(:namespace) }
- let(:another_project) { create(:project, :public, namespace: namespace) }
- let(:milestone) { create(:milestone, project: another_project) }
- let(:reference) { "#{another_project.full_path}%#{milestone.iid}" }
- let!(:result) { reference_filter("See #{reference}") }
+ let_it_be(:milestone) { create(:milestone, project: another_project) }
+ let(:reference) { "#{another_project.full_path}%#{milestone.iid}" }
+ let!(:result) { reference_filter("See #{reference}") }
it 'points to referenced project milestone page' do
expect(result.css('a').first.attr('href')).to eq urls
@@ -226,12 +226,10 @@ RSpec.describe Banzai::Filter::MilestoneReferenceFilter do
end
shared_examples 'cross-project / same-namespace complete reference' do
- let(:namespace) { create(:namespace) }
- let(:project) { create(:project, :public, namespace: namespace) }
- let(:another_project) { create(:project, :public, namespace: namespace) }
- let(:milestone) { create(:milestone, project: another_project) }
- let(:reference) { "#{another_project.full_path}%#{milestone.iid}" }
- let!(:result) { reference_filter("See #{reference}") }
+ let_it_be(:project) { create(:project, :public, namespace: namespace) }
+ let_it_be(:milestone) { create(:milestone, project: another_project) }
+ let(:reference) { "#{another_project.full_path}%#{milestone.iid}" }
+ let!(:result) { reference_filter("See #{reference}") }
it 'points to referenced project milestone page' do
expect(result.css('a').first.attr('href')).to eq urls
@@ -265,12 +263,10 @@ RSpec.describe Banzai::Filter::MilestoneReferenceFilter do
end
shared_examples 'cross project shorthand reference' do
- let(:namespace) { create(:namespace) }
- let(:project) { create(:project, :public, namespace: namespace) }
- let(:another_project) { create(:project, :public, namespace: namespace) }
- let(:milestone) { create(:milestone, project: another_project) }
- let(:reference) { "#{another_project.path}%#{milestone.iid}" }
- let!(:result) { reference_filter("See #{reference}") }
+ let_it_be(:project) { create(:project, :public, namespace: namespace) }
+ let_it_be(:milestone) { create(:milestone, project: another_project) }
+ let(:reference) { "#{another_project.path}%#{milestone.iid}" }
+ let!(:result) { reference_filter("See #{reference}") }
it 'points to referenced project milestone page' do
expect(result.css('a').first.attr('href')).to eq urls
@@ -439,13 +435,13 @@ RSpec.describe Banzai::Filter::MilestoneReferenceFilter do
context 'when milestone is open' do
context 'project milestones' do
- let(:milestone) { create(:milestone, project: project) }
+ let_it_be_with_reload(:milestone) { create(:milestone, project: project) }
include_context 'project milestones'
end
context 'group milestones' do
- let(:milestone) { create(:milestone, group: group) }
+ let_it_be_with_reload(:milestone) { create(:milestone, group: group) }
include_context 'group milestones'
end
@@ -453,13 +449,13 @@ RSpec.describe Banzai::Filter::MilestoneReferenceFilter do
context 'when milestone is closed' do
context 'project milestones' do
- let(:milestone) { create(:milestone, :closed, project: project) }
+ let_it_be_with_reload(:milestone) { create(:milestone, :closed, project: project) }
include_context 'project milestones'
end
context 'group milestones' do
- let(:milestone) { create(:milestone, :closed, group: group) }
+ let_it_be_with_reload(:milestone) { create(:milestone, :closed, group: group) }
include_context 'group milestones'
end
diff --git a/spec/lib/feature/definition_spec.rb b/spec/lib/feature/definition_spec.rb
index 49224cf4279..fa0207d829a 100644
--- a/spec/lib/feature/definition_spec.rb
+++ b/spec/lib/feature/definition_spec.rb
@@ -105,6 +105,7 @@ RSpec.describe Feature::Definition do
describe '.load_all!' do
let(:store1) { Dir.mktmpdir('path1') }
let(:store2) { Dir.mktmpdir('path2') }
+ let(:definitions) { {} }
before do
allow(described_class).to receive(:paths).and_return(
@@ -115,28 +116,30 @@ RSpec.describe Feature::Definition do
)
end
+ subject { described_class.send(:load_all!) }
+
it "when there's no feature flags a list of definitions is empty" do
- expect(described_class.load_all!).to be_empty
+ is_expected.to be_empty
end
it "when there's a single feature flag it properly loads them" do
write_feature_flag(store1, path, yaml_content)
- expect(described_class.load_all!).to be_one
+ is_expected.to be_one
end
it "when the same feature flag is stored multiple times raises exception" do
write_feature_flag(store1, path, yaml_content)
write_feature_flag(store2, path, yaml_content)
- expect { described_class.load_all! }
+ expect { subject }
.to raise_error(/Feature flag 'feature_flag' is already defined/)
end
it "when one of the YAMLs is invalid it does raise exception" do
write_feature_flag(store1, path, '{}')
- expect { described_class.load_all! }
+ expect { subject }
.to raise_error(/Feature flag is missing name/)
end
diff --git a/spec/lib/feature_spec.rb b/spec/lib/feature_spec.rb
index acd7d97ac85..5dff9dbd995 100644
--- a/spec/lib/feature_spec.rb
+++ b/spec/lib/feature_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe Feature, stub_feature_flags: false do
before do
# reset Flipper AR-engine
Feature.reset
+ skip_feature_flags_yaml_validation
end
describe '.get' do
@@ -253,6 +254,9 @@ RSpec.describe Feature, stub_feature_flags: false do
end
before do
+ stub_env('LAZILY_CREATE_FEATURE_FLAG', '0')
+
+ allow(Feature::Definition).to receive(:valid_usage!).and_call_original
allow(Feature::Definition).to receive(:definitions) do
{ definition.key => definition }
end
diff --git a/spec/lib/gitlab/alert_management/alert_params_spec.rb b/spec/lib/gitlab/alert_management/alert_params_spec.rb
deleted file mode 100644
index c3171be5e29..00000000000
--- a/spec/lib/gitlab/alert_management/alert_params_spec.rb
+++ /dev/null
@@ -1,101 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::AlertManagement::AlertParams do
- let_it_be(:project) { create(:project, :repository, :private) }
-
- describe '.from_generic_alert' do
- let(:started_at) { Time.current.change(usec: 0).rfc3339 }
- let(:default_payload) do
- {
- 'title' => 'Alert title',
- 'description' => 'Description',
- 'monitoring_tool' => 'Monitoring tool name',
- 'service' => 'Service',
- 'hosts' => ['gitlab.com'],
- 'start_time' => started_at,
- 'some' => { 'extra' => { 'payload' => 'here' } }
- }
- end
-
- let(:payload) { default_payload }
-
- subject { described_class.from_generic_alert(project: project, payload: payload) }
-
- it 'returns Alert compatible parameters' do
- is_expected.to eq(
- project_id: project.id,
- title: 'Alert title',
- description: 'Description',
- monitoring_tool: 'Monitoring tool name',
- service: 'Service',
- severity: 'critical',
- hosts: ['gitlab.com'],
- payload: payload,
- started_at: started_at,
- ended_at: nil,
- fingerprint: nil,
- environment: nil
- )
- end
-
- context 'when severity given' do
- let(:payload) { default_payload.merge(severity: 'low') }
-
- it 'returns Alert compatible parameters' do
- expect(subject[:severity]).to eq('low')
- end
- end
-
- context 'when there are no hosts in the payload' do
- let(:payload) { {} }
-
- it 'hosts param is an empty array' do
- expect(subject[:hosts]).to be_empty
- end
- end
- end
-
- describe '.from_prometheus_alert' do
- let(:payload) do
- {
- 'status' => 'firing',
- 'labels' => {
- 'alertname' => 'GitalyFileServerDown',
- 'channel' => 'gitaly',
- 'pager' => 'pagerduty',
- 'severity' => 's1'
- },
- 'annotations' => {
- 'description' => 'Alert description',
- 'runbook' => 'troubleshooting/gitaly-down.md',
- 'title' => 'Alert title'
- },
- 'startsAt' => '2020-04-27T10:10:22.265949279Z',
- 'endsAt' => '0001-01-01T00:00:00Z',
- 'generatorURL' => 'http://8d467bd4607a:9090/graph?g0.expr=vector%281%29&g0.tab=1',
- 'fingerprint' => 'b6ac4d42057c43c1'
- }
- end
-
- let(:parsed_alert) { Gitlab::Alerting::Alert.new(project: project, payload: payload) }
-
- subject { described_class.from_prometheus_alert(project: project, parsed_alert: parsed_alert) }
-
- it 'returns Alert-compatible params' do
- is_expected.to eq(
- project_id: project.id,
- title: 'Alert title',
- description: 'Alert description',
- monitoring_tool: 'Prometheus',
- payload: payload,
- started_at: parsed_alert.starts_at,
- ended_at: parsed_alert.ends_at,
- fingerprint: parsed_alert.gitlab_fingerprint,
- environment: parsed_alert.environment,
- prometheus_alert: parsed_alert.gitlab_alert
- )
- end
- end
-end
diff --git a/spec/lib/gitlab/alert_management/payload/generic_spec.rb b/spec/lib/gitlab/alert_management/payload/generic_spec.rb
index 538a822503e..b7660462b0d 100644
--- a/spec/lib/gitlab/alert_management/payload/generic_spec.rb
+++ b/spec/lib/gitlab/alert_management/payload/generic_spec.rb
@@ -46,7 +46,7 @@ RSpec.describe Gitlab::AlertManagement::Payload::Generic do
subject { parsed_payload.starts_at }
around do |example|
- Timecop.freeze(current_time) { example.run }
+ travel_to(current_time) { example.run }
end
context 'without start_time' do
@@ -86,4 +86,34 @@ RSpec.describe Gitlab::AlertManagement::Payload::Generic do
it_behaves_like 'parsable alert payload field', 'gitlab_environment_name'
end
+
+ describe '#description' do
+ subject { parsed_payload.description }
+
+ it_behaves_like 'parsable alert payload field', 'description'
+ end
+
+ describe '#ends_at' do
+ let(:current_time) { Time.current.change(usec: 0).utc }
+
+ subject { parsed_payload.ends_at }
+
+ around do |example|
+ travel_to(current_time) { example.run }
+ end
+
+ context 'without end_time' do
+ it { is_expected.to be_nil }
+ end
+
+ context "with end_time" do
+ let(:value) { 10.minutes.ago.change(usec: 0).utc }
+
+ before do
+ raw_payload['end_time'] = value.to_s
+ end
+
+ it { is_expected.to eq(value) }
+ end
+ end
end
diff --git a/spec/lib/gitlab/alerting/alert_spec.rb b/spec/lib/gitlab/alerting/alert_spec.rb
deleted file mode 100644
index b53b71e3f3e..00000000000
--- a/spec/lib/gitlab/alerting/alert_spec.rb
+++ /dev/null
@@ -1,299 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Alerting::Alert do
- let_it_be(:project) { create(:project) }
-
- let(:alert) { build(:alerting_alert, project: project, payload: payload) }
- let(:payload) { {} }
-
- shared_context 'gitlab alert' do
- let!(:gitlab_alert) { create(:prometheus_alert, project: project) }
- let(:gitlab_alert_id) { gitlab_alert.id }
-
- before do
- payload['labels'] = {
- 'gitlab_alert_id' => gitlab_alert.prometheus_metric_id.to_s,
- 'gitlab_prometheus_alert_id' => gitlab_alert_id
- }
- end
- end
-
- shared_context 'full query' do
- before do
- payload['generatorURL'] = 'http://localhost:9090/graph?g0.expr=vector%281%29'
- end
- end
-
- shared_examples 'invalid alert' do
- it 'is invalid' do
- expect(alert).not_to be_valid
- end
- end
-
- shared_examples 'parse payload' do |*pairs|
- context 'without payload' do
- it { is_expected.to be_nil }
- end
-
- pairs.each do |pair|
- context "with #{pair}" do
- let(:value) { 'some value' }
-
- before do
- section, name = pair.split('/')
- payload[section] = { name => value }
- end
-
- it { is_expected.to eq(value) }
- end
- end
- end
-
- describe '#gitlab_alert' do
- subject { alert.gitlab_alert }
-
- context 'without payload' do
- it { is_expected.to be_nil }
- end
-
- context 'with gitlab alert' do
- include_context 'gitlab alert'
-
- it { is_expected.to eq(gitlab_alert) }
- end
-
- context 'with unknown gitlab alert' do
- include_context 'gitlab alert' do
- let(:gitlab_alert_id) { 'unknown' }
- end
-
- it { is_expected.to be_nil }
- end
-
- context 'when two alerts with the same metric exist' do
- include_context 'gitlab alert'
-
- let!(:second_gitlab_alert) do
- create(:prometheus_alert,
- project: project,
- prometheus_metric_id: gitlab_alert.prometheus_metric_id
- )
- end
-
- context 'alert id given in params' do
- before do
- payload['labels'] = {
- 'gitlab_alert_id' => gitlab_alert.prometheus_metric_id.to_s,
- 'gitlab_prometheus_alert_id' => second_gitlab_alert.id
- }
- end
-
- it { is_expected.to eq(second_gitlab_alert) }
- end
-
- context 'metric id given in params' do
- # This tests the case when two alerts are found, as metric id
- # is not unique.
-
- # Note the metric id was incorrectly named as 'gitlab_alert_id'
- # in PrometheusAlert#to_param.
- before do
- payload['labels'] = { 'gitlab_alert_id' => gitlab_alert.prometheus_metric_id }
- end
-
- it { is_expected.to be_nil }
- end
- end
- end
-
- describe '#title' do
- subject { alert.title }
-
- it_behaves_like 'parse payload',
- 'annotations/title',
- 'annotations/summary',
- 'labels/alertname'
-
- context 'with gitlab alert' do
- include_context 'gitlab alert'
-
- context 'with annotations/title' do
- let(:value) { 'annotation title' }
-
- before do
- payload['annotations'] = { 'title' => value }
- end
-
- it { is_expected.to eq(gitlab_alert.title) }
- end
- end
- end
-
- describe '#description' do
- subject { alert.description }
-
- it_behaves_like 'parse payload', 'annotations/description'
- end
-
- describe '#annotations' do
- subject { alert.annotations }
-
- context 'without payload' do
- it { is_expected.to eq([]) }
- end
-
- context 'with payload' do
- before do
- payload['annotations'] = { 'foo' => 'value1', 'bar' => 'value2' }
- end
-
- it 'parses annotations' do
- expect(subject.size).to eq(2)
- expect(subject.map(&:label)).to eq(%w[foo bar])
- expect(subject.map(&:value)).to eq(%w[value1 value2])
- end
- end
- end
-
- describe '#environment' do
- subject { alert.environment }
-
- context 'without gitlab_alert' do
- it { is_expected.to be_nil }
- end
-
- context 'with gitlab alert' do
- include_context 'gitlab alert'
-
- it { is_expected.to eq(gitlab_alert.environment) }
- end
- end
-
- describe '#starts_at' do
- subject { alert.starts_at }
-
- context 'with empty startsAt' do
- before do
- payload['startsAt'] = nil
- end
-
- it { is_expected.to be_nil }
- end
-
- context 'with invalid startsAt' do
- before do
- payload['startsAt'] = 'invalid'
- end
-
- it { is_expected.to be_nil }
- end
-
- context 'with payload' do
- let(:time) { Time.current.change(usec: 0) }
-
- before do
- payload['startsAt'] = time.rfc3339
- end
-
- it { is_expected.to eq(time) }
- end
- end
-
- describe '#full_query' do
- using RSpec::Parameterized::TableSyntax
-
- subject { alert.full_query }
-
- where(:generator_url, :expected_query) do
- nil | nil
- 'http://localhost' | nil
- 'invalid url' | nil
- 'http://localhost:9090/graph?g1.expr=vector%281%29' | nil
- 'http://localhost:9090/graph?g0.expr=vector%281%29' | 'vector(1)'
- end
-
- with_them do
- before do
- payload['generatorURL'] = generator_url
- end
-
- it { is_expected.to eq(expected_query) }
- end
-
- context 'with gitlab alert' do
- include_context 'gitlab alert'
- include_context 'full query'
-
- it { is_expected.to eq(gitlab_alert.full_query) }
- end
- end
-
- describe '#y_label' do
- subject { alert.y_label }
-
- it_behaves_like 'parse payload', 'annotations/gitlab_y_label'
-
- context 'when y_label is not included in the payload' do
- it_behaves_like 'parse payload', 'annotations/title'
- end
- end
-
- describe '#alert_markdown' do
- subject { alert.alert_markdown }
-
- it_behaves_like 'parse payload', 'annotations/gitlab_incident_markdown'
- end
-
- describe '#gitlab_fingerprint' do
- subject { alert.gitlab_fingerprint }
-
- context 'when the alert is a GitLab managed alert' do
- include_context 'gitlab alert'
-
- it 'returns a fingerprint' do
- plain_fingerprint = [alert.metric_id, alert.starts_at_raw].join('/')
-
- is_expected.to eq(Digest::SHA1.hexdigest(plain_fingerprint))
- end
- end
-
- context 'when the alert is from self managed Prometheus' do
- include_context 'full query'
-
- it 'returns a fingerprint' do
- plain_fingerprint = [alert.starts_at_raw, alert.title, alert.full_query].join('/')
-
- is_expected.to eq(Digest::SHA1.hexdigest(plain_fingerprint))
- end
- end
- end
-
- describe '#valid?' do
- before do
- payload.update(
- 'annotations' => { 'title' => 'some title' },
- 'startsAt' => Time.current.rfc3339
- )
- end
-
- subject { alert }
-
- it { is_expected.to be_valid }
-
- context 'without project' do
- let(:project) { nil }
-
- it { is_expected.not_to be_valid }
- end
-
- context 'without starts_at' do
- before do
- payload['startsAt'] = nil
- end
-
- it { is_expected.not_to be_valid }
- end
- end
-end
diff --git a/spec/lib/gitlab/alerting/notification_payload_parser_spec.rb b/spec/lib/gitlab/alerting/notification_payload_parser_spec.rb
deleted file mode 100644
index ff5ab1116fa..00000000000
--- a/spec/lib/gitlab/alerting/notification_payload_parser_spec.rb
+++ /dev/null
@@ -1,204 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Alerting::NotificationPayloadParser do
- let_it_be(:project) { build(:project) }
-
- describe '.call' do
- let(:starts_at) { Time.current.change(usec: 0) }
- let(:ends_at) { Time.current.change(usec: 0) }
- let(:payload) do
- {
- 'title' => 'alert title',
- 'start_time' => starts_at.rfc3339,
- 'end_time' => ends_at.rfc3339,
- 'description' => 'Description',
- 'monitoring_tool' => 'Monitoring tool name',
- 'service' => 'Service',
- 'hosts' => ['gitlab.com'],
- 'severity' => 'low'
- }
- end
-
- subject { described_class.call(payload, project) }
-
- it 'returns Prometheus-like payload' do
- is_expected.to eq(
- {
- 'annotations' => {
- 'title' => 'alert title',
- 'description' => 'Description',
- 'monitoring_tool' => 'Monitoring tool name',
- 'service' => 'Service',
- 'hosts' => ['gitlab.com'],
- 'severity' => 'low'
- },
- 'startsAt' => starts_at.rfc3339,
- 'endsAt' => ends_at.rfc3339
- }
- )
- end
-
- context 'when title is blank' do
- before do
- payload[:title] = ''
- end
-
- it 'sets a predefined title' do
- expect(subject.dig('annotations', 'title')).to eq('New: Incident')
- end
- end
-
- context 'when hosts attribute is a string' do
- before do
- payload[:hosts] = 'gitlab.com'
- end
-
- it 'returns hosts as an array of one element' do
- expect(subject.dig('annotations', 'hosts')).to eq(['gitlab.com'])
- end
- end
-
- context 'when the time is in unsupported format' do
- before do
- payload[:start_time] = 'invalid/date/format'
- end
-
- it 'sets startsAt to a current time in RFC3339 format' do
- expect(subject['startsAt']).to eq(starts_at.rfc3339)
- end
- end
-
- context 'when payload is blank' do
- let(:payload) { {} }
-
- it 'returns default parameters' do
- is_expected.to match(
- 'annotations' => {
- 'title' => described_class::DEFAULT_TITLE,
- 'severity' => described_class::DEFAULT_SEVERITY
- },
- 'startsAt' => starts_at.rfc3339
- )
- end
-
- context 'when severity is blank' do
- before do
- payload[:severity] = ''
- end
-
- it 'sets severity to the default ' do
- expect(subject.dig('annotations', 'severity')).to eq(described_class::DEFAULT_SEVERITY)
- end
- end
- end
-
- context 'with fingerprint' do
- before do
- payload[:fingerprint] = data
- end
-
- shared_examples 'fingerprint generation' do
- it 'generates the fingerprint correctly' do
- expect(result).to eq(Gitlab::AlertManagement::Fingerprint.generate(data))
- end
- end
-
- context 'with blank fingerprint' do
- it_behaves_like 'fingerprint generation' do
- let(:data) { ' ' }
- let(:result) { subject.dig('annotations', 'fingerprint') }
- end
- end
-
- context 'with fingerprint given' do
- it_behaves_like 'fingerprint generation' do
- let(:data) { 'fingerprint' }
- let(:result) { subject.dig('annotations', 'fingerprint') }
- end
- end
-
- context 'with array fingerprint given' do
- it_behaves_like 'fingerprint generation' do
- let(:data) { [1, 'fingerprint', 'given'] }
- let(:result) { subject.dig('annotations', 'fingerprint') }
- end
- end
- end
-
- context 'with environment' do
- let(:environment) { create(:environment, project: project) }
-
- before do
- payload[:gitlab_environment_name] = environment.name
- end
-
- it 'sets the environment ' do
- expect(subject.dig('annotations', 'environment')).to eq(environment)
- end
- end
-
- context 'when payload attributes have blank lines' do
- let(:payload) do
- {
- 'title' => '',
- 'start_time' => '',
- 'end_time' => '',
- 'description' => '',
- 'monitoring_tool' => '',
- 'service' => '',
- 'hosts' => ['']
- }
- end
-
- it 'returns default parameters' do
- is_expected.to eq(
- 'annotations' => {
- 'title' => 'New: Incident',
- 'severity' => described_class::DEFAULT_SEVERITY
- },
- 'startsAt' => starts_at.rfc3339
- )
- end
- end
-
- context 'when payload has secondary params' do
- let(:payload) do
- {
- 'description' => 'Description',
- 'additional' => {
- 'params' => {
- '1' => 'Some value 1',
- '2' => 'Some value 2',
- 'blank' => ''
- }
- }
- }
- end
-
- it 'adds secondary params to annotations' do
- is_expected.to eq(
- 'annotations' => {
- 'title' => 'New: Incident',
- 'severity' => described_class::DEFAULT_SEVERITY,
- 'description' => 'Description',
- 'additional.params.1' => 'Some value 1',
- 'additional.params.2' => 'Some value 2'
- },
- 'startsAt' => starts_at.rfc3339
- )
- end
- end
-
- context 'when secondary params hash is too big' do
- before do
- allow(Gitlab::Utils::SafeInlineHash).to receive(:merge_keys!).and_raise(ArgumentError)
- end
-
- it 'catches and re-raises an error' do
- expect { subject }.to raise_error Gitlab::Alerting::NotificationPayloadParser::BadPayloadError, 'The payload is too big'
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/analytics/unique_visits_spec.rb b/spec/lib/gitlab/analytics/unique_visits_spec.rb
index 1432c9ac58f..6ac58e13f4c 100644
--- a/spec/lib/gitlab/analytics/unique_visits_spec.rb
+++ b/spec/lib/gitlab/analytics/unique_visits_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe Gitlab::Analytics::UniqueVisits, :clean_gitlab_redis_shared_state
# Without freezing the time, the test may behave inconsistently
# depending on which day of the week test is run.
reference_time = Time.utc(2020, 6, 1)
- Timecop.freeze(reference_time) { example.run }
+ travel_to(reference_time) { example.run }
end
describe '#track_visit' do
diff --git a/spec/lib/gitlab/auth/auth_finders_spec.rb b/spec/lib/gitlab/auth/auth_finders_spec.rb
index 1ac8ebe1369..2ebde145bfd 100644
--- a/spec/lib/gitlab/auth/auth_finders_spec.rb
+++ b/spec/lib/gitlab/auth/auth_finders_spec.rb
@@ -419,10 +419,30 @@ RSpec.describe Gitlab::Auth::AuthFinders do
expect(find_user_from_web_access_token(:ics)).to eq(user)
end
- it 'returns the user for API requests' do
- set_header('SCRIPT_NAME', '/api/endpoint')
+ context 'for API requests' do
+ it 'returns the user' do
+ set_header('SCRIPT_NAME', '/api/endpoint')
+
+ expect(find_user_from_web_access_token(:api)).to eq(user)
+ end
+
+ it 'returns nil if URL does not start with /api/' do
+ set_header('SCRIPT_NAME', '/relative_root/api/endpoint')
+
+ expect(find_user_from_web_access_token(:api)).to be_nil
+ end
- expect(find_user_from_web_access_token(:api)).to eq(user)
+ context 'when relative_url_root is set' do
+ before do
+ stub_config_setting(relative_url_root: '/relative_root')
+ end
+
+ it 'returns the user' do
+ set_header('SCRIPT_NAME', '/relative_root/api/endpoint')
+
+ expect(find_user_from_web_access_token(:api)).to eq(user)
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/auth/current_user_mode_spec.rb b/spec/lib/gitlab/auth/current_user_mode_spec.rb
index 60b403780c0..ffd7813190a 100644
--- a/spec/lib/gitlab/auth/current_user_mode_spec.rb
+++ b/spec/lib/gitlab/auth/current_user_mode_spec.rb
@@ -121,7 +121,7 @@ RSpec.describe Gitlab::Auth::CurrentUserMode, :do_not_mock_admin_mode, :request_
subject.enable_admin_mode!(password: user.password)
expect(subject.admin_mode?).to be(true), 'admin mode is not active in the present'
- Timecop.freeze(Gitlab::Auth::CurrentUserMode::MAX_ADMIN_MODE_TIME.from_now) do
+ travel_to(Gitlab::Auth::CurrentUserMode::MAX_ADMIN_MODE_TIME.from_now) do
# in the future this will be a new request, simulate by clearing the RequestStore
Gitlab::SafeRequestStore.clear!
diff --git a/spec/lib/gitlab/background_migration/merge_request_assignees_migration_progress_check_spec.rb b/spec/lib/gitlab/background_migration/merge_request_assignees_migration_progress_check_spec.rb
index a3840e3a22e..85a9c88ebff 100644
--- a/spec/lib/gitlab/background_migration/merge_request_assignees_migration_progress_check_spec.rb
+++ b/spec/lib/gitlab/background_migration/merge_request_assignees_migration_progress_check_spec.rb
@@ -73,7 +73,7 @@ RSpec.describe Gitlab::BackgroundMigration::MergeRequestAssigneesMigrationProgre
described_class.new.perform
- expect(Feature.enabled?(:multiple_merge_request_assignees)).to eq(true)
+ expect(Feature.enabled?(:multiple_merge_request_assignees, type: :licensed)).to eq(true)
end
end
diff --git a/spec/lib/gitlab/background_migration/migrate_users_bio_to_user_details_spec.rb b/spec/lib/gitlab/background_migration/migrate_users_bio_to_user_details_spec.rb
index db3cbe7ccdc..3cec5cb4c35 100644
--- a/spec/lib/gitlab/background_migration/migrate_users_bio_to_user_details_spec.rb
+++ b/spec/lib/gitlab/background_migration/migrate_users_bio_to_user_details_spec.rb
@@ -82,21 +82,4 @@ RSpec.describe Gitlab::BackgroundMigration::MigrateUsersBioToUserDetails, :migra
expect(user_detail).to be_nil
end
-
- context 'when `migrate_bio_to_user_details` feature flag is off' do
- before do
- stub_feature_flags(migrate_bio_to_user_details: false)
- end
-
- it 'does nothing' do
- already_existing_user_details = user_details.where(user_id: [
- user_has_different_details.id,
- user_already_has_details.id
- ])
-
- subject
-
- expect(user_details.all).to match_array(already_existing_user_details)
- end
- end
end
diff --git a/spec/lib/gitlab/background_migration/user_mentions/create_resource_user_mention_spec.rb b/spec/lib/gitlab/background_migration/user_mentions/create_resource_user_mention_spec.rb
index 392b44d1a1f..2dae4a65eeb 100644
--- a/spec/lib/gitlab/background_migration/user_mentions/create_resource_user_mention_spec.rb
+++ b/spec/lib/gitlab/background_migration/user_mentions/create_resource_user_mention_spec.rb
@@ -74,14 +74,14 @@ RSpec.describe Gitlab::BackgroundMigration::UserMentions::CreateResourceUserMent
let(:user_mentions) { merge_request_user_mentions }
let(:resource) { merge_request }
- it_behaves_like 'resource mentions migration', MigrateMergeRequestMentionsToDb, MergeRequest
+ it_behaves_like 'resource mentions migration', MigrateMergeRequestMentionsToDb, 'MergeRequest'
context 'when FF disabled' do
before do
stub_feature_flags(migrate_user_mentions: false)
end
- it_behaves_like 'resource migration not run', MigrateMergeRequestMentionsToDb, MergeRequest
+ it_behaves_like 'resource migration not run', MigrateMergeRequestMentionsToDb, 'MergeRequest'
end
end
@@ -103,14 +103,14 @@ RSpec.describe Gitlab::BackgroundMigration::UserMentions::CreateResourceUserMent
let(:user_mentions) { commit_user_mentions }
let(:resource) { commit }
- it_behaves_like 'resource notes mentions migration', MigrateCommitNotesMentionsToDb, Commit
+ it_behaves_like 'resource notes mentions migration', MigrateCommitNotesMentionsToDb, 'Commit'
context 'when FF disabled' do
before do
stub_feature_flags(migrate_user_mentions: false)
end
- it_behaves_like 'resource notes migration not run', MigrateCommitNotesMentionsToDb, Commit
+ it_behaves_like 'resource notes migration not run', MigrateCommitNotesMentionsToDb, 'Commit'
end
end
end
diff --git a/spec/lib/gitlab/bitbucket_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_import/importer_spec.rb
index d4483bf1754..b723c31c4aa 100644
--- a/spec/lib/gitlab/bitbucket_import/importer_spec.rb
+++ b/spec/lib/gitlab/bitbucket_import/importer_spec.rb
@@ -312,7 +312,7 @@ RSpec.describe Gitlab::BitbucketImport::Importer do
# attributes later.
existing_label.reload
- Timecop.freeze(Time.now + 1.minute) do
+ travel_to(Time.now + 1.minute) do
importer.execute
label_after_import = project.labels.find(existing_label.id)
diff --git a/spec/lib/gitlab/checks/matching_merge_request_spec.rb b/spec/lib/gitlab/checks/matching_merge_request_spec.rb
new file mode 100644
index 00000000000..ca7ee784ee3
--- /dev/null
+++ b/spec/lib/gitlab/checks/matching_merge_request_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Checks::MatchingMergeRequest do
+ describe '#match?' do
+ let_it_be(:newrev) { '012345678' }
+ let_it_be(:target_branch) { 'feature' }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:locked_merge_request) do
+ create(:merge_request,
+ :locked,
+ source_project: project,
+ target_project: project,
+ target_branch: target_branch,
+ in_progress_merge_commit_sha: newrev)
+ end
+
+ subject { described_class.new(newrev, target_branch, project) }
+
+ it 'matches a merge request' do
+ expect(subject.match?).to be true
+ end
+
+ it 'does not match any merge request' do
+ matcher = described_class.new(newrev, 'test', project)
+
+ expect(matcher.match?).to be false
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/ansi2json/line_spec.rb b/spec/lib/gitlab/ci/ansi2json/line_spec.rb
index 8b1cd812a70..d681447a0e8 100644
--- a/spec/lib/gitlab/ci/ansi2json/line_spec.rb
+++ b/spec/lib/gitlab/ci/ansi2json/line_spec.rb
@@ -58,6 +58,15 @@ RSpec.describe Gitlab::Ci::Ansi2json::Line do
end
end
+ describe '#set_section_options' do
+ it 'sets the current section\'s options' do
+ options = { collapsed: true }
+ subject.set_section_options(options)
+
+ expect(subject.to_h[:section_options]).to eq(options)
+ end
+ end
+
describe '#set_as_section_header' do
it 'change the section_header to true' do
expect { subject.set_as_section_header }
diff --git a/spec/lib/gitlab/ci/ansi2json_spec.rb b/spec/lib/gitlab/ci/ansi2json_spec.rb
index cb6949fddc2..c9c0d1a744e 100644
--- a/spec/lib/gitlab/ci/ansi2json_spec.rb
+++ b/spec/lib/gitlab/ci/ansi2json_spec.rb
@@ -229,7 +229,7 @@ RSpec.describe Gitlab::Ci::Ansi2json do
expect(convert_json(trace)).to eq([
{
offset: 0,
- content: [{ text: "section_end:1:2<div>hello</div>" }],
+ content: [{ text: 'section_end:1:2<div>hello</div>' }],
section: 'prepare-script',
section_header: true
},
@@ -329,6 +329,32 @@ RSpec.describe Gitlab::Ci::Ansi2json do
])
end
end
+
+ context 'with section options' do
+ let(:option_section_start) { "section_start:#{section_start_time.to_i}:#{section_name}[collapsed=true,unused_option=123]\r\033[0K"}
+
+ it 'provides section options when set' do
+ trace = "#{option_section_start}hello#{section_end}"
+ expect(convert_json(trace)).to eq([
+ {
+ offset: 0,
+ content: [{ text: 'hello' }],
+ section: 'prepare-script',
+ section_header: true,
+ section_options: {
+ 'collapsed' => 'true',
+ 'unused_option' => '123'
+ }
+ },
+ {
+ offset: 83,
+ content: [],
+ section: 'prepare-script',
+ section_duration: '01:03'
+ }
+ ])
+ end
+ end
end
describe 'incremental updates' do
@@ -339,7 +365,7 @@ RSpec.describe Gitlab::Ci::Ansi2json do
context 'with split word' do
let(:pre_text) { "\e[1mHello " }
- let(:text) { "World" }
+ let(:text) { 'World' }
let(:lines) do
[
@@ -355,7 +381,7 @@ RSpec.describe Gitlab::Ci::Ansi2json do
context 'with split word on second line' do
let(:pre_text) { "Good\nmorning " }
- let(:text) { "World" }
+ let(:text) { 'World' }
let(:lines) do
[
@@ -514,7 +540,7 @@ RSpec.describe Gitlab::Ci::Ansi2json do
end
describe 'trucates' do
- let(:text) { "Hello World" }
+ let(:text) { 'Hello World' }
let(:stream) { StringIO.new(text) }
let(:subject) { described_class.convert(stream) }
@@ -522,11 +548,11 @@ RSpec.describe Gitlab::Ci::Ansi2json do
stream.seek(3, IO::SEEK_SET)
end
- it "returns truncated output" do
+ it 'returns truncated output' do
expect(subject.truncated).to be_truthy
end
- it "does not append output" do
+ it 'does not append output' do
expect(subject.append).to be_falsey
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/product/matrix_spec.rb b/spec/lib/gitlab/ci/config/entry/product/matrix_spec.rb
index 39697884e3b..3388ae0af2f 100644
--- a/spec/lib/gitlab/ci/config/entry/product/matrix_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/product/matrix_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
+require 'spec_helper'
require_dependency 'active_model'
RSpec.describe ::Gitlab::Ci::Config::Entry::Product::Matrix do
@@ -46,33 +46,140 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Product::Matrix do
end
end
- context 'when entry config has only one variable' do
- let(:config) do
- [
- {
- 'VAR_1' => %w[test]
- }
- ]
+ context 'with one_dimensional_matrix feature flag enabled' do
+ before do
+ stub_feature_flags(one_dimensional_matrix: true)
+ matrix.compose!
end
- describe '#valid?' do
- it { is_expected.not_to be_valid }
- end
+ context 'when entry config has only one variable with multiple values' do
+ let(:config) do
+ [
+ {
+ 'VAR_1' => %w[build test]
+ }
+ ]
+ end
- describe '#errors' do
- it 'returns error about too many jobs' do
- expect(matrix.errors)
- .to include('variables config requires at least 2 items')
+ describe '#valid?' do
+ it { is_expected.to be_valid }
+ end
+
+ describe '#errors' do
+ it 'returns no errors' do
+ expect(matrix.errors)
+ .to be_empty
+ end
+ end
+
+ describe '#value' do
+ before do
+ matrix.compose!
+ end
+
+ it 'returns the value without raising an error' do
+ expect(matrix.value).to eq([{ 'VAR_1' => %w[build test] }])
+ end
end
+
+ context 'when entry config has only one variable with one value' do
+ let(:config) do
+ [
+ {
+ 'VAR_1' => %w[test]
+ }
+ ]
+ end
+
+ describe '#valid?' do
+ it { is_expected.to be_valid }
+ end
+
+ describe '#errors' do
+ it 'returns no errors' do
+ expect(matrix.errors)
+ .to be_empty
+ end
+ end
+
+ describe '#value' do
+ before do
+ matrix.compose!
+ end
+
+ it 'returns the value without raising an error' do
+ expect(matrix.value).to eq([{ 'VAR_1' => %w[test] }])
+ end
+ end
+ end
+ end
+ end
+
+ context 'with one_dimensional_matrix feature flag disabled' do
+ before do
+ stub_feature_flags(one_dimensional_matrix: false)
+ matrix.compose!
end
- describe '#value' do
- before do
- matrix.compose!
+ context 'when entry config has only one variable with multiple values' do
+ let(:config) do
+ [
+ {
+ 'VAR_1' => %w[build test]
+ }
+ ]
end
- it 'returns the value without raising an error' do
- expect(matrix.value).to eq([{ 'VAR_1' => ['test'] }])
+ describe '#valid?' do
+ it { is_expected.not_to be_valid }
+ end
+
+ describe '#errors' do
+ it 'returns error about too many jobs' do
+ expect(matrix.errors)
+ .to include('variables config requires at least 2 items')
+ end
+ end
+
+ describe '#value' do
+ before do
+ matrix.compose!
+ end
+
+ it 'returns the value without raising an error' do
+ expect(matrix.value).to eq([{ 'VAR_1' => %w[build test] }])
+ end
+ end
+
+ context 'when entry config has only one variable with one value' do
+ let(:config) do
+ [
+ {
+ 'VAR_1' => %w[test]
+ }
+ ]
+ end
+
+ describe '#valid?' do
+ it { is_expected.not_to be_valid }
+ end
+
+ describe '#errors' do
+ it 'returns no errors' do
+ expect(matrix.errors)
+ .to include('variables config requires at least 2 items')
+ end
+ end
+
+ describe '#value' do
+ before do
+ matrix.compose!
+ end
+
+ it 'returns the value without raising an error' do
+ expect(matrix.value).to eq([{ 'VAR_1' => %w[test] }])
+ end
+ end
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/product/variables_spec.rb b/spec/lib/gitlab/ci/config/entry/product/variables_spec.rb
index 230b001d620..407efb438b5 100644
--- a/spec/lib/gitlab/ci/config/entry/product/variables_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/product/variables_spec.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
+# After Feature one_dimensional_matrix is removed, this can be changed back to fast_spec_helper
+require 'spec_helper'
require_dependency 'active_model'
RSpec.describe Gitlab::Ci::Config::Entry::Product::Variables do
@@ -45,43 +46,71 @@ RSpec.describe Gitlab::Ci::Config::Entry::Product::Variables do
end
end
- context 'when entry value is not correct' do
- shared_examples 'invalid variables' do |message|
- describe '#errors' do
- it 'saves errors' do
- expect(entry.errors).to include(message)
- end
+ context 'with one_dimensional_matrix feature flag enabled' do
+ context 'with only one variable' do
+ before do
+ stub_feature_flags(one_dimensional_matrix: true)
end
+ let(:config) { { VAR: 'test' } }
describe '#valid?' do
- it 'is not valid' do
- expect(entry).not_to be_valid
+ it 'is valid' do
+ expect(entry).to be_valid
+ end
+ end
+
+ describe '#errors' do
+ it 'does not append errors' do
+ expect(entry.errors).to be_empty
end
end
end
+ end
- context 'with array' do
- let(:config) { [:VAR, 'test'] }
+ context 'with one_dimensional_matrix feature flag disabled' do
+ context 'when entry value is not correct' do
+ before do
+ stub_feature_flags(one_dimensional_matrix: false)
+ end
+ shared_examples 'invalid variables' do |message|
+ describe '#errors' do
+ it 'saves errors' do
+ expect(entry.errors).to include(message)
+ end
+ end
- it_behaves_like 'invalid variables', /should be a hash of key value pairs/
- end
+ describe '#valid?' do
+ it 'is not valid' do
+ expect(entry).not_to be_valid
+ end
+ end
+ end
- context 'with empty array' do
- let(:config) { { VAR: 'test', VAR2: [] } }
+ context 'with array' do
+ let(:config) { [:VAR, 'test'] }
- it_behaves_like 'invalid variables', /should be a hash of key value pairs/
- end
+ it_behaves_like 'invalid variables', /should be a hash of key value pairs/
+ end
- context 'with nested array' do
- let(:config) { { VAR: 'test', VAR2: [1, [2]] } }
+ context 'with empty array' do
+ let(:config) { { VAR: 'test', VAR2: [] } }
- it_behaves_like 'invalid variables', /should be a hash of key value pairs/
- end
+ it_behaves_like 'invalid variables', /should be a hash of key value pairs/
+ end
- context 'with only one variable' do
- let(:config) { { VAR: 'test' } }
+ context 'with nested array' do
+ let(:config) { { VAR: 'test', VAR2: [1, [2]] } }
+
+ it_behaves_like 'invalid variables', /should be a hash of key value pairs/
+ end
- it_behaves_like 'invalid variables', /variables config requires at least 2 items/
+ context 'with one_dimensional_matrix feature flag disabled' do
+ context 'with only one variable' do
+ let(:config) { { VAR: 'test' } }
+
+ it_behaves_like 'invalid variables', /variables config requires at least 2 items/
+ end
+ end
end
end
end
diff --git a/spec/lib/gitlab/ci/cron_parser_spec.rb b/spec/lib/gitlab/ci/cron_parser_spec.rb
index f724825a9cc..dd27b4045c9 100644
--- a/spec/lib/gitlab/ci/cron_parser_spec.rb
+++ b/spec/lib/gitlab/ci/cron_parser_spec.rb
@@ -82,7 +82,7 @@ RSpec.describe Gitlab::Ci::CronParser do
context 'when PST (Pacific Standard Time)' do
it 'converts time in server time zone' do
- Timecop.freeze(Time.utc(2017, 1, 1)) do
+ travel_to(Time.utc(2017, 1, 1)) do
expect(subject.hour).to eq(hour_in_utc)
end
end
@@ -90,7 +90,7 @@ RSpec.describe Gitlab::Ci::CronParser do
context 'when PDT (Pacific Daylight Time)' do
it 'converts time in server time zone' do
- Timecop.freeze(Time.utc(2017, 6, 1)) do
+ travel_to(Time.utc(2017, 6, 1)) do
expect(subject.hour).to eq(hour_in_utc)
end
end
@@ -117,7 +117,7 @@ RSpec.describe Gitlab::Ci::CronParser do
context 'when CET (Central European Time)' do
it 'converts time in server time zone' do
- Timecop.freeze(Time.utc(2017, 1, 1)) do
+ travel_to(Time.utc(2017, 1, 1)) do
expect(subject.hour).to eq(hour_in_utc)
end
end
@@ -125,7 +125,7 @@ RSpec.describe Gitlab::Ci::CronParser do
context 'when CEST (Central European Summer Time)' do
it 'converts time in server time zone' do
- Timecop.freeze(Time.utc(2017, 6, 1)) do
+ travel_to(Time.utc(2017, 6, 1)) do
expect(subject.hour).to eq(hour_in_utc)
end
end
@@ -152,7 +152,7 @@ RSpec.describe Gitlab::Ci::CronParser do
context 'when EST (Eastern Standard Time)' do
it 'converts time in server time zone' do
- Timecop.freeze(Time.utc(2017, 1, 1)) do
+ travel_to(Time.utc(2017, 1, 1)) do
expect(subject.hour).to eq(hour_in_utc)
end
end
@@ -160,7 +160,7 @@ RSpec.describe Gitlab::Ci::CronParser do
context 'when EDT (Eastern Daylight Time)' do
it 'converts time in server time zone' do
- Timecop.freeze(Time.utc(2017, 6, 1)) do
+ travel_to(Time.utc(2017, 6, 1)) do
expect(subject.hour).to eq(hour_in_utc)
end
end
@@ -174,7 +174,7 @@ RSpec.describe Gitlab::Ci::CronParser do
# (e.g. America/Chicago) at the start of the test. Stubbing
# TZ doesn't appear to be enough.
it 'generates day without TZInfo::AmbiguousTime error' do
- Timecop.freeze(Time.utc(2020, 1, 1)) do
+ travel_to(Time.utc(2020, 1, 1)) do
expect(subject.year).to eq(year)
expect(subject.month).to eq(12)
expect(subject.day).to eq(1)
diff --git a/spec/lib/gitlab/ci/parsers/test/junit_spec.rb b/spec/lib/gitlab/ci/parsers/test/junit_spec.rb
index 1f497dea2bf..5a3a9b53da6 100644
--- a/spec/lib/gitlab/ci/parsers/test/junit_spec.rb
+++ b/spec/lib/gitlab/ci/parsers/test/junit_spec.rb
@@ -4,7 +4,7 @@ require 'fast_spec_helper'
RSpec.describe Gitlab::Ci::Parsers::Test::Junit do
describe '#parse!' do
- subject { described_class.new.parse!(junit, test_suite, args) }
+ subject { described_class.new.parse!(junit, test_suite, **args) }
let(:test_suite) { Gitlab::Ci::Reports::TestSuite.new('rspec') }
let(:test_cases) { flattened_test_cases(test_suite) }
diff --git a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
index 34df0e86a18..0b961336f3f 100644
--- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do
- let(:project) { create(:project, :repository) }
- let(:head_sha) { project.repository.head_commit.id }
- let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: head_sha) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:head_sha) { project.repository.head_commit.id }
+ let(:pipeline) { build(:ci_empty_pipeline, project: project, sha: head_sha) }
let(:attributes) { { name: 'rspec', ref: 'master', scheduling_type: :stage } }
let(:previous_stages) { [] }
@@ -503,7 +503,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do
using RSpec::Parameterized
let(:pipeline) do
- build(:ci_empty_pipeline, ref: 'deploy', tag: false, source: source)
+ build(:ci_empty_pipeline, ref: 'deploy', tag: false, source: source, project: project)
end
context 'matches' do
@@ -766,7 +766,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do
context 'with a matching changes: rule' do
let(:pipeline) do
- create(:ci_pipeline, project: project).tap do |pipeline|
+ build(:ci_pipeline, project: project).tap do |pipeline|
stub_pipeline_modified_paths(pipeline, %w[app/models/ci/pipeline.rb spec/models/ci/pipeline_spec.rb .gitlab-ci.yml])
end
end
diff --git a/spec/lib/gitlab/ci/runner/backoff_spec.rb b/spec/lib/gitlab/ci/runner/backoff_spec.rb
new file mode 100644
index 00000000000..f147d69f7cd
--- /dev/null
+++ b/spec/lib/gitlab/ci/runner/backoff_spec.rb
@@ -0,0 +1,126 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'rspec-parameterized'
+require 'active_support/testing/time_helpers'
+
+RSpec.describe Gitlab::Ci::Runner::Backoff do
+ include ActiveSupport::Testing::TimeHelpers
+
+ describe '#duration' do
+ it 'returns backoff duration from start' do
+ freeze_time do
+ described_class.new(5.minutes.ago).then do |backoff|
+ expect(backoff.duration).to eq 5.minutes
+ end
+ end
+ end
+
+ it 'returns an integer value' do
+ freeze_time do
+ described_class.new(5.seconds.ago).then do |backoff|
+ expect(backoff.duration).to be 5
+ end
+ end
+ end
+
+ it 'returns the smallest number greater than or equal to duration' do
+ freeze_time do
+ described_class.new(0.5.seconds.ago).then do |backoff|
+ expect(backoff.duration).to be 1
+ end
+ end
+ end
+ end
+
+ describe '#slot' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:started, :slot) do
+ 0 | 0
+ 0.1 | 0
+ 0.9 | 0
+ 1 | 0
+ 1.1 | 0
+ 1.9 | 0
+ 2 | 0
+ 2.9 | 0
+ 3 | 0
+ 4 | 1
+ 5 | 1
+ 6 | 1
+ 7 | 1
+ 8 | 2
+ 9 | 2
+ 9.9 | 2
+ 10 | 2
+ 15 | 2
+ 16 | 3
+ 31 | 3
+ 32 | 4
+ 63 | 4
+ 64 | 5
+ 127 | 5
+ 128 | 6
+ 250 | 6
+ 310 | 7
+ 520 | 8
+ 999 | 8
+ end
+
+ with_them do
+ it 'falls into an appropaite backoff slot' do
+ freeze_time do
+ backoff = described_class.new(started.seconds.ago)
+ expect(backoff.slot).to eq slot
+ end
+ end
+ end
+ end
+
+ describe '#to_seconds' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:started, :backoff) do
+ 0 | 1
+ 0.1 | 1
+ 0.9 | 1
+ 1 | 1
+ 1.1 | 1
+ 1.9 | 1
+ 2 | 1
+ 3 | 1
+ 4 | 2
+ 5 | 2
+ 6 | 2
+ 6.5 | 2
+ 7 | 2
+ 8 | 4
+ 9 | 4
+ 9.9 | 4
+ 10 | 4
+ 15 | 4
+ 16 | 8
+ 31 | 8
+ 32 | 16
+ 63 | 16
+ 64 | 32
+ 127 | 32
+ 128 | 64
+ 250 | 64
+ 310 | 64
+ 520 | 64
+ 999 | 64
+ end
+
+ with_them do
+ it 'calculates backoff based on an appropriate slot' do
+ freeze_time do
+ described_class.new(started.seconds.ago).then do |delay|
+ expect(delay.to_seconds).to eq backoff
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/trace/checksum_spec.rb b/spec/lib/gitlab/ci/trace/checksum_spec.rb
new file mode 100644
index 00000000000..794794c3f69
--- /dev/null
+++ b/spec/lib/gitlab/ci/trace/checksum_spec.rb
@@ -0,0 +1,121 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Trace::Checksum do
+ let(:build) { create(:ci_build, :running) }
+
+ subject { described_class.new(build) }
+
+ context 'when build pending state exists' do
+ before do
+ create(:ci_build_pending_state, build: build, trace_checksum: 'crc32:d4777540')
+ end
+
+ context 'when matching persisted trace chunks exist' do
+ before do
+ create_chunk(index: 0, data: 'a' * 128.kilobytes)
+ create_chunk(index: 1, data: 'b' * 128.kilobytes)
+ create_chunk(index: 2, data: 'ccccccccccccccccc')
+ end
+
+ it 'calculates combined trace chunks CRC32 correctly' do
+ expect(subject.chunks_crc32).to eq 3564598592
+ expect(subject).to be_valid
+ end
+ end
+
+ context 'when trace chunks were persisted in a wrong order' do
+ before do
+ create_chunk(index: 0, data: 'b' * 128.kilobytes)
+ create_chunk(index: 1, data: 'a' * 128.kilobytes)
+ create_chunk(index: 2, data: 'ccccccccccccccccc')
+ end
+
+ it 'makes trace checksum invalid' do
+ expect(subject).not_to be_valid
+ end
+ end
+
+ context 'when one of the trace chunks is missing' do
+ before do
+ create_chunk(index: 0, data: 'a' * 128.kilobytes)
+ create_chunk(index: 2, data: 'ccccccccccccccccc')
+ end
+
+ it 'makes trace checksum invalid' do
+ expect(subject).not_to be_valid
+ end
+ end
+
+ context 'when checksums of persisted trace chunks do not match' do
+ before do
+ create_chunk(index: 0, data: 'a' * 128.kilobytes)
+ create_chunk(index: 1, data: 'X' * 128.kilobytes)
+ create_chunk(index: 2, data: 'ccccccccccccccccc')
+ end
+
+ it 'makes trace checksum invalid' do
+ expect(subject).not_to be_valid
+ end
+ end
+
+ context 'when persisted trace chunks are missing' do
+ it 'makes trace checksum invalid' do
+ expect(subject.state_crc32).to eq 3564598592
+ expect(subject).not_to be_valid
+ end
+ end
+ end
+
+ context 'when build pending state is missing' do
+ describe '#state_crc32' do
+ it 'returns nil' do
+ expect(subject.state_crc32).to be_nil
+ end
+ end
+
+ describe '#valid?' do
+ it { is_expected.not_to be_valid }
+ end
+ end
+
+ describe '#trace_chunks' do
+ before do
+ create_chunk(index: 0, data: 'abcdefg')
+ end
+
+ it 'does not load raw_data from a database store' do
+ subject.trace_chunks.first.then do |chunk|
+ expect(chunk).to be_database
+ expect { chunk.raw_data }
+ .to raise_error ActiveModel::MissingAttributeError
+ end
+ end
+ end
+
+ describe '#last_chunk' do
+ context 'when there are no chunks' do
+ it 'returns nil' do
+ expect(subject.last_chunk).to be_nil
+ end
+ end
+
+ context 'when there are multiple chunks' do
+ before do
+ create_chunk(index: 1, data: '1234')
+ create_chunk(index: 0, data: 'abcd')
+ end
+
+ it 'returns chunk with the highest index' do
+ expect(subject.last_chunk.chunk_index).to eq 1
+ end
+ end
+ end
+
+ def create_chunk(index:, data:)
+ create(:ci_build_trace_chunk, :persisted, build: build,
+ chunk_index: index,
+ initial_data: data)
+ end
+end
diff --git a/spec/lib/gitlab/ci/trace/metrics_spec.rb b/spec/lib/gitlab/ci/trace/metrics_spec.rb
new file mode 100644
index 00000000000..6518d0ab075
--- /dev/null
+++ b/spec/lib/gitlab/ci/trace/metrics_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Trace::Metrics, :prometheus do
+ describe '#increment_trace_bytes' do
+ context 'when incrementing by more than one' do
+ it 'increments a single counter' do
+ subject.increment_trace_bytes(10)
+ subject.increment_trace_bytes(20)
+ subject.increment_trace_bytes(30)
+
+ expect(described_class.trace_bytes.get).to eq 60
+ expect(described_class.trace_bytes.values.count).to eq 1
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb
index 171877dbaee..f037e803fb4 100644
--- a/spec/lib/gitlab/ci/trace_spec.rb
+++ b/spec/lib/gitlab/ci/trace_spec.rb
@@ -111,4 +111,13 @@ RSpec.describe Gitlab::Ci::Trace, :clean_gitlab_redis_shared_state do
end
end
end
+
+ describe '#lock' do
+ it 'acquires an exclusive lease on the trace' do
+ trace.lock do
+ expect { trace.lock }
+ .to raise_error described_class::LockedError
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/yaml_processor/result_spec.rb b/spec/lib/gitlab/ci/yaml_processor/result_spec.rb
new file mode 100644
index 00000000000..7e3cd7ec254
--- /dev/null
+++ b/spec/lib/gitlab/ci/yaml_processor/result_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+module Gitlab
+ module Ci
+ class YamlProcessor
+ RSpec.describe Result do
+ include StubRequests
+
+ let(:user) { create(:user) }
+ let(:ci_config) { Gitlab::Ci::Config.new(config_content, user: user) }
+ let(:result) { described_class.new(ci_config: ci_config, warnings: ci_config&.warnings) }
+
+ describe '#merged_yaml' do
+ subject(:merged_yaml) { result.merged_yaml }
+
+ let(:config_content) do
+ YAML.dump(
+ include: { remote: 'https://example.com/sample.yml' },
+ test: { stage: 'test', script: 'echo' }
+ )
+ end
+
+ let(:included_yml) do
+ YAML.dump(
+ another_test: { stage: 'test', script: 'echo 2' }
+ )
+ end
+
+ before do
+ stub_full_request('https://example.com/sample.yml').to_return(body: included_yml)
+ end
+
+ it 'returns expanded yaml config' do
+ expanded_config = YAML.safe_load(merged_yaml, [Symbol])
+ included_config = YAML.safe_load(included_yml, [Symbol])
+
+ expect(expanded_config).to include(*included_config.keys)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cleanup/orphan_lfs_file_references_spec.rb b/spec/lib/gitlab/cleanup/orphan_lfs_file_references_spec.rb
index efdfc0a980b..6b568320953 100644
--- a/spec/lib/gitlab/cleanup/orphan_lfs_file_references_spec.rb
+++ b/spec/lib/gitlab/cleanup/orphan_lfs_file_references_spec.rb
@@ -42,12 +42,24 @@ RSpec.describe Gitlab::Cleanup::OrphanLfsFileReferences do
expect(null_logger).to receive(:info).with("Looking for orphan LFS files for project #{project.name_with_namespace}")
expect(null_logger).to receive(:info).with("Removed invalid references: 1")
expect(ProjectCacheWorker).to receive(:perform_async).with(project.id, [], [:lfs_objects_size])
+ expect(service).to receive(:remove_orphan_references).and_call_original
expect { service.run! }.to change { project.lfs_objects.count }.from(2).to(1)
expect(LfsObjectsProject.exists?(invalid_reference.id)).to be_falsey
end
+ it 'does nothing if the project has no LFS objects' do
+ expect(null_logger).to receive(:info).with(/Looking for orphan LFS files/)
+ expect(null_logger).to receive(:info).with(/Nothing to do/)
+
+ project.lfs_objects_projects.delete_all
+
+ expect(service).not_to receive(:remove_orphan_references)
+
+ service.run!
+ end
+
context 'LFS object is in design repository' do
before do
expect(project.design_repository).to receive(:exists?).and_return(true)
diff --git a/spec/lib/gitlab/code_navigation_path_spec.rb b/spec/lib/gitlab/code_navigation_path_spec.rb
index 4dc864b158d..206541f7c0d 100644
--- a/spec/lib/gitlab/code_navigation_path_spec.rb
+++ b/spec/lib/gitlab/code_navigation_path_spec.rb
@@ -16,10 +16,6 @@ RSpec.describe Gitlab::CodeNavigationPath do
subject { described_class.new(project, commit_sha).full_json_path_for(path) }
- before do
- stub_feature_flags(code_navigation: project)
- end
-
context 'when a pipeline exist for a sha' do
it 'returns path to a file in the artifact' do
expect(subject).to eq(lsif_path)
@@ -41,15 +37,5 @@ RSpec.describe Gitlab::CodeNavigationPath do
expect(subject).to eq(lsif_path)
end
end
-
- context 'when code_navigation feature is disabled' do
- before do
- stub_feature_flags(code_navigation: false)
- end
-
- it 'returns nil' do
- expect(subject).to be_nil
- end
- end
end
end
diff --git a/spec/lib/gitlab/cycle_analytics/events_spec.rb b/spec/lib/gitlab/cycle_analytics/events_spec.rb
index e0a8e2c17a3..a31f34d82d7 100644
--- a/spec/lib/gitlab/cycle_analytics/events_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/events_spec.rb
@@ -2,16 +2,20 @@
require 'spec_helper'
-RSpec.describe 'cycle analytics events' do
- let(:project) { create(:project, :repository) }
+RSpec.describe 'cycle analytics events', :aggregate_failures do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user, :admin) }
let(:from_date) { 10.days.ago }
- let(:user) { create(:user, :admin) }
let!(:context) { create(:issue, project: project, created_at: 2.days.ago) }
let(:events) do
- CycleAnalytics::ProjectLevel.new(project, options: { from: from_date, current_user: user })[stage].events
+ CycleAnalytics::ProjectLevel
+ .new(project, options: { from: from_date, current_user: user })[stage]
+ .events
end
+ let(:event) { events.first }
+
before do
setup(context)
end
@@ -19,36 +23,15 @@ RSpec.describe 'cycle analytics events' do
describe '#issue_events' do
let(:stage) { :issue }
- it 'has the total time' do
- expect(events.first[:total_time]).not_to be_empty
- end
-
- it 'has a title' do
- expect(events.first[:title]).to eq(context.title)
- end
-
- it 'has the URL' do
- expect(events.first[:url]).not_to be_nil
- end
-
- it 'has an iid' do
- expect(events.first[:iid]).to eq(context.iid.to_s)
- end
-
- it 'has a created_at timestamp' do
- expect(events.first[:created_at]).to end_with('ago')
- end
-
- it "has the author's URL" do
- expect(events.first[:author][:web_url]).not_to be_nil
- end
-
- it "has the author's avatar URL" do
- expect(events.first[:author][:avatar_url]).not_to be_nil
- end
-
- it "has the author's name" do
- expect(events.first[:author][:name]).to eq(context.author.name)
+ it 'has correct attributes' do
+ expect(event[:total_time]).not_to be_empty
+ expect(event[:title]).to eq(context.title)
+ expect(event[:url]).not_to be_nil
+ expect(event[:iid]).to eq(context.iid.to_s)
+ expect(event[:created_at]).to end_with('ago')
+ expect(event[:author][:web_url]).not_to be_nil
+ expect(event[:author][:avatar_url]).not_to be_nil
+ expect(event[:author][:name]).to eq(context.author.name)
end
end
@@ -59,36 +42,15 @@ RSpec.describe 'cycle analytics events' do
create_commit_referencing_issue(context)
end
- it 'has the total time' do
- expect(events.first[:total_time]).not_to be_empty
- end
-
- it 'has a title' do
- expect(events.first[:title]).to eq(context.title)
- end
-
- it 'has the URL' do
- expect(events.first[:url]).not_to be_nil
- end
-
- it 'has an iid' do
- expect(events.first[:iid]).to eq(context.iid.to_s)
- end
-
- it 'has a created_at timestamp' do
- expect(events.first[:created_at]).to end_with('ago')
- end
-
- it "has the author's URL" do
- expect(events.first[:author][:web_url]).not_to be_nil
- end
-
- it "has the author's avatar URL" do
- expect(events.first[:author][:avatar_url]).not_to be_nil
- end
-
- it "has the author's name" do
- expect(events.first[:author][:name]).to eq(context.author.name)
+ it 'has correct attributes' do
+ expect(event[:total_time]).not_to be_empty
+ expect(event[:title]).to eq(context.title)
+ expect(event[:url]).not_to be_nil
+ expect(event[:iid]).to eq(context.iid.to_s)
+ expect(event[:created_at]).to end_with('ago')
+ expect(event[:author][:web_url]).not_to be_nil
+ expect(event[:author][:avatar_url]).not_to be_nil
+ expect(event[:author][:name]).to eq(context.author.name)
end
end
@@ -100,32 +62,14 @@ RSpec.describe 'cycle analytics events' do
create_commit_referencing_issue(context)
end
- it 'has the total time' do
- expect(events.first[:total_time]).not_to be_empty
- end
-
- it 'has a title' do
- expect(events.first[:title]).to eq('Awesome merge_request')
- end
-
- it 'has an iid' do
- expect(events.first[:iid]).to eq(context.iid.to_s)
- end
-
- it 'has a created_at timestamp' do
- expect(events.first[:created_at]).to end_with('ago')
- end
-
- it "has the author's URL" do
- expect(events.first[:author][:web_url]).not_to be_nil
- end
-
- it "has the author's avatar URL" do
- expect(events.first[:author][:avatar_url]).not_to be_nil
- end
-
- it "has the author's name" do
- expect(events.first[:author][:name]).to eq(MergeRequest.first.author.name)
+ it 'has correct attributes' do
+ expect(event[:total_time]).not_to be_empty
+ expect(event[:title]).to eq('Awesome merge_request')
+ expect(event[:iid]).to eq(context.iid.to_s)
+ expect(event[:created_at]).to end_with('ago')
+ expect(event[:author][:web_url]).not_to be_nil
+ expect(event[:author][:avatar_url]).not_to be_nil
+ expect(event[:author][:name]).to eq(MergeRequest.first.author.name)
end
end
@@ -152,40 +96,16 @@ RSpec.describe 'cycle analytics events' do
merge_merge_requests_closing_issue(user, project, context)
end
- it 'has the name' do
- expect(events.first[:name]).not_to be_nil
- end
-
- it 'has the ID' do
- expect(events.first[:id]).not_to be_nil
- end
-
- it 'has the URL' do
- expect(events.first[:url]).not_to be_nil
- end
-
- it 'has the branch name' do
- expect(events.first[:branch]).not_to be_nil
- end
-
- it 'has the branch URL' do
- expect(events.first[:branch][:url]).not_to be_nil
- end
-
- it 'has the short SHA' do
- expect(events.first[:short_sha]).not_to be_nil
- end
-
- it 'has the commit URL' do
- expect(events.first[:commit_url]).not_to be_nil
- end
-
- it 'has the date' do
- expect(events.first[:date]).not_to be_nil
- end
-
- it 'has the total time' do
- expect(events.first[:total_time]).not_to be_empty
+ it 'has correct attributes' do
+ expect(event[:name]).not_to be_nil
+ expect(event[:id]).not_to be_nil
+ expect(event[:url]).not_to be_nil
+ expect(event[:branch]).not_to be_nil
+ expect(event[:branch][:url]).not_to be_nil
+ expect(event[:short_sha]).not_to be_nil
+ expect(event[:commit_url]).not_to be_nil
+ expect(event[:date]).not_to be_nil
+ expect(event[:total_time]).not_to be_empty
end
end
@@ -197,40 +117,16 @@ RSpec.describe 'cycle analytics events' do
merge_merge_requests_closing_issue(user, project, context)
end
- it 'has the total time' do
- expect(events.first[:total_time]).not_to be_empty
- end
-
- it 'has a title' do
- expect(events.first[:title]).to eq('Awesome merge_request')
- end
-
- it 'has an iid' do
- expect(events.first[:iid]).to eq(context.iid.to_s)
- end
-
- it 'has the URL' do
- expect(events.first[:url]).not_to be_nil
- end
-
- it 'has a state' do
- expect(events.first[:state]).not_to be_nil
- end
-
- it 'has a created_at timestamp' do
- expect(events.first[:created_at]).not_to be_nil
- end
-
- it "has the author's URL" do
- expect(events.first[:author][:web_url]).not_to be_nil
- end
-
- it "has the author's avatar URL" do
- expect(events.first[:author][:avatar_url]).not_to be_nil
- end
-
- it "has the author's name" do
- expect(events.first[:author][:name]).to eq(MergeRequest.first.author.name)
+ it 'has correct attributes' do
+ expect(event[:total_time]).not_to be_empty
+ expect(event[:title]).to eq('Awesome merge_request')
+ expect(event[:iid]).to eq(context.iid.to_s)
+ expect(event[:url]).not_to be_nil
+ expect(event[:state]).not_to be_nil
+ expect(event[:created_at]).not_to be_nil
+ expect(event[:author][:web_url]).not_to be_nil
+ expect(event[:author][:avatar_url]).not_to be_nil
+ expect(event[:author][:name]).to eq(MergeRequest.first.author.name)
end
end
@@ -257,58 +153,25 @@ RSpec.describe 'cycle analytics events' do
deploy_master(user, project)
end
- it 'has the name' do
- expect(events.first[:name]).not_to be_nil
- end
-
- it 'has the ID' do
- expect(events.first[:id]).not_to be_nil
- end
-
- it 'has the URL' do
- expect(events.first[:url]).not_to be_nil
- end
-
- it 'has the branch name' do
- expect(events.first[:branch]).not_to be_nil
- end
-
- it 'has the branch URL' do
- expect(events.first[:branch][:url]).not_to be_nil
- end
-
- it 'has the short SHA' do
- expect(events.first[:short_sha]).not_to be_nil
- end
-
- it 'has the commit URL' do
- expect(events.first[:commit_url]).not_to be_nil
- end
-
- it 'has the date' do
- expect(events.first[:date]).not_to be_nil
- end
-
- it 'has the total time' do
- expect(events.first[:total_time]).not_to be_empty
- end
-
- it "has the author's URL" do
- expect(events.first[:author][:web_url]).not_to be_nil
- end
-
- it "has the author's avatar URL" do
- expect(events.first[:author][:avatar_url]).not_to be_nil
- end
-
- it "has the author's name" do
- expect(events.first[:author][:name]).to eq(MergeRequest.first.author.name)
+ it 'has correct attributes' do
+ expect(event[:name]).not_to be_nil
+ expect(event[:id]).not_to be_nil
+ expect(event[:url]).not_to be_nil
+ expect(event[:branch]).not_to be_nil
+ expect(event[:branch][:url]).not_to be_nil
+ expect(event[:short_sha]).not_to be_nil
+ expect(event[:commit_url]).not_to be_nil
+ expect(event[:date]).not_to be_nil
+ expect(event[:total_time]).not_to be_empty
+ expect(event[:author][:web_url]).not_to be_nil
+ expect(event[:author][:avatar_url]).not_to be_nil
+ expect(event[:author][:name]).to eq(MergeRequest.first.author.name)
end
end
def setup(context)
milestone = create(:milestone, project: project)
- context.update(milestone: milestone)
+ context.update!(milestone: milestone)
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)
diff --git a/spec/lib/gitlab/danger/commit_linter_spec.rb b/spec/lib/gitlab/danger/commit_linter_spec.rb
index c31522c538d..882cede759b 100644
--- a/spec/lib/gitlab/danger/commit_linter_spec.rb
+++ b/spec/lib/gitlab/danger/commit_linter_spec.rb
@@ -323,6 +323,16 @@ RSpec.describe Gitlab::Danger::CommitLinter do
end
end
+ context 'when message includes a value that is surrounded by backticks' do
+ let(:commit_message) { "A commit message `%20`" }
+
+ it 'does not add a problem' do
+ expect(commit_linter).not_to receive(:add_problem)
+
+ commit_linter.lint
+ end
+ end
+
context 'when message includes a short reference' do
[
'A commit message to fix #1234',
@@ -336,7 +346,9 @@ RSpec.describe Gitlab::Danger::CommitLinter do
'A commit message to fix gitlab-org/gitlab#1234',
'A commit message to fix gitlab-org/gitlab!1234',
'A commit message to fix gitlab-org/gitlab&1234',
- 'A commit message to fix gitlab-org/gitlab%1234'
+ 'A commit message to fix gitlab-org/gitlab%1234',
+ 'A commit message to fix "gitlab-org/gitlab%1234"',
+ 'A commit message to fix `gitlab-org/gitlab%1234'
].each do |message|
let(:commit_message) { message }
diff --git a/spec/lib/gitlab/danger/helper_spec.rb b/spec/lib/gitlab/danger/helper_spec.rb
index c7d55c396ef..708e9a13aed 100644
--- a/spec/lib/gitlab/danger/helper_spec.rb
+++ b/spec/lib/gitlab/danger/helper_spec.rb
@@ -435,6 +435,28 @@ RSpec.describe Gitlab::Danger::Helper do
end
end
+ describe '#draft_mr?' do
+ it 'returns false when `gitlab_helper` is unavailable' do
+ expect(helper).to receive(:gitlab_helper).and_return(nil)
+
+ expect(helper).not_to be_draft_mr
+ end
+
+ it 'returns true for a draft MR' do
+ expect(fake_gitlab).to receive(:mr_json)
+ .and_return('title' => 'Draft: My MR title')
+
+ expect(helper).to be_draft_mr
+ end
+
+ it 'returns false for non draft MR' do
+ expect(fake_gitlab).to receive(:mr_json)
+ .and_return('title' => 'My MR title')
+
+ expect(helper).not_to be_draft_mr
+ end
+ end
+
describe '#cherry_pick_mr?' do
it 'returns false when `gitlab_helper` is unavailable' do
expect(helper).to receive(:gitlab_helper).and_return(nil)
diff --git a/spec/lib/gitlab/danger/roulette_spec.rb b/spec/lib/gitlab/danger/roulette_spec.rb
index b471e17e2e7..9acaa57ee10 100644
--- a/spec/lib/gitlab/danger/roulette_spec.rb
+++ b/spec/lib/gitlab/danger/roulette_spec.rb
@@ -7,7 +7,7 @@ require 'gitlab/danger/roulette'
RSpec.describe Gitlab::Danger::Roulette do
around do |example|
- Timecop.freeze(Time.utc(2020, 06, 22, 10)) { example.run }
+ travel_to(Time.utc(2020, 06, 22, 10)) { example.run }
end
let(:backend_available) { true }
@@ -67,14 +67,18 @@ RSpec.describe Gitlab::Danger::Roulette do
)
end
- let(:teammate_json) do
+ let(:teammates) do
[
backend_maintainer.to_h,
frontend_maintainer.to_h,
frontend_reviewer.to_h,
software_engineer_in_test.to_h,
engineering_productivity_reviewer.to_h
- ].to_json
+ ]
+ end
+
+ let(:teammate_json) do
+ teammates.to_json
end
subject(:roulette) { Object.new.extend(described_class) }
@@ -210,6 +214,69 @@ RSpec.describe Gitlab::Danger::Roulette do
end
end
end
+
+ describe 'reviewer suggestion probability' do
+ let(:reviewer) { teammate_with_capability('reviewer', 'reviewer backend') }
+ let(:hungry_reviewer) { teammate_with_capability('hungry_reviewer', 'reviewer backend', hungry: true) }
+ let(:traintainer) { teammate_with_capability('traintainer', 'trainee_maintainer backend') }
+ let(:hungry_traintainer) { teammate_with_capability('hungry_traintainer', 'trainee_maintainer backend', hungry: true) }
+ let(:teammates) do
+ [
+ reviewer.to_h,
+ hungry_reviewer.to_h,
+ traintainer.to_h,
+ hungry_traintainer.to_h
+ ]
+ end
+
+ let(:categories) { [:backend] }
+
+ # This test is testing probability with inherent randomness.
+ # The variance is inversely related to sample size
+ # Given large enough sample size, the variance would be smaller,
+ # but the test would take longer.
+ # Given smaller sample size, the variance would be larger,
+ # but the test would take less time.
+ let!(:sample_size) { 500 }
+ let!(:variance) { 0.1 }
+
+ before do
+ # This test needs actual randomness to simulate probabilities
+ allow(subject).to receive(:new_random).and_return(Random.new)
+ WebMock
+ .stub_request(:get, described_class::ROULETTE_DATA_URL)
+ .to_return(body: teammate_json)
+ end
+
+ it 'has 1:2:3:4 probability of picking reviewer, hungry_reviewer, traintainer, hungry_traintainer' do
+ picks = Array.new(sample_size).map do
+ spins = subject.spin(project, categories, timezone_experiment: timezone_experiment)
+ spins.first.reviewer.name
+ end
+
+ expect(probability(picks, 'reviewer')).to be_within(variance).of(0.1)
+ expect(probability(picks, 'hungry_reviewer')).to be_within(variance).of(0.2)
+ expect(probability(picks, 'traintainer')).to be_within(variance).of(0.3)
+ expect(probability(picks, 'hungry_traintainer')).to be_within(variance).of(0.4)
+ end
+
+ def probability(picks, role)
+ picks.count(role).to_f / picks.length
+ end
+
+ def teammate_with_capability(name, capability, hungry: false)
+ Gitlab::Danger::Teammate.new(
+ {
+ 'name' => name,
+ 'projects' => {
+ 'gitlab' => capability
+ },
+ 'available' => true,
+ 'hungry' => hungry
+ }
+ )
+ end
+ end
end
RSpec::Matchers.define :match_teammates do |expected|
diff --git a/spec/lib/gitlab/danger/teammate_spec.rb b/spec/lib/gitlab/danger/teammate_spec.rb
index 6fd32493d6b..5a47d74a7f3 100644
--- a/spec/lib/gitlab/danger/teammate_spec.rb
+++ b/spec/lib/gitlab/danger/teammate_spec.rb
@@ -149,7 +149,7 @@ RSpec.describe Gitlab::Danger::Teammate do
describe '#local_hour' do
around do |example|
- Timecop.freeze(Time.utc(2020, 6, 23, 10)) { example.run }
+ travel_to(Time.utc(2020, 6, 23, 10)) { example.run }
end
context 'when author is given' do
diff --git a/spec/lib/gitlab/database/background_migration_job_spec.rb b/spec/lib/gitlab/database/background_migration_job_spec.rb
index dd5bf8b512f..42695925a1c 100644
--- a/spec/lib/gitlab/database/background_migration_job_spec.rb
+++ b/spec/lib/gitlab/database/background_migration_job_spec.rb
@@ -85,7 +85,7 @@ RSpec.describe Gitlab::Database::BackgroundMigrationJob do
let!(:job1) { create(:background_migration_job, :succeeded, created_at: initial_time, updated_at: initial_time) }
it 'does not update non-pending jobs' do
- Timecop.freeze(initial_time + 1.day) do
+ travel_to(initial_time + 1.day) do
expect { described_class.mark_all_as_succeeded('TestJob', [1, 100]) }
.to change { described_class.succeeded.count }.from(1).to(2)
end
diff --git a/spec/lib/gitlab/database/batch_count_spec.rb b/spec/lib/gitlab/database/batch_count_spec.rb
index 71d3666602f..93c499eb56d 100644
--- a/spec/lib/gitlab/database/batch_count_spec.rb
+++ b/spec/lib/gitlab/database/batch_count_spec.rb
@@ -108,6 +108,24 @@ RSpec.describe Gitlab::Database::BatchCount do
expect { described_class.batch_count(model.distinct(column)) }.to raise_error 'Use distinct count for optimized distinct counting'
end
end
+
+ context 'when a relation is grouped' do
+ let!(:one_more_issue) { create(:issue, author: user, project: model.first.project) }
+
+ before do
+ stub_const('Gitlab::Database::BatchCounter::MIN_REQUIRED_BATCH_SIZE', 1)
+ end
+
+ context 'count by default column' do
+ let(:count) do
+ described_class.batch_count(model.group(column), batch_size: 2)
+ end
+
+ it 'counts grouped records' do
+ expect(count).to eq({ user.id => 4, another_user.id => 2 })
+ end
+ end
+ end
end
describe '#batch_distinct_count' do
@@ -175,6 +193,24 @@ RSpec.describe Gitlab::Database::BatchCount do
end.to raise_error 'Use distinct count only with non id fields'
end
end
+
+ context 'when a relation is grouped' do
+ let!(:one_more_issue) { create(:issue, author: user, project: model.first.project) }
+
+ before do
+ stub_const('Gitlab::Database::BatchCounter::MIN_REQUIRED_BATCH_SIZE', 1)
+ end
+
+ context 'distinct count by non-unique column' do
+ let(:count) do
+ described_class.batch_distinct_count(model.group(column), :project_id, batch_size: 2)
+ end
+
+ it 'counts grouped records' do
+ expect(count).to eq({ user.id => 3, another_user.id => 2 })
+ end
+ end
+ end
end
describe '#batch_sum' do
diff --git a/spec/lib/gitlab/database/concurrent_reindex_spec.rb b/spec/lib/gitlab/database/concurrent_reindex_spec.rb
deleted file mode 100644
index 4e2c3f547d4..00000000000
--- a/spec/lib/gitlab/database/concurrent_reindex_spec.rb
+++ /dev/null
@@ -1,207 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Database::ConcurrentReindex, '#execute' do
- subject { described_class.new(index_name, logger: logger) }
-
- let(:table_name) { '_test_reindex_table' }
- let(:column_name) { '_test_column' }
- let(:index_name) { '_test_reindex_index' }
- let(:logger) { double('logger', debug: nil, info: nil, error: nil ) }
- let(:connection) { ActiveRecord::Base.connection }
-
- before do
- connection.execute(<<~SQL)
- CREATE TABLE #{table_name} (
- id serial NOT NULL PRIMARY KEY,
- #{column_name} integer NOT NULL);
-
- CREATE INDEX #{index_name} ON #{table_name} (#{column_name});
- SQL
- end
-
- context 'when the index does not exist' do
- before do
- connection.execute(<<~SQL)
- DROP INDEX #{index_name}
- SQL
- end
-
- it 'raises an error' do
- expect { subject.execute }.to raise_error(described_class::ReindexError, /does not exist/)
- end
- end
-
- context 'when the index is unique' do
- before do
- connection.execute(<<~SQL)
- DROP INDEX #{index_name};
- CREATE UNIQUE INDEX #{index_name} ON #{table_name} (#{column_name})
- SQL
- end
-
- it 'raises an error' do
- expect do
- subject.execute
- end.to raise_error(described_class::ReindexError, /UNIQUE indexes are currently not supported/)
- end
- end
-
- context 'replacing the original index with a rebuilt copy' do
- let(:replacement_name) { 'tmp_reindex__test_reindex_index' }
- let(:replaced_name) { 'old_reindex__test_reindex_index' }
-
- let(:create_index) { "CREATE INDEX CONCURRENTLY #{replacement_name} ON public.#{table_name} USING btree (#{column_name})" }
- let(:drop_index) { "DROP INDEX CONCURRENTLY IF EXISTS #{replacement_name}" }
-
- let!(:original_index) { find_index_create_statement }
-
- before do
- allow(subject).to receive(:connection).and_return(connection)
- allow(subject).to receive(:disable_statement_timeout).and_yield
- end
-
- it 'replaces the existing index with an identical index' do
- expect(subject).to receive(:disable_statement_timeout).exactly(3).times.and_yield
-
- expect_to_execute_concurrently_in_order(drop_index)
- expect_to_execute_concurrently_in_order(create_index)
-
- expect_next_instance_of(::Gitlab::Database::WithLockRetries) do |instance|
- expect(instance).to receive(:run).with(raise_on_exhaustion: true).and_yield
- end
-
- expect_to_execute_in_order("ALTER INDEX #{index_name} RENAME TO #{replaced_name}")
- expect_to_execute_in_order("ALTER INDEX #{replacement_name} RENAME TO #{index_name}")
- expect_to_execute_in_order("ALTER INDEX #{replaced_name} RENAME TO #{replacement_name}")
-
- expect_to_execute_concurrently_in_order(drop_index)
-
- subject.execute
-
- check_index_exists
- end
-
- context 'when a dangling index is left from a previous run' do
- before do
- connection.execute("CREATE INDEX #{replacement_name} ON #{table_name} (#{column_name})")
- end
-
- it 'replaces the existing index with an identical index' do
- expect(subject).to receive(:disable_statement_timeout).exactly(3).times.and_yield
-
- expect_to_execute_concurrently_in_order(drop_index)
- expect_to_execute_concurrently_in_order(create_index)
-
- expect_next_instance_of(::Gitlab::Database::WithLockRetries) do |instance|
- expect(instance).to receive(:run).with(raise_on_exhaustion: true).and_yield
- end
-
- expect_to_execute_in_order("ALTER INDEX #{index_name} RENAME TO #{replaced_name}")
- expect_to_execute_in_order("ALTER INDEX #{replacement_name} RENAME TO #{index_name}")
- expect_to_execute_in_order("ALTER INDEX #{replaced_name} RENAME TO #{replacement_name}")
-
- expect_to_execute_concurrently_in_order(drop_index)
-
- subject.execute
-
- check_index_exists
- end
- end
-
- context 'when it fails to create the replacement index' do
- it 'safely cleans up and signals the error' do
- expect_to_execute_concurrently_in_order(drop_index)
-
- expect(connection).to receive(:execute).with(create_index).ordered
- .and_raise(ActiveRecord::ConnectionTimeoutError, 'connect timeout')
-
- expect_to_execute_concurrently_in_order(drop_index)
-
- expect { subject.execute }.to raise_error(described_class::ReindexError, /connect timeout/)
-
- check_index_exists
- end
- end
-
- context 'when the replacement index is not valid' do
- it 'safely cleans up and signals the error' do
- expect_to_execute_concurrently_in_order(drop_index)
- expect_to_execute_concurrently_in_order(create_index)
-
- expect(subject).to receive(:replacement_index_valid?).and_return(false)
-
- expect_to_execute_concurrently_in_order(drop_index)
-
- expect { subject.execute }.to raise_error(described_class::ReindexError, /replacement index was created as INVALID/)
-
- check_index_exists
- end
- end
-
- context 'when a database error occurs while swapping the indexes' do
- it 'safely cleans up and signals the error' do
- expect_to_execute_concurrently_in_order(drop_index)
- expect_to_execute_concurrently_in_order(create_index)
-
- expect_next_instance_of(::Gitlab::Database::WithLockRetries) do |instance|
- expect(instance).to receive(:run).with(raise_on_exhaustion: true).and_yield
- end
-
- expect(connection).to receive(:execute).ordered
- .with("ALTER INDEX #{index_name} RENAME TO #{replaced_name}")
- .and_raise(ActiveRecord::ConnectionTimeoutError, 'connect timeout')
-
- expect_to_execute_concurrently_in_order(drop_index)
-
- expect { subject.execute }.to raise_error(described_class::ReindexError, /connect timeout/)
-
- check_index_exists
- end
- end
-
- context 'when with_lock_retries fails to acquire the lock' do
- it 'safely cleans up and signals the error' do
- expect_to_execute_concurrently_in_order(drop_index)
- expect_to_execute_concurrently_in_order(create_index)
-
- expect_next_instance_of(::Gitlab::Database::WithLockRetries) do |instance|
- expect(instance).to receive(:run).with(raise_on_exhaustion: true)
- .and_raise(::Gitlab::Database::WithLockRetries::AttemptsExhaustedError, 'exhausted')
- end
-
- expect_to_execute_concurrently_in_order(drop_index)
-
- expect { subject.execute }.to raise_error(described_class::ReindexError, /exhausted/)
-
- check_index_exists
- end
- end
- end
-
- def expect_to_execute_concurrently_in_order(sql)
- # Indexes cannot be created CONCURRENTLY in a transaction. Since the tests are wrapped in transactions,
- # verify the original call but pass through the non-concurrent form.
- expect(connection).to receive(:execute).with(sql).ordered.and_wrap_original do |method, sql|
- method.call(sql.sub(/CONCURRENTLY/, ''))
- end
- end
-
- def expect_to_execute_in_order(sql)
- expect(connection).to receive(:execute).with(sql).ordered.and_call_original
- end
-
- def find_index_create_statement
- ActiveRecord::Base.connection.select_value(<<~SQL)
- SELECT indexdef
- FROM pg_indexes
- WHERE schemaname = 'public'
- AND indexname = #{ActiveRecord::Base.connection.quote(index_name)}
- SQL
- end
-
- def check_index_exists
- expect(find_index_create_statement).to eq(original_index)
- end
-end
diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb
index 0bdcca630aa..727ad243349 100644
--- a/spec/lib/gitlab/database/migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers_spec.rb
@@ -1128,7 +1128,65 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
name: 'index_on_issues_gl_project_id',
length: [],
order: [],
- opclasses: { 'gl_project_id' => 'bar' })
+ opclass: { 'gl_project_id' => 'bar' })
+
+ model.copy_indexes(:issues, :project_id, :gl_project_id)
+ end
+ end
+
+ context 'using an index with multiple columns and custom operator classes' do
+ it 'copies the index' do
+ index = double(:index,
+ columns: %w(project_id foobar),
+ name: 'index_on_issues_project_id_foobar',
+ using: :gin,
+ where: nil,
+ opclasses: { 'project_id' => 'bar', 'foobar' => :gin_trgm_ops },
+ unique: false,
+ lengths: [],
+ orders: [])
+
+ allow(model).to receive(:indexes_for).with(:issues, 'project_id')
+ .and_return([index])
+
+ expect(model).to receive(:add_concurrent_index)
+ .with(:issues,
+ %w(gl_project_id foobar),
+ unique: false,
+ name: 'index_on_issues_gl_project_id_foobar',
+ length: [],
+ order: [],
+ opclass: { 'gl_project_id' => 'bar', 'foobar' => :gin_trgm_ops },
+ using: :gin)
+
+ model.copy_indexes(:issues, :project_id, :gl_project_id)
+ end
+ end
+
+ context 'using an index with multiple columns and a custom operator class on the non affected column' do
+ it 'copies the index' do
+ index = double(:index,
+ columns: %w(project_id foobar),
+ name: 'index_on_issues_project_id_foobar',
+ using: :gin,
+ where: nil,
+ opclasses: { 'foobar' => :gin_trgm_ops },
+ unique: false,
+ lengths: [],
+ orders: [])
+
+ allow(model).to receive(:indexes_for).with(:issues, 'project_id')
+ .and_return([index])
+
+ expect(model).to receive(:add_concurrent_index)
+ .with(:issues,
+ %w(gl_project_id foobar),
+ unique: false,
+ name: 'index_on_issues_gl_project_id_foobar',
+ length: [],
+ order: [],
+ opclass: { 'foobar' => :gin_trgm_ops },
+ using: :gin)
model.copy_indexes(:issues, :project_id, :gl_project_id)
end
@@ -1400,15 +1458,32 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
)
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
+
+ context 'when an index exists for a table with the same name in another schema' do
+ before do
+ ActiveRecord::Base.connection.execute(
+ 'CREATE SCHEMA new_test_schema'
+ )
+
+ ActiveRecord::Base.connection.execute(
+ 'CREATE TABLE new_test_schema.projects (id integer, name character varying)'
+ )
+
+ ActiveRecord::Base.connection.execute(
+ 'CREATE INDEX test_index_on_name ON new_test_schema.projects (LOWER(name));'
+ )
+ end
+
+ it 'returns false if the index does not exist in the current schema' do
+ expect(model.index_exists_by_name?(:projects, 'test_index_on_name'))
+ .to be_falsy
+ end
+ end
end
describe '#create_or_update_plan_limit' do
@@ -1863,11 +1938,17 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
ActiveRecord::Base.connection.execute(
'ALTER TABLE projects ADD CONSTRAINT check_1 CHECK (char_length(path) <= 5) NOT VALID'
)
- end
- after do
ActiveRecord::Base.connection.execute(
- 'ALTER TABLE projects DROP CONSTRAINT IF EXISTS check_1'
+ 'CREATE SCHEMA new_test_schema'
+ )
+
+ ActiveRecord::Base.connection.execute(
+ 'CREATE TABLE new_test_schema.projects (id integer, name character varying)'
+ )
+
+ ActiveRecord::Base.connection.execute(
+ 'ALTER TABLE new_test_schema.projects ADD CONSTRAINT check_2 CHECK (char_length(name) <= 5)'
)
end
@@ -1885,6 +1966,11 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
expect(model.check_constraint_exists?(:users, 'check_1'))
.to be_falsy
end
+
+ it 'returns false if a constraint with the same name exists for the same table in another schema' do
+ expect(model.check_constraint_exists?(:projects, 'check_2'))
+ .to be_falsy
+ end
end
describe '#add_check_constraint' do
diff --git a/spec/lib/gitlab/database/obsolete_ignored_columns_spec.rb b/spec/lib/gitlab/database/obsolete_ignored_columns_spec.rb
index 034bf966db7..8a35d8149ad 100644
--- a/spec/lib/gitlab/database/obsolete_ignored_columns_spec.rb
+++ b/spec/lib/gitlab/database/obsolete_ignored_columns_spec.rb
@@ -52,7 +52,7 @@ RSpec.describe Gitlab::Database::ObsoleteIgnoredColumns do
describe '#execute' do
it 'returns a list of class names and columns pairs' do
- Timecop.freeze(REMOVE_DATE) do
+ travel_to(REMOVE_DATE) do
expect(subject.execute).to eq([
['Testing::A', {
'unused' => IgnorableColumns::ColumnIgnore.new(Date.parse('2019-01-01'), '12.0'),
diff --git a/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb b/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb
index 334cac653cf..885eef5723e 100644
--- a/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb
+++ b/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb
@@ -47,7 +47,7 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do
let(:partitioning_key) { :created_at }
around do |example|
- Timecop.freeze(Date.parse('2020-08-22')) { example.run }
+ travel_to(Date.parse('2020-08-22')) { example.run }
end
context 'with existing partitions' do
diff --git a/spec/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table_spec.rb b/spec/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table_spec.rb
index ec3d0a6dbcb..c43b51e10a0 100644
--- a/spec/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table_spec.rb
+++ b/spec/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table_spec.rb
@@ -116,23 +116,6 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::BackfillPartition
expect(jobs_updated).to eq(1)
end
- context 'when the feature flag is disabled' do
- let(:mock_connection) { double('connection') }
-
- before do
- allow(subject).to receive(:connection).and_return(mock_connection)
- stub_feature_flags(backfill_partitioned_audit_events: false)
- end
-
- it 'exits without attempting to copy data' do
- expect(mock_connection).not_to receive(:execute)
-
- subject.perform(1, 100, source_table, destination_table, unique_key)
-
- expect(destination_model.count).to eq(0)
- end
- end
-
context 'when the job is run within an explicit transaction block' do
let(:mock_connection) { double('connection') }
diff --git a/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb b/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb
index 44ef0b307fe..147637cf471 100644
--- a/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb
+++ b/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb
@@ -213,7 +213,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
it 'creates partitions including the next month from today' do
today = Date.new(2020, 5, 8)
- Timecop.freeze(today) do
+ travel_to(today) do
migration.partition_table_by_date source_table, partition_column, min_date: min_date
expect_range_partitions_for(partitioned_table, {
@@ -233,7 +233,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
context 'without min_date, max_date' do
it 'creates partitions for the current and next month' do
current_date = Date.new(2020, 05, 22)
- Timecop.freeze(current_date.to_time) do
+ travel_to(current_date.to_time) do
migration.partition_table_by_date source_table, partition_column
expect_range_partitions_for(partitioned_table, {
@@ -514,6 +514,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
allow(migration).to receive(:table_exists?).with(partitioned_table).and_return(true)
allow(migration).to receive(:copy_missed_records)
allow(migration).to receive(:execute).with(/VACUUM/)
+ allow(migration).to receive(:execute).with(/^(RE)?SET/)
end
it 'finishes remaining jobs for the correct table' do
@@ -567,6 +568,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
allow(Gitlab::BackgroundMigration).to receive(:steal)
allow(migration).to receive(:execute).with(/VACUUM/)
+ allow(migration).to receive(:execute).with(/^(RE)?SET/)
end
it 'idempotently cleans up after failed background migrations' do
diff --git a/spec/lib/gitlab/database/postgres_index_spec.rb b/spec/lib/gitlab/database/postgres_index_spec.rb
new file mode 100644
index 00000000000..1da67a5a6c0
--- /dev/null
+++ b/spec/lib/gitlab/database/postgres_index_spec.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::PostgresIndex do
+ before do
+ ActiveRecord::Base.connection.execute(<<~SQL)
+ CREATE INDEX foo_idx ON public.users (name);
+ CREATE UNIQUE INDEX bar_key ON public.users (id);
+
+ CREATE TABLE example_table (id serial primary key);
+ SQL
+ end
+
+ def find(name)
+ described_class.by_identifier(name)
+ end
+
+ describe '.by_identifier' do
+ it 'finds the index' do
+ expect(find('public.foo_idx')).to be_a(Gitlab::Database::PostgresIndex)
+ end
+
+ it 'raises an error if not found' do
+ expect { find('public.idontexist') }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ it 'raises ArgumentError if given a non-fully qualified index name' do
+ expect { find('foo') }.to raise_error(ArgumentError, /not fully qualified/)
+ end
+ end
+
+ describe '.regular' do
+ it 'only non-unique indexes' do
+ expect(described_class.regular).to all(have_attributes(unique: false))
+ end
+
+ it 'only non partitioned indexes ' do
+ expect(described_class.regular).to all(have_attributes(partitioned: false))
+ end
+
+ it 'only indexes that dont serve an exclusion constraint' do
+ expect(described_class.regular).to all(have_attributes(exclusion: false))
+ end
+ end
+
+ describe '.not_match' do
+ it 'excludes indexes matching the given regex' do
+ expect(described_class.not_match('^bar_k').map(&:name)).to all(match(/^(?!bar_k).*/))
+ end
+
+ it 'matches indexes without this prefix regex' do
+ expect(described_class.not_match('^bar_k')).not_to be_empty
+ end
+ end
+
+ describe '.random_few' do
+ it 'limits to two records by default' do
+ expect(described_class.random_few(2).size).to eq(2)
+ end
+ end
+
+ describe '#unique?' do
+ it 'returns true for a unique index' do
+ expect(find('public.bar_key')).to be_unique
+ end
+
+ it 'returns false for a regular, non-unique index' do
+ expect(find('public.foo_idx')).not_to be_unique
+ end
+
+ it 'returns true for a primary key index' do
+ expect(find('public.example_table_pkey')).to be_unique
+ end
+ end
+
+ describe '#valid_index?' do
+ it 'returns true if the index is invalid' do
+ expect(find('public.foo_idx')).to be_valid_index
+ end
+
+ it 'returns false if the index is marked as invalid' do
+ ActiveRecord::Base.connection.execute(<<~SQL)
+ UPDATE pg_index SET indisvalid=false
+ FROM pg_class
+ WHERE pg_class.relname = 'foo_idx' AND pg_index.indexrelid = pg_class.oid
+ SQL
+
+ expect(find('public.foo_idx')).not_to be_valid_index
+ end
+ end
+
+ describe '#to_s' do
+ it 'returns the index name' do
+ expect(find('public.foo_idx').to_s).to eq('foo_idx')
+ end
+ end
+
+ describe '#name' do
+ it 'returns the name' do
+ expect(find('public.foo_idx').name).to eq('foo_idx')
+ end
+ end
+
+ describe '#schema' do
+ it 'returns the index schema' do
+ expect(find('public.foo_idx').schema).to eq('public')
+ end
+ end
+
+ describe '#definition' do
+ it 'returns the index definition' do
+ expect(find('public.foo_idx').definition).to eq('CREATE INDEX foo_idx ON public.users USING btree (name)')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/reindexing/concurrent_reindex_spec.rb b/spec/lib/gitlab/database/reindexing/concurrent_reindex_spec.rb
new file mode 100644
index 00000000000..a80bf8176d2
--- /dev/null
+++ b/spec/lib/gitlab/database/reindexing/concurrent_reindex_spec.rb
@@ -0,0 +1,255 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Reindexing::ConcurrentReindex, '#perform' do
+ subject { described_class.new(index, logger: logger) }
+
+ let(:table_name) { '_test_reindex_table' }
+ let(:column_name) { '_test_column' }
+ let(:index_name) { '_test_reindex_index' }
+ let(:index) { instance_double(Gitlab::Database::PostgresIndex, indexrelid: 42, name: index_name, schema: 'public', partitioned?: false, unique?: false, exclusion?: false, definition: 'CREATE INDEX _test_reindex_index ON public._test_reindex_table USING btree (_test_column)') }
+ let(:logger) { double('logger', debug: nil, info: nil, error: nil ) }
+ let(:connection) { ActiveRecord::Base.connection }
+
+ before do
+ connection.execute(<<~SQL)
+ CREATE TABLE #{table_name} (
+ id serial NOT NULL PRIMARY KEY,
+ #{column_name} integer NOT NULL);
+
+ CREATE INDEX #{index.name} ON #{table_name} (#{column_name});
+ SQL
+ end
+
+ context 'when the index is unique' do
+ before do
+ allow(index).to receive(:unique?).and_return(true)
+ end
+
+ it 'raises an error' do
+ expect do
+ subject.perform
+ end.to raise_error(described_class::ReindexError, /UNIQUE indexes are currently not supported/)
+ end
+ end
+
+ context 'when the index is partitioned' do
+ before do
+ allow(index).to receive(:partitioned?).and_return(true)
+ end
+
+ it 'raises an error' do
+ expect do
+ subject.perform
+ end.to raise_error(described_class::ReindexError, /partitioned indexes are currently not supported/)
+ end
+ end
+
+ context 'when the index serves an exclusion constraint' do
+ before do
+ allow(index).to receive(:exclusion?).and_return(true)
+ end
+
+ it 'raises an error' do
+ expect do
+ subject.perform
+ end.to raise_error(described_class::ReindexError, /indexes serving an exclusion constraint are currently not supported/)
+ end
+ end
+
+ context 'when the index is a lingering temporary index from a previous reindexing run' do
+ context 'with the temporary index prefix' do
+ let(:index_name) { 'tmp_reindex_something' }
+
+ it 'raises an error' do
+ expect do
+ subject.perform
+ end.to raise_error(described_class::ReindexError, /left-over temporary index/)
+ end
+ end
+
+ context 'with the replaced index prefix' do
+ let(:index_name) { 'old_reindex_something' }
+
+ it 'raises an error' do
+ expect do
+ subject.perform
+ end.to raise_error(described_class::ReindexError, /left-over temporary index/)
+ end
+ end
+ end
+
+ context 'replacing the original index with a rebuilt copy' do
+ let(:replacement_name) { 'tmp_reindex_42' }
+ let(:replaced_name) { 'old_reindex_42' }
+
+ let(:create_index) { "CREATE INDEX CONCURRENTLY #{replacement_name} ON public.#{table_name} USING btree (#{column_name})" }
+ let(:drop_index) do
+ <<~SQL
+ DROP INDEX CONCURRENTLY
+ IF EXISTS "public"."#{replacement_name}"
+ SQL
+ end
+
+ let!(:original_index) { find_index_create_statement }
+
+ it 'integration test: executing full index replacement without mocks' do
+ allow(connection).to receive(:execute).and_wrap_original do |method, sql|
+ method.call(sql.sub(/CONCURRENTLY/, ''))
+ end
+
+ subject.perform
+
+ check_index_exists
+ end
+
+ context 'mocked specs' do
+ before do
+ allow(subject).to receive(:connection).and_return(connection)
+ allow(subject).to receive(:disable_statement_timeout).and_yield
+ end
+
+ it 'replaces the existing index with an identical index' do
+ expect(subject).to receive(:disable_statement_timeout).twice.and_yield
+
+ expect_to_execute_concurrently_in_order(create_index)
+
+ expect_next_instance_of(::Gitlab::Database::WithLockRetries) do |instance|
+ expect(instance).to receive(:run).with(raise_on_exhaustion: true).and_yield
+ end
+
+ expect_index_rename(index.name, replaced_name)
+ expect_index_rename(replacement_name, index.name)
+ expect_index_rename(replaced_name, replacement_name)
+
+ expect_to_execute_concurrently_in_order(drop_index)
+
+ subject.perform
+
+ check_index_exists
+ end
+
+ context 'when a dangling index is left from a previous run' do
+ before do
+ connection.execute("CREATE INDEX #{replacement_name} ON #{table_name} (#{column_name})")
+ end
+
+ it 'replaces the existing index with an identical index' do
+ expect(subject).to receive(:disable_statement_timeout).exactly(3).times.and_yield
+
+ expect_to_execute_concurrently_in_order(drop_index)
+ expect_to_execute_concurrently_in_order(create_index)
+
+ expect_next_instance_of(::Gitlab::Database::WithLockRetries) do |instance|
+ expect(instance).to receive(:run).with(raise_on_exhaustion: true).and_yield
+ end
+
+ expect_index_rename(index.name, replaced_name)
+ expect_index_rename(replacement_name, index.name)
+ expect_index_rename(replaced_name, replacement_name)
+
+ expect_to_execute_concurrently_in_order(drop_index)
+
+ subject.perform
+
+ check_index_exists
+ end
+ end
+
+ context 'when it fails to create the replacement index' do
+ it 'safely cleans up and signals the error' do
+ expect(connection).to receive(:execute).with(create_index).ordered
+ .and_raise(ActiveRecord::ConnectionTimeoutError, 'connect timeout')
+
+ expect_to_execute_concurrently_in_order(drop_index)
+
+ expect { subject.perform }.to raise_error(ActiveRecord::ConnectionTimeoutError, /connect timeout/)
+
+ check_index_exists
+ end
+ end
+
+ context 'when the replacement index is not valid' do
+ it 'safely cleans up and signals the error' do
+ replacement_index = double('replacement index', valid_index?: false)
+ allow(Gitlab::Database::PostgresIndex).to receive(:find_by).with(schema: 'public', name: replacement_name).and_return(nil, replacement_index)
+
+ expect_to_execute_concurrently_in_order(create_index)
+
+ expect_to_execute_concurrently_in_order(drop_index)
+
+ expect { subject.perform }.to raise_error(described_class::ReindexError, /replacement index was created as INVALID/)
+
+ check_index_exists
+ end
+ end
+
+ context 'when a database error occurs while swapping the indexes' do
+ it 'safely cleans up and signals the error' do
+ replacement_index = double('replacement index', valid_index?: true)
+ allow(Gitlab::Database::PostgresIndex).to receive(:find_by).with(schema: 'public', name: replacement_name).and_return(nil, replacement_index)
+
+ expect_to_execute_concurrently_in_order(create_index)
+
+ expect_next_instance_of(::Gitlab::Database::WithLockRetries) do |instance|
+ expect(instance).to receive(:run).with(raise_on_exhaustion: true).and_yield
+ end
+
+ expect_index_rename(index.name, replaced_name).and_raise(ActiveRecord::ConnectionTimeoutError, 'connect timeout')
+
+ expect_to_execute_concurrently_in_order(drop_index)
+
+ expect { subject.perform }.to raise_error(ActiveRecord::ConnectionTimeoutError, /connect timeout/)
+
+ check_index_exists
+ end
+ end
+
+ context 'when with_lock_retries fails to acquire the lock' do
+ it 'safely cleans up and signals the error' do
+ expect_to_execute_concurrently_in_order(create_index)
+
+ expect_next_instance_of(::Gitlab::Database::WithLockRetries) do |instance|
+ expect(instance).to receive(:run).with(raise_on_exhaustion: true)
+ .and_raise(::Gitlab::Database::WithLockRetries::AttemptsExhaustedError, 'exhausted')
+ end
+
+ expect_to_execute_concurrently_in_order(drop_index)
+
+ expect { subject.perform }.to raise_error(::Gitlab::Database::WithLockRetries::AttemptsExhaustedError, /exhausted/)
+
+ check_index_exists
+ end
+ end
+ end
+ end
+
+ def expect_to_execute_concurrently_in_order(sql)
+ # Indexes cannot be created CONCURRENTLY in a transaction. Since the tests are wrapped in transactions,
+ # verify the original call but pass through the non-concurrent form.
+ expect(connection).to receive(:execute).with(sql).ordered.and_wrap_original do |method, sql|
+ method.call(sql.sub(/CONCURRENTLY/, ''))
+ end
+ end
+
+ def expect_index_rename(from, to)
+ expect(connection).to receive(:execute).with(<<~SQL).ordered
+ ALTER INDEX "public"."#{from}"
+ RENAME TO "#{to}"
+ SQL
+ end
+
+ def find_index_create_statement
+ ActiveRecord::Base.connection.select_value(<<~SQL)
+ SELECT indexdef
+ FROM pg_indexes
+ WHERE schemaname = 'public'
+ AND indexname = #{ActiveRecord::Base.connection.quote(index.name)}
+ SQL
+ end
+
+ def check_index_exists
+ expect(find_index_create_statement).to eq(original_index)
+ end
+end
diff --git a/spec/lib/gitlab/database/reindexing/reindex_action_spec.rb b/spec/lib/gitlab/database/reindexing/reindex_action_spec.rb
new file mode 100644
index 00000000000..efb5b8463a1
--- /dev/null
+++ b/spec/lib/gitlab/database/reindexing/reindex_action_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Reindexing::ReindexAction, '.keep_track_of' do
+ let(:index) { double('index', identifier: 'public.something', ondisk_size_bytes: 10240, reload: nil) }
+ let(:size_after) { 512 }
+
+ it 'yields to the caller' do
+ expect { |b| described_class.keep_track_of(index, &b) }.to yield_control
+ end
+
+ def find_record
+ described_class.find_by(index_identifier: index.identifier)
+ end
+
+ it 'creates the record with a start time and updates its end time' do
+ freeze_time do
+ described_class.keep_track_of(index) do
+ expect(find_record.action_start).to be_within(1.second).of(Time.zone.now)
+
+ travel(10.seconds)
+ end
+
+ duration = find_record.action_end - find_record.action_start
+
+ expect(duration).to be_within(1.second).of(10.seconds)
+ end
+ end
+
+ it 'creates the record with its status set to :started and updates its state to :finished' do
+ described_class.keep_track_of(index) do
+ expect(find_record).to be_started
+ end
+
+ expect(find_record).to be_finished
+ end
+
+ it 'creates the record with the indexes start size and updates its end size' do
+ described_class.keep_track_of(index) do
+ expect(find_record.ondisk_size_bytes_start).to eq(index.ondisk_size_bytes)
+
+ expect(index).to receive(:reload).once
+ allow(index).to receive(:ondisk_size_bytes).and_return(size_after)
+ end
+
+ expect(find_record.ondisk_size_bytes_end).to eq(size_after)
+ end
+
+ context 'in case of errors' do
+ it 'sets the state to failed' do
+ expect do
+ described_class.keep_track_of(index) do
+ raise 'something went wrong'
+ end
+ end.to raise_error(/something went wrong/)
+
+ expect(find_record).to be_failed
+ end
+
+ it 'records the end time' do
+ freeze_time do
+ expect do
+ described_class.keep_track_of(index) do
+ raise 'something went wrong'
+ end
+ end.to raise_error(/something went wrong/)
+
+ expect(find_record.action_end).to be_within(1.second).of(Time.zone.now)
+ end
+ end
+
+ it 'records the resulting index size' do
+ expect(index).to receive(:reload).once
+ allow(index).to receive(:ondisk_size_bytes).and_return(size_after)
+
+ expect do
+ described_class.keep_track_of(index) do
+ raise 'something went wrong'
+ end
+ end.to raise_error(/something went wrong/)
+
+ expect(find_record.ondisk_size_bytes_end).to eq(size_after)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/reindexing_spec.rb b/spec/lib/gitlab/database/reindexing_spec.rb
new file mode 100644
index 00000000000..26954a9a32f
--- /dev/null
+++ b/spec/lib/gitlab/database/reindexing_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Reindexing do
+ describe '.perform' do
+ before do
+ allow(Gitlab::Database::Reindexing::ReindexAction).to receive(:keep_track_of).and_yield
+ end
+
+ shared_examples_for 'reindexing' do
+ before do
+ indexes.zip(reindexers).each do |index, reindexer|
+ allow(Gitlab::Database::Reindexing::ConcurrentReindex).to receive(:new).with(index).and_return(reindexer)
+ allow(reindexer).to receive(:perform)
+ end
+ end
+
+ it 'performs concurrent reindexing for each index' do
+ indexes.zip(reindexers).each do |index, reindexer|
+ expect(Gitlab::Database::Reindexing::ConcurrentReindex).to receive(:new).with(index).ordered.and_return(reindexer)
+ expect(reindexer).to receive(:perform)
+ end
+
+ subject
+ end
+
+ it 'keeps track of actions and creates ReindexAction records' do
+ indexes.each do |index|
+ expect(Gitlab::Database::Reindexing::ReindexAction).to receive(:keep_track_of).with(index).and_yield
+ end
+
+ subject
+ end
+ end
+
+ context 'with multiple indexes' do
+ subject { described_class.perform(indexes) }
+
+ let(:indexes) { [instance_double('Gitlab::Database::PostgresIndex'), instance_double('Gitlab::Database::PostgresIndex')] }
+ let(:reindexers) { [instance_double('Gitlab::Database::Reindexing::ConcurrentReindex'), instance_double('Gitlab::Database::Reindexing::ConcurrentReindex')] }
+
+ it_behaves_like 'reindexing'
+ end
+
+ context 'single index' do
+ subject { described_class.perform(indexes.first) }
+
+ let(:indexes) { [instance_double('Gitlab::Database::PostgresIndex')] }
+ let(:reindexers) { [instance_double('Gitlab::Database::Reindexing::ConcurrentReindex')] }
+
+ it_behaves_like 'reindexing'
+ end
+ end
+
+ describe '.candidate_indexes' do
+ subject { described_class.candidate_indexes }
+
+ it 'retrieves regular indexes that are no left-overs from previous runs' do
+ result = double
+ expect(Gitlab::Database::PostgresIndex).to receive_message_chain('regular.not_match.not_match').with(no_args).with('^tmp_reindex_').with('^old_reindex_').and_return(result)
+
+ expect(subject).to eq(result)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/similarity_score_spec.rb b/spec/lib/gitlab/database/similarity_score_spec.rb
index e36a4f610e1..cf75e5a72d9 100644
--- a/spec/lib/gitlab/database/similarity_score_spec.rb
+++ b/spec/lib/gitlab/database/similarity_score_spec.rb
@@ -90,4 +90,15 @@ RSpec.describe Gitlab::Database::SimilarityScore do
expect(subject).to eq(%w[different same gitlab-danger])
end
end
+
+ describe 'annotation' do
+ it 'annotates the generated SQL expression' do
+ expression = Gitlab::Database::SimilarityScore.build_expression(search: 'test', rules: [
+ { column: Arel.sql('path'), multiplier: 1 },
+ { column: Arel.sql('name'), multiplier: 0.8 }
+ ])
+
+ expect(Gitlab::Database::SimilarityScore).to be_order_by_similarity(expression)
+ end
+ end
end
diff --git a/spec/lib/gitlab/database/with_lock_retries_spec.rb b/spec/lib/gitlab/database/with_lock_retries_spec.rb
index 2cc6e175500..220ae705e71 100644
--- a/spec/lib/gitlab/database/with_lock_retries_spec.rb
+++ b/spec/lib/gitlab/database/with_lock_retries_spec.rb
@@ -104,9 +104,69 @@ RSpec.describe Gitlab::Database::WithLockRetries do
end
context 'after 3 iterations' do
- let(:retry_count) { 4 }
+ it_behaves_like 'retriable exclusive lock on `projects`' do
+ let(:retry_count) { 4 }
+ end
+
+ context 'setting the idle transaction timeout' do
+ context 'when there is no outer transaction: disable_ddl_transaction! is set in the migration' do
+ it 'does not disable the idle transaction timeout' do
+ allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false)
+ allow(subject).to receive(:run_block_with_transaction).once.and_raise(ActiveRecord::LockWaitTimeout)
+ allow(subject).to receive(:run_block_with_transaction).once
+
+ expect(subject).not_to receive(:disable_idle_in_transaction_timeout)
+
+ subject.run {}
+ end
+ end
- it_behaves_like 'retriable exclusive lock on `projects`'
+ context 'when there is outer transaction: disable_ddl_transaction! is not set in the migration' do
+ it 'disables the idle transaction timeout so the code can sleep and retry' do
+ allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(true)
+
+ n = 0
+ allow(subject).to receive(:run_block_with_transaction).twice do
+ n += 1
+ raise(ActiveRecord::LockWaitTimeout) if n == 1
+ end
+
+ expect(subject).to receive(:disable_idle_in_transaction_timeout).once
+
+ subject.run {}
+ end
+ end
+ end
+ end
+
+ context 'after the retries are exhausted' do
+ let(:timing_configuration) do
+ [
+ [1.second, 1.second]
+ ]
+ end
+
+ context 'when there is no outer transaction: disable_ddl_transaction! is set in the migration' do
+ it 'does not disable the lock_timeout' do
+ allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false)
+ allow(subject).to receive(:run_block_with_transaction).once.and_raise(ActiveRecord::LockWaitTimeout)
+
+ expect(subject).not_to receive(:disable_lock_timeout)
+
+ subject.run {}
+ end
+ end
+
+ context 'when there is outer transaction: disable_ddl_transaction! is not set in the migration' do
+ it 'disables the lock_timeout' do
+ allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(true)
+ allow(subject).to receive(:run_block_with_transaction).once.and_raise(ActiveRecord::LockWaitTimeout)
+
+ expect(subject).to receive(:disable_lock_timeout)
+
+ subject.run {}
+ end
+ end
end
context 'after the retries, without setting lock_timeout' do
diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb
index 420aa0a8df6..dd3daf1b2c5 100644
--- a/spec/lib/gitlab/database_spec.rb
+++ b/spec/lib/gitlab/database_spec.rb
@@ -70,25 +70,6 @@ RSpec.describe Gitlab::Database do
end
end
- describe '.postgresql_9_or_less?' do
- it 'returns true when using postgresql 8.4' do
- allow(described_class).to receive(:version).and_return('8.4')
- expect(described_class.postgresql_9_or_less?).to eq(true)
- end
-
- it 'returns true when using PostgreSQL 9.6' do
- allow(described_class).to receive(:version).and_return('9.6')
-
- expect(described_class.postgresql_9_or_less?).to eq(true)
- end
-
- it 'returns false when using PostgreSQL 10 or newer' do
- allow(described_class).to receive(:version).and_return('10')
-
- expect(described_class.postgresql_9_or_less?).to eq(false)
- end
- end
-
describe '.postgresql_minimum_supported_version?' do
it 'returns false when using PostgreSQL 10' do
allow(described_class).to receive(:version).and_return('10')
@@ -150,68 +131,6 @@ RSpec.describe Gitlab::Database do
end
end
- describe '.pg_wal_lsn_diff' do
- it 'returns old name when using PostgreSQL 9.6' do
- allow(described_class).to receive(:version).and_return('9.6')
-
- expect(described_class.pg_wal_lsn_diff).to eq('pg_xlog_location_diff')
- end
-
- it 'returns new name when using PostgreSQL 10 or newer' do
- allow(described_class).to receive(:version).and_return('10')
-
- expect(described_class.pg_wal_lsn_diff).to eq('pg_wal_lsn_diff')
- end
- end
-
- describe '.pg_current_wal_insert_lsn' do
- it 'returns old name when using PostgreSQL 9.6' do
- allow(described_class).to receive(:version).and_return('9.6')
-
- expect(described_class.pg_current_wal_insert_lsn).to eq('pg_current_xlog_insert_location')
- end
-
- it 'returns new name when using PostgreSQL 10 or newer' do
- allow(described_class).to receive(:version).and_return('10')
-
- expect(described_class.pg_current_wal_insert_lsn).to eq('pg_current_wal_insert_lsn')
- end
- end
-
- describe '.pg_last_wal_receive_lsn' do
- it 'returns old name when using PostgreSQL 9.6' do
- allow(described_class).to receive(:version).and_return('9.6')
-
- expect(described_class.pg_last_wal_receive_lsn).to eq('pg_last_xlog_receive_location')
- end
-
- it 'returns new name when using PostgreSQL 10 or newer' do
- allow(described_class).to receive(:version).and_return('10')
-
- expect(described_class.pg_last_wal_receive_lsn).to eq('pg_last_wal_receive_lsn')
- end
- end
-
- describe '.pg_last_wal_replay_lsn' do
- it 'returns old name when using PostgreSQL 9.6' do
- allow(described_class).to receive(:version).and_return('9.6')
-
- expect(described_class.pg_last_wal_replay_lsn).to eq('pg_last_xlog_replay_location')
- end
-
- it 'returns new name when using PostgreSQL 10 or newer' do
- allow(described_class).to receive(:version).and_return('10')
-
- expect(described_class.pg_last_wal_replay_lsn).to eq('pg_last_wal_replay_lsn')
- end
- end
-
- describe '.pg_last_xact_replay_timestamp' do
- it 'returns pg_last_xact_replay_timestamp' do
- expect(described_class.pg_last_xact_replay_timestamp).to eq('pg_last_xact_replay_timestamp')
- end
- end
-
describe '.nulls_last_order' do
it { expect(described_class.nulls_last_order('column', 'ASC')).to eq 'column ASC NULLS LAST'}
it { expect(described_class.nulls_last_order('column', 'DESC')).to eq 'column DESC NULLS LAST'}
diff --git a/spec/lib/gitlab/diff/file_collection/merge_request_diff_batch_spec.rb b/spec/lib/gitlab/diff/file_collection/merge_request_diff_batch_spec.rb
index bd60c24859c..72a66b0451e 100644
--- a/spec/lib/gitlab/diff/file_collection/merge_request_diff_batch_spec.rb
+++ b/spec/lib/gitlab/diff/file_collection/merge_request_diff_batch_spec.rb
@@ -120,7 +120,7 @@ RSpec.describe Gitlab::Diff::FileCollection::MergeRequestDiffBatch do
described_class.new(merge_request.merge_request_diff,
batch_page,
batch_size,
- collection_default_args)
+ **collection_default_args)
end
end
diff --git a/spec/lib/gitlab/diff/highlight_cache_spec.rb b/spec/lib/gitlab/diff/highlight_cache_spec.rb
index 7e926f86096..f6810d7a966 100644
--- a/spec/lib/gitlab/diff/highlight_cache_spec.rb
+++ b/spec/lib/gitlab/diff/highlight_cache_spec.rb
@@ -43,7 +43,8 @@ RSpec.describe Gitlab::Diff::HighlightCache, :clean_gitlab_redis_cache do
describe '#decorate' do
# Manually creates a Diff::File object to avoid triggering the cache on
- # the FileCollection::MergeRequestDiff
+ # the FileCollection::MergeRequestDiff
+ #
let(:diff_file) do
diffs = merge_request.diffs
raw_diff = diffs.diffable.raw_diffs(diffs.diff_options.merge(paths: ['CHANGELOG'])).first
@@ -73,6 +74,37 @@ RSpec.describe Gitlab::Diff::HighlightCache, :clean_gitlab_redis_cache do
expect(rich_texts).to all(be_html_safe)
end
+
+ context "when diff_file is uncached due to default_max_patch_bytes change" do
+ before do
+ expect(cache).to receive(:read_file).at_least(:once).and_return([])
+
+ # Stub out the application's default and current patch size limits. We
+ # want them to be different, and the diff file to be sized between
+ # the 2 values.
+ #
+ diff_file_size_kb = (diff_file.diff.diff.bytesize * 10)
+
+ stub_const("#{diff_file.diff.class}::DEFAULT_MAX_PATCH_BYTES", diff_file_size_kb - 1 )
+ expect(diff_file.diff.class).to receive(:patch_safe_limit_bytes).and_return(diff_file_size_kb + 1)
+ expect(diff_file.diff.class)
+ .to receive(:patch_safe_limit_bytes)
+ .with(diff_file.diff.class::DEFAULT_MAX_PATCH_BYTES)
+ .and_call_original
+ end
+
+ it "manually writes highlighted lines to the cache" do
+ expect(cache).to receive(:write_to_redis_hash).and_call_original
+
+ cache.decorate(diff_file)
+ end
+
+ it "assigns highlighted diff lines to the DiffFile" do
+ expect(diff_file.highlighted_diff_lines.size).to be > 5
+
+ cache.decorate(diff_file)
+ end
+ end
end
shared_examples 'caches missing entries' do
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 07b8070be30..ef448ee96a4 100644
--- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
@@ -65,24 +65,15 @@ RSpec.describe Gitlab::Email::Handler::CreateNoteHandler do
end
end
- [true, false].each do |state_tracking_enabled|
- context "and current user can update noteable #{state_tracking_enabled ? 'enabled' : 'disabled'}" do
- before do
- stub_feature_flags(track_resource_state_change_events: state_tracking_enabled)
-
- project.add_developer(user)
- end
+ context "and current user can update noteable" do
+ before do
+ project.add_developer(user)
+ end
- it 'does not raise an error' do
- if state_tracking_enabled
- expect { receiver.execute }.to change { noteable.resource_state_events.count }.by(1)
- else
- # One system note is created for the 'close' event
- expect { receiver.execute }.to change { noteable.notes.count }.by(1)
- end
+ it 'does not raise an error' do
+ expect { receiver.execute }.to change { noteable.resource_state_events.count }.by(1)
- expect(noteable.reload).to be_closed
- end
+ expect(noteable.reload).to be_closed
end
end
end
diff --git a/spec/lib/gitlab/exclusive_lease_helpers_spec.rb b/spec/lib/gitlab/exclusive_lease_helpers_spec.rb
index 01e2fe8ce17..40669f06371 100644
--- a/spec/lib/gitlab/exclusive_lease_helpers_spec.rb
+++ b/spec/lib/gitlab/exclusive_lease_helpers_spec.rb
@@ -25,13 +25,17 @@ RSpec.describe Gitlab::ExclusiveLeaseHelpers, :clean_gitlab_redis_shared_state d
let!(:lease) { stub_exclusive_lease(unique_key, 'uuid') }
it 'calls the given block' do
- expect { |b| class_instance.in_lock(unique_key, &b) }.to yield_with_args(false)
+ expect { |b| class_instance.in_lock(unique_key, &b) }
+ .to yield_with_args(false, an_instance_of(described_class::SleepingLock))
end
it 'calls the given block continuously' do
- expect { |b| class_instance.in_lock(unique_key, &b) }.to yield_with_args(false)
- expect { |b| class_instance.in_lock(unique_key, &b) }.to yield_with_args(false)
- expect { |b| class_instance.in_lock(unique_key, &b) }.to yield_with_args(false)
+ expect { |b| class_instance.in_lock(unique_key, &b) }
+ .to yield_with_args(false, an_instance_of(described_class::SleepingLock))
+ expect { |b| class_instance.in_lock(unique_key, &b) }
+ .to yield_with_args(false, an_instance_of(described_class::SleepingLock))
+ expect { |b| class_instance.in_lock(unique_key, &b) }
+ .to yield_with_args(false, an_instance_of(described_class::SleepingLock))
end
it 'cancels the exclusive lease after the block' do
@@ -74,7 +78,8 @@ RSpec.describe Gitlab::ExclusiveLeaseHelpers, :clean_gitlab_redis_shared_state d
expect(lease).to receive(:try_obtain).exactly(3).times { nil }
expect(lease).to receive(:try_obtain).once { unique_key }
- expect { |b| class_instance.in_lock(unique_key, &b) }.to yield_with_args(true)
+ expect { |b| class_instance.in_lock(unique_key, &b) }
+ .to yield_with_args(true, an_instance_of(described_class::SleepingLock))
end
end
end
diff --git a/spec/lib/gitlab/experimentation_spec.rb b/spec/lib/gitlab/experimentation_spec.rb
index 9bc865f4d29..aecbc8734db 100644
--- a/spec/lib/gitlab/experimentation_spec.rb
+++ b/spec/lib/gitlab/experimentation_spec.rb
@@ -69,12 +69,26 @@ RSpec.describe Gitlab::Experimentation do
end
end
+ describe '#push_frontend_experiment' do
+ it 'pushes an experiment to the frontend' do
+ gon = instance_double('gon')
+ experiments = { experiments: { 'myExperiment' => true } }
+
+ stub_experiment_for_user(my_experiment: true)
+ allow(controller).to receive(:gon).and_return(gon)
+
+ expect(gon).to receive(:push).with(experiments, true)
+
+ controller.push_frontend_experiment(:my_experiment)
+ end
+ end
+
describe '#experiment_enabled?' do
subject { controller.experiment_enabled?(:test_experiment) }
context 'cookie is not present' do
- it 'calls Gitlab::Experimentation.enabled_for_user? with the name of the experiment and an experimentation_subject_index of nil' do
- expect(Gitlab::Experimentation).to receive(:enabled_for_user?).with(:test_experiment, nil)
+ it 'calls Gitlab::Experimentation.enabled_for_value? with the name of the experiment and an experimentation_subject_index of nil' do
+ expect(Gitlab::Experimentation).to receive(:enabled_for_value?).with(:test_experiment, nil)
controller.experiment_enabled?(:test_experiment)
end
end
@@ -85,22 +99,22 @@ RSpec.describe Gitlab::Experimentation do
get :index
end
- it 'calls Gitlab::Experimentation.enabled_for_user? with the name of the experiment and an experimentation_subject_index of the modulo 100 of the hex value of the uuid' do
+ it 'calls Gitlab::Experimentation.enabled_for_value? with the name of the experiment and an experimentation_subject_index of the modulo 100 of the hex value of the uuid' do
# 'abcd1234'.hex % 100 = 76
- expect(Gitlab::Experimentation).to receive(:enabled_for_user?).with(:test_experiment, 76)
+ expect(Gitlab::Experimentation).to receive(:enabled_for_value?).with(:test_experiment, 76)
controller.experiment_enabled?(:test_experiment)
end
end
it 'returns true when DNT: 0 is set in the request' do
- allow(Gitlab::Experimentation).to receive(:enabled_for_user?) { true }
+ allow(Gitlab::Experimentation).to receive(:enabled_for_value?) { true }
controller.request.headers['DNT'] = '0'
is_expected.to be_truthy
end
it 'returns false when DNT: 1 is set in the request' do
- allow(Gitlab::Experimentation).to receive(:enabled_for_user?) { true }
+ allow(Gitlab::Experimentation).to receive(:enabled_for_value?) { true }
controller.request.headers['DNT'] = '1'
is_expected.to be_falsy
@@ -336,8 +350,8 @@ RSpec.describe Gitlab::Experimentation do
end
end
- describe '.enabled_for_user?' do
- subject { described_class.enabled_for_user?(:test_experiment, experimentation_subject_index) }
+ describe '.enabled_for_value?' do
+ subject { described_class.enabled_for_value?(:test_experiment, experimentation_subject_index) }
let(:experimentation_subject_index) { 9 }
@@ -377,4 +391,32 @@ RSpec.describe Gitlab::Experimentation do
end
end
end
+
+ describe '.enabled_for_attribute?' do
+ subject { described_class.enabled_for_attribute?(:test_experiment, attribute) }
+
+ let(:attribute) { 'abcd' } # Digest::SHA1.hexdigest('abcd').hex % 100 = 7
+
+ context 'experiment is disabled' do
+ before do
+ allow(described_class).to receive(:enabled?).and_return(false)
+ end
+
+ it { is_expected.to be false }
+ end
+
+ context 'experiment is enabled' do
+ before do
+ allow(described_class).to receive(:enabled?).and_return(true)
+ end
+
+ it { is_expected.to be true }
+
+ context 'outside enabled ratio' do
+ let(:attribute) { 'abc' } # Digest::SHA1.hexdigest('abc').hex % 100 = 17
+
+ it { is_expected.to be false }
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/git/branch_spec.rb b/spec/lib/gitlab/git/branch_spec.rb
index e1bcf4aeeb1..9271f635b14 100644
--- a/spec/lib/gitlab/git/branch_spec.rb
+++ b/spec/lib/gitlab/git/branch_spec.rb
@@ -85,9 +85,9 @@ RSpec.describe Gitlab::Git::Branch, :seed_helper do
}
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 } }
+ let(:stale_sha) { travel_to(Gitlab::Git::Branch::STALE_BRANCH_THRESHOLD.ago - 5.days) { create_commit } }
+ let(:active_sha) { travel_to(Gitlab::Git::Branch::STALE_BRANCH_THRESHOLD.ago + 5.days) { create_commit } }
+ let(:future_sha) { travel_to(100.days.since) { create_commit } }
before do
repository.create_branch('stale-1', stale_sha)
diff --git a/spec/lib/gitlab/git/diff_collection_spec.rb b/spec/lib/gitlab/git/diff_collection_spec.rb
index b202015464f..8198c2651a7 100644
--- a/spec/lib/gitlab/git/diff_collection_spec.rb
+++ b/spec/lib/gitlab/git/diff_collection_spec.rb
@@ -531,7 +531,9 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
let(:iterator) { [fake_diff(1, 1)] * 4 }
before do
- stub_const('Gitlab::Git::DiffCollection::DEFAULT_LIMITS', { max_files: 2, max_lines: max_lines })
+ allow(Gitlab::Git::DiffCollection)
+ .to receive(:default_limits)
+ .and_return({ max_files: 2, max_lines: max_lines })
end
it 'prunes diffs by default even little ones' do
@@ -556,7 +558,9 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
end
before do
- stub_const('Gitlab::Git::DiffCollection::DEFAULT_LIMITS', { max_files: max_files, max_lines: 80 })
+ allow(Gitlab::Git::DiffCollection)
+ .to receive(:default_limits)
+ .and_return({ max_files: max_files, max_lines: 80 })
end
it 'prunes diffs by default even little ones' do
@@ -581,7 +585,9 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
end
before do
- stub_const('Gitlab::Git::DiffCollection::DEFAULT_LIMITS', { max_files: max_files, max_lines: 80 })
+ allow(Gitlab::Git::DiffCollection)
+ .to receive(:default_limits)
+ .and_return({ max_files: max_files, max_lines: 80 })
end
it 'prunes diffs by default even little ones' do
@@ -665,8 +671,9 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
end
before do
- stub_const('Gitlab::Git::DiffCollection::DEFAULT_LIMITS',
- { max_files: max_files, max_lines: 80 })
+ allow(Gitlab::Git::DiffCollection)
+ .to receive(:default_limits)
+ .and_return({ max_files: max_files, max_lines: 80 })
end
it 'considers size of diffs before the offset for prunning' do
diff --git a/spec/lib/gitlab/git/diff_spec.rb b/spec/lib/gitlab/git/diff_spec.rb
index 117c519e98d..980a52bb61e 100644
--- a/spec/lib/gitlab/git/diff_spec.rb
+++ b/spec/lib/gitlab/git/diff_spec.rb
@@ -284,13 +284,21 @@ EOT
end
describe '#line_count' do
- it 'returns the correct number of lines' do
- diff = described_class.new(gitaly_diff)
+ let(:diff) { described_class.new(gitaly_diff) }
+ it 'returns the correct number of lines' do
expect(diff.line_count).to eq(7)
end
end
+ describe "#diff_bytesize" do
+ let(:diff) { described_class.new(gitaly_diff) }
+
+ it "returns the size of the diff in bytes" do
+ expect(diff.diff_bytesize).to eq(diff.diff.bytesize)
+ end
+ end
+
describe '#too_large?' do
it 'returns true for a diff that is too large' do
diff = described_class.new(diff: 'a' * 204800)
diff --git a/spec/lib/gitlab/git/object_pool_spec.rb b/spec/lib/gitlab/git/object_pool_spec.rb
index c8fbc674c73..e1873c6ddb5 100644
--- a/spec/lib/gitlab/git/object_pool_spec.rb
+++ b/spec/lib/gitlab/git/object_pool_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe Gitlab::Git::ObjectPool do
describe '#create' do
before do
- subject.create
+ subject.create # rubocop:disable Rails/SaveBang
end
context "when the pool doesn't exist yet" do
@@ -31,7 +31,7 @@ RSpec.describe Gitlab::Git::ObjectPool do
context 'when the pool already exists' do
it 'raises an FailedPrecondition' do
expect do
- subject.create
+ subject.create # rubocop:disable Rails/SaveBang
end.to raise_error(GRPC::FailedPrecondition)
end
end
diff --git a/spec/lib/gitlab/git/remote_mirror_spec.rb b/spec/lib/gitlab/git/remote_mirror_spec.rb
index 423c4aa9620..92504b7aafe 100644
--- a/spec/lib/gitlab/git/remote_mirror_spec.rb
+++ b/spec/lib/gitlab/git/remote_mirror_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe Gitlab::Git::RemoteMirror do
.to receive(:update_remote_mirror)
.with(ref_name, ['master'], ssh_key: 'KEY', known_hosts: 'KNOWN HOSTS', keep_divergent_refs: true)
- remote_mirror.update
+ remote_mirror.update # rubocop:disable Rails/SaveBang
end
it 'wraps gitaly errors' do
@@ -24,7 +24,7 @@ RSpec.describe Gitlab::Git::RemoteMirror do
.to receive(:update_remote_mirror)
.and_raise(StandardError)
- expect { remote_mirror.update }.to raise_error(StandardError)
+ expect { remote_mirror.update }.to raise_error(StandardError) # rubocop:disable Rails/SaveBang
end
end
end
diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb
index 73eecd3401a..3dc0db1bc3c 100644
--- a/spec/lib/gitlab/git/repository_spec.rb
+++ b/spec/lib/gitlab/git/repository_spec.rb
@@ -2079,7 +2079,7 @@ RSpec.describe Gitlab::Git::Repository, :seed_helper do
let(:object_pool_rugged) { Rugged::Repository.new(object_pool_path) }
before do
- object_pool.create
+ object_pool.create # rubocop:disable Rails/SaveBang
end
it 'does not raise an error when disconnecting a non-linked repository' do
diff --git a/spec/lib/gitlab/git/rugged_impl/use_rugged_spec.rb b/spec/lib/gitlab/git/rugged_impl/use_rugged_spec.rb
index 4f6a3fb823e..16cea1dc1a3 100644
--- a/spec/lib/gitlab/git/rugged_impl/use_rugged_spec.rb
+++ b/spec/lib/gitlab/git/rugged_impl/use_rugged_spec.rb
@@ -7,7 +7,7 @@ require 'tempfile'
RSpec.describe Gitlab::Git::RuggedImpl::UseRugged, :seed_helper do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
- let(:feature_flag_name) { 'feature-flag-name' }
+ let(:feature_flag_name) { wrapper.rugged_feature_keys.first }
let(:temp_gitaly_metadata_file) { create_temporary_gitaly_metadata_file }
before(:all) do
@@ -47,7 +47,7 @@ RSpec.describe Gitlab::Git::RuggedImpl::UseRugged, :seed_helper do
end
end
- context 'when feature flag is not persisted' do
+ context 'when feature flag is not persisted', stub_feature_flags: false do
context 'when running puma with multiple threads' do
before do
allow(subject).to receive(:running_puma_with_multiple_threads?).and_return(true)
diff --git a/spec/lib/gitlab/git_access_snippet_spec.rb b/spec/lib/gitlab/git_access_snippet_spec.rb
index 3b8b5fd82c6..362ea3c006e 100644
--- a/spec/lib/gitlab/git_access_snippet_spec.rb
+++ b/spec/lib/gitlab/git_access_snippet_spec.rb
@@ -232,29 +232,6 @@ RSpec.describe Gitlab::GitAccessSnippet do
end
end
- context 'when geo is enabled', if: Gitlab.ee? do
- let(:user) { snippet.author }
- let!(:primary_node) { FactoryBot.create(:geo_node, :primary) }
-
- before do
- allow(::Gitlab::Database).to receive(:read_only?).and_return(true)
- allow(::Gitlab::Geo).to receive(:secondary_with_primary?).and_return(true)
- end
-
- # Without override, push access would return Gitlab::GitAccessResult::CustomAction
- it 'skips geo for snippet' do
- expect { push_access_check }.to raise_forbidden(/You can't push code to a read-only GitLab instance/)
- end
-
- context 'when user is migration bot' do
- let(:user) { migration_bot }
-
- it 'skips geo for snippet' do
- expect { push_access_check }.to raise_forbidden(/You can't push code to a read-only GitLab instance/)
- end
- end
- end
-
context 'when changes are specific' do
let(:changes) { "2d1db523e11e777e49377cfb22d368deec3f0793 ddd0f15ae83993f5cb66a927a28673882e99100b master" }
let(:user) { snippet.author }
diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
index 9581b017839..f977fe1638f 100644
--- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
@@ -13,6 +13,10 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
let(:client) { described_class.new(repository) }
describe '#diff_from_parent' do
+ before do
+ stub_feature_flags(increased_diff_limits: false)
+ end
+
context 'when a commit has a parent' do
it 'sends an RPC request with the parent ID as left commit' do
request = Gitaly::CommitDiffRequest.new(
diff --git a/spec/lib/gitlab/gon_helper_spec.rb b/spec/lib/gitlab/gon_helper_spec.rb
index 95db6b2b4e0..3d3f381b6d2 100644
--- a/spec/lib/gitlab/gon_helper_spec.rb
+++ b/spec/lib/gitlab/gon_helper_spec.rb
@@ -10,6 +10,10 @@ RSpec.describe Gitlab::GonHelper do
end
describe '#push_frontend_feature_flag' do
+ before do
+ skip_feature_flags_yaml_validation
+ end
+
it 'pushes a feature flag to the frontend' do
gon = instance_double('gon')
thing = stub_feature_flag_gate('thing')
diff --git a/spec/lib/gitlab/grape_logging/loggers/queue_duration_logger_spec.rb b/spec/lib/gitlab/grape_logging/loggers/queue_duration_logger_spec.rb
index e68c1446502..9538c4bae2b 100644
--- a/spec/lib/gitlab/grape_logging/loggers/queue_duration_logger_spec.rb
+++ b/spec/lib/gitlab/grape_logging/loggers/queue_duration_logger_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe Gitlab::GrapeLogging::Loggers::QueueDurationLogger do
end
it 'returns the correct duration in seconds' do
- Timecop.freeze(start_time) do
+ travel_to(start_time) do
subject.before
expect(subject.parameters(mock_request, nil)).to eq( { 'queue_duration_s': 1.hour.to_f })
diff --git a/spec/lib/gitlab/graphql/markdown_field/resolver_spec.rb b/spec/lib/gitlab/graphql/markdown_field/resolver_spec.rb
deleted file mode 100644
index af604e1c7d5..00000000000
--- a/spec/lib/gitlab/graphql/markdown_field/resolver_spec.rb
+++ /dev/null
@@ -1,33 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe Gitlab::Graphql::MarkdownField::Resolver do
- include Gitlab::Routing
- let(:resolver) { described_class.new(:note) }
-
- describe '#proc' do
- let(:project) { create(:project, :public) }
- let(:issue) { create(:issue, project: project) }
- let(:note) do
- create(:note,
- note: "Referencing #{issue.to_reference(full: true)}")
- end
-
- it 'renders markdown correctly' do
- expect(resolver.proc.call(note, {}, {})).to include(issue_path(issue))
- end
-
- context 'when the issue is not publicly accessible' do
- let(:project) { create(:project, :private) }
-
- it 'hides the references from users that are not allowed to see the reference' do
- expect(resolver.proc.call(note, {}, {})).not_to include(issue_path(issue))
- end
-
- it 'shows the reference to users that are allowed to see it' do
- expect(resolver.proc.call(note, {}, { current_user: project.owner }))
- .to include(issue_path(issue))
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/graphql/markdown_field_spec.rb b/spec/lib/gitlab/graphql/markdown_field_spec.rb
index e3da925376e..82090f992eb 100644
--- a/spec/lib/gitlab/graphql/markdown_field_spec.rb
+++ b/spec/lib/gitlab/graphql/markdown_field_spec.rb
@@ -2,6 +2,8 @@
require 'spec_helper'
RSpec.describe Gitlab::Graphql::MarkdownField do
+ include Gitlab::Routing
+
describe '.markdown_field' do
it 'creates the field with some default attributes' do
field = class_with_markdown_field(:test_html, null: true, method: :hello).fields['testHtml']
@@ -13,7 +15,7 @@ RSpec.describe Gitlab::Graphql::MarkdownField do
end
context 'developer warnings' do
- let(:expected_error) { /Only `method` is allowed to specify the markdown field/ }
+ let_it_be(:expected_error) { /Only `method` is allowed to specify the markdown field/ }
it 'raises when passing a resolver' do
expect { class_with_markdown_field(:test_html, null: true, resolver: 'not really') }
@@ -27,30 +29,61 @@ RSpec.describe Gitlab::Graphql::MarkdownField do
end
context 'resolving markdown' do
- let(:note) { build(:note, note: '# Markdown!') }
- let(:thing_with_markdown) { double('markdown thing', object: note) }
- let(:expected_markdown) { '<h1 data-sourcepos="1:1-1:11" dir="auto">Markdown!</h1>' }
- let(:query_type) { GraphQL::ObjectType.new }
- let(:schema) { GraphQL::Schema.define(query: query_type, mutation: nil)}
- let(:context) { GraphQL::Query::Context.new(query: OpenStruct.new(schema: schema), values: nil, object: nil) }
+ let_it_be(:note) { build(:note, note: '# Markdown!') }
+ let_it_be(:expected_markdown) { '<h1 data-sourcepos="1:1-1:11" dir="auto">Markdown!</h1>' }
+ let_it_be(:query_type) { GraphQL::ObjectType.new }
+ let_it_be(:schema) { GraphQL::Schema.define(query: query_type, mutation: nil)}
+ let_it_be(:query) { GraphQL::Query.new(schema, document: nil, context: {}, variables: {}) }
+ let_it_be(:context) { GraphQL::Query::Context.new(query: query, values: {}, object: nil) }
+
+ let(:type_class) { class_with_markdown_field(:note_html, null: false) }
+ let(:type_instance) { type_class.authorized_new(note, context) }
+ let(:field) { type_class.fields['noteHtml'] }
it 'renders markdown from the same property as the field name without the `_html` suffix' do
- field = class_with_markdown_field(:note_html, null: false).fields['noteHtml']
+ expect(field.to_graphql.resolve(type_instance, {}, context)).to eq(expected_markdown)
+ end
+
+ context 'when a `method` argument is passed' do
+ let(:type_class) { class_with_markdown_field(:test_html, null: false, method: :note) }
+ let(:field) { type_class.fields['testHtml'] }
- expect(field.to_graphql.resolve(thing_with_markdown, {}, context)).to eq(expected_markdown)
+ it 'renders markdown from a specific property' do
+ expect(field.to_graphql.resolve(type_instance, {}, context)).to eq(expected_markdown)
+ end
end
- it 'renders markdown from a specific property when a `method` argument is passed' do
- field = class_with_markdown_field(:test_html, null: false, method: :note).fields['testHtml']
+ describe 'basic verification that references work' do
+ let_it_be(:project) { create(:project, :public) }
+ let(:issue) { create(:issue, project: project) }
+ let(:note) { build(:note, note: "Referencing #{issue.to_reference(full: true)}") }
+
+ it 'renders markdown correctly' do
+ expect(field.to_graphql.resolve(type_instance, {}, context)).to include(issue_path(issue))
+ end
+
+ context 'when the issue is not publicly accessible' do
+ let_it_be(:project) { create(:project, :private) }
+
+ it 'hides the references from users that are not allowed to see the reference' do
+ expect(field.to_graphql.resolve(type_instance, {}, context)).not_to include(issue_path(issue))
+ end
+
+ it 'shows the reference to users that are allowed to see it' do
+ context = GraphQL::Query::Context.new(query: query, values: { current_user: project.owner }, object: nil)
+ type_instance = type_class.authorized_new(note, context)
- expect(field.to_graphql.resolve(thing_with_markdown, {}, context)).to eq(expected_markdown)
+ expect(field.to_graphql.resolve(type_instance, {}, context)).to include(issue_path(issue))
+ end
+ end
end
end
end
def class_with_markdown_field(name, **args)
- Class.new(GraphQL::Schema::Object) do
+ Class.new(Types::BaseObject) do
prepend Gitlab::Graphql::MarkdownField
+ graphql_name 'MarkdownFieldTest'
markdown_field name, **args
end
diff --git a/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb
index 444c10074a0..77a8588e2cb 100644
--- a/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb
+++ b/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb
@@ -63,6 +63,17 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::OrderInfo do
expect(order_list.first.sort_direction).to eq :desc
end
end
+
+ context 'when ordering by CASE', :aggregate_failuers do
+ let(:relation) { Project.order(Arel::Nodes::Case.new(Project.arel_table[:pending_delete]).when(true).then(100).else(1000).asc) }
+
+ it 'assigns the right attribute name, named function, and direction' do
+ expect(order_list.count).to eq 1
+ expect(order_list.first.attribute_name).to eq 'pending_delete'
+ expect(order_list.first.named_function).to be_kind_of(Arel::Nodes::Case)
+ expect(order_list.first.sort_direction).to eq :asc
+ end
+ end
end
describe '#validate_ordering' do
diff --git a/spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb
index c7e7db4d535..fa631aa5666 100644
--- a/spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb
+++ b/spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb
@@ -136,11 +136,12 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::QueryBuilder do
let(:relation) { Project.sorted_by_similarity_desc('test', include_in_select: true) }
let(:arel_table) { Project.arel_table }
let(:decoded_cursor) { { 'similarity' => 0.5, 'id' => 100 } }
+ let(:similarity_function_call) { Gitlab::Database::SimilarityScore::SIMILARITY_FUNCTION_CALL_WITH_ANNOTATION }
let(:similarity_sql) do
[
- '(SIMILARITY(COALESCE("projects"."path", \'\'), \'test\') * CAST(\'1\' AS numeric))',
- '(SIMILARITY(COALESCE("projects"."name", \'\'), \'test\') * CAST(\'0.7\' AS numeric))',
- '(SIMILARITY(COALESCE("projects"."description", \'\'), \'test\') * CAST(\'0.2\' AS numeric))'
+ "(#{similarity_function_call}(COALESCE(\"projects\".\"path\", ''), 'test') * CAST('1' AS numeric))",
+ "(#{similarity_function_call}(COALESCE(\"projects\".\"name\", ''), 'test') * CAST('0.7' AS numeric))",
+ "(#{similarity_function_call}(COALESCE(\"projects\".\"description\", ''), 'test') * CAST('0.2' AS numeric))"
].join(' + ')
end
diff --git a/spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb b/spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb
index 89d2ab8bb87..c8432513185 100644
--- a/spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb
+++ b/spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe Gitlab::Graphql::QueryAnalyzers::LoggerAnalyzer do
end
it 'returns a duration in seconds' do
- allow(GraphQL::Analysis).to receive(:analyze_query).and_return([4, 2])
+ allow(GraphQL::Analysis).to receive(:analyze_query).and_return([4, 2, [[], []]])
allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(monotonic_time_before, monotonic_time_after)
allow(Gitlab::GraphqlLogger).to receive(:info)
diff --git a/spec/lib/gitlab/group_search_results_spec.rb b/spec/lib/gitlab/group_search_results_spec.rb
index 045c922783a..009f66d2108 100644
--- a/spec/lib/gitlab/group_search_results_spec.rb
+++ b/spec/lib/gitlab/group_search_results_spec.rb
@@ -17,10 +17,17 @@ RSpec.describe Gitlab::GroupSearchResults do
describe 'issues search' do
let_it_be(:opened_result) { create(:issue, :opened, project: project, title: 'foo opened') }
let_it_be(:closed_result) { create(:issue, :closed, project: project, title: 'foo closed') }
+ let_it_be(:confidential_result) { create(:issue, :confidential, project: project, title: 'foo confidential') }
+
let(:query) { 'foo' }
let(:scope) { 'issues' }
+ before do
+ project.add_developer(user)
+ end
+
include_examples 'search results filtered by state'
+ include_examples 'search results filtered by confidential'
end
describe 'merge_requests search' do
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 3126d87a0d6..7c33f29ebf3 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -51,6 +51,7 @@ issues:
- status_page_published_incident
- namespace
- note_authors
+- issue_email_participants
events:
- author
- project
@@ -242,6 +243,7 @@ ci_pipelines:
- latest_builds_report_results
- messages
- pipeline_artifacts
+- latest_statuses
ci_refs:
- project
- ci_pipelines
@@ -536,6 +538,8 @@ project:
- vulnerability_historical_statistics
- product_analytics_events
- pipeline_artifacts
+- terraform_states
+- alert_management_http_integrations
award_emoji:
- awardable
- user
diff --git a/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb b/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb
index 93b6f93f0ec..d084b9d7f7e 100644
--- a/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb
+++ b/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb
@@ -10,14 +10,17 @@ RSpec.describe Gitlab::ImportExport::FastHashSerializer do
# all items are properly serialized while traversing the simple hash.
subject { Gitlab::Json.parse(Gitlab::Json.generate(described_class.new(project, tree).execute)) }
- let!(:project) { setup_project }
- let(:user) { create(:user) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { setup_project }
let(:shared) { project.import_export_shared }
let(:reader) { Gitlab::ImportExport::Reader.new(shared: shared) }
let(:tree) { reader.project_tree }
- before do
+ before_all do
project.add_maintainer(user)
+ end
+
+ before do
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')
end
@@ -224,7 +227,6 @@ RSpec.describe Gitlab::ImportExport::FastHashSerializer do
group: group,
approvals_before_merge: 1
)
- allow(project).to receive(:commit).and_return(Commit.new(RepoHelpers.sample_commit, project))
issue = create(:issue, assignees: [user], project: project)
snippet = create(:project_snippet, project: project)
diff --git a/spec/lib/gitlab/import_export/group/relation_factory_spec.rb b/spec/lib/gitlab/import_export/group/relation_factory_spec.rb
index eb9a3fa9bd8..6b2f80cc80a 100644
--- a/spec/lib/gitlab/import_export/group/relation_factory_spec.rb
+++ b/spec/lib/gitlab/import_export/group/relation_factory_spec.rb
@@ -5,16 +5,19 @@ require 'spec_helper'
RSpec.describe Gitlab::ImportExport::Group::RelationFactory do
let(:group) { create(:group) }
let(:members_mapper) { double('members_mapper').as_null_object }
- let(:user) { create(:admin) }
+ let(:admin) { create(:admin) }
+ let(:importer_user) { admin }
let(:excluded_keys) { [] }
let(:created_object) do
- described_class.create(relation_sym: relation_sym,
- relation_hash: relation_hash,
- members_mapper: members_mapper,
- object_builder: Gitlab::ImportExport::Group::ObjectBuilder,
- user: user,
- importable: group,
- excluded_keys: excluded_keys)
+ described_class.create(
+ relation_sym: relation_sym,
+ relation_hash: relation_hash,
+ members_mapper: members_mapper,
+ object_builder: Gitlab::ImportExport::Group::ObjectBuilder,
+ user: importer_user,
+ importable: group,
+ excluded_keys: excluded_keys
+ )
end
context 'label object' do
@@ -24,18 +27,18 @@ RSpec.describe Gitlab::ImportExport::Group::RelationFactory do
let(:relation_hash) do
{
- 'id' => 123456,
- 'title' => 'Bruffefunc',
- 'color' => '#1d2da4',
- 'project_id' => nil,
- 'created_at' => '2019-11-20T17:02:20.546Z',
- 'updated_at' => '2019-11-20T17:02:20.546Z',
- 'template' => false,
+ 'id' => 123456,
+ 'title' => 'Bruffefunc',
+ 'color' => '#1d2da4',
+ 'project_id' => nil,
+ 'created_at' => '2019-11-20T17:02:20.546Z',
+ 'updated_at' => '2019-11-20T17:02:20.546Z',
+ 'template' => false,
'description' => 'Description',
- 'group_id' => original_group_id,
- 'type' => 'GroupLabel',
- 'priorities' => [],
- 'textColor' => '#FFFFFF'
+ 'group_id' => original_group_id,
+ 'type' => 'GroupLabel',
+ 'priorities' => [],
+ 'textColor' => '#FFFFFF'
}
end
@@ -60,58 +63,28 @@ RSpec.describe Gitlab::ImportExport::Group::RelationFactory do
end
end
- context 'Notes user references' do
- let(:relation_sym) { :notes }
- let(:new_user) { create(:user) }
- let(:exported_member) do
- {
- 'id' => 111,
- 'access_level' => 30,
- 'source_id' => 1,
- 'source_type' => 'Namespace',
- 'user_id' => 3,
- 'notification_level' => 3,
- 'created_at' => '2016-11-18T09:29:42.634Z',
- 'updated_at' => '2016-11-18T09:29:42.634Z',
- 'user' => {
- 'id' => 999,
- 'email' => new_user.email,
- 'username' => new_user.username
- }
- }
- end
-
+ it_behaves_like 'Notes user references' do
+ let(:importable) { group }
let(:relation_hash) do
{
- 'id' => 4947,
- 'note' => 'note',
+ 'id' => 4947,
+ 'note' => 'note',
'noteable_type' => 'Epic',
- 'author_id' => 999,
- 'created_at' => '2016-11-18T09:29:42.634Z',
- 'updated_at' => '2016-11-18T09:29:42.634Z',
- 'project_id' => 1,
- 'attachment' => {
+ 'author_id' => 999,
+ 'created_at' => '2016-11-18T09:29:42.634Z',
+ 'updated_at' => '2016-11-18T09:29:42.634Z',
+ 'project_id' => 1,
+ 'attachment' => {
'url' => nil
},
- 'noteable_id' => 377,
- 'system' => true,
- 'author' => {
+ 'noteable_id' => 377,
+ 'system' => true,
+ 'author' => {
'name' => 'Administrator'
},
'events' => []
}
end
-
- let(:members_mapper) do
- Gitlab::ImportExport::MembersMapper.new(
- exported_members: [exported_member],
- user: user,
- importable: group)
- end
-
- it 'maps the right author to the imported note' do
- expect(created_object.author).to eq(new_user)
- end
end
def random_id
diff --git a/spec/lib/gitlab/import_export/lfs_saver_spec.rb b/spec/lib/gitlab/import_export/lfs_saver_spec.rb
index db76eb9538b..55b4f7479b8 100644
--- a/spec/lib/gitlab/import_export/lfs_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/lfs_saver_spec.rb
@@ -74,14 +74,6 @@ RSpec.describe Gitlab::ImportExport::LfsSaver do
}
)
end
-
- it 'does not save a json file if feature is disabled' do
- stub_feature_flags(export_lfs_objects_projects: false)
-
- saver.save
-
- expect(File.exist?(lfs_json_file)).to eq(false)
- end
end
end
diff --git a/spec/lib/gitlab/import_export/project/relation_factory_spec.rb b/spec/lib/gitlab/import_export/project/relation_factory_spec.rb
index 31cf2362628..50bc6a30044 100644
--- a/spec/lib/gitlab/import_export/project/relation_factory_spec.rb
+++ b/spec/lib/gitlab/import_export/project/relation_factory_spec.rb
@@ -3,19 +3,22 @@
require 'spec_helper'
RSpec.describe Gitlab::ImportExport::Project::RelationFactory do
- let(:group) { create(:group) }
+ let(:group) { create(:group) }
let(:project) { create(:project, :repository, group: group) }
let(:members_mapper) { double('members_mapper').as_null_object }
- let(:user) { create(:admin) }
+ let(:admin) { create(:admin) }
+ let(:importer_user) { admin }
let(:excluded_keys) { [] }
let(:created_object) do
- described_class.create(relation_sym: relation_sym,
- relation_hash: relation_hash,
- object_builder: Gitlab::ImportExport::Project::ObjectBuilder,
- members_mapper: members_mapper,
- user: user,
- importable: project,
- excluded_keys: excluded_keys)
+ described_class.create(
+ relation_sym: relation_sym,
+ relation_hash: relation_hash,
+ object_builder: Gitlab::ImportExport::Project::ObjectBuilder,
+ members_mapper: members_mapper,
+ user: importer_user,
+ importable: project,
+ excluded_keys: excluded_keys
+ )
end
before do
@@ -113,9 +116,9 @@ RSpec.describe Gitlab::ImportExport::Project::RelationFactory do
"created_at" => "2016-11-18T09:29:42.634Z",
"updated_at" => "2016-11-18T09:29:42.634Z",
"user" => {
- "id" => user.id,
- "email" => user.email,
- "username" => user.username
+ "id" => admin.id,
+ "email" => admin.email,
+ "username" => admin.username
}
}
end
@@ -123,7 +126,7 @@ RSpec.describe Gitlab::ImportExport::Project::RelationFactory do
let(:members_mapper) do
Gitlab::ImportExport::MembersMapper.new(
exported_members: [exported_member],
- user: user,
+ user: importer_user,
importable: project)
end
@@ -134,9 +137,9 @@ RSpec.describe Gitlab::ImportExport::Project::RelationFactory do
'source_branch' => "feature_conflict",
'source_project_id' => project.id,
'target_project_id' => project.id,
- 'author_id' => user.id,
- 'assignee_id' => user.id,
- 'updated_by_id' => user.id,
+ 'author_id' => admin.id,
+ 'assignee_id' => admin.id,
+ 'updated_by_id' => admin.id,
'title' => "MR1",
'created_at' => "2016-06-14T15:02:36.568Z",
'updated_at' => "2016-06-14T15:02:56.815Z",
@@ -151,11 +154,11 @@ RSpec.describe Gitlab::ImportExport::Project::RelationFactory do
end
it 'has preloaded author' do
- expect(created_object.author).to equal(user)
+ expect(created_object.author).to equal(admin)
end
it 'has preloaded updated_by' do
- expect(created_object.updated_by).to equal(user)
+ expect(created_object.updated_by).to equal(admin)
end
it 'has preloaded source project' do
@@ -264,27 +267,8 @@ RSpec.describe Gitlab::ImportExport::Project::RelationFactory do
end
end
- context 'Notes user references' do
- let(:relation_sym) { :notes }
- let(:new_user) { create(:user) }
- let(:exported_member) do
- {
- "id" => 111,
- "access_level" => 30,
- "source_id" => 1,
- "source_type" => "Project",
- "user_id" => 3,
- "notification_level" => 3,
- "created_at" => "2016-11-18T09:29:42.634Z",
- "updated_at" => "2016-11-18T09:29:42.634Z",
- "user" => {
- "id" => 999,
- "email" => new_user.email,
- "username" => new_user.username
- }
- }
- end
-
+ it_behaves_like 'Notes user references' do
+ let(:importable) { project }
let(:relation_hash) do
{
"id" => 4947,
@@ -305,17 +289,6 @@ RSpec.describe Gitlab::ImportExport::Project::RelationFactory do
"events" => []
}
end
-
- let(:members_mapper) do
- Gitlab::ImportExport::MembersMapper.new(
- exported_members: [exported_member],
- user: user,
- importable: project)
- end
-
- it 'maps the right author to the imported note' do
- expect(created_object.author).to eq(new_user)
- end
end
context 'encrypted attributes' do
diff --git a/spec/lib/gitlab/job_waiter_spec.rb b/spec/lib/gitlab/job_waiter_spec.rb
index 7aa0a3485fb..a9edb2b530b 100644
--- a/spec/lib/gitlab/job_waiter_spec.rb
+++ b/spec/lib/gitlab/job_waiter_spec.rb
@@ -2,23 +2,26 @@
require 'spec_helper'
-RSpec.describe Gitlab::JobWaiter do
+RSpec.describe Gitlab::JobWaiter, :redis do
describe '.notify' do
it 'pushes the jid to the named queue' do
- key = 'gitlab:job_waiter:foo'
- jid = 1
+ key = described_class.new.key
- redis = double('redis')
- expect(Gitlab::Redis::SharedState).to receive(:with).and_yield(redis)
- expect(redis).to receive(:lpush).with(key, jid)
+ described_class.notify(key, 123)
- described_class.notify(key, jid)
+ Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.ttl(key)).to be > 0
+ end
end
end
describe '#wait' do
let(:waiter) { described_class.new(2) }
+ before do
+ allow_any_instance_of(described_class).to receive(:wait).and_call_original
+ end
+
it 'returns when all jobs have been completed' do
described_class.notify(waiter.key, 'a')
described_class.notify(waiter.key, 'b')
diff --git a/spec/lib/gitlab/lfs/client_spec.rb b/spec/lib/gitlab/lfs/client_spec.rb
index 03563a632d6..14af9d02cb5 100644
--- a/spec/lib/gitlab/lfs/client_spec.rb
+++ b/spec/lib/gitlab/lfs/client_spec.rb
@@ -7,6 +7,8 @@ RSpec.describe Gitlab::Lfs::Client do
let(:username) { 'user' }
let(:password) { 'password' }
let(:credentials) { { user: username, password: password, auth_method: 'password' } }
+ let(:git_lfs_content_type) { 'application/vnd.git-lfs+json' }
+ let(:git_lfs_user_agent) { "GitLab #{Gitlab::VERSION} LFS client" }
let(:basic_auth_headers) do
{ 'Authorization' => "Basic #{Base64.strict_encode64("#{username}:#{password}")}" }
@@ -21,6 +23,15 @@ RSpec.describe Gitlab::Lfs::Client do
}
end
+ let(:verify_action) do
+ {
+ "href" => "#{base_url}/some/file/verify",
+ "header" => {
+ "Key" => "value"
+ }
+ }
+ end
+
subject(:lfs_client) { described_class.new(base_url, credentials: credentials) }
describe '#batch' do
@@ -34,10 +45,10 @@ RSpec.describe Gitlab::Lfs::Client do
).to_return(
status: 200,
body: { 'objects' => 'anything', 'transfer' => 'basic' }.to_json,
- headers: { 'Content-Type' => 'application/vnd.git-lfs+json' }
+ headers: { 'Content-Type' => git_lfs_content_type }
)
- result = lfs_client.batch('upload', objects)
+ result = lfs_client.batch!('upload', objects)
expect(stub).to have_been_requested
expect(result).to eq('objects' => 'anything', 'transfer' => 'basic')
@@ -48,7 +59,7 @@ RSpec.describe Gitlab::Lfs::Client do
it 'raises an error' do
stub_batch(objects: objects, headers: basic_auth_headers).to_return(status: 400)
- expect { lfs_client.batch('upload', objects) }.to raise_error(/Failed/)
+ expect { lfs_client.batch!('upload', objects) }.to raise_error(/Failed/)
end
end
@@ -56,7 +67,7 @@ RSpec.describe Gitlab::Lfs::Client do
it 'raises an error' do
stub_batch(objects: objects, headers: basic_auth_headers).to_return(status: 400)
- expect { lfs_client.batch('upload', objects) }.to raise_error(/Failed/)
+ expect { lfs_client.batch!('upload', objects) }.to raise_error(/Failed/)
end
end
@@ -68,17 +79,23 @@ RSpec.describe Gitlab::Lfs::Client do
).to_return(
status: 200,
body: { 'transfer' => 'carrier-pigeon' }.to_json,
- headers: { 'Content-Type' => 'application/vnd.git-lfs+json' }
+ headers: { 'Content-Type' => git_lfs_content_type }
)
- expect { lfs_client.batch('upload', objects) }.to raise_error(/Unsupported transfer/)
+ expect { lfs_client.batch!('upload', objects) }.to raise_error(/Unsupported transfer/)
end
end
def stub_batch(objects:, headers:, operation: 'upload', transfer: 'basic')
- objects = objects.map { |o| { oid: o.oid, size: o.size } }
+ objects = objects.as_json(only: [:oid, :size])
body = { operation: operation, 'transfers': [transfer], objects: objects }.to_json
+ headers = {
+ 'Accept' => git_lfs_content_type,
+ 'Content-Type' => git_lfs_content_type,
+ 'User-Agent' => git_lfs_user_agent
+ }.merge(headers)
+
stub_request(:post, base_url + '/info/lfs/objects/batch').with(body: body, headers: headers)
end
end
@@ -90,7 +107,7 @@ RSpec.describe Gitlab::Lfs::Client do
it "makes an HTTP PUT with expected parameters" do
stub_upload(object: object, headers: upload_action['header']).to_return(status: 200)
- lfs_client.upload(object, upload_action, authenticated: true)
+ lfs_client.upload!(object, upload_action, authenticated: true)
end
end
@@ -101,7 +118,7 @@ RSpec.describe Gitlab::Lfs::Client do
headers: basic_auth_headers.merge(upload_action['header'])
).to_return(status: 200)
- lfs_client.upload(object, upload_action, authenticated: false)
+ lfs_client.upload!(object, upload_action, authenticated: false)
expect(stub).to have_been_requested
end
@@ -110,13 +127,13 @@ RSpec.describe Gitlab::Lfs::Client do
context 'LFS object has no file' do
let(:object) { LfsObject.new }
- it 'makes an HJTT PUT with expected parameters' do
+ it 'makes an HTTP PUT with expected parameters' do
stub = stub_upload(
object: object,
headers: upload_action['header']
).to_return(status: 200)
- lfs_client.upload(object, upload_action, authenticated: true)
+ lfs_client.upload!(object, upload_action, authenticated: true)
expect(stub).to have_been_requested
end
@@ -126,7 +143,7 @@ RSpec.describe Gitlab::Lfs::Client do
it 'raises an error' do
stub_upload(object: object, headers: upload_action['header']).to_return(status: 400)
- expect { lfs_client.upload(object, upload_action, authenticated: true) }.to raise_error(/Failed/)
+ expect { lfs_client.upload!(object, upload_action, authenticated: true) }.to raise_error(/Failed/)
end
end
@@ -134,15 +151,75 @@ RSpec.describe Gitlab::Lfs::Client do
it 'raises an error' do
stub_upload(object: object, headers: upload_action['header']).to_return(status: 500)
- expect { lfs_client.upload(object, upload_action, authenticated: true) }.to raise_error(/Failed/)
+ expect { lfs_client.upload!(object, upload_action, authenticated: true) }.to raise_error(/Failed/)
end
end
def stub_upload(object:, headers:)
+ headers = {
+ 'Content-Type' => 'application/octet-stream',
+ 'Content-Length' => object.size.to_s,
+ 'User-Agent' => git_lfs_user_agent
+ }.merge(headers)
+
stub_request(:put, upload_action['href']).with(
body: object.file.read,
headers: headers.merge('Content-Length' => object.size.to_s)
)
end
end
+
+ describe "#verify" do
+ let_it_be(:object) { create(:lfs_object) }
+
+ context 'server returns 200 OK to an authenticated request' do
+ it "makes an HTTP POST with expected parameters" do
+ stub_verify(object: object, headers: verify_action['header']).to_return(status: 200)
+
+ lfs_client.verify!(object, verify_action, authenticated: true)
+ end
+ end
+
+ context 'server returns 200 OK to an unauthenticated request' do
+ it "makes an HTTP POST with expected parameters" do
+ stub = stub_verify(
+ object: object,
+ headers: basic_auth_headers.merge(upload_action['header'])
+ ).to_return(status: 200)
+
+ lfs_client.verify!(object, verify_action, authenticated: false)
+
+ expect(stub).to have_been_requested
+ end
+ end
+
+ context 'server returns 400 error' do
+ it 'raises an error' do
+ stub_verify(object: object, headers: verify_action['header']).to_return(status: 400)
+
+ expect { lfs_client.verify!(object, verify_action, authenticated: true) }.to raise_error(/Failed/)
+ end
+ end
+
+ context 'server returns 500 error' do
+ it 'raises an error' do
+ stub_verify(object: object, headers: verify_action['header']).to_return(status: 500)
+
+ expect { lfs_client.verify!(object, verify_action, authenticated: true) }.to raise_error(/Failed/)
+ end
+ end
+
+ def stub_verify(object:, headers:)
+ headers = {
+ 'Accept' => git_lfs_content_type,
+ 'Content-Type' => git_lfs_content_type,
+ 'User-Agent' => git_lfs_user_agent
+ }.merge(headers)
+
+ stub_request(:post, verify_action['href']).with(
+ body: object.to_json(only: [:oid, :size]),
+ headers: headers
+ )
+ end
+ end
end
diff --git a/spec/lib/gitlab/lfs_token_spec.rb b/spec/lib/gitlab/lfs_token_spec.rb
index 9b8b2c1417a..4b40e8960b2 100644
--- a/spec/lib/gitlab/lfs_token_spec.rb
+++ b/spec/lib/gitlab/lfs_token_spec.rb
@@ -104,7 +104,7 @@ RSpec.describe Gitlab::LfsToken, :clean_gitlab_redis_shared_state do
# Needs to be at least LfsToken::DEFAULT_EXPIRE_TIME + 60 seconds
# in order to check whether it is valid 1 minute after it has expired
- Timecop.freeze(Time.now + described_class::DEFAULT_EXPIRE_TIME + 60) do
+ travel_to(Time.now + described_class::DEFAULT_EXPIRE_TIME + 60) do
expect(lfs_token.token_valid?(expired_token)).to be false
end
end
diff --git a/spec/lib/gitlab/manifest_import/manifest_spec.rb b/spec/lib/gitlab/manifest_import/manifest_spec.rb
index 2e8753b0880..352120c079d 100644
--- a/spec/lib/gitlab/manifest_import/manifest_spec.rb
+++ b/spec/lib/gitlab/manifest_import/manifest_spec.rb
@@ -12,19 +12,7 @@ RSpec.describe Gitlab::ManifestImport::Manifest do
end
context 'missing or invalid attributes' do
- let(:file) { Tempfile.new('foo') }
-
- before do
- content = <<~EOS
- <manifest>
- <remote review="invalid-url" />
- <project name="platform/build"/>
- </manifest>
- EOS
-
- file.write(content)
- file.rewind
- end
+ let(:file) { File.open(Rails.root.join('spec/fixtures/invalid_manifest.xml')) }
it { expect(manifest.valid?).to be false }
diff --git a/spec/lib/gitlab/manifest_import/metadata_spec.rb b/spec/lib/gitlab/manifest_import/metadata_spec.rb
new file mode 100644
index 00000000000..c8158d3e148
--- /dev/null
+++ b/spec/lib/gitlab/manifest_import/metadata_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::ManifestImport::Metadata, :clean_gitlab_redis_shared_state do
+ let(:user) { double(id: 1) }
+ let(:repositories) do
+ [
+ { id: 'test1', url: 'http://demo.host/test1' },
+ { id: 'test2', url: 'http://demo.host/test2' }
+ ]
+ end
+
+ describe '#save' do
+ it 'stores data in Redis with an expiry of EXPIRY_TIME' do
+ status = described_class.new(user)
+ repositories_key = 'manifest_import:metadata:user:1:repositories'
+ group_id_key = 'manifest_import:metadata:user:1:group_id'
+
+ status.save(repositories, 2)
+
+ Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.ttl(repositories_key)).to be_within(5).of(described_class::EXPIRY_TIME)
+ expect(redis.ttl(group_id_key)).to be_within(5).of(described_class::EXPIRY_TIME)
+ end
+ end
+ end
+
+ describe '#repositories' do
+ it 'allows repositories to round-trip with symbol keys' do
+ status = described_class.new(user)
+
+ status.save(repositories, 2)
+
+ expect(status.repositories).to eq(repositories)
+ end
+
+ it 'uses the fallback when there is nothing in Redis' do
+ fallback = { manifest_import_repositories: repositories }
+ status = described_class.new(user, fallback: fallback)
+
+ expect(status.repositories).to eq(repositories)
+ end
+ end
+
+ describe '#group_id' do
+ it 'returns the group ID as an integer' do
+ status = described_class.new(user)
+
+ status.save(repositories, 2)
+
+ expect(status.group_id).to eq(2)
+ end
+
+ it 'uses the fallback when there is nothing in Redis' do
+ fallback = { manifest_import_group_id: 3 }
+ status = described_class.new(user, fallback: fallback)
+
+ expect(status.group_id).to eq(3)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/dashboard/importers/prometheus_metrics_spec.rb b/spec/lib/gitlab/metrics/dashboard/importers/prometheus_metrics_spec.rb
index 09d5e048f6a..ff8f5797f9d 100644
--- a/spec/lib/gitlab/metrics/dashboard/importers/prometheus_metrics_spec.rb
+++ b/spec/lib/gitlab/metrics/dashboard/importers/prometheus_metrics_spec.rb
@@ -8,9 +8,16 @@ RSpec.describe Gitlab::Metrics::Dashboard::Importers::PrometheusMetrics do
describe '#execute' do
let(:project) { create(:project) }
let(:dashboard_path) { 'path/to/dashboard.yml' }
+ let(:prometheus_adapter) { double('adapter', clear_prometheus_reactive_cache!: nil) }
subject { described_class.new(dashboard_hash, project: project, dashboard_path: dashboard_path) }
+ before do
+ allow_next_instance_of(::Clusters::Applications::ScheduleUpdateService) do |update_service|
+ allow(update_service).to receive(:execute)
+ end
+ end
+
context 'valid dashboard' do
let(:dashboard_hash) { load_sample_dashboard }
@@ -21,20 +28,32 @@ RSpec.describe Gitlab::Metrics::Dashboard::Importers::PrometheusMetrics do
end
context 'with existing metrics' do
+ let(:existing_metric_attributes) do
+ {
+ project: project,
+ identifier: 'metric_b',
+ title: 'overwrite',
+ y_label: 'overwrite',
+ query: 'overwrite',
+ unit: 'overwrite',
+ legend: 'overwrite',
+ dashboard_path: dashboard_path
+ }
+ end
+
let!(:existing_metric) do
- create(:prometheus_metric, {
- project: project,
- identifier: 'metric_b',
- title: 'overwrite',
- y_label: 'overwrite',
- query: 'overwrite',
- unit: 'overwrite',
- legend: 'overwrite'
- })
+ create(:prometheus_metric, existing_metric_attributes)
+ end
+
+ let!(:existing_alert) do
+ alert = create(:prometheus_alert, project: project, prometheus_metric: existing_metric)
+ existing_metric.prometheus_alerts << alert
+
+ alert
end
it 'updates existing PrometheusMetrics' do
- described_class.new(dashboard_hash, project: project, dashboard_path: dashboard_path).execute
+ subject.execute
expect(existing_metric.reload.attributes.with_indifferent_access).to include({
title: 'Super Chart B',
@@ -49,6 +68,15 @@ RSpec.describe Gitlab::Metrics::Dashboard::Importers::PrometheusMetrics do
expect { subject.execute }.to change { PrometheusMetric.count }.by(2)
end
+ it 'updates affected environments' do
+ expect(::Clusters::Applications::ScheduleUpdateService).to receive(:new).with(
+ existing_alert.environment.cluster_prometheus_adapter,
+ project
+ ).and_return(double('ScheduleUpdateService', execute: true))
+
+ subject.execute
+ end
+
context 'with stale metrics' do
let!(:stale_metric) do
create(:prometheus_metric,
@@ -59,11 +87,45 @@ RSpec.describe Gitlab::Metrics::Dashboard::Importers::PrometheusMetrics do
)
end
+ let!(:stale_alert) do
+ alert = create(:prometheus_alert, project: project, prometheus_metric: stale_metric)
+ stale_metric.prometheus_alerts << alert
+
+ alert
+ end
+
+ it 'updates existing PrometheusMetrics' do
+ subject.execute
+
+ expect(existing_metric.reload.attributes.with_indifferent_access).to include({
+ title: 'Super Chart B',
+ y_label: 'y_label',
+ query: 'query',
+ unit: 'unit',
+ legend: 'Legend Label'
+ })
+ end
+
it 'deletes stale metrics' do
subject.execute
expect { stale_metric.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
+
+ it 'deletes stale alert' do
+ subject.execute
+
+ expect { stale_alert.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ it 'updates affected environments' do
+ expect(::Clusters::Applications::ScheduleUpdateService).to receive(:new).with(
+ existing_alert.environment.cluster_prometheus_adapter,
+ project
+ ).and_return(double('ScheduleUpdateService', execute: true))
+
+ subject.execute
+ end
end
end
end
diff --git a/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb b/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb
index 69b779d36eb..0c77dc540f3 100644
--- a/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb
+++ b/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb
@@ -38,69 +38,49 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware do
end
context 'request is a health check endpoint' do
- it 'increments health endpoint counter' do
- env['PATH_INFO'] = '/-/liveness'
+ ['/-/liveness', '/-/liveness/', '/-/%6D%65%74%72%69%63%73'].each do |path|
+ context "when path is #{path}" do
+ before do
+ env['PATH_INFO'] = path
+ end
- expect(described_class).to receive_message_chain(:http_health_requests_total, :increment).with(method: 'get')
+ it 'increments health endpoint counter rather than overall counter' do
+ expect(described_class).to receive_message_chain(:http_health_requests_total, :increment).with(method: 'get')
+ expect(described_class).not_to receive(:http_request_total)
- subject.call(env)
- end
-
- context 'with trailing slash' do
- before do
- env['PATH_INFO'] = '/-/liveness/'
- end
+ subject.call(env)
+ end
- it 'increments health endpoint counter' do
- expect(described_class).to receive_message_chain(:http_health_requests_total, :increment).with(method: 'get')
+ it 'does not record the request duration' do
+ expect(described_class).not_to receive(:http_request_duration_seconds)
- subject.call(env)
- end
- end
-
- context 'with percent encoded values' do
- before do
- env['PATH_INFO'] = '/-/%6D%65%74%72%69%63%73' # /-/metrics
- end
-
- it 'increments health endpoint counter' do
- expect(described_class).to receive_message_chain(:http_health_requests_total, :increment).with(method: 'get')
-
- subject.call(env)
+ subject.call(env)
+ end
end
end
end
context 'request is not a health check endpoint' do
- it 'does not increment health endpoint counter' do
- env['PATH_INFO'] = '/-/ordinary-requests'
-
- expect(described_class).not_to receive(:http_health_requests_total)
-
- subject.call(env)
- end
-
- context 'path info is a root path' do
- before do
- env['PATH_INFO'] = '/-/'
- end
-
- it 'does not increment health endpoint counter' do
- expect(described_class).not_to receive(:http_health_requests_total)
-
- subject.call(env)
- end
- end
-
- context 'path info is a subpath' do
- before do
- env['PATH_INFO'] = '/-/health/subpath'
- end
-
- it 'does not increment health endpoint counter' do
- expect(described_class).not_to receive(:http_health_requests_total)
-
- subject.call(env)
+ ['/-/ordinary-requests', '/-/', '/-/health/subpath'].each do |path|
+ context "when path is #{path}" do
+ before do
+ env['PATH_INFO'] = path
+ end
+
+ it 'increments overall counter rather than health endpoint counter' do
+ expect(described_class).to receive_message_chain(:http_request_total, :increment).with(method: 'get')
+ expect(described_class).not_to receive(:http_health_requests_total)
+
+ subject.call(env)
+ end
+
+ it 'records the request duration' do
+ expect(described_class)
+ .to receive_message_chain(:http_request_duration_seconds, :observe)
+ .with({ method: 'get', status: '200' }, a_positive_execution_time)
+
+ subject.call(env)
+ end
end
end
end
diff --git a/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb b/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb
index cdb48024531..a9dae72f4db 100644
--- a/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb
+++ b/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe Gitlab::Middleware::RailsQueueDuration do
expect(transaction).to receive(:observe).with(:gitlab_rails_queue_duration_seconds, 1)
- Timecop.freeze(Time.at(3)) do
+ travel_to(Time.at(3)) do
expect(middleware.call(env)).to eq('yay')
end
end
diff --git a/spec/lib/gitlab/pagination/offset_pagination_spec.rb b/spec/lib/gitlab/pagination/offset_pagination_spec.rb
index be20f0194f7..c9a23170137 100644
--- a/spec/lib/gitlab/pagination/offset_pagination_spec.rb
+++ b/spec/lib/gitlab/pagination/offset_pagination_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe Gitlab::Pagination::OffsetPagination do
let(:request_context) { double("request_context") }
- subject do
+ subject(:paginator) do
described_class.new(request_context)
end
@@ -119,6 +119,34 @@ RSpec.describe Gitlab::Pagination::OffsetPagination do
subject.paginate(resource)
end
end
+
+ it 'does not return the total headers when excluding them' do
+ expect_no_header('X-Total')
+ expect_no_header('X-Total-Pages')
+ expect_header('X-Per-Page', '2')
+ expect_header('X-Page', '1')
+
+ paginator.paginate(resource, exclude_total_headers: true)
+ end
+ end
+
+ context 'when resource is a paginatable array' do
+ let(:resource) { Kaminari.paginate_array(Project.all.to_a) }
+
+ it_behaves_like 'response with pagination headers'
+
+ it 'only returns the requested resources' do
+ expect(paginator.paginate(resource).count).to eq(2)
+ end
+
+ it 'does not return total headers when excluding them' do
+ expect_no_header('X-Total')
+ expect_no_header('X-Total-Pages')
+ expect_header('X-Per-Page', '2')
+ expect_header('X-Page', '1')
+
+ paginator.paginate(resource, exclude_total_headers: true)
+ end
end
end
diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb
index fe0735b8043..a76ad1f6f4c 100644
--- a/spec/lib/gitlab/project_search_results_spec.rb
+++ b/spec/lib/gitlab/project_search_results_spec.rb
@@ -265,9 +265,15 @@ RSpec.describe Gitlab::ProjectSearchResults do
let_it_be(:project) { create(:project, :public) }
let_it_be(:closed_result) { create(:issue, :closed, project: project, title: 'foo closed') }
let_it_be(:opened_result) { create(:issue, :opened, project: project, title: 'foo opened') }
+ let_it_be(:confidential_result) { create(:issue, :confidential, project: project, title: 'foo confidential') }
let(:query) { 'foo' }
+ before do
+ project.add_developer(user)
+ end
+
include_examples 'search results filtered by state'
+ include_examples 'search results filtered by confidential'
end
end
diff --git a/spec/lib/gitlab/project_template_spec.rb b/spec/lib/gitlab/project_template_spec.rb
index fa45c605b1b..98bd2efdbc6 100644
--- a/spec/lib/gitlab/project_template_spec.rb
+++ b/spec/lib/gitlab/project_template_spec.rb
@@ -8,9 +8,9 @@ RSpec.describe Gitlab::ProjectTemplate do
expected = %w[
rails spring express iosswift dotnetcore android
gomicro gatsby hugo jekyll plainhtml gitbook
- hexo sse_middleman nfhugo nfjekyll nfplainhtml
- nfgitbook nfhexo salesforcedx serverless_framework
- jsonnet cluster_management
+ hexo sse_middleman gitpod_spring_petclinic nfhugo
+ nfjekyll nfplainhtml nfgitbook nfhexo salesforcedx
+ serverless_framework jsonnet cluster_management
]
expect(described_class.all).to be_an(Array)
diff --git a/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb b/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb
index 8abc944eeb1..b2350eff9f9 100644
--- a/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb
+++ b/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe Gitlab::Prometheus::Queries::AdditionalMetricsDeploymentQuery do
around do |example|
- Timecop.freeze(Time.local(2008, 9, 1, 12, 0, 0)) { example.run }
+ travel_to(Time.local(2008, 9, 1, 12, 0, 0)) { example.run }
end
include_examples 'additional metrics query' do
diff --git a/spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb b/spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb
index 4683c4eae28..66b93d0dd72 100644
--- a/spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb
+++ b/spec/lib/gitlab/prometheus/queries/deployment_query_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe Gitlab::Prometheus::Queries::DeploymentQuery do
around do |example|
time_without_subsecond_values = Time.local(2008, 9, 1, 12, 0, 0)
- Timecop.freeze(time_without_subsecond_values) { example.run }
+ travel_to(time_without_subsecond_values) { example.run }
end
it 'sends appropriate queries to prometheus' do
diff --git a/spec/lib/gitlab/prometheus/query_variables_spec.rb b/spec/lib/gitlab/prometheus/query_variables_spec.rb
index 1422d48152a..1dbdb892a5d 100644
--- a/spec/lib/gitlab/prometheus/query_variables_spec.rb
+++ b/spec/lib/gitlab/prometheus/query_variables_spec.rb
@@ -4,12 +4,12 @@ require 'spec_helper'
RSpec.describe Gitlab::Prometheus::QueryVariables do
describe '.call' do
+ let_it_be_with_refind(:environment) { create(:environment) }
let(:project) { environment.project }
- let(:environment) { create(:environment) }
let(:slug) { environment.slug }
let(:params) { {} }
- subject { described_class.call(environment, params) }
+ subject { described_class.call(environment, **params) }
it { is_expected.to include(ci_environment_slug: slug) }
it { is_expected.to include(ci_project_name: project.name) }
diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb
index 88c3315150b..704a4e7b224 100644
--- a/spec/lib/gitlab/regex_spec.rb
+++ b/spec/lib/gitlab/regex_spec.rb
@@ -99,6 +99,36 @@ RSpec.describe Gitlab::Regex do
it { is_expected.not_to match('foo-') }
end
+ describe '.build_trace_section_regex' do
+ subject { described_class.build_trace_section_regex }
+
+ context 'without options' do
+ example = "section_start:1600445393032:NAME\r\033\[0K"
+
+ it { is_expected.to match(example) }
+ it { is_expected.to match("section_end:12345678:aBcDeFg1234\r\033\[0K") }
+ it { is_expected.to match("section_start:0:sect_for_alpha-v1.0\r\033\[0K") }
+ it { is_expected.not_to match("section_start:section:0\r\033\[0K") }
+ it { is_expected.not_to match("section_:1600445393032:NAME\r\033\[0K") }
+ it { is_expected.not_to match(example.upcase) }
+ end
+
+ context 'with options' do
+ it { is_expected.to match("section_start:1600445393032:NAME[collapsed=true]\r\033\[0K") }
+ it { is_expected.to match("section_start:1600445393032:NAME[collapsed=true, example_option=false]\r\033\[0K") }
+ it { is_expected.to match("section_start:1600445393032:NAME[collapsed=true,example_option=false]\r\033\[0K") }
+ it { is_expected.to match("section_start:1600445393032:NAME[numeric_option=1234567]\r\033\[0K") }
+ # Without splitting the regex in one for start and one for end,
+ # this is possible, however, it is ignored for section_end.
+ it { is_expected.to match("section_end:1600445393032:NAME[collapsed=true]\r\033\[0K") }
+ it { is_expected.not_to match("section_start:1600445393032:NAME[collapsed=[]]]\r\033\[0K") }
+ it { is_expected.not_to match("section_start:1600445393032:NAME[collapsed = true]\r\033\[0K") }
+ it { is_expected.not_to match("section_start:1600445393032:NAME[collapsed = true, example_option=false]\r\033\[0K") }
+ it { is_expected.not_to match("section_start:1600445393032:NAME[collapsed=true, example_option=false]\r\033\[0K") }
+ it { is_expected.not_to match("section_start:1600445393032:NAME[]\r\033\[0K") }
+ end
+ end
+
describe '.container_repository_name_regex' do
subject { described_class.container_repository_name_regex }
@@ -384,6 +414,140 @@ RSpec.describe Gitlab::Regex do
it { is_expected.not_to match('%2e%2e%2f1.2.3') }
end
+ describe '.debian_package_name_regex' do
+ subject { described_class.debian_package_name_regex }
+
+ it { is_expected.to match('0ad') }
+ it { is_expected.to match('g++') }
+ it { is_expected.to match('lua5.1') }
+ it { is_expected.to match('samba') }
+
+ # may not be empty string
+ it { is_expected.not_to match('') }
+ # must start with an alphanumeric character
+ it { is_expected.not_to match('-a') }
+ it { is_expected.not_to match('+a') }
+ it { is_expected.not_to match('.a') }
+ it { is_expected.not_to match('_a') }
+ # only letters, digits and characters '-+._'
+ it { is_expected.not_to match('a~') }
+ it { is_expected.not_to match('aé') }
+
+ # More strict Lintian regex
+ # at least 2 chars
+ it { is_expected.not_to match('a') }
+ # lowercase only
+ it { is_expected.not_to match('Aa') }
+ it { is_expected.not_to match('aA') }
+ # No underscore
+ it { is_expected.not_to match('a_b') }
+ end
+
+ describe '.debian_version_regex' do
+ subject { described_class.debian_version_regex }
+
+ context 'valid versions' do
+ it { is_expected.to match('1.0') }
+ it { is_expected.to match('1.0~alpha1') }
+ it { is_expected.to match('2:4.9.5+dfsg-5+deb10u1') }
+ end
+
+ context 'dpkg errors' do
+ # version string is empty
+ it { is_expected.not_to match('') }
+ # version string has embedded spaces
+ it { is_expected.not_to match('1 0') }
+ # epoch in version is empty
+ it { is_expected.not_to match(':1.0') }
+ # epoch in version is not number
+ it { is_expected.not_to match('a:1.0') }
+ # epoch in version is negative
+ it { is_expected.not_to match('-1:1.0') }
+ # epoch in version is too big
+ it { is_expected.not_to match('9999999999:1.0') }
+ # nothing after colon in version number
+ it { is_expected.not_to match('2:') }
+ # revision number is empty
+ # Note: we are less strict here
+ # it { is_expected.not_to match('1.0-') }
+ # version number is empty
+ it { is_expected.not_to match('-1') }
+ it { is_expected.not_to match('2:-1') }
+ end
+
+ context 'dpkg warnings' do
+ # version number does not start with digit
+ it { is_expected.not_to match('a') }
+ it { is_expected.not_to match('a1.0') }
+ # invalid character in version number
+ it { is_expected.not_to match('1_0') }
+ # invalid character in revision number
+ it { is_expected.not_to match('1.0-1_0') }
+ end
+
+ context 'dpkg accepts' do
+ # dpkg accepts leading or trailing space
+ it { is_expected.not_to match(' 1.0') }
+ it { is_expected.not_to match('1.0 ') }
+ # dpkg accepts multiple colons
+ it { is_expected.not_to match('1:2:3') }
+ end
+ end
+
+ describe '.debian_architecture_regex' do
+ subject { described_class.debian_architecture_regex }
+
+ it { is_expected.to match('amd64') }
+ it { is_expected.to match('kfreebsd-i386') }
+
+ # may not be empty string
+ it { is_expected.not_to match('') }
+ # must start with an alphanumeric
+ it { is_expected.not_to match('-a') }
+ it { is_expected.not_to match('+a') }
+ it { is_expected.not_to match('.a') }
+ it { is_expected.not_to match('_a') }
+ # only letters, digits and characters '-'
+ it { is_expected.not_to match('a+b') }
+ it { is_expected.not_to match('a.b') }
+ it { is_expected.not_to match('a_b') }
+ it { is_expected.not_to match('a~') }
+ it { is_expected.not_to match('aé') }
+
+ # More strict
+ # Enforce lowercase
+ it { is_expected.not_to match('AMD64') }
+ it { is_expected.not_to match('Amd64') }
+ it { is_expected.not_to match('aMD64') }
+ end
+
+ describe '.debian_distribution_regex' do
+ subject { described_class.debian_distribution_regex }
+
+ it { is_expected.to match('buster') }
+ it { is_expected.to match('buster-updates') }
+ it { is_expected.to match('Debian10.5') }
+
+ # Do not allow slash, even if this exists in the wild
+ it { is_expected.not_to match('jessie/updates') }
+
+ # Do not allow Unicode
+ it { is_expected.not_to match('hé') }
+ end
+
+ describe '.debian_component_regex' do
+ subject { described_class.debian_component_regex }
+
+ it { is_expected.to match('main') }
+ it { is_expected.to match('non-free') }
+
+ # Do not allow slash
+ it { is_expected.not_to match('non/free') }
+
+ # Do not allow Unicode
+ it { is_expected.not_to match('hé') }
+ end
+
describe '.semver_regex' do
subject { described_class.semver_regex }
@@ -434,4 +598,45 @@ RSpec.describe Gitlab::Regex do
it { is_expected.not_to match('%2e%2e%2f1.2.3') }
it { is_expected.not_to match('') }
end
+
+ describe '.generic_package_name_regex' do
+ subject { described_class.generic_package_name_regex }
+
+ it { is_expected.to match('123') }
+ it { is_expected.to match('foo') }
+ it { is_expected.to match('foo.bar.baz-2.0-20190901.47283-1') }
+ it { is_expected.not_to match('../../foo') }
+ it { is_expected.not_to match('..\..\foo') }
+ it { is_expected.not_to match('%2f%2e%2e%2f%2essh%2fauthorized_keys') }
+ it { is_expected.not_to match('$foo/bar') }
+ it { is_expected.not_to match('my file name') }
+ it { is_expected.not_to match('!!()()') }
+ end
+
+ describe '.generic_package_file_name_regex' do
+ subject { described_class.generic_package_file_name_regex }
+
+ it { is_expected.to match('123') }
+ it { is_expected.to match('foo') }
+ it { is_expected.to match('foo.bar.baz-2.0-20190901.47283-1.jar') }
+ it { is_expected.not_to match('../../foo') }
+ it { is_expected.not_to match('..\..\foo') }
+ it { is_expected.not_to match('%2f%2e%2e%2f%2essh%2fauthorized_keys') }
+ it { is_expected.not_to match('$foo/bar') }
+ it { is_expected.not_to match('my file name') }
+ it { is_expected.not_to match('!!()()') }
+ end
+
+ describe '.prefixed_semver_regex' do
+ subject { described_class.prefixed_semver_regex }
+
+ it { is_expected.to match('v1.2.3') }
+ it { is_expected.to match('v1.2.3-beta') }
+ it { is_expected.to match('v1.2.3-alpha.3') }
+ it { is_expected.not_to match('v1') }
+ it { is_expected.not_to match('v1.2') }
+ it { is_expected.not_to match('v1./2.3') }
+ it { is_expected.not_to match('v../../../../../1.2.3') }
+ it { is_expected.not_to match('v%2e%2e%2f1.2.3') }
+ end
end
diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb
index b4cf6a568b4..cdb626778a8 100644
--- a/spec/lib/gitlab/search_results_spec.rb
+++ b/spec/lib/gitlab/search_results_spec.rb
@@ -11,9 +11,11 @@ RSpec.describe Gitlab::SearchResults do
let_it_be(:issue) { create(:issue, project: project, title: 'foo') }
let_it_be(:milestone) { create(:milestone, project: project, title: 'foo') }
let(:merge_request) { create(:merge_request, source_project: project, title: 'foo') }
+ let(:query) { 'foo' }
let(:filters) { {} }
+ let(:sort) { nil }
- subject(:results) { described_class.new(user, 'foo', Project.order(:id), filters: filters) }
+ subject(:results) { described_class.new(user, query, Project.order(:id), sort: sort, filters: filters) }
context 'as a user with access' do
before do
@@ -137,10 +139,12 @@ RSpec.describe Gitlab::SearchResults do
end
describe '#merge_requests' do
+ let(:scope) { 'merge_requests' }
+
it 'includes project filter by default' do
expect(results).to receive(:project_ids_relation).and_call_original
- results.objects('merge_requests')
+ results.objects(scope)
end
it 'skips project filter if default project context is used' do
@@ -148,24 +152,34 @@ RSpec.describe Gitlab::SearchResults do
expect(results).not_to receive(:project_ids_relation)
- results.objects('merge_requests')
+ results.objects(scope)
end
context 'filtering' do
let!(:opened_result) { create(:merge_request, :opened, source_project: project, title: 'foo opened') }
let!(:closed_result) { create(:merge_request, :closed, source_project: project, title: 'foo closed') }
- let(:scope) { 'merge_requests' }
let(:query) { 'foo' }
include_examples 'search results filtered by state'
end
+
+ context 'ordering' do
+ let(:query) { 'sorted' }
+ let!(:old_result) { create(:merge_request, :opened, source_project: project, source_branch: 'old-1', title: 'sorted old', created_at: 1.month.ago) }
+ let!(:new_result) { create(:merge_request, :opened, source_project: project, source_branch: 'new-1', title: 'sorted recent', created_at: 1.day.ago) }
+ let!(:very_old_result) { create(:merge_request, :opened, source_project: project, source_branch: 'very-old-1', title: 'sorted very old', created_at: 1.year.ago) }
+
+ include_examples 'search results sorted'
+ end
end
describe '#issues' do
+ let(:scope) { 'issues' }
+
it 'includes project filter by default' do
expect(results).to receive(:project_ids_relation).and_call_original
- results.objects('issues')
+ results.objects(scope)
end
it 'skips project filter if default project context is used' do
@@ -173,16 +187,25 @@ RSpec.describe Gitlab::SearchResults do
expect(results).not_to receive(:project_ids_relation)
- results.objects('issues')
+ results.objects(scope)
end
context 'filtering' do
- let(:scope) { 'issues' }
-
let_it_be(:closed_result) { create(:issue, :closed, project: project, title: 'foo closed') }
let_it_be(:opened_result) { create(:issue, :opened, project: project, title: 'foo open') }
+ let_it_be(:confidential_result) { create(:issue, :confidential, project: project, title: 'foo confidential') }
include_examples 'search results filtered by state'
+ include_examples 'search results filtered by confidential'
+ end
+
+ context 'ordering' do
+ let(:query) { 'sorted' }
+ let!(:old_result) { create(:issue, project: project, title: 'sorted old', created_at: 1.month.ago) }
+ let!(:new_result) { create(:issue, project: project, title: 'sorted recent', created_at: 1.day.ago) }
+ let!(:very_old_result) { create(:issue, project: project, title: 'sorted very old', created_at: 1.year.ago) }
+
+ include_examples 'search results sorted'
end
end
diff --git a/spec/lib/gitlab/sql/pattern_spec.rb b/spec/lib/gitlab/sql/pattern_spec.rb
index 220ac2ff6da..9bf6f0b82bc 100644
--- a/spec/lib/gitlab/sql/pattern_spec.rb
+++ b/spec/lib/gitlab/sql/pattern_spec.rb
@@ -3,6 +3,43 @@
require 'spec_helper'
RSpec.describe Gitlab::SQL::Pattern do
+ using RSpec::Parameterized::TableSyntax
+
+ describe '.fuzzy_search' do
+ let_it_be(:issue1) { create(:issue, title: 'noise foo noise', description: 'noise bar noise') }
+ let_it_be(:issue2) { create(:issue, title: 'noise baz noise', description: 'noise foo noise') }
+ let_it_be(:issue3) { create(:issue, title: 'Oh', description: 'Ah') }
+
+ subject(:fuzzy_search) { Issue.fuzzy_search(query, columns) }
+
+ where(:query, :columns, :expected) do
+ 'foo' | [Issue.arel_table[:title]] | %i[issue1]
+
+ 'foo' | %i[title] | %i[issue1]
+ 'foo' | %w[title] | %i[issue1]
+ 'foo' | %i[description] | %i[issue2]
+ 'foo' | %i[title description] | %i[issue1 issue2]
+ 'bar' | %i[title description] | %i[issue1]
+ 'baz' | %i[title description] | %i[issue2]
+ 'qux' | %i[title description] | []
+
+ 'oh' | %i[title description] | %i[issue3]
+ 'OH' | %i[title description] | %i[issue3]
+ 'ah' | %i[title description] | %i[issue3]
+ 'AH' | %i[title description] | %i[issue3]
+ 'oh' | %i[title] | %i[issue3]
+ 'ah' | %i[description] | %i[issue3]
+ end
+
+ with_them do
+ let(:expected_issues) { expected.map { |sym| send(sym) } }
+
+ it 'finds the expected issues' do
+ expect(fuzzy_search).to match_array(expected_issues)
+ end
+ end
+ end
+
describe '.to_pattern' do
subject(:to_pattern) { User.to_pattern(query) }
diff --git a/spec/lib/gitlab/static_site_editor/config/file_config/entry/global_spec.rb b/spec/lib/gitlab/static_site_editor/config/file_config/entry/global_spec.rb
new file mode 100644
index 00000000000..9ce6007165b
--- /dev/null
+++ b/spec/lib/gitlab/static_site_editor/config/file_config/entry/global_spec.rb
@@ -0,0 +1,245 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::StaticSiteEditor::Config::FileConfig::Entry::Global do
+ let(:global) { described_class.new(hash) }
+ let(:default_image_upload_path_value) { 'source/images' }
+
+ let(:default_mounts_value) do
+ [
+ {
+ source: 'source',
+ target: ''
+ }
+ ]
+ end
+
+ let(:default_static_site_generator_value) { 'middleman' }
+
+ shared_examples_for 'valid default configuration' do
+ describe '#compose!' do
+ before do
+ global.compose!
+ end
+
+ it 'creates nodes hash' do
+ expect(global.descendants).to be_an Array
+ end
+
+ it 'creates node object for each entry' do
+ expect(global.descendants.count).to eq 3
+ end
+
+ it 'creates node object using valid class' do
+ expect(global.descendants.map(&:class)).to match_array(expected_node_object_classes)
+ end
+
+ it 'sets a description containing "Static Site Editor" for all nodes' do
+ expect(global.descendants.map(&:description)).to all(match(/Static Site Editor/))
+ end
+
+ describe '#leaf?' do
+ it 'is not leaf' do
+ expect(global).not_to be_leaf
+ end
+ end
+ end
+
+ context 'when not composed' do
+ describe '#static_site_generator_value' do
+ it 'returns nil' do
+ expect(global.static_site_generator_value).to be nil
+ end
+ end
+
+ describe '#leaf?' do
+ it 'is leaf' do
+ expect(global).to be_leaf
+ end
+ end
+ end
+
+ context 'when composed' do
+ before do
+ global.compose!
+ end
+
+ describe '#errors' do
+ it 'has no errors' do
+ expect(global.errors).to be_empty
+ end
+ end
+
+ describe '#image_upload_path_value' do
+ it 'returns correct values' do
+ expect(global.image_upload_path_value).to eq(default_image_upload_path_value)
+ end
+ end
+
+ describe '#mounts_value' do
+ it 'returns correct values' do
+ expect(global.mounts_value).to eq(default_mounts_value)
+ end
+ end
+
+ describe '#static_site_generator_value' do
+ it 'returns correct values' do
+ expect(global.static_site_generator_value).to eq(default_static_site_generator_value)
+ end
+ end
+ end
+ end
+
+ describe '.nodes' do
+ it 'returns a hash' do
+ expect(described_class.nodes).to be_a(Hash)
+ end
+
+ context 'when filtering all the entry/node names' do
+ it 'contains the expected node names' do
+ expected_node_names = %i[
+ image_upload_path
+ mounts
+ static_site_generator
+ ]
+ expect(described_class.nodes.keys).to match_array(expected_node_names)
+ end
+ end
+ end
+
+ context 'when configuration is valid' do
+ context 'when some entries defined' do
+ let(:expected_node_object_classes) do
+ [
+ Gitlab::StaticSiteEditor::Config::FileConfig::Entry::ImageUploadPath,
+ Gitlab::StaticSiteEditor::Config::FileConfig::Entry::Mounts,
+ Gitlab::StaticSiteEditor::Config::FileConfig::Entry::StaticSiteGenerator
+ ]
+ end
+
+ let(:hash) do
+ {
+ image_upload_path: default_image_upload_path_value,
+ mounts: default_mounts_value,
+ static_site_generator: default_static_site_generator_value
+ }
+ end
+
+ it_behaves_like 'valid default configuration'
+ end
+ end
+
+ context 'when value is an empty hash' do
+ let(:expected_node_object_classes) do
+ [
+ Gitlab::Config::Entry::Unspecified,
+ Gitlab::Config::Entry::Unspecified,
+ Gitlab::Config::Entry::Unspecified
+ ]
+ end
+
+ let(:hash) { {} }
+
+ it_behaves_like 'valid default configuration'
+ end
+
+ context 'when configuration is not valid' do
+ before do
+ global.compose!
+ end
+
+ context 'when a single entry is invalid' do
+ let(:hash) do
+ { image_upload_path: { not_a_string: true } }
+ end
+
+ describe '#errors' do
+ it 'reports errors' do
+ expect(global.errors)
+ .to include 'image_upload_path config should be a string'
+ end
+ end
+ end
+
+ context 'when a multiple entries are invalid' do
+ let(:hash) do
+ {
+ image_upload_path: { not_a_string: true },
+ static_site_generator: { not_a_string: true }
+ }
+ end
+
+ describe '#errors' do
+ it 'reports errors' do
+ expect(global.errors)
+ .to match_array([
+ 'image_upload_path config should be a string',
+ 'static_site_generator config should be a string',
+ "static_site_generator config should be 'middleman'"
+ ])
+ end
+ end
+ end
+
+ context 'when there is an invalid key' do
+ let(:hash) do
+ { invalid_key: true }
+ end
+
+ describe '#errors' do
+ it 'reports errors' do
+ expect(global.errors)
+ .to include 'global config contains unknown keys: invalid_key'
+ end
+ end
+ end
+ end
+
+ context 'when value is not a hash' do
+ let(:hash) { [] }
+
+ describe '#valid?' do
+ it 'is not valid' do
+ expect(global).not_to be_valid
+ end
+ end
+
+ describe '#errors' do
+ it 'returns error about invalid type' do
+ expect(global.errors.first).to match /should be a hash/
+ end
+ end
+ end
+
+ describe '#specified?' do
+ it 'is concrete entry that is defined' do
+ expect(global.specified?).to be true
+ end
+ end
+
+ describe '#[]' do
+ before do
+ global.compose!
+ end
+
+ let(:hash) do
+ { static_site_generator: default_static_site_generator_value }
+ end
+
+ context 'when entry exists' do
+ it 'returns correct entry' do
+ expect(global[:static_site_generator])
+ .to be_an_instance_of Gitlab::StaticSiteEditor::Config::FileConfig::Entry::StaticSiteGenerator
+ expect(global[:static_site_generator].value).to eq default_static_site_generator_value
+ end
+ end
+
+ context 'when entry does not exist' do
+ it 'always return unspecified node' do
+ expect(global[:some][:unknown][:node])
+ .not_to be_specified
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/static_site_editor/config/file_config/entry/image_upload_path_spec.rb b/spec/lib/gitlab/static_site_editor/config/file_config/entry/image_upload_path_spec.rb
new file mode 100644
index 00000000000..c2b7fbf6f98
--- /dev/null
+++ b/spec/lib/gitlab/static_site_editor/config/file_config/entry/image_upload_path_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::StaticSiteEditor::Config::FileConfig::Entry::ImageUploadPath do
+ subject(:image_upload_path_entry) { described_class.new(config) }
+
+ describe 'validations' do
+ context 'with a valid config' do
+ let(:config) { 'an-image-upload-path' }
+
+ it { is_expected.to be_valid }
+
+ describe '#value' do
+ it 'returns a image_upload_path key' do
+ expect(image_upload_path_entry.value).to eq config
+ end
+ end
+ end
+
+ context 'with an invalid config' do
+ let(:config) { { not_a_string: true } }
+
+ it { is_expected.not_to be_valid }
+
+ it 'reports errors about wrong type' do
+ expect(image_upload_path_entry.errors)
+ .to include 'image upload path config should be a string'
+ end
+ end
+ end
+
+ describe '.default' do
+ it 'returns default image_upload_path' do
+ expect(described_class.default).to eq 'source/images'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/static_site_editor/config/file_config/entry/mount_spec.rb b/spec/lib/gitlab/static_site_editor/config/file_config/entry/mount_spec.rb
new file mode 100644
index 00000000000..04248fc60a5
--- /dev/null
+++ b/spec/lib/gitlab/static_site_editor/config/file_config/entry/mount_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::StaticSiteEditor::Config::FileConfig::Entry::Mount do
+ subject(:entry) { described_class.new(config) }
+
+ describe 'validations' do
+ context 'with a valid config' do
+ context 'and target is a non-empty string' do
+ let(:config) do
+ {
+ source: 'source',
+ target: 'sub-site'
+ }
+ end
+
+ it { is_expected.to be_valid }
+
+ describe '#value' do
+ it 'returns mount configuration' do
+ expect(entry.value).to eq config
+ end
+ end
+ end
+
+ context 'and target is an empty string' do
+ let(:config) do
+ {
+ source: 'source',
+ target: ''
+ }
+ end
+
+ it { is_expected.to be_valid }
+
+ describe '#value' do
+ it 'returns mount configuration' do
+ expect(entry.value).to eq config
+ end
+ end
+ end
+ end
+
+ context 'with an invalid config' do
+ context 'when source is not a string' do
+ let(:config) { { source: 123, target: 'target' } }
+
+ it { is_expected.not_to be_valid }
+
+ it 'reports error' do
+ expect(entry.errors)
+ .to include 'mount source should be a string'
+ end
+ end
+
+ context 'when source is not present' do
+ let(:config) { { target: 'target' } }
+
+ it { is_expected.not_to be_valid }
+
+ it 'reports error' do
+ expect(entry.errors)
+ .to include "mount source can't be blank"
+ end
+ end
+
+ context 'when target is not a string' do
+ let(:config) { { source: 'source', target: 123 } }
+
+ it { is_expected.not_to be_valid }
+
+ it 'reports error' do
+ expect(entry.errors)
+ .to include 'mount target should be a string'
+ end
+ end
+
+ context 'when there is an unknown key present' do
+ let(:config) { { test: 100 } }
+
+ it { is_expected.not_to be_valid }
+
+ it 'reports error' do
+ expect(entry.errors)
+ .to include 'mount config contains unknown keys: test'
+ end
+ end
+ end
+ end
+
+ describe '.default' do
+ it 'returns default mount' do
+ expect(described_class.default)
+ .to eq({
+ source: 'source',
+ target: ''
+ })
+ end
+ end
+end
diff --git a/spec/lib/gitlab/static_site_editor/config/file_config/entry/mounts_spec.rb b/spec/lib/gitlab/static_site_editor/config/file_config/entry/mounts_spec.rb
new file mode 100644
index 00000000000..0ae2ece9474
--- /dev/null
+++ b/spec/lib/gitlab/static_site_editor/config/file_config/entry/mounts_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::StaticSiteEditor::Config::FileConfig::Entry::Mounts do
+ subject(:entry) { described_class.new(config) }
+
+ describe 'validations' do
+ context 'with a valid config' do
+ let(:config) do
+ [
+ {
+ source: 'source',
+ target: ''
+ },
+ {
+ source: 'sub-site/source',
+ target: 'sub-site'
+ }
+ ]
+ end
+
+ it { is_expected.to be_valid }
+
+ describe '#value' do
+ it 'returns mounts configuration' do
+ expect(entry.value).to eq config
+ end
+ end
+ end
+
+ context 'with an invalid config' do
+ let(:config) { { not_an_array: true } }
+
+ it { is_expected.not_to be_valid }
+
+ it 'reports errors about wrong type' do
+ expect(entry.errors)
+ .to include 'mounts config should be a array'
+ end
+ end
+ end
+
+ describe '.default' do
+ it 'returns default mounts' do
+ expect(described_class.default)
+ .to eq([{
+ source: 'source',
+ target: ''
+ }])
+ end
+ end
+end
diff --git a/spec/lib/gitlab/static_site_editor/config/file_config/entry/static_site_generator_spec.rb b/spec/lib/gitlab/static_site_editor/config/file_config/entry/static_site_generator_spec.rb
new file mode 100644
index 00000000000..a9c730218cf
--- /dev/null
+++ b/spec/lib/gitlab/static_site_editor/config/file_config/entry/static_site_generator_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::StaticSiteEditor::Config::FileConfig::Entry::StaticSiteGenerator do
+ let(:static_site_generator) { described_class.new(config) }
+
+ describe 'validations' do
+ context 'when value is valid' do
+ let(:config) { 'middleman' }
+
+ describe '#value' do
+ it 'returns a static_site_generator key' do
+ expect(static_site_generator.value).to eq config
+ end
+ end
+
+ describe '#valid?' do
+ it 'is valid' do
+ expect(static_site_generator).to be_valid
+ end
+ end
+ end
+
+ context 'when value is invalid' do
+ let(:config) { 'not-a-valid-generator' }
+
+ describe '#valid?' do
+ it 'is not valid' do
+ expect(static_site_generator).not_to be_valid
+ end
+ end
+ end
+
+ context 'when value has a wrong type' do
+ let(:config) { { not_a_string: true } }
+
+ it 'reports errors about wrong type' do
+ expect(static_site_generator.errors)
+ .to include 'static site generator config should be a string'
+ end
+ end
+ end
+
+ describe '.default' do
+ it 'returns default static_site_generator' do
+ expect(described_class.default).to eq 'middleman'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/static_site_editor/config/file_config_spec.rb b/spec/lib/gitlab/static_site_editor/config/file_config_spec.rb
index 594425c2dab..d444d4f1df7 100644
--- a/spec/lib/gitlab/static_site_editor/config/file_config_spec.rb
+++ b/spec/lib/gitlab/static_site_editor/config/file_config_spec.rb
@@ -3,13 +3,85 @@
require 'spec_helper'
RSpec.describe Gitlab::StaticSiteEditor::Config::FileConfig do
- subject(:config) { described_class.new }
+ let(:config) do
+ described_class.new(yml)
+ end
+
+ context 'when config is valid' do
+ context 'when config has valid values' do
+ let(:yml) do
+ <<-EOS
+ static_site_generator: middleman
+ EOS
+ end
+
+ describe '#to_hash_with_defaults' do
+ it 'returns hash created from string' do
+ expect(config.to_hash_with_defaults.fetch(:static_site_generator)).to eq 'middleman'
+ end
+ end
+
+ describe '#valid?' do
+ it 'is valid' do
+ expect(config).to be_valid
+ end
+
+ it 'has no errors' do
+ expect(config.errors).to be_empty
+ end
+ end
+ end
+ end
+
+ context 'when a config entry has an empty value' do
+ let(:yml) { 'static_site_generator: ' }
+
+ describe '#to_hash' do
+ it 'returns default value' do
+ expect(config.to_hash_with_defaults.fetch(:static_site_generator)).to eq 'middleman'
+ end
+ end
+
+ describe '#valid?' do
+ it 'is valid' do
+ expect(config).to be_valid
+ end
+
+ it 'has no errors' do
+ expect(config.errors).to be_empty
+ end
+ end
+ end
+
+ context 'when config is invalid' do
+ context 'when yml is incorrect' do
+ let(:yml) { '// invalid' }
+
+ describe '.new' do
+ it 'raises error' do
+ expect { config }.to raise_error(described_class::ConfigError, /Invalid configuration format/)
+ end
+ end
+ end
+
+ context 'when config value exists but is not a valid value' do
+ let(:yml) { 'static_site_generator: "unsupported-generator"' }
+
+ describe '#valid?' do
+ it 'is not valid' do
+ expect(config).not_to be_valid
+ end
- describe '#data' do
- subject { config.data }
+ it 'has errors' do
+ expect(config.errors).not_to be_empty
+ end
+ end
- it 'returns hardcoded data for now' do
- is_expected.to match(static_site_generator: 'middleman')
+ describe '#errors' do
+ it 'returns an array of strings' do
+ expect(config.errors).to all(be_an_instance_of(String))
+ end
+ end
end
end
end
diff --git a/spec/lib/gitlab/static_site_editor/config/generated_config_spec.rb b/spec/lib/gitlab/static_site_editor/config/generated_config_spec.rb
index 3433a54be9c..2f761b69e60 100644
--- a/spec/lib/gitlab/static_site_editor/config/generated_config_spec.rb
+++ b/spec/lib/gitlab/static_site_editor/config/generated_config_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe Gitlab::StaticSiteEditor::Config::GeneratedConfig do
project: 'project',
project_id: project.id,
return_url: 'http://example.com',
- is_supported_content: 'true',
+ is_supported_content: true,
base_url: '/namespace/project/-/sse/master%2FREADME.md',
merge_requests_illustration_path: %r{illustrations/merge_requests}
})
@@ -65,7 +65,7 @@ RSpec.describe Gitlab::StaticSiteEditor::Config::GeneratedConfig do
stub_feature_flags(sse_erb_support: project)
end
- it { is_expected.to include(is_supported_content: 'true') }
+ it { is_expected.to include(is_supported_content: true) }
end
context 'when feature flag is disabled' do
@@ -75,7 +75,7 @@ RSpec.describe Gitlab::StaticSiteEditor::Config::GeneratedConfig do
stub_feature_flags(sse_erb_support: false)
end
- it { is_expected.to include(is_supported_content: 'false') }
+ it { is_expected.to include(is_supported_content: false) }
end
end
@@ -88,31 +88,31 @@ RSpec.describe Gitlab::StaticSiteEditor::Config::GeneratedConfig do
context 'when branch is not master' do
let(:ref) { 'my-branch' }
- it { is_expected.to include(is_supported_content: 'false') }
+ it { is_expected.to include(is_supported_content: false) }
end
context 'when file does not have a markdown extension' do
let(:path) { 'README.txt' }
- it { is_expected.to include(is_supported_content: 'false') }
+ it { is_expected.to include(is_supported_content: false) }
end
context 'when file does not have an extension' do
let(:path) { 'README' }
- it { is_expected.to include(is_supported_content: 'false') }
+ it { is_expected.to include(is_supported_content: false) }
end
context 'when file does not exist' do
let(:path) { 'UNKNOWN.md' }
- it { is_expected.to include(is_supported_content: 'false') }
+ it { is_expected.to include(is_supported_content: false) }
end
context 'when repository is empty' do
let(:repository) { create(:project_empty_repo).repository }
- it { is_expected.to include(is_supported_content: 'false') }
+ it { is_expected.to include(is_supported_content: false) }
end
context 'when return_url is not a valid URL' do
@@ -132,5 +132,11 @@ RSpec.describe Gitlab::StaticSiteEditor::Config::GeneratedConfig do
it { is_expected.to include(return_url: nil) }
end
+
+ context 'when a commit for the ref cannot be found' do
+ let(:ref) { 'nonexistent-ref' }
+
+ it { is_expected.to include(commit_id: nil) }
+ end
end
end
diff --git a/spec/lib/gitlab/themes_spec.rb b/spec/lib/gitlab/themes_spec.rb
index 68ff28becfa..6d03cf496b8 100644
--- a/spec/lib/gitlab/themes_spec.rb
+++ b/spec/lib/gitlab/themes_spec.rb
@@ -47,4 +47,18 @@ RSpec.describe Gitlab::Themes, lib: true do
expect(ids).not_to be_empty
end
end
+
+ describe 'theme.css_filename' do
+ described_class.each do |theme|
+ next unless theme.css_filename
+
+ context "for #{theme.name}" do
+ it 'returns an existing CSS filename' do
+ css_file_path = Rails.root.join('app/assets/stylesheets/themes', theme.css_filename + '.scss')
+
+ expect(File.exist?(css_file_path)).to eq(true)
+ end
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/tracking_spec.rb b/spec/lib/gitlab/tracking_spec.rb
index f0bf7b9964f..6ddeaf98370 100644
--- a/spec/lib/gitlab/tracking_spec.rb
+++ b/spec/lib/gitlab/tracking_spec.rb
@@ -47,7 +47,7 @@ RSpec.describe Gitlab::Tracking do
end
around do |example|
- Timecop.freeze(timestamp) { example.run }
+ travel_to(timestamp) { example.run }
end
before do
diff --git a/spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb
index 2a674557b76..f2c1d8718d7 100644
--- a/spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/editor_unique_counter_spec.rb
@@ -41,11 +41,11 @@ RSpec.describe Gitlab::UsageDataCounters::EditorUniqueCounter, :clean_gitlab_red
context 'for web IDE edit actions' do
it_behaves_like 'tracks and counts action' do
def track_action(params)
- described_class.track_web_ide_edit_action(params)
+ described_class.track_web_ide_edit_action(**params)
end
def count_unique(params)
- described_class.count_web_ide_edit_actions(params)
+ described_class.count_web_ide_edit_actions(**params)
end
end
end
@@ -53,11 +53,11 @@ RSpec.describe Gitlab::UsageDataCounters::EditorUniqueCounter, :clean_gitlab_red
context 'for SFE edit actions' do
it_behaves_like 'tracks and counts action' do
def track_action(params)
- described_class.track_sfe_edit_action(params)
+ described_class.track_sfe_edit_action(**params)
end
def count_unique(params)
- described_class.count_sfe_edit_actions(params)
+ described_class.count_sfe_edit_actions(**params)
end
end
end
@@ -65,11 +65,11 @@ RSpec.describe Gitlab::UsageDataCounters::EditorUniqueCounter, :clean_gitlab_red
context 'for snippet editor edit actions' do
it_behaves_like 'tracks and counts action' do
def track_action(params)
- described_class.track_snippet_editor_edit_action(params)
+ described_class.track_snippet_editor_edit_action(**params)
end
def count_unique(params)
- described_class.count_snippet_editor_edit_actions(params)
+ described_class.count_snippet_editor_edit_actions(**params)
end
end
end
diff --git a/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb
index f881da71251..3255e3616b2 100644
--- a/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
# depending on which day of the week test is run.
# Monday 6th of June
reference_time = Time.utc(2020, 6, 1)
- Timecop.freeze(reference_time) { example.run }
+ travel_to(reference_time) { example.run }
end
describe '.categories' do
@@ -238,16 +238,20 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
it 'returns the number of unique events for all known events' do
results = {
- 'category1' => {
- 'event1_slot' => 1,
- 'event2_slot' => 1,
- 'category1_total_unique_counts_weekly' => 2,
- 'category1_total_unique_counts_monthly' => 3
- },
- 'category2' => {
- 'event3' => 1,
- 'event4' => 1
- }
+ "category1" => {
+ "event1_slot_weekly" => 1,
+ "event1_slot_monthly" => 1,
+ "event2_slot_weekly" => 1,
+ "event2_slot_monthly" => 2,
+ "category1_total_unique_counts_weekly" => 2,
+ "category1_total_unique_counts_monthly" => 3
+ },
+ "category2" => {
+ "event3_weekly" => 1,
+ "event3_monthly" => 1,
+ "event4_weekly" => 1,
+ "event4_monthly" => 1
+ }
}
expect(subject.unique_events_data).to eq(results)
diff --git a/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb
index 479fe36bcdd..4cc85c86de1 100644
--- a/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb
@@ -47,7 +47,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
let(:action) { described_class::ISSUE_TITLE_CHANGED }
def track_action(params)
- described_class.track_issue_title_changed_action(params)
+ described_class.track_issue_title_changed_action(**params)
end
end
end
@@ -57,7 +57,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
let(:action) { described_class::ISSUE_DESCRIPTION_CHANGED }
def track_action(params)
- described_class.track_issue_description_changed_action(params)
+ described_class.track_issue_description_changed_action(**params)
end
end
end
@@ -67,7 +67,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
let(:action) { described_class::ISSUE_ASSIGNEE_CHANGED }
def track_action(params)
- described_class.track_issue_assignee_changed_action(params)
+ described_class.track_issue_assignee_changed_action(**params)
end
end
end
@@ -77,7 +77,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
let(:action) { described_class::ISSUE_MADE_CONFIDENTIAL }
def track_action(params)
- described_class.track_issue_made_confidential_action(params)
+ described_class.track_issue_made_confidential_action(**params)
end
end
end
@@ -87,7 +87,47 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
let(:action) { described_class::ISSUE_MADE_VISIBLE }
def track_action(params)
- described_class.track_issue_made_visible_action(params)
+ described_class.track_issue_made_visible_action(**params)
+ end
+ end
+ end
+
+ context 'for Issue created actions' do
+ it_behaves_like 'tracks and counts action' do
+ let(:action) { described_class::ISSUE_CREATED }
+
+ def track_action(params)
+ described_class.track_issue_created_action(**params)
+ end
+ end
+ end
+
+ context 'for Issue closed actions' do
+ it_behaves_like 'tracks and counts action' do
+ let(:action) { described_class::ISSUE_CLOSED }
+
+ def track_action(params)
+ described_class.track_issue_closed_action(**params)
+ end
+ end
+ end
+
+ context 'for Issue reopened actions' do
+ it_behaves_like 'tracks and counts action' do
+ let(:action) { described_class::ISSUE_REOPENED }
+
+ def track_action(params)
+ described_class.track_issue_reopened_action(**params)
+ end
+ end
+ end
+
+ context 'for Issue label changed actions' do
+ it_behaves_like 'tracks and counts action' do
+ let(:action) { described_class::ISSUE_LABEL_CHANGED }
+
+ def track_action(params)
+ described_class.track_issue_label_changed_action(**params)
end
end
end
diff --git a/spec/lib/gitlab/usage_data_counters/track_unique_events_spec.rb b/spec/lib/gitlab/usage_data_counters/track_unique_events_spec.rb
index 8f5f1347ce8..d1144dd0bc5 100644
--- a/spec/lib/gitlab/usage_data_counters/track_unique_events_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/track_unique_events_spec.rb
@@ -8,11 +8,11 @@ RSpec.describe Gitlab::UsageDataCounters::TrackUniqueEvents, :clean_gitlab_redis
let(:time) { Time.zone.now }
def track_event(params)
- track_unique_events.track_event(params)
+ track_unique_events.track_event(**params)
end
def count_unique(params)
- track_unique_events.count_unique_events(params)
+ track_unique_events.count_unique_events(**params)
end
context 'tracking an event' do
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index 6631a0d3cc6..984fd4c08e6 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -29,7 +29,8 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
user_minimum_id user_maximum_id unique_visit_service
deployment_minimum_id deployment_maximum_id
approval_merge_request_rule_minimum_id
- approval_merge_request_rule_maximum_id)
+ approval_merge_request_rule_maximum_id
+ auth_providers)
values.each do |key|
expect(described_class).to receive(:clear_memoization).with(key)
end
@@ -167,6 +168,8 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
describe 'usage_activity_by_stage_manage' do
it 'includes accurate usage_activity_by_stage data' do
+ described_class.clear_memoization(:auth_providers)
+
stub_config(
omniauth:
{ providers: omniauth_providers }
@@ -174,21 +177,29 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
for_defined_days_back do
user = create(:user)
+ user2 = create(:user)
create(:event, author: user)
create(:group_member, user: user)
+ create(:authentication_event, user: user, provider: :ldapmain, result: :success)
+ create(:authentication_event, user: user2, provider: :ldapsecondary, result: :success)
+ create(:authentication_event, user: user2, provider: :group_saml, result: :success)
+ create(:authentication_event, user: user2, provider: :group_saml, result: :success)
+ create(:authentication_event, user: user, provider: :group_saml, result: :failed)
end
expect(described_class.usage_activity_by_stage_manage({})).to include(
events: 2,
groups: 2,
- users_created: 4,
- omniauth_providers: ['google_oauth2']
+ users_created: 6,
+ omniauth_providers: ['google_oauth2'],
+ user_auth_by_provider: { 'group_saml' => 2, 'ldap' => 4 }
)
expect(described_class.usage_activity_by_stage_manage(described_class.last_28_days_time_period)).to include(
events: 1,
groups: 1,
- users_created: 2,
- omniauth_providers: ['google_oauth2']
+ users_created: 3,
+ omniauth_providers: ['google_oauth2'],
+ user_auth_by_provider: { 'group_saml' => 1, 'ldap' => 2 }
)
end
@@ -244,6 +255,20 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
)
end
+ it 'includes group imports usage data' do
+ for_defined_days_back do
+ user = create(:user)
+ group = create(:group)
+ group.add_owner(user)
+ create(:group_import_state, group: group, user: user)
+ end
+
+ expect(described_class.usage_activity_by_stage_manage({}))
+ .to include(groups_imported: 2)
+ expect(described_class.usage_activity_by_stage_manage(described_class.last_28_days_time_period))
+ .to include(groups_imported: 1)
+ end
+
def omniauth_providers
[
OpenStruct.new(name: 'google_oauth2'),
@@ -260,17 +285,20 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
cluster = create(:cluster, user: user)
create(:project, creator: user)
create(:clusters_applications_prometheus, :installed, cluster: cluster)
+ create(:project_tracing_setting)
end
expect(described_class.usage_activity_by_stage_monitor({})).to include(
clusters: 2,
clusters_applications_prometheus: 2,
- operations_dashboard_default_dashboard: 2
+ operations_dashboard_default_dashboard: 2,
+ projects_with_tracing_enabled: 2
)
expect(described_class.usage_activity_by_stage_monitor(described_class.last_28_days_time_period)).to include(
clusters: 1,
clusters_applications_prometheus: 1,
- operations_dashboard_default_dashboard: 1
+ operations_dashboard_default_dashboard: 1,
+ projects_with_tracing_enabled: 1
)
end
end
@@ -420,6 +448,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(count_data[:projects_inheriting_instance_mattermost_active]).to eq(1)
expect(count_data[:projects_with_repositories_enabled]).to eq(3)
expect(count_data[:projects_with_error_tracking_enabled]).to eq(1)
+ expect(count_data[:projects_with_tracing_enabled]).to eq(1)
expect(count_data[:projects_with_alerts_service_enabled]).to eq(1)
expect(count_data[:projects_with_prometheus_alerts]).to eq(2)
expect(count_data[:projects_with_terraform_reports]).to eq(2)
@@ -472,8 +501,10 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(count_data[:personal_snippets]).to eq(2)
expect(count_data[:project_snippets]).to eq(4)
+ expect(count_data[:projects_creating_incidents]).to eq(2)
expect(count_data[:projects_with_packages]).to eq(2)
expect(count_data[:packages]).to eq(4)
+ expect(count_data[:user_preferences_user_gitpod_enabled]).to eq(1)
end
it 'gathers object store usage correctly' do
@@ -628,6 +659,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(subject[:gitlab_shared_runners_enabled]).to eq(Gitlab.config.gitlab_ci.shared_runners_enabled)
expect(subject[:web_ide_clientside_preview_enabled]).to eq(Gitlab::CurrentSettings.web_ide_clientside_preview_enabled?)
expect(subject[:grafana_link_enabled]).to eq(Gitlab::CurrentSettings.grafana_enabled?)
+ expect(subject[:gitpod_enabled]).to eq(Gitlab::CurrentSettings.gitpod_enabled?)
end
context 'with embedded Prometheus' do
@@ -657,6 +689,20 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(subject[:grafana_link_enabled]).to eq(false)
end
end
+
+ context 'with Gitpod' do
+ it 'returns true when is enabled' do
+ stub_application_setting(gitpod_enabled: true)
+
+ expect(subject[:gitpod_enabled]).to eq(true)
+ end
+
+ it 'returns false when is disabled' do
+ stub_application_setting(gitpod_enabled: false)
+
+ expect(subject[:gitpod_enabled]).to eq(false)
+ end
+ end
end
describe '.components_usage_data' do
@@ -979,7 +1025,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
end
end
- def for_defined_days_back(days: [29, 2])
+ def for_defined_days_back(days: [31, 3])
days.each do |n|
Timecop.travel(n.days.ago) do
yield
@@ -1146,11 +1192,13 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
categories.each do |category|
keys = ::Gitlab::UsageDataCounters::HLLRedisCounter.events_for_category(category)
+ metrics = keys.map { |key| "#{key}_weekly" } + keys.map { |key| "#{key}_monthly" }
+
if ineligible_total_categories.exclude?(category)
- keys.append("#{category}_total_unique_counts_weekly", "#{category}_total_unique_counts_monthly")
+ metrics.append("#{category}_total_unique_counts_weekly", "#{category}_total_unique_counts_monthly")
end
- expect(subject[:redis_hll_counters][category].keys).to match_array(keys)
+ expect(subject[:redis_hll_counters][category].keys).to match_array(metrics)
end
end
end
@@ -1169,6 +1217,8 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
end
describe '.snowplow_event_counts' do
+ let_it_be(:time_period) { { collector_tstamp: 8.days.ago..1.day.ago } }
+
context 'when self-monitoring project exists' do
let_it_be(:project) { create(:project) }
@@ -1181,14 +1231,14 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
stub_feature_flags(product_analytics: project)
create(:product_analytics_event, project: project, se_category: 'epics', se_action: 'promote')
- create(:product_analytics_event, project: project, se_category: 'epics', se_action: 'promote', collector_tstamp: 28.days.ago)
+ create(:product_analytics_event, project: project, se_category: 'epics', se_action: 'promote', collector_tstamp: 2.days.ago)
+ create(:product_analytics_event, project: project, se_category: 'epics', se_action: 'promote', collector_tstamp: 9.days.ago)
+
+ create(:product_analytics_event, project: project, se_category: 'foo', se_action: 'bar', collector_tstamp: 2.days.ago)
end
it 'returns promoted_issues for the time period' do
- expect(described_class.snowplow_event_counts[:promoted_issues]).to eq(2)
- expect(described_class.snowplow_event_counts(
- time_period: described_class.last_28_days_time_period(column: :collector_tstamp)
- )[:promoted_issues]).to eq(1)
+ expect(described_class.snowplow_event_counts(time_period)[:promoted_issues]).to eq(1)
end
end
@@ -1198,14 +1248,14 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
end
it 'returns an empty hash' do
- expect(described_class.snowplow_event_counts).to eq({})
+ expect(described_class.snowplow_event_counts(time_period)).to eq({})
end
end
end
context 'when self-monitoring project does not exist' do
it 'returns an empty hash' do
- expect(described_class.snowplow_event_counts).to eq({})
+ expect(described_class.snowplow_event_counts(time_period)).to eq({})
end
end
end
diff --git a/spec/lib/gitlab/utils/usage_data_spec.rb b/spec/lib/gitlab/utils/usage_data_spec.rb
index 362cbaa78e9..9c0dc69ccd1 100644
--- a/spec/lib/gitlab/utils/usage_data_spec.rb
+++ b/spec/lib/gitlab/utils/usage_data_spec.rb
@@ -212,33 +212,26 @@ RSpec.describe Gitlab::Utils::UsageData do
describe '#track_usage_event' do
let(:value) { '9f302fea-f828-4ca9-aef4-e10bd723c0b3' }
- let(:event_name) { 'my_event' }
+ let(:event_name) { 'incident_management_alert_status_changed' }
let(:unknown_event) { 'unknown' }
let(:feature) { "usage_data_#{event_name}" }
+ before do
+ skip_feature_flags_yaml_validation
+ end
+
context 'with feature enabled' do
before do
stub_feature_flags(feature => true)
end
it 'tracks redis hll event' do
- stub_application_setting(usage_ping_enabled: true)
-
expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(value, event_name)
described_class.track_usage_event(event_name, value)
end
- it 'does not track event when usage ping is not enabled' do
- stub_application_setting(usage_ping_enabled: false)
- expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
-
- described_class.track_usage_event(event_name, value)
- end
-
it 'raise an error for unknown event' do
- stub_application_setting(usage_ping_enabled: true)
-
expect { described_class.track_usage_event(unknown_event, value) }.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownEvent)
end
end
diff --git a/spec/lib/gitlab/webpack/manifest_spec.rb b/spec/lib/gitlab/webpack/manifest_spec.rb
new file mode 100644
index 00000000000..e3aeceda148
--- /dev/null
+++ b/spec/lib/gitlab/webpack/manifest_spec.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'json'
+
+RSpec.describe Gitlab::Webpack::Manifest do
+ let(:manifest) do
+ <<-EOF
+ {
+ "errors": [],
+ "assetsByChunkName": {
+ "entry1": [ "entry1.js", "entry1-a.js" ],
+ "entry2": "entry2.js"
+ }
+ }
+ EOF
+ end
+
+ around do |example|
+ Gitlab::Webpack::Manifest.clear_manifest!
+
+ example.run
+
+ Gitlab::Webpack::Manifest.clear_manifest!
+ end
+
+ shared_examples_for "a valid manifest" do
+ it "returns single entry asset paths from the manifest" do
+ expect(Gitlab::Webpack::Manifest.asset_paths("entry2")).to eq(["/public_path/entry2.js"])
+ end
+
+ it "returns multiple entry asset paths from the manifest" do
+ expect(Gitlab::Webpack::Manifest.asset_paths("entry1")).to eq(["/public_path/entry1.js", "/public_path/entry1-a.js"])
+ end
+
+ it "errors on a missing entry point" do
+ expect { Gitlab::Webpack::Manifest.asset_paths("herp") }.to raise_error(Gitlab::Webpack::Manifest::AssetMissingError)
+ end
+ end
+
+ before do
+ # Test that config variables work while we're here
+ ::Rails.configuration.webpack.dev_server.host = 'hostname'
+ ::Rails.configuration.webpack.dev_server.port = 1999
+ ::Rails.configuration.webpack.dev_server.manifest_host = 'hostname'
+ ::Rails.configuration.webpack.dev_server.manifest_port = 2000
+ ::Rails.configuration.webpack.manifest_filename = "my_manifest.json"
+ ::Rails.configuration.webpack.public_path = "public_path"
+ ::Rails.configuration.webpack.output_dir = "manifest_output"
+ end
+
+ context "with dev server enabled" do
+ before do
+ ::Rails.configuration.webpack.dev_server.enabled = true
+
+ stub_request(:get, "http://hostname:2000/public_path/my_manifest.json").to_return(body: manifest, status: 200)
+ end
+
+ describe ".asset_paths" do
+ it_behaves_like "a valid manifest"
+
+ it "errors if we can't find the manifest" do
+ ::Rails.configuration.webpack.manifest_filename = "broken.json"
+ stub_request(:get, "http://hostname:2000/public_path/broken.json").to_raise(SocketError)
+
+ expect { Gitlab::Webpack::Manifest.asset_paths("entry1") }.to raise_error(Gitlab::Webpack::Manifest::ManifestLoadError)
+ end
+
+ describe "webpack errors" do
+ context "when webpack has 'Module build failed' errors in its manifest" do
+ it "errors" do
+ error_manifest = Gitlab::Json.parse(manifest).merge("errors" => [
+ "somethingModule build failed something",
+ "I am an error"
+ ]).to_json
+ stub_request(:get, "http://hostname:2000/public_path/my_manifest.json").to_return(body: error_manifest, status: 200)
+
+ expect { Gitlab::Webpack::Manifest.asset_paths("entry1") }.to raise_error(Gitlab::Webpack::Manifest::WebpackError)
+ end
+ end
+
+ context "when webpack does not have 'Module build failed' errors in its manifest" do
+ it "does not error" do
+ error_manifest = Gitlab::Json.parse(manifest).merge("errors" => ["something went wrong"]).to_json
+ stub_request(:get, "http://hostname:2000/public_path/my_manifest.json").to_return(body: error_manifest, status: 200)
+
+ expect { Gitlab::Webpack::Manifest.asset_paths("entry1") }.not_to raise_error
+ end
+ end
+
+ it "does not error if errors is present but empty" do
+ error_manifest = Gitlab::Json.parse(manifest).merge("errors" => []).to_json
+ stub_request(:get, "http://hostname:2000/public_path/my_manifest.json").to_return(body: error_manifest, status: 200)
+ expect { Gitlab::Webpack::Manifest.asset_paths("entry1") }.not_to raise_error
+ end
+ end
+ end
+ end
+
+ context "with dev server disabled" do
+ before do
+ ::Rails.configuration.webpack.dev_server.enabled = false
+ allow(File).to receive(:read).with(::Rails.root.join("manifest_output/my_manifest.json")).and_return(manifest)
+ end
+
+ describe ".asset_paths" do
+ it_behaves_like "a valid manifest"
+
+ it "errors if we can't find the manifest" do
+ ::Rails.configuration.webpack.manifest_filename = "broken.json"
+ allow(File).to receive(:read).with(::Rails.root.join("manifest_output/broken.json")).and_raise(Errno::ENOENT)
+ expect { Gitlab::Webpack::Manifest.asset_paths("entry1") }.to raise_error(Gitlab::Webpack::Manifest::ManifestLoadError)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab_danger_spec.rb b/spec/lib/gitlab_danger_spec.rb
index b534823a888..e332647cf8a 100644
--- a/spec/lib/gitlab_danger_spec.rb
+++ b/spec/lib/gitlab_danger_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe GitlabDanger do
describe '.local_warning_message' do
it 'returns an informational message with rules that can run' do
- expect(described_class.local_warning_message).to eq('==> Only the following Danger rules can be run locally: changes_size, documentation, frozen_string, duplicate_yarn_dependencies, prettier, eslint, karma, database, commit_messages, telemetry, utility_css, pajamas')
+ expect(described_class.local_warning_message).to eq('==> Only the following Danger rules can be run locally: changes_size, documentation, frozen_string, duplicate_yarn_dependencies, prettier, eslint, karma, database, commit_messages, product_analytics, utility_css, pajamas')
end
end
diff --git a/spec/lib/google_api/auth_spec.rb b/spec/lib/google_api/auth_spec.rb
index eeb99bfbb6c..92cb9e494ac 100644
--- a/spec/lib/google_api/auth_spec.rb
+++ b/spec/lib/google_api/auth_spec.rb
@@ -12,12 +12,12 @@ RSpec.describe GoogleApi::Auth do
end
describe '#authorize_url' do
- subject { client.authorize_url }
+ subject { Addressable::URI.parse(client.authorize_url) }
it 'returns authorize_url' do
- is_expected.to start_with('https://accounts.google.com/o/oauth2')
- is_expected.to include(URI.encode(redirect_uri, URI::PATTERN::RESERVED))
- is_expected.to include(URI.encode(redirect_to, URI::PATTERN::RESERVED))
+ expect(subject.to_s).to start_with('https://accounts.google.com/o/oauth2')
+ expect(subject.query_values['state']).to eq(redirect_to)
+ expect(subject.query_values['redirect_uri']).to eq(redirect_uri)
end
end
diff --git a/spec/lib/grafana/time_window_spec.rb b/spec/lib/grafana/time_window_spec.rb
index 9ee65c6cf20..0657bed7b28 100644
--- a/spec/lib/grafana/time_window_spec.rb
+++ b/spec/lib/grafana/time_window_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Grafana::TimeWindow do
let(:to) { '1552828200000' }
around do |example|
- Timecop.freeze(Time.utc(2019, 3, 17, 13, 10)) { example.run }
+ travel_to(Time.utc(2019, 3, 17, 13, 10)) { example.run }
end
describe '#formatted' do
@@ -37,7 +37,7 @@ RSpec.describe Grafana::RangeWithDefaults do
let(:to) { Grafana::Timestamp.from_ms_since_epoch('1552828200000') }
around do |example|
- Timecop.freeze(Time.utc(2019, 3, 17, 13, 10)) { example.run }
+ travel_to(Time.utc(2019, 3, 17, 13, 10)) { example.run }
end
describe '#to_hash' do
@@ -82,7 +82,7 @@ RSpec.describe Grafana::Timestamp do
let(:timestamp) { Time.at(1552799400) }
around do |example|
- Timecop.freeze(Time.utc(2019, 3, 17, 13, 10)) { example.run }
+ travel_to(Time.utc(2019, 3, 17, 13, 10)) { example.run }
end
describe '#formatted' do
diff --git a/spec/lib/pager_duty/webhook_payload_parser_spec.rb b/spec/lib/pager_duty/webhook_payload_parser_spec.rb
index 0010165318d..54c61b9121c 100644
--- a/spec/lib/pager_duty/webhook_payload_parser_spec.rb
+++ b/spec/lib/pager_duty/webhook_payload_parser_spec.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'fast_spec_helper'
+require 'json_schemer'
RSpec.describe PagerDuty::WebhookPayloadParser do
describe '.call' do
@@ -8,36 +9,36 @@ RSpec.describe PagerDuty::WebhookPayloadParser do
File.read(File.join(File.dirname(__FILE__), '../../fixtures/pager_duty/webhook_incident_trigger.json'))
end
+ let(:triggered_event) do
+ {
+ 'event' => 'incident.trigger',
+ 'incident' => {
+ 'url' => 'https://webdemo.pagerduty.com/incidents/PRORDTY',
+ 'incident_number' => 33,
+ 'title' => 'My new incident',
+ 'status' => 'triggered',
+ 'created_at' => '2017-09-26T15:14:36Z',
+ 'urgency' => 'high',
+ 'incident_key' => nil,
+ 'assignees' => [{
+ 'summary' => 'Laura Haley',
+ 'url' => 'https://webdemo.pagerduty.com/users/P553OPV'
+ }],
+ 'impacted_services' => [{
+ 'summary' => 'Production XDB Cluster',
+ 'url' => 'https://webdemo.pagerduty.com/services/PN49J75'
+ }]
+ }
+ }
+ end
+
subject(:parse) { described_class.call(payload) }
context 'when payload is a correct PagerDuty payload' do
let(:payload) { Gitlab::Json.parse(fixture_file) }
it 'returns parsed payload' do
- is_expected.to eq(
- [
- {
- 'event' => 'incident.trigger',
- 'incident' => {
- 'url' => 'https://webdemo.pagerduty.com/incidents/PRORDTY',
- 'incident_number' => 33,
- 'title' => 'My new incident',
- 'status' => 'triggered',
- 'created_at' => '2017-09-26T15:14:36Z',
- 'urgency' => 'high',
- 'incident_key' => nil,
- 'assignees' => [{
- 'summary' => 'Laura Haley',
- 'url' => 'https://webdemo.pagerduty.com/users/P553OPV'
- }],
- 'impacted_services' => [{
- 'summary' => 'Production XDB Cluster',
- 'url' => 'https://webdemo.pagerduty.com/services/PN49J75'
- }]
- }
- }
- ]
- )
+ is_expected.to eq([triggered_event])
end
context 'when assignments summary and html_url are blank' do
@@ -69,11 +70,42 @@ RSpec.describe PagerDuty::WebhookPayloadParser do
end
end
- context 'when payload has no incidents' do
+ context 'when payload schema is invalid' do
let(:payload) { { 'messages' => [{ 'event' => 'incident.trigger' }] } }
it 'returns payload with blank incidents' do
- is_expected.to eq([{ 'event' => 'incident.trigger', 'incident' => {} }])
+ is_expected.to eq([])
+ end
+ end
+
+ context 'when payload consists of two messages' do
+ context 'when one of the messages has no incident data' do
+ let(:payload) do
+ valid_payload = Gitlab::Json.parse(fixture_file)
+ event = { 'event' => 'incident.trigger' }
+ valid_payload['messages'] = valid_payload['messages'].append(event)
+ valid_payload
+ end
+
+ it 'returns parsed payload with valid events only' do
+ is_expected.to eq([triggered_event])
+ end
+ end
+
+ context 'when one of the messages has unknown event' do
+ let(:payload) do
+ valid_payload = Gitlab::Json.parse(fixture_file)
+ event = { 'event' => 'incident.unknown', 'incident' => valid_payload['messages'].first['incident'] }
+ valid_payload['messages'] = valid_payload['messages'].append(event)
+ valid_payload
+ end
+
+ it 'returns parsed payload' do
+ unknown_event = triggered_event.dup
+ unknown_event['event'] = 'incident.unknown'
+
+ is_expected.to contain_exactly(triggered_event, unknown_event)
+ end
end
end
end
diff --git a/spec/lib/safe_zip/extract_spec.rb b/spec/lib/safe_zip/extract_spec.rb
index 30b7e1cdd2c..443430b267d 100644
--- a/spec/lib/safe_zip/extract_spec.rb
+++ b/spec/lib/safe_zip/extract_spec.rb
@@ -15,11 +15,7 @@ RSpec.describe SafeZip::Extract do
describe '#extract' do
subject { object.extract(directories: directories, to: target_path) }
- shared_examples 'extracts archive' do |param|
- before do
- stub_feature_flags(safezip_use_rubyzip: param)
- end
-
+ shared_examples 'extracts archive' do
it 'does extract archive' do
subject
@@ -28,11 +24,7 @@ RSpec.describe SafeZip::Extract do
end
end
- shared_examples 'fails to extract archive' do |param|
- before do
- stub_feature_flags(safezip_use_rubyzip: param)
- end
-
+ shared_examples 'fails to extract archive' do
it 'does not extract archive' do
expect { subject }.to raise_error(SafeZip::Extract::Error)
end
@@ -42,13 +34,7 @@ RSpec.describe SafeZip::Extract do
context "when using #{name} archive" do
let(:archive_name) { name }
- context 'for RubyZip' do
- it_behaves_like 'extracts archive', true
- end
-
- context 'for UnZip' do
- it_behaves_like 'extracts archive', false
- end
+ it_behaves_like 'extracts archive'
end
end
@@ -56,13 +42,7 @@ RSpec.describe SafeZip::Extract do
context "when using #{name} archive" do
let(:archive_name) { name }
- context 'for RubyZip' do
- it_behaves_like 'fails to extract archive', true
- end
-
- context 'for UnZip (UNSAFE)' do
- it_behaves_like 'extracts archive', false
- end
+ it_behaves_like 'fails to extract archive'
end
end
@@ -70,13 +50,7 @@ RSpec.describe SafeZip::Extract do
let(:archive_name) { 'valid-simple.zip' }
let(:directories) { %w(non/existing) }
- context 'for RubyZip' do
- it_behaves_like 'fails to extract archive', true
- end
-
- context 'for UnZip' do
- it_behaves_like 'fails to extract archive', false
- end
+ it_behaves_like 'fails to extract archive'
end
end
end
diff --git a/spec/mailers/abuse_report_mailer_spec.rb b/spec/mailers/abuse_report_mailer_spec.rb
index 4eb616722ac..061f972fd35 100644
--- a/spec/mailers/abuse_report_mailer_spec.rb
+++ b/spec/mailers/abuse_report_mailer_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe AbuseReportMailer do
describe '.notify' do
before do
- stub_application_setting(admin_notification_email: 'admin@example.com')
+ stub_application_setting(abuse_notification_email: 'admin@example.com')
end
let(:report) { create(:abuse_report) }
@@ -17,8 +17,8 @@ RSpec.describe AbuseReportMailer do
it_behaves_like 'appearance header and footer enabled'
it_behaves_like 'appearance header and footer not enabled'
- context 'with admin_notification_email set' do
- it 'sends to the admin_notification_email' do
+ context 'with abuse_notification_email set' do
+ it 'sends to the abuse_notification_email' do
is_expected.to deliver_to 'admin@example.com'
end
@@ -27,9 +27,9 @@ RSpec.describe AbuseReportMailer do
end
end
- context 'with no admin_notification_email set' do
+ context 'with no abuse_notification_email set' do
it 'returns early' do
- stub_application_setting(admin_notification_email: nil)
+ stub_application_setting(abuse_notification_email: nil)
expect { described_class.notify(spy).deliver_now }
.not_to change { ActionMailer::Base.deliveries.count }
diff --git a/spec/mailers/emails/projects_spec.rb b/spec/mailers/emails/projects_spec.rb
index 599f62a8113..aa5947bf68e 100644
--- a/spec/mailers/emails/projects_spec.rb
+++ b/spec/mailers/emails/projects_spec.rb
@@ -30,107 +30,118 @@ RSpec.describe Emails::Projects do
let_it_be(:user) { create(:user) }
describe '#prometheus_alert_fired_email' do
+ let(:default_title) { Gitlab::AlertManagement::Payload::Generic::DEFAULT_TITLE }
+ let(:payload) { { 'startsAt' => Time.now.rfc3339 } }
+ let(:alert_attributes) { build(:alert_management_alert, :from_payload, payload: payload, project: project).attributes }
+
subject do
- Notify.prometheus_alert_fired_email(project.id, user.id, alert_params)
+ Notify.prometheus_alert_fired_email(project.id, user.id, alert_attributes)
end
- let(:alert_params) do
- { 'startsAt' => Time.now.rfc3339 }
+ context 'missing required attributes' do
+ let(:alert_attributes) { build(:alert_management_alert, :prometheus, :from_payload, payload: payload, project: project).attributes }
+
+ it_behaves_like 'no email'
end
- context 'with a gitlab alert' do
- before do
- alert_params['labels'] = { 'gitlab_alert_id' => alert.prometheus_metric_id.to_s }
- end
+ context 'with minimum required attributes' do
+ let(:payload) { {} }
- let(:title) do
- "#{alert.title} #{alert.computed_operator} #{alert.threshold}"
- end
+ 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'
- let(:metrics_url) do
- metrics_project_environment_url(project, environment)
+ it 'has expected subject' do
+ is_expected.to have_subject("#{project.name} | Alert: #{default_title}")
end
- let(:environment) { alert.environment }
+ it 'has expected content' do
+ is_expected.to have_body_text('An alert has been triggered')
+ is_expected.to have_body_text(project.full_path)
+ is_expected.not_to have_body_text('Description:')
+ is_expected.not_to have_body_text('Environment:')
+ is_expected.not_to have_body_text('Metric:')
+ end
+ end
- let!(:alert) { create(:prometheus_alert, project: project) }
+ context 'with description' do
+ let(:payload) { { 'description' => 'alert description' } }
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 expected subject' do
- is_expected.to have_subject("#{project.name} | Alert: #{environment.name}: #{title} for 5 minutes")
+ is_expected.to have_subject("#{project.name} | Alert: #{default_title}")
end
it 'has expected content' do
is_expected.to have_body_text('An alert has been triggered')
is_expected.to have_body_text(project.full_path)
- is_expected.to have_body_text('Environment:')
- is_expected.to have_body_text(environment.name)
- is_expected.to have_body_text('Metric:')
- is_expected.to have_body_text(alert.full_query)
- is_expected.to have_body_text(metrics_url)
+ is_expected.to have_body_text('Description:')
+ is_expected.to have_body_text('alert description')
+ is_expected.not_to have_body_text('Environment:')
+ is_expected.not_to have_body_text('Metric:')
end
-
- it_behaves_like 'shows the incident issues url'
end
- context 'with no payload' do
- let(:alert_params) { {} }
+ context 'with environment' do
+ let_it_be(:environment) { create(:environment, project: project) }
+ let(:payload) { { 'gitlab_environment_name' => environment.name } }
+ let(:metrics_url) { metrics_project_environment_url(project, environment) }
- it_behaves_like 'no email'
- end
+ 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'
- context 'with an unknown alert' do
- before do
- alert_params['labels'] = { 'gitlab_alert_id' => 'unknown' }
+ it 'has expected subject' do
+ is_expected.to have_subject("#{project.name} | Alert: #{environment.name}: #{default_title}")
end
- it_behaves_like 'no email'
+ it 'has expected content' do
+ is_expected.to have_body_text('An alert has been triggered')
+ is_expected.to have_body_text(project.full_path)
+ is_expected.to have_body_text('Environment:')
+ is_expected.to have_body_text(environment.name)
+ is_expected.not_to have_body_text('Description:')
+ is_expected.not_to have_body_text('Metric:')
+ end
end
- context 'with an external alert' do
- let(:title) { 'alert title' }
+ context 'with gitlab alerting rule' do
+ let_it_be(:prometheus_alert) { create(:prometheus_alert, project: project) }
+ let_it_be(:environment) { prometheus_alert.environment }
- let(:metrics_url) do
- metrics_project_environments_url(project)
- end
+ let(:alert_attributes) { build(:alert_management_alert, :prometheus, :from_payload, payload: payload, project: project).attributes }
+ let(:title) { "#{prometheus_alert.title} #{prometheus_alert.computed_operator} #{prometheus_alert.threshold}" }
+ let(:metrics_url) { metrics_project_environment_url(project, environment) }
before do
- alert_params['annotations'] = { 'title' => title }
- alert_params['generatorURL'] = 'http://localhost:9090/graph?g0.expr=vector%281%29&g0.tab=1'
+ payload['labels'] = {
+ 'gitlab_alert_id' => prometheus_alert.prometheus_metric_id,
+ 'alertname' => prometheus_alert.title
+ }
end
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 'shows the incident issues url'
it 'has expected subject' do
- is_expected.to have_subject("#{project.name} | Alert: #{title}")
+ is_expected.to have_subject("#{project.name} | Alert: #{environment.name}: #{title} for 5 minutes")
end
it 'has expected content' do
is_expected.to have_body_text('An alert has been triggered')
is_expected.to have_body_text(project.full_path)
+ is_expected.to have_body_text('Environment:')
+ is_expected.to have_body_text(environment.name)
+ is_expected.to have_body_text('Metric:')
+ is_expected.to have_body_text(prometheus_alert.full_query)
+ is_expected.to have_body_text(metrics_url)
is_expected.not_to have_body_text('Description:')
- is_expected.not_to have_body_text('Environment:')
end
-
- context 'with annotated description' do
- let(:description) { 'description' }
-
- before do
- alert_params['annotations']['description'] = description
- end
-
- it 'shows the description' do
- is_expected.to have_body_text('Description:')
- is_expected.to have_body_text(description)
- end
- end
-
- it_behaves_like 'shows the incident issues url'
end
end
end
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index b9f95a9eb00..fa03c374633 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -1508,12 +1508,44 @@ RSpec.describe Notify do
)
end
- describe 'group invitation' do
+ describe 'invitations' do
let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } }
let(:group_member) { invite_to_group(group, inviter: inviter) }
let(:inviter) { owner }
- subject { described_class.member_invited_email('group', group_member.id, group_member.invite_token) }
+ subject { described_class.member_invited_email('Group', group_member.id, group_member.invite_token) }
+
+ shared_examples "tracks the 'sent' event for the invitation reminders experiment" do
+ before do
+ stub_experiment(invitation_reminders: true)
+ allow(Gitlab::Experimentation).to receive(:enabled_for_attribute?).with(:invitation_reminders, group_member.invite_email).and_return(experimental_group)
+ end
+
+ it "tracks the 'sent' event", :snowplow do
+ subject.deliver_now
+
+ expect_snowplow_event(
+ category: 'Growth::Acquisition::Experiment::InvitationReminders',
+ label: Digest::MD5.hexdigest(group_member.to_global_id.to_s),
+ property: experimental_group ? 'experimental_group' : 'control_group',
+ action: 'sent'
+ )
+ end
+ end
+
+ describe 'tracking for the invitation reminders experiment' do
+ context 'when invite email is in the experimental group' do
+ let(:experimental_group) { true }
+
+ it_behaves_like "tracks the 'sent' event for the invitation reminders experiment"
+ end
+
+ context 'when invite email is in the control group' do
+ let(:experimental_group) { false }
+
+ it_behaves_like "tracks the 'sent' event for the invitation reminders experiment"
+ end
+ end
context 'when invite_email_experiment is disabled' do
before do
diff --git a/spec/migrations/add_partial_index_to_ci_builds_table_on_user_id_name_spec.rb b/spec/migrations/add_partial_index_to_ci_builds_table_on_user_id_name_spec.rb
new file mode 100644
index 00000000000..018d48bea66
--- /dev/null
+++ b/spec/migrations/add_partial_index_to_ci_builds_table_on_user_id_name_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20200908064229_add_partial_index_to_ci_builds_table_on_user_id_name.rb')
+
+RSpec.describe AddPartialIndexToCiBuildsTableOnUserIdName do
+ let(:migration) { described_class.new }
+
+ describe '#up' do
+ it 'creates temporary partial index on type' do
+ expect { migration.up }.to change { migration.index_exists?(:ci_builds, [:user_id, :name], name: described_class::INDEX_NAME) }.from(false).to(true)
+ end
+ end
+
+ describe '#down' do
+ it 'removes temporary partial index on type' do
+ migration.up
+
+ expect { migration.down }.to change { migration.index_exists?(:ci_builds, [:user_id, :name], name: described_class::INDEX_NAME) }.from(true).to(false)
+ end
+ end
+end
diff --git a/spec/migrations/backfill_status_page_published_incidents_spec.rb b/spec/migrations/backfill_status_page_published_incidents_spec.rb
index 2b1ab891038..674484cdf0a 100644
--- a/spec/migrations/backfill_status_page_published_incidents_spec.rb
+++ b/spec/migrations/backfill_status_page_published_incidents_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe BackfillStatusPagePublishedIncidents, :migration do
end
it 'creates a StatusPage::PublishedIncident record for each published issue' do
- Timecop.freeze(current_time) do
+ travel_to(current_time) do
expect(incidents.all).to be_empty
migrate!
diff --git a/spec/migrations/cleanup_group_import_states_with_null_user_id_spec.rb b/spec/migrations/cleanup_group_import_states_with_null_user_id_spec.rb
new file mode 100644
index 00000000000..f9285c857de
--- /dev/null
+++ b/spec/migrations/cleanup_group_import_states_with_null_user_id_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+# In order to test the CleanupGroupImportStatesWithNullUserId migration, we need
+# to first create GroupImportState with NULL user_id
+# and then run the migration to check that user_id was populated or record removed
+#
+# The problem is that the CleanupGroupImportStatesWithNullUserId migration comes
+# after the NOT NULL constraint has been added with a previous migration (AddNotNullConstraintToUserOnGroupImportStates)
+# That means that while testing the current class we can not insert GroupImportState records with an
+# invalid user_id as constraint is blocking it from doing so
+#
+# To solve this problem, use SchemaVersionFinder to set schema one version prior to AddNotNullConstraintToUserOnGroupImportStates
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20200907092715_add_not_null_constraint_to_user_on_group_import_states.rb')
+require Rails.root.join('db', 'post_migrate', '20200909161624_cleanup_group_import_states_with_null_user_id.rb')
+
+RSpec.describe CleanupGroupImportStatesWithNullUserId, :migration,
+ schema: MigrationHelpers::SchemaVersionFinder.migration_prior(AddNotNullConstraintToUserOnGroupImportStates) do
+ let(:namespaces_table) { table(:namespaces) }
+ let(:users_table) { table(:users) }
+ let(:group_import_states_table) { table(:group_import_states) }
+ let(:members_table) { table(:members) }
+
+ describe 'Group import states clean up' do
+ context 'when user_id is present' do
+ it 'does not update group_import_state record' do
+ user_1 = users_table.create!(name: 'user1', email: 'user1@example.com', projects_limit: 1)
+ group_1 = namespaces_table.create!(name: 'group_1', path: 'group_1', type: 'Group')
+ create_member(user_id: user_1.id, type: 'GroupMember', source_type: 'Namespace', source_id: group_1.id, access_level: described_class::Group::OWNER)
+ group_import_state_1 = group_import_states_table.create!(group_id: group_1.id, user_id: user_1.id, status: 0)
+
+ expect(group_import_state_1.user_id).to eq(user_1.id)
+
+ disable_migrations_output { migrate! }
+
+ expect(group_import_state_1.reload.user_id).to eq(user_1.id)
+ end
+ end
+
+ context 'when user_id is missing' do
+ it 'updates user_id with group default owner id' do
+ user_2 = users_table.create!(name: 'user2', email: 'user2@example.com', projects_limit: 1)
+ group_2 = namespaces_table.create!(name: 'group_2', path: 'group_2', type: 'Group')
+ create_member(user_id: user_2.id, type: 'GroupMember', source_type: 'Namespace', source_id: group_2.id, access_level: described_class::Group::OWNER)
+ group_import_state_2 = group_import_states_table.create!(group_id: group_2.id, user_id: nil, status: 0)
+
+ disable_migrations_output { migrate! }
+
+ expect(group_import_state_2.reload.user_id).to eq(user_2.id)
+ end
+ end
+
+ context 'when group does not contain any owners' do
+ it 'removes group_import_state record' do
+ group_3 = namespaces_table.create!(name: 'group_3', path: 'group_3', type: 'Group')
+ group_import_state_3 = group_import_states_table.create!(group_id: group_3.id, user_id: nil, status: 0)
+
+ disable_migrations_output { migrate! }
+
+ expect { group_import_state_3.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ context 'when group has parent' do
+ it 'updates user_id with parent group default owner id' do
+ user = users_table.create!(name: 'user4', email: 'user4@example.com', projects_limit: 1)
+ group_1 = namespaces_table.create!(name: 'group_1', path: 'group_1', type: 'Group')
+ create_member(user_id: user.id, type: 'GroupMember', source_type: 'Namespace', source_id: group_1.id, access_level: described_class::Group::OWNER)
+ group_2 = namespaces_table.create!(name: 'group_2', path: 'group_2', type: 'Group', parent_id: group_1.id)
+ group_import_state = group_import_states_table.create!(group_id: group_2.id, user_id: nil, status: 0)
+
+ disable_migrations_output { migrate! }
+
+ expect(group_import_state.reload.user_id).to eq(user.id)
+ end
+ end
+
+ context 'when group has owner_id' do
+ it 'updates user_id with owner_id' do
+ user = users_table.create!(name: 'user', email: 'user@example.com', projects_limit: 1)
+ group = namespaces_table.create!(name: 'group', path: 'group', type: 'Group', owner_id: user.id)
+ group_import_state = group_import_states_table.create!(group_id: group.id, user_id: nil, status: 0)
+
+ disable_migrations_output { migrate! }
+
+ expect(group_import_state.reload.user_id).to eq(user.id)
+ end
+ end
+ end
+
+ def create_member(options)
+ members_table.create!(
+ {
+ notification_level: 0,
+ ldap: false,
+ override: false
+ }.merge(options)
+ )
+ end
+end
diff --git a/spec/migrations/ensure_filled_file_store_on_package_files_spec.rb b/spec/migrations/ensure_filled_file_store_on_package_files_spec.rb
new file mode 100644
index 00000000000..8a0f51ab27e
--- /dev/null
+++ b/spec/migrations/ensure_filled_file_store_on_package_files_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20200915185707_ensure_filled_file_store_on_package_files.rb')
+
+RSpec.describe EnsureFilledFileStoreOnPackageFiles, schema: 20200910175553 do
+ let!(:packages_package_files) { table(:packages_package_files) }
+ let!(:packages_packages) { table(:packages_packages) }
+ let!(:namespaces) { table(:namespaces) }
+ let!(:projects) { table(:projects) }
+ let!(:namespace) { namespaces.create!(name: 'foo', path: 'foo') }
+ let!(:project) { projects.create!(namespace_id: namespace.id) }
+ let!(:package) { packages_packages.create!(project_id: project.id, name: 'bar', package_type: 1) }
+
+ before do
+ constraint_name = 'check_4c5e6bb0b3'
+
+ # In order to insert a row with a NULL to fill.
+ ActiveRecord::Base.connection.execute "ALTER TABLE packages_package_files DROP CONSTRAINT #{constraint_name}"
+
+ @file_store_1 = packages_package_files.create!(file_store: 1, file_name: 'foo_1', file: 'foo_1', package_id: package.id)
+ @file_store_2 = packages_package_files.create!(file_store: 2, file_name: 'foo_2', file: 'foo_2', package_id: package.id)
+ @file_store_nil = packages_package_files.create!(file_store: nil, file_name: 'foo_nil', file: 'foo_nil', package_id: package.id)
+
+ # revert DB structure
+ ActiveRecord::Base.connection.execute "ALTER TABLE packages_package_files ADD CONSTRAINT #{constraint_name} CHECK ((file_store IS NOT NULL)) NOT VALID"
+ end
+
+ it 'correctly migrates nil file_store to 1' do
+ migrate!
+
+ @file_store_1.reload
+ @file_store_2.reload
+ @file_store_nil.reload
+
+ expect(@file_store_1.file_store).to eq(1) # unchanged
+ expect(@file_store_2.file_store).to eq(2) # unchanged
+ expect(@file_store_nil.file_store).to eq(1) # nil => 1
+ end
+end
diff --git a/spec/models/alert_management/http_integration_spec.rb b/spec/models/alert_management/http_integration_spec.rb
new file mode 100644
index 00000000000..37d67dfe09a
--- /dev/null
+++ b/spec/models/alert_management/http_integration_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AlertManagement::HttpIntegration do
+ let_it_be(:project) { create(:project) }
+
+ subject(:integration) { build(:alert_management_http_integration) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:project) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:project) }
+ it { is_expected.to validate_presence_of(:name) }
+ it { is_expected.to validate_length_of(:name).is_at_most(255) }
+ it { is_expected.to validate_presence_of(:endpoint_identifier) }
+ it { is_expected.to validate_length_of(:endpoint_identifier).is_at_most(255) }
+
+ context 'when active' do
+ # Using `create` instead of `build` the integration so `token` is set.
+ # Uniqueness spec saves integration with `validate: false` otherwise.
+ subject { create(:alert_management_http_integration) }
+
+ it { is_expected.to validate_uniqueness_of(:endpoint_identifier).scoped_to(:project_id, :active) }
+ end
+
+ context 'when inactive' do
+ subject { create(:alert_management_http_integration, :inactive) }
+
+ it { is_expected.not_to validate_uniqueness_of(:endpoint_identifier).scoped_to(:project_id, :active) }
+ end
+ end
+
+ describe '#token' do
+ subject { integration.token }
+
+ shared_context 'assign token' do |token|
+ let!(:previous_token) { integration.token }
+
+ before do
+ integration.token = token
+ integration.valid?
+ end
+ end
+
+ shared_examples 'valid token' do
+ it { is_expected.to match(/\A\h{32}\z/) }
+ end
+
+ context 'when unsaved' do
+ context 'when unassigned' do
+ before do
+ integration.valid?
+ end
+
+ it_behaves_like 'valid token'
+ end
+
+ context 'when assigned' do
+ include_context 'assign token', 'random_token'
+
+ it_behaves_like 'valid token'
+ it { is_expected.not_to eq('random_token') }
+ end
+ end
+
+ context 'when persisted' do
+ before do
+ integration.save!
+ integration.reload
+ end
+
+ it_behaves_like 'valid token'
+
+ context 'when resetting' do
+ include_context 'assign token', ''
+
+ it_behaves_like 'valid token'
+ it { is_expected.not_to eq(previous_token) }
+ end
+
+ context 'when reassigning' do
+ include_context 'assign token', 'random_token'
+
+ it_behaves_like 'valid token'
+ it { is_expected.to eq(previous_token) }
+ end
+ end
+ end
+end
diff --git a/spec/models/analytics/instance_statistics/measurement_spec.rb b/spec/models/analytics/instance_statistics/measurement_spec.rb
index 4df847ea524..379272cfcb9 100644
--- a/spec/models/analytics/instance_statistics/measurement_spec.rb
+++ b/spec/models/analytics/instance_statistics/measurement_spec.rb
@@ -20,7 +20,11 @@ RSpec.describe Analytics::InstanceStatistics::Measurement, type: :model do
issues: 3,
merge_requests: 4,
groups: 5,
- pipelines: 6
+ pipelines: 6,
+ pipelines_succeeded: 7,
+ pipelines_failed: 8,
+ pipelines_canceled: 9,
+ pipelines_skipped: 10
}.with_indifferent_access)
end
end
@@ -42,4 +46,28 @@ RSpec.describe Analytics::InstanceStatistics::Measurement, type: :model do
it { is_expected.to match_array([measurement_1, measurement_2]) }
end
end
+
+ describe '#measurement_identifier_values' do
+ subject { described_class.measurement_identifier_values.count }
+
+ context 'when the `store_ci_pipeline_counts_by_status` feature flag is off' do
+ let(:expected_count) { Analytics::InstanceStatistics::Measurement.identifiers.size - Analytics::InstanceStatistics::Measurement::EXPERIMENTAL_IDENTIFIERS.size }
+
+ before do
+ stub_feature_flags(store_ci_pipeline_counts_by_status: false)
+ end
+
+ it { is_expected.to eq(expected_count) }
+ end
+
+ context 'when the `store_ci_pipeline_counts_by_status` feature flag is on' do
+ let(:expected_count) { Analytics::InstanceStatistics::Measurement.identifiers.size }
+
+ before do
+ stub_feature_flags(store_ci_pipeline_counts_by_status: true)
+ end
+
+ it { is_expected.to eq(expected_count) }
+ end
+ end
end
diff --git a/spec/models/application_setting/term_spec.rb b/spec/models/application_setting/term_spec.rb
index 82347453437..51a6027698f 100644
--- a/spec/models/application_setting/term_spec.rb
+++ b/spec/models/application_setting/term_spec.rb
@@ -17,6 +17,7 @@ RSpec.describe ApplicationSetting::Term do
describe '#accepted_by_user?' do
let(:user) { create(:user) }
+ let(:project_bot) { create(:user, :project_bot) }
let(:term) { create(:term) }
it 'is true when the user accepted the terms' do
@@ -25,6 +26,10 @@ RSpec.describe ApplicationSetting::Term do
expect(term.accepted_by_user?(user)).to be(true)
end
+ it 'is true when user is a bot' do
+ expect(term.accepted_by_user?(project_bot)).to be(true)
+ end
+
it 'is false when the user declined the terms' do
decline_terms(term, user)
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index 9f76fb3330d..f572e6ffade 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -320,7 +320,7 @@ RSpec.describe ApplicationSetting do
end
end
- it_behaves_like 'an object with email-formated attributes', :admin_notification_email do
+ it_behaves_like 'an object with email-formated attributes', :abuse_notification_email do
subject { setting }
end
diff --git a/spec/models/audit_event_spec.rb b/spec/models/audit_event_spec.rb
index a1ed48c57f4..5c87c2e68db 100644
--- a/spec/models/audit_event_spec.rb
+++ b/spec/models/audit_event_spec.rb
@@ -6,6 +6,13 @@ RSpec.describe AuditEvent do
let_it_be(:audit_event) { create(:project_audit_event) }
subject { audit_event }
+ describe 'validations' do
+ include_examples 'validates IP address' do
+ let(:attribute) { :ip_address }
+ let(:object) { create(:audit_event) }
+ end
+ end
+
describe '#as_json' do
context 'ip_address' do
subject { build(:group_audit_event, ip_address: '192.168.1.1').as_json }
diff --git a/spec/models/authentication_event_spec.rb b/spec/models/authentication_event_spec.rb
index 56b0111f2c7..483d45c08be 100644
--- a/spec/models/authentication_event_spec.rb
+++ b/spec/models/authentication_event_spec.rb
@@ -11,5 +11,41 @@ RSpec.describe AuthenticationEvent do
it { is_expected.to validate_presence_of(:provider) }
it { is_expected.to validate_presence_of(:user_name) }
it { is_expected.to validate_presence_of(:result) }
+
+ include_examples 'validates IP address' do
+ let(:attribute) { :ip_address }
+ let(:object) { create(:authentication_event) }
+ end
+ end
+
+ describe 'scopes' do
+ let_it_be(:ldap_event) { create(:authentication_event, provider: :ldapmain, result: :failed) }
+ let_it_be(:google_oauth2) { create(:authentication_event, provider: :google_oauth2, result: :success) }
+
+ describe '.for_provider' do
+ it 'returns events only for the specified provider' do
+ expect(described_class.for_provider(:ldapmain)).to match_array ldap_event
+ end
+ end
+
+ describe '.ldap' do
+ it 'returns all events for an LDAP provider' do
+ expect(described_class.ldap).to match_array ldap_event
+ end
+ end
+ end
+
+ describe '.providers' do
+ before do
+ create(:authentication_event, provider: :ldapmain)
+ create(:authentication_event, provider: :google_oauth2)
+ create(:authentication_event, provider: :standard)
+ create(:authentication_event, provider: :standard)
+ create(:authentication_event, provider: :standard)
+ end
+
+ it 'returns an array of distinct providers' do
+ expect(described_class.providers).to match_array %w(ldapmain google_oauth2 standard)
+ end
end
end
diff --git a/spec/models/ci/build_pending_state_spec.rb b/spec/models/ci/build_pending_state_spec.rb
new file mode 100644
index 00000000000..a546d2aff65
--- /dev/null
+++ b/spec/models/ci/build_pending_state_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::BuildPendingState do
+ describe '#crc32' do
+ context 'when checksum does not exist' do
+ let(:pending_state) do
+ build(:ci_build_pending_state, trace_checksum: nil)
+ end
+
+ it 'returns nil' do
+ expect(pending_state.crc32).to be_nil
+ end
+ end
+
+ context 'when checksum is in hexadecimal' do
+ let(:pending_state) do
+ build(:ci_build_pending_state, trace_checksum: 'crc32:75bcd15')
+ end
+
+ it 'returns decimal representation of the checksum' do
+ expect(pending_state.crc32).to eq 123456789
+ end
+ end
+ end
+end
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 1e551d9ee33..cb29cbcbb72 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -4652,4 +4652,24 @@ RSpec.describe Ci::Build do
it { is_expected.to be_nil }
end
end
+
+ describe '#run_on_status_commit' do
+ it 'runs provided hook after status commit' do
+ action = spy('action')
+
+ build.run_on_status_commit { action.perform! }
+ build.success!
+
+ expect(action).to have_received(:perform!).once
+ end
+
+ it 'does not run hooks when status has not changed' do
+ action = spy('action')
+
+ build.run_on_status_commit { action.perform! }
+ build.save!
+
+ expect(action).not_to have_received(:perform!)
+ end
+ end
end
diff --git a/spec/models/ci/build_trace_chunk_spec.rb b/spec/models/ci/build_trace_chunk_spec.rb
index fefe5e3bfca..57e58fe494f 100644
--- a/spec/models/ci/build_trace_chunk_spec.rb
+++ b/spec/models/ci/build_trace_chunk_spec.rb
@@ -779,4 +779,62 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do
it_behaves_like 'deletes all build_trace_chunk and data in redis'
end
end
+
+ describe 'comparable build trace chunks' do
+ describe '#<=>' do
+ context 'when chunks are associated with different builds' do
+ let(:first) { create(:ci_build_trace_chunk, build: build, chunk_index: 1) }
+ let(:second) { create(:ci_build_trace_chunk, chunk_index: 1) }
+
+ it 'returns nil' do
+ expect(first <=> second).to be_nil
+ end
+ end
+
+ context 'when there are two chunks with different indexes' do
+ let(:first) { create(:ci_build_trace_chunk, build: build, chunk_index: 1) }
+ let(:second) { create(:ci_build_trace_chunk, build: build, chunk_index: 0) }
+
+ it 'indicates the the first one is greater than then second' do
+ expect(first <=> second).to eq 1
+ end
+ end
+
+ context 'when there are two chunks with the same index within the same build' do
+ let(:chunk) { create(:ci_build_trace_chunk) }
+
+ it 'indicates the these are equal' do
+ expect(chunk <=> chunk).to be_zero # rubocop:disable Lint/UselessComparison
+ end
+ end
+ end
+
+ describe '#==' do
+ context 'when chunks have the same index' do
+ let(:chunk) { create(:ci_build_trace_chunk) }
+
+ it 'indicates that the chunks are equal' do
+ expect(chunk).to eq chunk
+ end
+ end
+
+ context 'when chunks have different indexes' do
+ let(:first) { create(:ci_build_trace_chunk, build: build, chunk_index: 1) }
+ let(:second) { create(:ci_build_trace_chunk, build: build, chunk_index: 0) }
+
+ it 'indicates that the chunks are not equal' do
+ expect(first).not_to eq second
+ end
+ end
+
+ context 'when chunks are associated with different builds' do
+ let(:first) { create(:ci_build_trace_chunk, build: build, chunk_index: 1) }
+ let(:second) { create(:ci_build_trace_chunk, chunk_index: 1) }
+
+ it 'indicates that the chunks are not equal' do
+ expect(first).not_to eq second
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/ci/freeze_period_status_spec.rb b/spec/models/ci/freeze_period_status_spec.rb
index 831895cb528..f51381f7a5f 100644
--- a/spec/models/ci/freeze_period_status_spec.rb
+++ b/spec/models/ci/freeze_period_status_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe Ci::FreezePeriodStatus do
shared_examples 'within freeze period' do |time|
it 'is frozen' do
- Timecop.freeze(time) do
+ travel_to(time) do
expect(subject).to be_truthy
end
end
@@ -19,7 +19,7 @@ RSpec.describe Ci::FreezePeriodStatus do
shared_examples 'outside freeze period' do |time|
it 'is not frozen' do
- Timecop.freeze(time) do
+ travel_to(time) do
expect(subject).to be_falsy
end
end
diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb
index 779839df670..26851c93ac3 100644
--- a/spec/models/ci/job_artifact_spec.rb
+++ b/spec/models/ci/job_artifact_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe Ci::JobArtifact do
it_behaves_like 'having unique enum values'
- it_behaves_like 'UpdateProjectStatistics' do
+ it_behaves_like 'UpdateProjectStatistics', :with_counter_attribute do
let_it_be(:job, reload: true) { create(:ci_build) }
subject { build(:ci_job_artifact, :archive, job: job, size: 107464) }
@@ -44,7 +44,7 @@ RSpec.describe Ci::JobArtifact do
let!(:metrics_report) { create(:ci_job_artifact, :junit) }
let!(:codequality_report) { create(:ci_job_artifact, :codequality) }
- it { is_expected.to eq([metrics_report, codequality_report]) }
+ it { is_expected.to match_array([metrics_report, codequality_report]) }
end
end
diff --git a/spec/models/ci/pipeline_schedule_spec.rb b/spec/models/ci/pipeline_schedule_spec.rb
index 949d5f7bd04..cec3b544e50 100644
--- a/spec/models/ci/pipeline_schedule_spec.rb
+++ b/spec/models/ci/pipeline_schedule_spec.rb
@@ -56,7 +56,7 @@ RSpec.describe Ci::PipelineSchedule do
subject { described_class.runnable_schedules }
let!(:pipeline_schedule) do
- Timecop.freeze(1.day.ago) do
+ travel_to(1.day.ago) do
create(:ci_pipeline_schedule, :hourly)
end
end
@@ -118,7 +118,7 @@ RSpec.describe Ci::PipelineSchedule do
let(:pipeline_schedule) { create(:ci_pipeline_schedule, :every_minute) }
it "updates next_run_at to the sidekiq worker's execution time" do
- Timecop.freeze(Time.zone.parse("2019-06-01 12:18:00+0000")) do
+ travel_to(Time.zone.parse("2019-06-01 12:18:00+0000")) do
expect(pipeline_schedule.next_run_at).to eq(cron_worker_next_run_at)
end
end
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 228a1e8f7a2..d33ccf0e6f2 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -2436,7 +2436,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#retry_failed' do
- let(:latest_status) { pipeline.statuses.latest.pluck(:status) }
+ let(:latest_status) { pipeline.latest_statuses.pluck(:status) }
before do
stub_not_protect_default_branch
@@ -3628,6 +3628,16 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
expect(builds).to include(rspec, jest)
expect(builds).not_to include(karma)
end
+
+ it 'returns only latest builds' do
+ obsolete = create(:ci_build, name: "jest", coverage: 10.12, pipeline: pipeline, retried: true)
+ retried = create(:ci_build, name: "jest", coverage: 20.11, pipeline: pipeline)
+
+ builds = pipeline.builds_with_coverage
+
+ expect(builds).to include(retried)
+ expect(builds).not_to include(obsolete)
+ end
end
describe '#base_and_ancestors' do
diff --git a/spec/models/ci_platform_metric_spec.rb b/spec/models/ci_platform_metric_spec.rb
index 0b00875df43..f73db713791 100644
--- a/spec/models/ci_platform_metric_spec.rb
+++ b/spec/models/ci_platform_metric_spec.rb
@@ -45,7 +45,7 @@ RSpec.describe CiPlatformMetric do
let(:tomorrow) { today + 1.day }
it 'inserts platform target counts for that day' do
- Timecop.freeze(today) do
+ travel_to(today) do
create(:ci_variable, key: described_class::CI_VARIABLE_KEY, value: 'ECS')
create(:ci_variable, key: described_class::CI_VARIABLE_KEY, value: 'ECS')
create(:ci_variable, key: described_class::CI_VARIABLE_KEY, value: 'FARGATE')
@@ -53,7 +53,7 @@ RSpec.describe CiPlatformMetric do
create(:ci_variable, key: described_class::CI_VARIABLE_KEY, value: 'FARGATE')
described_class.insert_auto_devops_platform_targets!
end
- Timecop.freeze(tomorrow) do
+ travel_to(tomorrow) do
create(:ci_variable, key: described_class::CI_VARIABLE_KEY, value: 'FARGATE')
described_class.insert_auto_devops_platform_targets!
end
@@ -69,7 +69,7 @@ RSpec.describe CiPlatformMetric do
let(:today) { Time.zone.local(1982, 4, 24) }
it 'ignores those values' do
- Timecop.freeze(today) do
+ travel_to(today) do
create(:ci_variable, key: described_class::CI_VARIABLE_KEY, value: 'ECS')
create(:ci_variable, key: described_class::CI_VARIABLE_KEY, value: 'FOO')
create(:ci_variable, key: described_class::CI_VARIABLE_KEY, value: 'BAR')
diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb
index fbabfd25b2f..ef916c73e0b 100644
--- a/spec/models/clusters/applications/runner_spec.rb
+++ b/spec/models/clusters/applications/runner_spec.rb
@@ -69,8 +69,8 @@ RSpec.describe Clusters::Applications::Runner do
expect(values).to include('privileged: true')
expect(values).to include('image: ubuntu:16.04')
expect(values).to include('resources')
- expect(values).to match(/runnerToken: '?#{Regexp.escape(ci_runner.token)}/)
- expect(values).to match(/gitlabUrl: '?#{Regexp.escape(Gitlab::Routing.url_helpers.root_url)}/)
+ expect(values).to match(/runnerToken: ['"]?#{Regexp.escape(ci_runner.token)}/)
+ expect(values).to match(/gitlabUrl: ['"]?#{Regexp.escape(Gitlab::Routing.url_helpers.root_url)}/)
end
context 'without a runner' do
diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb
index 024539e34ec..dd9b96f39ad 100644
--- a/spec/models/clusters/cluster_spec.rb
+++ b/spec/models/clusters/cluster_spec.rb
@@ -47,6 +47,7 @@ RSpec.describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
it { is_expected.to delegate_method(:external_hostname).to(:application_ingress).with_prefix }
it { is_expected.to respond_to :project }
+ it { is_expected.to be_namespace_per_environment }
describe 'applications have inverse_of: :cluster option' do
let(:cluster) { create(:cluster) }
diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb
index 6e23f95af03..877188097fd 100644
--- a/spec/models/commit_status_spec.rb
+++ b/spec/models/commit_status_spec.rb
@@ -493,47 +493,104 @@ RSpec.describe CommitStatus do
end
end
- describe '#group_name' do
- let(:commit_status) do
- build(:commit_status, pipeline: pipeline, stage: 'test')
- end
-
- subject { commit_status.group_name }
+ context 'with the one_dimensional_matrix feature flag disabled' do
+ describe '#group_name' do
+ before do
+ stub_feature_flags(one_dimensional_matrix: false)
+ end
- tests = {
- 'rspec:windows' => 'rspec:windows',
- 'rspec:windows 0' => 'rspec:windows 0',
- 'rspec:windows 0 test' => 'rspec:windows 0 test',
- 'rspec:windows 0 1' => 'rspec:windows',
- 'rspec:windows 0 1 name' => 'rspec:windows name',
- 'rspec:windows 0/1' => 'rspec:windows',
- 'rspec:windows 0/1 name' => 'rspec:windows name',
- 'rspec:windows 0:1' => 'rspec:windows',
- 'rspec:windows 0:1 name' => 'rspec:windows name',
- 'rspec:windows 10000 20000' => 'rspec:windows',
- 'rspec:windows 0 : / 1' => 'rspec:windows',
- 'rspec:windows 0 : / 1 name' => 'rspec:windows name',
- '0 1 name ruby' => 'name ruby',
- '0 :/ 1 name ruby' => 'name ruby',
- 'rspec: [aws]' => 'rspec: [aws]',
- 'rspec: [aws] 0/1' => 'rspec: [aws]',
- 'rspec: [aws, max memory]' => 'rspec',
- 'rspec:linux: [aws, max memory, data]' => 'rspec:linux',
- 'rspec: [inception: [something, other thing], value]' => 'rspec',
- 'rspec:windows 0/1: [name, other]' => 'rspec:windows',
- 'rspec:windows: [name, other] 0/1' => 'rspec:windows',
- 'rspec:windows: [name, 0/1] 0/1' => 'rspec:windows',
- 'rspec:windows: [0/1, name]' => 'rspec:windows',
- 'rspec:windows: [, ]' => 'rspec:windows',
- 'rspec:windows: [name]' => 'rspec:windows: [name]',
- 'rspec:windows: [name,other]' => 'rspec:windows: [name,other]'
- }
+ let(:commit_status) do
+ build(:commit_status, pipeline: pipeline, stage: 'test')
+ end
+
+ subject { commit_status.group_name }
+
+ tests = {
+ 'rspec:windows' => 'rspec:windows',
+ 'rspec:windows 0' => 'rspec:windows 0',
+ 'rspec:windows 0 test' => 'rspec:windows 0 test',
+ 'rspec:windows 0 1' => 'rspec:windows',
+ 'rspec:windows 0 1 name' => 'rspec:windows name',
+ 'rspec:windows 0/1' => 'rspec:windows',
+ 'rspec:windows 0/1 name' => 'rspec:windows name',
+ 'rspec:windows 0:1' => 'rspec:windows',
+ 'rspec:windows 0:1 name' => 'rspec:windows name',
+ 'rspec:windows 10000 20000' => 'rspec:windows',
+ 'rspec:windows 0 : / 1' => 'rspec:windows',
+ 'rspec:windows 0 : / 1 name' => 'rspec:windows name',
+ '0 1 name ruby' => 'name ruby',
+ '0 :/ 1 name ruby' => 'name ruby',
+ 'rspec: [aws]' => 'rspec: [aws]',
+ 'rspec: [aws] 0/1' => 'rspec: [aws]',
+ 'rspec: [aws, max memory]' => 'rspec',
+ 'rspec:linux: [aws, max memory, data]' => 'rspec:linux',
+ 'rspec: [inception: [something, other thing], value]' => 'rspec',
+ 'rspec:windows 0/1: [name, other]' => 'rspec:windows',
+ 'rspec:windows: [name, other] 0/1' => 'rspec:windows',
+ 'rspec:windows: [name, 0/1] 0/1' => 'rspec:windows',
+ 'rspec:windows: [0/1, name]' => 'rspec:windows',
+ 'rspec:windows: [, ]' => 'rspec:windows',
+ 'rspec:windows: [name]' => 'rspec:windows: [name]',
+ 'rspec:windows: [name,other]' => 'rspec:windows: [name,other]'
+ }
+
+ tests.each do |name, group_name|
+ it "'#{name}' puts in '#{group_name}'" do
+ commit_status.name = name
+
+ is_expected.to eq(group_name)
+ end
+ end
+ end
+ end
- tests.each do |name, group_name|
- it "'#{name}' puts in '#{group_name}'" do
- commit_status.name = name
+ context 'with one_dimensional_matrix feature flag enabled' do
+ describe '#group_name' do
+ before do
+ stub_feature_flags(one_dimensional_matrix: true)
+ end
- is_expected.to eq(group_name)
+ let(:commit_status) do
+ build(:commit_status, pipeline: pipeline, stage: 'test')
+ end
+
+ subject { commit_status.group_name }
+
+ tests = {
+ 'rspec:windows' => 'rspec:windows',
+ 'rspec:windows 0' => 'rspec:windows 0',
+ 'rspec:windows 0 test' => 'rspec:windows 0 test',
+ 'rspec:windows 0 1' => 'rspec:windows',
+ 'rspec:windows 0 1 name' => 'rspec:windows name',
+ 'rspec:windows 0/1' => 'rspec:windows',
+ 'rspec:windows 0/1 name' => 'rspec:windows name',
+ 'rspec:windows 0:1' => 'rspec:windows',
+ 'rspec:windows 0:1 name' => 'rspec:windows name',
+ 'rspec:windows 10000 20000' => 'rspec:windows',
+ 'rspec:windows 0 : / 1' => 'rspec:windows',
+ 'rspec:windows 0 : / 1 name' => 'rspec:windows name',
+ '0 1 name ruby' => 'name ruby',
+ '0 :/ 1 name ruby' => 'name ruby',
+ 'rspec: [aws]' => 'rspec',
+ 'rspec: [aws] 0/1' => 'rspec',
+ 'rspec: [aws, max memory]' => 'rspec',
+ 'rspec:linux: [aws, max memory, data]' => 'rspec:linux',
+ 'rspec: [inception: [something, other thing], value]' => 'rspec',
+ 'rspec:windows 0/1: [name, other]' => 'rspec:windows',
+ 'rspec:windows: [name, other] 0/1' => 'rspec:windows',
+ 'rspec:windows: [name, 0/1] 0/1' => 'rspec:windows',
+ 'rspec:windows: [0/1, name]' => 'rspec:windows',
+ 'rspec:windows: [, ]' => 'rspec:windows',
+ 'rspec:windows: [name]' => 'rspec:windows',
+ 'rspec:windows: [name,other]' => 'rspec:windows'
+ }
+
+ tests.each do |name, group_name|
+ it "'#{name}' puts in '#{group_name}'" do
+ commit_status.name = name
+
+ is_expected.to eq(group_name)
+ end
end
end
end
diff --git a/spec/models/concerns/avatarable_spec.rb b/spec/models/concerns/avatarable_spec.rb
index 8a8eeea39dc..5bed2cb9a14 100644
--- a/spec/models/concerns/avatarable_spec.rb
+++ b/spec/models/concerns/avatarable_spec.rb
@@ -21,7 +21,7 @@ RSpec.describe Avatarable do
it 'validates the file size' do
expect(validator).to receive(:validate_each).and_call_original
- project.update(avatar: 'uploads/avatar.png')
+ project.update!(avatar: 'uploads/avatar.png')
end
end
@@ -29,7 +29,7 @@ RSpec.describe Avatarable do
it 'skips validation of file size' do
expect(validator).not_to receive(:validate_each)
- project.update(name: 'Hello world')
+ project.update!(name: 'Hello world')
end
end
end
diff --git a/spec/models/concerns/bulk_insertable_associations_spec.rb b/spec/models/concerns/bulk_insertable_associations_spec.rb
index 5a40639e493..25b13c8233d 100644
--- a/spec/models/concerns/bulk_insertable_associations_spec.rb
+++ b/spec/models/concerns/bulk_insertable_associations_spec.rb
@@ -187,7 +187,7 @@ RSpec.describe BulkInsertableAssociations do
it 'invalidates the parent and returns false' do
build_invalid_items(parent: parent)
- expect(save_with_bulk_inserts(parent, bangify: false)).to be false
+ expect(BulkInsertableAssociations.with_bulk_insert { parent.save }).to be false # rubocop:disable Rails/SaveBang
expect(parent.errors[:bulk_foos].size).to eq(1)
expect(BulkFoo.count).to eq(0)
@@ -211,8 +211,8 @@ RSpec.describe BulkInsertableAssociations do
private
- def save_with_bulk_inserts(entity, bangify: true)
- BulkInsertableAssociations.with_bulk_insert { bangify ? entity.save! : entity.save }
+ def save_with_bulk_inserts(entity)
+ BulkInsertableAssociations.with_bulk_insert { entity.save! }
end
def build_items(parent:, relation: :bulk_foos, count: 10)
diff --git a/spec/models/concerns/cache_markdown_field_spec.rb b/spec/models/concerns/cache_markdown_field_spec.rb
index 440943171c3..37e2f5fb8d4 100644
--- a/spec/models/concerns/cache_markdown_field_spec.rb
+++ b/spec/models/concerns/cache_markdown_field_spec.rb
@@ -285,7 +285,7 @@ RSpec.describe CacheMarkdownField, :clean_gitlab_redis_cache do
it_behaves_like 'a class with cached markdown fields'
describe '#attribute_invalidated?' do
- let(:thing) { klass.create(description: markdown, description_html: html, cached_markdown_version: cache_version) }
+ let(:thing) { klass.create!(description: markdown, description_html: html, cached_markdown_version: cache_version) }
it 'returns true when cached_markdown_version is different' do
thing.cached_markdown_version += 1
@@ -318,7 +318,7 @@ RSpec.describe CacheMarkdownField, :clean_gitlab_redis_cache do
let(:thing) do
# This forces the record to have outdated HTML. We can't use `create` because the `before_create` hook
# would re-render the HTML to the latest version
- klass.create.tap do |thing|
+ klass.create!.tap do |thing|
thing.update_columns(description: markdown, description_html: old_html, cached_markdown_version: old_version)
end
end
@@ -326,7 +326,7 @@ RSpec.describe CacheMarkdownField, :clean_gitlab_redis_cache do
it 'correctly updates cached HTML even if refresh_markdown_cache is called before updating the attribute' do
thing.refresh_markdown_cache
- thing.update(description: updated_markdown)
+ thing.update!(description: updated_markdown)
expect(thing.description_html).to eq(updated_html)
end
diff --git a/spec/models/concerns/case_sensitivity_spec.rb b/spec/models/concerns/case_sensitivity_spec.rb
index 521b47c63fd..5fb7cdb4443 100644
--- a/spec/models/concerns/case_sensitivity_spec.rb
+++ b/spec/models/concerns/case_sensitivity_spec.rb
@@ -12,8 +12,8 @@ RSpec.describe CaseSensitivity do
end
end
- let!(:model_1) { model.create(path: 'mOdEl-1', name: 'mOdEl 1') }
- let!(:model_2) { model.create(path: 'mOdEl-2', name: 'mOdEl 2') }
+ let!(:model_1) { model.create!(path: 'mOdEl-1', name: 'mOdEl 1') }
+ let!(:model_2) { model.create!(path: 'mOdEl-2', name: 'mOdEl 2') }
it 'finds a single instance by a single attribute regardless of case' do
expect(model.iwhere(path: 'MODEL-1')).to contain_exactly(model_1)
diff --git a/spec/models/concerns/checksummable_spec.rb b/spec/models/concerns/checksummable_spec.rb
index b469b2e5c18..3a0387333e8 100644
--- a/spec/models/concerns/checksummable_spec.rb
+++ b/spec/models/concerns/checksummable_spec.rb
@@ -3,17 +3,21 @@
require 'spec_helper'
RSpec.describe Checksummable do
- describe ".hexdigest" do
- let(:fake_class) do
- Class.new do
- include Checksummable
- end
+ subject do
+ Class.new { include Checksummable }
+ end
+
+ describe ".crc32" do
+ it 'returns the CRC32 of data' do
+ expect(subject.crc32('abcd')).to eq 3984772369
end
+ end
+ describe ".hexdigest" do
it 'returns the SHA256 sum of the file' do
expected = Digest::SHA256.file(__FILE__).hexdigest
- expect(fake_class.hexdigest(__FILE__)).to eq(expected)
+ expect(subject.hexdigest(__FILE__)).to eq(expected)
end
end
end
diff --git a/spec/models/concerns/counter_attribute_spec.rb b/spec/models/concerns/counter_attribute_spec.rb
index f23865a5dbb..a19fbae3cfb 100644
--- a/spec/models/concerns/counter_attribute_spec.rb
+++ b/spec/models/concerns/counter_attribute_spec.rb
@@ -12,6 +12,36 @@ RSpec.describe CounterAttribute, :counter_attribute, :clean_gitlab_redis_shared_
let(:model) { CounterAttributeModel.find(project_statistics.id) }
end
+ describe 'after_flush callbacks' do
+ let(:attribute) { model.class.counter_attributes.first}
+
+ subject { model.flush_increments_to_database!(attribute) }
+
+ it 'has registered callbacks' do # defined in :counter_attribute RSpec tag
+ expect(model.class.after_flush_callbacks.size).to eq(1)
+ end
+
+ context 'when there are increments to flush' do
+ before do
+ model.delayed_increment_counter(attribute, 10)
+ end
+
+ it 'executes the callbacks' do
+ subject
+
+ expect(model.flushed).to be_truthy
+ end
+ end
+
+ context 'when there are no increments to flush' do
+ it 'does not execute the callbacks' do
+ subject
+
+ expect(model.flushed).to be_nil
+ end
+ end
+ end
+
describe '.steal_increments' do
let(:increment_key) { 'counters:Model:123:attribute' }
let(:flushed_key) { 'counter:Model:123:attribute:flushed' }
diff --git a/spec/models/concerns/featurable_spec.rb b/spec/models/concerns/featurable_spec.rb
index 31186b5fc77..99acc563950 100644
--- a/spec/models/concerns/featurable_spec.rb
+++ b/spec/models/concerns/featurable_spec.rb
@@ -180,6 +180,6 @@ RSpec.describe Featurable do
def update_all_project_features(project, features, value)
project_feature_attributes = features.map { |f| ["#{f}_access_level", value] }.to_h
- project.project_feature.update(project_feature_attributes)
+ project.project_feature.update!(project_feature_attributes)
end
end
diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb
index 44561e2e55a..ff5b270cf33 100644
--- a/spec/models/concerns/issuable_spec.rb
+++ b/spec/models/concerns/issuable_spec.rb
@@ -69,7 +69,7 @@ RSpec.describe Issuable do
it 'returns nil when author is nil' do
issue.author_id = nil
- issue.save(validate: false)
+ issue.save!(validate: false)
expect(issue.author_name).to eq nil
end
@@ -361,13 +361,13 @@ RSpec.describe Issuable do
end
it 'returns true when a subcription exists and subscribed is true' do
- issue.subscriptions.create(user: user, project: project, subscribed: true)
+ issue.subscriptions.create!(user: user, project: project, subscribed: true)
expect(issue.subscribed?(user, project)).to be_truthy
end
it 'returns false when a subcription exists and subscribed is false' do
- issue.subscriptions.create(user: user, project: project, subscribed: false)
+ issue.subscriptions.create!(user: user, project: project, subscribed: false)
expect(issue.subscribed?(user, project)).to be_falsey
end
@@ -383,13 +383,13 @@ RSpec.describe Issuable do
end
it 'returns true when a subcription exists and subscribed is true' do
- issue.subscriptions.create(user: user, project: project, subscribed: true)
+ issue.subscriptions.create!(user: user, project: project, subscribed: true)
expect(issue.subscribed?(user, project)).to be_truthy
end
it 'returns false when a subcription exists and subscribed is false' do
- issue.subscriptions.create(user: user, project: project, subscribed: false)
+ issue.subscriptions.create!(user: user, project: project, subscribed: false)
expect(issue.subscribed?(user, project)).to be_falsey
end
@@ -437,7 +437,7 @@ RSpec.describe Issuable do
let(:labels) { create_list(:label, 2) }
before do
- issue.update(labels: [labels[1]])
+ issue.update!(labels: [labels[1]])
expect(Gitlab::HookData::IssuableBuilder)
.to receive(:new).with(issue).and_return(builder)
end
@@ -456,7 +456,7 @@ RSpec.describe Issuable do
context 'total_time_spent is updated' do
before do
issue.spend_time(duration: 2, user_id: user.id, spent_at: Time.current)
- issue.save
+ issue.save!
expect(Gitlab::HookData::IssuableBuilder)
.to receive(:new).with(issue).and_return(builder)
end
@@ -497,8 +497,8 @@ RSpec.describe Issuable do
let(:user2) { create(:user) }
before do
- merge_request.update(assignees: [user])
- merge_request.update(assignees: [user, user2])
+ merge_request.update!(assignees: [user])
+ merge_request.update!(assignees: [user, user2])
expect(Gitlab::HookData::IssuableBuilder)
.to receive(:new).with(merge_request).and_return(builder)
end
@@ -554,7 +554,7 @@ RSpec.describe Issuable do
before do
label_link = issue.label_links.find_by(label_id: second_label.id)
label_link.label_id = nil
- label_link.save(validate: false)
+ label_link.save!(validate: false)
end
it 'filters out bad labels' do
@@ -824,7 +824,7 @@ RSpec.describe Issuable do
where(:issuable_type, :supports_time_tracking) do
:issue | true
- :incident | false
+ :incident | true
:merge_request | true
end
@@ -926,58 +926,4 @@ RSpec.describe Issuable do
end
end
end
-
- describe '#update_severity' do
- let(:severity) { 'low' }
-
- subject(:update_severity) { issuable.update_severity(severity) }
-
- context 'when issuable not an incident' do
- %i(issue merge_request).each do |issuable_type|
- let(:issuable) { build_stubbed(issuable_type) }
-
- it { is_expected.to be_nil }
-
- it 'does not set severity' do
- expect { subject }.not_to change(IssuableSeverity, :count)
- end
- end
- end
-
- context 'when issuable is an incident' do
- let!(:issuable) { create(:incident) }
-
- context 'when issuable does not have issuable severity yet' do
- it 'creates new record' do
- expect { update_severity }.to change { IssuableSeverity.where(issue: issuable).count }.to(1)
- end
-
- it 'sets severity to specified value' do
- expect { update_severity }.to change { issuable.severity }.to('low')
- end
- end
-
- context 'when issuable has an issuable severity' do
- let!(:issuable_severity) { create(:issuable_severity, issue: issuable, severity: 'medium') }
-
- it 'does not create new record' do
- expect { update_severity }.not_to change(IssuableSeverity, :count)
- end
-
- it 'updates existing issuable severity' do
- expect { update_severity }.to change { issuable_severity.severity }.to(severity)
- end
- end
-
- context 'when severity value is unsupported' do
- let(:severity) { 'unsupported-severity' }
-
- it 'sets the severity to default value' do
- update_severity
-
- expect(issuable.issuable_severity.severity).to eq(IssuableSeverity::DEFAULT)
- end
- end
- end
- end
end
diff --git a/spec/models/concerns/mentionable_spec.rb b/spec/models/concerns/mentionable_spec.rb
index 758b5aa2ce4..516c0fd75bc 100644
--- a/spec/models/concerns/mentionable_spec.rb
+++ b/spec/models/concerns/mentionable_spec.rb
@@ -177,7 +177,7 @@ RSpec.describe Issue, "Mentionable" do
expect(SystemNoteService).not_to receive(:cross_reference)
- issue.update(description: 'New description')
+ issue.update!(description: 'New description')
issue.create_new_cross_references!
end
@@ -186,7 +186,7 @@ RSpec.describe Issue, "Mentionable" do
expect(SystemNoteService).to receive(:cross_reference).with(issues[1], any_args)
- issue.update(description: issues[1].to_reference)
+ issue.update!(description: issues[1].to_reference)
issue.create_new_cross_references!
end
@@ -196,7 +196,7 @@ RSpec.describe Issue, "Mentionable" do
expect(SystemNoteService).to receive(:cross_reference).with(issues[1], any_args)
- note.update(note: issues[1].to_reference)
+ note.update!(note: issues[1].to_reference)
note.create_new_cross_references!
end
end
diff --git a/spec/models/concerns/milestoneable_spec.rb b/spec/models/concerns/milestoneable_spec.rb
index f5b82e42ad4..c37582cb65d 100644
--- a/spec/models/concerns/milestoneable_spec.rb
+++ b/spec/models/concerns/milestoneable_spec.rb
@@ -61,7 +61,7 @@ RSpec.describe Milestoneable do
it 'returns true with a milestone from the the parent of the issue project group' do
parent = create(:group)
- group.update(parent: parent)
+ group.update!(parent: parent)
milestone = create(:milestone, group: parent)
expect(build_milestoneable(milestone.id).milestone_available?).to be_truthy
diff --git a/spec/models/concerns/milestoneish_spec.rb b/spec/models/concerns/milestoneish_spec.rb
index 58cd054efd5..3b8fc465421 100644
--- a/spec/models/concerns/milestoneish_spec.rb
+++ b/spec/models/concerns/milestoneish_spec.rb
@@ -102,7 +102,7 @@ RSpec.describe Milestone, 'Milestoneish' do
with_them do
before do
- project.update(visibility_level: project_visibility_levels[visibility])
+ project.update!(visibility_level: project_visibility_levels[visibility])
end
it 'returns the proper participants' do
@@ -139,7 +139,7 @@ RSpec.describe Milestone, 'Milestoneish' do
with_them do
before do
- project.update(visibility_level: project_visibility_levels[visibility])
+ project.update!(visibility_level: project_visibility_levels[visibility])
end
it 'returns the proper participants' do
@@ -171,7 +171,7 @@ RSpec.describe Milestone, 'Milestoneish' do
context 'when project is private' do
before do
- project.update(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
end
it 'does not return any merge request for a non member' do
@@ -195,7 +195,7 @@ RSpec.describe Milestone, 'Milestoneish' do
context 'when merge requests are available to project members' do
before do
- project.project_feature.update(merge_requests_access_level: ProjectFeature::PRIVATE)
+ project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE)
end
it 'does not return any merge request for a non member' do
diff --git a/spec/models/concerns/reactive_caching_spec.rb b/spec/models/concerns/reactive_caching_spec.rb
index b12ad82920f..7e031bdd263 100644
--- a/spec/models/concerns/reactive_caching_spec.rb
+++ b/spec/models/concerns/reactive_caching_spec.rb
@@ -14,6 +14,7 @@ RSpec.describe ReactiveCaching, :use_clean_rails_memory_store_caching do
self.reactive_cache_lifetime = 5.minutes
self.reactive_cache_refresh_interval = 15.seconds
+ self.reactive_cache_work_type = :no_dependency
attr_reader :id
@@ -372,4 +373,14 @@ RSpec.describe ReactiveCaching, :use_clean_rails_memory_store_caching do
it { expect(subject.reactive_cache_hard_limit).to be_nil }
it { expect(subject.reactive_cache_worker_finder).to respond_to(:call) }
end
+
+ describe 'classes including this concern' do
+ it 'sets reactive_cache_work_type' do
+ classes = ObjectSpace.each_object(Class).select do |klass|
+ klass < described_class && klass.name
+ end
+
+ expect(classes).to all(have_attributes(reactive_cache_work_type: be_in(described_class::WORK_TYPE.keys)))
+ end
+ end
end
diff --git a/spec/models/concerns/resolvable_discussion_spec.rb b/spec/models/concerns/resolvable_discussion_spec.rb
index c91ddfee944..c0e5ddc23b1 100644
--- a/spec/models/concerns/resolvable_discussion_spec.rb
+++ b/spec/models/concerns/resolvable_discussion_spec.rb
@@ -553,13 +553,13 @@ RSpec.describe Discussion, ResolvableDiscussion do
let(:time) { Time.current.utc }
before do
- Timecop.freeze(time - 1.second) do
+ travel_to(time - 1.second) do
first_note.resolve!(current_user)
end
- Timecop.freeze(time) do
+ travel_to(time) do
third_note.resolve!(current_user)
end
- Timecop.freeze(time + 1.second) do
+ travel_to(time + 1.second) do
second_note.resolve!(current_user)
end
end
diff --git a/spec/models/concerns/routable_spec.rb b/spec/models/concerns/routable_spec.rb
index 15d754861b2..e4cf68663ef 100644
--- a/spec/models/concerns/routable_spec.rb
+++ b/spec/models/concerns/routable_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe Group, 'Routable' do
end
it 'updates route record on path change' do
- group.update(path: 'wow', name: 'much')
+ group.update!(path: 'wow', name: 'much')
expect(group.route.path).to eq('wow')
expect(group.route.name).to eq('much')
diff --git a/spec/models/concerns/schedulable_spec.rb b/spec/models/concerns/schedulable_spec.rb
index 875c2d80e55..62acd12e267 100644
--- a/spec/models/concerns/schedulable_spec.rb
+++ b/spec/models/concerns/schedulable_spec.rb
@@ -35,7 +35,7 @@ RSpec.describe Schedulable do
context 'for a pipeline_schedule' do
# let! is used to reset the next_run_at value before each spec
let(:object) do
- Timecop.freeze(1.day.ago) do
+ travel_to(1.day.ago) do
create(:ci_pipeline_schedule, :hourly)
end
end
diff --git a/spec/models/concerns/subscribable_spec.rb b/spec/models/concerns/subscribable_spec.rb
index 2a43e748e58..3e52ca5cf63 100644
--- a/spec/models/concerns/subscribable_spec.rb
+++ b/spec/models/concerns/subscribable_spec.rb
@@ -20,13 +20,13 @@ RSpec.describe Subscribable, 'Subscribable' do
end
it 'returns true when a subcription exists and subscribed is true' do
- resource.subscriptions.create(user: user_1, subscribed: true)
+ resource.subscriptions.create!(user: user_1, subscribed: true)
expect(resource.subscribed?(user_1)).to be_truthy
end
it 'returns false when a subcription exists and subscribed is false' do
- resource.subscriptions.create(user: user_1, subscribed: false)
+ resource.subscriptions.create!(user: user_1, subscribed: false)
expect(resource.subscribed?(user_1)).to be_falsey
end
@@ -38,13 +38,13 @@ RSpec.describe Subscribable, 'Subscribable' do
end
it 'returns true when a subcription exists and subscribed is true' do
- resource.subscriptions.create(user: user_1, project: project, subscribed: true)
+ resource.subscriptions.create!(user: user_1, project: project, subscribed: true)
expect(resource.subscribed?(user_1, project)).to be_truthy
end
it 'returns false when a subcription exists and subscribed is false' do
- resource.subscriptions.create(user: user_1, project: project, subscribed: false)
+ resource.subscriptions.create!(user: user_1, project: project, subscribed: false)
expect(resource.subscribed?(user_1, project)).to be_falsey
end
@@ -58,9 +58,9 @@ RSpec.describe Subscribable, 'Subscribable' do
it 'returns the subscribed users' do
user_2 = create(:user)
- resource.subscriptions.create(user: user_1, subscribed: true)
- resource.subscriptions.create(user: user_2, project: project, subscribed: true)
- resource.subscriptions.create(user: create(:user), project: project, subscribed: false)
+ resource.subscriptions.create!(user: user_1, subscribed: true)
+ resource.subscriptions.create!(user: user_2, project: project, subscribed: true)
+ resource.subscriptions.create!(user: create(:user), project: project, subscribed: false)
expect(resource.subscribers(project)).to contain_exactly(user_1, user_2)
end
@@ -113,7 +113,7 @@ RSpec.describe Subscribable, 'Subscribable' do
describe '#unsubscribe' do
context 'without project' do
it 'unsubscribes the given current user' do
- resource.subscriptions.create(user: user_1, subscribed: true)
+ resource.subscriptions.create!(user: user_1, subscribed: true)
expect(resource.subscribed?(user_1)).to be_truthy
resource.unsubscribe(user_1)
@@ -124,7 +124,7 @@ RSpec.describe Subscribable, 'Subscribable' do
context 'with project' do
it 'unsubscribes the given current user' do
- resource.subscriptions.create(user: user_1, project: project, subscribed: true)
+ resource.subscriptions.create!(user: user_1, project: project, subscribed: true)
expect(resource.subscribed?(user_1, project)).to be_truthy
resource.unsubscribe(user_1, project)
@@ -139,7 +139,7 @@ RSpec.describe Subscribable, 'Subscribable' do
context 'when desired_state is set to true' do
context 'when a user is subscribed to the resource' do
it 'keeps the user subscribed' do
- resource.subscriptions.create(user: user_1, subscribed: true, project: resource_project)
+ resource.subscriptions.create!(user: user_1, subscribed: true, project: resource_project)
resource.set_subscription(user_1, true, resource_project)
@@ -159,7 +159,7 @@ RSpec.describe Subscribable, 'Subscribable' do
context 'when desired_state is set to false' do
context 'when a user is subscribed to the resource' do
it 'unsubscribes the user from the resource' do
- resource.subscriptions.create(user: user_1, subscribed: true, project: resource_project)
+ resource.subscriptions.create!(user: user_1, subscribed: true, project: resource_project)
expect { resource.set_subscription(user_1, false, resource_project) }
.to change { resource.subscribed?(user_1, resource_project) }
diff --git a/spec/models/concerns/token_authenticatable_spec.rb b/spec/models/concerns/token_authenticatable_spec.rb
index e0e764fc63c..90e94b5dca9 100644
--- a/spec/models/concerns/token_authenticatable_spec.rb
+++ b/spec/models/concerns/token_authenticatable_spec.rb
@@ -137,7 +137,7 @@ RSpec.describe PersonalAccessToken, 'TokenAuthenticatable' do
subject { PersonalAccessToken.find_by_token(token_value) }
it 'finds the token' do
- personal_access_token.save
+ personal_access_token.save!
expect(subject).to eq(personal_access_token)
end
diff --git a/spec/models/container_repository_spec.rb b/spec/models/container_repository_spec.rb
index 953f92d103b..2a7aaed5204 100644
--- a/spec/models/container_repository_spec.rb
+++ b/spec/models/container_repository_spec.rb
@@ -184,6 +184,33 @@ RSpec.describe ContainerRepository do
end
end
+ describe '#start_expiration_policy!' do
+ subject { repository.start_expiration_policy! }
+
+ it 'sets the expiration policy started at to now' do
+ Timecop.freeze do
+ expect { subject }
+ .to change { repository.expiration_policy_started_at }.from(nil).to(Time.zone.now)
+ end
+ end
+ end
+
+ describe '#reset_expiration_policy_started_at!' do
+ subject { repository.reset_expiration_policy_started_at! }
+
+ before do
+ repository.start_expiration_policy!
+ end
+
+ it 'resets the expiration policy started at' do
+ started_at = repository.expiration_policy_started_at
+
+ expect(started_at).not_to be_nil
+ expect { subject }
+ .to change { repository.expiration_policy_started_at }.from(started_at).to(nil)
+ end
+ end
+
describe '.build_from_path' do
let(:registry_path) do
ContainerRegistry::Path.new(project.full_path + '/some/image')
diff --git a/spec/models/design_management/design_collection_spec.rb b/spec/models/design_management/design_collection_spec.rb
index 8575cc80b5b..bc8330c7dd3 100644
--- a/spec/models/design_management/design_collection_spec.rb
+++ b/spec/models/design_management/design_collection_spec.rb
@@ -101,6 +101,18 @@ RSpec.describe DesignManagement::DesignCollection do
end
end
+ describe "#empty?" do
+ it "is true when the design collection has no designs" do
+ expect(collection).to be_empty
+ end
+
+ it "is false when the design collection has designs" do
+ create(:design, issue: issue)
+
+ expect(collection).not_to be_empty
+ end
+ end
+
describe "#versions" do
it "includes versions for all designs" do
version_1 = create(:design_version)
diff --git a/spec/models/design_management/design_spec.rb b/spec/models/design_management/design_spec.rb
index d4adc0d42d0..2ce9f00a056 100644
--- a/spec/models/design_management/design_spec.rb
+++ b/spec/models/design_management/design_spec.rb
@@ -206,6 +206,15 @@ RSpec.describe DesignManagement::Design do
end
end
+ describe ".build_full_path" do
+ it "builds the full path for a design" do
+ design = build(:design, issue: issue, filename: "hello.jpg")
+ expected_path = "#{DesignManagement.designs_directory}/issue-#{design.issue.iid}/hello.jpg"
+
+ expect(described_class.build_full_path(issue, design)).to eq(expected_path)
+ end
+ end
+
describe '#visible_in?' do
let_it_be(:issue) { create(:issue, project: issue.project) }
diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb
index bafcb7a3741..47492715c11 100644
--- a/spec/models/event_spec.rb
+++ b/spec/models/event_spec.rb
@@ -918,6 +918,56 @@ RSpec.describe Event do
expect(destroyed).to eq('deleted')
expect(archived).to eq('archived')
end
+
+ it 'handles correct push_action' do
+ project = create(:project)
+ user = create(:user)
+ project.add_developer(user)
+ push_event = create_push_event(project, user)
+
+ expect(push_event.push_action?).to be true
+ expect(push_event.action_name).to eq('pushed to')
+ end
+
+ context 'handles correct base actions' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:trait, :action_name) do
+ :created | 'created'
+ :updated | 'opened'
+ :closed | 'closed'
+ :reopened | 'opened'
+ :commented | 'commented on'
+ :merged | 'accepted'
+ :joined | 'joined'
+ :left | 'left'
+ :destroyed | 'destroyed'
+ :expired | 'removed due to membership expiration from'
+ :approved | 'approved'
+ end
+
+ with_them do
+ it 'with correct name and method' do
+ event = build(:event, trait)
+
+ expect(event.action_name).to eq(action_name)
+ end
+ end
+ end
+
+ context 'for created_project_action?' do
+ it 'returns created for created event' do
+ action = build(:project_created_event)
+
+ expect(action.action_name).to eq('created')
+ end
+
+ it 'returns imported for imported event' do
+ action = build(:project_imported_event)
+
+ expect(action.action_name).to eq('imported')
+ end
+ end
end
def create_push_event(project, user)
diff --git a/spec/models/group_import_state_spec.rb b/spec/models/group_import_state_spec.rb
index 4404ef64966..469b5c96ac9 100644
--- a/spec/models/group_import_state_spec.rb
+++ b/spec/models/group_import_state_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe GroupImportState do
describe 'validations' do
let_it_be(:group) { create(:group) }
+ it { is_expected.to belong_to(:user).required }
it { is_expected.to validate_presence_of(:group) }
it { is_expected.to validate_presence_of(:status) }
diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb
index 15972f66fd6..ed027d02b5b 100644
--- a/spec/models/group_spec.rb
+++ b/spec/models/group_spec.rb
@@ -224,6 +224,20 @@ RSpec.describe Group do
end
end
+ describe '.without_integration' do
+ let(:another_group) { create(:group) }
+ let(:instance_integration) { build(:jira_service, :instance) }
+
+ before do
+ create(:jira_service, group: group, project: nil)
+ create(:slack_service, group: another_group, project: nil)
+ end
+
+ it 'returns groups without integration' do
+ expect(Group.without_integration(instance_integration)).to contain_exactly(another_group)
+ end
+ end
+
describe '.public_or_visible_to_user' do
let!(:private_group) { create(:group, :private) }
let!(:internal_group) { create(:group, :internal) }
@@ -1330,229 +1344,134 @@ RSpec.describe Group do
end
end
- describe '#shared_runners_allowed?' do
- using RSpec::Parameterized::TableSyntax
-
- where(:shared_runners_enabled, :allow_descendants_override, :expected_shared_runners_allowed) do
- true | false | true
- true | true | true
- false | false | false
- false | true | true
- end
-
- with_them do
- let!(:group) { create(:group, shared_runners_enabled: shared_runners_enabled, allow_descendants_override_disabled_shared_runners: allow_descendants_override) }
-
- it 'returns the expected result' do
- expect(group.shared_runners_allowed?).to eq(expected_shared_runners_allowed)
- end
- end
+ def subject_and_reload(*models)
+ subject
+ models.map(&:reload)
end
- describe '#parent_allows_shared_runners?' do
- context 'when parent group is present' do
- using RSpec::Parameterized::TableSyntax
-
- where(:shared_runners_enabled, :allow_descendants_override, :expected_shared_runners_allowed) do
- true | false | true
- true | true | true
- false | false | false
- false | true | true
- end
-
- with_them do
- let!(:parent_group) { create(:group, shared_runners_enabled: shared_runners_enabled, allow_descendants_override_disabled_shared_runners: allow_descendants_override) }
- let!(:group) { create(:group, parent: parent_group) }
-
- it 'returns the expected result' do
- expect(group.parent_allows_shared_runners?).to eq(expected_shared_runners_allowed)
+ describe '#update_shared_runners_setting!' do
+ context 'enabled' do
+ subject { group.update_shared_runners_setting!('enabled') }
+
+ context 'group that its ancestors have shared runners disabled' do
+ let_it_be(:parent) { create(:group, :shared_runners_disabled) }
+ let_it_be(:group) { create(:group, :shared_runners_disabled, parent: parent) }
+ let_it_be(:project) { create(:project, shared_runners_enabled: false, group: group) }
+
+ it 'raises error and does not enable shared Runners' do
+ expect { subject_and_reload(parent, group, project) }
+ .to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Shared runners enabled cannot be enabled because parent group has shared Runners disabled')
+ .and not_change { parent.shared_runners_enabled }
+ .and not_change { group.shared_runners_enabled }
+ .and not_change { project.shared_runners_enabled }
end
end
- end
-
- context 'when parent group is missing' do
- let!(:group) { create(:group) }
-
- it 'returns true' do
- expect(group.parent_allows_shared_runners?).to be_truthy
- end
- end
- end
-
- describe '#parent_enabled_shared_runners?' do
- subject { group.parent_enabled_shared_runners? }
-
- context 'when parent group is present' do
- context 'When shared Runners are disabled' do
- let!(:parent_group) { create(:group, :shared_runners_disabled) }
- let!(:group) { create(:group, parent: parent_group) }
-
- it { is_expected.to be_falsy }
- end
-
- context 'When shared Runners are enabled' do
- let!(:parent_group) { create(:group) }
- let!(:group) { create(:group, parent: parent_group) }
-
- it { is_expected.to be_truthy }
- end
- end
-
- context 'when parent group is missing' do
- let!(:group) { create(:group) }
-
- it { is_expected.to be_truthy }
- end
- end
-
- describe '#enable_shared_runners!' do
- subject { group.enable_shared_runners! }
- context 'group that its ancestors have shared runners disabled' do
- let_it_be(:parent) { create(:group, :shared_runners_disabled) }
- let_it_be(:group) { create(:group, :shared_runners_disabled, parent: parent) }
- let_it_be(:project) { create(:project, shared_runners_enabled: false, group: group) }
+ context 'root group with shared runners disabled' do
+ let_it_be(:group) { create(:group, :shared_runners_disabled) }
+ let_it_be(:sub_group) { create(:group, :shared_runners_disabled, parent: group) }
+ let_it_be(:project) { create(:project, shared_runners_enabled: false, group: sub_group) }
- it 'raises error and does not enable shared Runners' do
- expect { subject }
- .to raise_error(described_class::UpdateSharedRunnersError, 'Shared Runners disabled for the parent group')
- .and not_change { parent.reload.shared_runners_enabled }
- .and not_change { group.reload.shared_runners_enabled }
- .and not_change { project.reload.shared_runners_enabled }
+ it 'enables shared Runners only for itself' do
+ expect { subject_and_reload(group, sub_group, project) }
+ .to change { group.shared_runners_enabled }.from(false).to(true)
+ .and not_change { sub_group.shared_runners_enabled }
+ .and not_change { project.shared_runners_enabled }
+ end
end
end
- context 'root group with shared runners disabled' do
- let_it_be(:group) { create(:group, :shared_runners_disabled) }
- let_it_be(:sub_group) { create(:group, :shared_runners_disabled, parent: group) }
- let_it_be(:project) { create(:project, shared_runners_enabled: false, group: sub_group) }
-
- it 'enables shared Runners only for itself' do
- expect { subject }
- .to change { group.reload.shared_runners_enabled }.from(false).to(true)
- .and not_change { sub_group.reload.shared_runners_enabled }
- .and not_change { project.reload.shared_runners_enabled }
+ context 'disabled_and_unoverridable' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:sub_group) { create(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent: group) }
+ let_it_be(:sub_group_2) { create(:group, parent: group) }
+ let_it_be(:project) { create(:project, group: group, shared_runners_enabled: true) }
+ let_it_be(:project_2) { create(:project, group: sub_group_2, shared_runners_enabled: true) }
+
+ subject { group.update_shared_runners_setting!('disabled_and_unoverridable') }
+
+ it 'disables shared Runners for all descendant groups and projects' do
+ expect { subject_and_reload(group, sub_group, sub_group_2, project, project_2) }
+ .to change { group.shared_runners_enabled }.from(true).to(false)
+ .and not_change { group.allow_descendants_override_disabled_shared_runners }
+ .and not_change { sub_group.shared_runners_enabled }
+ .and change { sub_group.allow_descendants_override_disabled_shared_runners }.from(true).to(false)
+ .and change { sub_group_2.shared_runners_enabled }.from(true).to(false)
+ .and not_change { sub_group_2.allow_descendants_override_disabled_shared_runners }
+ .and change { project.shared_runners_enabled }.from(true).to(false)
+ .and change { project_2.shared_runners_enabled }.from(true).to(false)
+ end
+
+ context 'with override on self' do
+ let_it_be(:group) { create(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners) }
+
+ it 'disables it' do
+ expect { subject_and_reload(group) }
+ .to not_change { group.shared_runners_enabled }
+ .and change { group.allow_descendants_override_disabled_shared_runners }.from(true).to(false)
+ end
end
end
- end
-
- describe '#disable_shared_runners!' do
- let_it_be(:group) { create(:group) }
- let_it_be(:sub_group) { create(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent: group) }
- let_it_be(:sub_group_2) { create(:group, parent: group) }
- let_it_be(:project) { create(:project, group: group, shared_runners_enabled: true) }
- let_it_be(:project_2) { create(:project, group: sub_group_2, shared_runners_enabled: true) }
-
- subject { group.disable_shared_runners! }
-
- it 'disables shared Runners for all descendant groups and projects' do
- expect { subject }
- .to change { group.reload.shared_runners_enabled }.from(true).to(false)
- .and not_change { group.reload.allow_descendants_override_disabled_shared_runners }
- .and not_change { sub_group.reload.shared_runners_enabled }
- .and not_change { sub_group.reload.allow_descendants_override_disabled_shared_runners }
- .and change { sub_group_2.reload.shared_runners_enabled }.from(true).to(false)
- .and not_change { sub_group_2.reload.allow_descendants_override_disabled_shared_runners }
- .and change { project.reload.shared_runners_enabled }.from(true).to(false)
- .and change { project_2.reload.shared_runners_enabled }.from(true).to(false)
- end
- end
-
- describe '#allow_descendants_override_disabled_shared_runners!' do
- subject { group.allow_descendants_override_disabled_shared_runners! }
- context 'top level group' do
- let_it_be(:group) { create(:group, :shared_runners_disabled) }
- let_it_be(:sub_group) { create(:group, :shared_runners_disabled, parent: group) }
- let_it_be(:project) { create(:project, shared_runners_enabled: false, group: sub_group) }
+ context 'disabled_with_override' do
+ subject { group.update_shared_runners_setting!('disabled_with_override') }
- it 'enables allow descendants to override only for itself' do
- expect { subject }
- .to change { group.reload.allow_descendants_override_disabled_shared_runners }.from(false).to(true)
- .and not_change { group.reload.shared_runners_enabled }
- .and not_change { sub_group.reload.allow_descendants_override_disabled_shared_runners }
- .and not_change { sub_group.reload.shared_runners_enabled }
- .and not_change { project.reload.shared_runners_enabled }
- end
- end
+ context 'top level group' do
+ let_it_be(:group) { create(:group, :shared_runners_disabled) }
+ let_it_be(:sub_group) { create(:group, :shared_runners_disabled, parent: group) }
+ let_it_be(:project) { create(:project, shared_runners_enabled: false, group: sub_group) }
- context 'group that its ancestors have shared Runners disabled but allows to override' do
- let_it_be(:parent) { create(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners) }
- let_it_be(:group) { create(:group, :shared_runners_disabled, parent: parent) }
- let_it_be(:project) { create(:project, shared_runners_enabled: false, group: group) }
-
- it 'enables allow descendants to override' do
- expect { subject }
- .to not_change { parent.reload.allow_descendants_override_disabled_shared_runners }
- .and not_change { parent.reload.shared_runners_enabled }
- .and change { group.reload.allow_descendants_override_disabled_shared_runners }.from(false).to(true)
- .and not_change { group.reload.shared_runners_enabled }
- .and not_change { project.reload.shared_runners_enabled }
+ it 'enables allow descendants to override only for itself' do
+ expect { subject_and_reload(group, sub_group, project) }
+ .to change { group.allow_descendants_override_disabled_shared_runners }.from(false).to(true)
+ .and not_change { group.shared_runners_enabled }
+ .and not_change { sub_group.allow_descendants_override_disabled_shared_runners }
+ .and not_change { sub_group.shared_runners_enabled }
+ .and not_change { project.shared_runners_enabled }
+ end
end
- end
- context 'when parent does not allow' do
- let_it_be(:parent) { create(:group, :shared_runners_disabled, allow_descendants_override_disabled_shared_runners: false ) }
- let_it_be(:group) { create(:group, :shared_runners_disabled, allow_descendants_override_disabled_shared_runners: false, parent: parent) }
+ context 'group that its ancestors have shared Runners disabled but allows to override' do
+ let_it_be(:parent) { create(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners) }
+ let_it_be(:group) { create(:group, :shared_runners_disabled, parent: parent) }
+ let_it_be(:project) { create(:project, shared_runners_enabled: false, group: group) }
- it 'raises error and does not allow descendants to override' do
- expect { subject }
- .to raise_error(described_class::UpdateSharedRunnersError, 'Group level shared Runners not allowed')
- .and not_change { parent.reload.allow_descendants_override_disabled_shared_runners }
- .and not_change { parent.reload.shared_runners_enabled }
- .and not_change { group.reload.allow_descendants_override_disabled_shared_runners }
- .and not_change { group.reload.shared_runners_enabled }
+ it 'enables allow descendants to override' do
+ expect { subject_and_reload(parent, group, project) }
+ .to not_change { parent.allow_descendants_override_disabled_shared_runners }
+ .and not_change { parent.shared_runners_enabled }
+ .and change { group.allow_descendants_override_disabled_shared_runners }.from(false).to(true)
+ .and not_change { group.shared_runners_enabled }
+ .and not_change { project.shared_runners_enabled }
+ end
end
- end
- context 'top level group that has shared Runners enabled' do
- let_it_be(:group) { create(:group, shared_runners_enabled: true) }
- let_it_be(:sub_group) { create(:group, :shared_runners_disabled, parent: group) }
- let_it_be(:project) { create(:project, shared_runners_enabled: false, group: sub_group) }
+ context 'when parent does not allow' do
+ let_it_be(:parent) { create(:group, :shared_runners_disabled, allow_descendants_override_disabled_shared_runners: false ) }
+ let_it_be(:group) { create(:group, :shared_runners_disabled, allow_descendants_override_disabled_shared_runners: false, parent: parent) }
- it 'raises error and does not change config' do
- expect { subject }
- .to raise_error(described_class::UpdateSharedRunnersError, 'Shared Runners enabled')
- .and not_change { group.reload.allow_descendants_override_disabled_shared_runners }
- .and not_change { group.reload.shared_runners_enabled }
- .and not_change { sub_group.reload.allow_descendants_override_disabled_shared_runners }
- .and not_change { sub_group.reload.shared_runners_enabled }
- .and not_change { project.reload.shared_runners_enabled }
+ it 'raises error and does not allow descendants to override' do
+ expect { subject_and_reload(parent, group) }
+ .to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Allow descendants override disabled shared runners cannot be enabled because parent group does not allow it')
+ .and not_change { parent.allow_descendants_override_disabled_shared_runners }
+ .and not_change { parent.shared_runners_enabled }
+ .and not_change { group.allow_descendants_override_disabled_shared_runners }
+ .and not_change { group.shared_runners_enabled }
+ end
end
- end
- end
- describe '#disallow_descendants_override_disabled_shared_runners!' do
- subject { group.disallow_descendants_override_disabled_shared_runners! }
+ context 'top level group that has shared Runners enabled' do
+ let_it_be(:group) { create(:group, shared_runners_enabled: true) }
+ let_it_be(:sub_group) { create(:group, shared_runners_enabled: true, parent: group) }
+ let_it_be(:project) { create(:project, shared_runners_enabled: true, group: sub_group) }
- context 'top level group' do
- let_it_be(:group) { create(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners ) }
- let_it_be(:sub_group) { create(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent: group) }
- let_it_be(:project) { create(:project, shared_runners_enabled: true, group: sub_group) }
-
- it 'disables allow project to override for descendants and disables project shared Runners' do
- expect { subject }
- .to not_change { group.reload.shared_runners_enabled }
- .and change { group.reload.allow_descendants_override_disabled_shared_runners }.from(true).to(false)
- .and not_change { sub_group.reload.shared_runners_enabled }
- .and change { sub_group.reload.allow_descendants_override_disabled_shared_runners }.from(true).to(false)
- .and change { project.reload.shared_runners_enabled }.from(true).to(false)
- end
- end
-
- context 'top level group that has shared Runners enabled' do
- let_it_be(:group) { create(:group, shared_runners_enabled: true) }
- let_it_be(:sub_group) { create(:group, :shared_runners_disabled, parent: group) }
- let_it_be(:project) { create(:project, shared_runners_enabled: false, group: sub_group) }
-
- it 'results error and does not change config' do
- expect { subject }
- .to raise_error(described_class::UpdateSharedRunnersError, 'Shared Runners enabled')
- .and not_change { group.reload.allow_descendants_override_disabled_shared_runners }
- .and not_change { group.reload.shared_runners_enabled }
- .and not_change { sub_group.reload.allow_descendants_override_disabled_shared_runners }
- .and not_change { sub_group.reload.shared_runners_enabled }
- .and not_change { project.reload.shared_runners_enabled }
+ it 'enables allow descendants to override & disables shared runners everywhere' do
+ expect { subject_and_reload(group, sub_group, project) }
+ .to change { group.shared_runners_enabled }.from(true).to(false)
+ .and change { group.allow_descendants_override_disabled_shared_runners }.from(false).to(true)
+ .and change { sub_group.shared_runners_enabled }.from(true).to(false)
+ .and change { project.shared_runners_enabled }.from(true).to(false)
+ end
end
end
end
diff --git a/spec/models/import_failure_spec.rb b/spec/models/import_failure_spec.rb
index cdef125e890..9fee1b0ae7b 100644
--- a/spec/models/import_failure_spec.rb
+++ b/spec/models/import_failure_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe ImportFailure do
it 'orders hard failures by newest first' do
older_failure = hard_failure.dup
- Timecop.freeze(1.day.before(hard_failure.created_at)) do
+ travel_to(1.day.before(hard_failure.created_at)) do
older_failure.save!
expect(ImportFailure.hard_failures_by_correlation_id(correlation_id)).to eq([hard_failure, older_failure])
diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb
index 87ba0f3f7e6..e4bb6522689 100644
--- a/spec/models/integration_spec.rb
+++ b/spec/models/integration_spec.rb
@@ -11,18 +11,18 @@ RSpec.describe Integration do
before do
create(:jira_service, project: project_1, inherit_from_id: instance_integration.id)
create(:jira_service, project: project_2, inherit_from_id: nil)
- create(:slack_service, project: project_1, inherit_from_id: nil)
+ create(:slack_service, project: project_3, inherit_from_id: nil)
end
- describe '#with_custom_integration_for' do
+ describe '.with_custom_integration_for' do
it 'returns projects with custom integrations' do
expect(Project.with_custom_integration_for(instance_integration)).to contain_exactly(project_2)
end
end
- describe '#ids_without_integration' do
- it 'returns projects ids without an integration' do
- expect(Project.ids_without_integration(instance_integration, 100)).to contain_exactly(project_3.id)
+ describe '.without_integration' do
+ it 'returns projects without integration' do
+ expect(Project.without_integration(instance_integration)).to contain_exactly(project_3)
end
end
end
diff --git a/spec/models/issue/metrics_spec.rb b/spec/models/issue/metrics_spec.rb
index 966e4321378..1d3c09a48b7 100644
--- a/spec/models/issue/metrics_spec.rb
+++ b/spec/models/issue/metrics_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe Issue::Metrics do
context "milestones" do
it "records the first time an issue is associated with a milestone" do
time = Time.current
- Timecop.freeze(time) { subject.update(milestone: create(:milestone, project: project)) }
+ travel_to(time) { subject.update(milestone: create(:milestone, project: project)) }
metrics = subject.metrics
expect(metrics).to be_present
@@ -47,9 +47,9 @@ RSpec.describe Issue::Metrics do
it "does not record the second time an issue is associated with a milestone" do
time = Time.current
- Timecop.freeze(time) { subject.update(milestone: create(:milestone, project: project)) }
- Timecop.freeze(time + 2.hours) { subject.update(milestone: nil) }
- Timecop.freeze(time + 6.hours) { subject.update(milestone: create(:milestone, project: project)) }
+ travel_to(time) { subject.update(milestone: create(:milestone, project: project)) }
+ travel_to(time + 2.hours) { subject.update(milestone: nil) }
+ travel_to(time + 6.hours) { subject.update(milestone: create(:milestone, project: project)) }
metrics = subject.metrics
expect(metrics).to be_present
@@ -61,7 +61,7 @@ RSpec.describe Issue::Metrics do
it "records the first time an issue is associated with a list label" do
list_label = create(:list).label
time = Time.current
- Timecop.freeze(time) { subject.update(label_ids: [list_label.id]) }
+ travel_to(time) { subject.update(label_ids: [list_label.id]) }
metrics = subject.metrics
expect(metrics).to be_present
@@ -71,9 +71,9 @@ RSpec.describe Issue::Metrics do
it "does not record the second time an issue is associated with a list label" do
time = Time.current
first_list_label = create(:list).label
- Timecop.freeze(time) { subject.update(label_ids: [first_list_label.id]) }
+ travel_to(time) { subject.update(label_ids: [first_list_label.id]) }
second_list_label = create(:list).label
- Timecop.freeze(time + 5.hours) { subject.update(label_ids: [second_list_label.id]) }
+ travel_to(time + 5.hours) { subject.update(label_ids: [second_list_label.id]) }
metrics = subject.metrics
expect(metrics).to be_present
diff --git a/spec/models/issue_email_participant_spec.rb b/spec/models/issue_email_participant_spec.rb
new file mode 100644
index 00000000000..f19e65e31f3
--- /dev/null
+++ b/spec/models/issue_email_participant_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IssueEmailParticipant do
+ describe "Associations" do
+ it { is_expected.to belong_to(:issue) }
+ end
+
+ describe 'Validations' do
+ subject { build(:issue_email_participant) }
+
+ it { is_expected.to validate_presence_of(:issue) }
+ it { is_expected.to validate_presence_of(:email) }
+ it { is_expected.to validate_uniqueness_of(:email).scoped_to([:issue_id]) }
+
+ it_behaves_like 'an object with RFC3696 compliant email-formated attributes', :email
+ end
+end
diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb
index 283d945157b..16ea2989eda 100644
--- a/spec/models/issue_spec.rb
+++ b/spec/models/issue_spec.rb
@@ -28,10 +28,11 @@ RSpec.describe Issue do
it { is_expected.to have_and_belong_to_many(:prometheus_alert_events) }
it { is_expected.to have_and_belong_to_many(:self_managed_prometheus_alert_events) }
it { is_expected.to have_many(:prometheus_alerts) }
+ it { is_expected.to have_many(:issue_email_participants) }
describe 'versions.most_recent' do
it 'returns the most recent version' do
- issue = create(:issue)
+ issue = create(:issue, project: reusable_project)
create_list(:design_version, 2, issue: issue)
last_version = create(:design_version, issue: issue)
@@ -79,19 +80,19 @@ RSpec.describe Issue do
end
end
- subject { create(:issue) }
+ subject { create(:issue, project: reusable_project) }
describe 'callbacks' do
describe '#ensure_metrics' do
it 'creates metrics after saving' do
- issue = create(:issue)
+ issue = create(:issue, project: reusable_project)
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 = create(:issue, project: reusable_project)
issue.close!
@@ -102,6 +103,14 @@ RSpec.describe Issue do
it 'records current metrics' do
expect_any_instance_of(Issue::Metrics).to receive(:record!)
+ create(:issue, project: reusable_project)
+ end
+ end
+
+ describe '#record_create_action' do
+ it 'records the creation action after saving' do
+ expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).to receive(:track_issue_created_action)
+
create(:issue)
end
end
@@ -111,8 +120,8 @@ RSpec.describe Issue do
subject { described_class.with_alert_management_alerts }
it 'gets only issues with alerts' do
- alert = create(:alert_management_alert, issue: create(:issue))
- issue = create(:issue)
+ alert = create(:alert_management_alert, project: reusable_project, issue: create(:issue, project: reusable_project))
+ issue = create(:issue, project: reusable_project)
expect(subject).to contain_exactly(alert.issue)
expect(subject).not_to include(issue)
@@ -130,10 +139,9 @@ RSpec.describe Issue do
end
describe '.with_issue_type' do
- let_it_be(:project) { create(:project) }
- let_it_be(:issue) { create(:issue, project: project) }
- let_it_be(:incident) { create(:incident, project: project) }
- let_it_be(:test_case) { create(:quality_test_case, project: project) }
+ let_it_be(:issue) { create(:issue, project: reusable_project) }
+ let_it_be(:incident) { create(:incident, project: reusable_project) }
+ let_it_be(:test_case) { create(:quality_test_case, project: reusable_project) }
it 'gives issues with the given issue type' do
expect(described_class.with_issue_type('issue'))
@@ -146,6 +154,24 @@ RSpec.describe Issue do
end
end
+ describe '.order_severity' do
+ let_it_be(:issue_high_severity) { create(:issuable_severity, severity: :high).issue }
+ let_it_be(:issue_low_severity) { create(:issuable_severity, severity: :low).issue }
+ let_it_be(:issue_no_severity) { create(:incident) }
+
+ context 'sorting ascending' do
+ subject { described_class.order_severity_asc }
+
+ it { is_expected.to eq([issue_no_severity, issue_low_severity, issue_high_severity]) }
+ end
+
+ context 'sorting descending' do
+ subject { described_class.order_severity_desc }
+
+ it { is_expected.to eq([issue_high_severity, issue_low_severity, issue_no_severity]) }
+ end
+ end
+
describe '#order_by_position_and_priority' do
let(:project) { reusable_project }
let(:p1) { create(:label, title: 'P1', project: project, priority: 1) }
@@ -195,7 +221,7 @@ RSpec.describe Issue do
end
describe '#close' do
- subject(:issue) { create(:issue, state: 'opened') }
+ subject(:issue) { create(:issue, project: reusable_project, state: 'opened') }
it 'sets closed_at to Time.current when an issue is closed' do
expect { issue.close }.to change { issue.closed_at }.from(nil)
@@ -210,7 +236,7 @@ RSpec.describe Issue do
end
describe '#reopen' do
- let(:issue) { create(:issue, state: 'closed', closed_at: Time.current, closed_by: user) }
+ let(:issue) { create(:issue, project: reusable_project, state: 'closed', closed_at: Time.current, closed_by: user) }
it 'sets closed_at to nil when an issue is reopend' do
expect { issue.reopen }.to change { issue.closed_at }.to(nil)
@@ -293,7 +319,7 @@ RSpec.describe Issue do
end
describe '#assignee_or_author?' do
- let(:issue) { create(:issue) }
+ let(:issue) { create(:issue, project: reusable_project) }
it 'returns true for a user that is assigned to an issue' do
issue.assignees << user
@@ -313,22 +339,21 @@ RSpec.describe Issue do
end
describe '#related_issues' do
- let(:user) { create(:user) }
- let(:authorized_project) { create(:project) }
- let(:authorized_project2) { create(:project) }
- let(:unauthorized_project) { create(:project) }
+ let_it_be(:authorized_project) { create(:project) }
+ let_it_be(:authorized_project2) { create(:project) }
+ let_it_be(:unauthorized_project) { create(:project) }
- let(:authorized_issue_a) { create(:issue, project: authorized_project) }
- let(:authorized_issue_b) { create(:issue, project: authorized_project) }
- let(:authorized_issue_c) { create(:issue, project: authorized_project2) }
+ let_it_be(:authorized_issue_a) { create(:issue, project: authorized_project) }
+ let_it_be(:authorized_issue_b) { create(:issue, project: authorized_project) }
+ let_it_be(:authorized_issue_c) { create(:issue, project: authorized_project2) }
- let(:unauthorized_issue) { create(:issue, project: unauthorized_project) }
+ let_it_be(:unauthorized_issue) { create(:issue, project: unauthorized_project) }
- let!(:issue_link_a) { create(:issue_link, source: authorized_issue_a, target: authorized_issue_b) }
- let!(:issue_link_b) { create(:issue_link, source: authorized_issue_a, target: unauthorized_issue) }
- let!(:issue_link_c) { create(:issue_link, source: authorized_issue_a, target: authorized_issue_c) }
+ let_it_be(:issue_link_a) { create(:issue_link, source: authorized_issue_a, target: authorized_issue_b) }
+ let_it_be(:issue_link_b) { create(:issue_link, source: authorized_issue_a, target: unauthorized_issue) }
+ let_it_be(:issue_link_c) { create(:issue_link, source: authorized_issue_a, target: authorized_issue_c) }
- before do
+ before_all do
authorized_project.add_developer(user)
authorized_project2.add_developer(user)
end
@@ -366,17 +391,16 @@ RSpec.describe Issue do
end
context 'user is reporter in project issue belongs to' do
- let(:project) { create(:project) }
- let(:issue) { create(:issue, project: project) }
+ let(:issue) { create(:issue, project: reusable_project) }
- before do
- project.add_reporter(user)
+ before_all do
+ reusable_project.add_reporter(user)
end
it { is_expected.to eq true }
context 'issue not persisted' do
- let(:issue) { build(:issue, project: project) }
+ let(:issue) { build(:issue, project: reusable_project) }
it { is_expected.to eq false }
end
@@ -384,7 +408,7 @@ RSpec.describe Issue do
context 'checking destination project also' do
subject { issue.can_move?(user, to_project) }
- let(:to_project) { create(:project) }
+ let_it_be(:to_project) { create(:project) }
context 'destination project allowed' do
before do
@@ -420,7 +444,7 @@ RSpec.describe Issue do
end
describe '#duplicated?' do
- let(:issue) { create(:issue) }
+ let(:issue) { create(:issue, project: reusable_project) }
subject { issue.duplicated? }
@@ -429,7 +453,7 @@ RSpec.describe Issue do
end
context 'issue already duplicated' do
- let(:duplicated_to_issue) { create(:issue) }
+ let(:duplicated_to_issue) { create(:issue, project: reusable_project) }
let(:issue) { create(:issue, duplicated_to: duplicated_to_issue) }
it { is_expected.to eq true }
@@ -440,13 +464,13 @@ RSpec.describe Issue do
subject { issue.from_service_desk? }
context 'when issue author is support bot' do
- let(:issue) { create(:issue, author: ::User.support_bot) }
+ let(:issue) { create(:issue, project: reusable_project, author: ::User.support_bot) }
it { is_expected.to be_truthy }
end
context 'when issue author is not support bot' do
- let(:issue) { create(:issue) }
+ let(:issue) { create(:issue, project: reusable_project) }
it { is_expected.to be_falsey }
end
@@ -495,7 +519,7 @@ RSpec.describe Issue do
end
describe '#has_related_branch?' do
- let(:issue) { create(:issue, title: "Blue Bell Knoll") }
+ let(:issue) { create(:issue, project: reusable_project, title: "Blue Bell Knoll") }
subject { issue.has_related_branch? }
@@ -528,7 +552,7 @@ RSpec.describe Issue do
end
describe "#to_branch_name" do
- let(:issue) { create(:issue, title: 'testing-issue') }
+ let_it_be(:issue) { create(:issue, project: reusable_project, title: 'testing-issue') }
it 'starts with the issue iid' do
expect(issue.to_branch_name).to match(/\A#{issue.iid}-[A-Za-z\-]+\z/)
@@ -539,12 +563,12 @@ RSpec.describe Issue do
end
it "does not contain the issue title if confidential" do
- issue = create(:issue, title: 'testing-issue', confidential: true)
+ issue = create(:issue, project: reusable_project, title: 'testing-issue', confidential: true)
expect(issue.to_branch_name).to match(/confidential-issue\z/)
end
context 'issue title longer than 100 characters' do
- let(:issue) { create(:issue, iid: 999, title: 'Lorem ipsum dolor sit amet consectetur adipiscing elit Mauris sit amet ipsum id lacus custom fringilla convallis') }
+ let_it_be(:issue) { create(:issue, project: reusable_project, iid: 999, title: 'Lorem ipsum dolor sit amet consectetur adipiscing elit Mauris sit amet ipsum id lacus custom fringilla convallis') }
it "truncates branch name to at most 100 characters" do
expect(issue.to_branch_name.length).to be <= 100
@@ -581,15 +605,14 @@ RSpec.describe Issue do
describe '#participants' do
context 'using a public project' do
- let(:project) { create(:project, :public) }
- let(:issue) { create(:issue, project: project) }
+ let_it_be(:issue) { create(:issue, project: reusable_project) }
let!(:note1) do
- create(:note_on_issue, noteable: issue, project: project, note: 'a')
+ create(:note_on_issue, noteable: issue, project: reusable_project, note: 'a')
end
let!(:note2) do
- create(:note_on_issue, noteable: issue, project: project, note: 'b')
+ create(:note_on_issue, noteable: issue, project: reusable_project, note: 'b')
end
it 'includes the issue author' do
@@ -604,8 +627,8 @@ RSpec.describe Issue do
context 'using a private project' do
it 'does not include mentioned users that do not have access to the project' do
project = create(:project)
- user = create(:user)
issue = create(:issue, project: project)
+ user = create(:user)
create(:note_on_issue,
noteable: issue,
@@ -621,10 +644,9 @@ RSpec.describe Issue do
it 'updates when assignees change' do
user1 = create(:user)
user2 = create(:user)
- project = create(:project)
- issue = create(:issue, assignees: [user1], project: project)
- project.add_developer(user1)
- project.add_developer(user2)
+ issue = create(:issue, assignees: [user1], project: reusable_project)
+ reusable_project.add_developer(user1)
+ reusable_project.add_developer(user2)
expect(user1.assigned_open_issues_count).to eq(1)
expect(user2.assigned_open_issues_count).to eq(0)
@@ -638,9 +660,8 @@ RSpec.describe Issue do
end
describe '#visible_to_user?' do
- let(:project) { build(:project) }
+ let(:project) { reusable_project }
let(:issue) { build(:issue, project: project) }
- let(:user) { create(:user) }
subject { issue.visible_to_user?(user) }
@@ -661,6 +682,10 @@ RSpec.describe Issue do
context 'without a user' do
let(:user) { nil }
+ before do
+ project.project_feature.update_attribute(:issues_access_level, ProjectFeature::PUBLIC)
+ end
+
it 'returns true when the issue is publicly visible' do
expect(issue).to receive(:publicly_visible?).and_return(true)
@@ -995,7 +1020,8 @@ RSpec.describe Issue do
with_them do
it 'checks for spam on issues that can be seen anonymously' do
- project = create(:project, visibility_level: visibility_level)
+ project = reusable_project
+ project.update(visibility_level: visibility_level)
issue = create(:issue, project: project, confidential: confidential, description: 'original description')
issue.assign_attributes(new_attributes)
@@ -1016,8 +1042,8 @@ RSpec.describe Issue do
describe '.public_only' do
it 'only returns public issues' do
- public_issue = create(:issue)
- create(:issue, confidential: true)
+ public_issue = create(:issue, project: reusable_project)
+ create(:issue, project: reusable_project, confidential: true)
expect(described_class.public_only).to eq([public_issue])
end
@@ -1025,15 +1051,15 @@ RSpec.describe Issue do
describe '.confidential_only' do
it 'only returns confidential_only issues' do
- create(:issue)
- confidential_issue = create(:issue, confidential: true)
+ create(:issue, project: reusable_project)
+ confidential_issue = create(:issue, project: reusable_project, confidential: true)
expect(described_class.confidential_only).to eq([confidential_issue])
end
end
describe '.by_project_id_and_iid' do
- let_it_be(:issue_a) { create(:issue) }
+ let_it_be(:issue_a) { create(:issue, project: reusable_project) }
let_it_be(:issue_b) { create(:issue, iid: issue_a.iid) }
let_it_be(:issue_c) { create(:issue, project: issue_a.project) }
let_it_be(:issue_d) { create(:issue, project: issue_a.project) }
@@ -1050,8 +1076,8 @@ RSpec.describe Issue do
describe '.service_desk' do
it 'returns the service desk issue' do
- service_desk_issue = create(:issue, author: ::User.support_bot)
- regular_issue = create(:issue)
+ service_desk_issue = create(:issue, project: reusable_project, author: ::User.support_bot)
+ regular_issue = create(:issue, project: reusable_project)
expect(described_class.service_desk).to include(service_desk_issue)
expect(described_class.service_desk).not_to include(regular_issue)
@@ -1064,7 +1090,7 @@ RSpec.describe Issue do
describe "#labels_hook_attrs" do
let(:label) { create(:label) }
- let(:issue) { create(:labeled_issue, labels: [label]) }
+ let(:issue) { create(:labeled_issue, project: reusable_project, labels: [label]) }
it "returns a list of label hook attributes" do
expect(issue.labels_hook_attrs).to eq([label.hook_attrs])
@@ -1073,7 +1099,7 @@ RSpec.describe Issue do
context "relative positioning" do
it_behaves_like "a class that supports relative positioning" do
- let_it_be(:project) { create(:project) }
+ let_it_be(:project) { reusable_project }
let(:factory) { :issue }
let(:default_params) { { project: project } }
end
@@ -1083,7 +1109,7 @@ RSpec.describe Issue do
describe "#previous_updated_at" do
let_it_be(:updated_at) { Time.zone.local(2012, 01, 06) }
- let_it_be(:issue) { create(:issue, updated_at: updated_at) }
+ let_it_be(:issue) { create(:issue, project: reusable_project, updated_at: updated_at) }
it 'returns updated_at value if updated_at did not change at all' do
allow(issue).to receive(:previous_changes).and_return({})
@@ -1121,7 +1147,7 @@ RSpec.describe Issue do
end
describe 'current designs' do
- let(:issue) { create(:issue) }
+ let(:issue) { create(:issue, project: reusable_project) }
subject { issue.designs.current }
@@ -1213,4 +1239,12 @@ RSpec.describe Issue do
expect(issue.allows_reviewers?).to be(false)
end
end
+
+ describe '#issue_type_supports?' do
+ let_it_be(:issue) { create(:issue) }
+
+ it 'raises error when feature is invalid' do
+ expect { issue.issue_type_supports?(:unkown_feature) }.to raise_error(ArgumentError)
+ end
+ end
end
diff --git a/spec/models/iteration_spec.rb b/spec/models/iteration_spec.rb
index 19a1625aad3..e7ec5de0ef1 100644
--- a/spec/models/iteration_spec.rb
+++ b/spec/models/iteration_spec.rb
@@ -119,7 +119,7 @@ RSpec.describe Iteration do
let(:start_date) { 5.days.from_now }
let(:due_date) { 6.days.from_now }
- shared_examples_for 'overlapping dates' do
+ shared_examples_for 'overlapping dates' do |skip_constraint_test: false|
context 'when start_date is in range' do
let(:start_date) { 5.days.from_now }
let(:due_date) { 3.weeks.from_now }
@@ -129,9 +129,11 @@ RSpec.describe Iteration do
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
end
- it 'is not valid even if forced' do
- subject.validate # to generate iid/etc
- expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
+ unless skip_constraint_test
+ it 'is not valid even if forced' do
+ subject.validate # to generate iid/etc
+ expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
+ end
end
end
@@ -144,9 +146,11 @@ RSpec.describe Iteration do
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
end
- it 'is not valid even if forced' do
- subject.validate # to generate iid/etc
- expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
+ unless skip_constraint_test
+ it 'is not valid even if forced' do
+ subject.validate # to generate iid/etc
+ expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
+ end
end
end
@@ -156,9 +160,11 @@ RSpec.describe Iteration do
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
end
- it 'is not valid even if forced' do
- subject.validate # to generate iid/etc
- expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
+ unless skip_constraint_test
+ it 'is not valid even if forced' do
+ subject.validate # to generate iid/etc
+ expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
+ end
end
end
end
@@ -177,6 +183,14 @@ RSpec.describe Iteration do
expect { subject.save! }.not_to raise_exception
end
end
+
+ context 'sub-group' do
+ let(:subgroup) { create(:group, parent: group) }
+
+ subject { build(:iteration, group: subgroup, start_date: start_date, due_date: due_date) }
+
+ it_behaves_like 'overlapping dates', skip_constraint_test: true
+ end
end
context 'project' do
@@ -210,6 +224,17 @@ RSpec.describe Iteration do
end
end
end
+
+ context 'project in a group' do
+ let_it_be(:project) { create(:project, group: create(:group)) }
+ let_it_be(:existing_iteration) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
+
+ subject { build(:iteration, :skip_project_validation, project: project, start_date: start_date, due_date: due_date) }
+
+ it_behaves_like 'overlapping dates' do
+ let(:constraint_name) { 'iteration_start_and_due_daterange_project_id_constraint' }
+ end
+ end
end
end
diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb
index 90950d93db4..118b1492cd6 100644
--- a/spec/models/member_spec.rb
+++ b/spec/models/member_spec.rb
@@ -212,6 +212,16 @@ RSpec.describe Member do
it { expect(described_class.non_request).to include @accepted_request_member }
end
+ describe '.not_accepted_invitations' do
+ let_it_be(:not_accepted_invitation) { create(:project_member, :invited) }
+ let_it_be(:accepted_invitation) { create(:project_member, :invited, invite_accepted_at: Date.today) }
+
+ subject { described_class.not_accepted_invitations }
+
+ it { is_expected.to include(not_accepted_invitation) }
+ it { is_expected.not_to include(accepted_invitation) }
+ end
+
describe '.not_accepted_invitations_by_user' do
let(:invited_by_user) { create(:project_member, :invited, project: project, created_by: @owner_user) }
@@ -225,6 +235,33 @@ RSpec.describe Member do
it { is_expected.to contain_exactly(invited_by_user) }
end
+ describe '.not_expired' do
+ let_it_be(:expiring_yesterday) { create(:group_member, expires_at: 1.day.from_now) }
+ let_it_be(:expiring_today) { create(:group_member, expires_at: 2.days.from_now) }
+ let_it_be(:expiring_tomorrow) { create(:group_member, expires_at: 3.days.from_now) }
+ let_it_be(:not_expiring) { create(:group_member) }
+
+ subject { described_class.not_expired }
+
+ around do |example|
+ travel_to(2.days.from_now) { example.run }
+ end
+
+ it { is_expected.not_to include(expiring_yesterday, expiring_today) }
+ it { is_expected.to include(expiring_tomorrow, not_expiring) }
+ end
+
+ describe '.last_ten_days_excluding_today' do
+ let_it_be(:created_today) { create(:group_member, created_at: Date.today.beginning_of_day) }
+ let_it_be(:created_yesterday) { create(:group_member, created_at: 1.day.ago) }
+ let_it_be(:created_eleven_days_ago) { create(:group_member, created_at: 11.days.ago) }
+
+ subject { described_class.last_ten_days_excluding_today }
+
+ it { is_expected.to include(created_yesterday) }
+ it { is_expected.not_to include(created_today, created_eleven_days_ago) }
+ end
+
describe '.search_invite_email' do
it 'returns only members the matching e-mail' do
create(:group_member, :invited)
@@ -683,6 +720,45 @@ RSpec.describe Member do
end
end
+ describe '#send_invitation_reminder' do
+ subject { member.send_invitation_reminder(0) }
+
+ context 'an invited group member' do
+ let!(:member) { create(:group_member, :invited) }
+
+ it 'sends a reminder' do
+ expect_any_instance_of(NotificationService).to receive(:invite_member_reminder).with(member, member.raw_invite_token, 0)
+
+ subject
+ end
+ end
+
+ context 'an invited member without a raw invite token set' do
+ let!(:member) { create(:group_member, :invited) }
+
+ before do
+ member.instance_variable_set(:@raw_invite_token, nil)
+ allow_any_instance_of(NotificationService).to receive(:invite_member_reminder)
+ end
+
+ it 'generates a new token' do
+ expect(member).to receive(:generate_invite_token!)
+
+ subject
+ end
+ end
+
+ context 'an uninvited member' do
+ let!(:member) { create(:group_member) }
+
+ it 'does not send a reminder' do
+ expect_any_instance_of(NotificationService).not_to receive(:invite_member_reminder)
+
+ subject
+ end
+ end
+ end
+
describe "#invite_to_unknown_user?" do
subject { member.invite_to_unknown_user? }
diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb
index 2c64201e84d..4ce2b5f0f48 100644
--- a/spec/models/merge_request_diff_spec.rb
+++ b/spec/models/merge_request_diff_spec.rb
@@ -180,6 +180,17 @@ RSpec.describe MergeRequestDiff do
expect(diff.external_diff_store).to eq(file_store)
end
+ it 'migrates a nil diff file' do
+ expect(diff).not_to be_stored_externally
+ MergeRequestDiffFile.where(merge_request_diff_id: diff.id).update_all(diff: nil)
+
+ stub_external_diffs_setting(enabled: true)
+
+ diff.migrate_files_to_external_storage!
+
+ expect(diff).to be_stored_externally
+ end
+
it 'safely handles a transaction error when migrating to external storage' do
expect(diff).not_to be_stored_externally
expect(diff.external_diff).not_to be_exists
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 98f709a0610..5fde9b8661d 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -2358,48 +2358,43 @@ RSpec.describe MergeRequest, factory_default: :keep do
end
end
- context 'when state event tracking is disabled' do
+ context 'when no metrics or merge event exists' do
+ let(:user) { create(:user) }
+ let(:merge_request) { create(:merge_request, :merged) }
+
before do
- stub_feature_flags(track_resource_state_change_events: false)
+ merge_request.metrics.destroy!
end
- context 'when merging note is persisted, but no metrics or merge event exists' do
- let(:user) { create(:user) }
- let(:merge_request) { create(:merge_request, :merged) }
-
+ context 'when resource event for the merge exists' do
before do
- merge_request.metrics.destroy!
-
SystemNoteService.change_status(merge_request,
merge_request.target_project,
user,
merge_request.state, nil)
end
- it 'returns merging note creation date' do
+ it 'returns the resource event creation date' do
expect(merge_request.reload.metrics).to be_nil
expect(merge_request.merge_event).to be_nil
- expect(merge_request.notes.count).to eq(1)
- expect(merge_request.merged_at).to eq(merge_request.notes.first.created_at)
+ expect(merge_request.resource_state_events.count).to eq(1)
+ expect(merge_request.merged_at).to eq(merge_request.resource_state_events.first.created_at)
end
end
- end
-
- context 'when state event tracking is enabled' do
- let(:user) { create(:user) }
- let(:merge_request) { create(:merge_request, :merged) }
- before do
- merge_request.metrics.destroy!
-
- SystemNoteService.change_status(merge_request,
- merge_request.target_project,
- user,
- merge_request.state, nil)
- end
+ context 'when system note for the merge exists' do
+ before do
+ # We do not create these system notes anymore but we need this to work for existing MRs
+ # that used system notes instead of resource state events
+ create(:note, :system, noteable: merge_request, note: 'merged')
+ end
- it 'does not create a system note' do
- expect(merge_request.notes).to be_empty
+ it 'returns the merging note creation date' do
+ expect(merge_request.reload.metrics).to be_nil
+ expect(merge_request.merge_event).to be_nil
+ expect(merge_request.notes.count).to eq(1)
+ expect(merge_request.merged_at).to eq(merge_request.notes.first.created_at)
+ end
end
end
end
@@ -4261,24 +4256,6 @@ RSpec.describe MergeRequest, factory_default: :keep do
end
end
- describe '#allows_reviewers?' do
- it 'returns false without merge_request_reviewers feature' do
- stub_feature_flags(merge_request_reviewers: false)
-
- merge_request = build_stubbed(:merge_request)
-
- expect(merge_request.allows_reviewers?).to be(false)
- end
-
- it 'returns true with merge_request_reviewers feature' do
- stub_feature_flags(merge_request_reviewers: true)
-
- merge_request = build_stubbed(:merge_request)
-
- expect(merge_request.allows_reviewers?).to be(true)
- end
- end
-
describe '#merge_ref_head' do
let(:merge_request) { create(:merge_request) }
@@ -4304,4 +4281,36 @@ RSpec.describe MergeRequest, factory_default: :keep do
end
end
end
+
+ describe '#allows_reviewers?' do
+ it 'returns false without merge_request_reviewers feature' do
+ stub_feature_flags(merge_request_reviewers: false)
+
+ merge_request = build_stubbed(:merge_request)
+
+ expect(merge_request.allows_reviewers?).to be(false)
+ end
+
+ it 'returns true with merge_request_reviewers feature' do
+ stub_feature_flags(merge_request_reviewers: true)
+
+ merge_request = build_stubbed(:merge_request)
+
+ expect(merge_request.allows_reviewers?).to be(true)
+ end
+ end
+
+ describe '#update_and_mark_in_progress_merge_commit_sha' do
+ let(:ref) { subject.target_project.repository.commit.id }
+
+ before do
+ expect(subject.target_project).to receive(:mark_primary_write_location)
+ end
+
+ it 'updates commit ID' do
+ expect { subject.update_and_mark_in_progress_merge_commit_sha(ref) }
+ .to change { subject.in_progress_merge_commit_sha }
+ .from(nil).to(ref)
+ end
+ end
end
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index ca1f06370d4..2e607639e1b 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -1320,4 +1320,140 @@ RSpec.describe Namespace do
end
end
end
+
+ describe '#shared_runners_setting' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:shared_runners_enabled, :allow_descendants_override_disabled_shared_runners, :shared_runners_setting) do
+ true | true | 'enabled'
+ true | false | 'enabled'
+ false | true | 'disabled_with_override'
+ false | false | 'disabled_and_unoverridable'
+ end
+
+ with_them do
+ let(:namespace) { build(:namespace, shared_runners_enabled: shared_runners_enabled, allow_descendants_override_disabled_shared_runners: allow_descendants_override_disabled_shared_runners)}
+
+ it 'returns the result' do
+ expect(namespace.shared_runners_setting).to eq(shared_runners_setting)
+ end
+ end
+ end
+
+ describe '#shared_runners_setting_higher_than?' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:shared_runners_enabled, :allow_descendants_override_disabled_shared_runners, :other_setting, :result) do
+ true | true | 'enabled' | false
+ true | true | 'disabled_with_override' | true
+ true | true | 'disabled_and_unoverridable' | true
+ false | true | 'enabled' | false
+ false | true | 'disabled_with_override' | false
+ false | true | 'disabled_and_unoverridable' | true
+ false | false | 'enabled' | false
+ false | false | 'disabled_with_override' | false
+ false | false | 'disabled_and_unoverridable' | false
+ end
+
+ with_them do
+ let(:namespace) { build(:namespace, shared_runners_enabled: shared_runners_enabled, allow_descendants_override_disabled_shared_runners: allow_descendants_override_disabled_shared_runners)}
+
+ it 'returns the result' do
+ expect(namespace.shared_runners_setting_higher_than?(other_setting)).to eq(result)
+ end
+ end
+ end
+
+ describe 'validation #changing_shared_runners_enabled_is_allowed' do
+ context 'without a parent' do
+ let(:namespace) { build(:namespace, shared_runners_enabled: true) }
+
+ it 'is valid' do
+ expect(namespace).to be_valid
+ end
+ end
+
+ context 'with a parent' do
+ context 'when parent has shared runners disabled' do
+ let(:parent) { create(:namespace, :shared_runners_disabled) }
+ let(:sub_namespace) { build(:namespace, shared_runners_enabled: true, parent_id: parent.id) }
+
+ it 'is invalid' do
+ expect(sub_namespace).to be_invalid
+ expect(sub_namespace.errors[:shared_runners_enabled]).to include('cannot be enabled because parent group has shared Runners disabled')
+ end
+ end
+
+ context 'when parent has shared runners disabled but allows override' do
+ let(:parent) { create(:namespace, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners) }
+ let(:sub_namespace) { build(:namespace, shared_runners_enabled: true, parent_id: parent.id) }
+
+ it 'is valid' do
+ expect(sub_namespace).to be_valid
+ end
+ end
+
+ context 'when parent has shared runners enabled' do
+ let(:parent) { create(:namespace, shared_runners_enabled: true) }
+ let(:sub_namespace) { build(:namespace, shared_runners_enabled: true, parent_id: parent.id) }
+
+ it 'is valid' do
+ expect(sub_namespace).to be_valid
+ end
+ end
+ end
+ end
+
+ describe 'validation #changing_allow_descendants_override_disabled_shared_runners_is_allowed' do
+ context 'without a parent' do
+ context 'with shared runners disabled' do
+ let(:namespace) { build(:namespace, :allow_descendants_override_disabled_shared_runners, :shared_runners_disabled) }
+
+ it 'is valid' do
+ expect(namespace).to be_valid
+ end
+ end
+
+ context 'with shared runners enabled' do
+ let(:namespace) { create(:namespace) }
+
+ it 'is invalid' do
+ namespace.allow_descendants_override_disabled_shared_runners = true
+
+ expect(namespace).to be_invalid
+ expect(namespace.errors[:allow_descendants_override_disabled_shared_runners]).to include('cannot be changed if shared runners are enabled')
+ end
+ end
+ end
+
+ context 'with a parent' do
+ context 'when parent does not allow shared runners' do
+ let(:parent) { create(:namespace, :shared_runners_disabled) }
+ let(:sub_namespace) { build(:namespace, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent_id: parent.id) }
+
+ it 'is invalid' do
+ expect(sub_namespace).to be_invalid
+ expect(sub_namespace.errors[:allow_descendants_override_disabled_shared_runners]).to include('cannot be enabled because parent group does not allow it')
+ end
+ end
+
+ context 'when parent allows shared runners and setting to true' do
+ let(:parent) { create(:namespace, shared_runners_enabled: true) }
+ let(:sub_namespace) { build(:namespace, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent_id: parent.id) }
+
+ it 'is valid' do
+ expect(sub_namespace).to be_valid
+ end
+ end
+
+ context 'when parent allows shared runners and setting to false' do
+ let(:parent) { create(:namespace, shared_runners_enabled: true) }
+ let(:sub_namespace) { build(:namespace, :shared_runners_disabled, allow_descendants_override_disabled_shared_runners: false, parent_id: parent.id) }
+
+ it 'is valid' do
+ expect(sub_namespace).to be_valid
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/notification_setting_spec.rb b/spec/models/notification_setting_spec.rb
index 0f765d6b09b..bc50e2af373 100644
--- a/spec/models/notification_setting_spec.rb
+++ b/spec/models/notification_setting_spec.rb
@@ -175,6 +175,7 @@ RSpec.describe NotificationSetting do
:reopen_merge_request,
:close_merge_request,
:reassign_merge_request,
+ :change_reviewer_merge_request,
:merge_merge_request,
:failed_pipeline,
:success_pipeline,
diff --git a/spec/models/operations/feature_flag_spec.rb b/spec/models/operations/feature_flag_spec.rb
index db432e73355..b4e941f2856 100644
--- a/spec/models/operations/feature_flag_spec.rb
+++ b/spec/models/operations/feature_flag_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe Operations::FeatureFlag do
context 'a version 1 feature flag' do
it 'is valid if associated with Operations::FeatureFlagScope models' do
project = create(:project)
- feature_flag = described_class.create({ name: 'test', project: project, version: 1,
+ feature_flag = described_class.create!({ name: 'test', project: project, version: 1,
scopes_attributes: [{ environment_scope: '*', active: false }] })
expect(feature_flag).to be_valid
@@ -33,9 +33,10 @@ RSpec.describe Operations::FeatureFlag do
it 'is invalid if associated with Operations::FeatureFlags::Strategy models' do
project = create(:project)
- feature_flag = described_class.create({ name: 'test', project: project, version: 1,
+ feature_flag = described_class.new({ name: 'test', project: project, version: 1,
strategies_attributes: [{ name: 'default', parameters: {} }] })
+ expect(feature_flag.valid?).to eq(false)
expect(feature_flag.errors.messages).to eq({
version_associations: ["version 1 feature flags may not have strategies"]
})
@@ -45,9 +46,10 @@ RSpec.describe Operations::FeatureFlag do
context 'a version 2 feature flag' do
it 'is invalid if associated with Operations::FeatureFlagScope models' do
project = create(:project)
- feature_flag = described_class.create({ name: 'test', project: project, version: 2,
+ feature_flag = described_class.new({ name: 'test', project: project, version: 2,
scopes_attributes: [{ environment_scope: '*', active: false }] })
+ expect(feature_flag.valid?).to eq(false)
expect(feature_flag.errors.messages).to eq({
version_associations: ["version 2 feature flags may not have scopes"]
})
@@ -55,7 +57,7 @@ RSpec.describe Operations::FeatureFlag do
it 'is valid if associated with Operations::FeatureFlags::Strategy models' do
project = create(:project)
- feature_flag = described_class.create({ name: 'test', project: project, version: 2,
+ feature_flag = described_class.create!({ name: 'test', project: project, version: 2,
strategies_attributes: [{ name: 'default', parameters: {} }] })
expect(feature_flag).to be_valid
@@ -75,7 +77,7 @@ RSpec.describe Operations::FeatureFlag do
it 'defaults to 1 if unspecified' do
project = create(:project)
- feature_flag = described_class.create(name: 'my_flag', project: project, active: true)
+ feature_flag = described_class.create!(name: 'my_flag', project: project, active: true)
expect(feature_flag).to be_valid
expect(feature_flag.version_before_type_cast).to eq(1)
@@ -113,14 +115,14 @@ RSpec.describe Operations::FeatureFlag do
context 'with a version 1 feature flag' do
it 'creates a default scope' do
- feature_flag = described_class.create({ name: 'test', project: project, scopes_attributes: [], version: 1 })
+ feature_flag = described_class.create!({ name: 'test', project: project, scopes_attributes: [], version: 1 })
expect(feature_flag.scopes.count).to eq(1)
expect(feature_flag.scopes.first.environment_scope).to eq('*')
end
it 'allows specifying the default scope in the parameters' do
- feature_flag = described_class.create({ name: 'test', project: project,
+ feature_flag = described_class.create!({ name: 'test', project: project,
scopes_attributes: [{ environment_scope: '*', active: false },
{ environment_scope: 'review/*', active: true }], version: 1 })
@@ -131,7 +133,7 @@ RSpec.describe Operations::FeatureFlag do
context 'with a version 2 feature flag' do
it 'does not create a default scope' do
- feature_flag = described_class.create({ name: 'test', project: project, scopes_attributes: [], version: 2 })
+ feature_flag = described_class.create!({ name: 'test', project: project, scopes_attributes: [], version: 2 })
expect(feature_flag.scopes).to eq([])
end
diff --git a/spec/models/operations/feature_flags/strategy_spec.rb b/spec/models/operations/feature_flags/strategy_spec.rb
index 04e3ef26e9d..0ecb49e75f3 100644
--- a/spec/models/operations/feature_flags/strategy_spec.rb
+++ b/spec/models/operations/feature_flags/strategy_spec.rb
@@ -4,11 +4,12 @@ require 'spec_helper'
RSpec.describe Operations::FeatureFlags::Strategy do
let_it_be(:project) { create(:project) }
+ let_it_be(:feature_flag) { create(:operations_feature_flag, project: project) }
describe 'validations' do
it do
is_expected.to validate_inclusion_of(:name)
- .in_array(%w[default gradualRolloutUserId userWithId gitlabUserList])
+ .in_array(%w[default gradualRolloutUserId flexibleRollout userWithId gitlabUserList])
.with_message('strategy name is invalid')
end
@@ -19,7 +20,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
end
with_them do
it 'skips parameters validation' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: invalid_name, parameters: { bad: 'params' })
@@ -36,7 +36,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
end
with_them do
it 'must have valid parameters for the strategy' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'gradualRolloutUserId', parameters: invalid_parameters)
@@ -45,7 +44,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
end
it 'allows the parameters in any order' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'gradualRolloutUserId',
parameters: { percentage: '10', groupId: 'mygroup' })
@@ -55,13 +53,12 @@ RSpec.describe Operations::FeatureFlags::Strategy do
describe 'percentage' do
where(:invalid_value) do
- [50, 40.0, { key: "value" }, "garbage", "00", "01", "101", "-1", "-10", "0100",
- "1000", "10.0", "5%", "25%", "100hi", "e100", "30m", " ", "\r\n", "\n", "\t",
- "\n10", "20\n", "\n100", "100\n", "\n ", nil]
+ [50, 40.0, { key: "value" }, "garbage", "101", "-1", "-10", "1000", "10.0", "5%", "25%",
+ "100hi", "e100", "30m", " ", "\r\n", "\n", "\t", "\n10", "20\n", "\n100", "100\n",
+ "\n ", nil]
end
with_them do
it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'gradualRolloutUserId',
parameters: { groupId: 'mygroup', percentage: invalid_value })
@@ -75,7 +72,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
end
with_them do
it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'gradualRolloutUserId',
parameters: { groupId: 'mygroup', percentage: valid_value })
@@ -92,7 +88,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
end
with_them do
it 'must be a string value of up to 32 lowercase characters' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'gradualRolloutUserId',
parameters: { groupId: invalid_value, percentage: '40' })
@@ -106,7 +101,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
end
with_them do
it 'must be a string value of up to 32 lowercase characters' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'gradualRolloutUserId',
parameters: { groupId: valid_value, percentage: '40' })
@@ -117,13 +111,132 @@ RSpec.describe Operations::FeatureFlags::Strategy do
end
end
+ context 'when the strategy name is flexibleRollout' do
+ valid_parameters = { rollout: '40', groupId: 'mygroup', stickiness: 'DEFAULT' }
+ where(invalid_parameters: [
+ nil,
+ {},
+ *valid_parameters.to_a.combination(1).to_a.map { |p| p.to_h },
+ *valid_parameters.to_a.combination(2).to_a.map { |p| p.to_h },
+ { **valid_parameters, userIds: '4' },
+ { **valid_parameters, extra: nil }
+ ])
+ with_them do
+ it 'must have valid parameters for the strategy' do
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'flexibleRollout',
+ parameters: invalid_parameters)
+
+ expect(strategy.errors[:parameters]).to eq(['parameters are invalid'])
+ end
+ end
+
+ [
+ [:rollout, '10'],
+ [:stickiness, 'DEFAULT'],
+ [:groupId, 'mygroup']
+ ].permutation(3).each do |parameters|
+ it "allows the parameters in the order #{parameters.map { |p| p.first }.join(', ')}" do
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'flexibleRollout',
+ parameters: Hash[parameters])
+
+ expect(strategy.errors[:parameters]).to be_empty
+ end
+ end
+
+ describe 'rollout' do
+ where(invalid_value: [50, 40.0, { key: "value" }, "garbage", "101", "-1", " ", "-10",
+ "1000", "10.0", "5%", "25%", "100hi", "e100", "30m", "\r\n",
+ "\n", "\t", "\n10", "20\n", "\n100", "100\n", "\n ", nil])
+ with_them do
+ it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do
+ parameters = { stickiness: 'DEFAULT', groupId: 'mygroup', rollout: invalid_value }
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'flexibleRollout',
+ parameters: parameters)
+
+ expect(strategy.errors[:parameters]).to eq([
+ 'rollout must be a string between 0 and 100 inclusive'
+ ])
+ end
+ end
+
+ where(valid_value: %w[0 1 10 38 100 93])
+ with_them do
+ it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do
+ parameters = { stickiness: 'DEFAULT', groupId: 'mygroup', rollout: valid_value }
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'flexibleRollout',
+ parameters: parameters)
+
+ expect(strategy.errors[:parameters]).to eq([])
+ end
+ end
+ end
+
+ describe 'groupId' do
+ where(invalid_value: [nil, 4, 50.0, {}, 'spaces bad', 'bad$', '%bad', '<bad', 'bad>',
+ '!bad', '.bad', 'Bad', 'bad1', "", " ", "b" * 33, "ba_d", "ba\nd"])
+ with_them do
+ it 'must be a string value of up to 32 lowercase characters' do
+ parameters = { stickiness: 'DEFAULT', groupId: invalid_value, rollout: '40' }
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'flexibleRollout',
+ parameters: parameters)
+
+ expect(strategy.errors[:parameters]).to eq(['groupId parameter is invalid'])
+ end
+ end
+
+ where(valid_value: ["somegroup", "anothergroup", "okay", "g", "a" * 32])
+ with_them do
+ it 'must be a string value of up to 32 lowercase characters' do
+ parameters = { stickiness: 'DEFAULT', groupId: valid_value, rollout: '40' }
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'flexibleRollout',
+ parameters: parameters)
+
+ expect(strategy.errors[:parameters]).to eq([])
+ end
+ end
+ end
+
+ describe 'stickiness' do
+ where(invalid_value: [nil, " ", "default", "DEFAULT\n", "UserId", "USER", "USERID "])
+ with_them do
+ it 'must be a string representing a supported stickiness setting' do
+ parameters = { stickiness: invalid_value, groupId: 'mygroup', rollout: '40' }
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'flexibleRollout',
+ parameters: parameters)
+
+ expect(strategy.errors[:parameters]).to eq([
+ 'stickiness parameter must be DEFAULT, USERID, SESSIONID, or RANDOM'
+ ])
+ end
+ end
+
+ where(valid_value: %w[DEFAULT USERID SESSIONID RANDOM])
+ with_them do
+ it 'must be a string representing a supported stickiness setting' do
+ parameters = { stickiness: valid_value, groupId: 'mygroup', rollout: '40' }
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'flexibleRollout',
+ parameters: parameters)
+
+ expect(strategy.errors[:parameters]).to eq([])
+ end
+ end
+ end
+ end
+
context 'when the strategy name is userWithId' do
where(:invalid_parameters) do
[nil, { userIds: 'sam', percentage: '40' }, { userIds: 'sam', some: 'param' }, { percentage: '40' }, {}]
end
with_them do
it 'must have valid parameters for the strategy' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'userWithId', parameters: invalid_parameters)
@@ -140,7 +253,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
end
with_them do
it 'is valid with a string of comma separated values' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'userWithId', parameters: { userIds: valid_value })
@@ -155,7 +267,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
end
with_them do
it 'is invalid' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'userWithId', parameters: { userIds: invalid_value })
@@ -173,7 +284,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
end
with_them do
it 'must be empty' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'default',
parameters: invalid_value)
@@ -183,7 +293,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
end
it 'must be empty' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'default',
parameters: {})
@@ -198,7 +307,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
end
with_them do
it 'must be empty' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'gitlabUserList',
parameters: invalid_value)
@@ -208,7 +316,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
end
it 'must be empty' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'gitlabUserList',
parameters: {})
@@ -221,7 +328,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
describe 'associations' do
context 'when name is gitlabUserList' do
it 'is valid when associated with a user list' do
- feature_flag = create(:operations_feature_flag, project: project)
user_list = create(:operations_feature_flag_user_list, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'gitlabUserList',
@@ -232,7 +338,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
end
it 'is invalid without a user list' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'gitlabUserList',
parameters: {})
@@ -242,7 +347,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
it 'is invalid when associated with a user list from another project' do
other_project = create(:project)
- feature_flag = create(:operations_feature_flag, project: project)
user_list = create(:operations_feature_flag_user_list, project: other_project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'gitlabUserList',
@@ -255,7 +359,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
context 'when name is default' do
it 'is invalid when associated with a user list' do
- feature_flag = create(:operations_feature_flag, project: project)
user_list = create(:operations_feature_flag_user_list, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'default',
@@ -266,7 +369,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
end
it 'is valid without a user list' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'default',
parameters: {})
@@ -277,7 +379,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
context 'when name is userWithId' do
it 'is invalid when associated with a user list' do
- feature_flag = create(:operations_feature_flag, project: project)
user_list = create(:operations_feature_flag_user_list, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'userWithId',
@@ -288,7 +389,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
end
it 'is valid without a user list' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'userWithId',
parameters: { userIds: 'user1' })
@@ -299,7 +399,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
context 'when name is gradualRolloutUserId' do
it 'is invalid when associated with a user list' do
- feature_flag = create(:operations_feature_flag, project: project)
user_list = create(:operations_feature_flag_user_list, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'gradualRolloutUserId',
@@ -310,7 +409,6 @@ RSpec.describe Operations::FeatureFlags::Strategy do
end
it 'is valid without a user list' do
- feature_flag = create(:operations_feature_flag, project: project)
strategy = described_class.create(feature_flag: feature_flag,
name: 'gradualRolloutUserId',
parameters: { groupId: 'default', percentage: '10' })
@@ -318,6 +416,30 @@ RSpec.describe Operations::FeatureFlags::Strategy do
expect(strategy.errors[:user_list]).to be_empty
end
end
+
+ context 'when name is flexibleRollout' do
+ it 'is invalid when associated with a user list' do
+ user_list = create(:operations_feature_flag_user_list, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'flexibleRollout',
+ user_list: user_list,
+ parameters: { groupId: 'default',
+ rollout: '10',
+ stickiness: 'DEFAULT' })
+
+ expect(strategy.errors[:user_list]).to eq(['must be blank'])
+ end
+
+ it 'is valid without a user list' do
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'flexibleRollout',
+ parameters: { groupId: 'default',
+ rollout: '10',
+ stickiness: 'DEFAULT' })
+
+ expect(strategy.errors[:user_list]).to be_empty
+ end
+ end
end
end
end
diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb
index ea1f75d04e7..6a3969802f3 100644
--- a/spec/models/packages/package_spec.rb
+++ b/spec/models/packages/package_spec.rb
@@ -108,6 +108,20 @@ RSpec.describe Packages::Package, type: :model do
it { is_expected.not_to allow_value('.foobar').for(:name) }
it { is_expected.not_to allow_value('%foo%bar').for(:name) }
end
+
+ context 'generic package' do
+ subject { build_stubbed(:generic_package) }
+
+ it { is_expected.to allow_value('123').for(:name) }
+ it { is_expected.to allow_value('foo').for(:name) }
+ it { is_expected.to allow_value('foo.bar.baz-2.0-20190901.47283-1').for(:name) }
+ it { is_expected.not_to allow_value('../../foo').for(:name) }
+ it { is_expected.not_to allow_value('..\..\foo').for(:name) }
+ it { is_expected.not_to allow_value('%2f%2e%2e%2f%2essh%2fauthorized_keys').for(:name) }
+ it { is_expected.not_to allow_value('$foo/bar').for(:name) }
+ it { is_expected.not_to allow_value('my file name').for(:name) }
+ it { is_expected.not_to allow_value('!!().for(:name)().for(:name)').for(:name) }
+ end
end
describe '#version' do
diff --git a/spec/models/plan_limits_spec.rb b/spec/models/plan_limits_spec.rb
index bc6398de9a4..67fb11f34e0 100644
--- a/spec/models/plan_limits_spec.rb
+++ b/spec/models/plan_limits_spec.rb
@@ -199,6 +199,7 @@ RSpec.describe PlanLimits do
ci_max_artifact_size_secret_detection
ci_max_artifact_size_requirements
ci_max_artifact_size_coverage_fuzzing
+ ci_max_artifact_size_api_fuzzing
]
end
diff --git a/spec/models/project_feature_usage_spec.rb b/spec/models/project_feature_usage_spec.rb
index 908b98ee9c2..d55d41fab85 100644
--- a/spec/models/project_feature_usage_spec.rb
+++ b/spec/models/project_feature_usage_spec.rb
@@ -48,7 +48,7 @@ RSpec.describe ProjectFeatureUsage, type: :model do
feature_usage.log_jira_dvcs_integration_usage
first_logged_at = feature_usage.jira_dvcs_cloud_last_sync_at
- Timecop.freeze(1.hour.from_now) do
+ travel_to(1.hour.from_now) do
ProjectFeatureUsage.new(project_id: project.id).log_jira_dvcs_integration_usage
end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index fe971832695..20b10be8c3b 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -72,6 +72,7 @@ RSpec.describe Project do
it { is_expected.to have_one(:last_event).class_name('Event') }
it { is_expected.to have_one(:forked_from_project).through(:fork_network_member) }
it { is_expected.to have_one(:auto_devops).class_name('ProjectAutoDevops') }
+ it { is_expected.to have_one(:tracing_setting).class_name('ProjectTracingSetting') }
it { is_expected.to have_one(:error_tracking_setting).class_name('ErrorTracking::ProjectErrorTrackingSetting') }
it { is_expected.to have_one(:project_setting) }
it { is_expected.to have_one(:alerting_setting).class_name('Alerting::ProjectAlertingSetting') }
@@ -116,6 +117,7 @@ RSpec.describe Project do
it { is_expected.to have_many(:prometheus_alert_events) }
it { is_expected.to have_many(:self_managed_prometheus_alert_events) }
it { is_expected.to have_many(:alert_management_alerts) }
+ it { is_expected.to have_many(:alert_management_http_integrations) }
it { is_expected.to have_many(:jira_imports) }
it { is_expected.to have_many(:metrics_users_starred_dashboards).inverse_of(:project) }
it { is_expected.to have_many(:repository_storage_moves) }
@@ -123,6 +125,7 @@ RSpec.describe Project do
it { is_expected.to have_many(:packages).class_name('Packages::Package') }
it { is_expected.to have_many(:package_files).class_name('Packages::PackageFile') }
it { is_expected.to have_many(:pipeline_artifacts) }
+ it { is_expected.to have_many(:terraform_states).class_name('Terraform::State').inverse_of(:project) }
# GitLab Pages
it { is_expected.to have_many(:pages_domains) }
@@ -5812,6 +5815,38 @@ RSpec.describe Project do
end
end
+ describe 'validation #changing_shared_runners_enabled_is_allowed' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:shared_runners_setting, :project_shared_runners_enabled, :valid_record) do
+ 'enabled' | true | true
+ 'enabled' | false | true
+ 'disabled_with_override' | true | true
+ 'disabled_with_override' | false | true
+ 'disabled_and_unoverridable' | true | false
+ 'disabled_and_unoverridable' | false | true
+ end
+
+ with_them do
+ let(:group) { create(:group) }
+ let(:project) { build(:project, namespace: group, shared_runners_enabled: project_shared_runners_enabled) }
+
+ before do
+ allow_next_found_instance_of(Group) do |group|
+ allow(group).to receive(:shared_runners_setting).and_return(shared_runners_setting)
+ end
+ end
+
+ it 'validates the configuration' do
+ expect(project.valid?).to eq(valid_record)
+
+ unless valid_record
+ expect(project.errors[:shared_runners_enabled]).to contain_exactly('cannot be enabled because parent group does not allow it')
+ end
+ end
+ end
+ end
+
describe '#mark_pages_as_deployed' do
let(:project) { create(:project) }
let(:artifacts_archive) { create(:ci_job_artifact, project: project) }
diff --git a/spec/models/project_statistics_spec.rb b/spec/models/project_statistics_spec.rb
index 383fabcfffb..9f40dbb3401 100644
--- a/spec/models/project_statistics_spec.rb
+++ b/spec/models/project_statistics_spec.rb
@@ -201,6 +201,23 @@ RSpec.describe ProjectStatistics do
statistics.refresh!(only: [:commit_count])
end
end
+
+ context 'when the database is read-only' do
+ it 'does nothing' do
+ allow(Gitlab::Database).to receive(:read_only?) { true }
+
+ expect(statistics).not_to receive(:update_commit_count)
+ expect(statistics).not_to receive(:update_repository_size)
+ expect(statistics).not_to receive(:update_wiki_size)
+ expect(statistics).not_to receive(:update_lfs_objects_size)
+ expect(statistics).not_to receive(:update_snippets_size)
+ expect(statistics).not_to receive(:save!)
+ expect(Namespaces::ScheduleAggregationWorker)
+ .not_to receive(:perform_async)
+
+ statistics.refresh!
+ end
+ end
end
describe '#update_commit_count' do
@@ -324,22 +341,51 @@ RSpec.describe ProjectStatistics do
describe '.increment_statistic' do
shared_examples 'a statistic that increases storage_size' do
it 'increases the statistic by that amount' do
- expect { described_class.increment_statistic(project.id, stat, 13) }
+ expect { described_class.increment_statistic(project, stat, 13) }
.to change { statistics.reload.send(stat) || 0 }
.by(13)
end
it 'increases also storage size by that amount' do
- expect { described_class.increment_statistic(project.id, stat, 20) }
+ expect { described_class.increment_statistic(project, stat, 20) }
.to change { statistics.reload.storage_size }
.by(20)
end
end
+ shared_examples 'a statistic that increases storage_size asynchronously' do
+ it 'stores the increment temporarily in Redis', :clean_gitlab_redis_shared_state do
+ described_class.increment_statistic(project, stat, 13)
+
+ Gitlab::Redis::SharedState.with do |redis|
+ increment = redis.get(statistics.counter_key(stat))
+ expect(increment.to_i).to eq(13)
+ end
+ end
+
+ it 'schedules a worker to update the statistic and storage_size async' do
+ expect(FlushCounterIncrementsWorker)
+ .to receive(:perform_in)
+ .with(CounterAttribute::WORKER_DELAY, described_class.name, statistics.id, stat)
+
+ expect(FlushCounterIncrementsWorker)
+ .to receive(:perform_in)
+ .with(CounterAttribute::WORKER_DELAY, described_class.name, statistics.id, :storage_size)
+
+ described_class.increment_statistic(project, stat, 20)
+ end
+ end
+
context 'when adjusting :build_artifacts_size' do
let(:stat) { :build_artifacts_size }
- it_behaves_like 'a statistic that increases storage_size'
+ it_behaves_like 'a statistic that increases storage_size asynchronously'
+
+ it_behaves_like 'a statistic that increases storage_size' do
+ before do
+ stub_feature_flags(efficient_counter_attribute: false)
+ end
+ end
end
context 'when adjusting :pipeline_artifacts_size' do
diff --git a/spec/models/project_tracing_setting_spec.rb b/spec/models/project_tracing_setting_spec.rb
new file mode 100644
index 00000000000..a7e4e557b25
--- /dev/null
+++ b/spec/models/project_tracing_setting_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ProjectTracingSetting do
+ describe '#external_url' do
+ let_it_be(:project) { create(:project) }
+
+ let(:tracing_setting) { project.build_tracing_setting }
+
+ describe 'Validations' do
+ describe 'external_url' do
+ it 'accepts a valid url' do
+ tracing_setting.external_url = 'https://gitlab.com'
+
+ expect(tracing_setting).to be_valid
+ end
+
+ it 'fails with an invalid url' do
+ tracing_setting.external_url = 'gitlab.com'
+
+ expect(tracing_setting).to be_invalid
+ end
+
+ it 'fails with a blank string' do
+ tracing_setting.external_url = nil
+
+ expect(tracing_setting).to be_invalid
+ end
+
+ it 'sanitizes the url' do
+ tracing_setting.external_url = %{https://replaceme.com/'><script>alert(document.cookie)</script>}
+
+ expect(tracing_setting).to be_valid
+ expect(tracing_setting.external_url).to eq(%{https://replaceme.com/'&gt;})
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/resource_label_event_spec.rb b/spec/models/resource_label_event_spec.rb
index 960db31d488..da1fe70c891 100644
--- a/spec/models/resource_label_event_spec.rb
+++ b/spec/models/resource_label_event_spec.rb
@@ -50,26 +50,36 @@ RSpec.describe ResourceLabelEvent, type: :model do
end
end
- describe '#expire_etag_cache' do
- def expect_expiration(issue)
- expect_next_instance_of(Gitlab::EtagCaching::Store) do |instance|
- expect(instance).to receive(:touch)
- .with("/#{issue.project.namespace.to_param}/#{issue.project.to_param}/noteable/issue/#{issue.id}/notes")
+ context 'callbacks' do
+ describe '#usage_metrics' do
+ it 'tracks changed labels' do
+ expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).to receive(:track_issue_label_changed_action)
+
+ subject.save!
end
end
- it 'expires resource note etag cache on event save' do
- expect_expiration(subject.issuable)
+ describe '#expire_etag_cache' do
+ def expect_expiration(issue)
+ expect_next_instance_of(Gitlab::EtagCaching::Store) do |instance|
+ expect(instance).to receive(:touch)
+ .with("/#{issue.project.namespace.to_param}/#{issue.project.to_param}/noteable/issue/#{issue.id}/notes")
+ end
+ end
- subject.save!
- end
+ it 'expires resource note etag cache on event save' do
+ expect_expiration(subject.issuable)
- it 'expires resource note etag cache on event destroy' do
- subject.save!
+ subject.save!
+ end
+
+ it 'expires resource note etag cache on event destroy' do
+ subject.save!
- expect_expiration(subject.issuable)
+ expect_expiration(subject.issuable)
- subject.destroy!
+ subject.destroy!
+ end
end
end
diff --git a/spec/models/resource_milestone_event_spec.rb b/spec/models/resource_milestone_event_spec.rb
index 0a5292b2d16..c1761e5b2e8 100644
--- a/spec/models/resource_milestone_event_spec.rb
+++ b/spec/models/resource_milestone_event_spec.rb
@@ -11,6 +11,7 @@ RSpec.describe ResourceMilestoneEvent, type: :model do
it_behaves_like 'timebox resource event validations'
it_behaves_like 'timebox resource event states'
it_behaves_like 'timebox resource event actions'
+ it_behaves_like 'timebox resource tracks issue metrics', :milestone
describe 'associations' do
it { is_expected.to belong_to(:milestone) }
diff --git a/spec/models/resource_state_event_spec.rb b/spec/models/resource_state_event_spec.rb
index fc6575b2db8..b8a93bdbe3b 100644
--- a/spec/models/resource_state_event_spec.rb
+++ b/spec/models/resource_state_event_spec.rb
@@ -39,4 +39,20 @@ RSpec.describe ResourceStateEvent, type: :model do
end
end
end
+
+ context 'callbacks' do
+ describe '#usage_metrics' do
+ it 'tracks closed issues' do
+ expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).to receive(:track_issue_closed_action)
+
+ create(described_class.name.underscore.to_sym, issue: issue, state: described_class.states[:closed])
+ end
+
+ it 'tracks reopened issues' do
+ expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).to receive(:track_issue_reopened_action)
+
+ create(described_class.name.underscore.to_sym, issue: issue, state: described_class.states[:reopened])
+ end
+ end
+ end
end
diff --git a/spec/models/resource_weight_event_spec.rb b/spec/models/resource_weight_event_spec.rb
index 8a37883d933..170b302e094 100644
--- a/spec/models/resource_weight_event_spec.rb
+++ b/spec/models/resource_weight_event_spec.rb
@@ -73,4 +73,14 @@ RSpec.describe ResourceWeightEvent, type: :model do
expect(event.discussion_id).to eq('73d167c478')
end
end
+
+ context 'callbacks' do
+ describe '#usage_metrics' do
+ it 'tracks changed weights' do
+ expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).to receive(:track_issue_weight_changed_action).with(author: user1)
+
+ create(:resource_weight_event, issue: issue1, user: user1)
+ end
+ end
+ end
end
diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb
index 32e2012e284..42a12a98d63 100644
--- a/spec/models/service_spec.rb
+++ b/spec/models/service_spec.rb
@@ -320,18 +320,28 @@ RSpec.describe Service do
end
it 'sets service to inactive' do
- service = described_class.build_from_integration(project.id, integration)
+ service = described_class.build_from_integration(integration, project_id: project.id)
expect(service).to be_valid
expect(service.active).to be false
end
end
- context 'when integration is an instance' do
+ context 'when integration is an instance-level integration' do
let(:integration) { create(:jira_service, :instance) }
it 'sets inherit_from_id from integration' do
- service = described_class.build_from_integration(project.id, integration)
+ service = described_class.build_from_integration(integration, project_id: project.id)
+
+ expect(service.inherit_from_id).to eq(integration.id)
+ end
+ end
+
+ context 'when integration is a group-level integration' do
+ let(:integration) { create(:jira_service, group: group, project: nil) }
+
+ it 'sets inherit_from_id from integration' do
+ service = described_class.build_from_integration(integration, project_id: project.id)
expect(service.inherit_from_id).to eq(integration.id)
end
@@ -350,8 +360,22 @@ RSpec.describe Service do
end
shared_examples 'service creation from an integration' do
- it 'creates a correct service' do
- service = described_class.build_from_integration(project.id, integration)
+ it 'creates a correct service for a project integration' do
+ service = described_class.build_from_integration(integration, project_id: project.id)
+
+ expect(service).to be_active
+ expect(service.url).to eq(url)
+ expect(service.api_url).to eq(api_url)
+ expect(service.username).to eq(username)
+ expect(service.password).to eq(password)
+ expect(service.template).to eq(false)
+ expect(service.instance).to eq(false)
+ expect(service.project).to eq(project)
+ expect(service.group).to eq(nil)
+ end
+
+ it 'creates a correct service for a group integration' do
+ service = described_class.build_from_integration(integration, group_id: group.id)
expect(service).to be_active
expect(service.url).to eq(url)
@@ -360,6 +384,8 @@ RSpec.describe Service do
expect(service.password).to eq(password)
expect(service.template).to eq(false)
expect(service.instance).to eq(false)
+ expect(service.project).to eq(nil)
+ expect(service.group).to eq(group)
end
end
diff --git a/spec/models/snippet_input_action_spec.rb b/spec/models/snippet_input_action_spec.rb
index 43dc70bea98..0a9ab47f2f0 100644
--- a/spec/models/snippet_input_action_spec.rb
+++ b/spec/models/snippet_input_action_spec.rb
@@ -67,7 +67,7 @@ RSpec.describe SnippetInputAction do
let(:options) { { action: action, file_path: file_path, content: content, previous_path: previous_path } }
let(:expected_options) { options.merge(action: action.to_sym) }
- subject { described_class.new(options).to_commit_action }
+ subject { described_class.new(**options).to_commit_action }
it 'transforms attributes to commit action' do
expect(subject).to eq(expected_options)
diff --git a/spec/models/snippet_repository_spec.rb b/spec/models/snippet_repository_spec.rb
index 95602a4de0e..30690ce2fa3 100644
--- a/spec/models/snippet_repository_spec.rb
+++ b/spec/models/snippet_repository_spec.rb
@@ -35,7 +35,7 @@ RSpec.describe SnippetRepository do
it 'returns nil when files argument is empty' do
expect(snippet.repository).not_to receive(:multi_action)
- operation = snippet_repository.multi_files_action(user, [], commit_opts)
+ operation = snippet_repository.multi_files_action(user, [], **commit_opts)
expect(operation).to be_nil
end
@@ -43,7 +43,7 @@ RSpec.describe SnippetRepository do
it 'returns nil when files argument is nil' do
expect(snippet.repository).not_to receive(:multi_action)
- operation = snippet_repository.multi_files_action(user, nil, commit_opts)
+ operation = snippet_repository.multi_files_action(user, nil, **commit_opts)
expect(operation).to be_nil
end
@@ -60,7 +60,7 @@ RSpec.describe SnippetRepository do
end
expect do
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end.not_to raise_error
aggregate_failures do
@@ -77,13 +77,13 @@ RSpec.describe SnippetRepository do
it 'tries to obtain an exclusive lease' do
expect(Gitlab::ExclusiveLease).to receive(:new).with("multi_files_action:#{snippet.id}", anything).and_call_original
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end
it 'cancels the lease when the method has finished' do
expect(Gitlab::ExclusiveLease).to receive(:cancel).with("multi_files_action:#{snippet.id}", anything).and_call_original
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end
it 'raises an error if the lease cannot be obtained' do
@@ -92,7 +92,7 @@ RSpec.describe SnippetRepository do
end
expect do
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end.to raise_error(described_class::CommitError)
end
@@ -114,7 +114,7 @@ RSpec.describe SnippetRepository do
it 'infers the commit action based on the parameters if not present' do
expect(repo).to receive(:multi_action).with(user, hash_including(actions: result))
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end
context 'when commit actions are present' do
@@ -128,7 +128,7 @@ RSpec.describe SnippetRepository do
user,
hash_including(actions: array_including(hash_including(action: expected_action)))))
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end
end
@@ -149,7 +149,7 @@ RSpec.describe SnippetRepository do
specify do
existing_content = blob_at(snippet, previous_path).data
- snippet_repository.multi_files_action(user, [move_action], commit_opts)
+ snippet_repository.multi_files_action(user, [move_action], **commit_opts)
blob = blob_at(snippet, new_path)
expect(blob).not_to be_nil
@@ -177,7 +177,7 @@ RSpec.describe SnippetRepository do
specify do
last_commit_id = snippet.repository.head_commit.id
- snippet_repository.multi_files_action(user, [update_action], commit_opts)
+ snippet_repository.multi_files_action(user, [update_action], **commit_opts)
expect(snippet.repository.head_commit.id).to eq last_commit_id
end
@@ -214,13 +214,13 @@ RSpec.describe SnippetRepository do
before do
expect(blob_at(snippet, default_name)).to be_nil
- snippet_repository.multi_files_action(user, [new_file], commit_opts)
+ snippet_repository.multi_files_action(user, [new_file], **commit_opts)
expect(blob_at(snippet, default_name)).to be
end
it 'reuses the existing file name' do
- snippet_repository.multi_files_action(user, [existing_file], commit_opts)
+ snippet_repository.multi_files_action(user, [existing_file], **commit_opts)
blob = blob_at(snippet, default_name)
expect(blob.data).to eq existing_file[:content]
@@ -234,7 +234,7 @@ RSpec.describe SnippetRepository do
it 'assigns a new name to the file' do
expect(blob_at(snippet, default_name)).to be_nil
- snippet_repository.multi_files_action(user, [new_file], commit_opts)
+ snippet_repository.multi_files_action(user, [new_file], **commit_opts)
blob = blob_at(snippet, default_name)
expect(blob.data).to eq new_file[:content]
@@ -246,7 +246,7 @@ RSpec.describe SnippetRepository do
before do
expect do
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end.not_to raise_error
end
@@ -259,10 +259,10 @@ RSpec.describe SnippetRepository do
before do
# Pre-populate repository with 9 unnamed snippets.
- snippet_repository.multi_files_action(user, pre_populate_data, commit_opts)
+ snippet_repository.multi_files_action(user, pre_populate_data, **commit_opts)
expect do
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end.not_to raise_error
end
@@ -274,7 +274,7 @@ RSpec.describe SnippetRepository do
it 'raises a path specific error' do
expect do
- snippet_repository.multi_files_action(user, data, commit_opts)
+ snippet_repository.multi_files_action(user, data, **commit_opts)
end.to raise_error(error)
end
end
diff --git a/spec/models/snippet_statistics_spec.rb b/spec/models/snippet_statistics_spec.rb
index 8def6a0bbd4..1fb4ed47169 100644
--- a/spec/models/snippet_statistics_spec.rb
+++ b/spec/models/snippet_statistics_spec.rb
@@ -75,15 +75,28 @@ RSpec.describe SnippetStatistics do
end
describe '#refresh!' do
- subject { statistics.refresh! }
-
it 'retrieves and saves statistic data from repository' do
expect(statistics).to receive(:update_commit_count)
expect(statistics).to receive(:update_file_count)
expect(statistics).to receive(:update_repository_size)
expect(statistics).to receive(:save!)
- subject
+ statistics.refresh!
+ end
+
+ context 'when the database is read-only' do
+ it 'does nothing' do
+ allow(Gitlab::Database).to receive(:read_only?) { true }
+
+ expect(statistics).not_to receive(:update_commit_count)
+ expect(statistics).not_to receive(:update_file_count)
+ expect(statistics).not_to receive(:update_repository_size)
+ expect(statistics).not_to receive(:save!)
+ expect(Namespaces::ScheduleAggregationWorker)
+ .not_to receive(:perform_async)
+
+ statistics.refresh!
+ end
end
end
diff --git a/spec/models/terraform/state_spec.rb b/spec/models/terraform/state_spec.rb
index 01ae80a61d1..1d99d103bb8 100644
--- a/spec/models/terraform/state_spec.rb
+++ b/spec/models/terraform/state_spec.rb
@@ -15,7 +15,24 @@ RSpec.describe Terraform::State do
it { is_expected.to validate_presence_of(:project_id) }
before do
- stub_terraform_state_object_storage(Terraform::StateUploader)
+ stub_terraform_state_object_storage
+ end
+
+ describe 'scopes' do
+ describe '.ordered_by_name' do
+ let_it_be(:project) { create(:project) }
+ let(:names) { %w(state_d state_b state_a state_c) }
+
+ subject { described_class.ordered_by_name }
+
+ before do
+ names.each do |name|
+ create(:terraform_state, project: project, name: name)
+ end
+ end
+
+ it { expect(subject.map(&:name)).to eq(names.sort) }
+ end
end
describe '#file' do
@@ -43,7 +60,7 @@ RSpec.describe Terraform::State do
context 'when file is stored locally' do
before do
- stub_terraform_state_object_storage(Terraform::StateUploader, enabled: false)
+ stub_terraform_state_object_storage(enabled: false)
end
it_behaves_like 'mounted file in local store'
diff --git a/spec/models/terraform/state_version_spec.rb b/spec/models/terraform/state_version_spec.rb
index 72dd29e1571..cc5ea87159d 100644
--- a/spec/models/terraform/state_version_spec.rb
+++ b/spec/models/terraform/state_version_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe Terraform::StateVersion do
subject { create(:terraform_state_version) }
before do
- stub_terraform_state_object_storage(Terraform::StateUploader)
+ stub_terraform_state_object_storage
end
describe '#file' do
diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb
index 44e81455a67..a9c4c6680cd 100644
--- a/spec/models/todo_spec.rb
+++ b/spec/models/todo_spec.rb
@@ -200,26 +200,42 @@ RSpec.describe Todo do
describe '#self_assigned?' do
let(:user_1) { build(:user) }
- before do
- subject.user = user_1
- subject.author = user_1
- subject.action = Todo::ASSIGNED
- end
+ context 'when self_added' do
+ before do
+ subject.user = user_1
+ subject.author = user_1
+ end
- it 'is true when todo is ASSIGNED and self_added' do
- expect(subject).to be_self_assigned
- end
+ it 'returns true for ASSIGNED' do
+ subject.action = Todo::ASSIGNED
+
+ expect(subject).to be_self_assigned
+ end
- it 'is false when the todo is not ASSIGNED' do
- subject.action = Todo::MENTIONED
+ it 'returns true for REVIEW_REQUESTED' do
+ subject.action = Todo::REVIEW_REQUESTED
- expect(subject).not_to be_self_assigned
+ expect(subject).to be_self_assigned
+ end
+
+ it 'returns false for other action' do
+ subject.action = Todo::MENTIONED
+
+ expect(subject).not_to be_self_assigned
+ end
end
- it 'is false when todo is not self_added' do
- subject.author = build(:user)
+ context 'when todo is not self_added' do
+ before do
+ subject.user = user_1
+ subject.author = build(:user)
+ end
- expect(subject).not_to be_self_assigned
+ it 'returns false' do
+ subject.action = Todo::ASSIGNED
+
+ expect(subject).not_to be_self_assigned
+ end
end
end
@@ -427,7 +443,7 @@ RSpec.describe Todo do
it 'updates updated_at' do
create(:todo, :pending)
- Timecop.freeze(1.day.from_now) do
+ travel_to(1.day.from_now) do
expected_update_date = Time.current.utc
ids = described_class.batch_update(state: :done)
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 1841288cd4b..af5614ba85e 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -3902,7 +3902,7 @@ RSpec.describe User do
it 'changes the namespace (just to compare to when username is not changed)' do
expect do
- Timecop.freeze(1.second.from_now) do
+ travel_to(1.second.from_now) do
user.update!(username: new_username)
end
end.to change { user.namespace.updated_at }
@@ -4330,28 +4330,32 @@ RSpec.describe User do
describe '#required_terms_not_accepted?' do
let(:user) { build(:user) }
+ let(:project_bot) { create(:user, :project_bot) }
subject { user.required_terms_not_accepted? }
context "when terms are not enforced" do
- it { is_expected.to be_falsy }
+ it { is_expected.to be_falsey }
end
- context "when terms are enforced and accepted by the user" do
+ context "when terms are enforced" do
before do
enforce_terms
- accept_terms(user)
end
- it { is_expected.to be_falsy }
- end
+ it "is not accepted by the user" do
+ expect(subject).to be_truthy
+ end
- context "when terms are enforced but the user has not accepted" do
- before do
- enforce_terms
+ it "is accepted by the user" do
+ accept_terms(user)
+
+ expect(subject).to be_falsey
end
- it { is_expected.to be_truthy }
+ it "auto accepts the term for project bots" do
+ expect(project_bot.required_terms_not_accepted?).to be_falsey
+ end
end
end
@@ -4895,7 +4899,7 @@ RSpec.describe User do
user.block
end
- it { is_expected.to eq User::BLOCKED_MESSAGE }
+ it { is_expected.to eq :blocked }
end
context 'when user is an internal user' do
@@ -4903,7 +4907,7 @@ RSpec.describe User do
user.update(user_type: :ghost)
end
- it { is_expected.to be User::LOGIN_FORBIDDEN }
+ it { is_expected.to be :forbidden }
end
context 'when user is locked' do
diff --git a/spec/models/wiki_directory_spec.rb b/spec/models/wiki_directory_spec.rb
index 4cac90786eb..9b6cec99ddb 100644
--- a/spec/models/wiki_directory_spec.rb
+++ b/spec/models/wiki_directory_spec.rb
@@ -3,43 +3,97 @@
require 'spec_helper'
RSpec.describe WikiDirectory do
- describe 'validations' do
- subject { build(:wiki_directory) }
+ subject(:directory) { build(:wiki_directory) }
+ describe 'validations' do
it { is_expected.to validate_presence_of(:slug) }
end
+ describe '.group_pages' do
+ let_it_be(:toplevel1) { build(:wiki_page, title: 'aaa-toplevel1') }
+ let_it_be(:toplevel2) { build(:wiki_page, title: 'zzz-toplevel2') }
+ let_it_be(:toplevel3) { build(:wiki_page, title: 'zzz-toplevel3') }
+ let_it_be(:child1) { build(:wiki_page, title: 'parent1/child1') }
+ let_it_be(:child2) { build(:wiki_page, title: 'parent1/child2') }
+ let_it_be(:child3) { build(:wiki_page, title: 'parent2/child3') }
+ let_it_be(:grandchild1) { build(:wiki_page, title: 'parent1/subparent/grandchild1') }
+ let_it_be(:grandchild2) { build(:wiki_page, title: 'parent1/subparent/grandchild2') }
+
+ it 'returns a nested array of entries' do
+ entries = described_class.group_pages(
+ [toplevel1, toplevel2, toplevel3, child1, child2, child3, grandchild1, grandchild2].sort_by(&:title)
+ )
+
+ expect(entries).to match([
+ toplevel1,
+ a_kind_of(WikiDirectory).and(
+ having_attributes(
+ slug: 'parent1', entries: [
+ child1,
+ child2,
+ a_kind_of(WikiDirectory).and(
+ having_attributes(
+ slug: 'parent1/subparent',
+ entries: [grandchild1, grandchild2]
+ )
+ )
+ ]
+ )
+ ),
+ a_kind_of(WikiDirectory).and(
+ having_attributes(
+ slug: 'parent2',
+ entries: [child3]
+ )
+ ),
+ toplevel2,
+ toplevel3
+ ])
+ end
+ end
+
describe '#initialize' do
- context 'when there are pages' do
- let(:pages) { [build(:wiki_page)] }
- let(:directory) { described_class.new('/path_up_to/dir', pages) }
+ context 'when there are entries' do
+ let(:entries) { [build(:wiki_page)] }
+ let(:directory) { described_class.new('/path_up_to/dir', entries) }
it 'sets the slug attribute' do
expect(directory.slug).to eq('/path_up_to/dir')
end
- it 'sets the pages attribute' do
- expect(directory.pages).to eq(pages)
+ it 'sets the entries attribute' do
+ expect(directory.entries).to eq(entries)
end
end
- context 'when there are no pages' do
+ context 'when there are no entries' do
let(:directory) { described_class.new('/path_up_to/dir') }
it 'sets the slug attribute' do
expect(directory.slug).to eq('/path_up_to/dir')
end
- it 'sets the pages attribute to an empty array' do
- expect(directory.pages).to eq([])
+ it 'sets the entries attribute to an empty array' do
+ expect(directory.entries).to eq([])
end
end
end
+ describe '#title' do
+ it 'returns the basename of the directory, with hyphens replaced by spaces' do
+ directory.slug = 'parent'
+ expect(directory.title).to eq('parent')
+
+ directory.slug = 'parent/child'
+ expect(directory.title).to eq('child')
+
+ directory.slug = 'parent/child-foo'
+ expect(directory.title).to eq('child foo')
+ end
+ end
+
describe '#to_partial_path' do
it 'returns the relative path to the partial to be used' do
- directory = build(:wiki_directory)
-
expect(directory.to_partial_path).to eq('../shared/wikis/wiki_directory')
end
end
diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb
index aa8b9ce58b9..be94eca550c 100644
--- a/spec/models/wiki_page_spec.rb
+++ b/spec/models/wiki_page_spec.rb
@@ -4,16 +4,25 @@ require "spec_helper"
RSpec.describe WikiPage do
let_it_be(:user) { create(:user) }
- let(:container) { create(:project, :wiki_repo) }
- let(:wiki) { Wiki.for_container(container, user) }
- let(:new_page) { build(:wiki_page, wiki: wiki, title: 'test page', content: 'test content') }
+ let_it_be(:container) { create(:project) }
- let(:existing_page) do
- create(:wiki_page, wiki: wiki, title: 'test page', content: 'test content', message: 'test commit')
- wiki.find_page('test page')
+ def create_wiki_page(attrs = {})
+ page = build_wiki_page(attrs)
+
+ page.create(message: (attrs[:message] || 'test commit'))
+
+ container.wiki.find_page(page.slug)
end
- subject { new_page }
+ def build_wiki_page(attrs = {})
+ wiki_page_attrs = { container: container, content: 'test content' }.merge(attrs)
+
+ build(:wiki_page, wiki_page_attrs)
+ end
+
+ def wiki
+ container.wiki
+ end
def disable_front_matter
stub_feature_flags(Gitlab::WikiPages::FrontMatterParser::FEATURE_FLAG => false)
@@ -23,92 +32,16 @@ RSpec.describe WikiPage do
stub_feature_flags(Gitlab::WikiPages::FrontMatterParser::FEATURE_FLAG => thing)
end
- describe '.group_by_directory' do
- context 'when there are no pages' do
- it 'returns an empty array' do
- expect(described_class.group_by_directory(nil)).to eq([])
- expect(described_class.group_by_directory([])).to eq([])
- end
- end
-
- context 'when there are pages' do
- before do
- wiki.create_page('dir_1/dir_1_1/page_3', 'content')
- wiki.create_page('page_1', 'content')
- wiki.create_page('dir_1/page_2', 'content')
- wiki.create_page('dir_2', 'page with dir name')
- wiki.create_page('dir_2/page_5', 'content')
- wiki.create_page('page_6', 'content')
- wiki.create_page('dir_2/page_4', 'content')
- end
-
- let(:page_1) { wiki.find_page('page_1') }
- let(:page_6) { wiki.find_page('page_6') }
- let(:page_dir_2) { wiki.find_page('dir_2') }
-
- let(:dir_1) do
- WikiDirectory.new('dir_1', [wiki.find_page('dir_1/page_2')])
- end
-
- let(:dir_1_1) do
- WikiDirectory.new('dir_1/dir_1_1', [wiki.find_page('dir_1/dir_1_1/page_3')])
- end
-
- let(:dir_2) do
- pages = [wiki.find_page('dir_2/page_5'),
- wiki.find_page('dir_2/page_4')]
- WikiDirectory.new('dir_2', pages)
- end
+ # Use for groups of tests that do not modify their `subject`.
+ #
+ # include_context 'subject is persisted page', title: 'my title'
+ shared_context 'subject is persisted page' do |attrs = {}|
+ let_it_be(:persisted_page) { create_wiki_page(attrs) }
- describe "#list_pages" do
- context 'sort by title' do
- let(:grouped_entries) { described_class.group_by_directory(wiki.list_pages) }
- let(:expected_grouped_entries) { [dir_1_1, dir_1, page_dir_2, dir_2, page_1, page_6] }
-
- it 'returns an array with pages and directories' do
- grouped_entries.each_with_index do |page_or_dir, i|
- expected_page_or_dir = expected_grouped_entries[i]
- expected_slugs = get_slugs(expected_page_or_dir)
- slugs = get_slugs(page_or_dir)
-
- expect(slugs).to match_array(expected_slugs)
- end
- end
- end
-
- context 'sort by created_at' do
- let(:grouped_entries) { described_class.group_by_directory(wiki.list_pages(sort: 'created_at')) }
- let(:expected_grouped_entries) { [dir_1_1, page_1, dir_1, page_dir_2, dir_2, page_6] }
-
- it 'returns an array with pages and directories' do
- grouped_entries.each_with_index do |page_or_dir, i|
- expected_page_or_dir = expected_grouped_entries[i]
- expected_slugs = get_slugs(expected_page_or_dir)
- slugs = get_slugs(page_or_dir)
-
- expect(slugs).to match_array(expected_slugs)
- end
- end
- end
-
- it 'returns an array with retained order with directories at the top' do
- expected_order = ['dir_1/dir_1_1/page_3', 'dir_1/page_2', 'dir_2', 'dir_2/page_4', 'dir_2/page_5', 'page_1', 'page_6']
-
- grouped_entries = described_class.group_by_directory(wiki.list_pages)
-
- actual_order =
- grouped_entries.flat_map do |page_or_dir|
- get_slugs(page_or_dir)
- end
- expect(actual_order).to eq(expected_order)
- end
- end
- end
+ subject { persisted_page }
end
describe '#front_matter' do
- let_it_be(:project) { create(:project) }
- let(:container) { project }
let(:wiki_page) { create(:wiki_page, container: container, content: content) }
shared_examples 'a page without front-matter' do
@@ -230,14 +163,14 @@ RSpec.describe WikiPage do
describe "#initialize" do
context "when initialized with an existing page" do
- subject { existing_page }
+ include_context 'subject is persisted page', title: 'test initialization'
it "sets the slug attribute" do
- expect(subject.slug).to eq("test-page")
+ expect(subject.slug).to eq("test-initialization")
end
it "sets the title attribute" do
- expect(subject.title).to eq("test page")
+ expect(subject.title).to eq("test initialization")
end
it "sets the formatted content attribute" do
@@ -259,6 +192,8 @@ RSpec.describe WikiPage do
end
describe "validations" do
+ subject { build_wiki_page }
+
it "validates presence of title" do
subject.attributes.delete(:title)
@@ -305,7 +240,7 @@ RSpec.describe WikiPage do
end
context 'with an existing page exceeding the limit' do
- subject { existing_page }
+ include_context 'subject is persisted page'
before do
subject
@@ -414,18 +349,22 @@ RSpec.describe WikiPage do
describe "#create" do
let(:attributes) do
{
- title: "Index",
+ title: SecureRandom.hex,
content: "Home Page",
format: "markdown",
message: 'Custom Commit Message'
}
end
+ let(:title) { attributes[:title] }
+
+ subject { build_wiki_page }
+
context "with valid attributes" do
it "saves the wiki page" do
subject.create(attributes)
- expect(wiki.find_page("Index")).not_to be_nil
+ expect(wiki.find_page(title)).not_to be_nil
end
it "returns true" do
@@ -435,7 +374,7 @@ RSpec.describe WikiPage do
it 'saves the wiki page with message' do
subject.create(attributes)
- expect(wiki.find_page("Index").message).to eq 'Custom Commit Message'
+ expect(wiki.find_page(title).message).to eq 'Custom Commit Message'
end
it 'if the title is preceded by a / it is removed' do
@@ -447,9 +386,7 @@ RSpec.describe WikiPage do
context "with invalid attributes" do
it 'does not create the page' do
- subject.create(title: '')
-
- expect(wiki.find_page('New Page')).to be_nil
+ expect { subject.create(title: '') }.not_to change { wiki.list_pages.length }
end
end
end
@@ -458,46 +395,40 @@ RSpec.describe WikiPage do
let(:title) { 'Index v1.2.3' }
describe "#create" do
- let(:attributes) { { title: title, content: "Home Page", format: "markdown" } }
-
- context "with valid attributes" do
- it "saves the wiki page" do
- subject.create(attributes)
+ subject { build_wiki_page }
- expect(wiki.find_page(title)).not_to be_nil
- end
+ it "saves the wiki page and returns true", :aggregate_failures do
+ attributes = { title: title, content: "Home Page", format: "markdown" }
- it "returns true" do
- expect(subject.create(attributes)).to eq(true)
- end
+ expect(subject.create(attributes)).to eq(true)
+ expect(wiki.find_page(title)).not_to be_nil
end
end
describe '#update' do
- subject { create(:wiki_page, wiki: wiki, title: title) }
+ subject { create_wiki_page(title: title) }
+
+ it 'updates the content of the page and returns true', :aggregate_failures do
+ expect(subject.update(content: 'new content')).to be_truthy
- it 'updates the content of the page' do
- subject.update(content: 'new content')
page = wiki.find_page(title)
expect([subject.content, page.content]).to all(eq('new content'))
end
-
- it "returns true" do
- expect(subject.update(content: "more content")).to be_truthy
- end
end
end
describe "#update" do
- subject { existing_page }
+ let!(:original_title) { subject.title }
+
+ subject { create_wiki_page }
context "with valid attributes" do
it "updates the content of the page" do
new_content = "new content"
subject.update(content: new_content)
- page = wiki.find_page('test page')
+ page = wiki.find_page(original_title)
expect([subject.content, page.content]).to all(eq("new content"))
end
@@ -514,10 +445,9 @@ RSpec.describe WikiPage do
describe 'updating front_matter' do
shared_examples 'able to update front-matter' do
it 'updates the wiki-page front-matter' do
- title = subject.title
content = subject.content
subject.update(front_matter: { slugs: ['x'] })
- page = wiki.find_page(title)
+ page = wiki.find_page(original_title)
expect([subject, page]).to all(
have_attributes(
@@ -566,10 +496,9 @@ RSpec.describe WikiPage do
end
it 'updates the wiki-page front-matter and content together' do
- title = subject.title
content = 'totally new content'
subject.update(content: content, front_matter: { slugs: ['x'] })
- page = wiki.find_page(title)
+ page = wiki.find_page(original_title)
expect([subject, page]).to all(
have_attributes(
@@ -598,11 +527,11 @@ RSpec.describe WikiPage do
context 'when renaming a page' do
it 'raises an error if the page already exists' do
- wiki.create_page('Existing Page', 'content')
+ existing_page = create_wiki_page
- expect { subject.update(title: 'Existing Page', content: 'new_content') }.to raise_error(WikiPage::PageRenameError)
- expect(subject.title).to eq 'test page'
- expect(subject.content).to eq 'new_content'
+ expect { subject.update(title: existing_page.title, content: 'new_content') }.to raise_error(WikiPage::PageRenameError)
+ expect(subject.title).to eq original_title
+ expect(subject.content).to eq 'new_content' # We don't revert the content
end
it 'updates the content and rename the file' do
@@ -623,7 +552,7 @@ RSpec.describe WikiPage do
wiki.create_page('foo/Existing Page', 'content')
expect { subject.update(title: 'foo/Existing Page', content: 'new_content') }.to raise_error(WikiPage::PageRenameError)
- expect(subject.title).to eq 'test page'
+ expect(subject.title).to eq original_title
expect(subject.content).to eq 'new_content'
end
@@ -639,20 +568,22 @@ RSpec.describe WikiPage do
expect(page.content).to eq new_content
end
- context 'in subdir' do
- subject { create(:wiki_page, wiki: wiki, title: 'foo/Existing Page') }
-
+ describe 'in subdir' do
it 'moves the page to the root folder if the title is preceded by /' do
- expect(subject.slug).to eq 'foo/Existing-Page'
- expect(subject.update(title: '/Existing Page', content: 'new_content')).to be_truthy
- expect(subject.slug).to eq 'Existing-Page'
+ page = create_wiki_page(title: 'foo/Existing Page')
+
+ 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 = subject.slug
+ page = create_wiki_page(title: 'foo/Another Existing Page')
- expect(subject.update(title: 'Existing Page', content: 'new_content')).to be_truthy
- expect(subject.slug).to eq original_path
+ original_path = page.slug
+
+ expect(page.update(title: 'Another Existing Page', content: 'new_content')).to be_truthy
+ expect(page.slug).to eq original_path
end
end
@@ -660,7 +591,7 @@ RSpec.describe WikiPage do
it 'does nothing if the title is preceded by /' do
original_path = subject.slug
- expect(subject.update(title: '/test page', content: 'new_content')).to be_truthy
+ expect(subject.update(title: "/#{subject.title}", content: 'new_content')).to be_truthy
expect(subject.slug).to eq original_path
end
end
@@ -671,7 +602,7 @@ RSpec.describe WikiPage do
expect(subject.update(title: '', content: 'new_content')).to be_falsey
expect(subject.content).to eq 'new_content'
- page = wiki.find_page('test page')
+ page = wiki.find_page(original_title)
expect(page.content).to eq 'test content'
end
@@ -679,21 +610,17 @@ RSpec.describe WikiPage do
end
describe "#delete" do
- subject { existing_page }
-
- it "deletes the page" do
- subject.delete
-
- expect(wiki.list_pages).to be_empty
- end
+ it "deletes the page and returns true", :aggregate_failures do
+ page = create_wiki_page
- it "returns true" do
- expect(subject.delete).to eq(true)
+ expect do
+ expect(page.delete).to eq(true)
+ end.to change { wiki.list_pages.length }.by(-1)
end
end
describe "#versions" do
- subject { existing_page }
+ include_context 'subject is persisted page'
it "returns an array of all commits for the page" do
3.times { |i| subject.update(content: "content #{i}") }
@@ -709,19 +636,21 @@ RSpec.describe WikiPage do
describe '#title_changed?' do
using RSpec::Parameterized::TableSyntax
+ let_it_be(:unsaved_page) { build_wiki_page(title: 'test page') }
+ let_it_be(:existing_page) { create_wiki_page(title: 'test page') }
+ let_it_be(:directory_page) { create_wiki_page(title: 'parent directory/child page') }
+ let_it_be(:page_with_special_characters) { create_wiki_page(title: 'test+page') }
let(:untitled_page) { described_class.new(wiki) }
- let(:directory_page) { create(:wiki_page, title: 'parent directory/child page') }
- let(:page_with_special_characters) { create(:wiki_page, title: 'test+page') }
where(:page, :title, :changed) do
:untitled_page | nil | false
:untitled_page | 'new title' | true
- :new_page | nil | true
- :new_page | 'test page' | true
- :new_page | 'test-page' | true
- :new_page | 'test+page' | true
- :new_page | 'new title' | true
+ :unsaved_page | nil | true
+ :unsaved_page | 'test page' | true
+ :unsaved_page | 'test-page' | true
+ :unsaved_page | 'test+page' | true
+ :unsaved_page | 'new title' | true
:existing_page | nil | false
:existing_page | 'test page' | false
@@ -764,7 +693,7 @@ RSpec.describe WikiPage do
describe '#content_changed?' do
context 'with a new page' do
- subject { new_page }
+ subject { build_wiki_page }
it 'returns true if content is set' do
subject.attributes[:content] = 'new'
@@ -780,7 +709,7 @@ RSpec.describe WikiPage do
end
context 'with an existing page' do
- subject { existing_page }
+ include_context 'subject is persisted page'
it 'returns false' do
expect(subject.content_changed?).to be(false)
@@ -816,17 +745,21 @@ RSpec.describe WikiPage do
describe '#path' do
it 'returns the path when persisted' do
- expect(existing_page.path).to eq('test-page.md')
+ existing_page = create_wiki_page(title: 'path test')
+
+ expect(existing_page.path).to eq('path-test.md')
end
it 'returns nil when not persisted' do
- expect(new_page.path).to be_nil
+ unsaved_page = build_wiki_page(title: 'path test')
+
+ expect(unsaved_page.path).to be_nil
end
end
describe '#directory' do
context 'when the page is at the root directory' do
- subject { existing_page }
+ include_context 'subject is persisted page', title: 'directory test'
it 'returns an empty string' do
expect(subject.directory).to eq('')
@@ -834,7 +767,7 @@ RSpec.describe WikiPage do
end
context 'when the page is inside an actual directory' do
- subject { create(:wiki_page, title: 'dir_1/dir_1_1/file') }
+ include_context 'subject is persisted page', title: 'dir_1/dir_1_1/directory test'
it 'returns the full directory hierarchy' do
expect(subject.directory).to eq('dir_1/dir_1_1')
@@ -843,7 +776,7 @@ RSpec.describe WikiPage do
end
describe '#historical?' do
- subject { existing_page }
+ include_context 'subject is persisted page'
let(:old_version) { subject.versions.last.id }
let(:old_page) { wiki.find_page(subject.title, old_version) }
@@ -883,22 +816,22 @@ RSpec.describe WikiPage do
describe '#persisted?' do
it 'returns true for a persisted page' do
- expect(existing_page).to be_persisted
+ expect(create_wiki_page).to be_persisted
end
it 'returns false for an unpersisted page' do
- expect(new_page).not_to be_persisted
+ expect(build_wiki_page).not_to be_persisted
end
end
describe '#to_partial_path' do
it 'returns the relative path to the partial to be used' do
- expect(subject.to_partial_path).to eq('../shared/wikis/wiki_page')
+ expect(build_wiki_page.to_partial_path).to eq('../shared/wikis/wiki_page')
end
end
describe '#==' do
- subject { existing_page }
+ include_context 'subject is persisted page'
it 'returns true for identical wiki page' do
expect(subject).to eq(subject)
@@ -906,7 +839,7 @@ RSpec.describe WikiPage do
it 'returns true for updated wiki page' do
subject.update(content: "Updated content")
- updated_page = wiki.find_page(existing_page.slug)
+ updated_page = wiki.find_page(subject.slug)
expect(updated_page).not_to be_nil
expect(updated_page).to eq(subject)
@@ -921,7 +854,7 @@ RSpec.describe WikiPage do
end
it 'returns false for page with different slug on same container' do
- other_page = create(:wiki_page, container: subject.container)
+ other_page = create_wiki_page
expect(subject.slug).not_to eq(other_page.slug)
expect(subject.container).to eq(other_page.container)
@@ -929,7 +862,7 @@ RSpec.describe WikiPage do
end
it 'returns false for page with the same slug on a different container' do
- other_page = create(:wiki_page, title: existing_page.slug)
+ other_page = create(:wiki_page, title: subject.slug)
expect(subject.slug).to eq(other_page.slug)
expect(subject.container).not_to eq(other_page.container)
@@ -938,7 +871,7 @@ RSpec.describe WikiPage do
end
describe '#last_commit_sha' do
- subject { existing_page }
+ include_context 'subject is persisted page'
it 'returns commit sha' do
expect(subject.last_commit_sha).to eq subject.last_version.sha
@@ -948,13 +881,15 @@ RSpec.describe WikiPage do
last_commit_sha_before_update = subject.last_commit_sha
subject.update(content: "new content")
- page = wiki.find_page('test page')
+ page = wiki.find_page(subject.title)
expect(page.last_commit_sha).not_to eq last_commit_sha_before_update
end
end
describe '#hook_attrs' do
+ subject { build_wiki_page }
+
it 'adds absolute urls for images in the content' do
subject.attributes[:content] = 'test![WikiPage_Image](/uploads/abc/WikiPage_Image.png)'
@@ -965,19 +900,21 @@ RSpec.describe WikiPage do
describe '#version_commit_timestamp' do
context 'for a new page' do
it 'returns nil' do
- expect(new_page.version_commit_timestamp).to be_nil
+ expect(build_wiki_page.version_commit_timestamp).to be_nil
end
end
context 'for page that exists' do
it 'returns the timestamp of the commit' do
+ existing_page = create_wiki_page
+
expect(existing_page.version_commit_timestamp).to eq(existing_page.version.commit.committed_date)
end
end
end
describe '#diffs' do
- subject { existing_page }
+ include_context 'subject is persisted page'
it 'returns a diff instance' do
diffs = subject.diffs(foo: 'bar')
@@ -993,14 +930,4 @@ RSpec.describe WikiPage do
)
end
end
-
- private
-
- def get_slugs(page_or_dir)
- if page_or_dir.is_a? WikiPage
- [page_or_dir.slug]
- else
- page_or_dir.pages.present? ? page_or_dir.pages.map(&:slug) : []
- end
- end
end
diff --git a/spec/policies/design_management/design_policy_spec.rb b/spec/policies/design_management/design_policy_spec.rb
index 5a74d979ef3..117279d1638 100644
--- a/spec/policies/design_management/design_policy_spec.rb
+++ b/spec/policies/design_management/design_policy_spec.rb
@@ -71,6 +71,11 @@ RSpec.describe DesignManagement::DesignPolicy do
end
end
+ shared_examples_for "read-only design abilities" do
+ it { is_expected.to be_allowed(*guest_design_abilities) }
+ it { is_expected.to be_disallowed(*developer_design_abilities) }
+ end
+
shared_examples_for "design abilities available for members" do
context "for owners" do
let(:current_user) { owner }
@@ -86,8 +91,7 @@ RSpec.describe DesignManagement::DesignPolicy do
end
context "when admin mode disabled" do
- it { is_expected.to be_allowed(*guest_design_abilities) }
- it { is_expected.to be_disallowed(*developer_design_abilities) }
+ it_behaves_like "read-only design abilities"
end
end
@@ -106,16 +110,10 @@ RSpec.describe DesignManagement::DesignPolicy do
context "for reporters" do
let(:current_user) { reporter }
- it { is_expected.to be_allowed(*guest_design_abilities) }
- it { is_expected.to be_disallowed(*developer_design_abilities) }
+ it_behaves_like "read-only design abilities"
end
end
- shared_examples_for "read-only design abilities" do
- it { is_expected.to be_allowed(:read_design) }
- it { is_expected.to be_disallowed(:create_design, :destroy_design) }
- end
-
context "when DesignManagement is not enabled" do
before do
enable_design_management(false)
@@ -135,15 +133,13 @@ RSpec.describe DesignManagement::DesignPolicy do
let_it_be(:project) { create(:project, :private) }
let(:current_user) { guest }
- it { is_expected.to be_allowed(*guest_design_abilities) }
- it { is_expected.to be_disallowed(*developer_design_abilities) }
+ it_behaves_like "read-only design abilities"
end
context "for anonymous users in public projects" do
let(:current_user) { nil }
- it { is_expected.to be_allowed(*guest_design_abilities) }
- it { is_expected.to be_disallowed(*developer_design_abilities) }
+ it_behaves_like "read-only design abilities"
end
context "when the issue is confidential" do
@@ -164,20 +160,6 @@ RSpec.describe DesignManagement::DesignPolicy do
end
end
- context "when the issue is locked" do
- let_it_be(:issue) { create(:issue, :locked, project: project) }
- let(:current_user) { owner }
-
- it_behaves_like "read-only design abilities"
- end
-
- context "when the issue has moved" do
- let_it_be(:issue) { create(:issue, project: project, moved_to: create(:issue)) }
- let(:current_user) { owner }
-
- it_behaves_like "read-only design abilities"
- end
-
context "when the project is archived" do
let_it_be(:project) { create(:project, :public, :archived) }
let_it_be(:issue) { create(:issue, project: project) }
diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb
index 6cd1c201c62..e483598c47a 100644
--- a/spec/policies/global_policy_spec.rb
+++ b/spec/policies/global_policy_spec.rb
@@ -229,12 +229,6 @@ RSpec.describe GlobalPolicy do
it { is_expected.not_to be_allowed(:access_api) }
end
-
- it 'when `inactive_policy_condition` feature flag is turned off' do
- stub_feature_flags(inactive_policy_condition: false)
-
- is_expected.to be_allowed(:access_api)
- end
end
end
@@ -321,12 +315,6 @@ RSpec.describe GlobalPolicy do
end
it { is_expected.not_to be_allowed(:access_git) }
-
- it 'when `inactive_policy_condition` feature flag is turned off' do
- stub_feature_flags(inactive_policy_condition: false)
-
- is_expected.to be_allowed(:access_git)
- end
end
context 'when terms are enforced' do
@@ -403,12 +391,6 @@ RSpec.describe GlobalPolicy do
end
it { is_expected.not_to be_allowed(:use_slash_commands) }
-
- it 'when `inactive_policy_condition` feature flag is turned off' do
- stub_feature_flags(inactive_policy_condition: false)
-
- is_expected.to be_allowed(:use_slash_commands)
- end
end
context 'when access locked' do
diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb
index dbe444acb58..fecf5f3e4f8 100644
--- a/spec/policies/group_policy_spec.rb
+++ b/spec/policies/group_policy_spec.rb
@@ -812,4 +812,74 @@ RSpec.describe GroupPolicy do
it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
end
end
+
+ describe 'read_package' do
+ context 'admin' do
+ let(:current_user) { admin }
+
+ it { is_expected.to be_allowed(:read_package) }
+ end
+
+ context 'with owner' do
+ let(:current_user) { owner }
+
+ it { is_expected.to be_allowed(:read_package) }
+ end
+
+ context 'with maintainer' do
+ let(:current_user) { maintainer }
+
+ it { is_expected.to be_allowed(:read_package) }
+ end
+
+ context 'with reporter' do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_allowed(:read_package) }
+ end
+
+ context 'with guest' do
+ let(:current_user) { guest }
+
+ it { is_expected.to be_disallowed(:read_package) }
+ end
+
+ context 'with non member' do
+ let(:current_user) { create(:user) }
+
+ it { is_expected.to be_disallowed(:read_package) }
+ end
+
+ context 'with anonymous' do
+ let(:current_user) { nil }
+
+ it { is_expected.to be_disallowed(:read_package) }
+ end
+ end
+
+ context 'deploy token access' do
+ let!(:group_deploy_token) do
+ create(:group_deploy_token, group: group, deploy_token: deploy_token)
+ end
+
+ subject { described_class.new(deploy_token, group) }
+
+ context 'a deploy token with read_package_registry scope' do
+ let(:deploy_token) { create(:deploy_token, :group, read_package_registry: true) }
+
+ it { is_expected.to be_allowed(:read_package) }
+ it { is_expected.to be_allowed(:read_group) }
+ it { is_expected.to be_disallowed(:create_package) }
+ end
+
+ context 'a deploy token with write_package_registry scope' do
+ let(:deploy_token) { create(:deploy_token, :group, write_package_registry: true) }
+
+ it { is_expected.to be_allowed(:create_package) }
+ it { is_expected.to be_allowed(:read_group) }
+ it { is_expected.to be_disallowed(:destroy_package) }
+ end
+ end
+
+ it_behaves_like 'Self-managed Core resource access tokens'
end
diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb
index 0c457148b4d..d66ef81efca 100644
--- a/spec/policies/project_policy_spec.rb
+++ b/spec/policies/project_policy_spec.rb
@@ -941,4 +941,6 @@ RSpec.describe ProjectPolicy do
end
end
end
+
+ it_behaves_like 'Self-managed Core resource access tokens'
end
diff --git a/spec/policies/terraform/state_policy_spec.rb b/spec/policies/terraform/state_policy_spec.rb
new file mode 100644
index 00000000000..82152920997
--- /dev/null
+++ b/spec/policies/terraform/state_policy_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Terraform::StatePolicy do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:terraform_state) { create(:terraform_state, project: project)}
+
+ subject { described_class.new(user, terraform_state) }
+
+ describe 'rules' do
+ context 'no access' do
+ let(:user) { create(:user) }
+
+ it { is_expected.to be_disallowed(:read_terraform_state) }
+ it { is_expected.to be_disallowed(:admin_terraform_state) }
+ end
+
+ context 'developer' do
+ let(:user) { create(:user, developer_projects: [project]) }
+
+ it { is_expected.to be_allowed(:read_terraform_state) }
+ it { is_expected.to be_disallowed(:admin_terraform_state) }
+ end
+
+ context 'maintainer' do
+ let(:user) { create(:user, maintainer_projects: [project]) }
+
+ it { is_expected.to be_allowed(:read_terraform_state) }
+ it { is_expected.to be_allowed(:admin_terraform_state) }
+ end
+ end
+end
diff --git a/spec/presenters/ci/pipeline_presenter_spec.rb b/spec/presenters/ci/pipeline_presenter_spec.rb
index 18f79bc930c..5cb9d340e06 100644
--- a/spec/presenters/ci/pipeline_presenter_spec.rb
+++ b/spec/presenters/ci/pipeline_presenter_spec.rb
@@ -5,17 +5,20 @@ require 'spec_helper'
RSpec.describe Ci::PipelinePresenter do
include Gitlab::Routing
- let(:user) { create(:user) }
+ let_it_be(:user) { create(:user) }
+ let_it_be_with_reload(:project) { create(:project, :test_repo) }
+ let_it_be_with_reload(:pipeline) { create(:ci_pipeline, project: project) }
let(:current_user) { user }
- let(:project) { create(:project, :test_repo) }
- let(:pipeline) { create(:ci_pipeline, project: project) }
subject(:presenter) do
described_class.new(pipeline)
end
- before do
+ before_all do
project.add_developer(user)
+ end
+
+ before do
allow(presenter).to receive(:current_user) { current_user }
end
@@ -184,8 +187,8 @@ RSpec.describe Ci::PipelinePresenter do
describe '#all_related_merge_request_text' do
subject { presenter.all_related_merge_request_text }
- let(:mr_1) { create(:merge_request) }
- let(:mr_2) { create(:merge_request) }
+ let_it_be(:mr_1) { create(:merge_request) }
+ let_it_be(:mr_2) { create(:merge_request) }
context 'with zero related merge requests (branch pipeline)' do
it { is_expected.to eq('No related merge requests found.') }
@@ -242,7 +245,7 @@ RSpec.describe Ci::PipelinePresenter do
end
context 'permissions' do
- let(:merge_request) { create(:merge_request, :with_detached_merge_request_pipeline, source_project: project) }
+ let_it_be_with_refind(:merge_request) { create(:merge_request, :with_detached_merge_request_pipeline, source_project: project) }
let(:pipeline) { merge_request.all_pipelines.take }
shared_examples 'private merge requests' do
diff --git a/spec/presenters/event_presenter_spec.rb b/spec/presenters/event_presenter_spec.rb
index 6798be21d28..5a67fd92c9d 100644
--- a/spec/presenters/event_presenter_spec.rb
+++ b/spec/presenters/event_presenter_spec.rb
@@ -38,4 +38,34 @@ RSpec.describe EventPresenter do
it { is_expected.to eq([project, target]) }
end
end
+
+ describe '#target_type_name' do
+ it 'returns design for a design event' do
+ expect(build(:design_event).present).to have_attributes(target_type_name: 'design')
+ end
+
+ it 'returns project for a project event' do
+ expect(build(:project_created_event).present).to have_attributes(target_type_name: 'project')
+ end
+
+ it 'returns milestone for a milestone event' do
+ expect(group_event.present).to have_attributes(target_type_name: 'milestone')
+ end
+ end
+
+ describe '#note_target_type_name' do
+ it 'returns design for an event on a comment on a design' do
+ expect(build(:event, :commented, :for_design).present)
+ .to have_attributes(note_target_type_name: 'design')
+ end
+
+ it 'returns nil for an event without a target' do
+ expect(build(:event).present).to have_attributes(note_target_type_name: be_nil)
+ end
+
+ it 'returns issue for an issue comment event' do
+ expect(build(:event, :commented, target: build(:note_on_issue)).present)
+ .to have_attributes(note_target_type_name: 'issue')
+ end
+ end
end
diff --git a/spec/presenters/merge_request_presenter_spec.rb b/spec/presenters/merge_request_presenter_spec.rb
index f1e581efd44..76b77ee0de2 100644
--- a/spec/presenters/merge_request_presenter_spec.rb
+++ b/spec/presenters/merge_request_presenter_spec.rb
@@ -3,9 +3,9 @@
require 'spec_helper'
RSpec.describe MergeRequestPresenter do
- let(:resource) { create(:merge_request, source_project: project) }
- let(:project) { create(:project) }
- let(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:resource) { create(:merge_request, source_project: project) }
+ let_it_be(:user) { create(:user) }
describe '#ci_status' do
subject { described_class.new(resource).ci_status }
@@ -73,8 +73,6 @@ RSpec.describe MergeRequestPresenter do
end
describe '#conflict_resolution_path' do
- let(:project) { create :project }
- let(:user) { create :user }
let(:presenter) { described_class.new(resource, current_user: user) }
let(:path) { presenter.conflict_resolution_path }
@@ -107,18 +105,21 @@ RSpec.describe MergeRequestPresenter do
end
context 'issues links' do
- let(:project) { create(:project, :private, :repository, creator: user, namespace: user.namespace) }
- let(:issue_a) { create(:issue, project: project) }
- let(:issue_b) { create(:issue, project: project) }
+ let_it_be(:project) { create(:project, :private, :repository, creator: user, namespace: user.namespace) }
+ let_it_be(:issue_a) { create(:issue, project: project) }
+ let_it_be(:issue_b) { create(:issue, project: project) }
- let(:resource) do
+ let_it_be(:resource) do
create(:merge_request,
source_project: project, target_project: project,
description: "Fixes #{issue_a.to_reference} Related #{issue_b.to_reference}")
end
- before do
+ before_all do
project.add_developer(user)
+ end
+
+ before do
allow(resource.project).to receive(:default_branch)
.and_return(resource.target_branch)
resource.cache_merge_request_closes_issues!
diff --git a/spec/presenters/projects/prometheus/alert_presenter_spec.rb b/spec/presenters/projects/prometheus/alert_presenter_spec.rb
deleted file mode 100644
index 98dba28829e..00000000000
--- a/spec/presenters/projects/prometheus/alert_presenter_spec.rb
+++ /dev/null
@@ -1,346 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Projects::Prometheus::AlertPresenter do
- include Gitlab::Routing.url_helpers
-
- let_it_be(:project, reload: true) { create(:project) }
-
- let(:presenter) { described_class.new(alert) }
- let(:payload) { {} }
- let(:alert) { create(:alerting_alert, project: project, payload: payload) }
-
- shared_context 'gitlab alert' do
- let(:gitlab_alert) { create(:prometheus_alert, project: project) }
- let(:metric_id) { gitlab_alert.prometheus_metric_id }
-
- let(:alert) do
- create(:alerting_alert, project: project, metric_id: metric_id, payload: payload)
- end
- end
-
- describe '#project_full_path' do
- subject { presenter.project_full_path }
-
- it { is_expected.to eq(project.full_path) }
- end
-
- describe '#start_time' do
- subject { presenter.start_time }
-
- let(:starts_at) { '2020-10-31T14:02:04Z' }
-
- before do
- payload['startsAt'] = starts_at
- end
-
- context 'with valid utc datetime' do
- it { is_expected.to eq('31 October 2020, 2:02PM (UTC)') }
-
- context 'with admin time zone not UTC' do
- before do
- allow(Time).to receive(:zone).and_return(ActiveSupport::TimeZone.new('Perth'))
- end
-
- it { is_expected.to eq('31 October 2020, 2:02PM (UTC)') }
- end
- end
-
- context 'with invalid datetime' do
- let(:starts_at) { 'invalid' }
-
- it { is_expected.to be_nil }
- end
- end
-
- describe '#issue_summary_markdown' do
- let(:markdown_line_break) { ' ' }
-
- subject { presenter.issue_summary_markdown }
-
- context 'without default payload' do
- it do
- is_expected.to eq(
- <<~MARKDOWN.chomp
- **Start time:** #{presenter.start_time}
-
- MARKDOWN
- )
- end
- end
-
- context 'with optional attributes' do
- before do
- payload['annotations'] = {
- 'title' => 'Alert Title',
- 'foo' => 'value1',
- 'bar' => 'value2',
- 'description' => 'Alert Description',
- 'monitoring_tool' => 'monitoring_tool_name',
- 'service' => 'service_name',
- 'hosts' => ['http://localhost:3000', 'http://localhost:3001']
- }
- payload['generatorURL'] = 'http://host?g0.expr=query'
- end
-
- it do
- is_expected.to eq(
- <<~MARKDOWN.chomp
- **Start time:** #{presenter.start_time}#{markdown_line_break}
- **full_query:** `query`#{markdown_line_break}
- **Service:** service_name#{markdown_line_break}
- **Monitoring tool:** monitoring_tool_name#{markdown_line_break}
- **Hosts:** http://localhost:3000 http://localhost:3001
-
- MARKDOWN
- )
- end
- end
-
- context 'when hosts is a string' do
- before do
- payload['annotations'] = { 'hosts' => 'http://localhost:3000' }
- end
-
- it do
- is_expected.to eq(
- <<~MARKDOWN.chomp
- **Start time:** #{presenter.start_time}#{markdown_line_break}
- **Hosts:** http://localhost:3000
-
- MARKDOWN
- )
- end
- end
-
- context 'with embedded metrics' do
- let(:starts_at) { '2018-03-12T09:06:00Z' }
-
- shared_examples_for 'markdown with metrics embed' do
- let(:embed_regex) { /\n\[\]\(#{Regexp.quote(presenter.metrics_dashboard_url)}\)\z/ }
-
- context 'without a starting time available' do
- around do |example|
- Timecop.freeze(starts_at) { example.run }
- end
-
- before do
- payload.delete('startsAt')
- end
-
- it { is_expected.to match(embed_regex) }
- end
-
- context 'with a starting time available' do
- it { is_expected.to match(embed_regex) }
- end
- end
-
- context 'for gitlab-managed prometheus alerts' do
- include_context 'gitlab-managed prometheus alert attributes'
-
- let(:alert) do
- create(:alerting_alert, project: project, metric_id: prometheus_metric_id, payload: payload)
- end
-
- it_behaves_like 'markdown with metrics embed'
- end
-
- context 'for alerts from a self-managed prometheus' do
- include_context 'self-managed prometheus alert attributes'
-
- it_behaves_like 'markdown with metrics embed'
-
- context 'without y_label' do
- let(:y_label) { title }
-
- before do
- payload['annotations'].delete('gitlab_y_label')
- end
-
- it_behaves_like 'markdown with metrics embed'
- end
-
- context 'when not enough information is present for an embed' do
- shared_examples_for 'does not include an embed' do
- it { is_expected.not_to match(/\[\]\(.+\)/) }
- end
-
- context 'without title' do
- before do
- payload['annotations'].delete('title')
- end
-
- it_behaves_like 'does not include an embed'
- end
-
- context 'without environment' do
- before do
- payload['labels'].delete('gitlab_environment_name')
- end
-
- it_behaves_like 'does not include an embed'
- end
-
- context 'without full_query' do
- before do
- payload.delete('generatorURL')
- end
-
- it_behaves_like 'does not include an embed'
- end
- end
- end
- end
- end
-
- describe '#show_performance_dashboard_link?' do
- subject { presenter.show_performance_dashboard_link? }
-
- it { is_expected.to be_falsey }
-
- context 'with gitlab alert' do
- include_context 'gitlab alert'
-
- it { is_expected.to eq(true) }
- end
- end
-
- describe '#show_incident_issues_link?' do
- subject { presenter.show_incident_issues_link? }
-
- it { is_expected.to be_falsey }
-
- context 'create issue setting enabled' do
- before do
- create(:project_incident_management_setting, project: project, create_issue: true)
- end
-
- it { is_expected.to eq(true) }
- end
- end
-
- describe '#details_url' do
- subject { presenter.details_url }
-
- it { is_expected.to eq(nil) }
-
- context 'alert management alert present' do
- let_it_be(:am_alert) { create(:alert_management_alert, project: project) }
- let(:alert) { create(:alerting_alert, project: project, payload: payload, am_alert: am_alert) }
-
- it { is_expected.to eq("http://localhost/#{project.full_path}/-/alert_management/#{am_alert.iid}/details") }
- end
- end
-
- context 'with gitlab alert' do
- include_context 'gitlab alert'
-
- describe '#full_title' do
- let(:query_title) do
- "#{gitlab_alert.title} #{gitlab_alert.computed_operator} #{gitlab_alert.threshold} for 5 minutes"
- end
-
- let(:expected_subject) do
- "#{alert.environment.name}: #{query_title}"
- end
-
- subject { presenter.full_title }
-
- it { is_expected.to eq(expected_subject) }
- end
-
- describe '#metric_query' do
- subject { presenter.metric_query }
-
- it { is_expected.to eq(gitlab_alert.full_query) }
- end
-
- describe '#environment_name' do
- subject { presenter.environment_name }
-
- it { is_expected.to eq(alert.environment.name) }
- end
-
- describe '#performance_dashboard_link' do
- let(:expected_link) { metrics_project_environment_url(project, alert.environment) }
-
- subject { presenter.performance_dashboard_link }
-
- it { is_expected.to eq(expected_link) }
- end
-
- describe '#incident_issues_link' do
- let(:expected_link) { project_issues_url(project, label_name: described_class::INCIDENT_LABEL_NAME) }
-
- subject { presenter.incident_issues_link }
-
- it { is_expected.to eq(expected_link) }
- end
- end
-
- context 'without gitlab alert' do
- describe '#full_title' do
- subject { presenter.full_title }
-
- context 'with title' do
- let(:title) { 'some title' }
-
- before do
- expect(alert).to receive(:title).and_return(title)
- end
-
- it { is_expected.to eq(title) }
- end
-
- context 'without title' do
- it { is_expected.to eq('') }
- end
- end
-
- describe '#metric_query' do
- subject { presenter.metric_query }
-
- it { is_expected.to be_nil }
- end
-
- describe '#environment_name' do
- subject { presenter.environment_name }
-
- it { is_expected.to be_nil }
- end
-
- describe '#performance_dashboard_link' do
- let(:expected_link) { metrics_project_environments_url(project) }
-
- subject { presenter.performance_dashboard_link }
-
- it { is_expected.to eq(expected_link) }
- end
- end
-
- describe '#metrics_dashboard_url' do
- subject { presenter.metrics_dashboard_url }
-
- context 'for a non-prometheus alert' do
- it { is_expected.to be_nil }
- end
-
- context 'for a self-managed prometheus alert' do
- include_context 'self-managed prometheus alert attributes'
-
- let(:prometheus_payload) { payload }
-
- it { is_expected.to eq(dashboard_url_for_alert) }
- end
-
- context 'for a gitlab-managed prometheus alert' do
- include_context 'gitlab-managed prometheus alert attributes'
-
- let(:prometheus_payload) { payload }
-
- it { is_expected.to eq(dashboard_url_for_alert) }
- end
- end
-end
diff --git a/spec/presenters/sentry_error_presenter_spec.rb b/spec/presenters/sentry_error_presenter_spec.rb
index af9e7c8a2b2..86e43be1fa7 100644
--- a/spec/presenters/sentry_error_presenter_spec.rb
+++ b/spec/presenters/sentry_error_presenter_spec.rb
@@ -26,4 +26,12 @@ RSpec.describe SentryErrorPresenter do
expect(count).to eq error.frequency.first[1]
end
end
+
+ describe '#project_id' do
+ subject { presenter.project_id }
+
+ it 'returns a global ID of the correct type' do
+ expect(subject).to eq(Gitlab::GlobalId.build(model_name: 'SentryProject', id: error.project_id).to_s)
+ end
+ end
end
diff --git a/spec/presenters/snippet_blob_presenter_spec.rb b/spec/presenters/snippet_blob_presenter_spec.rb
index 915f43fe572..d7268c79a2c 100644
--- a/spec/presenters/snippet_blob_presenter_spec.rb
+++ b/spec/presenters/snippet_blob_presenter_spec.rb
@@ -3,70 +3,75 @@
require 'spec_helper'
RSpec.describe SnippetBlobPresenter do
+ let_it_be(:snippet) { create(:personal_snippet, :repository) }
+
+ let(:branch) { snippet.default_branch }
+ let(:blob) { snippet.blobs.first }
+
describe '#rich_data' do
+ let(:data_endpoint_url) { "/-/snippets/#{snippet.id}/raw/#{branch}/#{file}" }
+
before do
allow_next_instance_of(described_class) do |instance|
allow(instance).to receive(:current_user).and_return(nil)
end
+
+ blob.name = File.basename(file)
+ blob.path = file
end
- subject { described_class.new(snippet.blob).rich_data }
+ subject { described_class.new(blob).rich_data }
context 'with PersonalSnippet' do
- let(:snippet) { create(:personal_snippet, :repository) }
-
context 'when blob is binary' do
- it 'returns the HTML associated with the binary' do
- allow(snippet).to receive(:blob).and_return(snippet.repository.blob_at('master', 'files/images/logo-black.png'))
+ let(:file) { 'files/images/logo-black.png' }
+ let(:blob) { blob_at(file) }
+ it 'returns the HTML associated with the binary' do
expect(subject).to include('file-content image_file')
end
end
context 'with markdown format' do
- let(:snippet) { create(:personal_snippet, file_name: 'test.md', content: '*foo*') }
+ let(:file) { 'README.md' }
+ let(:blob) { blob_at(file) }
it 'returns rich markdown content' do
- expected = <<~HTML
- <div class="file-content md">
- <p data-sourcepos="1:1-1:5" dir="auto"><em>foo</em></p>
- </div>
- HTML
-
- expect(subject).to eq(expected)
+ expect(subject).to include('file-content md')
end
end
context 'with notebook format' do
- let(:snippet) { create(:personal_snippet, file_name: 'test.ipynb') }
+ let(:file) { 'test.ipynb' }
it 'returns rich notebook content' do
- expect(subject.strip).to eq %Q(<div class="file-content" data-endpoint="/-/snippets/#{snippet.id}/raw" id="js-notebook-viewer"></div>)
+ expect(subject.strip).to eq %Q(<div class="file-content" data-endpoint="#{data_endpoint_url}" id="js-notebook-viewer"></div>)
end
end
context 'with openapi format' do
- let(:snippet) { create(:personal_snippet, file_name: 'openapi.yml') }
+ let(:file) { 'openapi.yml' }
it 'returns rich openapi content' do
- expect(subject).to eq %Q(<div class="file-content" data-endpoint="/-/snippets/#{snippet.id}/raw" id="js-openapi-viewer"></div>\n)
+ expect(subject).to eq %Q(<div class="file-content" data-endpoint="#{data_endpoint_url}" id="js-openapi-viewer"></div>\n)
end
end
context 'with svg format' do
- let(:snippet) { create(:personal_snippet, file_name: 'test.svg') }
+ let(:file) { 'files/images/wm.svg' }
+ let(:blob) { blob_at(file) }
it 'returns rich svg content' do
result = Nokogiri::HTML::DocumentFragment.parse(subject)
image_tag = result.search('img').first
- expect(image_tag.attr('src')).to include("data:#{snippet.blob.mime_type};base64")
- expect(image_tag.attr('alt')).to eq('test.svg')
+ expect(image_tag.attr('src')).to include("data:#{blob.mime_type};base64")
+ expect(image_tag.attr('alt')).to eq(File.basename(file))
end
end
context 'with other format' do
- let(:snippet) { create(:personal_snippet, file_name: 'test') }
+ let(:file) { 'test' }
it 'does not return no rich content' do
expect(subject).to be_nil
@@ -76,36 +81,41 @@ RSpec.describe SnippetBlobPresenter do
end
describe '#plain_data' do
- let(:snippet) { build(:personal_snippet) }
+ let(:blob) { blob_at(file) }
- subject { described_class.new(snippet.blob).plain_data }
+ subject { described_class.new(blob).plain_data }
- it 'returns nil when the snippet blob is binary' do
- allow(snippet.blob).to receive(:binary?).and_return(true)
+ context 'when blob is binary' do
+ let(:file) { 'files/images/logo-black.png' }
- expect(subject).to be_nil
+ it 'returns nil' do
+ expect(subject).to be_nil
+ end
end
- it 'returns plain content when snippet file is markup' do
- snippet.file_name = 'test.md'
- snippet.content = '*foo*'
+ context 'when blob is markup' do
+ let(:file) { 'README.md' }
- expect(subject).to eq '<span id="LC1" class="line" lang="markdown"><span class="ge">*foo*</span></span>'
+ it 'returns plain content' do
+ expect(subject).to include('<span id="LC1" class="line" lang="markdown">')
+ end
end
- it 'returns highlighted syntax content' do
- snippet.file_name = 'test.rb'
- snippet.content = 'class Foo;end'
+ context 'when blob has syntax' do
+ let(:file) { 'files/ruby/regex.rb' }
- expect(subject)
- .to eq '<span id="LC1" class="line" lang="ruby"><span class="k">class</span> <span class="nc">Foo</span><span class="p">;</span><span class="k">end</span></span>'
+ it 'returns highlighted syntax content' do
+ expect(subject)
+ .to include '<span id="LC1" class="line" lang="ruby"><span class="k">module</span> <span class="nn">Gitlab</span>'
+ end
end
- it 'returns plain text highlighted content' do
- snippet.file_name = 'test'
- snippet.content = 'foo'
+ context 'when blob has plain data' do
+ let(:file) { 'LICENSE' }
- expect(subject).to eq '<span id="LC1" class="line" lang="plaintext">foo</span>'
+ it 'returns plain text highlighted content' do
+ expect(subject).to include('<span id="LC1" class="line" lang="plaintext">The MIT License (MIT)</span>')
+ end
end
end
@@ -179,4 +189,8 @@ RSpec.describe SnippetBlobPresenter do
end
end
end
+
+ def blob_at(path)
+ snippet.repository.blob_at(branch, path)
+ end
end
diff --git a/spec/presenters/snippet_presenter_spec.rb b/spec/presenters/snippet_presenter_spec.rb
index 681564ed2b0..66c6ba8fa0e 100644
--- a/spec/presenters/snippet_presenter_spec.rb
+++ b/spec/presenters/snippet_presenter_spec.rb
@@ -163,25 +163,4 @@ RSpec.describe SnippetPresenter do
end
end
end
-
- describe '#blobs' do
- let(:snippet) { personal_snippet }
-
- subject { presenter.blobs }
-
- context 'when snippet does not have a repository' do
- it 'returns an array with one SnippetBlob' do
- expect(subject.size).to eq(1)
- expect(subject.first).to eq(snippet.blob)
- end
- end
-
- context 'when snippet has a repository' do
- let(:snippet) { create(:snippet, :repository, author: user) }
-
- it 'returns an array with all repository blobs' do
- expect(subject).to match_array(snippet.blobs)
- end
- end
- end
end
diff --git a/spec/requests/api/admin/instance_clusters_spec.rb b/spec/requests/api/admin/instance_clusters_spec.rb
index b68541b5d92..9d0661089a9 100644
--- a/spec/requests/api/admin/instance_clusters_spec.rb
+++ b/spec/requests/api/admin/instance_clusters_spec.rb
@@ -162,6 +162,7 @@ RSpec.describe ::API::Admin::InstanceClusters do
name: 'test-instance-cluster',
domain: 'domain.example.com',
managed: false,
+ namespace_per_environment: false,
platform_kubernetes_attributes: platform_kubernetes_attributes,
clusterable: clusterable
}
@@ -206,6 +207,7 @@ RSpec.describe ::API::Admin::InstanceClusters do
expect(cluster_result.enabled).to eq(true)
expect(platform_kubernetes.authorization_type).to eq('rbac')
expect(cluster_result.managed).to be_falsy
+ expect(cluster_result.namespace_per_environment).to eq(false)
expect(platform_kubernetes.api_url).to eq("https://example.com")
expect(platform_kubernetes.token).to eq('sample-token')
end
@@ -235,6 +237,22 @@ RSpec.describe ::API::Admin::InstanceClusters do
end
end
+ context 'when namespace_per_environment is not set' do
+ let(:cluster_params) do
+ {
+ name: 'test-cluster',
+ domain: 'domain.example.com',
+ platform_kubernetes_attributes: platform_kubernetes_attributes
+ }
+ end
+
+ it 'defaults to true' do
+ cluster_result = Clusters::Cluster.find(json_response['id'])
+
+ expect(cluster_result).to be_namespace_per_environment
+ end
+ end
+
context 'when an instance cluster already exists' do
it 'allows user to add multiple clusters' do
post api('/admin/clusters/add', admin_user), params: multiple_cluster_params
diff --git a/spec/requests/api/ci/runner/jobs_artifacts_spec.rb b/spec/requests/api/ci/runner/jobs_artifacts_spec.rb
index 97110b63ff6..71be0c30f5a 100644
--- a/spec/requests/api/ci/runner/jobs_artifacts_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_artifacts_spec.rb
@@ -227,10 +227,6 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
context 'authorize uploading of an lsif artifact' do
- before do
- stub_feature_flags(code_navigation: job.project)
- end
-
it 'adds ProcessLsif header' do
authorize_artifacts_with_token_in_headers(artifact_type: :lsif)
@@ -249,32 +245,6 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
.to change { Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(tracking_params) }
.by(1)
end
-
- context 'code_navigation feature flag is disabled' do
- before do
- stub_feature_flags(code_navigation: false)
- end
-
- it 'responds with a forbidden error' do
- authorize_artifacts_with_token_in_headers(artifact_type: :lsif)
-
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:forbidden)
- expect(json_response['ProcessLsif']).to be_falsy
- end
- end
-
- it 'does not track code_intelligence usage ping' do
- tracking_params = {
- event_names: 'i_source_code_code_intelligence',
- start_date: Date.yesterday,
- end_date: Date.today
- }
-
- expect { authorize_artifacts_with_token_in_headers(artifact_type: :lsif) }
- .not_to change { Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(tracking_params) }
- end
- end
end
def authorize_artifacts(params = {}, request_headers = headers)
diff --git a/spec/requests/api/ci/runner/jobs_put_spec.rb b/spec/requests/api/ci/runner/jobs_put_spec.rb
index 183a3b26e00..92d38621105 100644
--- a/spec/requests/api/ci/runner/jobs_put_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_put_spec.rb
@@ -46,64 +46,59 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
context 'when status is given' do
- it 'mark job as succeeded' do
+ it 'marks job as succeeded' do
update_job(state: 'success')
- job.reload
- expect(job).to be_success
+ expect(job.reload).to be_success
+ expect(response.header).not_to have_key('X-GitLab-Trace-Update-Interval')
end
- it 'mark job as failed' do
+ it 'marks job as failed' do
update_job(state: 'failed')
- job.reload
- expect(job).to be_failed
+ expect(job.reload).to be_failed
expect(job).to be_unknown_failure
+ expect(response.header).not_to have_key('X-GitLab-Trace-Update-Interval')
end
context 'when failure_reason is script_failure' do
before do
update_job(state: 'failed', failure_reason: 'script_failure')
- job.reload
end
- it { expect(job).to be_script_failure }
+ it { expect(job.reload).to be_script_failure }
end
context 'when failure_reason is runner_system_failure' do
before do
update_job(state: 'failed', failure_reason: 'runner_system_failure')
- job.reload
end
- it { expect(job).to be_runner_system_failure }
+ it { expect(job.reload).to be_runner_system_failure }
end
context 'when failure_reason is unrecognized value' do
before do
update_job(state: 'failed', failure_reason: 'what_is_this')
- job.reload
end
- it { expect(job).to be_unknown_failure }
+ it { expect(job.reload).to be_unknown_failure }
end
context 'when failure_reason is job_execution_timeout' do
before do
update_job(state: 'failed', failure_reason: 'job_execution_timeout')
- job.reload
end
- it { expect(job).to be_job_execution_timeout }
+ it { expect(job.reload).to be_job_execution_timeout }
end
context 'when failure_reason is unmet_prerequisites' do
before do
update_job(state: 'failed', failure_reason: 'unmet_prerequisites')
- job.reload
end
- it { expect(job).to be_unmet_prerequisites }
+ it { expect(job.reload).to be_unmet_prerequisites }
end
context 'when unmigrated live trace chunks exist' do
@@ -119,24 +114,21 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
expect(job.pending_state).to be_present
expect(response).to have_gitlab_http_status(:accepted)
+ expect(response.header['X-GitLab-Trace-Update-Interval']).to be > 0
end
end
context 'when runner retries request after receiving 202' do
it 'responds with 202 and then with 200', :sidekiq_inline do
- perform_enqueued_jobs do
- update_job(state: 'success', checksum: 'crc32:12345678')
- end
+ update_job(state: 'success', checksum: 'crc32:12345678')
- expect(job.reload.pending_state).to be_present
expect(response).to have_gitlab_http_status(:accepted)
+ expect(job.reload.pending_state).to be_present
- perform_enqueued_jobs do
- update_job(state: 'success', checksum: 'crc32:12345678')
- end
+ update_job(state: 'success', checksum: 'crc32:12345678')
- expect(job.reload.pending_state).not_to be_present
expect(response).to have_gitlab_http_status(:ok)
+ expect(job.reload.pending_state).not_to be_present
end
end
@@ -149,8 +141,9 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
update_job(state: 'success', checksum: 'crc:12345678')
expect(job.reload).to be_success
- expect(job.pending_state).not_to be_present
+ expect(job.pending_state).to be_present
expect(response).to have_gitlab_http_status(:ok)
+ expect(response.header).not_to have_key('X-GitLab-Trace-Update-Interval')
end
end
end
diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb
index d34244771ad..98c1e0228d4 100644
--- a/spec/requests/api/commits_spec.rb
+++ b/spec/requests/api/commits_spec.rb
@@ -36,6 +36,13 @@ RSpec.describe API::Commits do
end
it 'include correct pagination headers' do
+ get api(route, current_user)
+
+ expect(response).to include_limited_pagination_headers
+ end
+
+ it 'includes the total headers when the count is not disabled' do
+ stub_feature_flags(api_commits_without_count: false)
commit_count = project.repository.count_commits(ref: 'master').to_s
get api(route, current_user)
@@ -79,12 +86,10 @@ RSpec.describe API::Commits do
it 'include correct pagination headers' do
commits = project.repository.commits("master", limit: 2)
after = commits.second.created_at
- commit_count = project.repository.count_commits(ref: 'master', after: after).to_s
get api("/projects/#{project_id}/repository/commits?since=#{after.utc.iso8601}", user)
- expect(response).to include_pagination_headers
- expect(response.headers['X-Total']).to eq(commit_count)
+ expect(response).to include_limited_pagination_headers
expect(response.headers['X-Page']).to eql('1')
end
end
@@ -109,12 +114,10 @@ RSpec.describe API::Commits do
it 'include correct pagination headers' do
commits = project.repository.commits("master", limit: 2)
before = commits.second.created_at
- commit_count = project.repository.count_commits(ref: 'master', before: before).to_s
get api("/projects/#{project_id}/repository/commits?until=#{before.utc.iso8601}", user)
- expect(response).to include_pagination_headers
- expect(response.headers['X-Total']).to eq(commit_count)
+ expect(response).to include_limited_pagination_headers
expect(response.headers['X-Page']).to eql('1')
end
end
@@ -137,49 +140,49 @@ RSpec.describe API::Commits do
context "path optional parameter" do
it "returns project commits matching provided path parameter" do
path = 'files/ruby/popen.rb'
- commit_count = project.repository.count_commits(ref: 'master', path: path).to_s
get api("/projects/#{project_id}/repository/commits?path=#{path}", user)
expect(json_response.size).to eq(3)
expect(json_response.first["id"]).to eq("570e7b2abdd848b95f2f578043fc23bd6f6fd24d")
- expect(response).to include_pagination_headers
- expect(response.headers['X-Total']).to eq(commit_count)
+ expect(response).to include_limited_pagination_headers
end
it 'include correct pagination headers' do
path = 'files/ruby/popen.rb'
- commit_count = project.repository.count_commits(ref: 'master', path: path).to_s
get api("/projects/#{project_id}/repository/commits?path=#{path}", user)
- expect(response).to include_pagination_headers
- expect(response.headers['X-Total']).to eq(commit_count)
+ expect(response).to include_limited_pagination_headers
expect(response.headers['X-Page']).to eql('1')
end
end
context 'all optional parameter' do
it 'returns all project commits' do
- commit_count = project.repository.count_commits(all: true)
+ expected_commit_ids = project.repository.commits(nil, all: true, limit: 50).map(&:id)
+
+ get api("/projects/#{project_id}/repository/commits?all=true&per_page=50", user)
- get api("/projects/#{project_id}/repository/commits?all=true", user)
+ commit_ids = json_response.map { |c| c['id'] }
- expect(response).to include_pagination_headers
- expect(response.headers['X-Total']).to eq(commit_count.to_s)
+ expect(response).to include_limited_pagination_headers
+ expect(commit_ids).to eq(expected_commit_ids)
expect(response.headers['X-Page']).to eql('1')
end
end
context 'first_parent optional parameter' do
it 'returns all first_parent commits' do
- commit_count = project.repository.count_commits(ref: SeedRepo::Commit::ID, first_parent: true)
+ expected_commit_ids = project.repository.commits(SeedRepo::Commit::ID, limit: 50, first_parent: true).map(&:id)
- get api("/projects/#{project_id}/repository/commits", user), params: { ref_name: SeedRepo::Commit::ID, first_parent: 'true' }
+ get api("/projects/#{project_id}/repository/commits?per_page=50", user), params: { ref_name: SeedRepo::Commit::ID, first_parent: 'true' }
- expect(response).to include_pagination_headers
- expect(commit_count).to eq(12)
- expect(response.headers['X-Total']).to eq(commit_count.to_s)
+ commit_ids = json_response.map { |c| c['id'] }
+
+ expect(response).to include_limited_pagination_headers
+ expect(expected_commit_ids.size).to eq(12)
+ expect(commit_ids).to eq(expected_commit_ids)
end
end
@@ -209,11 +212,7 @@ RSpec.describe API::Commits do
end
it 'returns correct headers' do
- commit_count = project.repository.count_commits(ref: ref_name).to_s
-
- expect(response).to include_pagination_headers
- expect(response.headers['X-Total']).to eq(commit_count)
- expect(response.headers['X-Page']).to eq('1')
+ expect(response).to include_limited_pagination_headers
expect(response.headers['Link']).to match(/page=1&per_page=5/)
expect(response.headers['Link']).to match(/page=2&per_page=5/)
end
@@ -972,7 +971,7 @@ RSpec.describe API::Commits do
refs.concat(project.repository.tag_names_contains(commit_id).map {|name| ['tag', name]})
expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
+ expect(response).to include_limited_pagination_headers
expect(json_response).to be_an Array
expect(json_response.map { |r| [r['type'], r['name']] }.compact).to eq(refs)
end
@@ -1262,7 +1261,7 @@ RSpec.describe API::Commits do
get api(route, current_user)
expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
+ expect(response).to include_limited_pagination_headers
expect(json_response.size).to be >= 1
expect(json_response.first.keys).to include 'diff'
end
@@ -1276,7 +1275,7 @@ RSpec.describe API::Commits do
get api(route, current_user)
expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
+ expect(response).to include_limited_pagination_headers
expect(json_response.size).to be <= 1
end
end
@@ -1914,7 +1913,7 @@ RSpec.describe API::Commits do
get api("/projects/#{project.id}/repository/commits/#{commit.id}/merge_requests", user)
expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
+ expect(response).to include_limited_pagination_headers
expect(json_response.length).to eq(1)
expect(json_response[0]['id']).to eq(merged_mr.id)
end
diff --git a/spec/requests/api/debian_group_packages_spec.rb b/spec/requests/api/debian_group_packages_spec.rb
new file mode 100644
index 00000000000..8a05d20fb33
--- /dev/null
+++ b/spec/requests/api/debian_group_packages_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe API::DebianGroupPackages do
+ include HttpBasicAuthHelpers
+ include WorkhorseHelpers
+
+ include_context 'Debian repository shared context', :group do
+ describe 'GET groups/:id/-/packages/debian/dists/*distribution/Release.gpg' do
+ let(:url) { "/groups/#{group.id}/-/packages/debian/dists/#{distribution}/Release.gpg" }
+
+ it_behaves_like 'Debian group repository GET endpoint', :not_found, nil
+ end
+
+ describe 'GET groups/:id/-/packages/debian/dists/*distribution/Release' do
+ let(:url) { "/groups/#{group.id}/-/packages/debian/dists/#{distribution}/Release" }
+
+ it_behaves_like 'Debian group repository GET endpoint', :success, 'TODO Release'
+ end
+
+ describe 'GET groups/:id/-/packages/debian/dists/*distribution/InRelease' do
+ let(:url) { "/groups/#{group.id}/-/packages/debian/dists/#{distribution}/InRelease" }
+
+ it_behaves_like 'Debian group repository GET endpoint', :not_found, nil
+ end
+
+ describe 'GET groups/:id/-/packages/debian/dists/*distribution/:component/binary-:architecture/Packages' do
+ let(:url) { "/groups/#{group.id}/-/packages/debian/dists/#{distribution}/#{component}/binary-#{architecture}/Packages" }
+
+ it_behaves_like 'Debian group repository GET endpoint', :success, 'TODO Packages'
+ end
+
+ describe 'GET groups/:id/-/packages/debian/pool/:component/:letter/:source_package/:file_name' do
+ let(:url) { "/groups/#{group.id}/-/packages/debian/pool/#{component}/#{letter}/#{source_package}/#{package_name}_#{package_version}_#{architecture}.deb" }
+
+ it_behaves_like 'Debian group repository GET endpoint', :success, 'TODO File'
+ end
+ end
+end
diff --git a/spec/requests/api/debian_project_packages_spec.rb b/spec/requests/api/debian_project_packages_spec.rb
new file mode 100644
index 00000000000..d2f208d0079
--- /dev/null
+++ b/spec/requests/api/debian_project_packages_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe API::DebianProjectPackages do
+ include HttpBasicAuthHelpers
+ include WorkhorseHelpers
+
+ include_context 'Debian repository shared context', :project do
+ describe 'GET projects/:id/-/packages/debian/dists/*distribution/Release.gpg' do
+ let(:url) { "/projects/#{project.id}/-/packages/debian/dists/#{distribution}/Release.gpg" }
+
+ it_behaves_like 'Debian project repository GET endpoint', :not_found, nil
+ end
+
+ describe 'GET projects/:id/-/packages/debian/dists/*distribution/Release' do
+ let(:url) { "/projects/#{project.id}/-/packages/debian/dists/#{distribution}/Release" }
+
+ it_behaves_like 'Debian project repository GET endpoint', :success, 'TODO Release'
+ end
+
+ describe 'GET projects/:id/-/packages/debian/dists/*distribution/InRelease' do
+ let(:url) { "/projects/#{project.id}/-/packages/debian/dists/#{distribution}/InRelease" }
+
+ it_behaves_like 'Debian project repository GET endpoint', :not_found, nil
+ end
+
+ describe 'GET projects/:id/-/packages/debian/dists/*distribution/:component/binary-:architecture/Packages' do
+ let(:url) { "/projects/#{project.id}/-/packages/debian/dists/#{distribution}/#{component}/binary-#{architecture}/Packages" }
+
+ it_behaves_like 'Debian project repository GET endpoint', :success, 'TODO Packages'
+ end
+
+ describe 'GET projects/:id/-/packages/debian/pool/:component/:letter/:source_package/:file_name' do
+ let(:url) { "/projects/#{project.id}/-/packages/debian/pool/#{component}/#{letter}/#{source_package}/#{package_name}_#{package_version}_#{architecture}.deb" }
+
+ it_behaves_like 'Debian project repository GET endpoint', :success, 'TODO File'
+ end
+
+ describe 'PUT projects/:id/-/packages/debian/incoming/:file_name' do
+ let(:method) { :put }
+ let(:url) { "/projects/#{project.id}/-/packages/debian/incoming/#{file_name}" }
+
+ it_behaves_like 'Debian project repository PUT endpoint', :created, nil
+ end
+ end
+end
diff --git a/spec/requests/api/features_spec.rb b/spec/requests/api/features_spec.rb
index 2746e777306..3f443b4f92b 100644
--- a/spec/requests/api/features_spec.rb
+++ b/spec/requests/api/features_spec.rb
@@ -12,6 +12,8 @@ RSpec.describe API::Features, stub_feature_flags: false do
Flipper.register(:perf_team) do |actor|
actor.respond_to?(:admin) && actor.admin?
end
+
+ skip_feature_flags_yaml_validation
end
describe 'GET /features' do
diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb
index bb4e88f97f8..f77f127ddc8 100644
--- a/spec/requests/api/files_spec.rb
+++ b/spec/requests/api/files_spec.rb
@@ -747,7 +747,7 @@ RSpec.describe API::Files do
it "updates existing file in project repo with accepts correct last commit id" do
last_commit = Gitlab::Git::Commit
- .last_for_path(project.repository, 'master', URI.unescape(file_path))
+ .last_for_path(project.repository, 'master', Addressable::URI.unencode_component(file_path))
params_with_correct_id = params.merge(last_commit_id: last_commit.id)
put api(route(file_path), user), params: params_with_correct_id
@@ -757,7 +757,7 @@ RSpec.describe API::Files do
it "returns 400 when file path is invalid" do
last_commit = Gitlab::Git::Commit
- .last_for_path(project.repository, 'master', URI.unescape(file_path))
+ .last_for_path(project.repository, 'master', Addressable::URI.unencode_component(file_path))
params_with_correct_id = params.merge(last_commit_id: last_commit.id)
put api(route(rouge_file_path), user), params: params_with_correct_id
@@ -769,7 +769,7 @@ RSpec.describe API::Files do
it_behaves_like 'when path is absolute' do
let(:last_commit) do
Gitlab::Git::Commit
- .last_for_path(project.repository, 'master', URI.unescape(file_path))
+ .last_for_path(project.repository, 'master', Addressable::URI.unencode_component(file_path))
end
let(:params_with_correct_id) { params.merge(last_commit_id: last_commit.id) }
diff --git a/spec/requests/api/generic_packages_spec.rb b/spec/requests/api/generic_packages_spec.rb
index ed852fe75c7..2cb686167f1 100644
--- a/spec/requests/api/generic_packages_spec.rb
+++ b/spec/requests/api/generic_packages_spec.rb
@@ -4,79 +4,432 @@ require 'spec_helper'
RSpec.describe API::GenericPackages do
let_it_be(:personal_access_token) { create(:personal_access_token) }
- let_it_be(:project) { create(:project) }
+ let_it_be(:project, reload: true) { create(:project) }
+ let(:workhorse_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') }
+ let(:workhorse_header) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token } }
+ let(:user) { personal_access_token.user }
+ let(:ci_build) { create(:ci_build, :running, user: user) }
- describe 'GET /api/v4/projects/:id/packages/generic/ping' do
- let(:user) { personal_access_token.user }
- let(:auth_token) { personal_access_token.token }
+ def auth_header
+ return {} if user_role == :anonymous
+ case authenticate_with
+ when :personal_access_token
+ personal_access_token_header
+ when :job_token
+ job_token_header
+ when :invalid_personal_access_token
+ personal_access_token_header('wrong token')
+ when :invalid_job_token
+ job_token_header('wrong token')
+ end
+ end
+
+ def personal_access_token_header(value = nil)
+ { Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER => value || personal_access_token.token }
+ end
+
+ def job_token_header(value = nil)
+ { Gitlab::Auth::AuthFinders::JOB_TOKEN_HEADER => value || ci_build.token }
+ end
+
+ shared_examples 'secure endpoint' do
before do
project.add_developer(user)
end
- context 'packages feature is disabled' do
- it 'responds with 404 Not Found' do
- stub_packages_setting(enabled: false)
+ it 'rejects malicious request' do
+ subject
- ping(personal_access_token: auth_token)
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
- expect(response).to have_gitlab_http_status(:not_found)
+ describe 'PUT /api/v4/projects/:id/packages/generic/:package_name/:package_version/:file_name/authorize' do
+ context 'with valid project' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:project_visibility, :user_role, :member?, :authenticate_with, :expected_status) do
+ 'PUBLIC' | :developer | true | :personal_access_token | :success
+ 'PUBLIC' | :guest | true | :personal_access_token | :forbidden
+ 'PUBLIC' | :developer | true | :invalid_personal_access_token | :unauthorized
+ 'PUBLIC' | :guest | true | :invalid_personal_access_token | :unauthorized
+ 'PUBLIC' | :developer | false | :personal_access_token | :forbidden
+ 'PUBLIC' | :guest | false | :personal_access_token | :forbidden
+ 'PUBLIC' | :developer | false | :invalid_personal_access_token | :unauthorized
+ 'PUBLIC' | :guest | false | :invalid_personal_access_token | :unauthorized
+ 'PUBLIC' | :anonymous | false | :none | :unauthorized
+ 'PRIVATE' | :developer | true | :personal_access_token | :success
+ 'PRIVATE' | :guest | true | :personal_access_token | :forbidden
+ 'PRIVATE' | :developer | true | :invalid_personal_access_token | :unauthorized
+ 'PRIVATE' | :guest | true | :invalid_personal_access_token | :unauthorized
+ 'PRIVATE' | :developer | false | :personal_access_token | :not_found
+ 'PRIVATE' | :guest | false | :personal_access_token | :not_found
+ 'PRIVATE' | :developer | false | :invalid_personal_access_token | :unauthorized
+ 'PRIVATE' | :guest | false | :invalid_personal_access_token | :unauthorized
+ 'PRIVATE' | :anonymous | false | :none | :unauthorized
+ 'PUBLIC' | :developer | true | :job_token | :success
+ 'PUBLIC' | :developer | true | :invalid_job_token | :unauthorized
+ 'PUBLIC' | :developer | false | :job_token | :forbidden
+ 'PUBLIC' | :developer | false | :invalid_job_token | :unauthorized
+ 'PRIVATE' | :developer | true | :job_token | :success
+ 'PRIVATE' | :developer | true | :invalid_job_token | :unauthorized
+ 'PRIVATE' | :developer | false | :job_token | :not_found
+ 'PRIVATE' | :developer | false | :invalid_job_token | :unauthorized
+ end
+
+ with_them do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility, false))
+ project.send("add_#{user_role}", user) if member? && user_role != :anonymous
+ end
+
+ it "responds with #{params[:expected_status]}" do
+ authorize_upload_file(workhorse_header.merge(auth_header))
+
+ expect(response).to have_gitlab_http_status(expected_status)
+ end
+ end
+ end
+
+ context 'application security' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:param_name, :param_value) do
+ :package_name | 'my-package/../'
+ :package_name | 'my-package%2f%2e%2e%2f'
+ :file_name | '../.ssh%2fauthorized_keys'
+ :file_name | '%2e%2e%2f.ssh%2fauthorized_keys'
+ end
+
+ with_them do
+ subject { authorize_upload_file(workhorse_header.merge(personal_access_token_header), param_name => param_value) }
+
+ it_behaves_like 'secure endpoint'
end
end
context 'generic_packages feature flag is disabled' do
it 'responds with 404 Not Found' do
stub_feature_flags(generic_packages: false)
+ project.add_developer(user)
- ping(personal_access_token: auth_token)
+ authorize_upload_file(workhorse_header.merge(personal_access_token_header))
expect(response).to have_gitlab_http_status(:not_found)
end
end
- context 'generic_packages feature flag is enabled' do
+ def authorize_upload_file(request_headers, package_name: 'mypackage', file_name: 'myfile.tar.gz')
+ url = "/projects/#{project.id}/packages/generic/#{package_name}/0.0.1/#{file_name}/authorize"
+
+ put api(url), headers: request_headers
+ end
+ end
+
+ describe 'PUT /api/v4/projects/:id/packages/generic/:package_name/:package_version/:file_name' do
+ include WorkhorseHelpers
+
+ let(:file_upload) { fixture_file_upload('spec/fixtures/packages/generic/myfile.tar.gz') }
+ let(:params) { { file: file_upload } }
+
+ context 'authentication' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:project_visibility, :user_role, :member?, :authenticate_with, :expected_status) do
+ 'PUBLIC' | :guest | true | :personal_access_token | :forbidden
+ 'PUBLIC' | :developer | true | :invalid_personal_access_token | :unauthorized
+ 'PUBLIC' | :guest | true | :invalid_personal_access_token | :unauthorized
+ 'PUBLIC' | :developer | false | :personal_access_token | :forbidden
+ 'PUBLIC' | :guest | false | :personal_access_token | :forbidden
+ 'PUBLIC' | :developer | false | :invalid_personal_access_token | :unauthorized
+ 'PUBLIC' | :guest | false | :invalid_personal_access_token | :unauthorized
+ 'PUBLIC' | :anonymous | false | :none | :unauthorized
+ 'PRIVATE' | :guest | true | :personal_access_token | :forbidden
+ 'PRIVATE' | :developer | true | :invalid_personal_access_token | :unauthorized
+ 'PRIVATE' | :guest | true | :invalid_personal_access_token | :unauthorized
+ 'PRIVATE' | :developer | false | :personal_access_token | :not_found
+ 'PRIVATE' | :guest | false | :personal_access_token | :not_found
+ 'PRIVATE' | :developer | false | :invalid_personal_access_token | :unauthorized
+ 'PRIVATE' | :guest | false | :invalid_personal_access_token | :unauthorized
+ 'PRIVATE' | :anonymous | false | :none | :unauthorized
+ 'PUBLIC' | :developer | true | :invalid_job_token | :unauthorized
+ 'PUBLIC' | :developer | false | :job_token | :forbidden
+ 'PUBLIC' | :developer | false | :invalid_job_token | :unauthorized
+ 'PRIVATE' | :developer | true | :invalid_job_token | :unauthorized
+ 'PRIVATE' | :developer | false | :job_token | :not_found
+ 'PRIVATE' | :developer | false | :invalid_job_token | :unauthorized
+ end
+
+ with_them do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility, false))
+ project.send("add_#{user_role}", user) if member? && user_role != :anonymous
+ end
+
+ it "responds with #{params[:expected_status]}" do
+ headers = workhorse_header.merge(auth_header)
+
+ upload_file(params, headers)
+
+ expect(response).to have_gitlab_http_status(expected_status)
+ end
+ end
+ end
+
+ context 'when user can upload packages and has valid credentials' do
before do
- stub_feature_flags(generic_packages: true)
+ project.add_developer(user)
end
- context 'authenticating using personal access token' do
- it 'responds with 200 OK when valid personal access token is provided' do
- ping(personal_access_token: auth_token)
+ it 'creates package and package file when valid personal access token is used' do
+ headers = workhorse_header.merge(personal_access_token_header)
+
+ expect { upload_file(params, headers) }
+ .to change { project.packages.generic.count }.by(1)
+ .and change { Packages::PackageFile.count }.by(1)
+
+ aggregate_failures do
+ expect(response).to have_gitlab_http_status(:created)
- expect(response).to have_gitlab_http_status(:ok)
+ package = project.packages.generic.last
+ expect(package.name).to eq('mypackage')
+ expect(package.version).to eq('0.0.1')
+ expect(package.build_info).to be_nil
+
+ package_file = package.package_files.last
+ expect(package_file.file_name).to eq('myfile.tar.gz')
end
+ end
+
+ it 'creates package, package file, and package build info when valid job token is used' do
+ headers = workhorse_header.merge(job_token_header)
+
+ expect { upload_file(params, headers) }
+ .to change { project.packages.generic.count }.by(1)
+ .and change { Packages::PackageFile.count }.by(1)
- it 'responds with 401 Unauthorized when invalid personal access token provided' do
- ping(personal_access_token: 'invalid-token')
+ aggregate_failures do
+ expect(response).to have_gitlab_http_status(:created)
- expect(response).to have_gitlab_http_status(:unauthorized)
+ package = project.packages.generic.last
+ expect(package.name).to eq('mypackage')
+ expect(package.version).to eq('0.0.1')
+ expect(package.build_info.pipeline).to eq(ci_build.pipeline)
+
+ package_file = package.package_files.last
+ expect(package_file.file_name).to eq('myfile.tar.gz')
end
end
- context 'authenticating using job token' do
- it 'responds with 200 OK when valid job token is provided' do
- job_token = create(:ci_build, :running, user: user).token
+ context 'event tracking' do
+ subject { upload_file(params, workhorse_header.merge(personal_access_token_header)) }
+
+ it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package'
+ end
+
+ it 'rejects request without a file from workhorse' do
+ headers = workhorse_header.merge(personal_access_token_header)
+ upload_file({}, headers)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'rejects request without an auth token' do
+ upload_file(params, workhorse_header)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ it 'rejects request without workhorse rewritten fields' do
+ headers = workhorse_header.merge(personal_access_token_header)
+ upload_file(params, headers, send_rewritten_field: false)
- ping(job_token: job_token)
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
- expect(response).to have_gitlab_http_status(:ok)
+ it 'rejects request if file size is too large' do
+ allow_next_instance_of(UploadedFile) do |uploaded_file|
+ allow(uploaded_file).to receive(:size).and_return(project.actual_limits.generic_packages_max_file_size + 1)
end
- it 'responds with 401 Unauthorized when invalid job token provided' do
- ping(job_token: 'invalid-token')
+ headers = workhorse_header.merge(personal_access_token_header)
+ upload_file(params, headers)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'rejects request without workhorse header' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).once
- expect(response).to have_gitlab_http_status(:unauthorized)
+ upload_file(params, personal_access_token_header)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'application security' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:param_name, :param_value) do
+ :package_name | 'my-package/../'
+ :package_name | 'my-package%2f%2e%2e%2f'
+ :file_name | '../.ssh%2fauthorized_keys'
+ :file_name | '%2e%2e%2f.ssh%2fauthorized_keys'
+ end
+
+ with_them do
+ subject { upload_file(params, workhorse_header.merge(personal_access_token_header), param_name => param_value) }
+
+ it_behaves_like 'secure endpoint'
+ end
+ end
+
+ def upload_file(params, request_headers, send_rewritten_field: true, package_name: 'mypackage', file_name: 'myfile.tar.gz')
+ url = "/projects/#{project.id}/packages/generic/#{package_name}/0.0.1/#{file_name}"
+
+ workhorse_finalize(
+ api(url),
+ method: :put,
+ file_key: :file,
+ params: params,
+ headers: request_headers,
+ send_rewritten_field: send_rewritten_field
+ )
+ end
+ end
+
+ describe 'GET /api/v4/projects/:id/packages/generic/:package_name/:package_version/:file_name' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:package) { create(:generic_package, project: project) }
+ let_it_be(:package_file) { create(:package_file, :generic, package: package) }
+
+ context 'authentication' do
+ where(:project_visibility, :user_role, :member?, :authenticate_with, :expected_status) do
+ 'PUBLIC' | :developer | true | :personal_access_token | :success
+ 'PUBLIC' | :guest | true | :personal_access_token | :success
+ 'PUBLIC' | :developer | true | :invalid_personal_access_token | :unauthorized
+ 'PUBLIC' | :guest | true | :invalid_personal_access_token | :unauthorized
+ 'PUBLIC' | :developer | false | :personal_access_token | :success
+ 'PUBLIC' | :guest | false | :personal_access_token | :success
+ 'PUBLIC' | :developer | false | :invalid_personal_access_token | :unauthorized
+ 'PUBLIC' | :guest | false | :invalid_personal_access_token | :unauthorized
+ 'PUBLIC' | :anonymous | false | :none | :unauthorized
+ 'PRIVATE' | :developer | true | :personal_access_token | :success
+ 'PRIVATE' | :guest | true | :personal_access_token | :forbidden
+ 'PRIVATE' | :developer | true | :invalid_personal_access_token | :unauthorized
+ 'PRIVATE' | :guest | true | :invalid_personal_access_token | :unauthorized
+ 'PRIVATE' | :developer | false | :personal_access_token | :not_found
+ 'PRIVATE' | :guest | false | :personal_access_token | :not_found
+ 'PRIVATE' | :developer | false | :invalid_personal_access_token | :unauthorized
+ 'PRIVATE' | :guest | false | :invalid_personal_access_token | :unauthorized
+ 'PRIVATE' | :anonymous | false | :none | :unauthorized
+ 'PUBLIC' | :developer | true | :job_token | :success
+ 'PUBLIC' | :developer | true | :invalid_job_token | :unauthorized
+ 'PUBLIC' | :developer | false | :job_token | :success
+ 'PUBLIC' | :developer | false | :invalid_job_token | :unauthorized
+ 'PRIVATE' | :developer | true | :job_token | :success
+ 'PRIVATE' | :developer | true | :invalid_job_token | :unauthorized
+ 'PRIVATE' | :developer | false | :job_token | :not_found
+ 'PRIVATE' | :developer | false | :invalid_job_token | :unauthorized
+ end
+
+ with_them do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility, false))
+ project.send("add_#{user_role}", user) if member? && user_role != :anonymous
+ end
+
+ it "responds with #{params[:expected_status]}" do
+ download_file(auth_header)
+
+ expect(response).to have_gitlab_http_status(expected_status)
end
end
end
- def ping(personal_access_token: nil, job_token: nil)
- headers = {
- Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER => personal_access_token.presence,
- Gitlab::Auth::AuthFinders::JOB_TOKEN_HEADER => job_token.presence
- }.compact
+ context 'event tracking' do
+ before do
+ project.add_developer(user)
+ end
+
+ subject { download_file(personal_access_token_header) }
+
+ it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package'
+ end
+
+ it 'rejects a malicious file name request' do
+ project.add_developer(user)
+
+ download_file(personal_access_token_header, file_name: '../.ssh%2fauthorized_keys')
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'rejects a malicious file name request' do
+ project.add_developer(user)
+
+ download_file(personal_access_token_header, file_name: '%2e%2e%2f.ssh%2fauthorized_keys')
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'rejects a malicious package name request' do
+ project.add_developer(user)
+
+ download_file(personal_access_token_header, package_name: 'my-package/../')
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'rejects a malicious package name request' do
+ project.add_developer(user)
+
+ download_file(personal_access_token_header, package_name: 'my-package%2f%2e%2e%2f')
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ context 'application security' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:param_name, :param_value) do
+ :package_name | 'my-package/../'
+ :package_name | 'my-package%2f%2e%2e%2f'
+ :file_name | '../.ssh%2fauthorized_keys'
+ :file_name | '%2e%2e%2f.ssh%2fauthorized_keys'
+ end
+
+ with_them do
+ subject { download_file(personal_access_token_header, param_name => param_value) }
+
+ it_behaves_like 'secure endpoint'
+ end
+ end
+
+ it 'responds with 404 Not Found for non existing package' do
+ project.add_developer(user)
+
+ download_file(personal_access_token_header, package_name: 'no-such-package')
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'responds with 404 Not Found for non existing package file' do
+ project.add_developer(user)
+
+ download_file(personal_access_token_header, file_name: 'no-such-file')
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ def download_file(request_headers, package_name: nil, file_name: nil)
+ package_name ||= package.name
+ file_name ||= package_file.file_name
+ url = "/projects/#{project.id}/packages/generic/#{package_name}/#{package.version}/#{file_name}"
- get api('/projects/%d/packages/generic/ping' % project.id), headers: headers
+ get api(url), headers: request_headers
end
end
end
diff --git a/spec/requests/api/graphql/gitlab_schema_spec.rb b/spec/requests/api/graphql/gitlab_schema_spec.rb
index ee7dba545be..fe1c7c15de2 100644
--- a/spec/requests/api/graphql/gitlab_schema_spec.rb
+++ b/spec/requests/api/graphql/gitlab_schema_spec.rb
@@ -190,7 +190,9 @@ RSpec.describe 'GitlabSchema configurations' do
variables: {}.to_s,
complexity: 181,
depth: 13,
- duration_s: 7
+ duration_s: 7,
+ used_fields: an_instance_of(Array),
+ used_deprecated_fields: an_instance_of(Array)
}
expect_any_instance_of(Gitlab::Graphql::QueryAnalyzers::LoggerAnalyzer).to receive(:duration).and_return(7)
diff --git a/spec/requests/api/graphql/mutations/boards/lists/destroy_spec.rb b/spec/requests/api/graphql/mutations/boards/lists/destroy_spec.rb
new file mode 100644
index 00000000000..42f690f53ed
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/boards/lists/destroy_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Boards::Lists::Destroy do
+ include GraphqlHelpers
+
+ let_it_be(:current_user, reload: true) { create(:user) }
+ let_it_be(:project, reload: true) { create(:project) }
+ let_it_be(:board) { create(:board, project: project) }
+ let_it_be(:list) { create(:list, board: board) }
+ let(:mutation) do
+ variables = {
+ list_id: GitlabSchema.id_from_object(list).to_s
+ }
+
+ graphql_mutation(:destroy_board_list, variables)
+ end
+
+ subject { post_graphql_mutation(mutation, current_user: current_user) }
+
+ def mutation_response
+ graphql_mutation_response(:destroy_board_list)
+ end
+
+ context 'when the user does not have permission' do
+ it_behaves_like 'a mutation that returns a top-level access error'
+
+ it 'does not destroy the list' do
+ expect { subject }.not_to change { List.count }
+ end
+ end
+
+ context 'when the user has permission' do
+ before do
+ project.add_maintainer(current_user)
+ end
+
+ context 'when given id is not for a list' do
+ let_it_be(:list) { build_stubbed(:issue, project: project) }
+
+ it 'returns an error' do
+ subject
+
+ expect(graphql_errors.first['message']).to include('does not represent an instance of List')
+ end
+ end
+
+ context 'when everything is ok' do
+ it 'destroys the list' do
+ expect { subject }.to change { List.count }.from(2).to(1)
+ end
+
+ it 'returns an empty list' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(mutation_response).to have_key('list')
+ expect(mutation_response['list']).to be_nil
+ end
+ end
+
+ context 'when the list is not destroyable' do
+ let_it_be(:list) { create(:list, board: board, list_type: :backlog) }
+
+ it 'does not destroy the list' do
+ expect { subject }.not_to change { List.count }.from(3)
+ end
+
+ it 'returns an error and not nil list' do
+ subject
+
+ expect(mutation_response['errors']).not_to be_empty
+ expect(mutation_response['list']).not_to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/snippets/create_spec.rb b/spec/requests/api/graphql/mutations/snippets/create_spec.rb
index 1bb446de708..a708c3fdf1f 100644
--- a/spec/requests/api/graphql/mutations/snippets/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/create_spec.rb
@@ -79,18 +79,20 @@ RSpec.describe 'Creating a Snippet' do
end
shared_examples 'creates snippet' do
- it 'returns the created Snippet' do
+ it 'returns the created Snippet', :aggregate_failures do
expect do
subject
end.to change { Snippet.count }.by(1)
+ snippet = Snippet.last
+ created_file_1 = snippet.repository.blob_at('HEAD', file_1[:filePath])
+ created_file_2 = snippet.repository.blob_at('HEAD', file_2[:filePath])
+
+ expect(created_file_1.data).to match(file_1[:content])
+ expect(created_file_2.data).to match(file_2[:content])
expect(mutation_response['snippet']['title']).to eq(title)
expect(mutation_response['snippet']['description']).to eq(description)
expect(mutation_response['snippet']['visibilityLevel']).to eq(visibility_level)
- expect(mutation_response['snippet']['blobs'][0]['plainData']).to match(file_1[:content])
- expect(mutation_response['snippet']['blobs'][0]['fileName']).to match(file_1[:file_path])
- expect(mutation_response['snippet']['blobs'][1]['plainData']).to match(file_2[:content])
- expect(mutation_response['snippet']['blobs'][1]['fileName']).to match(file_2[:file_path])
end
context 'when action is invalid' do
diff --git a/spec/requests/api/graphql/mutations/snippets/update_spec.rb b/spec/requests/api/graphql/mutations/snippets/update_spec.rb
index 58ce74b9263..67a9869c001 100644
--- a/spec/requests/api/graphql/mutations/snippets/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/update_spec.rb
@@ -73,7 +73,6 @@ RSpec.describe 'Updating a Snippet' do
aggregate_failures do
expect(blob_to_update.data).to eq updated_content
expect(blob_to_delete).to be_nil
- expect(blob_in_mutation_response(updated_file)['plainData']).to match(updated_content)
expect(mutation_response['snippet']['title']).to eq(updated_title)
expect(mutation_response['snippet']['description']).to eq(updated_description)
expect(mutation_response['snippet']['visibilityLevel']).to eq('public')
@@ -100,7 +99,6 @@ RSpec.describe 'Updating a Snippet' do
aggregate_failures do
expect(blob_at(updated_file).data).to eq blob_to_update.data
expect(blob_at(deleted_file).data).to eq blob_to_delete.data
- expect(blob_in_mutation_response(deleted_file)['plainData']).not_to be_nil
expect(mutation_response['snippet']['title']).to eq(original_title)
expect(mutation_response['snippet']['description']).to eq(original_description)
expect(mutation_response['snippet']['visibilityLevel']).to eq('private')
@@ -108,10 +106,6 @@ RSpec.describe 'Updating a Snippet' do
end
end
- def blob_in_mutation_response(filename)
- mutation_response['snippet']['blobs'].select { |blob| blob['name'] == filename }[0]
- end
-
def blob_at(filename)
snippet.repository.blob_at('HEAD', filename)
end
diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb
index 5d4276f47ca..40fec6ba068 100644
--- a/spec/requests/api/graphql/project/issues_spec.rb
+++ b/spec/requests/api/graphql/project/issues_spec.rb
@@ -53,16 +53,37 @@ RSpec.describe 'getting an issue list for a project' do
context 'when limiting the number of results' do
let(:query) do
- graphql_query_for(
- 'project',
- { 'fullPath' => project.full_path },
- "issues(first: 1) { #{fields} }"
- )
+ <<~GQL
+ query($path: ID!, $n: Int) {
+ project(fullPath: $path) {
+ issues(first: $n) { #{fields} }
+ }
+ }
+ GQL
+ end
+
+ let(:issue_limit) { 1 }
+ let(:variables) do
+ { path: project.full_path, n: issue_limit }
end
it_behaves_like 'a working graphql query' do
before do
- post_graphql(query, current_user: current_user)
+ post_graphql(query, current_user: current_user, variables: variables)
+ end
+
+ it 'only returns N issues' do
+ expect(issues_data.size).to eq(issue_limit)
+ end
+ end
+
+ context 'no limit is provided' do
+ let(:issue_limit) { nil }
+
+ it 'returns all issues' do
+ post_graphql(query, current_user: current_user, variables: variables)
+
+ expect(issues_data.size).to be > 1
end
end
@@ -71,7 +92,7 @@ RSpec.describe 'getting an issue list for a project' do
# Newest first, we only want to see the newest checked
expect(Ability).not_to receive(:allowed?).with(current_user, :read_issue, issues.first)
- post_graphql(query, current_user: current_user)
+ post_graphql(query, current_user: current_user, variables: variables)
end
end
diff --git a/spec/requests/api/graphql_spec.rb b/spec/requests/api/graphql_spec.rb
index ff1a5aa1540..94a66f54e4d 100644
--- a/spec/requests/api/graphql_spec.rb
+++ b/spec/requests/api/graphql_spec.rb
@@ -9,7 +9,15 @@ RSpec.describe 'GraphQL' do
context 'logging' do
shared_examples 'logging a graphql query' do
let(:expected_params) do
- { query_string: query, variables: variables.to_s, duration_s: anything, depth: 1, complexity: 1 }
+ {
+ query_string: query,
+ variables: variables.to_s,
+ duration_s: anything,
+ depth: 1,
+ complexity: 1,
+ used_fields: ['Query.echo'],
+ used_deprecated_fields: []
+ }
end
it 'logs a query with the expected params' do
diff --git a/spec/requests/api/group_clusters_spec.rb b/spec/requests/api/group_clusters_spec.rb
index 068af1485e2..eb21ae9468c 100644
--- a/spec/requests/api/group_clusters_spec.rb
+++ b/spec/requests/api/group_clusters_spec.rb
@@ -172,6 +172,7 @@ RSpec.describe API::GroupClusters do
name: 'test-cluster',
domain: 'domain.example.com',
managed: false,
+ namespace_per_environment: false,
platform_kubernetes_attributes: platform_kubernetes_attributes,
management_project_id: management_project_id
}
@@ -206,6 +207,7 @@ RSpec.describe API::GroupClusters do
expect(cluster_result.domain).to eq('domain.example.com')
expect(cluster_result.managed).to be_falsy
expect(cluster_result.management_project_id).to eq management_project_id
+ expect(cluster_result.namespace_per_environment).to eq(false)
expect(platform_kubernetes.rbac?).to be_truthy
expect(platform_kubernetes.api_url).to eq(api_url)
expect(platform_kubernetes.token).to eq('sample-token')
@@ -237,6 +239,22 @@ RSpec.describe API::GroupClusters do
end
end
+ context 'when namespace_per_environment is not set' do
+ let(:cluster_params) do
+ {
+ name: 'test-cluster',
+ domain: 'domain.example.com',
+ platform_kubernetes_attributes: platform_kubernetes_attributes
+ }
+ end
+
+ it 'defaults to true' do
+ cluster_result = Clusters::Cluster.find(json_response['id'])
+
+ expect(cluster_result).to be_namespace_per_environment
+ end
+ end
+
context 'current user does not have access to management_project_id' do
let(:management_project_id) { create(:project).id }
diff --git a/spec/requests/api/group_container_repositories_spec.rb b/spec/requests/api/group_container_repositories_spec.rb
index 3128becae6d..4b97fad79dd 100644
--- a/spec/requests/api/group_container_repositories_spec.rb
+++ b/spec/requests/api/group_container_repositories_spec.rb
@@ -44,7 +44,7 @@ RSpec.describe API::GroupContainerRepositories do
let(:object) { group }
end
- it_behaves_like 'a gitlab tracking event', described_class.name, 'list_repositories'
+ it_behaves_like 'a package tracking event', described_class.name, 'list_repositories'
context 'with invalid group id' do
let(:url) { "/groups/#{non_existing_record_id}/registry/repositories" }
diff --git a/spec/requests/api/group_packages_spec.rb b/spec/requests/api/group_packages_spec.rb
index f67cafbd8f5..72ba25c59af 100644
--- a/spec/requests/api/group_packages_spec.rb
+++ b/spec/requests/api/group_packages_spec.rb
@@ -77,7 +77,7 @@ RSpec.describe API::GroupPackages do
it_behaves_like 'returns packages', :group, :owner
it_behaves_like 'returns packages', :group, :maintainer
it_behaves_like 'returns packages', :group, :developer
- it_behaves_like 'rejects packages access', :group, :reporter, :forbidden
+ it_behaves_like 'returns packages', :group, :reporter
it_behaves_like 'rejects packages access', :group, :guest, :forbidden
context 'with subgroup' do
@@ -88,7 +88,7 @@ RSpec.describe API::GroupPackages do
it_behaves_like 'returns packages with subgroups', :group, :owner
it_behaves_like 'returns packages with subgroups', :group, :maintainer
it_behaves_like 'returns packages with subgroups', :group, :developer
- it_behaves_like 'rejects packages access', :group, :reporter, :forbidden
+ it_behaves_like 'returns packages with subgroups', :group, :reporter
it_behaves_like 'rejects packages access', :group, :guest, :forbidden
context 'excluding subgroup' do
@@ -97,7 +97,7 @@ RSpec.describe API::GroupPackages do
it_behaves_like 'returns packages', :group, :owner
it_behaves_like 'returns packages', :group, :maintainer
it_behaves_like 'returns packages', :group, :developer
- it_behaves_like 'rejects packages access', :group, :reporter, :forbidden
+ it_behaves_like 'returns packages', :group, :reporter
it_behaves_like 'rejects packages access', :group, :guest, :forbidden
end
end
diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb
index da423e986c3..c7756a4fae5 100644
--- a/spec/requests/api/groups_spec.rb
+++ b/spec/requests/api/groups_spec.rb
@@ -1391,6 +1391,139 @@ RSpec.describe API::Groups do
end
end
+ describe 'GET /groups/:id/descendant_groups' do
+ let_it_be(:child_group1) { create(:group, parent: group1) }
+ let_it_be(:private_child_group1) { create(:group, :private, parent: group1) }
+ let_it_be(:sub_child_group1) { create(:group, parent: child_group1) }
+ let_it_be(:child_group2) { create(:group, :private, parent: group2) }
+ let_it_be(:sub_child_group2) { create(:group, :private, parent: child_group2) }
+ let(:response_groups) { json_response.map { |group| group['name'] } }
+
+ context 'when unauthenticated' do
+ it 'returns only public descendants' do
+ get api("/groups/#{group1.id}/descendant_groups")
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(2)
+ expect(response_groups).to contain_exactly(child_group1.name, sub_child_group1.name)
+ end
+
+ it 'returns 404 for a private group' do
+ get api("/groups/#{group2.id}/descendant_groups")
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when authenticated as user' do
+ context 'when user is not member of a public group' do
+ it 'returns no descendants for the public group' do
+ get api("/groups/#{group1.id}/descendant_groups", user2)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(0)
+ end
+
+ context 'when using all_available in request' do
+ it 'returns public descendants' do
+ get api("/groups/#{group1.id}/descendant_groups", user2), params: { all_available: true }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(2)
+ expect(response_groups).to contain_exactly(child_group1.name, sub_child_group1.name)
+ end
+ end
+ end
+
+ context 'when user is not member of a private group' do
+ it 'returns 404 for the private group' do
+ get api("/groups/#{group2.id}/descendant_groups", user1)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when user is member of public group' do
+ before do
+ group1.add_guest(user2)
+ end
+
+ it 'returns private descendants' do
+ get api("/groups/#{group1.id}/descendant_groups", user2)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(3)
+ expect(response_groups).to contain_exactly(child_group1.name, sub_child_group1.name, private_child_group1.name)
+ end
+
+ context 'when using statistics in request' do
+ it 'does not include statistics' do
+ get api("/groups/#{group1.id}/descendant_groups", user2), params: { statistics: true }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ expect(json_response.first).not_to include 'statistics'
+ end
+ end
+ end
+
+ context 'when user is member of private group' do
+ before do
+ group2.add_guest(user1)
+ end
+
+ it 'returns descendants' do
+ get api("/groups/#{group2.id}/descendant_groups", user1)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(2)
+ expect(response_groups).to contain_exactly(child_group2.name, sub_child_group2.name)
+ end
+ end
+ end
+
+ context 'when authenticated as admin' do
+ it 'returns private descendants of a public group' do
+ get api("/groups/#{group1.id}/descendant_groups", admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(3)
+ end
+
+ it 'returns descendants of a private group' do
+ get api("/groups/#{group2.id}/descendant_groups", admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ expect(json_response.length).to eq(2)
+ end
+
+ it 'does not include statistics by default' do
+ get api("/groups/#{group1.id}/descendant_groups", admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ expect(json_response.first).not_to include('statistics')
+ end
+
+ it 'includes statistics if requested' do
+ get api("/groups/#{group1.id}/descendant_groups", admin), params: { statistics: true }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ expect(json_response.first).to include('statistics')
+ end
+ end
+ end
+
describe "POST /groups" do
it_behaves_like 'group avatar upload' do
def make_upload_request
diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb
index 9c0ea14e3e3..91d10791541 100644
--- a/spec/requests/api/helpers_spec.rb
+++ b/spec/requests/api/helpers_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe API::Helpers do
include described_class
include TermsHelper
- let(:user) { create(:user) }
+ let_it_be(:user, reload: true) { create(:user) }
let(:admin) { create(:admin) }
let(:key) { create(:key, user: user) }
@@ -243,6 +243,67 @@ RSpec.describe API::Helpers do
end
end
end
+
+ describe "when authenticating using a job token" do
+ let_it_be(:job, reload: true) do
+ create(:ci_build, user: user, status: :running)
+ end
+
+ let(:route_authentication_setting) { {} }
+
+ before do
+ allow_any_instance_of(self.class).to receive(:route_authentication_setting)
+ .and_return(route_authentication_setting)
+ end
+
+ context 'when route is allowed to be authenticated' do
+ let(:route_authentication_setting) { { job_token_allowed: true } }
+
+ it "returns a 401 response for an invalid token" do
+ env[Gitlab::Auth::AuthFinders::JOB_TOKEN_HEADER] = 'invalid token'
+
+ expect { current_user }.to raise_error /401/
+ end
+
+ it "returns a 401 response for a job that's not running" do
+ job.update!(status: :success)
+ env[Gitlab::Auth::AuthFinders::JOB_TOKEN_HEADER] = job.token
+
+ expect { current_user }.to raise_error /401/
+ end
+
+ it "returns a 403 response for a user without access" do
+ env[Gitlab::Auth::AuthFinders::JOB_TOKEN_HEADER] = job.token
+ allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(false)
+
+ expect { current_user }.to raise_error /403/
+ end
+
+ it 'returns a 403 response for a user who is blocked' do
+ user.block!
+ env[Gitlab::Auth::AuthFinders::JOB_TOKEN_HEADER] = job.token
+
+ expect { current_user }.to raise_error /403/
+ end
+
+ it "sets current_user" do
+ env[Gitlab::Auth::AuthFinders::JOB_TOKEN_HEADER] = job.token
+
+ expect(current_user).to eq(user)
+ end
+ end
+
+ context 'when route is not allowed to be authenticated' do
+ let(:route_authentication_setting) { { job_token_allowed: false } }
+
+ it "sets current_user to nil" do
+ env[Gitlab::Auth::AuthFinders::JOB_TOKEN_HEADER] = job.token
+ allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(true)
+
+ expect(current_user).to be_nil
+ end
+ end
+ end
end
describe '.handle_api_exception' do
diff --git a/spec/requests/api/internal/base_spec.rb b/spec/requests/api/internal/base_spec.rb
index 4a0a7c81781..e13b492ecc8 100644
--- a/spec/requests/api/internal/base_spec.rb
+++ b/spec/requests/api/internal/base_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe API::Internal::Base do
+ include APIInternalBaseHelpers
+
let_it_be(:user, reload: true) { create(:user) }
let_it_be(:project, reload: true) { create(:project, :repository, :wiki_repo) }
let_it_be(:personal_snippet) { create(:personal_snippet, :repository, author: user) }
@@ -1207,88 +1209,6 @@ RSpec.describe API::Internal::Base do
end
end
- def gl_repository_for(container)
- case container
- when ProjectWiki
- Gitlab::GlRepository::WIKI.identifier_for_container(container.project)
- when Project
- Gitlab::GlRepository::PROJECT.identifier_for_container(container)
- when Snippet
- Gitlab::GlRepository::SNIPPET.identifier_for_container(container)
- else
- nil
- end
- end
-
- def full_path_for(container)
- case container
- when PersonalSnippet
- "snippets/#{container.id}"
- when ProjectSnippet
- "#{container.project.full_path}/snippets/#{container.id}"
- else
- container.full_path
- end
- end
-
- def pull(key, container, protocol = 'ssh')
- post(
- api("/internal/allowed"),
- params: {
- key_id: key.id,
- project: full_path_for(container),
- gl_repository: gl_repository_for(container),
- action: 'git-upload-pack',
- secret_token: secret_token,
- protocol: protocol
- }
- )
- end
-
- def push(key, container, protocol = 'ssh', env: nil, changes: nil)
- push_with_path(key,
- full_path: full_path_for(container),
- gl_repository: gl_repository_for(container),
- protocol: protocol,
- env: env,
- changes: changes)
- end
-
- def push_with_path(key, full_path:, gl_repository: nil, protocol: 'ssh', env: nil, changes: nil)
- changes ||= 'd14d6c0abdd253381df51a723d58691b2ee1ab08 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/master'
-
- params = {
- changes: changes,
- key_id: key.id,
- project: full_path,
- action: 'git-receive-pack',
- secret_token: secret_token,
- protocol: protocol,
- env: env
- }
- params[:gl_repository] = gl_repository if gl_repository
-
- post(
- api("/internal/allowed"),
- params: params
- )
- end
-
- def archive(key, container)
- post(
- api("/internal/allowed"),
- params: {
- ref: 'master',
- key_id: key.id,
- project: full_path_for(container),
- gl_repository: gl_repository_for(container),
- action: 'git-upload-archive',
- secret_token: secret_token,
- protocol: 'ssh'
- }
- )
- end
-
def lfs_auth_project(project)
post(
api("/internal/lfs_authenticate"),
diff --git a/spec/requests/api/internal/lfs_spec.rb b/spec/requests/api/internal/lfs_spec.rb
new file mode 100644
index 00000000000..4739ec62992
--- /dev/null
+++ b/spec/requests/api/internal/lfs_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Internal::Lfs do
+ include APIInternalBaseHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:lfs_object) { create(:lfs_object, :with_file) }
+ let_it_be(:lfs_objects_project) { create(:lfs_objects_project, project: project, lfs_object: lfs_object) }
+ let_it_be(:gl_repository) { "project-#{project.id}" }
+ let_it_be(:filename) { lfs_object.file.path }
+
+ let(:secret_token) { Gitlab::Shell.secret_token }
+
+ describe 'GET /internal/lfs' do
+ let(:valid_params) do
+ { oid: lfs_object.oid, gl_repository: gl_repository, secret_token: secret_token }
+ end
+
+ context 'with invalid auth' do
+ let(:invalid_params) { valid_params.merge!(secret_token: 'invalid_tokne') }
+
+ it 'returns 401' do
+ get api("/internal/lfs"), params: invalid_params
+ end
+ end
+
+ context 'with valid auth' do
+ context 'LFS in local storage' do
+ it 'sends the file' do
+ get api("/internal/lfs"), params: valid_params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers['Content-Type']).to eq('application/octet-stream')
+ expect(response.headers['Content-Length'].to_i).to eq(File.stat(filename).size)
+ expect(response.body).to eq(File.open(filename, 'rb', &:read))
+ end
+
+ # https://www.rubydoc.info/github/rack/rack/master/Rack/Sendfile
+ it 'delegates sending to Web server' do
+ get api("/internal/lfs"), params: valid_params, env: { 'HTTP_X_SENDFILE_TYPE' => 'X-Sendfile' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers['Content-Type']).to eq('application/octet-stream')
+ expect(response.headers['Content-Length'].to_i).to eq(0)
+ expect(response.headers['X-Sendfile']).to be_present
+ expect(response.body).to eq("")
+ end
+
+ it 'retuns 404 for unknown file' do
+ params = valid_params.merge(oid: SecureRandom.hex)
+
+ get api("/internal/lfs"), params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'returns 404 if LFS object does not belong to project' do
+ other_lfs = create(:lfs_object, :with_file)
+ params = valid_params.merge(oid: other_lfs.oid)
+
+ get api("/internal/lfs"), params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'LFS in object storage' do
+ let!(:lfs_object2) { create(:lfs_object, :with_file) }
+ let!(:lfs_objects_project2) { create(:lfs_objects_project, project: project, lfs_object: lfs_object2) }
+ let(:valid_params) do
+ { oid: lfs_object2.oid, gl_repository: gl_repository, secret_token: secret_token }
+ end
+
+ before do
+ stub_lfs_object_storage(enabled: true)
+ lfs_object2.file.migrate!(LfsObjectUploader::Store::REMOTE)
+ end
+
+ it 'notifies Workhorse to send the file' do
+ get api("/internal/lfs"), params: valid_params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("send-url:")
+ expect(response.headers['Content-Type']).to eq('application/octet-stream')
+ expect(response.headers['Content-Length'].to_i).to eq(0)
+ expect(response.body).to eq("")
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/lint_spec.rb b/spec/requests/api/lint_spec.rb
index 4c60c8bd2a3..6b5a4b6436a 100644
--- a/spec/requests/api/lint_spec.rb
+++ b/spec/requests/api/lint_spec.rb
@@ -17,23 +17,52 @@ RSpec.describe API::Lint do
expect(json_response['status']).to eq('valid')
expect(json_response['errors']).to eq([])
end
+
+ it 'outputs expanded yaml content' do
+ post api('/ci/lint'), params: { content: yaml_content, include_merged_yaml: true }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to have_key('merged_yaml')
+ end
end
context 'with an invalid .gitlab_ci.yml' do
- it 'responds with errors about invalid syntax' do
- post api('/ci/lint'), params: { content: 'invalid content' }
+ context 'with invalid syntax' do
+ let(:yaml_content) { 'invalid content' }
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['status']).to eq('invalid')
- expect(json_response['errors']).to eq(['Invalid configuration format'])
+ it 'responds with errors about invalid syntax' do
+ post api('/ci/lint'), params: { content: yaml_content }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['status']).to eq('invalid')
+ expect(json_response['errors']).to eq(['Invalid configuration format'])
+ end
+
+ it 'outputs expanded yaml content' do
+ post api('/ci/lint'), params: { content: yaml_content, include_merged_yaml: true }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to have_key('merged_yaml')
+ end
end
- it "responds with errors about invalid configuration" do
- post api('/ci/lint'), params: { content: '{ image: "ruby:2.7", services: ["postgres"] }' }
+ context 'with invalid configuration' do
+ let(:yaml_content) { '{ image: "ruby:2.7", services: ["postgres"] }' }
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['status']).to eq('invalid')
- expect(json_response['errors']).to eq(['jobs config should contain at least one visible job'])
+ it 'responds with errors about invalid configuration' do
+ post api('/ci/lint'), params: { content: yaml_content }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['status']).to eq('invalid')
+ expect(json_response['errors']).to eq(['jobs config should contain at least one visible job'])
+ end
+
+ it 'outputs expanded yaml content' do
+ post api('/ci/lint'), params: { content: yaml_content, include_merged_yaml: true }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to have_key('merged_yaml')
+ end
end
end
diff --git a/spec/requests/api/maven_packages_spec.rb b/spec/requests/api/maven_packages_spec.rb
index 0a23aed109b..a67bc157e5a 100644
--- a/spec/requests/api/maven_packages_spec.rb
+++ b/spec/requests/api/maven_packages_spec.rb
@@ -36,7 +36,7 @@ RSpec.describe API::MavenPackages do
context 'with jar file' do
let_it_be(:package_file) { jar_file }
- it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package'
+ it_behaves_like 'a package tracking event', described_class.name, 'pull_package'
end
end
@@ -571,7 +571,7 @@ RSpec.describe API::MavenPackages do
context 'event tracking' do
subject { upload_file_with_token(params) }
- it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package'
+ it_behaves_like 'a package tracking event', described_class.name, 'push_package'
end
it 'creates package and stores package file' do
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index 2757c56e0fe..0e5fa24ad66 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -1140,7 +1140,7 @@ RSpec.describe API::MergeRequests do
context 'when a merge request has more than the changes limit' do
it "returns a string indicating that more changes were made" do
- stub_const('Commit::DIFF_HARD_LIMIT_FILES', 5)
+ allow(Commit).to receive(:diff_hard_limit_files).and_return(5)
merge_request_overflow = create(:merge_request, :simple,
author: user,
diff --git a/spec/requests/api/npm_packages_spec.rb b/spec/requests/api/npm_packages_spec.rb
index 108ea84b7e6..8a3ccd7c6e3 100644
--- a/spec/requests/api/npm_packages_spec.rb
+++ b/spec/requests/api/npm_packages_spec.rb
@@ -88,12 +88,16 @@ RSpec.describe API::NpmPackages do
it_behaves_like 'returning the npm package info'
context 'with unknown package' do
+ subject { get api("/packages/npm/unknown") }
+
it 'returns a redirect' do
- get api("/packages/npm/unknown")
+ subject
expect(response).to have_gitlab_http_status(:found)
expect(response.headers['Location']).to eq('https://registry.npmjs.org/unknown')
end
+
+ it_behaves_like 'a gitlab tracking event', described_class.name, 'npm_request_forward'
end
end
@@ -193,7 +197,7 @@ RSpec.describe API::NpmPackages do
expect(response.media_type).to eq('application/octet-stream')
end
- it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package'
+ it_behaves_like 'a package tracking event', described_class.name, 'pull_package'
end
context 'private project' do
@@ -301,7 +305,7 @@ RSpec.describe API::NpmPackages do
context 'with access token' do
subject { upload_package_with_token(package_name, params) }
- it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package'
+ it_behaves_like 'a package tracking event', described_class.name, 'push_package'
it 'creates npm package with file' do
expect { subject }
diff --git a/spec/requests/api/project_clusters_spec.rb b/spec/requests/api/project_clusters_spec.rb
index ff35e380476..7b37862af74 100644
--- a/spec/requests/api/project_clusters_spec.rb
+++ b/spec/requests/api/project_clusters_spec.rb
@@ -171,6 +171,7 @@ RSpec.describe API::ProjectClusters do
name: 'test-cluster',
domain: 'domain.example.com',
managed: false,
+ namespace_per_environment: false,
platform_kubernetes_attributes: platform_kubernetes_attributes,
management_project_id: management_project_id
}
@@ -202,6 +203,7 @@ RSpec.describe API::ProjectClusters do
expect(cluster_result.domain).to eq('domain.example.com')
expect(cluster_result.managed).to be_falsy
expect(cluster_result.management_project_id).to eq management_project_id
+ expect(cluster_result.namespace_per_environment).to eq(false)
expect(platform_kubernetes.rbac?).to be_truthy
expect(platform_kubernetes.api_url).to eq(api_url)
expect(platform_kubernetes.namespace).to eq(namespace)
@@ -235,6 +237,22 @@ RSpec.describe API::ProjectClusters do
end
end
+ context 'when namespace_per_environment is not set' do
+ let(:cluster_params) do
+ {
+ name: 'test-cluster',
+ domain: 'domain.example.com',
+ platform_kubernetes_attributes: platform_kubernetes_attributes
+ }
+ end
+
+ it 'defaults to true' do
+ cluster_result = Clusters::Cluster.find(json_response['id'])
+
+ expect(cluster_result).to be_namespace_per_environment
+ end
+ end
+
context 'current user does not have access to management_project_id' do
let(:management_project_id) { create(:project).id }
diff --git a/spec/requests/api/project_container_repositories_spec.rb b/spec/requests/api/project_container_repositories_spec.rb
index 6cf0619cde4..a0c310a448b 100644
--- a/spec/requests/api/project_container_repositories_spec.rb
+++ b/spec/requests/api/project_container_repositories_spec.rb
@@ -45,7 +45,7 @@ RSpec.describe API::ProjectContainerRepositories do
it_behaves_like 'rejected container repository access', :guest, :forbidden
it_behaves_like 'rejected container repository access', :anonymous, :not_found
- it_behaves_like 'a gitlab tracking event', described_class.name, 'list_repositories'
+ it_behaves_like 'a package tracking event', described_class.name, 'list_repositories'
it_behaves_like 'returns repositories for allowed users', :reporter, 'project' do
let(:object) { project }
@@ -57,7 +57,7 @@ RSpec.describe API::ProjectContainerRepositories do
it_behaves_like 'rejected container repository access', :developer, :forbidden
it_behaves_like 'rejected container repository access', :anonymous, :not_found
- it_behaves_like 'a gitlab tracking event', described_class.name, 'delete_repository'
+ it_behaves_like 'a package tracking event', described_class.name, 'delete_repository'
context 'for maintainer' do
let(:api_user) { maintainer }
@@ -86,7 +86,7 @@ RSpec.describe API::ProjectContainerRepositories do
stub_container_registry_tags(repository: root_repository.path, tags: %w(rootA latest))
end
- it_behaves_like 'a gitlab tracking event', described_class.name, 'list_tags'
+ it_behaves_like 'a package tracking event', described_class.name, 'list_tags'
it 'returns a list of tags' do
subject
@@ -114,7 +114,7 @@ RSpec.describe API::ProjectContainerRepositories do
it_behaves_like 'rejected container repository access', :developer, :forbidden
it_behaves_like 'rejected container repository access', :anonymous, :not_found
- it_behaves_like 'a gitlab tracking event', described_class.name, 'delete_tag_bulk'
+ it_behaves_like 'a package tracking event', described_class.name, 'delete_tag_bulk'
end
context 'for maintainer' do
diff --git a/spec/requests/api/project_packages_spec.rb b/spec/requests/api/project_packages_spec.rb
index 2f0d0fc87ec..4c8599d1a20 100644
--- a/spec/requests/api/project_packages_spec.rb
+++ b/spec/requests/api/project_packages_spec.rb
@@ -23,6 +23,19 @@ RSpec.describe API::ProjectPackages do
it_behaves_like 'returns packages', :project, :no_type
end
+ context 'with conan package' do
+ let!(:conan_package) { create(:conan_package, project: project) }
+
+ it 'uses the conan recipe as the package name' do
+ subject
+
+ response_conan_package = json_response.find { |package| package['id'] == conan_package.id }
+
+ expect(response_conan_package['name']).to eq(conan_package.conan_recipe)
+ expect(response_conan_package['conan_package_name']).to eq(conan_package.name)
+ end
+ end
+
context 'project is private' do
let(:project) { create(:project, :private) }
diff --git a/spec/requests/api/releases_spec.rb b/spec/requests/api/releases_spec.rb
index 779ae983886..01ead9eef54 100644
--- a/spec/requests/api/releases_spec.rb
+++ b/spec/requests/api/releases_spec.rb
@@ -259,7 +259,7 @@ RSpec.describe API::Releases do
end
it '#collected_at' do
- Timecop.freeze(Time.now.round) do
+ travel_to(Time.now.round) do
get api("/projects/#{project.id}/releases/v0.1", maintainer)
expect(json_response['evidences'].first['collected_at'].to_datetime.to_i).to be_within(1.minute).of(release.evidences.first.created_at.to_i)
@@ -476,7 +476,7 @@ RSpec.describe API::Releases do
it 'sets the released_at to the current time if the released_at parameter is not provided' do
now = Time.zone.parse('2015-08-25 06:00:00Z')
- Timecop.freeze(now) do
+ travel_to(now) do
post api("/projects/#{project.id}/releases", maintainer), params: params
expect(project.releases.last.released_at).to eq(now)
diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb
index 36707f32d04..45bce8c8a5c 100644
--- a/spec/requests/api/repositories_spec.rb
+++ b/spec/requests/api/repositories_spec.rb
@@ -402,7 +402,9 @@ RSpec.describe API::Repositories do
end
it "returns an empty string when the diff overflows" do
- stub_const('Gitlab::Git::DiffCollection::DEFAULT_LIMITS', { max_files: 2, max_lines: 2 })
+ allow(Gitlab::Git::DiffCollection)
+ .to receive(:default_limits)
+ .and_return({ max_files: 2, max_lines: 2 })
get api(route, current_user), params: { from: 'master', to: 'feature' }
diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb
index af6731f3015..523f0f72f11 100644
--- a/spec/requests/api/search_spec.rb
+++ b/spec/requests/api/search_spec.rb
@@ -231,18 +231,6 @@ RSpec.describe API::Search do
it_behaves_like 'pagination', scope: :users
it_behaves_like 'ping counters', scope: :users
-
- context 'when users search feature is disabled' do
- before do
- stub_feature_flags(users_search: false)
-
- get api(endpoint, user), params: { scope: 'users', search: 'billy' }
- end
-
- it 'returns 400 error' do
- expect(response).to have_gitlab_http_status(:bad_request)
- end
- end
end
context 'for snippet_titles scope' do
@@ -416,18 +404,6 @@ RSpec.describe API::Search do
include_examples 'pagination', scope: :users
end
-
- context 'when users search feature is disabled' do
- before do
- stub_feature_flags(users_search: false)
-
- get api(endpoint, user), params: { scope: 'users', search: 'billy' }
- end
-
- it 'returns 400 error' do
- expect(response).to have_gitlab_http_status(:bad_request)
- end
- end
end
context 'for users scope with group path as id' do
@@ -589,18 +565,6 @@ RSpec.describe API::Search do
include_examples 'pagination', scope: :users
end
-
- context 'when users search feature is disabled' do
- before do
- stub_feature_flags(users_search: false)
-
- get api(endpoint, user), params: { scope: 'users', search: 'billy' }
- end
-
- it 'returns 400 error' do
- expect(response).to have_gitlab_http_status(:bad_request)
- end
- end
end
context 'for notes scope' do
diff --git a/spec/requests/api/services_spec.rb b/spec/requests/api/services_spec.rb
index 5528a0c094f..63ed57c5045 100644
--- a/spec/requests/api/services_spec.rb
+++ b/spec/requests/api/services_spec.rb
@@ -264,4 +264,34 @@ RSpec.describe API::Services do
expect(json_response['properties']['notify_only_broken_pipelines']).to eq(true)
end
end
+
+ describe 'Hangouts Chat service' do
+ let(:service_name) { 'hangouts-chat' }
+ let(:params) do
+ {
+ webhook: 'https://hook.example.com',
+ branches_to_be_notified: 'default'
+ }
+ end
+
+ before do
+ project.create_hangouts_chat_service(
+ active: true,
+ properties: params
+ )
+ end
+
+ it 'accepts branches_to_be_notified for update', :aggregate_failures do
+ put api("/projects/#{project.id}/services/#{service_name}", user), params: params.merge(branches_to_be_notified: 'all')
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['properties']['branches_to_be_notified']).to eq('all')
+ end
+
+ it 'only requires the webhook param' do
+ put api("/projects/#{project.id}/services/#{service_name}", user), params: { webhook: 'https://hook.example.com' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
end
diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb
index ef12f6dbed3..b0face6ec41 100644
--- a/spec/requests/api/settings_spec.rb
+++ b/spec/requests/api/settings_spec.rb
@@ -413,6 +413,14 @@ RSpec.describe API::Settings, 'Settings' do
end
end
+ it 'supports legacy admin_notification_email' do
+ put api('/application/settings', admin),
+ params: { admin_notification_email: 'test@example.com' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['abuse_notification_email']).to eq('test@example.com')
+ end
+
context "missing sourcegraph_url value when sourcegraph_enabled is true" do
it "returns a blank parameter error message" do
put api("/application/settings", admin), params: { sourcegraph_enabled: true }
diff --git a/spec/requests/api/terraform/state_spec.rb b/spec/requests/api/terraform/state_spec.rb
index 8d128bd911f..aff41ff5974 100644
--- a/spec/requests/api/terraform/state_spec.rb
+++ b/spec/requests/api/terraform/state_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe API::Terraform::State do
let(:state_path) { "/projects/#{project_id}/terraform/state/#{state_name}" }
before do
- stub_terraform_state_object_storage(Terraform::StateUploader)
+ stub_terraform_state_object_storage
end
describe 'GET /projects/:id/terraform/state/:name' do
diff --git a/spec/requests/api/terraform/state_version_spec.rb b/spec/requests/api/terraform/state_version_spec.rb
new file mode 100644
index 00000000000..ade0aacf805
--- /dev/null
+++ b/spec/requests/api/terraform/state_version_spec.rb
@@ -0,0 +1,210 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Terraform::StateVersion do
+ include HttpBasicAuthHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:developer) { create(:user, developer_projects: [project]) }
+ let_it_be(:maintainer) { create(:user, maintainer_projects: [project]) }
+ let_it_be(:user_without_access) { create(:user) }
+
+ let_it_be(:state) { create(:terraform_state, project: project) }
+
+ let!(:versions) { create_list(:terraform_state_version, 3, terraform_state: state) }
+
+ let(:current_user) { maintainer }
+ let(:auth_header) { user_basic_auth_header(current_user) }
+ let(:project_id) { project.id }
+ let(:state_name) { state.name }
+ let(:version) { versions.last }
+ let(:version_serial) { version.version }
+ let(:state_version_path) { "/projects/#{project_id}/terraform/state/#{state_name}/versions/#{version_serial}" }
+
+ describe 'GET /projects/:id/terraform/state/:name/versions/:serial' do
+ subject(:request) { get api(state_version_path), headers: auth_header }
+
+ context 'with invalid authentication' do
+ let(:auth_header) { basic_auth_header('bad', 'token') }
+
+ it 'returns unauthorized status' do
+ request
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'with no authentication' do
+ let(:auth_header) { nil }
+
+ it 'returns unauthorized status' do
+ request
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'personal acceess token authentication' do
+ context 'with maintainer permissions' do
+ let(:current_user) { maintainer }
+
+ it 'returns the state contents at the given version' do
+ request
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.body).to eq(version.file.read)
+ end
+
+ context 'for a project that does not exist' do
+ let(:project_id) { '0000' }
+
+ it 'returns not found status' do
+ request
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'with developer permissions' do
+ let(:current_user) { developer }
+
+ it 'returns the state contents at the given version' do
+ request
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.body).to eq(version.file.read)
+ end
+ end
+
+ context 'with no permissions' do
+ let(:current_user) { user_without_access }
+
+ it 'returns not found status' do
+ request
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'job token authentication' do
+ let(:auth_header) { job_basic_auth_header(job) }
+
+ context 'with maintainer permissions' do
+ let(:job) { create(:ci_build, status: :running, project: project, user: maintainer) }
+
+ it 'returns the state contents at the given version' do
+ request
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.body).to eq(version.file.read)
+ end
+
+ it 'returns unauthorized status if the the job is not running' do
+ job.update!(status: :failed)
+ request
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ context 'for a project that does not exist' do
+ let(:project_id) { '0000' }
+
+ it 'returns not found status' do
+ request
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'with developer permissions' do
+ let(:job) { create(:ci_build, status: :running, project: project, user: developer) }
+
+ it 'returns the state contents at the given version' do
+ request
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.body).to eq(version.file.read)
+ end
+ end
+
+ context 'with no permissions' do
+ let(:current_user) { user_without_access }
+ let(:job) { create(:ci_build, status: :running, user: current_user) }
+
+ it 'returns not found status' do
+ request
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+
+ describe 'DELETE /projects/:id/terraform/state/:name/versions/:serial' do
+ subject(:request) { delete api(state_version_path), headers: auth_header }
+
+ context 'with invalid authentication' do
+ let(:auth_header) { basic_auth_header('bad', 'token') }
+
+ it 'returns unauthorized status' do
+ request
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'with no authentication' do
+ let(:auth_header) { nil }
+
+ it 'returns unauthorized status' do
+ request
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'with maintainer permissions' do
+ let(:current_user) { maintainer }
+
+ it 'deletes the version' do
+ expect { request }.to change { Terraform::StateVersion.count }.by(-1)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+
+ context 'version does not exist' do
+ let(:version_serial) { -1 }
+
+ it 'does not delete a version' do
+ expect { request }.to change { Terraform::StateVersion.count }.by(0)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'with developer permissions' do
+ let(:current_user) { developer }
+
+ it 'returns forbidden status' do
+ expect { request }.to change { Terraform::StateVersion.count }.by(0)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'with no permissions' do
+ let(:current_user) { user_without_access }
+
+ it 'returns not found status' do
+ expect { request }.to change { Terraform::StateVersion.count }.by(0)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/unleash_spec.rb b/spec/requests/api/unleash_spec.rb
new file mode 100644
index 00000000000..0b70d62b093
--- /dev/null
+++ b/spec/requests/api/unleash_spec.rb
@@ -0,0 +1,608 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Unleash do
+ include FeatureFlagHelpers
+
+ let_it_be(:project, refind: true) { create(:project) }
+ let(:project_id) { project.id }
+ let(:params) { }
+ let(:headers) { }
+
+ shared_examples 'authenticated request' do
+ context 'when using instance id' do
+ let(:client) { create(:operations_feature_flags_client, project: project) }
+ let(:params) { { instance_id: client.token } }
+
+ it 'responds with OK' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ context 'when repository is disabled' do
+ before do
+ project.project_feature.update!(
+ repository_access_level: ::ProjectFeature::DISABLED,
+ merge_requests_access_level: ::ProjectFeature::DISABLED,
+ builds_access_level: ::ProjectFeature::DISABLED
+ )
+ end
+
+ it 'responds with forbidden' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'when repository is private' do
+ before do
+ project.project_feature.update!(
+ repository_access_level: ::ProjectFeature::PRIVATE,
+ merge_requests_access_level: ::ProjectFeature::DISABLED,
+ builds_access_level: ::ProjectFeature::DISABLED
+ )
+ end
+
+ it 'responds with OK' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ context 'when using header' do
+ let(:client) { create(:operations_feature_flags_client, project: project) }
+ let(:headers) { { "UNLEASH-INSTANCEID" => client.token }}
+
+ it 'responds with OK' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'when using bogus instance id' do
+ let(:params) { { instance_id: 'token' } }
+
+ it 'responds with unauthorized' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'when using not existing project' do
+ let(:project_id) { -5000 }
+ let(:params) { { instance_id: 'token' } }
+
+ it 'responds with unauthorized' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+ end
+
+ shared_examples_for 'support multiple environments' do
+ let!(:client) { create(:operations_feature_flags_client, project: project) }
+ let!(:base_headers) { { "UNLEASH-INSTANCEID" => client.token } }
+ let!(:headers) { base_headers.merge({ "UNLEASH-APPNAME" => "test" }) }
+
+ let!(:feature_flag_1) do
+ create(:operations_feature_flag, name: "feature_flag_1", project: project, active: true)
+ end
+
+ let!(:feature_flag_2) do
+ create(:operations_feature_flag, name: "feature_flag_2", project: project, active: false)
+ end
+
+ before do
+ create_scope(feature_flag_1, 'production', false)
+ create_scope(feature_flag_2, 'review/*', true)
+ end
+
+ it 'does not have N+1 problem' do
+ control_count = ActiveRecord::QueryRecorder.new { get api(features_url), headers: headers }.count
+
+ create(:operations_feature_flag, name: "feature_flag_3", project: project, active: true)
+
+ expect { get api(features_url), headers: headers }.not_to exceed_query_limit(control_count)
+ end
+
+ context 'when app name is staging' do
+ let(:headers) { base_headers.merge({ "UNLEASH-APPNAME" => "staging" }) }
+
+ it 'returns correct active values' do
+ subject
+
+ feature_flag_1 = json_response['features'].find { |f| f['name'] == 'feature_flag_1' }
+ feature_flag_2 = json_response['features'].find { |f| f['name'] == 'feature_flag_2' }
+
+ expect(feature_flag_1['enabled']).to eq(true)
+ expect(feature_flag_2['enabled']).to eq(false)
+ end
+ end
+
+ context 'when app name is production' do
+ let(:headers) { base_headers.merge({ "UNLEASH-APPNAME" => "production" }) }
+
+ it 'returns correct active values' do
+ subject
+
+ feature_flag_1 = json_response['features'].find { |f| f['name'] == 'feature_flag_1' }
+ feature_flag_2 = json_response['features'].find { |f| f['name'] == 'feature_flag_2' }
+
+ expect(feature_flag_1['enabled']).to eq(false)
+ expect(feature_flag_2['enabled']).to eq(false)
+ end
+ end
+
+ context 'when app name is review/patch-1' do
+ let(:headers) { base_headers.merge({ "UNLEASH-APPNAME" => "review/patch-1" }) }
+
+ it 'returns correct active values' do
+ subject
+
+ feature_flag_1 = json_response['features'].find { |f| f['name'] == 'feature_flag_1' }
+ feature_flag_2 = json_response['features'].find { |f| f['name'] == 'feature_flag_2' }
+
+ expect(feature_flag_1['enabled']).to eq(true)
+ expect(feature_flag_2['enabled']).to eq(false)
+ end
+ end
+
+ context 'when app name is empty' do
+ let(:headers) { base_headers }
+
+ it 'returns empty list' do
+ subject
+
+ expect(json_response['features'].count).to eq(0)
+ end
+ end
+ end
+
+ %w(/feature_flags/unleash/:project_id/features /feature_flags/unleash/:project_id/client/features).each do |features_endpoint|
+ describe "GET #{features_endpoint}" do
+ let(:features_url) { features_endpoint.sub(':project_id', project_id.to_s) }
+ let(:client) { create(:operations_feature_flags_client, project: project) }
+
+ subject { get api(features_url), params: params, headers: headers }
+
+ it_behaves_like 'authenticated request'
+
+ context 'with version 1 (legacy) feature flags' do
+ let(:feature_flag) { create(:operations_feature_flag, project: project, name: 'feature1', active: true, version: 1) }
+
+ it_behaves_like 'support multiple environments'
+
+ context 'with a list of feature flags' do
+ let(:headers) { { "UNLEASH-INSTANCEID" => client.token, "UNLEASH-APPNAME" => "production" } }
+ let!(:enabled_feature_flag) { create(:operations_feature_flag, project: project, name: 'feature1', active: true, version: 1) }
+ let!(:disabled_feature_flag) { create(:operations_feature_flag, project: project, name: 'feature2', active: false, version: 1) }
+
+ it 'responds with a list of features' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['version']).to eq(1)
+ expect(json_response['features']).not_to be_empty
+ expect(json_response['features'].map { |f| f['name'] }.sort).to eq(%w[feature1 feature2])
+ expect(json_response['features'].sort_by {|f| f['name'] }.map { |f| f['enabled'] }).to eq([true, false])
+ end
+
+ it 'matches json schema' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('unleash/unleash')
+ end
+ end
+
+ it 'returns a feature flag strategy' do
+ create(:operations_feature_flag_scope,
+ feature_flag: feature_flag,
+ environment_scope: 'sandbox',
+ active: true,
+ strategies: [{ name: "gradualRolloutUserId",
+ parameters: { groupId: "default", percentage: "50" } }])
+ headers = { "UNLEASH-INSTANCEID" => client.token, "UNLEASH-APPNAME" => "sandbox" }
+
+ get api(features_url), headers: headers
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['features'].first['enabled']).to eq(true)
+ strategies = json_response['features'].first['strategies']
+ expect(strategies).to eq([{
+ "name" => "gradualRolloutUserId",
+ "parameters" => {
+ "percentage" => "50",
+ "groupId" => "default"
+ }
+ }])
+ end
+
+ it 'returns a default strategy for a scope' do
+ create(:operations_feature_flag_scope, feature_flag: feature_flag, environment_scope: 'sandbox', active: true)
+ headers = { "UNLEASH-INSTANCEID" => client.token, "UNLEASH-APPNAME" => "sandbox" }
+
+ get api(features_url), headers: headers
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['features'].first['enabled']).to eq(true)
+ strategies = json_response['features'].first['strategies']
+ expect(strategies).to eq([{ "name" => "default", "parameters" => {} }])
+ end
+
+ it 'returns multiple strategies for a feature flag' do
+ create(:operations_feature_flag_scope,
+ feature_flag: feature_flag,
+ environment_scope: 'staging',
+ active: true,
+ strategies: [{ name: "userWithId", parameters: { userIds: "max,fred" } },
+ { name: "gradualRolloutUserId",
+ parameters: { groupId: "default", percentage: "50" } }])
+ headers = { "UNLEASH-INSTANCEID" => client.token, "UNLEASH-APPNAME" => "staging" }
+
+ get api(features_url), headers: headers
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['features'].first['enabled']).to eq(true)
+ strategies = json_response['features'].first['strategies'].sort_by { |s| s['name'] }
+ expect(strategies).to eq([{
+ "name" => "gradualRolloutUserId",
+ "parameters" => {
+ "percentage" => "50",
+ "groupId" => "default"
+ }
+ }, {
+ "name" => "userWithId",
+ "parameters" => {
+ "userIds" => "max,fred"
+ }
+ }])
+ end
+
+ it 'returns a disabled feature when the flag is disabled' do
+ flag = create(:operations_feature_flag, project: project, name: 'test_feature', active: false, version: 1)
+ create(:operations_feature_flag_scope, feature_flag: flag, environment_scope: 'production', active: true)
+ headers = { "UNLEASH-INSTANCEID" => client.token, "UNLEASH-APPNAME" => "production" }
+
+ get api(features_url), headers: headers
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['features'].first['enabled']).to eq(false)
+ end
+
+ context "with an inactive scope" do
+ let!(:scope) { create(:operations_feature_flag_scope, feature_flag: feature_flag, environment_scope: 'production', active: false, strategies: [{ name: "default", parameters: {} }]) }
+ let(:headers) { { "UNLEASH-INSTANCEID" => client.token, "UNLEASH-APPNAME" => "production" } }
+
+ it 'returns a disabled feature' do
+ get api(features_url), headers: headers
+
+ expect(response).to have_gitlab_http_status(:ok)
+ feature_json = json_response['features'].first
+ expect(feature_json['enabled']).to eq(false)
+ expect(feature_json['strategies']).to eq([{ 'name' => 'default', 'parameters' => {} }])
+ end
+ end
+ end
+
+ context 'with version 2 feature flags' do
+ it 'does not return a flag without any strategies' do
+ create(:operations_feature_flag, project: project,
+ name: 'feature1', active: true, version: 2)
+
+ get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'production' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['features']).to be_empty
+ end
+
+ it 'returns a flag with a default strategy' do
+ feature_flag = create(:operations_feature_flag, project: project,
+ name: 'feature1', active: true, version: 2)
+ strategy = create(:operations_strategy, feature_flag: feature_flag,
+ name: 'default', parameters: {})
+ create(:operations_scope, strategy: strategy, environment_scope: 'production')
+
+ get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'production' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['features']).to eq([{
+ 'name' => 'feature1',
+ 'enabled' => true,
+ 'strategies' => [{
+ 'name' => 'default',
+ 'parameters' => {}
+ }]
+ }])
+ end
+
+ it 'returns a flag with a userWithId strategy' do
+ feature_flag = create(:operations_feature_flag, project: project,
+ name: 'feature1', active: true, version: 2)
+ strategy = create(:operations_strategy, feature_flag: feature_flag,
+ name: 'userWithId', parameters: { userIds: 'user123,user456' })
+ create(:operations_scope, strategy: strategy, environment_scope: 'production')
+
+ get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'production' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['features']).to eq([{
+ 'name' => 'feature1',
+ 'enabled' => true,
+ 'strategies' => [{
+ 'name' => 'userWithId',
+ 'parameters' => { 'userIds' => 'user123,user456' }
+ }]
+ }])
+ end
+
+ it 'returns a flag with multiple strategies' do
+ feature_flag = create(:operations_feature_flag, project: project,
+ name: 'feature1', active: true, version: 2)
+ strategy_a = create(:operations_strategy, feature_flag: feature_flag,
+ name: 'userWithId', parameters: { userIds: 'user_a,user_b' })
+ strategy_b = create(:operations_strategy, feature_flag: feature_flag,
+ name: 'gradualRolloutUserId', parameters: { groupId: 'default', percentage: '45' })
+ create(:operations_scope, strategy: strategy_a, environment_scope: 'production')
+ create(:operations_scope, strategy: strategy_b, environment_scope: 'production')
+
+ get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'production' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['features'].map { |f| f['name'] }.sort).to eq(['feature1'])
+ features_json = json_response['features'].map do |feature|
+ feature.merge(feature.slice('strategies').transform_values { |v| v.sort_by { |s| s['name'] } })
+ end
+ expect(features_json).to eq([{
+ 'name' => 'feature1',
+ 'enabled' => true,
+ 'strategies' => [{
+ 'name' => 'gradualRolloutUserId',
+ 'parameters' => { 'groupId' => 'default', 'percentage' => '45' }
+ }, {
+ 'name' => 'userWithId',
+ 'parameters' => { 'userIds' => 'user_a,user_b' }
+ }]
+ }])
+ end
+
+ it 'returns only flags matching the environment scope' do
+ feature_flag_a = create(:operations_feature_flag, project: project,
+ name: 'feature1', active: true, version: 2)
+ strategy_a = create(:operations_strategy, feature_flag: feature_flag_a)
+ create(:operations_scope, strategy: strategy_a, environment_scope: 'production')
+ feature_flag_b = create(:operations_feature_flag, project: project,
+ name: 'feature2', active: true, version: 2)
+ strategy_b = create(:operations_strategy, feature_flag: feature_flag_b)
+ create(:operations_scope, strategy: strategy_b, environment_scope: 'staging')
+
+ get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'staging' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['features'].map { |f| f['name'] }.sort).to eq(['feature2'])
+ expect(json_response['features']).to eq([{
+ 'name' => 'feature2',
+ 'enabled' => true,
+ 'strategies' => [{
+ 'name' => 'default',
+ 'parameters' => {}
+ }]
+ }])
+ end
+
+ it 'returns only strategies matching the environment scope' do
+ feature_flag = create(:operations_feature_flag, project: project,
+ name: 'feature1', active: true, version: 2)
+ strategy_a = create(:operations_strategy, feature_flag: feature_flag,
+ name: 'userWithId', parameters: { userIds: 'user2,user8,user4' })
+ create(:operations_scope, strategy: strategy_a, environment_scope: 'production')
+ strategy_b = create(:operations_strategy, feature_flag: feature_flag,
+ name: 'default', parameters: {})
+ create(:operations_scope, strategy: strategy_b, environment_scope: 'staging')
+
+ get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'production' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['features']).to eq([{
+ 'name' => 'feature1',
+ 'enabled' => true,
+ 'strategies' => [{
+ 'name' => 'userWithId',
+ 'parameters' => { 'userIds' => 'user2,user8,user4' }
+ }]
+ }])
+ end
+
+ it 'returns only flags for the given project' do
+ project_b = create(:project)
+ feature_flag_a = create(:operations_feature_flag, project: project, name: 'feature_a', active: true, version: 2)
+ strategy_a = create(:operations_strategy, feature_flag: feature_flag_a)
+ create(:operations_scope, strategy: strategy_a, environment_scope: 'sandbox')
+ feature_flag_b = create(:operations_feature_flag, project: project_b, name: 'feature_b', active: true, version: 2)
+ strategy_b = create(:operations_strategy, feature_flag: feature_flag_b)
+ create(:operations_scope, strategy: strategy_b, environment_scope: 'sandbox')
+
+ get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'sandbox' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['features']).to eq([{
+ 'name' => 'feature_a',
+ 'enabled' => true,
+ 'strategies' => [{
+ 'name' => 'default',
+ 'parameters' => {}
+ }]
+ }])
+ end
+
+ it 'returns all strategies with a matching scope' do
+ feature_flag = create(:operations_feature_flag, project: project,
+ name: 'feature1', active: true, version: 2)
+ strategy_a = create(:operations_strategy, feature_flag: feature_flag,
+ name: 'userWithId', parameters: { userIds: 'user2,user8,user4' })
+ create(:operations_scope, strategy: strategy_a, environment_scope: '*')
+ strategy_b = create(:operations_strategy, feature_flag: feature_flag,
+ name: 'default', parameters: {})
+ create(:operations_scope, strategy: strategy_b, environment_scope: 'review/*')
+ strategy_c = create(:operations_strategy, feature_flag: feature_flag,
+ name: 'gradualRolloutUserId', parameters: { groupId: 'default', percentage: '15' })
+ create(:operations_scope, strategy: strategy_c, environment_scope: 'review/patch-1')
+
+ get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'review/patch-1' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['features'].first['strategies'].sort_by { |s| s['name'] }).to eq([{
+ 'name' => 'default',
+ 'parameters' => {}
+ }, {
+ 'name' => 'gradualRolloutUserId',
+ 'parameters' => { 'groupId' => 'default', 'percentage' => '15' }
+ }, {
+ 'name' => 'userWithId',
+ 'parameters' => { 'userIds' => 'user2,user8,user4' }
+ }])
+ end
+
+ it 'returns a strategy with more than one matching scope' do
+ feature_flag = create(:operations_feature_flag, project: project,
+ name: 'feature1', active: true, version: 2)
+ strategy = create(:operations_strategy, feature_flag: feature_flag,
+ name: 'default', parameters: {})
+ create(:operations_scope, strategy: strategy, environment_scope: 'production')
+ create(:operations_scope, strategy: strategy, environment_scope: '*')
+
+ get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'production' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['features']).to eq([{
+ 'name' => 'feature1',
+ 'enabled' => true,
+ 'strategies' => [{
+ 'name' => 'default',
+ 'parameters' => {}
+ }]
+ }])
+ end
+
+ it 'returns a disabled flag with a matching scope' do
+ feature_flag = create(:operations_feature_flag, project: project,
+ name: 'myfeature', active: false, version: 2)
+ strategy = create(:operations_strategy, feature_flag: feature_flag,
+ name: 'default', parameters: {})
+ create(:operations_scope, strategy: strategy, environment_scope: 'production')
+
+ get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'production' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['features']).to eq([{
+ 'name' => 'myfeature',
+ 'enabled' => false,
+ 'strategies' => [{
+ 'name' => 'default',
+ 'parameters' => {}
+ }]
+ }])
+ end
+
+ it 'returns a userWithId strategy for a gitlabUserList strategy' do
+ feature_flag = create(:operations_feature_flag, :new_version_flag, project: project,
+ name: 'myfeature', active: true)
+ user_list = create(:operations_feature_flag_user_list, project: project,
+ name: 'My List', user_xids: 'user1,user2')
+ strategy = create(:operations_strategy, feature_flag: feature_flag,
+ name: 'gitlabUserList', parameters: {}, user_list: user_list)
+ create(:operations_scope, strategy: strategy, environment_scope: 'production')
+
+ get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'production' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['features']).to eq([{
+ 'name' => 'myfeature',
+ 'enabled' => true,
+ 'strategies' => [{
+ 'name' => 'userWithId',
+ 'parameters' => { 'userIds' => 'user1,user2' }
+ }]
+ }])
+ end
+ end
+
+ context 'when mixing version 1 and version 2 feature flags' do
+ it 'returns both types of flags when both match' do
+ feature_flag_a = create(:operations_feature_flag, project: project,
+ name: 'feature_a', active: true, version: 2)
+ strategy = create(:operations_strategy, feature_flag: feature_flag_a,
+ name: 'userWithId', parameters: { userIds: 'user8' })
+ create(:operations_scope, strategy: strategy, environment_scope: 'staging')
+ feature_flag_b = create(:operations_feature_flag, project: project,
+ name: 'feature_b', active: true, version: 1)
+ create(:operations_feature_flag_scope, feature_flag: feature_flag_b,
+ active: true, strategies: [{ name: 'default', parameters: {} }], environment_scope: 'staging')
+
+ get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'staging' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['features'].sort_by {|f| f['name']}).to eq([{
+ 'name' => 'feature_a',
+ 'enabled' => true,
+ 'strategies' => [{
+ 'name' => 'userWithId',
+ 'parameters' => { 'userIds' => 'user8' }
+ }]
+ }, {
+ 'name' => 'feature_b',
+ 'enabled' => true,
+ 'strategies' => [{
+ 'name' => 'default',
+ 'parameters' => {}
+ }]
+ }])
+ end
+
+ it 'returns legacy flags when only legacy flags match' do
+ feature_flag_a = create(:operations_feature_flag, project: project,
+ name: 'feature_a', active: true, version: 2)
+ strategy = create(:operations_strategy, feature_flag: feature_flag_a,
+ name: 'userWithId', parameters: { userIds: 'user8' })
+ create(:operations_scope, strategy: strategy, environment_scope: 'production')
+ feature_flag_b = create(:operations_feature_flag, project: project,
+ name: 'feature_b', active: true, version: 1)
+ create(:operations_feature_flag_scope, feature_flag: feature_flag_b,
+ active: true, strategies: [{ name: 'default', parameters: {} }], environment_scope: 'staging')
+
+ get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'staging' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['features']).to eq([{
+ 'name' => 'feature_b',
+ 'enabled' => true,
+ 'strategies' => [{
+ 'name' => 'default',
+ 'parameters' => {}
+ }]
+ }])
+ end
+ end
+ end
+ end
+
+ describe 'POST /feature_flags/unleash/:project_id/client/register' do
+ subject { post api("/feature_flags/unleash/#{project_id}/client/register"), params: params, headers: headers }
+
+ it_behaves_like 'authenticated request'
+ end
+
+ describe 'POST /feature_flags/unleash/:project_id/client/metrics' do
+ subject { post api("/feature_flags/unleash/#{project_id}/client/metrics"), params: params, headers: headers }
+
+ it_behaves_like 'authenticated request'
+ end
+end
diff --git a/spec/requests/api/usage_data_spec.rb b/spec/requests/api/usage_data_spec.rb
index 46dd54dcc73..4f4f386e9db 100644
--- a/spec/requests/api/usage_data_spec.rb
+++ b/spec/requests/api/usage_data_spec.rb
@@ -66,6 +66,10 @@ RSpec.describe API::UsageData do
end
context 'with unknown event' do
+ before do
+ skip_feature_flags_yaml_validation
+ end
+
it 'returns status ok' do
expect(Gitlab::Redis::HLL).not_to receive(:add)
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index 806b586ef49..72dd22038c9 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -1460,39 +1460,22 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
end
describe 'GET /user/:id/gpg_keys' do
- context 'when unauthenticated' do
- it 'returns authentication error' do
- get api("/users/#{user.id}/gpg_keys")
+ it 'returns 404 for non-existing user' do
+ get api('/users/0/gpg_keys')
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 User Not Found')
end
- context 'when authenticated' do
- it 'returns 404 for non-existing user' do
- get api('/users/0/gpg_keys', admin)
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(json_response['message']).to eq('404 User Not Found')
- end
-
- it 'returns 404 error if key not foud' do
- delete api("/users/#{user.id}/gpg_keys/#{non_existing_record_id}", admin)
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(json_response['message']).to eq('404 GPG Key Not Found')
- end
-
- it 'returns array of GPG keys' do
- user.gpg_keys << gpg_key
+ it 'returns array of GPG keys' do
+ user.gpg_keys << gpg_key
- get api("/users/#{user.id}/gpg_keys", admin)
+ get api("/users/#{user.id}/gpg_keys")
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response.first['key']).to eq(gpg_key.key)
- end
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.first['key']).to eq(gpg_key.key)
end
end
@@ -2308,23 +2291,31 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
end
describe 'POST /users/:id/activate' do
+ subject(:activate) { post api("/users/#{user_id}/activate", api_user) }
+
+ let(:user_id) { user.id }
+
context 'performed by a non-admin user' do
+ let(:api_user) { user }
+
it 'is not authorized to perform the action' do
- post api("/users/#{user.id}/activate", user)
+ activate
expect(response).to have_gitlab_http_status(:forbidden)
end
end
context 'performed by an admin user' do
+ let(:api_user) { admin }
+
context 'for a deactivated user' do
before do
user.deactivate
-
- post api("/users/#{user.id}/activate", admin)
end
it 'activates a deactivated user' do
+ activate
+
expect(response).to have_gitlab_http_status(:created)
expect(user.reload.state).to eq('active')
end
@@ -2333,11 +2324,11 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
context 'for an active user' do
before do
user.activate
-
- post api("/users/#{user.id}/activate", admin)
end
it 'returns 201' do
+ activate
+
expect(response).to have_gitlab_http_status(:created)
expect(user.reload.state).to eq('active')
end
@@ -2346,11 +2337,11 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
context 'for a blocked user' do
before do
user.block
-
- post api("/users/#{user.id}/activate", admin)
end
it 'returns 403' do
+ activate
+
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response['message']).to eq('403 Forbidden - A blocked user must be unblocked to be activated')
expect(user.reload.state).to eq('blocked')
@@ -2360,11 +2351,11 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
context 'for a ldap blocked user' do
before do
user.ldap_block
-
- post api("/users/#{user.id}/activate", admin)
end
it 'returns 403' do
+ activate
+
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response['message']).to eq('403 Forbidden - A blocked user must be unblocked to be activated')
expect(user.reload.state).to eq('ldap_blocked')
@@ -2372,8 +2363,10 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
end
context 'for a user that does not exist' do
+ let(:user_id) { 0 }
+
before do
- post api("/users/0/activate", admin)
+ activate
end
it_behaves_like '404'
diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb
index dbba2b35d74..a3bfa7ea33c 100644
--- a/spec/requests/git_http_spec.rb
+++ b/spec/requests/git_http_spec.rb
@@ -90,7 +90,7 @@ RSpec.describe 'Git HTTP requests' do
shared_examples_for 'pulls are allowed' do
it 'allows pulls' do
- download(path, env) do |response|
+ download(path, **env) do |response|
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
end
@@ -99,7 +99,7 @@ RSpec.describe 'Git HTTP requests' do
shared_examples_for 'pushes are allowed' do
it 'allows pushes', :sidekiq_might_not_need_inline do
- upload(path, env) do |response|
+ upload(path, **env) do |response|
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
end
@@ -259,7 +259,7 @@ RSpec.describe 'Git HTTP requests' do
it_behaves_like 'pulls are allowed'
it 'rejects pushes with 403 Forbidden' do
- upload(path, env) do |response|
+ upload(path, **env) do |response|
expect(response).to have_gitlab_http_status(:forbidden)
expect(response.body).to eq(git_access_wiki_error(:write_to_wiki))
end
@@ -347,7 +347,7 @@ RSpec.describe 'Git HTTP requests' do
end
it 'rejects pushes with 403 Forbidden' do
- upload(path, env) do |response|
+ upload(path, **env) do |response|
expect(response).to have_gitlab_http_status(:forbidden)
expect(response.body).to eq(git_access_error(:receive_pack_disabled_over_http))
end
@@ -358,7 +358,7 @@ RSpec.describe 'Git HTTP requests' do
it "rejects pushes with 403 Forbidden" do
allow(Gitlab.config.gitlab_shell).to receive(:upload_pack).and_return(false)
- download(path, env) do |response|
+ download(path, **env) do |response|
expect(response).to have_gitlab_http_status(:forbidden)
expect(response.body).to eq(git_access_error(:upload_pack_disabled_over_http))
end
@@ -370,7 +370,7 @@ RSpec.describe 'Git HTTP requests' do
it_behaves_like 'pulls are allowed'
it 'rejects pushes with 403 Forbidden' do
- upload(path, env) do |response|
+ upload(path, **env) do |response|
expect(response).to have_gitlab_http_status(:forbidden)
expect(response.body).to eq('You are not allowed to push code to this project.')
end
@@ -485,7 +485,7 @@ RSpec.describe 'Git HTTP requests' do
user.block
project.add_maintainer(user)
- download(path, env) do |response|
+ download(path, **env) do |response|
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
@@ -507,7 +507,7 @@ RSpec.describe 'Git HTTP requests' do
it "resets the IP in Rack Attack on download" do
expect(Rack::Attack::Allow2Ban).to receive(:reset).twice
- download(path, env) do
+ download(path, **env) do
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
end
@@ -516,7 +516,7 @@ RSpec.describe 'Git HTTP requests' do
it "resets the IP in Rack Attack on upload" do
expect(Rack::Attack::Allow2Ban).to receive(:reset).twice
- upload(path, env) do
+ upload(path, **env) do
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
end
@@ -525,7 +525,7 @@ RSpec.describe 'Git HTTP requests' do
it 'updates the user last activity', :clean_gitlab_redis_shared_state do
expect(user.last_activity_on).to be_nil
- download(path, env) do |response|
+ download(path, **env) do |response|
expect(user.reload.last_activity_on).to eql(Date.today)
end
end
@@ -699,7 +699,7 @@ RSpec.describe 'Git HTTP requests' do
end
it 'uploads get status 404 with "project was moved" message' do
- upload(path, env) do |response|
+ upload(path, **env) do |response|
expect(response).to have_gitlab_http_status(:ok)
end
end
@@ -917,11 +917,11 @@ RSpec.describe 'Git HTTP requests' do
expect(response).to have_gitlab_http_status(:forbidden)
end
- download(path, env) do |response|
+ download(path, **env) do |response|
expect(response).to have_gitlab_http_status(:forbidden)
end
- upload(path, env) do |response|
+ upload(path, **env) do |response|
expect(response).to have_gitlab_http_status(:forbidden)
end
end
diff --git a/spec/requests/projects/cycle_analytics_events_spec.rb b/spec/requests/projects/cycle_analytics_events_spec.rb
index 4338bfa3759..3f57b8ba67b 100644
--- a/spec/requests/projects/cycle_analytics_events_spec.rb
+++ b/spec/requests/projects/cycle_analytics_events_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe 'value stream analytics events' do
project.add_developer(user)
3.times do |count|
- Timecop.freeze(Time.now + count.days) do
+ travel_to(Time.now + count.days) do
create_cycle
end
end
diff --git a/spec/requests/request_profiler_spec.rb b/spec/requests/request_profiler_spec.rb
index 7f9999bf3d2..72689595480 100644
--- a/spec/requests/request_profiler_spec.rb
+++ b/spec/requests/request_profiler_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe 'Request Profiler' do
time = Time.now
path = "/#{project.full_path}"
- Timecop.freeze(time) do
+ travel_to(time) do
get path, params: {}, headers: { 'X-Profile-Token' => Gitlab::RequestProfiler.profile_token, 'X-Profile-Mode' => profile_type }
end
diff --git a/spec/routing/admin_routing_spec.rb b/spec/routing/admin_routing_spec.rb
index fedafff0d1b..9374df0c4a2 100644
--- a/spec/routing/admin_routing_spec.rb
+++ b/spec/routing/admin_routing_spec.rb
@@ -184,3 +184,9 @@ RSpec.describe Admin::PlanLimitsController, "routing" do
expect(post("/admin/plan_limits")).to route_to('admin/plan_limits#create')
end
end
+
+RSpec.describe Admin::RunnersController, "routing" do
+ it "to #runner_setup_scripts" do
+ expect(get("/admin/runners/runner_setup_scripts")).to route_to('admin/runners#runner_setup_scripts')
+ end
+end
diff --git a/spec/routing/group_routing_spec.rb b/spec/routing/group_routing_spec.rb
index f4d5f899519..9de99b73d23 100644
--- a/spec/routing/group_routing_spec.rb
+++ b/spec/routing/group_routing_spec.rb
@@ -43,6 +43,10 @@ RSpec.shared_examples 'groups routing' do
expect(get("/groups/#{group_path}/-/milestones")).to route_to('groups/milestones#index', group_id: group_path)
end
+ it "to #runner_setup_scripts" do
+ expect(get("/groups/#{group_path}/-/settings/ci_cd/runner_setup_scripts")).to route_to('groups/settings/ci_cd#runner_setup_scripts', group_id: group_path)
+ end
+
it 'routes to the avatars controller' do
expect(delete("/groups/#{group_path}/-/avatar"))
.to route_to(group_id: group_path,
diff --git a/spec/routing/instance_statistics_routing_spec.rb b/spec/routing/instance_statistics_routing_spec.rb
deleted file mode 100644
index 7eec807fb0b..00000000000
--- a/spec/routing/instance_statistics_routing_spec.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Instance Statistics', 'routing' do
- include RSpec::Rails::RequestExampleGroup
-
- it "routes '/-/instance_statistics' to dev ops report" do
- expect(get('/-/instance_statistics')).to redirect_to('/admin/dev_ops_report')
- end
-end
diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb
index b80baf0aa13..b04bae3e224 100644
--- a/spec/routing/project_routing_spec.rb
+++ b/spec/routing/project_routing_spec.rb
@@ -731,6 +731,12 @@ RSpec.describe 'project routing' do
end
end
+ describe Projects::Settings::CiCdController, 'routing' do
+ it "to #runner_setup_scripts" do
+ expect(get("/gitlab/gitlabhq/-/settings/ci_cd/runner_setup_scripts")).to route_to('projects/settings/ci_cd#runner_setup_scripts', namespace_id: 'gitlab', project_id: 'gitlabhq')
+ end
+ end
+
describe Projects::TemplatesController, 'routing' do
describe '#show' do
def show_with_template_type(template_type)
diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb
index 722e687838f..85a4c0e4bca 100644
--- a/spec/routing/routing_spec.rb
+++ b/spec/routing/routing_spec.rb
@@ -374,3 +374,9 @@ RSpec.describe Snippets::BlobsController, "routing" do
.to route_to('snippets/blobs#raw', snippet_id: '1', ref: 'master', path: 'lib/version.rb')
end
end
+
+RSpec.describe RunnerSetupController, 'routing' do
+ it 'to #platforms' do
+ expect(get("/-/runner_setup/platforms")).to route_to('runner_setup#platforms')
+ end
+end
diff --git a/spec/rubocop/cop/code_reuse/active_record_spec.rb b/spec/rubocop/cop/code_reuse/active_record_spec.rb
index 25eca185f26..e15b9e11aed 100644
--- a/spec/rubocop/cop/code_reuse/active_record_spec.rb
+++ b/spec/rubocop/cop/code_reuse/active_record_spec.rb
@@ -84,7 +84,7 @@ RSpec.describe RuboCop::Cop::CodeReuse::ActiveRecord, type: :rubocop do
SOURCE
end
- it 'autocorrects offenses in instance methods by whitelisting them' do
+ it 'autocorrects offenses in instance methods by allowing them' do
corrected = autocorrect_source(<<~SOURCE)
def foo
User.where
@@ -100,7 +100,7 @@ RSpec.describe RuboCop::Cop::CodeReuse::ActiveRecord, type: :rubocop do
SOURCE
end
- it 'autocorrects offenses in class methods by whitelisting them' do
+ it 'autocorrects offenses in class methods by allowing them' do
corrected = autocorrect_source(<<~SOURCE)
def self.foo
User.where
@@ -116,7 +116,7 @@ RSpec.describe RuboCop::Cop::CodeReuse::ActiveRecord, type: :rubocop do
SOURCE
end
- it 'autocorrects offenses in blocks by whitelisting them' do
+ it 'autocorrects offenses in blocks by allowing them' do
corrected = autocorrect_source(<<~SOURCE)
get '/' do
User.where
diff --git a/spec/serializers/ci/trigger_entity_spec.rb b/spec/serializers/ci/trigger_entity_spec.rb
new file mode 100644
index 00000000000..b2f3337d166
--- /dev/null
+++ b/spec/serializers/ci/trigger_entity_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::TriggerEntity do
+ let(:project) { create(:project) }
+ let(:trigger) { create(:ci_trigger, project: project, token: '237f3604900a4cd71ed06ef13e57b96d') }
+ let(:user) { create(:user) }
+ let(:entity) { described_class.new(trigger, current_user: user, project: project) }
+
+ describe '#as_json' do
+ let(:as_json) { entity.as_json }
+ let(:project_trigger_path) { "/#{project.full_path}/-/triggers/#{trigger.id}" }
+
+ it 'contains required fields' do
+ expect(as_json).to include(
+ :description, :owner, :last_used, :token, :has_token_exposed, :can_access_project
+ )
+ end
+
+ it 'contains user fields' do
+ expect(as_json[:owner].to_json).to match_schema('entities/user')
+ end
+
+ context 'when current user can manage triggers' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'returns short_token as token' do
+ expect(as_json[:token]).to eq(trigger.short_token)
+ end
+
+ it 'contains project_trigger_path' do
+ expect(as_json[:project_trigger_path]).to eq(project_trigger_path)
+ end
+
+ it 'does not contain edit_project_trigger_path' do
+ expect(as_json).not_to include(:edit_project_trigger_path)
+ end
+
+ it 'returns has_token_exposed' do
+ expect(as_json[:has_token_exposed]).to eq(false)
+ end
+ end
+
+ context 'when current user is the owner of the trigger' do
+ before do
+ project.add_maintainer(user)
+ trigger.update!(owner: user)
+ end
+
+ it 'returns token as token' do
+ expect(as_json[:token]).to eq(trigger.token)
+ end
+
+ it 'contains project_trigger_path' do
+ expect(as_json[:project_trigger_path]).to eq(project_trigger_path)
+ end
+
+ it 'contains edit_project_trigger_path' do
+ expect(as_json[:edit_project_trigger_path]).to eq("#{project_trigger_path}/edit")
+ end
+
+ it 'returns has_token_exposed' do
+ expect(as_json[:has_token_exposed]).to eq(true)
+ end
+ end
+ end
+end
diff --git a/spec/serializers/ci/trigger_serializer_spec.rb b/spec/serializers/ci/trigger_serializer_spec.rb
new file mode 100644
index 00000000000..a669a8c3ed0
--- /dev/null
+++ b/spec/serializers/ci/trigger_serializer_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::TriggerSerializer do
+ describe '#represent' do
+ let(:represent) { described_class.new.represent(trigger) }
+
+ let(:trigger) { build_stubbed(:ci_trigger) }
+
+ it 'matches schema' do
+ expect(represent.to_json).to match_schema('entities/trigger')
+ end
+ end
+end
diff --git a/spec/serializers/cluster_serializer_spec.rb b/spec/serializers/cluster_serializer_spec.rb
index 04999975276..e65e97b6ae0 100644
--- a/spec/serializers/cluster_serializer_spec.rb
+++ b/spec/serializers/cluster_serializer_spec.rb
@@ -13,6 +13,7 @@ RSpec.describe ClusterSerializer do
:cluster_type,
:enabled,
:environment_scope,
+ :id,
:gitlab_managed_apps_logs_path,
:enable_advanced_logs_querying,
:kubernetes_errors,
diff --git a/spec/serializers/diff_file_base_entity_spec.rb b/spec/serializers/diff_file_base_entity_spec.rb
index 94c39e11790..99dbaff4b7e 100644
--- a/spec/serializers/diff_file_base_entity_spec.rb
+++ b/spec/serializers/diff_file_base_entity_spec.rb
@@ -3,10 +3,24 @@
require 'spec_helper'
RSpec.describe DiffFileBaseEntity do
- let(:project) { create(:project, :repository) }
+ include ProjectForksHelper
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+
let(:repository) { project.repository }
let(:entity) { described_class.new(diff_file, options).as_json }
+ shared_examples 'nil if removed source branch' do |key|
+ before do
+ allow(merge_request).to receive(:source_branch_exists?).and_return(false)
+ end
+
+ specify do
+ expect(entity[key]).to eq(nil)
+ end
+ end
+
context 'submodule information for a' do
let(:commit_sha) { "" }
let(:commit) { project.commit(commit_sha) }
@@ -67,7 +81,7 @@ RSpec.describe DiffFileBaseEntity do
context 'edit_path' do
let(:diff_file) { merge_request.diffs.diff_files.to_a.last }
- let(:options) { { request: EntityRequest.new(current_user: create(:user)), merge_request: merge_request } }
+ let(:options) { { request: EntityRequest.new(current_user: user), merge_request: merge_request } }
let(:params) { {} }
shared_examples 'a diff file edit path to the source branch' do
@@ -81,16 +95,7 @@ RSpec.describe DiffFileBaseEntity do
let(:params) { { from_merge_request_iid: merge_request.iid } }
it_behaves_like 'a diff file edit path to the source branch'
-
- context 'removed source branch' do
- before do
- allow(merge_request).to receive(:source_branch_exists?).and_return(false)
- end
-
- it do
- expect(entity[:edit_path]).to eq(nil)
- end
- end
+ it_behaves_like 'nil if removed source branch', :edit_path
end
context 'closed' do
@@ -118,4 +123,30 @@ RSpec.describe DiffFileBaseEntity do
end
end
end
+
+ context 'ide_edit_path' do
+ let(:source_project) { project }
+ let(:merge_request) { create(:merge_request, iid: 123, target_project: target_project, source_project: source_project) }
+ let(:diff_file) { merge_request.diffs.diff_files.to_a.last }
+ let(:options) { { request: EntityRequest.new(current_user: user), merge_request: merge_request } }
+ let(:expected_merge_request_path) { "/-/ide/project/#{source_project.full_path}/merge_requests/#{merge_request.iid}" }
+
+ context 'when source_project and target_project are the same' do
+ let(:target_project) { source_project }
+
+ it_behaves_like 'nil if removed source branch', :ide_edit_path
+
+ it 'returns the merge_request ide route' do
+ expect(entity[:ide_edit_path]).to eq expected_merge_request_path
+ end
+ end
+
+ context 'when source_project and target_project are different' do
+ let(:target_project) { fork_project(source_project, source_project.owner, repository: true) }
+
+ it 'returns the merge_request ide route with the target_project as param' do
+ expect(entity[:ide_edit_path]).to eq("#{expected_merge_request_path}?target_project=#{ERB::Util.url_encode(target_project.full_path)}")
+ end
+ end
+ end
end
diff --git a/spec/serializers/diffs_entity_spec.rb b/spec/serializers/diffs_entity_spec.rb
index 7c59e4aed83..5928a1c24b3 100644
--- a/spec/serializers/diffs_entity_spec.rb
+++ b/spec/serializers/diffs_entity_spec.rb
@@ -68,15 +68,5 @@ RSpec.describe DiffsEntity do
end
end
end
-
- context 'when code_navigation feature flag is disabled' do
- it 'does not include code navigation properties' do
- stub_feature_flags(code_navigation: false)
-
- expect(Gitlab::CodeNavigationPath).not_to receive(:new)
-
- expect(subject).not_to include(:definition_path_prefix)
- end
- end
end
end
diff --git a/spec/serializers/feature_flag_entity_spec.rb b/spec/serializers/feature_flag_entity_spec.rb
new file mode 100644
index 00000000000..21ecfe59c31
--- /dev/null
+++ b/spec/serializers/feature_flag_entity_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe FeatureFlagEntity do
+ let(:feature_flag) { create(:operations_feature_flag, project: project) }
+ let(:project) { create(:project) }
+ let(:request) { double('request', current_user: user) }
+ let(:user) { create(:user) }
+ let(:entity) { described_class.new(feature_flag, request: request) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ subject { entity.as_json }
+
+ it 'has feature flag attributes' do
+ expect(subject).to include(:id, :active, :created_at, :updated_at,
+ :description, :name, :edit_path, :destroy_path)
+ end
+end
diff --git a/spec/serializers/feature_flag_serializer_spec.rb b/spec/serializers/feature_flag_serializer_spec.rb
new file mode 100644
index 00000000000..fab8ca93b1b
--- /dev/null
+++ b/spec/serializers/feature_flag_serializer_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe FeatureFlagSerializer do
+ let(:serializer) { described_class.new(project: project, current_user: user) }
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:feature_flags) { create_list(:operations_feature_flag, 3) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ describe '#represent' do
+ subject { serializer.represent(feature_flags) }
+
+ it 'includes feature flag attributes' do
+ is_expected.to all(include(:id, :active, :created_at, :updated_at,
+ :description, :name))
+ end
+ end
+end
diff --git a/spec/serializers/feature_flag_summary_entity_spec.rb b/spec/serializers/feature_flag_summary_entity_spec.rb
new file mode 100644
index 00000000000..385a9deb2d7
--- /dev/null
+++ b/spec/serializers/feature_flag_summary_entity_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe FeatureFlagSummaryEntity do
+ let!(:feature_flag) { create(:operations_feature_flag, project: project) }
+ let(:project) { create(:project) }
+ let(:request) { double('request', current_user: user) }
+ let(:user) { create(:user) }
+ let(:entity) { described_class.new(project, request: request) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ subject { entity.as_json }
+
+ it 'has summary information' do
+ expect(subject).to include(:count)
+ end
+end
diff --git a/spec/serializers/feature_flag_summary_serializer_spec.rb b/spec/serializers/feature_flag_summary_serializer_spec.rb
new file mode 100644
index 00000000000..79cef6765f7
--- /dev/null
+++ b/spec/serializers/feature_flag_summary_serializer_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe FeatureFlagSummarySerializer do
+ let(:serializer) { described_class.new(project: project, current_user: user) }
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let!(:feature_flags) { create(:operations_feature_flag, project: project) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ describe '#represent' do
+ subject { serializer.represent(project) }
+
+ it 'has summary information' do
+ expect(subject).to include(:count)
+ end
+ end
+end
diff --git a/spec/serializers/feature_flags_client_serializer_spec.rb b/spec/serializers/feature_flags_client_serializer_spec.rb
new file mode 100644
index 00000000000..3746142a3f1
--- /dev/null
+++ b/spec/serializers/feature_flags_client_serializer_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe FeatureFlagsClientSerializer do
+ let(:project) { create(:project) }
+ let(:feature_flags_client) { project.create_operations_feature_flags_client! }
+ let(:serializer) { described_class.new }
+
+ describe '#represent_token' do
+ subject { serializer.represent_token(feature_flags_client).to_json }
+
+ it 'includes feature flags client token' do
+ expect(subject).to match_schema('feature_flags_client_token')
+ end
+ end
+end
diff --git a/spec/serializers/group_group_link_entity_spec.rb b/spec/serializers/group_group_link_entity_spec.rb
index 8384563e3e6..9affe4af381 100644
--- a/spec/serializers/group_group_link_entity_spec.rb
+++ b/spec/serializers/group_group_link_entity_spec.rb
@@ -5,9 +5,27 @@ require 'spec_helper'
RSpec.describe GroupGroupLinkEntity do
include_context 'group_group_link'
- subject(:json) { described_class.new(group_group_link).to_json }
+ let_it_be(:current_user) { create(:user) }
+ let(:entity) { described_class.new(group_group_link) }
+
+ before do
+ allow(entity).to receive(:current_user).and_return(current_user)
+ end
it 'matches json schema' do
- expect(json).to match_schema('entities/group_group_link')
+ expect(entity.to_json).to match_schema('entities/group_group_link')
+ end
+
+ context 'a user with :admin_group_member permissions' do
+ before do
+ allow(entity).to receive(:can?).with(current_user, :admin_group_member, shared_group).and_return(true)
+ end
+
+ it 'sets `can_update` and `can_remove` to `true`' do
+ json = entity.as_json
+
+ expect(json[:can_update]).to be true
+ expect(json[:can_remove]).to be true
+ end
end
end
diff --git a/spec/serializers/merge_request_poll_cached_widget_entity_spec.rb b/spec/serializers/merge_request_poll_cached_widget_entity_spec.rb
index 51564de6041..031dc729a79 100644
--- a/spec/serializers/merge_request_poll_cached_widget_entity_spec.rb
+++ b/spec/serializers/merge_request_poll_cached_widget_entity_spec.rb
@@ -3,12 +3,11 @@
require 'spec_helper'
RSpec.describe MergeRequestPollCachedWidgetEntity do
- include ProjectForksHelper
using RSpec::Parameterized::TableSyntax
- let(:project) { create :project, :repository }
- let(:resource) { create(:merge_request, source_project: project, target_project: project) }
- let(:user) { create(:user) }
+ let_it_be(:project, refind: true) { create :project, :repository }
+ let_it_be(:resource, refind: true) { create(:merge_request, source_project: project, target_project: project) }
+ let_it_be(:user) { create(:user) }
let(:request) { double('request', current_user: user, project: project) }
@@ -174,8 +173,6 @@ RSpec.describe MergeRequestPollCachedWidgetEntity do
end
context 'when auto merge is not enabled' do
- let(:resource) { create(:merge_request) }
-
it 'returns auto merge related information' do
expect(subject[:auto_merge_enabled]).to be_falsy
end
@@ -215,15 +212,55 @@ RSpec.describe MergeRequestPollCachedWidgetEntity do
expect(subject[:commits_without_merge_commits].size).to eq(12)
end
end
+ end
- context 'when merge request is not mergeable' do
- before do
- allow(resource).to receive(:mergeable?).and_return(false)
+ describe 'pipeline' do
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, project: project, ref: resource.source_branch, sha: resource.source_branch_sha, head_pipeline_of: resource) }
+
+ before do
+ allow_any_instance_of(MergeRequestPresenter).to receive(:can?).and_call_original
+ allow_any_instance_of(MergeRequestPresenter).to receive(:can?).with(user, :read_pipeline, anything).and_return(can_access)
+ end
+
+ context 'when user has access to pipelines' do
+ let(:can_access) { true }
+
+ context 'when is up to date' do
+ let(:req) { double('request', current_user: user, project: project) }
+
+ it 'returns pipeline' do
+ pipeline_payload =
+ MergeRequests::PipelineEntity
+ .represent(pipeline, request: req)
+ .as_json
+
+ expect(subject[:pipeline]).to eq(pipeline_payload)
+ end
+
+ context 'when merge_request_cached_pipeline_serializer is disabled' do
+ it 'does not return pipeline' do
+ stub_feature_flags(merge_request_cached_pipeline_serializer: false)
+
+ expect(subject[:pipeline]).to be_nil
+ end
+ end
+ end
+
+ context 'when user does not have access to pipelines' do
+ let(:can_access) { false }
+ let(:req) { double('request', current_user: user, project: project) }
+
+ it 'does not have pipeline' do
+ expect(subject[:pipeline]).to eq(nil)
+ end
end
- it 'does not have default_squash_commit_message and commits_without_merge_commits' do
- expect(subject[:default_squash_commit_message]).to eq(nil)
- expect(subject[:commits_without_merge_commits]).to eq(nil)
+ context 'when is not up to date' do
+ it 'returns nil' do
+ pipeline.update!(sha: "not up to date")
+
+ expect(subject[:pipeline]).to eq(nil)
+ end
end
end
end
diff --git a/spec/serializers/merge_request_poll_widget_entity_spec.rb b/spec/serializers/merge_request_poll_widget_entity_spec.rb
index 161940dd01a..1e5a8915da0 100644
--- a/spec/serializers/merge_request_poll_widget_entity_spec.rb
+++ b/spec/serializers/merge_request_poll_widget_entity_spec.rb
@@ -44,20 +44,6 @@ RSpec.describe MergeRequestPollWidgetEntity do
expect(subject[:merge_pipeline]).to eq(pipeline_payload)
end
- context 'when merge_request_short_pipeline_serializer is disabled' do
- it 'returns detailed info about pipeline' do
- stub_feature_flags(merge_request_short_pipeline_serializer: false)
-
- pipeline.reload
- pipeline_payload =
- PipelineDetailsEntity
- .represent(pipeline, request: request)
- .as_json
-
- expect(subject[:merge_pipeline]).to eq(pipeline_payload)
- end
- end
-
context 'when user cannot read pipelines on target project' do
before do
project.add_guest(user)
@@ -236,21 +222,16 @@ RSpec.describe MergeRequestPollWidgetEntity do
context 'when is up to date' do
let(:req) { double('request', current_user: user, project: project) }
- it 'returns pipeline' do
- pipeline_payload =
- MergeRequests::PipelineEntity
- .represent(pipeline, request: req)
- .as_json
-
- expect(subject[:pipeline]).to eq(pipeline_payload)
+ it 'does not return pipeline' do
+ expect(subject[:pipeline]).to be_nil
end
- context 'when merge_request_short_pipeline_serializer is disabled' do
+ context 'when merge_request_cached_pipeline_serializer is disabled' do
it 'returns detailed info about pipeline' do
- stub_feature_flags(merge_request_short_pipeline_serializer: false)
+ stub_feature_flags(merge_request_cached_pipeline_serializer: false)
pipeline_payload =
- PipelineDetailsEntity
+ MergeRequests::PipelineEntity
.represent(pipeline, request: req)
.as_json
@@ -276,10 +257,6 @@ RSpec.describe MergeRequestPollWidgetEntity do
let(:result) { false }
let(:req) { double('request', current_user: user, project: project) }
- it 'does not have pipeline' do
- expect(subject[:pipeline]).to eq(nil)
- end
-
it 'does not return ci_status' do
expect(subject[:ci_status]).to eq(nil)
end
diff --git a/spec/serializers/merge_request_widget_entity_spec.rb b/spec/serializers/merge_request_widget_entity_spec.rb
index 1432c4499ae..a1017d05bc3 100644
--- a/spec/serializers/merge_request_widget_entity_spec.rb
+++ b/spec/serializers/merge_request_widget_entity_spec.rb
@@ -272,7 +272,7 @@ RSpec.describe MergeRequestWidgetEntity do
describe 'user callouts' do
context 'when suggest pipeline feature is enabled' do
before do
- stub_feature_flags(suggest_pipeline: true)
+ stub_experiment(suggest_pipeline: true)
end
it 'provides a valid path value for user callout path' do
@@ -308,7 +308,7 @@ RSpec.describe MergeRequestWidgetEntity do
context 'when suggest pipeline feature is not enabled' do
before do
- stub_feature_flags(suggest_pipeline: false)
+ stub_experiment(suggest_pipeline: false)
end
it 'provides no valid value for user callout path' do
diff --git a/spec/serializers/paginated_diff_entity_spec.rb b/spec/serializers/paginated_diff_entity_spec.rb
index a2c58baed55..821ed34d3ec 100644
--- a/spec/serializers/paginated_diff_entity_spec.rb
+++ b/spec/serializers/paginated_diff_entity_spec.rb
@@ -31,14 +31,4 @@ RSpec.describe PaginatedDiffEntity do
total_pages: 7
)
end
-
- context 'when code_navigation feature flag is disabled' do
- it 'does not execute Gitlab::CodeNavigationPath' do
- stub_feature_flags(code_navigation: false)
-
- expect(Gitlab::CodeNavigationPath).not_to receive(:new)
-
- subject
- end
- end
end
diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb
index b42a4f6ad3f..e0f6ab68034 100644
--- a/spec/serializers/pipeline_serializer_spec.rb
+++ b/spec/serializers/pipeline_serializer_spec.rb
@@ -155,7 +155,7 @@ RSpec.describe PipelineSerializer do
it 'verifies number of queries', :request_store do
recorded = ActiveRecord::QueryRecorder.new { subject }
- expected_queries = Gitlab.ee? ? 43 : 40
+ expected_queries = Gitlab.ee? ? 39 : 36
expect(recorded.count).to be_within(2).of(expected_queries)
expect(recorded.cached_count).to eq(0)
@@ -176,7 +176,7 @@ RSpec.describe PipelineSerializer do
# pipeline. With the same ref this check is cached but if refs are
# different then there is an extra query per ref
# https://gitlab.com/gitlab-org/gitlab-foss/issues/46368
- expected_queries = Gitlab.ee? ? 49 : 46
+ expected_queries = Gitlab.ee? ? 42 : 39
expect(recorded.count).to be_within(2).of(expected_queries)
expect(recorded.cached_count).to eq(0)
@@ -199,11 +199,10 @@ RSpec.describe PipelineSerializer do
it 'verifies number of queries', :request_store do
recorded = ActiveRecord::QueryRecorder.new { subject }
- # 99 queries by default + 2 related to preloading
- # :source_pipeline and :source_job
# Existing numbers are high and require performance optimization
+ # Ongoing issue:
# https://gitlab.com/gitlab-org/gitlab/-/issues/225156
- expected_queries = Gitlab.ee? ? 95 : 86
+ expected_queries = Gitlab.ee? ? 85 : 76
expect(recorded.count).to be_within(2).of(expected_queries)
expect(recorded.cached_count).to eq(0)
diff --git a/spec/serializers/test_case_entity_spec.rb b/spec/serializers/test_case_entity_spec.rb
index 32e9562f4c1..45e63e3feec 100644
--- a/spec/serializers/test_case_entity_spec.rb
+++ b/spec/serializers/test_case_entity_spec.rb
@@ -19,6 +19,7 @@ RSpec.describe TestCaseEntity do
expect(subject[:status]).to eq('success')
expect(subject[:name]).to eq('Test#sum when a is 1 and b is 3 returns summary')
expect(subject[:classname]).to eq('spec.test_spec')
+ expect(subject[:file]).to eq('./spec/test_spec.rb')
expect(subject[:execution_time]).to eq(1.11)
end
end
@@ -30,6 +31,7 @@ RSpec.describe TestCaseEntity do
expect(subject[:status]).to eq('failed')
expect(subject[:name]).to eq('Test#sum when a is 1 and b is 3 returns summary')
expect(subject[:classname]).to eq('spec.test_spec')
+ expect(subject[:file]).to eq('./spec/test_spec.rb')
expect(subject[:execution_time]).to eq(2.22)
end
end
diff --git a/spec/services/admin/propagate_integration_service_spec.rb b/spec/services/admin/propagate_integration_service_spec.rb
index 49d974b7154..5dfe39aebbc 100644
--- a/spec/services/admin/propagate_integration_service_spec.rb
+++ b/spec/services/admin/propagate_integration_service_spec.rb
@@ -10,9 +10,7 @@ RSpec.describe Admin::PropagateIntegrationService do
stub_jira_service_test
end
- let(:excluded_attributes) { %w[id project_id group_id inherit_from_id instance created_at updated_at default] }
- let!(:project) { create(:project) }
- let!(:group) { create(:group) }
+ let_it_be(:project) { create(:project) }
let!(:instance_integration) do
JiraService.create!(
instance: true,
@@ -39,7 +37,7 @@ RSpec.describe Admin::PropagateIntegrationService do
let!(:not_inherited_integration) do
JiraService.create!(
- project: create(:project),
+ project: project,
inherit_from_id: nil,
instance: false,
active: true,
@@ -52,7 +50,7 @@ RSpec.describe Admin::PropagateIntegrationService do
let!(:different_type_inherited_integration) do
BambooService.create!(
- project: create(:project),
+ project: project,
inherit_from_id: instance_integration.id,
instance: false,
active: true,
@@ -64,75 +62,37 @@ RSpec.describe Admin::PropagateIntegrationService do
)
end
- shared_examples 'inherits settings from integration' do
- it 'updates the inherited integrations' do
- described_class.propagate(instance_integration)
+ context 'with inherited integration' do
+ let(:integration) { inherited_integration }
- expect(integration.reload.inherit_from_id).to eq(instance_integration.id)
- expect(integration.attributes.except(*excluded_attributes))
- .to eq(instance_integration.attributes.except(*excluded_attributes))
- end
+ it 'calls to PropagateIntegrationProjectWorker' do
+ expect(PropagateIntegrationInheritWorker).to receive(:perform_async)
+ .with(instance_integration.id, inherited_integration.id, inherited_integration.id)
- context 'integration with data fields' do
- let(:excluded_attributes) { %w[id service_id created_at updated_at] }
-
- it 'updates the data fields from inherited integrations' do
- described_class.propagate(instance_integration)
-
- expect(integration.reload.data_fields.attributes.except(*excluded_attributes))
- .to eq(instance_integration.data_fields.attributes.except(*excluded_attributes))
- end
- end
- end
-
- shared_examples 'does not inherit settings from integration' do
- it 'does not update the not inherited integrations' do
described_class.propagate(instance_integration)
-
- expect(integration.reload.attributes.except(*excluded_attributes))
- .not_to eq(instance_integration.attributes.except(*excluded_attributes))
end
end
- context 'update only inherited integrations' do
- it_behaves_like 'inherits settings from integration' do
- let(:integration) { inherited_integration }
- end
-
- it_behaves_like 'does not inherit settings from integration' do
- let(:integration) { not_inherited_integration }
- end
-
- it_behaves_like 'does not inherit settings from integration' do
- let(:integration) { different_type_inherited_integration }
- end
+ context 'with a project without integration' do
+ let!(:another_project) { create(:project) }
- it_behaves_like 'inherits settings from integration' do
- let(:integration) { project.jira_service }
- end
+ it 'calls to PropagateIntegrationProjectWorker' do
+ expect(PropagateIntegrationProjectWorker).to receive(:perform_async)
+ .with(instance_integration.id, another_project.id, another_project.id)
- it_behaves_like 'inherits settings from integration' do
- let(:integration) { Service.find_by(group_id: group.id) }
+ described_class.propagate(instance_integration)
end
end
- it 'updates project#has_external_issue_tracker for issue tracker services' do
- described_class.propagate(instance_integration)
+ context 'with a group without integration' do
+ let!(:group) { create(:group) }
- expect(project.reload.has_external_issue_tracker).to eq(true)
- end
+ it 'calls to PropagateIntegrationProjectWorker' do
+ expect(PropagateIntegrationGroupWorker).to receive(:perform_async)
+ .with(instance_integration.id, group.id, group.id)
- it 'updates project#has_external_wiki for external wiki services' do
- instance_integration = ExternalWikiService.create!(
- instance: true,
- active: true,
- push_events: false,
- external_wiki_url: 'http://external-wiki-url.com'
- )
-
- described_class.propagate(instance_integration)
-
- expect(project.reload.has_external_wiki).to eq(true)
+ described_class.propagate(instance_integration)
+ end
end
end
end
diff --git a/spec/services/admin/propagate_service_template_spec.rb b/spec/services/admin/propagate_service_template_spec.rb
index 15654653095..d95d31ceaea 100644
--- a/spec/services/admin/propagate_service_template_spec.rb
+++ b/spec/services/admin/propagate_service_template_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe Admin::PropagateServiceTemplate do
describe '.propagate' do
+ let_it_be(:project) { create(:project) }
let!(:service_template) do
PushoverService.create!(
template: true,
@@ -19,124 +20,40 @@ RSpec.describe Admin::PropagateServiceTemplate do
)
end
- let!(:project) { create(:project) }
- let(:excluded_attributes) { %w[id project_id template created_at updated_at default] }
-
- it 'creates services for projects' do
- expect(project.pushover_service).to be_nil
+ it 'calls to PropagateIntegrationProjectWorker' do
+ expect(PropagateIntegrationProjectWorker).to receive(:perform_async)
+ .with(service_template.id, project.id, project.id)
described_class.propagate(service_template)
-
- expect(project.reload.pushover_service).to be_present
- end
-
- it 'creates services for a project that has another service' do
- BambooService.create!(
- active: true,
- project: project,
- properties: {
- bamboo_url: 'http://gitlab.com',
- username: 'mic',
- password: 'password',
- build_key: 'build'
- }
- )
-
- expect(project.pushover_service).to be_nil
-
- described_class.propagate(service_template)
-
- expect(project.reload.pushover_service).to be_present
- end
-
- it 'does not create the service if it exists already' do
- other_service = BambooService.create!(
- template: true,
- active: true,
- properties: {
- bamboo_url: 'http://gitlab.com',
- username: 'mic',
- password: 'password',
- build_key: 'build'
- }
- )
-
- Service.build_from_integration(project.id, service_template).save!
- Service.build_from_integration(project.id, other_service).save!
-
- expect { described_class.propagate(service_template) }
- .not_to change { Service.count }
end
- it 'creates the service containing the template attributes' do
- described_class.propagate(service_template)
-
- expect(project.pushover_service.properties).to eq(service_template.properties)
-
- expect(project.pushover_service.attributes.except(*excluded_attributes))
- .to eq(service_template.attributes.except(*excluded_attributes))
- end
-
- context 'service with data fields' do
- include JiraServiceHelper
-
- let(:service_template) do
- stub_jira_service_test
-
- JiraService.create!(
- template: true,
+ context 'with a project that has another service' do
+ before do
+ BambooService.create!(
active: true,
- push_events: false,
- url: 'http://jira.instance.com',
- username: 'user',
- password: 'secret'
+ project: project,
+ properties: {
+ bamboo_url: 'http://gitlab.com',
+ username: 'mic',
+ password: 'password',
+ build_key: 'build'
+ }
)
end
- it 'creates the service containing the template attributes' do
- described_class.propagate(service_template)
-
- expect(project.jira_service.attributes.except(*excluded_attributes))
- .to eq(service_template.attributes.except(*excluded_attributes))
-
- excluded_attributes = %w[id service_id created_at updated_at]
- expect(project.jira_service.data_fields.attributes.except(*excluded_attributes))
- .to eq(service_template.data_fields.attributes.except(*excluded_attributes))
- end
- end
-
- describe 'bulk update', :use_sql_query_cache do
- let(:project_total) { 5 }
-
- before do
- stub_const('Admin::PropagateServiceTemplate::BATCH_SIZE', 3)
-
- project_total.times { create(:project) }
+ it 'calls to PropagateIntegrationProjectWorker' do
+ expect(PropagateIntegrationProjectWorker).to receive(:perform_async)
+ .with(service_template.id, project.id, project.id)
described_class.propagate(service_template)
end
-
- it 'creates services for all projects' do
- expect(Service.all.reload.count).to eq(project_total + 2)
- end
- end
-
- describe 'external tracker' do
- it 'updates the project external tracker' do
- service_template.update!(category: 'issue_tracker')
-
- expect { described_class.propagate(service_template) }
- .to change { project.reload.has_external_issue_tracker }.to(true)
- end
end
- describe 'external wiki' do
- it 'updates the project external tracker' do
- service_template.update!(type: 'ExternalWikiService')
+ it 'does not create the service if it exists already' do
+ Service.build_from_integration(service_template, project_id: project.id).save!
- expect { described_class.propagate(service_template) }
- .to change { project.reload.has_external_wiki }.to(true)
- end
+ expect { described_class.propagate(service_template) }
+ .not_to change { Service.count }
end
end
end
diff --git a/spec/services/alert_management/process_prometheus_alert_service_spec.rb b/spec/services/alert_management/process_prometheus_alert_service_spec.rb
index b14cc65506a..ad4ab26c198 100644
--- a/spec/services/alert_management/process_prometheus_alert_service_spec.rb
+++ b/spec/services/alert_management/process_prometheus_alert_service_spec.rb
@@ -148,28 +148,20 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
expect { execute }.to change { alert.reload.resolved? }.to(true)
end
- [true, false].each do |state_tracking_enabled|
- context 'existing issue' do
- before do
- stub_feature_flags(track_resource_state_change_events: state_tracking_enabled)
- end
-
- let!(:alert) { create(:alert_management_alert, :with_issue, project: project, fingerprint: fingerprint) }
-
- it 'closes the issue' do
- issue = alert.issue
-
- expect { execute }
- .to change { issue.reload.state }
- .from('opened')
- .to('closed')
- end
-
- if state_tracking_enabled
- specify { expect { execute }.to change(ResourceStateEvent, :count).by(1) }
- else
- specify { expect { execute }.to change(Note, :count).by(1) }
- end
+ context 'existing issue' do
+ let!(:alert) { create(:alert_management_alert, :with_issue, project: project, fingerprint: fingerprint) }
+
+ it 'closes the issue' do
+ issue = alert.issue
+
+ expect { execute }
+ .to change { issue.reload.state }
+ .from('opened')
+ .to('closed')
+ end
+
+ it 'creates a resource state event' do
+ expect { execute }.to change(ResourceStateEvent, :count).by(1)
end
end
end
diff --git a/spec/services/audit_event_service_spec.rb b/spec/services/audit_event_service_spec.rb
index 93de2a23edc..3317fcf8444 100644
--- a/spec/services/audit_event_service_spec.rb
+++ b/spec/services/audit_event_service_spec.rb
@@ -57,7 +57,7 @@ RSpec.describe AuditEventService do
let(:audit_service) { described_class.new(user, user, with: 'standard') }
it 'creates an authentication event' do
- expect(AuthenticationEvent).to receive(:create).with(
+ expect(AuthenticationEvent).to receive(:new).with(
user: user,
user_name: user.name,
ip_address: user.current_sign_in_ip,
@@ -67,6 +67,17 @@ RSpec.describe AuditEventService do
audit_service.for_authentication.security_event
end
+
+ it 'tracks exceptions when the event cannot be created' do
+ allow(user).to receive_messages(current_sign_in_ip: 'invalid IP')
+
+ expect(Gitlab::ErrorTracking).to(
+ receive(:track_exception)
+ .with(ActiveRecord::RecordInvalid, audit_event_type: 'AuthenticationEvent').and_call_original
+ )
+
+ audit_service.for_authentication.security_event
+ end
end
end
diff --git a/spec/services/bulk_create_integration_service_spec.rb b/spec/services/bulk_create_integration_service_spec.rb
new file mode 100644
index 00000000000..5d896f78b35
--- /dev/null
+++ b/spec/services/bulk_create_integration_service_spec.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkCreateIntegrationService do
+ include JiraServiceHelper
+
+ before do
+ stub_jira_service_test
+ end
+
+ let(:excluded_attributes) { %w[id project_id group_id inherit_from_id instance template created_at updated_at] }
+ let!(:instance_integration) { create(:jira_service, :instance) }
+ let!(:template_integration) { create(:jira_service, :template) }
+
+ shared_examples 'creates integration from batch ids' do
+ it 'updates the inherited integrations' do
+ described_class.new(integration, batch, association).execute
+
+ expect(created_integration.attributes.except(*excluded_attributes))
+ .to eq(integration.attributes.except(*excluded_attributes))
+ end
+
+ context 'integration with data fields' do
+ let(:excluded_attributes) { %w[id service_id created_at updated_at] }
+
+ it 'updates the data fields from inherited integrations' do
+ described_class.new(integration, batch, association).execute
+
+ expect(created_integration.reload.data_fields.attributes.except(*excluded_attributes))
+ .to eq(integration.data_fields.attributes.except(*excluded_attributes))
+ end
+ end
+ end
+
+ shared_examples 'updates inherit_from_id' do
+ it 'updates inherit_from_id attributes' do
+ described_class.new(integration, batch, association).execute
+
+ expect(created_integration.reload.inherit_from_id).to eq(integration.id)
+ end
+ end
+
+ shared_examples 'runs project callbacks' do
+ it 'updates projects#has_external_issue_tracker for issue tracker services' do
+ described_class.new(integration, batch, association).execute
+
+ expect(project.reload.has_external_issue_tracker).to eq(true)
+ end
+
+ context 'with an external wiki integration' do
+ let(:integration) do
+ ExternalWikiService.create!(
+ instance: true,
+ active: true,
+ push_events: false,
+ external_wiki_url: 'http://external-wiki-url.com'
+ )
+ end
+
+ it 'updates projects#has_external_wiki for external wiki services' do
+ described_class.new(integration, batch, association).execute
+
+ expect(project.reload.has_external_wiki).to eq(true)
+ end
+ end
+ end
+
+ context 'with an instance-level integration' do
+ let(:integration) { instance_integration }
+
+ context 'with a project association' do
+ let!(:project) { create(:project) }
+ let(:created_integration) { project.jira_service }
+ let(:batch) { Project.all }
+ let(:association) { 'project' }
+
+ it_behaves_like 'creates integration from batch ids'
+ it_behaves_like 'updates inherit_from_id'
+ it_behaves_like 'runs project callbacks'
+ end
+
+ context 'with a group association' do
+ let!(:group) { create(:group) }
+ let(:created_integration) { Service.find_by(group: group) }
+ let(:batch) { Group.all }
+ let(:association) { 'group' }
+
+ it_behaves_like 'creates integration from batch ids'
+ it_behaves_like 'updates inherit_from_id'
+ end
+ end
+
+ context 'with a template integration' do
+ let(:integration) { template_integration }
+
+ context 'with a project association' do
+ let!(:project) { create(:project) }
+ let(:created_integration) { project.jira_service }
+ let(:batch) { Project.all }
+ let(:association) { 'project' }
+
+ it_behaves_like 'creates integration from batch ids'
+ it_behaves_like 'runs project callbacks'
+ end
+ end
+end
diff --git a/spec/services/bulk_update_integration_service_spec.rb b/spec/services/bulk_update_integration_service_spec.rb
new file mode 100644
index 00000000000..2f0bfd31600
--- /dev/null
+++ b/spec/services/bulk_update_integration_service_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkUpdateIntegrationService do
+ include JiraServiceHelper
+
+ before do
+ stub_jira_service_test
+ end
+
+ let(:excluded_attributes) { %w[id project_id group_id inherit_from_id instance template created_at updated_at] }
+ let!(:instance_integration) do
+ JiraService.create!(
+ instance: true,
+ active: true,
+ push_events: true,
+ url: 'http://update-jira.instance.com',
+ username: 'user',
+ password: 'secret'
+ )
+ end
+
+ let!(:integration) do
+ JiraService.create!(
+ project: create(:project),
+ inherit_from_id: instance_integration.id,
+ instance: false,
+ active: true,
+ push_events: false,
+ url: 'http://jira.instance.com',
+ username: 'user',
+ password: 'secret'
+ )
+ end
+
+ context 'with inherited integration' do
+ it 'updates the integration' do
+ described_class.new(instance_integration, Service.inherit_from_id(instance_integration.id)).execute
+
+ expect(integration.reload.inherit_from_id).to eq(instance_integration.id)
+ expect(integration.attributes.except(*excluded_attributes))
+ .to eq(instance_integration.attributes.except(*excluded_attributes))
+ end
+
+ context 'with integration with data fields' do
+ let(:excluded_attributes) { %w[id service_id created_at updated_at] }
+
+ it 'updates the data fields from the integration' do
+ described_class.new(instance_integration, Service.inherit_from_id(instance_integration.id)).execute
+
+ expect(integration.reload.data_fields.attributes.except(*excluded_attributes))
+ .to eq(instance_integration.data_fields.attributes.except(*excluded_attributes))
+ end
+ end
+ end
+end
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index e0893ed6de3..c28c3449485 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -731,30 +731,11 @@ RSpec.describe Ci::CreatePipelineService do
.and_call_original
end
- context 'when ci_pipeline_rewind_iid is enabled' do
- before do
- stub_feature_flags(ci_pipeline_rewind_iid: true)
- end
-
- it 'rewinds iid' do
- result = execute_service
-
- expect(result).not_to be_persisted
- expect(internal_id.last_value).to eq(0)
- end
- end
-
- context 'when ci_pipeline_rewind_iid is disabled' do
- before do
- stub_feature_flags(ci_pipeline_rewind_iid: false)
- end
-
- it 'does not rewind iid' do
- result = execute_service
+ it 'rewinds iid' do
+ result = execute_service
- expect(result).not_to be_persisted
- expect(internal_id.last_value).to eq(1)
- end
+ expect(result).not_to be_persisted
+ expect(internal_id.last_value).to eq(0)
end
end
end
diff --git a/spec/services/ci/expire_pipeline_cache_service_spec.rb b/spec/services/ci/expire_pipeline_cache_service_spec.rb
index b5d664947de..8df5d0bc159 100644
--- a/spec/services/ci/expire_pipeline_cache_service_spec.rb
+++ b/spec/services/ci/expire_pipeline_cache_service_spec.rb
@@ -26,9 +26,11 @@ RSpec.describe Ci::ExpirePipelineCacheService do
project = merge_request.target_project
merge_request_pipelines_path = "/#{project.full_path}/-/merge_requests/#{merge_request.iid}/pipelines.json"
+ merge_request_widget_path = "/#{project.full_path}/-/merge_requests/#{merge_request.iid}/cached_widget.json"
allow_any_instance_of(Gitlab::EtagCaching::Store).to receive(:touch)
expect_any_instance_of(Gitlab::EtagCaching::Store).to receive(:touch).with(merge_request_pipelines_path)
+ expect_any_instance_of(Gitlab::EtagCaching::Store).to receive(:touch).with(merge_request_widget_path)
subject.execute(merge_request.all_pipelines.last)
end
diff --git a/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb b/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb
index 77645298bc7..2936d6fae4d 100644
--- a/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb
+++ b/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb
@@ -43,12 +43,12 @@ RSpec.shared_context 'Pipeline Processing Service Tests With Yaml' do
{
pipeline: pipeline.status,
stages: pipeline.stages.pluck(:name, :status).to_h,
- jobs: pipeline.statuses.latest.pluck(:name, :status).to_h
+ jobs: pipeline.latest_statuses.pluck(:name, :status).to_h
}
end
def event_on_jobs(event, job_names)
- statuses = pipeline.statuses.latest.by_name(job_names).to_a
+ statuses = pipeline.latest_statuses.by_name(job_names).to_a
expect(statuses.count).to eq(job_names.count) # ensure that we have the same counts
statuses.each { |status| status.public_send("#{event}!") }
diff --git a/spec/services/ci/pipelines/create_artifact_service_spec.rb b/spec/services/ci/pipelines/create_artifact_service_spec.rb
index d5e9cf83a6d..6f177889ed3 100644
--- a/spec/services/ci/pipelines/create_artifact_service_spec.rb
+++ b/spec/services/ci/pipelines/create_artifact_service_spec.rb
@@ -35,16 +35,6 @@ RSpec.describe ::Ci::Pipelines::CreateArtifactService do
end
end
- context 'when feature is disabled' do
- it 'does not create a pipeline artifact' do
- stub_feature_flags(coverage_report_view: false)
-
- subject
-
- expect(Ci::PipelineArtifact.count).to eq(0)
- end
- end
-
context 'when pipeline artifact has already been created' do
it 'do not raise an error and do not persist the same artifact twice' do
expect { 2.times { described_class.new.execute(pipeline) } }.not_to raise_error(ActiveRecord::RecordNotUnique)
diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb
index 51741440075..4c1e698d52d 100644
--- a/spec/services/ci/retry_build_service_spec.rb
+++ b/spec/services/ci/retry_build_service_spec.rb
@@ -154,7 +154,7 @@ RSpec.describe Ci::RetryBuildService do
describe '#execute' do
let(:new_build) do
- Timecop.freeze(1.second.from_now) do
+ travel_to(1.second.from_now) do
service.execute(build)
end
end
@@ -257,7 +257,7 @@ RSpec.describe Ci::RetryBuildService do
describe '#reprocess' do
let(:new_build) do
- Timecop.freeze(1.second.from_now) do
+ travel_to(1.second.from_now) do
service.reprocess!(build)
end
end
diff --git a/spec/services/ci/update_build_queue_service_spec.rb b/spec/services/ci/update_build_queue_service_spec.rb
index 0f4c0fa5ecb..ebccfdc5140 100644
--- a/spec/services/ci/update_build_queue_service_spec.rb
+++ b/spec/services/ci/update_build_queue_service_spec.rb
@@ -45,21 +45,7 @@ RSpec.describe Ci::UpdateBuildQueueService do
runner.update!(contacted_at: Ci::Runner.recent_queue_deadline)
end
- context 'when ci_update_queues_for_online_runners is enabled' do
- before do
- stub_feature_flags(ci_update_queues_for_online_runners: true)
- end
-
- it_behaves_like 'does not refresh runner'
- end
-
- context 'when ci_update_queues_for_online_runners is disabled' do
- before do
- stub_feature_flags(ci_update_queues_for_online_runners: false)
- end
-
- it_behaves_like 'refreshes runner'
- end
+ it_behaves_like 'does not refresh runner'
end
end
diff --git a/spec/services/ci/update_build_state_service_spec.rb b/spec/services/ci/update_build_state_service_spec.rb
index f5ad732bf7e..aa1de368154 100644
--- a/spec/services/ci/update_build_state_service_spec.rb
+++ b/spec/services/ci/update_build_state_service_spec.rb
@@ -83,9 +83,26 @@ RSpec.describe Ci::UpdateBuildStateService do
{ checksum: 'crc32:12345678', state: 'failed', failure_reason: 'script_failure' }
end
+ context 'when build does not have associated trace chunks' do
+ it 'updates a build status' do
+ result = subject.execute
+
+ expect(build).to be_failed
+ expect(result.status).to eq 200
+ end
+
+ it 'does not increment invalid trace metric' do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .not_to have_received(:increment_trace_operation)
+ .with(operation: :invalid)
+ end
+ end
+
context 'when build trace has been migrated' do
before do
- create(:ci_build_trace_chunk, :database_with_data, build: build)
+ create(:ci_build_trace_chunk, :persisted, build: build, initial_data: 'abcd')
end
it 'updates a build state' do
@@ -100,6 +117,12 @@ RSpec.describe Ci::UpdateBuildStateService do
expect(result.status).to eq 200
end
+ it 'does not set a backoff value' do
+ result = subject.execute
+
+ expect(result.backoff).to be_nil
+ end
+
it 'increments trace finalized operation metric' do
execute_with_stubbed_metrics!
@@ -107,6 +130,48 @@ RSpec.describe Ci::UpdateBuildStateService do
.to have_received(:increment_trace_operation)
.with(operation: :finalized)
end
+
+ context 'when trace checksum is not valid' do
+ it 'increments invalid trace metric' do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .to have_received(:increment_trace_operation)
+ .with(operation: :invalid)
+ end
+ end
+
+ context 'when trace checksum is valid' do
+ let(:params) { { checksum: 'crc32:ed82cd11', state: 'success' } }
+
+ it 'does not increment invalid trace metric' do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .not_to have_received(:increment_trace_operation)
+ .with(operation: :invalid)
+ end
+ end
+
+ context 'when failed to acquire a build trace lock' do
+ it 'accepts a state update request' do
+ build.trace.lock do
+ result = subject.execute
+
+ expect(result.status).to eq 202
+ end
+ end
+
+ it 'increment locked trace metric' do
+ build.trace.lock do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .to have_received(:increment_trace_operation)
+ .with(operation: :locked)
+ end
+ end
+ end
end
context 'when build trace has not been migrated yet' do
@@ -126,6 +191,12 @@ RSpec.describe Ci::UpdateBuildStateService do
expect(result.status).to eq 202
end
+ it 'sets a request backoff value' do
+ result = subject.execute
+
+ expect(result.backoff.to_i).to be > 0
+ end
+
it 'schedules live chunks for migration' do
expect(Ci::BuildTraceChunkFlushWorker)
.to receive(:perform_async)
@@ -134,14 +205,6 @@ RSpec.describe Ci::UpdateBuildStateService do
subject.execute
end
- it 'increments trace accepted operation metric' do
- execute_with_stubbed_metrics!
-
- expect(metrics)
- .to have_received(:increment_trace_operation)
- .with(operation: :accepted)
- end
-
it 'creates a pending state record' do
subject.execute
@@ -153,6 +216,22 @@ RSpec.describe Ci::UpdateBuildStateService do
end
end
+ it 'increments trace accepted operation metric' do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .to have_received(:increment_trace_operation)
+ .with(operation: :accepted)
+ end
+
+ it 'does not increment invalid trace metric' do
+ execute_with_stubbed_metrics!
+
+ expect(metrics)
+ .not_to have_received(:increment_trace_operation)
+ .with(operation: :invalid)
+ end
+
context 'when build pending state is outdated' do
before do
build.create_pending_state(
diff --git a/spec/services/clusters/gcp/finalize_creation_service_spec.rb b/spec/services/clusters/gcp/finalize_creation_service_spec.rb
index be362dc6e23..a20cf90a770 100644
--- a/spec/services/clusters/gcp/finalize_creation_service_spec.rb
+++ b/spec/services/clusters/gcp/finalize_creation_service_spec.rb
@@ -84,11 +84,9 @@ RSpec.describe Clusters::Gcp::FinalizeCreationService, '#execute' do
before do
stub_cloud_platform_get_zone_cluster(
provider.gcp_project_id, provider.zone, cluster.name,
- {
- endpoint: endpoint,
- username: username,
- password: password
- }
+ endpoint: endpoint,
+ username: username,
+ password: password
)
stub_kubeclient_discover(api_url)
@@ -101,11 +99,9 @@ RSpec.describe Clusters::Gcp::FinalizeCreationService, '#execute' do
stub_kubeclient_get_secret(
api_url,
- {
- metadata_name: secret_name,
- token: Base64.encode64(token),
- namespace: 'default'
- }
+ metadata_name: secret_name,
+ token: Base64.encode64(token),
+ namespace: 'default'
)
stub_kubeclient_put_cluster_role_binding(api_url, 'gitlab-admin')
diff --git a/spec/services/clusters/kubernetes/configure_istio_ingress_service_spec.rb b/spec/services/clusters/kubernetes/configure_istio_ingress_service_spec.rb
index b4402aadc88..f26177a56d0 100644
--- a/spec/services/clusters/kubernetes/configure_istio_ingress_service_spec.rb
+++ b/spec/services/clusters/kubernetes/configure_istio_ingress_service_spec.rb
@@ -26,27 +26,21 @@ RSpec.describe Clusters::Kubernetes::ConfigureIstioIngressService, '#execute' do
stub_kubeclient_get_secret(
api_url,
- {
- metadata_name: "#{namespace}-token",
- token: Base64.encode64('sample-token'),
- namespace: namespace
- }
+ metadata_name: "#{namespace}-token",
+ token: Base64.encode64('sample-token'),
+ namespace: namespace
)
stub_kubeclient_get_secret(
api_url,
- {
- metadata_name: 'istio-ingressgateway-ca-certs',
- namespace: 'istio-system'
- }
+ metadata_name: 'istio-ingressgateway-ca-certs',
+ namespace: 'istio-system'
)
stub_kubeclient_get_secret(
api_url,
- {
- metadata_name: 'istio-ingressgateway-certs',
- namespace: 'istio-system'
- }
+ metadata_name: 'istio-ingressgateway-certs',
+ namespace: 'istio-system'
)
stub_kubeclient_put_secret(api_url, 'istio-ingressgateway-ca-certs', namespace: 'istio-system')
diff --git a/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb b/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb
index ee10c59390e..7e3f1fdb379 100644
--- a/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb
+++ b/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb
@@ -41,11 +41,9 @@ RSpec.describe Clusters::Kubernetes::CreateOrUpdateNamespaceService, '#execute'
stub_kubeclient_get_secret(
api_url,
- {
- metadata_name: "#{namespace}-token",
- token: Base64.encode64('sample-token'),
- namespace: namespace
- }
+ metadata_name: "#{namespace}-token",
+ token: Base64.encode64('sample-token'),
+ namespace: namespace
)
end
diff --git a/spec/services/clusters/kubernetes/fetch_kubernetes_token_service_spec.rb b/spec/services/clusters/kubernetes/fetch_kubernetes_token_service_spec.rb
index c4daae9dbf0..7a283a974d2 100644
--- a/spec/services/clusters/kubernetes/fetch_kubernetes_token_service_spec.rb
+++ b/spec/services/clusters/kubernetes/fetch_kubernetes_token_service_spec.rb
@@ -31,11 +31,9 @@ RSpec.describe Clusters::Kubernetes::FetchKubernetesTokenService do
before do
stub_kubeclient_get_secret(
api_url,
- {
- metadata_name: service_account_token_name,
- namespace: namespace,
- token: token
- }
+ metadata_name: service_account_token_name,
+ namespace: namespace,
+ token: token
)
end
@@ -54,11 +52,9 @@ RSpec.describe Clusters::Kubernetes::FetchKubernetesTokenService do
before do
stub_kubeclient_get_secret_not_found_then_found(
api_url,
- {
- metadata_name: service_account_token_name,
- namespace: namespace,
- token: token
- }
+ metadata_name: service_account_token_name,
+ namespace: namespace,
+ token: token
)
end
@@ -96,11 +92,9 @@ RSpec.describe Clusters::Kubernetes::FetchKubernetesTokenService do
before do
stub_kubeclient_get_secret(
api_url,
- {
- metadata_name: service_account_token_name,
- namespace: namespace,
- token: nil
- }
+ metadata_name: service_account_token_name,
+ namespace: namespace,
+ token: nil
)
end
diff --git a/spec/services/deployments/after_create_service_spec.rb b/spec/services/deployments/after_create_service_spec.rb
index 6cdb4c88191..ee9b008c2f8 100644
--- a/spec/services/deployments/after_create_service_spec.rb
+++ b/spec/services/deployments/after_create_service_spec.rb
@@ -269,14 +269,14 @@ RSpec.describe Deployments::AfterCreateService do
it "does not overwrite the older 'first_deployed_to_production_at' time" do
# Previous deploy
time = 5.minutes.from_now
- Timecop.freeze(time) { service.execute }
+ travel_to(time) { service.execute }
expect(merge_request.reload.metrics.merged_at).to be < merge_request.reload.metrics.first_deployed_to_production_at
previous_time = merge_request.reload.metrics.first_deployed_to_production_at
# Current deploy
- Timecop.freeze(time + 12.hours) { service.execute }
+ travel_to(time + 12.hours) { service.execute }
expect(merge_request.reload.metrics.first_deployed_to_production_at).to eq(previous_time)
end
diff --git a/spec/services/design_management/copy_design_collection/copy_service_spec.rb b/spec/services/design_management/copy_design_collection/copy_service_spec.rb
new file mode 100644
index 00000000000..e93e5f13fea
--- /dev/null
+++ b/spec/services/design_management/copy_design_collection/copy_service_spec.rb
@@ -0,0 +1,259 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe DesignManagement::CopyDesignCollection::CopyService, :clean_gitlab_redis_shared_state do
+ include DesignManagementTestHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:issue, refind: true) { create(:issue, project: project) }
+ let(:target_issue) { create(:issue) }
+
+ subject { described_class.new(project, user, issue: issue, target_issue: target_issue).execute }
+
+ before do
+ enable_design_management
+ end
+
+ shared_examples 'service error' do |message:|
+ it 'returns an error response', :aggregate_failures do
+ expect(subject).to be_kind_of(ServiceResponse)
+ expect(subject).to be_error
+ expect(subject.message).to eq(message)
+ end
+ end
+
+ shared_examples 'service success' do
+ it 'returns a success response', :aggregate_failures do
+ expect(subject).to be_kind_of(ServiceResponse)
+ expect(subject).to be_success
+ end
+ end
+
+ include_examples 'service error', message: 'User cannot copy design collection to issue'
+
+ context 'when user has permission to read the design collection' do
+ before_all do
+ project.add_reporter(user)
+ end
+
+ include_examples 'service error', message: 'User cannot copy design collection to issue'
+
+ context 'when the user also has permission to admin the target issue' do
+ let(:target_repository) { target_issue.project.design_repository }
+
+ before do
+ target_issue.project.add_reporter(user)
+ end
+
+ include_examples 'service error', message: 'Target design collection must first be queued'
+
+ context 'when the target design collection has been queued' do
+ before do
+ target_issue.design_collection.start_copy!
+ end
+
+ include_examples 'service error', message: 'Design collection has no designs'
+
+ context 'when design collection has designs' do
+ let_it_be(:designs) do
+ create_list(:design, 3, :with_lfs_file, :with_relative_position, issue: issue, project: project)
+ end
+
+ context 'when target issue already has designs' do
+ before do
+ create(:design, issue: target_issue, project: target_issue.project)
+ end
+
+ include_examples 'service error', message: 'Target design collection already has designs'
+ end
+
+ include_examples 'service success'
+
+ it 'creates a design repository for the target project' do
+ expect { subject }.to change { target_repository.exists? }.from(false).to(true)
+ end
+
+ context 'when the target project already has a design repository' do
+ before do
+ target_repository.create_if_not_exists
+ end
+
+ include_examples 'service success'
+ end
+
+ it 'copies the designs correctly', :aggregate_failures do
+ expect { subject }.to change { target_issue.designs.count }.by(3)
+
+ old_designs = issue.designs.ordered
+ new_designs = target_issue.designs.ordered
+
+ new_designs.zip(old_designs).each do |new_design, old_design|
+ expect(new_design).to have_attributes(
+ filename: old_design.filename,
+ relative_position: old_design.relative_position,
+ issue: target_issue,
+ project: target_issue.project
+ )
+ end
+ end
+
+ it 'copies the design versions correctly', :aggregate_failures do
+ expect { subject }.to change { target_issue.design_versions.count }.by(3)
+
+ old_versions = issue.design_versions.ordered
+ new_versions = target_issue.design_versions.ordered
+
+ new_versions.zip(old_versions).each do |new_version, old_version|
+ expect(new_version).to have_attributes(
+ created_at: old_version.created_at,
+ author_id: old_version.author_id
+ )
+ expect(new_version.designs.pluck(:filename)).to eq(old_version.designs.pluck(:filename))
+ expect(new_version.actions.pluck(:event)).to eq(old_version.actions.pluck(:event))
+ end
+ end
+
+ it 'copies the design actions correctly', :aggregate_failures do
+ expect { subject }.to change { DesignManagement::Action.count }.by(3)
+
+ old_actions = issue.design_versions.ordered.flat_map(&:actions)
+ new_actions = target_issue.design_versions.ordered.flat_map(&:actions)
+
+ new_actions.zip(old_actions).each do |new_action, old_action|
+ # This is a way to identify if the versions linked to the actions
+ # are correct is to compare design filenames, as the SHA changes.
+ new_design_filenames = new_action.version.designs.ordered.pluck(:filename)
+ old_design_filenames = old_action.version.designs.ordered.pluck(:filename)
+
+ expect(new_design_filenames).to eq(old_design_filenames)
+ expect(new_action.event).to eq(old_action.event)
+ expect(new_action.design.filename).to eq(old_action.design.filename)
+ end
+ end
+
+ it 'copies design notes correctly', :aggregate_failures, :sidekiq_inline do
+ old_notes = [
+ create(:diff_note_on_design, note: 'first note', noteable: designs.first, project: project, author: create(:user)),
+ create(:diff_note_on_design, note: 'second note', noteable: designs.first, project: project, author: create(:user))
+ ]
+ matchers = old_notes.map do |note|
+ have_attributes(
+ note.attributes.slice(
+ :type,
+ :author_id,
+ :note,
+ :position
+ )
+ )
+ end
+
+ expect { subject }.to change { Note.count }.by(2)
+
+ new_notes = target_issue.designs.first.notes.fresh
+
+ expect(new_notes).to match_array(matchers)
+ end
+
+ it 'links the LfsObjects' do
+ expect { subject }.to change { target_issue.project.lfs_objects.count }.by(3)
+ end
+
+ it 'copies the Git repository data', :aggregate_failures do
+ subject
+
+ commit_shas = target_repository.commits('master', limit: 99).map(&:id)
+
+ expect(commit_shas).to include(*target_issue.design_versions.ordered.pluck(:sha))
+ end
+
+ it 'creates a master branch if none previously existed' do
+ expect { subject }.to change { target_repository.branch_names }.from([]).to(['master'])
+ end
+
+ it 'leaves the design collection in the correct copy state' do
+ subject
+
+ expect(target_issue.design_collection).to be_copy_ready
+ end
+
+ describe 'rollback' do
+ before do
+ # Ensure the very last step throws an error
+ expect_next_instance_of(described_class) do |service|
+ expect(service).to receive(:finalize!).and_raise
+ end
+ end
+
+ include_examples 'service error', message: 'Designs were unable to be copied successfully'
+
+ it 'rollsback all PostgreSQL data created', :aggregate_failures do
+ expect { subject }.not_to change {
+ [
+ DesignManagement::Design.count,
+ DesignManagement::Action.count,
+ DesignManagement::Version.count,
+ Note.count
+ ]
+ }
+
+ collections = [
+ target_issue.design_collection,
+ target_issue.designs,
+ target_issue.design_versions
+ ]
+
+ expect(collections).to all(be_empty)
+ end
+
+ it 'does not alter master branch', :aggregate_failures do
+ # Add some Git data to the target_repository, so we are testing
+ # that any original data remains
+ issue_2 = create(:issue, project: target_issue.project)
+ create(:design, :with_file, issue: issue_2, project: target_issue.project)
+
+ expect { subject }.not_to change {
+ expect(target_repository.commits('master', limit: 10).size).to eq(1)
+ }
+ end
+
+ it 'sets the design collection copy state' do
+ subject
+
+ expect(target_issue.design_collection).to be_copy_error
+ end
+ end
+ end
+ end
+ end
+ end
+
+ describe 'Alert if schema changes', :aggregate_failures do
+ let_it_be(:config_file) { Rails.root.join('lib/gitlab/design_management/copy_design_collection_model_attributes.yml') }
+ let_it_be(:config) { YAML.load_file(config_file).symbolize_keys }
+
+ %w(Design Action Version).each do |model|
+ specify do
+ attributes = config["#{model.downcase}_attributes".to_sym] || []
+ ignored_attributes = config["ignore_#{model.downcase}_attributes".to_sym]
+
+ expect(attributes + ignored_attributes).to contain_exactly(
+ *DesignManagement.const_get(model, false).column_names
+ ), failure_message(model)
+ end
+ end
+
+ def failure_message(model)
+ <<-MSG
+ The schema of the `#{model}` model has changed.
+
+ `#{described_class.name}` refers to specific lists of attributes of `#{model}` to either
+ copy or ignore, so that we continue to copy designs correctly after schema changes.
+
+ Please update:
+ #{config_file}
+ to reflect the latest changes to `#{model}`. See that file for more information.
+ MSG
+ end
+ end
+end
diff --git a/spec/services/design_management/copy_design_collection/queue_service_spec.rb b/spec/services/design_management/copy_design_collection/queue_service_spec.rb
new file mode 100644
index 00000000000..2d9ea4633a0
--- /dev/null
+++ b/spec/services/design_management/copy_design_collection/queue_service_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe DesignManagement::CopyDesignCollection::QueueService, :clean_gitlab_redis_shared_state do
+ include DesignManagementTestHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:target_issue, refind: true) { create(:issue) }
+ let_it_be(:design) { create(:design, issue: issue, project: issue.project) }
+
+ subject { described_class.new(user, issue, target_issue).execute }
+
+ before do
+ enable_design_management
+ end
+
+ it 'returns an error if user does not have permission' do
+ expect(subject).to be_kind_of(ServiceResponse)
+ expect(subject).to be_error
+ expect(subject.message).to eq('User cannot copy designs to issue')
+ end
+
+ context 'when user has permission' do
+ before_all do
+ issue.project.add_reporter(user)
+ target_issue.project.add_reporter(user)
+ end
+
+ it 'returns an error if design collection copy_state is not queuable' do
+ target_issue.design_collection.start_copy!
+
+ expect(subject).to be_kind_of(ServiceResponse)
+ expect(subject).to be_error
+ expect(subject.message).to eq('Target design collection copy state must be `ready`')
+ end
+
+ it 'sets the design collection copy state' do
+ expect { subject }.to change { target_issue.design_collection.copy_state }.from('ready').to('in_progress')
+ end
+
+ it 'queues a DesignManagement::CopyDesignCollectionWorker' do
+ expect { subject }.to change(DesignManagement::CopyDesignCollectionWorker.jobs, :size).by(1)
+ end
+
+ it 'returns success' do
+ expect(subject).to be_kind_of(ServiceResponse)
+ expect(subject).to be_success
+ end
+ end
+end
diff --git a/spec/services/design_management/delete_designs_service_spec.rb b/spec/services/design_management/delete_designs_service_spec.rb
index ace63b6e59c..9b2866cab11 100644
--- a/spec/services/design_management/delete_designs_service_spec.rb
+++ b/spec/services/design_management/delete_designs_service_spec.rb
@@ -105,7 +105,7 @@ RSpec.describe DesignManagement::DeleteDesignsService do
end
it 'informs the new-version-worker' do
- expect(::DesignManagement::NewVersionWorker).to receive(:perform_async).with(Integer)
+ expect(::DesignManagement::NewVersionWorker).to receive(:perform_async).with(Integer, false)
run_service
end
diff --git a/spec/services/design_management/generate_image_versions_service_spec.rb b/spec/services/design_management/generate_image_versions_service_spec.rb
index 631eec97e5a..749030af97d 100644
--- a/spec/services/design_management/generate_image_versions_service_spec.rb
+++ b/spec/services/design_management/generate_image_versions_service_spec.rb
@@ -52,25 +52,50 @@ RSpec.describe DesignManagement::GenerateImageVersionsService do
end
context 'when an error is encountered when generating the image versions' do
- before do
- expect_next_instance_of(DesignManagement::DesignV432x230Uploader) do |uploader|
- expect(uploader).to receive(:cache!).and_raise(CarrierWave::DownloadError, 'foo')
+ context "CarrierWave::IntegrityError" do
+ before do
+ expect_next_instance_of(DesignManagement::DesignV432x230Uploader) do |uploader|
+ expect(uploader).to receive(:cache!).and_raise(CarrierWave::IntegrityError, 'foo')
+ end
+ end
+
+ it 'logs the exception' do
+ expect(Gitlab::ErrorTracking).to receive(:log_exception).with(
+ instance_of(CarrierWave::IntegrityError),
+ project_id: project.id, version_id: version.id, design_id: version.designs.first.id
+ )
+
+ described_class.new(version).execute
end
- end
- it 'logs the error' do
- expect(Gitlab::AppLogger).to receive(:error).with('foo')
+ it 'logs the error' do
+ expect(Gitlab::AppLogger).to receive(:error).with('foo')
- described_class.new(version).execute
+ described_class.new(version).execute
+ end
end
- it 'tracks the error' do
- expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
- instance_of(CarrierWave::DownloadError),
- project_id: project.id, version_id: version.id, design_id: version.designs.first.id
- )
+ context "CarrierWave::UploadError" do
+ before do
+ expect_next_instance_of(DesignManagement::DesignV432x230Uploader) do |uploader|
+ expect(uploader).to receive(:cache!).and_raise(CarrierWave::UploadError, 'foo')
+ end
+ end
- described_class.new(version).execute
+ it 'logs the error' do
+ expect(Gitlab::AppLogger).to receive(:error).with('foo')
+
+ described_class.new(version).execute
+ end
+
+ it 'tracks the error' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
+ instance_of(CarrierWave::UploadError),
+ project_id: project.id, version_id: version.id, design_id: version.designs.first.id
+ )
+
+ described_class.new(version).execute
+ end
end
end
end
diff --git a/spec/services/design_management/save_designs_service_spec.rb b/spec/services/design_management/save_designs_service_spec.rb
index abba5de2c27..ec241daf3cd 100644
--- a/spec/services/design_management/save_designs_service_spec.rb
+++ b/spec/services/design_management/save_designs_service_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe DesignManagement::SaveDesignsService do
end
allow(::DesignManagement::NewVersionWorker)
- .to receive(:perform_async).with(Integer).and_return(nil)
+ .to receive(:perform_async).with(Integer, false).and_return(nil)
end
def run_service(files_to_upload = nil)
@@ -128,6 +128,25 @@ RSpec.describe DesignManagement::SaveDesignsService do
expect { run_parallel(blocks) }.to change(DesignManagement::Version, :count).by(parellism)
end
+ context 'when the design collection is in the process of being copied', :clean_gitlab_redis_shared_state do
+ before do
+ issue.design_collection.start_copy!
+ end
+
+ it_behaves_like 'a service error'
+ end
+
+ context 'when the design collection has a copy error', :clean_gitlab_redis_shared_state do
+ before do
+ issue.design_collection.copy_state = 'error'
+ issue.design_collection.send(:set_stored_copy_state!)
+ end
+
+ it 'resets the copy state' do
+ expect { run_service }.to change { issue.design_collection.copy_state }.from('error').to('ready')
+ end
+ end
+
describe 'the response' do
it 'includes designs with the expected properties' do
updated_designs = response[:designs]
@@ -220,7 +239,7 @@ RSpec.describe DesignManagement::SaveDesignsService do
counter = Gitlab::UsageDataCounters::DesignsCounter
expect(::DesignManagement::NewVersionWorker)
- .to receive(:perform_async).once.with(Integer).and_return(nil)
+ .to receive(:perform_async).once.with(Integer, false).and_return(nil)
expect { run_service }
.to change { Event.count }.by(2)
@@ -254,7 +273,7 @@ RSpec.describe DesignManagement::SaveDesignsService do
design_repository.has_visible_content?
expect(::DesignManagement::NewVersionWorker)
- .to receive(:perform_async).once.with(Integer).and_return(nil)
+ .to receive(:perform_async).once.with(Integer, false).and_return(nil)
expect { service.execute }
.to change { issue.designs.count }.from(0).to(2)
@@ -271,6 +290,14 @@ RSpec.describe DesignManagement::SaveDesignsService do
expect(response[:message]).to match(/only \d+ files are allowed simultaneously/i)
end
end
+
+ context 'when uploading duplicate files' do
+ let(:files) { [rails_sample, dk_png, rails_sample] }
+
+ it 'returns the correct error' do
+ expect(response[:message]).to match('Duplicate filenames are not allowed!')
+ end
+ end
end
context 'when the user is not allowed to upload designs' do
diff --git a/spec/services/feature_flags/create_service_spec.rb b/spec/services/feature_flags/create_service_spec.rb
new file mode 100644
index 00000000000..e80a24f9760
--- /dev/null
+++ b/spec/services/feature_flags/create_service_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe FeatureFlags::CreateService do
+ let(:project) { create(:project) }
+ let(:developer) { create(:user) }
+ let(:reporter) { create(:user) }
+ let(:user) { developer }
+
+ before do
+ project.add_developer(developer)
+ project.add_reporter(reporter)
+ end
+
+ describe '#execute' do
+ subject do
+ described_class.new(project, user, params).execute
+ end
+
+ let(:feature_flag) { subject[:feature_flag] }
+
+ context 'when feature flag can not be created' do
+ let(:params) { {} }
+
+ it 'returns status error' do
+ expect(subject[:status]).to eq(:error)
+ end
+
+ it 'returns validation errors' do
+ expect(subject[:message]).to include("Name can't be blank")
+ end
+
+ it 'does not create audit log' do
+ expect { subject }.not_to change { AuditEvent.count }
+ end
+ end
+
+ context 'when feature flag is saved correctly' do
+ let(:params) do
+ {
+ name: 'feature_flag',
+ description: 'description',
+ scopes_attributes: [{ environment_scope: '*', active: true },
+ { environment_scope: 'production', active: false }]
+ }
+ end
+
+ it 'returns status success' do
+ expect(subject[:status]).to eq(:success)
+ end
+
+ it 'creates feature flag' do
+ expect { subject }.to change { Operations::FeatureFlag.count }.by(1)
+ end
+
+ it 'creates audit event' do
+ expected_message = 'Created feature flag <strong>feature_flag</strong> '\
+ 'with description <strong>"description"</strong>. '\
+ 'Created rule <strong>*</strong> and set it as <strong>active</strong> '\
+ 'with strategies <strong>[{"name"=>"default", "parameters"=>{}}]</strong>. '\
+ 'Created rule <strong>production</strong> and set it as <strong>inactive</strong> '\
+ 'with strategies <strong>[{"name"=>"default", "parameters"=>{}}]</strong>.'
+
+ expect { subject }.to change { AuditEvent.count }.by(1)
+ expect(AuditEvent.last.details[:custom_message]).to eq(expected_message)
+ end
+
+ context 'when user is reporter' do
+ let(:user) { reporter }
+
+ it 'returns error status' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:message]).to eq('Access Denied')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/feature_flags/destroy_service_spec.rb b/spec/services/feature_flags/destroy_service_spec.rb
new file mode 100644
index 00000000000..df83969e167
--- /dev/null
+++ b/spec/services/feature_flags/destroy_service_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe FeatureFlags::DestroyService do
+ include FeatureFlagHelpers
+
+ let(:project) { create(:project) }
+ let(:developer) { create(:user) }
+ let(:reporter) { create(:user) }
+ let(:user) { developer }
+ let!(:feature_flag) { create(:operations_feature_flag, project: project) }
+
+ before do
+ project.add_developer(developer)
+ project.add_reporter(reporter)
+ end
+
+ describe '#execute' do
+ subject { described_class.new(project, user, params).execute(feature_flag) }
+
+ let(:audit_event_message) { AuditEvent.last.details[:custom_message] }
+ let(:params) { {} }
+
+ it 'returns status success' do
+ expect(subject[:status]).to eq(:success)
+ end
+
+ it 'destroys feature flag' do
+ expect { subject }.to change { Operations::FeatureFlag.count }.by(-1)
+ end
+
+ it 'creates audit log' do
+ expect { subject }.to change { AuditEvent.count }.by(1)
+ expect(audit_event_message).to eq("Deleted feature flag <strong>#{feature_flag.name}</strong>.")
+ end
+
+ context 'when user is reporter' do
+ let(:user) { reporter }
+
+ it 'returns error status' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:message]).to eq('Access Denied')
+ end
+ end
+
+ context 'when feature flag can not be destroyed' do
+ before do
+ allow(feature_flag).to receive(:destroy).and_return(false)
+ end
+
+ it 'returns status error' do
+ expect(subject[:status]).to eq(:error)
+ end
+
+ it 'does not create audit log' do
+ expect { subject }.not_to change { AuditEvent.count }
+ end
+ end
+ end
+end
diff --git a/spec/services/feature_flags/disable_service_spec.rb b/spec/services/feature_flags/disable_service_spec.rb
new file mode 100644
index 00000000000..00369d63ccf
--- /dev/null
+++ b/spec/services/feature_flags/disable_service_spec.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe FeatureFlags::DisableService do
+ include FeatureFlagHelpers
+
+ let_it_be(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:service) { described_class.new(project, user, params) }
+ let(:params) { {} }
+
+ before do
+ project.add_developer(user)
+ end
+
+ describe '#execute' do
+ subject { service.execute }
+
+ context 'with params to disable default strategy on prd scope' do
+ let(:params) do
+ {
+ name: 'awesome',
+ environment_scope: 'prd',
+ strategy: { name: 'userWithId', parameters: { 'userIds': 'User:1' } }.deep_stringify_keys
+ }
+ end
+
+ context 'when there is a persisted feature flag' do
+ let!(:feature_flag) { create_flag(project, params[:name]) }
+
+ context 'when there is a persisted scope' do
+ let!(:scope) do
+ create_scope(feature_flag, params[:environment_scope], true, strategies)
+ end
+
+ context 'when there is a persisted strategy' do
+ let(:strategies) do
+ [
+ { name: 'userWithId', parameters: { 'userIds': 'User:1' } }.deep_stringify_keys,
+ { name: 'userWithId', parameters: { 'userIds': 'User:2' } }.deep_stringify_keys
+ ]
+ end
+
+ it 'deletes the specified strategy' do
+ subject
+
+ scope.reload
+ expect(scope.strategies.count).to eq(1)
+ expect(scope.strategies).not_to include(params[:strategy])
+ end
+
+ context 'when strategies will be empty' do
+ let(:strategies) { [params[:strategy]] }
+
+ it 'deletes the persisted scope' do
+ subject
+
+ expect(feature_flag.scopes.exists?(environment_scope: params[:environment_scope]))
+ .to eq(false)
+ end
+ end
+ end
+
+ context 'when there is no persisted strategy' do
+ let(:strategies) { [{ name: 'default', parameters: {} }] }
+
+ it 'returns error' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:message]).to include('Strategy not found')
+ end
+ end
+ end
+
+ context 'when there is no persisted scope' do
+ it 'returns error' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:message]).to include('Feature Flag Scope not found')
+ end
+ end
+ end
+
+ context 'when there is no persisted feature flag' do
+ it 'returns error' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:message]).to include('Feature Flag not found')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/feature_flags/enable_service_spec.rb b/spec/services/feature_flags/enable_service_spec.rb
new file mode 100644
index 00000000000..26dffcae0c7
--- /dev/null
+++ b/spec/services/feature_flags/enable_service_spec.rb
@@ -0,0 +1,153 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe FeatureFlags::EnableService do
+ include FeatureFlagHelpers
+
+ let_it_be(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:service) { described_class.new(project, user, params) }
+ let(:params) { {} }
+
+ before do
+ project.add_developer(user)
+ end
+
+ describe '#execute' do
+ subject { service.execute }
+
+ context 'with params to enable default strategy on prd scope' do
+ let(:params) do
+ {
+ name: 'awesome',
+ environment_scope: 'prd',
+ strategy: { name: 'default', parameters: {} }.stringify_keys
+ }
+ end
+
+ context 'when there is no persisted feature flag' do
+ it 'creates a new feature flag with scope' do
+ feature_flag = subject[:feature_flag]
+ scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope])
+ expect(subject[:status]).to eq(:success)
+ expect(feature_flag.name).to eq(params[:name])
+ expect(feature_flag.default_scope).not_to be_active
+ expect(scope).to be_active
+ expect(scope.strategies).to include(params[:strategy])
+ end
+
+ context 'when params include default scope' do
+ let(:params) do
+ {
+ name: 'awesome',
+ environment_scope: '*',
+ strategy: { name: 'userWithId', parameters: { 'userIds': 'abc' } }.deep_stringify_keys
+ }
+ end
+
+ it 'create a new feature flag with an active default scope with the specified strategy' do
+ feature_flag = subject[:feature_flag]
+ expect(subject[:status]).to eq(:success)
+ expect(feature_flag.default_scope).to be_active
+ expect(feature_flag.default_scope.strategies).to include(params[:strategy])
+ end
+ end
+ end
+
+ context 'when there is a persisted feature flag' do
+ let!(:feature_flag) { create_flag(project, params[:name]) }
+
+ context 'when there is no persisted scope' do
+ it 'creates a new scope for the persisted feature flag' do
+ feature_flag = subject[:feature_flag]
+ scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope])
+ expect(subject[:status]).to eq(:success)
+ expect(feature_flag.name).to eq(params[:name])
+ expect(scope).to be_active
+ expect(scope.strategies).to include(params[:strategy])
+ end
+ end
+
+ context 'when there is a persisted scope' do
+ let!(:feature_flag_scope) do
+ create_scope(feature_flag, params[:environment_scope], active, strategies)
+ end
+
+ let(:active) { true }
+
+ context 'when the persisted scope does not have the specified strategy yet' do
+ let(:strategies) { [{ name: 'userWithId', parameters: { 'userIds': 'abc' } }] }
+
+ it 'adds the specified strategy to the scope' do
+ subject
+
+ feature_flag_scope.reload
+ expect(feature_flag_scope.strategies).to include(params[:strategy])
+ end
+
+ context 'when the persisted scope is inactive' do
+ let(:active) { false }
+
+ it 'reactivates the scope' do
+ expect { subject }
+ .to change { feature_flag_scope.reload.active }.from(false).to(true)
+ end
+ end
+ end
+
+ context 'when the persisted scope has the specified strategy already' do
+ let(:strategies) { [params[:strategy]] }
+
+ it 'does not add a duplicated strategy to the scope' do
+ expect { subject }
+ .not_to change { feature_flag_scope.reload.strategies.count }
+ end
+ end
+ end
+ end
+ end
+
+ context 'when strategy is not specified in params' do
+ let(:params) do
+ {
+ name: 'awesome',
+ environment_scope: 'prd'
+ }
+ end
+
+ it 'returns error' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:message]).to include('Scopes strategies must be an array of strategy hashes')
+ end
+ end
+
+ context 'when environment scope is not specified in params' do
+ let(:params) do
+ {
+ name: 'awesome',
+ strategy: { name: 'default', parameters: {} }.stringify_keys
+ }
+ end
+
+ it 'returns error' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:message]).to include("Scopes environment scope can't be blank")
+ end
+ end
+
+ context 'when name is not specified in params' do
+ let(:params) do
+ {
+ environment_scope: 'prd',
+ strategy: { name: 'default', parameters: {} }.stringify_keys
+ }
+ end
+
+ it 'returns error' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:message]).to include("Name can't be blank")
+ end
+ end
+ end
+end
diff --git a/spec/services/feature_flags/update_service_spec.rb b/spec/services/feature_flags/update_service_spec.rb
new file mode 100644
index 00000000000..16c3ff23443
--- /dev/null
+++ b/spec/services/feature_flags/update_service_spec.rb
@@ -0,0 +1,250 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe FeatureFlags::UpdateService do
+ let(:project) { create(:project) }
+ let(:developer) { create(:user) }
+ let(:reporter) { create(:user) }
+ let(:user) { developer }
+ let(:feature_flag) { create(:operations_feature_flag, project: project, active: true) }
+
+ before do
+ project.add_developer(developer)
+ project.add_reporter(reporter)
+ end
+
+ describe '#execute' do
+ subject { described_class.new(project, user, params).execute(feature_flag) }
+
+ let(:params) { { name: 'new_name' } }
+ let(:audit_event_message) do
+ AuditEvent.last.details[:custom_message]
+ end
+
+ it 'returns success status' do
+ expect(subject[:status]).to eq(:success)
+ end
+
+ it 'creates audit event with correct message' do
+ name_was = feature_flag.name
+
+ expect { subject }.to change { AuditEvent.count }.by(1)
+ expect(audit_event_message).to(
+ eq("Updated feature flag <strong>new_name</strong>. "\
+ "Updated name from <strong>\"#{name_was}\"</strong> "\
+ "to <strong>\"new_name\"</strong>.")
+ )
+ end
+
+ context 'with invalid params' do
+ let(:params) { { name: nil } }
+
+ it 'returns error status' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:http_status]).to eq(:bad_request)
+ end
+
+ it 'returns error messages' do
+ expect(subject[:message]).to include("Name can't be blank")
+ end
+
+ it 'does not create audit event' do
+ expect { subject }.not_to change { AuditEvent.count }
+ end
+ end
+
+ context 'when user is reporter' do
+ let(:user) { reporter }
+
+ it 'returns error status' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:message]).to eq('Access Denied')
+ end
+ end
+
+ context 'when nothing is changed' do
+ let(:params) { {} }
+
+ it 'returns success status' do
+ expect(subject[:status]).to eq(:success)
+ end
+
+ it 'does not create audit event' do
+ expect { subject }.not_to change { AuditEvent.count }
+ end
+ end
+
+ context 'description is being changed' do
+ let(:params) { { description: 'new description' } }
+
+ it 'creates audit event with changed description' do
+ expect { subject }.to change { AuditEvent.count }.by(1)
+ expect(audit_event_message).to(
+ include("Updated description from <strong>\"\"</strong>"\
+ " to <strong>\"new description\"</strong>.")
+ )
+ end
+ end
+
+ context 'when flag active state is changed' do
+ let(:params) do
+ {
+ active: false
+ }
+ end
+
+ it 'creates audit event about changing active state' do
+ expect { subject }.to change { AuditEvent.count }.by(1)
+ expect(audit_event_message).to(
+ include('Updated active from <strong>"true"</strong> to <strong>"false"</strong>.')
+ )
+ end
+ end
+
+ context 'when scope active state is changed' do
+ let(:params) do
+ {
+ scopes_attributes: [{ id: feature_flag.scopes.first.id, active: false }]
+ }
+ end
+
+ it 'creates audit event about changing active state' do
+ expect { subject }.to change { AuditEvent.count }.by(1)
+ expect(audit_event_message).to(
+ include("Updated rule <strong>*</strong> active state "\
+ "from <strong>true</strong> to <strong>false</strong>.")
+ )
+ end
+ end
+
+ context 'when scope is renamed' do
+ let(:changed_scope) { feature_flag.scopes.create!(environment_scope: 'review', active: true) }
+ let(:params) do
+ {
+ scopes_attributes: [{ id: changed_scope.id, environment_scope: 'staging' }]
+ }
+ end
+
+ it 'creates audit event with changed name' do
+ expect { subject }.to change { AuditEvent.count }.by(1)
+ expect(audit_event_message).to(
+ include("Updated rule <strong>staging</strong> environment scope "\
+ "from <strong>review</strong> to <strong>staging</strong>.")
+ )
+ end
+
+ context 'when scope can not be updated' do
+ let(:params) do
+ {
+ scopes_attributes: [{ id: changed_scope.id, environment_scope: '' }]
+ }
+ end
+
+ it 'returns error status' do
+ expect(subject[:status]).to eq(:error)
+ end
+
+ it 'returns error messages' do
+ expect(subject[:message]).to include("Scopes environment scope can't be blank")
+ end
+
+ it 'does not create audit event' do
+ expect { subject }.not_to change { AuditEvent.count }
+ end
+ end
+ end
+
+ context 'when scope is deleted' do
+ let(:deleted_scope) { feature_flag.scopes.create!(environment_scope: 'review', active: true) }
+ let(:params) do
+ {
+ scopes_attributes: [{ id: deleted_scope.id, '_destroy': true }]
+ }
+ end
+
+ it 'creates audit event with deleted scope' do
+ expect { subject }.to change { AuditEvent.count }.by(1)
+ expect(audit_event_message).to include("Deleted rule <strong>review</strong>.")
+ end
+
+ context 'when scope can not be deleted' do
+ before do
+ allow(deleted_scope).to receive(:destroy).and_return(false)
+ end
+
+ it 'does not create audit event' do
+ expect do
+ subject
+ end.to not_change { AuditEvent.count }.and raise_error(ActiveRecord::RecordNotDestroyed)
+ end
+ end
+ end
+
+ context 'when new scope is being added' do
+ let(:new_environment_scope) { 'review' }
+ let(:params) do
+ {
+ scopes_attributes: [{ environment_scope: new_environment_scope, active: true }]
+ }
+ end
+
+ it 'creates audit event with new scope' do
+ expected = 'Created rule <strong>review</strong> and set it as <strong>active</strong> '\
+ 'with strategies <strong>[{"name"=>"default", "parameters"=>{}}]</strong>.'
+
+ subject
+
+ expect(audit_event_message).to include(expected)
+ end
+
+ context 'when scope can not be created' do
+ let(:new_environment_scope) { '' }
+
+ it 'returns error status' do
+ expect(subject[:status]).to eq(:error)
+ end
+
+ it 'returns error messages' do
+ expect(subject[:message]).to include("Scopes environment scope can't be blank")
+ end
+
+ it 'does not create audit event' do
+ expect { subject }.not_to change { AuditEvent.count }
+ end
+ end
+ end
+
+ context 'when the strategy is changed' do
+ let(:scope) do
+ create(:operations_feature_flag_scope,
+ feature_flag: feature_flag,
+ environment_scope: 'sandbox',
+ strategies: [{ name: "default", parameters: {} }])
+ end
+
+ let(:params) do
+ {
+ scopes_attributes: [{
+ id: scope.id,
+ environment_scope: 'sandbox',
+ strategies: [{
+ name: 'gradualRolloutUserId',
+ parameters: {
+ groupId: 'mygroup',
+ percentage: "40"
+ }
+ }]
+ }]
+ }
+ end
+
+ it 'creates an audit event' do
+ expected = %r{Updated rule <strong>sandbox</strong> strategies from <strong>.*</strong> to <strong>.*</strong>.}
+
+ expect { subject }.to change { AuditEvent.count }.by(1)
+ expect(audit_event_message).to match(expected)
+ end
+ end
+ end
+end
diff --git a/spec/services/git/branch_hooks_service_spec.rb b/spec/services/git/branch_hooks_service_spec.rb
index db25bb766c9..a5290f0be68 100644
--- a/spec/services/git/branch_hooks_service_spec.rb
+++ b/spec/services/git/branch_hooks_service_spec.rb
@@ -429,4 +429,26 @@ RSpec.describe Git::BranchHooksService do
end
end
end
+
+ describe 'Metrics dashboard sync' do
+ context 'with feature flag enabled' do
+ before do
+ Feature.enable(:metrics_dashboards_sync)
+ end
+
+ it 'imports metrics to database' do
+ expect(Metrics::Dashboard::SyncDashboardsWorker).to receive(:perform_async)
+
+ service.execute
+ end
+ end
+
+ context 'with feature flag disabled' do
+ it 'imports metrics to database' do
+ expect(Metrics::Dashboard::SyncDashboardsWorker).to receive(:perform_async)
+
+ service.execute
+ end
+ end
+ end
end
diff --git a/spec/services/git/wiki_push_service_spec.rb b/spec/services/git/wiki_push_service_spec.rb
index 816f20f0bc3..cd38f2e97fb 100644
--- a/spec/services/git/wiki_push_service_spec.rb
+++ b/spec/services/git/wiki_push_service_spec.rb
@@ -254,24 +254,6 @@ RSpec.describe Git::WikiPushService, services: true do
service.execute
end
end
-
- context 'the wiki_events_on_git_push feature is disabled' do
- before do
- stub_feature_flags(wiki_events_on_git_push: false)
- end
-
- it_behaves_like 'a no-op push'
-
- context 'but is enabled for a given container' do
- before do
- stub_feature_flags(wiki_events_on_git_push: wiki.container)
- end
-
- it 'creates events' do
- expect { process_changes { write_new_page } }.to change(Event, :count).by(1)
- end
- end
- end
end
end
diff --git a/spec/services/groups/create_service_spec.rb b/spec/services/groups/create_service_spec.rb
index fc877f45a39..d5479fa2a06 100644
--- a/spec/services/groups/create_service_spec.rb
+++ b/spec/services/groups/create_service_spec.rb
@@ -138,4 +138,91 @@ RSpec.describe Groups::CreateService, '#execute' do
expect(group.namespace_settings).to be_persisted
end
end
+
+ describe 'create service for the group' do
+ let(:service) { described_class.new(user, group_params) }
+ let(:created_group) { service.execute }
+
+ context 'with an active instance-level integration' do
+ let!(:instance_integration) { create(:prometheus_service, :instance, api_url: 'https://prometheus.instance.com/') }
+
+ it 'creates a service from the instance-level integration' do
+ expect(created_group.services.count).to eq(1)
+ expect(created_group.services.first.api_url).to eq(instance_integration.api_url)
+ expect(created_group.services.first.inherit_from_id).to eq(instance_integration.id)
+ end
+
+ context 'with an active group-level integration' do
+ let(:service) { described_class.new(user, group_params.merge(parent_id: group.id)) }
+ let!(:group_integration) { create(:prometheus_service, group: group, project: nil, api_url: 'https://prometheus.group.com/') }
+ let(:group) do
+ create(:group).tap do |group|
+ group.add_owner(user)
+ end
+ end
+
+ it 'creates a service from the group-level integration' do
+ expect(created_group.services.count).to eq(1)
+ expect(created_group.services.first.api_url).to eq(group_integration.api_url)
+ expect(created_group.services.first.inherit_from_id).to eq(group_integration.id)
+ end
+
+ context 'with an active subgroup' do
+ let(:service) { described_class.new(user, group_params.merge(parent_id: subgroup.id)) }
+ let!(:subgroup_integration) { create(:prometheus_service, group: subgroup, project: nil, api_url: 'https://prometheus.subgroup.com/') }
+ let(:subgroup) do
+ create(:group, parent: group).tap do |subgroup|
+ subgroup.add_owner(user)
+ end
+ end
+
+ it 'creates a service from the subgroup-level integration' do
+ expect(created_group.services.count).to eq(1)
+ expect(created_group.services.first.api_url).to eq(subgroup_integration.api_url)
+ expect(created_group.services.first.inherit_from_id).to eq(subgroup_integration.id)
+ end
+ end
+ end
+ end
+ end
+
+ context 'shared runners configuration' do
+ context 'parent group present' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:shared_runners_config, :descendants_override_disabled_shared_runners_config) do
+ true | false
+ false | false
+ # true | true # invalid at the group level, leaving as comment to make explicit
+ false | true
+ end
+
+ with_them do
+ let!(:group) { create(:group, shared_runners_enabled: shared_runners_config, allow_descendants_override_disabled_shared_runners: descendants_override_disabled_shared_runners_config) }
+ let!(:service) { described_class.new(user, group_params.merge(parent_id: group.id)) }
+
+ before do
+ group.add_owner(user)
+ end
+
+ it 'creates group following the parent config' do
+ new_group = service.execute
+
+ expect(new_group.shared_runners_enabled).to eq(shared_runners_config)
+ expect(new_group.allow_descendants_override_disabled_shared_runners).to eq(descendants_override_disabled_shared_runners_config)
+ end
+ end
+ end
+
+ context 'root group' do
+ let!(:service) { described_class.new(user) }
+
+ it 'follows default config' do
+ new_group = service.execute
+
+ expect(new_group.shared_runners_enabled).to eq(true)
+ expect(new_group.allow_descendants_override_disabled_shared_runners).to eq(false)
+ end
+ end
+ end
end
diff --git a/spec/services/groups/import_export/import_service_spec.rb b/spec/services/groups/import_export/import_service_spec.rb
index 4aac602a6da..f284225e23a 100644
--- a/spec/services/groups/import_export/import_service_spec.rb
+++ b/spec/services/groups/import_export/import_service_spec.rb
@@ -10,6 +10,15 @@ RSpec.describe Groups::ImportExport::ImportService do
context 'when the job can be successfully scheduled' do
subject(:import_service) { described_class.new(group: group, user: user) }
+ it 'creates group import state' do
+ import_service.async_execute
+
+ import_state = group.import_state
+
+ expect(import_state.user).to eq(user)
+ expect(import_state.group).to eq(group)
+ end
+
it 'enqueues an import job' do
expect(GroupImportWorker).to receive(:perform_async).with(user.id, group.id)
diff --git a/spec/services/groups/transfer_service_spec.rb b/spec/services/groups/transfer_service_spec.rb
index 89e4d091ff7..6144b86a316 100644
--- a/spec/services/groups/transfer_service_spec.rb
+++ b/spec/services/groups/transfer_service_spec.rb
@@ -285,6 +285,44 @@ RSpec.describe Groups::TransferService do
end
end
+ context 'shared runners configuration' do
+ before do
+ create(:group_member, :owner, group: new_parent_group, user: user)
+ end
+
+ context 'if parent group has disabled shared runners but allows overrides' do
+ let(:new_parent_group) { create(:group, shared_runners_enabled: false, allow_descendants_override_disabled_shared_runners: true) }
+
+ it 'calls update service' do
+ expect(Groups::UpdateSharedRunnersService).to receive(:new).with(group, user, { shared_runners_setting: 'disabled_with_override' }).and_call_original
+
+ transfer_service.execute(new_parent_group)
+ end
+ end
+
+ context 'if parent group does not allow shared runners' do
+ let(:new_parent_group) { create(:group, shared_runners_enabled: false, allow_descendants_override_disabled_shared_runners: false) }
+
+ it 'calls update service' do
+ expect(Groups::UpdateSharedRunnersService).to receive(:new).with(group, user, { shared_runners_setting: 'disabled_and_unoverridable' }).and_call_original
+
+ transfer_service.execute(new_parent_group)
+ end
+ end
+
+ context 'if parent group allows shared runners' do
+ let(:group) { create(:group, :public, :nested, shared_runners_enabled: false) }
+ let(:new_parent_group) { create(:group, shared_runners_enabled: true) }
+
+ it 'does not call update service and keeps them disabled on the group' do
+ expect(Groups::UpdateSharedRunnersService).not_to receive(:new)
+
+ transfer_service.execute(new_parent_group)
+ expect(group.reload.shared_runners_enabled).to be_falsy
+ end
+ end
+ end
+
context 'when a group is transferred to its subgroup' do
let(:new_parent_group) { create(:group, parent: group) }
diff --git a/spec/services/groups/update_service_spec.rb b/spec/services/groups/update_service_spec.rb
index 1e6a8d53354..a79cda86a86 100644
--- a/spec/services/groups/update_service_spec.rb
+++ b/spec/services/groups/update_service_spec.rb
@@ -283,6 +283,31 @@ RSpec.describe Groups::UpdateService do
end
end
+ context 'change shared Runners config' do
+ let(:group) { create(:group) }
+ let(:project) { create(:project, shared_runners_enabled: true, group: group) }
+
+ subject { described_class.new(group, user, shared_runners_setting: 'disabled_and_unoverridable').execute }
+
+ before do
+ group.add_owner(user)
+ end
+
+ it 'calls the shared runners update service' do
+ expect_any_instance_of(::Groups::UpdateSharedRunnersService).to receive(:execute).and_return({ status: :success })
+
+ expect(subject).to be_truthy
+ end
+
+ it 'handles errors in the shared runners update service' do
+ expect_any_instance_of(::Groups::UpdateSharedRunnersService).to receive(:execute).and_return({ status: :error, message: 'something happened' })
+
+ expect(subject).to be_falsy
+
+ expect(group.errors[:update_shared_runners].first).to eq('something happened')
+ end
+ end
+
def update_group(group, user, opts)
Groups::UpdateService.new(group, user, opts).execute
end
diff --git a/spec/services/groups/update_shared_runners_service_spec.rb b/spec/services/groups/update_shared_runners_service_spec.rb
index 9fd8477a455..e2838c4ce0b 100644
--- a/spec/services/groups/update_shared_runners_service_spec.rb
+++ b/spec/services/groups/update_shared_runners_service_spec.rb
@@ -13,17 +13,14 @@ RSpec.describe Groups::UpdateSharedRunnersService do
context 'when current_user is not the group owner' do
let_it_be(:group) { create(:group) }
- let(:params) { { shared_runners_enabled: '0' } }
+ let(:params) { { shared_runners_setting: 'enabled' } }
before do
group.add_maintainer(user)
end
it 'results error and does not call any method' do
- expect(group).not_to receive(:enable_shared_runners!)
- expect(group).not_to receive(:disable_shared_runners!)
- expect(group).not_to receive(:allow_descendants_override_disabled_shared_runners!)
- expect(group).not_to receive(:disallow_descendants_override_disabled_shared_runners!)
+ expect(group).not_to receive(:update_shared_runners_setting!)
expect(subject[:status]).to eq(:error)
expect(subject[:message]).to eq('Operation not allowed')
@@ -37,191 +34,60 @@ RSpec.describe Groups::UpdateSharedRunnersService do
end
context 'enable shared Runners' do
- where(:desired_params) do
- ['1', true]
- end
-
- with_them do
- let(:params) { { shared_runners_enabled: desired_params } }
-
- context 'group that its ancestors have shared runners disabled' do
- let_it_be(:parent) { create(:group, :shared_runners_disabled) }
- let_it_be(:group) { create(:group, :shared_runners_disabled, parent: parent) }
-
- it 'results error' do
- expect(subject[:status]).to eq(:error)
- expect(subject[:message]).to eq('Shared Runners disabled for the parent group')
- end
- end
+ let(:params) { { shared_runners_setting: 'enabled' } }
- context 'root group with shared runners disabled' do
- let_it_be(:group) { create(:group, :shared_runners_disabled) }
+ context 'group that its ancestors have shared runners disabled' do
+ let_it_be(:parent) { create(:group, :shared_runners_disabled) }
+ let_it_be(:group) { create(:group, :shared_runners_disabled, parent: parent) }
- it 'receives correct method and succeeds' do
- expect(group).to receive(:enable_shared_runners!)
- expect(group).not_to receive(:disable_shared_runners!)
- expect(group).not_to receive(:allow_descendants_override_disabled_shared_runners!)
- expect(group).not_to receive(:disallow_descendants_override_disabled_shared_runners!)
-
- expect(subject[:status]).to eq(:success)
- end
+ it 'results error' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:message]).to eq('Validation failed: Shared runners enabled cannot be enabled because parent group has shared Runners disabled')
end
end
- end
-
- context 'disable shared Runners' do
- let_it_be(:group) { create(:group) }
-
- where(:desired_params) do
- ['0', false]
- end
- with_them do
- let(:params) { { shared_runners_enabled: desired_params } }
+ context 'root group with shared runners disabled' do
+ let_it_be(:group) { create(:group, :shared_runners_disabled) }
it 'receives correct method and succeeds' do
- expect(group).to receive(:disable_shared_runners!)
- expect(group).not_to receive(:enable_shared_runners!)
- expect(group).not_to receive(:allow_descendants_override_disabled_shared_runners!)
- expect(group).not_to receive(:disallow_descendants_override_disabled_shared_runners!)
+ expect(group).to receive(:update_shared_runners_setting!).with('enabled')
expect(subject[:status]).to eq(:success)
end
end
end
- context 'allow descendants to override' do
- where(:desired_params) do
- ['1', true]
- end
-
- with_them do
- let(:params) { { allow_descendants_override_disabled_shared_runners: desired_params } }
-
- context 'top level group' do
- let_it_be(:group) { create(:group, :shared_runners_disabled) }
-
- it 'receives correct method and succeeds' do
- expect(group).to receive(:allow_descendants_override_disabled_shared_runners!)
- expect(group).not_to receive(:disallow_descendants_override_disabled_shared_runners!)
- expect(group).not_to receive(:enable_shared_runners!)
- expect(group).not_to receive(:disable_shared_runners!)
-
- expect(subject[:status]).to eq(:success)
- end
- end
+ context 'disable shared Runners' do
+ let_it_be(:group) { create(:group) }
+ let(:params) { { shared_runners_setting: 'disabled_and_unoverridable' } }
- context 'when parent does not allow' do
- let_it_be(:parent) { create(:group, :shared_runners_disabled, allow_descendants_override_disabled_shared_runners: false ) }
- let_it_be(:group) { create(:group, :shared_runners_disabled, allow_descendants_override_disabled_shared_runners: false, parent: parent) }
+ it 'receives correct method and succeeds' do
+ expect(group).to receive(:update_shared_runners_setting!).with('disabled_and_unoverridable')
- it 'results error' do
- expect(subject[:status]).to eq(:error)
- expect(subject[:message]).to eq('Group level shared Runners not allowed')
- end
- end
+ expect(subject[:status]).to eq(:success)
end
end
- context 'disallow descendants to override' do
- where(:desired_params) do
- ['0', false]
- end
-
- with_them do
- let(:params) { { allow_descendants_override_disabled_shared_runners: desired_params } }
-
- context 'top level group' do
- let_it_be(:group) { create(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners ) }
-
- it 'receives correct method and succeeds' do
- expect(group).to receive(:disallow_descendants_override_disabled_shared_runners!)
- expect(group).not_to receive(:allow_descendants_override_disabled_shared_runners!)
- expect(group).not_to receive(:enable_shared_runners!)
- expect(group).not_to receive(:disable_shared_runners!)
-
- expect(subject[:status]).to eq(:success)
- end
- end
-
- context 'top level group that has shared Runners enabled' do
- let_it_be(:group) { create(:group, shared_runners_enabled: true) }
-
- it 'results error' do
- expect(subject[:status]).to eq(:error)
- expect(subject[:message]).to eq('Shared Runners enabled')
- end
- end
- end
- end
+ context 'allow descendants to override' do
+ let(:params) { { shared_runners_setting: 'disabled_with_override' } }
- context 'both params are present' do
- context 'shared_runners_enabled: 1 and allow_descendants_override_disabled_shared_runners' do
+ context 'top level group' do
let_it_be(:group) { create(:group, :shared_runners_disabled) }
- let_it_be(:sub_group) { create(:group, :shared_runners_disabled, parent: group) }
- let_it_be(:project) { create(:project, shared_runners_enabled: false, group: sub_group) }
- where(:allow_descendants_override) do
- ['1', true, '0', false]
- end
+ it 'receives correct method and succeeds' do
+ expect(group).to receive(:update_shared_runners_setting!).with('disabled_with_override')
- with_them do
- let(:params) { { shared_runners_enabled: '1', allow_descendants_override_disabled_shared_runners: allow_descendants_override } }
-
- it 'results in an error because shared Runners are enabled' do
- expect { subject }
- .to not_change { group.reload.shared_runners_enabled }
- .and not_change { sub_group.reload.shared_runners_enabled }
- .and not_change { project.reload.shared_runners_enabled }
- .and not_change { group.reload.allow_descendants_override_disabled_shared_runners }
- .and not_change { sub_group.reload.allow_descendants_override_disabled_shared_runners }
- expect(subject[:status]).to eq(:error)
- expect(subject[:message]).to eq('Cannot set shared_runners_enabled to true and allow_descendants_override_disabled_shared_runners')
- end
+ expect(subject[:status]).to eq(:success)
end
end
- context 'shared_runners_enabled: 0 and allow_descendants_override_disabled_shared_runners: 0' do
- let_it_be(:group) { create(:group, :allow_descendants_override_disabled_shared_runners) }
- let_it_be(:sub_group) { create(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent: group) }
- let_it_be(:sub_group_2) { create(:group, parent: group) }
- let_it_be(:project) { create(:project, group: group, shared_runners_enabled: true) }
- let_it_be(:project_2) { create(:project, group: sub_group_2, shared_runners_enabled: true) }
-
- let(:params) { { shared_runners_enabled: '0', allow_descendants_override_disabled_shared_runners: '0' } }
-
- it 'disables shared Runners and disable allow_descendants_override_disabled_shared_runners' do
- expect { subject }
- .to change { group.reload.shared_runners_enabled }.from(true).to(false)
- .and change { group.reload.allow_descendants_override_disabled_shared_runners }.from(true).to(false)
- .and not_change { sub_group.reload.shared_runners_enabled }
- .and change { sub_group.reload.allow_descendants_override_disabled_shared_runners }.from(true).to(false)
- .and change { sub_group_2.reload.shared_runners_enabled }.from(true).to(false)
- .and not_change { sub_group_2.reload.allow_descendants_override_disabled_shared_runners }
- .and change { project.reload.shared_runners_enabled }.from(true).to(false)
- .and change { project_2.reload.shared_runners_enabled }.from(true).to(false)
- end
- end
+ context 'when parent does not allow' do
+ let_it_be(:parent) { create(:group, :shared_runners_disabled, allow_descendants_override_disabled_shared_runners: false ) }
+ let_it_be(:group) { create(:group, :shared_runners_disabled, allow_descendants_override_disabled_shared_runners: false, parent: parent) }
- context 'shared_runners_enabled: 0 and allow_descendants_override_disabled_shared_runners: 1' do
- let_it_be(:group) { create(:group) }
- let_it_be(:sub_group) { create(:group, :shared_runners_disabled, parent: group) }
- let_it_be(:sub_group_2) { create(:group, parent: group) }
- let_it_be(:project) { create(:project, group: group, shared_runners_enabled: true) }
- let_it_be(:project_2) { create(:project, group: sub_group_2, shared_runners_enabled: true) }
-
- let(:params) { { shared_runners_enabled: '0', allow_descendants_override_disabled_shared_runners: '1' } }
-
- it 'disables shared Runners and enable allow_descendants_override_disabled_shared_runners only for itself' do
- expect { subject }
- .to change { group.reload.shared_runners_enabled }.from(true).to(false)
- .and change { group.reload.allow_descendants_override_disabled_shared_runners }.from(false).to(true)
- .and not_change { sub_group.reload.shared_runners_enabled }
- .and not_change { sub_group.reload.allow_descendants_override_disabled_shared_runners }
- .and change { sub_group_2.reload.shared_runners_enabled }.from(true).to(false)
- .and not_change { sub_group_2.reload.allow_descendants_override_disabled_shared_runners }
- .and change { project.reload.shared_runners_enabled }.from(true).to(false)
- .and change { project_2.reload.shared_runners_enabled }.from(true).to(false)
+ it 'results error' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:message]).to eq('Validation failed: Allow descendants override disabled shared runners cannot be enabled because parent group does not allow it')
end
end
end
diff --git a/spec/services/incident_management/incidents/update_severity_service_spec.rb b/spec/services/incident_management/incidents/update_severity_service_spec.rb
new file mode 100644
index 00000000000..bc1abf82cf2
--- /dev/null
+++ b/spec/services/incident_management/incidents/update_severity_service_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IncidentManagement::Incidents::UpdateSeverityService do
+ let_it_be(:user) { create(:user) }
+
+ describe '#execute' do
+ let(:severity) { 'low' }
+ let(:system_note_worker) { ::IncidentManagement::AddSeveritySystemNoteWorker }
+
+ subject(:update_severity) { described_class.new(issuable, user, severity).execute }
+
+ before do
+ allow(system_note_worker).to receive(:perform_async)
+ end
+
+ shared_examples 'adds a system note' do
+ it 'calls AddSeveritySystemNoteWorker' do
+ update_severity
+
+ expect(system_note_worker).to have_received(:perform_async).with(issuable.id, user.id)
+ end
+ end
+
+ context 'when issuable not an incident' do
+ %i(issue merge_request).each do |issuable_type|
+ let(:issuable) { build_stubbed(issuable_type) }
+
+ it { is_expected.to be_nil }
+
+ it 'does not set severity' do
+ expect { update_severity }.not_to change(IssuableSeverity, :count)
+ end
+
+ it 'does not add a system note' do
+ update_severity
+
+ expect(system_note_worker).not_to have_received(:perform_async)
+ end
+ end
+ end
+
+ context 'when issuable is an incident' do
+ let!(:issuable) { create(:incident) }
+
+ context 'when issuable does not have issuable severity yet' do
+ it 'creates new record' do
+ expect { update_severity }.to change { IssuableSeverity.where(issue: issuable).count }.to(1)
+ end
+
+ it 'sets severity to specified value' do
+ expect { update_severity }.to change { issuable.severity }.to('low')
+ end
+
+ it_behaves_like 'adds a system note'
+ end
+
+ context 'when issuable has an issuable severity' do
+ let!(:issuable_severity) { create(:issuable_severity, issue: issuable, severity: 'medium') }
+
+ it 'does not create new record' do
+ expect { update_severity }.not_to change(IssuableSeverity, :count)
+ end
+
+ it 'updates existing issuable severity' do
+ expect { update_severity }.to change { issuable_severity.severity }.to(severity)
+ end
+
+ it_behaves_like 'adds a system note'
+ end
+
+ context 'when severity value is unsupported' do
+ let(:severity) { 'unsupported-severity' }
+
+ it 'sets the severity to default value' do
+ update_severity
+
+ expect(issuable.issuable_severity.severity).to eq(IssuableSeverity::DEFAULT)
+ end
+
+ it_behaves_like 'adds a system note'
+ 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 168a80a97c0..f2bc4f717af 100644
--- a/spec/services/issuable/bulk_update_service_spec.rb
+++ b/spec/services/issuable/bulk_update_service_spec.rb
@@ -254,7 +254,7 @@ RSpec.describe Issuable::BulkUpdateService do
describe 'unsubscribe from issues' do
let(:issues) do
create_list(:closed_issue, 2, project: project) do |issue|
- issue.subscriptions.create(user: user, project: project, subscribed: true)
+ issue.subscriptions.create!(user: user, project: project, subscribed: true)
end
end
diff --git a/spec/services/issuable/clone/attributes_rewriter_spec.rb b/spec/services/issuable/clone/attributes_rewriter_spec.rb
index 372e6d480e3..7f434b8b246 100644
--- a/spec/services/issuable/clone/attributes_rewriter_spec.rb
+++ b/spec/services/issuable/clone/attributes_rewriter_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe Issuable::Clone::AttributesRewriter do
group_label = create(:group_label, title: 'group_label', group: group)
create(:label, title: 'label3', project: project2)
- original_issue.update(labels: [project1_label_1, project1_label_2, group_label])
+ original_issue.update!(labels: [project1_label_1, project1_label_2, group_label])
subject.execute
@@ -48,7 +48,7 @@ RSpec.describe Issuable::Clone::AttributesRewriter do
it 'sets milestone to nil when old issue milestone is not in the new project' do
milestone = create(:milestone, title: 'milestone', project: project1)
- original_issue.update(milestone: milestone)
+ original_issue.update!(milestone: milestone)
subject.execute
@@ -59,7 +59,7 @@ RSpec.describe Issuable::Clone::AttributesRewriter do
milestone_project1 = create(:milestone, title: 'milestone', project: project1)
milestone_project2 = create(:milestone, title: 'milestone', project: project2)
- original_issue.update(milestone: milestone_project1)
+ original_issue.update!(milestone: milestone_project1)
subject.execute
@@ -69,7 +69,7 @@ RSpec.describe Issuable::Clone::AttributesRewriter do
it 'copies the milestone when old issue milestone is a group milestone' do
milestone = create(:milestone, title: 'milestone', group: group)
- original_issue.update(milestone: milestone)
+ original_issue.update!(milestone: milestone)
subject.execute
@@ -85,7 +85,7 @@ RSpec.describe Issuable::Clone::AttributesRewriter do
let!(:milestone2_project2) { create(:milestone, title: 'milestone2', project: project2) }
before do
- original_issue.update(milestone: milestone2_project1)
+ original_issue.update!(milestone: milestone2_project1)
create_event(milestone1_project1)
create_event(milestone2_project1)
diff --git a/spec/services/issuable/common_system_notes_service_spec.rb b/spec/services/issuable/common_system_notes_service_spec.rb
index 217550542bb..fc01ee8f672 100644
--- a/spec/services/issuable/common_system_notes_service_spec.rb
+++ b/spec/services/issuable/common_system_notes_service_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe Issuable::CommonSystemNotesService do
before do
issuable.labels << label
- issuable.save
+ issuable.save!
end
it 'creates a resource label event' do
@@ -69,7 +69,7 @@ RSpec.describe Issuable::CommonSystemNotesService do
subject { described_class.new(project, user).execute(issuable, old_labels: [], is_update: false) }
it 'does not create system note for title and description' do
- issuable.save
+ issuable.save!
expect { subject }.not_to change { issuable.notes.count }
end
@@ -78,7 +78,7 @@ RSpec.describe Issuable::CommonSystemNotesService do
label = create(:label, project: project)
issuable.labels << label
- issuable.save
+ issuable.save!
expect { subject }.to change { issuable.resource_label_events.count }.from(0).to(1)
@@ -104,7 +104,7 @@ RSpec.describe Issuable::CommonSystemNotesService do
it 'creates a system note for due_date set' do
issuable.due_date = Date.today
- issuable.save
+ issuable.save!
expect { subject }.to change { issuable.notes.count }.from(0).to(1)
expect(issuable.notes.last.note).to match('changed due date')
diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb
index 4db6e5cac12..9076fb11c9b 100644
--- a/spec/services/issues/close_service_spec.rb
+++ b/spec/services/issues/close_service_spec.rb
@@ -233,26 +233,11 @@ RSpec.describe Issues::CloseService do
expect(email.subject).to include(issue.title)
end
- context 'when resource state events are disabled' do
- before do
- stub_feature_flags(track_resource_state_change_events: false)
- end
-
- it 'creates system note about the issue being closed' do
- close_issue
-
- note = issue.notes.last
- expect(note.note).to include "closed"
- end
- end
-
- context 'when resource state events are enabled' do
- it 'creates resource state event about the issue being closed' do
- close_issue
+ it 'creates resource state event about the issue being closed' do
+ close_issue
- event = issue.resource_state_events.last
- expect(event.state).to eq('closed')
- end
+ event = issue.resource_state_events.last
+ expect(event.state).to eq('closed')
end
it 'marks todos as done' do
diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb
index c2989dc86cf..7997b8de3fd 100644
--- a/spec/services/issues/move_service_spec.rb
+++ b/spec/services/issues/move_service_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Issues::MoveService do
+ include DesignManagementTestHelpers
+
let_it_be(:user) { create(:user) }
let_it_be(:author) { create(:user) }
let_it_be(:title) { 'Some issue' }
@@ -201,6 +203,54 @@ RSpec.describe Issues::MoveService do
expect(copied_notes.order('id ASC').pluck(:note)).to eq(notes.map(&:note))
end
end
+
+ context 'issue with a design', :clean_gitlab_redis_shared_state do
+ let!(:design) { create(:design, :with_lfs_file, issue: old_issue) }
+ let!(:note) { create(:diff_note_on_design, noteable: design, issue: old_issue, project: old_issue.project) }
+ let(:subject) { move_service.execute(old_issue, new_project) }
+
+ before do
+ enable_design_management
+ end
+
+ it 'calls CopyDesignCollection::QueueService' do
+ expect(DesignManagement::CopyDesignCollection::QueueService).to receive(:new)
+ .with(user, old_issue, kind_of(Issue))
+ .and_call_original
+
+ subject
+ end
+
+ it 'logs if QueueService returns an error', :aggregate_failures do
+ error_message = 'error'
+
+ expect_next_instance_of(DesignManagement::CopyDesignCollection::QueueService) do |service|
+ expect(service).to receive(:execute).and_return(
+ ServiceResponse.error(message: error_message)
+ )
+ end
+ expect(Gitlab::AppLogger).to receive(:error).with(error_message)
+
+ subject
+ end
+
+ it 'does not call QueueService when the feature flag is disabled' do
+ stub_feature_flags(design_management_copy_designs: false)
+
+ expect(DesignManagement::CopyDesignCollection::QueueService).not_to receive(:new)
+
+ subject
+ end
+
+ # Perform a small integration test to ensure the services and worker
+ # can correctly create designs.
+ it 'copies the design and its notes', :sidekiq_inline, :aggregate_failures do
+ new_issue = subject
+
+ expect(new_issue.designs.size).to eq(1)
+ expect(new_issue.designs.first.notes.size).to eq(1)
+ end
+ end
end
describe 'move permissions' do
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index b3e8fba4e9a..cfda27795c7 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -650,7 +650,7 @@ RSpec.describe Issues::UpdateService, :mailer do
context 'when the labels change' do
before do
- Timecop.freeze(1.minute.from_now) do
+ travel_to(1.minute.from_now) do
update_issue(label_ids: [label.id])
end
end
diff --git a/spec/services/keys/last_used_service_spec.rb b/spec/services/keys/last_used_service_spec.rb
index 82b6b05975b..a2cd5ffdd38 100644
--- a/spec/services/keys/last_used_service_spec.rb
+++ b/spec/services/keys/last_used_service_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe Keys::LastUsedService do
key = create(:key, last_used_at: 1.year.ago)
time = Time.zone.now
- Timecop.freeze(time) { described_class.new(key).execute }
+ travel_to(time) { described_class.new(key).execute }
expect(key.reload.last_used_at).to be_like_time(time)
end
diff --git a/spec/services/lfs/push_service_spec.rb b/spec/services/lfs/push_service_spec.rb
index 8e5b98fdc9c..f67284ff48d 100644
--- a/spec/services/lfs/push_service_spec.rb
+++ b/spec/services/lfs/push_service_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe Lfs::PushService do
stub_lfs_batch(lfs_object)
expect(lfs_client)
- .to receive(:upload)
+ .to receive(:upload!)
.with(lfs_object, upload_action_spec(lfs_object), authenticated: true)
expect(service.execute).to eq(status: :success)
@@ -28,7 +28,7 @@ RSpec.describe Lfs::PushService do
it 'does nothing if there are no LFS objects' do
lfs_object.destroy!
- expect(lfs_client).not_to receive(:upload)
+ expect(lfs_client).not_to receive(:upload!)
expect(service.execute).to eq(status: :success)
end
@@ -36,20 +36,39 @@ RSpec.describe Lfs::PushService do
it 'does not upload the object when upload is not requested' do
stub_lfs_batch(lfs_object, upload: false)
- expect(lfs_client).not_to receive(:upload)
+ expect(lfs_client).not_to receive(:upload!)
expect(service.execute).to eq(status: :success)
end
+ it 'verifies the upload if requested' do
+ stub_lfs_batch(lfs_object, verify: true)
+
+ expect(lfs_client).to receive(:upload!)
+ expect(lfs_client)
+ .to receive(:verify!)
+ .with(lfs_object, verify_action_spec(lfs_object), authenticated: true)
+
+ expect(service.execute).to eq(status: :success)
+ end
+
+ it 'skips verification if requested but upload fails' do
+ stub_lfs_batch(lfs_object, verify: true)
+
+ expect(lfs_client).to receive(:upload!) { raise 'failed' }
+ expect(lfs_client).not_to receive(:verify!)
+ expect(service.execute).to eq(status: :error, message: 'failed')
+ end
+
it 'returns a failure when submitting a batch fails' do
- expect(lfs_client).to receive(:batch) { raise 'failed' }
+ expect(lfs_client).to receive(:batch!) { raise 'failed' }
expect(service.execute).to eq(status: :error, message: 'failed')
end
it 'returns a failure when submitting an upload fails' do
stub_lfs_batch(lfs_object)
- expect(lfs_client).to receive(:upload) { raise 'failed' }
+ expect(lfs_client).to receive(:upload!) { raise 'failed' }
expect(service.execute).to eq(status: :error, message: 'failed')
end
@@ -71,23 +90,28 @@ RSpec.describe Lfs::PushService do
create(:lfs_objects_project, project: project, repository_type: type).lfs_object
end
- def stub_lfs_batch(*objects, upload: true)
+ def stub_lfs_batch(*objects, upload: true, verify: false)
expect(lfs_client)
- .to receive(:batch).with('upload', containing_exactly(*objects))
- .and_return('transfer' => 'basic', 'objects' => objects.map { |o| object_spec(o, upload: upload) })
+ .to receive(:batch!).with('upload', containing_exactly(*objects))
+ .and_return('transfer' => 'basic', 'objects' => objects.map { |o| object_spec(o, upload: upload, verify: verify) })
end
- def batch_spec(*objects, upload: true)
+ def batch_spec(*objects, upload: true, verify: false)
{ 'transfer' => 'basic', 'objects' => objects.map {|o| object_spec(o, upload: upload) } }
end
- def object_spec(object, upload: true)
- { 'oid' => object.oid, 'size' => object.size, 'authenticated' => true }.tap do |spec|
- spec['actions'] = { 'upload' => upload_action_spec(object) } if upload
+ def object_spec(object, upload: true, verify: false)
+ { 'oid' => object.oid, 'size' => object.size, 'authenticated' => true, 'actions' => {} }.tap do |spec|
+ spec['actions']['upload'] = upload_action_spec(object) if upload
+ spec['actions']['verify'] = verify_action_spec(object) if verify
end
end
def upload_action_spec(object)
{ 'href' => "https://example.com/#{object.oid}/#{object.size}", 'header' => { 'Key' => 'value' } }
end
+
+ def verify_action_spec(object)
+ { 'href' => "https://example.com/#{object.oid}/#{object.size}/verify", 'header' => { 'Key' => 'value' } }
+ end
end
diff --git a/spec/services/members/destroy_service_spec.rb b/spec/services/members/destroy_service_spec.rb
index 3b3f2f3b95a..4f731ad5852 100644
--- a/spec/services/members/destroy_service_spec.rb
+++ b/spec/services/members/destroy_service_spec.rb
@@ -29,15 +29,15 @@ RSpec.describe Members::DestroyService do
end
it 'destroys the member' do
- expect { described_class.new(current_user).execute(member, opts) }.to change { member.source.members_and_requesters.count }.by(-1)
+ expect { described_class.new(current_user).execute(member, **opts) }.to change { member.source.members_and_requesters.count }.by(-1)
end
it 'destroys member notification_settings' do
if member_user.notification_settings.any?
- expect { described_class.new(current_user).execute(member, opts) }
+ 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) }
+ expect { described_class.new(current_user).execute(member, **opts) }
.not_to change { member_user.notification_settings.count }
end
end
@@ -63,7 +63,7 @@ RSpec.describe Members::DestroyService do
expect(service).to receive(:enqueue_unassign_issuables).with(member)
end
- service.execute(member, opts)
+ service.execute(member, **opts)
expect(member_user.assigned_open_merge_requests_count).to be(0)
expect(member_user.assigned_open_issues_count).to be(0)
@@ -83,14 +83,14 @@ RSpec.describe Members::DestroyService do
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, opts)
+ described_class.new(current_user).execute(member, **opts)
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, opts)
+ described_class.new(member_user).execute(member, **opts)
end
end
end
@@ -280,7 +280,6 @@ RSpec.describe Members::DestroyService do
context 'subresources' do
let(:user) { create(:user) }
let(:member_user) { create(:user) }
- let(:opts) { {} }
let(:group) { create(:group, :public) }
let(:subgroup) { create(:group, parent: group) }
@@ -303,7 +302,7 @@ RSpec.describe Members::DestroyService do
group_member = create(:group_member, :developer, group: group, user: member_user)
- described_class.new(user).execute(group_member, opts)
+ described_class.new(user).execute(group_member)
end
it 'removes the project membership' do
@@ -350,7 +349,6 @@ RSpec.describe Members::DestroyService do
context 'deletion of invitations created by deleted project member' do
let(:user) { project.owner }
let(:member_user) { create(:user) }
- let(:opts) { {} }
let(:project) { create(:project) }
@@ -359,7 +357,7 @@ RSpec.describe Members::DestroyService do
project_member = create(:project_member, :maintainer, user: member_user, project: project)
- described_class.new(user).execute(project_member, opts)
+ described_class.new(user).execute(project_member)
end
it 'removes project members invited by deleted user' do
diff --git a/spec/services/members/invitation_reminder_email_service_spec.rb b/spec/services/members/invitation_reminder_email_service_spec.rb
new file mode 100644
index 00000000000..88280869476
--- /dev/null
+++ b/spec/services/members/invitation_reminder_email_service_spec.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Members::InvitationReminderEmailService do
+ describe 'sending invitation reminders' do
+ subject { described_class.new(invitation).execute }
+
+ let_it_be(:frozen_time) { Date.today.beginning_of_day }
+ let_it_be(:invitation) { build(:group_member, :invited, created_at: frozen_time) }
+
+ context 'when the experiment is disabled' do
+ before do
+ allow(Gitlab::Experimentation).to receive(:enabled_for_attribute?).and_return(false)
+ invitation.expires_at = frozen_time + 2.days
+ end
+
+ it 'does not send an invitation' do
+ travel_to(frozen_time + 1.day) do
+ expect(invitation).not_to receive(:send_invitation_reminder)
+
+ subject
+ end
+ end
+ end
+
+ context 'when the experiment is enabled' do
+ before do
+ allow(Gitlab::Experimentation).to receive(:enabled_for_attribute?).and_return(true)
+ invitation.expires_at = frozen_time + expires_at_days.days if expires_at_days
+ end
+
+ using RSpec::Parameterized::TableSyntax
+
+ where(:expires_at_days, :send_reminder_at_days) do
+ 0 | []
+ 1 | []
+ 2 | [1]
+ 3 | [1, 2]
+ 4 | [1, 2, 3]
+ 5 | [1, 2, 4]
+ 6 | [1, 3, 5]
+ 7 | [1, 3, 5]
+ 8 | [2, 3, 6]
+ 9 | [2, 4, 7]
+ 10 | [2, 4, 8]
+ 11 | [2, 4, 8]
+ 12 | [2, 5, 9]
+ 13 | [2, 5, 10]
+ 14 | [2, 5, 10]
+ 15 | [2, 5, 10]
+ nil | [2, 5, 10]
+ end
+
+ with_them do
+ # Create an invitation today with an expiration date from 0 to 10 days in the future or without an expiration date
+ # We chose 10 days here, because we fetch invitations that were created at most 10 days ago.
+ (0..10).each do |day|
+ it 'sends an invitation reminder only on the expected days' do
+ next if day > (expires_at_days || 10) # We don't need to test after the invitation has already expired
+
+ # We are traveling in a loop from today to 10 days from now
+ travel_to(frozen_time + day.days) do
+ # Given an expiration date and the number of days after the creation of the invitation based on the current day in the loop, a reminder may be sent
+ if (reminder_index = send_reminder_at_days.index(day))
+ expect(invitation).to receive(:send_invitation_reminder).with(reminder_index)
+ else
+ expect(invitation).not_to receive(:send_invitation_reminder)
+ end
+
+ subject
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb
index e7ac286f48b..67fb4eaade5 100644
--- a/spec/services/merge_requests/close_service_spec.rb
+++ b/spec/services/merge_requests/close_service_spec.rb
@@ -19,54 +19,45 @@ RSpec.describe MergeRequests::CloseService do
describe '#execute' do
it_behaves_like 'cache counters invalidator'
- [true, false].each do |state_tracking_enabled|
- context "valid params with state_tracking #{state_tracking_enabled ? 'enabled' : 'disabled'}" do
- let(:service) { described_class.new(project, user, {}) }
+ context 'valid params' do
+ let(:service) { described_class.new(project, user, {}) }
- before do
- stub_feature_flags(track_resource_state_change_events: state_tracking_enabled)
-
- allow(service).to receive(:execute_hooks)
+ before do
+ allow(service).to receive(:execute_hooks)
- perform_enqueued_jobs do
- @merge_request = service.execute(merge_request)
- end
+ perform_enqueued_jobs do
+ @merge_request = service.execute(merge_request)
end
+ end
- it { expect(@merge_request).to be_valid }
- it { expect(@merge_request).to be_closed }
+ it { expect(@merge_request).to be_valid }
+ it { expect(@merge_request).to be_closed }
- it 'executes hooks with close action' do
- expect(service).to have_received(:execute_hooks)
- .with(@merge_request, 'close')
- end
+ it 'executes hooks with close action' do
+ expect(service).to have_received(:execute_hooks)
+ .with(@merge_request, 'close')
+ end
- it 'sends email to user2 about assign of new merge_request', :sidekiq_might_not_need_inline do
- email = ActionMailer::Base.deliveries.last
- expect(email.to.first).to eq(user2.email)
- expect(email.subject).to include(merge_request.title)
- end
+ it 'sends email to user2 about assign of new merge_request', :sidekiq_might_not_need_inline do
+ email = ActionMailer::Base.deliveries.last
+ expect(email.to.first).to eq(user2.email)
+ expect(email.subject).to include(merge_request.title)
+ end
- it 'creates system note about merge_request reassign' do
- if state_tracking_enabled
- event = @merge_request.resource_state_events.last
- expect(event.state).to eq('closed')
- else
- note = @merge_request.notes.last
- expect(note.note).to include 'closed'
- end
- end
+ it 'creates a resource event' do
+ event = @merge_request.resource_state_events.last
+ expect(event.state).to eq('closed')
+ end
- it 'marks todos as done' do
- expect(todo.reload).to be_done
- end
+ it 'marks todos as done' do
+ expect(todo.reload).to be_done
+ end
- context 'when auto merge is enabled' do
- let(:merge_request) { create(:merge_request, :merge_when_pipeline_succeeds) }
+ context 'when auto merge is enabled' do
+ let(:merge_request) { create(:merge_request, :merge_when_pipeline_succeeds) }
- it 'cancels the auto merge' do
- expect(@merge_request).not_to be_auto_merge_enabled
- end
+ it 'cancels the auto merge' do
+ expect(@merge_request).not_to be_auto_merge_enabled
end
end
end
diff --git a/spec/services/merge_requests/export_csv_service_spec.rb b/spec/services/merge_requests/export_csv_service_spec.rb
new file mode 100644
index 00000000000..8161a444231
--- /dev/null
+++ b/spec/services/merge_requests/export_csv_service_spec.rb
@@ -0,0 +1,115 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe MergeRequests::ExportCsvService do
+ let_it_be(:merge_request) { create(:merge_request) }
+ let(:csv) { CSV.parse(subject.csv_data, headers: true).first }
+
+ subject { described_class.new(MergeRequest.where(id: merge_request.id)) }
+
+ describe 'csv_data' do
+ it 'contains the correct information', :aggregate_failures do
+ expect(csv['MR IID']).to eq(merge_request.iid.to_s)
+ expect(csv['Title']).to eq(merge_request.title)
+ expect(csv['State']).to eq(merge_request.state)
+ expect(csv['Description']).to eq(merge_request.description)
+ expect(csv['Source Branch']).to eq(merge_request.source_branch)
+ expect(csv['Target Branch']).to eq(merge_request.target_branch)
+ expect(csv['Source Project ID']).to eq(merge_request.source_project_id.to_s)
+ expect(csv['Target Project ID']).to eq(merge_request.target_project_id.to_s)
+ expect(csv['Author']).to eq(merge_request.author.name)
+ expect(csv['Author Username']).to eq(merge_request.author.username)
+ end
+
+ describe 'assignees' do
+ context 'when assigned' do
+ let_it_be(:merge_request) { create(:merge_request, assignees: create_list(:user, 2)) }
+
+ it 'contains the names of assignees' do
+ expect(csv['Assignees']).to eq(merge_request.assignees.map(&:name).join(', '))
+ end
+
+ it 'contains the usernames of assignees' do
+ expect(csv['Assignee Usernames']).to eq(merge_request.assignees.map(&:username).join(', '))
+ end
+ end
+
+ context 'when not assigned' do
+ it 'returns empty strings' do
+ expect(csv['Assignees']).to eq('')
+ expect(csv['Assignee Usernames']).to eq('')
+ end
+ end
+ end
+
+ describe 'approvers' do
+ context 'when approved' do
+ let_it_be(:merge_request) { create(:merge_request) }
+ let(:approvers) { create_list(:user, 2) }
+
+ before do
+ merge_request.approved_by_users = approvers
+ end
+
+ it 'contains the names of approvers separated by a comma' do
+ expect(csv['Approvers'].split(', ')).to contain_exactly(approvers[0].name, approvers[1].name)
+ end
+
+ it 'contains the usernames of approvers separated by a comma' do
+ expect(csv['Approver Usernames'].split(', ')).to contain_exactly(approvers[0].username, approvers[1].username)
+ end
+ end
+
+ context 'when not approved' do
+ it 'returns empty strings' do
+ expect(csv['Approvers']).to eq('')
+ expect(csv['Approver Usernames']).to eq('')
+ end
+ end
+ end
+
+ describe 'merged user' do
+ context 'MR is merged' do
+ let_it_be(:merge_request) { create(:merge_request, :merged, :with_merged_metrics) }
+
+ it 'is merged' do
+ expect(csv['State']).to eq('merged')
+ end
+
+ it 'has a merged user' do
+ expect(csv['Merged User']).to eq(merge_request.metrics.merged_by.name)
+ expect(csv['Merged Username']).to eq(merge_request.metrics.merged_by.username)
+ end
+ end
+
+ context 'MR is not merged' do
+ it 'returns empty strings' do
+ expect(csv['Merged User']).to eq('')
+ expect(csv['Merged Username']).to eq('')
+ end
+ end
+ end
+
+ describe 'milestone' do
+ context 'milestone is assigned' do
+ let_it_be(:merge_request) { create(:merge_request) }
+ let_it_be(:milestone) { create(:milestone, :active, project: merge_request.project) }
+
+ before do
+ merge_request.update!(milestone_id: milestone.id)
+ end
+
+ it 'contains the milestone ID' do
+ expect(csv['Milestone ID']).to eq(merge_request.milestone.id.to_s)
+ end
+ end
+
+ context 'no milestone is assigned' do
+ it 'returns an empty string' do
+ expect(csv['Milestone ID']).to eq('')
+ end
+ 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 55856deeaca..64c473d947f 100644
--- a/spec/services/merge_requests/ff_merge_service_spec.rb
+++ b/spec/services/merge_requests/ff_merge_service_spec.rb
@@ -22,74 +22,72 @@ RSpec.describe MergeRequests::FfMergeService do
end
describe '#execute' do
- [true, false].each do |state_tracking_enabled|
- context "valid params with state_tracking #{state_tracking_enabled ? 'enabled' : 'disabled'}" do
- let(:service) { described_class.new(project, user, valid_merge_params) }
-
- def execute_ff_merge
- perform_enqueued_jobs do
- service.execute(merge_request)
- end
+ context 'valid params' do
+ let(:service) { described_class.new(project, user, valid_merge_params) }
+
+ def execute_ff_merge
+ perform_enqueued_jobs do
+ service.execute(merge_request)
end
+ end
- before do
- stub_feature_flags(track_resource_state_change_events: state_tracking_enabled)
+ before do
+ allow(service).to receive(:execute_hooks)
+ end
- allow(service).to receive(:execute_hooks)
- end
+ it "does not create merge commit" do
+ execute_ff_merge
- it "does not create merge commit" do
- execute_ff_merge
+ source_branch_sha = merge_request.source_project.repository.commit(merge_request.source_branch).sha
+ target_branch_sha = merge_request.target_project.repository.commit(merge_request.target_branch).sha
- source_branch_sha = merge_request.source_project.repository.commit(merge_request.source_branch).sha
- target_branch_sha = merge_request.target_project.repository.commit(merge_request.target_branch).sha
+ expect(source_branch_sha).to eq(target_branch_sha)
+ end
- expect(source_branch_sha).to eq(target_branch_sha)
- end
+ it 'keeps the merge request valid' do
+ expect { execute_ff_merge }
+ .not_to change { merge_request.valid? }
+ end
- it 'keeps the merge request valid' do
- expect { execute_ff_merge }
- .not_to change { merge_request.valid? }
- end
+ it 'updates the merge request to merged' do
+ expect { execute_ff_merge }
+ .to change { merge_request.merged? }
+ .from(false)
+ .to(true)
+ end
- it 'updates the merge request to merged' do
- expect { execute_ff_merge }
- .to change { merge_request.merged? }
- .from(false)
- .to(true)
- end
+ it 'sends email to user2 about merge of new merge_request' do
+ execute_ff_merge
- it 'sends email to user2 about merge of new merge_request' do
- execute_ff_merge
+ email = ActionMailer::Base.deliveries.last
+ expect(email.to.first).to eq(user2.email)
+ expect(email.subject).to include(merge_request.title)
+ end
- email = ActionMailer::Base.deliveries.last
- expect(email.to.first).to eq(user2.email)
- expect(email.subject).to include(merge_request.title)
- end
+ it 'creates resource event about merge_request merge' do
+ execute_ff_merge
- it 'creates system note about merge_request merge' do
- execute_ff_merge
+ event = merge_request.resource_state_events.last
+ expect(event.state).to eq('merged')
+ end
- if state_tracking_enabled
- event = merge_request.resource_state_events.last
- expect(event.state).to eq('merged')
- else
- note = merge_request.notes.last
- expect(note.note).to include 'merged'
- end
- end
+ it 'does not update squash_commit_sha if it is not a squash' do
+ expect(merge_request).to receive(:update_and_mark_in_progress_merge_commit_sha).twice.and_call_original
- it 'does not update squash_commit_sha if it is not a squash' do
- expect { execute_ff_merge }.not_to change { merge_request.squash_commit_sha }
- end
+ expect { execute_ff_merge }.not_to change { merge_request.squash_commit_sha }
+ expect(merge_request.in_progress_merge_commit_sha).to be_nil
+ end
- it 'updates squash_commit_sha if it is a squash' do
- merge_request.update!(squash: true)
+ it 'updates squash_commit_sha if it is a squash' do
+ expect(merge_request).to receive(:update_and_mark_in_progress_merge_commit_sha).twice.and_call_original
- expect { execute_ff_merge }
- .to change { merge_request.squash_commit_sha }
- .from(nil)
- end
+ merge_request.update!(squash: true)
+
+ expect { execute_ff_merge }
+ .to change { merge_request.squash_commit_sha }
+ .from(nil)
+
+ expect(merge_request.in_progress_merge_commit_sha).to be_nil
end
end
diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb
index 8328f461029..d0e3102f157 100644
--- a/spec/services/merge_requests/merge_service_spec.rb
+++ b/spec/services/merge_requests/merge_service_spec.rb
@@ -20,12 +20,9 @@ RSpec.describe MergeRequests::MergeService do
end
context 'valid params' do
- let(:state_tracking) { true }
-
before do
- stub_feature_flags(track_resource_state_change_events: state_tracking)
-
allow(service).to receive(:execute_hooks)
+ expect(merge_request).to receive(:update_and_mark_in_progress_merge_commit_sha).twice.and_call_original
perform_enqueued_jobs do
service.execute(merge_request)
@@ -47,20 +44,9 @@ RSpec.describe MergeRequests::MergeService do
end
context 'note creation' do
- context 'when resource state event tracking is disabled' do
- let(:state_tracking) { false }
-
- it 'creates system note about merge_request merge' do
- note = merge_request.notes.last
- expect(note.note).to include 'merged'
- end
- end
-
- context 'when resource state event tracking is enabled' do
- it 'creates resource state event about merge_request merge' do
- event = merge_request.resource_state_events.last
- expect(event.state).to eq('merged')
- end
+ it 'creates resource state event about merge_request merge' do
+ event = merge_request.resource_state_events.last
+ expect(event.state).to eq('merged')
end
end
diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb
index cace1e0bf09..ca0c4b29ebe 100644
--- a/spec/services/merge_requests/refresh_service_spec.rb
+++ b/spec/services/merge_requests/refresh_service_spec.rb
@@ -367,76 +367,58 @@ RSpec.describe MergeRequests::RefreshService do
end
end
- [true, false].each do |state_tracking_enabled|
- context "push to origin repo target branch with state tracking #{state_tracking_enabled ? 'enabled' : 'disabled'}", :sidekiq_might_not_need_inline do
+ context 'push to origin repo target branch', :sidekiq_might_not_need_inline do
+ context 'when all MRs to the target branch had diffs' do
before do
- stub_feature_flags(track_resource_state_change_events: state_tracking_enabled)
+ service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/feature')
+ reload_mrs
end
- context 'when all MRs to the target branch had diffs' do
- before do
- service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/feature')
- reload_mrs
- end
+ it 'updates the merge state' do
+ expect(@merge_request).to be_merged
+ expect(@fork_merge_request).to be_merged
+ expect(@build_failed_todo).to be_done
+ expect(@fork_build_failed_todo).to be_done
- it 'updates the merge state' do
- expect(@merge_request).to be_merged
- expect(@fork_merge_request).to be_merged
- expect(@build_failed_todo).to be_done
- expect(@fork_build_failed_todo).to be_done
-
- if state_tracking_enabled
- expect(@merge_request.resource_state_events.last.state).to eq('merged')
- expect(@fork_merge_request.resource_state_events.last.state).to eq('merged')
- else
- expect(@merge_request.notes.last.note).to include('merged')
- expect(@fork_merge_request.notes.last.note).to include('merged')
- end
- end
+ expect(@merge_request.resource_state_events.last.state).to eq('merged')
+ expect(@fork_merge_request.resource_state_events.last.state).to eq('merged')
end
+ end
- context 'when an MR to be closed was empty already' do
- let!(:empty_fork_merge_request) do
- create(:merge_request,
- source_project: @fork_project,
- source_branch: 'master',
- target_branch: 'master',
- target_project: @project)
- end
+ context 'when an MR to be closed was empty already' do
+ let!(:empty_fork_merge_request) do
+ create(:merge_request,
+ source_project: @fork_project,
+ source_branch: 'master',
+ target_branch: 'master',
+ target_project: @project)
+ end
- before do
- # This spec already has a fake push, so pretend that we were targeting
- # feature all along.
- empty_fork_merge_request.update_columns(target_branch: 'feature')
+ before do
+ # This spec already has a fake push, so pretend that we were targeting
+ # feature all along.
+ empty_fork_merge_request.update_columns(target_branch: 'feature')
- service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/feature')
- reload_mrs
- empty_fork_merge_request.reload
- end
+ service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/feature')
+ reload_mrs
+ empty_fork_merge_request.reload
+ end
- it 'only updates the non-empty MRs' do
- expect(@merge_request).to be_merged
- expect(@fork_merge_request).to be_merged
-
- expect(empty_fork_merge_request).to be_open
- expect(empty_fork_merge_request.merge_request_diff.state).to eq('empty')
- expect(empty_fork_merge_request.notes).to be_empty
-
- if state_tracking_enabled
- expect(@merge_request.resource_state_events.last.state).to eq('merged')
- expect(@fork_merge_request.resource_state_events.last.state).to eq('merged')
- else
- expect(@merge_request.notes.last.note).to include('merged')
- expect(@fork_merge_request.notes.last.note).to include('merged')
- end
- end
+ it 'only updates the non-empty MRs' do
+ expect(@merge_request).to be_merged
+ expect(@fork_merge_request).to be_merged
+
+ expect(empty_fork_merge_request).to be_open
+ expect(empty_fork_merge_request.merge_request_diff.state).to eq('empty')
+ expect(empty_fork_merge_request.notes).to be_empty
+
+ expect(@merge_request.resource_state_events.last.state).to eq('merged')
+ expect(@fork_merge_request.resource_state_events.last.state).to eq('merged')
end
end
- context "manual merge of source branch #{state_tracking_enabled ? 'enabled' : 'disabled'}", :sidekiq_might_not_need_inline do
+ context 'manual merge of source branch', :sidekiq_might_not_need_inline do
before do
- stub_feature_flags(track_resource_state_change_events: state_tracking_enabled)
-
# Merge master -> feature branch
@project.repository.merge(@user, @merge_request.diff_head_sha, @merge_request, 'Test message')
commit = @project.repository.commit('feature')
@@ -445,13 +427,8 @@ RSpec.describe MergeRequests::RefreshService do
end
it 'updates the merge state' do
- if state_tracking_enabled
- expect(@merge_request.resource_state_events.last.state).to eq('merged')
- expect(@fork_merge_request.resource_state_events.last.state).to eq('merged')
- else
- expect(@merge_request.notes.last.note).to include('merged')
- expect(@fork_merge_request.notes.last.note).to include('merged')
- end
+ expect(@merge_request.resource_state_events.last.state).to eq('merged')
+ expect(@fork_merge_request.resource_state_events.last.state).to eq('merged')
expect(@merge_request).to be_merged
expect(@merge_request.diffs.size).to be > 0
@@ -616,29 +593,21 @@ RSpec.describe MergeRequests::RefreshService do
end
end
- [true, false].each do |state_tracking_enabled|
- context "push to origin repo target branch after fork project was removed #{state_tracking_enabled ? 'enabled' : 'disabled'}" do
- before do
- stub_feature_flags(track_resource_state_change_events: state_tracking_enabled)
+ context 'push to origin repo target branch after fork project was removed' do
+ before do
+ @fork_project.destroy!
+ service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/feature')
+ reload_mrs
+ end
- @fork_project.destroy!
- service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/feature')
- reload_mrs
- end
+ it 'updates the merge request state' do
+ expect(@merge_request.resource_state_events.last.state).to eq('merged')
- it 'updates the merge request state' do
- if state_tracking_enabled
- expect(@merge_request.resource_state_events.last.state).to eq('merged')
- else
- expect(@merge_request.notes.last.note).to include('merged')
- end
-
- expect(@merge_request).to be_merged
- expect(@fork_merge_request).to be_open
- expect(@fork_merge_request.notes).to be_empty
- expect(@build_failed_todo).to be_done
- expect(@fork_build_failed_todo).to be_done
- end
+ expect(@merge_request).to be_merged
+ expect(@fork_merge_request).to be_open
+ expect(@fork_merge_request.notes).to be_empty
+ expect(@build_failed_todo).to be_done
+ expect(@fork_build_failed_todo).to be_done
end
end
diff --git a/spec/services/merge_requests/reopen_service_spec.rb b/spec/services/merge_requests/reopen_service_spec.rb
index 0066834180e..ffc2ebb344c 100644
--- a/spec/services/merge_requests/reopen_service_spec.rb
+++ b/spec/services/merge_requests/reopen_service_spec.rb
@@ -20,11 +20,8 @@ RSpec.describe MergeRequests::ReopenService do
context 'valid params' do
let(:service) { described_class.new(project, user, {}) }
- let(:state_tracking) { true }
before do
- stub_feature_flags(track_resource_state_change_events: state_tracking)
-
allow(service).to receive(:execute_hooks)
perform_enqueued_jobs do
@@ -47,20 +44,9 @@ RSpec.describe MergeRequests::ReopenService do
end
context 'note creation' do
- context 'when state event tracking is disabled' do
- let(:state_tracking) { false }
-
- it 'creates system note about merge_request reopen' do
- note = merge_request.notes.last
- expect(note.note).to include 'reopened'
- end
- end
-
- context 'when state event tracking is enabled' do
- it 'creates resource state event about merge_request reopen' do
- event = merge_request.resource_state_events.last
- expect(event.state).to eq('reopened')
- end
+ it 'creates resource state event about merge_request reopen' do
+ event = merge_request.resource_state_events.last
+ expect(event.state).to eq('reopened')
end
end
end
diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb
index 3c3e10495d3..ed8872b71f7 100644
--- a/spec/services/merge_requests/update_service_spec.rb
+++ b/spec/services/merge_requests/update_service_spec.rb
@@ -53,7 +53,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
title: 'New title',
description: 'Also please fix',
assignee_ids: [user.id],
- reviewer_ids: [user.id],
+ reviewer_ids: [],
state_event: 'close',
label_ids: [label.id],
target_branch: 'target',
@@ -78,7 +78,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
expect(@merge_request).to be_valid
expect(@merge_request.title).to eq('New title')
expect(@merge_request.assignees).to match_array([user])
- expect(@merge_request.reviewers).to match_array([user])
+ expect(@merge_request.reviewers).to match_array([])
expect(@merge_request).to be_closed
expect(@merge_request.labels.count).to eq(1)
expect(@merge_request.labels.first.title).to eq(label.name)
@@ -116,6 +116,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
labels: [],
mentioned_users: [user2],
assignees: [user3],
+ reviewers: [],
milestone: nil,
total_time_spent: 0,
description: "FYI #{user2.to_reference}"
@@ -138,6 +139,35 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
expect(note.note).to include "assigned to #{user.to_reference} and unassigned #{user3.to_reference}"
end
+ context 'with reviewers' do
+ let(:opts) { { reviewer_ids: [user2.id] } }
+
+ context 'when merge_request_reviewers feature is disabled' do
+ before(:context) do
+ stub_feature_flags(merge_request_reviewers: false)
+ end
+
+ it 'does not create a system note about merge_request review request' do
+ note = find_note('review requested from')
+
+ expect(note).to be_nil
+ end
+ end
+
+ context 'when merge_request_reviewers feature is enabled' do
+ before(:context) do
+ stub_feature_flags(merge_request_reviewers: true)
+ end
+
+ it 'creates system note about merge_request review request' do
+ note = find_note('requested review from')
+
+ expect(note).not_to be_nil
+ expect(note.note).to include "requested review from #{user2.to_reference}"
+ end
+ end
+ end
+
it 'creates a resource label event' do
event = merge_request.resource_label_events.last
@@ -467,15 +497,15 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
end
context 'when reviewers gets changed' do
- before do
+ it 'marks pending todo as done' do
update_merge_request({ reviewer_ids: [user2.id] })
- end
- it 'marks pending todo as done' do
expect(pending_todo.reload).to be_done
end
it 'creates a pending todo for new review request' do
+ update_merge_request({ reviewer_ids: [user2.id] })
+
attributes = {
project: project,
author: user,
@@ -488,6 +518,17 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
expect(Todo.where(attributes).count).to eq 1
end
+
+ it 'sends email reviewer change notifications to old and new reviewers', :sidekiq_might_not_need_inline do
+ merge_request.reviewers = [user2]
+
+ perform_enqueued_jobs do
+ update_merge_request({ reviewer_ids: [user3.id] })
+ end
+
+ should_email(user2)
+ should_email(user3)
+ end
end
context 'when the milestone is removed' do
@@ -542,7 +583,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
context 'when the labels change' do
before do
- Timecop.freeze(1.minute.from_now) do
+ travel_to(1.minute.from_now) do
update_merge_request({ label_ids: [label.id] })
end
end
diff --git a/spec/services/metrics/dashboard/custom_dashboard_service_spec.rb b/spec/services/metrics/dashboard/custom_dashboard_service_spec.rb
index aea9c25d104..5dc30c156ac 100644
--- a/spec/services/metrics/dashboard/custom_dashboard_service_spec.rb
+++ b/spec/services/metrics/dashboard/custom_dashboard_service_spec.rb
@@ -67,6 +67,23 @@ RSpec.describe Metrics::Dashboard::CustomDashboardService, :use_clean_rails_memo
.at_least(:once)
end
+ context 'with metric in database' do
+ let!(:prometheus_metric) do
+ create(:prometheus_metric, project: project, identifier: 'metric_a1', group: 'custom')
+ end
+
+ it 'includes metric_id' do
+ dashboard = described_class.new(*service_params).get_dashboard
+
+ metric_id = dashboard[:dashboard][:panel_groups].find { |panel_group| panel_group[:group] == 'Group A' }
+ .fetch(:panels).find { |panel| panel[:title] == 'Super Chart A1' }
+ .fetch(:metrics).find { |metric| metric[:id] == 'metric_a1' }
+ .fetch(:metric_id)
+
+ expect(metric_id).to eq(prometheus_metric.id)
+ end
+ end
+
context 'and the dashboard is then deleted' do
it 'does not return the previously cached dashboard' do
described_class.new(*service_params).get_dashboard
diff --git a/spec/services/milestones/destroy_service_spec.rb b/spec/services/milestones/destroy_service_spec.rb
index 66c5c504c64..dd68471d927 100644
--- a/spec/services/milestones/destroy_service_spec.rb
+++ b/spec/services/milestones/destroy_service_spec.rb
@@ -45,7 +45,7 @@ RSpec.describe Milestones::DestroyService do
let(:group_milestone) { create(:milestone, group: group) }
before do
- project.update(namespace: group)
+ project.update!(namespace: group)
group.add_developer(user)
end
diff --git a/spec/services/milestones/promote_service_spec.rb b/spec/services/milestones/promote_service_spec.rb
index f0a34241c74..8f4201d8d94 100644
--- a/spec/services/milestones/promote_service_spec.rb
+++ b/spec/services/milestones/promote_service_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe Milestones::PromoteService do
end
it 'raises error if project does not belong to a group' do
- project.update(namespace: user.namespace)
+ project.update!(namespace: user.namespace)
expect { service.execute(milestone) }.to raise_error(described_class::PromoteMilestoneError)
end
diff --git a/spec/services/milestones/transfer_service_spec.rb b/spec/services/milestones/transfer_service_spec.rb
index 4a626fe688a..6f4f55b2bd0 100644
--- a/spec/services/milestones/transfer_service_spec.rb
+++ b/spec/services/milestones/transfer_service_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe Milestones::TransferService do
new_group.add_maintainer(user)
project.add_maintainer(user)
# simulate project transfer
- project.update(group: new_group)
+ project.update!(group: new_group)
end
context 'without existing milestone at the new group level' do
diff --git a/spec/services/namespace_settings/update_service_spec.rb b/spec/services/namespace_settings/update_service_spec.rb
new file mode 100644
index 00000000000..2319bdcd4de
--- /dev/null
+++ b/spec/services/namespace_settings/update_service_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe NamespaceSettings::UpdateService do
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:settings) { {} }
+
+ subject(:service) { described_class.new(user, group, settings) }
+
+ describe "#execute" do
+ context "group has no namespace_settings" do
+ it "builds out a new namespace_settings record" do
+ expect do
+ service.execute
+ end.to change { NamespaceSetting.count }.by(1)
+ end
+ end
+
+ context "group has a namespace_settings" do
+ before do
+ create(:namespace_settings, namespace: group)
+
+ service.execute
+ end
+
+ it "doesn't create a new namespace_setting record" do
+ expect do
+ service.execute
+ end.not_to change { NamespaceSetting.count }
+ end
+ end
+ end
+end
diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb
index 7c0d4b756bd..4da9f4115a1 100644
--- a/spec/services/notes/create_service_spec.rb
+++ b/spec/services/notes/create_service_spec.rb
@@ -437,7 +437,7 @@ RSpec.describe Notes::CreateService do
expect do
existing_note
- Timecop.freeze(Time.current + 1.minute) { subject }
+ travel_to(Time.current + 1.minute) { subject }
existing_note.reload
end.to change { existing_note.type }.from(nil).to('DiscussionNote')
diff --git a/spec/services/notes/update_service_spec.rb b/spec/services/notes/update_service_spec.rb
index 47b8ba0cd72..66efdf8abe7 100644
--- a/spec/services/notes/update_service_spec.rb
+++ b/spec/services/notes/update_service_spec.rb
@@ -42,7 +42,7 @@ RSpec.describe Notes::UpdateService do
end
it 'does not update the note when params is blank' do
- Timecop.freeze(1.day.from_now) do
+ travel_to(1.day.from_now) do
expect { update_note({}) }.not_to change { note.reload.updated_at }
end
end
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index 03e24524f9f..473a06c4c8c 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -150,6 +150,16 @@ RSpec.describe NotificationService, :mailer do
end
end
+ shared_examples 'participating by reviewer notification' do
+ it 'emails the participant' do
+ issuable.reviewers << participant
+
+ notification_trigger
+
+ should_email(participant)
+ end
+ end
+
shared_examples_for 'participating notifications' do
it_behaves_like 'participating by note notification'
it_behaves_like 'participating by author notification'
@@ -1778,6 +1788,60 @@ RSpec.describe NotificationService, :mailer do
end
end
+ describe '#changed_reviewer_of_merge_request' do
+ let(:merge_request) { create(:merge_request, author: author, source_project: project, reviewers: [reviewer], description: 'cc @participant') }
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:reviewer) { create(:user) }
+
+ before do
+ update_custom_notification(:change_reviewer_merge_request, @u_guest_custom, resource: project)
+ update_custom_notification(:change_reviewer_merge_request, @u_custom_global)
+ end
+
+ it 'sends emails to relevant users only', :aggregate_failures do
+ notification.changed_reviewer_of_merge_request(merge_request, current_user, [reviewer])
+
+ merge_request.reviewers.each { |reviewer| should_email(reviewer) }
+ should_email(merge_request.author)
+ should_email(@u_watcher)
+ should_email(@u_participant_mentioned)
+ should_email(@subscriber)
+ should_email(@watcher_and_subscriber)
+ should_email(@u_guest_watcher)
+ should_email(@u_guest_custom)
+ should_email(@u_custom_global)
+ should_not_email(@unsubscriber)
+ should_not_email(@u_participating)
+ should_not_email(@u_disabled)
+ should_not_email(@u_lazy_participant)
+ end
+
+ it 'adds "review requested" reason for new reviewer' do
+ notification.changed_reviewer_of_merge_request(merge_request, current_user, [reviewer])
+
+ merge_request.reviewers.each do |assignee|
+ email = find_email_for(assignee)
+
+ expect(email).to have_header('X-GitLab-NotificationReason', NotificationReason::REVIEW_REQUESTED)
+ end
+ end
+
+ context 'participating notifications with reviewers' do
+ let(:participant) { create(:user, username: 'user-participant') }
+ let(:issuable) { merge_request }
+ let(:notification_trigger) { notification.changed_reviewer_of_merge_request(merge_request, current_user, [reviewer]) }
+
+ it_behaves_like 'participating notifications'
+ it_behaves_like 'participating by reviewer notification'
+ end
+
+ it_behaves_like 'project emails are disabled' do
+ let(:notification_target) { merge_request }
+ let(:notification_trigger) { notification.changed_reviewer_of_merge_request(merge_request, current_user, [reviewer]) }
+ end
+ end
+
describe '#push_to_merge_request' do
before do
update_custom_notification(:push_to_merge_request, @u_guest_custom, resource: project)
@@ -3018,32 +3082,25 @@ RSpec.describe NotificationService, :mailer do
describe '#prometheus_alerts_fired' do
let!(:project) { create(:project) }
- let!(:prometheus_alert) { create(:prometheus_alert, project: project) }
let!(:master) { create(:user) }
let!(:developer) { create(:user) }
+ let(:alert_attributes) { build(:alert_management_alert, project: project).attributes }
before do
project.add_maintainer(master)
end
it 'sends the email to owners and masters' do
- expect(Notify).to receive(:prometheus_alert_fired_email).with(project.id, master.id, prometheus_alert).and_call_original
- expect(Notify).to receive(:prometheus_alert_fired_email).with(project.id, project.owner.id, prometheus_alert).and_call_original
- expect(Notify).not_to receive(:prometheus_alert_fired_email).with(project.id, developer.id, prometheus_alert)
+ expect(Notify).to receive(:prometheus_alert_fired_email).with(project.id, master.id, alert_attributes).and_call_original
+ expect(Notify).to receive(:prometheus_alert_fired_email).with(project.id, project.owner.id, alert_attributes).and_call_original
+ expect(Notify).not_to receive(:prometheus_alert_fired_email).with(project.id, developer.id, alert_attributes)
- subject.prometheus_alerts_fired(prometheus_alert.project, [prometheus_alert])
+ subject.prometheus_alerts_fired(project, [alert_attributes])
end
it_behaves_like 'project emails are disabled' do
- before do
- allow_next_instance_of(::Gitlab::Alerting::Alert) do |instance|
- allow(instance).to receive(:valid?).and_return(true)
- end
- end
-
- let(:alert_params) { { 'labels' => { 'gitlab_alert_id' => 'unknown' } } }
- let(:notification_target) { prometheus_alert.project }
- let(:notification_trigger) { subject.prometheus_alerts_fired(prometheus_alert.project, [alert_params]) }
+ let(:notification_target) { project }
+ let(:notification_trigger) { subject.prometheus_alerts_fired(project, [alert_attributes]) }
around do |example|
perform_enqueued_jobs { example.run }
diff --git a/spec/services/packages/create_event_service_spec.rb b/spec/services/packages/create_event_service_spec.rb
new file mode 100644
index 00000000000..7e66b430a8c
--- /dev/null
+++ b/spec/services/packages/create_event_service_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Packages::CreateEventService do
+ let(:scope) { 'container' }
+ let(:event_name) { 'push_package' }
+
+ let(:params) do
+ {
+ scope: scope,
+ event_name: event_name
+ }
+ end
+
+ subject { described_class.new(nil, user, params).execute }
+
+ describe '#execute' do
+ shared_examples 'package event creation' do |originator_type, expected_scope|
+ it 'creates the event' do
+ expect { subject }.to change { Packages::Event.count }.by(1)
+
+ expect(subject.originator_type).to eq(originator_type)
+ expect(subject.originator).to eq(user&.id)
+ expect(subject.event_scope).to eq(expected_scope)
+ expect(subject.event_type).to eq(event_name)
+ end
+ end
+
+ context 'with a user' do
+ let(:user) { create(:user) }
+
+ it_behaves_like 'package event creation', 'user', 'container'
+ end
+
+ context 'with a deploy token' do
+ let(:user) { create(:deploy_token) }
+
+ it_behaves_like 'package event creation', 'deploy_token', 'container'
+ end
+
+ context 'with no user' do
+ let(:user) { nil }
+
+ it_behaves_like 'package event creation', 'guest', 'container'
+ end
+
+ context 'with a package as scope' do
+ let(:user) { nil }
+ let(:scope) { create(:npm_package) }
+
+ it_behaves_like 'package event creation', 'guest', 'npm'
+ end
+ end
+end
diff --git a/spec/services/packages/generic/create_package_file_service_spec.rb b/spec/services/packages/generic/create_package_file_service_spec.rb
new file mode 100644
index 00000000000..0ae109ef996
--- /dev/null
+++ b/spec/services/packages/generic/create_package_file_service_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::Generic::CreatePackageFileService do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+
+ describe '#execute' do
+ let(:sha256) { '440e5e148a25331bbd7991575f7d54933c0ebf6cc735a18ee5066ac1381bb590' }
+ let(:temp_file) { Tempfile.new("test") }
+ let(:file) { UploadedFile.new(temp_file.path, sha256: sha256) }
+ let(:package) { create(:generic_package, project: project) }
+ let(:params) do
+ {
+ package_name: 'mypackage',
+ package_version: '0.0.1',
+ file: file,
+ file_name: 'myfile.tar.gz.1'
+ }
+ end
+
+ before do
+ FileUtils.touch(temp_file)
+ end
+
+ after do
+ FileUtils.rm_f(temp_file)
+ end
+
+ it 'creates package file' do
+ package_service = double
+ package_params = {
+ name: params[:package_name],
+ version: params[:package_version],
+ build: params[:build]
+ }
+ expect(::Packages::Generic::FindOrCreatePackageService).to receive(:new).with(project, user, package_params).and_return(package_service)
+ expect(package_service).to receive(:execute).and_return(package)
+
+ service = described_class.new(project, user, params)
+
+ expect { service.execute }.to change { package.package_files.count }.by(1)
+
+ package_file = package.package_files.last
+ aggregate_failures do
+ expect(package_file.package).to eq(package)
+ expect(package_file.file_name).to eq('myfile.tar.gz.1')
+ expect(package_file.size).to eq(file.size)
+ expect(package_file.file_sha256).to eq(sha256)
+ end
+ end
+ end
+end
diff --git a/spec/services/packages/generic/find_or_create_package_service_spec.rb b/spec/services/packages/generic/find_or_create_package_service_spec.rb
new file mode 100644
index 00000000000..5a9b8b03279
--- /dev/null
+++ b/spec/services/packages/generic/find_or_create_package_service_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::Generic::FindOrCreatePackageService do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:ci_build) { create(:ci_build, :running, user: user) }
+
+ let(:params) do
+ {
+ name: 'mypackage',
+ version: '0.0.1'
+ }
+ end
+
+ describe '#execute' do
+ context 'when packages does not exist yet' do
+ it 'creates package' do
+ service = described_class.new(project, user, params)
+
+ expect { service.execute }.to change { project.packages.generic.count }.by(1)
+
+ package = project.packages.generic.last
+
+ aggregate_failures do
+ expect(package.creator).to eq(user)
+ expect(package.name).to eq('mypackage')
+ expect(package.version).to eq('0.0.1')
+ expect(package.build_info).to be_nil
+ end
+ end
+
+ it 'creates package and package build info when build is provided' do
+ service = described_class.new(project, user, params.merge(build: ci_build))
+
+ expect { service.execute }.to change { project.packages.generic.count }.by(1)
+
+ package = project.packages.generic.last
+
+ aggregate_failures do
+ expect(package.creator).to eq(user)
+ expect(package.name).to eq('mypackage')
+ expect(package.version).to eq('0.0.1')
+ expect(package.build_info.pipeline).to eq(ci_build.pipeline)
+ end
+ end
+ end
+
+ context 'when packages already exists' do
+ let!(:package) { project.packages.generic.create!(params) }
+
+ context 'when package was created manually' do
+ it 'finds the package and does not create package build info even if build is provided' do
+ service = described_class.new(project, user, params.merge(build: ci_build))
+
+ expect do
+ found_package = service.execute
+
+ expect(found_package).to eq(package)
+ end.not_to change { project.packages.generic.count }
+
+ expect(package.reload.build_info).to be_nil
+ end
+ end
+
+ context 'when package was created by pipeline' do
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+
+ before do
+ package.create_build_info!(pipeline: pipeline)
+ end
+
+ it 'finds the package and does not change package build info even if build is provided' do
+ service = described_class.new(project, user, params.merge(build: ci_build))
+
+ expect do
+ found_package = service.execute
+
+ expect(found_package).to eq(package)
+ end.not_to change { project.packages.generic.count }
+
+ expect(package.reload.build_info.pipeline).to eq(pipeline)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/alerting/notify_service_spec.rb b/spec/services/projects/alerting/notify_service_spec.rb
index 77a0e330109..d8e94a0885b 100644
--- a/spec/services/projects/alerting/notify_service_spec.rb
+++ b/spec/services/projects/alerting/notify_service_spec.rb
@@ -89,6 +89,7 @@ RSpec.describe Projects::Alerting::NotifyService do
it 'creates a system note corresponding to alert creation' do
expect { subject }.to change(Note, :count).by(1)
+ expect(Note.last.note).to include(payload_raw.fetch(:monitoring_tool))
end
context 'existing alert with same fingerprint' do
@@ -127,23 +128,8 @@ RSpec.describe Projects::Alerting::NotifyService do
let(:alert) { create(:alert_management_alert, :with_issue, project: project, fingerprint: fingerprint_sha) }
let(:issue) { alert.issue }
- context 'state_tracking is enabled' do
- before do
- stub_feature_flags(track_resource_state_change_events: true)
- end
-
- it { expect { subject }.to change { issue.reload.state }.from('opened').to('closed') }
- it { expect { subject }.to change(ResourceStateEvent, :count).by(1) }
- end
-
- context 'state_tracking is disabled' do
- before do
- stub_feature_flags(track_resource_state_change_events: false)
- end
-
- it { expect { subject }.to change { issue.reload.state }.from('opened').to('closed') }
- it { expect { subject }.to change(Note, :count).by(1) }
- end
+ it { expect { subject }.to change { issue.reload.state }.from('opened').to('closed') }
+ it { expect { subject }.to change(ResourceStateEvent, :count).by(1) }
end
end
end
@@ -208,15 +194,19 @@ RSpec.describe Projects::Alerting::NotifyService do
environment_id: nil
)
end
+
+ it 'creates a system note corresponding to alert creation' do
+ expect { subject }.to change(Note, :count).by(1)
+ expect(Note.last.note).to include('Generic Alert Endpoint')
+ end
end
end
context 'with overlong payload' do
- let(:payload_raw) do
- {
- title: 'a' * Gitlab::Utils::DeepSize::DEFAULT_MAX_SIZE,
- start_time: starts_at.rfc3339
- }
+ let(:deep_size_object) { instance_double(Gitlab::Utils::DeepSize, valid?: false) }
+
+ before do
+ allow(Gitlab::Utils::DeepSize).to receive(:new).and_return(deep_size_object)
end
it_behaves_like 'does not process incident issues due to error', http_status: :bad_request
@@ -230,17 +220,6 @@ RSpec.describe Projects::Alerting::NotifyService do
it_behaves_like 'processes incident issues'
- context 'with an invalid payload' do
- before do
- allow(Gitlab::Alerting::NotificationPayloadParser)
- .to receive(:call)
- .and_raise(Gitlab::Alerting::NotificationPayloadParser::BadPayloadError)
- end
-
- it_behaves_like 'does not process incident issues due to error', http_status: :bad_request
- it_behaves_like 'does not an create alert management alert'
- end
-
context 'when alert already exists' do
let(:fingerprint_sha) { Digest::SHA1.hexdigest(fingerprint) }
let!(:alert) { create(:alert_management_alert, project: project, fingerprint: fingerprint_sha) }
diff --git a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb
index 2c708e75a25..2f2474f2681 100644
--- a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb
@@ -245,7 +245,7 @@ RSpec.describe Projects::ContainerRepository::CleanupTagsService do
end
it 'succeeds without a user' do
- expect_delete(%w(Bb Ba C))
+ expect_delete(%w(Bb Ba C), container_expiration_policy: true)
is_expected.to include(status: :success, deleted: %w(Bb Ba C))
end
@@ -287,10 +287,10 @@ RSpec.describe Projects::ContainerRepository::CleanupTagsService do
end
end
- def expect_delete(tags)
+ def expect_delete(tags, container_expiration_policy: nil)
expect(Projects::ContainerRepository::DeleteTagsService)
.to receive(:new)
- .with(repository.project, user, tags: tags)
+ .with(repository.project, user, tags: tags, container_expiration_policy: container_expiration_policy)
.and_call_original
expect_any_instance_of(Projects::ContainerRepository::DeleteTagsService)
diff --git a/spec/services/projects/container_repository/delete_tags_service_spec.rb b/spec/services/projects/container_repository/delete_tags_service_spec.rb
index 5116427dad2..c3ae26b1f05 100644
--- a/spec/services/projects/container_repository/delete_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/delete_tags_service_spec.rb
@@ -85,6 +85,41 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
end
end
+ RSpec.shared_examples 'supporting fast delete' do
+ context 'when the registry supports fast delete' do
+ before do
+ allow(repository.client).to receive(:supports_tag_delete?).and_return(true)
+ end
+
+ it_behaves_like 'calling the correct delete tags service', ::Projects::ContainerRepository::Gitlab::DeleteTagsService
+
+ it_behaves_like 'handling invalid params'
+
+ context 'with the real service' do
+ before do
+ stub_delete_reference_requests(tags)
+ expect_delete_tag_by_names(tags)
+ end
+
+ it { is_expected.to include(status: :success) }
+
+ it_behaves_like 'logging a success response'
+ end
+
+ context 'with a timeout error' do
+ before do
+ expect_next_instance_of(::Projects::ContainerRepository::Gitlab::DeleteTagsService) do |delete_service|
+ expect(delete_service).to receive(:delete_tags).and_raise(::Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError)
+ end
+ end
+
+ it { is_expected.to include(status: :error, message: 'timeout while deleting tags') }
+
+ it_behaves_like 'logging an error response', message: 'timeout while deleting tags'
+ end
+ end
+ end
+
describe '#execute' do
let(:tags) { %w[A Ba] }
@@ -103,62 +138,7 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
project.add_developer(user)
end
- context 'when the registry supports fast delete' do
- context 'and the feature is enabled' do
- before do
- allow(repository.client).to receive(:supports_tag_delete?).and_return(true)
- end
-
- it_behaves_like 'calling the correct delete tags service', ::Projects::ContainerRepository::Gitlab::DeleteTagsService
-
- it_behaves_like 'handling invalid params'
-
- context 'with the real service' do
- before do
- stub_delete_reference_requests(tags)
- expect_delete_tag_by_names(tags)
- end
-
- it { is_expected.to include(status: :success) }
-
- it_behaves_like 'logging a success response'
- end
-
- context 'with a timeout error' do
- before do
- expect_next_instance_of(::Projects::ContainerRepository::Gitlab::DeleteTagsService) do |delete_service|
- expect(delete_service).to receive(:delete_tags).and_raise(::Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError)
- end
- end
-
- it { is_expected.to include(status: :error, message: 'timeout while deleting tags') }
-
- it_behaves_like 'logging an error response', message: 'timeout while deleting tags'
- end
- end
-
- context 'and the feature is disabled' do
- before do
- stub_feature_flags(container_registry_fast_tag_delete: false)
- end
-
- it_behaves_like 'calling the correct delete tags service', ::Projects::ContainerRepository::ThirdParty::DeleteTagsService
-
- it_behaves_like 'handling invalid params'
-
- context 'with the real service' do
- before do
- stub_upload('sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3')
- tags.each { |tag| stub_put_manifest_request(tag) }
- expect_delete_tag_by_digest('sha256:dummy')
- end
-
- it { is_expected.to include(status: :success) }
-
- it_behaves_like 'logging a success response'
- end
- end
- end
+ it_behaves_like 'supporting fast delete'
context 'when the registry does not support fast delete' do
before do
@@ -170,5 +150,19 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
it_behaves_like 'handling invalid params'
end
end
+
+ context 'without user' do
+ let_it_be(:user) { nil }
+
+ context 'when not run by a cleanup policy' do
+ it { is_expected.to include(status: :error) }
+ end
+
+ context 'when run by a cleanup policy' do
+ let(:params) { { tags: tags, container_expiration_policy: true } }
+
+ it_behaves_like 'supporting fast delete'
+ end
+ end
end
end
diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb
index e1df8700795..b81b3e095cf 100644
--- a/spec/services/projects/create_service_spec.rb
+++ b/spec/services/projects/create_service_spec.rb
@@ -487,18 +487,7 @@ RSpec.describe Projects::CreateService, '#execute' do
describe 'create service for the project' do
subject(:project) { create_project(user, opts) }
- context 'when there is an active instance-level and an active template integration' do
- let!(:template_integration) { create(:prometheus_service, :template, api_url: 'https://prometheus.template.com/') }
- let!(:instance_integration) { create(:prometheus_service, :instance, api_url: 'https://prometheus.instance.com/') }
-
- it 'creates a service from the instance-level integration' do
- expect(project.services.count).to eq(1)
- expect(project.services.first.api_url).to eq(instance_integration.api_url)
- expect(project.services.first.inherit_from_id).to eq(instance_integration.id)
- end
- end
-
- context 'when there is an active service template' do
+ context 'with an active service template' do
let!(:template_integration) { create(:prometheus_service, :template, api_url: 'https://prometheus.template.com/') }
it 'creates a service from the template' do
@@ -506,6 +495,60 @@ RSpec.describe Projects::CreateService, '#execute' do
expect(project.services.first.api_url).to eq(template_integration.api_url)
expect(project.services.first.inherit_from_id).to be_nil
end
+
+ context 'with an active instance-level integration' do
+ let!(:instance_integration) { create(:prometheus_service, :instance, api_url: 'https://prometheus.instance.com/') }
+
+ it 'creates a service from the instance-level integration' do
+ expect(project.services.count).to eq(1)
+ expect(project.services.first.api_url).to eq(instance_integration.api_url)
+ expect(project.services.first.inherit_from_id).to eq(instance_integration.id)
+ end
+
+ context 'with an active group-level integration' do
+ let!(:group_integration) { create(:prometheus_service, group: group, project: nil, api_url: 'https://prometheus.group.com/') }
+ let!(:group) do
+ create(:group).tap do |group|
+ group.add_owner(user)
+ end
+ end
+
+ let(:opts) do
+ {
+ name: 'GitLab',
+ namespace_id: group.id
+ }
+ end
+
+ it 'creates a service from the group-level integration' do
+ expect(project.services.count).to eq(1)
+ expect(project.services.first.api_url).to eq(group_integration.api_url)
+ expect(project.services.first.inherit_from_id).to eq(group_integration.id)
+ end
+
+ context 'with an active subgroup' do
+ let!(:subgroup_integration) { create(:prometheus_service, group: subgroup, project: nil, api_url: 'https://prometheus.subgroup.com/') }
+ let!(:subgroup) do
+ create(:group, parent: group).tap do |subgroup|
+ subgroup.add_owner(user)
+ end
+ end
+
+ let(:opts) do
+ {
+ name: 'GitLab',
+ namespace_id: subgroup.id
+ }
+ end
+
+ it 'creates a service from the subgroup-level integration' do
+ expect(project.services.count).to eq(1)
+ expect(project.services.first.api_url).to eq(subgroup_integration.api_url)
+ expect(project.services.first.inherit_from_id).to eq(subgroup_integration.id)
+ end
+ end
+ end
+ end
end
context 'when there is an invalid integration' do
@@ -739,4 +782,100 @@ RSpec.describe Projects::CreateService, '#execute' do
def create_project(user, opts)
Projects::CreateService.new(user, opts).execute
end
+
+ context 'shared Runners config' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:user) { create :user }
+
+ context 'when parent group is present' do
+ let_it_be(:group) do
+ create(:group) do |group|
+ group.add_owner(user)
+ end
+ end
+
+ before do
+ allow_next_found_instance_of(Group) do |group|
+ allow(group).to receive(:shared_runners_setting).and_return(shared_runners_setting)
+ end
+
+ user.refresh_authorized_projects # Ensure cache is warm
+ end
+
+ context 'default value based on parent group setting' do
+ where(:shared_runners_setting, :desired_config_for_new_project, :expected_result_for_project) do
+ 'enabled' | nil | true
+ 'disabled_with_override' | nil | false
+ 'disabled_and_unoverridable' | nil | false
+ end
+
+ with_them do
+ it 'creates project following the parent config' do
+ params = opts.merge(namespace_id: group.id)
+ params = params.merge(shared_runners_enabled: desired_config_for_new_project) unless desired_config_for_new_project.nil?
+ project = create_project(user, params)
+
+ expect(project).to be_valid
+ expect(project.shared_runners_enabled).to eq(expected_result_for_project)
+ end
+ end
+ end
+
+ context 'parent group is present and allows desired config' do
+ where(:shared_runners_setting, :desired_config_for_new_project, :expected_result_for_project) do
+ 'enabled' | true | true
+ 'enabled' | false | false
+ 'disabled_with_override' | false | false
+ 'disabled_with_override' | true | true
+ 'disabled_and_unoverridable' | false | false
+ end
+
+ with_them do
+ it 'creates project following the parent config' do
+ params = opts.merge(namespace_id: group.id, shared_runners_enabled: desired_config_for_new_project)
+ project = create_project(user, params)
+
+ expect(project).to be_valid
+ expect(project.shared_runners_enabled).to eq(expected_result_for_project)
+ end
+ end
+ end
+
+ context 'parent group is present and disallows desired config' do
+ where(:shared_runners_setting, :desired_config_for_new_project) do
+ 'disabled_and_unoverridable' | true
+ end
+
+ with_them do
+ it 'does not create project' do
+ params = opts.merge(namespace_id: group.id, shared_runners_enabled: desired_config_for_new_project)
+ project = create_project(user, params)
+
+ expect(project.persisted?).to eq(false)
+ expect(project).to be_invalid
+ expect(project.errors[:shared_runners_enabled]).to include('cannot be enabled because parent group does not allow it')
+ end
+ end
+ end
+ end
+
+ context 'parent group is not present' do
+ where(:desired_config, :expected_result) do
+ true | true
+ false | false
+ nil | true
+ end
+
+ with_them do
+ it 'follows desired config' do
+ opts[:shared_runners_enabled] = desired_config unless desired_config.nil?
+ project = create_project(user, opts)
+
+ expect(project).to be_valid
+ expect(project.shared_runners_enabled).to eq(expected_result)
+ end
+ end
+ end
+ end
end
diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb
index a0e83fb4a21..3ae96d7a5ab 100644
--- a/spec/services/projects/transfer_service_spec.rb
+++ b/spec/services/projects/transfer_service_spec.rb
@@ -314,6 +314,37 @@ RSpec.describe Projects::TransferService do
end
end
+ context 'shared Runners group level configurations' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:project_shared_runners_enabled, :shared_runners_setting, :expected_shared_runners_enabled) do
+ true | 'disabled_and_unoverridable' | false
+ false | 'disabled_and_unoverridable' | false
+ true | 'disabled_with_override' | true
+ false | 'disabled_with_override' | false
+ true | 'enabled' | true
+ false | 'enabled' | false
+ end
+
+ with_them do
+ let(:project) { create(:project, :public, :repository, namespace: user.namespace, shared_runners_enabled: project_shared_runners_enabled) }
+ let(:group) { create(:group) }
+
+ before do
+ group.add_owner(user)
+ expect_next_found_instance_of(Group) do |group|
+ expect(group).to receive(:shared_runners_setting).and_return(shared_runners_setting)
+ end
+
+ execute_transfer
+ end
+
+ it 'updates shared runners based on the parent group' do
+ expect(project.shared_runners_enabled).to eq(expected_shared_runners_enabled)
+ end
+ end
+ end
+
context 'missing group labels applied to issues or merge requests' do
it 'delegates transfer to Labels::TransferService' 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 bfb3cbb0131..b6fd72cd4f5 100644
--- a/spec/services/projects/update_pages_service_spec.rb
+++ b/spec/services/projects/update_pages_service_spec.rb
@@ -16,8 +16,6 @@ RSpec.describe Projects::UpdatePagesService do
subject { described_class.new(project, build) }
before do
- stub_feature_flags(safezip_use_rubyzip: true)
-
project.remove_pages
end
@@ -104,10 +102,6 @@ RSpec.describe Projects::UpdatePagesService do
let(:file) { fixture_file_upload("spec/fixtures/pages_non_writeable.zip") }
context 'when using RubyZip' do
- before do
- stub_feature_flags(safezip_use_rubyzip: true)
- end
-
it 'succeeds to extract' do
expect(execute).to eq(:success)
expect(project.pages_metadatum).to be_deployed
diff --git a/spec/services/projects/update_remote_mirror_service_spec.rb b/spec/services/projects/update_remote_mirror_service_spec.rb
index 1de04888e0a..f0e76bb81b3 100644
--- a/spec/services/projects/update_remote_mirror_service_spec.rb
+++ b/spec/services/projects/update_remote_mirror_service_spec.rb
@@ -68,25 +68,12 @@ RSpec.describe Projects::UpdateRemoteMirrorService do
end
context "when given URLs containing escaped elements" do
- using RSpec::Parameterized::TableSyntax
+ it_behaves_like "URLs containing escaped elements return expected status" do
+ let(:result) { execute! }
- where(:url, :result_status) do
- "https://user:0a%23@test.example.com/project.git" | :success
- "https://git.example.com:1%2F%2F@source.developers.google.com/project.git" | :success
- CGI.escape("git://localhost:1234/some-path?some-query=some-val\#@example.com/") | :error
- CGI.escape(CGI.escape("https://user:0a%23@test.example.com/project.git")) | :error
- end
-
- with_them do
before do
allow(remote_mirror).to receive(:url).and_return(url)
end
-
- it "returns expected status" do
- result = execute!
-
- expect(result[:status]).to eq(result_status)
- end
end
end
diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb
index 7832d727220..3375d9762c8 100644
--- a/spec/services/projects/update_service_spec.rb
+++ b/spec/services/projects/update_service_spec.rb
@@ -151,6 +151,32 @@ RSpec.describe Projects::UpdateService do
expect(project.reload).to be_internal
end
end
+
+ context 'when updating shared runners' do
+ context 'can enable shared runners' do
+ let(:group) { create(:group, shared_runners_enabled: true) }
+ let(:project) { create(:project, namespace: group, shared_runners_enabled: false) }
+
+ it 'enables shared runners' do
+ result = update_project(project, user, shared_runners_enabled: true)
+
+ expect(result).to eq({ status: :success })
+ expect(project.reload.shared_runners_enabled).to be_truthy
+ end
+ end
+
+ context 'cannot enable shared runners' do
+ let(:group) { create(:group, :shared_runners_disabled) }
+ let(:project) { create(:project, namespace: group, shared_runners_enabled: false) }
+
+ it 'does not enable shared runners' do
+ result = update_project(project, user, shared_runners_enabled: true)
+
+ expect(result).to eq({ status: :error, message: 'Shared runners enabled cannot be enabled because parent group does not allow it' })
+ expect(project.reload.shared_runners_enabled).to be_falsey
+ end
+ end
+ end
end
describe 'when updating project that has forks' do
diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb
index b970a48051f..8f0c60d5d65 100644
--- a/spec/services/quick_actions/interpret_service_spec.rb
+++ b/spec/services/quick_actions/interpret_service_spec.rb
@@ -3,23 +3,27 @@
require 'spec_helper'
RSpec.describe QuickActions::InterpretService do
- let(:project) { create(:project, :public) }
- let(:developer) { create(:user) }
- let(:developer2) { create(:user) }
- let(:issue) { create(:issue, project: project) }
+ let_it_be(:public_project) { create(:project, :public) }
+ let_it_be(:repository_project) { create(:project, :repository) }
+ let_it_be(:project) { public_project }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:developer2) { create(:user) }
+ let_it_be_with_reload(:issue) { create(:issue, project: project) }
let(:milestone) { create(:milestone, project: project, title: '9.10') }
let(:commit) { create(:commit, project: project) }
- let(:inprogress) { create(:label, project: project, title: 'In Progress') }
- let(:helmchart) { create(:label, project: project, title: 'Helm Chart Registry') }
- let(:bug) { create(:label, project: project, title: 'Bug') }
- let(:note) { build(:note, commit_id: merge_request.diff_head_sha) }
+ let_it_be(:inprogress) { create(:label, project: project, title: 'In Progress') }
+ let_it_be(:helmchart) { create(:label, project: project, title: 'Helm Chart Registry') }
+ let_it_be(:bug) { create(:label, project: project, title: 'Bug') }
let(:service) { described_class.new(project, developer) }
+ before_all do
+ public_project.add_developer(developer)
+ repository_project.add_developer(developer)
+ end
+
before do
stub_licensed_features(multiple_issue_assignees: false,
multiple_merge_request_assignees: false)
-
- project.add_developer(developer)
end
describe '#execute' do
@@ -146,7 +150,6 @@ RSpec.describe QuickActions::InterpretService do
shared_examples 'multiword label name starting without ~' do
it 'fetches label ids and populates add_label_ids if content contains /label' do
- helmchart # populate the label
_, updates = service.execute(content, issuable)
expect(updates).to eq(add_label_ids: [helmchart.id])
@@ -155,7 +158,6 @@ RSpec.describe QuickActions::InterpretService do
shared_examples 'label name is included in the middle of another label name' do
it 'ignores the sublabel when the content contains the includer label name' do
- helmchart # populate the label
create(:label, project: project, title: 'Chart')
_, updates = service.execute(content, issuable)
@@ -493,7 +495,7 @@ RSpec.describe QuickActions::InterpretService do
end
shared_examples 'merge immediately command' do
- let(:project) { create(:project, :repository) }
+ let(:project) { repository_project }
it 'runs merge command if content contains /merge' do
_, updates, _ = service.execute(content, issuable)
@@ -509,7 +511,7 @@ RSpec.describe QuickActions::InterpretService do
end
shared_examples 'merge automatically command' do
- let(:project) { create(:project, :repository) }
+ let(:project) { repository_project }
it 'runs merge command if content contains /merge and returns merge message' do
_, updates, message = service.execute(content, issuable)
@@ -600,7 +602,7 @@ RSpec.describe QuickActions::InterpretService do
context 'when issuable is already confidential' do
before do
- issuable.update(confidential: true)
+ issuable.update!(confidential: true)
end
it 'does not return the success message' do
@@ -722,7 +724,7 @@ RSpec.describe QuickActions::InterpretService do
end
context 'when sha is missing' do
- let(:project) { create(:project, :repository) }
+ let(:project) { repository_project }
let(:service) { described_class.new(project, developer, {}) }
it 'precheck passes and returns merge command' do
@@ -844,7 +846,7 @@ RSpec.describe QuickActions::InterpretService do
end
it 'returns the unassign message for all the assignee if content contains /unassign' do
- issue.update(assignee_ids: [developer.id, developer2.id])
+ issue.update!(assignee_ids: [developer.id, developer2.id])
_, _, message = service.execute(content, issue)
expect(message).to eq("Removed assignees #{developer.to_reference} and #{developer2.to_reference}.")
@@ -860,7 +862,7 @@ RSpec.describe QuickActions::InterpretService do
end
it 'returns the unassign message for all the assignee if content contains /unassign' do
- merge_request.update(assignee_ids: [developer.id, developer2.id])
+ merge_request.update!(assignee_ids: [developer.id, developer2.id])
_, _, message = service.execute(content, merge_request)
expect(message).to eq("Removed assignees #{developer.to_reference} and #{developer2.to_reference}.")
@@ -879,10 +881,14 @@ RSpec.describe QuickActions::InterpretService do
end
context 'only group milestones available' do
- let(:ancestor_group) { create(:group) }
- let(:group) { create(:group, parent: ancestor_group) }
- let(:project) { create(:project, :public, namespace: group) }
- let(:milestone) { create(:milestone, group: ancestor_group, title: '10.0') }
+ let_it_be(:ancestor_group) { create(:group) }
+ let_it_be(:group) { create(:group, parent: ancestor_group) }
+ let_it_be(:project) { create(:project, :public, namespace: group) }
+ let_it_be(:milestone) { create(:milestone, group: ancestor_group, title: '10.0') }
+
+ before_all do
+ project.add_developer(developer)
+ end
it_behaves_like 'milestone command' do
let(:content) { "/milestone %#{milestone.title}" }
@@ -1457,14 +1463,14 @@ RSpec.describe QuickActions::InterpretService do
end
context '/board_move command' do
- let(:todo) { create(:label, project: project, title: 'To Do') }
- let(:inreview) { create(:label, project: project, title: 'In Review') }
+ let_it_be(:todo) { create(:label, project: project, title: 'To Do') }
+ let_it_be(:inreview) { create(:label, project: project, title: 'In Review') }
let(:content) { %{/board_move ~"#{inreview.title}"} }
- let!(:board) { create(:board, project: project) }
- let!(:todo_list) { create(:list, board: board, label: todo) }
- let!(:inreview_list) { create(:list, board: board, label: inreview) }
- let!(:inprogress_list) { create(:list, board: board, label: inprogress) }
+ let_it_be(:board) { create(:board, project: project) }
+ let_it_be(:todo_list) { create(:list, board: board, label: todo) }
+ let_it_be(:inreview_list) { create(:list, board: board, label: inreview) }
+ let_it_be(:inprogress_list) { create(:list, board: board, label: inprogress) }
it 'populates remove_label_ids for all current board columns' do
issue.update!(label_ids: [todo.id, inprogress.id])
@@ -1599,6 +1605,10 @@ RSpec.describe QuickActions::InterpretService do
context "when logged user cannot create_merge_requests in the project" do
let(:project) { create(:project, :archived) }
+ before do
+ project.add_developer(developer)
+ end
+
it_behaves_like 'empty command'
end
@@ -1844,8 +1854,7 @@ RSpec.describe QuickActions::InterpretService do
end
describe 'relabel command' do
- let(:content) { '/relabel Bug' }
- let!(:bug) { create(:label, project: project, title: 'Bug') }
+ let(:content) { "/relabel #{bug.title}" }
let(:feature) { create(:label, project: project, title: 'Feature') }
it 'includes label name' do
@@ -1938,8 +1947,7 @@ RSpec.describe QuickActions::InterpretService do
end
describe 'board move command' do
- let(:content) { '/board_move ~bug' }
- let!(:bug) { create(:label, project: project, title: 'bug') }
+ let(:content) { "/board_move ~#{bug.title}" }
let!(:board) { create(:board, project: project) }
it 'includes the label name' do
diff --git a/spec/services/resource_access_tokens/create_service_spec.rb b/spec/services/resource_access_tokens/create_service_spec.rb
index 7dbd55a6909..4a77e5fed9b 100644
--- a/spec/services/resource_access_tokens/create_service_spec.rb
+++ b/spec/services/resource_access_tokens/create_service_spec.rb
@@ -24,16 +24,6 @@ RSpec.describe ResourceAccessTokens::CreateService do
end
end
- shared_examples 'fails when flag is disabled' do
- before do
- stub_feature_flags(resource_access_token: false)
- end
-
- it 'returns nil' do
- expect(subject).to be nil
- end
- end
-
shared_examples 'fails on gitlab.com' do
before do
allow(Gitlab).to receive(:com?) { true }
@@ -53,6 +43,7 @@ RSpec.describe ResourceAccessTokens::CreateService do
access_token = response.payload[:access_token]
expect(access_token.user.reload.user_type).to eq("#{resource_type}_bot")
+ expect(access_token.user.created_by_id).to eq(user.id)
end
context 'email confirmation status' do
@@ -180,7 +171,6 @@ RSpec.describe ResourceAccessTokens::CreateService do
let_it_be(:resource) { project }
it_behaves_like 'fails when user does not have the permission to create a Resource Bot'
- it_behaves_like 'fails when flag is disabled'
it_behaves_like 'fails on gitlab.com'
context 'user with valid permission' do
diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb
index f6bb7acee57..fc613a6224a 100644
--- a/spec/services/search_service_spec.rb
+++ b/spec/services/search_service_spec.rb
@@ -444,7 +444,7 @@ RSpec.describe SearchService do
context 'with :with_api_entity_associations' do
let(:unredacted_results) { ar_relation(MergeRequest.with_api_entity_associations, readable, unreadable) }
- it_behaves_like "redaction limits N+1 queries", limit: 7
+ it_behaves_like "redaction limits N+1 queries", limit: 8
end
end
@@ -481,7 +481,7 @@ RSpec.describe SearchService do
end
context 'with :with_api_entity_associations' do
- it_behaves_like "redaction limits N+1 queries", limit: 12
+ it_behaves_like "redaction limits N+1 queries", limit: 13
end
end
@@ -496,7 +496,7 @@ RSpec.describe SearchService do
end
context 'with :with_api_entity_associations' do
- it_behaves_like "redaction limits N+1 queries", limit: 3
+ it_behaves_like "redaction limits N+1 queries", limit: 4
end
end
diff --git a/spec/services/snippets/update_service_spec.rb b/spec/services/snippets/update_service_spec.rb
index 641fc56294a..406ece30bd7 100644
--- a/spec/services/snippets/update_service_spec.rb
+++ b/spec/services/snippets/update_service_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Snippets::UpdateService do
- describe '#execute' do
+ describe '#execute', :aggregate_failures do
let_it_be(:user) { create(:user) }
let_it_be(:admin) { create :user, admin: true }
let(:visibility_level) { Gitlab::VisibilityLevel::PRIVATE }
@@ -97,40 +97,81 @@ RSpec.describe Snippets::UpdateService do
end
shared_examples 'creates repository and creates file' do
- it 'creates repository' do
- expect(snippet.repository).not_to exist
+ context 'when file_name and content params are used' do
+ it 'creates repository' do
+ expect(snippet.repository).not_to exist
- subject
+ subject
- expect(snippet.repository).to exist
- end
+ expect(snippet.repository).to exist
+ end
- it 'commits the files to the repository' do
- subject
+ it 'commits the files to the repository' do
+ subject
- expect(snippet.blobs.count).to eq 1
+ expect(snippet.blobs.count).to eq 1
- blob = snippet.repository.blob_at('master', options[:file_name])
+ blob = snippet.repository.blob_at('master', options[:file_name])
- expect(blob.data).to eq options[:content]
+ expect(blob.data).to eq options[:content]
+ end
+
+ context 'when the repository creation fails' do
+ before do
+ allow(snippet).to receive(:repository_exists?).and_return(false)
+ end
+
+ it 'raise an error' do
+ expect(subject).to be_error
+ expect(subject.payload[:snippet].errors[:repository].to_sentence).to eq 'Error updating the snippet - Repository could not be created'
+ end
+
+ it 'does not try to commit file' do
+ expect(service).not_to receive(:create_commit)
+
+ subject
+ end
+ end
end
- context 'when the repository creation fails' do
- before do
- allow(snippet).to receive(:repository_exists?).and_return(false)
+ context 'when snippet_actions param is used' do
+ let(:file_path) { 'CHANGELOG' }
+ let(:created_file_path) { 'New file'}
+ let(:content) { 'foobar' }
+ let(:snippet_actions) { [{ action: :move, previous_path: snippet.file_name, file_path: file_path }, { action: :create, file_path: created_file_path, content: content }] }
+ let(:base_opts) do
+ {
+ snippet_actions: snippet_actions
+ }
end
- it 'raise an error' do
- response = subject
+ it 'performs operation without raising errors' do
+ db_content = snippet.content
- expect(response).to be_error
- expect(response.payload[:snippet].errors[:repository].to_sentence).to eq 'Error updating the snippet - Repository could not be created'
+ expect(subject).to be_success
+
+ new_blob = snippet.repository.blob_at('master', file_path)
+ created_file = snippet.repository.blob_at('master', created_file_path)
+
+ expect(new_blob.data).to eq db_content
+ expect(created_file.data).to eq content
end
- it 'does not try to commit file' do
- expect(service).not_to receive(:create_commit)
+ context 'when the repository is not created' do
+ it 'keeps snippet database data' do
+ old_file_name = snippet.file_name
+ old_file_content = snippet.content
- subject
+ expect_next_instance_of(described_class) do |instance|
+ expect(instance).to receive(:create_repository_for).and_raise(StandardError)
+ end
+
+ snippet = subject.payload[:snippet]
+
+ expect(subject).to be_error
+ expect(snippet.file_name).to eq(old_file_name)
+ expect(snippet.content).to eq(old_file_content)
+ end
end
end
end
@@ -366,10 +407,9 @@ RSpec.describe Snippets::UpdateService do
let(:snippet_actions) { [{ action: 'invalid_action' }] }
it 'raises a validation error' do
- response = subject
- snippet = response.payload[:snippet]
+ snippet = subject.payload[:snippet]
- expect(response).to be_error
+ expect(subject).to be_error
expect(snippet.errors.full_messages_for(:snippet_actions)).to eq ['Snippet actions have invalid data']
end
end
@@ -377,13 +417,12 @@ RSpec.describe Snippets::UpdateService do
context 'when an error is raised committing the file' do
it 'keeps any snippet modifications' do
expect_next_instance_of(described_class) do |instance|
- expect(instance).to receive(:create_repository_for).and_raise(StandardError)
+ expect(instance).to receive(:create_commit).and_raise(StandardError)
end
- response = subject
- snippet = response.payload[:snippet]
+ snippet = subject.payload[:snippet]
- expect(response).to be_error
+ expect(subject).to be_error
expect(snippet.title).to eq(new_title)
expect(snippet.file_name).to eq(file_path)
expect(snippet.content).to eq(content)
diff --git a/spec/services/static_site_editor/config_service_spec.rb b/spec/services/static_site_editor/config_service_spec.rb
index 5fff4e0af53..fed373828a1 100644
--- a/spec/services/static_site_editor/config_service_spec.rb
+++ b/spec/services/static_site_editor/config_service_spec.rb
@@ -7,8 +7,8 @@ RSpec.describe StaticSiteEditor::ConfigService do
let_it_be(:user) { create(:user) }
# params
- let(:ref) { double(:ref) }
- let(:path) { double(:path) }
+ let(:ref) { 'master' }
+ let(:path) { 'README.md' }
let(:return_url) { double(:return_url) }
# stub data
@@ -42,22 +42,84 @@ RSpec.describe StaticSiteEditor::ConfigService do
allow_next_instance_of(Gitlab::StaticSiteEditor::Config::GeneratedConfig) do |config|
allow(config).to receive(:data) { generated_data }
end
+ end
+
+ context 'when reading file from repo fails with an unexpected error' do
+ let(:unexpected_error) { RuntimeError.new('some unexpected error') }
- allow_next_instance_of(Gitlab::StaticSiteEditor::Config::FileConfig) do |config|
- allow(config).to receive(:data) { file_data }
+ before do
+ allow(project.repository).to receive(:blob_data_at).and_raise(unexpected_error)
+ end
+
+ it 'returns an error response' do
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_exception).with(unexpected_error).and_call_original
+ expect { execute }.to raise_error(unexpected_error)
end
end
- it 'returns merged generated data and config file data' do
- expect(execute).to be_success
- expect(execute.payload).to eq(generated: true, file: true)
+ context 'when file is missing' do
+ before do
+ allow(project.repository).to receive(:blob_data_at).and_raise(GRPC::NotFound)
+ expect_next_instance_of(Gitlab::StaticSiteEditor::Config::FileConfig, '{}') do |config|
+ allow(config).to receive(:valid?) { true }
+ allow(config).to receive(:to_hash_with_defaults) { file_data }
+ end
+ end
+
+ it 'returns default config' do
+ expect(execute).to be_success
+ expect(execute.payload).to eq(generated: true, file: true)
+ end
end
- it 'returns an error if any keys would be overwritten by the merge' do
- generated_data[:duplicate_key] = true
- file_data[:duplicate_key] = true
- expect(execute).to be_error
- expect(execute.message).to match(/duplicate key.*duplicate_key.*found/i)
+ context 'when file is present' do
+ before do
+ allow(project.repository).to receive(:blob_data_at).with(ref, anything) do
+ config_content
+ end
+ end
+
+ context 'and configuration is not valid' do
+ let(:config_content) { 'invalid content' }
+
+ before do
+ expect_next_instance_of(Gitlab::StaticSiteEditor::Config::FileConfig, config_content) do |config|
+ error = 'error'
+ allow(config).to receive_message_chain('errors.first') { error }
+ allow(config).to receive(:valid?) { false }
+ end
+ end
+
+ it 'returns an error' do
+ expect(execute).to be_error
+ expect(execute.message).to eq('Invalid configuration format')
+ end
+ end
+
+ context 'and configuration is valid' do
+ # NOTE: This has to be a valid config, even though it is mocked, because
+ # `expect_next_instance_of` executes the constructor logic.
+ let(:config_content) { 'static_site_generator: middleman' }
+
+ before do
+ expect_next_instance_of(Gitlab::StaticSiteEditor::Config::FileConfig, config_content) do |config|
+ allow(config).to receive(:valid?) { true }
+ allow(config).to receive(:to_hash_with_defaults) { file_data }
+ end
+ end
+
+ it 'returns merged generated data and config file data' do
+ expect(execute).to be_success
+ expect(execute.payload).to eq(generated: true, file: true)
+ end
+
+ it 'returns an error if any keys would be overwritten by the merge' do
+ generated_data[:duplicate_key] = true
+ file_data[:duplicate_key] = true
+ expect(execute).to be_error
+ expect(execute.message).to match(/duplicate key.*duplicate_key.*found/i)
+ end
+ end
end
end
end
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index 47b8621b5c9..42e48b9ad81 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -64,6 +64,18 @@ RSpec.describe SystemNoteService do
end
end
+ describe '.change_issuable_reviewers' do
+ let(:reviewers) { [double, double] }
+
+ it 'calls IssuableService' do
+ expect_next_instance_of(::SystemNotes::IssuablesService) do |service|
+ expect(service).to receive(:change_issuable_reviewers).with(reviewers)
+ end
+
+ described_class.change_issuable_reviewers(noteable, project, author, reviewers)
+ end
+ end
+
describe '.close_after_error_tracking_resolve' do
it 'calls IssuableService' do
expect_next_instance_of(::SystemNotes::IssuablesService) do |service|
@@ -741,4 +753,16 @@ RSpec.describe SystemNoteService do
described_class.create_new_alert(alert, monitoring_tool)
end
end
+
+ describe '.change_incident_severity' do
+ let(:incident) { build(:incident) }
+
+ it 'calls IncidentService' do
+ expect_next_instance_of(SystemNotes::IncidentService) do |service|
+ expect(service).to receive(:change_incident_severity)
+ end
+
+ described_class.change_incident_severity(incident, author)
+ end
+ end
end
diff --git a/spec/services/system_notes/incident_service_spec.rb b/spec/services/system_notes/incident_service_spec.rb
new file mode 100644
index 00000000000..ab9b9eb2bd4
--- /dev/null
+++ b/spec/services/system_notes/incident_service_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::SystemNotes::IncidentService do
+ let_it_be(:author) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:noteable) { create(:incident, project: project) }
+ let_it_be(:issuable_severity) { create(:issuable_severity, issue: noteable, severity: :medium) }
+
+ describe '#change_incident_severity' do
+ subject(:change_severity) { described_class.new(noteable: noteable, project: project, author: author).change_incident_severity }
+
+ before do
+ allow(Gitlab::AppLogger).to receive(:error).and_call_original
+ end
+
+ it_behaves_like 'a system note' do
+ let(:action) { 'severity' }
+ end
+
+ IssuableSeverity.severities.keys.each do |severity|
+ context "with #{severity} severity" do
+ before do
+ issuable_severity.update!(severity: severity)
+ end
+
+ it 'has the appropriate message' do
+ severity_label = IssuableSeverity::SEVERITY_LABELS.fetch(severity.to_sym)
+
+ expect(change_severity.note).to eq("changed the severity to **#{severity_label}**")
+ end
+ end
+ end
+
+ context 'when severity is invalid' do
+ let(:invalid_severity) { 'invalid-severity' }
+
+ before do
+ allow(noteable).to receive(:severity).and_return(invalid_severity)
+ end
+
+ it 'does not create system note' do
+ expect { change_severity }.not_to change { noteable.notes.count }
+ end
+
+ it 'writes error to logs' do
+ change_severity
+
+ expect(Gitlab::AppLogger).to have_received(:error).with(
+ message: 'Cannot create a system note for severity change',
+ noteable_class: noteable.class.to_s,
+ noteable_id: noteable.id,
+ severity: invalid_severity
+ )
+ end
+ end
+ end
+end
diff --git a/spec/services/system_notes/issuables_service_spec.rb b/spec/services/system_notes/issuables_service_spec.rb
index fec2a711dc2..e78b00fb67a 100644
--- a/spec/services/system_notes/issuables_service_spec.rb
+++ b/spec/services/system_notes/issuables_service_spec.rb
@@ -128,49 +128,76 @@ RSpec.describe ::SystemNotes::IssuablesService do
end
end
- describe '#change_status' do
- subject { service.change_status(status, source) }
+ describe '#change_issuable_reviewers' do
+ subject { service.change_issuable_reviewers([reviewer]) }
- context 'when resource state event tracking is enabled' do
- let(:status) { 'reopened' }
- let(:source) { nil }
+ let_it_be(:noteable) { create(:merge_request, :simple, source_project: project) }
+ let_it_be(:reviewer) { create(:user) }
+ let_it_be(:reviewer1) { create(:user) }
+ let_it_be(:reviewer2) { create(:user) }
+ let_it_be(:reviewer3) { create(:user) }
- it 'does not change note count' do
- expect { subject }.not_to change { Note.count }
- end
+ it_behaves_like 'a system note' do
+ let(:action) { 'reviewer' }
end
- context 'with status reopened' do
- before do
- stub_feature_flags(track_resource_state_change_events: false)
- end
+ def build_note(old_reviewers, new_reviewers)
+ noteable.reviewers = new_reviewers
+ service.change_issuable_reviewers(old_reviewers).note
+ end
- let(:status) { 'reopened' }
- let(:source) { nil }
+ it 'builds a correct phrase when a reviewer is added to a non-assigned merge request' do
+ expect(build_note([], [reviewer1])).to eq "requested review from @#{reviewer1.username}"
+ end
- it_behaves_like 'a note with overridable created_at'
+ it 'builds a correct phrase when reviewer is removed' do
+ expect(build_note([reviewer], [])).to eq "removed review request for @#{reviewer.username}"
+ end
- it_behaves_like 'a system note' do
- let(:action) { 'opened' }
- end
+ it 'builds a correct phrase when reviewers changed' do
+ expect(build_note([reviewer1], [reviewer2])).to(
+ eq("requested review from @#{reviewer2.username} and removed review request for @#{reviewer1.username}")
+ )
end
- context 'with a source' do
- before do
- stub_feature_flags(track_resource_state_change_events: false)
- end
+ it 'builds a correct phrase when three reviewers removed and one added' do
+ expect(build_note([reviewer, reviewer1, reviewer2], [reviewer3])).to(
+ eq("requested review from @#{reviewer3.username} and removed review request for @#{reviewer.username}, @#{reviewer1.username}, and @#{reviewer2.username}")
+ )
+ end
- let(:status) { 'opened' }
- let(:source) { double('commit', gfm_reference: 'commit 123456') }
+ it 'builds a correct phrase when one reviewer is changed from a set' do
+ expect(build_note([reviewer, reviewer1], [reviewer, reviewer2])).to(
+ eq("requested review from @#{reviewer2.username} and removed review request for @#{reviewer1.username}")
+ )
+ end
- it_behaves_like 'a note with overridable created_at'
+ it 'builds a correct phrase when one reviewer removed from a set' do
+ expect(build_note([reviewer, reviewer1, reviewer2], [reviewer, reviewer1])).to(
+ eq( "removed review request for @#{reviewer2.username}")
+ )
+ end
- it 'sets the note text' do
- expect(subject.note).to eq "#{status} via commit 123456"
+ it 'builds a correct phrase when the locale is different' do
+ Gitlab::I18n.with_locale('pt-BR') do
+ expect(build_note([reviewer, reviewer1, reviewer2], [reviewer3])).to(
+ eq("requested review from @#{reviewer3.username} and removed review request for @#{reviewer.username}, @#{reviewer1.username}, and @#{reviewer2.username}")
+ )
end
end
end
+ describe '#change_status' do
+ subject { service.change_status(status, source) }
+
+ let(:status) { 'reopened' }
+ let(:source) { nil }
+
+ it 'creates a resource state event' do
+ expect { subject }.to change { ResourceStateEvent.count }.by(1)
+ end
+ end
+
describe '#change_title' do
let(:noteable) { create(:issue, project: project, title: 'Lorem ipsum') }
@@ -636,67 +663,26 @@ RSpec.describe ::SystemNotes::IssuablesService do
describe '#close_after_error_tracking_resolve' do
subject { service.close_after_error_tracking_resolve }
- context 'when state tracking is enabled' do
- before do
- stub_feature_flags(track_resource_state_change_events: true)
- end
-
- it 'creates the expected state event' do
- subject
-
- event = ResourceStateEvent.last
-
- expect(event.close_after_error_tracking_resolve).to eq(true)
- expect(event.state).to eq('closed')
- end
- end
+ it 'creates the expected state event' do
+ subject
- context 'when state tracking is disabled' do
- before do
- stub_feature_flags(track_resource_state_change_events: false)
- end
+ event = ResourceStateEvent.last
- it_behaves_like 'a system note' do
- let(:action) { 'closed' }
- end
-
- it 'creates the expected system note' do
- expect(subject.note)
- .to eq('resolved the corresponding error and closed the issue.')
- end
+ expect(event.close_after_error_tracking_resolve).to eq(true)
+ expect(event.state).to eq('closed')
end
end
describe '#auto_resolve_prometheus_alert' do
subject { service.auto_resolve_prometheus_alert }
- context 'when state tracking is enabled' do
- before do
- stub_feature_flags(track_resource_state_change_events: true)
- end
+ it 'creates the expected state event' do
+ subject
- it 'creates the expected state event' do
- subject
+ event = ResourceStateEvent.last
- event = ResourceStateEvent.last
-
- expect(event.close_auto_resolve_prometheus_alert).to eq(true)
- expect(event.state).to eq('closed')
- end
- end
-
- context 'when state tracking is disabled' do
- before do
- stub_feature_flags(track_resource_state_change_events: false)
- end
-
- it_behaves_like 'a system note' do
- let(:action) { 'closed' }
- end
-
- it 'creates the expected system note' do
- expect(subject.note).to eq('automatically closed this issue because the alert resolved.')
- end
+ expect(event.close_auto_resolve_prometheus_alert).to eq(true)
+ expect(event.state).to eq('closed')
end
end
end
diff --git a/spec/services/users/build_service_spec.rb b/spec/services/users/build_service_spec.rb
index c14fdb35bfa..b445b5b81b1 100644
--- a/spec/services/users/build_service_spec.rb
+++ b/spec/services/users/build_service_spec.rb
@@ -16,6 +16,10 @@ RSpec.describe Users::BuildService do
expect(service.execute).to be_valid
end
+ it 'sets the created_by_id' do
+ expect(service.execute.created_by_id).to eq(admin_user.id)
+ end
+
context 'calls the UpdateCanonicalEmailService' do
specify do
expect(Users::UpdateCanonicalEmailService).to receive(:new).and_call_original
@@ -128,6 +132,16 @@ RSpec.describe Users::BuildService do
it 'raises AccessDeniedError exception' do
expect { service.execute }.to raise_error Gitlab::Access::AccessDeniedError
end
+
+ context 'when authorization is skipped' do
+ subject(:built_user) { service.execute(skip_authorization: true) }
+
+ it { is_expected.to be_valid }
+
+ it 'sets the created_by_id' do
+ expect(built_user.created_by_id).to eq(user.id)
+ end
+ end
end
context 'with nil user' do
diff --git a/spec/simplecov_env.rb b/spec/simplecov_env.rb
index 17b76205d9e..617a45ae449 100644
--- a/spec/simplecov_env.rb
+++ b/spec/simplecov_env.rb
@@ -2,7 +2,6 @@
require 'simplecov'
require 'simplecov-cobertura'
-require 'active_support/core_ext/numeric/time'
require_relative '../lib/gitlab/utils'
module SimpleCovEnv
@@ -75,7 +74,7 @@ module SimpleCovEnv
add_group 'Libraries', %w[/lib /ee/lib]
add_group 'Tooling', %w[/haml_lint /rubocop /tooling]
- merge_timeout 365.days
+ merge_timeout 365 * 24 * 3600
end
end
end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 11a83bd9501..2962225859d 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -66,7 +66,11 @@ RSpec.configure do |config|
config.display_try_failure_messages = true
config.infer_spec_type_from_file_location!
- config.full_backtrace = !!ENV['CI']
+
+ # Add :full_backtrace tag to an example if full_backtrace output is desired
+ config.before(:each, full_backtrace: true) do |example|
+ config.full_backtrace = true
+ end
unless ENV['CI']
# Re-run failures locally with `--only-failures`
@@ -124,6 +128,7 @@ RSpec.configure do |config|
config.include LoginHelpers, type: :feature
config.include SearchHelpers, type: :feature
config.include WaitHelpers, type: :feature
+ config.include WaitForRequests, type: :feature
config.include EmailHelpers, :mailer, type: :mailer
config.include Warden::Test::Helpers, type: :request
config.include Gitlab::Routing, type: :routing
@@ -133,7 +138,6 @@ RSpec.configure do |config|
config.include InputHelper, :js
config.include SelectionHelper, :js
config.include InspectRequests, :js
- config.include WaitForRequests, :js
config.include LiveDebugger, :js
config.include MigrationsHelpers, :migration
config.include RedisHelpers
@@ -233,6 +237,12 @@ RSpec.configure do |config|
# expect(Gitlab::Git::KeepAround).to receive(:execute).and_call_original
allow(Gitlab::Git::KeepAround).to receive(:execute)
+ # Stub these calls due to being expensive operations
+ # It can be reenabled for specific tests via:
+ #
+ # expect(Gitlab::JobWaiter).to receive(:wait).and_call_original
+ allow_any_instance_of(Gitlab::JobWaiter).to receive(:wait)
+
Gitlab::ProcessMemoryCache.cache_backend.clear
Sidekiq::Worker.clear_all
diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb
index 66fce4fddf1..27c360ee39a 100644
--- a/spec/support/capybara.rb
+++ b/spec/support/capybara.rb
@@ -123,6 +123,10 @@ RSpec.configure do |config|
port: session.server.port,
protocol: 'http')
+ # CSRF protection is disabled by default. We only enable this for JS specs because some forms
+ # require Javascript to set the CSRF token.
+ allow_any_instance_of(ActionController::Base).to receive(:protect_against_forgery?).and_return(true)
+
# reset window size between tests
unless session.current_window.size == CAPYBARA_WINDOW_SIZE
begin
diff --git a/spec/support/counter_attribute.rb b/spec/support/counter_attribute.rb
index ea71b25b4c0..8bd40b72dcf 100644
--- a/spec/support/counter_attribute.rb
+++ b/spec/support/counter_attribute.rb
@@ -9,6 +9,12 @@ RSpec.configure do |config|
counter_attribute :build_artifacts_size
counter_attribute :commit_count
+
+ attr_accessor :flushed
+
+ counter_attribute_after_flush do |subject|
+ subject.flushed = true
+ end
end
end
end
diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb
index a86161bfded..c9d372993b5 100644
--- a/spec/support/factory_bot.rb
+++ b/spec/support/factory_bot.rb
@@ -3,7 +3,3 @@
FactoryBot::SyntaxRunner.class_eval do
include RSpec::Mocks::ExampleMethods
end
-
-# Use FactoryBot 4.x behavior:
-# https://github.com/thoughtbot/factory_bot/blob/master/GETTING_STARTED.md#associations
-FactoryBot.use_parent_strategy = false
diff --git a/spec/support/google_api/cloud_platform_helpers.rb b/spec/support/google_api/cloud_platform_helpers.rb
index 38ffca8c5ae..286f3c03357 100644
--- a/spec/support/google_api/cloud_platform_helpers.rb
+++ b/spec/support/google_api/cloud_platform_helpers.rb
@@ -24,7 +24,7 @@ module GoogleApi
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)))
+ .to_return(cloud_platform_response(cloud_platform_cluster_body(**options)))
end
def stub_cloud_platform_get_zone_cluster_error(project_id, zone, cluster_id)
diff --git a/spec/support/helpers/api_internal_base_helpers.rb b/spec/support/helpers/api_internal_base_helpers.rb
new file mode 100644
index 00000000000..058d07c7a1d
--- /dev/null
+++ b/spec/support/helpers/api_internal_base_helpers.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+module APIInternalBaseHelpers
+ def gl_repository_for(container)
+ case container
+ when ProjectWiki
+ Gitlab::GlRepository::WIKI.identifier_for_container(container.project)
+ when Project
+ Gitlab::GlRepository::PROJECT.identifier_for_container(container)
+ when Snippet
+ Gitlab::GlRepository::SNIPPET.identifier_for_container(container)
+ else
+ nil
+ end
+ end
+
+ def full_path_for(container)
+ case container
+ when PersonalSnippet
+ "snippets/#{container.id}"
+ when ProjectSnippet
+ "#{container.project.full_path}/snippets/#{container.id}"
+ else
+ container.full_path
+ end
+ end
+
+ def pull(key, container, protocol = 'ssh')
+ post(
+ api("/internal/allowed"),
+ params: {
+ key_id: key.id,
+ project: full_path_for(container),
+ gl_repository: gl_repository_for(container),
+ action: 'git-upload-pack',
+ secret_token: secret_token,
+ protocol: protocol
+ }
+ )
+ end
+
+ def push(key, container, protocol = 'ssh', env: nil, changes: nil)
+ push_with_path(key,
+ full_path: full_path_for(container),
+ gl_repository: gl_repository_for(container),
+ protocol: protocol,
+ env: env,
+ changes: changes)
+ end
+
+ def push_with_path(key, full_path:, gl_repository: nil, protocol: 'ssh', env: nil, changes: nil)
+ changes ||= 'd14d6c0abdd253381df51a723d58691b2ee1ab08 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/master'
+
+ params = {
+ changes: changes,
+ key_id: key.id,
+ project: full_path,
+ action: 'git-receive-pack',
+ secret_token: secret_token,
+ protocol: protocol,
+ env: env
+ }
+ params[:gl_repository] = gl_repository if gl_repository
+
+ post(
+ api("/internal/allowed"),
+ params: params
+ )
+ end
+
+ def archive(key, container)
+ post(
+ api("/internal/allowed"),
+ params: {
+ ref: 'master',
+ key_id: key.id,
+ project: full_path_for(container),
+ gl_repository: gl_repository_for(container),
+ action: 'git-upload-archive',
+ secret_token: secret_token,
+ protocol: 'ssh'
+ }
+ )
+ end
+end
diff --git a/spec/support/helpers/features/canonical_link_helpers.rb b/spec/support/helpers/features/canonical_link_helpers.rb
new file mode 100644
index 00000000000..da3a28f1cb2
--- /dev/null
+++ b/spec/support/helpers/features/canonical_link_helpers.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+# These helpers allow you to manipulate with notes.
+#
+# Usage:
+# describe "..." do
+# include Spec::Support::Helpers::Features::CanonicalLinkHelpers
+# ...
+#
+# expect(page).to have_canonical_link(url)
+#
+module Spec
+ module Support
+ module Helpers
+ module Features
+ module CanonicalLinkHelpers
+ def have_canonical_link(url)
+ have_xpath("//link[@rel=\"canonical\" and @href=\"#{url}\"]", visible: false)
+ end
+
+ def have_any_canonical_links
+ have_xpath('//link[@rel="canonical"]', visible: false)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/helpers/git_http_helpers.rb b/spec/support/helpers/git_http_helpers.rb
index de8bb9ac8e3..c9c1c4dcfc9 100644
--- a/spec/support/helpers/git_http_helpers.rb
+++ b/spec/support/helpers/git_http_helpers.rb
@@ -5,45 +5,45 @@ require_relative 'workhorse_helpers'
module GitHttpHelpers
include WorkhorseHelpers
- def clone_get(project, options = {})
+ def clone_get(project, **options)
get "/#{project}/info/refs", params: { service: 'git-upload-pack' }, headers: auth_env(*options.values_at(:user, :password, :spnego_request_token))
end
- def clone_post(project, options = {})
+ def clone_post(project, **options)
post "/#{project}/git-upload-pack", headers: auth_env(*options.values_at(:user, :password, :spnego_request_token))
end
- def push_get(project, options = {})
+ def push_get(project, **options)
get "/#{project}/info/refs", params: { service: 'git-receive-pack' }, headers: auth_env(*options.values_at(:user, :password, :spnego_request_token))
end
- def push_post(project, options = {})
+ def push_post(project, **options)
post "/#{project}/git-receive-pack", headers: auth_env(*options.values_at(:user, :password, :spnego_request_token))
end
def download(project, user: nil, password: nil, spnego_request_token: nil)
- args = [project, { user: user, password: password, spnego_request_token: spnego_request_token }]
+ args = { user: user, password: password, spnego_request_token: spnego_request_token }
- clone_get(*args)
+ clone_get(project, **args)
yield response
- clone_post(*args)
+ clone_post(project, **args)
yield response
end
def upload(project, user: nil, password: nil, spnego_request_token: nil)
- args = [project, { user: user, password: password, spnego_request_token: spnego_request_token }]
+ args = { user: user, password: password, spnego_request_token: spnego_request_token }
- push_get(*args)
+ push_get(project, **args)
yield response
- push_post(*args)
+ push_post(project, **args)
yield response
end
- def download_or_upload(*args, &block)
- download(*args, &block)
- upload(*args, &block)
+ def download_or_upload(project, **args, &block)
+ download(project, **args, &block)
+ upload(project, **args, &block)
end
def auth_env(user, password, spnego_request_token)
diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb
index 5635ba3df05..e754a24417c 100644
--- a/spec/support/helpers/graphql_helpers.rb
+++ b/spec/support/helpers/graphql_helpers.rb
@@ -234,7 +234,8 @@ module GraphqlHelpers
end
def post_graphql(query, current_user: nil, variables: nil, headers: {})
- post api('/', current_user, version: 'graphql'), params: { query: query, variables: variables }, headers: headers
+ params = { query: query, variables: variables&.to_json }
+ post api('/', current_user, version: 'graphql'), params: params, headers: headers
end
def post_graphql_mutation(mutation, current_user: nil)
diff --git a/spec/support/helpers/kubernetes_helpers.rb b/spec/support/helpers/kubernetes_helpers.rb
index 8882f31e2f4..16d2e84cc0f 100644
--- a/spec/support/helpers/kubernetes_helpers.rb
+++ b/spec/support/helpers/kubernetes_helpers.rb
@@ -153,7 +153,7 @@ module KubernetesHelpers
options[:name] ||= "kubetest"
options[:domain] ||= "example.com"
- options[:response] ||= kube_response(kube_knative_services_body(options))
+ options[:response] ||= kube_response(kube_knative_services_body(**options))
stub_kubeclient_discover(service.api_url)
@@ -167,7 +167,7 @@ module KubernetesHelpers
options[:namespace] ||= "default"
WebMock.stub_request(:get, api_url + "/api/v1/namespaces/#{options[:namespace]}/secrets/#{options[:metadata_name]}")
- .to_return(kube_response(kube_v1_secret_body(options)))
+ .to_return(kube_response(kube_v1_secret_body(**options)))
end
def stub_kubeclient_get_secret_error(api_url, name, namespace: 'default', status: 404)
@@ -517,7 +517,7 @@ module KubernetesHelpers
def kube_knative_services_body(**options)
{
"kind" => "List",
- "items" => [knative_09_service(options)]
+ "items" => [knative_09_service(**options)]
}
end
@@ -604,7 +604,7 @@ module KubernetesHelpers
}
end
- def kube_deployment(name: "kube-deployment", environment_slug: "production", project_slug: "project-path-slug", track: nil)
+ def kube_deployment(name: "kube-deployment", environment_slug: "production", project_slug: "project-path-slug", track: nil, replicas: 3)
{
"metadata" => {
"name" => name,
@@ -617,7 +617,7 @@ module KubernetesHelpers
"track" => track
}.compact
},
- "spec" => { "replicas" => 3 },
+ "spec" => { "replicas" => replicas },
"status" => {
"observedGeneration" => 4
}
diff --git a/spec/support/helpers/search_helpers.rb b/spec/support/helpers/search_helpers.rb
index db6e47459e9..328f272724a 100644
--- a/spec/support/helpers/search_helpers.rb
+++ b/spec/support/helpers/search_helpers.rb
@@ -1,6 +1,14 @@
# frozen_string_literal: true
module SearchHelpers
+ def fill_in_search(text)
+ page.within('.search-input-wrap') do
+ fill_in('search', with: text)
+ end
+
+ wait_for_all_requests
+ end
+
def submit_search(query, scope: nil)
page.within('.search-form, .search-page-form') do
field = find_field('search')
@@ -11,6 +19,8 @@ module SearchHelpers
else
click_button('Search')
end
+
+ wait_for_all_requests
end
end
diff --git a/spec/support/helpers/stub_experiments.rb b/spec/support/helpers/stub_experiments.rb
index ff3b02dc3f6..7a6154d5ef9 100644
--- a/spec/support/helpers/stub_experiments.rb
+++ b/spec/support/helpers/stub_experiments.rb
@@ -22,10 +22,10 @@ module StubExperiments
# Examples
# - `stub_experiment_for_user(signup_flow: false)` ... Disable `signup_flow` experiment for user.
def stub_experiment_for_user(experiments)
- allow(Gitlab::Experimentation).to receive(:enabled_for_user?).and_call_original
+ allow(Gitlab::Experimentation).to receive(:enabled_for_value?).and_call_original
experiments.each do |experiment_key, enabled|
- allow(Gitlab::Experimentation).to receive(:enabled_for_user?).with(experiment_key, anything) { enabled }
+ allow(Gitlab::Experimentation).to receive(:enabled_for_value?).with(experiment_key, anything) { enabled }
end
end
end
diff --git a/spec/support/helpers/stub_feature_flags.rb b/spec/support/helpers/stub_feature_flags.rb
index 792a1c21c31..7f30a2a70cd 100644
--- a/spec/support/helpers/stub_feature_flags.rb
+++ b/spec/support/helpers/stub_feature_flags.rb
@@ -62,4 +62,8 @@ module StubFeatureFlags
StubFeatureGate.new(object)
end
+
+ def skip_feature_flags_yaml_validation
+ allow(Feature::Definition).to receive(:valid_usage!)
+ end
end
diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb
index 476b7d34ee5..dba3d2b137e 100644
--- a/spec/support/helpers/stub_object_storage.rb
+++ b/spec/support/helpers/stub_object_storage.rb
@@ -82,13 +82,27 @@ module StubObjectStorage
**params)
end
- def stub_terraform_state_object_storage(uploader = described_class, **params)
+ def stub_terraform_state_object_storage(**params)
stub_object_storage_uploader(config: Gitlab.config.terraform_state.object_store,
- uploader: uploader,
+ uploader: Terraform::VersionedStateUploader,
+ remote_directory: 'terraform',
+ **params)
+ end
+
+ def stub_terraform_state_version_object_storage(**params)
+ stub_object_storage_uploader(config: Gitlab.config.terraform_state.object_store,
+ uploader: Terraform::StateUploader,
remote_directory: 'terraform',
**params)
end
+ def stub_pages_object_storage(uploader = described_class, **params)
+ stub_object_storage_uploader(config: Gitlab.config.pages.object_store,
+ uploader: uploader,
+ remote_directory: 'pages',
+ **params)
+ end
+
def stub_object_storage_multipart_init(endpoint, upload_id = "upload_id")
stub_request(:post, %r{\A#{endpoint}tmp/uploads/[a-z0-9-]*\?uploads\z})
.to_return status: 200, body: <<-EOS.strip_heredoc
diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb
index d92fcdc2d4a..17e806d21d9 100644
--- a/spec/support/helpers/usage_data_helpers.rb
+++ b/spec/support/helpers/usage_data_helpers.rb
@@ -99,6 +99,7 @@ module UsageDataHelpers
projects_with_error_tracking_enabled
projects_with_alerts_service_enabled
projects_with_prometheus_alerts
+ projects_with_tracing_enabled
projects_with_expiration_policy_enabled
projects_with_expiration_policy_disabled
projects_with_expiration_policy_enabled_with_keep_n_unset
@@ -133,6 +134,7 @@ module UsageDataHelpers
todos
uploads
web_hooks
+ user_preferences_user_gitpod_enabled
).push(*SMAU_KEYS)
USAGE_DATA_KEYS = %i(
@@ -228,7 +230,7 @@ module UsageDataHelpers
receive_matchers.each { |m| expect(prometheus_client).to m }
end
- def for_defined_days_back(days: [29, 2])
+ def for_defined_days_back(days: [31, 3])
days.each do |n|
Timecop.travel(n.days.ago) do
yield
diff --git a/spec/support/helpers/wait_for_requests.rb b/spec/support/helpers/wait_for_requests.rb
index 2cfd47634ca..43060e571a9 100644
--- a/spec/support/helpers/wait_for_requests.rb
+++ b/spec/support/helpers/wait_for_requests.rb
@@ -48,17 +48,10 @@ module WaitForRequests
def finished_all_js_requests?
return true unless javascript_test?
- finished_all_ajax_requests? &&
- finished_all_axios_requests?
- end
-
- def finished_all_axios_requests?
- Capybara.page.evaluate_script('window.pendingRequests || 0').zero? # rubocop:disable Style/NumericPredicate
+ finished_all_ajax_requests?
end
def finished_all_ajax_requests?
- return true if Capybara.page.evaluate_script('typeof jQuery === "undefined"')
-
- Capybara.page.evaluate_script('jQuery.active').zero? # rubocop:disable Style/NumericPredicate
+ Capybara.page.evaluate_script('window.pendingRequests || window.pendingRailsUJSRequests || 0').zero? # rubocop:disable Style/NumericPredicate
end
end
diff --git a/spec/support/helpers/wiki_helpers.rb b/spec/support/helpers/wiki_helpers.rb
index e59c6bde264..8873a90579d 100644
--- a/spec/support/helpers/wiki_helpers.rb
+++ b/spec/support/helpers/wiki_helpers.rb
@@ -13,16 +13,16 @@ module WikiHelpers
find('.svg-content .js-lazy-loaded') if example.nil? || example.metadata.key?(:js)
end
- def upload_file_to_wiki(container, user, file_name)
- opts = {
+ def upload_file_to_wiki(wiki, user, file_name)
+ params = {
file_name: file_name,
file_content: File.read(expand_fixture_path(file_name))
}
::Wikis::CreateAttachmentService.new(
- container: container,
+ container: wiki.container,
current_user: user,
- params: opts
- ).execute[:result][:file_path]
+ params: params
+ ).execute.dig(:result, :file_path)
end
end
diff --git a/spec/support/matchers/be_sorted.rb b/spec/support/matchers/be_sorted.rb
new file mode 100644
index 00000000000..1455060fe71
--- /dev/null
+++ b/spec/support/matchers/be_sorted.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# Assert that this collection is sorted by argument and order
+#
+# By default, this checks that the collection is sorted ascending
+# but you can check order by specific field and order by passing
+# them, eg:
+#
+# ```
+# expect(collection).to be_sorted(:field, :desc)
+# ```
+RSpec::Matchers.define :be_sorted do |by, order = :asc|
+ match do |actual|
+ next true unless actual.present? # emtpy collection is sorted
+
+ actual
+ .then { |collection| by ? collection.sort_by(&by) : collection.sort }
+ .then { |sorted_collection| order.to_sym == :desc ? sorted_collection.reverse : sorted_collection }
+ .then { |sorted_collection| sorted_collection == actual }
+ end
+end
diff --git a/spec/support/migrations_helpers/schema_version_finder.rb b/spec/support/migrations_helpers/schema_version_finder.rb
new file mode 100644
index 00000000000..b677db7ea26
--- /dev/null
+++ b/spec/support/migrations_helpers/schema_version_finder.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+# Sometimes data migration specs require adding invalid test data in order to test
+# the migration (e.g. adding a row with null foreign key). Certain db migrations that
+# add constraints (e.g. NOT NULL constraint) prevent invalid records from being added
+# and data migration from being tested. For this reason, SchemaVersionFinder can be used
+# to find and use schema prior to specified one.
+#
+# @example
+# RSpec.describe CleanupThings, :migration, schema: MigrationHelpers::SchemaVersionFinder.migration_prior(AddNotNullConstraint) do ...
+#
+# SchemaVersionFinder returns schema version prior to the one specified, which allows to then add
+# invalid records to the database, which in return allows to properly test data migration.
+module MigrationHelpers
+ class SchemaVersionFinder
+ def self.migrations_paths
+ ActiveRecord::Migrator.migrations_paths
+ end
+
+ def self.migration_context
+ ActiveRecord::MigrationContext.new(migrations_paths, ActiveRecord::SchemaMigration)
+ end
+
+ def self.migrations
+ migration_context.migrations
+ end
+
+ def self.migration_prior(migration_klass)
+ migrations.each_cons(2) do |previous, migration|
+ break previous.version if migration.name == migration_klass.name
+ end
+ end
+ end
+end
diff --git a/spec/support/models/merge_request_without_merge_request_diff.rb b/spec/support/models/merge_request_without_merge_request_diff.rb
new file mode 100644
index 00000000000..5cdf1feb7a5
--- /dev/null
+++ b/spec/support/models/merge_request_without_merge_request_diff.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class MergeRequestWithoutMergeRequestDiff < ::MergeRequest
+ self.inheritance_column = :_type_disabled
+
+ def ensure_merge_request_diff; end
+end
diff --git a/spec/support/shared_contexts/cache_allowed_users_in_namespace_shared_context.rb b/spec/support/shared_contexts/cache_allowed_users_in_namespace_shared_context.rb
index e3c1d0afa53..bfb719fd840 100644
--- a/spec/support/shared_contexts/cache_allowed_users_in_namespace_shared_context.rb
+++ b/spec/support/shared_contexts/cache_allowed_users_in_namespace_shared_context.rb
@@ -25,7 +25,7 @@ RSpec.shared_examples 'allowed user IDs are cached' do
expect(described_class.l1_cache_backend).to receive(:fetch).and_call_original
expect(described_class.l2_cache_backend).to receive(:fetch).and_call_original
expect(subject).to be_truthy
- end.not_to exceed_query_limit(2)
+ end.not_to exceed_query_limit(3)
end
end
end
diff --git a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb
index 113252a6ab5..84910d0dfe4 100644
--- a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb
+++ b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb
@@ -85,7 +85,7 @@ RSpec.shared_examples 'multiple issue boards' do
wait_for_requests
- expect(page).to have_selector('.board', count: 3)
+ expect(page).to have_selector('.board', count: 5)
in_boards_switcher_dropdown do
click_link board.name
@@ -93,7 +93,7 @@ RSpec.shared_examples 'multiple issue boards' do
wait_for_requests
- expect(page).to have_selector('.board', count: 2)
+ expect(page).to have_selector('.board', count: 4)
end
it 'maintains sidebar state over board switch' do
diff --git a/spec/support/shared_examples/controllers/known_sign_in_shared_examples.rb b/spec/support/shared_examples/controllers/known_sign_in_shared_examples.rb
index 7f26155f9d6..3f147f942ba 100644
--- a/spec/support/shared_examples/controllers/known_sign_in_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/known_sign_in_shared_examples.rb
@@ -59,7 +59,7 @@ RSpec.shared_examples 'known sign in' do
it 'notifies the user when the cookie is expired' do
stub_cookie
- Timecop.freeze((KnownSignIn::KNOWN_SIGN_IN_COOKIE_EXPIRY + 1.day).from_now) do
+ travel_to((KnownSignIn::KNOWN_SIGN_IN_COOKIE_EXPIRY + 1.day).from_now) do
expect_next_instance_of(NotificationService) do |instance|
expect(instance).to receive(:unknown_sign_in)
end
diff --git a/spec/support/shared_examples/controllers/milestone_tabs_shared_examples.rb b/spec/support/shared_examples/controllers/milestone_tabs_shared_examples.rb
index 925c45005f0..2d35b1681ea 100644
--- a/spec/support/shared_examples/controllers/milestone_tabs_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/milestone_tabs_shared_examples.rb
@@ -2,9 +2,28 @@
RSpec.shared_examples 'milestone tabs' do
def go(path, extra_params = {})
- params = { namespace_id: project.namespace.to_param, project_id: project, id: milestone.iid }
+ get path, params: request_params.merge(extra_params)
+ end
+
+ describe '#issues' do
+ context 'as html' do
+ before do
+ go(:issues, format: 'html')
+ end
- get path, params: params.merge(extra_params)
+ it 'redirects to milestone#show' do
+ expect(response).to redirect_to(milestone_path)
+ end
+ end
+
+ context 'as json' do
+ it 'renders the issues tab template to a string' do
+ go(:issues, format: 'json')
+
+ expect(response).to render_template('shared/milestones/_issues_tab')
+ expect(json_response).to have_key('html')
+ end
+ end
end
describe '#merge_requests' do
diff --git a/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb b/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb
index 4ca400dd87b..1cf31c47df8 100644
--- a/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb
@@ -226,7 +226,7 @@ RSpec.shared_examples 'wiki controller actions' do
where(:file_name) { ['dk.png', 'unsanitized.svg', 'git-cheat-sheet.pdf'] }
with_them do
- let(:id) { upload_file_to_wiki(container, user, file_name) }
+ let(:id) { upload_file_to_wiki(wiki, user, file_name) }
it 'delivers the file with the correct headers' do
subject
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
index c9910487798..218f1afce98 100644
--- a/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb
+++ b/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb
@@ -11,6 +11,15 @@ RSpec.shared_examples 'an editable merge request' do
expect(page).to have_content user.name
end
+ find('.js-reviewer-search').click
+ page.within '.dropdown-menu-user' do
+ click_link user.name
+ end
+ expect(find('input[name="merge_request[reviewer_ids][]"]', visible: false).value).to match(user.id.to_s)
+ page.within '.js-reviewer-search' do
+ expect(page).to have_content user.name
+ end
+
click_button 'Milestone'
page.within '.issue-milestone' do
click_link milestone.title
@@ -38,6 +47,10 @@ RSpec.shared_examples 'an editable merge request' do
expect(page).to have_content user.name
end
+ page.within '.reviewer' do
+ expect(page).to have_content user.name
+ end
+
page.within '.milestone' do
expect(page).to have_content milestone.title
end
@@ -124,16 +137,3 @@ end
def get_textarea_height
page.evaluate_script('document.getElementById("merge_request_description").offsetHeight')
end
-
-RSpec.shared_examples 'an editable merge request with reviewers' do
- it 'updates merge request', :js do
- find('.js-reviewer-search').click
- page.within '.dropdown-menu-user' do
- click_link user.name
- end
- expect(find('input[name="merge_request[reviewer_ids][]"]', visible: false).value).to match(user.id.to_s)
- page.within '.js-reviewer-search' do
- expect(page).to have_content user.name
- end
- end
-end
diff --git a/spec/support/shared_examples/features/navbar_shared_examples.rb b/spec/support/shared_examples/features/navbar_shared_examples.rb
index 91a4048fa7c..c768e95c45a 100644
--- a/spec/support/shared_examples/features/navbar_shared_examples.rb
+++ b/spec/support/shared_examples/features/navbar_shared_examples.rb
@@ -3,12 +3,14 @@
RSpec.shared_examples 'verified navigation bar' do
let(:expected_structure) do
structure.compact!
- structure.each { |s| s[:nav_sub_items].compact! }
+ structure.each { |s| s[:nav_sub_items]&.compact! }
structure
end
it 'renders correctly' do
current_structure = page.all('.sidebar-top-level-items > li', class: ['!hidden']).map do |item|
+ next if item.find_all('a').empty?
+
nav_item = item.find_all('a').first.text.gsub(/\s+\d+$/, '') # remove counts at the end
nav_sub_items = item.all('.sidebar-sub-level-items > li', class: ['!fly-out-top-item']).map do |list_item|
@@ -16,7 +18,7 @@ RSpec.shared_examples 'verified navigation bar' do
end
{ nav_item: nav_item, nav_sub_items: nav_sub_items }
- end
+ end.compact
expect(current_structure).to eq(expected_structure)
end
diff --git a/spec/support/shared_examples/features/packages_shared_examples.rb b/spec/support/shared_examples/features/packages_shared_examples.rb
index f201421e827..4d2e13aa5bc 100644
--- a/spec/support/shared_examples/features/packages_shared_examples.rb
+++ b/spec/support/shared_examples/features/packages_shared_examples.rb
@@ -84,11 +84,11 @@ RSpec.shared_examples 'shared package sorting' do
let(:packages) { [package_two, package_one] }
end
- it_behaves_like 'correctly sorted packages list', 'Created' do
+ it_behaves_like 'correctly sorted packages list', 'Published' do
let(:packages) { [package_two, package_one] }
end
- it_behaves_like 'correctly sorted packages list', 'Created', ascending: true do
+ it_behaves_like 'correctly sorted packages list', 'Published', ascending: true do
let(:packages) { [package_one, package_two] }
end
end
diff --git a/spec/support/shared_examples/features/page_description_shared_examples.rb b/spec/support/shared_examples/features/page_description_shared_examples.rb
new file mode 100644
index 00000000000..81653220b4c
--- /dev/null
+++ b/spec/support/shared_examples/features/page_description_shared_examples.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'page meta description' do |expected_description|
+ it 'renders the page with description, og:description, and twitter:description meta tags that contains a plain-text version of the markdown', :aggregate_failures do
+ %w(name='description' property='og:description' property='twitter:description').each do |selector|
+ expect(page).to have_selector("meta[#{selector}][content='#{expected_description}']", visible: false)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/protected_branches_with_deploy_keys_examples.rb b/spec/support/shared_examples/features/protected_branches_with_deploy_keys_examples.rb
new file mode 100644
index 00000000000..a2d2143271c
--- /dev/null
+++ b/spec/support/shared_examples/features/protected_branches_with_deploy_keys_examples.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'when the deploy_keys_on_protected_branches FF is turned on' do
+ before do
+ stub_feature_flags(deploy_keys_on_protected_branches: true)
+ project.add_maintainer(user)
+ sign_in(user)
+ end
+
+ let(:dropdown_sections_minus_deploy_keys) { all_dropdown_sections - ['Deploy Keys'] }
+
+ context 'when deploy keys are enabled to this project' do
+ let!(:deploy_key_1) { create(:deploy_key, title: 'title 1', projects: [project]) }
+ let!(:deploy_key_2) { create(:deploy_key, title: 'title 2', projects: [project]) }
+
+ context 'when only one deploy key can push' do
+ before do
+ deploy_key_1.deploy_keys_projects.first.update!(can_push: true)
+ end
+
+ it "shows all dropdown sections in the 'Allowed to push' main dropdown, with only one deploy key" do
+ visit project_protected_branches_path(project)
+
+ find(".js-allowed-to-push").click
+ wait_for_requests
+
+ within('.qa-allowed-to-push-dropdown') do
+ dropdown_headers = page.all('.dropdown-header').map(&:text)
+
+ expect(dropdown_headers).to contain_exactly(*all_dropdown_sections)
+ expect(page).to have_content('title 1')
+ expect(page).not_to have_content('title 2')
+ end
+ end
+
+ it "shows all sections but not deploy keys in the 'Allowed to merge' main dropdown" do
+ visit project_protected_branches_path(project)
+
+ find(".js-allowed-to-merge").click
+ wait_for_requests
+
+ within('.qa-allowed-to-merge-dropdown') do
+ dropdown_headers = page.all('.dropdown-header').map(&:text)
+
+ expect(dropdown_headers).to contain_exactly(*dropdown_sections_minus_deploy_keys)
+ end
+ end
+
+ it "shows all sections in the 'Allowed to push' update dropdown" do
+ create(:protected_branch, :no_one_can_push, project: project, name: 'master')
+
+ visit project_protected_branches_path(project)
+
+ within(".js-protected-branch-edit-form") do
+ find(".js-allowed-to-push").click
+ wait_for_requests
+
+ dropdown_headers = page.all('.dropdown-header').map(&:text)
+
+ expect(dropdown_headers).to contain_exactly(*all_dropdown_sections)
+ end
+ end
+ end
+
+ context 'when no deploy key can push' do
+ it "just shows all sections but not deploy keys in the 'Allowed to push' dropdown" do
+ visit project_protected_branches_path(project)
+
+ find(".js-allowed-to-push").click
+ wait_for_requests
+
+ within('.qa-allowed-to-push-dropdown') do
+ dropdown_headers = page.all('.dropdown-header').map(&:text)
+
+ expect(dropdown_headers).to contain_exactly(*dropdown_sections_minus_deploy_keys)
+ end
+ end
+
+ it "just shows all sections but not deploy keys in the 'Allowed to push' update dropdown" do
+ create(:protected_branch, :no_one_can_push, project: project, name: 'master')
+
+ visit project_protected_branches_path(project)
+
+ within(".js-protected-branch-edit-form") do
+ find(".js-allowed-to-push").click
+ wait_for_requests
+
+ dropdown_headers = page.all('.dropdown-header').map(&:text)
+
+ expect(dropdown_headers).to contain_exactly(*dropdown_sections_minus_deploy_keys)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/wiki/file_attachments_shared_examples.rb b/spec/support/shared_examples/features/wiki/file_attachments_shared_examples.rb
new file mode 100644
index 00000000000..0ef1ccdfe57
--- /dev/null
+++ b/spec/support/shared_examples/features/wiki/file_attachments_shared_examples.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+# Requires a context containing:
+# wiki
+
+RSpec.shared_examples 'wiki file attachments' do
+ include DropzoneHelper
+
+ context 'uploading attachments', :js do
+ def attach_with_dropzone(wait = false)
+ dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, wait)
+ end
+
+ context 'before uploading' do
+ it 'shows "Attach a file" button' do
+ expect(page).to have_button('Attach a file')
+ expect(page).not_to have_selector('.uploading-progress-container', visible: true)
+ end
+ end
+
+ context 'uploading is in progress', :capybara_ignore_server_errors do
+ it 'cancels uploading on clicking to "Cancel" button' do
+ slow_requests do
+ attach_with_dropzone
+
+ click_button 'Cancel'
+ end
+
+ expect(page).to have_button('Attach a file')
+ expect(page).not_to have_button('Cancel')
+ expect(page).not_to have_selector('.uploading-progress-container', visible: true)
+ end
+
+ it 'shows "Attaching a file" message on uploading 1 file' do
+ slow_requests do
+ attach_with_dropzone
+
+ expect(page).to have_selector('.attaching-file-message', visible: true, text: 'Attaching a file -')
+ end
+ end
+ end
+
+ context 'uploading is complete' do
+ it 'shows "Attach a file" button on uploading complete' do
+ attach_with_dropzone
+ wait_for_requests
+
+ expect(page).to have_button('Attach a file')
+ expect(page).not_to have_selector('.uploading-progress-container', visible: true)
+ end
+
+ it 'the markdown link is added to the page' do
+ fill_in(:wiki_content, with: '')
+ attach_with_dropzone(true)
+ wait_for_requests
+
+ expect(page.find('#wiki_content').value)
+ .to match(%r{\!\[dk\]\(uploads/\h{32}/dk\.png\)$})
+ end
+
+ it 'the links point to the wiki root url' do
+ attach_with_dropzone(true)
+ wait_for_requests
+
+ find('.js-md-preview-button').click
+ file_path = page.find('input[name="files[]"]', visible: :hidden).value
+ link = page.find('a.no-attachment-icon')['href']
+ img_link = page.find('a.no-attachment-icon img')['src']
+
+ expect(link).to eq img_link
+ expect(URI.parse(link).path).to eq File.join(wiki.wiki_base_path, file_path)
+ end
+
+ it 'the file has been added to the wiki repository' do
+ expect do
+ attach_with_dropzone(true)
+ wait_for_requests
+ end.to change { wiki.repository.ls_files('HEAD').count }.by(1)
+
+ file_path = page.find('input[name="files[]"]', visible: :hidden).value
+
+ expect(wiki.find_file(file_path, 'HEAD').path).not_to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb
new file mode 100644
index 00000000000..44d82d2e753
--- /dev/null
+++ b/spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb
@@ -0,0 +1,245 @@
+# frozen_string_literal: true
+
+# Requires a context containing:
+# wiki
+# user
+
+RSpec.shared_examples 'User creates wiki page' do
+ include WikiHelpers
+
+ before do
+ sign_in(user)
+ end
+
+ context "when wiki is empty" do
+ before do |example|
+ visit wiki_path(wiki)
+
+ wait_for_svg_to_be_loaded(example)
+
+ click_link "Create your first page"
+ end
+
+ it "shows validation error message" do
+ page.within(".wiki-form") do
+ fill_in(:wiki_content, with: "")
+
+ click_on("Create page")
+ end
+
+ expect(page).to have_content("The form contains the following error:").and have_content("Content can't be blank")
+
+ page.within(".wiki-form") do
+ fill_in(:wiki_content, with: "[link test](test)")
+
+ click_on("Create page")
+ end
+
+ expect(page).to have_content("Home").and have_content("link test")
+
+ click_link("link test")
+
+ expect(page).to have_content("Create New Page")
+ end
+
+ it "shows non-escaped link in the pages list" do
+ fill_in(:wiki_title, with: "one/two/three-test")
+
+ 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(page).to have_link(href: wiki_page_path(wiki, 'one/two/three-test'))
+ end
+
+ it "has `Create home` as a commit message", :js do
+ wait_for_requests
+
+ expect(page).to have_field("wiki[message]", with: "Create home")
+ end
+
+ it "creates a page from the home page" do
+ fill_in(:wiki_content, with: "[test](test)\n[GitLab API doc](api)\n[Rake tasks](raketasks)\n# Wiki header\n")
+ fill_in(:wiki_message, with: "Adding links to wiki")
+
+ page.within(".wiki-form") do
+ click_button("Create page")
+ end
+
+ expect(current_path).to eq(wiki_page_path(wiki, "home"))
+ expect(page).to have_content("test GitLab API doc Rake tasks Wiki header")
+ .and have_content("Home")
+ .and have_content("Last edited by #{user.name}")
+ .and have_header_with_correct_id_and_link(1, "Wiki header", "wiki-header")
+
+ click_link("test")
+
+ expect(current_path).to eq(wiki_page_path(wiki, "test"))
+
+ page.within(:css, ".nav-text") do
+ expect(page).to have_content("Create New Page")
+ end
+
+ click_link("Home")
+
+ expect(current_path).to eq(wiki_page_path(wiki, "home"))
+
+ click_link("GitLab API")
+
+ expect(current_path).to eq(wiki_page_path(wiki, "api"))
+
+ page.within(:css, ".nav-text") do
+ expect(page).to have_content("Create")
+ end
+
+ click_link("Home")
+
+ expect(current_path).to eq(wiki_page_path(wiki, "home"))
+
+ click_link("Rake tasks")
+
+ expect(current_path).to eq(wiki_page_path(wiki, "raketasks"))
+
+ page.within(:css, ".nav-text") do
+ expect(page).to have_content("Create")
+ end
+ end
+
+ it "creates ASCII wiki with LaTeX blocks", :js do
+ stub_application_setting(plantuml_url: "http://localhost", plantuml_enabled: true)
+
+ ascii_content = <<~MD
+ :stem: latexmath
+
+ [stem]
+ ++++
+ \\sqrt{4} = 2
+ ++++
+
+ another part
+
+ [latexmath]
+ ++++
+ \\beta_x \\gamma
+ ++++
+
+ stem:[2+2] is 4
+ MD
+
+ find("#wiki_format option[value=asciidoc]").select_option
+
+ fill_in(:wiki_content, with: ascii_content)
+
+ page.within(".wiki-form") do
+ click_button("Create page")
+ end
+
+ page.within ".md" do
+ expect(page).to have_selector(".katex", count: 3).and have_content("2+2 is 4")
+ end
+ end
+
+ it 'creates a wiki page with Org markup', :aggregate_failures do
+ org_content = <<~ORG
+ * Heading
+ ** Subheading
+ [[home][Link to Home]]
+ ORG
+
+ page.within('.wiki-form') do
+ find('#wiki_format option[value=org]').select_option
+ fill_in(:wiki_content, with: org_content)
+ click_button('Create page')
+ end
+
+ expect(page).to have_selector('h1', text: 'Heading')
+ expect(page).to have_selector('h2', text: 'Subheading')
+ expect(page).to have_link(href: wiki_page_path(wiki, 'home'))
+ end
+
+ it_behaves_like 'wiki file attachments'
+ end
+
+ context "when wiki is not empty", :js do
+ before do
+ create(:wiki_page, wiki: wiki, title: 'home', content: 'Home page')
+
+ visit wiki_path(wiki)
+ end
+
+ context "via the `new wiki page` page" do
+ it "creates a page with a single word" do
+ click_link("New page")
+
+ page.within(".wiki-form") do
+ fill_in(:wiki_title, with: "foo")
+ fill_in(:wiki_content, with: "My awesome wiki!")
+ end
+
+ # Commit message field should have correct value.
+ expect(page).to have_field("wiki[message]", with: "Create foo")
+
+ click_button("Create page")
+
+ expect(page).to have_content("foo")
+ .and have_content("Last edited by #{user.name}")
+ .and have_content("My awesome wiki!")
+ end
+
+ it "creates a page with spaces in the name" do
+ click_link("New page")
+
+ page.within(".wiki-form") do
+ fill_in(:wiki_title, with: "Spaces in the name")
+ fill_in(:wiki_content, with: "My awesome wiki!")
+ end
+
+ # Commit message field should have correct value.
+ expect(page).to have_field("wiki[message]", with: "Create Spaces in the name")
+
+ click_button("Create page")
+
+ expect(page).to have_content("Spaces in the name")
+ .and have_content("Last edited by #{user.name}")
+ .and have_content("My awesome wiki!")
+ end
+
+ it "creates a page with hyphens in the name" do
+ click_link("New page")
+
+ page.within(".wiki-form") do
+ fill_in(:wiki_title, with: "hyphens-in-the-name")
+ fill_in(:wiki_content, with: "My awesome wiki!")
+ end
+
+ # Commit message field should have correct value.
+ expect(page).to have_field("wiki[message]", with: "Create hyphens in the name")
+
+ page.within(".wiki-form") do
+ fill_in(:wiki_content, with: "My awesome wiki!")
+
+ click_button("Create page")
+ end
+
+ expect(page).to have_content("hyphens in the name")
+ .and have_content("Last edited by #{user.name}")
+ .and have_content("My awesome wiki!")
+ end
+ end
+
+ it "shows the emoji autocompletion dropdown" do
+ click_link("New page")
+
+ page.within(".wiki-form") do
+ find("#wiki_content").native.send_keys("")
+
+ fill_in(:wiki_content, with: ":")
+ end
+
+ expect(page).to have_selector(".atwho-view")
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/wiki/user_deletes_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_deletes_wiki_page_shared_examples.rb
new file mode 100644
index 00000000000..e1fd9c8dbec
--- /dev/null
+++ b/spec/support/shared_examples/features/wiki/user_deletes_wiki_page_shared_examples.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+# Requires a context containing:
+# wiki
+# user
+
+RSpec.shared_examples 'User deletes wiki page' do
+ include WikiHelpers
+
+ let(:wiki_page) { create(:wiki_page, wiki: wiki) }
+
+ before do
+ sign_in(user)
+ visit wiki_page_path(wiki, wiki_page)
+ end
+
+ it 'deletes a page', :js do
+ click_on('Edit')
+ click_on('Delete')
+ find('.modal-footer .btn-danger').click
+
+ expect(page).to have_content('Page was successfully deleted')
+ end
+end
diff --git a/spec/support/shared_examples/features/wiki/user_previews_wiki_changes_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_previews_wiki_changes_shared_examples.rb
new file mode 100644
index 00000000000..cf6ae6cfa5b
--- /dev/null
+++ b/spec/support/shared_examples/features/wiki/user_previews_wiki_changes_shared_examples.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+# Requires a context containing:
+# wiki
+# user
+
+RSpec.shared_examples 'User previews wiki changes' do
+ let(:wiki_page) { build(:wiki_page, wiki: wiki) }
+
+ before do
+ sign_in(user)
+ end
+
+ shared_examples 'relative links' do
+ let_it_be(:page_content) do
+ <<~HEREDOC
+ Some text so key event for [ does not trigger an incorrect replacement.
+ [regular link](regular)
+ [relative link 1](../relative)
+ [relative link 2](./relative)
+ [relative link 3](./e/f/relative)
+ [spaced link](title with spaces)
+ HEREDOC
+ end
+
+ def relative_path(path)
+ (Pathname.new(wiki.wiki_base_path) + File.dirname(wiki_page.path).tr(' ', '-') + path).to_s
+ end
+
+ shared_examples "rewrites relative links" do
+ specify do
+ expect(element).to have_link('regular link', href: wiki.wiki_base_path + '/regular')
+ expect(element).to have_link('spaced link', href: wiki.wiki_base_path + '/title%20with%20spaces')
+
+ expect(element).to have_link('relative link 1', href: relative_path('../relative'))
+ expect(element).to have_link('relative link 2', href: relative_path('./relative'))
+ expect(element).to have_link('relative link 3', href: relative_path('./e/f/relative'))
+ end
+ end
+
+ context "when there are no spaces or hyphens in the page name" do
+ let(:wiki_page) { build(:wiki_page, wiki: wiki, title: 'a/b/c/d', content: page_content) }
+
+ it_behaves_like 'rewrites relative links'
+ end
+
+ context "when there are spaces in the page name" do
+ let(:wiki_page) { build(:wiki_page, wiki: wiki, title: 'a page/b page/c page/d page', content: page_content) }
+
+ it_behaves_like 'rewrites relative links'
+ end
+
+ context "when there are hyphens in the page name" do
+ let(:wiki_page) { build(:wiki_page, wiki: wiki, title: 'a-page/b-page/c-page/d-page', content: page_content) }
+
+ it_behaves_like 'rewrites relative links'
+ end
+ end
+
+ context "when rendering a new wiki page", :js do
+ before do
+ wiki_page.create # rubocop:disable Rails/SaveBang
+ visit wiki_page_path(wiki, wiki_page)
+ end
+
+ it_behaves_like 'relative links' do
+ let(:element) { page.find('.js-wiki-page-content') }
+ end
+ end
+
+ context "when previewing an existing wiki page", :js do
+ let(:preview) { page.find('.md-preview-holder') }
+
+ before do
+ wiki_page.create # rubocop:disable Rails/SaveBang
+ visit wiki_page_path(wiki, wiki_page, action: :edit)
+ end
+
+ it_behaves_like 'relative links' do
+ before do
+ click_on 'Preview'
+ end
+
+ let(:element) { preview }
+ end
+
+ it 'renders content with CommonMark' do
+ fill_in :wiki_content, with: "1. one\n - sublist\n"
+ click_on "Preview"
+
+ # the above generates two separate lists (not embedded) in CommonMark
+ expect(preview).to have_content("sublist")
+ expect(preview).not_to have_xpath("//ol//li//ul")
+ end
+
+ it "does not linkify double brackets inside code blocks as expected" do
+ fill_in :wiki_content, with: <<-HEREDOC
+ `[[do_not_linkify]]`
+ ```
+ [[also_do_not_linkify]]
+ ```
+ HEREDOC
+ click_on "Preview"
+
+ expect(preview).to have_content("do_not_linkify")
+ expect(preview).to have_content('[[do_not_linkify]]')
+ expect(preview).to have_content('[[also_do_not_linkify]]')
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb
new file mode 100644
index 00000000000..fee455207f1
--- /dev/null
+++ b/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb
@@ -0,0 +1,231 @@
+# frozen_string_literal: true
+
+# Requires a context containing:
+# wiki
+# user
+
+RSpec.shared_examples 'User updates wiki page' do
+ include WikiHelpers
+
+ before do
+ sign_in(user)
+ end
+
+ context 'when wiki is empty' do
+ before do |example|
+ visit(wiki_path(wiki))
+
+ wait_for_svg_to_be_loaded(example)
+
+ click_link "Create your first page"
+ end
+
+ it 'redirects back to the home edit page' do
+ page.within(:css, '.wiki-form .form-actions') do
+ click_on('Cancel')
+ end
+
+ expect(current_path).to eq wiki_path(wiki)
+ end
+
+ it 'updates a page that has a path', :js do
+ fill_in(:wiki_title, with: 'one/two/three-test')
+
+ 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
+
+ it_behaves_like 'wiki file attachments'
+ end
+
+ context 'when wiki is not empty' do
+ let!(:wiki_page) { create(:wiki_page, wiki: wiki, title: 'home', content: 'Home page') }
+
+ before do
+ visit(wiki_path(wiki))
+
+ click_link('Edit')
+ end
+
+ it 'updates a page', :js do
+ # Commit message field should have correct value.
+ expect(page).to have_field('wiki[message]', with: 'Update home')
+
+ 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
+
+ it 'updates the commit message as the title is changed', :js do
+ fill_in(:wiki_title, with: '& < > \ \ { } &')
+
+ expect(page).to have_field('wiki[message]', with: 'Update & < > \ \ { } &')
+ end
+
+ it 'correctly escapes the commit message entities', :js do
+ fill_in(:wiki_title, with: 'Wiki title')
+
+ expect(page).to have_field('wiki[message]', with: 'Update Wiki title')
+ end
+
+ it 'shows a validation error message' do
+ 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
+
+ it 'shows the emoji autocompletion dropdown', :js do
+ find('#wiki_content').native.send_keys('')
+ fill_in(:wiki_content, with: ':')
+
+ expect(page).to have_selector('.atwho-view')
+ end
+
+ it 'shows the error message' do
+ wiki_page.update(content: 'Update') # rubocop:disable Rails/SaveBang
+
+ click_button('Save changes')
+
+ expect(page).to have_content('Someone edited the page the same time you did.')
+ end
+
+ it 'updates a page' do
+ fill_in('Content', with: 'Updated Wiki Content')
+ click_on('Save changes')
+
+ expect(page).to have_content('Updated Wiki Content')
+ end
+
+ it 'cancels editing of a page' do
+ page.within(:css, '.wiki-form .form-actions') do
+ click_on('Cancel')
+ end
+
+ expect(current_path).to eq(wiki_page_path(wiki, wiki_page))
+ end
+
+ it_behaves_like 'wiki file attachments'
+ end
+
+ context 'when the page is in a subdir' do
+ let(:page_name) { 'page_name' }
+ let(:page_dir) { "foo/bar/#{page_name}" }
+ let!(:wiki_page) { create(:wiki_page, wiki: wiki, title: page_dir, content: 'Home page') }
+
+ before do
+ visit wiki_page_path(wiki, wiki_page, action: :edit)
+ end
+
+ it 'moves the page to the root folder' do
+ fill_in(:wiki_title, with: "/#{page_name}")
+
+ click_button('Save changes')
+
+ expect(current_path).to eq(wiki_page_path(wiki, page_name))
+ end
+
+ it 'moves the page to other dir' do
+ new_page_dir = "foo1/bar1/#{page_name}"
+
+ fill_in(:wiki_title, with: new_page_dir)
+
+ click_button('Save changes')
+
+ expect(current_path).to eq(wiki_page_path(wiki, new_page_dir))
+ end
+
+ it 'remains in the same place if title has not changed' do
+ original_path = wiki_page_path(wiki, wiki_page)
+
+ fill_in(:wiki_title, with: page_name)
+
+ click_button('Save changes')
+
+ expect(current_path).to eq(original_path)
+ end
+
+ it 'can be moved to a different dir with a different name' do
+ new_page_dir = "foo1/bar1/new_page_name"
+
+ fill_in(:wiki_title, with: new_page_dir)
+
+ click_button('Save changes')
+
+ expect(current_path).to eq(wiki_page_path(wiki, new_page_dir))
+ end
+
+ it 'can be renamed and moved to the root folder' do
+ new_name = 'new_page_name'
+
+ fill_in(:wiki_title, with: "/#{new_name}")
+
+ click_button('Save changes')
+
+ expect(current_path).to eq(wiki_page_path(wiki, new_name))
+ end
+
+ it 'squishes the title before creating the page' do
+ new_page_dir = " foo1 / bar1 / #{page_name} "
+
+ fill_in(:wiki_title, with: new_page_dir)
+
+ click_button('Save changes')
+
+ expect(current_path).to eq(wiki_page_path(wiki, "foo1/bar1/#{page_name}"))
+ end
+
+ it_behaves_like 'wiki file attachments'
+ end
+
+ context 'when an existing page exceeds the content size limit' do
+ let!(:wiki_page) { create(:wiki_page, wiki: wiki, content: "one\ntwo\nthree") }
+
+ before do
+ stub_application_setting(wiki_page_max_content_bytes: 10)
+
+ visit wiki_page_path(wiki_page.wiki, wiki_page, action: :edit)
+ end
+
+ it 'allows changing the title if the content does not change' do
+ fill_in 'Title', with: 'new title'
+ click_on 'Save changes'
+
+ expect(page).to have_content('Wiki was successfully updated.')
+ end
+
+ it 'shows a validation error when trying to change the content' do
+ fill_in 'Content', with: 'new content'
+ click_on 'Save changes'
+
+ expect(page).to have_content('The form contains the following error:')
+ expect(page).to have_content('Content is too long (11 Bytes). The maximum size is 10 Bytes.')
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/wiki/user_uses_wiki_shortcuts_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_uses_wiki_shortcuts_shared_examples.rb
new file mode 100644
index 00000000000..0330b345a18
--- /dev/null
+++ b/spec/support/shared_examples/features/wiki/user_uses_wiki_shortcuts_shared_examples.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+# Requires a context containing:
+# wiki
+# user
+
+RSpec.shared_examples 'User uses wiki shortcuts' do
+ let(:wiki_page) { create(:wiki_page, wiki: wiki, title: 'home', content: 'Home page') }
+
+ before do
+ sign_in(user)
+ visit wiki_page_path(wiki, wiki_page)
+ end
+
+ it 'Visit edit wiki page using "e" keyboard shortcut', :js do
+ find('body').native.send_key('e')
+
+ expect(find('.wiki-page-title')).to have_content('Edit Page')
+ end
+end
diff --git a/spec/support/shared_examples/features/wiki/user_views_asciidoc_page_with_includes_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_views_asciidoc_page_with_includes_shared_examples.rb
new file mode 100644
index 00000000000..3b2fda4e05b
--- /dev/null
+++ b/spec/support/shared_examples/features/wiki/user_views_asciidoc_page_with_includes_shared_examples.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'User views AsciiDoc page with includes' do
+ let_it_be(:wiki_content_selector) { '[data-qa-selector=wiki_page_content]' }
+ let!(:included_wiki_page) { create_wiki_page('included_page', content: 'Content from the included page')}
+ let!(:wiki_page) { create_wiki_page('home', content: "Content from the main page.\ninclude::included_page.asciidoc[]") }
+
+ def create_wiki_page(title, content:)
+ attrs = {
+ title: title,
+ content: content,
+ format: :asciidoc
+ }
+
+ create(:wiki_page, wiki: wiki, **attrs)
+ end
+
+ before do
+ sign_in(user)
+ end
+
+ context 'when the file being included exists', :js do
+ it 'includes the file contents' do
+ visit(wiki_page_path(wiki, wiki_page))
+
+ page.within(:css, wiki_content_selector) do
+ expect(page).to have_content('Content from the main page. Content from the included page')
+ end
+ end
+
+ context 'when there are multiple versions of the wiki pages' do
+ before do
+ # rubocop:disable Rails/SaveBang
+ included_wiki_page.update(message: 'updated included file', content: 'Updated content from the included page')
+ wiki_page.update(message: 'updated wiki page', content: "Updated content from the main page.\ninclude::included_page.asciidoc[]")
+ # rubocop:enable Rails/SaveBang
+ end
+
+ let(:latest_version_id) { wiki_page.versions.first.id }
+ let(:oldest_version_id) { wiki_page.versions.last.id }
+
+ context 'viewing the latest version' do
+ it 'includes the latest content' do
+ visit(wiki_page_path(wiki, wiki_page, version_id: latest_version_id))
+
+ page.within(:css, wiki_content_selector) do
+ expect(page).to have_content('Updated content from the main page. Updated content from the included page')
+ end
+ end
+ end
+
+ context 'viewing the original version' do
+ it 'includes the content from the original version' do
+ visit(wiki_page_path(wiki, wiki_page, version_id: oldest_version_id))
+
+ page.within(:css, wiki_content_selector) do
+ expect(page).to have_content('Content from the main page. Content from the included page')
+ end
+ end
+ end
+ end
+ end
+
+ context 'when the file being included does not exist', :js do
+ before do
+ included_wiki_page.delete
+ end
+
+ it 'outputs an error' do
+ visit(wiki_page_path(wiki, wiki_page))
+
+ page.within(:css, wiki_content_selector) do
+ expect(page).to have_content('Content from the main page. [ERROR: include::included_page.asciidoc[] - unresolved directive]')
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/wiki/user_views_wiki_empty_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_views_wiki_empty_shared_examples.rb
new file mode 100644
index 00000000000..d7f5b485a82
--- /dev/null
+++ b/spec/support/shared_examples/features/wiki/user_views_wiki_empty_shared_examples.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+# Requires a context containing:
+# wiki
+
+RSpec.shared_examples 'User views empty wiki' do
+ let(:element) { page.find('.row.empty-state') }
+ let(:container_name) { wiki.container.class.name.humanize(capitalize: false) }
+ let(:confluence_link) { 'Enable the Confluence Wiki integration' }
+
+ shared_examples 'wiki is not found' do
+ it 'shows an error message' do
+ visit wiki_path(wiki)
+
+ if @current_user
+ expect(page).to have_content('Page Not Found')
+ else
+ expect(page).to have_content('You need to sign in')
+ end
+ end
+ end
+
+ shared_examples 'empty wiki message' do |writable: false, issuable: false, confluence: false|
+ # This mirrors the logic in:
+ # - app/views/shared/empty_states/_wikis.html.haml
+ # - WikiHelper#wiki_empty_state_messages
+ it 'shows the empty state message with the expected elements' do
+ visit wiki_path(wiki)
+
+ if writable
+ expect(element).to have_content("The wiki lets you write documentation for your #{container_name}")
+ else
+ expect(element).to have_content("This #{container_name} has no wiki pages")
+ expect(element).to have_content("You must be a #{container_name} member")
+ end
+
+ if issuable && !writable
+ expect(element).to have_content("improve the wiki for this #{container_name}")
+ expect(element).to have_link("issue tracker", href: project_issues_path(project))
+ expect(element).to have_link("Suggest wiki improvement", href: new_project_issue_path(project))
+ else
+ expect(element).not_to have_content("improve the wiki for this #{container_name}")
+ expect(element).not_to have_link("issue tracker")
+ expect(element).not_to have_link("Suggest wiki improvement")
+ end
+
+ if confluence
+ expect(element).to have_link(confluence_link)
+ else
+ expect(element).not_to have_link(confluence_link)
+ end
+
+ if writable
+ element.click_link 'Create your first page'
+
+ expect(page).to have_button('Create page')
+ else
+ expect(element).not_to have_link('Create your first page')
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb
new file mode 100644
index 00000000000..40998ebde4c
--- /dev/null
+++ b/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb
@@ -0,0 +1,278 @@
+# frozen_string_literal: true
+
+# Requires a context containing:
+# wiki
+# user
+
+RSpec.shared_examples 'User views a wiki page' do
+ include WikiHelpers
+
+ let(:path) { 'image.png' }
+ let(:wiki_page) do
+ create(:wiki_page,
+ wiki: wiki,
+ title: 'home', content: "Look at this [image](#{path})\n\n ![alt text](#{path})")
+ end
+
+ before do
+ sign_in(user)
+ end
+
+ context 'when wiki is empty', :js do
+ before do
+ visit wiki_path(wiki)
+
+ wait_for_svg_to_be_loaded
+
+ click_link "Create your first page"
+
+ fill_in(:wiki_title, with: 'one/two/three-test')
+
+ page.within('.wiki-form') do
+ fill_in(:wiki_content, with: 'wiki content')
+ click_on('Create page')
+ end
+
+ expect(page).to have_content('Wiki was successfully updated.')
+ end
+
+ it 'shows the history of a page that has a path' do
+ expect(current_path).to include('one/two/three-test')
+
+ first(:link, text: 'three').click
+ click_on('Page history')
+
+ expect(current_path).to include('one/two/three-test')
+
+ page.within(:css, '.nav-text') do
+ expect(page).to have_content('History')
+ end
+ end
+
+ it 'shows an old version of a page' do
+ 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('Wiki was successfully updated.')
+
+ click_on('Page history')
+
+ within('.nav-text') do
+ expect(page).to have_content('History')
+ end
+
+ within('.wiki-history') do
+ expect(page).to have_css('a[href*="?version_id"]', count: 4)
+ end
+ end
+ end
+
+ context 'when a page does not have history' do
+ before do
+ visit(wiki_page_path(wiki, 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
+
+ context 'shows a file stored in a page' do
+ let(:path) { upload_file_to_wiki(wiki, user, 'dk.png') }
+
+ it do
+ expect(page).to have_xpath("//img[@data-src='#{wiki.wiki_base_path}/#{path}']")
+ expect(page).to have_link('image', href: "#{wiki.wiki_base_path}/#{path}")
+
+ click_on('image')
+
+ expect(current_path).to match("wikis/#{path}")
+ expect(page).not_to have_xpath('/html') # Page should render the image which means there is no html involved
+ end
+ end
+
+ it 'shows the creation page if file does not exist' do
+ expect(page).to have_link('image', href: "#{wiki.wiki_base_path}/#{path}")
+
+ click_on('image')
+
+ expect(current_path).to match("wikis/#{path}")
+ expect(page).to have_content('Create New Page')
+ end
+ end
+
+ context 'when a page has history' do
+ before do
+ wiki_page.update(message: 'updated home', content: 'updated [some link](other-page)') # rubocop:disable Rails/SaveBang
+ end
+
+ it 'shows the page history' do
+ visit(wiki_page_path(wiki, wiki_page))
+
+ expect(page).to have_selector('a.btn', text: 'Edit')
+
+ 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')
+ end
+
+ it 'does not show the "Edit" button' do
+ visit(wiki_page_path(wiki, wiki_page, version_id: wiki_page.versions.last.id))
+
+ expect(page).not_to have_selector('a.btn', text: 'Edit')
+ end
+
+ context 'show the diff' do
+ before do
+ skip('Diffing for group wikis will be implemented in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42610') if wiki.container.is_a?(Group)
+ end
+
+ def expect_diff_links(commit)
+ diff_path = wiki_page_path(wiki, wiki_page, version_id: commit, action: :diff)
+
+ expect(page).to have_link('Hide whitespace changes', href: "#{diff_path}&w=1")
+ expect(page).to have_link('Inline', href: "#{diff_path}&view=inline")
+ expect(page).to have_link('Side-by-side', href: "#{diff_path}&view=parallel")
+ expect(page).to have_link("View page @ #{commit.short_id}", href: wiki_page_path(wiki, wiki_page, version_id: commit))
+ expect(page).to have_css('.diff-file[data-blob-diff-path="%s"]' % diff_path)
+ end
+
+ it 'links to the correct diffs' do
+ visit wiki_page_path(wiki, wiki_page, action: :history)
+
+ commit1 = wiki.commit('HEAD^')
+ commit2 = wiki.commit
+
+ expect(page).to have_link('created page: home', href: wiki_page_path(wiki, wiki_page, version_id: commit1, action: :diff))
+ expect(page).to have_link('updated home', href: wiki_page_path(wiki, wiki_page, version_id: commit2, action: :diff))
+ end
+
+ it 'between the current and the previous version of a page' do
+ commit = wiki.commit
+ visit wiki_page_path(wiki, wiki_page, version_id: commit, action: :diff)
+
+ expect(page).to have_content('by John Doe')
+ expect(page).to have_content('updated home')
+ expect(page).to have_content('Showing 1 changed file with 1 addition and 3 deletions')
+ expect(page).to have_content('some link')
+
+ expect_diff_links(commit)
+ end
+
+ it 'between two old versions of a page' do
+ wiki_page.update(message: 'latest home change', content: 'updated [another link](other-page)') # rubocop:disable Rails/SaveBang:
+ commit = wiki.commit('HEAD^')
+ visit wiki_page_path(wiki, wiki_page, version_id: commit, action: :diff)
+
+ expect(page).to have_content('by John Doe')
+ expect(page).to have_content('updated home')
+ expect(page).to have_content('Showing 1 changed file with 1 addition and 3 deletions')
+ expect(page).to have_content('some link')
+ expect(page).not_to have_content('latest home change')
+ expect(page).not_to have_content('another link')
+
+ expect_diff_links(commit)
+ end
+
+ it 'for the oldest version of a page' do
+ commit = wiki.commit('HEAD^')
+ visit wiki_page_path(wiki, wiki_page, version_id: commit, action: :diff)
+
+ expect(page).to have_content('by John Doe')
+ expect(page).to have_content('created page: home')
+ expect(page).to have_content('Showing 1 changed file with 4 additions and 0 deletions')
+ expect(page).to have_content('Look at this')
+
+ expect_diff_links(commit)
+ end
+ end
+ end
+
+ context 'when a page has special characters in its title' do
+ let(:title) { '<foo> !@#$%^&*()[]{}=_+\'"\\|<>? <bar>' }
+
+ before do
+ wiki_page.update(title: title ) # rubocop:disable Rails/SaveBang
+ end
+
+ it 'preserves the special characters' do
+ visit(wiki_page_path(wiki, wiki_page))
+
+ expect(page).to have_css('.wiki-page-title', text: title)
+ expect(page).to have_css('.wiki-pages li', text: title)
+ end
+ end
+
+ context 'when a page has XSS in its title or content' do
+ let(:title) { '<script>alert("title")<script>' }
+
+ before do
+ wiki_page.update(title: title, content: 'foo <script>alert("content")</script> bar') # rubocop:disable Rails/SaveBang
+ end
+
+ it 'safely displays the page' do
+ visit(wiki_page_path(wiki, wiki_page))
+
+ expect(page).to have_css('.wiki-page-title', text: title)
+ expect(page).to have_content('foo bar')
+ end
+ end
+
+ context 'when a page has XSS in its message' do
+ before do
+ wiki_page.update(message: '<script>alert(true)<script>', content: 'XSS update') # rubocop:disable Rails/SaveBang
+ end
+
+ it 'safely displays the message' do
+ visit(wiki_page_path(wiki, wiki_page, action: :history))
+
+ expect(page).to have_content('<script>alert(true)<script>')
+ end
+ end
+
+ context 'when page has invalid content encoding' do
+ let(:content) { (+'whatever').force_encoding('ISO-8859-1') }
+
+ before do
+ allow(Gitlab::EncodingHelper).to receive(:encode!).and_return(content)
+
+ visit(wiki_page_path(wiki, wiki_page))
+ end
+
+ it 'does not show "Edit" button' do
+ expect(page).not_to have_selector('a.btn', text: 'Edit')
+ end
+
+ it 'shows error' do
+ page.within(:css, '.flash-notice') do
+ expect(page).to have_content('The content of this page is not encoded in UTF-8. Edits can only be made via the Git repository.')
+ end
+ end
+ end
+
+ it 'opens a default wiki page', :js do
+ visit wiki.container.web_url
+
+ find('.shortcuts-wiki').click
+
+ wait_for_svg_to_be_loaded
+
+ click_link "Create your first page"
+
+ expect(page).to have_content('Create New Page')
+ end
+end
diff --git a/spec/support/shared_examples/features/wiki/user_views_wiki_pages_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_views_wiki_pages_shared_examples.rb
new file mode 100644
index 00000000000..314c2074eee
--- /dev/null
+++ b/spec/support/shared_examples/features/wiki/user_views_wiki_pages_shared_examples.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+# Requires a context containing:
+# wiki
+# user
+
+RSpec.shared_examples 'User views wiki pages' do
+ include WikiHelpers
+
+ let!(:wiki_page1) do
+ create(:wiki_page, wiki: wiki, title: '3 home', content: '3')
+ end
+
+ let!(:wiki_page2) do
+ create(:wiki_page, wiki: wiki, title: '1 home', content: '1')
+ end
+
+ let!(:wiki_page3) do
+ create(:wiki_page, wiki: wiki, title: '2 home', content: '2')
+ end
+
+ let(:pages) do
+ page.find('.wiki-pages-list').all('li').map { |li| li.find('a') }
+ end
+
+ before do
+ sign_in(user)
+ visit(wiki_path(wiki, action: :pages))
+ end
+
+ context 'ordered by title' do
+ let(:pages_ordered_by_title) { [wiki_page2, wiki_page3, wiki_page1] }
+
+ context 'asc' do
+ it 'pages are displayed in direct order' do
+ pages.each.with_index do |page_title, index|
+ expect(page_title.text).to eq(pages_ordered_by_title[index].title)
+ end
+ end
+ end
+
+ context 'desc' do
+ before do
+ page.within('.wiki-sort-dropdown') do
+ page.find('.rspec-reverse-sort').click
+ end
+ end
+
+ it 'pages are displayed in reversed order' do
+ pages.reverse_each.with_index do |page_title, index|
+ expect(page_title.text).to eq(pages_ordered_by_title[index].title)
+ end
+ end
+ end
+ end
+
+ context 'ordered by created_at' do
+ let(:pages_ordered_by_created_at) { [wiki_page1, wiki_page2, wiki_page3] }
+
+ before do
+ page.within('.wiki-sort-dropdown') do
+ click_button('Title')
+ click_link('Created date')
+ end
+ end
+
+ context 'asc' do
+ it 'pages are displayed in direct order' do
+ pages.each.with_index do |page_title, index|
+ expect(page_title.text).to eq(pages_ordered_by_created_at[index].title)
+ end
+ end
+ end
+
+ context 'desc' do
+ before do
+ page.within('.wiki-sort-dropdown') do
+ page.find('.rspec-reverse-sort').click
+ end
+ end
+
+ it 'pages are displayed in reversed order' do
+ pages.reverse_each.with_index do |page_title, index|
+ expect(page_title.text).to eq(pages_ordered_by_created_at[index].title)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/wiki/user_views_wiki_sidebar_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_views_wiki_sidebar_shared_examples.rb
new file mode 100644
index 00000000000..a7ba7a8ad07
--- /dev/null
+++ b/spec/support/shared_examples/features/wiki/user_views_wiki_sidebar_shared_examples.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+# Requires a context containing:
+# wiki
+# user
+
+RSpec.shared_examples 'User views wiki sidebar' do
+ include WikiHelpers
+
+ before do
+ sign_in(user)
+ end
+
+ context 'when there are some existing pages' do
+ before do
+ create(:wiki_page, wiki: wiki, title: 'home', content: 'home')
+ create(:wiki_page, wiki: wiki, title: 'another', content: 'another')
+ end
+
+ it 'renders a default sidebar when there is no customized sidebar' do
+ visit wiki_path(wiki)
+
+ expect(page).to have_content('another')
+ expect(page).not_to have_link('View All Pages')
+ end
+
+ context 'when there is a customized sidebar' do
+ before do
+ create(:wiki_page, wiki: wiki, title: '_sidebar', content: 'My customized sidebar')
+ end
+
+ it 'renders my customized sidebar instead of the default one' do
+ visit wiki_path(wiki)
+
+ expect(page).to have_content('My customized sidebar')
+ expect(page).not_to have_content('Another')
+ end
+ end
+ end
+
+ context 'when there are 15 existing pages' do
+ before do
+ (1..5).each { |i| create(:wiki_page, wiki: wiki, title: "my page #{i}") }
+ (6..10).each { |i| create(:wiki_page, wiki: wiki, title: "parent/my page #{i}") }
+ (11..15).each { |i| create(:wiki_page, wiki: wiki, title: "grandparent/parent/my page #{i}") }
+ end
+
+ it 'shows all pages in the sidebar' do
+ visit wiki_path(wiki)
+
+ (1..15).each { |i| expect(page).to have_content("my page #{i}") }
+ expect(page).not_to have_link('View All Pages')
+ end
+
+ context 'when there are more than 15 existing pages' do
+ before do
+ create(:wiki_page, wiki: wiki, title: 'my page 16')
+ end
+
+ it 'shows the first 15 pages in the sidebar' do
+ visit wiki_path(wiki)
+
+ expect(page).to have_text('my page', count: 15)
+ expect(page).to have_link('View All Pages')
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/wiki_file_attachments_shared_examples.rb b/spec/support/shared_examples/features/wiki_file_attachments_shared_examples.rb
deleted file mode 100644
index d30e8241da0..00000000000
--- a/spec/support/shared_examples/features/wiki_file_attachments_shared_examples.rb
+++ /dev/null
@@ -1,88 +0,0 @@
-# frozen_string_literal: true
-
-# Requires a context containing:
-# project
-
-RSpec.shared_examples 'wiki file attachments' do
- include DropzoneHelper
-
- context 'uploading attachments', :js do
- let(:wiki) { project.wiki }
-
- def attach_with_dropzone(wait = false)
- dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, wait)
- end
-
- context 'before uploading' do
- it 'shows "Attach a file" button' do
- expect(page).to have_button('Attach a file')
- expect(page).not_to have_selector('.uploading-progress-container', visible: true)
- end
- end
-
- context 'uploading is in progress', :capybara_ignore_server_errors do
- it 'cancels uploading on clicking to "Cancel" button' do
- slow_requests do
- attach_with_dropzone
-
- click_button 'Cancel'
- end
-
- expect(page).to have_button('Attach a file')
- expect(page).not_to have_button('Cancel')
- expect(page).not_to have_selector('.uploading-progress-container', visible: true)
- end
-
- it 'shows "Attaching a file" message on uploading 1 file' do
- slow_requests do
- attach_with_dropzone
-
- expect(page).to have_selector('.attaching-file-message', visible: true, text: 'Attaching a file -')
- end
- end
- end
-
- context 'uploading is complete' do
- it 'shows "Attach a file" button on uploading complete' do
- attach_with_dropzone
- wait_for_requests
-
- expect(page).to have_button('Attach a file')
- expect(page).not_to have_selector('.uploading-progress-container', visible: true)
- end
-
- it 'the markdown link is added to the page' do
- fill_in(:wiki_content, with: '')
- attach_with_dropzone(true)
- wait_for_requests
-
- expect(page.find('#wiki_content').value)
- .to match(%r{\!\[dk\]\(uploads/\h{32}/dk\.png\)$})
- end
-
- it 'the links point to the wiki root url' do
- attach_with_dropzone(true)
- wait_for_requests
-
- find('.js-md-preview-button').click
- file_path = page.find('input[name="files[]"]', visible: :hidden).value
- link = page.find('a.no-attachment-icon')['href']
- img_link = page.find('a.no-attachment-icon img')['src']
-
- expect(link).to eq img_link
- expect(URI.parse(link).path).to eq File.join(wiki.wiki_base_path, file_path)
- end
-
- it 'the file has been added to the wiki repository' do
- expect do
- attach_with_dropzone(true)
- wait_for_requests
- end.to change { wiki.repository.ls_files('HEAD').count }.by(1)
-
- file_path = page.find('input[name="files[]"]', visible: :hidden).value
-
- expect(wiki.find_file(file_path, 'HEAD').path).not_to be_nil
- end
- end
- end
-end
diff --git a/spec/support/shared_examples/lib/gitlab/background_migration/mentions_migration_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/background_migration/mentions_migration_shared_examples.rb
index e93077c42e1..7707e79386c 100644
--- a/spec/support/shared_examples/lib/gitlab/background_migration/mentions_migration_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/background_migration/mentions_migration_shared_examples.rb
@@ -1,12 +1,13 @@
# frozen_string_literal: true
-RSpec.shared_examples 'resource mentions migration' do |migration_class, resource_class|
+RSpec.shared_examples 'resource mentions migration' do |migration_class, resource_class_name|
it 'migrates resource mentions' do
join = migration_class::JOIN
conditions = migration_class::QUERY_CONDITIONS
+ resource_class = "#{Gitlab::BackgroundMigration::UserMentions::Models}::#{resource_class_name}".constantize
expect do
- subject.perform(resource_class.name, join, conditions, false, resource_class.minimum(:id), resource_class.maximum(:id))
+ subject.perform(resource_class_name, join, conditions, false, resource_class.minimum(:id), resource_class.maximum(:id))
end.to change { user_mentions.count }.by(1)
user_mention = user_mentions.last
@@ -16,23 +17,23 @@ RSpec.shared_examples 'resource mentions migration' do |migration_class, resourc
# check that performing the same job twice does not fail and does not change counts
expect do
- subject.perform(resource_class.name, join, conditions, false, resource_class.minimum(:id), resource_class.maximum(:id))
+ subject.perform(resource_class_name, join, conditions, false, resource_class.minimum(:id), resource_class.maximum(:id))
end.to change { user_mentions.count }.by(0)
end
end
-RSpec.shared_examples 'resource notes mentions migration' do |migration_class, resource_class|
+RSpec.shared_examples 'resource notes mentions migration' do |migration_class, resource_class_name|
it 'migrates mentions from note' do
join = migration_class::JOIN
conditions = migration_class::QUERY_CONDITIONS
# there are 5 notes for each noteable_type, but two do not have mentions and
# another one's noteable_id points to an inexistent resource
- expect(notes.where(noteable_type: resource_class.to_s).count).to eq 5
+ expect(notes.where(noteable_type: resource_class_name).count).to eq 5
expect(user_mentions.count).to eq 0
expect do
- subject.perform(resource_class.name, join, conditions, true, Note.minimum(:id), Note.maximum(:id))
+ subject.perform(resource_class_name, join, conditions, true, Note.minimum(:id), Note.maximum(:id))
end.to change { user_mentions.count }.by(2)
# check that the user_mention for regular note is created
@@ -51,7 +52,7 @@ RSpec.shared_examples 'resource notes mentions migration' do |migration_class, r
# check that performing the same job twice does not fail and does not change counts
expect do
- subject.perform(resource_class.name, join, conditions, true, Note.minimum(:id), Note.maximum(:id))
+ subject.perform(resource_class_name, join, conditions, true, Note.minimum(:id), Note.maximum(:id))
end.to change { user_mentions.count }.by(0)
end
end
@@ -83,24 +84,25 @@ RSpec.shared_examples 'schedules resource mentions migration' do |resource_class
end
end
-RSpec.shared_examples 'resource migration not run' do |migration_class, resource_class|
+RSpec.shared_examples 'resource migration not run' do |migration_class, resource_class_name|
it 'does not migrate mentions' do
join = migration_class::JOIN
conditions = migration_class::QUERY_CONDITIONS
+ resource_class = "#{Gitlab::BackgroundMigration::UserMentions::Models}::#{resource_class_name}".constantize
expect do
- subject.perform(resource_class.name, join, conditions, false, resource_class.minimum(:id), resource_class.maximum(:id))
+ subject.perform(resource_class_name, join, conditions, false, resource_class.minimum(:id), resource_class.maximum(:id))
end.to change { user_mentions.count }.by(0)
end
end
-RSpec.shared_examples 'resource notes migration not run' do |migration_class, resource_class|
+RSpec.shared_examples 'resource notes migration not run' do |migration_class, resource_class_name|
it 'does not migrate mentions' do
join = migration_class::JOIN
conditions = migration_class::QUERY_CONDITIONS
expect do
- subject.perform(resource_class.name, join, conditions, true, Note.minimum(:id), Note.maximum(:id))
+ subject.perform(resource_class_name, join, conditions, true, Note.minimum(:id), Note.maximum(:id))
end.to change { user_mentions.count }.by(0)
end
end
diff --git a/spec/support/shared_examples/lib/gitlab/diff_file_collections_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/diff_file_collections_shared_examples.rb
index e43ce936b90..469c0c287b1 100644
--- a/spec/support/shared_examples/lib/gitlab/diff_file_collections_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/diff_file_collections_shared_examples.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.shared_examples 'diff statistics' do |test_include_stats_flag: true|
- subject { described_class.new(diffable, collection_default_args) }
+ subject { described_class.new(diffable, **collection_default_args) }
def stub_stats_find_by_path(path, stats_mock)
expect_next_instance_of(Gitlab::Git::DiffStatsCollection) do |collection|
diff --git a/spec/support/shared_examples/lib/gitlab/import_export/relation_factory_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/import_export/relation_factory_shared_examples.rb
new file mode 100644
index 00000000000..33061f17bde
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/import_export/relation_factory_shared_examples.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+
+# required context:
+# - importable: group or project
+# - relation_hash: a note relation that's being imported
+# - created_object: the object created with the relation factory
+RSpec.shared_examples 'Notes user references' do
+ let(:relation_sym) { :notes }
+ let(:mapped_user) { create(:user) }
+ let(:exported_member) do
+ {
+ 'id' => 111,
+ 'access_level' => 30,
+ 'source_id' => 1,
+ 'source_type' => importable.class.name == 'Project' ? 'Project' : 'Namespace',
+ 'user_id' => 3,
+ 'notification_level' => 3,
+ 'created_at' => '2016-11-18T09:29:42.634Z',
+ 'updated_at' => '2016-11-18T09:29:42.634Z',
+ 'user' => {
+ 'id' => 999,
+ 'email' => mapped_user.email,
+ 'username' => mapped_user.username
+ }
+ }
+ end
+
+ let(:members_mapper) do
+ Gitlab::ImportExport::MembersMapper.new(
+ exported_members: [exported_member].compact,
+ user: importer_user,
+ importable: importable
+ )
+ end
+
+ shared_examples 'sets the note author to the importer user' do
+ it { expect(created_object.author).to eq(importer_user) }
+ end
+
+ shared_examples 'sets the note author to the mapped user' do
+ it { expect(created_object.author).to eq(mapped_user) }
+ end
+
+ shared_examples 'does not add original autor note' do
+ it { expect(created_object.note).not_to include('*By Administrator') }
+ end
+
+ shared_examples 'adds original autor note' do
+ it { expect(created_object.note).to include('*By Administrator') }
+ end
+
+ context 'when the importer is admin' do
+ let(:importer_user) { create(:admin) }
+
+ context 'and the note author is not mapped' do
+ let(:exported_member) { nil }
+
+ include_examples 'sets the note author to the importer user'
+
+ include_examples 'adds original autor note'
+ end
+
+ context 'and the note author is the importer user' do
+ let(:mapped_user) { importer_user }
+
+ include_examples 'sets the note author to the mapped user'
+
+ include_examples 'does not add original autor note'
+ end
+
+ context 'and the note author exists in the target instance' do
+ let(:mapped_user) { create(:user) }
+
+ include_examples 'sets the note author to the mapped user'
+
+ include_examples 'does not add original autor note'
+ end
+ end
+
+ context 'when the importer is not admin' do
+ let(:importer_user) { create(:user) }
+
+ context 'and the note author is not mapped' do
+ let(:exported_member) { nil }
+
+ include_examples 'sets the note author to the importer user'
+
+ include_examples 'adds original autor note'
+ end
+
+ context 'and the note author is the importer user' do
+ let(:mapped_user) { importer_user }
+
+ include_examples 'sets the note author to the importer user'
+
+ include_examples 'adds original autor note'
+ end
+
+ context 'and the note author exists in the target instance' do
+ let(:mapped_user) { create(:user) }
+
+ include_examples 'sets the note author to the importer user'
+
+ include_examples 'adds original autor note'
+ end
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/search_confidential_filter_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/search_confidential_filter_shared_examples.rb
new file mode 100644
index 00000000000..6c8ab38413d
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/search_confidential_filter_shared_examples.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'search results filtered by confidential' do
+ context 'filter not provided (all behavior)' do
+ let(:filters) { {} }
+
+ context 'when Feature search_filter_by_confidential enabled' do
+ it 'returns confidential and not confidential results', :aggregate_failures do
+ expect(results.objects('issues')).to include confidential_result
+ expect(results.objects('issues')).to include opened_result
+ end
+ end
+
+ context 'when Feature search_filter_by_confidential not enabled' do
+ before do
+ stub_feature_flags(search_filter_by_confidential: false)
+ end
+
+ it 'returns confidential and not confidential results', :aggregate_failures do
+ expect(results.objects('issues')).to include confidential_result
+ expect(results.objects('issues')).to include opened_result
+ end
+ end
+ end
+
+ context 'confidential filter' do
+ let(:filters) { { confidential: 'yes' } }
+
+ context 'when Feature search_filter_by_confidential enabled' do
+ it 'returns only confidential results', :aggregate_failures do
+ expect(results.objects('issues')).to include confidential_result
+ expect(results.objects('issues')).not_to include opened_result
+ end
+ end
+
+ context 'when Feature search_filter_by_confidential not enabled' do
+ before do
+ stub_feature_flags(search_filter_by_confidential: false)
+ end
+
+ it 'returns confidential and not confidential results', :aggregate_failures do
+ expect(results.objects('issues')).to include confidential_result
+ expect(results.objects('issues')).to include opened_result
+ end
+ end
+ end
+
+ context 'not confidential filter' do
+ let(:filters) { { confidential: 'no' } }
+
+ context 'when Feature search_filter_by_confidential enabled' do
+ it 'returns not confidential results', :aggregate_failures do
+ expect(results.objects('issues')).not_to include confidential_result
+ expect(results.objects('issues')).to include opened_result
+ end
+ end
+
+ context 'when Feature search_filter_by_confidential not enabled' do
+ before do
+ stub_feature_flags(search_filter_by_confidential: false)
+ end
+
+ it 'returns confidential and not confidential results', :aggregate_failures do
+ expect(results.objects('issues')).to include confidential_result
+ expect(results.objects('issues')).to include opened_result
+ end
+ end
+ end
+
+ context 'unsupported filter' do
+ let(:filters) { { confidential: 'goodbye' } }
+
+ context 'when Feature search_filter_by_confidential enabled' do
+ it 'returns confidential and not confidential results', :aggregate_failures do
+ expect(results.objects('issues')).to include confidential_result
+ expect(results.objects('issues')).to include opened_result
+ end
+ end
+
+ context 'when Feature search_filter_by_confidential not enabled' do
+ before do
+ stub_feature_flags(search_filter_by_confidential: false)
+ end
+
+ it 'returns confidential and not confidential results', :aggregate_failures do
+ expect(results.objects('issues')).to include confidential_result
+ expect(results.objects('issues')).to include opened_result
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/search_results_sorted_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/search_results_sorted_shared_examples.rb
new file mode 100644
index 00000000000..765279a78fe
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/search_results_sorted_shared_examples.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'search results sorted' do
+ context 'sort: newest' do
+ let(:sort) { 'newest' }
+
+ it 'sorts results by created_at' do
+ expect(results.objects(scope).map(&:id)).to eq([new_result.id, old_result.id, very_old_result.id])
+ end
+ end
+
+ context 'sort: oldest' do
+ let(:sort) { 'oldest' }
+
+ it 'sorts results by created_at' do
+ expect(results.objects(scope).map(&:id)).to eq([very_old_result.id, old_result.id, new_result.id])
+ end
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/search_issue_state_filter_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/search_state_filter_shared_examples.rb
index e80ec516407..e80ec516407 100644
--- a/spec/support/shared_examples/lib/gitlab/search_issue_state_filter_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/search_state_filter_shared_examples.rb
diff --git a/spec/support/shared_examples/models/mentionable_shared_examples.rb b/spec/support/shared_examples/models/mentionable_shared_examples.rb
index 94c52bdaaa6..0ee0b7e6d88 100644
--- a/spec/support/shared_examples/models/mentionable_shared_examples.rb
+++ b/spec/support/shared_examples/models/mentionable_shared_examples.rb
@@ -207,29 +207,8 @@ RSpec.shared_examples 'an editable mentionable' do
end
RSpec.shared_examples 'mentions in description' do |mentionable_type|
- describe 'when store_mentioned_users_to_db feature disabled' do
+ describe 'when storing user mentions' do
before do
- stub_feature_flags(store_mentioned_users_to_db: false)
- mentionable.store_mentions!
- end
-
- context 'when mentionable description contains mentions' do
- let(:user) { create(:user) }
- let(:mentionable) { create(mentionable_type, description: "#{user.to_reference} some description") }
-
- it 'stores no mentions' do
- expect(mentionable.user_mentions.count).to eq 0
- end
-
- it 'renders description_html correctly' do
- expect(mentionable.description_html).to include("<a href=\"/#{user.username}\" data-user=\"#{user.id}\"")
- end
- end
- end
-
- describe 'when store_mentioned_users_to_db feature enabled' do
- before do
- stub_feature_flags(store_mentioned_users_to_db: true)
mentionable.store_mentions!
end
diff --git a/spec/support/shared_examples/models/relative_positioning_shared_examples.rb b/spec/support/shared_examples/models/relative_positioning_shared_examples.rb
index d1437244082..7cf014e8023 100644
--- a/spec/support/shared_examples/models/relative_positioning_shared_examples.rb
+++ b/spec/support/shared_examples/models/relative_positioning_shared_examples.rb
@@ -31,6 +31,41 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end
end
+ def as_item(item)
+ item # Override to perform a transformation, if necessary
+ end
+
+ def as_items(items)
+ items.map { |item| as_item(item) }
+ end
+
+ describe '#scoped_items' do
+ it 'includes all items with the same scope' do
+ scope = as_items([item1, item2, new_item, create_item])
+ irrelevant = create(factory, {}) # This should not share the scope
+ context = RelativePositioning.mover.context(item1)
+
+ same_scope = as_items(context.scoped_items)
+
+ expect(same_scope).to include(*scope)
+ expect(same_scope).not_to include(as_item(irrelevant))
+ end
+ end
+
+ describe '#relative_siblings' do
+ it 'includes all items with the same scope, except self' do
+ scope = as_items([item2, new_item, create_item])
+ irrelevant = create(factory, {}) # This should not share the scope
+ context = RelativePositioning.mover.context(item1)
+
+ siblings = as_items(context.relative_siblings)
+
+ expect(siblings).to include(*scope)
+ expect(siblings).not_to include(as_item(item1))
+ expect(siblings).not_to include(as_item(irrelevant))
+ end
+ end
+
describe '.move_nulls_to_end' do
let(:item3) { create_item }
let(:sibling_query) { item1.class.relative_positioning_query_base(item1) }
@@ -47,7 +82,7 @@ RSpec.shared_examples 'a class that supports relative positioning' do
expect(item1.relative_position).to be(1000)
expect(sibling_query.where(relative_position: nil)).not_to exist
- expect(sibling_query.reorder(:relative_position, :id)).to eq([item1, item2, item3])
+ expect(as_items(sibling_query.reorder(:relative_position, :id))).to eq(as_items([item1, item2, item3]))
end
it 'preserves relative position' do
@@ -120,16 +155,16 @@ RSpec.shared_examples 'a class that supports relative positioning' do
it 'does not have an N+1 issue' do
create_items_with_positions(10..12)
- a, b, c, d, e, f = create_items_with_positions([nil, nil, nil, nil, nil, nil])
+ a, b, c, d, e, f, *xs = create_items_with_positions([nil] * 10)
baseline = ActiveRecord::QueryRecorder.new do
- described_class.move_nulls_to_end([a, e])
+ described_class.move_nulls_to_end([a, b])
end
- expect { described_class.move_nulls_to_end([b, c, d]) }
+ expect { described_class.move_nulls_to_end([c, d, e, f]) }
.not_to exceed_query_limit(baseline)
- expect { described_class.move_nulls_to_end([f]) }
+ expect { described_class.move_nulls_to_end(xs) }
.not_to exceed_query_limit(baseline.count)
end
end
@@ -149,7 +184,7 @@ RSpec.shared_examples 'a class that supports relative positioning' do
expect(items.sort_by(&:relative_position)).to eq(items)
expect(sibling_query.where(relative_position: nil)).not_to exist
- expect(sibling_query.reorder(:relative_position, :id)).to eq(items)
+ expect(as_items(sibling_query.reorder(:relative_position, :id))).to eq(as_items(items))
expect(item3.relative_position).to be(1000)
end
@@ -652,3 +687,119 @@ RSpec.shared_examples 'a class that supports relative positioning' do
(RelativePositioning::MIN_POSITION..).take(size)
end
end
+
+RSpec.shared_examples 'no-op relative positioning' do
+ def create_item(**params)
+ create(factory, params.merge(default_params))
+ end
+
+ let_it_be(:item1) { create_item }
+ let_it_be(:item2) { create_item }
+ let_it_be(:new_item) { create_item(relative_position: nil) }
+
+ def any_relative_positions
+ new_item.class.reorder(:relative_position, :id).pluck(:id, :relative_position)
+ end
+
+ shared_examples 'a no-op method' do
+ it 'does not raise errors' do
+ expect { perform }.not_to raise_error
+ end
+
+ it 'does not perform any DB queries' do
+ expect { perform }.not_to exceed_query_limit(0)
+ end
+
+ it 'does not change any relative_position' do
+ expect { perform }.not_to change { any_relative_positions }
+ end
+ end
+
+ describe '.scoped_items' do
+ subject { RelativePositioning.mover.context(item1).scoped_items }
+
+ it 'is empty' do
+ expect(subject).to be_empty
+ end
+ end
+
+ describe '.relative_siblings' do
+ subject { RelativePositioning.mover.context(item1).relative_siblings }
+
+ it 'is empty' do
+ expect(subject).to be_empty
+ end
+ end
+
+ describe '.move_nulls_to_end' do
+ subject { item1.class.move_nulls_to_end([new_item, item1]) }
+
+ it_behaves_like 'a no-op method' do
+ def perform
+ subject
+ end
+ end
+
+ it 'does not move any items' do
+ expect(subject).to eq(0)
+ end
+ end
+
+ describe '.move_nulls_to_start' do
+ subject { item1.class.move_nulls_to_start([new_item, item1]) }
+
+ it_behaves_like 'a no-op method' do
+ def perform
+ subject
+ end
+ end
+
+ it 'does not move any items' do
+ expect(subject).to eq(0)
+ end
+ end
+
+ describe 'instance methods' do
+ subject { new_item }
+
+ describe '#move_to_start' do
+ it_behaves_like 'a no-op method' do
+ def perform
+ subject.move_to_start
+ end
+ end
+ end
+
+ describe '#move_to_end' do
+ it_behaves_like 'a no-op method' do
+ def perform
+ subject.move_to_end
+ end
+ end
+ end
+
+ describe '#move_between' do
+ it_behaves_like 'a no-op method' do
+ def perform
+ subject.move_between(item1, item2)
+ end
+ end
+ end
+
+ describe '#move_before' do
+ it_behaves_like 'a no-op method' do
+ def perform
+ subject.move_before(item1)
+ end
+ end
+ end
+
+ describe '#move_after' do
+ it_behaves_like 'a no-op method' do
+ def perform
+ subject.move_after(item1)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/resource_timebox_event_shared_examples.rb b/spec/support/shared_examples/models/resource_timebox_event_shared_examples.rb
index 07552b62cdd..5198508d48b 100644
--- a/spec/support/shared_examples/models/resource_timebox_event_shared_examples.rb
+++ b/spec/support/shared_examples/models/resource_timebox_event_shared_examples.rb
@@ -73,3 +73,13 @@ RSpec.shared_examples 'timebox resource event actions' do
end
end
end
+
+RSpec.shared_examples 'timebox resource tracks issue metrics' do |type|
+ describe '#usage_metrics' do
+ it 'tracks usage' do
+ expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).to receive(:"track_issue_#{type}_changed_action")
+
+ create(described_class.name.underscore.to_sym, issue: create(:issue))
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/throttled_touch_shared_examples.rb b/spec/support/shared_examples/models/throttled_touch_shared_examples.rb
index 14b851d2828..e869cbce6ae 100644
--- a/spec/support/shared_examples/models/throttled_touch_shared_examples.rb
+++ b/spec/support/shared_examples/models/throttled_touch_shared_examples.rb
@@ -13,8 +13,8 @@ RSpec.shared_examples 'throttled touch' do
first_updated_at = Time.zone.now - (ThrottledTouch::TOUCH_INTERVAL * 2)
second_updated_at = Time.zone.now - (ThrottledTouch::TOUCH_INTERVAL * 1.5)
- Timecop.freeze(first_updated_at) { subject.touch }
- Timecop.freeze(second_updated_at) { subject.touch }
+ travel_to(first_updated_at) { subject.touch }
+ travel_to(second_updated_at) { subject.touch }
expect(subject.updated_at).to be_like_time(first_updated_at)
end
diff --git a/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb b/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb
index 557025569b8..7b591ad84d1 100644
--- a/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb
+++ b/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-RSpec.shared_examples 'UpdateProjectStatistics' do
+RSpec.shared_examples 'UpdateProjectStatistics' do |with_counter_attribute|
let(:project) { subject.project }
let(:project_statistics_name) { described_class.project_statistics_name }
let(:statistic_attribute) { described_class.statistic_attribute }
@@ -13,108 +13,230 @@ RSpec.shared_examples 'UpdateProjectStatistics' do
subject.read_attribute(statistic_attribute).to_i
end
- it { is_expected.to be_new_record }
+ def read_pending_increment
+ Gitlab::Redis::SharedState.with do |redis|
+ key = project.statistics.counter_key(project_statistics_name)
+ redis.get(key).to_i
+ end
+ end
- context 'when creating' do
- it 'updates the project statistics' do
- delta0 = reload_stat
+ it { is_expected.to be_new_record }
- subject.save!
+ context 'when feature flag efficient_counter_attribute is disabled' do
+ before do
+ stub_feature_flags(efficient_counter_attribute: false)
+ end
- delta1 = reload_stat
+ context 'when creating' do
+ it 'updates the project statistics' do
+ delta0 = reload_stat
- expect(delta1).to eq(delta0 + read_attribute)
- expect(delta1).to be > delta0
- end
+ subject.save!
- it 'schedules a namespace statistics worker' do
- expect(Namespaces::ScheduleAggregationWorker)
- .to receive(:perform_async).once
+ delta1 = reload_stat
- subject.save!
- end
- end
+ expect(delta1).to eq(delta0 + read_attribute)
+ expect(delta1).to be > delta0
+ end
- context 'when updating' do
- let(:delta) { 42 }
+ it 'schedules a namespace statistics worker' do
+ expect(Namespaces::ScheduleAggregationWorker)
+ .to receive(:perform_async).once
- before do
- subject.save!
+ subject.save!
+ end
end
- it 'updates project statistics' do
- expect(ProjectStatistics)
- .to receive(:increment_statistic)
- .and_call_original
+ context 'when updating' do
+ let(:delta) { 42 }
- subject.write_attribute(statistic_attribute, read_attribute + delta)
+ before do
+ subject.save!
+ end
- expect { subject.save! }
- .to change { reload_stat }
- .by(delta)
- end
+ it 'updates project statistics' do
+ expect(ProjectStatistics)
+ .to receive(:increment_statistic)
+ .and_call_original
- it 'schedules a namespace statistics worker' do
- expect(Namespaces::ScheduleAggregationWorker)
- .to receive(:perform_async).once
+ subject.write_attribute(statistic_attribute, read_attribute + delta)
- subject.write_attribute(statistic_attribute, read_attribute + delta)
- subject.save!
- end
+ expect { subject.save! }
+ .to change { reload_stat }
+ .by(delta)
+ end
- it 'avoids N + 1 queries' do
- subject.write_attribute(statistic_attribute, read_attribute + delta)
+ it 'schedules a namespace statistics worker' do
+ expect(Namespaces::ScheduleAggregationWorker)
+ .to receive(:perform_async).once
- control_count = ActiveRecord::QueryRecorder.new do
+ subject.write_attribute(statistic_attribute, read_attribute + delta)
subject.save!
end
- subject.write_attribute(statistic_attribute, read_attribute + delta)
+ it 'avoids N + 1 queries' do
+ subject.write_attribute(statistic_attribute, read_attribute + delta)
- expect do
- subject.save!
- end.not_to exceed_query_limit(control_count)
- end
- end
+ control_count = ActiveRecord::QueryRecorder.new do
+ subject.save!
+ end
- context 'when destroying' do
- before do
- subject.save!
+ subject.write_attribute(statistic_attribute, read_attribute + delta)
+
+ expect do
+ subject.save!
+ end.not_to exceed_query_limit(control_count)
+ end
end
- it 'updates the project statistics' do
- delta0 = reload_stat
+ context 'when destroying' do
+ before do
+ subject.save!
+ end
- subject.destroy!
+ it 'updates the project statistics' do
+ delta0 = reload_stat
- delta1 = reload_stat
+ subject.destroy!
- expect(delta1).to eq(delta0 - read_attribute)
- expect(delta1).to be < delta0
- end
+ delta1 = reload_stat
+
+ expect(delta1).to eq(delta0 - read_attribute)
+ expect(delta1).to be < delta0
+ end
+
+ it 'schedules a namespace statistics worker' do
+ expect(Namespaces::ScheduleAggregationWorker)
+ .to receive(:perform_async).once
- it 'schedules a namespace statistics worker' do
- expect(Namespaces::ScheduleAggregationWorker)
- .to receive(:perform_async).once
+ subject.destroy!
+ end
+
+ context 'when it is destroyed from the project level' do
+ it 'does not update the project statistics' do
+ expect(ProjectStatistics)
+ .not_to receive(:increment_statistic)
+
+ project.update!(pending_delete: true)
+ project.destroy!
+ end
+
+ it 'does not schedule a namespace statistics worker' do
+ expect(Namespaces::ScheduleAggregationWorker)
+ .not_to receive(:perform_async)
- subject.destroy!
+ project.update!(pending_delete: true)
+ project.destroy!
+ end
+ end
end
+ end
- context 'when it is destroyed from the project level' do
- it 'does not update the project statistics' do
- expect(ProjectStatistics)
- .not_to receive(:increment_statistic)
+ def expect_flush_counter_increments_worker_performed
+ expect(FlushCounterIncrementsWorker)
+ .to receive(:perform_in)
+ .with(CounterAttribute::WORKER_DELAY, project.statistics.class.name, project.statistics.id, project_statistics_name)
+ expect(FlushCounterIncrementsWorker)
+ .to receive(:perform_in)
+ .with(CounterAttribute::WORKER_DELAY, project.statistics.class.name, project.statistics.id, :storage_size)
- project.update!(pending_delete: true)
- project.destroy!
+ yield
+
+ # simulate worker running now
+ expect(Namespaces::ScheduleAggregationWorker).to receive(:perform_async)
+ FlushCounterIncrementsWorker.new.perform(project.statistics.class.name, project.statistics.id, project_statistics_name)
+ end
+
+ if with_counter_attribute
+ context 'when statistic is a counter attribute', :clean_gitlab_redis_shared_state do
+ context 'when creating' do
+ it 'stores pending increments for async update' do
+ initial_stat = reload_stat
+ expected_increment = read_attribute
+
+ expect_flush_counter_increments_worker_performed do
+ subject.save!
+
+ expect(read_pending_increment).to eq(expected_increment)
+ expect(expected_increment).to be > initial_stat
+ expect(expected_increment).to be_positive
+ end
+ end
end
- it 'does not schedule a namespace statistics worker' do
- expect(Namespaces::ScheduleAggregationWorker)
- .not_to receive(:perform_async)
+ context 'when updating' do
+ let(:delta) { 42 }
+
+ before do
+ subject.save!
+ redis_shared_state_cleanup!
+ end
+
+ it 'stores pending increments for async update' do
+ expect(ProjectStatistics)
+ .to receive(:increment_statistic)
+ .and_call_original
+
+ subject.write_attribute(statistic_attribute, read_attribute + delta)
+
+ expect_flush_counter_increments_worker_performed do
+ subject.save!
+
+ expect(read_pending_increment).to eq(delta)
+ end
+ end
+
+ it 'avoids N + 1 queries' do
+ subject.write_attribute(statistic_attribute, read_attribute + delta)
+
+ control_count = ActiveRecord::QueryRecorder.new do
+ subject.save!
+ end
+
+ subject.write_attribute(statistic_attribute, read_attribute + delta)
+
+ expect do
+ subject.save!
+ end.not_to exceed_query_limit(control_count)
+ end
+ end
- project.update!(pending_delete: true)
- project.destroy!
+ context 'when destroying' do
+ before do
+ subject.save!
+ redis_shared_state_cleanup!
+ end
+
+ it 'stores pending increment for async update' do
+ initial_stat = reload_stat
+ expected_increment = -read_attribute
+
+ expect_flush_counter_increments_worker_performed do
+ subject.destroy!
+
+ expect(read_pending_increment).to eq(expected_increment)
+ expect(expected_increment).to be < initial_stat
+ expect(expected_increment).to be_negative
+ end
+ end
+
+ context 'when it is destroyed from the project level' do
+ it 'does not update the project statistics' do
+ expect(ProjectStatistics)
+ .not_to receive(:increment_statistic)
+
+ project.update!(pending_delete: true)
+ project.destroy!
+ end
+
+ it 'does not schedule a namespace statistics worker' do
+ expect(Namespaces::ScheduleAggregationWorker)
+ .not_to receive(:perform_async)
+
+ project.update!(pending_delete: true)
+ project.destroy!
+ end
+ end
end
end
end
diff --git a/spec/support/shared_examples/models/wiki_shared_examples.rb b/spec/support/shared_examples/models/wiki_shared_examples.rb
index b87f7fe97e1..336a734a25f 100644
--- a/spec/support/shared_examples/models/wiki_shared_examples.rb
+++ b/spec/support/shared_examples/models/wiki_shared_examples.rb
@@ -164,7 +164,7 @@ RSpec.shared_examples 'wiki model' do
def total_pages(entries)
entries.sum do |entry|
- entry.is_a?(WikiDirectory) ? entry.pages.size : 1
+ entry.is_a?(WikiDirectory) ? total_pages(entry.entries) : 1
end
end
diff --git a/spec/support/shared_examples/policies/resource_access_token_shared_examples.rb b/spec/support/shared_examples/policies/resource_access_token_shared_examples.rb
new file mode 100644
index 00000000000..7710e756e5b
--- /dev/null
+++ b/spec/support/shared_examples/policies/resource_access_token_shared_examples.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'Self-managed Core resource access tokens' do
+ before do
+ allow(::Gitlab).to receive(:com?).and_return(false)
+ end
+
+ context 'with owner' do
+ let(:current_user) { owner }
+
+ it { is_expected.to be_allowed(:admin_resource_access_tokens) }
+ end
+
+ context 'with developer' do
+ let(:current_user) { developer }
+
+ it { is_expected.not_to be_allowed(:admin_resource_access_tokens) }
+ end
+end
+
+RSpec.shared_examples 'GitLab.com Core resource access tokens' do
+ before do
+ allow(::Gitlab).to receive(:com?).and_return(true)
+ stub_ee_application_setting(should_check_namespace_plan: true)
+ end
+
+ context 'with owner' do
+ let(:current_user) { owner }
+
+ it { is_expected.not_to be_allowed(:admin_resource_access_tokens) }
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb
index 5c122b4b5d6..4b5299cebec 100644
--- a/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb
@@ -75,7 +75,7 @@ RSpec.shared_examples 'Composer package creation' do |user_type, status, add_mem
expect(response).to have_gitlab_http_status(status)
end
- it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package'
+ it_behaves_like 'a package tracking event', described_class.name, 'push_package'
end
end
diff --git a/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb b/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb
index 3e058838773..e776cf13217 100644
--- a/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb
@@ -79,11 +79,3 @@ RSpec.shared_examples 'returns repositories for allowed users' do |user_type, sc
end
end
end
-
-RSpec.shared_examples 'a gitlab tracking event' do |category, action|
- it "creates a gitlab tracking event #{action}" do
- expect(Gitlab::Tracking).to receive(:event).with(category, action, {})
-
- subject
- end
-end
diff --git a/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb
new file mode 100644
index 00000000000..ec32cb4b2ff
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb
@@ -0,0 +1,309 @@
+# frozen_string_literal: true
+
+RSpec.shared_context 'Debian repository shared context' do |object_type|
+ before do
+ stub_feature_flags(debian_packages: true)
+ end
+
+ if object_type == :project
+ let(:project) { create(:project, :public) }
+ elsif object_type == :group
+ let(:group) { create(:group, :public) }
+ end
+
+ let(:user) { create(:user) }
+ let(:personal_access_token) { create(:personal_access_token, user: user) }
+
+ let(:distribution) { 'bullseye' }
+ let(:component) { 'main' }
+ let(:architecture) { 'amd64' }
+ let(:source_package) { 'sample' }
+ let(:letter) { source_package[0..2] == 'lib' ? source_package[0..3] : source_package[0] }
+ let(:package_name) { 'libsample0' }
+ let(:package_version) { '1.2.3~alpha2-1' }
+ let(:file_name) { "#{package_name}_#{package_version}_#{architecture}.deb" }
+
+ let(:method) { :get }
+
+ let(:workhorse_params) do
+ if method == :put
+ file_upload = fixture_file_upload("spec/fixtures/packages/debian/#{file_name}")
+ { file: file_upload }
+ else
+ {}
+ end
+ end
+
+ let(:params) { workhorse_params }
+
+ let(:auth_headers) { {} }
+ let(:workhorse_headers) do
+ if method == :put
+ workhorse_token = JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256')
+ { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token }
+ else
+ {}
+ end
+ end
+
+ let(:headers) { auth_headers.merge(workhorse_headers) }
+
+ let(:send_rewritten_field) { true }
+
+ subject do
+ if method == :put
+ workhorse_finalize(
+ api(url),
+ method: method,
+ file_key: :file,
+ params: params,
+ headers: headers,
+ send_rewritten_field: send_rewritten_field
+ )
+ else
+ send method, api(url), headers: headers, params: params
+ end
+ end
+end
+
+RSpec.shared_context 'Debian repository auth headers' do |user_role, user_token, auth_method = :token|
+ let(:token) { user_token ? personal_access_token.token : 'wrong' }
+
+ let(:auth_headers) do
+ if user_role == :anonymous
+ {}
+ elsif auth_method == :token
+ { 'Private-Token' => token }
+ else
+ basic_auth_header(user.username, token)
+ end
+ end
+end
+
+RSpec.shared_context 'Debian repository project access' do |project_visibility_level, user_role, user_token, auth_method|
+ include_context 'Debian repository auth headers', user_role, user_token, auth_method do
+ before do
+ project.update_column(:visibility_level, Gitlab::VisibilityLevel.const_get(project_visibility_level, false))
+ end
+ end
+end
+
+RSpec.shared_examples 'Debian project repository GET request' do |user_role, add_member, status, body|
+ context "for user type #{user_role}" do
+ before do
+ project.send("add_#{user_role}", user) if add_member && user_role != :anonymous
+ end
+
+ and_body = body.nil? ? '' : ' and expected body'
+
+ it "returns #{status}#{and_body}" do
+ subject
+
+ expect(response).to have_gitlab_http_status(status)
+
+ unless body.nil?
+ expect(response.body).to eq(body)
+ end
+ end
+ end
+end
+
+RSpec.shared_examples 'Debian project repository PUT request' do |user_role, add_member, status, body|
+ context "for user type #{user_role}" do
+ before do
+ project.send("add_#{user_role}", user) if add_member && user_role != :anonymous
+ end
+
+ and_body = body.nil? ? '' : ' and expected body'
+
+ if status == :created
+ it 'creates package files' do
+ pending "Debian package creation not implemented"
+ expect { subject }
+ .to change { project.packages.debian.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(status)
+
+ unless body.nil?
+ expect(response.body).to eq(body)
+ end
+ end
+ it_behaves_like 'a package tracking event', described_class.name, 'push_package'
+ else
+ it "returns #{status}#{and_body}" do
+ subject
+
+ expect(response).to have_gitlab_http_status(status)
+
+ unless body.nil?
+ expect(response.body).to eq(body)
+ end
+ end
+ end
+ end
+end
+
+RSpec.shared_examples 'rejects Debian access with unknown project id' do
+ context 'with an unknown project' do
+ let(:project) { double(id: non_existing_record_id) }
+
+ context 'as anonymous' do
+ it_behaves_like 'Debian project repository GET request', :anonymous, true, :not_found, nil
+ end
+
+ context 'as authenticated user' do
+ subject { get api(url), headers: basic_auth_header(user.username, personal_access_token.token) }
+
+ it_behaves_like 'Debian project repository GET request', :anonymous, true, :not_found, nil
+ end
+ end
+end
+
+RSpec.shared_examples 'Debian project repository GET endpoint' do |success_status, success_body|
+ context 'with valid project' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:project_visibility_level, :user_role, :member, :user_token, :expected_status, :expected_body) do
+ 'PUBLIC' | :developer | true | true | success_status | success_body
+ 'PUBLIC' | :guest | true | true | success_status | success_body
+ 'PUBLIC' | :developer | true | false | success_status | success_body
+ 'PUBLIC' | :guest | true | false | success_status | success_body
+ 'PUBLIC' | :developer | false | true | success_status | success_body
+ 'PUBLIC' | :guest | false | true | success_status | success_body
+ 'PUBLIC' | :developer | false | false | success_status | success_body
+ 'PUBLIC' | :guest | false | false | success_status | success_body
+ 'PUBLIC' | :anonymous | false | true | success_status | success_body
+ 'PRIVATE' | :developer | true | true | success_status | success_body
+ 'PRIVATE' | :guest | true | true | :forbidden | nil
+ 'PRIVATE' | :developer | true | false | :not_found | nil
+ 'PRIVATE' | :guest | true | false | :not_found | nil
+ 'PRIVATE' | :developer | false | true | :not_found | nil
+ 'PRIVATE' | :guest | false | true | :not_found | nil
+ 'PRIVATE' | :developer | false | false | :not_found | nil
+ 'PRIVATE' | :guest | false | false | :not_found | nil
+ 'PRIVATE' | :anonymous | false | true | :not_found | nil
+ end
+
+ with_them do
+ include_context 'Debian repository project access', params[:project_visibility_level], params[:user_role], params[:user_token], :basic do
+ it_behaves_like 'Debian project repository GET request', params[:user_role], params[:member], params[:expected_status], params[:expected_body]
+ end
+ end
+ end
+
+ it_behaves_like 'rejects Debian access with unknown project id'
+end
+
+RSpec.shared_examples 'Debian project repository PUT endpoint' do |success_status, success_body|
+ context 'with valid project' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:project_visibility_level, :user_role, :member, :user_token, :expected_status, :expected_body) do
+ 'PUBLIC' | :developer | true | true | success_status | nil
+ 'PUBLIC' | :guest | true | true | :forbidden | nil
+ 'PUBLIC' | :developer | true | false | :unauthorized | nil
+ 'PUBLIC' | :guest | true | false | :unauthorized | nil
+ 'PUBLIC' | :developer | false | true | :forbidden | nil
+ 'PUBLIC' | :guest | false | true | :forbidden | nil
+ 'PUBLIC' | :developer | false | false | :unauthorized | nil
+ 'PUBLIC' | :guest | false | false | :unauthorized | nil
+ 'PUBLIC' | :anonymous | false | true | :unauthorized | nil
+ 'PRIVATE' | :developer | true | true | success_status | nil
+ 'PRIVATE' | :guest | true | true | :forbidden | nil
+ 'PRIVATE' | :developer | true | false | :not_found | nil
+ 'PRIVATE' | :guest | true | false | :not_found | nil
+ 'PRIVATE' | :developer | false | true | :not_found | nil
+ 'PRIVATE' | :guest | false | true | :not_found | nil
+ 'PRIVATE' | :developer | false | false | :not_found | nil
+ 'PRIVATE' | :guest | false | false | :not_found | nil
+ 'PRIVATE' | :anonymous | false | true | :not_found | nil
+ end
+
+ with_them do
+ include_context 'Debian repository project access', params[:project_visibility_level], params[:user_role], params[:user_token], :basic do
+ it_behaves_like 'Debian project repository PUT request', params[:user_role], params[:member], params[:expected_status], params[:expected_body]
+ end
+ end
+ end
+
+ it_behaves_like 'rejects Debian access with unknown project id'
+end
+
+RSpec.shared_context 'Debian repository group access' do |group_visibility_level, user_role, user_token, auth_method|
+ include_context 'Debian repository auth headers', user_role, user_token, auth_method do
+ before do
+ group.update_column(:visibility_level, Gitlab::VisibilityLevel.const_get(group_visibility_level, false))
+ end
+ end
+end
+
+RSpec.shared_examples 'Debian group repository GET request' do |user_role, add_member, status, body|
+ context "for user type #{user_role}" do
+ before do
+ group.send("add_#{user_role}", user) if add_member && user_role != :anonymous
+ end
+
+ and_body = body.nil? ? '' : ' and expected body'
+
+ it "returns #{status}#{and_body}" do
+ subject
+
+ expect(response).to have_gitlab_http_status(status)
+
+ unless body.nil?
+ expect(response.body).to eq(body)
+ end
+ end
+ end
+end
+
+RSpec.shared_examples 'rejects Debian access with unknown group id' do
+ context 'with an unknown group' do
+ let(:group) { double(id: non_existing_record_id) }
+
+ context 'as anonymous' do
+ it_behaves_like 'Debian group repository GET request', :anonymous, true, :not_found, nil
+ end
+
+ context 'as authenticated user' do
+ subject { get api(url), headers: basic_auth_header(user.username, personal_access_token.token) }
+
+ it_behaves_like 'Debian group repository GET request', :anonymous, true, :not_found, nil
+ end
+ end
+end
+
+RSpec.shared_examples 'Debian group repository GET endpoint' do |success_status, success_body|
+ context 'with valid group' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:group_visibility_level, :user_role, :member, :user_token, :expected_status, :expected_body) do
+ 'PUBLIC' | :developer | true | true | success_status | success_body
+ 'PUBLIC' | :guest | true | true | success_status | success_body
+ 'PUBLIC' | :developer | true | false | success_status | success_body
+ 'PUBLIC' | :guest | true | false | success_status | success_body
+ 'PUBLIC' | :developer | false | true | success_status | success_body
+ 'PUBLIC' | :guest | false | true | success_status | success_body
+ 'PUBLIC' | :developer | false | false | success_status | success_body
+ 'PUBLIC' | :guest | false | false | success_status | success_body
+ 'PUBLIC' | :anonymous | false | true | success_status | success_body
+ 'PRIVATE' | :developer | true | true | success_status | success_body
+ 'PRIVATE' | :guest | true | true | :forbidden | nil
+ 'PRIVATE' | :developer | true | false | :not_found | nil
+ 'PRIVATE' | :guest | true | false | :not_found | nil
+ 'PRIVATE' | :developer | false | true | :not_found | nil
+ 'PRIVATE' | :guest | false | true | :not_found | nil
+ 'PRIVATE' | :developer | false | false | :not_found | nil
+ 'PRIVATE' | :guest | false | false | :not_found | nil
+ 'PRIVATE' | :anonymous | false | true | :not_found | nil
+ end
+
+ with_them do
+ include_context 'Debian repository group access', params[:group_visibility_level], params[:user_role], params[:user_token], :basic do
+ it_behaves_like 'Debian group repository GET request', params[:user_role], params[:member], params[:expected_status], params[:expected_body]
+ end
+ end
+ end
+
+ it_behaves_like 'rejects Debian access with unknown group id'
+end
diff --git a/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb
index f26af6cb766..5145880ef9a 100644
--- a/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb
@@ -90,7 +90,7 @@ RSpec.shared_examples 'group and project boards query' do
it_behaves_like 'a working graphql query' do
before do
- post_graphql(query_single_board, current_user: current_user)
+ post_graphql(query_single_board("id: \"gid://gitlab/Board/1\""), current_user: current_user)
end
end
diff --git a/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb
index 6aac51a5903..58e99776fd9 100644
--- a/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb
@@ -26,7 +26,7 @@ RSpec.shared_examples 'process nuget service index request' do |user_type, statu
it_behaves_like 'returning response status', status
- it_behaves_like 'a gitlab tracking event', described_class.name, 'nuget_service_index'
+ it_behaves_like 'a package tracking event', described_class.name, 'cli_metadata'
it 'returns a valid json response' do
subject
@@ -169,7 +169,7 @@ RSpec.shared_examples 'process nuget upload' do |user_type, status, add_member =
context 'with correct params' do
it_behaves_like 'package workhorse uploads'
it_behaves_like 'creates nuget package files'
- it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package'
+ it_behaves_like 'a package tracking event', described_class.name, 'push_package'
end
end
@@ -286,7 +286,7 @@ RSpec.shared_examples 'process nuget download content request' do |user_type, st
it_behaves_like 'returning response status', status
- it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package'
+ it_behaves_like 'a package tracking event', described_class.name, 'pull_package'
it 'returns a valid package archive' do
subject
@@ -336,7 +336,7 @@ RSpec.shared_examples 'process nuget search request' do |user_type, status, add_
it_behaves_like 'returns a valid json search response', status, 4, [1, 5, 5, 1]
- it_behaves_like 'a gitlab tracking event', described_class.name, 'search_package'
+ it_behaves_like 'a package tracking event', described_class.name, 'search_package'
context 'with skip set to 2' do
let(:skip) { 2 }
diff --git a/spec/support/shared_examples/requests/api/packages_shared_examples.rb b/spec/support/shared_examples/requests/api/packages_shared_examples.rb
index c9a33701161..d730ed53109 100644
--- a/spec/support/shared_examples/requests/api/packages_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/packages_shared_examples.rb
@@ -126,3 +126,11 @@ RSpec.shared_examples 'job token for package uploads' do
end
end
end
+
+RSpec.shared_examples 'a package tracking event' do |category, action|
+ it "creates a gitlab tracking event #{action}" do
+ expect(Gitlab::Tracking).to receive(:event).with(category, action, {})
+
+ expect { subject }.to change { Packages::Event.count }.by(1)
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb
index 715c494840e..40bedc84366 100644
--- a/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb
@@ -52,7 +52,7 @@ RSpec.shared_examples 'PyPi package creation' do |user_type, status, add_member
context 'with correct params' do
it_behaves_like 'package workhorse uploads'
it_behaves_like 'creating pypi package files'
- it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package'
+ it_behaves_like 'a package tracking event', described_class.name, 'push_package'
end
end
@@ -119,7 +119,7 @@ RSpec.shared_examples 'PyPi package versions' do |user_type, status, add_member
end
it_behaves_like 'returning response status', status
- it_behaves_like 'a gitlab tracking event', described_class.name, 'list_package'
+ it_behaves_like 'a package tracking event', described_class.name, 'list_package'
end
end
@@ -136,7 +136,7 @@ RSpec.shared_examples 'PyPi package download' do |user_type, status, add_member
end
it_behaves_like 'returning response status', status
- it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package'
+ it_behaves_like 'a package tracking event', described_class.name, 'pull_package'
end
end
diff --git a/spec/support/shared_examples/requests/api/tracking_shared_examples.rb b/spec/support/shared_examples/requests/api/tracking_shared_examples.rb
new file mode 100644
index 00000000000..2e6feae3f98
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/tracking_shared_examples.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'a gitlab tracking event' do |category, action|
+ it "creates a gitlab tracking event #{action}" do
+ expect(Gitlab::Tracking).to receive(:event).with(category, action, {})
+
+ subject
+ end
+end
diff --git a/spec/support/shared_examples/services/boards/boards_create_service_shared_examples.rb b/spec/support/shared_examples/services/boards/boards_create_service_shared_examples.rb
index fced2e59ace..f28c78aec97 100644
--- a/spec/support/shared_examples/services/boards/boards_create_service_shared_examples.rb
+++ b/spec/support/shared_examples/services/boards/boards_create_service_shared_examples.rb
@@ -7,7 +7,7 @@ RSpec.shared_examples 'boards create service' do
end
it 'creates the default lists' do
- board = service.execute
+ board = service.execute.payload
expect(board.lists.size).to eq 2
expect(board.lists.first).to be_backlog
diff --git a/spec/support/shared_examples/services/merge_request_shared_examples.rb b/spec/support/shared_examples/services/merge_request_shared_examples.rb
index a7032640217..2bd06ac3e9c 100644
--- a/spec/support/shared_examples/services/merge_request_shared_examples.rb
+++ b/spec/support/shared_examples/services/merge_request_shared_examples.rb
@@ -13,11 +13,10 @@ RSpec.shared_examples 'reviewer_ids filter' do
end
context 'with reviewer_ids' do
- let(:reviewer_ids_param) { { reviewer_ids: [reviewer1.id, reviewer2.id, reviewer3.id] } }
+ let(:reviewer_ids_param) { { reviewer_ids: [reviewer1.id, reviewer2.id] } }
let(:reviewer1) { create(:user) }
let(:reviewer2) { create(:user) }
- let(:reviewer3) { create(:user) }
context 'when the current user can admin the merge_request' do
context 'when merge_request_reviewer feature is enabled' do
@@ -25,14 +24,13 @@ RSpec.shared_examples 'reviewer_ids filter' do
stub_feature_flags(merge_request_reviewer: true)
end
- context 'with reviewers who can read the merge_request' do
+ context 'with a reviewer who can read the merge_request' do
before do
project.add_developer(reviewer1)
- project.add_developer(reviewer2)
end
it 'contains reviewers who can read the merge_request' do
- expect(execute.reviewers).to contain_exactly(reviewer1, reviewer2)
+ expect(execute.reviewers).to contain_exactly(reviewer1)
end
end
end
diff --git a/spec/support/shared_examples/services/packages_shared_examples.rb b/spec/support/shared_examples/services/packages_shared_examples.rb
index 7fd59c3d963..c00a087311c 100644
--- a/spec/support/shared_examples/services/packages_shared_examples.rb
+++ b/spec/support/shared_examples/services/packages_shared_examples.rb
@@ -170,6 +170,7 @@ RSpec.shared_examples 'filters on each package_type' do |is_project: false|
let_it_be(:package5) { create(:pypi_package, project: project) }
let_it_be(:package6) { create(:composer_package, project: project) }
let_it_be(:package7) { create(:generic_package, project: project) }
+ let_it_be(:package8) { create(:golang_package, project: project) }
Packages::Package.package_types.keys.each do |package_type|
context "for package type #{package_type}" do
diff --git a/spec/support/shared_examples/services/projects/urls_with_escaped_elements_shared_example.rb b/spec/support/shared_examples/services/projects/urls_with_escaped_elements_shared_example.rb
new file mode 100644
index 00000000000..df8b1f91629
--- /dev/null
+++ b/spec/support/shared_examples/services/projects/urls_with_escaped_elements_shared_example.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+# Shared examples that test requests against URLs with escaped elements
+#
+RSpec.shared_examples "URLs containing escaped elements return expected status" do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:url, :result_status) do
+ "https://user:0a%23@test.example.com/project.git" | :success
+ "https://git.example.com:1%2F%2F@source.developers.google.com/project.git" | :success
+ CGI.escape("git://localhost:1234/some-path?some-query=some-val\#@example.com/") | :error
+ CGI.escape(CGI.escape("https://user:0a%23@test.example.com/project.git")) | :error
+ end
+
+ with_them do
+ it "returns expected status" do
+ expect(result[:status]).to eq(result_status)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/validators/ip_address_validator_shared_examples.rb b/spec/support/shared_examples/validators/ip_address_validator_shared_examples.rb
new file mode 100644
index 00000000000..5680d4f772c
--- /dev/null
+++ b/spec/support/shared_examples/validators/ip_address_validator_shared_examples.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'validates IP address' do
+ subject { object }
+
+ it { is_expected.to allow_value('192.168.17.43').for(attribute.to_sym) }
+ it { is_expected.to allow_value('2001:0db8:85a3:0000:0000:8a2e:0370:7334').for(attribute.to_sym) }
+
+ it { is_expected.not_to allow_value('invalid IP').for(attribute.to_sym) }
+end
diff --git a/spec/support_specs/helpers/stub_feature_flags_spec.rb b/spec/support_specs/helpers/stub_feature_flags_spec.rb
index 5d1e4e1627d..7434929d665 100644
--- a/spec/support_specs/helpers/stub_feature_flags_spec.rb
+++ b/spec/support_specs/helpers/stub_feature_flags_spec.rb
@@ -3,12 +3,31 @@
require 'spec_helper'
RSpec.describe StubFeatureFlags do
- let(:feature_name) { :test_feature }
+ DUMMY_FEATURE_FLAG = :dummy_feature_flag # rubocop:disable RSpec/LeakyConstantDeclaration
+
+ # We inject dummy feature flag defintion
+ # to ensure that we strong validate it's usage
+ # as well
+ before(:all) do
+ definition = Feature::Definition.new(
+ nil,
+ name: DUMMY_FEATURE_FLAG,
+ type: 'development',
+ # we allow ambigious usage of `default_enabled:`
+ default_enabled: [false, true]
+ )
+
+ Feature::Definition.definitions[DUMMY_FEATURE_FLAG] = definition
+ end
+
+ after(:all) do
+ Feature::Definition.definitions.delete(DUMMY_FEATURE_FLAG)
+ end
describe '#stub_feature_flags' do
using RSpec::Parameterized::TableSyntax
- let(:feature_name) { :test_feature }
+ let(:feature_name) { DUMMY_FEATURE_FLAG }
context 'when checking global state' do
where(:feature_actors, :expected_result) do
@@ -121,14 +140,14 @@ RSpec.describe StubFeatureFlags do
describe 'stub timing' do
context 'let_it_be variable' do
- let_it_be(:let_it_be_var) { Feature.enabled?(:any_feature_flag) }
+ let_it_be(:let_it_be_var) { Feature.enabled?(DUMMY_FEATURE_FLAG) }
it { expect(let_it_be_var).to eq true }
end
context 'before_all variable' do
before_all do
- @suite_var = Feature.enabled?(:any_feature_flag)
+ @suite_var = Feature.enabled?(DUMMY_FEATURE_FLAG)
end
it { expect(@suite_var).to eq true }
@@ -136,14 +155,14 @@ RSpec.describe StubFeatureFlags do
context 'before(:all) variable' do
before(:all) do
- @suite_var = Feature.enabled?(:any_feature_flag)
+ @suite_var = Feature.enabled?(DUMMY_FEATURE_FLAG)
end
it { expect(@suite_var).to eq true }
end
context 'with stub_feature_flags meta' do
- let(:var) { Feature.enabled?(:any_feature_flag) }
+ let(:var) { Feature.enabled?(DUMMY_FEATURE_FLAG) }
context 'as true', :stub_feature_flags do
it { expect(var).to eq true }
diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb
index a2cc2b12e5e..212a7f74b40 100644
--- a/spec/tasks/gitlab/backup_rake_spec.rb
+++ b/spec/tasks/gitlab/backup_rake_spec.rb
@@ -325,11 +325,13 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do
end
let!(:project_a) { create(:project, :repository) }
+ let!(:project_a_wiki_page) { create(:wiki_page, container: project_a) }
+ let!(:project_a_design) { create(:design, :with_file, issue: create(:issue, project: project_a)) }
let!(:project_b) { create(:project, :repository, repository_storage: 'test_second_storage') }
let!(:b_storage_dir) { File.join(test_second_storage_dir, File.dirname(project_b.disk_path)) }
- context 'no concurrency' do
- it 'includes repositories in all repository storages' do
+ shared_examples 'includes repositories in all repository storages' do
+ specify :aggregate_failures do
expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout
tar_contents, exit_status = Gitlab::Popen.popen(
@@ -337,27 +339,25 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do
)
expect(exit_status).to eq(0)
- expect(tar_contents).to match("repositories/#{project_a.disk_path}.bundle")
- expect(tar_contents).to match("repositories/#{project_b.disk_path}.bundle")
+ expect(tar_contents).to include(
+ "repositories/#{project_a.disk_path}.bundle",
+ "repositories/#{project_a.disk_path}.wiki.bundle",
+ "repositories/#{project_a.disk_path}.design.bundle",
+ "repositories/#{project_b.disk_path}.bundle"
+ )
end
end
+ context 'no concurrency' do
+ it_behaves_like 'includes repositories in all repository storages'
+ end
+
context 'with concurrency' do
before do
stub_env('GITLAB_BACKUP_MAX_CONCURRENCY', 4)
end
- it 'includes repositories in all repository storages' do
- expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout
-
- tar_contents, exit_status = Gitlab::Popen.popen(
- %W{tar -tvf #{backup_tar} repositories}
- )
-
- expect(exit_status).to eq(0)
- expect(tar_contents).to match("repositories/#{project_a.disk_path}.bundle")
- expect(tar_contents).to match("repositories/#{project_b.disk_path}.bundle")
- end
+ it_behaves_like 'includes repositories in all repository storages'
end
end
@@ -370,7 +370,7 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do
end
it 'has defaults' do
- expect_next_instance_of(::Backup::Repository) do |instance|
+ expect_next_instance_of(::Backup::Repositories) do |instance|
expect(instance).to receive(:dump)
.with(max_concurrency: 1, max_storage_concurrency: 1)
.and_call_original
@@ -383,7 +383,7 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do
stub_env('GITLAB_BACKUP_MAX_CONCURRENCY', 5)
stub_env('GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY', 2)
- expect_next_instance_of(::Backup::Repository) do |instance|
+ expect_next_instance_of(::Backup::Repositories) do |instance|
expect(instance).to receive(:dump)
.with(max_concurrency: 5, max_storage_concurrency: 2)
.and_call_original
diff --git a/spec/tasks/gitlab/db_rake_spec.rb b/spec/tasks/gitlab/db_rake_spec.rb
index 99efd394e83..e4630aefb85 100644
--- a/spec/tasks/gitlab/db_rake_spec.rb
+++ b/spec/tasks/gitlab/db_rake_spec.rb
@@ -164,25 +164,77 @@ RSpec.describe 'gitlab:db namespace rake task' do
end
end
+ describe 'drop_tables' do
+ subject { run_rake_task('gitlab:db:drop_tables') }
+
+ let(:tables) { %w(one two) }
+ let(:views) { %w(three four) }
+ let(:connection) { ActiveRecord::Base.connection }
+
+ before do
+ allow(connection).to receive(:execute).and_return(nil)
+
+ allow(connection).to receive(:tables).and_return(tables)
+ allow(connection).to receive(:views).and_return(views)
+ end
+
+ it 'drops all tables, except schema_migrations' do
+ expect(connection).to receive(:execute).with('DROP TABLE IF EXISTS "one" CASCADE')
+ expect(connection).to receive(:execute).with('DROP TABLE IF EXISTS "two" CASCADE')
+
+ subject
+ end
+
+ it 'drops all views' do
+ expect(connection).to receive(:execute).with('DROP VIEW IF EXISTS "three" CASCADE')
+ expect(connection).to receive(:execute).with('DROP VIEW IF EXISTS "four" CASCADE')
+
+ subject
+ end
+
+ it 'truncates schema_migrations table' do
+ expect(connection).to receive(:execute).with('TRUNCATE schema_migrations')
+
+ subject
+ end
+
+ it 'drops extra schemas' do
+ Gitlab::Database::EXTRA_SCHEMAS.each do |schema|
+ expect(connection).to receive(:execute).with("DROP SCHEMA IF EXISTS \"#{schema}\"")
+ end
+
+ subject
+ end
+ end
+
describe 'reindex' do
+ let(:reindex) { double('reindex') }
+ let(:indexes) { double('indexes') }
+
context 'when no index_name is given' do
- it 'raises an error' do
- expect do
- run_rake_task('gitlab:db:reindex', '')
- end.to raise_error(ArgumentError, /must give the index name/)
+ it 'rebuilds a random number of large indexes' do
+ expect(Gitlab::Database::Reindexing).to receive_message_chain('candidate_indexes.random_few').and_return(indexes)
+ expect(Gitlab::Database::Reindexing).to receive(:perform).with(indexes)
+
+ run_rake_task('gitlab:db:reindex')
end
end
- it 'calls the index rebuilder with the proper arguments' do
- reindex = double('rebuilder')
+ context 'with index name given' do
+ let(:index) { double('index') }
- expect(Gitlab::Database::ConcurrentReindex).to receive(:new)
- .with('some_index_name', logger: instance_of(Logger))
- .and_return(reindex)
+ it 'calls the index rebuilder with the proper arguments' do
+ expect(Gitlab::Database::PostgresIndex).to receive(:by_identifier).with('public.foo_idx').and_return(index)
+ expect(Gitlab::Database::Reindexing).to receive(:perform).with(index)
- expect(reindex).to receive(:execute)
+ run_rake_task('gitlab:db:reindex', '[public.foo_idx]')
+ end
+
+ it 'raises an error if the index does not exist' do
+ expect(Gitlab::Database::PostgresIndex).to receive(:by_identifier).with('public.absent_index').and_raise(ActiveRecord::RecordNotFound)
- run_rake_task('gitlab:db:reindex', '[some_index_name]')
+ expect { run_rake_task('gitlab:db:reindex', '[public.absent_index]') }.to raise_error(ActiveRecord::RecordNotFound)
+ end
end
end
diff --git a/spec/uploaders/pages/deployment_uploader_spec.rb b/spec/uploaders/pages/deployment_uploader_spec.rb
new file mode 100644
index 00000000000..1d9a0efe228
--- /dev/null
+++ b/spec/uploaders/pages/deployment_uploader_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Pages::DeploymentUploader do
+ let(:pages_deployment) { create(:pages_deployment) }
+ let(:uploader) { described_class.new(pages_deployment, :file) }
+
+ subject { uploader }
+
+ it_behaves_like "builds correct paths",
+ store_dir: %r[/\h{2}/\h{2}/\h{64}/pages_deployments/\d+],
+ cache_dir: %r[pages/@hashed/tmp/cache],
+ work_dir: %r[pages/@hashed/tmp/work]
+
+ context 'when object store is REMOTE' do
+ before do
+ stub_pages_object_storage
+ end
+
+ include_context 'with storage', described_class::Store::REMOTE
+
+ it_behaves_like 'builds correct paths', store_dir: %r[\A\h{2}/\h{2}/\h{64}/pages_deployments/\d+\z]
+ end
+
+ context 'when file is stored in valid local_path' do
+ let(:file) do
+ fixture_file_upload("spec/fixtures/pages.zip")
+ end
+
+ before do
+ uploader.store!(file)
+ end
+
+ subject { uploader.file.path }
+
+ it { is_expected.to match(%r[#{uploader.root}/@hashed/\h{2}/\h{2}/\h{64}/pages_deployments/#{pages_deployment.id}/pages.zip]) }
+ end
+end
diff --git a/spec/validators/ip_address_validator_spec.rb b/spec/validators/ip_address_validator_spec.rb
new file mode 100644
index 00000000000..382250378c2
--- /dev/null
+++ b/spec/validators/ip_address_validator_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IpAddressValidator do
+ let(:model) do
+ Class.new do
+ include ActiveModel::Model
+ include ActiveModel::Validations
+
+ attr_accessor :ip_address
+ alias_method :ip_address_before_type_cast, :ip_address
+
+ validates :ip_address, ip_address: true
+ end.new
+ end
+
+ using RSpec::Parameterized::TableSyntax
+
+ where(:ip_address, :validity, :errors) do
+ 'invalid IP' | false | { ip_address: ['must be a valid IPv4 or IPv6 address'] }
+ '192.168.17.43' | true | {}
+ '2001:0db8:85a3::8a2e:0370:7334' | true | {}
+ nil | true | {}
+ '' | true | {}
+ end
+
+ with_them do
+ before do
+ model.ip_address = ip_address
+ model.validate
+ end
+
+ it { expect(model.valid?).to eq(validity) }
+ it { expect(model.errors.messages).to eq(errors) }
+ end
+end
diff --git a/spec/views/admin/dashboard/index.html.haml_spec.rb b/spec/views/admin/dashboard/index.html.haml_spec.rb
index 70fb77944cc..e9223c84674 100644
--- a/spec/views/admin/dashboard/index.html.haml_spec.rb
+++ b/spec/views/admin/dashboard/index.html.haml_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe 'admin/dashboard/index.html.haml' do
render
expect(rendered).not_to have_content "Users in License"
- expect(rendered).not_to have_content "Active Users"
+ expect(rendered).not_to have_content "Billable Users"
expect(rendered).not_to have_content "Maximum Users"
expect(rendered).not_to have_content "Users over License"
end
diff --git a/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb
index 777dc0c8571..2c37565328a 100644
--- a/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb
@@ -92,7 +92,11 @@ RSpec.describe 'layouts/nav/sidebar/_admin' do
end
context 'on settings' do
+ let(:gitlab_com?) { false }
+
before do
+ allow(::Gitlab).to receive(:com?) { gitlab_com? }
+
render
end
@@ -100,6 +104,20 @@ RSpec.describe 'layouts/nav/sidebar/_admin' do
expect(rendered).to have_link('General', href: general_admin_application_settings_path)
end
+ context 'when GitLab.com' do
+ let(:gitlab_com?) { true }
+
+ it 'does not include Integrations link' do
+ expect(rendered).not_to have_link('Integrations', href: integrations_admin_application_settings_path)
+ end
+ end
+
+ context 'when not GitLab.com' do
+ it 'includes Integrations link' do
+ expect(rendered).to have_link('Integrations', href: integrations_admin_application_settings_path)
+ end
+ end
+
context 'when GitLab FOSS' do
it 'does not include Templates link' do
expect(rendered).not_to have_link('Templates', href: '/admin/application_settings/templates')
diff --git a/spec/views/profiles/preferences/show.html.haml_spec.rb b/spec/views/profiles/preferences/show.html.haml_spec.rb
index 1b8b28367c1..1dae953227d 100644
--- a/spec/views/profiles/preferences/show.html.haml_spec.rb
+++ b/spec/views/profiles/preferences/show.html.haml_spec.rb
@@ -20,6 +20,14 @@ RSpec.describe 'profiles/preferences/show' do
it 'has an id for anchoring' do
expect(rendered).to have_css('#navigation-theme')
end
+
+ it 'has correct stylesheet tags' do
+ Gitlab::Themes.each do |theme|
+ next unless theme.css_filename
+
+ expect(rendered).to have_selector("link[href*=\"themes/#{theme.css_filename}\"]", visible: false)
+ end
+ end
end
context 'syntax highlighting theme' do
diff --git a/spec/views/projects/merge_requests/diffs/_diffs.html.haml_spec.rb b/spec/views/projects/merge_requests/diffs/_diffs.html.haml_spec.rb
deleted file mode 100644
index 7cdc817d784..00000000000
--- a/spec/views/projects/merge_requests/diffs/_diffs.html.haml_spec.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'projects/merge_requests/diffs/_diffs.html.haml' do
- include Devise::Test::ControllerHelpers
-
- let(:user) { create(:user) }
- let(:project) { create(:project, :public, :repository) }
- let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project, author: user) }
-
- before do
- allow(view).to receive(:url_for).and_return(controller.request.fullpath)
-
- assign(:merge_request, merge_request)
- assign(:environment, merge_request.environments_for(user).last)
- assign(:diffs, merge_request.diffs)
- assign(:merge_request_diffs, merge_request.diffs)
- assign(:diff_notes_disabled, true) # disable note creation
- assign(:use_legacy_diff_notes, false)
- assign(:grouped_diff_discussions, {})
- assign(:notes, [])
- end
-
- context 'for a commit' do
- let(:commit) { merge_request.commits.last }
-
- before do
- assign(:commit, commit)
- end
-
- it "shows the commit scope" do
- render
-
- expect(rendered).to have_content "Only comments from the following commit are shown below"
- end
- end
-end
diff --git a/spec/views/search/_results.html.haml_spec.rb b/spec/views/search/_results.html.haml_spec.rb
index 9e95dc40ff8..033b2304e33 100644
--- a/spec/views/search/_results.html.haml_spec.rb
+++ b/spec/views/search/_results.html.haml_spec.rb
@@ -60,6 +60,28 @@ RSpec.describe 'search/_results' do
expect(rendered).to have_selector('#js-search-filter-by-state')
end
+
+ context 'Feature search_filter_by_confidential' do
+ context 'when disabled' do
+ before do
+ stub_feature_flags(search_filter_by_confidential: false)
+ end
+
+ it 'does not render the confidential drop down' do
+ render
+
+ expect(rendered).not_to have_selector('#js-search-filter-by-confidential')
+ end
+ end
+
+ context 'when enabled' do
+ it 'renders the confidential drop down' do
+ render
+
+ expect(rendered).to have_selector('#js-search-filter-by-confidential')
+ end
+ end
+ end
end
end
end
diff --git a/spec/views/shared/milestones/_issuables.html.haml_spec.rb b/spec/views/shared/milestones/_issuables.html.haml_spec.rb
index 70ab6914580..5eed2c96a45 100644
--- a/spec/views/shared/milestones/_issuables.html.haml_spec.rb
+++ b/spec/views/shared/milestones/_issuables.html.haml_spec.rb
@@ -6,8 +6,7 @@ RSpec.describe 'shared/milestones/_issuables.html.haml' do
let(:issuables_size) { 100 }
before do
- allow(view).to receive_messages(title: nil, id: nil, show_project_name: nil,
- show_full_project_name: nil, dom_class: '',
+ allow(view).to receive_messages(title: nil, id: nil, show_project_name: nil, dom_class: '',
issuables: double(length: issuables_size).as_null_object)
stub_template 'shared/milestones/_issuable.html.haml' => ''
diff --git a/spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb b/spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb
index 8db86071dc4..667ec0bcb75 100644
--- a/spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb
+++ b/spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe Analytics::InstanceStatistics::CounterJobWorker do
it 'counts a scope and stores the result' do
subject
- measurement = Analytics::InstanceStatistics::Measurement.first
+ measurement = Analytics::InstanceStatistics::Measurement.users.first
expect(measurement.recorded_at).to be_like_time(recorded_at)
expect(measurement.identifier).to eq('users')
expect(measurement.count).to eq(2)
@@ -33,7 +33,7 @@ RSpec.describe Analytics::InstanceStatistics::CounterJobWorker do
it 'sets 0 as the count' do
subject
- measurement = Analytics::InstanceStatistics::Measurement.first
+ measurement = Analytics::InstanceStatistics::Measurement.groups.first
expect(measurement.recorded_at).to be_like_time(recorded_at)
expect(measurement.identifier).to eq('groups')
expect(measurement.count).to eq(0)
@@ -51,4 +51,20 @@ RSpec.describe Analytics::InstanceStatistics::CounterJobWorker do
expect { subject }.not_to change { Analytics::InstanceStatistics::Measurement.count }
end
+
+ context 'when pipelines_succeeded identifier is passed' do
+ let_it_be(:pipeline) { create(:ci_pipeline, :success) }
+
+ let(:successful_pipelines_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:pipelines_succeeded) }
+ let(:job_args) { [successful_pipelines_measurement_identifier, pipeline.id, pipeline.id, recorded_at] }
+
+ it 'counts successful pipelines' do
+ subject
+
+ measurement = Analytics::InstanceStatistics::Measurement.pipelines_succeeded.first
+ expect(measurement.recorded_at).to be_like_time(recorded_at)
+ expect(measurement.identifier).to eq('pipelines_succeeded')
+ expect(measurement.count).to eq(1)
+ end
+ end
end
diff --git a/spec/workers/authorized_project_update/periodic_recalculate_worker_spec.rb b/spec/workers/authorized_project_update/periodic_recalculate_worker_spec.rb
index 2d633828ae3..9d4d48d0568 100644
--- a/spec/workers/authorized_project_update/periodic_recalculate_worker_spec.rb
+++ b/spec/workers/authorized_project_update/periodic_recalculate_worker_spec.rb
@@ -11,17 +11,5 @@ RSpec.describe AuthorizedProjectUpdate::PeriodicRecalculateWorker do
subject.perform
end
-
- context 'feature flag :periodic_project_authorization_recalculation is disabled' do
- before do
- stub_feature_flags(periodic_project_authorization_recalculation: false)
- end
-
- it 'does not call AuthorizedProjectUpdate::PeriodicRecalculateService' do
- expect(AuthorizedProjectUpdate::PeriodicRecalculateService).not_to receive(:new)
-
- subject.perform
- end
- end
end
end
diff --git a/spec/workers/authorized_project_update/user_refresh_over_user_range_worker_spec.rb b/spec/workers/authorized_project_update/user_refresh_over_user_range_worker_spec.rb
index c49e4c453bf..a27c431523e 100644
--- a/spec/workers/authorized_project_update/user_refresh_over_user_range_worker_spec.rb
+++ b/spec/workers/authorized_project_update/user_refresh_over_user_range_worker_spec.rb
@@ -14,17 +14,5 @@ RSpec.describe AuthorizedProjectUpdate::UserRefreshOverUserRangeWorker do
subject.perform(start_user_id, end_user_id)
end
-
- context 'feature flag :periodic_project_authorization_recalculation is disabled' do
- before do
- stub_feature_flags(periodic_project_authorization_recalculation: false)
- end
-
- it 'does not call AuthorizedProjectUpdate::RecalculateForUserRangeService' do
- expect(AuthorizedProjectUpdate::RecalculateForUserRangeService).not_to receive(:new)
-
- subject.perform(start_user_id, end_user_id)
- end
- end
end
end
diff --git a/spec/workers/cleanup_container_repository_worker_spec.rb b/spec/workers/cleanup_container_repository_worker_spec.rb
index 0545f7a35e4..9cf8974a2a1 100644
--- a/spec/workers/cleanup_container_repository_worker_spec.rb
+++ b/spec/workers/cleanup_container_repository_worker_spec.rb
@@ -40,14 +40,35 @@ RSpec.describe CleanupContainerRepositoryWorker, :clean_gitlab_redis_shared_stat
context 'container expiration policy' do
let(:params) { { key: 'value', 'container_expiration_policy' => true } }
+ before do
+ allow(ContainerRepository)
+ .to receive(:find_by_id).with(repository.id).and_return(repository)
+ end
+
it 'executes the destroy service' do
+ expect(repository).to receive(:start_expiration_policy!).and_call_original
+ expect(repository).to receive(:reset_expiration_policy_started_at!).and_call_original
expect(Projects::ContainerRepository::CleanupTagsService).to receive(:new)
.with(project, nil, params.merge('container_expiration_policy' => true))
.and_return(service)
- expect(service).to receive(:execute)
+ expect(service).to receive(:execute).and_return(status: :success)
+
+ subject.perform(nil, repository.id, params)
+ expect(repository.reload.expiration_policy_started_at).to be_nil
+ end
+
+ it "doesn't reset the expiration policy started at if the destroy service returns an error" do
+ expect(repository).to receive(:start_expiration_policy!).and_call_original
+ expect(repository).not_to receive(:reset_expiration_policy_started_at!)
+ expect(Projects::ContainerRepository::CleanupTagsService).to receive(:new)
+ .with(project, nil, params.merge('container_expiration_policy' => true))
+ .and_return(service)
+
+ expect(service).to receive(:execute).and_return(status: :error, message: 'timeout while deleting tags')
subject.perform(nil, repository.id, params)
+ expect(repository.reload.expiration_policy_started_at).not_to be_nil
end
end
end
diff --git a/spec/workers/concerns/limited_capacity/job_tracker_spec.rb b/spec/workers/concerns/limited_capacity/job_tracker_spec.rb
new file mode 100644
index 00000000000..2c79f347903
--- /dev/null
+++ b/spec/workers/concerns/limited_capacity/job_tracker_spec.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe LimitedCapacity::JobTracker, :clean_gitlab_redis_queues do
+ let(:job_tracker) do
+ described_class.new('namespace')
+ end
+
+ describe '#register' do
+ it 'adds jid to the set' do
+ job_tracker.register('a-job-id')
+
+ expect(job_tracker.running_jids).to contain_exactly('a-job-id')
+ end
+
+ it 'updates the counter' do
+ expect { job_tracker.register('a-job-id') }
+ .to change { job_tracker.count }
+ .from(0)
+ .to(1)
+ end
+
+ it 'does it in only one Redis call' do
+ expect(job_tracker).to receive(:with_redis).once.and_call_original
+
+ job_tracker.register('a-job-id')
+ end
+ end
+
+ describe '#remove' do
+ before do
+ job_tracker.register(%w[a-job-id other-job-id])
+ end
+
+ it 'removes jid from the set' do
+ job_tracker.remove('other-job-id')
+
+ expect(job_tracker.running_jids).to contain_exactly('a-job-id')
+ end
+
+ it 'updates the counter' do
+ expect { job_tracker.remove('other-job-id') }
+ .to change { job_tracker.count }
+ .from(2)
+ .to(1)
+ end
+
+ it 'does it in only one Redis call' do
+ expect(job_tracker).to receive(:with_redis).once.and_call_original
+
+ job_tracker.remove('other-job-id')
+ end
+ end
+
+ describe '#clean_up' do
+ before do
+ job_tracker.register('a-job-id')
+ end
+
+ context 'with running jobs' do
+ before do
+ expect(Gitlab::SidekiqStatus).to receive(:completed_jids)
+ .with(%w[a-job-id])
+ .and_return([])
+ end
+
+ it 'does not remove the jid from the set' do
+ expect { job_tracker.clean_up }
+ .not_to change { job_tracker.running_jids.include?('a-job-id') }
+ end
+
+ it 'does only one Redis call to get the job ids' do
+ expect(job_tracker).to receive(:with_redis).once.and_call_original
+
+ job_tracker.clean_up
+ end
+ end
+
+ context 'with completed jobs' do
+ it 'removes the jid from the set' do
+ expect { job_tracker.clean_up }
+ .to change { job_tracker.running_jids.include?('a-job-id') }
+ end
+
+ it 'updates the counter' do
+ expect { job_tracker.clean_up }
+ .to change { job_tracker.count }
+ .from(1)
+ .to(0)
+ end
+
+ it 'gets the job ids, removes them, and updates the counter with only two Redis calls' do
+ expect(job_tracker).to receive(:with_redis).twice.and_call_original
+
+ job_tracker.clean_up
+ end
+ end
+ end
+end
diff --git a/spec/workers/concerns/limited_capacity/worker_spec.rb b/spec/workers/concerns/limited_capacity/worker_spec.rb
new file mode 100644
index 00000000000..8a15675c04d
--- /dev/null
+++ b/spec/workers/concerns/limited_capacity/worker_spec.rb
@@ -0,0 +1,285 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe LimitedCapacity::Worker, :clean_gitlab_redis_queues, :aggregate_failures do
+ let(:worker_class) do
+ Class.new do
+ def self.name
+ 'DummyWorker'
+ end
+
+ include ApplicationWorker
+ include LimitedCapacity::Worker
+ end
+ end
+
+ let(:worker) { worker_class.new }
+
+ let(:job_tracker) do
+ LimitedCapacity::JobTracker.new(worker_class.name)
+ end
+
+ before do
+ worker.jid = 'my-jid'
+ allow(worker).to receive(:job_tracker).and_return(job_tracker)
+ end
+
+ describe 'required methods' do
+ it { expect { worker.perform_work }.to raise_error(NotImplementedError) }
+ it { expect { worker.remaining_work_count }.to raise_error(NotImplementedError) }
+ it { expect { worker.max_running_jobs }.to raise_error(NotImplementedError) }
+ end
+
+ describe 'Sidekiq options' do
+ it 'does not retry failed jobs' do
+ expect(worker_class.sidekiq_options['retry']).to eq(0)
+ end
+
+ it 'does not deduplicate jobs' do
+ expect(worker_class.get_deduplicate_strategy).to eq(:none)
+ end
+ end
+
+ describe '.perform_with_capacity' do
+ subject(:perform_with_capacity) { worker_class.perform_with_capacity(:arg) }
+
+ before do
+ expect_next_instance_of(worker_class) do |instance|
+ expect(instance).to receive(:remove_failed_jobs)
+ expect(instance).to receive(:report_prometheus_metrics)
+
+ allow(instance).to receive(:remaining_work_count).and_return(remaining_work_count)
+ allow(instance).to receive(:remaining_capacity).and_return(remaining_capacity)
+ end
+ end
+
+ context 'when capacity is larger than work' do
+ let(:remaining_work_count) { 2 }
+ let(:remaining_capacity) { 3 }
+
+ it 'enqueues jobs for remaining work' do
+ expect(worker_class)
+ .to receive(:bulk_perform_async)
+ .with([[:arg], [:arg]])
+
+ perform_with_capacity
+ end
+ end
+
+ context 'when capacity is lower than work' do
+ let(:remaining_work_count) { 5 }
+ let(:remaining_capacity) { 3 }
+
+ it 'enqueues jobs for remaining work' do
+ expect(worker_class)
+ .to receive(:bulk_perform_async)
+ .with([[:arg], [:arg], [:arg]])
+
+ perform_with_capacity
+ end
+ end
+ end
+
+ describe '#perform' do
+ subject(:perform) { worker.perform(:arg) }
+
+ context 'with capacity' do
+ before do
+ allow(worker).to receive(:max_running_jobs).and_return(10)
+ allow(worker).to receive(:running_jobs_count).and_return(0)
+ allow(worker).to receive(:remaining_work_count).and_return(0)
+ end
+
+ it 'calls perform_work' do
+ expect(worker).to receive(:perform_work).with(:arg)
+
+ perform
+ end
+
+ it 're-enqueues itself' do
+ allow(worker).to receive(:perform_work)
+ expect(worker).to receive(:re_enqueue).with(:arg)
+
+ perform
+ end
+
+ it 'registers itself in the running set' do
+ allow(worker).to receive(:perform_work)
+ expect(job_tracker).to receive(:register).with('my-jid')
+
+ perform
+ end
+
+ it 'removes itself from the running set' do
+ expect(job_tracker).to receive(:remove).with('my-jid')
+
+ allow(worker).to receive(:perform_work)
+
+ perform
+ end
+
+ it 'reports prometheus metrics' do
+ allow(worker).to receive(:perform_work)
+ expect(worker).to receive(:report_prometheus_metrics)
+
+ perform
+ end
+ end
+
+ context 'with capacity and without work' do
+ before do
+ allow(worker).to receive(:max_running_jobs).and_return(10)
+ allow(worker).to receive(:running_jobs_count).and_return(0)
+ allow(worker).to receive(:remaining_work_count).and_return(0)
+ allow(worker).to receive(:perform_work)
+ end
+
+ it 'does not re-enqueue itself' do
+ expect(worker_class).not_to receive(:perform_async)
+
+ perform
+ end
+ end
+
+ context 'without capacity' do
+ before do
+ allow(worker).to receive(:max_running_jobs).and_return(10)
+ allow(worker).to receive(:running_jobs_count).and_return(15)
+ allow(worker).to receive(:remaining_work_count).and_return(10)
+ end
+
+ it 'does not call perform_work' do
+ expect(worker).not_to receive(:perform_work)
+
+ perform
+ end
+
+ it 'does not re-enqueue itself' do
+ expect(worker_class).not_to receive(:perform_async)
+
+ perform
+ end
+
+ it 'does not register in the running set' do
+ expect(job_tracker).not_to receive(:register)
+
+ perform
+ end
+
+ it 'removes itself from the running set' do
+ expect(job_tracker).to receive(:remove).with('my-jid')
+
+ perform
+ end
+
+ it 'reports prometheus metrics' do
+ expect(worker).to receive(:report_prometheus_metrics)
+
+ perform
+ end
+ end
+
+ context 'when perform_work fails' do
+ it 'does not re-enqueue itself' do
+ expect(worker).not_to receive(:re_enqueue)
+
+ expect { perform }.to raise_error(NotImplementedError)
+ end
+
+ it 'removes itself from the running set' do
+ expect(job_tracker).to receive(:remove)
+
+ expect { perform }.to raise_error(NotImplementedError)
+ end
+
+ it 'reports prometheus metrics' do
+ expect(worker).to receive(:report_prometheus_metrics)
+
+ expect { perform }.to raise_error(NotImplementedError)
+ end
+ end
+ end
+
+ describe '#remaining_capacity' do
+ subject(:remaining_capacity) { worker.remaining_capacity }
+
+ before do
+ expect(worker).to receive(:max_running_jobs).and_return(max_capacity)
+ end
+
+ context 'when changing the capacity to a lower value' do
+ let(:max_capacity) { -1 }
+
+ it { expect(remaining_capacity).to eq(0) }
+ end
+
+ context 'when registering new jobs' do
+ let(:max_capacity) { 2 }
+
+ before do
+ job_tracker.register('a-job-id')
+ end
+
+ it { expect(remaining_capacity).to eq(1) }
+ end
+
+ context 'with jobs in the queue' do
+ let(:max_capacity) { 2 }
+
+ before do
+ expect(worker_class).to receive(:queue_size).and_return(1)
+ end
+
+ it { expect(remaining_capacity).to eq(1) }
+ end
+
+ context 'with both running jobs and queued jobs' do
+ let(:max_capacity) { 10 }
+
+ before do
+ expect(worker_class).to receive(:queue_size).and_return(5)
+ expect(worker).to receive(:running_jobs_count).and_return(3)
+ end
+
+ it { expect(remaining_capacity).to eq(2) }
+ end
+ end
+
+ describe '#remove_failed_jobs' do
+ subject(:remove_failed_jobs) { worker.remove_failed_jobs }
+
+ before do
+ job_tracker.register('a-job-id')
+ allow(worker).to receive(:max_running_jobs).and_return(2)
+
+ expect(job_tracker).to receive(:clean_up).and_call_original
+ end
+
+ context 'with failed jobs' do
+ it 'update the available capacity' do
+ expect { remove_failed_jobs }.to change { worker.remaining_capacity }.by(1)
+ end
+ end
+ end
+
+ describe '#report_prometheus_metrics' do
+ subject(:report_prometheus_metrics) { worker.report_prometheus_metrics }
+
+ before do
+ allow(worker).to receive(:running_jobs_count).and_return(5)
+ allow(worker).to receive(:max_running_jobs).and_return(7)
+ allow(worker).to receive(:remaining_work_count).and_return(9)
+ end
+
+ it 'reports number of running jobs' do
+ labels = { worker: 'DummyWorker' }
+
+ report_prometheus_metrics
+
+ expect(Gitlab::Metrics.registry.get(:limited_capacity_worker_running_jobs).get(labels)).to eq(5)
+ expect(Gitlab::Metrics.registry.get(:limited_capacity_worker_max_running_jobs).get(labels)).to eq(7)
+ expect(Gitlab::Metrics.registry.get(:limited_capacity_worker_remaining_work_count).get(labels)).to eq(9)
+ end
+ end
+end
diff --git a/spec/workers/design_management/copy_design_collection_worker_spec.rb b/spec/workers/design_management/copy_design_collection_worker_spec.rb
new file mode 100644
index 00000000000..45bfc47ca7e
--- /dev/null
+++ b/spec/workers/design_management/copy_design_collection_worker_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe DesignManagement::CopyDesignCollectionWorker, :clean_gitlab_redis_shared_state do
+ describe '#perform' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:target_issue) { create(:issue) }
+
+ subject { described_class.new.perform(user.id, issue.id, target_issue.id) }
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { [user.id, issue.id, target_issue.id] }
+
+ specify { subject }
+ end
+
+ it 'calls DesignManagement::CopyDesignCollection::CopyService' do
+ expect_next_instance_of(DesignManagement::CopyDesignCollection::CopyService) do |service|
+ expect(service).to receive(:execute).and_return(ServiceResponse.success)
+ end
+
+ subject
+ end
+
+ it 'logs if there was an error calling the service' do
+ message = 'Error message'
+
+ allow_next_instance_of(DesignManagement::CopyDesignCollection::CopyService) do |service|
+ allow(service).to receive(:execute).and_return(ServiceResponse.error(message: message))
+ end
+
+ expect(Gitlab::AppLogger).to receive(:warn).with(message)
+
+ subject
+ end
+ end
+end
diff --git a/spec/workers/design_management/new_version_worker_spec.rb b/spec/workers/design_management/new_version_worker_spec.rb
index 4d57c46487e..3320d7a062d 100644
--- a/spec/workers/design_management/new_version_worker_spec.rb
+++ b/spec/workers/design_management/new_version_worker_spec.rb
@@ -36,6 +36,10 @@ RSpec.describe DesignManagement::NewVersionWorker do
expect { worker.perform(version.id) }.to change { Note.system.count }.by(1)
end
+ it 'does not create a system note if skip_system_notes is true' do
+ expect { worker.perform(version.id, true) }.not_to change { Note.system.count }
+ end
+
it 'invokes GenerateImageVersionsService' do
expect_next_instance_of(DesignManagement::GenerateImageVersionsService) do |service|
expect(service).to receive(:execute)
diff --git a/spec/workers/git_garbage_collect_worker_spec.rb b/spec/workers/git_garbage_collect_worker_spec.rb
index 1be6e86b650..018971e288c 100644
--- a/spec/workers/git_garbage_collect_worker_spec.rb
+++ b/spec/workers/git_garbage_collect_worker_spec.rb
@@ -129,46 +129,36 @@ RSpec.describe GitGarbageCollectWorker do
let_it_be(:lfs_reference) { create(:lfs_objects_project, project: project) }
let(:lfs_object) { lfs_reference.lfs_object }
- context 'with cleanup_lfs_during_gc feature flag enabled' do
- before do
- stub_feature_flags(cleanup_lfs_during_gc: true)
+ it 'cleans up unreferenced LFS objects' do
+ expect_next_instance_of(Gitlab::Cleanup::OrphanLfsFileReferences) do |svc|
+ expect(svc.project).to eq(project)
+ expect(svc.dry_run).to be_falsy
+ expect(svc).to receive(:run!).and_call_original
end
- it 'cleans up unreferenced LFS objects' do
- expect_next_instance_of(Gitlab::Cleanup::OrphanLfsFileReferences) do |svc|
- expect(svc.project).to eq(project)
- expect(svc.dry_run).to be_falsy
- expect(svc).to receive(:run!).and_call_original
- end
-
- subject.perform(*params)
-
- expect(project.lfs_objects.reload).not_to include(lfs_object)
- end
+ subject.perform(*params)
- it 'does nothing if the database is read-only' do
- allow(Gitlab::Database).to receive(:read_only?) { true }
+ expect(project.lfs_objects.reload).not_to include(lfs_object)
+ end
- expect_any_instance_of(Gitlab::Cleanup::OrphanLfsFileReferences).not_to receive(:run!)
+ it 'catches and logs exceptions' do
+ expect_any_instance_of(Gitlab::Cleanup::OrphanLfsFileReferences)
+ .to receive(:run!)
+ .and_raise(/Failed/)
- subject.perform(*params)
+ expect(Gitlab::GitLogger).to receive(:warn)
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
- expect(project.lfs_objects.reload).to include(lfs_object)
- end
+ subject.perform(*params)
end
- context 'with cleanup_lfs_during_gc feature flag disabled' do
- before do
- stub_feature_flags(cleanup_lfs_during_gc: false)
- end
-
- it 'does not clean up unreferenced LFS objects' do
- expect_any_instance_of(Gitlab::Cleanup::OrphanLfsFileReferences).not_to receive(:run!)
+ it 'does nothing if the database is read-only' do
+ allow(Gitlab::Database).to receive(:read_only?) { true }
+ expect_any_instance_of(Gitlab::Cleanup::OrphanLfsFileReferences).not_to receive(:run!)
- subject.perform(*params)
+ subject.perform(*params)
- expect(project.lfs_objects.reload).to include(lfs_object)
- end
+ expect(project.lfs_objects.reload).to include(lfs_object)
end
end
end
diff --git a/spec/workers/group_import_worker_spec.rb b/spec/workers/group_import_worker_spec.rb
index fb2d49c21af..3fa24ecd7bc 100644
--- a/spec/workers/group_import_worker_spec.rb
+++ b/spec/workers/group_import_worker_spec.rb
@@ -3,12 +3,14 @@
require 'spec_helper'
RSpec.describe GroupImportWorker do
- let!(:user) { create(:user) }
- let!(:group) { create(:group) }
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
subject { described_class.new }
before do
+ create(:group_import_state, group: group, user: user)
+
allow_next_instance_of(described_class) do |job|
allow(job).to receive(:jid).and_return(SecureRandom.hex(8))
end
@@ -26,44 +28,11 @@ RSpec.describe GroupImportWorker do
subject.perform(user.id, group.id)
end
- context 'when the import state does not exist' do
- it 'creates group import' do
- expect(group.import_state).to be_nil
-
- subject.perform(user.id, group.id)
- import_state = group.reload.import_state
-
- expect(import_state).to be_instance_of(GroupImportState)
- expect(import_state.status_name).to eq(:finished)
- expect(import_state.jid).not_to be_empty
- end
-
- it 'sets the group import status to started' do
- expect_next_instance_of(GroupImportState) do |import|
- expect(import).to receive(:start!).and_call_original
- end
-
- subject.perform(user.id, group.id)
- end
-
- it 'sets the group import status to finished' do
- expect_next_instance_of(GroupImportState) do |import|
- expect(import).to receive(:finish!).and_call_original
- end
+ it 'updates the existing state' do
+ expect { subject.perform(user.id, group.id) }
+ .not_to change { GroupImportState.count }
- subject.perform(user.id, group.id)
- end
- end
-
- context 'when the import state already exists' do
- it 'updates the existing state' do
- existing_state = create(:group_import_state, group: group)
-
- expect { subject.perform(user.id, group.id) }
- .not_to change { GroupImportState.count }
-
- expect(existing_state.reload).to be_finished
- end
+ expect(group.import_state.reload).to be_finished
end
end
@@ -83,11 +52,9 @@ RSpec.describe GroupImportWorker do
end
it 'sets the group import status to failed' do
- expect_next_instance_of(GroupImportState) do |import|
- expect(import).to receive(:fail_op).and_call_original
- end
-
expect { subject.perform(user.id, group.id) }.to raise_exception(Gitlab::ImportExport::Error)
+
+ expect(group.import_state.reload.status).to eq(-1)
end
end
end
diff --git a/spec/workers/incident_management/add_severity_system_note_worker_spec.rb b/spec/workers/incident_management/add_severity_system_note_worker_spec.rb
new file mode 100644
index 00000000000..203c62ffe6f
--- /dev/null
+++ b/spec/workers/incident_management/add_severity_system_note_worker_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IncidentManagement::AddSeveritySystemNoteWorker do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:incident) { create(:incident, project: project) }
+ let_it_be(:issuable_severity) { create(:issuable_severity, issue: incident, severity: :medium) }
+
+ describe '#perform' do
+ let(:incident_id) { incident.id }
+ let(:user_id) { user.id }
+
+ subject(:perform) { described_class.new.perform(incident_id, user_id) }
+
+ shared_examples 'does not add a system note' do
+ it 'does not change incident notes count' do
+ expect { perform }.not_to change { incident.notes.count }
+ end
+ end
+
+ context 'when incident and user exist' do
+ it 'creates a system note' do
+ expect { perform }.to change { incident.notes.where(author: user).count }.by(1)
+ end
+ end
+
+ context 'when incident does not exist' do
+ let(:incident_id) { -1 }
+
+ it_behaves_like 'does not add a system note'
+ end
+
+ context 'when incident_id is nil' do
+ let(:incident_id) { nil }
+
+ it_behaves_like 'does not add a system note'
+ end
+
+ context 'when issue is not an incident' do
+ let_it_be(:issue) { create(:issue, project: project) }
+ let(:incident_id) { issue.id }
+
+ it_behaves_like 'does not add a system note'
+ end
+
+ context 'when user does not exist' do
+ let(:user_id) { -1 }
+
+ it_behaves_like 'does not add a system note'
+ end
+
+ context 'when user_id is nil' do
+ let(:user_id) { nil }
+
+ it_behaves_like 'does not add a system note'
+ end
+ end
+end
diff --git a/spec/workers/incident_management/process_alert_worker_spec.rb b/spec/workers/incident_management/process_alert_worker_spec.rb
index 20ab283b49b..41d4f31da24 100644
--- a/spec/workers/incident_management/process_alert_worker_spec.rb
+++ b/spec/workers/incident_management/process_alert_worker_spec.rb
@@ -9,7 +9,6 @@ RSpec.describe IncidentManagement::ProcessAlertWorker do
describe '#perform' do
let_it_be(:started_at) { Time.now.rfc3339 }
let_it_be(:payload) { { 'title' => 'title', 'start_time' => started_at } }
- let_it_be(:parsed_payload) { Gitlab::Alerting::NotificationPayloadParser.call(payload, project) }
let_it_be(:alert) { create(:alert_management_alert, project: project, payload: payload, started_at: started_at) }
let(:created_issue) { Issue.last! }
@@ -68,7 +67,6 @@ RSpec.describe IncidentManagement::ProcessAlertWorker do
context 'prometheus alert' do
let_it_be(:alert) { create(:alert_management_alert, :prometheus, project: project, started_at: started_at) }
- let_it_be(:parsed_payload) { alert.payload }
it_behaves_like 'creates issue successfully'
end
diff --git a/spec/workers/incident_management/process_prometheus_alert_worker_spec.rb b/spec/workers/incident_management/process_prometheus_alert_worker_spec.rb
index c294892a66f..2ca4193aa72 100644
--- a/spec/workers/incident_management/process_prometheus_alert_worker_spec.rb
+++ b/spec/workers/incident_management/process_prometheus_alert_worker_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe IncidentManagement::ProcessPrometheusAlertWorker do
describe '#perform' do
let_it_be(:project) { create(:project) }
let_it_be(:prometheus_alert) { create(:prometheus_alert, project: project) }
- let(:payload_key) { Gitlab::Alerting::Alert.new(project: project, payload: alert_params).gitlab_fingerprint }
+ let(:payload_key) { Gitlab::AlertManagement::Payload::Prometheus.new(project: project, payload: alert_params).gitlab_fingerprint }
let!(:prometheus_alert_event) { create(:prometheus_alert_event, prometheus_alert: prometheus_alert, payload_key: payload_key) }
let!(:settings) { create(:project_incident_management_setting, project: project, create_issue: true) }
diff --git a/spec/workers/member_invitation_reminder_emails_worker_spec.rb b/spec/workers/member_invitation_reminder_emails_worker_spec.rb
new file mode 100644
index 00000000000..64d096f84f5
--- /dev/null
+++ b/spec/workers/member_invitation_reminder_emails_worker_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe MemberInvitationReminderEmailsWorker do
+ describe '#perform' do
+ subject { described_class.new.perform }
+
+ context 'feature flag disabled' do
+ before do
+ stub_experiment(invitation_reminders: false)
+ end
+
+ it 'does not raise an error' do
+ expect { subject }.not_to raise_error
+ end
+ end
+
+ context 'feature flag enabled' do
+ before do
+ stub_experiment(invitation_reminders: true)
+ end
+
+ it 'does not raise an error' do
+ expect { subject }.not_to raise_error
+ end
+ end
+ end
+end
diff --git a/spec/workers/metrics/dashboard/sync_dashboards_worker_spec.rb b/spec/workers/metrics/dashboard/sync_dashboards_worker_spec.rb
new file mode 100644
index 00000000000..19b79835825
--- /dev/null
+++ b/spec/workers/metrics/dashboard/sync_dashboards_worker_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Metrics::Dashboard::SyncDashboardsWorker do
+ include MetricsDashboardHelpers
+ subject(:worker) { described_class.new }
+
+ let(:project) { project_with_dashboard(dashboard_path) }
+ let(:dashboard_path) { '.gitlab/dashboards/test.yml' }
+
+ describe ".perform" do
+ it 'imports metrics' do
+ expect { worker.perform(project.id) }.to change(PrometheusMetric, :count).by(3)
+ end
+
+ it 'is idempotent' do
+ 2.times do
+ worker.perform(project.id)
+ end
+
+ expect(PrometheusMetric.count).to eq(3)
+ end
+ end
+end
diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb
index 50d164d1705..cb626b08456 100644
--- a/spec/workers/post_receive_spec.rb
+++ b/spec/workers/post_receive_spec.rb
@@ -290,7 +290,7 @@ RSpec.describe PostReceive do
# MySQL drops milliseconds in the timestamps, so advance at least
# a second to ensure we see changes.
- Timecop.freeze(1.second.from_now) do
+ travel_to(1.second.from_now) do
expect do
perform
project.reload
diff --git a/spec/workers/propagate_integration_group_worker_spec.rb b/spec/workers/propagate_integration_group_worker_spec.rb
new file mode 100644
index 00000000000..b3c9255db57
--- /dev/null
+++ b/spec/workers/propagate_integration_group_worker_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe PropagateIntegrationGroupWorker do
+ describe '#perform' do
+ let_it_be(:group1) { create(:group) }
+ let_it_be(:group2) { create(:group) }
+ let_it_be(:integration) { create(:redmine_service, :instance) }
+
+ before do
+ allow(BulkCreateIntegrationService).to receive(:new)
+ .with(integration, match_array([group1, group2]), 'group')
+ .and_return(double(execute: nil))
+ end
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { [integration.id, group1.id, group2.id] }
+
+ it 'calls to BulkCreateIntegrationService' do
+ expect(BulkCreateIntegrationService).to receive(:new)
+ .with(integration, match_array([group1, group2]), 'group')
+ .and_return(double(execute: nil))
+
+ subject
+ end
+ end
+
+ context 'with an invalid integration id' do
+ it 'returns without failure' do
+ expect(BulkCreateIntegrationService).not_to receive(:new)
+
+ subject.perform(0, group1.id, group2.id)
+ end
+ end
+ end
+end
diff --git a/spec/workers/propagate_integration_inherit_worker_spec.rb b/spec/workers/propagate_integration_inherit_worker_spec.rb
new file mode 100644
index 00000000000..88946b9926e
--- /dev/null
+++ b/spec/workers/propagate_integration_inherit_worker_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe PropagateIntegrationInheritWorker do
+ describe '#perform' do
+ let_it_be(:integration) { create(:redmine_service, :instance) }
+ let_it_be(:integration1) { create(:redmine_service, inherit_from_id: integration.id) }
+ let_it_be(:integration2) { create(:bugzilla_service, inherit_from_id: integration.id) }
+ let_it_be(:integration3) { create(:redmine_service) }
+
+ before do
+ allow(BulkUpdateIntegrationService).to receive(:new)
+ .with(integration, match_array(integration1))
+ .and_return(double(execute: nil))
+ end
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { [integration.id, integration1.id, integration3.id] }
+
+ it 'calls to BulkCreateIntegrationService' do
+ expect(BulkUpdateIntegrationService).to receive(:new)
+ .with(integration, match_array(integration1))
+ .and_return(double(execute: nil))
+
+ subject
+ end
+ end
+
+ context 'with an invalid integration id' do
+ it 'returns without failure' do
+ expect(BulkUpdateIntegrationService).not_to receive(:new)
+
+ subject.perform(0, integration1.id, integration3.id)
+ end
+ end
+ end
+end
diff --git a/spec/workers/propagate_integration_project_worker_spec.rb b/spec/workers/propagate_integration_project_worker_spec.rb
new file mode 100644
index 00000000000..3742ce5fde8
--- /dev/null
+++ b/spec/workers/propagate_integration_project_worker_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe PropagateIntegrationProjectWorker do
+ describe '#perform' do
+ let_it_be(:project1) { create(:project) }
+ let_it_be(:project2) { create(:project) }
+ let_it_be(:integration) { create(:redmine_service, :instance) }
+
+ before do
+ allow(BulkCreateIntegrationService).to receive(:new)
+ .with(integration, match_array([project1, project2]), 'project')
+ .and_return(double(execute: nil))
+ end
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { [integration.id, project1.id, project2.id] }
+
+ it 'calls to BulkCreateIntegrationService' do
+ expect(BulkCreateIntegrationService).to receive(:new)
+ .with(integration, match_array([project1, project2]), 'project')
+ .and_return(double(execute: nil))
+
+ subject
+ end
+ end
+
+ context 'with an invalid integration id' do
+ it 'returns without failure' do
+ expect(BulkCreateIntegrationService).not_to receive(:new)
+
+ subject.perform(0, project1.id, project2.id)
+ end
+ end
+ end
+end
diff --git a/vendor/Dockerfile/Golang-alpine.Dockerfile b/vendor/Dockerfile/Golang-alpine.Dockerfile
index 0287315219b..acea2eb9ace 100644
--- a/vendor/Dockerfile/Golang-alpine.Dockerfile
+++ b/vendor/Dockerfile/Golang-alpine.Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.8-alpine AS builder
+FROM golang:1.15-alpine AS builder
WORKDIR /usr/src/app
diff --git a/vendor/Dockerfile/Golang-scratch.Dockerfile b/vendor/Dockerfile/Golang-scratch.Dockerfile
index 9057a2d0e51..e5fd03c9a38 100644
--- a/vendor/Dockerfile/Golang-scratch.Dockerfile
+++ b/vendor/Dockerfile/Golang-scratch.Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.8-alpine AS builder
+FROM golang:1.15-alpine AS builder
# We'll likely need to add SSL root certificates
RUN apk --no-cache add ca-certificates
diff --git a/vendor/Dockerfile/Golang.Dockerfile b/vendor/Dockerfile/Golang.Dockerfile
index f9699dff665..bef0fdce065 100644
--- a/vendor/Dockerfile/Golang.Dockerfile
+++ b/vendor/Dockerfile/Golang.Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.8 AS builder
+FROM golang:1.15 AS builder
WORKDIR /usr/src/app
diff --git a/vendor/gitignore/C++.gitignore b/vendor/gitignore/C++.gitignore
index 259148fa18f..259148fa18f 100755..100644
--- a/vendor/gitignore/C++.gitignore
+++ b/vendor/gitignore/C++.gitignore
diff --git a/vendor/gitignore/Java.gitignore b/vendor/gitignore/Java.gitignore
index a1c2a238a96..a1c2a238a96 100755..100644
--- a/vendor/gitignore/Java.gitignore
+++ b/vendor/gitignore/Java.gitignore
diff --git a/vendor/languages.yml b/vendor/languages.yml
index a6edb0415b2..5e7a955b1bb 100755
--- a/vendor/languages.yml
+++ b/vendor/languages.yml
@@ -1,7 +1,8 @@
-# Extracted from https://github.com/github/linguist/blob/master/lib/linguist/languages.yml
-
# Defines all Languages known to GitHub.
#
+# fs_name - Optional field. Only necessary as a replacement for the sample directory name if the
+# language name is not a valid filename under the Windows filesystem (e.g., if it
+# contains an asterisk).
# type - Either data, programming, markup, prose, or nil
# aliases - An Array of additional aliases (implicitly
# includes name.downcase)
@@ -22,7 +23,7 @@
# language_id - Integer used as a language-name-independent indexed field so that we can rename
# languages in Linguist without reindexing all the code on GitHub. Must not be
# changed for existing languages without the explicit permission of GitHub staff.
-# color - CSS hex color to represent the language. Only used if type is "programming" or "prose".
+# color - CSS hex color to represent the language. Only used if type is "programming" or "markup".
# tm_scope - The TextMate scope that represents this programming
# language. This should match one of the scopes listed in
# the grammars.yml file. Use "none" if there is no grammar
@@ -45,11 +46,19 @@
tm_scope: source.bsl
ace_mode: text
language_id: 0
+4D:
+ type: programming
+ extensions:
+ - ".4dm"
+ tm_scope: source.4dm
+ ace_mode: text
+ language_id: 577529595
ABAP:
type: programming
color: "#E8274B"
extensions:
- ".abap"
+ tm_scope: source.abap
ace_mode: abap
language_id: 1
ABNF:
@@ -72,6 +81,14 @@ AGS Script:
codemirror_mode: clike
codemirror_mime_type: text/x-c++src
language_id: 2
+AL Code:
+ type: programming
+ color: "#3AA2B5"
+ extensions:
+ - ".al"
+ tm_scope: source.al
+ ace_mode: text
+ language_id: 658971832
AMPL:
type: programming
color: "#E6EFBB"
@@ -86,6 +103,7 @@ ANTLR:
color: "#9DC3FF"
extensions:
- ".g4"
+ tm_scope: source.antlr
ace_mode: text
language_id: 4
API Blueprint:
@@ -111,6 +129,14 @@ APL:
codemirror_mode: apl
codemirror_mime_type: text/apl
language_id: 6
+ASL:
+ type: programming
+ ace_mode: text
+ extensions:
+ - ".asl"
+ - ".dsl"
+ tm_scope: source.asl
+ language_id: 124996147
ASN.1:
type: data
extensions:
@@ -121,15 +147,14 @@ ASN.1:
codemirror_mode: asn.1
codemirror_mime_type: text/x-ttcn-asn
language_id: 7
-ASP:
+ASP.NET:
type: programming
- color: "#6a40fd"
tm_scope: text.html.asp
+ color: "#9400ff"
aliases:
- aspx
- aspx-vb
extensions:
- - ".asp"
- ".asax"
- ".ascx"
- ".ashx"
@@ -139,7 +164,7 @@ ASP:
ace_mode: text
codemirror_mode: htmlembedded
codemirror_mime_type: application/x-aspx
- language_id: 8
+ language_id: 564186416
ATS:
type: programming
color: "#1ac620"
@@ -174,6 +199,7 @@ Ada:
aliases:
- ada95
- ada2005
+ tm_scope: source.ada
ace_mode: ada
language_id: 11
Adobe Font Metrics:
@@ -193,6 +219,7 @@ Agda:
color: "#315665"
extensions:
- ".agda"
+ tm_scope: source.agda
ace_mode: text
language_id: 12
Alloy:
@@ -200,6 +227,7 @@ Alloy:
color: "#64C800"
extensions:
- ".als"
+ tm_scope: source.alloy
ace_mode: text
language_id: 13
Alpine Abuild:
@@ -215,6 +243,18 @@ Alpine Abuild:
codemirror_mode: shell
codemirror_mime_type: text/x-sh
language_id: 14
+Altium Designer:
+ type: data
+ aliases:
+ - altium
+ extensions:
+ - ".OutJob"
+ - ".PcbDoc"
+ - ".PrjPCB"
+ - ".SchDoc"
+ tm_scope: source.ini
+ ace_mode: ini
+ language_id: 187772328
AngelScript:
type: programming
color: "#C7D7DC"
@@ -253,6 +293,7 @@ ApacheConf:
language_id: 16
Apex:
type: programming
+ color: "#1797c0"
extensions:
- ".cls"
tm_scope: source.java
@@ -277,6 +318,7 @@ AppleScript:
- ".scpt"
interpreters:
- osascript
+ tm_scope: source.applescript
ace_mode: applescript
color: "#101F1F"
language_id: 19
@@ -315,11 +357,24 @@ Assembly:
extensions:
- ".asm"
- ".a51"
+ - ".i"
- ".inc"
- ".nasm"
tm_scope: source.assembly
ace_mode: assembly_x86
language_id: 24
+Asymptote:
+ type: programming
+ color: "#ff0000"
+ extensions:
+ - ".asy"
+ interpreters:
+ - asy
+ tm_scope: source.c++
+ ace_mode: c_cpp
+ codemirror_mode: clike
+ codemirror_mime_type: text/x-kotlin
+ language_id: 591605007
Augeas:
type: programming
extensions:
@@ -350,6 +405,13 @@ AutoIt:
tm_scope: source.autoit
ace_mode: autohotkey
language_id: 27
+Avro IDL:
+ type: data
+ extensions:
+ - ".avdl"
+ tm_scope: source.avro
+ ace_mode: text
+ language_id: 785497837
Awk:
type: programming
extensions:
@@ -363,6 +425,7 @@ Awk:
- gawk
- mawk
- nawk
+ tm_scope: source.awk
ace_mode: text
language_id: 28
Ballerina:
@@ -391,12 +454,24 @@ Befunge:
type: programming
extensions:
- ".befunge"
+ tm_scope: source.befunge
ace_mode: text
language_id: 30
+BibTeX:
+ type: markup
+ group: TeX
+ extensions:
+ - ".bib"
+ - ".bibtex"
+ tm_scope: text.bibtex
+ ace_mode: tex
+ codemirror_mode: stex
+ codemirror_mime_type: text/x-stex
+ language_id: 982188347
Bison:
type: programming
group: Yacc
- tm_scope: source.bison
+ tm_scope: source.yacc
extensions:
- ".bison"
ace_mode: text
@@ -410,7 +485,7 @@ BitBake:
language_id: 32
Blade:
type: markup
- group: HTML
+ color: "#f7523f"
extensions:
- ".blade"
- ".blade.php"
@@ -437,6 +512,7 @@ BlitzMax:
- ".bmx"
aliases:
- bmax
+ tm_scope: source.blitzmax
ace_mode: text
language_id: 35
Bluespec:
@@ -472,12 +548,6 @@ Brightscript:
tm_scope: source.brightscript
ace_mode: text
language_id: 39
-Bro:
- type: programming
- extensions:
- - ".bro"
- ace_mode: text
- language_id: 40
C:
type: programming
color: "#555555"
@@ -488,6 +558,7 @@ C:
- ".idc"
interpreters:
- tcc
+ tm_scope: source.c
ace_mode: c_cpp
codemirror_mode: clike
codemirror_mime_type: text/x-csrc
@@ -504,11 +575,12 @@ C#:
extensions:
- ".cs"
- ".cake"
- - ".cshtml"
- ".csx"
+ - ".linq"
language_id: 42
C++:
type: programming
+ tm_scope: source.c++
ace_mode: c_cpp
codemirror_mode: clike
codemirror_mime_type: text/x-c++src
@@ -567,6 +639,7 @@ CMake:
- ".cmake.in"
filenames:
- CMakeLists.txt
+ tm_scope: source.cmake
ace_mode: text
codemirror_mode: cmake
codemirror_mime_type: text/x-cmake
@@ -579,6 +652,7 @@ COBOL:
- ".ccp"
- ".cobol"
- ".cpy"
+ tm_scope: source.cobol
ace_mode: cobol
codemirror_mode: cobol
codemirror_mime_type: text/x-cobol
@@ -594,12 +668,11 @@ COLLADA:
language_id: 49
CSON:
type: data
- group: CoffeeScript
+ color: "#244776"
tm_scope: source.coffee
ace_mode: coffee
codemirror_mode: coffeescript
codemirror_mime_type: text/x-coffeescript
- searchable: false
extensions:
- ".cson"
language_id: 424
@@ -627,6 +700,20 @@ CWeb:
tm_scope: none
ace_mode: text
language_id: 657332628
+Cabal Config:
+ type: data
+ aliases:
+ - Cabal
+ extensions:
+ - ".cabal"
+ filenames:
+ - cabal.config
+ - cabal.project
+ ace_mode: haskell
+ codemirror_mode: haskell
+ codemirror_mime_type: text/x-haskell
+ tm_scope: source.cabal
+ language_id: 677095381
Cap'n Proto:
type: programming
tm_scope: source.capnp
@@ -658,6 +745,7 @@ Chapel:
- chpl
extensions:
- ".chpl"
+ tm_scope: source.chapel
ace_mode: text
language_id: 55
Charity:
@@ -679,6 +767,7 @@ ChucK:
Cirru:
type: programming
color: "#ccccff"
+ tm_scope: source.cirru
ace_mode: cirru
extensions:
- ".cirru"
@@ -691,6 +780,16 @@ Clarion:
- ".clw"
tm_scope: source.clarion
language_id: 59
+Classic ASP:
+ type: programming
+ color: "#6a40fd"
+ tm_scope: text.html.asp
+ aliases:
+ - asp
+ extensions:
+ - ".asp"
+ ace_mode: text
+ language_id: 8
Clean:
type: programming
color: "#3F85AF"
@@ -710,6 +809,7 @@ Click:
language_id: 61
Clojure:
type: programming
+ tm_scope: source.clojure
ace_mode: clojure
codemirror_mode: clojure
codemirror_mime_type: text/x-clojure
@@ -759,6 +859,16 @@ CoNLL-U:
- CoNLL
- CoNLL-X
language_id: 421026389
+CodeQL:
+ type: programming
+ extensions:
+ - ".ql"
+ - ".qll"
+ tm_scope: source.ql
+ ace_mode: text
+ language_id: 424259634
+ aliases:
+ - ql
CoffeeScript:
type: programming
tm_scope: source.coffee
@@ -868,6 +978,7 @@ Coq:
extensions:
- ".coq"
- ".v"
+ tm_scope: source.coq
ace_mode: text
language_id: 69
Cpp-ObjDump:
@@ -893,7 +1004,7 @@ Creole:
language_id: 71
Crystal:
type: programming
- color: "#776791"
+ color: "#000100"
extensions:
- ".cr"
ace_mode: ruby
@@ -960,6 +1071,7 @@ Cython:
- ".pxi"
aliases:
- pyrex
+ tm_scope: source.cython
ace_mode: text
codemirror_mode: python
codemirror_mime_type: text/x-cython
@@ -970,6 +1082,7 @@ D:
extensions:
- ".d"
- ".di"
+ tm_scope: source.d
ace_mode: d
codemirror_mode: d
codemirror_mime_type: text/x-d
@@ -1021,6 +1134,16 @@ DTrace:
codemirror_mode: clike
codemirror_mime_type: text/x-csrc
language_id: 85
+Dafny:
+ type: programming
+ color: "#FFEC25"
+ extensions:
+ - ".dfy"
+ interpreters:
+ - dafny
+ tm_scope: text.dfy.dafny
+ ace_mode: text
+ language_id: 969323346
Darcs Patch:
type: data
aliases:
@@ -1038,6 +1161,7 @@ Dart:
- ".dart"
interpreters:
- dart
+ tm_scope: source.dart
ace_mode: dart
codemirror_mode: dart
codemirror_mime_type: application/dart
@@ -1050,6 +1174,16 @@ DataWeave:
ace_mode: text
tm_scope: source.data-weave
language_id: 974514097
+Dhall:
+ type: programming
+ color: "#dfafff"
+ extensions:
+ - ".dhall"
+ tm_scope: source.haskell
+ ace_mode: haskell
+ codemirror_mode: haskell
+ codemirror_mime_type: text/x-haskell
+ language_id: 793969321
Diff:
type: data
extensions:
@@ -1062,9 +1196,16 @@ Diff:
codemirror_mode: diff
codemirror_mime_type: text/x-diff
language_id: 88
+DirectX 3D File:
+ type: data
+ extensions:
+ - ".x"
+ ace_mode: text
+ tm_scope: none
+ language_id: 201049282
Dockerfile:
type: programming
- color: "#0db7ed"
+ color: "#384d54"
tm_scope: source.dockerfile
extensions:
- ".dockerfile"
@@ -1090,6 +1231,7 @@ Dylan:
- ".dyl"
- ".intr"
- ".lid"
+ tm_scope: source.dylan
ace_mode: text
codemirror_mode: dylan
codemirror_mime_type: text/x-dylan
@@ -1137,9 +1279,19 @@ EJS:
group: HTML
extensions:
- ".ejs"
+ - ".ect"
+ - ".jst"
tm_scope: text.html.js
ace_mode: ejs
language_id: 95
+EML:
+ type: data
+ extensions:
+ - ".eml"
+ - ".mbox"
+ tm_scope: text.eml.basic
+ ace_mode: text
+ language_id: 529653389
EQ:
type: programming
color: "#a78649"
@@ -1180,20 +1332,33 @@ Ecere Projects:
codemirror_mode: javascript
codemirror_mime_type: application/json
language_id: 98
+EditorConfig:
+ type: data
+ group: INI
+ filenames:
+ - ".editorconfig"
+ aliases:
+ - editor-config
+ ace_mode: ini
+ codemirror_mode: properties
+ codemirror_mime_type: text/x-properties
+ tm_scope: source.editorconfig
+ language_id: 96139566
Edje Data Collection:
type: data
extensions:
- ".edc"
- tm_scope: source.json
- ace_mode: json
- codemirror_mode: javascript
- codemirror_mime_type: application/json
+ tm_scope: source.c++
+ ace_mode: c_cpp
+ codemirror_mode: clike
+ codemirror_mime_type: text/x-c++src
language_id: 342840478
Eiffel:
type: programming
- color: "#946d57"
+ color: "#4d6977"
extensions:
- ".e"
+ tm_scope: source.eiffel
ace_mode: eiffel
codemirror_mode: eiffel
codemirror_mime_type: text/x-eiffel
@@ -1204,6 +1369,7 @@ Elixir:
extensions:
- ".ex"
- ".exs"
+ tm_scope: source.elixir
ace_mode: elixir
filenames:
- mix.lock
@@ -1273,6 +1439,7 @@ Erlang:
- rebar.config
- rebar.config.lock
- rebar.lock
+ tm_scope: source.erlang
ace_mode: erlang
codemirror_mode: erlang
codemirror_mime_type: text/x-erlang
@@ -1293,6 +1460,26 @@ F#:
codemirror_mode: mllike
codemirror_mime_type: text/x-fsharp
language_id: 105
+F*:
+ fs_name: Fstar
+ type: programming
+ color: "#572e30"
+ aliases:
+ - fstar
+ extensions:
+ - ".fst"
+ tm_scope: source.fstar
+ ace_mode: text
+ language_id: 336943375
+FIGlet Font:
+ type: data
+ aliases:
+ - FIGfont
+ extensions:
+ - ".flf"
+ tm_scope: source.figfont
+ ace_mode: text
+ language_id: 686129783
FLUX:
type: programming
color: "#88ccff"
@@ -1310,6 +1497,7 @@ Factor:
filenames:
- ".factor-boot-rc"
- ".factor-rc"
+ tm_scope: source.factor
ace_mode: text
codemirror_mode: factor
codemirror_mime_type: text/x-factor
@@ -1322,6 +1510,7 @@ Fancy:
- ".fancypack"
filenames:
- Fakefile
+ tm_scope: source.fancy
ace_mode: text
language_id: 109
Fantom:
@@ -1332,6 +1521,14 @@ Fantom:
tm_scope: source.fan
ace_mode: text
language_id: 110
+Faust:
+ type: programming
+ color: "#c37240"
+ extensions:
+ - ".dsp"
+ tm_scope: source.faust
+ ace_mode: text
+ language_id: 622529198
Filebench WML:
type: programming
extensions:
@@ -1367,27 +1564,38 @@ Forth:
- ".fr"
- ".frt"
- ".fs"
+ tm_scope: source.forth
ace_mode: forth
codemirror_mode: forth
codemirror_mime_type: text/x-forth
language_id: 114
Fortran:
+ group: Fortran
type: programming
color: "#4d41b1"
extensions:
- - ".f90"
- ".f"
- - ".f03"
- - ".f08"
- ".f77"
- - ".f95"
- ".for"
- ".fpp"
- tm_scope: source.fortran.modern
+ tm_scope: source.fortran
ace_mode: text
codemirror_mode: fortran
codemirror_mime_type: text/x-fortran
language_id: 107
+Fortran Free Form:
+ group: Fortran
+ type: programming
+ extensions:
+ - ".f90"
+ - ".f03"
+ - ".f08"
+ - ".f95"
+ tm_scope: source.fortran.modern
+ ace_mode: text
+ codemirror_mode: fortran
+ codemirror_mime_type: text/x-fortran
+ language_id: 761352333
FreeMarker:
type: programming
color: "#0050b2"
@@ -1406,15 +1614,33 @@ Frege:
tm_scope: source.haskell
ace_mode: haskell
language_id: 116
+Futhark:
+ type: programming
+ color: "#5f021f"
+ extensions:
+ - ".fut"
+ tm_scope: source.futhark
+ ace_mode: text
+ language_id: 97358117
G-code:
- type: data
+ type: programming
+ color: "#D08CF2"
extensions:
- ".g"
+ - ".cnc"
- ".gco"
- ".gcode"
tm_scope: source.gcode
ace_mode: gcode
language_id: 117
+GAML:
+ type: programming
+ color: "#FFC766"
+ extensions:
+ - ".gaml"
+ tm_scope: none
+ ace_mode: text
+ language_id: 290345951
GAMS:
type: programming
extensions:
@@ -1458,6 +1684,13 @@ GDScript:
tm_scope: source.gdscript
ace_mode: text
language_id: 123
+GEDCOM:
+ type: data
+ ace_mode: text
+ extensions:
+ - ".ged"
+ tm_scope: source.gedcom
+ language_id: 459577965
GLSL:
type: programming
extensions:
@@ -1470,7 +1703,9 @@ GLSL:
- ".fshader"
- ".geo"
- ".geom"
+ - ".glslf"
- ".glslv"
+ - ".gs"
- ".gshader"
- ".shader"
- ".tesc"
@@ -1479,6 +1714,7 @@ GLSL:
- ".vrx"
- ".vsh"
- ".vshader"
+ tm_scope: source.glsl
ace_mode: glsl
language_id: 124
GN:
@@ -1497,7 +1733,7 @@ GN:
language_id: 302957008
Game Maker Language:
type: programming
- color: "#8fb200"
+ color: "#71b417"
extensions:
- ".gml"
tm_scope: source.c++
@@ -1556,12 +1792,14 @@ Gerber Image:
- ".gbp"
- ".gbs"
- ".gko"
+ - ".gml"
- ".gpb"
- ".gpt"
- ".gtl"
- ".gto"
- ".gtp"
- ".gts"
+ - ".sol"
interpreters:
- gerbv
- gerbview
@@ -1583,12 +1821,41 @@ Gherkin:
type: programming
extensions:
- ".feature"
+ - ".story"
tm_scope: text.gherkin.feature
aliases:
- cucumber
ace_mode: text
color: "#5B2063"
language_id: 76
+Git Attributes:
+ type: data
+ group: INI
+ aliases:
+ - gitattributes
+ filenames:
+ - ".gitattributes"
+ tm_scope: source.gitattributes
+ ace_mode: gitignore
+ codemirror_mode: shell
+ codemirror_mime_type: text/x-sh
+ language_id: 956324166
+Git Config:
+ type: data
+ group: INI
+ aliases:
+ - gitconfig
+ - gitmodules
+ extensions:
+ - ".gitconfig"
+ filenames:
+ - ".gitconfig"
+ - ".gitmodules"
+ ace_mode: ini
+ codemirror_mode: properties
+ codemirror_mime_type: text/x-properties
+ tm_scope: source.gitconfig
+ language_id: 807968997
Glyph:
type: programming
color: "#c1ac7f"
@@ -1599,6 +1866,13 @@ Glyph:
codemirror_mode: tcl
codemirror_mime_type: text/x-tcl
language_id: 130
+Glyph Bitmap Distribution Format:
+ type: data
+ extensions:
+ - ".bdf"
+ tm_scope: source.bdf
+ ace_mode: text
+ language_id: 997665271
Gnuplot:
type: programming
color: "#f0a9f0"
@@ -1606,19 +1880,22 @@ Gnuplot:
- ".gp"
- ".gnu"
- ".gnuplot"
+ - ".p"
- ".plot"
- ".plt"
interpreters:
- gnuplot
+ tm_scope: source.gnuplot
ace_mode: text
language_id: 131
Go:
type: programming
- color: "#375eab"
+ color: "#00ADD8"
aliases:
- golang
extensions:
- ".go"
+ tm_scope: source.go
ace_mode: golang
codemirror_mode: go
codemirror_mime_type: text/x-go
@@ -1660,12 +1937,10 @@ Grammatical Framework:
type: programming
aliases:
- gf
- wrap: false
extensions:
- ".gf"
- searchable: true
- color: "#79aa7a"
- tm_scope: source.haskell
+ color: "#ff0000"
+ tm_scope: source.gf
ace_mode: haskell
codemirror_mode: haskell
codemirror_mime_type: text/x-haskell
@@ -1682,6 +1957,7 @@ GraphQL:
extensions:
- ".graphql"
- ".gql"
+ - ".graphqls"
tm_scope: source.graphql
ace_mode: text
language_id: 139
@@ -1695,6 +1971,7 @@ Graphviz (DOT):
language_id: 140
Groovy:
type: programming
+ tm_scope: source.groovy
ace_mode: groovy
codemirror_mode: groovy
codemirror_mime_type: text/x-groovy
@@ -1722,12 +1999,25 @@ Groovy Server Pages:
codemirror_mode: htmlembedded
codemirror_mime_type: application/x-jsp
language_id: 143
+HAProxy:
+ type: data
+ extensions:
+ - ".cfg"
+ filenames:
+ - haproxy.cfg
+ tm_scope: source.haproxy-config
+ ace_mode: text
+ language_id: 366607477
HCL:
type: programming
extensions:
- ".hcl"
+ - ".nomad"
- ".tf"
- ".tfvars"
+ - ".workflow"
+ aliases:
+ - terraform
ace_mode: ruby
codemirror_mode: ruby
codemirror_mime_type: text/x-ruby
@@ -1768,8 +2058,8 @@ HTML+Django:
group: HTML
extensions:
- ".jinja"
+ - ".j2"
- ".jinja2"
- - ".mustache"
- ".njk"
aliases:
- django
@@ -1812,9 +2102,12 @@ HTML+ERB:
group: HTML
aliases:
- erb
+ - rhtml
+ - html+ruby
extensions:
- ".erb"
- ".erb.deface"
+ - ".rhtml"
ace_mode: text
codemirror_mode: htmlembedded
codemirror_mime_type: application/x-erb
@@ -1829,6 +2122,19 @@ HTML+PHP:
codemirror_mode: php
codemirror_mime_type: application/x-httpd-php
language_id: 151
+HTML+Razor:
+ type: markup
+ tm_scope: text.html.cshtml
+ group: HTML
+ aliases:
+ - razor
+ extensions:
+ - ".cshtml"
+ - ".razor"
+ ace_mode: razor
+ codemirror_mode: htmlmixed
+ codemirror_mime_type: text/html
+ language_id: 479039817
HTTP:
type: data
extensions:
@@ -1851,24 +2157,27 @@ Hack:
codemirror_mode: php
codemirror_mime_type: application/x-httpd-php
extensions:
+ - ".hack"
- ".hh"
+ - ".hhi"
- ".php"
- tm_scope: text.html.php
+ tm_scope: source.hack
color: "#878787"
language_id: 153
Haml:
- group: HTML
type: markup
+ color: "#ece2a9"
extensions:
- ".haml"
- ".haml.deface"
+ tm_scope: text.haml
ace_mode: haml
codemirror_mode: haml
codemirror_mime_type: text/x-haml
language_id: 154
Handlebars:
type: markup
- group: HTML
+ color: "#f7931e"
aliases:
- hbs
- htmlbars
@@ -1891,9 +2200,11 @@ Haskell:
color: "#5e5086"
extensions:
- ".hs"
+ - ".hs-boot"
- ".hsc"
interpreters:
- runhaskell
+ tm_scope: source.haskell
ace_mode: haskell
codemirror_mode: haskell
codemirror_mime_type: text/x-haskell
@@ -1913,10 +2224,21 @@ HiveQL:
type: programming
extensions:
- ".q"
+ - ".hql"
color: "#dce200"
tm_scope: source.hql
ace_mode: sql
language_id: 931814087
+HolyC:
+ type: programming
+ color: "#ffefaf"
+ extensions:
+ - ".hc"
+ tm_scope: source.hc
+ ace_mode: c_cpp
+ codemirror_mode: clike
+ codemirror_mime_type: text/x-csrc
+ language_id: 928121743
Hy:
type: programming
ace_mode: text
@@ -1924,10 +2246,10 @@ Hy:
extensions:
- ".hy"
interpreters:
- - "hy"
+ - hy
aliases:
- hylang
- tm_scope: none
+ tm_scope: source.hy
language_id: 159
HyPhy:
type: programming
@@ -1942,18 +2264,20 @@ IDL:
extensions:
- ".pro"
- ".dlm"
+ tm_scope: source.idl
ace_mode: text
codemirror_mode: idl
codemirror_mime_type: text/x-idl
language_id: 161
IGOR Pro:
type: programming
+ color: "#0000cc"
extensions:
- ".ipf"
aliases:
- igor
- igorpro
- tm_scope: none
+ tm_scope: source.igor
ace_mode: text
language_id: 162
INI:
@@ -1961,12 +2285,12 @@ INI:
extensions:
- ".ini"
- ".cfg"
+ - ".dof"
+ - ".lektorproject"
- ".prefs"
- ".pro"
- ".properties"
filenames:
- - ".editorconfig"
- - ".gitconfig"
- buildozer.spec
tm_scope: source.ini
aliases:
@@ -1997,6 +2321,36 @@ Idris:
ace_mode: text
tm_scope: source.idris
language_id: 165
+Ignore List:
+ type: data
+ group: INI
+ aliases:
+ - ignore
+ - gitignore
+ - git-ignore
+ extensions:
+ - ".gitignore"
+ filenames:
+ - ".atomignore"
+ - ".babelignore"
+ - ".bzrignore"
+ - ".coffeelintignore"
+ - ".cvsignore"
+ - ".dockerignore"
+ - ".eslintignore"
+ - ".gitignore"
+ - ".nodemonignore"
+ - ".npmignore"
+ - ".prettierignore"
+ - ".stylelintignore"
+ - ".vscodeignore"
+ - gitignore-global
+ - gitignore_global
+ ace_mode: gitignore
+ tm_scope: source.gitignore
+ codemirror_mode: shell
+ codemirror_mime_type: text/x-sh
+ language_id: 74444240
Inform 7:
type: programming
wrap: true
@@ -2013,7 +2367,8 @@ Inno Setup:
type: programming
extensions:
- ".iss"
- tm_scope: none
+ - ".isl"
+ tm_scope: source.inno
ace_mode: text
language_id: 167
Io:
@@ -2023,6 +2378,7 @@ Io:
- ".io"
interpreters:
- io
+ tm_scope: source.io
ace_mode: io
language_id: 168
Ioke:
@@ -2032,6 +2388,7 @@ Ioke:
- ".ik"
interpreters:
- ioke
+ tm_scope: source.ioke
ace_mode: text
language_id: 169
Isabelle:
@@ -2072,7 +2429,6 @@ JFlex:
JSON:
type: data
tm_scope: source.json
- group: JavaScript
ace_mode: json
codemirror_mode: javascript
codemirror_mime_type: application/json
@@ -2082,18 +2438,24 @@ JSON:
- ".avsc"
- ".geojson"
- ".gltf"
+ - ".har"
+ - ".ice"
- ".JSON-tmLanguage"
- ".jsonl"
+ - ".mcmeta"
- ".tfstate"
- ".tfstate.backup"
- ".topojson"
- ".webapp"
- ".webmanifest"
+ - ".yy"
+ - ".yyp"
filenames:
- ".arcconfig"
- ".htmlhintrc"
- ".tern-config"
- ".tern-project"
+ - ".watchmanconfig"
- composer.lock
- mcmod.info
language_id: 174
@@ -2107,6 +2469,7 @@ JSON with Comments:
aliases:
- jsonc
extensions:
+ - ".jsonc"
- ".sublime-build"
- ".sublime-commands"
- ".sublime-completions"
@@ -2126,7 +2489,10 @@ JSON with Comments:
- ".jscsrc"
- ".jshintrc"
- ".jslintrc"
+ - jsconfig.json
+ - language-configuration.json
- tsconfig.json
+ - tslint.json
language_id: 423
JSON5:
type: data
@@ -2139,11 +2505,12 @@ JSON5:
language_id: 175
JSONLD:
type: data
- group: JavaScript
- ace_mode: javascript
extensions:
- ".jsonld"
tm_scope: source.js
+ ace_mode: javascript
+ codemirror_mode: javascript
+ codemirror_mime_type: application/json
language_id: 176
JSONiq:
color: "#40d47e"
@@ -2174,6 +2541,7 @@ Jasmin:
language_id: 180
Java:
type: programming
+ tm_scope: source.java
ace_mode: java
codemirror_mode: clike
codemirror_mime_type: text/x-java
@@ -2181,6 +2549,15 @@ Java:
extensions:
- ".java"
language_id: 181
+Java Properties:
+ type: data
+ extensions:
+ - ".properties"
+ tm_scope: source.java-properties
+ ace_mode: properties
+ codemirror_mode: properties
+ codemirror_mime_type: text/x-properties
+ language_id: 519377561
Java Server Pages:
type: programming
group: Java
@@ -2207,6 +2584,7 @@ JavaScript:
- ".js"
- "._js"
- ".bones"
+ - ".cjs"
- ".es"
- ".es6"
- ".frag"
@@ -2227,8 +2605,27 @@ JavaScript:
filenames:
- Jakefile
interpreters:
+ - chakra
+ - d8
+ - gjs
+ - js
- node
+ - nodejs
+ - qjs
+ - rhino
+ - v8
+ - v8-shell
language_id: 183
+JavaScript+ERB:
+ type: programming
+ tm_scope: source.js
+ group: JavaScript
+ extensions:
+ - ".js.erb"
+ ace_mode: javascript
+ codemirror_mode: javascript
+ codemirror_mime_type: application/javascript
+ language_id: 914318960
Jison:
type: programming
group: Yacc
@@ -2256,6 +2653,15 @@ Jolie:
ace_mode: text
tm_scope: source.jolie
language_id: 998078858
+Jsonnet:
+ color: "#0064bd"
+ type: programming
+ ace_mode: text
+ extensions:
+ - ".jsonnet"
+ - ".libsonnet"
+ tm_scope: source.jsonnet
+ language_id: 664885656
Julia:
type: programming
extensions:
@@ -2263,6 +2669,7 @@ Julia:
interpreters:
- julia
color: "#a270ba"
+ tm_scope: source.julia
ace_mode: julia
codemirror_mode: julia
codemirror_mime_type: text/x-julia
@@ -2289,6 +2696,18 @@ KRL:
tm_scope: none
ace_mode: text
language_id: 186
+Kaitai Struct:
+ type: programming
+ aliases:
+ - ksy
+ ace_mode: yaml
+ codemirror_mode: yaml
+ codemirror_mime_type: text/x-yaml
+ color: "#773b37"
+ extensions:
+ - ".ksy"
+ tm_scope: source.yaml
+ language_id: 818804755
KiCad Layout:
type: data
aliases:
@@ -2355,6 +2774,7 @@ LLVM:
type: programming
extensions:
- ".ll"
+ tm_scope: source.llvm
ace_mode: text
color: "#185619"
language_id: 191
@@ -2368,6 +2788,7 @@ LOLCODE:
language_id: 192
LSL:
type: programming
+ tm_scope: source.lsl
ace_mode: lsl
extensions:
- ".lsl"
@@ -2376,6 +2797,15 @@ LSL:
- lsl
color: "#3d9970"
language_id: 193
+LTspice Symbol:
+ type: data
+ extensions:
+ - ".asy"
+ tm_scope: source.ltspice.symbol
+ ace_mode: text
+ codemirror_mode: spreadsheet
+ codemirror_mime_type: text/x-spreadsheet
+ language_id: 1013566805
LabVIEW:
type: programming
extensions:
@@ -2393,7 +2823,6 @@ Lasso:
- ".las"
- ".lasso8"
- ".lasso9"
- - ".ldml"
tm_scope: file.lasso
aliases:
- lassoscript
@@ -2401,7 +2830,7 @@ Lasso:
language_id: 195
Latte:
type: markup
- group: HTML
+ color: "#f2a542"
extensions:
- ".latte"
tm_scope: text.html.smarty
@@ -2414,11 +2843,12 @@ Lean:
extensions:
- ".lean"
- ".hlean"
+ tm_scope: source.lean
ace_mode: text
language_id: 197
Less:
type: markup
- group: CSS
+ color: "#1d365d"
extensions:
- ".less"
tm_scope: source.css.less
@@ -2434,7 +2864,10 @@ Lex:
extensions:
- ".l"
- ".lex"
- tm_scope: none
+ filenames:
+ - Lexer.x
+ - lexer.x
+ tm_scope: source.lex
ace_mode: text
language_id: 199
LilyPond:
@@ -2442,6 +2875,7 @@ LilyPond:
extensions:
- ".ly"
- ".ily"
+ tm_scope: source.lilypond
ace_mode: text
language_id: 200
Limbo:
@@ -2495,6 +2929,7 @@ Literate CoffeeScript:
- litcoffee
extensions:
- ".litcoffee"
+ - ".coffee.md"
language_id: 206
Literate Haskell:
type: programming
@@ -2520,6 +2955,7 @@ LiveScript:
- "._ls"
filenames:
- Slakefile
+ tm_scope: source.livescript
ace_mode: livescript
codemirror_mode: livescript
codemirror_mime_type: text/x-livescript
@@ -2538,6 +2974,7 @@ Logtalk:
extensions:
- ".lgt"
- ".logtalk"
+ tm_scope: source.logtalk
ace_mode: text
language_id: 210
LookML:
@@ -2561,6 +2998,7 @@ LoomScript:
language_id: 212
Lua:
type: programming
+ tm_scope: source.lua
ace_mode: lua
codemirror_mode: lua
codemirror_mime_type: text/x-lua
@@ -2572,7 +3010,10 @@ Lua:
- ".p8"
- ".pd_lua"
- ".rbxs"
+ - ".rockspec"
- ".wlua"
+ filenames:
+ - ".luacheckrc"
interpreters:
- lua
language_id: 213
@@ -2592,7 +3033,7 @@ M4:
type: programming
extensions:
- ".m4"
- tm_scope: none
+ tm_scope: source.m4
ace_mode: text
language_id: 215
M4Sugar:
@@ -2604,9 +3045,22 @@ M4Sugar:
- ".m4"
filenames:
- configure.ac
- tm_scope: none
+ tm_scope: source.m4
ace_mode: text
language_id: 216
+MATLAB:
+ type: programming
+ color: "#e16737"
+ aliases:
+ - octave
+ extensions:
+ - ".matlab"
+ - ".m"
+ tm_scope: source.matlab
+ ace_mode: matlab
+ codemirror_mode: octave
+ codemirror_mime_type: text/x-octave
+ language_id: 225
MAXScript:
type: programming
color: "#00a6a6"
@@ -2616,6 +3070,14 @@ MAXScript:
tm_scope: source.maxscript
ace_mode: text
language_id: 217
+MLIR:
+ type: programming
+ color: "#5EC8DB"
+ extensions:
+ - ".mlir"
+ tm_scope: source.mlir
+ ace_mode: text
+ language_id: 448253929
MQL4:
type: programming
color: "#62A8D6"
@@ -2655,6 +3117,18 @@ MUF:
codemirror_mode: forth
codemirror_mime_type: text/x-forth
language_id: 219
+Macaulay2:
+ type: programming
+ extensions:
+ - ".m2"
+ aliases:
+ - m2
+ interpreters:
+ - M2
+ ace_mode: text
+ tm_scope: source.m2
+ color: "#d8ffff"
+ language_id: 34167825
Makefile:
type: programming
color: "#427819"
@@ -2684,6 +3158,7 @@ Makefile:
- mkfile
interpreters:
- make
+ tm_scope: source.makefile
ace_mode: makefile
codemirror_mode: cmake
codemirror_mime_type: text/x-cmake
@@ -2698,6 +3173,7 @@ Mako:
language_id: 221
Markdown:
type: prose
+ color: "#083fa1"
aliases:
- pandoc
ace_mode: markdown
@@ -2709,16 +3185,19 @@ Markdown:
- ".markdown"
- ".mdown"
- ".mdwn"
+ - ".mdx"
- ".mkd"
- ".mkdn"
- ".mkdown"
- ".ronn"
- ".workbook"
+ filenames:
+ - contents.lr
tm_scope: source.gfm
language_id: 222
Marko:
- group: HTML
type: markup
+ color: "#42bff2"
tm_scope: text.marko
extensions:
- ".marko"
@@ -2750,22 +3229,11 @@ Mathematica:
- ".wlt"
aliases:
- mma
+ tm_scope: source.mathematica
ace_mode: text
codemirror_mode: mathematica
codemirror_mime_type: text/x-mathematica
language_id: 224
-Matlab:
- type: programming
- color: "#e16737"
- aliases:
- - octave
- extensions:
- - ".matlab"
- - ".m"
- ace_mode: matlab
- codemirror_mode: octave
- codemirror_mime_type: text/x-octave
- language_id: 225
Maven POM:
type: data
tm_scope: text.xml.pom
@@ -2831,6 +3299,13 @@ Metal:
codemirror_mode: clike
codemirror_mime_type: text/x-c++src
language_id: 230
+Microsoft Developer Studio Project:
+ type: data
+ extensions:
+ - ".dsp"
+ tm_scope: none
+ ace_mode: text
+ language_id: 800983837
MiniD:
type: programming
searchable: false
@@ -2910,8 +3385,44 @@ MoonScript:
- ".moon"
interpreters:
- moon
+ tm_scope: source.moonscript
ace_mode: text
language_id: 238
+Motorola 68K Assembly:
+ type: programming
+ group: Assembly
+ aliases:
+ - m68k
+ extensions:
+ - ".asm"
+ - ".i"
+ - ".inc"
+ - ".s"
+ - ".x68"
+ tm_scope: source.m68k
+ ace_mode: assembly_x86
+ language_id: 477582706
+Muse:
+ type: prose
+ extensions:
+ - ".muse"
+ tm_scope: text.muse
+ ace_mode: text
+ wrap: true
+ language_id: 474864066
+ aliases:
+ - amusewiki
+ - emacs muse
+Mustache:
+ type: markup
+ group: HTML
+ extensions:
+ - ".mustache"
+ tm_scope: text.html.smarty
+ ace_mode: smarty
+ codemirror_mode: smarty
+ codemirror_mime_type: text/x-smarty
+ language_id: 638334590
Myghty:
type: programming
extensions:
@@ -2919,6 +3430,14 @@ Myghty:
tm_scope: none
ace_mode: text
language_id: 239
+NASL:
+ type: programming
+ extensions:
+ - ".nasl"
+ - ".inc"
+ tm_scope: source.nasl
+ ace_mode: text
+ language_id: 171666519
NCL:
type: programming
color: "#28431f"
@@ -2927,6 +3446,16 @@ NCL:
tm_scope: source.ncl
ace_mode: text
language_id: 240
+NEON:
+ type: data
+ extensions:
+ - ".neon"
+ tm_scope: source.neon
+ ace_mode: text
+ aliases:
+ - nette object notation
+ - ne-on
+ language_id: 481192983
NL:
type: data
extensions:
@@ -2934,11 +3463,22 @@ NL:
tm_scope: none
ace_mode: text
language_id: 241
+NPM Config:
+ type: data
+ group: INI
+ aliases:
+ - npmrc
+ filenames:
+ - ".npmrc"
+ tm_scope: source.ini.npmrc
+ ace_mode: text
+ language_id: 685022663
NSIS:
type: programming
extensions:
- ".nsi"
- ".nsh"
+ tm_scope: source.nsis
ace_mode: text
codemirror_mode: nsis
codemirror_mime_type: text/x-nsis
@@ -2957,6 +3497,7 @@ Nemerle:
color: "#3d3c6e"
extensions:
- ".n"
+ tm_scope: source.nemerle
ace_mode: text
language_id: 243
NetLinx:
@@ -3016,6 +3557,7 @@ Nextflow:
Nginx:
type: data
extensions:
+ - ".nginx"
- ".nginxconf"
- ".vhost"
filenames:
@@ -3029,10 +3571,15 @@ Nginx:
language_id: 248
Nim:
type: programming
- color: "#37775b"
+ color: "#ffc200"
extensions:
- ".nim"
+ - ".nim.cfg"
+ - ".nimble"
- ".nimrod"
+ - ".nims"
+ filenames:
+ - nim.cfg
ace_mode: text
tm_scope: source.nim
language_id: 249
@@ -3116,6 +3663,21 @@ ObjDump:
tm_scope: objdump.x86asm
ace_mode: assembly_x86
language_id: 256
+Object Data Instance Notation:
+ type: data
+ extensions:
+ - ".odin"
+ tm_scope: source.odin-ehr
+ ace_mode: text
+ language_id: 985227236
+ObjectScript:
+ type: programming
+ extensions:
+ - ".cls"
+ language_id: 202735509
+ tm_scope: source.objectscript
+ color: "#424893"
+ ace_mode: text
Objective-C:
type: programming
tm_scope: source.objc
@@ -3158,6 +3720,17 @@ Objective-J:
tm_scope: source.js.objj
ace_mode: text
language_id: 259
+Odin:
+ type: programming
+ color: "#60AFFE"
+ aliases:
+ - odinlang
+ - odin-lang
+ extensions:
+ - ".odin"
+ tm_scope: source.odin
+ ace_mode: text
+ language_id: 889244082
Omgrofl:
type: programming
extensions:
@@ -3170,6 +3743,7 @@ Opa:
type: programming
extensions:
- ".opa"
+ tm_scope: source.opa
ace_mode: text
language_id: 261
Opal:
@@ -3180,6 +3754,13 @@ Opal:
tm_scope: source.opal
ace_mode: text
language_id: 262
+Open Policy Agent:
+ type: programming
+ ace_mode: text
+ extensions:
+ - ".rego"
+ language_id: 840483232
+ tm_scope: source.rego
OpenCL:
type: programming
group: C
@@ -3204,6 +3785,14 @@ OpenEdge ABL:
tm_scope: source.abl
ace_mode: text
language_id: 264
+OpenQASM:
+ type: programming
+ extensions:
+ - ".qasm"
+ color: "#AA70FF"
+ tm_scope: source.qasm
+ ace_mode: text
+ language_id: 153739399
OpenRC runscript:
type: programming
group: Shell
@@ -3223,6 +3812,13 @@ OpenSCAD:
tm_scope: source.scad
ace_mode: scad
language_id: 266
+OpenStep Property List:
+ type: data
+ extensions:
+ - ".plist"
+ tm_scope: source.plist
+ ace_mode: text
+ language_id: 598917541
OpenType Feature File:
type: data
aliases:
@@ -3275,15 +3871,6 @@ P4:
tm_scope: source.p4
ace_mode: text
language_id: 348895984
-PAWN:
- type: programming
- color: "#dbb284"
- extensions:
- - ".pwn"
- - ".inc"
- tm_scope: source.pawn
- ace_mode: text
- language_id: 271
PHP:
type: programming
tm_scope: text.html.php
@@ -3344,6 +3931,7 @@ PLpgSQL:
codemirror_mime_type: text/x-sql
tm_scope: source.sql
extensions:
+ - ".pgsql"
- ".sql"
language_id: 274
POV-Ray SDL:
@@ -3354,6 +3942,7 @@ POV-Ray SDL:
extensions:
- ".pov"
- ".inc"
+ tm_scope: source.pov-ray sdl
ace_mode: text
language_id: 275
Pan:
@@ -3417,10 +4006,21 @@ Pascal:
- ".pp"
interpreters:
- instantfpc
+ tm_scope: source.pascal
ace_mode: pascal
codemirror_mode: pascal
codemirror_mime_type: text/x-pascal
language_id: 281
+Pawn:
+ type: programming
+ color: "#dbb284"
+ extensions:
+ - ".pwn"
+ - ".inc"
+ - ".sma"
+ tm_scope: source.pawn
+ ace_mode: text
+ language_id: 271
Pep8:
type: programming
color: "#C76F5B"
@@ -3458,30 +4058,6 @@ Perl:
aliases:
- cperl
language_id: 282
-Perl 6:
- type: programming
- color: "#0000fb"
- extensions:
- - ".6pl"
- - ".6pm"
- - ".nqp"
- - ".p6"
- - ".p6l"
- - ".p6m"
- - ".pl"
- - ".pl6"
- - ".pm"
- - ".pm6"
- - ".t"
- interpreters:
- - perl6
- aliases:
- - perl6
- tm_scope: source.perl6fe
- ace_mode: perl
- codemirror_mode: perl
- codemirror_mime_type: text/x-perl
- language_id: 283
Pic:
type: markup
group: Roff
@@ -3526,8 +4102,18 @@ Pike:
- ".pmod"
interpreters:
- pike
+ tm_scope: source.pike
ace_mode: text
language_id: 287
+PlantUML:
+ type: data
+ extensions:
+ - ".puml"
+ - ".iuml"
+ - ".plantuml"
+ tm_scope: source.wsd
+ ace_mode: text
+ language_id: 833504686
Pod:
type: prose
ace_mode: perl
@@ -3540,6 +4126,17 @@ Pod:
- perl
tm_scope: none
language_id: 288
+Pod 6:
+ type: prose
+ ace_mode: perl
+ tm_scope: source.perl6fe
+ wrap: true
+ extensions:
+ - ".pod"
+ - ".pod6"
+ interpreters:
+ - perl6
+ language_id: 155357471
PogoScript:
type: programming
color: "#d80074"
@@ -3561,6 +4158,7 @@ PostCSS:
group: CSS
extensions:
- ".pcss"
+ - ".postcss"
ace_mode: text
language_id: 262764437
PostScript:
@@ -3569,6 +4167,7 @@ PostScript:
extensions:
- ".ps"
- ".eps"
+ - ".epsi"
- ".pfa"
tm_scope: source.postscript
aliases:
@@ -3589,6 +4188,7 @@ PowerBuilder:
PowerShell:
type: programming
color: "#012456"
+ tm_scope: source.powershell
ace_mode: powershell
codemirror_mode: powershell
codemirror_mime_type: application/x-powershell
@@ -3602,13 +4202,29 @@ PowerShell:
interpreters:
- pwsh
language_id: 293
+Prisma:
+ type: data
+ color: "#0c344b"
+ extensions:
+ - ".prisma"
+ tm_scope: source.prisma
+ ace_mode: text
+ language_id: 499933428
Processing:
type: programming
color: "#0096D8"
extensions:
- ".pde"
+ tm_scope: source.processing
ace_mode: text
language_id: 294
+Proguard:
+ type: data
+ extensions:
+ - ".pro"
+ tm_scope: none
+ ace_mode: text
+ language_id: 716513858
Prolog:
type: programming
color: "#74283c"
@@ -3654,8 +4270,8 @@ Public Key:
codemirror_mime_type: application/pgp
language_id: 298
Pug:
- group: HTML
type: markup
+ color: "#a86454"
extensions:
- ".jade"
- ".pug"
@@ -3704,13 +4320,13 @@ PureScript:
language_id: 302
Python:
type: programming
+ tm_scope: source.python
ace_mode: python
codemirror_mode: python
codemirror_mime_type: text/x-python
color: "#3572A5"
extensions:
- ".py"
- - ".bzl"
- ".cgi"
- ".fcgi"
- ".gyp"
@@ -3723,27 +4339,25 @@ Python:
- ".pyt"
- ".pyw"
- ".rpy"
+ - ".smk"
- ".spec"
- ".tac"
- ".wsgi"
- ".xpy"
filenames:
- ".gclient"
- - BUCK
- - BUILD
- - BUILD.bazel
+ - DEPS
- SConscript
- SConstruct
- Snakefile
- - WORKSPACE
- wscript
interpreters:
- python
- python2
- python3
aliases:
- - rusthon
- python3
+ - rusthon
language_id: 303
Python console:
type: programming
@@ -3763,6 +4377,16 @@ Python traceback:
tm_scope: text.python.traceback
ace_mode: text
language_id: 304
+Q#:
+ type: programming
+ extensions:
+ - ".qs"
+ aliases:
+ - qsharp
+ color: "#fed659"
+ ace_mode: text
+ tm_scope: source.qsharp
+ language_id: 697448245
QML:
type: programming
color: "#44a51c"
@@ -3779,8 +4403,22 @@ QMake:
- ".pri"
interpreters:
- qmake
+ tm_scope: source.qmake
ace_mode: text
language_id: 306
+Qt Script:
+ type: programming
+ ace_mode: javascript
+ codemirror_mode: javascript
+ codemirror_mime_type: text/javascript
+ extensions:
+ - ".qs"
+ filenames:
+ - installscript.qs
+ - toolchain_installscript.qs
+ color: "#00b841"
+ tm_scope: source.js
+ language_id: 558193693
Quake:
type: programming
filenames:
@@ -3806,6 +4444,7 @@ R:
- expr-dist
interpreters:
- Rscript
+ tm_scope: source.r
ace_mode: r
codemirror_mode: r
codemirror_mime_type: text/x-rsrc
@@ -3854,18 +4493,6 @@ REXX:
tm_scope: source.rexx
ace_mode: text
language_id: 311
-RHTML:
- type: markup
- group: HTML
- extensions:
- - ".rhtml"
- tm_scope: text.html.erb
- aliases:
- - html+ruby
- ace_mode: rhtml
- codemirror_mode: htmlembedded
- codemirror_mime_type: application/x-erb
- language_id: 312
RMarkdown:
type: prose
wrap: true
@@ -3904,12 +4531,13 @@ RUNOFF:
extensions:
- ".rnh"
- ".rno"
+ wrap: true
tm_scope: text.runoff
ace_mode: text
language_id: 315
Racket:
type: programming
- color: "#22228f"
+ color: "#3c5caa"
extensions:
- ".rkt"
- ".rktd"
@@ -3931,6 +4559,33 @@ Ragel:
tm_scope: none
ace_mode: text
language_id: 317
+Raku:
+ type: programming
+ color: "#0000fb"
+ extensions:
+ - ".6pl"
+ - ".6pm"
+ - ".nqp"
+ - ".p6"
+ - ".p6l"
+ - ".p6m"
+ - ".pl"
+ - ".pl6"
+ - ".pm"
+ - ".pm6"
+ - ".t"
+ interpreters:
+ - perl6
+ - raku
+ - rakudo
+ aliases:
+ - perl6
+ - perl-6
+ tm_scope: source.perl6fe
+ ace_mode: perl
+ codemirror_mode: perl
+ codemirror_mime_type: text/x-perl
+ language_id: 283
Rascal:
type: programming
color: "#fffaa0"
@@ -3948,9 +4603,21 @@ Raw token data:
tm_scope: none
ace_mode: text
language_id: 318
+Readline Config:
+ type: data
+ group: INI
+ aliases:
+ - inputrc
+ - readline
+ filenames:
+ - ".inputrc"
+ - inputrc
+ tm_scope: source.inputrc
+ ace_mode: text
+ language_id: 538732839
Reason:
type: programming
- group: OCaml
+ color: "#ff5847"
ace_mode: rust
codemirror_mode: rust
codemirror_mime_type: text/x-rustsrc
@@ -4020,6 +4687,13 @@ RenderScript:
tm_scope: none
ace_mode: text
language_id: 323
+Rich Text Format:
+ type: markup
+ extensions:
+ - ".rtf"
+ tm_scope: text.rtf
+ ace_mode: text
+ language_id: 51601661
Ring:
type: programming
color: "#2D54CB"
@@ -4028,6 +4702,14 @@ Ring:
tm_scope: source.ring
ace_mode: text
language_id: 431
+Riot:
+ type: markup
+ color: "#A71E49"
+ ace_mode: html
+ extensions:
+ - ".riot"
+ tm_scope: text.html.riot
+ language_id: 878396783
RobotFramework:
type: programming
extensions:
@@ -4039,7 +4721,7 @@ Roff:
type: markup
color: "#ecdebe"
extensions:
- - ".man"
+ - ".roff"
- ".1"
- ".1in"
- ".1m"
@@ -4048,6 +4730,8 @@ Roff:
- ".3"
- ".3in"
- ".3m"
+ - ".3p"
+ - ".3pm"
- ".3qt"
- ".3x"
- ".4"
@@ -4057,23 +4741,65 @@ Roff:
- ".8"
- ".9"
- ".l"
+ - ".man"
+ - ".mdoc"
- ".me"
- ".ms"
- ".n"
- ".nr"
- ".rno"
- - ".roff"
- ".tmac"
filenames:
+ - eqnrc
- mmn
- mmt
+ - troffrc
+ - troffrc-end
tm_scope: text.roff
aliases:
+ - groff
+ - man
+ - manpage
+ - man page
+ - man-page
+ - mdoc
- nroff
+ - troff
+ wrap: true
ace_mode: text
codemirror_mode: troff
codemirror_mime_type: text/troff
language_id: 141
+Roff Manpage:
+ type: markup
+ group: Roff
+ extensions:
+ - ".1"
+ - ".1in"
+ - ".1m"
+ - ".1x"
+ - ".2"
+ - ".3"
+ - ".3in"
+ - ".3m"
+ - ".3p"
+ - ".3pm"
+ - ".3qt"
+ - ".3x"
+ - ".4"
+ - ".5"
+ - ".6"
+ - ".7"
+ - ".8"
+ - ".9"
+ - ".man"
+ - ".mdoc"
+ wrap: true
+ tm_scope: text.roff
+ ace_mode: text
+ codemirror_mode: troff
+ codemirror_mime_type: text/troff
+ language_id: 612669833
Rouge:
type: programming
ace_mode: clojure
@@ -4086,6 +4812,7 @@ Rouge:
language_id: 325
Ruby:
type: programming
+ tm_scope: source.ruby
ace_mode: ruby
codemirror_mode: ruby
codemirror_mime_type: text/x-ruby
@@ -4109,6 +4836,7 @@ Ruby:
- ".podspec"
- ".rabl"
- ".rake"
+ - ".rbi"
- ".rbuild"
- ".rbw"
- ".rbx"
@@ -4126,6 +4854,7 @@ Ruby:
filenames:
- ".irbrc"
- ".pryrc"
+ - ".simplecov"
- Appraisals
- Berksfile
- Brewfile
@@ -4153,6 +4882,7 @@ Rust:
extensions:
- ".rs"
- ".rs.in"
+ tm_scope: source.rust
ace_mode: rust
codemirror_mode: rust
codemirror_mime_type: text/x-rustsrc
@@ -4169,8 +4899,8 @@ SAS:
language_id: 328
SCSS:
type: markup
- tm_scope: source.scss
- group: CSS
+ color: "#c6538c"
+ tm_scope: source.css.scss
ace_mode: scss
codemirror_mode: css
codemirror_mime_type: text/x-scss
@@ -4252,6 +4982,19 @@ SRecode Template:
extensions:
- ".srt"
language_id: 335
+SSH Config:
+ type: data
+ group: INI
+ filenames:
+ - ssh-config
+ - ssh_config
+ - sshconfig
+ - sshconfig.snip
+ - sshd-config
+ - sshd_config
+ ace_mode: text
+ tm_scope: source.ssh-config
+ language_id: 554920715
STON:
type: data
group: Smalltalk
@@ -4262,16 +5005,25 @@ STON:
language_id: 336
SVG:
type: data
+ color: "#ff9900"
extensions:
- ".svg"
- tm_scope: text.xml
+ tm_scope: text.xml.svg
ace_mode: xml
codemirror_mode: xml
codemirror_mime_type: text/xml
language_id: 337
+SWIG:
+ type: programming
+ extensions:
+ - ".i"
+ tm_scope: source.c++
+ ace_mode: c_cpp
+ codemirror_mode: clike
+ codemirror_mime_type: text/x-c++src
+ language_id: 1066250075
Sage:
type: programming
- group: Python
extensions:
- ".sage"
- ".sagews"
@@ -4295,8 +5047,8 @@ SaltStack:
language_id: 339
Sass:
type: markup
+ color: "#a53b70"
tm_scope: source.sass
- group: CSS
extensions:
- ".sass"
ace_mode: sass
@@ -4305,6 +5057,7 @@ Sass:
language_id: 340
Scala:
type: programming
+ tm_scope: source.scala
ace_mode: scala
codemirror_mode: clike
codemirror_mime_type: text/x-scala
@@ -4336,12 +5089,14 @@ Scheme:
- ".sps"
- ".ss"
interpreters:
+ - scheme
- guile
- bigloo
- chicken
- csi
- gosh
- r6rs
+ tm_scope: source.scheme
ace_mode: scheme
codemirror_mode: scheme
codemirror_mime_type: text/x-scheme
@@ -4352,6 +5107,7 @@ Scilab:
- ".sci"
- ".sce"
- ".tst"
+ tm_scope: source.scilab
ace_mode: text
language_id: 344
Self:
@@ -4383,6 +5139,7 @@ Shell:
- ".bats"
- ".cgi"
- ".command"
+ - ".env"
- ".fcgi"
- ".ksh"
- ".sh.in"
@@ -4390,11 +5147,15 @@ Shell:
- ".tool"
- ".zsh"
filenames:
+ - ".bash_aliases"
- ".bash_history"
- ".bash_logout"
- ".bash_profile"
- ".bashrc"
- ".cshrc"
+ - ".env"
+ - ".env.example"
+ - ".flaskenv"
- ".login"
- ".profile"
- ".zlogin"
@@ -4404,6 +5165,7 @@ Shell:
- ".zshrc"
- 9fs
- PKGBUILD
+ - bash_aliases
- bash_logout
- bash_profile
- bashrc
@@ -4427,6 +5189,7 @@ Shell:
- rc
- sh
- zsh
+ tm_scope: source.shell
ace_mode: sh
codemirror_mode: shell
codemirror_mime_type: text/x-sh
@@ -4451,6 +5214,15 @@ Shen:
tm_scope: source.shen
ace_mode: text
language_id: 348
+Sieve:
+ type: programming
+ tm_scope: source.sieve
+ ace_mode: text
+ extensions:
+ - ".sieve"
+ codemirror_mode: sieve
+ codemirror_mime_type: application/sieve
+ language_id: 208976687
Slash:
type: programming
color: "#007eff"
@@ -4459,9 +5231,17 @@ Slash:
tm_scope: text.html.slash
ace_mode: text
language_id: 349
+Slice:
+ type: programming
+ color: "#003fa2"
+ tm_scope: source.slice
+ ace_mode: text
+ extensions:
+ - ".ice"
+ language_id: 894641667
Slim:
- group: HTML
type: markup
+ color: "#2b2b2b"
extensions:
- ".slim"
tm_scope: text.slim
@@ -4469,6 +5249,16 @@ Slim:
codemirror_mode: slim
codemirror_mime_type: text/x-slim
language_id: 350
+SmPL:
+ type: programming
+ extensions:
+ - ".cocci"
+ aliases:
+ - coccinelle
+ ace_mode: text
+ tm_scope: source.smpl
+ color: "#c94949"
+ language_id: 164123055
Smali:
type: programming
extensions:
@@ -4484,6 +5274,7 @@ Smalltalk:
- ".cs"
aliases:
- squeak
+ tm_scope: source.smalltalk
ace_mode: text
codemirror_mode: smalltalk
codemirror_mime_type: text/x-stsrc
@@ -4502,17 +5293,18 @@ Solidity:
color: "#AA6746"
ace_mode: text
tm_scope: source.solidity
+ extensions:
+ - ".sol"
language_id: 237469032
SourcePawn:
type: programming
- color: "#5c7611"
+ color: "#f69e1d"
aliases:
- sourcemod
extensions:
- ".sp"
- ".inc"
- - ".sma"
- tm_scope: source.sp
+ tm_scope: source.sourcepawn
ace_mode: text
language_id: 354
Spline Font Database:
@@ -4546,7 +5338,7 @@ Standard ML:
aliases:
- sml
extensions:
- - ".ML"
+ - ".ml"
- ".fun"
- ".sig"
- ".sml"
@@ -4555,6 +5347,25 @@ Standard ML:
codemirror_mode: mllike
codemirror_mime_type: text/x-ocaml
language_id: 357
+Starlark:
+ type: programming
+ tm_scope: source.python
+ ace_mode: python
+ codemirror_mode: python
+ codemirror_mime_type: text/x-python
+ color: "#76d275"
+ extensions:
+ - ".bzl"
+ filenames:
+ - BUCK
+ - BUILD
+ - BUILD.bazel
+ - Tiltfile
+ - WORKSPACE
+ aliases:
+ - bazel
+ - bzl
+ language_id: 960266174
Stata:
type: programming
extensions:
@@ -4565,11 +5376,12 @@ Stata:
- ".mata"
- ".matah"
- ".sthlp"
+ tm_scope: source.stata
ace_mode: text
language_id: 358
Stylus:
type: markup
- group: CSS
+ color: "#ff6347"
extensions:
- ".styl"
tm_scope: source.stylus
@@ -4602,11 +5414,22 @@ SuperCollider:
tm_scope: source.supercollider
ace_mode: text
language_id: 361
+Svelte:
+ type: markup
+ color: "#ff3e00"
+ tm_scope: source.svelte
+ ace_mode: html
+ codemirror_mode: htmlmixed
+ codemirror_mime_type: text/html
+ extensions:
+ - ".svelte"
+ language_id: 928734530
Swift:
type: programming
color: "#ffac45"
extensions:
- ".swift"
+ tm_scope: source.swift
ace_mode: text
codemirror_mode: swift
codemirror_mime_type: text/x-swift
@@ -4618,6 +5441,7 @@ SystemVerilog:
- ".sv"
- ".svh"
- ".vh"
+ tm_scope: source.systemverilog
ace_mode: verilog
codemirror_mode: verilog
codemirror_mime_type: text/x-systemverilog
@@ -4647,11 +5471,36 @@ TOML:
filenames:
- Cargo.lock
- Gopkg.lock
+ - poetry.lock
tm_scope: source.toml
ace_mode: toml
codemirror_mode: toml
codemirror_mime_type: text/x-toml
language_id: 365
+TSQL:
+ type: programming
+ extensions:
+ - ".sql"
+ ace_mode: sql
+ tm_scope: source.tsql
+ language_id: 918334941
+TSV:
+ type: data
+ ace_mode: text
+ tm_scope: source.generic-db
+ extensions:
+ - ".tsv"
+ language_id: 1035892117
+TSX:
+ type: programming
+ group: TypeScript
+ extensions:
+ - ".tsx"
+ tm_scope: source.tsx
+ ace_mode: javascript
+ codemirror_mode: jsx
+ codemirror_mime_type: text/jsx
+ language_id: 94901924
TXL:
type: programming
extensions:
@@ -4672,6 +5521,7 @@ Tcl:
interpreters:
- tclsh
- wish
+ tm_scope: source.tcl
ace_mode: tcl
codemirror_mode: tcl
codemirror_mime_type: text/x-tcl
@@ -4682,6 +5532,9 @@ Tcsh:
extensions:
- ".tcsh"
- ".csh"
+ interpreters:
+ - tcsh
+ - csh
tm_scope: source.shell
ace_mode: sh
codemirror_mode: shell
@@ -4693,6 +5546,7 @@ TeX:
ace_mode: tex
codemirror_mode: stex
codemirror_mime_type: text/x-stex
+ tm_scope: text.tex.latex
wrap: true
aliases:
- latex
@@ -4700,7 +5554,6 @@ TeX:
- ".tex"
- ".aux"
- ".bbx"
- - ".bib"
- ".cbx"
- ".cls"
- ".dtx"
@@ -4725,12 +5578,25 @@ Terra:
extensions:
- ".t"
color: "#00004c"
+ tm_scope: source.terra
ace_mode: lua
codemirror_mode: lua
codemirror_mime_type: text/x-lua
interpreters:
- lua
language_id: 371
+Texinfo:
+ type: prose
+ wrap: true
+ extensions:
+ - ".texinfo"
+ - ".texi"
+ - ".txi"
+ ace_mode: text
+ tm_scope: text.texinfo
+ interpreters:
+ - makeinfo
+ language_id: 988020015
Text:
type: prose
wrap: true
@@ -4757,10 +5623,17 @@ Text:
- README.mysql
- click.me
- delete.me
+ - go.mod
+ - go.sum
- keep.me
+ - package.mask
+ - package.use.mask
+ - package.use.stable.mask
- read.me
- readme.1st
- test.me
+ - use.mask
+ - use.stable.mask
tm_scope: none
ace_mode: text
language_id: 372
@@ -4801,7 +5674,7 @@ Turtle:
language_id: 376
Twig:
type: markup
- group: HTML
+ color: "#c1d026"
extensions:
- ".twig"
tm_scope: text.html.twig
@@ -4823,9 +5696,11 @@ TypeScript:
color: "#2b7489"
aliases:
- ts
+ interpreters:
+ - deno
+ - ts-node
extensions:
- ".ts"
- - ".tsx"
tm_scope: source.ts
ace_mode: typescript
codemirror_mode: javascript
@@ -4849,6 +5724,7 @@ Unity3D Asset:
extensions:
- ".anim"
- ".asset"
+ - ".mask"
- ".mat"
- ".meta"
- ".prefab"
@@ -4866,6 +5742,7 @@ Unix Assembly:
language_id: 120
Uno:
type: programming
+ color: "#9933cc"
extensions:
- ".uno"
ace_mode: csharp
@@ -4894,6 +5771,46 @@ UrWeb:
tm_scope: source.ur
ace_mode: text
language_id: 383
+V:
+ type: programming
+ color: "#4f87c4"
+ aliases:
+ - vlang
+ extensions:
+ - ".v"
+ tm_scope: source.v
+ ace_mode: golang
+ codemirror_mode: go
+ codemirror_mime_type: text/x-go
+ language_id: 603371597
+VBA:
+ type: programming
+ color: "#867db1"
+ extensions:
+ - ".bas"
+ - ".cls"
+ - ".frm"
+ - ".frx"
+ - ".vba"
+ tm_scope: source.vbnet
+ aliases:
+ - vb6
+ - visual basic 6
+ - visual basic for applications
+ ace_mode: text
+ codemirror_mode: vb
+ codemirror_mime_type: text/x-vb
+ language_id: 399230729
+VBScript:
+ type: programming
+ color: "#15dcdc"
+ extensions:
+ - ".vbs"
+ tm_scope: source.vbnet
+ ace_mode: text
+ codemirror_mode: vbscript
+ codemirror_mime_type: text/vbscript
+ language_id: 408016005
VCL:
type: programming
color: "#148AA8"
@@ -4914,6 +5831,7 @@ VHDL:
- ".vhs"
- ".vht"
- ".vhw"
+ tm_scope: source.vhdl
ace_mode: vhdl
codemirror_mode: vhdl
codemirror_mime_type: text/x-vhdl
@@ -4924,6 +5842,7 @@ Vala:
extensions:
- ".vala"
- ".vapi"
+ tm_scope: source.vala
ace_mode: vala
language_id: 386
Verilog:
@@ -4932,10 +5851,34 @@ Verilog:
extensions:
- ".v"
- ".veo"
+ tm_scope: source.verilog
ace_mode: verilog
codemirror_mode: verilog
codemirror_mime_type: text/x-verilog
language_id: 387
+Vim Help File:
+ type: prose
+ aliases:
+ - vimhelp
+ extensions:
+ - ".txt"
+ tm_scope: text.vim-help
+ ace_mode: text
+ language_id: 508563686
+Vim Snippet:
+ type: markup
+ aliases:
+ - SnipMate
+ - UltiSnip
+ - UltiSnips
+ - NeoSnippet
+ extensions:
+ - ".snip"
+ - ".snippet"
+ - ".snippets"
+ tm_scope: source.vim-snippet
+ ace_mode: text
+ language_id: 81265970
Vim script:
type: programming
color: "#199f4b"
@@ -4946,7 +5889,10 @@ Vim script:
- nvim
extensions:
- ".vim"
+ - ".vba"
+ - ".vmb"
filenames:
+ - ".exrc"
- ".gvimrc"
- ".nvimrc"
- ".vimrc"
@@ -4956,22 +5902,18 @@ Vim script:
- vimrc
ace_mode: text
language_id: 388
-Visual Basic:
+Visual Basic .NET:
type: programming
color: "#945db7"
extensions:
- ".vb"
- - ".bas"
- - ".cls"
- - ".frm"
- - ".frx"
- - ".vba"
- ".vbhtml"
- - ".vbs"
- tm_scope: source.vbnet
aliases:
- - vb.net
+ - visual basic
- vbnet
+ - vb .net
+ - vb.net
+ tm_scope: source.vbnet
ace_mode: text
codemirror_mode: vb
codemirror_mime_type: text/x-vb
@@ -5038,6 +5980,41 @@ WebIDL:
codemirror_mode: webidl
codemirror_mime_type: text/x-webidl
language_id: 395
+WebVTT:
+ type: data
+ wrap: true
+ extensions:
+ - ".vtt"
+ tm_scope: source.vtt
+ ace_mode: text
+ language_id: 658679714
+Wget Config:
+ type: data
+ group: INI
+ aliases:
+ - wgetrc
+ filenames:
+ - ".wgetrc"
+ tm_scope: source.wgetrc
+ ace_mode: text
+ language_id: 668457123
+Windows Registry Entries:
+ type: data
+ extensions:
+ - ".reg"
+ tm_scope: source.reg
+ ace_mode: ini
+ codemirror_mode: properties
+ codemirror_mime_type: text/x-properties
+ language_id: 969674868
+Wollok:
+ type: programming
+ color: "#a23738"
+ extensions:
+ - ".wlk"
+ ace_mode: text
+ tm_scope: source.wollok
+ language_id: 632745969
World of Warcraft Addon Data:
type: data
extensions:
@@ -5057,6 +6034,16 @@ X BitMap:
codemirror_mode: clike
codemirror_mime_type: text/x-csrc
language_id: 782911107
+X Font Directory Index:
+ type: data
+ filenames:
+ - encodings.dir
+ - fonts.alias
+ - fonts.dir
+ - fonts.scale
+ tm_scope: source.fontdir
+ ace_mode: text
+ language_id: 208700028
X PixMap:
type: data
group: C
@@ -5101,6 +6088,7 @@ XCompose:
language_id: 225167241
XML:
type: data
+ tm_scope: text.xml
ace_mode: xml
codemirror_mode: xml
codemirror_mime_type: text/xml
@@ -5135,7 +6123,9 @@ XML:
- ".fxml"
- ".glade"
- ".gml"
+ - ".gmx"
- ".grxml"
+ - ".gst"
- ".iml"
- ".ivy"
- ".jelly"
@@ -5155,7 +6145,6 @@ XML:
- ".odd"
- ".osm"
- ".pkgproj"
- - ".plist"
- ".pluginspec"
- ".proj"
- ".props"
@@ -5171,15 +6160,9 @@ XML:
- ".shproj"
- ".srdf"
- ".storyboard"
- - ".stTheme"
- ".sublime-snippet"
- ".targets"
- - ".tmCommand"
- ".tml"
- - ".tmLanguage"
- - ".tmPreferences"
- - ".tmSnippet"
- - ".tmTheme"
- ".ts"
- ".tsx"
- ".ui"
@@ -5192,6 +6175,7 @@ XML:
- ".vstemplate"
- ".vxml"
- ".wixproj"
+ - ".workflow"
- ".wsdl"
- ".wsf"
- ".wxi"
@@ -5222,6 +6206,22 @@ XML:
- Web.config
- packages.config
language_id: 399
+XML Property List:
+ type: data
+ group: XML
+ extensions:
+ - ".plist"
+ - ".stTheme"
+ - ".tmCommand"
+ - ".tmLanguage"
+ - ".tmPreferences"
+ - ".tmSnippet"
+ - ".tmTheme"
+ tm_scope: text.xml.plist
+ ace_mode: xml
+ codemirror_mode: xml
+ codemirror_mime_type: text/xml
+ language_id: 75622871
XPages:
type: data
extensions:
@@ -5287,17 +6287,19 @@ Xojo:
- ".xojo_script"
- ".xojo_toolbar"
- ".xojo_window"
- tm_scope: source.vbnet
+ tm_scope: source.xojo
ace_mode: text
language_id: 405
Xtend:
type: programming
extensions:
- ".xtend"
+ tm_scope: source.xtend
ace_mode: text
language_id: 406
YAML:
type: data
+ color: "#cb171e"
tm_scope: source.yaml
aliases:
- yml
@@ -5310,12 +6312,14 @@ YAML:
- ".syntax"
- ".yaml"
- ".yaml-tmlanguage"
+ - ".yaml.sed"
- ".yml.mysql"
filenames:
- ".clang-format"
- ".clang-tidy"
- ".gemrc"
- glide.lock
+ - yarn.lock
ace_mode: yaml
codemirror_mode: yaml
codemirror_mime_type: text/x-yaml
@@ -5328,23 +6332,71 @@ YANG:
ace_mode: text
language_id: 408
YARA:
- type: data
+ type: programming
+ color: "#220000"
ace_mode: text
extensions:
- ".yar"
- ".yara"
tm_scope: source.yara
language_id: 805122868
+YASnippet:
+ type: markup
+ aliases:
+ - snippet
+ - yas
+ color: "#32AB90"
+ extensions:
+ - ".yasnippet"
+ tm_scope: source.yasnippet
+ ace_mode: text
+ language_id: 378760102
Yacc:
type: programming
extensions:
- ".y"
- ".yacc"
- ".yy"
- tm_scope: source.bison
+ tm_scope: source.yacc
ace_mode: text
color: "#4B6C4B"
language_id: 409
+ZAP:
+ type: programming
+ color: "#0d665e"
+ extensions:
+ - ".zap"
+ - ".xzap"
+ tm_scope: source.zap
+ ace_mode: text
+ language_id: 952972794
+ZIL:
+ type: programming
+ color: "#dc75e5"
+ extensions:
+ - ".zil"
+ - ".mud"
+ tm_scope: source.zil
+ ace_mode: text
+ language_id: 973483626
+Zeek:
+ type: programming
+ aliases:
+ - bro
+ extensions:
+ - ".zeek"
+ - ".bro"
+ tm_scope: source.zeek
+ ace_mode: text
+ language_id: 40
+ZenScript:
+ type: programming
+ color: "#00BCD1"
+ extensions:
+ - ".zs"
+ tm_scope: source.zenscript
+ ace_mode: text
+ language_id: 494938890
Zephir:
type: programming
color: "#118f9e"
@@ -5353,6 +6405,14 @@ Zephir:
tm_scope: source.php.zephir
ace_mode: php
language_id: 410
+Zig:
+ type: programming
+ color: "#ec915c"
+ extensions:
+ - ".zig"
+ tm_scope: source.zig
+ ace_mode: text
+ language_id: 646424281
Zimpl:
type: programming
extensions:
@@ -5362,6 +6422,17 @@ Zimpl:
tm_scope: none
ace_mode: text
language_id: 411
+cURL Config:
+ type: data
+ group: INI
+ aliases:
+ - curlrc
+ filenames:
+ - ".curlrc"
+ - _curlrc
+ tm_scope: source.curlrc
+ ace_mode: text
+ language_id: 992375436
desktop:
type: data
extensions:
@@ -5370,6 +6441,20 @@ desktop:
tm_scope: source.desktop
ace_mode: text
language_id: 412
+dircolors:
+ type: data
+ extensions:
+ - ".dircolors"
+ filenames:
+ - ".dir_colors"
+ - ".dircolors"
+ - DIR_COLORS
+ - _dir_colors
+ - _dircolors
+ - dir_colors
+ tm_scope: source.dircolors
+ ace_mode: text
+ language_id: 691605112
eC:
type: programming
color: "#913960"
@@ -5398,12 +6483,40 @@ fish:
tm_scope: source.fish
ace_mode: text
language_id: 415
+mIRC Script:
+ type: programming
+ color: "#3d57c3"
+ extensions:
+ - ".mrc"
+ tm_scope: source.msl
+ ace_mode: text
+ language_id: 517654727
+mcfunction:
+ type: programming
+ color: "#E22837"
+ extensions:
+ - ".mcfunction"
+ tm_scope: source.mcfunction
+ ace_mode: text
+ language_id: 462488745
mupad:
type: programming
extensions:
- ".mu"
+ tm_scope: source.mupad
ace_mode: text
language_id: 416
+nanorc:
+ type: data
+ group: INI
+ extensions:
+ - ".nanorc"
+ filenames:
+ - ".nanorc"
+ - nanorc
+ tm_scope: source.nanorc
+ ace_mode: text
+ language_id: 775996197
nesC:
type: programming
color: "#94B0C7"
@@ -5417,6 +6530,7 @@ ooc:
color: "#b0b77e"
extensions:
- ".ooc"
+ tm_scope: source.ooc
ace_mode: text
language_id: 418
q:
@@ -5437,6 +6551,7 @@ reStructuredText:
- ".rest"
- ".rest.txt"
- ".rst.txt"
+ tm_scope: text.restructuredtext
ace_mode: text
codemirror_mode: rst
codemirror_mime_type: text/x-rst
diff --git a/vendor/project_templates/gitpod_spring_petclinic.tar.gz b/vendor/project_templates/gitpod_spring_petclinic.tar.gz
new file mode 100644
index 00000000000..76308dc73df
--- /dev/null
+++ b/vendor/project_templates/gitpod_spring_petclinic.tar.gz
Binary files differ
diff --git a/yarn.lock b/yarn.lock
index 9bb1756daa6..3532ccc6beb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -843,15 +843,15 @@
eslint-plugin-vue "^6.2.1"
vue-eslint-parser "^7.0.0"
-"@gitlab/svgs@1.164.0":
- version "1.164.0"
- resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.164.0.tgz#6cefad871c45f945ef92b99015d0f510b1d2de4a"
- integrity sha512-a9e/cYUc1QQk7azjH4x/m6/p3icavwGEi5F9ipNlDqiJtUor5tqojxvMxPOhuVbN/mTwnC6lGsSZg4tqTsdJAQ==
+"@gitlab/svgs@1.169.0":
+ version "1.169.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.169.0.tgz#08a22b94527ff1af38ae241f432b29aa858376f9"
+ integrity sha512-YfEDBU/NihL0FXN+us6UjenyJEC7YQseBFoz/NnYCBxOG1v76tan1JhU7Unx4hp1PdHv5JFMTFHB/JOe+4r5MQ==
-"@gitlab/ui@21.3.1":
- version "21.3.1"
- resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-21.3.1.tgz#027b767804540539da73d4874370895d7398adea"
- integrity sha512-ynyg8i8W8Ud+GoySr4hAjJoW55kWMwSEFLX5MEX8CbdqGurkTLqHYLLpXPBSSnVEcw4stR+bFbKSc35rmBkWPA==
+"@gitlab/ui@21.10.0":
+ version "21.10.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-21.10.0.tgz#8f82c77e5f2690e21853a7ef1b9a31339130c0f2"
+ integrity sha512-NKQ8CiEMJX7/WKYL5y+7yqthnmvi8qhYVgUtbbFW6HWZ0Nwu7EL7WFDY1xE5HPHXgp1se8JKuhUhH2GCYP/5Ag==
dependencies:
"@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0"
@@ -1071,10 +1071,15 @@
consola "^2.10.1"
node-fetch "^2.6.0"
-"@rails/actioncable@^6.0.3-1":
- version "6.0.3-1"
- resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-6.0.3-1.tgz#9b9eb8858a6507162911007d355d9a206e1c5caa"
- integrity sha512-szFhWD+V5TAxVNVIG16klgq+ypqA5k5AecLarTTrXgOG8cawVbQdOAwLbCmzkwiQ60rGSxAFoC1u2LrzxSK2Aw==
+"@rails/actioncable@^6.0.3-3":
+ version "6.0.3-3"
+ resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-6.0.3-3.tgz#fb1a46d3d353512764d5fa3cea2f492391601b7a"
+ integrity sha512-+DEbtzvD2ITPKOGBAV0lLdUvImCsHtUYfxwW7TDKOrGNEFqgbVJij7aO6ZE8a3aDPQ7NnO/MlyrPwLVeiIZRSw==
+
+"@rails/ujs@^6.0.3-2":
+ version "6.0.3-2"
+ resolved "https://registry.yarnpkg.com/@rails/ujs/-/ujs-6.0.3-2.tgz#e14c1f29086858215ce7ccd9ad6d8888c458b4a3"
+ integrity sha512-WcpIEftNCfGDEgk6KerOugiet75Mir5q/HT1yt3dDhpBI91BaZ15lfSQIsZwMw2nyeDz9A9QBz8dAFAd4gXIzg==
"@sentry/browser@^5.22.3":
version "5.22.3"
@@ -1581,9 +1586,9 @@ abab@^2.0.0, abab@^2.0.3:
integrity sha512-tsFzPpcttalNjFBCFMqsKYQcWxxen1pgJR56by//QwvJc4/OUS3kPOOttx2tSIfjsylB0pYu7f5D3K1RCxUnUg==
abbrev@1:
- version "1.0.9"
- resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.0.9.tgz#91b4792588a7738c25f35dd6f63752a2f8776135"
- integrity sha1-kbR5JYinc4wl813W9jdSovh3YTU=
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
+ integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
version "1.3.7"
@@ -1662,10 +1667,10 @@ ajv-keywords@^3.1.0, ajv-keywords@^3.4.1:
resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.4.1.tgz#ef916e271c64ac12171fd8384eaae6b2345854da"
integrity sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ==
-ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.5.5:
- version "6.11.0"
- resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.11.0.tgz#c3607cbc8ae392d8a5a536f25b21f8e5f3f87fe9"
- integrity sha512-nCprB/0syFYy9fVYU1ox1l2KN8S9I+tziH8D4zdZuLT3N6RMlGSGt5FSTpAiHB/Whv8Qs1cWHma1aMKZyaHRKA==
+ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.3:
+ version "6.12.5"
+ resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.5.tgz#19b0e8bae8f476e5ba666300387775fb1a00a4da"
+ integrity sha512-lRF8RORchjpKG50/WFf8xmg7sgCLFiYNNnqdKflk63whMQcWR5ngGjiSXkL9bjxy6B2npOK2HSMN49jEBMSkag==
dependencies:
fast-deep-equal "^3.1.1"
fast-json-stable-stringify "^2.0.0"
@@ -2074,9 +2079,9 @@ aws-sign2@~0.7.0:
integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
aws4@^1.8.0:
- version "1.8.0"
- resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f"
- integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==
+ version "1.10.1"
+ resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.10.1.tgz#e1e82e4f3e999e2cfd61b161280d16a111f86428"
+ integrity sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA==
axios-mock-adapter@^1.15.0:
version "1.15.0"
@@ -2671,11 +2676,6 @@ camelcase@^2.0.0:
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=
-camelcase@^3.0.0:
- version "3.0.0"
- resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a"
- integrity sha1-MvxLn82vhF/N9+c7uXysImHwqwo=
-
camelcase@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
@@ -2875,15 +2875,6 @@ clipboard@^2.0.0:
select "^1.1.2"
tiny-emitter "^2.0.0"
-cliui@^3.2.0:
- version "3.2.0"
- resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d"
- integrity sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=
- dependencies:
- string-width "^1.0.1"
- strip-ansi "^3.0.1"
- wrap-ansi "^2.0.0"
-
cliui@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-4.1.0.tgz#348422dbe82d800b3022eef4f6ac10bf2e4d1b49"
@@ -3007,9 +2998,9 @@ colors@^1.1.0:
integrity sha512-mmGt/1pZqYRjMxB1axhTo16/snVZ5krrKkcmMeVKxzECMMXoCgnvTPp10QgHfcbQZw8Dq2jMNG6je4JlWU0gWg==
combined-stream@^1.0.6, combined-stream@~1.0.6:
- version "1.0.7"
- resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.7.tgz#2d1d24317afb8abe95d6d2c0b07b57813539d828"
- integrity sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w==
+ version "1.0.8"
+ resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
+ integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
dependencies:
delayed-stream "~1.0.0"
@@ -3842,7 +3833,7 @@ decamelize-keys@^1.0.0:
decamelize "^1.1.0"
map-obj "^1.0.0"
-decamelize@^1.1.0, decamelize@^1.1.1, decamelize@^1.1.2, decamelize@^1.2.0:
+decamelize@^1.1.0, decamelize@^1.1.2, decamelize@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
@@ -4911,9 +4902,9 @@ fake-xml-http-request@^2.1.1:
integrity sha512-Kn2WYYS6cDBS5jq/voOfSGCA0TafOYAUPbEp8mUVpD/DVV5bQIDjlq+MLLvNUokkbTpjBVlLDaM5PnX+PwZMlw==
fast-deep-equal@^3.1.1:
- version "3.1.1"
- resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz#545145077c501491e33b15ec408c294376e94ae4"
- integrity sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
+ integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-glob@^2.2.6:
version "2.2.6"
@@ -4928,9 +4919,9 @@ fast-glob@^2.2.6:
micromatch "^3.1.10"
fast-json-stable-stringify@2.x, 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"
- integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I=
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
+ integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
fast-levenshtein@~2.0.6:
version "2.0.6"
@@ -5513,9 +5504,9 @@ globjoin@^0.1.4:
integrity sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM=
globule@^1.0.0:
- version "1.2.1"
- resolved "https://registry.yarnpkg.com/globule/-/globule-1.2.1.tgz#5dffb1b191f22d20797a9369b49eab4e9839696d"
- integrity sha512-g7QtgWF4uYSL5/dn71WxubOrS7JVGCnFPEnoeChJmBnyR9Mw8nGoEwOgJL/RC2Te0WhbsEUCejfH8SZNJ+adYQ==
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/globule/-/globule-1.3.2.tgz#d8bdd9e9e4eef8f96e245999a5dee7eb5d8529c4"
+ integrity sha512-7IDTQTIu2xzXkT+6mlluidnWo+BypnbSoEVVQCGfzqnl5Ik8d3e1d4wycb8Rj9tWW+Z39uPWsdlquqiqPCd/pA==
dependencies:
glob "~7.1.1"
lodash "~4.17.10"
@@ -5581,16 +5572,6 @@ graphql@^14.7.0:
dependencies:
iterall "^1.2.2"
-gray-matter@^4.0.2:
- version "4.0.2"
- resolved "https://registry.yarnpkg.com/gray-matter/-/gray-matter-4.0.2.tgz#9aa379e3acaf421193fce7d2a28cebd4518ac454"
- integrity sha512-7hB/+LxrOjq/dd8APlK0r24uL/67w7SkYnfwhNFwg/VDIGWGmduTDYf3WNstLW2fbbmRwrDGCVSJ2isuf2+4Hw==
- dependencies:
- js-yaml "^3.11.0"
- kind-of "^6.0.2"
- section-matter "^1.0.0"
- strip-bom-string "^1.0.0"
-
growly@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081"
@@ -5626,11 +5607,11 @@ har-schema@^2.0.0:
integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=
har-validator@~5.1.3:
- version "5.1.3"
- resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080"
- integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==
+ version "5.1.5"
+ resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd"
+ integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==
dependencies:
- ajv "^6.5.5"
+ ajv "^6.12.3"
har-schema "^2.0.0"
has-ansi@^2.0.0:
@@ -5775,9 +5756,9 @@ hoopy@^0.1.2:
integrity sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==
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"
- integrity sha1-eg0JeGPYhsD6u9zTe/F1jYvs+KU=
+ version "2.8.8"
+ resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488"
+ integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==
hpack.js@^2.1.6:
version "2.1.6"
@@ -6014,9 +5995,9 @@ imurmurhash@^0.1.4:
integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
in-publish@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/in-publish/-/in-publish-2.0.0.tgz#e20ff5e3a2afc2690320b6dc552682a9c7fadf51"
- integrity sha1-4g/146KvwmkDILbcVSaCqcf631E=
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/in-publish/-/in-publish-2.0.1.tgz#948b1a535c8030561cea522f73f78f4be357e00c"
+ integrity sha512-oDM0kUSNFC31ShNxHKUyfZKy8ZeXZBWMjMdZHKLOk13uvT27VTL/QzRGfRUcevJhpkZAvlhPYuXkF7eNWrtyxQ==
indent-string@^2.1.0:
version "2.1.0"
@@ -6117,11 +6098,6 @@ invariant@^2.2.2, invariant@^2.2.4:
dependencies:
loose-envify "^1.0.0"
-invert-kv@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6"
- integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY=
-
invert-kv@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02"
@@ -6283,11 +6259,9 @@ is-extglob@^2.1.0, is-extglob@^2.1.1:
integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
is-finite@^1.0.0:
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa"
- integrity sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=
- dependencies:
- number-is-nan "^1.0.0"
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.1.0.tgz#904135c77fb42c0641d6aa1bcdbc4daa8da082f3"
+ integrity sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==
is-fullwidth-code-point@^1.0.0:
version "1.0.0"
@@ -7068,13 +7042,6 @@ jmespath@0.15.0:
resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217"
integrity sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=
-jquery-ujs@1.2.2:
- version "1.2.2"
- resolved "https://registry.yarnpkg.com/jquery-ujs/-/jquery-ujs-1.2.2.tgz#6a8ef1020e6b6dda385b90a4bddc128c21c56397"
- integrity sha1-ao7xAg5rbdo4W5CkvdwSjCHFY5c=
- dependencies:
- jquery ">=1.8.0"
-
jquery.caret@^0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/jquery.caret/-/jquery.caret-0.3.1.tgz#9c093318faf327eff322e826ca9f3241368bc7b8"
@@ -7085,15 +7052,15 @@ jquery.waitforimages@^2.2.0:
resolved "https://registry.yarnpkg.com/jquery.waitforimages/-/jquery.waitforimages-2.2.0.tgz#63f23131055a1b060dc913e6d874bcc9b9e6b16b"
integrity sha1-Y/IxMQVaGwYNyRPm2HS8ybnmsWs=
-"jquery@>= 1.9.1", jquery@>=1.8.0, jquery@^3.5.0:
+"jquery@>= 1.9.1", jquery@^3.5.0:
version "3.5.1"
resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.5.1.tgz#d7b4d08e1bfdb86ad2f1a3d039ea17304717abb5"
integrity sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg==
js-base64@^2.1.8:
- version "2.5.1"
- resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.5.1.tgz#1efa39ef2c5f7980bb1784ade4a8af2de3291121"
- integrity sha512-M7kLczedRMYX4L8Mdh4MzyAMM9O5osx+4FcOQuTvr3A9F2D9S5JXheN0ewNbrvK2UatkTRhL5ejGmGSjNMiZuw==
+ version "2.6.4"
+ resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.6.4.tgz#f4e686c5de1ea1f867dbcad3d46d969428df98c4"
+ integrity sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==
js-beautify@^1.6.12, js-beautify@^1.8.8:
version "1.10.3"
@@ -7116,7 +7083,7 @@ js-cookie@^2.2.1:
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
-js-yaml@^3.11.0, js-yaml@^3.12.0, js-yaml@^3.13.1, js-yaml@~3.13.1:
+js-yaml@^3.12.0, js-yaml@^3.13.1, js-yaml@~3.13.1:
version "3.13.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847"
integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==
@@ -7476,13 +7443,6 @@ latest-version@^5.0.0:
dependencies:
package-json "^6.3.0"
-lcid@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835"
- integrity sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=
- dependencies:
- invert-kv "^1.0.0"
-
lcid@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/lcid/-/lcid-2.0.0.tgz#6ef5d2df60e52f82eb228a4c373e8d1f397253cf"
@@ -8164,17 +8124,17 @@ miller-rabin@^4.0.0:
bn.js "^4.0.0"
brorand "^1.0.1"
-mime-db@1.40.0, "mime-db@>= 1.40.0 < 2":
- version "1.40.0"
- resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32"
- integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==
+mime-db@1.44.0, "mime-db@>= 1.40.0 < 2":
+ version "1.44.0"
+ resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92"
+ integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==
mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24:
- version "2.1.24"
- resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81"
- integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==
+ version "2.1.27"
+ resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f"
+ integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==
dependencies:
- mime-db "1.40.0"
+ mime-db "1.44.0"
mime@1.6.0:
version "1.6.0"
@@ -8241,11 +8201,6 @@ minimist-options@^3.0.1:
arrify "^1.0.1"
is-plain-obj "^1.1.0"
-minimist@0.0.8, minimist@~0.0.1:
- version "0.0.8"
- resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
- integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=
-
minimist@1.1.x:
version "1.1.3"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.1.3.tgz#3bedfd91a92d39016fcfaa1c681e8faa1a1efda8"
@@ -8256,6 +8211,11 @@ minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5, minimist@~1.
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
+minimist@~0.0.1:
+ version "0.0.8"
+ resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
+ integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=
+
minipass-collect@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617"
@@ -8341,11 +8301,11 @@ mixin-deep@^1.2.0:
is-extendable "^1.0.1"
mkdirp@0.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1:
- version "0.5.1"
- resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
- integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=
+ version "0.5.5"
+ resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
+ integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
dependencies:
- minimist "0.0.8"
+ minimist "^1.2.5"
mock-apollo-client@^0.4.0:
version "0.4.0"
@@ -8563,10 +8523,10 @@ node-releases@^1.1.53:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.58.tgz#8ee20eef30fa60e52755fcc0942def5a734fe935"
integrity sha512-NxBudgVKiRh/2aPWMgPR7bPTX0VPmGx5QBwCtdHitnqFE5/O8DeBXuIMH1nwNnw/aMo6AjOrpsHzfY3UbUJ7yg==
-node-sass@^4.12.0:
- version "4.12.0"
- resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.12.0.tgz#0914f531932380114a30cc5fa4fa63233a25f017"
- integrity sha512-A1Iv4oN+Iel6EPv77/HddXErL2a+gZ4uBeZUy+a8O35CFYTXhgA8MgLCWBtwpGZdCvTvQ9d+bQxX/QC36GDPpQ==
+node-sass@^4.14.1:
+ version "4.14.1"
+ resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.14.1.tgz#99c87ec2efb7047ed638fb4c9db7f3a42e2217b5"
+ integrity sha512-sjCuOlvGyCJS40R8BscF5vhVlQjNN069NtQ1gSxyK1u9iqvn6tf7O1R4GNowVZfiZUCRt5MmMs1xd+4V/7Yr0g==
dependencies:
async-foreach "^0.1.3"
chalk "^1.1.1"
@@ -8575,14 +8535,14 @@ node-sass@^4.12.0:
get-stdin "^4.0.1"
glob "^7.0.3"
in-publish "^2.0.0"
- lodash "^4.17.11"
+ lodash "^4.17.15"
meow "^3.7.0"
mkdirp "^0.5.1"
nan "^2.13.2"
node-gyp "^3.8.0"
npmlog "^4.0.0"
request "^2.88.0"
- sass-graph "^2.2.4"
+ sass-graph "2.2.5"
stdout-stream "^1.4.0"
"true-case-path" "^1.0.2"
@@ -8871,13 +8831,6 @@ os-homedir@^1.0.0:
resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
-os-locale@^1.4.0:
- version "1.4.0"
- resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-1.4.0.tgz#20f9f17ae29ed345e8bde583b13d2009803c14d9"
- integrity sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=
- dependencies:
- lcid "^1.0.0"
-
os-locale@^3.0.0, os-locale@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.1.0.tgz#a802a6ee17f24c10483ab9935719cef4ed16bf1a"
@@ -9953,9 +9906,9 @@ read-pkg@^3.0.0:
path-type "^3.0.0"
"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6:
- version "2.3.6"
- resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
- integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==
+ version "2.3.7"
+ resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
+ integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
dependencies:
core-util-is "~1.0.0"
inherits "~2.0.3"
@@ -10302,9 +10255,9 @@ resolve@1.1.7:
integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=
resolve@1.x, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.3.2, resolve@^1.5.0:
- version "1.15.0"
- resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.0.tgz#1b7ca96073ebb52e741ffd799f6b39ea462c67f5"
- integrity sha512-+hTmAldEGE80U2wJJDC1lebb5jWqvTYAfm3YZ1ckk1gBr0MnCqUKlwK1e+anaFljIl+F5tR5IoZcm4ZDA1zMQw==
+ version "1.17.0"
+ resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444"
+ integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==
dependencies:
path-parse "^1.0.6"
@@ -10433,15 +10386,15 @@ sane@^4.0.3:
minimist "^1.1.1"
walker "~1.0.5"
-sass-graph@^2.2.4:
- version "2.2.4"
- resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz#13fbd63cd1caf0908b9fd93476ad43a51d1e0b49"
- integrity sha1-E/vWPNHK8JCLn9k0dq1DpR0eC0k=
+sass-graph@2.2.5:
+ version "2.2.5"
+ resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.5.tgz#a981c87446b8319d96dce0671e487879bd24c2e8"
+ integrity sha512-VFWDAHOe6mRuT4mZRd4eKE+d8Uedrk6Xnh7Sh9b4NGufQLQjOrvf/MQoOdx+0s92L89FeyUUNfU597j/3uNpag==
dependencies:
glob "^7.0.0"
lodash "^4.0.0"
scss-tokenizer "^0.2.3"
- yargs "^7.0.0"
+ yargs "^13.3.2"
sax@1.2.1:
version "1.2.1"
@@ -10502,14 +10455,6 @@ scss-tokenizer@^0.2.3:
js-base64 "^2.1.8"
source-map "^0.4.2"
-section-matter@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/section-matter/-/section-matter-1.0.0.tgz#e9041953506780ec01d59f292a19c7b850b84167"
- integrity sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==
- dependencies:
- extend-shallow "^2.0.1"
- kind-of "^6.0.0"
-
select-hose@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
@@ -10674,9 +10619,9 @@ sigmund@^1.0.1:
integrity sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA=
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"
- integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
+ integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==
sisteransi@^1.0.0:
version "1.0.0"
@@ -10878,22 +10823,31 @@ source-map@^0.7.3:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
-spdx-correct@~1.0.0:
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-1.0.2.tgz#4b3073d933ff51f3912f03ac5519498a4150db40"
- integrity sha1-SzBz2TP/UfORLwOsVRlJikFQ20A=
+spdx-correct@^3.0.0:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9"
+ integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==
dependencies:
- spdx-license-ids "^1.0.2"
+ spdx-expression-parse "^3.0.0"
+ spdx-license-ids "^3.0.0"
-spdx-expression-parse@~1.0.0:
- version "1.0.4"
- resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-1.0.4.tgz#9bdf2f20e1f40ed447fbe273266191fced51626c"
- integrity sha1-m98vIOH0DtRH++JzJmGR/O1RYmw=
+spdx-exceptions@^2.1.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d"
+ integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==
-spdx-license-ids@^1.0.2:
- version "1.2.2"
- resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-1.2.2.tgz#c9df7a3424594ade6bd11900d596696dc06bac57"
- integrity sha1-yd96NCRZSt5r0RkA1ZZpbcBrrFc=
+spdx-expression-parse@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679"
+ integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==
+ dependencies:
+ spdx-exceptions "^2.1.0"
+ spdx-license-ids "^3.0.0"
+
+spdx-license-ids@^3.0.0:
+ version "3.0.6"
+ resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.6.tgz#c80757383c28abf7296744998cbc106ae8b854ce"
+ integrity sha512-+orQK83kyMva3WyPf59k1+Y525csj5JejicWut55zeTWANuN17qSiSLUXWtzHeNWORSvT7GLDJ/E/XiIWoXBTw==
spdy-transport@^3.0.0:
version "3.0.0"
@@ -10941,9 +10895,9 @@ sql.js@^0.4.0:
integrity sha1-I76WNVIOsP9Dp0Hn6DA5cmbohEU=
sshpk@^1.7.0:
- version "1.15.2"
- resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.15.2.tgz#c946d6bd9b1a39d0e8635763f5242d6ed6dcb629"
- integrity sha512-Ra/OXQtuh0/enyl4ETZAfTaeksa6BXks5ZcjpSUNrjBr0DvrJKX+1fsKDPpT9TBXgHAFsa4510aNVgI8g/+SzA==
+ version "1.16.1"
+ resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
+ integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==
dependencies:
asn1 "~0.2.3"
assert-plus "^1.0.0"
@@ -11071,7 +11025,7 @@ string-length@^2.0.0:
astral-regex "^1.0.0"
strip-ansi "^4.0.0"
-string-width@^1.0.1, string-width@^1.0.2:
+string-width@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=
@@ -11172,11 +11126,6 @@ strip-ansi@^6.0.0:
dependencies:
ansi-regex "^5.0.0"
-strip-bom-string@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/strip-bom-string/-/strip-bom-string-1.0.0.tgz#e5211e9224369fbb81d633a2f00044dc8cedad92"
- integrity sha1-5SEekiQ2n7uB1jOi8ABE3IztrZI=
-
strip-bom@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"
@@ -12024,9 +11973,9 @@ upper-case@^1.1.1:
integrity sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=
uri-js@^4.2.2:
- version "4.2.2"
- resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0"
- integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==
+ version "4.4.0"
+ resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.0.tgz#aa714261de793e8a82347a7bcc9ce74e86f28602"
+ integrity sha512-B0yRTzYdUCCn9n+F4+Gh4yIDtMQcaJsmYBDsTSG8g/OejKBodLQ2IHfN3bM7jUsRXndopT7OIXWdYqc1fjmV6g==
dependencies:
punycode "^2.1.0"
@@ -12141,12 +12090,12 @@ v8-compile-cache@2.0.3, v8-compile-cache@^2.0.3:
integrity sha512-CNmdbwQMBjwr9Gsmohvm0pbL954tJrNzf6gWL3K+QMQf00PF7ERGrEiLgjuU3mKreLC2MeGhUsNV9ybTbLgd3w==
validate-npm-package-license@^3.0.1:
- version "3.0.1"
- resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz#2804babe712ad3379459acfbe24746ab2c303fbc"
- integrity sha1-KAS6vnEq0zeUWaz74kdGqywwP7w=
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
+ integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==
dependencies:
- spdx-correct "~1.0.0"
- spdx-expression-parse "~1.0.0"
+ spdx-correct "^3.0.0"
+ spdx-expression-parse "^3.0.0"
vary@~1.1.2:
version "1.1.2"
@@ -12304,10 +12253,10 @@ vue-jest@4.0.0-beta.2:
source-map "^0.5.6"
ts-jest "^23.10.5"
-vue-loader@^15.9.0:
- version "15.9.0"
- resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-15.9.0.tgz#5d4b0378a4606188fc83e587ed23c94bc3a10998"
- integrity sha512-FeDHvTSpwyLeF7LIV1PYkvqUQgTJ8UmOxhSlCyRSxaXCKk+M6NF4tDQsLsPPNeDPyR7TfRQ8MLg6v+8PsDV9xQ==
+vue-loader@^15.9.3:
+ version "15.9.3"
+ resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-15.9.3.tgz#0de35d9e555d3ed53969516cac5ce25531299dda"
+ integrity sha512-Y67VnGGgVLH5Voostx8JBZgPQTlDQeOVBLOEsjc2cXbCYBKexSKEpOA56x0YZofoDOTszrLnIShyOX1p9uCEHA==
dependencies:
"@vue/component-compiler-utils" "^3.1.0"
hash-sum "^1.0.2"
@@ -12315,10 +12264,10 @@ vue-loader@^15.9.0:
vue-hot-reload-api "^2.3.0"
vue-style-loader "^4.1.0"
-vue-router@^3.4.3:
- version "3.4.3"
- resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.4.3.tgz#fa93768616ee338aa174f160ac965167fa572ffa"
- integrity sha512-BADg1mjGWX18Dpmy6bOGzGNnk7B/ZA0RxuA6qedY/YJwirMfKXIDzcccmHbQI0A6k5PzMdMloc0ElHfyOoX35A==
+vue-router@^3.4.5:
+ version "3.4.5"
+ resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.4.5.tgz#d396ec037b35931bdd1e9b7edd86f9788dc15175"
+ integrity sha512-ioRY5QyDpXM9TDjOX6hX79gtaMXSVDDzSlbIlyAmbHNteIL81WIVB2e+jbzV23vzxtoV0krdS2XHm+GxFg+Nxg==
vue-runtime-helpers@^1.1.2:
version "1.1.2"
@@ -12333,10 +12282,10 @@ vue-style-loader@^4.1.0:
hash-sum "^1.0.2"
loader-utils "^1.0.2"
-vue-template-compiler@^2.5.20, vue-template-compiler@^2.6.10:
- version "2.6.10"
- resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.6.10.tgz#323b4f3495f04faa3503337a82f5d6507799c9cc"
- integrity sha512-jVZkw4/I/HT5ZMvRnhv78okGusqe0+qH2A0Em0Cp8aq78+NK9TII263CDVz2QXZsIT+yyV/gZc/j/vlwa+Epyg==
+vue-template-compiler@^2.5.20, vue-template-compiler@^2.6.12:
+ version "2.6.12"
+ resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.6.12.tgz#947ed7196744c8a5285ebe1233fe960437fcc57e"
+ integrity sha512-OzzZ52zS41YUbkCBfdXShQTe69j1gQDZ9HIX8miuC9C3rBCk9wIRjLiZZLrmX9V+Ftq/YEyv1JaVr5Y/hNtByg==
dependencies:
de-indent "^1.0.2"
he "^1.1.0"
@@ -12351,10 +12300,10 @@ vue-virtual-scroll-list@^1.4.4:
resolved "https://registry.yarnpkg.com/vue-virtual-scroll-list/-/vue-virtual-scroll-list-1.4.4.tgz#5fca7a13f785899bbfb70471ec4fe222437d8495"
integrity sha512-wU7FDpd9Xy4f62pf8SBg/ak21jMI/pdx4s4JPah+z/zuhmeAafQgp8BjtZvvt+b0BZOsOS1FJuCfUH7azTkivQ==
-vue@^2.6.10:
- version "2.6.10"
- resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.10.tgz#a72b1a42a4d82a721ea438d1b6bf55e66195c637"
- integrity sha512-ImThpeNU9HbdZL3utgMCq0oiMzAkt1mcgy3/E6zWC/G6AaQoeuFdsl9nDhTDU3X1R6FK7nsIUuRACVcjI+A2GQ==
+vue@^2.6.12:
+ version "2.6.12"
+ resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.12.tgz#f5ebd4fa6bd2869403e29a896aed4904456c9123"
+ integrity sha512-uhmLFETqPPNyuLLbsKz6ioJ4q7AZHzD8ZVFNATNyICSZouqP2Sz0rotWQC8UNBF6VGSCs5abnKJoStA6JbCbfg==
vuedraggable@^2.23.0:
version "2.23.0"
@@ -12626,11 +12575,6 @@ whatwg-url@^8.0.0:
tr46 "^2.0.2"
webidl-conversions "^5.0.0"
-which-module@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f"
- integrity sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=
-
which-module@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
@@ -12829,11 +12773,6 @@ xterm@3.14.5:
resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.14.5.tgz#c9d14e48be6873aa46fb429f22f2165557fd2dea"
integrity sha512-DVmQ8jlEtL+WbBKUZuMxHMBgK/yeIZwkXB81bH+MGaKKnJGYwA+770hzhXPfwEIokK9On9YIFPRleVp/5G7z9g==
-y18n@^3.2.1:
- version "3.2.1"
- resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41"
- integrity sha1-bRX7qITAhnnA136I53WegR4H+kE=
-
"y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
@@ -12869,21 +12808,14 @@ yargs-parser@^11.1.1:
camelcase "^5.0.0"
decamelize "^1.2.0"
-yargs-parser@^13.1.0:
- version "13.1.1"
- resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.1.tgz#d26058532aa06d365fe091f6a1fc06b2f7e5eca0"
- integrity sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ==
+yargs-parser@^13.1.0, yargs-parser@^13.1.2:
+ version "13.1.2"
+ resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38"
+ integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==
dependencies:
camelcase "^5.0.0"
decamelize "^1.2.0"
-yargs-parser@^5.0.0:
- version "5.0.0"
- resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-5.0.0.tgz#275ecf0d7ffe05c77e64e7c86e4cd94bf0e1228a"
- integrity sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo=
- dependencies:
- camelcase "^3.0.0"
-
yargs@12.0.5, yargs@^12.0.2:
version "12.0.5"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.5.tgz#05f5997b609647b64f66b81e3b4b10a368e7ad13"
@@ -12919,24 +12851,21 @@ yargs@13.2.4:
y18n "^4.0.0"
yargs-parser "^13.1.0"
-yargs@^7.0.0:
- version "7.1.0"
- resolved "https://registry.yarnpkg.com/yargs/-/yargs-7.1.0.tgz#6ba318eb16961727f5d284f8ea003e8d6154d0c8"
- integrity sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg=
+yargs@^13.3.2:
+ version "13.3.2"
+ resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd"
+ integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==
dependencies:
- camelcase "^3.0.0"
- cliui "^3.2.0"
- decamelize "^1.1.1"
- get-caller-file "^1.0.1"
- os-locale "^1.4.0"
- read-pkg-up "^1.0.1"
+ cliui "^5.0.0"
+ find-up "^3.0.0"
+ get-caller-file "^2.0.1"
require-directory "^2.1.1"
- require-main-filename "^1.0.1"
+ require-main-filename "^2.0.0"
set-blocking "^2.0.0"
- string-width "^1.0.2"
- which-module "^1.0.0"
- y18n "^3.2.1"
- yargs-parser "^5.0.0"
+ string-width "^3.0.0"
+ which-module "^2.0.0"
+ y18n "^4.0.0"
+ yargs-parser "^13.1.2"
yarn-check-webpack-plugin@^1.2.0:
version "1.2.0"