diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb index 04c63d8e87693abb5380b5d58f95f04acef02a90..741a0ca771f7825bcf49ab338f3534db339b3f3b 100644 --- a/app/graphql/mutations/work_items/update.rb +++ b/app/graphql/mutations/work_items/update.rb @@ -22,6 +22,8 @@ def resolve(id:, **attributes) spam_params = ::Spam::SpamParams.new_from_request(request: context[:request]) widget_params = extract_widget_params!(work_item.work_item_type, attributes) + interpret_quick_actions!(work_item, current_user, widget_params, attributes) + update_result = ::WorkItems::UpdateService.new( project: work_item.project, current_user: current_user, @@ -43,6 +45,37 @@ def resolve(id:, **attributes) def find_object(id:) GitlabSchema.find_by_gid(id) end + + def interpret_quick_actions!(work_item, current_user, widget_params, attributes = {}) + return unless work_item.work_item_type.widgets.include?(::WorkItems::Widgets::Description) + + description_param = widget_params[::WorkItems::Widgets::Description.api_symbol] + return unless description_param + + original_description = description_param.fetch(:description, work_item.description) + + description, command_params = QuickActions::InterpretService + .new(work_item.project, current_user, {}) + .execute(original_description, work_item) + + description_param[:description] = description if description && description != original_description + + # Widgets have a set of quick action params that they must process. + # Map them to widget_params so they can be picked up by widget services. + work_item.work_item_type.widgets + .filter { |widget| widget.respond_to?(:quick_action_params) } + .each do |widget| + widget.quick_action_params + .filter { |param_name| command_params.key?(param_name) } + .each do |param_name| + widget_params[widget.api_symbol] ||= {} + widget_params[widget.api_symbol][param_name] = command_params.delete(param_name) + end + end + + # The command_params not processed by widgets (e.g. title) should be placed in 'attributes'. + attributes.merge!(command_params || {}) + end end end end diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb index 9b434ef946cc92eb46389278413428e6a7b1490f..258a86d73164bc33ffcb01b725d714f1e4210c02 100644 --- a/app/models/work_items/type.rb +++ b/app/models/work_items/type.rb @@ -145,6 +145,10 @@ def supports_assignee? widgets.include? ::WorkItems::Widgets::Assignees end + def default_issue? + name == WorkItems::Type::TYPE_NAMES[:issue] + end + private def strip_whitespace diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index f6a1db2dcaa3398f5fc92ded6ef89a6fab81e925..a92110fd84338a0336603bb9b116c26fcdcdfcf9 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -100,6 +100,14 @@ def resolve_discussions_with_issue(issue) private + def handle_quick_actions(issue) + # Do not handle quick actions unless the work item is the default Issue. + # The available quick actions for a work item depend on its type and widgets. + return if @params[:work_item_type].present? && @params[:work_item_type] != WorkItems::Type.default_by_type(:issue) + + super + end + def authorization_action :create_issue end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index d56e7858990a5cc823b813458251e77418306a82..d43df0da3fdd3e89bcb47b6c02d57d6459471516 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -116,6 +116,14 @@ def move_issue_to_new_project(issue) attr_reader :spam_params + def handle_quick_actions(issue) + # Do not handle quick actions unless the work item is the default Issue. + # The available quick actions for a work item depend on its type and widgets. + return unless issue.work_item_type.default_issue? + + super + end + def handle_date_changes(issue) return unless issue.previous_changes.slice('due_date', 'start_date').any? diff --git a/ee/spec/requests/api/graphql/mutations/work_items/update_spec.rb b/ee/spec/requests/api/graphql/mutations/work_items/update_spec.rb index 77e834640a9896840406dd391a4e581f87d5ccd4..550d88ef1e15f31905f6dd011eec149dc441237e 100644 --- a/ee/spec/requests/api/graphql/mutations/work_items/update_spec.rb +++ b/ee/spec/requests/api/graphql/mutations/work_items/update_spec.rb @@ -159,6 +159,9 @@ ... on WorkItemWidgetWeight { weight } + ... on WorkItemWidgetDescription { + description + } } } errors @@ -203,6 +206,43 @@ expect(response).to have_gitlab_http_status(:success) end end + + context 'when using quick action' do + let(:input) { { 'descriptionWidget' => { 'description' => "/weight #{new_weight}" } } } + + it_behaves_like 'update work item weight widget' + + context 'when setting weight to null' do + let(:input) { { 'descriptionWidget' => { 'description' => "/clear_weight" } } } + + before do + work_item.update!(weight: 2) + end + + it 'updates the work item' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to change(work_item, :weight).from(2).to(nil) + + expect(response).to have_gitlab_http_status(:success) + end + end + + context 'when the work item type does not support the weight widget' do + let_it_be(:work_item) { create(:work_item, :task, project: project) } + + let(:input) do + { 'descriptionWidget' => { 'description' => "Updating weight.\n/weight 1" } } + end + + before do + stub_const('::WorkItems::Type::WIDGETS_FOR_TYPE', { task: [::WorkItems::Widgets::Description] }) + end + + it_behaves_like 'work item is not updated' + end + end end it_behaves_like 'user without permission to admin work item cannot update the attribute' @@ -389,6 +429,9 @@ def work_item_status ... on WorkItemWidgetHealthStatus { healthStatus } + ... on WorkItemWidgetDescription { + description + } } } errors @@ -429,6 +472,52 @@ def work_item_status } ) end + + context 'when using quick action' do + let(:input) { { 'descriptionWidget' => { 'description' => "/health_status on_track" } } } + + it 'updates work item health status' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to change { work_item.health_status }.from('needs_attention').to('on_track') + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'healthStatus' => 'onTrack', + 'type' => 'HEALTH_STATUS' + } + ) + end + + context 'when clearing health status' do + let(:input) { { 'descriptionWidget' => { 'description' => "/clear_health_status" } } } + + it 'updates the work item' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to change { work_item.health_status }.from('needs_attention').to(nil) + + expect(response).to have_gitlab_http_status(:success) + end + end + + context 'when the work item type does not support the health status widget' do + let_it_be(:work_item) { create(:work_item, :task, project: project) } + + let(:input) do + { 'descriptionWidget' => { 'description' => "Updating health status.\n/health_status on_track" } } + end + + before do + stub_const('::WorkItems::Type::WIDGETS_FOR_TYPE', { task: [::WorkItems::Widgets::Description] }) + end + + it_behaves_like 'work item is not updated' + end + end end end end diff --git a/spec/models/work_items/type_spec.rb b/spec/models/work_items/type_spec.rb index cf2e5d25756724c36a5fb8ebb6630e09042ec181..65c6b22f5c243acb60338cc16487e3533e9af558 100644 --- a/spec/models/work_items/type_spec.rb +++ b/spec/models/work_items/type_spec.rb @@ -146,4 +146,22 @@ it { is_expected.to be_falsey } end end + + describe '#default_issue?' do + context 'when work item type is default Issue' do + let(:work_item_type) { build(:work_item_type, name: described_class::TYPE_NAMES[:issue]) } + + it 'returns true' do + expect(work_item_type.default_issue?).to be(true) + end + end + + context 'when work item type is not Issue' do + let(:work_item_type) { build(:work_item_type) } + + it 'returns false' do + expect(work_item_type.default_issue?).to be(false) + end + end + end end diff --git a/spec/requests/api/graphql/mutations/work_items/update_spec.rb b/spec/requests/api/graphql/mutations/work_items/update_spec.rb index b33a394d023c9f6a6522996d60df591b60b5c1be..271c2b917ad8d6f737ced35de05f9fcb6e88a231 100644 --- a/spec/requests/api/graphql/mutations/work_items/update_spec.rb +++ b/spec/requests/api/graphql/mutations/work_items/update_spec.rb @@ -127,7 +127,9 @@ let(:fields) do <<~FIELDS workItem { + title description + state widgets { type ... on WorkItemWidgetDescription { @@ -179,6 +181,9 @@ nodes { id } } } + ... on WorkItemWidgetDescription { + description + } } } errors @@ -201,6 +206,12 @@ let(:expected_labels) { [] } it_behaves_like 'mutation updating work item labels' + + context 'with quick action' do + let(:input) { { 'descriptionWidget' => { 'description' => "/remove_label ~\"#{existing_label.name}\"" } } } + + it_behaves_like 'mutation updating work item labels' + end end context 'when only adding labels' do @@ -208,6 +219,14 @@ let(:expected_labels) { [label1, label2, existing_label] } it_behaves_like 'mutation updating work item labels' + + context 'with quick action' do + let(:input) do + { 'descriptionWidget' => { 'description' => "/labels ~\"#{label1.name}\" ~\"#{label2.name}\"" } } + end + + it_behaves_like 'mutation updating work item labels' + end end context 'when adding and removing labels' do @@ -216,10 +235,46 @@ let(:expected_labels) { [label1, label2] } it_behaves_like 'mutation updating work item labels' + + context 'with quick action' do + let(:input) do + { 'descriptionWidget' => { 'description' => + "/label ~\"#{label1.name}\" ~\"#{label2.name}\"\n/remove_label ~\"#{existing_label.name}\"" } } + end + + it_behaves_like 'mutation updating work item labels' + end + end + + context 'when the work item type does not support labels widget' do + let_it_be(:work_item) { create(:work_item, :task, project: project) } + + let(:input) { { 'descriptionWidget' => { 'description' => "Updating labels.\n/labels ~\"#{label1.name}\"" } } } + + before do + stub_const('::WorkItems::Type::WIDGETS_FOR_TYPE', { task: [::WorkItems::Widgets::Description] }) + end + + it 'ignores the quick action' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.not_to change(work_item.labels, :count) + + expect(work_item.labels).to be_empty + expect(mutation_response['workItem']['widgets']).to include( + 'description' => "Updating labels.", + 'type' => 'DESCRIPTION' + ) + expect(mutation_response['workItem']['widgets']).not_to include( + 'labels', + 'type' => 'LABELS' + ) + end end end - context 'with due and start date widget input' do + context 'with due and start date widget input', :freeze_time do let(:start_date) { Date.today } let(:due_date) { 1.week.from_now.to_date } let(:fields) do @@ -231,6 +286,9 @@ startDate dueDate } + ... on WorkItemWidgetDescription { + description + } } } errors @@ -259,6 +317,80 @@ ) end + context 'when using quick action' do + let(:due_date) { Date.today } + + context 'when removing due date' do + let(:input) { { 'descriptionWidget' => { 'description' => "/remove_due_date" } } } + + before do + work_item.update!(due_date: due_date) + end + + it 'updates start and due date' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to not_change(work_item, :start_date).and( + change(work_item, :due_date).from(due_date).to(nil) + ) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include({ + 'startDate' => nil, + 'dueDate' => nil, + 'type' => 'START_AND_DUE_DATE' + }) + end + end + + context 'when setting due date' do + let(:input) { { 'descriptionWidget' => { 'description' => "/due today" } } } + + it 'updates due date' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to not_change(work_item, :start_date).and( + change(work_item, :due_date).from(nil).to(due_date) + ) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include({ + 'startDate' => nil, + 'dueDate' => Date.today.to_s, + 'type' => 'START_AND_DUE_DATE' + }) + end + end + + context 'when the work item type does not support start and due date widget' do + let_it_be(:work_item) { create(:work_item, :task, project: project) } + + let(:input) { { 'descriptionWidget' => { 'description' => "Updating due date.\n/due today" } } } + + before do + stub_const('::WorkItems::Type::WIDGETS_FOR_TYPE', { task: [::WorkItems::Widgets::Description] }) + end + + it 'ignores the quick action' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.not_to change(work_item, :due_date) + + expect(mutation_response['workItem']['widgets']).to include( + 'description' => "Updating due date.", + 'type' => 'DESCRIPTION' + ) + expect(mutation_response['workItem']['widgets']).not_to include({ + 'dueDate' => nil, + 'type' => 'START_AND_DUE_DATE' + }) + end + end + end + context 'when provided input is invalid' do let(:due_date) { 1.week.ago.to_date } @@ -516,6 +648,9 @@ } } } + ... on WorkItemWidgetDescription { + description + } } } errors @@ -544,6 +679,80 @@ } ) end + + context 'when using quick action' do + context 'when assigning a user' do + let(:input) { { 'descriptionWidget' => { 'description' => "/assign @#{developer.username}" } } } + + it 'updates the work item assignee' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to change(work_item, :assignee_ids).from([]).to([developer.id]) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'type' => 'ASSIGNEES', + 'assignees' => { + 'nodes' => [ + { 'id' => developer.to_global_id.to_s, 'username' => developer.username } + ] + } + } + ) + end + end + + context 'when unassigning a user' do + let(:input) { { 'descriptionWidget' => { 'description' => "/unassign @#{developer.username}" } } } + + before do + work_item.update!(assignee_ids: [developer.id]) + end + + it 'updates the work item assignee' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to change(work_item, :assignee_ids).from([developer.id]).to([]) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + 'type' => 'ASSIGNEES', + 'assignees' => { + 'nodes' => [] + } + ) + end + end + end + + context 'when the work item type does not support the assignees widget' do + let_it_be(:work_item) { create(:work_item, :task, project: project) } + + let(:input) do + { 'descriptionWidget' => { 'description' => "Updating assignee.\n/assign @#{developer.username}" } } + end + + before do + stub_const('::WorkItems::Type::WIDGETS_FOR_TYPE', { task: [::WorkItems::Widgets::Description] }) + end + + it 'ignores the quick action' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.not_to change(work_item, :assignee_ids) + + expect(mutation_response['workItem']['widgets']).to include({ + 'description' => "Updating assignee.", + 'type' => 'DESCRIPTION' + } + ) + expect(mutation_response['workItem']['widgets']).not_to include({ 'type' => 'ASSIGNEES' }) + end + end end context 'when updating milestone' do diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 7ab2046b6be46d862b4afc414c57ae6396246fd7..abb59ad7ebf2ea060111f55f3ac4c5bd2b862f79 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -565,6 +565,36 @@ end context 'Quick actions' do + context 'as work item' do + let(:opts) do + { + title: "My work item", + work_item_type: work_item_type, + description: "/shrug" + } + end + + context 'when work item type is not the default Issue' do + let(:work_item_type) { create(:work_item_type, namespace: project.namespace) } + + it 'saves the work item without applying the quick action' do + expect(result).to be_success + expect(issue).to be_persisted + expect(issue.description).to eq("/shrug") + end + end + + context 'when work item type is the default Issue' do + let(:work_item_type) { WorkItems::Type.default_by_type(:issue) } + + it 'saves the work item and applies the quick action' do + expect(result).to be_success + expect(issue).to be_persisted + expect(issue.description).to eq(" ¯\\_(ツ)_/¯") + end + end + end + context 'with assignee, milestone, and contact in params and command' do let_it_be(:contact) { create(:contact, group: group) } diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index 7fd09cc2779f5fe9f4bb739a8ab58859d182c30e..f1859b2208c3a3e450e97879e820a4727f28e356 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -1475,5 +1475,31 @@ def update_issuable(update_params) let(:existing_issue) { create(:issue, project: project) } let(:issuable) { described_class.new(project: project, current_user: user, params: params).execute(existing_issue) } end + + context 'with quick actions' do + context 'as work item' do + let(:opts) { { description: "/shrug" } } + + context 'when work item type is not the default Issue' do + let(:issue) { create(:work_item, :task, description: "") } + + it 'does not apply the quick action' do + expect do + update_issue(opts) + end.to change(issue, :description).to("/shrug") + end + end + + context 'when work item type is the default Issue' do + let(:issue) { create(:work_item, :issue, description: "") } + + it 'does not apply the quick action' do + expect do + update_issue(opts) + end.to change(issue, :description).to(" ¯\\_(ツ)_/¯") + end + end + end + end end end diff --git a/spec/support/shared_examples/graphql/mutations/work_items/update_description_widget_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/work_items/update_description_widget_shared_examples.rb index f672ec7f5ac695cb9c46045256ed6fc93705c53a..2ec48aa405b91d119d83b5fa0af0489bb32261d6 100644 --- a/spec/support/shared_examples/graphql/mutations/work_items/update_description_widget_shared_examples.rb +++ b/spec/support/shared_examples/graphql/mutations/work_items/update_description_widget_shared_examples.rb @@ -31,4 +31,88 @@ expect(mutation_response['errors']).to match_array(['Description error message']) end end + + context 'when the edited description includes quick action(s)' do + let(:input) { { 'descriptionWidget' => { 'description' => new_description } } } + + shared_examples 'quick action is applied' do + before do + post_graphql_mutation(mutation, current_user: current_user) + end + + it 'applies the quick action(s)' do + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']).to include(expected_response) + end + end + + context 'with /title quick action' do + it_behaves_like 'quick action is applied' do + let(:new_description) { "updated description\n/title updated title" } + let(:filtered_description) { "updated description" } + + let(:expected_response) do + { + 'title' => 'updated title', + 'widgets' => include({ + 'description' => filtered_description, + 'type' => 'DESCRIPTION' + }) + } + end + end + end + + context 'with /shrug, /tableflip and /cc quick action' do + it_behaves_like 'quick action is applied' do + let(:new_description) { "/tableflip updated description\n/shrug\n/cc @#{developer.username}" } + # note: \cc performs no action since 15.0 + let(:filtered_description) { "updated description (╯°□°)╯︵ ┻━┻\n ¯\\_(ツ)_/¯\n/cc @#{developer.username}" } + let(:expected_response) do + { + 'widgets' => include({ + 'description' => filtered_description, + 'type' => 'DESCRIPTION' + }) + } + end + end + end + + context 'with /close' do + it_behaves_like 'quick action is applied' do + let(:new_description) { "Resolved work item.\n/close" } + let(:filtered_description) { "Resolved work item." } + let(:expected_response) do + { + 'state' => 'CLOSED', + 'widgets' => include({ + 'description' => filtered_description, + 'type' => 'DESCRIPTION' + }) + } + end + end + end + + context 'with /reopen' do + before do + work_item.close! + end + + it_behaves_like 'quick action is applied' do + let(:new_description) { "Re-opening this work item.\n/reopen" } + let(:filtered_description) { "Re-opening this work item." } + let(:expected_response) do + { + 'state' => 'OPEN', + 'widgets' => include({ + 'description' => filtered_description, + 'type' => 'DESCRIPTION' + }) + } + end + end + end + end end