From 59f160b0cf3ca52fc25f827e57d0dc1273a50521 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Tue, 22 Feb 2022 12:14:09 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .../projects/forks/new/components/app.vue | 43 +--- .../forks/new/components/fork_form.vue | 25 +- .../forks/new/components/fork_groups_list.vue | 93 -------- .../new/components/fork_groups_list_item.vue | 148 ------------ .../pages/projects/forks/new/index.js | 79 +++---- .../security_configuration/constants.js | 2 +- app/controllers/projects/forks_controller.rb | 8 +- app/models/merge_request.rb | 19 +- app/serializers/fork_namespace_entity.rb | 8 - app/services/merge_requests/create_service.rb | 10 + .../third_party/delete_tags_service.rb | 8 +- .../system_notes/issuables_service.rb | 5 + .../projects/forks/_fork_button.html.haml | 20 -- app/views/projects/forks/new.html.haml | 39 +--- .../ci_pending_builds_queue_source.yml | 2 +- ....yml => merge_request_eager_fetch_ref.yml} | 12 +- .../development/track_work_items_activity.yml | 8 + ...4202927_users_updating_work_item_title.yml | 25 ++ ..._users_updating_work_item_title_weekly.yml | 25 ++ doc/api/graphql/reference/index.md | 19 ++ doc/integration/elasticsearch.md | 14 +- .../project/repository/forking_workflow.md | 63 ++---- .../repository/img/fork_form_v13_10.png | Bin 40932 -> 0 bytes doc/user/project/service_desk.md | 14 +- lib/atlassian/jira_connect.rb | 5 +- lib/gitlab/process_supervisor.rb | 110 +++++++++ .../known_events/work_items.yml | 6 + .../work_item_activity_unique_counter.rb | 23 ++ locale/gitlab.pot | 21 -- qa/qa/page/project/fork/new.rb | 14 +- .../formatters/allure_metadata_formatter.rb | 2 +- sidekiq_cluster/cli.rb | 107 +++------ sidekiq_cluster/sidekiq_cluster.rb | 7 +- spec/commands/sidekiq_cluster/cli_spec.rb | 213 ++++++------------ .../projects/forks_controller_spec.rb | 9 - spec/features/projects/fork_spec.rb | 195 ---------------- .../projects/forks/new/components/app_spec.js | 13 +- .../forks/new/components/fork_form_spec.js | 28 ++- .../components/fork_groups_list_item_spec.js | 73 ------ .../new/components/fork_groups_list_spec.js | 123 ---------- spec/lib/atlassian/jira_connect_spec.rb | 29 +++ spec/lib/gitlab/process_supervisor_spec.rb | 127 +++++++++++ .../hll_redis_counter_spec.rb | 3 +- .../work_item_activity_unique_counter_spec.rb | 51 +++++ spec/models/merge_request_spec.rb | 23 ++ .../serializers/fork_namespace_entity_spec.rb | 22 -- .../merge_requests/create_service_spec.rb | 23 +- .../work_items/update_service_spec.rb | 4 + workhorse/.tool-versions | 2 +- 49 files changed, 724 insertions(+), 1198 deletions(-) delete mode 100644 app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue delete mode 100644 app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue delete mode 100644 app/views/projects/forks/_fork_button.html.haml rename config/feature_flags/development/{fork_project_form.yml => merge_request_eager_fetch_ref.yml} (54%) create mode 100644 config/feature_flags/development/track_work_items_activity.yml create mode 100644 config/metrics/counts_28d/20220214202927_users_updating_work_item_title.yml create mode 100644 config/metrics/counts_7d/20220216204730_users_updating_work_item_title_weekly.yml delete mode 100644 doc/user/project/repository/img/fork_form_v13_10.png create mode 100644 lib/gitlab/process_supervisor.rb create mode 100644 lib/gitlab/usage_data_counters/known_events/work_items.yml create mode 100644 lib/gitlab/usage_data_counters/work_item_activity_unique_counter.rb delete mode 100644 spec/frontend/pages/projects/forks/new/components/fork_groups_list_item_spec.js delete mode 100644 spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js create mode 100644 spec/lib/atlassian/jira_connect_spec.rb create mode 100644 spec/lib/gitlab/process_supervisor_spec.rb create mode 100644 spec/lib/gitlab/usage_data_counters/work_item_activity_unique_counter_spec.rb diff --git a/app/assets/javascripts/pages/projects/forks/new/components/app.vue b/app/assets/javascripts/pages/projects/forks/new/components/app.vue index 7fb41c6e7b7..0995a2118b1 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/app.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/app.vue @@ -10,38 +10,6 @@ export default { type: String, required: true, }, - endpoint: { - type: String, - required: true, - }, - projectFullPath: { - type: String, - required: true, - }, - projectId: { - type: String, - required: true, - }, - projectName: { - type: String, - required: true, - }, - projectPath: { - type: String, - required: true, - }, - projectDescription: { - type: String, - required: true, - }, - projectVisibility: { - type: String, - required: true, - }, - restrictedVisibilityLevels: { - type: Array, - required: true, - }, }, }; @@ -62,16 +30,7 @@ export default {

- +
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue index 25b62e6c971..701bf0c1e1d 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -72,40 +72,29 @@ export default { visibilityHelpPath: { default: '', }, - }, - props: { endpoint: { - type: String, - required: true, + default: '', }, projectFullPath: { - type: String, - required: true, + default: '', }, projectId: { - type: String, - required: true, + default: '', }, projectName: { - type: String, - required: true, + default: '', }, projectPath: { - type: String, - required: true, + default: '', }, projectDescription: { - type: String, - required: false, default: '', }, projectVisibility: { - type: String, - required: true, + default: '', }, restrictedVisibilityLevels: { - type: Array, - required: true, + default: [], }, }, data() { diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue deleted file mode 100644 index 10753de6cd0..00000000000 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue +++ /dev/null @@ -1,93 +0,0 @@ - - diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue deleted file mode 100644 index d41488acf46..00000000000 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue +++ /dev/null @@ -1,148 +0,0 @@ - - diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js index 1a171252048..cbf74f755e7 100644 --- a/app/assets/javascripts/pages/projects/forks/new/index.js +++ b/app/assets/javascripts/pages/projects/forks/new/index.js @@ -1,61 +1,42 @@ import Vue from 'vue'; import App from './components/app.vue'; -import ForkGroupsList from './components/fork_groups_list.vue'; const mountElement = document.getElementById('fork-groups-mount-element'); -if (gon.features.forkProjectForm) { - const { - forkIllustration, - endpoint, +const { + forkIllustration, + endpoint, + newGroupPath, + projectFullPath, + visibilityHelpPath, + projectId, + projectName, + projectPath, + projectDescription, + projectVisibility, + restrictedVisibilityLevels, +} = mountElement.dataset; + +// eslint-disable-next-line no-new +new Vue({ + el: mountElement, + provide: { newGroupPath, - projectFullPath, visibilityHelpPath, + endpoint, + projectFullPath, projectId, projectName, projectPath, projectDescription, projectVisibility, - restrictedVisibilityLevels, - } = mountElement.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el: mountElement, - provide: { - newGroupPath, - visibilityHelpPath, - }, - render(h) { - return h(App, { - props: { - forkIllustration, - endpoint, - newGroupPath, - projectFullPath, - visibilityHelpPath, - projectId, - projectName, - projectPath, - projectDescription, - projectVisibility, - restrictedVisibilityLevels: JSON.parse(restrictedVisibilityLevels), - }, - }); - }, - }); -} else { - const { endpoint } = mountElement.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el: mountElement, - render(h) { - return h(ForkGroupsList, { - props: { - endpoint, - }, - }); - }, - }); -} + restrictedVisibilityLevels: JSON.parse(restrictedVisibilityLevels), + }, + render(h) { + return h(App, { + props: { + forkIllustration, + }, + }); + }, +}); diff --git a/app/assets/javascripts/security_configuration/constants.js b/app/assets/javascripts/security_configuration/constants.js index 86cc3a9c2f9..ccb4b0d884c 100644 --- a/app/assets/javascripts/security_configuration/constants.js +++ b/app/assets/javascripts/security_configuration/constants.js @@ -1,5 +1,5 @@ export const TRACK_TOGGLE_TRAINING_PROVIDER_ACTION = 'toggle_security_training_provider'; export const TRACK_TOGGLE_TRAINING_PROVIDER_LABEL = 'update_security_training_provider'; - +export const TRACK_CLICK_TRAINING_LINK = 'click_security_training_link'; export const TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION = 'click_link'; export const TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL = 'security_training_provider'; diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 475c41eec9c..3208a5076e7 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -17,10 +17,6 @@ class Projects::ForksController < Projects::ApplicationController feature_category :source_code_management urgency :low, [:index] - before_action do - push_frontend_feature_flag(:fork_project_form, @project, default_enabled: :yaml) - end - def index @sort = forks_params[:sort] @@ -54,9 +50,7 @@ class Projects::ForksController < Projects::ApplicationController format.json do namespaces = load_namespaces_with_associations - [project.namespace] - namespaces = [current_user.namespace] + namespaces if - Feature.enabled?(:fork_project_form, project, default_enabled: :yaml) && - can_fork_to?(current_user.namespace) + namespaces = [current_user.namespace] + namespaces if can_fork_to?(current_user.namespace) render json: { namespaces: ForkNamespaceSerializer.new.represent( diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 29540cbde2f..5baf286d860 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1016,8 +1016,23 @@ class MergeRequest < ApplicationRecord merge_request_diff.persisted? || create_merge_request_diff end - def create_merge_request_diff + def eager_fetch_ref! + return unless valid? + + # has_internal_id normally attempts to allocate the iid in the + # before_create hook, but we need the iid to be available before + # that to fetch the ref into the target project. + track_target_project_iid! + ensure_target_project_iid! + fetch_ref! + # Prevent the after_create hook from fetching the source branch again + # Drop this field after rollout in https://gitlab.com/gitlab-org/gitlab/-/issues/353044. + @skip_fetch_ref = true + end + + def create_merge_request_diff + fetch_ref! unless skip_fetch_ref # n+1: https://gitlab.com/gitlab-org/gitlab/-/issues/19377 Gitlab::GitalyClient.allow_n_plus_1_calls do @@ -1950,6 +1965,8 @@ class MergeRequest < ApplicationRecord private + attr_accessor :skip_fetch_ref + def set_draft_status self.draft = draft? end diff --git a/app/serializers/fork_namespace_entity.rb b/app/serializers/fork_namespace_entity.rb index 2be37d23a05..997abb0f148 100644 --- a/app/serializers/fork_namespace_entity.rb +++ b/app/serializers/fork_namespace_entity.rb @@ -30,14 +30,6 @@ class ForkNamespaceEntity < Grape::Entity markdown_description(namespace) end - expose :can_create_project do |namespace, options| - if Feature.enabled?(:fork_project_form, options[:project], default_enabled: :yaml) - true - else - options[:current_user].can?(:create_projects, namespace) - end - end - private # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index c1292d924b2..66899e93299 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -31,6 +31,16 @@ module MergeRequests private + def before_create(merge_request) + # If the fetching of the source branch occurs in an ActiveRecord + # callback (e.g. after_create), a database transaction will be + # open while the Gitaly RPC waits. To avoid an idle in transaction + # timeout, we do this before we attempt to save the merge request. + if Feature.enabled?(:merge_request_eager_fetch_ref, @project, default_enabled: :yaml) + merge_request.eager_fetch_ref! + end + end + def set_projects! # @project is used to determine whether the user can set the merge request's # assignee, milestone and labels. Whether they can depends on their diff --git a/app/services/projects/container_repository/third_party/delete_tags_service.rb b/app/services/projects/container_repository/third_party/delete_tags_service.rb index 404642acf72..4184c676fc3 100644 --- a/app/services/projects/container_repository/third_party/delete_tags_service.rb +++ b/app/services/projects/container_repository/third_party/delete_tags_service.rb @@ -41,14 +41,12 @@ module Projects # update the manifests of the tags with the new dummy image def replace_tag_manifests(dummy_manifest) - deleted_tags = {} - - @tag_names.each do |name| + deleted_tags = @tag_names.map do |name| digest = @container_repository.client.put_tag(@container_repository.path, name, dummy_manifest) next unless digest - deleted_tags[name] = digest - end + [name, digest] + end.compact.to_h # make sure the digests are the same (it should always be) digests = deleted_tags.values.uniq diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index 09f36bb6501..91f9acf094b 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -160,6 +160,7 @@ module SystemNotes body = "changed title from **#{marked_old_title}** to **#{marked_new_title}**" issue_activity_counter.track_issue_title_changed_action(author: author) if noteable.is_a?(Issue) + work_item_activity_counter.track_work_item_title_changed_action(author: author) if noteable.is_a?(WorkItem) create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) end @@ -484,6 +485,10 @@ module SystemNotes Gitlab::UsageDataCounters::IssueActivityUniqueCounter end + def work_item_activity_counter + Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter + end + def track_cross_reference_action issue_activity_counter.track_issue_cross_referenced_action(author: author) if noteable.is_a?(Issue) end diff --git a/app/views/projects/forks/_fork_button.html.haml b/app/views/projects/forks/_fork_button.html.haml deleted file mode 100644 index 84259890a44..00000000000 --- a/app/views/projects/forks/_fork_button.html.haml +++ /dev/null @@ -1,20 +0,0 @@ -- avatar = namespace_icon(namespace, 100) -- can_create_project = current_user.can?(:create_projects, namespace) - -.bordered-box.fork-thumbnail.text-center.gl-m-3.gl-pb-5{ class: ("disabled" unless can_create_project) } - - if /no_((\w*)_)*avatar/.match(avatar) - = group_icon(namespace, class: "avatar rect-avatar s100 identicon mx-auto") - - else - .avatar-container.s100.mx-auto.gl-mt-5 - = image_tag(avatar, class: "avatar s100") - %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 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 gl-button btn-confirm", ("disabled" unless can_create_project)] diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml index 8848fbae9cb..7243852e1f5 100644 --- a/app/views/projects/forks/new.html.haml +++ b/app/views/projects/forks/new.html.haml @@ -1,30 +1,13 @@ - page_title s_("ForkProject|Fork project") -- if Feature.enabled?(:fork_project_form, @project, default_enabled: :yaml) - #fork-groups-mount-element{ data: { fork_illustration: image_path('illustrations/project-create-new-sm.svg'), - endpoint: new_project_fork_path(@project, format: :json), - new_group_path: new_group_path, - project_full_path: project_path(@project), - visibility_help_path: help_page_path("public_access/public_access"), - project_id: @project.id, - project_name: @project.name, - project_path: @project.path, - project_description: @project.description, - project_visibility: @project.visibility, - restricted_visibility_levels: Gitlab::CurrentSettings.restricted_visibility_levels.to_json } } -- else - .row.gl-mt-3 - .col-lg-3 - %h4.gl-mt-0 - = s_("ForkProject|Fork project") - %p - = s_("ForkProject|A fork is a copy of a project.") - %br - = s_('ForkProject|Forking a repository allows you to make changes without affecting the original project.') - .col-lg-9 - - if @own_namespace.present? - .fork-thumbnail-container.js-fork-content - %h5.gl-mt-0.gl-mb-0.gl-ml-3.gl-mr-3 - = s_("ForkProject|Select a namespace to fork the project") - = render 'fork_button', namespace: @own_namespace - #fork-groups-mount-element{ data: { endpoint: new_project_fork_path(@project, format: :json) } } +#fork-groups-mount-element{ data: { fork_illustration: image_path('illustrations/project-create-new-sm.svg'), + endpoint: new_project_fork_path(@project, format: :json), + new_group_path: new_group_path, + project_full_path: project_path(@project), + visibility_help_path: help_page_path("public_access/public_access"), + project_id: @project.id, + project_name: @project.name, + project_path: @project.path, + project_description: @project.description, + project_visibility: @project.visibility, + restricted_visibility_levels: Gitlab::CurrentSettings.restricted_visibility_levels.to_json } } diff --git a/config/feature_flags/development/ci_pending_builds_queue_source.yml b/config/feature_flags/development/ci_pending_builds_queue_source.yml index f6edd0e98ea..b4ad4fcae3b 100644 --- a/config/feature_flags/development/ci_pending_builds_queue_source.yml +++ b/config/feature_flags/development/ci_pending_builds_queue_source.yml @@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/350884 milestone: '14.0' type: development group: group::pipeline execution -default_enabled: false +default_enabled: true diff --git a/config/feature_flags/development/fork_project_form.yml b/config/feature_flags/development/merge_request_eager_fetch_ref.yml similarity index 54% rename from config/feature_flags/development/fork_project_form.yml rename to config/feature_flags/development/merge_request_eager_fetch_ref.yml index 90532c78c8a..25d7fa302ca 100644 --- a/config/feature_flags/development/fork_project_form.yml +++ b/config/feature_flags/development/merge_request_eager_fetch_ref.yml @@ -1,8 +1,8 @@ --- -name: fork_project_form -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53544 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/321387 -milestone: '13.10' +name: merge_request_eager_fetch_ref +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80876 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/353044 +milestone: '14.9' type: development -group: group::source code -default_enabled: true +group: group::code review +default_enabled: false diff --git a/config/feature_flags/development/track_work_items_activity.yml b/config/feature_flags/development/track_work_items_activity.yml new file mode 100644 index 00000000000..e4614f2d5e2 --- /dev/null +++ b/config/feature_flags/development/track_work_items_activity.yml @@ -0,0 +1,8 @@ +--- +name: track_work_items_activity +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80532 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/352903 +milestone: '14.9' +type: development +group: group::project management +default_enabled: false diff --git a/config/metrics/counts_28d/20220214202927_users_updating_work_item_title.yml b/config/metrics/counts_28d/20220214202927_users_updating_work_item_title.yml new file mode 100644 index 00000000000..734bf674f00 --- /dev/null +++ b/config/metrics/counts_28d/20220214202927_users_updating_work_item_title.yml @@ -0,0 +1,25 @@ +--- +key_path: redis_hll_counters.work_items.users_updating_work_item_title_monthly +description: Unique users updating a work item's title +product_category: team planning +product_section: dev +product_stage: plan +product_group: group::project management +value_type: number +status: active +milestone: '14.9' +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80532 +time_frame: 28d +data_source: redis_hll +data_category: optional +instrumentation_class: RedisHLLMetric +options: + events: + - users_updating_work_item_title +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/config/metrics/counts_7d/20220216204730_users_updating_work_item_title_weekly.yml b/config/metrics/counts_7d/20220216204730_users_updating_work_item_title_weekly.yml new file mode 100644 index 00000000000..92fb6dbd03d --- /dev/null +++ b/config/metrics/counts_7d/20220216204730_users_updating_work_item_title_weekly.yml @@ -0,0 +1,25 @@ +--- +key_path: redis_hll_counters.work_items.users_updating_work_item_title_weekly +description: Unique users updating a work item's title +product_category: team planning +product_section: dev +product_stage: plan +product_group: group::project management +value_type: number +status: active +milestone: '14.9' +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80532 +time_frame: 7d +data_source: redis_hll +data_category: optional +instrumentation_class: RedisHLLMetric +options: + events: + - users_updating_work_item_title +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 97e3365bc47..ba80160110f 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -4432,6 +4432,25 @@ Input type: `TimelineEventDestroyInput` | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | `timelineEvent` | [`TimelineEventType`](#timelineeventtype) | Timeline event. | +### `Mutation.timelineEventPromoteFromNote` + +Input type: `TimelineEventPromoteFromNoteInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `noteId` | [`NoteID!`](#noteid) | Note ID from which the timeline event promoted. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| `timelineEvent` | [`TimelineEventType`](#timelineeventtype) | Timeline event. | + ### `Mutation.timelineEventUpdate` Input type: `TimelineEventUpdateInput` diff --git a/doc/integration/elasticsearch.md b/doc/integration/elasticsearch.md index 4976f7c2664..5912d460079 100644 --- a/doc/integration/elasticsearch.md +++ b/doc/integration/elasticsearch.md @@ -206,7 +206,7 @@ The following Elasticsearch settings are available: | Parameter | Description | |-------------------------------------------------------|-------------| -| `Elasticsearch indexing` | Enables or disables Elasticsearch indexing and creates an empty index if one does not already exist. You may want to enable indexing but disable search in order to give the index time to be fully completed, for example. Also, keep in mind that this option doesn't have any impact on existing data, this only enables/disables the background indexer which tracks data changes and ensures new data is indexed. | +| `Elasticsearch indexing` | Enables or disables Elasticsearch indexing and creates an empty index if one does not already exist. You may want to enable indexing but disable search to give the index time to be fully completed, for example. Also, keep in mind that this option doesn't have any impact on existing data, this only enables/disables the background indexer which tracks data changes and ensures new data is indexed. | | `Pause Elasticsearch indexing` | Enables or disables temporary indexing pause. This is useful for cluster migration/reindexing. All changes are still tracked, but they are not committed to the Elasticsearch index until resumed. | | `Search with Elasticsearch enabled` | Enables or disables using Elasticsearch in search. | | `URL` | The URL of your Elasticsearch instance. Use a comma-separated list to support clustering (for example, `http://host1, https://host2:9200`). If your Elasticsearch instance is password-protected, use the `Username` and `Password` fields described below. Alternatively, use inline credentials such as `http://:@:9200/`. | @@ -221,8 +221,8 @@ The following Elasticsearch settings are available: | `AWS Secret Access Key` | The AWS secret access key. | | `Maximum file size indexed` | See [the explanation in instance limits.](../administration/instance_limits.md#maximum-file-size-indexed). | | `Maximum field length` | See [the explanation in instance limits.](../administration/instance_limits.md#maximum-field-length). | -| `Maximum bulk request size (MiB)` | The Maximum Bulk Request size is used by the GitLab Golang-based indexer processes and indicates how much data it ought to collect (and store in memory) in a given indexing process before submitting the payload to Elasticsearch's Bulk API. This setting should be used with the Bulk request concurrency setting (see below) and needs to accommodate the resource constraints of both the Elasticsearch host(s) and the host(s) running the GitLab Golang-based indexer either from the `gitlab-rake` command or the Sidekiq tasks. | -| `Bulk request concurrency` | The Bulk request concurrency indicates how many of the GitLab Golang-based indexer processes (or threads) can run in parallel to collect data to subsequently submit to Elasticsearch's Bulk API. This increases indexing performance, but fills the Elasticsearch bulk requests queue faster. This setting should be used together with the Maximum bulk request size setting (see above) and needs to accommodate the resource constraints of both the Elasticsearch host(s) and the host(s) running the GitLab Golang-based indexer either from the `gitlab-rake` command or the Sidekiq tasks. | +| `Maximum bulk request size (MiB)` | The Maximum Bulk Request size is used by the GitLab Golang-based indexer processes and indicates how much data it ought to collect (and store in memory) in a given indexing process before submitting the payload to Elasticsearch's Bulk API. This setting should be used with the Bulk request concurrency setting (see below) and needs to accommodate the resource constraints of both the Elasticsearch hosts and the hosts running the GitLab Golang-based indexer either from the `gitlab-rake` command or the Sidekiq tasks. | +| `Bulk request concurrency` | The Bulk request concurrency indicates how many of the GitLab Golang-based indexer processes (or threads) can run in parallel to collect data to subsequently submit to Elasticsearch's Bulk API. This increases indexing performance, but fills the Elasticsearch bulk requests queue faster. This setting should be used together with the Maximum bulk request size setting (see above) and needs to accommodate the resource constraints of both the Elasticsearch hosts and the hosts running the GitLab Golang-based indexer either from the `gitlab-rake` command or the Sidekiq tasks. | | `Client request timeout` | Elasticsearch HTTP client request timeout value in seconds. `0` means using the system default timeout value, which depends on the libraries that GitLab application is built upon. | WARNING: @@ -259,16 +259,16 @@ from the Elasticsearch index as expected. You can improve the language support for Chinese and Japanese languages by utilizing [`smartcn`](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-smartcn.html) and/or [`kuromoji`](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html) analysis plugins from Elastic. -To enable language(s) support: +To enable languages support: -1. Install the desired plugin(s), please refer to [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/plugins/7.9/installation.html) for plugins installation instructions. The plugin(s) must be installed on every node in the cluster, and each node must be restarted after installation. For a list of plugins, see the table later in this section. +1. Install the desired plugins, please refer to [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/plugins/7.9/installation.html) for plugins installation instructions. The plugins must be installed on every node in the cluster, and each node must be restarted after installation. For a list of plugins, see the table later in this section. 1. On the top bar, select **Menu > Admin**. 1. On the left sidebar, select **Settings > Advanced Search**. 1. Locate **Custom analyzers: language support**. -1. Enable plugin(s) support for **Indexing**. +1. Enable plugins support for **Indexing**. 1. Click **Save changes** for the changes to take effect. 1. Trigger [Zero downtime reindexing](#zero-downtime-reindexing) or reindex everything from scratch to create a new index with updated mappings. -1. Enable plugin(s) support for **Searching** after the previous step is completed. +1. Enable plugins support for **Searching** after the previous step is completed. For guidance on what to install, see the following Elasticsearch language plugin options: diff --git a/doc/user/project/repository/forking_workflow.md b/doc/user/project/repository/forking_workflow.md index b085372c8f2..ed0d94aa508 100644 --- a/doc/user/project/repository/forking_workflow.md +++ b/doc/user/project/repository/forking_workflow.md @@ -18,36 +18,24 @@ submit them through a merge request to the repository you don't have access to. ## Creating a fork +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/15013) a new form in GitLab 13.11 [with a flag](../../../user/feature_flags.md) named `fork_project_form`. Disabled by default. +> - [Enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/77181) in GitLab 14.8. Feature flag `fork_project_form` removed. + To fork an existing project in GitLab: -1. On the project's home page, in the top right, click **{fork}** **Fork**. +1. On the project's home page, in the top right, select **{fork}** **Fork**: + ![Fork this project](img/forking_workflow_fork_button_v13_10.png) +1. Optional. Edit the **Project name**. +1. For **Project URL**, select the [namespace](../../group/index.md#namespaces) + your fork should belong to. +1. Add a **Project slug**. This value becomes part of the URL to your fork. + It must be unique in the namespace. +1. Optional. Add a **Project description**. +1. Select the **Visibility level** for your fork. For more information about + visibility levels, read [Project and group visibility](../../../public_access/public_access.md). +1. Select **Fork project**. - ![Fork button](img/forking_workflow_fork_button_v13_10.png) - -1. Select the project to fork to: - - - Recommended method. Below **Select a namespace to fork the project**, identify - the project you want to fork to, and click **Select**. Only namespaces where you have - at least the Developer role for are shown. - - ![Choose namespace](img/forking_workflow_choose_namespace_v13_10.png) - - - Experimental method. If your GitLab administrator has - enabled the experimental fork project form, read - [Create a fork with the fork project form](#create-a-fork-with-the-fork-project-form). - Only namespaces where you have at least the Developer role for are shown. - - NOTE: - The project path must be unique in the namespace. - -GitLab creates your fork, and redirects you to the project page for your new fork. -The permissions you have in the namespace are your permissions in the fork. - -WARNING: -When a public project with the repository feature set to **Members Only** -is forked, the repository is public in the fork. The owner -of the fork must manually change the visibility. Issue -[#36662](https://gitlab.com/gitlab-org/gitlab/-/issues/36662) exists for this issue. +GitLab creates your fork, and redirects you to the new fork's page. ## Repository mirroring @@ -81,24 +69,3 @@ changes are added to the repository and branch you're merging into. ## Removing a fork relationship You can unlink your fork from its upstream project in the [advanced settings](../settings/index.md#removing-a-fork-relationship). - -## Create a fork with the fork project form **(FREE SELF)** - -> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/15013) in GitLab 13.11 [with a flag](../../../administration/feature_flags.md) named `fork_project_form`. Disabled by default. -> - [Enabled on self-managed and GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64967) in GitLab 13.8. - -FLAG: -On self-managed GitLab, by default this feature is available. To hide the feature, ask an administrator to [disable the feature flag](../../../administration/feature_flags.md) named `fork_project_form`. -On GitLab.com, this feature is available. - -This version of the fork project form is experimental: - -![Choose namespace](img/fork_form_v13_10.png) - -To use it, follow the instructions at [Creating a fork](#creating-a-fork) and provide: - -- The project name. -- The project URL. -- The project slug. -- Optional. The project description. -- The visibility level for your fork. diff --git a/doc/user/project/repository/img/fork_form_v13_10.png b/doc/user/project/repository/img/fork_form_v13_10.png deleted file mode 100644 index 00c2f89a844da1a2443ada2a3859f6463f369fb8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40932 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�-A%}L+UXt+n~qn0qtsO- zNN-=p1azK=9^&P>RiPErEzeWN4F(oyHj8|R(gq(B{Jjyh|KUcd&Lx#3T-ONfvDIf z@jh{gg$C$SksK1GS}zPd)GUz{Sd&(Z0Ghr2%bQR&Mt3#RGthv+{xEPlt*ZL7uC1zY zzAK!Yf^?DuHEew_l+(^)O=F=58`JboOpw%7LC5Lrn0}oFoT99?^?%j6P?m6-w_tLBl1{wm^8RI=TPj@Zo;!FuPz(>`05qS z5`s9&q0!=1v!5YQLPE^hxGizS@goVvx^M!)GR*cvhJ{MGz3Uv}$t& z5y-v{``VqIZ!y9jTA}|J)0s5BA|@xmbnb44OQ`dkmD#ToIuj{Gresetw_tP+b#Mx@ zpR05?OPUXtwt-OJpHBoV@Bx2@--S)0<-6QZl|5Oj%q4>%yeBW`;+N>`4TI6)XK9fi znao)a?tINcP=N&-`(MKCqTi;-*P2aLa$`8H#yrj2d?jXLHATxIjE__M3qPvhGKxIi znPvm7`7=gWmo^ z4#m6HlV7_9^Kvtr0=$)}ne{Vda|_e>gR#Q`sE=prmyB*%)OaruT2tft>$@TXdfJ}E z&Dr-rS5rW732vw@hq9~*;5%salRUctKvf2!ISR%~+f-(2nl6xHqJ?Q5VQkGauWB>Y;x_>n6KnQq#JxsmsXwl)$jCQ;oSutc)`#) zB|kxXM1XW_=M(vFwK~pD`Z+4bAybHCNE^{+Tt@U&T2WjGgo@I9)Y&mle{>w<^7mSw zM=Fa$+ERdlH1$bu2gr3c?uX}gpn$?at{Ucyaf59s4s$>rq1Ir6!D7YGTc1!6b|K=Q z&JX4>T$diyZxE@>7}DUB++D^giZtT>$c?4OlQ(*y8qBg9Nes)Io0$t-nmzUuX0ttO z(KcBg@TaY~)Wu;xA5*eS27WD6rVI;RiaWrrN~1nCk4yOa!M?$mSF~>T?==SmARB@E zE=%6=YnXXIq9tt=%Ma7hU^Tp~Q@lS6`~JW14e(tTb&igTZwcdmlf3jl)g5zJs=YoJ zv|e?`1A+8ivn3Wh;Pf0TPRGy!opK^erX@mJLAfT}LA|C;A27vh!h3gg27Kf?T+9*2 zU(hl0)(4*7hf(!f#x2di(zoMNC7T^FGvl1-7or~1!Y?ialaym0;|vE01(40JFCVyI zzyod=+$xF0cPgkNB(nIyy=B~(o}w`!70E_xMdq#UvIW~)tML+l=g4gTG!(-r_1HEpZ^T|RH8L1W{1Lns4<2N~5P|3{eflUY zKgrQZz$`4U9;SRgSh+U(5Zs1Cl?&fi!gA;c+*HGT6t20Hi@=j5mKo%GB%bO#iABcu z)xpRIOYh{b*kfHlxOpffu&(5}bH`)yQRM>B<)@2~3B}4|J>U27@A&)&ITI`T z&i9WEg4u6qrA%5=R69V76FqUNpV2T>fh_3uRue4A&Wa z*7}uX?&+~oh5yI-9jxa+1fBO@?G^ z!3;dW(mDn^R(!!9wb&wBDA?1~&(v~-U#`x~!UUk`I} z#CIhx0NJf6Zm!pdV`VvT2~y5i-|H_E@l(w6ezF@@Qy%%whH2msT^GS4E4MV#DPFxW znm`Ae)8j~tqA_2syHER?l+ws1QRRB5br)Nb;Xo(A~xy}W?(^sq2Ho!C%`kUX`N~1Og zVLf$mNUl*0Fb;v0(4JOckglWh>bcHQrv`d;t z?RkA=ss}T$w*nJZ=aEOhXFqA?_V-}%USS+NvSO%`PMd_g{-@pi0$wEBy*KK*m462S z;s=;k`fn53?xo0MMnWASQV-pL>V%q^Yq(T@V&|#8tkABixL6_il5;6b;P=Bap{90@ zx2i}^OT$%n!|Tl#7PrQtMg_o{Kn@{Lb=&Ry^uWIMSI0YgE%l$n<^~l!oq5= z@mstWV0-N4$iUNCsBMa)mkM!0G*@z+M^iKie&f$QRln?%+~1}q&lnTEA_pbBFKS_4 zk$}aku-G-TQ>Wh;Oe5-yC-@v z2sK`||2^^M%oJ49s+Xn*#oOH3Is@@p%zR?)PM4owJq49_NTRASiDB~T)ikFsp_DrG z-a-#{x%YQ_f5Woae>Tp^7Fw2Ers990wJ_}+>>NoKwQq1KWX^bHjE zka%}pxPt*{denwUnIQd`WOyo_w#=S#s9azDlH+`Mnm(*+ER!-e8D-};(f&-ylB%?& z6PbB2<4o*5Nj@(`6%)<}bItJC8B*wbX~wtw*V1~qf^PG(7gsnR12DZsMlFf4=9n}1 z<=XynbF=^RZy)*lBM0(z5p}PWN>JyaXJ#9UrO(&amd&32G4*RNKk|Xcki21d&u@*T6km`DRGL=mSBv9!}o&rZ&ciG7Ga5-wUtMm-@&q2UwxX+-GyM9*#UL% z$%Ma>Z0#bnri%Yh1+x^y*yx#=Dsz{gA)F09_sj)p$_b7GE&FBlKZp< zyfHoC*F>!LG3Vq5#g;+L83oEpxU&rHtRkbHv*Z~CmlFk8+mgX6z_w1h7RC1H4)~E# zs}QXX7s$4Ah2GKOy7?x@U)c4iICU<(v+SUVyIO&JG94`Z&!;mZ_O@r{*RX!Uu39YO zz4)hh?On*}^KSPyS;+0Jto~=-_3bf@?!PpU^%FxWfq>4ybXZpv`oH1tWYW}*RArAX zDEHm{V-3o78>!baD&rguwtBN}_GmeoIN9(5Yi(&aNBYXv*OTW9w$z<|ZbCD(%b=lV zJCtDt?8H+Vc-z8OS!W%+JwM6!5Dy8j*3DYp5qgAQg65Tk*J6UK-uoscP~h=;u5ell zRL$21)?_-b=|rEMbiZ8MWsyc(SO4&5Iu_|bSVBnb6|{0`a2VFqXt$>FCekc(fJW>n zzBt=b$N{R=#Kn{Mv)9BpS%!f1EYp34s?TaES=JEjyZxgxRO#URG-IUdlp1*=KFw@?=qWvntvBWNA|za!C&x*pl{>vD4tNy1-hh2vh<`xoIbI(z@I(Bidu46X zHlIFEuv6bA>CWVa|A%8Sv1@@T(VQhRtID#S|>t z!mb~UH;?IkN4?1}*worWbZ*MU91JzRx*K@S;C&%M@(J%iT!m4`+cIuNG?Na2>bI)*o$%@^lYnpmJ6#hQk^% zJ_--k!rZ%2Moj1LDQ4yJ$IL>zZ^III+XpZ#bAHGI4cuy7qbj$zqKv}k=j>ivM9(K2 z&{TH<(5e6f3#q{RZ+~LRY9Bw9mH@sXMPz2A-Y9Ov&v_w1?~dYHtZbkoJkGHi#z#q52;L+wSc^ zbLyI11c%n}Qg&W4gIi3?yyX0PuHR;5tdcF3{hWjByl>=iE_o4RU4@LjOCT+8E%sLx zv0F=pX3Z~RB{LScX=GGRY0TMB31jUtjh*OQYXn6xowwk?G*yvCPVxlWTD+1i_oLa{Yo8yaSzJq6(Kn?MT+3NF;uQf^2%SOuYQa-a#a@8~A%8&a=!?GDw z7d}gut4Nxzh54qScrS5oyre(@Pqx*dkg>vdu__t6gtmp+Q4M`N@foRW9Nq4MD*1rK zyzDo1fJR~LWAYo{H<}pw1*E-;zg!3x?a92w@NB;_F%+ha74a?<`nlg&54vL&6s1M$ zLHLt}6_`%-^UOY`M?U!yy}xPs@8LKt!)a69+j~;MhL_et=wU+EF>kj1{zcMaG)u`! zADx{MJJ}Cp>5GW8FV!}$JLk5V#n>QH_(tb3q2L?HiDfm83>AIntn&pFV8a>Dq^^y~ zmil;=s(X=fx;}6R4Ol#d1A>gj^1>|OMZ5SVJxc}14FJl2K>Uxum`Np}y|b(_dcaKZgk7kKE9rpJ5ze7#y^XrzmuXZlL%{iMd}rIGGD4e}(NpgE^< zkXjil*+HXL3|QZVnK-F&%y=DLldgTe@~blX>#=do@~flYm?)=f`{g<}@sceba=jp_ zrBJnRYb3CSJ~YM3S1pZcS|>doZlSiNXAo+Zllx_n)96Fpo@-xuv+?F zpBVJ)mn5ckHe_-v0nyj;-oW{)YB3`Ue_a#P_q^?>k7Gq`%H|IOq`li&dsiH`Nn#Br zPOo0>F;@LvkMS2Ah%-hXt>oBx)TM`204F*Q6QVAXB7IMg0Umb;P_Z7rxsszUjA}}= ziRy_*AnEw$Snq;5BB?X1WX-h)n67~hO3Nh!S7*qdGp)iR(8Rv|{xkaNK&KNkx z4#Q+B!Za4uKWr+$GO14mA#8Y@Q~!(U6w^2ej<8a>Ec1J!|a*mu#m9YvoYZ)%oCmh)rC}db`Qcc!j*0+ni-*Clsv&&r0)asyEs8b3RfJ zdWXm22!|Y$n|$7mHis#$OxopGcqPBZJM(&6j}4e2Ii}d*ZF`70K#3 zo{C!OxBX>dgNW|)#0`f>E2_1yBTLqU`lLS^))7C%OFLy z2>JqVq`x#f9jLn=Zgnc(o5N$H#&-*hwcxuhF+RSQ^` zDJ0Q4TlT(2-@9K*>#PRlmae259twYfz>ixuO zxRkfr%3>HbU`ZdG3#ND3>z2U@XD#SZ9B|;RS06mivw>hqrSBgMA5Z{g59aRTCM=MT z%LyQf(x8abp--aH^?#SnBHtbaJp0)_Vn+79eB1`DO4J91$jU<1{?$PuiK`VXg(4k{ zQPyB-fMRHDN?4(8J4Boi^ZF*|4ceL4BU#Rf2;R1cAbfaxU3W-vrN;tiWPGw>@jXx%C5 z40CWWf>8y$9oeYP(R&Edi%!!H$BOrcy5#tk!)NNr4@OfdDbigEr=L=E((8}EI+`Hx z5VRX8xz|fyC@2KhKK=Opic_GsvQ3}*- zykvf1=^fNq()=VVb&Bkwy7Tq)U8c^yzH?DM7y-&nse-|r;5?nFTPy7Y!bVbUN*wyJ z2Ke$qLVk(PQm^Ej`78FOaP+!|z@bH+m8rb@-=I~-JvV|vxcG9wi%n?I-<3CHBPfq% zs&nyQx8TeKW`tf$r0C=e(ISS@{8M-gJW8e+4&Xver{Vb5(!mo7!G^T6w&td4byjn+ z{!MihE0OZv4@&#Lp!|LVhYMn~|8+CEW_Isi{T)re(adbhG;TCFUh$NETCd=;pVFWW z7M{TisWxtPl`L&nsCBzDT)>A>x0-m4yrutQbO>{rwpYd6)V%2+>~i8GdAN293yELL z@VR{Yh`ab!+7aV1S8{Q&y*kTg2)7NnZTk}_xM z^JhQIHr29SgF?6{$PiZ9T=s4#WO#Ityp-C%dEe1^sEmz|Hr9>&jaVsrvH!TxN$sg{ z$bbmH-}@GOnsR?5zGRjnEXi9M|Da$gp}?n;U=is{`-^3=;4Sp?=D=rCqt9|rbi9lnQF~w%XadF3e$6%iKR9CTGQ@5(tIzrqQzW&u^BF5E* z$@Yf+JYJaA;}n0dXl|`wp{3jV0?0Oxr|swoVv@t4MV3$h)<2(b$dVF}eBf{RMXs>P zi|G8BS==h0!J=Vk&#nN^c+fz`cvU|VQIkUiI=tgQ3;Y0>0Yp@0xHtbW;UXOFw5|A$<5RJYGJhggU;rl6}Bf3_Nx#%^x?m>_7Wb>xQkGI0LGiudvn=57m`*l}G zJ}0S^AHPZp>Zuy$>n8}h)qRmdzu?bTG0%+XGTg0^7Q=#_+4As$j{NCXf-xyFmacHB zZYy974_`*yJ&&GIggKU^KQVH2Egl%qRUakeO-cQA*ZGNaSa=0)G^@5Bi%Gz=wwPe$ zeM8KCp5ASM4URMf)dWu1e(ZGEK2HFS-r2;avMNPH?!$Gl&~VpPpW>t#9*$)Ev4pDN z#Y92*jlQFI*1?-CKb9AE_(e0Gu3X>87t;TKioySQBUlym4G%zL$xKrEzcBz6ZRHw8 H+nE0Y8XY`M diff --git a/doc/user/project/service_desk.md b/doc/user/project/service_desk.md index 1e5b818c99d..6952da0e895 100644 --- a/doc/user/project/service_desk.md +++ b/doc/user/project/service_desk.md @@ -41,7 +41,7 @@ Here's how Service Desk works for you: 1. You provide a project-specific email address to your paying customers, who can email you directly from the application. 1. Each email they send creates an issue in the appropriate project. -1. Your team members navigate to the Service Desk issue tracker, where they can see new support +1. Your team members go to the Service Desk issue tracker, where they can see new support requests and respond inside associated issues. 1. Your team communicates back and forth with the customer to understand the request. 1. Your team starts working on implementing code to solve your customer's problem. @@ -153,7 +153,7 @@ To use a custom description template with Service Desk: 1. On the top bar, select **Menu > Projects** and find your project. 1. [Create a description template](description_templates.md#create-an-issue-template). 1. On the left sidebar, select **Settings > General > Service Desk**. -1. From the dropdown **Template to append to all Service Desk issues**, search or select your template. +1. From the dropdown list **Template to append to all Service Desk issues**, search or select your template. ### Using a custom email display name @@ -190,13 +190,13 @@ you can customize the mailbox used by Service Desk. This allows you to have a separate email address for Service Desk by also configuring a [custom suffix](#configuring-a-custom-email-address-suffix) in project settings. -The `address` must include the `+%{key}` placeholder within the 'user' -portion of the address, before the `@`. This is used to identify the project +The `address` must include the `+%{key}` placeholder in the 'user' +portion of the address, before the `@`. The placeholder is used to identify the project where the issue should be created. NOTE: When configuring a custom mailbox, the `service_desk_email` and `incoming_email` -configurations must always use separate mailboxes. This is important, because +configurations must always use separate mailboxes. It's important, because emails picked from `service_desk_email` mailbox are processed by a different worker and it would not recognize `incoming_email` emails. @@ -267,7 +267,7 @@ The Microsoft Graph API is not yet supported in source installations. See [this #### Configuring a custom email address suffix -You can set a custom suffix in your project's Service Desk settings once you have configured a [custom mailbox](#configuring-a-custom-mailbox). +You can set a custom suffix in your project's Service Desk settings after you have configured a [custom mailbox](#configuring-a-custom-mailbox). It can contain only lowercase letters (`a-z`), numbers (`0-9`), or underscores (`_`). When configured, the custom suffix creates a new Service Desk email address, consisting of the @@ -281,7 +281,7 @@ For example, suppose the `mygroup/myproject` project Service Desk settings has t The Service Desk email address for this project is: `contact+mygroup-myproject-support@example.com`. The [incoming email](../../administration/incoming_email.md) address still works. -If you don't configure the custom suffix, the default project identification will be used for identifying the project. You can see that email address in the project settings. +If you don't configure the custom suffix, the default project identification is used for identifying the project. You can see that email address in the project settings. ## Using Service Desk diff --git a/lib/atlassian/jira_connect.rb b/lib/atlassian/jira_connect.rb index 7f693eff59b..595cf0ac465 100644 --- a/lib/atlassian/jira_connect.rb +++ b/lib/atlassian/jira_connect.rb @@ -8,7 +8,10 @@ module Atlassian end def app_key - "gitlab-jira-connect-#{gitlab_host}" + # App key must be <= 64 characters. + # See: https://developer.atlassian.com/cloud/jira/platform/connect-app-descriptor/#app-descriptor-structure + + "gitlab-jira-connect-#{gitlab_host}"[..63] end private diff --git a/lib/gitlab/process_supervisor.rb b/lib/gitlab/process_supervisor.rb new file mode 100644 index 00000000000..f0d2bbc33bd --- /dev/null +++ b/lib/gitlab/process_supervisor.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module Gitlab + # Given a set of process IDs, the supervisor can monitor processes + # for being alive and invoke a callback if some or all should go away. + # The receiver of the callback can then act on this event, for instance + # by restarting those processes or performing clean-up work. + # + # The supervisor will also trap termination signals if provided and + # propagate those to the supervised processes. Any supervised processes + # that do not terminate within a specified grace period will be killed. + class ProcessSupervisor + DEFAULT_HEALTH_CHECK_INTERVAL_SECONDS = 5 + DEFAULT_TERMINATE_INTERVAL_SECONDS = 1 + DEFAULT_TERMINATE_TIMEOUT_SECONDS = 10 + + attr_reader :alive + + def initialize( + health_check_interval_seconds: DEFAULT_HEALTH_CHECK_INTERVAL_SECONDS, + check_terminate_interval_seconds: DEFAULT_TERMINATE_INTERVAL_SECONDS, + terminate_timeout_seconds: DEFAULT_TERMINATE_TIMEOUT_SECONDS, + term_signals: %i(INT TERM), + forwarded_signals: []) + + @term_signals = term_signals + @forwarded_signals = forwarded_signals + @health_check_interval_seconds = health_check_interval_seconds + @check_terminate_interval_seconds = check_terminate_interval_seconds + @terminate_timeout_seconds = terminate_timeout_seconds + end + + # Starts a supervision loop for the given process ID(s). + # + # If any or all processes go away, the IDs of any dead processes will + # be yielded to the given block, so callers can act on them. + # + # If the block returns a non-empty list of IDs, the supervisor will + # start observing those processes instead. Otherwise it will shut down. + def supervise(pid_or_pids, &on_process_death) + @pids = Array(pid_or_pids) + + trap_signals! + + @alive = true + while @alive + sleep(@health_check_interval_seconds) + + check_process_health(&on_process_death) + end + end + + private + + def check_process_health(&on_process_death) + unless all_alive? + dead_pids = @pids - live_pids + @pids = Array(yield(dead_pids)) + @alive = @pids.any? + end + end + + def trap_signals! + ProcessManagement.trap_signals(@term_signals) do |signal| + @alive = false + signal_all(signal) + wait_for_termination + end + + ProcessManagement.trap_signals(@forwarded_signals) do |signal| + signal_all(signal) + end + end + + def wait_for_termination + deadline = monotonic_time + @terminate_timeout_seconds + sleep(@check_terminate_interval_seconds) while continue_waiting?(deadline) + + hard_stop_stuck_pids + end + + def monotonic_time + Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second) + end + + def continue_waiting?(deadline) + any_alive? && monotonic_time < deadline + end + + def signal_all(signal) + ProcessManagement.signal_processes(@pids, signal) + end + + def hard_stop_stuck_pids + ProcessManagement.signal_processes(live_pids, "-KILL") + end + + def any_alive? + ProcessManagement.any_alive?(@pids) + end + + def all_alive? + ProcessManagement.all_alive?(@pids) + end + + def live_pids + ProcessManagement.pids_alive(@pids) + end + end +end diff --git a/lib/gitlab/usage_data_counters/known_events/work_items.yml b/lib/gitlab/usage_data_counters/known_events/work_items.yml new file mode 100644 index 00000000000..a33a635b801 --- /dev/null +++ b/lib/gitlab/usage_data_counters/known_events/work_items.yml @@ -0,0 +1,6 @@ +--- +- name: users_updating_work_item_title + category: work_items + redis_slot: users + aggregation: weekly + feature_flag: track_work_items_activity diff --git a/lib/gitlab/usage_data_counters/work_item_activity_unique_counter.rb b/lib/gitlab/usage_data_counters/work_item_activity_unique_counter.rb new file mode 100644 index 00000000000..2d5ea0368fd --- /dev/null +++ b/lib/gitlab/usage_data_counters/work_item_activity_unique_counter.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gitlab + module UsageDataCounters + module WorkItemActivityUniqueCounter + WORK_ITEM_TITLE_CHANGED = 'users_updating_work_item_title' + + class << self + def track_work_item_title_changed_action(author:) + track_unique_action(WORK_ITEM_TITLE_CHANGED, author) + end + + private + + def track_unique_action(action, author) + return unless author + + Gitlab::UsageDataCounters::HLLRedisCounter.track_event(action, values: author.id) + end + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 79632484c7f..11e87e307a5 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -15814,9 +15814,6 @@ msgstr "" msgid "ForkProject|Select a namespace" msgstr "" -msgid "ForkProject|Select a namespace to fork the project" -msgstr "" - msgid "ForkProject|The project can be accessed by any logged in user." msgstr "" @@ -16864,9 +16861,6 @@ msgstr "" msgid "Go to find file" msgstr "" -msgid "Go to fork" -msgstr "" - msgid "Go to issue boards" msgstr "" @@ -17668,9 +17662,6 @@ msgstr "" msgid "Groups and projects" msgstr "" -msgid "Groups and subgroups" -msgstr "" - msgid "Groups are a great way to organize projects and people." msgstr "" @@ -24561,9 +24552,6 @@ msgstr "" msgid "No available branches" msgstr "" -msgid "No available groups to fork the project." -msgstr "" - msgid "No branches found" msgstr "" @@ -42073,9 +42061,6 @@ msgstr "" msgid "You have not added any approvers. Start by adding users or groups." msgstr "" -msgid "You have reached your project limit" -msgstr "" - msgid "You have set up 2FA for your account! If you lose access to your 2FA device, you can use your recovery codes to access your account. Alternatively, if you upload an SSH key, you can %{anchorOpen}use that key to generate additional recovery codes%{anchorClose}." msgstr "" @@ -42106,12 +42091,6 @@ msgstr "" msgid "You must have maintainer access to force delete a lock" msgstr "" -msgid "You must have permission to create a project in a group before forking." -msgstr "" - -msgid "You must have permission to create a project in a namespace before forking." -msgstr "" - msgid "You must provide a valid current password" msgstr "" diff --git a/qa/qa/page/project/fork/new.rb b/qa/qa/page/project/fork/new.rb index cd743b648d8..e1b5e47dd0b 100644 --- a/qa/qa/page/project/fork/new.rb +++ b/qa/qa/page/project/fork/new.rb @@ -5,10 +5,6 @@ module QA module Project module Fork class New < Page::Base - view 'app/views/projects/forks/_fork_button.html.haml' do - element :fork_namespace_button - end - view 'app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue' do element :fork_namespace_dropdown element :fork_project_button @@ -16,13 +12,9 @@ module QA end def fork_project(namespace = Runtime::Namespace.path) - if has_element?(:fork_namespace_button, wait: 0) - click_element(:fork_namespace_button, name: namespace) - else - select_element(:fork_namespace_dropdown, namespace) - click_element(:fork_privacy_button, privacy_level: 'public') - click_element(:fork_project_button) - end + select_element(:fork_namespace_dropdown, namespace) + click_element(:fork_privacy_button, privacy_level: 'public') + click_element(:fork_project_button) end def fork_namespace_dropdown_values diff --git a/qa/qa/support/formatters/allure_metadata_formatter.rb b/qa/qa/support/formatters/allure_metadata_formatter.rb index ad342f51e2e..2ed4ee2066a 100644 --- a/qa/qa/support/formatters/allure_metadata_formatter.rb +++ b/qa/qa/support/formatters/allure_metadata_formatter.rb @@ -24,7 +24,7 @@ module QA log(:debug, "Fetched #{failures.length} flaky testcases!") rescue StandardError => e log(:error, "Failed to fetch flaky spec data for report: #{e}") - @failures = [] + @failures = {} end # Finished example diff --git a/sidekiq_cluster/cli.rb b/sidekiq_cluster/cli.rb index 2feb77601b8..f366cb26b8e 100644 --- a/sidekiq_cluster/cli.rb +++ b/sidekiq_cluster/cli.rb @@ -14,6 +14,7 @@ require_relative '../lib/gitlab/sidekiq_config/cli_methods' require_relative '../lib/gitlab/sidekiq_config/worker_matcher' require_relative '../lib/gitlab/sidekiq_logging/json_formatter' require_relative '../lib/gitlab/process_management' +require_relative '../lib/gitlab/process_supervisor' require_relative '../metrics_server/metrics_server' require_relative 'sidekiq_cluster' @@ -38,8 +39,7 @@ module Gitlab @metrics_dir = ENV["prometheus_multiproc_dir"] || File.absolute_path("tmp/prometheus_multiproc_dir/sidekiq") @pid = nil @interval = 5 - @alive = true - @processes = [] + @soft_timeout_seconds = DEFAULT_SOFT_TIMEOUT_SECONDS @logger = Logger.new(log_output) @logger.formatter = ::Gitlab::SidekiqLogging::JSONFormatter.new @rails_path = Dir.pwd @@ -103,95 +103,63 @@ module Gitlab @logger.info("Starting cluster with #{queue_groups.length} processes") end - start_metrics_server(wipe_metrics_dir: true) + start_and_supervise_workers(queue_groups) + end - @processes = SidekiqCluster.start( + def start_and_supervise_workers(queue_groups) + worker_pids = SidekiqCluster.start( queue_groups, env: @environment, directory: @rails_path, max_concurrency: @max_concurrency, min_concurrency: @min_concurrency, dryrun: @dryrun, - timeout: soft_timeout_seconds + timeout: @soft_timeout_seconds ) return if @dryrun - write_pid - trap_signals - start_loop - end - - def write_pid ProcessManagement.write_pid(@pid) if @pid - end - def soft_timeout_seconds - @soft_timeout_seconds || DEFAULT_SOFT_TIMEOUT_SECONDS - end + supervisor = Gitlab::ProcessSupervisor.new( + health_check_interval_seconds: @interval, + terminate_timeout_seconds: @soft_timeout_seconds + TIMEOUT_GRACE_PERIOD_SECONDS, + term_signals: TERMINATE_SIGNALS, + forwarded_signals: FORWARD_SIGNALS + ) - # The amount of time it'll wait for killing the alive Sidekiq processes. - def hard_timeout_seconds - soft_timeout_seconds + DEFAULT_HARD_TIMEOUT_SECONDS - end + metrics_server_pid = start_metrics_server - def monotonic_time - Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second) - end + all_pids = worker_pids + Array(metrics_server_pid) - def continue_waiting?(deadline) - ProcessManagement.any_alive?(@processes) && monotonic_time < deadline - end - - def hard_stop_stuck_pids - ProcessManagement.signal_processes(ProcessManagement.pids_alive(@processes), "-KILL") - end - - def wait_for_termination - deadline = monotonic_time + hard_timeout_seconds - sleep(CHECK_TERMINATE_INTERVAL_SECONDS) while continue_waiting?(deadline) - - hard_stop_stuck_pids - end - - def trap_signals - ProcessManagement.trap_signals(TERMINATE_SIGNALS) do |signal| - @alive = false - ProcessManagement.signal_processes(@processes, signal) - wait_for_termination - end - - ProcessManagement.trap_signals(FORWARD_SIGNALS) do |signal| - ProcessManagement.signal_processes(@processes, signal) - end - end - - def start_loop - while @alive - sleep(@interval) - - if metrics_server_enabled? && ProcessManagement.process_died?(@metrics_server_pid) - @logger.warn('Metrics server went away') - start_metrics_server(wipe_metrics_dir: false) - end - - unless ProcessManagement.all_alive?(@processes) - # If a child process died we'll just terminate the whole cluster. It's up to - # runit and such to then restart the cluster. + supervisor.supervise(all_pids) do |dead_pids| + # If we're not in the process of shutting down the cluster, + # and the metrics server died, restart it. + if supervisor.alive && dead_pids.include?(metrics_server_pid) + @logger.info('Metrics server terminated, restarting...') + metrics_server_pid = restart_metrics_server(wipe_metrics_dir: false) + all_pids = worker_pids + Array(metrics_server_pid) + else + # If a worker process died we'll just terminate the whole cluster. + # We let an external system (runit, kubernetes) handle the restart. @logger.info('A worker terminated, shutting down the cluster') - stop_metrics_server - ProcessManagement.signal_processes(@processes, :TERM) - break + ProcessManagement.signal_processes(all_pids - dead_pids, :TERM) + # Signal supervisor not to respawn workers and shut down. + [] end end end - def start_metrics_server(wipe_metrics_dir: false) + def start_metrics_server return unless metrics_server_enabled? + restart_metrics_server(wipe_metrics_dir: true) + end + + def restart_metrics_server(wipe_metrics_dir: false) @logger.info("Starting metrics server on port #{sidekiq_exporter_port}") - @metrics_server_pid = MetricsServer.fork( + MetricsServer.fork( 'sidekiq', metrics_dir: @metrics_dir, wipe_metrics_dir: wipe_metrics_dir, @@ -225,13 +193,6 @@ module Gitlab !@dryrun && sidekiq_exporter_enabled? && exporter_has_a_unique_port? end - def stop_metrics_server - return unless @metrics_server_pid - - @logger.info("Stopping metrics server (PID #{@metrics_server_pid})") - ProcessManagement.signal(@metrics_server_pid, :TERM) - end - def option_parser OptionParser.new do |opt| opt.banner = "#{File.basename(__FILE__)} [QUEUE,QUEUE] [QUEUE] ... [OPTIONS]" diff --git a/sidekiq_cluster/sidekiq_cluster.rb b/sidekiq_cluster/sidekiq_cluster.rb index c5139ab8874..3ba3211b0e4 100644 --- a/sidekiq_cluster/sidekiq_cluster.rb +++ b/sidekiq_cluster/sidekiq_cluster.rb @@ -4,8 +4,6 @@ require_relative '../lib/gitlab/process_management' module Gitlab module SidekiqCluster - CHECK_TERMINATE_INTERVAL_SECONDS = 1 - # How long to wait when asking for a clean termination. # It maps the Sidekiq default timeout: # https://github.com/mperham/sidekiq/wiki/Signals#term @@ -14,8 +12,9 @@ module Gitlab # is given through arguments. DEFAULT_SOFT_TIMEOUT_SECONDS = 25 - # After surpassing the soft timeout. - DEFAULT_HARD_TIMEOUT_SECONDS = 5 + # Additional time granted after surpassing the soft timeout + # before we kill the process. + TIMEOUT_GRACE_PERIOD_SECONDS = 5 # Starts Sidekiq workers for the pairs of processes. # diff --git a/spec/commands/sidekiq_cluster/cli_spec.rb b/spec/commands/sidekiq_cluster/cli_spec.rb index 15b738cacd1..6baaa98eff9 100644 --- a/spec/commands/sidekiq_cluster/cli_spec.rb +++ b/spec/commands/sidekiq_cluster/cli_spec.rb @@ -5,8 +5,11 @@ require 'rspec-parameterized' require_relative '../../support/stub_settings_source' require_relative '../../../sidekiq_cluster/cli' +require_relative '../../support/helpers/next_instance_of' RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubocop:disable RSpec/FilePath + include NextInstanceOf + let(:cli) { described_class.new('/dev/null') } let(:timeout) { Gitlab::SidekiqCluster::DEFAULT_SOFT_TIMEOUT_SECONDS } let(:default_options) do @@ -61,9 +64,8 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo context 'with arguments' do before do - allow(cli).to receive(:write_pid) - allow(cli).to receive(:trap_signals) - allow(cli).to receive(:start_loop) + allow(Gitlab::ProcessManagement).to receive(:write_pid) + allow_next_instance_of(Gitlab::ProcessSupervisor) { |it| allow(it).to receive(:supervise) } end it 'starts the Sidekiq workers' do @@ -81,7 +83,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo .to receive(:worker_queues).and_return(worker_queues) expect(Gitlab::SidekiqCluster) - .to receive(:start).with([worker_queues], default_options) + .to receive(:start).with([worker_queues], default_options).and_return([]) cli.run(%w(*)) end @@ -135,6 +137,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo it 'when given', 'starts Sidekiq workers with given timeout' do expect(Gitlab::SidekiqCluster).to receive(:start) .with([['foo']], default_options.merge(timeout: 10)) + .and_return([]) cli.run(%w(foo --timeout 10)) end @@ -142,6 +145,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo it 'when not given', 'starts Sidekiq workers with default timeout' do expect(Gitlab::SidekiqCluster).to receive(:start) .with([['foo']], default_options.merge(timeout: Gitlab::SidekiqCluster::DEFAULT_SOFT_TIMEOUT_SECONDS)) + .and_return([]) cli.run(%w(foo)) end @@ -257,7 +261,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo .to receive(:worker_queues).and_return(worker_queues) expect(Gitlab::SidekiqCluster) - .to receive(:start).with([worker_queues], default_options) + .to receive(:start).with([worker_queues], default_options).and_return([]) cli.run(%w(--queue-selector *)) end @@ -292,16 +296,15 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo context 'starting the server' do context 'without --dryrun' do + before do + allow(Gitlab::SidekiqCluster).to receive(:start).and_return([]) + allow(Gitlab::ProcessManagement).to receive(:write_pid) + allow_next_instance_of(Gitlab::ProcessSupervisor) { |it| allow(it).to receive(:supervise) } + end + context 'when there are no sidekiq_health_checks settings set' do let(:sidekiq_exporter_enabled) { true } - before do - allow(Gitlab::SidekiqCluster).to receive(:start) - allow(cli).to receive(:write_pid) - allow(cli).to receive(:trap_signals) - allow(cli).to receive(:start_loop) - end - it 'does not start a sidekiq metrics server' do expect(MetricsServer).not_to receive(:fork) @@ -312,13 +315,6 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo context 'when the sidekiq_exporter.port setting is not set' do let(:sidekiq_exporter_enabled) { true } - before do - allow(Gitlab::SidekiqCluster).to receive(:start) - allow(cli).to receive(:write_pid) - allow(cli).to receive(:trap_signals) - allow(cli).to receive(:start_loop) - end - it 'does not start a sidekiq metrics server' do expect(MetricsServer).not_to receive(:fork) @@ -342,13 +338,6 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo } end - before do - allow(Gitlab::SidekiqCluster).to receive(:start) - allow(cli).to receive(:write_pid) - allow(cli).to receive(:trap_signals) - allow(cli).to receive(:start_loop) - end - it 'does not start a sidekiq metrics server' do expect(MetricsServer).not_to receive(:fork) @@ -368,13 +357,6 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo } end - before do - allow(Gitlab::SidekiqCluster).to receive(:start) - allow(cli).to receive(:write_pid) - allow(cli).to receive(:trap_signals) - allow(cli).to receive(:start_loop) - end - it 'does not start a sidekiq metrics server' do expect(MetricsServer).not_to receive(:fork) @@ -397,13 +379,6 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo end with_them do - before do - allow(Gitlab::SidekiqCluster).to receive(:start) - allow(cli).to receive(:write_pid) - allow(cli).to receive(:trap_signals) - allow(cli).to receive(:start_loop) - end - specify do if start_metrics_server expect(MetricsServer).to receive(:fork).with('sidekiq', metrics_dir: metrics_dir, wipe_metrics_dir: true, reset_signals: trapped_signals) @@ -415,6 +390,23 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo end end end + + context 'when a PID is specified' do + it 'writes the PID to a file' do + expect(Gitlab::ProcessManagement).to receive(:write_pid).with('/dev/null') + + cli.option_parser.parse!(%w(-P /dev/null)) + cli.run(%w(foo)) + end + end + + context 'when no PID is specified' do + it 'does not write a PID' do + expect(Gitlab::ProcessManagement).not_to receive(:write_pid) + + cli.run(%w(foo)) + end + end end context 'with --dryrun set' do @@ -427,130 +419,55 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo end end end - - context 'supervising the server' do - let(:sidekiq_exporter_enabled) { true } - let(:sidekiq_health_checks_port) { '3907' } - - before do - allow(cli).to receive(:sleep).with(a_kind_of(Numeric)) - allow(MetricsServer).to receive(:fork).and_return(99) - cli.start_metrics_server - end - - it 'stops the metrics server when one of the processes has been terminated' do - allow(Gitlab::ProcessManagement).to receive(:process_died?).and_return(false) - allow(Gitlab::ProcessManagement).to receive(:all_alive?).with(an_instance_of(Array)).and_return(false) - allow(Gitlab::ProcessManagement).to receive(:signal_processes).with(an_instance_of(Array), :TERM) - - expect(Process).to receive(:kill).with(:TERM, 99) - - cli.start_loop - end - - it 'starts the metrics server when it is down' do - allow(Gitlab::ProcessManagement).to receive(:process_died?).and_return(true) - allow(Gitlab::ProcessManagement).to receive(:all_alive?).with(an_instance_of(Array)).and_return(false) - allow(cli).to receive(:stop_metrics_server) - - expect(MetricsServer).to receive(:fork).with( - 'sidekiq', metrics_dir: metrics_dir, wipe_metrics_dir: false, reset_signals: trapped_signals - ) - - cli.start_loop - end - end - end - end - - describe '#write_pid' do - context 'when a PID is specified' do - it 'writes the PID to a file' do - expect(Gitlab::ProcessManagement).to receive(:write_pid).with('/dev/null') - - cli.option_parser.parse!(%w(-P /dev/null)) - cli.write_pid - end end - context 'when no PID is specified' do - it 'does not write a PID' do - expect(Gitlab::ProcessManagement).not_to receive(:write_pid) + context 'supervising the cluster' do + let(:sidekiq_exporter_enabled) { true } + let(:sidekiq_health_checks_port) { '3907' } + let(:metrics_server_pid) { 99 } + let(:sidekiq_worker_pids) { [2, 42] } - cli.write_pid - end - end - end - - describe '#wait_for_termination' do - it 'waits for termination of all sub-processes and succeeds after 3 checks' do - expect(Gitlab::ProcessManagement).to receive(:any_alive?) - .with(an_instance_of(Array)).and_return(true, true, true, false) - - expect(Gitlab::ProcessManagement).to receive(:pids_alive) - .with([]).and_return([]) - - expect(Gitlab::ProcessManagement).to receive(:signal_processes) - .with([], "-KILL") - - stub_const("Gitlab::SidekiqCluster::CHECK_TERMINATE_INTERVAL_SECONDS", 0.1) - allow(cli).to receive(:terminate_timeout_seconds) { 1 } - - cli.wait_for_termination - end - - context 'with hanging workers' do before do - expect(cli).to receive(:write_pid) - expect(cli).to receive(:trap_signals) - expect(cli).to receive(:start_loop) + allow(Gitlab::SidekiqCluster).to receive(:start).and_return(sidekiq_worker_pids) + allow(Gitlab::ProcessManagement).to receive(:write_pid) end - it 'hard kills workers after timeout expires' do - worker_pids = [101, 102, 103] - expect(Gitlab::SidekiqCluster).to receive(:start) - .with([['foo']], default_options) - .and_return(worker_pids) + it 'stops the entire process cluster if one of the workers has been terminated' do + allow_next_instance_of(Gitlab::ProcessSupervisor) do |it| + allow(it).to receive(:supervise).and_yield([2]) + end - expect(Gitlab::ProcessManagement).to receive(:any_alive?) - .with(worker_pids).and_return(true).at_least(10).times - - expect(Gitlab::ProcessManagement).to receive(:pids_alive) - .with(worker_pids).and_return([102]) - - expect(Gitlab::ProcessManagement).to receive(:signal_processes) - .with([102], "-KILL") + expect(MetricsServer).to receive(:fork).once.and_return(metrics_server_pid) + expect(Gitlab::ProcessManagement).to receive(:signal_processes).with([42, 99], :TERM) cli.run(%w(foo)) - - stub_const("Gitlab::SidekiqCluster::CHECK_TERMINATE_INTERVAL_SECONDS", 0.1) - allow(cli).to receive(:terminate_timeout_seconds) { 1 } - - cli.wait_for_termination end - end - end - describe '#trap_signals' do - it 'traps termination and sidekiq specific signals' do - expect(Gitlab::ProcessManagement).to receive(:trap_signals).with(%i[INT TERM]) - expect(Gitlab::ProcessManagement).to receive(:trap_signals).with(%i[TTIN USR1 USR2 HUP]) + context 'when the supervisor is alive' do + it 'restarts the metrics server when it is down' do + allow_next_instance_of(Gitlab::ProcessSupervisor) do |it| + allow(it).to receive(:alive).and_return(true) + allow(it).to receive(:supervise).and_yield([metrics_server_pid]) + end - cli.trap_signals - end - end + expect(MetricsServer).to receive(:fork).twice.and_return(metrics_server_pid) - describe '#start_loop' do - it 'runs until one of the processes has been terminated' do - allow(cli).to receive(:sleep).with(a_kind_of(Numeric)) + cli.run(%w(foo)) + end + end - expect(Gitlab::ProcessManagement).to receive(:all_alive?) - .with(an_instance_of(Array)).and_return(false) + context 'when the supervisor is shutting down' do + it 'does not restart the metrics server' do + allow_next_instance_of(Gitlab::ProcessSupervisor) do |it| + allow(it).to receive(:alive).and_return(false) + allow(it).to receive(:supervise).and_yield([metrics_server_pid]) + end - expect(Gitlab::ProcessManagement).to receive(:signal_processes) - .with(an_instance_of(Array), :TERM) + expect(MetricsServer).to receive(:fork).once.and_return(metrics_server_pid) - cli.start_loop + cli.run(%w(foo)) + end + end end end end diff --git a/spec/controllers/projects/forks_controller_spec.rb b/spec/controllers/projects/forks_controller_spec.rb index 0f8f3b49e02..962ef93dc72 100644 --- a/spec/controllers/projects/forks_controller_spec.rb +++ b/spec/controllers/projects/forks_controller_spec.rb @@ -199,15 +199,6 @@ RSpec.describe Projects::ForksController do expect(json_response['namespaces'][1]['id']).to eq(group.id) end - it 'responds with group only when fork_project_form feature flag is disabled' do - stub_feature_flags(fork_project_form: false) - do_request - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['namespaces'].length).to eq(1) - expect(json_response['namespaces'][0]['id']).to eq(group.id) - end - context 'N+1 queries' do before do create(:fork_network, root_project: project) diff --git a/spec/features/projects/fork_spec.rb b/spec/features/projects/fork_spec.rb index f9a6b67e469..fb27f0961b6 100644 --- a/spec/features/projects/fork_spec.rb +++ b/spec/features/projects/fork_spec.rb @@ -164,199 +164,4 @@ RSpec.describe 'Project fork' do end end end - - context 'with fork_project_form feature flag disabled' do - before do - stub_feature_flags(fork_project_form: false) - sign_in(user) - end - - it_behaves_like 'fork button on project page' - - context 'user has exceeded personal project limit' do - before do - user.update!(projects_limit: 0) - end - - context 'with a group to fork to' do - let!(:group) { create(:group).tap { |group| group.add_owner(user) } } - - it 'allows user to fork only to the group on fork page', :js do - visit new_project_fork_path(project) - - to_personal_namespace = find('[data-qa-selector=fork_namespace_button].disabled') # rubocop:disable QA/SelectorUsage - to_group = find(".fork-groups button[data-qa-name=#{group.name}]") # rubocop:disable QA/SelectorUsage - - expect(to_personal_namespace).not_to be_nil - expect(to_group).not_to be_disabled - end - end - end - - it_behaves_like 'create fork page', ' Select a namespace to fork the project ' - - it 'forks the project', :sidekiq_might_not_need_inline do - visit project_path(project) - - click_link 'Fork' - - page.within '.fork-thumbnail-container' do - click_link 'Select' - end - - expect(page).to have_content 'Forked from' - - visit project_path(project) - - expect(page).to have_content(/new merge request/i) - - page.within '.nav-sidebar' do - first(:link, 'Merge requests').click - end - - expect(page).to have_content(/new merge request/i) - - page.within '#content-body' do - click_link('New merge request') - end - - expect(current_path).to have_content(/#{user.namespace.path}/i) - end - - it 'shows avatars when Gravatar is disabled' do - stub_application_setting(gravatar_enabled: false) - - visit project_path(project) - - click_link 'Fork' - - page.within('.fork-thumbnail-container') do - expect(page).to have_css('span.identicon') - end - end - - it 'shows the forked project on the list' do - visit project_path(project) - - click_link 'Fork' - - page.within '.fork-thumbnail-container' do - click_link 'Select' - end - - visit project_forks_path(project) - - forked_project = user.fork_of(project.reload) - - page.within('.js-projects-list-holder') do - expect(page).to have_content("#{forked_project.namespace.human_name} / #{forked_project.name}") - end - - forked_project.update!(path: 'test-crappy-path') - - visit project_forks_path(project) - - page.within('.js-projects-list-holder') do - expect(page).to have_content("#{forked_project.namespace.human_name} / #{forked_project.name}") - end - end - - context 'when the project is private' do - let(:project) { create(:project, :repository) } - let(:another_user) { create(:user, name: 'Mike') } - - before do - project.add_reporter(user) - project.add_reporter(another_user) - end - - it 'renders private forks of the project' do - visit project_path(project) - - another_project_fork = Projects::ForkService.new(project, another_user).execute - - click_link 'Fork' - - page.within '.fork-thumbnail-container' do - click_link 'Select' - end - - visit project_forks_path(project) - - page.within('.js-projects-list-holder') do - user_project_fork = user.fork_of(project.reload) - expect(page).to have_content("#{user_project_fork.namespace.human_name} / #{user_project_fork.name}") - end - - expect(page).not_to have_content("#{another_project_fork.namespace.human_name} / #{another_project_fork.name}") - end - end - - context 'when the user already forked the project' do - before do - create(:project, :repository, name: project.name, namespace: user.namespace) - end - - it 'renders error' do - visit project_path(project) - - click_link 'Fork' - - page.within '.fork-thumbnail-container' do - click_link 'Select' - end - - expect(page).to have_content "Name has already been taken" - end - end - - context 'maintainer in group' do - let(:group) { create(:group) } - - before do - group.add_maintainer(user) - end - - it 'allows user to fork project to group or to user namespace', :js do - visit project_path(project) - wait_for_requests - - expect(page).not_to have_css('a.disabled', text: 'Fork') - - click_link 'Fork' - - expect(page).to have_css('.fork-thumbnail') - expect(page).to have_css('.group-row') - expect(page).not_to have_css('.fork-thumbnail.disabled') - end - - it 'allows user to fork project to group and not user when exceeded project limit', :js do - user.projects_limit = 0 - user.save! - - visit project_path(project) - wait_for_requests - - expect(page).not_to have_css('a.disabled', text: 'Fork') - - click_link 'Fork' - - expect(page).to have_css('.fork-thumbnail.disabled') - expect(page).to have_css('.group-row') - end - - it 'links to the fork if the project was already forked within that namespace', :sidekiq_might_not_need_inline, :js do - forked_project = fork_project(project, user, namespace: group, repository: true) - - visit new_project_fork_path(project) - wait_for_requests - - expect(page).to have_css('.group-row a.btn', text: 'Go to fork') - - click_link 'Go to fork' - - expect(current_path).to eq(project_path(forked_project)) - end - end - end end diff --git a/spec/frontend/pages/projects/forks/new/components/app_spec.js b/spec/frontend/pages/projects/forks/new/components/app_spec.js index a7b4b9c42bd..0342b94a44d 100644 --- a/spec/frontend/pages/projects/forks/new/components/app_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/app_spec.js @@ -1,19 +1,12 @@ import { shallowMount } from '@vue/test-utils'; import App from '~/pages/projects/forks/new/components/app.vue'; +import ForkForm from '~/pages/projects/forks/new/components/fork_form.vue'; describe('App component', () => { let wrapper; const DEFAULT_PROPS = { forkIllustration: 'illustrations/project-create-new-sm.svg', - endpoint: '/some/project-full-path/-/forks/new.json', - projectFullPath: '/some/project-full-path', - projectId: '10', - projectName: 'Project Name', - projectPath: 'project-name', - projectDescription: 'some project description', - projectVisibility: 'private', - restrictedVisibilityLevels: [], }; const createComponent = (props = {}) => { @@ -37,7 +30,7 @@ describe('App component', () => { expect(wrapper.find('img').attributes('src')).toBe('illustrations/project-create-new-sm.svg'); }); - it('renders ForkForm component with prop', () => { - expect(wrapper.props()).toEqual(expect.objectContaining(DEFAULT_PROPS)); + it('renders ForkForm component', () => { + expect(wrapper.findComponent(ForkForm).exists()).toBe(true); }); }); diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js index dc5f1cb9e61..efbfd83a071 100644 --- a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js @@ -40,7 +40,9 @@ describe('ForkForm component', () => { }, ]; - const DEFAULT_PROPS = { + const DEFAULT_PROVIDE = { + newGroupPath: 'some/groups/path', + visibilityHelpPath: 'some/visibility/help/path', endpoint: '/some/project-full-path/-/forks/new.json', projectFullPath: '/some/project-full-path', projectId: '10', @@ -52,18 +54,14 @@ describe('ForkForm component', () => { }; const mockGetRequest = (data = {}, statusCode = httpStatus.OK) => { - axiosMock.onGet(DEFAULT_PROPS.endpoint).replyOnce(statusCode, data); + axiosMock.onGet(DEFAULT_PROVIDE.endpoint).replyOnce(statusCode, data); }; - const createComponentFactory = (mountFn) => (props = {}, data = {}) => { + const createComponentFactory = (mountFn) => (provide = {}, data = {}) => { wrapper = mountFn(ForkForm, { provide: { - newGroupPath: 'some/groups/path', - visibilityHelpPath: 'some/visibility/help/path', - }, - propsData: { - ...DEFAULT_PROPS, - ...props, + ...DEFAULT_PROVIDE, + ...provide, }, data() { return { @@ -111,7 +109,7 @@ describe('ForkForm component', () => { mockGetRequest(); createComponent(); - const { projectFullPath } = DEFAULT_PROPS; + const { projectFullPath } = DEFAULT_PROVIDE; const cancelButton = wrapper.find('[data-testid="cancel-button"]'); expect(cancelButton.attributes('href')).toBe(projectFullPath); @@ -130,10 +128,10 @@ describe('ForkForm component', () => { mockGetRequest(); createComponent(); - expect(findForkNameInput().attributes('value')).toBe(DEFAULT_PROPS.projectName); - expect(findForkSlugInput().attributes('value')).toBe(DEFAULT_PROPS.projectPath); + expect(findForkNameInput().attributes('value')).toBe(DEFAULT_PROVIDE.projectName); + expect(findForkSlugInput().attributes('value')).toBe(DEFAULT_PROVIDE.projectPath); expect(findForkDescriptionTextarea().attributes('value')).toBe( - DEFAULT_PROPS.projectDescription, + DEFAULT_PROVIDE.projectDescription, ); }); @@ -164,7 +162,7 @@ describe('ForkForm component', () => { it('make GET request from endpoint', async () => { await axios.waitForAll(); - expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROPS.endpoint); + expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROVIDE.endpoint); }); it('generate default option', async () => { @@ -469,7 +467,7 @@ describe('ForkForm component', () => { projectName, projectPath, projectVisibility, - } = DEFAULT_PROPS; + } = DEFAULT_PROVIDE; const url = `/api/${GON_API_VERSION}/projects/${projectId}/fork`; const project = { diff --git a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_item_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_groups_list_item_spec.js deleted file mode 100644 index 490dafed4ae..00000000000 --- a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_item_spec.js +++ /dev/null @@ -1,73 +0,0 @@ -import { GlBadge, GlButton, GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import ForkGroupsListItem from '~/pages/projects/forks/new/components/fork_groups_list_item.vue'; - -describe('Fork groups list item component', () => { - let wrapper; - - const DEFAULT_GROUP_DATA = { - id: 22, - name: 'Gitlab Org', - description: 'Ad et ipsam earum id aut nobis.', - visibility: 'public', - full_name: 'Gitlab Org', - created_at: '2020-06-22T03:32:05.664Z', - updated_at: '2020-06-22T03:32:05.664Z', - avatar_url: null, - fork_path: '/twitter/typeahead-js/-/forks?namespace_key=22', - forked_project_path: null, - permission: 'Owner', - relative_path: '/gitlab-org', - markdown_description: - '

Ad et ipsam earum id aut nobis.

', - can_create_project: true, - marked_for_deletion: false, - }; - - const DUMMY_PATH = '/dummy/path'; - - const createWrapper = (propsData) => { - wrapper = shallowMount(ForkGroupsListItem, { - propsData: { - ...propsData, - }, - }); - }; - - it('renders pending deletion badge if applicable', () => { - createWrapper({ group: { ...DEFAULT_GROUP_DATA, marked_for_deletion: true } }); - - expect(wrapper.find(GlBadge).text()).toBe('pending deletion'); - }); - - it('renders go to fork button if has forked project', () => { - createWrapper({ group: { ...DEFAULT_GROUP_DATA, forked_project_path: DUMMY_PATH } }); - - expect(wrapper.find(GlButton).text()).toBe('Go to fork'); - expect(wrapper.find(GlButton).attributes().href).toBe(DUMMY_PATH); - }); - - it('renders select button if has no forked project', () => { - createWrapper({ - group: { ...DEFAULT_GROUP_DATA, forked_project_path: null, fork_path: DUMMY_PATH }, - }); - - expect(wrapper.find(GlButton).text()).toBe('Select'); - expect(wrapper.find('form').attributes().action).toBe(DUMMY_PATH); - }); - - it('renders link to current group', () => { - const DUMMY_FULL_NAME = 'dummy'; - createWrapper({ - group: { ...DEFAULT_GROUP_DATA, relative_path: DUMMY_PATH, full_name: DUMMY_FULL_NAME }, - }); - - expect( - wrapper - .findAll(GlLink) - .filter((w) => w.text() === DUMMY_FULL_NAME) - .at(0) - .attributes().href, - ).toBe(DUMMY_PATH); - }); -}); diff --git a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js deleted file mode 100644 index 9f8dbf3d542..00000000000 --- a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js +++ /dev/null @@ -1,123 +0,0 @@ -import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import AxiosMockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; -import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import ForkGroupsList from '~/pages/projects/forks/new/components/fork_groups_list.vue'; -import ForkGroupsListItem from '~/pages/projects/forks/new/components/fork_groups_list_item.vue'; - -jest.mock('~/flash'); - -describe('Fork groups list component', () => { - let wrapper; - let axiosMock; - - const DEFAULT_PROPS = { - endpoint: '/dummy', - }; - - const replyWith = (...args) => axiosMock.onGet(DEFAULT_PROPS.endpoint).reply(...args); - - const createWrapper = (propsData) => { - wrapper = shallowMount(ForkGroupsList, { - propsData: { - ...DEFAULT_PROPS, - ...propsData, - }, - stubs: { - GlTabs: { - template: '
', - }, - }, - }); - }; - - beforeEach(() => { - axiosMock = new AxiosMockAdapter(axios); - }); - - afterEach(() => { - axiosMock.reset(); - - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } - }); - - it('fires load groups request on mount', async () => { - replyWith(200, { namespaces: [] }); - createWrapper(); - - await waitForPromises(); - - expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROPS.endpoint); - }); - - it('displays flash if loading groups fails', async () => { - replyWith(500); - createWrapper(); - - await waitForPromises(); - - expect(createFlash).toHaveBeenCalled(); - }); - - it('displays loading indicator while loading groups', () => { - replyWith(() => new Promise(() => {})); - createWrapper(); - - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); - }); - - it('displays empty text if no groups are available', async () => { - const EMPTY_TEXT = 'No available groups to fork the project.'; - replyWith(200, { namespaces: [] }); - createWrapper(); - - await waitForPromises(); - - expect(wrapper.text()).toContain(EMPTY_TEXT); - }); - - it('displays filter field when groups are available', async () => { - replyWith(200, { namespaces: [{ name: 'dummy1' }, { name: 'dummy2' }] }); - createWrapper(); - - await waitForPromises(); - - expect(wrapper.find(GlSearchBoxByType).exists()).toBe(true); - }); - - it('renders list items for each available group', async () => { - const namespaces = [{ name: 'dummy1' }, { name: 'dummy2' }, { name: 'otherdummy' }]; - - replyWith(200, { namespaces }); - createWrapper(); - - await waitForPromises(); - - expect(wrapper.findAll(ForkGroupsListItem)).toHaveLength(namespaces.length); - - namespaces.forEach((namespace, idx) => { - expect(wrapper.findAll(ForkGroupsListItem).at(idx).props()).toStrictEqual({ - group: namespace, - }); - }); - }); - - it('filters repositories on the fly', async () => { - replyWith(200, { - namespaces: [{ name: 'dummy1' }, { name: 'dummy2' }, { name: 'otherdummy' }], - }); - createWrapper(); - await waitForPromises(); - wrapper.find(GlSearchBoxByType).vm.$emit('input', 'other'); - await nextTick(); - - expect(wrapper.findAll(ForkGroupsListItem)).toHaveLength(1); - expect(wrapper.findAll(ForkGroupsListItem).at(0).props().group.name).toBe('otherdummy'); - }); -}); diff --git a/spec/lib/atlassian/jira_connect_spec.rb b/spec/lib/atlassian/jira_connect_spec.rb new file mode 100644 index 00000000000..d9c34e938b4 --- /dev/null +++ b/spec/lib/atlassian/jira_connect_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Atlassian::JiraConnect do + describe '.app_name' do + subject { described_class.app_name } + + it { is_expected.to eq('GitLab for Jira (localhost)') } + end + + describe '.app_key' do + subject(:app_key) { described_class.app_key } + + it { is_expected.to eq('gitlab-jira-connect-localhost') } + + context 'host name is too long' do + before do + hostname = 'x' * 100 + + stub_config(gitlab: { host: hostname }) + end + + it 'truncates the key to be no longer than 64 characters', :aggregate_failures do + expect(app_key).to eq('gitlab-jira-connect-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx') + end + end + end +end diff --git a/spec/lib/gitlab/process_supervisor_spec.rb b/spec/lib/gitlab/process_supervisor_spec.rb new file mode 100644 index 00000000000..d264c77d5fb --- /dev/null +++ b/spec/lib/gitlab/process_supervisor_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require_relative '../../../lib/gitlab/process_supervisor' + +RSpec.describe Gitlab::ProcessSupervisor do + let(:health_check_interval_seconds) { 0.1 } + let(:check_terminate_interval_seconds) { 1 } + let(:forwarded_signals) { [] } + let(:process_id) do + Process.spawn('while true; do sleep 1; done').tap do |pid| + Process.detach(pid) + end + end + + subject(:supervisor) do + described_class.new( + health_check_interval_seconds: health_check_interval_seconds, + check_terminate_interval_seconds: check_terminate_interval_seconds, + terminate_timeout_seconds: 1 + check_terminate_interval_seconds, + forwarded_signals: forwarded_signals + ) + end + + after do + if Gitlab::ProcessManagement.process_alive?(process_id) + Process.kill('KILL', process_id) + end + end + + describe '#supervise' do + context 'while supervised process is alive' do + it 'does not invoke callback' do + expect(Gitlab::ProcessManagement.process_alive?(process_id)).to be(true) + pids_killed = [] + + thread = Thread.new do + supervisor.supervise(process_id) do |dead_pids| + pids_killed = dead_pids + [] + end + end + + # Wait several times the poll frequency of the supervisor. + sleep health_check_interval_seconds * 10 + thread.terminate + + expect(pids_killed).to be_empty + expect(Gitlab::ProcessManagement.process_alive?(process_id)).to be(true) + end + end + + context 'when supervised process dies' do + it 'triggers callback with the dead PIDs' do + expect(Gitlab::ProcessManagement.process_alive?(process_id)).to be(true) + pids_killed = [] + + thread = Thread.new do + supervisor.supervise(process_id) do |dead_pids| + pids_killed = dead_pids + [] + end + end + + # Terminate the supervised process. + Process.kill('TERM', process_id) + + await_condition(sleep_sec: health_check_interval_seconds) do + pids_killed == [process_id] + end + thread.terminate + + expect(Gitlab::ProcessManagement.process_alive?(process_id)).to be(false) + end + end + + context 'signal handling' do + before do + allow(supervisor).to receive(:sleep) + allow(Gitlab::ProcessManagement).to receive(:trap_signals) + allow(Gitlab::ProcessManagement).to receive(:all_alive?).and_return(false) + allow(Gitlab::ProcessManagement).to receive(:signal_processes).with([process_id], anything) + end + + context 'termination signals' do + context 'when TERM results in timely shutdown of processes' do + it 'forwards them to observed processes without waiting for grace period to expire' do + allow(Gitlab::ProcessManagement).to receive(:any_alive?).and_return(false) + + expect(Gitlab::ProcessManagement).to receive(:trap_signals).ordered.with(%i(INT TERM)).and_yield(:TERM) + expect(Gitlab::ProcessManagement).to receive(:signal_processes).ordered.with([process_id], :TERM) + expect(supervisor).not_to receive(:sleep).with(check_terminate_interval_seconds) + + supervisor.supervise(process_id) { [] } + end + end + + context 'when TERM does not result in timely shutdown of processes' do + it 'issues a KILL signal after the grace period expires' do + expect(Gitlab::ProcessManagement).to receive(:trap_signals).with(%i(INT TERM)).and_yield(:TERM) + expect(Gitlab::ProcessManagement).to receive(:signal_processes).ordered.with([process_id], :TERM) + expect(supervisor).to receive(:sleep).ordered.with(check_terminate_interval_seconds).at_least(:once) + expect(Gitlab::ProcessManagement).to receive(:signal_processes).ordered.with([process_id], '-KILL') + + supervisor.supervise(process_id) { [] } + end + end + end + + context 'forwarded signals' do + let(:forwarded_signals) { %i(USR1) } + + it 'forwards given signals to the observed processes' do + expect(Gitlab::ProcessManagement).to receive(:trap_signals).with(%i(USR1)).and_yield(:USR1) + expect(Gitlab::ProcessManagement).to receive(:signal_processes).ordered.with([process_id], :USR1) + + supervisor.supervise(process_id) { [] } + end + end + end + end + + def await_condition(timeout_sec: 5, sleep_sec: 0.1) + Timeout.timeout(timeout_sec) do + sleep sleep_sec until yield + 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 5e74ea3293c..f07a9a494c0 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 @@ -50,7 +50,8 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s 'importer', 'network_policies', 'geo', - 'growth' + 'growth', + 'work_items' ) end end diff --git a/spec/lib/gitlab/usage_data_counters/work_item_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/work_item_activity_unique_counter_spec.rb new file mode 100644 index 00000000000..abd5d29d7e6 --- /dev/null +++ b/spec/lib/gitlab/usage_data_counters/work_item_activity_unique_counter_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter, :clean_gitlab_redis_shared_state do + let(:user) { build(:user, id: 1) } + + shared_examples 'counter that does not track the event' do + it 'does not track the event' do + expect { 3.times { track_event } }.to not_change { + Gitlab::UsageDataCounters::HLLRedisCounter.unique_events( + event_names: event_name, + start_date: 2.weeks.ago, + end_date: 2.weeks.from_now + ) + } + end + end + + describe '.track_work_item_title_changed_action' do + subject(:track_event) { described_class.track_work_item_title_changed_action(author: user) } + + let(:event_name) { described_class::WORK_ITEM_TITLE_CHANGED } + + context 'when track_work_items_activity FF is enabled' do + it 'tracks a unique event only once' do + expect { 3.times { track_event } }.to change { + Gitlab::UsageDataCounters::HLLRedisCounter.unique_events( + event_names: described_class::WORK_ITEM_TITLE_CHANGED, + start_date: 2.weeks.ago, + end_date: 2.weeks.from_now + ) + }.by(1) + end + + context 'when author is nil' do + let(:user) { nil } + + it_behaves_like 'counter that does not track the event' + end + end + + context 'when track_work_items_activity FF is disabled' do + before do + stub_feature_flags(track_work_items_activity: false) + end + + it_behaves_like 'counter that does not track the event' + end + end +end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index f2f2023a992..d5cbe1b16e6 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -4249,6 +4249,29 @@ RSpec.describe MergeRequest, factory_default: :keep do end end + describe '#eager_fetch_ref!' do + let(:project) { create(:project, :repository) } + + # We use build instead of create to test that an IID is allocated + subject { build(:merge_request, source_project: project) } + + it 'fetches the ref correctly' do + expect(subject.iid).to be_nil + + expect { subject.eager_fetch_ref! }.to change { subject.iid.to_i }.by(1) + + expect(subject.target_project.repository.ref_exists?(subject.ref_path)).to be_truthy + end + + it 'only fetches the ref once after saved' do + expect(subject.target_project.repository).to receive(:fetch_source_branch!).once.and_call_original + + subject.save! + + expect(subject.target_project.repository.ref_exists?(subject.ref_path)).to be_truthy + end + end + describe 'removing a merge request' do it 'refreshes the number of open merge requests of the target project' do project = subject.target_project diff --git a/spec/serializers/fork_namespace_entity_spec.rb b/spec/serializers/fork_namespace_entity_spec.rb index 32223b0d41a..91c59c4bda8 100644 --- a/spec/serializers/fork_namespace_entity_spec.rb +++ b/spec/serializers/fork_namespace_entity_spec.rb @@ -59,26 +59,4 @@ RSpec.describe ForkNamespaceEntity do it 'exposes human readable permission level' do expect(json[:permission]).to eql 'Developer' end - - it 'exposes can_create_project' do - expect(json[:can_create_project]).to be true - end - - context 'when fork_project_form feature flag is disabled' do - before do - stub_feature_flags(fork_project_form: false) - end - - it 'sets can_create_project to true when user can create projects in namespace' do - allow(user).to receive(:can?).with(:create_projects, namespace).and_return(true) - - expect(json[:can_create_project]).to be true - end - - it 'sets can_create_project to false when user is not allowed create projects in namespace' do - allow(user).to receive(:can?).with(:create_projects, namespace).and_return(false) - - expect(json[:can_create_project]).to be false - end - end end diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index a196c944eda..ecdc92a1b6f 100644 --- a/spec/services/merge_requests/create_service_spec.rb +++ b/spec/services/merge_requests/create_service_spec.rb @@ -454,7 +454,7 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do end end - context 'when source and target projects are different' do + shared_examples 'when source and target projects are different' do |eager_fetch_ref_enabled| let(:target_project) { fork_project(project, nil, repository: true) } let(:opts) do @@ -497,9 +497,18 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do end it 'creates the merge request', :sidekiq_might_not_need_inline do + expect_next_instance_of(MergeRequest) do |instance| + if eager_fetch_ref_enabled + expect(instance).to receive(:eager_fetch_ref!).and_call_original + else + expect(instance).not_to receive(:eager_fetch_ref!) + end + end + merge_request = described_class.new(project: project, current_user: user, params: opts).execute expect(merge_request).to be_persisted + expect(merge_request.iid).to be > 0 end it 'does not create the merge request when the target project is archived' do @@ -511,6 +520,18 @@ RSpec.describe MergeRequests::CreateService, :clean_gitlab_redis_shared_state do end end + context 'when merge_request_eager_fetch_ref is enabled' do + it_behaves_like 'when source and target projects are different', true + end + + context 'when merge_request_eager_fetch_ref is disabled' do + before do + stub_feature_flags(merge_request_eager_fetch_ref: false) + end + + it_behaves_like 'when source and target projects are different', false + end + context 'when user sets source project id' do let(:another_project) { create(:project) } diff --git a/spec/services/work_items/update_service_spec.rb b/spec/services/work_items/update_service_spec.rb index f71f1060e40..b2d3f428899 100644 --- a/spec/services/work_items/update_service_spec.rb +++ b/spec/services/work_items/update_service_spec.rb @@ -23,6 +23,9 @@ RSpec.describe WorkItems::UpdateService do it 'triggers issuable_title_updated graphql subscription' do expect(GraphqlTriggers).to receive(:issuable_title_updated).with(work_item).and_call_original + expect(Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter).to receive(:track_work_item_title_changed_action).with(author: current_user) + # During the work item transition we also want to track work items as issues + expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).to receive(:track_issue_title_changed_action) update_work_item end @@ -33,6 +36,7 @@ RSpec.describe WorkItems::UpdateService do it 'does not trigger issuable_title_updated graphql subscription' do expect(GraphqlTriggers).not_to receive(:issuable_title_updated) + expect(Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter).not_to receive(:track_work_item_title_changed_action) update_work_item end diff --git a/workhorse/.tool-versions b/workhorse/.tool-versions index 29cc9a03144..108bdd0f6a5 100644 --- a/workhorse/.tool-versions +++ b/workhorse/.tool-versions @@ -1 +1 @@ -golang 1.17.6 +golang 1.17.7