Commit a033bff8 authored by Alex Kalderimis's avatar Alex Kalderimis

Introduce concept of parse errors for quick actions

This allows quick actions to signal that they cannot be executed due to
failure to interpret the arguments passed to them.

An initial use is to guard against destructive updates caused by failure
to parse/find users in assignment commands.

As a result, behavior with invalid arguments changes, from tolerance
of bad arguments to stricter validation. Some operations that previously
would succeed (e.g. `/assign me to this issue`) will now fail.

Changelog: fixed
parent 660000b6
...@@ -61,6 +61,10 @@ module QuickActions ...@@ -61,6 +61,10 @@ module QuickActions
private private
def failed_parse(message)
raise Gitlab::QuickActions::CommandDefinition::ParseError, message
end
def extractor def extractor
Gitlab::QuickActions::Extractor.new(self.class.command_definitions) Gitlab::QuickActions::Extractor.new(self.class.command_definitions)
end end
...@@ -69,6 +73,7 @@ module QuickActions ...@@ -69,6 +73,7 @@ module QuickActions
def extract_users(params) def extract_users(params)
return [] if params.nil? return [] if params.nil?
args = params.split(' ').uniq
users = extract_references(params, :user) users = extract_references(params, :user)
if users.empty? if users.empty?
...@@ -76,10 +81,12 @@ module QuickActions ...@@ -76,10 +81,12 @@ module QuickActions
if params.strip == 'me' if params.strip == 'me'
[current_user] [current_user]
else else
User.where(username: params.split(' ').map(&:strip)) User.where(username: args)
end end
end end
failed_parse(format(_("Failed to find users for '%{params}'"), params: params)) unless users.size == args.size
users users
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
......
...@@ -194,6 +194,15 @@ RSpec.describe QuickActions::InterpretService do ...@@ -194,6 +194,15 @@ RSpec.describe QuickActions::InterpretService do
expect(updates[:reviewer_ids]).to match_array([user3.id]) expect(updates[:reviewer_ids]).to match_array([user3.id])
end 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
end end
end end
...@@ -224,9 +233,18 @@ RSpec.describe QuickActions::InterpretService do ...@@ -224,9 +233,18 @@ RSpec.describe QuickActions::InterpretService do
expect(updates[:assignee_ids]).to be_empty expect(updates[:assignee_ids]).to be_empty
end 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 end
context 'Merge Request' do context 'with a Merge Request' do
let(:merge_request) { create(:merge_request, source_project: project) } let(:merge_request) { create(:merge_request, source_project: project) }
it 'unassigns user if content contains /unassign @user' do it 'unassigns user if content contains /unassign @user' do
...@@ -237,7 +255,7 @@ RSpec.describe QuickActions::InterpretService do ...@@ -237,7 +255,7 @@ RSpec.describe QuickActions::InterpretService do
expect(updates[:assignee_ids]).to match_array([user.id]) expect(updates[:assignee_ids]).to match_array([user.id])
end end
context 'unassign command with multiple assignees' do describe 'applying unassign command with multiple assignees' do
it 'unassigns both users if content contains /unassign @user @user1' do it 'unassigns both users if content contains /unassign @user @user1' do
merge_request.update!(assignee_ids: [user.id, user2.id, user3.id]) merge_request.update!(assignee_ids: [user.id, user2.id, user3.id])
...@@ -246,7 +264,7 @@ RSpec.describe QuickActions::InterpretService do ...@@ -246,7 +264,7 @@ RSpec.describe QuickActions::InterpretService do
expect(updates[:assignee_ids]).to match_array([user3.id]) expect(updates[:assignee_ids]).to match_array([user3.id])
end end
context 'unlicensed' do context 'when unlicensed' do
before do before do
stub_licensed_features(multiple_merge_request_assignees: false) stub_licensed_features(multiple_merge_request_assignees: false)
end end
......
...@@ -3,6 +3,8 @@ ...@@ -3,6 +3,8 @@
module Gitlab module Gitlab
module QuickActions module QuickActions
class CommandDefinition class CommandDefinition
ParseError = Class.new(StandardError)
attr_accessor :name, :aliases, :description, :explanation, :execution_message, attr_accessor :name, :aliases, :description, :explanation, :execution_message,
:params, :condition_block, :parse_params_block, :action_block, :warning, :icon, :types :params, :condition_block, :parse_params_block, :action_block, :warning, :icon, :types
...@@ -41,7 +43,11 @@ module Gitlab ...@@ -41,7 +43,11 @@ module Gitlab
return unless available?(context) return unless available?(context)
message = if explanation.respond_to?(:call) message = if explanation.respond_to?(:call)
begin
execute_block(explanation, context, arg) execute_block(explanation, context, arg)
rescue ParseError => e
format(_('Problem with %{name} command: %{message}.'), name: name, message: e.message)
end
else else
explanation explanation
end end
...@@ -63,6 +69,8 @@ module Gitlab ...@@ -63,6 +69,8 @@ module Gitlab
return unless available?(context) return unless available?(context)
execute_block(action_block, context, arg) execute_block(action_block, context, arg)
rescue ParseError
# message propagation is handled in `execution_message`.
end end
def execute_message(context, arg) def execute_message(context, arg)
...@@ -74,6 +82,8 @@ module Gitlab ...@@ -74,6 +82,8 @@ module Gitlab
else else
execution_message execution_message
end end
rescue ParseError => e
format _('Could not apply %{name} command. %{message}.'), name: name, message: e.message
end end
def to_h(context) def to_h(context)
......
...@@ -184,7 +184,7 @@ module Gitlab ...@@ -184,7 +184,7 @@ module Gitlab
execution_message do |users = nil| execution_message do |users = nil|
reviewers = reviewers_to_add(users) reviewers = reviewers_to_add(users)
if reviewers.blank? if reviewers.blank?
_("Failed to assign a reviewer because no user was found.") _("Failed to assign a reviewer because no user was specified.")
else else
_('Assigned %{reviewer_users_sentence} as %{reviewer_text}.') % { reviewer_users_sentence: reviewer_users_sentence(users), _('Assigned %{reviewer_users_sentence} as %{reviewer_text}.') % { reviewer_users_sentence: reviewer_users_sentence(users),
reviewer_text: 'reviewer'.pluralize(reviewers.size) } reviewer_text: 'reviewer'.pluralize(reviewers.size) }
......
...@@ -9948,6 +9948,9 @@ msgstr "" ...@@ -9948,6 +9948,9 @@ msgstr ""
msgid "Could not apply %{name} command." msgid "Could not apply %{name} command."
msgstr "" msgstr ""
msgid "Could not apply %{name} command. %{message}."
msgstr ""
msgid "Could not authorize chat nickname. Try again!" msgid "Could not authorize chat nickname. Try again!"
msgstr "" msgstr ""
...@@ -14778,7 +14781,7 @@ msgid_plural "Failed to archive designs. Please try again." ...@@ -14778,7 +14781,7 @@ msgid_plural "Failed to archive designs. Please try again."
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "Failed to assign a reviewer because no user was found." msgid "Failed to assign a reviewer because no user was specified."
msgstr "" msgstr ""
msgid "Failed to assign a user because no user was found." msgid "Failed to assign a user because no user was found."
...@@ -14844,6 +14847,9 @@ msgstr "" ...@@ -14844,6 +14847,9 @@ msgstr ""
msgid "Failed to find import label for Jira import." msgid "Failed to find import label for Jira import."
msgstr "" msgstr ""
msgid "Failed to find users for '%{params}'"
msgstr ""
msgid "Failed to generate export, please try again later." msgid "Failed to generate export, please try again later."
msgstr "" msgstr ""
...@@ -27316,6 +27322,9 @@ msgstr "" ...@@ -27316,6 +27322,9 @@ msgstr ""
msgid "Private projects can be created in your personal namespace with:" msgid "Private projects can be created in your personal namespace with:"
msgstr "" msgstr ""
msgid "Problem with %{name} command: %{message}."
msgstr ""
msgid "Proceed" msgid "Proceed"
msgstr "" msgstr ""
......
...@@ -485,6 +485,8 @@ RSpec.describe QuickActions::InterpretService do ...@@ -485,6 +485,8 @@ RSpec.describe QuickActions::InterpretService do
end end
shared_examples 'failed command' do |error_msg| 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 it 'populates {} if content contains an unsupported command' do
_, updates, _ = service.execute(content, issuable) _, updates, _ = service.execute(content, issuable)
...@@ -494,11 +496,7 @@ RSpec.describe QuickActions::InterpretService do ...@@ -494,11 +496,7 @@ RSpec.describe QuickActions::InterpretService do
it "returns #{error_msg || 'an empty'} message" do it "returns #{error_msg || 'an empty'} message" do
_, _, message = service.execute(content, issuable) _, _, message = service.execute(content, issuable)
if error_msg expect(message).to match_msg
expect(message).to eq(error_msg)
else
expect(message).to be_empty
end
end end
end end
...@@ -887,9 +885,10 @@ RSpec.describe QuickActions::InterpretService do ...@@ -887,9 +885,10 @@ RSpec.describe QuickActions::InterpretService do
end end
end end
it_behaves_like 'failed command', "Failed to assign a user because no user was found." do it_behaves_like 'failed command', 'a parse error' do
let(:content) { '/assign @abcd1234' } let(:content) { '/assign @abcd1234' }
let(:issuable) { issue } let(:issuable) { issue }
let(:match_msg) { eq "Could not apply assign command. Failed to find users for '@abcd1234'." }
end end
it_behaves_like 'failed command', "Failed to assign a user because no user was found." do it_behaves_like 'failed command', "Failed to assign a user because no user was found." do
...@@ -953,7 +952,9 @@ RSpec.describe QuickActions::InterpretService do ...@@ -953,7 +952,9 @@ RSpec.describe QuickActions::InterpretService do
context 'with an incorrect user' do context 'with an incorrect user' do
let(:content) { '/assign_reviewer @abcd1234' } let(:content) { '/assign_reviewer @abcd1234' }
it_behaves_like 'failed command', "Failed to assign a reviewer because no user was found." it_behaves_like 'failed command', 'a parse error' do
let(:match_msg) { eq "Could not apply assign_reviewer command. Failed to find users for '@abcd1234'." }
end
end end
context 'with the "reviewer" alias' do context 'with the "reviewer" alias' do
...@@ -971,13 +972,16 @@ RSpec.describe QuickActions::InterpretService do ...@@ -971,13 +972,16 @@ RSpec.describe QuickActions::InterpretService do
context 'with no user' do context 'with no user' do
let(:content) { '/assign_reviewer' } let(:content) { '/assign_reviewer' }
it_behaves_like 'failed command', "Failed to assign a reviewer because no user was found." it_behaves_like 'failed command', "Failed to assign a reviewer because no user was specified."
end end
context 'includes only the user reference with extra text' do context 'with extra text' do
let(:content) { "/assign_reviewer @#{developer.username} do it!" } let(:arg) { "@#{developer.username} do it!" }
let(:content) { "/assign_reviewer #{arg}" }
it_behaves_like 'assign_reviewer command' it_behaves_like 'failed command', 'a parse error' do
let(:match_msg) { eq "Could not apply assign_reviewer command. Failed to find users for '#{arg}'." }
end
end end
end end
...@@ -2317,15 +2321,44 @@ RSpec.describe QuickActions::InterpretService do ...@@ -2317,15 +2321,44 @@ RSpec.describe QuickActions::InterpretService do
end end
describe 'assign command' do describe 'assign command' do
let(:content) { "/assign @#{developer.username} do it!" } shared_examples 'assigns developer' do
it 'tells us we will assign the developer' do
it 'includes only the user reference' do
_, explanations = service.explain(content, merge_request) _, explanations = service.explain(content, merge_request)
expect(explanations).to eq(["Assigns @#{developer.username}."]) expect(explanations).to eq(["Assigns @#{developer.username}."])
end end
end end
context 'when using a reference' do
let(:content) { "/assign @#{developer.username}" }
include_examples 'assigns developer'
end
context 'when using a bare username' do
let(:content) { "/assign #{developer.username}" }
include_examples 'assigns developer'
end
context 'when using me' do
let(:content) { "/assign me" }
include_examples 'assigns developer'
end
context 'when there are unparseable arguments' do
let(:arg) { "#{developer.username} to this issue" }
let(:content) { "/assign #{arg}" }
it 'tells us why we cannot do that' do
_, explanations = service.explain(content, merge_request)
expect(explanations).to eq ["Problem with assign command: Failed to find users for '#{arg}'."]
end
end
end
describe 'unassign command' do describe 'unassign command' do
let(:content) { '/unassign' } let(:content) { '/unassign' }
let(:issue) { create(:issue, project: project, assignees: [developer]) } let(:issue) { create(:issue, project: project, assignees: [developer]) }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment