From a0295e812f26fa19509b3a8e7866cd89928f828d Mon Sep 17 00:00:00 2001 From: Patrick Bajao <ebajao@gitlab.com> Date: Fri, 5 Aug 2022 12:36:38 +0800 Subject: [PATCH] Implement mergeRequestSetReviewers mutation Adds `mergeRequestSetReviewers` GraphQL mutation that will be used by the frontend to set reviewers. This has the same pattern of arguments and fields as the one for assignees. Changelog: added --- .../mutations/merge_requests/set_reviewers.rb | 52 ++++++ app/graphql/types/mutation_type.rb | 1 + .../update_assignees_service.rb | 23 +-- .../update_reviewers_service.rb | 44 +++++ app/services/merge_requests/update_service.rb | 36 +++- doc/api/graphql/reference/index.md | 22 +++ .../update_assignees_service.rb | 2 +- .../update_reviewers_service.rb | 18 ++ .../merge_requests/set_reviewers_spec.rb | 83 +++++++++ .../update_reviewers_service_spec.rb | 59 +++++++ .../merge_requests/set_reviewers_spec.rb | 140 +++++++++++++++ .../merge_requests/set_reviewers_spec.rb | 106 ++++++++++++ .../update_reviewers_service_spec.rb | 162 ++++++++++++++++++ 13 files changed, 721 insertions(+), 27 deletions(-) create mode 100644 app/graphql/mutations/merge_requests/set_reviewers.rb create mode 100644 app/services/merge_requests/update_reviewers_service.rb create mode 100644 ee/app/services/ee/merge_requests/update_reviewers_service.rb create mode 100644 ee/spec/graphql/mutations/merge_requests/set_reviewers_spec.rb create mode 100644 ee/spec/services/ee/merge_requests/update_reviewers_service_spec.rb create mode 100644 spec/graphql/mutations/merge_requests/set_reviewers_spec.rb create mode 100644 spec/requests/api/graphql/mutations/merge_requests/set_reviewers_spec.rb create mode 100644 spec/services/merge_requests/update_reviewers_service_spec.rb diff --git a/app/graphql/mutations/merge_requests/set_reviewers.rb b/app/graphql/mutations/merge_requests/set_reviewers.rb new file mode 100644 index 0000000000000..8d3f860159781 --- /dev/null +++ b/app/graphql/mutations/merge_requests/set_reviewers.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class SetReviewers < Base + graphql_name 'MergeRequestSetReviewers' + + argument :reviewer_usernames, + [GraphQL::Types::String], + required: true, + description: 'Usernames of reviewers to assign. Replaces existing reviewers by default.' + + argument :operation_mode, + Types::MutationOperationModeEnum, + required: false, + default_value: Types::MutationOperationModeEnum.default_mode, + description: 'Operation to perform. Defaults to REPLACE.' + + def resolve(project_path:, iid:, reviewer_usernames:, operation_mode:) + resource = authorized_find!(project_path: project_path, iid: iid) + + ::MergeRequests::UpdateReviewersService.new( + project: resource.project, + current_user: current_user, + params: { reviewer_ids: reviewer_ids(resource, reviewer_usernames, operation_mode) } + ).execute(resource) + + { + resource.class.name.underscore.to_sym => resource, + errors: errors_on_object(resource) + } + end + + private + + def reviewer_ids(resource, usernames, mode) + new_reviewers = UsersFinder.new(current_user, username: usernames).execute.to_a + new_reviewer_ids = user_ids(new_reviewers) + + case mode + when 'REPLACE' then new_reviewer_ids + when 'APPEND' then user_ids(resource.reviewers) | new_reviewer_ids + when 'REMOVE' then user_ids(resource.reviewers) - new_reviewer_ids + end + end + + def user_ids(users) + users.map(&:id) + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 47462b5e9f7a5..499c2e786bf4c 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -72,6 +72,7 @@ class MutationType < BaseObject mount_mutation Mutations::MergeRequests::SetSubscription mount_mutation Mutations::MergeRequests::SetDraft, calls_gitaly: true mount_mutation Mutations::MergeRequests::SetAssignees + mount_mutation Mutations::MergeRequests::SetReviewers mount_mutation Mutations::MergeRequests::ReviewerRereview mount_mutation Mutations::Metrics::Dashboard::Annotations::Create mount_mutation Mutations::Metrics::Dashboard::Annotations::Delete diff --git a/app/services/merge_requests/update_assignees_service.rb b/app/services/merge_requests/update_assignees_service.rb index 5b23f69ac4a15..a6b0235c525a6 100644 --- a/app/services/merge_requests/update_assignees_service.rb +++ b/app/services/merge_requests/update_assignees_service.rb @@ -11,7 +11,7 @@ def execute(merge_request) old_assignees = merge_request.assignees.to_a old_ids = old_assignees.map(&:id) - new_ids = new_assignee_ids(merge_request) + new_ids = new_user_ids(merge_request, update_attrs[:assignee_ids], :assignees) return merge_request if merge_request.errors.any? return merge_request if new_ids.size != update_attrs[:assignee_ids].size @@ -32,27 +32,8 @@ def execute(merge_request) private - def new_assignee_ids(merge_request) - # prime the cache - prevent N+1 lookup during authorization loop. - user_ids = update_attrs[:assignee_ids] - return [] if user_ids.empty? - - merge_request.project.team.max_member_access_for_user_ids(user_ids) - User.id_in(user_ids).map do |user| - if user.can?(:read_merge_request, merge_request) - user.id - else - merge_request.errors.add( - :assignees, - "Cannot assign #{user.to_reference} to #{merge_request.to_reference}" - ) - nil - end - end.compact - end - def assignee_ids - params.fetch(:assignee_ids).reject { _1 == 0 }.first(1) + filter_sentinel_values(params.fetch(:assignee_ids)).first(1) end def params diff --git a/app/services/merge_requests/update_reviewers_service.rb b/app/services/merge_requests/update_reviewers_service.rb new file mode 100644 index 0000000000000..8e974d756762f --- /dev/null +++ b/app/services/merge_requests/update_reviewers_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module MergeRequests + class UpdateReviewersService < UpdateService + def execute(merge_request) + return merge_request unless current_user&.can?(:update_merge_request, merge_request) + + old_reviewers = merge_request.reviewers.to_a + old_ids = old_reviewers.map(&:id) + new_ids = new_user_ids(merge_request, update_attrs[:reviewer_ids], :reviewers) + + return merge_request if merge_request.errors.any? + return merge_request if new_ids.size != update_attrs[:reviewer_ids].size + return merge_request if old_ids.to_set == new_ids.to_set # no-change + + merge_request.update!(update_attrs.merge(reviewer_ids: new_ids)) + handle_reviewers_change(merge_request, old_reviewers) + resolve_todos_for(merge_request) + execute_reviewers_hooks(merge_request, old_reviewers) + + merge_request + end + + private + + def reviewer_ids + filter_sentinel_values(params.fetch(:reviewer_ids)).first(1) + end + + def update_attrs + @attrs ||= { updated_by: current_user, reviewer_ids: reviewer_ids } + end + + def execute_reviewers_hooks(merge_request, old_reviewers) + execute_hooks( + merge_request, + 'update', + old_associations: { reviewers: old_reviewers } + ) + end + end +end + +MergeRequests::UpdateReviewersService.prepend_mod diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 603da4ef535d5..0902b5195a139 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -155,11 +155,7 @@ def notify_if_mentions_added(merge_request, old_mentioned_users) def resolve_todos(merge_request, old_labels, old_assignees, old_reviewers) return unless has_changes?(merge_request, old_labels: old_labels, old_assignees: old_assignees, old_reviewers: old_reviewers) - service_user = current_user - - merge_request.run_after_commit_or_now do - ::MergeRequests::ResolveTodosService.new(merge_request, service_user).async_execute - end + resolve_todos_for(merge_request) end def handle_target_branch_change(merge_request) @@ -296,6 +292,36 @@ def assignees_service def add_time_spent_service @add_time_spent_service ||= ::MergeRequests::AddSpentTimeService.new(project: project, current_user: current_user, params: params) end + + def new_user_ids(merge_request, user_ids, attribute) + # prime the cache - prevent N+1 lookup during authorization loop. + return [] if user_ids.empty? + + merge_request.project.team.max_member_access_for_user_ids(user_ids) + User.id_in(user_ids).map do |user| + if user.can?(:read_merge_request, merge_request) + user.id + else + merge_request.errors.add( + attribute, + "Cannot assign #{user.to_reference} to #{merge_request.to_reference}" + ) + nil + end + end.compact + end + + def resolve_todos_for(merge_request) + service_user = current_user + + merge_request.run_after_commit_or_now do + ::MergeRequests::ResolveTodosService.new(merge_request, service_user).async_execute + end + end + + def filter_sentinel_values(param) + param.reject { _1 == 0 } + end end end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 570f35839e196..effb6d3e590d4 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -3762,6 +3762,28 @@ Input type: `MergeRequestSetMilestoneInput` | <a id="mutationmergerequestsetmilestoneerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationmergerequestsetmilestonemergerequest"></a>`mergeRequest` | [`MergeRequest`](#mergerequest) | Merge request after mutation. | +### `Mutation.mergeRequestSetReviewers` + +Input type: `MergeRequestSetReviewersInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationmergerequestsetreviewersclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationmergerequestsetreviewersiid"></a>`iid` | [`String!`](#string) | IID of the merge request to mutate. | +| <a id="mutationmergerequestsetreviewersoperationmode"></a>`operationMode` | [`MutationOperationMode`](#mutationoperationmode) | Operation to perform. Defaults to REPLACE. | +| <a id="mutationmergerequestsetreviewersprojectpath"></a>`projectPath` | [`ID!`](#id) | Project the merge request to mutate is in. | +| <a id="mutationmergerequestsetreviewersreviewerusernames"></a>`reviewerUsernames` | [`[String!]!`](#string) | Usernames of reviewers to assign. Replaces existing reviewers by default. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationmergerequestsetreviewersclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationmergerequestsetreviewerserrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| <a id="mutationmergerequestsetreviewersmergerequest"></a>`mergeRequest` | [`MergeRequest`](#mergerequest) | Merge request after mutation. | + ### `Mutation.mergeRequestSetSubscription` Input type: `MergeRequestSetSubscriptionInput` diff --git a/ee/app/services/ee/merge_requests/update_assignees_service.rb b/ee/app/services/ee/merge_requests/update_assignees_service.rb index 21a52394e8bdf..ba8534f485660 100644 --- a/ee/app/services/ee/merge_requests/update_assignees_service.rb +++ b/ee/app/services/ee/merge_requests/update_assignees_service.rb @@ -5,7 +5,7 @@ module MergeRequests module UpdateAssigneesService def assignee_ids if project.licensed_feature_available?(:multiple_merge_request_assignees) - params.fetch(:assignee_ids).reject { _1 == 0 } + filter_sentinel_values(params.fetch(:assignee_ids)) else super end diff --git a/ee/app/services/ee/merge_requests/update_reviewers_service.rb b/ee/app/services/ee/merge_requests/update_reviewers_service.rb new file mode 100644 index 0000000000000..530fb349f1a61 --- /dev/null +++ b/ee/app/services/ee/merge_requests/update_reviewers_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module EE + module MergeRequests + module UpdateReviewersService + extend ::Gitlab::Utils::Override + + override :reviewer_ids + def reviewer_ids + if project.licensed_feature_available?(:multiple_merge_request_reviewers) + filter_sentinel_values(params.fetch(:reviewer_ids)) + else + super + end + end + end + end +end diff --git a/ee/spec/graphql/mutations/merge_requests/set_reviewers_spec.rb b/ee/spec/graphql/mutations/merge_requests/set_reviewers_spec.rb new file mode 100644 index 0000000000000..53fc3210736a8 --- /dev/null +++ b/ee/spec/graphql/mutations/merge_requests/set_reviewers_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::MergeRequests::SetReviewers do + let_it_be(:user) { create(:user) } + let_it_be(:merge_request, reload: true) { create(:merge_request) } + + subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) } + + describe '#resolve' do + let_it_be(:reviewers) { create_list(:user, 3) } + + let(:mode) { Types::MutationOperationModeEnum.default_mode } + let(:reviewer_usernames) { reviewers.map(&:username) } + let(:mutated_merge_request) { subject[:merge_request] } + + subject do + mutation.resolve(project_path: merge_request.project.full_path, + iid: merge_request.iid, + operation_mode: mode, + reviewer_usernames: reviewer_usernames) + end + + before do + reviewers.each do |user| + merge_request.project.add_developer(user) + end + end + + context 'when the user can update the merge_request' do + before do + merge_request.project.add_developer(user) + end + + it 'sets the reviewers' do + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.reviewers).to match_array(reviewers) + expect(subject[:errors]).to be_empty + end + + it 'removes reviewers not in the list' do + users = create_list(:user, 2) + users.each do |user| + merge_request.project.add_developer(user) + end + merge_request.reviewers = users + merge_request.save! + + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.reviewers).to match_array(reviewers) + expect(subject[:errors]).to be_empty + end + + context 'when passing "append" as true' do + subject do + mutation.resolve( + project_path: merge_request.project.full_path, + iid: merge_request.iid, + reviewer_usernames: reviewer_usernames, + operation_mode: Types::MutationOperationModeEnum.enum[:append] + ) + end + + let(:existing_reviewers) { create_list(:user, 2) } + + before do + existing_reviewers.each do |user| + merge_request.project.add_developer(user) + end + merge_request.reviewers = existing_reviewers + merge_request.save! + end + + it 'does not remove reviewers not in the list' do + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.reviewers).to match_array(reviewers + existing_reviewers) + expect(subject[:errors]).to be_empty + end + end + end + end +end diff --git a/ee/spec/services/ee/merge_requests/update_reviewers_service_spec.rb b/ee/spec/services/ee/merge_requests/update_reviewers_service_spec.rb new file mode 100644 index 0000000000000..6f85a5128ddd6 --- /dev/null +++ b/ee/spec/services/ee/merge_requests/update_reviewers_service_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequests::UpdateReviewersService do + let_it_be(:group) { create(:group, :public) } + let_it_be(:project) { create(:project, :private, :repository, group: group) } + let_it_be(:user) { create(:user) } + let_it_be(:user2) { create(:user) } + let_it_be(:user3) { create(:user) } + + let_it_be_with_reload(:merge_request) do + create(:merge_request, :simple, :unique_branches, + reviewer_ids: [user.id], + source_project: project, + author: user) + end + + before do + project.add_maintainer(user) + project.add_developer(user2) + project.add_developer(user3) + end + + let(:service) { described_class.new(project: project, current_user: user, params: opts) } + + describe 'execute' do + def set_reviewers + service.execute(merge_request) + merge_request.reload + end + + context 'when the parameters are valid' do + context 'when using sentinel values' do + let(:opts) { { reviewer_ids: [0, 0, 0] } } + + it 'removes all reviewers' do + expect { set_reviewers }.to change(merge_request, :reviewers).to([]) + end + end + + context 'when the reviewer_ids parameter is the empty list' do + let(:opts) { { reviewer_ids: [] } } + + it 'removes all reviewers' do + expect { set_reviewers }.to change(merge_request, :reviewers).to([]) + end + end + + context 'when the reviewer_ids parameter contains both zeros and valid IDs' do + let(:opts) { { reviewer_ids: [0, user2.id, 0, user3.id, 0] } } + + it 'ignores 0 IDs' do + expect { set_reviewers }.to change(merge_request, :reviewers).to(match_array([user2, user3])) + end + end + end + end +end diff --git a/spec/graphql/mutations/merge_requests/set_reviewers_spec.rb b/spec/graphql/mutations/merge_requests/set_reviewers_spec.rb new file mode 100644 index 0000000000000..df4aa885bbff8 --- /dev/null +++ b/spec/graphql/mutations/merge_requests/set_reviewers_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::MergeRequests::SetReviewers do + let_it_be(:user) { create(:user) } + let_it_be(:merge_request, reload: true) { create(:merge_request) } + let_it_be(:reviewer) { create(:user) } + let_it_be(:reviewer2) { create(:user) } + + subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) } + + describe '#resolve' do + let(:reviewer_usernames) { [reviewer.username] } + let(:mutated_merge_request) { subject[:merge_request] } + let(:mode) { described_class.arguments['operationMode'].default_value } + + subject do + mutation.resolve(project_path: merge_request.project.full_path, + iid: merge_request.iid, + operation_mode: mode, + reviewer_usernames: reviewer_usernames) + end + + it 'does not change reviewers if the merge_request is not accessible to the reviewers' do + merge_request.project.add_developer(user) + + expect { subject }.not_to change { merge_request.reload.reviewer_ids } + end + + it 'returns an operational error if the merge_request is not accessible to the reviewers' do + merge_request.project.add_developer(user) + + result = subject + + expect(result[:errors]).to include a_string_matching(/Cannot assign/) + end + + context 'when the user does not have permissions' do + it_behaves_like 'permission level for merge request mutation is correctly verified' + end + + context 'when the user can update the merge_request' do + before do + merge_request.project.add_developer(reviewer) + merge_request.project.add_developer(reviewer2) + merge_request.project.add_developer(user) + end + + it 'replaces the reviewer' do + merge_request.reviewers = [reviewer2] + merge_request.save! + + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.reviewers).to contain_exactly(reviewer) + expect(subject[:errors]).to be_empty + end + + it 'returns errors when merge_request could not be updated' do + allow(merge_request).to receive(:errors_on_object).and_return(['foo']) + + expect(subject[:errors]).not_to match_array(['foo']) + end + + context 'when passing an empty reviewer list' do + let(:reviewer_usernames) { [] } + + before do + merge_request.reviewers = [reviewer] + merge_request.save! + end + + it 'removes all reviewers' do + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.reviewers).to eq([]) + expect(subject[:errors]).to be_empty + end + end + + context 'when passing "append" as true' do + subject do + mutation.resolve( + project_path: merge_request.project.full_path, + iid: merge_request.iid, + reviewer_usernames: reviewer_usernames, + operation_mode: Types::MutationOperationModeEnum.enum[:append] + ) + end + + before do + merge_request.reviewers = [reviewer2] + merge_request.save! + + # In CE, APPEND is a NOOP as you can't have multiple reviewers + # We test multiple assignment in EE specs + stub_licensed_features(multiple_merge_request_reviewers: false) + end + + it 'is a NO-OP in FOSS' do + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.reviewers).to contain_exactly(reviewer2) + expect(subject[:errors]).to be_empty + end + end + + context 'when passing "remove" as true' do + before do + merge_request.reviewers = [reviewer] + merge_request.save! + end + + it 'removes named reviewer' do + mutated_merge_request = mutation.resolve( + project_path: merge_request.project.full_path, + iid: merge_request.iid, + reviewer_usernames: reviewer_usernames, + operation_mode: Types::MutationOperationModeEnum.enum[:remove] + )[:merge_request] + + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.reviewers).to eq([]) + expect(subject[:errors]).to be_empty + end + + it 'does not remove unnamed reviewer' do + mutated_merge_request = mutation.resolve( + project_path: merge_request.project.full_path, + iid: merge_request.iid, + reviewer_usernames: [reviewer2.username], + operation_mode: Types::MutationOperationModeEnum.enum[:remove] + )[:merge_request] + + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.reviewers).to contain_exactly(reviewer) + expect(subject[:errors]).to be_empty + end + end + end + end +end diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_reviewers_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_reviewers_spec.rb new file mode 100644 index 0000000000000..be786256ef223 --- /dev/null +++ b/spec/requests/api/graphql/mutations/merge_requests/set_reviewers_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Setting reviewers of a merge request', :assume_throttled do + include GraphqlHelpers + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:current_user) { create(:user, developer_projects: [project]) } + let_it_be(:reviewer) { create(:user) } + let_it_be(:reviewer2) { create(:user) } + let_it_be_with_reload(:merge_request) { create(:merge_request, source_project: project) } + + let(:input) { { reviewer_usernames: [reviewer.username] } } + let(:expected_result) do + [{ 'username' => reviewer.username }] + end + + let(:mutation) do + variables = { + project_path: project.full_path, + iid: merge_request.iid.to_s + } + graphql_mutation(:merge_request_set_reviewers, variables.merge(input), + <<-QL.strip_heredoc + clientMutationId + errors + mergeRequest { + id + reviewers { + nodes { + username + } + } + } + QL + ) + end + + def mutation_response + graphql_mutation_response(:merge_request_set_reviewers) + end + + def mutation_reviewer_nodes + mutation_response['mergeRequest']['reviewers']['nodes'] + end + + def run_mutation! + post_graphql_mutation(mutation, current_user: current_user) + end + + before do + project.add_developer(reviewer) + project.add_developer(reviewer2) + + merge_request.update!(reviewers: []) + end + + it 'returns an error if the user is not allowed to update the merge request' do + post_graphql_mutation(mutation, current_user: create(:user)) + + expect(graphql_errors).not_to be_empty + end + + context 'when the current user does not have permission to add reviewers' do + let(:current_user) { create(:user) } + + it 'does not change the reviewers' do + project.add_guest(current_user) + + expect { run_mutation! }.not_to change { merge_request.reset.reviewers.pluck(:id) } + + expect(graphql_errors).not_to be_empty + end + end + + context 'with reviewers already assigned' do + before do + merge_request.reviewers = [reviewer2] + merge_request.save! + end + + it 'replaces the reviewer' do + run_mutation! + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_reviewer_nodes).to match_array(expected_result) + end + end + + context 'when passing an empty list of reviewers' do + let(:input) { { reviewer_usernames: [] } } + + before do + merge_request.reviewers = [reviewer2] + merge_request.save! + end + + it 'removes reviewer' do + run_mutation! + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_reviewer_nodes).to eq([]) + end + end +end diff --git a/spec/services/merge_requests/update_reviewers_service_spec.rb b/spec/services/merge_requests/update_reviewers_service_spec.rb new file mode 100644 index 0000000000000..8920141adbb0f --- /dev/null +++ b/spec/services/merge_requests/update_reviewers_service_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequests::UpdateReviewersService do + include AfterNextHelpers + + let_it_be(:group) { create(:group, :public) } + let_it_be(:project) { create(:project, :private, :repository, group: group) } + let_it_be(:user) { create(:user) } + let_it_be(:user2) { create(:user) } + let_it_be(:user3) { create(:user) } + + let_it_be_with_reload(:merge_request) do + create(:merge_request, :simple, :unique_branches, + title: 'Old title', + description: "FYI #{user2.to_reference}", + reviewer_ids: [user3.id], + source_project: project, + target_project: project, + author: create(:user)) + end + + before do + project.add_maintainer(user) + project.add_developer(user2) + project.add_developer(user3) + merge_request.errors.clear + end + + let(:service) { described_class.new(project: project, current_user: user, params: opts) } + let(:opts) { { reviewer_ids: [user2.id] } } + + describe 'execute' do + def set_reviewers + service.execute(merge_request) + end + + def find_note(starting_with) + merge_request.notes.find do |note| + note && note.note.start_with?(starting_with) + end + end + + shared_examples 'removing all reviewers' do + it 'removes all reviewers' do + expect(set_reviewers).to have_attributes(reviewers: be_empty, errors: be_none) + end + end + + context 'when the parameters are valid' do + context 'when using sentinel values' do + let(:opts) { { reviewer_ids: [0] } } + + it_behaves_like 'removing all reviewers' + end + + context 'when the reviewer_ids parameter is the empty list' do + let(:opts) { { reviewer_ids: [] } } + + it_behaves_like 'removing all reviewers' + end + + it 'updates the MR' do + expect { set_reviewers } + .to change { merge_request.reload.reviewers }.from([user3]).to([user2]) + .and change(merge_request, :updated_at) + .and change(merge_request, :updated_by).to(user) + end + + it 'creates system note about merge_request review request' do + set_reviewers + + note = find_note('requested review from') + + expect(note).not_to be_nil + expect(note.note).to include "requested review from #{user2.to_reference}" + end + + it 'creates a pending todo for new review request' do + set_reviewers + + attributes = { + project: project, + author: user, + user: user2, + target_id: merge_request.id, + target_type: merge_request.class.name, + action: Todo::REVIEW_REQUESTED, + state: :pending + } + + expect(Todo.where(attributes).count).to eq 1 + end + + it 'sends email reviewer change notifications to old and new reviewers', :sidekiq_inline, :mailer do + perform_enqueued_jobs do + set_reviewers + end + + should_email(user2) + should_email(user3) + end + + it 'updates open merge request counter for reviewers', :use_clean_rails_memory_store_caching do + # Cache them to ensure the cache gets invalidated on update + expect(user2.review_requested_open_merge_requests_count).to eq(0) + expect(user3.review_requested_open_merge_requests_count).to eq(1) + + set_reviewers + + expect(user2.review_requested_open_merge_requests_count).to eq(1) + expect(user3.review_requested_open_merge_requests_count).to eq(0) + end + + it 'updates the tracking' do + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) + .to receive(:track_users_review_requested) + .with(users: [user2]) + + set_reviewers + end + + it 'tracks reviewers changed event' do + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) + .to receive(:track_reviewers_changed_action).once.with(user: user) + + set_reviewers + end + + it 'calls MergeRequest::ResolveTodosService#async_execute' do + expect_next_instance_of(MergeRequests::ResolveTodosService, merge_request, user) do |service| + expect(service).to receive(:async_execute) + end + + set_reviewers + end + + it 'executes hooks with update action' do + expect(service).to receive(:execute_hooks) + .with( + merge_request, + 'update', + old_associations: { + reviewers: [user3] + } + ) + + set_reviewers + end + + it 'does not update the reviewers if they do not have access' do + opts[:reviewer_ids] = [create(:user).id] + + expect(set_reviewers).to have_attributes( + reviewers: [user3], + errors: be_any + ) + end + end + end +end -- GitLab