diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-02 18:18:39 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-02 18:18:39 +0000 |
commit | b9ce0fe1e6311105b7a748126621f9bfbe37fb2e (patch) | |
tree | c73b711a72de036cf3f48be9365038fea171c8c6 /app/services | |
parent | 6f991190fe4dbb93070b090a9a31d71b25e8101d (diff) | |
download | gitlab-ce-b9ce0fe1e6311105b7a748126621f9bfbe37fb2e.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/services')
8 files changed, 598 insertions, 0 deletions
diff --git a/app/services/integrations/slack_event_service.rb b/app/services/integrations/slack_event_service.rb new file mode 100644 index 00000000000..65f3c226e34 --- /dev/null +++ b/app/services/integrations/slack_event_service.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# Performs the initial handling of event payloads sent from Slack to GitLab. +# See `API::Integrations::Slack::Events` which calls this service. +module Integrations + class SlackEventService + URL_VERIFICATION_EVENT = 'url_verification' + + UnknownEventError = Class.new(StandardError) + + def initialize(params) + # When receiving URL verification events, params[:type] is 'url_verification'. + # For all other events we subscribe to, params[:type] is 'event_callback' and + # the specific type of the event will be in params[:event][:type]. + # Remove both of these from the params before they are passed to the services. + type = params.delete(:type) + type = params[:event].delete(:type) if type == 'event_callback' + + @slack_event = type + @params = params + end + + def execute + raise UnknownEventError, "Unable to handle event type: '#{slack_event}'" unless routable_event? + + payload = route_event + + ServiceResponse.success(payload: payload) + end + + private + + # The `url_verification` slack_event response must be returned to Slack in-request, + # so for this event we call the service directly instead of through a worker. + # + # All other events must be handled asynchronously in order to return a 2xx response + # immediately to Slack in the request. See https://api.slack.com/apis/connections/events-api. + def route_in_request? + slack_event == URL_VERIFICATION_EVENT + end + + def routable_event? + route_in_request? || route_to_event_worker? + end + + def route_to_event_worker? + SlackEventWorker.event?(slack_event) + end + + # Returns a payload for the service response. + def route_event + return SlackEvents::UrlVerificationService.new(params).execute if route_in_request? + + SlackEventWorker.perform_async(slack_event: slack_event, params: params) + + {} + end + + attr_reader :slack_event, :params + end +end diff --git a/app/services/integrations/slack_events/app_home_opened_service.rb b/app/services/integrations/slack_events/app_home_opened_service.rb new file mode 100644 index 00000000000..48dda324270 --- /dev/null +++ b/app/services/integrations/slack_events/app_home_opened_service.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +# Handles the Slack `app_home_opened` event sent from Slack to GitLab. +# Responds with a POST to the Slack API 'views.publish' method. +# +# See: +# - https://api.slack.com/methods/views.publish +# - https://api.slack.com/events/app_home_opened +module Integrations + module SlackEvents + class AppHomeOpenedService + include Gitlab::Utils::StrongMemoize + + def initialize(params) + @slack_user_id = params.dig(:event, :user) + @slack_workspace_id = params[:team_id] + end + + def execute + # Legacy Slack App integrations will not yet have a token we can use + # to call the Slack API. Do nothing, and consider the service successful. + unless slack_installation + logger.info( + slack_user_id: slack_user_id, + slack_workspace_id: slack_workspace_id, + message: 'SlackInstallation record has no bot token' + ) + + return ServiceResponse.success + end + + begin + response = ::Slack::API.new(slack_installation).post( + 'views.publish', + payload + ) + rescue *Gitlab::HTTP::HTTP_ERRORS => e + return ServiceResponse + .error(message: 'HTTP exception when calling Slack API') + .track_exception( + as: e.class, + slack_user_id: slack_user_id, + slack_workspace_id: slack_workspace_id + ) + end + + return ServiceResponse.success if response['ok'] + + # For a list of errors, see: + # https://api.slack.com/methods/views.publish#errors + ServiceResponse.error( + message: 'Slack API returned an error', + payload: response + ).track_exception( + slack_user_id: slack_user_id, + slack_workspace_id: slack_workspace_id, + response: response.to_h + ) + end + + private + + def slack_installation + SlackIntegration.with_bot.find_by_team_id(slack_workspace_id) + end + strong_memoize_attr :slack_installation + + def slack_gitlab_user_connection + ChatNames::FindUserService.new(slack_workspace_id, slack_user_id).execute + end + strong_memoize_attr :slack_gitlab_user_connection + + def payload + { + user_id: slack_user_id, + view: ::Slack::BlockKit::AppHomeOpened.new( + slack_user_id, + slack_workspace_id, + slack_gitlab_user_connection, + slack_installation + ).build + } + end + + def logger + Gitlab::IntegrationsLogger + end + + attr_reader :slack_user_id, :slack_workspace_id + end + end +end diff --git a/app/services/integrations/slack_events/url_verification_service.rb b/app/services/integrations/slack_events/url_verification_service.rb new file mode 100644 index 00000000000..dbe2ffc77f8 --- /dev/null +++ b/app/services/integrations/slack_events/url_verification_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Returns the special URL verification response expected by Slack when the +# GitLab Slack app is first configured to receive Slack events. +# +# Slack will issue the challenge request to the endpoint that receives events +# and expect it to respond with same the `challenge` param back. +# +# See https://api.slack.com/apis/connections/events-api. +module Integrations + module SlackEvents + class UrlVerificationService + def initialize(params) + @challenge = params[:challenge] + end + + def execute + { challenge: challenge } + end + + private + + attr_reader :challenge + end + end +end diff --git a/app/services/integrations/slack_interaction_service.rb b/app/services/integrations/slack_interaction_service.rb new file mode 100644 index 00000000000..30e1a396f0d --- /dev/null +++ b/app/services/integrations/slack_interaction_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Integrations + class SlackInteractionService + UnknownInteractionError = Class.new(StandardError) + + INTERACTIONS = { + 'view_closed' => SlackInteractions::IncidentManagement::IncidentModalClosedService, + 'view_submission' => SlackInteractions::IncidentManagement::IncidentModalSubmitService, + 'block_actions' => SlackInteractions::BlockActionService + }.freeze + + def initialize(params) + @interaction_type = params.delete(:type) + @params = params + end + + def execute + raise UnknownInteractionError, "Unable to handle interaction type: '#{interaction_type}'" \ + unless interaction?(interaction_type) + + service_class = INTERACTIONS[interaction_type] + service_class.new(params).execute + + ServiceResponse.success + end + + private + + attr_reader :interaction_type, :params + + def interaction?(type) + INTERACTIONS.key?(type) + end + end +end diff --git a/app/services/integrations/slack_interactions/block_action_service.rb b/app/services/integrations/slack_interactions/block_action_service.rb new file mode 100644 index 00000000000..d135635fda4 --- /dev/null +++ b/app/services/integrations/slack_interactions/block_action_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Integrations + module SlackInteractions + class BlockActionService + ALLOWED_UPDATES_HANDLERS = { + 'incident_management_project' => SlackInteractions::SlackBlockActions::IncidentManagement::ProjectUpdateHandler + }.freeze + + def initialize(params) + @params = params + end + + def execute + actions.each do |action| + action_id = action[:action_id] + + action_handler_class = ALLOWED_UPDATES_HANDLERS[action_id] + action_handler_class.new(params, action).execute + end + end + + private + + def actions + params[:actions].select { |action| ALLOWED_UPDATES_HANDLERS[action[:action_id]] } + end + + attr_accessor :params + end + end +end diff --git a/app/services/integrations/slack_interactions/incident_management/incident_modal_closed_service.rb b/app/services/integrations/slack_interactions/incident_management/incident_modal_closed_service.rb new file mode 100644 index 00000000000..9daa5d76df7 --- /dev/null +++ b/app/services/integrations/slack_interactions/incident_management/incident_modal_closed_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Integrations + module SlackInteractions + module IncidentManagement + class IncidentModalClosedService + def initialize(params) + @params = params + end + + def execute + begin + response = close_modal + rescue *Gitlab::HTTP::HTTP_ERRORS => e + return ServiceResponse + .error(message: 'HTTP exception when calling Slack API') + .track_exception( + params: params, + as: e.class + ) + end + + return ServiceResponse.success if response['ok'] + + ServiceResponse.error( + message: _('Something went wrong while closing the incident form.'), + payload: response + ).track_exception( + response: response.to_h, + params: params + ) + end + + private + + attr_accessor :params + + def close_modal + request_body = Gitlab::Json.dump(close_request_body) + response_url = params.dig(:view, :private_metadata) + + Gitlab::HTTP.post(response_url, body: request_body, headers: headers) + end + + def close_request_body + { + replace_original: 'true', + text: _('Incident creation cancelled.') + } + end + + def headers + { 'Content-Type' => 'application/json' } + end + end + end + end +end diff --git a/app/services/integrations/slack_interactions/incident_management/incident_modal_submit_service.rb b/app/services/integrations/slack_interactions/incident_management/incident_modal_submit_service.rb new file mode 100644 index 00000000000..34af03640d3 --- /dev/null +++ b/app/services/integrations/slack_interactions/incident_management/incident_modal_submit_service.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +module Integrations + module SlackInteractions + module IncidentManagement + class IncidentModalSubmitService + include GitlabRoutingHelper + include Gitlab::Routing + + IssueCreateError = Class.new(StandardError) + + def initialize(params) + @params = params + @values = params.dig(:view, :state, :values) + @team_id = params.dig(:team, :id) + @user_id = params.dig(:user, :id) + @additional_message = '' + end + + def execute + create_response = Issues::CreateService.new( + container: project, + current_user: find_user.user, + params: incident_params, + spam_params: nil + ).execute + + raise IssueCreateError, create_response.errors.to_sentence if create_response.error? + + incident = create_response.payload[:issue] + incident_link = incident_link_text(incident) + response = send_to_slack(incident_link) + + return ServiceResponse.success(payload: { incident: incident }) if response['ok'] + + ServiceResponse.error( + message: _('Something went wrong when sending the incident link to Slack.'), + payload: response + ).track_exception( + response: response.to_h, + slack_workspace_id: team_id, + slack_user_id: user_id + ) + rescue StandardError => e + send_to_slack(_('There was a problem creating the incident. Please try again.')) + + ServiceResponse + .error( + message: e.message + ).track_exception( + slack_workspace_id: team_id, + slack_user_id: user_id, + as: e.class + ) + end + + private + + attr_accessor :params, :values, :team_id, :user_id, :additional_message + + def incident_params + { + title: values.dig(:title_input, :title, :value), + severity: severity, + confidential: confidential?, + description: description, + escalation_status: { status: status }, + issue_type: "incident", + assignee_ids: [assignee], + label_ids: labels + } + end + + def strip_markup(string) + SlackMarkdownSanitizer.sanitize(string) + end + + def send_to_slack(text) + response_url = params.dig(:view, :private_metadata) + + body = { + replace_original: 'true', + text: text + } + + Gitlab::HTTP.post( + response_url, + body: Gitlab::Json.dump(body), + headers: { 'Content-Type' => 'application/json' } + ) + end + + def incident_link_text(incident) + "#{_('New incident has been created')}: " \ + "<#{issue_url(incident)}|#{incident.to_reference} " \ + "- #{strip_markup(incident.title)}>. #{@additional_message}" + end + + def project + project_id = values.dig( + :project_and_severity_selector, + :incident_management_project, + :selected_option, + :value) + + Project.find(project_id) + end + + def find_user + ChatNames::FindUserService.new(team_id, user_id).execute + end + + def description + description = + values.dig(:incident_description, :description, :value) || + values.dig(project.id.to_s.to_sym, :description, :value) + + zoom_link = values.dig(:zoom, :link, :value) + + return description if zoom_link.blank? + + "#{description} \n/zoom #{zoom_link}" + end + + def confidential? + values.dig(:confidentiality, :confidential, :selected_options).present? + end + + def severity + values.dig(:project_and_severity_selector, :severity, :selected_option, :value) || 'unknown' + end + + def status + values.dig(:status_and_assignee_selector, :status, :selected_option, :value) + end + + def assignee + assignee_id = values.dig(:status_and_assignee_selector, :assignee, :selected_option, :value) + + return unless assignee_id + + user = User.find_by_id(assignee_id) + member = project.member(user) + + unless member + @additional_message = + "However, " \ + "#{user.name} was not assigned to the incident as they are not a member in #{project.name}." + + return + end + + member.user_id + end + + def labels + values.dig(:label_selector, :labels, :selected_options)&.pluck(:value) + end + end + end + end +end diff --git a/app/services/integrations/slack_interactions/slack_block_actions/incident_management/project_update_handler.rb b/app/services/integrations/slack_interactions/slack_block_actions/incident_management/project_update_handler.rb new file mode 100644 index 00000000000..5f24c8ec4f5 --- /dev/null +++ b/app/services/integrations/slack_interactions/slack_block_actions/incident_management/project_update_handler.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module Integrations + module SlackInteractions + module SlackBlockActions + module IncidentManagement + class ProjectUpdateHandler + include Gitlab::Utils::StrongMemoize + + def initialize(params, action) + @view = params[:view] + @action = action + @team_id = params.dig(:view, :team_id) + @user_id = params.dig(:user, :id) + end + + def execute + return if project_unchanged? + return unless allowed? + + post_updated_modal + end + + private + + def allowed? + return false unless current_user + + current_user.can?(:read_project, old_project) && + current_user.can?(:read_project, new_project) + end + + def current_user + ChatNames::FindUserService.new(team_id, user_id).execute&.user + end + strong_memoize_attr :current_user + + def slack_installation + SlackIntegration.with_bot.find_by_team_id(team_id) + end + strong_memoize_attr :slack_installation + + def post_updated_modal + modal = update_modal + + begin + response = ::Slack::API.new(slack_installation).post( + 'views.update', + { + view_id: view[:id], + view: modal + } + ) + rescue *::Gitlab::HTTP::HTTP_ERRORS => e + return ServiceResponse + .error(message: 'HTTP exception when calling Slack API') + .track_exception( + as: e.class, + slack_workspace_id: view[:team_id] + ) + end + + return ServiceResponse.success(message: _('Modal updated')) if response['ok'] + + ServiceResponse.error( + message: _('Something went wrong while updating the modal.'), + payload: response + ).track_exception( + response: response.to_h, + slack_workspace_id: view[:team_id], + slack_user_id: slack_installation.user_id + ) + end + + def update_modal + updated_view = update_incident_template + cleanup(updated_view) + end + + def update_incident_template + updated_view = view.dup + + incident_description_blocks = updated_view[:blocks].select do |block| + block[:block_id] == 'incident_description' || block[:block_id] == old_project.id.to_s + end + + incident_description_blocks.first[:element][:initial_value] = read_template_content + incident_description_blocks.first[:block_id] = new_project.id.to_s + + Integrations::SlackInteractions::IncidentManagement::IncidentModalOpenedService + .cache_write(view[:id], new_project.id.to_s) + + updated_view + end + + def new_project + Project.find(action.dig(:selected_option, :value)) + end + strong_memoize_attr :new_project + + def old_project + old_project_id = Integrations::SlackInteractions::IncidentManagement::IncidentModalOpenedService + .cache_read(view[:id]) + + Project.find(old_project_id) if old_project_id + end + strong_memoize_attr :old_project + + def project_unchanged? + old_project == new_project + end + + def read_template_content + new_project.incident_management_setting&.issue_template_content.to_s + end + + def cleanup(view) + view.except!( + :id, :team_id, :state, + :hash, :previous_view_id, + :root_view_id, :app_id, + :app_installed_team_id, + :bot_id) + end + + attr_accessor :view, :action, :team_id, :user_id + end + end + end + end +end |