ee/spec/services/quick_actions/interpret_service_spec.rb (1,672 lines of code) (raw):

# frozen_string_literal: true require 'spec_helper' RSpec.describe QuickActions::InterpretService, feature_category: :team_planning do let(:current_user) { create(:user) } let_it_be(:developer) { create(:user) } let(:developer2) { create(:user) } let(:user) { create(:user) } let_it_be(:user2) { create(:user) } let_it_be(:user3) { create(:user) } let_it_be_with_refind(:group) { create(:group) } let_it_be_with_refind(:project) { create(:project, :repository, :public, group: group) } let_it_be_with_reload(:issue) { create(:issue, project: project) } let(:service) { described_class.new(container: project, current_user: current_user) } before do stub_licensed_features(multiple_issue_assignees: true, multiple_merge_request_reviewers: true, multiple_merge_request_assignees: true) project.add_developer(current_user) project.add_developer(developer) end shared_examples 'quick action is unavailable' do |action| it 'does not recognize action' do expect(service.available_commands(target).map { |command| command[:name] }).not_to include(action) end end shared_examples 'quick action is available' do |action| it 'does recognize action' do expect(service.available_commands(target).map { |command| command[:name] }).to include(action) end end shared_examples 'failed command' do |error_msg| let(:match_msg) { error_msg ? eq(error_msg) : be_empty } it 'populates {} if content contains an unsupported command' do _, updates, _ = service.execute(content, issuable) expect(updates).to be_empty end it "returns #{error_msg || 'an empty'} message" do _, _, message = service.execute(content, issuable) expect(message).to match_msg end end shared_examples 'copy_metadata command' do it 'fetches issue or merge request and copies labels and milestone if content contains /copy_metadata reference' do source_issuable # populate the issue todo_label # populate this label inreview_label # populate this label _, updates, _ = service.execute(content, issuable) expect(updates[:add_label_ids]).to match_array([inreview_label.id, todo_label.id]) if source_issuable.milestone expect(updates[:milestone_id]).to eq(source_issuable.milestone.id) else expect(updates).not_to have_key(:milestone_id) end end it 'returns the copy metadata message' do _, _, message = service.execute("/copy_metadata #{source_issuable.to_reference}", issuable) translated_string = _("Copied labels and milestone from %{source_issuable_to_reference}.") formatted_message = format(translated_string, source_issuable_to_reference: source_issuable.to_reference.to_s) expect(message).to eq(formatted_message) end end describe '#execute' do let(:merge_request) { create(:merge_request, source_project: project) } context 'assign command' do context 'there is a group' do let(:group) { create(:group) } before do group.add_developer(user) group.add_developer(user2) group.add_developer(user3) end it 'assigns to group members' do cmd = "/assign #{group.to_reference}" _, updates, _ = service.execute(cmd, issue) expect(updates).to include(assignee_ids: match_array([user.id, user2.id, user3.id])) end it 'does not assign to more than QuickActions::UsersFinder::MAX_QUICK_ACTION_USERS' do stub_const('Gitlab::QuickActions::UsersExtractor::MAX_QUICK_ACTION_USERS', 2) cmd = "/assign #{group.to_reference}" _, updates, messages = service.execute(cmd, issue) expect(updates).to be_blank expect(messages).to include('Too many users') end end context 'Issue' do it 'fetches assignees and populates them if content contains /assign' do issue.update!(assignee_ids: [user.id, user2.id]) _, updates = service.execute("/unassign @#{user2.username}\n/assign @#{user3.username}", issue) expect(updates[:assignee_ids]).to match_array([user.id, user3.id]) end context 'with test_case issue type' do it 'does not mark to update assignee' do test_case = create(:quality_test_case, project: project) _, updates = service.execute("/assign @#{user3.username}", test_case) expect(updates[:assignee_ids]).to eq(nil) end end context 'assign command with multiple assignees' do it 'fetches assignee and populates assignee_ids if content contains /assign' do issue.update!(assignee_ids: [user.id]) _, updates = service.execute("/unassign @#{user.username}\n/assign @#{user2.username} @#{user3.username}", issue) expect(updates[:assignee_ids]).to match_array([user2.id, user3.id]) end end end context 'Merge Request' do let(:merge_request) { create(:merge_request, source_project: project) } it 'fetches assignees and populates them if content contains /assign' do merge_request.update!(assignee_ids: [user.id]) _, updates = service.execute("/assign @#{user2.username}", merge_request) expect(updates[:assignee_ids]).to match_array([user.id, user2.id]) end context 'assign command with a group of users' do let(:group) { create(:group) } let(:project) { create(:project, group: group) } let(:group_members) { create_list(:user, 3) } let(:command) { "/assign #{group.to_reference}" } before do group_members.each { group.add_developer(_1) } end it 'adds group members' do merge_request.update!(assignee_ids: [user.id]) _, updates = service.execute(command, merge_request) expect(updates[:assignee_ids]).to match_array [user.id, *group_members.map(&:id)] end end context 'assign command with multiple assignees' do it 'fetches assignee and populates assignee_ids if content contains /assign' do merge_request.update!(assignee_ids: [user.id]) _, updates = service.execute("/assign @#{user.username}\n/assign @#{user2.username} @#{user3.username}", issue) expect(updates[:assignee_ids]).to match_array([user.id, user2.id, user3.id]) end context 'unlicensed' do before do stub_licensed_features(multiple_merge_request_assignees: false) end it 'does not recognize /assign with multiple user references' do merge_request.update!(assignee_ids: [user.id]) _, updates = service.execute("/assign @#{user2.username} @#{user3.username}", merge_request) expect(updates[:assignee_ids]).to match_array([user2.id]) end end end end end context 'assign_reviewer command' do context 'with a merge request' do let(:merge_request) { create(:merge_request, source_project: project) } it 'fetches reviewers and populates them if content contains /assign_reviewer' do merge_request.update!(reviewer_ids: [user.id]) _, updates = service.execute("/assign_reviewer @#{user2.username}\n/assign_reviewer @#{user3.username}", merge_request) expect(updates[:reviewer_ids]).to match_array([user.id, user2.id, user3.id]) end context 'assign command with multiple reviewers' do it 'assigns multiple reviewers while respecting previous assignments' do merge_request.update!(reviewer_ids: [user.id]) _, updates = service.execute("/assign_reviewer @#{user.username}\n/assign_reviewer @#{user2.username} @#{user3.username}", merge_request) expect(updates[:reviewer_ids]).to match_array([user.id, user2.id, user3.id]) end end end end context 'unassign_reviewer command' do let(:content) { '/unassign_reviewer' } let(:merge_request) { create(:merge_request, source_project: project) } let(:merge_request_not_persisted) { build(:merge_request, source_project: project) } context 'unassign_reviewer command with multiple assignees' do it 'unassigns both reviewers if content contains /unassign_reviewer @user @user1' do merge_request.update!(reviewer_ids: [user.id, user2.id, user3.id]) _, updates = service.execute("/unassign_reviewer @#{user.username} @#{user2.username}", merge_request) expect(updates[:reviewer_ids]).to match_array([user3.id]) end it 'does not unassign reviewers if the content cannot be parsed' do merge_request.update!(reviewer_ids: [user.id, user2.id, user3.id]) _, updates, msg = service.execute("/unassign_reviewer nobody", merge_request) expect(updates[:reviewer_ids]).to be_nil expect(msg).to eq "Could not apply unassign_reviewer command. Failed to find users for 'nobody'." end context 'with "me" alias' do context 'when the current user is referenced both by username and "me"' do let(:content) do <<-QUICKACTION /assign me /assign_reviewer #{developer2.to_reference} #{current_user.to_reference} /unassign_reviewer me QUICKACTION end it 'will correctly remove the reviewer' do _, updates, _ = service.execute(content, merge_request) expect(updates[:reviewer_ids]).to match_array([developer2.id]) end it 'will correctly remove the reviewer even for non-persisted merge requests' do _, updates, _ = service.execute(content, merge_request_not_persisted) expect(updates[:reviewer_ids]).to match_array([developer2.id]) end end end end end context 'unassign command' do let(:content) { '/unassign' } context 'Issue' do it 'unassigns user if content contains /unassign @user' do issue.update!(assignee_ids: [user.id, user2.id]) _, updates = service.execute("/assign @#{user3.username}\n/unassign @#{user2.username}", issue) expect(updates[:assignee_ids]).to match_array([user.id, user3.id]) end it 'unassigns both users if content contains /unassign @user @user1' do issue.update!(assignee_ids: [user.id, user2.id]) _, updates = service.execute("/assign @#{user3.username}\n/unassign @#{user2.username} @#{user3.username}", issue) expect(updates[:assignee_ids]).to match_array([user.id]) end it 'unassigns all the users if content contains /unassign' do issue.update!(assignee_ids: [user.id, user2.id]) _, updates = service.execute("/assign @#{user3.username}\n/unassign", issue) expect(updates[:assignee_ids]).to be_empty end it 'does not apply command if the argument cannot be parsed' do issue.update!(assignee_ids: [user.id, user2.id]) _, updates, msg = service.execute("/assign nobody", issue) expect(updates[:assignee_ids]).to be_nil expect(msg).to eq "Could not apply assign command. Failed to find users for 'nobody'." end end context 'with a Merge Request' do let(:merge_request) { create(:merge_request, source_project: project) } it 'unassigns user if content contains /unassign @user' do merge_request.update!(assignee_ids: [user.id, user2.id]) _, updates = service.execute("/unassign @#{user2.username}", merge_request) expect(updates[:assignee_ids]).to match_array([user.id]) end describe 'applying unassign command with multiple assignees' do it 'unassigns both users if content contains /unassign @user @user1' do merge_request.update!(assignee_ids: [user.id, user2.id, user3.id]) _, updates = service.execute("/unassign @#{user.username} @#{user2.username}", merge_request) expect(updates[:assignee_ids]).to match_array([user3.id]) end context 'when unlicensed' do before do stub_licensed_features(multiple_merge_request_assignees: false) end it 'does not recognize /unassign @user' do merge_request.update!(assignee_ids: [user.id, user2.id, user3.id]) _, updates = service.execute("/unassign @#{user.username}", merge_request) expect(updates[:assignee_ids]).to be_empty end end end end end context 'reassign command' do let(:content) { "/reassign @#{current_user.username}" } context 'Merge Request' do let(:merge_request) { create(:merge_request, source_project: project) } context 'unlicensed' do before do stub_licensed_features(multiple_merge_request_assignees: false) end it 'does not recognize /reassign @user' do _, updates = service.execute(content, merge_request) expect(updates).to be_empty end end it 'reassigns user if content contains /reassign @user' do _, updates = service.execute("/reassign @#{current_user.username}", merge_request) expect(updates[:assignee_ids]).to match_array([current_user.id]) end context 'it reassigns multiple users' do let(:additional_user) { create(:user) } it 'reassigns user if content contains /reassign @user' do _, updates = service.execute("/reassign @#{current_user.username} @#{additional_user.username}", merge_request) expect(updates[:assignee_ids]).to match_array([current_user.id, additional_user.id]) end end end context 'Issue' do let(:content) { "/reassign @#{current_user.username}" } before do issue.update!(assignee_ids: [user.id]) end context 'unlicensed' do before do stub_licensed_features(multiple_issue_assignees: false) end it 'does not recognize /reassign @user' do _, updates = service.execute(content, issue) expect(updates).to be_empty end end it 'reassigns user if content contains /reassign @user' do _, updates = service.execute("/reassign @#{current_user.username}", issue) expect(updates[:assignee_ids]).to match_array([current_user.id]) end context 'with test_case issue type' do it 'does not mark to update assignee' do test_case = create(:quality_test_case, project: project) _, updates = service.execute("/reassign @#{current_user.username}", test_case) expect(updates[:assignee_ids]).to eq(nil) end end context 'it reassigns multiple users' do let(:additional_user) { create(:user) } it 'reassigns user if content contains /reassign @user' do _, updates = service.execute("/reassign @#{current_user.username} @#{additional_user.username}", issue) expect(updates[:assignee_ids]).to match_array([current_user.id, additional_user.id]) end end end end context 'reassign_reviewer command' do let(:content) { "/reassign_reviewer @#{current_user.username}" } context 'unlicensed' do before do stub_licensed_features(multiple_merge_request_reviewers: false) end it 'does not recognize /reassign_reviewer @user' do content = "/reassign_reviewer @#{current_user.username}" _, updates = service.execute(content, merge_request) expect(updates).to be_empty end end it 'reassigns reviewer if content contains /reassign_reviewer @user' do _, updates = service.execute("/reassign_reviewer @#{current_user.username}", merge_request) expect(updates[:reviewer_ids]).to match_array([current_user.id]) end end context 'iteration command' do let_it_be(:root_group) { create(:group, :private) } let_it_be(:group) { create(:group, :private, parent: root_group) } let_it_be(:project) { create(:project, :private, :repository, group: group) } let_it_be_with_reload(:work_item_issue) { create(:work_item, project: project) } context 'when iterations are enabled' do before do stub_licensed_features(iterations: true) end context 'when iteration exists' do let_it_be(:iteration) { create(:iteration, iterations_cadence: create(:iterations_cadence, group: group)) } let(:content) { "/iteration #{iteration.to_reference(project)}" } context 'with permissions' do before do group.add_developer(current_user) end it 'does not assign iteration when reference does not match any iteration' do _, updates, message = service.execute("/iteration *iteration:#{non_existing_record_id}", issue) expect(updates).to be_empty expect(message).to eq(_("Could not apply iteration command. Failed to find the referenced iteration.")) end shared_examples 'assigns iteration' do |factory| let(:issuable) do factory == :issue ? issue : build(factory, project: project) end it 'assigns an iteration' do _, updates, message = service.execute(content, issuable) expect(updates).to eq(iteration: iteration) expect(message).to eq("Set the iteration to #{iteration.to_reference}.") end end it_behaves_like 'assigns iteration', :issue context 'when issuable is a work item' do it_behaves_like 'assigns iteration', :work_item end context 'when issuable is an incident' do it_behaves_like 'assigns iteration', :incident end context 'when iteration is started' do before do iteration.start! end it_behaves_like 'assigns iteration', :issue end end context 'when the user does not have enough permissions' do before do allow(current_user).to receive(:can?).with(:use_quick_actions).and_return(true) allow(current_user).to receive(:can?).with(:admin_issue, project).and_return(false) allow(current_user).to receive(:can?).with(:admin_work_item, project).and_return(false) end it 'returns an error message' do [issue, work_item_issue].each do |issuable| _, updates, message = service.execute(content, issuable) expect(updates).to be_empty expect(message).to eq('Could not apply iteration command.') end end end end context 'with --current and --next options' do before do group.add_developer(current_user) end context "with iterations cadence reference" do let_it_be(:cadence) { create(:iterations_cadence, title: "one cadence", group: root_group) } let_it_be(:past_iteration) { create(:iteration, :with_due_date, iterations_cadence: cadence, start_date: 10.days.ago) } let_it_be(:current_iteration) { create(:iteration, :with_due_date, iterations_cadence: cadence, start_date: 2.days.ago) } let_it_be(:next_iteration) { create(:iteration, :with_due_date, iterations_cadence: cadence, start_date: 10.days.from_now) } let_it_be(:another_cadence) { create(:iterations_cadence, title: "another cadence", group: root_group) } let_it_be(:another_current_iteration) { create(:iteration, :with_due_date, iterations_cadence: another_cadence, start_date: 2.days.ago) } let_it_be(:empty_cadence) { create(:iterations_cadence, title: "empty cadence", group: root_group) } let_it_be(:inaccessible_cadence) { create(:iterations_cadence, title: "another cadence") } it 'does not assign any iteration when the referenced cadence is empty' do _, updates, message = service.execute("/iteration #{empty_cadence.to_reference} --current", issue) expect(updates).to be_empty expect(message).to eq(_('Could not apply iteration command. No current iteration found for the cadence.')) _, updates, message = service.execute("/iteration #{empty_cadence.to_reference} --next", issue) expect(updates).to be_empty expect(message).to eq(_('Could not apply iteration command. No upcoming iteration found for the cadence.')) end it 'does not assign any iteration when referencing non-existent iterations cadence' do _, updates, message = service.execute("/iteration [cadence:\"foobar cadence\"] --current", issue) expect(updates).to be_empty expect(message).to eq(_('Could not apply iteration command. Failed to find the referenced iteration cadence.')) _, updates, message = service.execute("/iteration [cadence:#{non_existing_record_id}] --current", issue) expect(updates).to be_empty expect(message).to eq(_('Could not apply iteration command. Failed to find the referenced iteration cadence.')) end it 'does not assign any iteration when referencing unauthorized iterations cadence' do _, updates, message = service.execute("/iteration #{inaccessible_cadence.to_reference} --current", issue) expect(updates).to be_empty expect(message).to eq(_('Could not apply iteration command. Failed to find the referenced iteration cadence.')) end it 'does not assign any iteration when option are missing' do _, updates, message = service.execute("/iteration #{cadence.to_reference}", issue) expect(updates).to be_empty expect(message).to eq(_("Could not apply iteration command. Missing option --current or --next.")) end context 'with --current option' do where(:content) do [ [lazy { "/iteration #{cadence.to_reference} --current" }], [lazy { "/iteration [cadence:\"#{cadence.title}\"] --current" }] ] end with_them do it 'assigns the current iteration of the referenced iterations cadence' do _, updates, message = service.execute(content, issue) expect(updates).to eq(iteration: current_iteration) expect(message).to eq("Set the iteration to #{current_iteration.to_reference}.") end end end context 'with --next option' do where(:content) do [ [lazy { "/iteration #{cadence.to_reference} --next" }], [lazy { "/iteration [cadence:\"#{cadence.title}\"] --next" }] ] end with_them do it 'assigns the next upcoming iteration of the referenced iterations cadence' do _, updates, message = service.execute(content, issue) expect(updates).to eq(iteration: next_iteration) expect(message).to eq("Set the iteration to #{next_iteration.to_reference}.") end end end end context "without iterations cadence reference" do let_it_be(:cadence) { create(:iterations_cadence, title: "cadence", group: group) } let_it_be(:current_iteration) { create(:iteration, :with_due_date, iterations_cadence: cadence, start_date: 2.days.ago) } let_it_be(:next_iteration) { create(:iteration, :with_due_date, iterations_cadence: cadence, start_date: 10.days.from_now) } context 'when a group hierarchy has a single iterations cadence' do it 'assigns the current iteration from the iterations cadence' do _, updates, message = service.execute("/iteration --current", issue) expect(updates).to eq(iteration: current_iteration) expect(message).to eq("Set the iteration to #{current_iteration.to_reference}.") end it 'assigns the next iteration from the iterations cadence' do _, updates, message = service.execute("/iteration --next", issue) expect(updates).to eq(iteration: next_iteration) expect(message).to eq("Set the iteration to #{next_iteration.to_reference}.") end end context 'when a group hierarchy has multiple iterations cadences' do before_all do create(:iterations_cadence, title: "another cadence", group: group) end it 'does not assign any iteration' do _, updates, message = service.execute("/iteration --current", issue) expect(updates).to be_empty expect(message).to eq(_('Could not apply iteration command. There are multiple cadences but no cadence is specified.')) _, updates, message = service.execute("/iteration --next", issue) expect(updates).to be_empty expect(message).to eq(_('Could not apply iteration command. There are multiple cadences but no cadence is specified.')) end end end end end context 'when iterations are disabled' do let_it_be(:iteration) { create(:iteration, iterations_cadence: create(:iterations_cadence, group: group)) } let(:content) { "/iteration #{iteration.to_reference(project)}" } before do stub_licensed_features(iterations: false) end it 'does not recognize /iteration' do _, updates = service.execute(content, issue) expect(updates).to be_empty end end end context 'remove_iteration command' do let_it_be(:iteration) { create(:iteration, iterations_cadence: create(:iterations_cadence, group: group)) } let(:content) { '/remove_iteration' } context 'when iterations are enabled' do before do stub_licensed_features(iterations: true) issue.update!(iteration: iteration) end shared_examples 'removes iteration' do |factory| let(:issuable) { create(factory, project: project, iteration: iteration) } it 'removes an assigned iteration' do _, updates, message = service.execute(content, issuable) expect(updates).to eq(iteration: nil) expect(message).to eq("Removed #{iteration.to_reference} iteration.") end end it_behaves_like 'removes iteration', :issue context 'when issuable is a work item' do it_behaves_like 'removes iteration', :work_item end context 'when issuable is an incident' do it_behaves_like 'removes iteration', :incident end context 'when the user does not have enough permissions' do before do allow(current_user).to receive(:can?).with(:use_quick_actions).and_return(true) allow(current_user).to receive(:can?).with(:admin_issue, project).and_return(false) allow(current_user).to receive(:can?).with(:admin_work_item, project).and_return(false) end let_it_be(:work_item_issue) { create(:work_item, :issue, project: project, iteration: iteration) } it 'returns an error message' do [issue, work_item_issue].each do |issuable| _, updates, message = service.execute(content, issuable) expect(updates).to be_empty expect(message).to eq('Could not apply remove_iteration command.') end end end end context 'when iterations are disabled' do before do stub_licensed_features(iterations: false) end it 'does not recognize /remove_iteration' do _, updates = service.execute(content, issue) expect(updates).to be_empty end end end context 'set_parent command' do context 'on an issue' do let_it_be(:issue) { create(:issue, project: project) } context 'when epics are enabled' do before do stub_licensed_features(epics: true) end it 'allows the /set_parent command' do expect(service.available_commands(issue)).to include(a_hash_including(name: :set_parent)) end end context 'when epics are disabled' do before do stub_licensed_features(epics: false) end it 'does not allow the /set_parent command' do expect(service.available_commands(issue)).not_to include(a_hash_including(name: :set_parent)) end end end context 'on a work_item' do let_it_be(:work_item) { create(:work_item, :task, project: project) } it 'allows the /set_parent command' do expect(service.available_commands(work_item)).to include(a_hash_including(name: :set_parent)) end end end context 'epic command' do context 'on an issue' do let_it_be_with_reload(:epic) { create(:epic, group: group) } let_it_be_with_reload(:private_epic) { create(:epic, group: create(:group, :private)) } let(:content) { "/epic #{epic.to_reference(project)}" } context 'when epics are enabled' do before do stub_licensed_features(epics: true) end context 'when epic exists' do it 'assigns an issue to an epic' do _, updates, message = service.execute(content, issue) expect(updates).to eq(epic: epic) expect(message).to eq('Added an issue to an epic.') end context 'when it is confidential' do before do epic.update!(confidential: true) epic.sync_object.update!(confidential: true) group.add_developer(current_user) end it 'shows an error' do _, updates, message = service.execute(content, issue) expect(updates).to be_empty expect(message).to eq('Cannot assign a confidential parent item to a non-confidential child item. Make the child item confidential and try again.') end end context 'when an issue belongs to a project without group' do let_it_be(:user_project) { create(:project) } let(:issue) { create(:issue, project: user_project) } before do user_project.add_guest(user) end it 'does not assign an issue to an epic' do _, updates = service.execute(content, issue) expect(updates).to be_empty end end context 'when issue is already added to epic' do it 'returns error message' do issue = create(:issue, project: project, epic: epic) WorkItem.find(issue.id).update!(work_item_parent: epic.sync_object) _, updates, message = service.execute(content, issue) expect(updates).to be_empty expect(message).to eq("#{issue.to_reference} has already been added to parent #{epic.sync_object.to_reference}.") end end context 'when issuable does not support epics' do it 'does not assign an incident to an epic' do incident = create(:incident, project: project) _, updates = service.execute(content, incident) expect(updates).to be_empty end end end context 'when epic does not exist' do let(:content) { "/epic none" } it 'does not assign an issue to an epic' do _, updates, message = service.execute(content, issue) expect(updates).to be_empty expect(message).to eq("This parent item does not exist or you don't have sufficient permission.") end end context 'when user has no permissions to read epic' do let(:content) { "/epic #{private_epic.to_reference(project)}" } it 'does not assign an issue to an epic' do _, updates, message = service.execute(content, issue) expect(updates).to be_empty expect(message).to eq("This parent item does not exist or you don't have sufficient permission.") end end context 'when user has no access to the issue' do before do allow(current_user).to receive(:can?).and_call_original allow(current_user).to receive(:can?).with(:admin_issue_relation, issue).and_return(false) end it 'returns error' do _, updates, message = service.execute(content, issue) expect(updates).to be_empty expect(message).to eq('Could not apply set_parent command.') end end end context 'when epics are disabled' do it 'does not recognize /epic' do _, updates = service.execute(content, issue) expect(updates).to be_empty end end end context 'on a work item' do let_it_be(:work_item_issue) { create(:work_item, :issue, project: project) } let_it_be(:epic) { create(:epic, :with_synced_work_item, group: group) } let(:content) { "/epic #{epic.to_reference}" } context 'when epics are enabled' do before do stub_licensed_features(epics: true) end context 'when epic exists' do it 'assigns an issue to an epic' do _, updates, message = service.execute(content, work_item_issue) expect(updates).to eq(set_parent: epic.sync_object) expect(message).to eq('Parent item set successfully.') end end end context 'when epics are disabled' do it 'does not recognize /epic' do _, updates = service.execute(content, work_item_issue) expect(updates).to be_empty end end end end context 'label command for epics' do let(:epic) { create(:epic, group: group) } let(:label) { create(:group_label, title: 'bug', group: group) } let(:project_label) { create(:label, title: 'project_label') } let(:content) { "/label ~#{label.title} ~#{project_label.title}" } let(:service) { described_class.new(container: group, current_user: current_user) } context 'when epics are enabled' do before do stub_licensed_features(epics: true) end context 'when a user has permissions to label an epic' do before do group.add_developer(current_user) end it 'populates valid label ids' do _, updates = service.execute(content, epic) expect(updates).to eq(add_label_ids: [label.id]) end end context 'when a user does not have permissions to label an epic' do it 'does not populate any labels' do _, updates = service.execute(content, epic) expect(updates).to be_empty end end end context 'when epics are disabled' do it 'does not populate any labels' do group.add_developer(current_user) _, updates = service.execute(content, epic) expect(updates).to be_empty end end end context '/label command' do context 'when target is a group level work item' do let(:current_user) { developer } let_it_be(:new_group) { create(:group, developers: developer) } let_it_be(:group_level_work_item) { create(:work_item, :group_level, namespace: new_group) } let(:service) { described_class.new(container: group, current_user: current_user) } context 'with group level work items license' do before do stub_licensed_features(epics: true) end # This spec was introduced just to validate that the label finder scopes que query to a single group. # The command checks that labels are available as part of the condition. # Query was timing out in .com https://gitlab.com/gitlab-org/gitlab/-/issues/441123 it 'is not available when there are no labels associated with the group' do expect(service.available_commands(group_level_work_item)).not_to include(a_hash_including(name: :label)) end context 'when a label exists at the group level' do before do create(:group_label, group: group) end it 'is available' do expect(service.available_commands(group_level_work_item)).to include(a_hash_including(name: :label)) end context 'without group level work items license' do before do stub_licensed_features(epics: false) end it 'is not available' do expect(service.available_commands(group_level_work_item)).not_to include(a_hash_including(name: :label)) end end end end end end context 'remove_epic command' do let(:epic) { create(:epic, group: group) } let(:content) { "/remove_epic" } before do stub_licensed_features(epics: true) issue.update!(epic: epic) end context 'when epics are disabled' do before do stub_licensed_features(epics: false) end it 'does not recognize /remove_epic' do _, updates = service.execute(content, issue) expect(updates).to be_empty end end context 'when subepics are enabled' do before do stub_licensed_features(epics: true, subepics: true) end it 'unassigns an issue from an epic' do _, updates = service.execute(content, issue) expect(updates).to eq(epic: nil) end end context 'when issuable does not support epics' do it 'does not recognize /remove_epic' do incident = create(:incident, project: project, epic: epic) _, updates = service.execute(content, incident) expect(updates).to be_empty end end context 'when user has no access to the issue' do before do allow(current_user).to receive(:can?).and_call_original allow(current_user).to receive(:can?).with(:admin_issue_relation, issue).and_return(false) end it 'returns error' do _, updates, message = service.execute(content, issue) expect(updates).to be_empty expect(message).to eq('Could not apply remove_epic command.') end end end context 'epic hierarchy commands' do it_behaves_like 'execute epic hierarchy commands' end context '/copy_metadata command' do let(:another_group) { build(:group) } before do stub_licensed_features(epics: true) another_group.add_planner(current_user) group.add_planner(current_user) end context "when a work item type epic is passed" do let(:todo_label) { create(:group_label, group: group, title: 'To Do') } let(:inreview_label) { create(:group_label, group: group, title: 'In Review') } let(:milestone) { create(:milestone, :on_group, group: group, title: '9.10') } let(:service) { described_class.new(container: group, current_user: current_user) } let(:source_issuable) do create(:work_item, :epic, namespace: group, milestone: milestone).tap do |wi| wi.labels << [todo_label, inreview_label] end end let(:content) { "/copy_metadata #{source_issuable.to_reference(group)}" } it_behaves_like 'copy_metadata command' do let(:issuable) { create(:work_item, namespace: group) } end it_behaves_like 'failed command' do let(:issuable) { create(:work_item, namespace: another_group) } end end end shared_examples 'weight command' do it 'populates weight specified by the /weight command' do _, updates = service.execute(content, issuable) expect(updates).to eq(weight: weight) end end shared_examples 'clear weight command' do it 'populates weight: nil if content contains /clear_weight' do issuable.update!(weight: 5) _, updates = service.execute(content, issuable) expect(updates).to eq(weight: nil) end it 'unsets weight if weight is 0' do issuable.update!(weight: 0) _, updates = service.execute(content, issuable) expect(updates).to eq(weight: nil) end end context 'issuable weights licensed' do let(:issuable) { issue } before do stub_licensed_features(issue_weights: true) end context 'weight' do let(:content) { "/weight #{weight}" } it_behaves_like 'weight command' do let(:weight) { 5 } end it_behaves_like 'weight command' do let(:weight) { 0 } end context 'when weight is negative' do it 'does not populate weight' do content = "/weight -10" _, updates = service.execute(content, issuable) expect(updates).to be_empty end end end context 'clear_weight' do it_behaves_like 'clear weight command' do let(:content) { '/clear_weight' } end end end context 'issuable weights unlicensed' do before do stub_licensed_features(issue_weights: false) end it 'does not recognise /weight X' do _, updates = service.execute('/weight 5', issue) expect(updates).to be_empty end it 'does not recognise /clear_weight' do _, updates = service.execute('/clear_weight', issue) expect(updates).to be_empty end end context 'issuable weights not supported by type' do let_it_be(:incident) { create(:incident, project: project) } before do stub_licensed_features(issue_weights: true) end it 'does not recognise /weight X' do _, updates = service.execute('/weight 5', incident) expect(updates).to be_empty end it 'does not recognise /clear_weight' do _, updates = service.execute('/clear_weight', incident) expect(updates).to be_empty end end shared_examples 'health_status command' do it 'populates health_status specified by the /health_status command' do _, updates = service.execute(content, issuable) expect(updates).to eq(health_status: health_status) end end shared_examples 'clear_health_status command' do it 'populates health_status: nil if content contains /clear_health_status' do issuable.update!(health_status: 'on_track') _, updates = service.execute(content, issuable) expect(updates).to eq(health_status: nil) end end context 'issuable health statuses licensed' do let(:issuable) { issue } before do stub_licensed_features(issuable_health_status: true) end context 'health_status' do let(:content) { "/health_status #{health_status}" } it_behaves_like 'health_status command' do let(:health_status) { 'on_track' } end it_behaves_like 'health_status command' do let(:health_status) { 'at_risk' } end context 'when health_status is invalid' do it 'does not populate health_status' do content = "/health_status unknown" _, updates = service.execute(content, issuable) expect(updates).to be_empty end end context 'when the user does not have enough permissions' do before do allow(current_user).to receive(:can?).with(:use_quick_actions).and_return(true) allow(current_user).to receive(:can?).with(:admin_issue, issuable).and_return(false) end it 'returns an error message' do content = "/health_status on_track" _, updates, message = service.execute(content, issuable) expect(updates).to be_empty expect(message).to eq('Could not apply health_status command.') end end end context 'clear_health_status' do it_behaves_like 'clear_health_status command' do let(:content) { '/clear_health_status' } end context 'when the user does not have enough permissions' do before do allow(current_user).to receive(:can?).with(:use_quick_actions).and_return(true) allow(current_user).to receive(:can?).with(:admin_issue, issuable).and_return(false) end it 'returns an error message' do content = "/clear_health_status" _, updates, message = service.execute(content, issuable) expect(updates).to be_empty expect(message).to eq('Could not apply clear_health_status command.') end end end end context 'issuable health_status unlicensed' do before do stub_licensed_features(issuable_health_status: false) end it 'does not recognise /health_status X' do _, updates = service.execute('/health_status needs_attention', issue) expect(updates).to be_empty end it 'does not recognise /clear_health_status' do _, updates = service.execute('/clear_health_status', issue) expect(updates).to be_empty end end context 'issuable health_status not supported by type' do let_it_be(:incident) { create(:incident, project: project) } before do stub_licensed_features(issuable_health_status: true) end it 'does not recognise /health_status X' do _, updates = service.execute('/health_status on_track', incident) expect(updates).to be_empty end it 'does not recognise /clear_health_status' do _, updates = service.execute('/clear_health_status', incident) expect(updates).to be_empty end end shared_examples 'empty command' do it 'populates {} if content contains an unsupported command' do _, updates = service.execute(content, issuable) expect(updates).to be_empty end end context 'not persisted merge request can not be merged' do it_behaves_like 'empty command' do let(:content) { "/merge" } let(:issuable) { build(:merge_request, source_project: project) } end end context 'not approved merge request can not be merged' do before do merge_request.target_project.update!(approvals_before_merge: 1) end it_behaves_like 'empty command' do let(:content) { "/merge" } let(:issuable) { build(:merge_request, source_project: project) } end end context 'when the merge request is not approved' do let!(:rule) { create(:any_approver_rule, merge_request: merge_request, approvals_required: 1) } let(:content) { '/merge' } context 'when "merge_when_checks_pass" is enabled' do let(:service) do described_class.new( container: project, current_user: current_user, params: { merge_request_diff_head_sha: merge_request.diff_head_sha } ) end it 'runs merge command and returns merge message' do _, updates, message = service.execute(content, merge_request) expect(updates).to eq(merge: merge_request.diff_head_sha) expect(message).to eq('Scheduled to merge this merge request (Merge when checks pass).') end end end context 'when the merge request is blocked' do let(:content) { '/merge' } let(:service) do described_class.new( container: project, current_user: current_user, params: { merge_request_diff_head_sha: issuable.diff_head_sha } ) end let(:issuable) { create(:merge_request, :blocked, source_project: project) } before do stub_licensed_features(blocking_merge_requests: true) end it 'runs merge command and returns merge message' do _, updates, message = service.execute(content, issuable) expect(updates).to eq(merge: issuable.diff_head_sha) expect(message).to eq('Scheduled to merge this merge request (Merge when checks pass).') end end context 'approved merge request can be merged' do before do merge_request.update!(approvals_before_merge: 1) merge_request.approvals.create!(user: current_user) end it_behaves_like 'empty command' do let(:content) { "/merge" } let(:issuable) { build(:merge_request, source_project: project) } end end context 'confidential command' do context 'for test cases' do it 'does mark to update confidential attribute' do issuable = create(:quality_test_case, project: project) _, updates, message = service.execute('/confidential', issuable) expect(message).to eq('Made this issue confidential.') expect(updates[:confidential]).to eq(true) end end context 'for requirements' do it 'fails supports confidentiality condition' do issuable = create(:issue, :requirement, project: project) _, updates, message = service.execute('/confidential', issuable) expect(message).to eq('Could not apply confidential command.') expect(updates[:confidential]).to be_nil end end context 'for epics' do let_it_be(:target_epic) { create(:epic, group: group) } let(:content) { '/confidential' } before do stub_licensed_features(epics: true) group.add_developer(current_user) end shared_examples 'command not applied' do it 'returns unsuccessful execution message' do _, updates, message = service.execute(content, target_epic) expect(message).to eq(execution_message) expect(updates[:confidential]).to eq(true) end end it 'returns correct explain message' do _, explanations = service.explain(content, target_epic) expect(explanations).to match_array(['Makes this epic confidential.']) end it 'returns successful execution message' do _, updates, message = service.execute(content, target_epic) expect(message).to eq('Made this epic confidential.') expect(updates[:confidential]).to eq(true) end context 'when epic has non-confidential issues' do before do target_epic.update!(confidential: false) issue.update!(confidential: false) create(:epic_issue, epic: target_epic, issue: issue) end it_behaves_like 'command not applied' do let_it_be(:execution_message) do 'Cannot make the epic confidential if it contains non-confidential issues' end end end context 'when epic has non-confidential epics' do before do target_epic.update!(confidential: false) create(:epic, group: group, parent: target_epic, confidential: false) end it_behaves_like 'command not applied' do let_it_be(:execution_message) do 'Cannot make the epic confidential if it contains non-confidential child epics' end end end context 'when a user has no permissions to set confidentiality' do before do group.add_guest(current_user) end it 'does not update epic confidentiality' do _, updates, message = service.execute(content, target_epic) expect(message).to eq('Could not apply confidential command.') expect(updates[:confidential]).to be_nil end end end end context 'blocking issues commands' do let(:user) { current_user } it_behaves_like 'issues link quick action', :blocks it_behaves_like 'issues link quick action', :blocked_by end context 'unlink command' do let_it_be(:unlink_target) { create(:issue, project: project) } let(:content) { "/unlink #{unlink_target.to_reference(issue)}" } subject(:unlink_issues) { service.execute(content, issue) } shared_examples 'command applied successfully' do it 'executes command successfully' do expect { unlink_issues }.to change { IssueLink.count }.by(-1) expect(unlink_issues[2]).to eq("Removed linked item #{unlink_target.to_reference(issue)}.") expect(issue.notes.last.note).to eq("removed the relation with #{unlink_target.to_reference}") expect(unlink_target.notes.last.note).to eq("removed the relation with #{issue.to_reference}") end end context 'when command includes blocking issue' do before do create(:issue_link, source: unlink_target, target: issue, link_type: 'blocks') end it_behaves_like 'command applied successfully' end context 'when command includes blocked issue' do before do create(:issue_link, source: issue, target: unlink_target, link_type: 'blocks') end it_behaves_like 'command applied successfully' end context 'when target is not an issue' do let_it_be(:unlink_target) { create(:work_item, :epic, namespace: group) } let_it_be(:unlink_source) { create(:work_item, :epic, namespace: group) } let_it_be(:issue) { unlink_source } before do group.add_owner(current_user) stub_licensed_features(epics: true) create(:issue_link, source: unlink_source, target: unlink_target, link_type: 'relates_to') end it_behaves_like 'command applied successfully' end context 'when provided issue is not linked' do it 'fails to execute command' do expect { unlink_issues }.not_to change { IssueLink.count } expect(unlink_issues[2]).to eq('No linked issue matches the provided parameter.') end end end describe 'status command' do shared_examples 'a failed command execution' do it 'fails with message' do _, updates, message = execute_command expect(message).to eq(expected_message) expect(updates).not_to have_key(:status) end end shared_examples 'a successful command execution' do it 'adds status reference to updates with message' do _, updates, message = execute_command expect(message).to eq(expected_message) expect(updates[:status]).to have_attributes( id: 2, name: 'In progress' ) end end shared_examples 'command is not available' do it 'is not part of the available commands' do expect(service.available_commands(work_item)).not_to include(a_hash_including(name: :status)) end end let_it_be_with_reload(:work_item) { create(:work_item, :task, project: project) } let(:content) { '/status in progress' } let(:expected_message) { format(s_("WorkItemStatus|Status set to %{status_name}."), status_name: 'In progress') } let(:generic_error_message) { 'Could not apply status command.' } subject(:execute_command) { service.execute(content, work_item) } before do stub_licensed_features(work_item_status: true) end it 'is part of the available commands' do expect(service.available_commands(work_item)).to include(a_hash_including(name: :status)) end it 'returns correct explain message' do _, explanations = service.explain(content, work_item) expect(explanations).to match_array([ # Lower case version because we use status_name and not status object format(s_("WorkItemStatus|Set status to %{status_name}."), status_name: 'in progress') ]) end it_behaves_like 'a successful command execution' context 'when status name does not reference a valid status' do let(:content) { '/status invalid' } let(:expected_message) do format( s_("WorkItemStatus|%{status_name} is not a valid status for this item."), { status_name: 'invalid' } ) end it_behaves_like 'a failed command execution' end context 'when status widget is not available for work item type' do let_it_be_with_reload(:work_item) { create(:work_item, :ticket, project: project) } let(:expected_message) { generic_error_message } it_behaves_like 'command is not available' it_behaves_like 'a failed command execution' end context 'when work_item_status licensed feature is disabled' do let(:expected_message) { generic_error_message } before do stub_licensed_features(work_item_status: false) end it_behaves_like 'command is not available' it_behaves_like 'a failed command execution' end context 'when work_item_status_feature_flag feature flag is disabled' do let(:expected_message) { generic_error_message } before do stub_feature_flags(work_item_status_feature_flag: false) end it_behaves_like 'command is not available' it_behaves_like 'a failed command execution' end end it_behaves_like 'quick actions that change work item type ee' end describe '#explain' do describe 'health_status command' do let(:content) { '/health_status on_track' } context 'issuable health statuses licensed' do before do stub_licensed_features(issuable_health_status: true) end it 'includes the value' do _, explanations = service.explain(content, issue) expect(explanations).to eq(['Sets health status to on_track.']) end end end describe 'epic command' do before do stub_licensed_features(epics: true) end context 'for an issue' do let(:issue) { create(:issue, project: project) } let(:epic) { create(:epic, group: project.group) } let(:content) { "/epic #{epic.to_reference}" } it 'applies the correct explanation' do _, explanations = service.explain(content, issue) expect(explanations).to eq(["Set #{epic.to_reference} as this item's parent item."]) end end context 'for a work_item' do let(:issue_work_item) { create(:work_item, :issue, project: project) } let(:epic_work_item) { create(:work_item, :epic, namespace: project.group) } let(:content) { "/epic #{epic_work_item.to_reference}" } it 'applies the correct explanation' do _, explanations = service.explain(content, issue_work_item) expect(explanations).to eq(["Set #{epic_work_item.to_reference} as this item's parent item."]) end end end describe 'milestone command' do context 'on group-level work items' do let_it_be(:group_milestone) { create(:milestone, group: group, title: 'Group Milestone') } let_it_be(:group_work_item) { create(:work_item, :epic, :group_level, namespace: group) } let_it_be(:group_service) { described_class.new(container: group, current_user: developer) } before do group.add_developer(developer) stub_licensed_features(epics: true) end it 'includes the milestone command in available commands for group level work items' do expect(group_service.available_commands(group_work_item)).to include(a_hash_including(name: :milestone)) end it 'updates the milestone on a group level work item' do _, updates, _ = group_service.execute("/milestone %\"#{group_milestone.title}\"", group_work_item) expect(updates).to eq(milestone_id: group_milestone.id) end end end describe 'unassign command' do let(:content) { '/unassign' } let(:issue) { create(:issue, project: project, assignees: [user, user2]) } it "includes all assignees' references" do _, explanations = service.explain(content, issue) expect(explanations).to eq(["Removes assignees @#{user.username} and @#{user2.username}."]) end end describe 'unassign command with assignee references' do let(:content) { "/unassign @#{user.username} @#{user3.username}" } let(:issue) { create(:issue, project: project, assignees: [user, user2, user3]) } it 'includes only selected assignee references' do _, explanations = service.explain(content, issue) expect(explanations.first).to match(/Removes assignees/) expect(explanations.first).to match("@#{user3.username}") expect(explanations.first).to match("@#{user.username}") end end describe 'weight command' do let(:content) { '/weight 4' } it 'includes the number' do _, explanations = service.explain(content, issue) expect(explanations).to eq(['Sets weight to 4.']) end context 'for a work item type that does not support weight' do let_it_be(:target) { create(:work_item, :epic, :group_level, namespace: group) } it_behaves_like 'quick action is unavailable', :weight it '/weight explain message is not available' do _, explanations = service.explain(content, target) expect(explanations).to be_empty end end end describe 'linked items commands' do let_it_be(:guest) { create(:user) } let_it_be(:restricted_project) { create(:project) } let_it_be(:ref1) { create(:issue, project: project).to_reference } let_it_be(:ref2) { create(:issue, project: project).to_reference } let_it_be(:ref3) { create(:work_item, project: project).to_reference } let_it_be(:ref4) { create(:work_item, :epic, namespace: group).to_reference } let_it_be(:ref5) { create(:work_item, :epic_with_legacy_epic, namespace: group).to_reference(full: true) } let(:target) { issue } before do issue.project.add_guest(guest) end context 'with /blocks' do let(:blocks_command) { "/blocks #{ref1} #{ref2} #{ref3} #{ref4}" } context 'with sufficient permissions' do before do issue.project.add_developer(current_user) end it '/blocks is available' do _, explanations = service.explain(blocks_command, issue) expect(explanations).to contain_exactly("Set this issue as blocking #{[ref1, ref2, ref3, ref4].to_sentence}.") end context 'with task as target' do let_it_be(:task) { create(:work_item, :task, project: project) } let(:target) { task } it 'replaces issue in explanation with task' do _, explanations = service.explain(blocks_command, task) expect(explanations).to contain_exactly("Set this task as blocking #{[ref1, ref2, ref3, ref4].to_sentence}.") end end context 'when licensed feature is not available' do before do stub_licensed_features(blocked_issues: false) end it_behaves_like 'quick action is unavailable', :blocks end context 'when target is not an issue' do let(:target) { create(:epic, group: group) } it_behaves_like 'quick action is unavailable', :blocks end end context 'with insufficient permissions' do let_it_be(:target) { create(:issue, project: restricted_project) } let(:current_user) { guest } it_behaves_like 'quick action is unavailable', :blocks end end context 'with /relate' do let(:relate_command) { "/relate #{ref1} #{ref2} #{ref3} #{ref4}" } context 'with sufficient permissions' do before do issue.project.add_developer(current_user) end it '/relate is available' do _, explanations = service.explain(relate_command, issue) expect(explanations).to contain_exactly( "Added #{[ref1, ref2, ref3, ref4].to_sentence} as a linked item related to this issue." ) end it '/relate execution method' do _, _, message = service.execute(relate_command, issue) expect(message).to eq( "Added #{[ref1, ref2, ref3, ref4].to_sentence} as a linked item related to this issue." ) end context 'when target is not an issue' do let(:target) { create(:epic, group: group) } it_behaves_like 'quick action is unavailable', :relate end context 'when target is a work item epic with a legacy epic' do let(:target) { create(:work_item, :epic_with_legacy_epic, namespace: group) } let(:relate_command) { "/relate #{ref5}" } before do group.add_developer(current_user) stub_licensed_features(epics: true, related_epics: true) end it '/relate execution method' do expect { service.execute(relate_command, target) } .to change { IssueLink.count }.by(1) .and change { Epic::RelatedEpicLink.count }.by(1) end end end context 'with insufficient permissions' do let_it_be(:target) { create(:issue, project: restricted_project) } let(:current_user) { guest } it_behaves_like 'quick action is unavailable', :relate end end context 'with /unlink' do let(:unlink_command) { "/unlink #{ref1}" } context 'with sufficient permissions' do before do issue.project.add_guest(current_user) end it '/unlink is available' do _, explanations = service.explain(unlink_command, issue) expect(explanations) .to contain_exactly("Removes linked item #{project.issues.second.to_reference(issue)}.") end context 'when target is not an issue' do let(:target) { create(:epic, group: group) } it_behaves_like 'quick action is unavailable', :unlink end end context 'with insufficient permissions' do let_it_be(:target) { create(:issue, project: restricted_project) } let(:current_user) { guest } it_behaves_like 'quick action is unavailable', :blocks end end context 'with /blocked_by' do let(:blocked_by_command) { "/blocked_by #{ref1} #{ref2} #{ref3} #{ref4}" } context 'with sufficient permissions' do before do issue.project.add_guest(current_user) end it '/blocked_by is available' do _, explanations = service.explain(blocked_by_command, issue) expect(explanations) .to contain_exactly("Set this issue as blocked by #{[ref1, ref2, ref3, ref4].to_sentence}.") end context 'when licensed feature is not available' do before do stub_licensed_features(blocked_issues: false) end it_behaves_like 'quick action is unavailable', :blocked_by end context 'when target is not an issue' do let(:target) { create(:epic, group: group) } it_behaves_like 'quick action is unavailable', :blocked_by end end context 'with insufficient permissions' do let_it_be(:target) { create(:issue, project: restricted_project) } let(:current_user) { guest } it_behaves_like 'quick action is unavailable', :blocked_by end end end describe 'promote_to command' do let(:content) { '/promote_to objective' } context 'when work item supports promotion' do context 'with key result' do let_it_be(:key_result) { build(:work_item, :key_result, project: project) } it 'includes the value' do _, explanations = service.explain(content, key_result) expect(explanations).to eq(['Promotes item to objective.']) end end context 'with issue' do let_it_be(:issue) { build(:work_item, :issue, project: project) } where(type: %w[incident epic]) with_them do it 'includes the type in the explanation' do _, explanations = service.explain("/promote_to #{type}", issue) expect(explanations).to eq(["Promotes item to #{type}."]) end end end end context 'when work item does not support promotion' do let_it_be(:requirement) { build(:work_item, :requirement, project: project) } it 'does not include the value' do _, explanations = service.explain(content, requirement) expect(explanations).to be_empty end end end describe 'checkin_reminder command' do let(:checkin_reminder_command) { "/checkin_reminder weekly" } context 'for a work item type that supports reminders' do let(:objective) { create(:work_item, :objective, project: project, author: current_user) } it '/checkin_reminder is available' do _, explanations = service.explain(checkin_reminder_command, objective) expect(explanations).to contain_exactly("Sets checkin reminder frequency to weekly.") end end context 'for a work item type that does not support reminders' do let(:key_result) { create(:work_item, :key_result, project: project) } it '/checkin_reminder is available' do _, explanations = service.explain(checkin_reminder_command, key_result) expect(explanations).not_to contain_exactly("Sets checkin reminder frequency to weekly.") end end end describe 'epic hierarchy commands' do it_behaves_like 'explain epic hierarchy commands' end end context '/duplicate command' do before do stub_licensed_features(epics: true) group.add_developer(current_user) end context 'when canonical item is an epic' do let(:duplicate_item) { create(:work_item, :epic_with_legacy_epic, namespace: group) } context 'when epic work item' do context 'with reference' do it_behaves_like 'duplicate command' do let(:content) { "/duplicate #{duplicate_item.to_reference(project, full: true)}" } let(:issuable) { issue } end end context 'with url' do it_behaves_like 'duplicate command' do let(:content) { "/duplicate #{Gitlab::UrlBuilder.build(duplicate_item)}" } let(:issuable) { issue } end end end context 'when legacy epic' do context 'with reference' do it_behaves_like 'duplicate command' do let(:content) { "/duplicate #{duplicate_item.sync_object.to_reference(project, full: true)}" } let(:issuable) { issue } end end context 'with url' do it_behaves_like 'duplicate command' do let(:content) { "/duplicate #{Gitlab::UrlBuilder.build(duplicate_item.sync_object)}" } let(:issuable) { issue } end end end end context 'when duplicate item is an epic work item' do let(:canonical_item) { create(:work_item, :epic_with_legacy_epic, namespace: group) } let(:duplicate_item) { issue } let(:service) { described_class.new(container: group, current_user: current_user) } context 'with reference' do it_behaves_like 'duplicate command' do let(:content) { "/duplicate #{duplicate_item.to_reference(group)}" } let(:issuable) { canonical_item } end end context 'with url' do it_behaves_like 'duplicate command' do let(:content) { "/duplicate #{Gitlab::UrlBuilder.build(duplicate_item)}" } let(:issuable) { canonical_item } end end end end describe 'clone issue command' do let(:content) { "/clone #{group.full_path}" } let(:group_service) { described_class.new(container: group, current_user: current_user) } let_it_be(:work_item) { create(:work_item, :epic_with_legacy_epic, :group_level, namespace: group) } before do group.add_maintainer(current_user) stub_licensed_features(epics: true) end context "when work item type is an epic" do it "/clone is available" do _, explanations = group_service.explain(content, work_item) expected_string = "Clones this item, without comments, to #{group.full_path}." expect(explanations).to match_array([_(expected_string)]) end it "recognizes the clone action when move and clone commands are supported" do expect(service.available_commands(work_item).pluck(:name)).to include(:clone) end it "does not recognize the clone action when move and clone commands are not supported" do allow(work_item).to receive(:supports_move_and_clone?).and_return(false) expect(service.available_commands(work_item).pluck(:name)).not_to include(:clone) end it 'returns the clone item message' do _, _, message = service.execute("/clone #{group.full_path}", work_item) translated_string = _("Cloned this item to %{group_full_path}.") formatted_message = format(translated_string, group_full_path: group.full_path.to_s) expect(message).to eq(formatted_message) end it 'returns clone item failure message when the referenced group is not found' do _, _, message = service.execute('/clone invalid', work_item) expect(message).to eq(_("Unable to clone. Target project or group doesn't exist or doesn't support this item type.")) end it 'returns clone item failure message when the path provided is to a project' do _, _, message = service.execute("/clone #{project.full_path}", work_item) expect(message).to eq(_("Unable to clone. Target project or group doesn't exist or doesn't support this item type.")) end it 'returns clone item failure message when the referenced group not authorized' do _, _, message = service.execute("/clone #{create(:group).full_path}", work_item) expect(message).to eq(_("Unable to clone. Insufficient permissions.")) end end end describe '/move issue command' do let(:target_group) { create(:group) } let(:content) { "/move #{target_group.full_path}" } let(:group_service) { described_class.new(container: group, current_user: current_user) } let_it_be(:work_item) { create(:work_item, :epic_with_legacy_epic, :group_level, namespace: group) } before do group.add_maintainer(current_user) target_group.add_maintainer(current_user) stub_licensed_features(epics: true) end context "when work item type is epic" do it "/move is available" do _, explanations = group_service.explain(content, work_item) expected_string = "Moves this item to #{target_group.full_path}." expect(explanations).to match_array([_(expected_string)]) end it 'recognizes the move action when move and clone is supported' do expect(service.available_commands(work_item).pluck(:name)).to include(:move) end it "does not recognize the move action when move and clone commands are not supported" do allow(work_item).to receive(:supports_move_and_clone?).and_return(false) expect(service.available_commands(work_item).pluck(:name)).not_to include(:move) end it "returns the move item message" do _, _, message = service.execute(content, work_item) translated_string = _("Moved this item to %{group_full_path}.") formatted_message = format(translated_string, group_full_path: target_group.full_path.to_s) expect(message).to eq(formatted_message) end it "returns move item failure message when target group is not found" do _, _, message = service.execute('/move invalid', work_item) expect(message).to eq(_("Unable to move. Target project or group doesn't exist or doesn't support this item type.")) end it "returns move item failure message when the path provided is to a project" do _, _, message = service.execute("/move #{project.full_path}", work_item) expect(message).to eq(_("Unable to move. Target project or group doesn't exist or doesn't support this item type.")) end it 'returns move item failure message when the referenced group not authorized' do _, _, message = service.execute("/move #{create(:group).full_path}", work_item) expect(message).to eq(_("Unable to move. Insufficient permissions.")) end end end end