diff options
Diffstat (limited to 'app/models/integrations')
41 files changed, 3077 insertions, 26 deletions
diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index 82111c7322e..dbd7aedf4fe 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Integrations - class Bamboo < CiService + class Bamboo < BaseCi include ActionView::Helpers::UrlHelper include ReactiveService diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb new file mode 100644 index 00000000000..5eae8bce92a --- /dev/null +++ b/app/models/integrations/base_chat_notification.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +# Base class for Chat notifications services +# This class is not meant to be used directly, but only to inherit from. + +module Integrations + class BaseChatNotification < Integration + include ChatMessage + include NotificationBranchSelection + + SUPPORTED_EVENTS = %w[ + push issue confidential_issue merge_request note confidential_note + tag_push pipeline wiki_page deployment + ].freeze + + SUPPORTED_EVENTS_FOR_LABEL_FILTER = %w[issue confidential_issue merge_request note confidential_note].freeze + + EVENT_CHANNEL = proc { |event| "#{event}_channel" } + + LABEL_NOTIFICATION_BEHAVIOURS = [ + MATCH_ANY_LABEL = 'match_any', + MATCH_ALL_LABELS = 'match_all' + ].freeze + + default_value_for :category, 'chat' + + prop_accessor :webhook, :username, :channel, :branches_to_be_notified, :labels_to_be_notified, :labels_to_be_notified_behavior + + # Custom serialized properties initialization + prop_accessor(*SUPPORTED_EVENTS.map { |event| EVENT_CHANNEL[event] }) + + boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch + + validates :webhook, presence: true, public_url: true, if: :activated? + validates :labels_to_be_notified_behavior, inclusion: { in: LABEL_NOTIFICATION_BEHAVIOURS }, allow_blank: true + + def initialize_properties + if properties.nil? + self.properties = {} + self.notify_only_broken_pipelines = true + self.branches_to_be_notified = "default" + self.labels_to_be_notified_behavior = MATCH_ANY_LABEL + elsif !self.notify_only_default_branch.nil? + # In older versions, there was only a boolean property named + # `notify_only_default_branch`. Now we have a string property named + # `branches_to_be_notified`. Instead of doing a background migration, we + # opted to set a value for the new property based on the old one, if + # users haven't specified one already. When users edit the service and + # select a value for this new property, it will override everything. + + self.branches_to_be_notified ||= notify_only_default_branch? ? "default" : "all" + end + end + + def confidential_issue_channel + properties['confidential_issue_channel'].presence || properties['issue_channel'] + end + + def confidential_note_channel + properties['confidential_note_channel'].presence || properties['note_channel'] + end + + def self.supported_events + SUPPORTED_EVENTS + end + + def fields + default_fields + build_event_channels + end + + def default_fields + [ + { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}", required: true }.freeze, + { type: 'text', name: 'username', placeholder: 'GitLab-integration' }.freeze, + { type: 'checkbox', name: 'notify_only_broken_pipelines', help: 'Do not send notifications for successful pipelines.' }.freeze, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }.freeze, + { + type: 'text', + name: 'labels_to_be_notified', + placeholder: '~backend,~frontend', + help: 'Send notifications for issue, merge request, and comment events with the listed labels only. Leave blank to receive notifications for all events.' + }.freeze, + { + type: 'select', + name: 'labels_to_be_notified_behavior', + choices: [ + ['Match any of the labels', MATCH_ANY_LABEL], + ['Match all of the labels', MATCH_ALL_LABELS] + ] + }.freeze + ].freeze + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + return unless webhook.present? + + object_kind = data[:object_kind] + + data = custom_data(data) + + return unless notify_label?(data) + + # WebHook events often have an 'update' event that follows a 'open' or + # 'close' action. Ignore update events for now to prevent duplicate + # messages from arriving. + + message = get_message(object_kind, data) + + return false unless message + + event_type = data[:event_type] || object_kind + + channel_names = get_channel_field(event_type).presence || channel.presence + channels = channel_names&.split(',')&.map(&:strip) + + opts = {} + opts[:channel] = channels if channels.present? + opts[:username] = username if username + + if notify(message, opts) + log_usage(event_type, user_id_from_hook_data(data)) + return true + end + + false + end + + def event_channel_names + supported_events.map { |event| event_channel_name(event) } + end + + def event_field(event) + fields.find { |field| field[:name] == event_channel_name(event) } + end + + def global_fields + fields.reject { |field| field[:name].end_with?('channel') } + end + + def default_channel_placeholder + raise NotImplementedError + end + + private + + def log_usage(_, _) + # Implement in child class + end + + def labels_to_be_notified_list + return [] if labels_to_be_notified.nil? + + labels_to_be_notified.delete('~').split(',').map(&:strip) + end + + def notify_label?(data) + return true unless SUPPORTED_EVENTS_FOR_LABEL_FILTER.include?(data[:object_kind]) && labels_to_be_notified.present? + + labels = data[:labels] || data.dig(:issue, :labels) || data.dig(:merge_request, :labels) || data.dig(:object_attributes, :labels) + + return false if labels.blank? + + matching_labels = labels_to_be_notified_list & labels.pluck(:title) + + if labels_to_be_notified_behavior == MATCH_ALL_LABELS + labels_to_be_notified_list.difference(matching_labels).empty? + else + matching_labels.any? + end + end + + def user_id_from_hook_data(data) + data.dig(:user, :id) || data[:user_id] + end + + # every notifier must implement this independently + def notify(message, opts) + raise NotImplementedError + end + + def custom_data(data) + data.merge(project_url: project_url, project_name: project_name).with_indifferent_access + end + + def get_message(object_kind, data) + case object_kind + when "push", "tag_push" + Integrations::ChatMessage::PushMessage.new(data) if notify_for_ref?(data) + when "issue" + Integrations::ChatMessage::IssueMessage.new(data) unless update?(data) + when "merge_request" + Integrations::ChatMessage::MergeMessage.new(data) unless update?(data) + when "note" + Integrations::ChatMessage::NoteMessage.new(data) + when "pipeline" + Integrations::ChatMessage::PipelineMessage.new(data) if should_pipeline_be_notified?(data) + when "wiki_page" + Integrations::ChatMessage::WikiPageMessage.new(data) + when "deployment" + Integrations::ChatMessage::DeploymentMessage.new(data) + end + end + + def get_channel_field(event) + field_name = event_channel_name(event) + self.public_send(field_name) # rubocop:disable GitlabSecurity/PublicSend + end + + def build_event_channels + supported_events.reduce([]) do |channels, event| + channels << { type: 'text', name: event_channel_name(event), placeholder: default_channel_placeholder } + end + end + + def event_channel_name(event) + EVENT_CHANNEL[event] + end + + def project_name + project.full_name + end + + def project_url + project.web_url + end + + def update?(data) + data[:object_attributes][:action] == 'update' + end + + def should_pipeline_be_notified?(data) + notify_for_ref?(data) && notify_for_pipeline?(data) + end + + def notify_for_ref?(data) + return true if data[:object_kind] == 'tag_push' + return true if data.dig(:object_attributes, :tag) + + notify_for_branch?(data) + end + + def notify_for_pipeline?(data) + case data[:object_attributes][:status] + when 'success' + !notify_only_broken_pipelines? + when 'failed' + true + else + false + end + end + end +end diff --git a/app/models/integrations/base_ci.rb b/app/models/integrations/base_ci.rb new file mode 100644 index 00000000000..b2e269b1b50 --- /dev/null +++ b/app/models/integrations/base_ci.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Base class for CI services +# List methods you need to implement to get your CI service +# working with GitLab merge requests +module Integrations + class BaseCi < Integration + default_value_for :category, 'ci' + + def valid_token?(token) + self.respond_to?(:token) && self.token.present? && ActiveSupport::SecurityUtils.secure_compare(token, self.token) + end + + def self.supported_events + %w(push) + end + + # Return complete url to build page + # + # Ex. + # http://jenkins.example.com:8888/job/test1/scm/bySHA1/12d65c + # + def build_page(sha, ref) + # implement inside child + end + + # Return string with build status or :error symbol + # + # Allowed states: 'success', 'failed', 'running', 'pending', 'skipped' + # + # + # Ex. + # @service.commit_status('13be4ac', 'master') + # # => 'success' + # + # @service.commit_status('2abe4ac', 'dev') + # # => 'running' + # + # + def commit_status(sha, ref) + # implement inside child + end + end +end diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb new file mode 100644 index 00000000000..6c24f762cd5 --- /dev/null +++ b/app/models/integrations/base_issue_tracker.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +module Integrations + class BaseIssueTracker < Integration + validate :one_issue_tracker, if: :activated?, on: :manual_change + + # TODO: we can probably just delegate as part of + # https://gitlab.com/gitlab-org/gitlab/issues/29404 + data_field :project_url, :issues_url, :new_issue_url + + default_value_for :category, 'issue_tracker' + + before_validation :handle_properties + before_validation :set_default_data, on: :create + + # Pattern used to extract links from comments + # Override this method on services that uses different patterns + # This pattern does not support cross-project references + # The other code assumes that this pattern is a superset of all + # overridden patterns. See ReferenceRegexes.external_pattern + def self.reference_pattern(only_long: false) + if only_long + /(\b[A-Z][A-Z0-9_]*-)#{Gitlab::Regex.issue}/ + else + /(\b[A-Z][A-Z0-9_]*-|#{Issue.reference_prefix})#{Gitlab::Regex.issue}/ + end + end + + def handle_properties + # this has been moved from initialize_properties and should be improved + # as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 + return unless properties + + @legacy_properties_data = properties.dup + data_values = properties.slice!('title', 'description') + data_values.reject! { |key| data_fields.changed.include?(key) } + data_values.slice!(*data_fields.attributes.keys) + data_fields.assign_attributes(data_values) if data_values.present? + + self.properties = {} + end + + def legacy_properties_data + @legacy_properties_data ||= {} + end + + def supports_data_fields? + true + end + + def data_fields + issue_tracker_data || self.build_issue_tracker_data + end + + def default? + default + end + + def issue_url(iid) + issues_url.gsub(':id', iid.to_s) + end + + def issue_tracker_path + project_url + end + + def new_issue_path + new_issue_url + end + + def issue_path(iid) + issue_url(iid) + end + + def fields + [ + { type: 'text', name: 'project_url', title: _('Project URL'), help: s_('IssueTracker|The URL to the project in the external issue tracker.'), required: true }, + { type: 'text', name: 'issues_url', title: s_('IssueTracker|Issue URL'), help: s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.') % { colon_id: '<code>:id</code>'.html_safe }, required: true }, + { type: 'text', name: 'new_issue_url', title: s_('IssueTracker|New issue URL'), help: s_('IssueTracker|The URL to create an issue in the external issue tracker.'), required: true } + ] + end + + def initialize_properties + {} + end + + # Initialize with default properties values + def set_default_data + return unless issues_tracker.present? + + # we don't want to override if we have set something + return if project_url || issues_url || new_issue_url + + data_fields.project_url = issues_tracker['project_url'] + data_fields.issues_url = issues_tracker['issues_url'] + data_fields.new_issue_url = issues_tracker['new_issue_url'] + end + + def self.supported_events + %w(push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + message = "#{self.type} was unable to reach #{self.project_url}. Check the url and try again." + result = false + + begin + response = Gitlab::HTTP.head(self.project_url, verify: true) + + if response + message = "#{self.type} received response #{response.code} when attempting to connect to #{self.project_url}" + result = true + end + rescue Gitlab::HTTP::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED, OpenSSL::SSL::SSLError => error + message = "#{self.type} had an error when trying to connect to #{self.project_url}: #{error.message}" + end + log_info(message) + result + end + + def support_close_issue? + false + end + + def support_cross_reference? + false + end + + def create_cross_reference_note(mentioned, noteable, author) + # implement inside child + end + + private + + def enabled_in_gitlab_config + Gitlab.config.issues_tracker && + Gitlab.config.issues_tracker.values.any? && + issues_tracker + end + + def issues_tracker + Gitlab.config.issues_tracker[to_param] + end + + def one_issue_tracker + return if template? || instance? + return if project.blank? + + if project.integrations.external_issue_trackers.where.not(id: id).any? + errors.add(:base, _('Another issue tracker is already in use. Only one issue tracker service can be active at a time')) + end + end + end +end diff --git a/app/models/integrations/base_slash_commands.rb b/app/models/integrations/base_slash_commands.rb new file mode 100644 index 00000000000..eacf1184aae --- /dev/null +++ b/app/models/integrations/base_slash_commands.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# Base class for ChatOps integrations +# This class is not meant to be used directly, but only to inherrit from. +module Integrations + class BaseSlashCommands < Integration + default_value_for :category, 'chat' + + prop_accessor :token + + has_many :chat_names, foreign_key: :service_id, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + + def valid_token?(token) + self.respond_to?(:token) && + self.token.present? && + ActiveSupport::SecurityUtils.secure_compare(token, self.token) + end + + def self.supported_events + %w() + end + + def can_test? + false + end + + def fields + [ + { type: 'text', name: 'token', placeholder: 'XXxxXXxxXXxxXXxxXXxxXXxx' } + ] + end + + def trigger(params) + return unless valid_token?(params[:token]) + + chat_user = find_chat_user(params) + user = chat_user&.user + + if user + unless user.can?(:use_slash_commands) + return Gitlab::SlashCommands::Presenters::Access.new.deactivated if user.deactivated? + + return Gitlab::SlashCommands::Presenters::Access.new.access_denied(project) + end + + Gitlab::SlashCommands::Command.new(project, chat_user, params).execute + else + url = authorize_chat_name_url(params) + Gitlab::SlashCommands::Presenters::Access.new(url).authorize + end + end + + private + + # rubocop: disable CodeReuse/ServiceClass + def find_chat_user(params) + ChatNames::FindUserService.new(self, params).execute + end + # rubocop: enable CodeReuse/ServiceClass + + # rubocop: disable CodeReuse/ServiceClass + def authorize_chat_name_url(params) + ChatNames::AuthorizeUserService.new(self, params).execute + end + # rubocop: enable CodeReuse/ServiceClass + end +end diff --git a/app/models/integrations/bugzilla.rb b/app/models/integrations/bugzilla.rb new file mode 100644 index 00000000000..9251015acb8 --- /dev/null +++ b/app/models/integrations/bugzilla.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Integrations + class Bugzilla < BaseIssueTracker + include ActionView::Helpers::UrlHelper + + validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? + + def title + 'Bugzilla' + end + + def description + s_("IssueTracker|Use Bugzilla as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bugzilla'), target: '_blank', rel: 'noopener noreferrer' + s_("IssueTracker|Use Bugzilla as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'bugzilla' + end + end +end diff --git a/app/models/integrations/buildkite.rb b/app/models/integrations/buildkite.rb new file mode 100644 index 00000000000..906a5d02f9c --- /dev/null +++ b/app/models/integrations/buildkite.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require "addressable/uri" + +module Integrations + class Buildkite < BaseCi + include ReactiveService + + ENDPOINT = "https://buildkite.com" + + prop_accessor :project_url, :token + + validates :project_url, presence: true, public_url: true, if: :activated? + validates :token, presence: true, if: :activated? + + after_save :compose_service_hook, if: :activated? + + def self.supported_events + %w(push merge_request tag_push) + end + + # This is a stub method to work with deprecated API response + # TODO: remove enable_ssl_verification after 14.0 + # https://gitlab.com/gitlab-org/gitlab/-/issues/222808 + def enable_ssl_verification + true + end + + # Since SSL verification will always be enabled for Buildkite, + # we no longer needs to store the boolean. + # This is a stub method to work with deprecated API param. + # TODO: remove enable_ssl_verification after 14.0 + # https://gitlab.com/gitlab-org/gitlab/-/issues/222808 + def enable_ssl_verification=(_value) + self.properties.delete('enable_ssl_verification') # Remove unused key + end + + def webhook_url + "#{buildkite_endpoint('webhook')}/deliver/#{webhook_token}" + end + + def compose_service_hook + hook = service_hook || build_service_hook + hook.url = webhook_url + hook.enable_ssl_verification = true + hook.save + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + service_hook.execute(data) + end + + def commit_status(sha, ref) + with_reactive_cache(sha, ref) {|cached| cached[:commit_status] } + end + + def commit_status_path(sha) + "#{buildkite_endpoint('gitlab')}/status/#{status_token}.json?commit=#{sha}" + end + + def build_page(sha, ref) + "#{project_url}/builds?commit=#{sha}" + end + + def title + 'Buildkite' + end + + def description + 'Run CI/CD pipelines with Buildkite.' + end + + def self.to_param + 'buildkite' + end + + def fields + [ + { type: 'text', + name: 'token', + title: 'Integration Token', + help: 'This token will be provided when you create a Buildkite pipeline with a GitLab repository', + required: true }, + + { type: 'text', + name: 'project_url', + title: 'Pipeline URL', + placeholder: "#{ENDPOINT}/acme-inc/test-pipeline", + required: true } + ] + end + + def calculate_reactive_cache(sha, ref) + response = Gitlab::HTTP.try_get(commit_status_path(sha), request_options) + + status = + if response&.code == 200 && response['status'] + response['status'] + else + :error + end + + { commit_status: status } + end + + private + + def webhook_token + token_parts.first + end + + def status_token + token_parts.second + end + + def token_parts + if token.present? + token.split(':') + else + [] + end + end + + def buildkite_endpoint(subdomain = nil) + if subdomain.present? + uri = Addressable::URI.parse(ENDPOINT) + new_endpoint = "#{uri.scheme || 'http'}://#{subdomain}.#{uri.host}" + + if uri.port.present? + "#{new_endpoint}:#{uri.port}" + else + new_endpoint + end + else + ENDPOINT + end + end + + def request_options + { verify: false, extra_log_info: { project_id: project_id } } + end + end +end diff --git a/app/models/integrations/builds_email.rb b/app/models/integrations/builds_email.rb deleted file mode 100644 index 2628848667e..00000000000 --- a/app/models/integrations/builds_email.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -# This class is to be removed with 9.1 -# We should also by then remove BuildsEmailService from database -# https://gitlab.com/gitlab-org/gitlab/-/issues/331064 -module Integrations - class BuildsEmail < Integration - def self.to_param - 'builds_email' - end - - def self.supported_events - %w[] - end - end -end diff --git a/app/models/integrations/chat_message/base_message.rb b/app/models/integrations/chat_message/base_message.rb index 2f70384d3b9..afe3ffc45a0 100644 --- a/app/models/integrations/chat_message/base_message.rb +++ b/app/models/integrations/chat_message/base_message.rb @@ -58,7 +58,7 @@ module Integrations end def format(string) - Slack::Messenger::Util::LinkFormatter.format(format_relative_links(string)) + ::Slack::Messenger::Util::LinkFormatter.format(format_relative_links(string)) end def format_relative_links(string) diff --git a/app/models/integrations/chat_message/pipeline_message.rb b/app/models/integrations/chat_message/pipeline_message.rb index a0f6f582e4c..a3f68d34035 100644 --- a/app/models/integrations/chat_message/pipeline_message.rb +++ b/app/models/integrations/chat_message/pipeline_message.rb @@ -105,7 +105,7 @@ module Integrations def failed_stages_field { title: s_("ChatMessage|Failed stage").pluralize(failed_stages.length), - value: Slack::Messenger::Util::LinkFormatter.format(failed_stages_links), + value: ::Slack::Messenger::Util::LinkFormatter.format(failed_stages_links), short: true } end @@ -113,7 +113,7 @@ module Integrations def failed_jobs_field { title: s_("ChatMessage|Failed job").pluralize(failed_jobs.length), - value: Slack::Messenger::Util::LinkFormatter.format(failed_jobs_links), + value: ::Slack::Messenger::Util::LinkFormatter.format(failed_jobs_links), short: true } end @@ -130,12 +130,12 @@ module Integrations fields = [ { title: ref_type == "tag" ? s_("ChatMessage|Tag") : s_("ChatMessage|Branch"), - value: Slack::Messenger::Util::LinkFormatter.format(ref_link), + value: ::Slack::Messenger::Util::LinkFormatter.format(ref_link), short: true }, { title: s_("ChatMessage|Commit"), - value: Slack::Messenger::Util::LinkFormatter.format(commit_link), + value: ::Slack::Messenger::Util::LinkFormatter.format(commit_link), short: true } ] diff --git a/app/models/integrations/chat_message/push_message.rb b/app/models/integrations/chat_message/push_message.rb index 0952986e923..fabd214633b 100644 --- a/app/models/integrations/chat_message/push_message.rb +++ b/app/models/integrations/chat_message/push_message.rb @@ -49,7 +49,7 @@ module Integrations end def format(string) - Slack::Messenger::Util::LinkFormatter.format(string) + ::Slack::Messenger::Util::LinkFormatter.format(string) end def commit_messages diff --git a/app/models/integrations/chat_message/wiki_page_message.rb b/app/models/integrations/chat_message/wiki_page_message.rb index 9b5275b8c03..00f0f911b0e 100644 --- a/app/models/integrations/chat_message/wiki_page_message.rb +++ b/app/models/integrations/chat_message/wiki_page_message.rb @@ -7,6 +7,7 @@ module Integrations attr_reader :wiki_page_url attr_reader :action attr_reader :description + attr_reader :diff_url def initialize(params) super @@ -16,6 +17,7 @@ module Integrations @title = obj_attr[:title] @wiki_page_url = obj_attr[:url] @description = obj_attr[:message] + @diff_url = obj_attr[:diff_url] @action = case obj_attr[:action] @@ -44,19 +46,23 @@ module Integrations private def message - "#{user_combined_name} #{action} #{wiki_page_link} in #{project_link}: *#{title}*" + "#{user_combined_name} #{action} #{wiki_page_link} (#{diff_link}) in #{project_link}: *#{title}*" end def description_message [{ text: format(@description), color: attachment_color }] end + def diff_link + link('Compare changes', diff_url) + end + def project_link - "[#{project_name}](#{project_url})" + link(project_name, project_url) end def wiki_page_link - "[wiki page](#{wiki_page_url})" + link('wiki page', wiki_page_url) end end end diff --git a/app/models/integrations/custom_issue_tracker.rb b/app/models/integrations/custom_issue_tracker.rb new file mode 100644 index 00000000000..635a9d093e9 --- /dev/null +++ b/app/models/integrations/custom_issue_tracker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Integrations + class CustomIssueTracker < BaseIssueTracker + include ActionView::Helpers::UrlHelper + validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? + + def title + s_('IssueTracker|Custom issue tracker') + end + + def description + s_("IssueTracker|Use a custom issue tracker as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/custom_issue_tracker'), target: '_blank', rel: 'noopener noreferrer' + s_('IssueTracker|Use a custom issue tracker that is not in the integration list. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'custom_issue_tracker' + end + end +end diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb new file mode 100644 index 00000000000..ef6d46fd3d3 --- /dev/null +++ b/app/models/integrations/discord.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "discordrb/webhooks" + +module Integrations + class Discord < BaseChatNotification + include ActionView::Helpers::UrlHelper + + ATTACHMENT_REGEX = /: (?<entry>.*?)\n - (?<name>.*)\n*/.freeze + + def title + s_("DiscordService|Discord Notifications") + end + + def description + s_("DiscordService|Send notifications about project events to a Discord channel.") + end + + def self.to_param + "discord" + end + + def help + docs_link = link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/discord_notifications'), target: '_blank', rel: 'noopener noreferrer' + s_('Send notifications about project events to a Discord channel. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def event_field(event) + # No-op. + end + + def default_channel_placeholder + # No-op. + end + + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page] + end + + def default_fields + [ + { type: "text", name: "webhook", placeholder: "https://discordapp.com/api/webhooks/…", help: "URL to the webhook for the Discord channel." }, + { type: "checkbox", name: "notify_only_broken_pipelines" }, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } + ] + end + + private + + def notify(message, opts) + client = Discordrb::Webhooks::Client.new(url: webhook) + + client.execute do |builder| + builder.add_embed do |embed| + embed.author = Discordrb::Webhooks::EmbedAuthor.new(name: message.user_name, icon_url: message.user_avatar) + embed.description = (message.pretext + "\n" + Array.wrap(message.attachments).join("\n")).gsub(ATTACHMENT_REGEX, " \\k<entry> - \\k<name>\n") + end + end + rescue RestClient::Exception => error + log_error(error.message) + false + end + + def custom_data(data) + super(data).merge(markdown: true) + end + end +end diff --git a/app/models/integrations/drone_ci.rb b/app/models/integrations/drone_ci.rb new file mode 100644 index 00000000000..096f7093b8c --- /dev/null +++ b/app/models/integrations/drone_ci.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Integrations + class DroneCi < BaseCi + include ReactiveService + include ServicePushDataValidations + + prop_accessor :drone_url, :token + boolean_accessor :enable_ssl_verification + + validates :drone_url, presence: true, public_url: true, if: :activated? + validates :token, presence: true, if: :activated? + + after_save :compose_service_hook, if: :activated? + + def compose_service_hook + hook = service_hook || build_service_hook + # If using a service template, project may not be available + hook.url = [drone_url, "/api/hook", "?owner=#{project.namespace.full_path}", "&name=#{project.path}", "&access_token=#{token}"].join if project + hook.enable_ssl_verification = !!enable_ssl_verification + hook.save + end + + def execute(data) + case data[:object_kind] + when 'push' + service_hook.execute(data) if push_valid?(data) + when 'merge_request' + service_hook.execute(data) if merge_request_valid?(data) + when 'tag_push' + service_hook.execute(data) if tag_push_valid?(data) + end + end + + def allow_target_ci? + true + end + + def self.supported_events + %w(push merge_request tag_push) + end + + def commit_status_path(sha, ref) + Gitlab::Utils.append_path( + drone_url, + "gitlab/#{project.full_path}/commits/#{sha}?branch=#{Addressable::URI.encode_component(ref.to_s)}&access_token=#{token}") + end + + def commit_status(sha, ref) + with_reactive_cache(sha, ref) { |cached| cached[:commit_status] } + end + + def calculate_reactive_cache(sha, ref) + response = Gitlab::HTTP.try_get(commit_status_path(sha, ref), + verify: enable_ssl_verification, + extra_log_info: { project_id: project_id }) + + status = + if response && response.code == 200 && response['status'] + case response['status'] + when 'killed' + :canceled + when 'failure', 'error' + # Because drone return error if some test env failed + :failed + else + response["status"] + end + else + :error + end + + { commit_status: status } + end + + def build_page(sha, ref) + Gitlab::Utils.append_path( + drone_url, + "gitlab/#{project.full_path}/redirect/commits/#{sha}?branch=#{Addressable::URI.encode_component(ref.to_s)}") + end + + def title + 'Drone' + end + + def description + s_('ProjectService|Run CI/CD pipelines with Drone.') + end + + def self.to_param + 'drone_ci' + end + + def help + s_('ProjectService|Run CI/CD pipelines with Drone.') + end + + def fields + [ + { type: 'text', name: 'token', help: s_('ProjectService|Token for the Drone project.'), required: true }, + { type: 'text', name: 'drone_url', title: s_('ProjectService|Drone server URL'), placeholder: 'http://drone.example.com', required: true }, + { type: 'checkbox', name: 'enable_ssl_verification', title: "Enable SSL verification" } + ] + end + end +end diff --git a/app/models/integrations/ewm.rb b/app/models/integrations/ewm.rb new file mode 100644 index 00000000000..0a4e8d92ed7 --- /dev/null +++ b/app/models/integrations/ewm.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Integrations + class Ewm < BaseIssueTracker + include ActionView::Helpers::UrlHelper + + validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? + + def self.reference_pattern(only_long: true) + @reference_pattern ||= %r{(?<issue>\b(bug|task|work item|workitem|rtcwi|defect)\b\s+\d+)}i + end + + def title + 'EWM' + end + + def description + s_("IssueTracker|Use IBM Engineering Workflow Management as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/ewm'), target: '_blank', rel: 'noopener noreferrer' + s_("IssueTracker|Use IBM Engineering Workflow Management as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'ewm' + end + + def can_test? + false + end + + def issue_url(iid) + issues_url.gsub(':id', iid.to_s.split(' ')[-1]) + end + end +end diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb new file mode 100644 index 00000000000..fec435443fa --- /dev/null +++ b/app/models/integrations/external_wiki.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Integrations + class ExternalWiki < Integration + include ActionView::Helpers::UrlHelper + + prop_accessor :external_wiki_url + validates :external_wiki_url, presence: true, public_url: true, if: :activated? + + def title + s_('ExternalWikiService|External wiki') + end + + def description + s_('ExternalWikiService|Link to an external wiki from the sidebar.') + end + + def self.to_param + 'external_wiki' + end + + def fields + [ + { + type: 'text', + name: 'external_wiki_url', + title: s_('ExternalWikiService|External wiki URL'), + placeholder: s_('ExternalWikiService|https://example.com/xxx/wiki/...'), + help: 'Enter the URL to the external wiki.', + required: true + } + ] + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/wiki/index', anchor: 'link-an-external-wiki'), target: '_blank', rel: 'noopener noreferrer' + + s_('Link an external wiki from the project\'s sidebar. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def execute(_data) + response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) + response.body if response.code == 200 + rescue StandardError + nil + end + + def self.supported_events + %w() + end + end +end diff --git a/app/models/integrations/flowdock.rb b/app/models/integrations/flowdock.rb new file mode 100644 index 00000000000..443f61e65dd --- /dev/null +++ b/app/models/integrations/flowdock.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Integrations + class Flowdock < Integration + include ActionView::Helpers::UrlHelper + + prop_accessor :token + validates :token, presence: true, if: :activated? + + def title + 'Flowdock' + end + + def description + s_('FlowdockService|Send event notifications from GitLab to Flowdock flows.') + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'flowdock'), target: '_blank', rel: 'noopener noreferrer' + s_('FlowdockService|Send event notifications from GitLab to Flowdock flows. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'flowdock' + end + + def fields + [ + { type: 'text', name: 'token', placeholder: s_('FlowdockService|1b609b52537...'), required: true, help: 'Enter your Flowdock token.' } + ] + end + + def self.supported_events + %w(push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + ::Flowdock::Git.post( + data[:ref], + data[:before], + data[:after], + token: token, + repo: project.repository, + repo_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}", + commit_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/-/commit/%s", + diff_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/compare/%s...%s" + ) + end + end +end diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb new file mode 100644 index 00000000000..d02cfe4ec56 --- /dev/null +++ b/app/models/integrations/hangouts_chat.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Integrations + class HangoutsChat < BaseChatNotification + include ActionView::Helpers::UrlHelper + + def title + 'Google Chat' + end + + def description + 'Send notifications from GitLab to a room in Google Chat.' + end + + def self.to_param + 'hangouts_chat' + end + + def help + docs_link = link_to _('How do I set up a Google Chat webhook?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/hangouts_chat'), target: '_blank', rel: 'noopener noreferrer' + s_('Before enabling this integration, create a webhook for the room in Google Chat where you want to receive notifications from this project. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def event_field(event) + end + + def default_channel_placeholder + end + + def webhook_placeholder + 'https://chat.googleapis.com/v1/spaces…' + end + + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + + def default_fields + [ + { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}" }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' }, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } + ] + end + + private + + def notify(message, opts) + simple_text = parse_simple_text_message(message) + ::HangoutsChat::Sender.new(webhook).simple(simple_text) + end + + def parse_simple_text_message(message) + header = message.pretext + return header if message.attachments.empty? + + attachment = message.attachments.first + title = format_attachment_title(attachment) + body = attachment[:text] + + [header, title, body].compact.join("\n") + end + + def format_attachment_title(attachment) + return attachment[:title] unless attachment[:title_link] + + "<#{attachment[:title_link]}|#{attachment[:title]}>" + end + end +end diff --git a/app/models/integrations/irker.rb b/app/models/integrations/irker.rb new file mode 100644 index 00000000000..7048dd641ea --- /dev/null +++ b/app/models/integrations/irker.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'uri' + +module Integrations + class Irker < Integration + prop_accessor :server_host, :server_port, :default_irc_uri + prop_accessor :recipients, :channels + boolean_accessor :colorize_messages + validates :recipients, presence: true, if: :validate_recipients? + + before_validation :get_channels + + def title + 'Irker (IRC gateway)' + end + + def description + 'Send IRC messages.' + end + + def self.to_param + 'irker' + end + + def self.supported_events + %w(push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + IrkerWorker.perform_async(project_id, channels, + colorize_messages, data, settings) + end + + def settings + { + server_host: server_host.presence || 'localhost', + server_port: server_port.presence || 6659 + } + end + + def fields + [ + { type: 'text', name: 'server_host', placeholder: 'localhost', + help: 'Irker daemon hostname (defaults to localhost)' }, + { type: 'text', name: 'server_port', placeholder: 6659, + help: 'Irker daemon port (defaults to 6659)' }, + { type: 'text', name: 'default_irc_uri', title: 'Default IRC URI', + help: 'A default IRC URI to prepend before each recipient (optional)', + placeholder: 'irc://irc.network.net:6697/' }, + { type: 'textarea', name: 'recipients', + placeholder: 'Recipients/channels separated by whitespaces', required: true, + help: 'Recipients have to be specified with a full URI: '\ + 'irc[s]://irc.network.net[:port]/#channel. Special cases: if '\ + 'you want the channel to be a nickname instead, append ",isnick" to ' \ + 'the channel name; if the channel is protected by a secret password, ' \ + ' append "?key=secretpassword" to the URI (Note that due to a bug, if you ' \ + ' want to use a password, you have to omit the "#" on the channel). If you ' \ + ' specify a default IRC URI to prepend before each recipient, you can just ' \ + ' give a channel name.' }, + { type: 'checkbox', name: 'colorize_messages' } + ] + end + + def help + ' NOTE: Irker does NOT have built-in authentication, which makes it' \ + ' vulnerable to spamming IRC channels if it is hosted outside of a ' \ + ' firewall. Please make sure you run the daemon within a secured network ' \ + ' to prevent abuse. For more details, read: http://www.catb.org/~esr/irker/security.html.' + end + + private + + def get_channels + return true unless activated? + return true if recipients.nil? || recipients.empty? + + map_recipients + + errors.add(:recipients, 'are all invalid') if channels.empty? + true + end + + def map_recipients + self.channels = recipients.split(/\s+/).map do |recipient| + format_channel(recipient) + end + channels.reject!(&:nil?) + end + + def format_channel(recipient) + uri = nil + + # Try to parse the chan as a full URI + begin + uri = consider_uri(URI.parse(recipient)) + rescue URI::InvalidURIError + end + + unless uri.present? && default_irc_uri.nil? + begin + new_recipient = URI.join(default_irc_uri, '/', recipient).to_s + uri = consider_uri(URI.parse(new_recipient)) + rescue StandardError + log_error("Unable to create a valid URL", default_irc_uri: default_irc_uri, recipient: recipient) + end + end + + uri + end + + def consider_uri(uri) + return if uri.scheme.nil? + + # Authorize both irc://domain.com/#chan and irc://domain.com/chan + if uri.is_a?(URI) && uri.scheme[/^ircs?\z/] && !uri.path.nil? + uri.to_s + end + end + end +end diff --git a/app/models/integrations/issue_tracker_data.rb b/app/models/integrations/issue_tracker_data.rb new file mode 100644 index 00000000000..8749075149f --- /dev/null +++ b/app/models/integrations/issue_tracker_data.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Integrations + class IssueTrackerData < ApplicationRecord + include BaseDataFields + + attr_encrypted :project_url, encryption_options + attr_encrypted :issues_url, encryption_options + attr_encrypted :new_issue_url, encryption_options + end +end diff --git a/app/models/integrations/jenkins.rb b/app/models/integrations/jenkins.rb new file mode 100644 index 00000000000..815e86bcaa1 --- /dev/null +++ b/app/models/integrations/jenkins.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Integrations + class Jenkins < BaseCi + include ActionView::Helpers::UrlHelper + + prop_accessor :jenkins_url, :project_name, :username, :password + + before_update :reset_password + + validates :jenkins_url, presence: true, addressable_url: true, if: :activated? + validates :project_name, presence: true, if: :activated? + validates :username, presence: true, if: ->(service) { service.activated? && service.password_touched? && service.password.present? } + + default_value_for :push_events, true + default_value_for :merge_requests_events, false + default_value_for :tag_push_events, false + + after_save :compose_service_hook, if: :activated? + + def reset_password + # don't reset the password if a new one is provided + if (jenkins_url_changed? || username.blank?) && !password_touched? + self.password = nil + end + end + + def compose_service_hook + hook = service_hook || build_service_hook + hook.url = hook_url + hook.save + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + service_hook.execute(data, "#{data[:object_kind]}_hook") + end + + def test(data) + begin + result = execute(data) + return { success: false, result: result[:message] } if result[:http_status] != 200 + rescue StandardError => error + return { success: false, result: error } + end + + { success: true, result: result[:message] } + end + + def hook_url + url = URI.parse(jenkins_url) + url.path = File.join(url.path || '/', "project/#{project_name}") + url.user = ERB::Util.url_encode(username) unless username.blank? + url.password = ERB::Util.url_encode(password) unless password.blank? + url.to_s + end + + def self.supported_events + %w(push merge_request tag_push) + end + + def title + 'Jenkins' + end + + def description + s_('Run CI/CD pipelines with Jenkins.') + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('integration/jenkins'), target: '_blank', rel: 'noopener noreferrer' + s_('Run CI/CD pipelines with Jenkins when you push to a repository, or when a merge request is created, updated, or merged. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'jenkins' + end + + def fields + [ + { + type: 'text', + name: 'jenkins_url', + title: s_('ProjectService|Jenkins server URL'), + required: true, + placeholder: 'http://jenkins.example.com', + help: s_('The URL of the Jenkins server.') + }, + { + type: 'text', + name: 'project_name', + required: true, + placeholder: 'my_project_name', + help: s_('The name of the Jenkins project. Copy the name from the end of the URL to the project.') + }, + { + type: 'text', + name: 'username', + required: true, + help: s_('The username for the Jenkins server.') + }, + { + type: 'password', + name: 'password', + help: s_('The password for the Jenkins server.'), + non_empty_password_title: s_('ProjectService|Enter new password.'), + non_empty_password_help: s_('ProjectService|Leave blank to use your current password.') + } + ] + end + end +end diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb new file mode 100644 index 00000000000..aa143cc28e1 --- /dev/null +++ b/app/models/integrations/jira.rb @@ -0,0 +1,588 @@ +# frozen_string_literal: true + +# Accessible as Project#external_issue_tracker +module Integrations + class Jira < BaseIssueTracker + extend ::Gitlab::Utils::Override + include Gitlab::Routing + include ApplicationHelper + include ActionView::Helpers::AssetUrlHelper + include Gitlab::Utils::StrongMemoize + + PROJECTS_PER_PAGE = 50 + JIRA_CLOUD_HOST = '.atlassian.net' + + ATLASSIAN_REFERRER_GITLAB_COM = { atlOrigin: 'eyJpIjoiY2QyZTJiZDRkNGZhNGZlMWI3NzRkNTBmZmVlNzNiZTkiLCJwIjoianN3LWdpdGxhYi1pbnQifQ' }.freeze + ATLASSIAN_REFERRER_SELF_MANAGED = { atlOrigin: 'eyJpIjoiYjM0MTA4MzUyYTYxNDVkY2IwMzVjOGQ3ZWQ3NzMwM2QiLCJwIjoianN3LWdpdGxhYlNNLWludCJ9' }.freeze + + validates :url, public_url: true, presence: true, if: :activated? + validates :api_url, public_url: true, allow_blank: true + validates :username, presence: true, if: :activated? + validates :password, presence: true, if: :activated? + + validates :jira_issue_transition_id, + format: { with: Gitlab::Regex.jira_transition_id_regex, message: s_("JiraService|IDs must be a list of numbers that can be split with , or ;") }, + allow_blank: true + + # Jira Cloud version is deprecating authentication via username and password. + # We should use username/password for Jira Server and email/api_token for Jira Cloud, + # for more information check: https://gitlab.com/gitlab-org/gitlab-foss/issues/49936. + + # TODO: we can probably just delegate as part of + # https://gitlab.com/gitlab-org/gitlab/issues/29404 + data_field :username, :password, :url, :api_url, :jira_issue_transition_automatic, :jira_issue_transition_id, :project_key, :issues_enabled, + :vulnerabilities_enabled, :vulnerabilities_issuetype + + before_update :reset_password + after_commit :update_deployment_type, on: [:create, :update], if: :update_deployment_type? + + enum comment_detail: { + standard: 1, + all_details: 2 + } + + # When these are false GitLab does not create cross reference + # comments on Jira except when an issue gets transitioned. + def self.supported_events + %w(commit merge_request) + end + + def self.supported_event_actions + %w(comment) + end + + # {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1 + def self.reference_pattern(only_long: true) + @reference_pattern ||= /(?<issue>\b#{Gitlab::Regex.jira_issue_key_regex})/ + end + + def initialize_properties + {} + end + + def data_fields + jira_tracker_data || self.build_jira_tracker_data + end + + def reset_password + data_fields.password = nil if reset_password? + end + + def set_default_data + return unless issues_tracker.present? + + return if url + + data_fields.url ||= issues_tracker['url'] + data_fields.api_url ||= issues_tracker['api_url'] + end + + def options + url = URI.parse(client_url) + + { + username: username&.strip, + password: password, + site: URI.join(url, '/').to_s.delete_suffix('/'), # Intended to find the root + context_path: (url.path.presence || '/').delete_suffix('/'), + auth_type: :basic, + read_timeout: 120, + use_cookies: true, + additional_cookies: ['OBBasicAuth=fromDialog'], + use_ssl: url.scheme == 'https' + } + end + + def client + @client ||= begin + JIRA::Client.new(options).tap do |client| + # Replaces JIRA default http client with our implementation + client.request_client = Gitlab::Jira::HttpClient.new(client.options) + end + end + end + + def help + jira_doc_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_url('integration/jira/index.html') } + s_("JiraService|You need to configure Jira before enabling this integration. For more details, read the %{jira_doc_link_start}Jira integration documentation%{link_end}.") % { jira_doc_link_start: jira_doc_link_start, link_end: '</a>'.html_safe } + end + + def title + 'Jira' + end + + def description + s_("JiraService|Use Jira as this project's issue tracker.") + end + + def self.to_param + 'jira' + end + + def fields + [ + { + type: 'text', + name: 'url', + title: s_('JiraService|Web URL'), + placeholder: 'https://jira.example.com', + help: s_('JiraService|Base URL of the Jira instance.'), + required: true + }, + { + type: 'text', + name: 'api_url', + title: s_('JiraService|Jira API URL'), + help: s_('JiraService|If different from Web URL.') + }, + { + type: 'text', + name: 'username', + title: s_('JiraService|Username or Email'), + help: s_('JiraService|Use a username for server version and an email for cloud version.'), + required: true + }, + { + type: 'password', + name: 'password', + title: s_('JiraService|Password or API token'), + non_empty_password_title: s_('JiraService|Enter new password or API token'), + non_empty_password_help: s_('JiraService|Leave blank to use your current password or API token.'), + help: s_('JiraService|Use a password for server version and an API token for cloud version.'), + required: true + } + ] + end + + def web_url(path = nil, **params) + return unless url.present? + + if Gitlab.com? + params.merge!(ATLASSIAN_REFERRER_GITLAB_COM) unless Gitlab.staging? + else + params.merge!(ATLASSIAN_REFERRER_SELF_MANAGED) unless Gitlab.dev_or_test_env? + end + + url = Addressable::URI.parse(self.url) + url.path = url.path.delete_suffix('/') + url.path << "/#{path.delete_prefix('/').delete_suffix('/')}" if path.present? + url.query_values = (url.query_values || {}).merge(params) + url.query_values = nil if url.query_values.empty? + + url.to_s + end + + override :project_url + def project_url + web_url + end + + override :issues_url + def issues_url + web_url('browse/:id') + end + + override :new_issue_url + def new_issue_url + web_url('secure/CreateIssue!default.jspa') + end + + alias_method :original_url, :url + def url + original_url&.delete_suffix('/') + end + + alias_method :original_api_url, :api_url + def api_url + original_api_url&.delete_suffix('/') + end + + def execute(push) + # This method is a no-op, because currently Integrations::Jira does not + # support any events. + end + + def find_issue(issue_key, rendered_fields: false, transitions: false) + expands = [] + expands << 'renderedFields' if rendered_fields + expands << 'transitions' if transitions + options = { expand: expands.join(',') } if expands.any? + + jira_request { client.Issue.find(issue_key, options || {}) } + end + + def close_issue(entity, external_issue, current_user) + issue = find_issue(external_issue.iid, transitions: jira_issue_transition_automatic) + + return if issue.nil? || has_resolution?(issue) || !issue_transition_enabled? + + commit_id = case entity + when Commit then entity.id + when MergeRequest then entity.diff_head_sha + end + + commit_url = build_entity_url(:commit, commit_id) + + # Depending on the Jira project's workflow, a comment during transition + # may or may not be allowed. Refresh the issue after transition and check + # if it is closed, so we don't have one comment for every commit. + issue = find_issue(issue.key) if transition_issue(issue) + add_issue_solved_comment(issue, commit_id, commit_url) if has_resolution?(issue) + log_usage(:close_issue, current_user) + end + + override :create_cross_reference_note + def create_cross_reference_note(mentioned, noteable, author) + unless can_cross_reference?(noteable) + return s_("JiraService|Events for %{noteable_model_name} are disabled.") % { noteable_model_name: noteable.model_name.plural.humanize(capitalize: false) } + end + + jira_issue = find_issue(mentioned.id) + + return unless jira_issue.present? + + noteable_id = noteable.respond_to?(:iid) ? noteable.iid : noteable.id + noteable_type = noteable_name(noteable) + entity_url = build_entity_url(noteable_type, noteable_id) + entity_meta = build_entity_meta(noteable) + + data = { + user: { + name: author.name, + url: resource_url(user_path(author)) + }, + project: { + name: project.full_path, + url: resource_url(project_path(project)) + }, + entity: { + id: entity_meta[:id], + name: noteable_type.humanize.downcase, + url: entity_url, + title: noteable.title, + description: entity_meta[:description], + branch: entity_meta[:branch] + } + } + + add_comment(data, jira_issue).tap { log_usage(:cross_reference, author) } + end + + def valid_connection? + test(nil)[:success] + end + + def test(_) + result = server_info + success = result.present? + result = @error&.message unless success + + { success: success, result: result } + end + + override :support_close_issue? + def support_close_issue? + true + end + + override :support_cross_reference? + def support_cross_reference? + true + end + + def issue_transition_enabled? + jira_issue_transition_automatic || jira_issue_transition_id.present? + end + + private + + def server_info + strong_memoize(:server_info) do + client_url.present? ? jira_request { client.ServerInfo.all.attrs } : nil + end + end + + def can_cross_reference?(noteable) + case noteable + when Commit then commit_events + when MergeRequest then merge_requests_events + else true + end + end + + # jira_issue_transition_id can have multiple values split by , or ; + # the issue is transitioned at the order given by the user + # if any transition fails it will log the error message and stop the transition sequence + def transition_issue(issue) + return transition_issue_to_done(issue) if jira_issue_transition_automatic + + jira_issue_transition_id.scan(Gitlab::Regex.jira_transition_id_regex).all? do |transition_id| + transition_issue_to_id(issue, transition_id) + end + end + + def transition_issue_to_id(issue, transition_id) + issue.transitions.build.save!( + transition: { id: transition_id } + ) + + true + rescue StandardError => error + log_error( + "Issue transition failed", + error: { + exception_class: error.class.name, + exception_message: error.message, + exception_backtrace: Gitlab::BacktraceCleaner.clean_backtrace(error.backtrace) + }, + client_url: client_url + ) + + false + end + + def transition_issue_to_done(issue) + transitions = issue.transitions rescue [] + + transition = transitions.find do |transition| + status = transition&.to&.statusCategory + status && status['key'] == 'done' + end + + return false unless transition + + transition_issue_to_id(issue, transition.id) + end + + def log_usage(action, user) + key = "i_ecosystem_jira_service_#{action}" + + Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user.id) + end + + def add_issue_solved_comment(issue, commit_id, commit_url) + link_title = "Solved by commit #{commit_id}." + comment = "Issue solved with [#{commit_id}|#{commit_url}]." + link_props = build_remote_link_props(url: commit_url, title: link_title, resolved: true) + send_message(issue, comment, link_props) + end + + def add_comment(data, issue) + entity_name = data[:entity][:name] + entity_url = data[:entity][:url] + entity_title = data[:entity][:title] + + message = comment_message(data) + link_title = "#{entity_name.capitalize} - #{entity_title}" + link_props = build_remote_link_props(url: entity_url, title: link_title) + + unless comment_exists?(issue, message) + send_message(issue, message, link_props) + end + end + + def comment_message(data) + user_link = build_jira_link(data[:user][:name], data[:user][:url]) + + entity = data[:entity] + entity_ref = all_details? ? "#{entity[:name]} #{entity[:id]}" : "a #{entity[:name]}" + entity_link = build_jira_link(entity_ref, entity[:url]) + + project_link = build_jira_link(project.full_name, Gitlab::Routing.url_helpers.project_url(project)) + branch = + if entity[:branch].present? + s_('JiraService| on branch %{branch_link}') % { + branch_link: build_jira_link(entity[:branch], project_tree_url(project, entity[:branch])) + } + end + + entity_message = entity[:description].presence if all_details? + entity_message ||= entity[:title].chomp + + s_('JiraService|%{user_link} mentioned this issue in %{entity_link} of %{project_link}%{branch}:{quote}%{entity_message}{quote}') % { + user_link: user_link, + entity_link: entity_link, + project_link: project_link, + branch: branch, + entity_message: entity_message + } + end + + def build_jira_link(title, url) + "[#{title}|#{url}]" + end + + def has_resolution?(issue) + issue.respond_to?(:resolution) && issue.resolution.present? + end + + def comment_exists?(issue, message) + comments = jira_request { issue.comments } + + comments.present? && comments.any? { |comment| comment.body.include?(message) } + end + + def send_message(issue, message, remote_link_props) + return unless client_url.present? + + jira_request do + remote_link = find_remote_link(issue, remote_link_props[:object][:url]) + + create_issue_comment(issue, message) unless remote_link + remote_link ||= issue.remotelink.build + remote_link.save!(remote_link_props) + + log_info("Successfully posted", client_url: client_url) + "SUCCESS: Successfully posted to #{client_url}." + end + end + + def create_issue_comment(issue, message) + return unless comment_on_event_enabled + + issue.comments.build.save!(body: message) + end + + def find_remote_link(issue, url) + links = jira_request { issue.remotelink.all } + return unless links + + links.find { |link| link.object["url"] == url } + end + + def build_remote_link_props(url:, title:, resolved: false) + status = { + resolved: resolved + } + + { + GlobalID: 'GitLab', + relationship: 'mentioned on', + object: { + url: url, + title: title, + status: status, + icon: { + title: 'GitLab', url16x16: asset_url(Gitlab::Favicon.main, host: gitlab_config.base_url) + } + } + } + end + + def resource_url(resource) + "#{Settings.gitlab.base_url.chomp("/")}#{resource}" + end + + def build_entity_url(noteable_type, entity_id) + polymorphic_url( + [ + self.project, + noteable_type.to_sym + ], + id: entity_id, + host: Settings.gitlab.base_url + ) + end + + def build_entity_meta(noteable) + if noteable.is_a?(Commit) + { + id: noteable.short_id, + description: noteable.safe_message, + branch: noteable.ref_names(project.repository).first + } + elsif noteable.is_a?(MergeRequest) + { + id: noteable.to_reference, + branch: noteable.source_branch + } + else + {} + end + end + + def noteable_name(noteable) + name = noteable.model_name.singular + + # ProjectSnippet inherits from Snippet class so it causes + # routing error building the URL. + name == "project_snippet" ? "snippet" : name + end + + # Handle errors when doing Jira API calls + def jira_request + yield + rescue StandardError => error + @error = error + log_error("Error sending message", client_url: client_url, error: @error.message) + nil + end + + def client_url + api_url.presence || url + end + + def reset_password? + # don't reset the password if a new one is provided + return false if password_touched? + return true if api_url_changed? + return false if api_url.present? + + url_changed? + end + + def update_deployment_type? + (api_url_changed? || url_changed? || username_changed? || password_changed?) && + can_test? + end + + def update_deployment_type + clear_memoization(:server_info) # ensure we run the request when we try to update deployment type + results = server_info + + unless results.present? + Gitlab::AppLogger.warn(message: "Jira API returned no ServerInfo, setting deployment_type from URL", server_info: results, url: client_url) + + return set_deployment_type_from_url + end + + if jira_cloud? + data_fields.deployment_cloud! + else + data_fields.deployment_server! + end + end + + def jira_cloud? + server_info['deploymentType'] == 'Cloud' || URI(client_url).hostname.end_with?(JIRA_CLOUD_HOST) + end + + def set_deployment_type_from_url + # This shouldn't happen but of course it will happen when an integration is removed. + # Instead of deleting the integration we set all fields to null + # and mark it as inactive + return data_fields.deployment_unknown! unless client_url + + # If API-based detection methods fail here then + # we can only assume it's either Cloud or Server + # based on the URL being *.atlassian.net + + if URI(client_url).hostname.end_with?(JIRA_CLOUD_HOST) + data_fields.deployment_cloud! + else + data_fields.deployment_server! + end + end + + def self.event_description(event) + case event + when "merge_request", "merge_request_events" + s_("JiraService|Jira comments are created when an issue is referenced in a merge request.") + when "commit", "commit_events" + s_("JiraService|Jira comments are created when an issue is referenced in a commit.") + end + end + end +end + +Integrations::Jira.prepend_mod_with('Integrations::Jira') diff --git a/app/models/integrations/jira_tracker_data.rb b/app/models/integrations/jira_tracker_data.rb new file mode 100644 index 00000000000..74352393b43 --- /dev/null +++ b/app/models/integrations/jira_tracker_data.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Integrations + class JiraTrackerData < ApplicationRecord + include BaseDataFields + + attr_encrypted :url, encryption_options + attr_encrypted :api_url, encryption_options + attr_encrypted :username, encryption_options + attr_encrypted :password, encryption_options + + enum deployment_type: { unknown: 0, server: 1, cloud: 2 }, _prefix: :deployment + end +end diff --git a/app/models/integrations/mattermost.rb b/app/models/integrations/mattermost.rb new file mode 100644 index 00000000000..07a5086b8e9 --- /dev/null +++ b/app/models/integrations/mattermost.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Integrations + class Mattermost < BaseChatNotification + include SlackMattermostNotifier + include ActionView::Helpers::UrlHelper + + def title + s_('Mattermost notifications') + end + + def description + s_('Send notifications about project events to Mattermost channels.') + end + + def self.to_param + 'mattermost' + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/mattermost'), target: '_blank', rel: 'noopener noreferrer' + s_('Send notifications about project events to Mattermost channels. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def default_channel_placeholder + 'my-channel' + end + + def webhook_placeholder + 'http://mattermost.example.com/hooks/' + end + end +end diff --git a/app/models/integrations/mattermost_slash_commands.rb b/app/models/integrations/mattermost_slash_commands.rb new file mode 100644 index 00000000000..6cd664da9e7 --- /dev/null +++ b/app/models/integrations/mattermost_slash_commands.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Integrations + class MattermostSlashCommands < BaseSlashCommands + include Ci::TriggersHelper + + prop_accessor :token + + def can_test? + false + end + + def title + 'Mattermost slash commands' + end + + def description + "Perform common tasks with slash commands." + end + + def self.to_param + 'mattermost_slash_commands' + end + + def configure(user, params) + token = ::Mattermost::Command.new(user) + .create(command(params)) + + update(active: true, token: token) if token + rescue ::Mattermost::Error => e + [false, e.message] + end + + def list_teams(current_user) + [::Mattermost::Team.new(current_user).all, nil] + rescue ::Mattermost::Error => e + [[], e.message] + end + + def chat_responder + ::Gitlab::Chat::Responder::Mattermost + end + + private + + def command(params) + pretty_project_name = project.full_name + + params.merge( + auto_complete: true, + auto_complete_desc: "Perform common operations on: #{pretty_project_name}", + auto_complete_hint: '[help]', + description: "Perform common operations on: #{pretty_project_name}", + display_name: "GitLab / #{pretty_project_name}", + method: 'P', + username: 'GitLab') + end + end +end diff --git a/app/models/integrations/microsoft_teams.rb b/app/models/integrations/microsoft_teams.rb new file mode 100644 index 00000000000..91e6800f03c --- /dev/null +++ b/app/models/integrations/microsoft_teams.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Integrations + class MicrosoftTeams < BaseChatNotification + def title + 'Microsoft Teams notifications' + end + + def description + 'Send notifications about project events to Microsoft Teams.' + end + + def self.to_param + 'microsoft_teams' + end + + def help + '<p>Use this service to send notifications about events in GitLab projects to your Microsoft Teams channels. <a href="https://docs.gitlab.com/ee/user/project/integrations/microsoft_teams.html">How do I configure this integration?</a></p>' + end + + def webhook_placeholder + 'https://outlook.office.com/webhook/…' + end + + def event_field(event) + end + + def default_channel_placeholder + end + + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + + def default_fields + [ + { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}" }, + { type: 'checkbox', name: 'notify_only_broken_pipelines', help: 'If selected, successful pipelines do not trigger a notification event.' }, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } + ] + end + + private + + def notify(message, opts) + ::MicrosoftTeams::Notifier.new(webhook).ping( + title: message.project_name, + summary: message.summary, + activity: message.activity, + attachments: message.attachments + ) + end + + def custom_data(data) + super(data).merge(markdown: true) + end + end +end diff --git a/app/models/integrations/mock_ci.rb b/app/models/integrations/mock_ci.rb new file mode 100644 index 00000000000..d31f6381767 --- /dev/null +++ b/app/models/integrations/mock_ci.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +# For an example companion mocking service, see https://gitlab.com/gitlab-org/gitlab-mock-ci-service +module Integrations + class MockCi < BaseCi + ALLOWED_STATES = %w[failed canceled running pending success success-with-warnings skipped not_found].freeze + + prop_accessor :mock_service_url + validates :mock_service_url, presence: true, public_url: true, if: :activated? + + def title + 'MockCI' + end + + def description + 'Mock an external CI' + end + + def self.to_param + 'mock_ci' + end + + def fields + [ + { + type: 'text', + name: 'mock_service_url', + title: s_('ProjectService|Mock service URL'), + placeholder: 'http://localhost:4004', + required: true + } + ] + end + + # Return complete url to build page + # + # Ex. + # http://jenkins.example.com:8888/job/test1/scm/bySHA1/12d65c + # + def build_page(sha, ref) + Gitlab::Utils.append_path( + mock_service_url, + "#{project.namespace.path}/#{project.path}/status/#{sha}") + end + + # Return string with build status or :error symbol + # + # Allowed states: 'success', 'failed', 'running', 'pending', 'skipped' + # + # Ex. + # @service.commit_status('13be4ac', 'master') + # # => 'success' + # + # @service.commit_status('2abe4ac', 'dev') + # # => 'running' + # + def commit_status(sha, ref) + response = Gitlab::HTTP.get(commit_status_path(sha), verify: false) + read_commit_status(response) + rescue Errno::ECONNREFUSED + :error + end + + def commit_status_path(sha) + Gitlab::Utils.append_path( + mock_service_url, + "#{project.namespace.path}/#{project.path}/status/#{sha}.json") + end + + def read_commit_status(response) + return :error unless response.code == 200 || response.code == 404 + + status = if response.code == 404 + 'pending' + else + response['status'] + end + + if status.present? && ALLOWED_STATES.include?(status) + status + else + :error + end + end + + def can_test? + false + end + end +end diff --git a/app/models/integrations/open_project.rb b/app/models/integrations/open_project.rb new file mode 100644 index 00000000000..e4cfb24151a --- /dev/null +++ b/app/models/integrations/open_project.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Integrations + class OpenProject < BaseIssueTracker + validates :url, public_url: true, presence: true, if: :activated? + validates :api_url, public_url: true, allow_blank: true, if: :activated? + validates :token, presence: true, if: :activated? + validates :project_identifier_code, presence: true, if: :activated? + + data_field :url, :api_url, :token, :closed_status_id, :project_identifier_code + + def data_fields + open_project_tracker_data || self.build_open_project_tracker_data + end + + def self.to_param + 'open_project' + end + end +end diff --git a/app/models/integrations/open_project_tracker_data.rb b/app/models/integrations/open_project_tracker_data.rb new file mode 100644 index 00000000000..b3f2618b94f --- /dev/null +++ b/app/models/integrations/open_project_tracker_data.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Integrations + class OpenProjectTrackerData < ApplicationRecord + include BaseDataFields + + # When the Open Project is fresh installed, the default closed status id is "13" based on current version: v8. + DEFAULT_CLOSED_STATUS_ID = "13" + + attr_encrypted :url, encryption_options + attr_encrypted :api_url, encryption_options + attr_encrypted :token, encryption_options + + def closed_status_id + super || DEFAULT_CLOSED_STATUS_ID + end + end +end diff --git a/app/models/integrations/packagist.rb b/app/models/integrations/packagist.rb new file mode 100644 index 00000000000..b597bd11175 --- /dev/null +++ b/app/models/integrations/packagist.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Integrations + class Packagist < Integration + prop_accessor :username, :token, :server + + validates :username, presence: true, if: :activated? + validates :token, presence: true, if: :activated? + + default_value_for :push_events, true + default_value_for :tag_push_events, true + + after_save :compose_service_hook, if: :activated? + + def title + 'Packagist' + end + + def description + s_('Integrations|Update your Packagist projects.') + end + + def self.to_param + 'packagist' + end + + def fields + [ + { type: 'text', name: 'username', placeholder: '', required: true }, + { type: 'text', name: 'token', placeholder: '', required: true }, + { type: 'text', name: 'server', placeholder: 'https://packagist.org', required: false } + ] + end + + def self.supported_events + %w(push merge_request tag_push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + service_hook.execute(data) + end + + def test(data) + begin + result = execute(data) + return { success: false, result: result[:message] } if result[:http_status] != 202 + rescue StandardError => error + return { success: false, result: error } + end + + { success: true, result: result[:message] } + end + + def compose_service_hook + hook = service_hook || build_service_hook + hook.url = hook_url + hook.save + end + + def hook_url + base_url = server.presence || 'https://packagist.org' + "#{base_url}/api/update-package?username=#{username}&apiToken=#{token}" + end + end +end diff --git a/app/models/integrations/pipelines_email.rb b/app/models/integrations/pipelines_email.rb new file mode 100644 index 00000000000..585bc14242a --- /dev/null +++ b/app/models/integrations/pipelines_email.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Integrations + class PipelinesEmail < Integration + include NotificationBranchSelection + + prop_accessor :recipients, :branches_to_be_notified + boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch + validates :recipients, presence: true, if: :validate_recipients? + + def initialize_properties + if properties.nil? + self.properties = {} + self.notify_only_broken_pipelines = true + self.branches_to_be_notified = "default" + elsif !self.notify_only_default_branch.nil? + # In older versions, there was only a boolean property named + # `notify_only_default_branch`. Now we have a string property named + # `branches_to_be_notified`. Instead of doing a background migration, we + # opted to set a value for the new property based on the old one, if + # users hasn't specified one already. When users edit the service and + # selects a value for this new property, it will override everything. + + self.branches_to_be_notified ||= notify_only_default_branch? ? "default" : "all" + end + end + + def title + _('Pipeline status emails') + end + + def description + _('Email the pipeline status to a list of recipients.') + end + + def self.to_param + 'pipelines_email' + end + + def self.supported_events + %w[pipeline] + end + + def self.default_test_event + 'pipeline' + end + + def execute(data, force: false) + return unless supported_events.include?(data[:object_kind]) + return unless force || should_pipeline_be_notified?(data) + + all_recipients = retrieve_recipients(data) + + return unless all_recipients.any? + + pipeline_id = data[:object_attributes][:id] + PipelineNotificationWorker.new.perform(pipeline_id, recipients: all_recipients) + end + + def can_test? + project&.ci_pipelines&.any? + end + + def fields + [ + { type: 'textarea', + name: 'recipients', + help: _('Comma-separated list of email addresses.'), + required: true }, + { type: 'checkbox', + name: 'notify_only_broken_pipelines' }, + { type: 'select', + name: 'branches_to_be_notified', + choices: branch_choices } + ] + end + + def test(data) + result = execute(data, force: true) + + { success: true, result: result } + rescue StandardError => error + { success: false, result: error } + end + + def should_pipeline_be_notified?(data) + notify_for_branch?(data) && notify_for_pipeline?(data) + end + + def notify_for_pipeline?(data) + case data[:object_attributes][:status] + when 'success' + !notify_only_broken_pipelines? + when 'failed' + true + else + false + end + end + + def retrieve_recipients(data) + recipients.to_s.split(/[,\r\n ]+/).reject(&:empty?) + end + end +end diff --git a/app/models/integrations/pivotaltracker.rb b/app/models/integrations/pivotaltracker.rb new file mode 100644 index 00000000000..46f97cc3c6b --- /dev/null +++ b/app/models/integrations/pivotaltracker.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Integrations + class Pivotaltracker < Integration + API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits' + + prop_accessor :token, :restrict_to_branch + validates :token, presence: true, if: :activated? + + def title + 'PivotalTracker' + end + + def description + s_('PivotalTrackerService|Add commit messages as comments to PivotalTracker stories.') + end + + def self.to_param + 'pivotaltracker' + end + + def fields + [ + { + type: 'text', + name: 'token', + placeholder: s_('PivotalTrackerService|Pivotal Tracker API token.'), + required: true + }, + { + type: 'text', + name: 'restrict_to_branch', + placeholder: s_('PivotalTrackerService|Comma-separated list of branches which will be ' \ + 'automatically inspected. Leave blank to include all branches.') + } + ] + end + + def self.supported_events + %w(push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + return unless allowed_branch?(data[:ref]) + + data[:commits].each do |commit| + message = { + 'source_commit' => { + 'commit_id' => commit[:id], + 'author' => commit[:author][:name], + 'url' => commit[:url], + 'message' => commit[:message] + } + } + Gitlab::HTTP.post( + API_ENDPOINT, + body: message.to_json, + headers: { + 'Content-Type' => 'application/json', + 'X-TrackerToken' => token + } + ) + end + end + + private + + def allowed_branch?(ref) + return true unless ref.present? && restrict_to_branch.present? + + branch = Gitlab::Git.ref_name(ref) + allowed_branches = restrict_to_branch.split(',').map(&:strip) + + branch.present? && allowed_branches.include?(branch) + end + end +end diff --git a/app/models/integrations/pushover.rb b/app/models/integrations/pushover.rb new file mode 100644 index 00000000000..b0cadc7ef4e --- /dev/null +++ b/app/models/integrations/pushover.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +module Integrations + class Pushover < Integration + BASE_URI = 'https://api.pushover.net/1' + + prop_accessor :api_key, :user_key, :device, :priority, :sound + validates :api_key, :user_key, :priority, presence: true, if: :activated? + + def title + 'Pushover' + end + + def description + s_('PushoverService|Get real-time notifications on your device.') + end + + def self.to_param + 'pushover' + end + + def fields + [ + { type: 'text', name: 'api_key', title: _('API key'), placeholder: s_('PushoverService|Your application key'), required: true }, + { type: 'text', name: 'user_key', placeholder: s_('PushoverService|Your user key'), required: true }, + { type: 'text', name: 'device', placeholder: s_('PushoverService|Leave blank for all active devices') }, + { type: 'select', name: 'priority', required: true, choices: + [ + [s_('PushoverService|Lowest Priority'), -2], + [s_('PushoverService|Low Priority'), -1], + [s_('PushoverService|Normal Priority'), 0], + [s_('PushoverService|High Priority'), 1] + ], + default_choice: 0 }, + { type: 'select', name: 'sound', choices: + [ + ['Device default sound', nil], + ['Pushover (default)', 'pushover'], + %w(Bike bike), + %w(Bugle bugle), + ['Cash Register', 'cashregister'], + %w(Classical classical), + %w(Cosmic cosmic), + %w(Falling falling), + %w(Gamelan gamelan), + %w(Incoming incoming), + %w(Intermission intermission), + %w(Magic magic), + %w(Mechanical mechanical), + ['Piano Bar', 'pianobar'], + %w(Siren siren), + ['Space Alarm', 'spacealarm'], + ['Tug Boat', 'tugboat'], + ['Alien Alarm (long)', 'alien'], + ['Climb (long)', 'climb'], + ['Persistent (long)', 'persistent'], + ['Pushover Echo (long)', 'echo'], + ['Up Down (long)', 'updown'], + ['None (silent)', 'none'] + ] } + ] + end + + def self.supported_events + %w(push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + ref = Gitlab::Git.ref_name(data[:ref]) + before = data[:before] + after = data[:after] + + message = + if Gitlab::Git.blank_ref?(before) + s_("PushoverService|%{user_name} pushed new branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref } + elsif Gitlab::Git.blank_ref?(after) + s_("PushoverService|%{user_name} deleted branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref } + else + s_("PushoverService|%{user_name} push to branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref } + end + + if data[:total_commits_count] > 0 + message = [message, s_("PushoverService|Total commits count: %{total_commits_count}") % { total_commits_count: data[:total_commits_count] }].join("\n") + end + + pushover_data = { + token: api_key, + user: user_key, + device: device, + priority: priority, + title: "#{project.full_name}", + message: message, + url: data[:project][:web_url], + url_title: s_("PushoverService|See project %{project_full_name}") % { project_full_name: project.full_name } + } + + # Sound parameter MUST NOT be sent to API if not selected + if sound + pushover_data[:sound] = sound + end + + Gitlab::HTTP.post('/messages.json', base_uri: BASE_URI, body: pushover_data) + end + end +end diff --git a/app/models/integrations/redmine.rb b/app/models/integrations/redmine.rb new file mode 100644 index 00000000000..990b538f294 --- /dev/null +++ b/app/models/integrations/redmine.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Integrations + class Redmine < BaseIssueTracker + include ActionView::Helpers::UrlHelper + validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? + + def title + 'Redmine' + end + + def description + s_("IssueTracker|Use Redmine as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/redmine'), target: '_blank', rel: 'noopener noreferrer' + s_('IssueTracker|Use Redmine as the issue tracker. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'redmine' + end + end +end diff --git a/app/models/integrations/slack.rb b/app/models/integrations/slack.rb new file mode 100644 index 00000000000..0381db3a67e --- /dev/null +++ b/app/models/integrations/slack.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Integrations + class Slack < BaseChatNotification + include SlackMattermostNotifier + extend ::Gitlab::Utils::Override + + SUPPORTED_EVENTS_FOR_USAGE_LOG = %w[ + push issue confidential_issue merge_request note confidential_note + tag_push wiki_page deployment + ].freeze + + prop_accessor EVENT_CHANNEL['alert'] + + def title + 'Slack notifications' + end + + def description + 'Send notifications about project events to Slack.' + end + + def self.to_param + 'slack' + end + + def default_channel_placeholder + _('#general, #development') + end + + def webhook_placeholder + 'https://hooks.slack.com/services/…' + end + + def supported_events + additional = [] + additional << 'alert' + + super + additional + end + + def get_message(object_kind, data) + return Integrations::ChatMessage::AlertMessage.new(data) if object_kind == 'alert' + + super + end + + override :log_usage + def log_usage(event, user_id) + return unless user_id + + return unless SUPPORTED_EVENTS_FOR_USAGE_LOG.include?(event) + + key = "i_ecosystem_slack_service_#{event}_notification" + + Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user_id) + end + end +end diff --git a/app/models/integrations/slack_slash_commands.rb b/app/models/integrations/slack_slash_commands.rb new file mode 100644 index 00000000000..ff1f806df45 --- /dev/null +++ b/app/models/integrations/slack_slash_commands.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Integrations + class SlackSlashCommands < BaseSlashCommands + include Ci::TriggersHelper + + def title + 'Slack slash commands' + end + + def description + "Perform common operations in Slack" + end + + def self.to_param + 'slack_slash_commands' + end + + def trigger(params) + # Format messages to be Slack-compatible + super.tap do |result| + result[:text] = format(result[:text]) if result.is_a?(Hash) + end + end + + def chat_responder + ::Gitlab::Chat::Responder::Slack + end + + private + + def format(text) + ::Slack::Messenger::Util::LinkFormatter.format(text) if text + end + end +end diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb new file mode 100644 index 00000000000..8284d5963ae --- /dev/null +++ b/app/models/integrations/teamcity.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +module Integrations + class Teamcity < BaseCi + include ReactiveService + include ServicePushDataValidations + + prop_accessor :teamcity_url, :build_type, :username, :password + + validates :teamcity_url, presence: true, public_url: true, if: :activated? + validates :build_type, presence: true, if: :activated? + validates :username, + presence: true, + if: ->(service) { service.activated? && service.password } + validates :password, + presence: true, + if: ->(service) { service.activated? && service.username } + + attr_accessor :response + + after_save :compose_service_hook, if: :activated? + before_update :reset_password + + class << self + def to_param + 'teamcity' + end + + def supported_events + %w(push merge_request) + end + + def event_description(event) + case event + when 'push', 'push_events' + 'TeamCity CI will be triggered after every push to the repository except branch delete' + when 'merge_request', 'merge_request_events' + 'TeamCity CI will be triggered after a merge request has been created or updated' + end + end + end + + def compose_service_hook + hook = service_hook || build_service_hook + hook.save + end + + def reset_password + if teamcity_url_changed? && !password_touched? + self.password = nil + end + end + + def title + 'JetBrains TeamCity' + end + + def description + s_('ProjectService|Run CI/CD pipelines with JetBrains TeamCity.') + end + + def help + s_('To run CI/CD pipelines with JetBrains TeamCity, input the GitLab project details in the TeamCity project Version Control Settings.') + end + + def fields + [ + { + type: 'text', + name: 'teamcity_url', + title: s_('ProjectService|TeamCity server URL'), + placeholder: 'https://teamcity.example.com', + required: true + }, + { + type: 'text', + name: 'build_type', + help: s_('ProjectService|The build configuration ID of the TeamCity project.'), + required: true + }, + { + type: 'text', + name: 'username', + help: s_('ProjectService|Must have permission to trigger a manual build in TeamCity.') + }, + { + type: 'password', + name: 'password', + non_empty_password_title: s_('ProjectService|Enter new password'), + non_empty_password_help: s_('ProjectService|Leave blank to use your current password') + } + ] + end + + def build_page(sha, ref) + with_reactive_cache(sha, ref) {|cached| cached[:build_page] } + end + + def commit_status(sha, ref) + with_reactive_cache(sha, ref) {|cached| cached[:commit_status] } + end + + def calculate_reactive_cache(sha, ref) + response = get_path("httpAuth/app/rest/builds/branch:unspecified:any,revision:#{sha}") + + if response + { build_page: read_build_page(response), commit_status: read_commit_status(response) } + else + { build_page: teamcity_url, commit_status: :error } + end + end + + def execute(data) + case data[:object_kind] + when 'push' + execute_push(data) + when 'merge_request' + execute_merge_request(data) + end + end + + private + + def execute_push(data) + branch = Gitlab::Git.ref_name(data[:ref]) + post_to_build_queue(data, branch) if push_valid?(data) + end + + def execute_merge_request(data) + branch = data[:object_attributes][:source_branch] + post_to_build_queue(data, branch) if merge_request_valid?(data) + end + + def read_build_page(response) + if response.code != 200 + # If actual build link can't be determined, + # send user to build summary page. + build_url("viewLog.html?buildTypeId=#{build_type}") + else + # If actual build link is available, go to build result page. + built_id = response['build']['id'] + build_url("viewLog.html?buildId=#{built_id}&buildTypeId=#{build_type}") + end + end + + def read_commit_status(response) + return :error unless response.code == 200 || response.code == 404 + + status = if response.code == 404 + 'Pending' + else + response['build']['status'] + end + + return :error unless status.present? + + if status.include?('SUCCESS') + 'success' + elsif status.include?('FAILURE') + 'failed' + elsif status.include?('Pending') + 'pending' + else + :error + end + end + + def build_url(path) + Gitlab::Utils.append_path(teamcity_url, path) + end + + def get_path(path) + Gitlab::HTTP.try_get(build_url(path), verify: false, basic_auth: basic_auth, extra_log_info: { project_id: project_id }) + end + + def post_to_build_queue(data, branch) + Gitlab::HTTP.post( + build_url('httpAuth/app/rest/buildQueue'), + body: "<build branchName=#{branch.encode(xml: :attr)}>"\ + "<buildType id=#{build_type.encode(xml: :attr)}/>"\ + '</build>', + headers: { 'Content-type' => 'application/xml' }, + basic_auth: basic_auth + ) + end + + def basic_auth + { username: username, password: password } + end + end +end diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb new file mode 100644 index 00000000000..03363c7c8b0 --- /dev/null +++ b/app/models/integrations/unify_circuit.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Integrations + class UnifyCircuit < BaseChatNotification + def title + 'Unify Circuit' + end + + def description + s_('Integrations|Send notifications about project events to Unify Circuit.') + end + + def self.to_param + 'unify_circuit' + end + + def help + 'This service sends notifications about projects events to a Unify Circuit conversation.<br /> + To set up this service: + <ol> + <li><a href="https://www.circuit.com/unifyportalfaqdetail?articleId=164448">Set up an incoming webhook for your conversation</a>. All notifications will come to this conversation.</li> + <li>Paste the <strong>Webhook URL</strong> into the field below.</li> + <li>Select events below to enable notifications.</li> + </ol>' + end + + def event_field(event) + end + + def default_channel_placeholder + end + + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + + def default_fields + [ + { type: 'text', name: 'webhook', placeholder: "e.g. https://circuit.com/rest/v2/webhooks/incoming/…", required: true }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' }, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } + ] + end + + private + + def notify(message, opts) + response = Gitlab::HTTP.post(webhook, body: { + subject: message.project_name, + text: message.summary, + markdown: true + }.to_json) + + response if response.success? + end + + def custom_data(data) + super(data).merge(markdown: true) + end + end +end diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb new file mode 100644 index 00000000000..3f420331035 --- /dev/null +++ b/app/models/integrations/webex_teams.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Integrations + class WebexTeams < BaseChatNotification + include ActionView::Helpers::UrlHelper + + def title + s_("WebexTeamsService|Webex Teams") + end + + def description + s_("WebexTeamsService|Send notifications about project events to Webex Teams.") + end + + def self.to_param + 'webex_teams' + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/webex_teams'), target: '_blank', rel: 'noopener noreferrer' + s_("WebexTeamsService|Send notifications about project events to a Webex Teams conversation. %{docs_link}") % { docs_link: docs_link.html_safe } + end + + def event_field(event) + end + + def default_channel_placeholder + end + + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + + def default_fields + [ + { type: 'text', name: 'webhook', placeholder: "https://api.ciscospark.com/v1/webhooks/incoming/...", required: true }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' }, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } + ] + end + + private + + def notify(message, opts) + header = { 'Content-Type' => 'application/json' } + response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.summary }.to_json) + + response if response.success? + end + + def custom_data(data) + super(data).merge(markdown: true) + end + end +end diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb new file mode 100644 index 00000000000..10531717f11 --- /dev/null +++ b/app/models/integrations/youtrack.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Integrations + class Youtrack < BaseIssueTracker + include ActionView::Helpers::UrlHelper + + validates :project_url, :issues_url, presence: true, public_url: true, if: :activated? + + # {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030 + def self.reference_pattern(only_long: false) + if only_long + /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)/ + else + /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)|(#{Issue.reference_prefix}#{Gitlab::Regex.issue})/ + end + end + + def title + 'YouTrack' + end + + def description + s_("IssueTracker|Use YouTrack as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/youtrack'), target: '_blank', rel: 'noopener noreferrer' + s_("IssueTracker|Use YouTrack as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'youtrack' + end + + def fields + [ + { type: 'text', name: 'project_url', title: _('Project URL'), help: s_('IssueTracker|The URL to the project in YouTrack.'), required: true }, + { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), help: s_('IssueTracker|The URL to view an issue in the YouTrack project. Must contain %{colon_id}.') % { colon_id: '<code>:id</code>'.html_safe }, required: true } + ] + end + end +end |