diff --git a/app/services/issues/convert_to_ticket_service.rb b/app/services/issues/convert_to_ticket_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..7f3ea0b54319fcbdbac1814db35538b7ba3743c7 --- /dev/null +++ b/app/services/issues/convert_to_ticket_service.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Issues + class ConvertToTicketService < ::BaseContainerService + def initialize(target:, current_user:, email:) + super(container: target.resource_parent, current_user: current_user) + + @target = target + @email = email + @original_author = target.author + end + + def execute + return error_feature_flag unless Feature.enabled?(:convert_to_ticket_quick_action, target.project, type: :beta) + return error_underprivileged unless current_user.can?(:"admin_#{target.to_ability_name}", target) + return error_already_ticket if ticket? + return error_invalid_email unless valid_email? + + update_target + add_note + + ServiceResponse.success(message: success_message) + end + + private + + attr_reader :target, :email, :original_author + + def update_target + target.update!( + service_desk_reply_to: email, + author: Users::Internal.support_bot, + confidential: true + ) + + # Migrate to IssueEmailParticipants::CreateService + # once :issue_email_participants feature flag has been removed + # https://gitlab.com/gitlab-org/gitlab/-/issues/440456 + IssueEmailParticipant.create!(issue: target, email: email) + end + + def add_note + message = s_( + "ServiceDesk|This issue has been converted to a Service Desk ticket. " \ + "The email address `%{email}` is the new author of this issue. " \ + "GitLab didn't send a `thank_you` Service Desk email. " \ + "The original author of this issue was `%{original_author}`." + ) + + ::Notes::CreateService.new( + project, + Users::Internal.support_bot, + noteable: target, + note: format(message, email: email, original_author: original_author.to_reference), + internal: true + ).execute + end + + def ticket? + target.from_service_desk? + end + + def valid_email? + email.present? && IssueEmailParticipant.new(issue: target, email: email).valid? + end + + def error(message) + ServiceResponse.error(message: message) + end + + def error_feature_flag + # Don't translate feature flag error because it's temporary. + error("Feature flag convert_to_ticket_quick_action is not enabled for this project.") + end + + def error_underprivileged + error(_("You don't have permission to manage this issue.")) + end + + def error_already_ticket + error(s_("ServiceDesk|Cannot convert to ticket because it is already a ticket.")) + end + + def error_invalid_email + error( + s_("ServiceDesk|Cannot convert issue to ticket because no email was provided or the format was invalid.") + ) + end + + def success_message + s_('ServiceDesk|Converted issue to Service Desk ticket.') + end + end +end diff --git a/config/events/i_quickactions_convert_to_ticket.yml b/config/events/i_quickactions_convert_to_ticket.yml new file mode 100644 index 0000000000000000000000000000000000000000..57e0cfda4943fef43ccd273637649cb5741b7c70 --- /dev/null +++ b/config/events/i_quickactions_convert_to_ticket.yml @@ -0,0 +1,18 @@ +--- +description: When the quickaction /convert_to_ticket is executed on an issue +category: InternalEventTracking +action: i_quickactions_convert_to_ticket +identifiers: +- user +product_section: seg +product_stage: service_management +product_group: respond +milestone: '16.9' +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/139553 +distributions: +- ce +- ee +tiers: +- free +- premium +- ultimate diff --git a/config/feature_flags/beta/convert_to_ticket_quick_action.yml b/config/feature_flags/beta/convert_to_ticket_quick_action.yml new file mode 100644 index 0000000000000000000000000000000000000000..10fa0210884500b7d2b9089228865784d37d42d5 --- /dev/null +++ b/config/feature_flags/beta/convert_to_ticket_quick_action.yml @@ -0,0 +1,9 @@ +--- +name: convert_to_ticket_quick_action +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/433376 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/139553 +rollout_issue_url: https://gitlab.com/gitlab-com/gl-infra/production/-/issues/17488 +milestone: '16.9' +group: group::respond +type: beta +default_enabled: false diff --git a/config/metrics/counts_28d/count_total_i_quickactions_convert_to_ticket_monthly.yml b/config/metrics/counts_28d/count_total_i_quickactions_convert_to_ticket_monthly.yml new file mode 100644 index 0000000000000000000000000000000000000000..7933f1f6a3509e39e05459db7cabeb68eae26406 --- /dev/null +++ b/config/metrics/counts_28d/count_total_i_quickactions_convert_to_ticket_monthly.yml @@ -0,0 +1,26 @@ +--- +key_path: counts.count_total_i_quickactions_convert_to_ticket_monthly +description: Monthly count of executions of the /convert_to_ticket quickaction on an issue +product_section: seg +product_stage: service_management +product_group: respond +performance_indicator_type: [] +value_type: number +status: active +milestone: "16.9" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/139553 +time_frame: 28d +data_source: internal_events +data_category: optional +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate +options: + events: + - i_quickactions_convert_to_ticket +events: +- name: i_quickactions_convert_to_ticket diff --git a/config/metrics/counts_7d/count_total_i_quickactions_convert_to_ticket_weekly.yml b/config/metrics/counts_7d/count_total_i_quickactions_convert_to_ticket_weekly.yml new file mode 100644 index 0000000000000000000000000000000000000000..144cbe4c56e02a6380e34f6bb364723cf31e3764 --- /dev/null +++ b/config/metrics/counts_7d/count_total_i_quickactions_convert_to_ticket_weekly.yml @@ -0,0 +1,26 @@ +--- +key_path: counts.count_total_i_quickactions_convert_to_ticket_weekly +description: Weekly count of executions of the /convert_to_ticket quickaction on an issue +product_section: seg +product_stage: service_management +product_group: respond +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.9' +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/139553 +time_frame: 7d +data_source: internal_events +data_category: optional +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate +options: + events: + - i_quickactions_convert_to_ticket +events: +- name: i_quickactions_convert_to_ticket diff --git a/doc/user/project/quick_actions.md b/doc/user/project/quick_actions.md index 59a9372d361628723dc4faddb2c8d9945c8a707e..7a55132e1fbd89b2ab1c76ebb1715d1e40444357 100644 --- a/doc/user/project/quick_actions.md +++ b/doc/user/project/quick_actions.md @@ -67,6 +67,7 @@ To auto-format this table, use the VS Code Markdown Table formatter: `https://do | `/clone <path/to/project> [--with_notes]` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Clone the issue to given project, or the current one if no arguments are given. Copies as much data as possible as long as the target project contains equivalent objects like labels, milestones, or epics. Does not copy comments or system notes unless `--with_notes` is provided as an argument. | | `/close` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Close. | | `/confidential` | **{check-circle}** Yes | **{dotted-circle}** No | **{check-circle}** Yes | Mark issue or epic as confidential. Support for epics [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/213741) in GitLab 15.6. | +| `/convert_to_ticket <email address>` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | [Convert an issue into a Service Desk ticket](service_desk/using_service_desk.md#convert-a-regular-issue-to-a-service-desk-ticket). [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/433376) in GitLab 16.9 [with a flag](../../administration/feature_flags.md) named `convert_to_ticket_quick_action`. Disabled by default. | | `/copy_metadata <!merge_request>` | **{check-circle}** Yes | **{check-circle}** Yes | **{dotted-circle}** No | Copy labels and milestone from another merge request in the project. | | `/copy_metadata <#issue>` | **{check-circle}** Yes | **{check-circle}** Yes | **{dotted-circle}** No | Copy labels and milestone from another issue in the project. | | `/create_merge_request <branch name>` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Create a new merge request starting from the current issue. | diff --git a/doc/user/project/service_desk/index.md b/doc/user/project/service_desk/index.md index 566d719dac813cc1ee08250cec8e68e0a9652f56..2903991e02f35ab7a695be91a6c20dc6216999ca 100644 --- a/doc/user/project/service_desk/index.md +++ b/doc/user/project/service_desk/index.md @@ -62,6 +62,7 @@ Meanwhile: - [As an end user (issue creator)](using_service_desk.md#as-an-end-user-issue-creator) - [As a responder to the issue](using_service_desk.md#as-a-responder-to-the-issue) - [Email contents and formatting](using_service_desk.md#email-contents-and-formatting) + - [Convert a regular issue to a Service Desk ticket](using_service_desk.md#convert-a-regular-issue-to-a-service-desk-ticket) - [Privacy considerations](using_service_desk.md#privacy-considerations) ## Troubleshooting Service Desk diff --git a/doc/user/project/service_desk/using_service_desk.md b/doc/user/project/service_desk/using_service_desk.md index 0680d92cda72c75997911f8554ddd62bcbd7f468..44e6c2e7c1f4172df429d80914e8d91c97bb043e 100644 --- a/doc/user/project/service_desk/using_service_desk.md +++ b/doc/user/project/service_desk/using_service_desk.md @@ -149,6 +149,23 @@ attachments are sent as part of the email. In other cases, the email contains li In GitLab 15.9 and earlier, uploads to a comment are sent as links in the email. +## Convert a regular issue to a Service Desk ticket + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/433376) in GitLab 16.9 [with a flag](../../../administration/feature_flags.md) named `convert_to_ticket_quick_action`. Disabled by default. + +FLAG: +On self-managed GitLab, by default this feature is not available. To make it available per group, +an administrator can [enable the feature flag](../../../administration/feature_flags.md) named `convert_to_ticket_quick_action`. +On GitLab.com, this feature is not available. + +Use the quick action `/convert_to_ticket external-issue-author@example.com` to convert any regular issue +into a Service Desk ticket. This assigns the provided email address as the external author of the ticket +and add them to the list of external participants. They receive Service Desk emails for any public +comment on the ticket and can reply to these emails. Replies add a new comment on the ticket. + +GitLab doesn't send [the default `thank_you` email](configure.md#customize-emails-sent-to-the-requester). +You can add a public comment on the ticket to let the end user know that the ticket has been created. + ## Privacy considerations > - [Changed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/108901) the minimum required role to view the creator's and participant's email in GitLab 15.9. diff --git a/lib/gitlab/quick_actions/issue_actions.rb b/lib/gitlab/quick_actions/issue_actions.rb index b3f56e8590ac40381f9c6aa5774ddf27c51a6eec..59a6eeb153e558384e70edf4011c13d78228207e 100644 --- a/lib/gitlab/quick_actions/issue_actions.rb +++ b/lib/gitlab/quick_actions/issue_actions.rb @@ -260,6 +260,27 @@ module IssueActions @execution_message[:remove_email] = response.message end + desc { s_('ServiceDesk|Convert issue to Service Desk ticket') } + explanation { s_('ServiceDesk|Converts this issue to a Service Desk ticket.') } + params 'external-issue-author@example.com' + types Issue + condition do + quick_action_target.persisted? && + Feature.enabled?(:convert_to_ticket_quick_action, parent, type: :beta) && + current_user.can?(:"admin_#{quick_action_target.to_ability_name}", quick_action_target) && + quick_action_target.respond_to?(:from_service_desk?) && + !quick_action_target.from_service_desk? + end + command :convert_to_ticket do |email = ""| + response = ::Issues::ConvertToTicketService.new( + target: quick_action_target, + current_user: current_user, + email: email + ).execute + + @execution_message[:convert_to_ticket] = response.message + end + desc { _('Promote issue to incident') } explanation { _('Promotes issue to incident') } execution_message { _('Issue has been promoted to incident') } diff --git a/lib/gitlab/usage_data_counters/quick_action_activity_unique_counter.rb b/lib/gitlab/usage_data_counters/quick_action_activity_unique_counter.rb index 6e4c5d4e8457396dcbac534396a49e27404e4a6a..aa5ba1cf34fb2b4a6a2671c5248c97732d501b5c 100644 --- a/lib/gitlab/usage_data_counters/quick_action_activity_unique_counter.rb +++ b/lib/gitlab/usage_data_counters/quick_action_activity_unique_counter.rb @@ -4,6 +4,10 @@ module Gitlab module UsageDataCounters module QuickActionActivityUniqueCounter class << self + # List of events that use the current internal events implementation. + # Only add internal events for new quick actions. + INTERNAL_EVENTS = %w[convert_to_ticket].freeze + # Tracks the quick action with name `name`. # `args` is expected to be a single string, will be split internally when necessary. def track_unique_action(name, args:, user:) @@ -12,7 +16,14 @@ def track_unique_action(name, args:, user:) args ||= '' name = prepare_name(name, args) - Gitlab::UsageDataCounters::HLLRedisCounter.track_event(:"i_quickactions_#{name}", values: user.id) + if INTERNAL_EVENTS.include?(name) + Gitlab::InternalEvents.track_event("i_quickactions_#{name}", user: user) + else + # Legacy event implementation. Migrate existing events to internal events. + # See implementation of `convert_to_ticket` quickaction and + # https://docs.gitlab.com/ee/development/internal_analytics/internal_event_instrumentation/migration.html#backend-1 + Gitlab::UsageDataCounters::HLLRedisCounter.track_event(:"i_quickactions_#{name}", values: user.id) + end end private diff --git a/locale/gitlab.pot b/locale/gitlab.pot index cc5c4eff54a257bff621cf9e3c1589ef3090e411..5410ad5b65a8e9cdcf80a934c1928256ac114786 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -45841,6 +45841,12 @@ msgstr "" msgid "ServiceDesk|CRAM-MD5" msgstr "" +msgid "ServiceDesk|Cannot convert issue to ticket because no email was provided or the format was invalid." +msgstr "" + +msgid "ServiceDesk|Cannot convert to ticket because it is already a ticket." +msgstr "" + msgid "ServiceDesk|Cannot create custom email" msgstr "" @@ -45859,6 +45865,15 @@ msgstr "" msgid "ServiceDesk|Connect a custom email address your customers can use to create Service Desk issues. Forward all emails from your custom email address to the Service Desk email address of this project. GitLab will send Service Desk emails from the custom address on your behalf using your SMTP credentials. %{linkStart}Learn more about prerequisites and the verification process%{linkEnd}." msgstr "" +msgid "ServiceDesk|Convert issue to Service Desk ticket" +msgstr "" + +msgid "ServiceDesk|Converted issue to Service Desk ticket." +msgstr "" + +msgid "ServiceDesk|Converts this issue to a Service Desk ticket." +msgstr "" + msgid "ServiceDesk|Copy Service Desk email address" msgstr "" @@ -46030,6 +46045,9 @@ msgstr "" msgid "ServiceDesk|This also adds an internal comment that mentions the assignees of the issue." msgstr "" +msgid "ServiceDesk|This issue has been converted to a Service Desk ticket. The email address `%{email}` is the new author of this issue. GitLab didn't send a `thank_you` Service Desk email. The original author of this issue was `%{original_author}`." +msgstr "" + msgid "ServiceDesk|This issue has been reopened because it received a new comment from an external participant." msgstr "" @@ -57040,6 +57058,9 @@ msgstr "" msgid "You don't have permission to manage email participants." msgstr "" +msgid "You don't have permission to manage this issue." +msgstr "" + msgid "You don't have permission to view this epic" msgstr "" diff --git a/spec/lib/gitlab/usage_data_counters/quick_action_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/quick_action_activity_unique_counter_spec.rb index 903ae64cf33f2bc8598dec5f46f2edb52bf9f573..2c21774f7fbf9751430bf6ccb2d740cb07c08e71 100644 --- a/spec/lib/gitlab/usage_data_counters/quick_action_activity_unique_counter_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/quick_action_activity_unique_counter_spec.rb @@ -21,6 +21,17 @@ end end + shared_examples_for 'a tracked quick action internal event' do + it_behaves_like 'internal event tracking' do + let(:event) { action } + end + + it 'tracks the internal event' do + expect(Gitlab::InternalEvents).to receive(:track_event).with(action, user: user).once + subject + end + end + subject { described_class.track_unique_action(quickaction_name, args: args, user: user) } describe '.track_unique_action' do @@ -189,10 +200,10 @@ end end - context 'tracking invite_email' do + context 'when tracking invite_email', feature_category: :service_desk do let(:quickaction_name) { 'invite_email' } - context 'single email' do + context 'with single email' do let(:args) { 'someone@gitlab.com' } it_behaves_like 'a tracked quick action unique event' do @@ -200,7 +211,7 @@ end end - context 'multiple emails' do + context 'with multiple emails' do let(:args) { 'someone@gitlab.com another@gitlab.com' } it_behaves_like 'a tracked quick action unique event' do @@ -208,4 +219,12 @@ end end end + + context 'when tracking convert_to_ticket', feature_category: :service_desk do + let(:quickaction_name) { 'convert_to_ticket' } + + it_behaves_like 'a tracked quick action internal event' do + let(:action) { 'i_quickactions_convert_to_ticket' } + end + end end diff --git a/spec/services/issues/convert_to_ticket_service_spec.rb b/spec/services/issues/convert_to_ticket_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c9f6a30f7602e272ebcbedf70eb1c332eff220a2 --- /dev/null +++ b/spec/services/issues/convert_to_ticket_service_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Issues::ConvertToTicketService, feature_category: :service_desk do + shared_examples 'a successful service execution' do + it 'converts issue to Service Desk issue', :aggregate_failures do + original_author = issue.author + + response = service.execute + expect(response).to be_success + expect(response.message).to eq(success_message) + + issue.reset + + expect(issue).to have_attributes( + confidential: true, + author: support_bot, + service_desk_reply_to: 'user@example.com' + ) + + external_participant = issue.issue_email_participants.last + expect(external_participant.email).to eq(email) + + note = issue.notes.last + expect(note.author).to eq(support_bot) + expect(note.note).to include(email) + expect(note.note).to include(original_author.to_reference) + end + end + + shared_examples 'a failed service execution' do + it 'returns error ServiceResponse with message', :aggregate_failures do + response = service.execute + expect(response).to be_error + expect(response.message).to eq(error_message) + end + end + + describe '#execute' do + let_it_be_with_reload(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:support_bot) { Users::Internal.support_bot } + let_it_be_with_reload(:issue) { create(:issue, project: project) } + + let(:email) { nil } + let(:service) { described_class.new(target: issue, current_user: user, email: email) } + + let(:error_feature_flag) { "Feature flag convert_to_ticket_quick_action is not enabled for this project." } + let(:error_underprivileged) { _("You don't have permission to manage this issue.") } + let(:error_already_ticket) { s_("ServiceDesk|Cannot convert to ticket because it is already a ticket.") } + let(:error_invalid_email) do + s_("ServiceDesk|Cannot convert issue to ticket because no email was provided or the format was invalid.") + end + + let(:success_message) { s_('ServiceDesk|Converted issue to Service Desk ticket.') } + + context 'when the user is not a project member' do + let(:error_message) { error_underprivileged } + + it_behaves_like 'a failed service execution' + end + + context 'when user has the reporter role in project' do + before_all do + project.add_reporter(user) + end + + context 'without email' do + let(:error_message) { error_invalid_email } + + it_behaves_like 'a failed service execution' + end + + context 'with invalid email' do + let(:email) { 'not-a-valid-email' } + let(:error_message) { error_invalid_email } + + it_behaves_like 'a failed service execution' + end + + context 'with valid email' do + let(:email) { 'user@example.com' } + + it_behaves_like 'a successful service execution' + + context 'when issue is Service Desk issue' do + let(:error_message) { error_already_ticket } + + before do + issue.update!( + author: Users::Internal.support_bot, + service_desk_reply_to: 'user@example.com' + ) + end + + it_behaves_like 'a failed service execution' + end + end + end + + context 'when feature flag convert_to_ticket_quick_action is disabled' do + let(:error_message) { error_feature_flag } + + before do + stub_feature_flags(convert_to_ticket_quick_action: false) + end + + it_behaves_like 'a failed service execution' + end + end +end diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb index 0e16e5b6d27e239e1aac17b4ecda3e5a480f5baf..2f1443cc297cec24c303c00ab99e662aaf1404ea 100644 --- a/spec/services/quick_actions/interpret_service_spec.rb +++ b/spec/services/quick_actions/interpret_service_spec.rb @@ -2566,6 +2566,89 @@ end end + describe 'convert_to_ticket command' do + shared_examples 'a failed command execution' do + it 'fails with message' do + _, _, message = convert_to_ticket + + expect(message).to eq(expected_message) + expect(issuable).to have_attributes( + confidential: false, + author_id: original_author.id, + service_desk_reply_to: nil + ) + end + end + + let_it_be_with_reload(:issuable) { issue } + let_it_be(:original_author) { issue.author } + + let(:content) { '/convert_to_ticket' } + let(:expected_message) do + s_("ServiceDesk|Cannot convert issue to ticket because no email was provided or the format was invalid.") + end + + subject(:convert_to_ticket) { service.execute(content, issuable) } + + it 'is part of the available commands' do + expect(service.available_commands(issuable)).to include(a_hash_including(name: :convert_to_ticket)) + end + + it_behaves_like 'a failed command execution' + + context 'when parameter is not an email' do + let(:content) { '/convert_to_ticket no-email-at-all' } + + it_behaves_like 'a failed command execution' + end + + context 'when parameter is an email' do + let(:content) { '/convert_to_ticket user@example.com' } + + it 'converts issue to Service Desk issue' do + _, _, message = convert_to_ticket + + expect(message).to eq(s_('ServiceDesk|Converted issue to Service Desk ticket.')) + expect(issuable).to have_attributes( + confidential: true, + author_id: Users::Internal.support_bot.id, + service_desk_reply_to: 'user@example.com' + ) + end + end + + context 'when issue is Service Desk issue' do + before do + issue.update!( + author: Users::Internal.support_bot, + service_desk_reply_to: 'user@example.com' + ) + end + + it 'is not part of the available commands' do + expect(service.available_commands(issuable)).not_to include(a_hash_including(name: :convert_to_ticket)) + end + end + + context 'with non-persisted issue' do + let(:issuable) { build(:issue) } + + it 'is not part of the available commands' do + expect(service.available_commands(issuable)).not_to include(a_hash_including(name: :convert_to_ticket)) + end + end + + context 'with feature flag convert_to_ticket_quick_action disabled' do + before do + stub_feature_flags(convert_to_ticket_quick_action: false) + end + + it 'is not part of the available commands' do + expect(service.available_commands(issuable)).not_to include(a_hash_including(name: :convert_to_ticket)) + end + end + end + context 'severity command' do let_it_be_with_reload(:issuable) { create(:incident, project: project) }