Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/models/integrations')
-rw-r--r--app/models/integrations/bamboo.rb2
-rw-r--r--app/models/integrations/base_chat_notification.rb255
-rw-r--r--app/models/integrations/base_ci.rb44
-rw-r--r--app/models/integrations/base_issue_tracker.rb156
-rw-r--r--app/models/integrations/base_slash_commands.rb67
-rw-r--r--app/models/integrations/bugzilla.rb26
-rw-r--r--app/models/integrations/buildkite.rb145
-rw-r--r--app/models/integrations/builds_email.rb16
-rw-r--r--app/models/integrations/chat_message/base_message.rb2
-rw-r--r--app/models/integrations/chat_message/pipeline_message.rb8
-rw-r--r--app/models/integrations/chat_message/push_message.rb2
-rw-r--r--app/models/integrations/chat_message/wiki_page_message.rb12
-rw-r--r--app/models/integrations/custom_issue_tracker.rb25
-rw-r--r--app/models/integrations/discord.rb68
-rw-r--r--app/models/integrations/drone_ci.rb106
-rw-r--r--app/models/integrations/ewm.rb38
-rw-r--r--app/models/integrations/external_wiki.rb52
-rw-r--r--app/models/integrations/flowdock.rb52
-rw-r--r--app/models/integrations/hangouts_chat.rb71
-rw-r--r--app/models/integrations/irker.rb123
-rw-r--r--app/models/integrations/issue_tracker_data.rb11
-rw-r--r--app/models/integrations/jenkins.rb113
-rw-r--r--app/models/integrations/jira.rb588
-rw-r--r--app/models/integrations/jira_tracker_data.rb14
-rw-r--r--app/models/integrations/mattermost.rb33
-rw-r--r--app/models/integrations/mattermost_slash_commands.rb59
-rw-r--r--app/models/integrations/microsoft_teams.rb59
-rw-r--r--app/models/integrations/mock_ci.rb90
-rw-r--r--app/models/integrations/open_project.rb20
-rw-r--r--app/models/integrations/open_project_tracker_data.rb18
-rw-r--r--app/models/integrations/packagist.rb67
-rw-r--r--app/models/integrations/pipelines_email.rb105
-rw-r--r--app/models/integrations/pivotaltracker.rb78
-rw-r--r--app/models/integrations/pushover.rb107
-rw-r--r--app/models/integrations/redmine.rb25
-rw-r--r--app/models/integrations/slack.rb59
-rw-r--r--app/models/integrations/slack_slash_commands.rb36
-rw-r--r--app/models/integrations/teamcity.rb191
-rw-r--r--app/models/integrations/unify_circuit.rb62
-rw-r--r--app/models/integrations/webex_teams.rb56
-rw-r--r--app/models/integrations/youtrack.rb42
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