summaryrefslogtreecommitdiff
path: root/app/models/integrations
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-05-19 15:44:42 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-05-19 15:44:42 +0000
commit4555e1b21c365ed8303ffb7a3325d773c9b8bf31 (patch)
tree5423a1c7516cffe36384133ade12572cf709398d /app/models/integrations
parente570267f2f6b326480d284e0164a6464ba4081bc (diff)
downloadgitlab-ce-4555e1b21c365ed8303ffb7a3325d773c9b8bf31.tar.gz
Add latest changes from gitlab-org/gitlab@13-12-stable-eev13.12.0-rc42
Diffstat (limited to 'app/models/integrations')
-rw-r--r--app/models/integrations/asana.rb109
-rw-r--r--app/models/integrations/assembla.rb38
-rw-r--r--app/models/integrations/bamboo.rb183
-rw-r--r--app/models/integrations/builds_email.rb16
-rw-r--r--app/models/integrations/campfire.rb104
-rw-r--r--app/models/integrations/chat_message/alert_message.rb76
-rw-r--r--app/models/integrations/chat_message/base_message.rb88
-rw-r--r--app/models/integrations/chat_message/deployment_message.rb87
-rw-r--r--app/models/integrations/chat_message/issue_message.rb74
-rw-r--r--app/models/integrations/chat_message/merge_message.rb83
-rw-r--r--app/models/integrations/chat_message/note_message.rb86
-rw-r--r--app/models/integrations/chat_message/pipeline_message.rb267
-rw-r--r--app/models/integrations/chat_message/push_message.rb120
-rw-r--r--app/models/integrations/chat_message/wiki_page_message.rb63
-rw-r--r--app/models/integrations/confluence.rb93
-rw-r--r--app/models/integrations/datadog.rb143
-rw-r--r--app/models/integrations/emails_on_push.rb99
17 files changed, 1729 insertions, 0 deletions
diff --git a/app/models/integrations/asana.rb b/app/models/integrations/asana.rb
new file mode 100644
index 00000000000..7949563a1dc
--- /dev/null
+++ b/app/models/integrations/asana.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+require 'asana'
+
+module Integrations
+ class Asana < Integration
+ include ActionView::Helpers::UrlHelper
+
+ prop_accessor :api_key, :restrict_to_branch
+ validates :api_key, presence: true, if: :activated?
+
+ def title
+ 'Asana'
+ end
+
+ def description
+ s_('AsanaService|Add commit messages as comments to Asana tasks.')
+ end
+
+ def help
+ docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/asana'), target: '_blank', rel: 'noopener noreferrer'
+ s_('Add commit messages as comments to Asana tasks. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
+ end
+
+ def self.to_param
+ 'asana'
+ end
+
+ def fields
+ [
+ {
+ type: 'text',
+ name: 'api_key',
+ title: 'API key',
+ help: s_('AsanaService|User Personal Access Token. User must have access to the task. All comments are attributed to this user.'),
+ # Example Personal Access Token from Asana docs
+ placeholder: '0/68a9e79b868c6789e79a124c30b0',
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'restrict_to_branch',
+ title: 'Restrict to branch (optional)',
+ help: s_('AsanaService|Comma-separated list of branches to be automatically inspected. Leave blank to include all branches.')
+ }
+ ]
+ end
+
+ def self.supported_events
+ %w(push)
+ end
+
+ def client
+ @_client ||= begin
+ ::Asana::Client.new do |c|
+ c.authentication :access_token, api_key
+ end
+ end
+ end
+
+ def execute(data)
+ return unless supported_events.include?(data[:object_kind])
+
+ # check the branch restriction is poplulated and branch is not included
+ branch = Gitlab::Git.ref_name(data[:ref])
+ branch_restriction = restrict_to_branch.to_s
+ if branch_restriction.present? && branch_restriction.index(branch).nil?
+ return
+ end
+
+ user = data[:user_name]
+ project_name = project.full_name
+
+ data[:commits].each do |commit|
+ push_msg = s_("AsanaService|%{user} pushed to branch %{branch} of %{project_name} ( %{commit_url} ):") % { user: user, branch: branch, project_name: project_name, commit_url: commit[:url] }
+ check_commit(commit[:message], push_msg)
+ end
+ end
+
+ def check_commit(message, push_msg)
+ # matches either:
+ # - #1234
+ # - https://app.asana.com/0/{project_gid}/{task_gid}
+ # optionally preceded with:
+ # - fix/ed/es/ing
+ # - close/s/d
+ # - closing
+ issue_finder = %r{(fix\w*|clos[ei]\w*+)?\W*(?:https://app\.asana\.com/\d+/\w+/(\w+)|#(\w+))}i
+
+ message.scan(issue_finder).each do |tuple|
+ # tuple will be
+ # [ 'fix', 'id_from_url', 'id_from_pound' ]
+ taskid = tuple[2] || tuple[1]
+
+ begin
+ task = ::Asana::Resources::Task.find_by_id(client, taskid)
+ task.add_comment(text: "#{push_msg} #{message}")
+
+ if tuple[0]
+ task.update(completed: true)
+ end
+ rescue StandardError => e
+ log_error(e.message)
+ next
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/integrations/assembla.rb b/app/models/integrations/assembla.rb
new file mode 100644
index 00000000000..6a36045330a
--- /dev/null
+++ b/app/models/integrations/assembla.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Integrations
+ class Assembla < Integration
+ prop_accessor :token, :subdomain
+ validates :token, presence: true, if: :activated?
+
+ def title
+ 'Assembla'
+ end
+
+ def description
+ _('Manage projects.')
+ end
+
+ def self.to_param
+ 'assembla'
+ end
+
+ def fields
+ [
+ { type: 'text', name: 'token', placeholder: '', required: true },
+ { type: 'text', name: 'subdomain', placeholder: '' }
+ ]
+ end
+
+ def self.supported_events
+ %w(push)
+ end
+
+ def execute(data)
+ return unless supported_events.include?(data[:object_kind])
+
+ url = "https://atlas.assembla.com/spaces/#{subdomain}/github_tool?secret_key=#{token}"
+ Gitlab::HTTP.post(url, body: { payload: data }.to_json, headers: { 'Content-Type' => 'application/json' })
+ end
+ end
+end
diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb
new file mode 100644
index 00000000000..82111c7322e
--- /dev/null
+++ b/app/models/integrations/bamboo.rb
@@ -0,0 +1,183 @@
+# frozen_string_literal: true
+
+module Integrations
+ class Bamboo < CiService
+ include ActionView::Helpers::UrlHelper
+ include ReactiveService
+
+ prop_accessor :bamboo_url, :build_key, :username, :password
+
+ validates :bamboo_url, presence: true, public_url: true, if: :activated?
+ validates :build_key, 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
+
+ def compose_service_hook
+ hook = service_hook || build_service_hook
+ hook.save
+ end
+
+ def reset_password
+ if bamboo_url_changed? && !password_touched?
+ self.password = nil
+ end
+ end
+
+ def title
+ s_('BambooService|Atlassian Bamboo')
+ end
+
+ def description
+ s_('BambooService|Run CI/CD pipelines with Atlassian Bamboo.')
+ end
+
+ def help
+ docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bamboo'), target: '_blank', rel: 'noopener noreferrer'
+ s_('BambooService|Run CI/CD pipelines with Atlassian Bamboo. You must set up automatic revision labeling and a repository trigger in Bamboo. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
+ end
+
+ def self.to_param
+ 'bamboo'
+ end
+
+ def fields
+ [
+ {
+ type: 'text',
+ name: 'bamboo_url',
+ title: s_('BambooService|Bamboo URL'),
+ placeholder: s_('https://bamboo.example.com'),
+ help: s_('BambooService|Bamboo service root URL.'),
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'build_key',
+ placeholder: s_('KEY'),
+ help: s_('BambooService|Bamboo build plan key.'),
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'username',
+ help: s_('BambooService|The user with API access to the Bamboo server.')
+ },
+ {
+ 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 execute(data)
+ return unless supported_events.include?(data[:object_kind])
+
+ get_path("updateAndBuild.action", { buildKey: build_key })
+ end
+
+ def calculate_reactive_cache(sha, ref)
+ response = try_get_path("rest/api/latest/result/byChangeset/#{sha}")
+
+ { build_page: read_build_page(response), commit_status: read_commit_status(response) }
+ end
+
+ private
+
+ def get_build_result(response)
+ return if response&.code != 200
+
+ # May be nil if no result, a single result hash, or an array if multiple results for a given changeset.
+ result = response.dig('results', 'results', 'result')
+
+ # In case of multiple results, arbitrarily assume the last one is the most relevant.
+ return result.last if result.is_a?(Array)
+
+ result
+ end
+
+ def read_build_page(response)
+ result = get_build_result(response)
+ key =
+ if result.blank?
+ # If actual build link can't be determined, send user to build summary page.
+ build_key
+ else
+ # If actual build link is available, go to build result page.
+ result.dig('planResultKey', 'key')
+ end
+
+ build_url("browse/#{key}")
+ end
+
+ def read_commit_status(response)
+ return :error unless response && (response.code == 200 || response.code == 404)
+
+ result = get_build_result(response)
+ status =
+ if result.blank?
+ 'Pending'
+ else
+ result.dig('buildState')
+ end
+
+ return :error unless status.present?
+
+ if status.include?('Success')
+ 'success'
+ elsif status.include?('Failed')
+ 'failed'
+ elsif status.include?('Pending')
+ 'pending'
+ else
+ :error
+ end
+ end
+
+ def try_get_path(path, query_params = {})
+ params = build_get_params(query_params)
+ params[:extra_log_info] = { project_id: project_id }
+
+ Gitlab::HTTP.try_get(build_url(path), params)
+ end
+
+ def get_path(path, query_params = {})
+ Gitlab::HTTP.get(build_url(path), build_get_params(query_params))
+ end
+
+ def build_url(path)
+ Gitlab::Utils.append_path(bamboo_url, path)
+ end
+
+ def build_get_params(query_params)
+ params = { verify: false, query: query_params }
+ return params if username.blank? && password.blank?
+
+ query_params[:os_authType] = 'basic'
+ params[:basic_auth] = basic_auth
+ params
+ end
+
+ def basic_auth
+ { username: username, password: password }
+ end
+ end
+end
diff --git a/app/models/integrations/builds_email.rb b/app/models/integrations/builds_email.rb
new file mode 100644
index 00000000000..2628848667e
--- /dev/null
+++ b/app/models/integrations/builds_email.rb
@@ -0,0 +1,16 @@
+# 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/campfire.rb b/app/models/integrations/campfire.rb
new file mode 100644
index 00000000000..eede3d00307
--- /dev/null
+++ b/app/models/integrations/campfire.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+module Integrations
+ class Campfire < Integration
+ prop_accessor :token, :subdomain, :room
+ validates :token, presence: true, if: :activated?
+
+ def title
+ 'Campfire'
+ end
+
+ def description
+ 'Send notifications about push events to Campfire chat rooms.'
+ end
+
+ def self.to_param
+ 'campfire'
+ end
+
+ def fields
+ [
+ { type: 'text', name: 'token', placeholder: '', required: true },
+ { type: 'text', name: 'subdomain', placeholder: '' },
+ { type: 'text', name: 'room', placeholder: '' }
+ ]
+ end
+
+ def self.supported_events
+ %w(push)
+ end
+
+ def execute(data)
+ return unless supported_events.include?(data[:object_kind])
+
+ message = build_message(data)
+ speak(self.room, message, auth)
+ end
+
+ private
+
+ def base_uri
+ @base_uri ||= "https://#{subdomain}.campfirenow.com"
+ end
+
+ def auth
+ # use a dummy password, as explained in the Campfire API doc:
+ # https://github.com/basecamp/campfire-api#authentication
+ @auth ||= {
+ basic_auth: {
+ username: token,
+ password: 'X'
+ }
+ }
+ end
+
+ # Post a message into a room, returns the message Hash in case of success.
+ # Returns nil otherwise.
+ # https://github.com/basecamp/campfire-api/blob/master/sections/messages.md#create-message
+ def speak(room_name, message, auth)
+ room = rooms(auth).find { |r| r["name"] == room_name }
+ return unless room
+
+ path = "/room/#{room["id"]}/speak.json"
+ body = {
+ body: {
+ message: {
+ type: 'TextMessage',
+ body: message
+ }
+ }
+ }
+ res = Gitlab::HTTP.post(path, base_uri: base_uri, **auth.merge(body))
+ res.code == 201 ? res : nil
+ end
+
+ # Returns a list of rooms, or [].
+ # https://github.com/basecamp/campfire-api/blob/master/sections/rooms.md#get-rooms
+ def rooms(auth)
+ res = Gitlab::HTTP.get("/rooms.json", base_uri: base_uri, **auth)
+ res.code == 200 ? res["rooms"] : []
+ end
+
+ def build_message(push)
+ ref = Gitlab::Git.ref_name(push[:ref])
+ before = push[:before]
+ after = push[:after]
+
+ message = []
+ message << "[#{project.full_name}] "
+ message << "#{push[:user_name]} "
+
+ if Gitlab::Git.blank_ref?(before)
+ message << "pushed new branch #{ref} \n"
+ elsif Gitlab::Git.blank_ref?(after)
+ message << "removed branch #{ref} \n"
+ else
+ message << "pushed #{push[:total_commits_count]} commits to #{ref}. "
+ message << "#{project.web_url}/compare/#{before}...#{after}"
+ end
+
+ message.join
+ end
+ end
+end
diff --git a/app/models/integrations/chat_message/alert_message.rb b/app/models/integrations/chat_message/alert_message.rb
new file mode 100644
index 00000000000..ef0579124fe
--- /dev/null
+++ b/app/models/integrations/chat_message/alert_message.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+module Integrations
+ module ChatMessage
+ class AlertMessage < BaseMessage
+ attr_reader :title
+ attr_reader :alert_url
+ attr_reader :severity
+ attr_reader :events
+ attr_reader :status
+ attr_reader :started_at
+
+ def initialize(params)
+ @project_name = params[:project_name] || params.dig(:project, :path_with_namespace)
+ @project_url = params.dig(:project, :web_url) || params[:project_url]
+ @title = params.dig(:object_attributes, :title)
+ @alert_url = params.dig(:object_attributes, :url)
+ @severity = params.dig(:object_attributes, :severity)
+ @events = params.dig(:object_attributes, :events)
+ @status = params.dig(:object_attributes, :status)
+ @started_at = params.dig(:object_attributes, :started_at)
+ end
+
+ def attachments
+ [{
+ title: title,
+ title_link: alert_url,
+ color: attachment_color,
+ fields: attachment_fields
+ }]
+ end
+
+ def message
+ "Alert firing in #{project_name}"
+ end
+
+ private
+
+ def attachment_color
+ "#C95823"
+ end
+
+ def attachment_fields
+ [
+ {
+ title: "Severity",
+ value: severity.to_s.humanize,
+ short: true
+ },
+ {
+ title: "Events",
+ value: events,
+ short: true
+ },
+ {
+ title: "Status",
+ value: status.to_s.humanize,
+ short: true
+ },
+ {
+ title: "Start time",
+ value: format_time(started_at),
+ short: true
+ }
+ ]
+ end
+
+ # This formats time into the following format
+ # April 23rd, 2020 1:06AM UTC
+ def format_time(time)
+ time = Time.zone.parse(time.to_s)
+ time.strftime("%B #{time.day.ordinalize}, %Y %l:%M%p %Z")
+ end
+ end
+ end
+end
diff --git a/app/models/integrations/chat_message/base_message.rb b/app/models/integrations/chat_message/base_message.rb
new file mode 100644
index 00000000000..2f70384d3b9
--- /dev/null
+++ b/app/models/integrations/chat_message/base_message.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+module Integrations
+ module ChatMessage
+ class BaseMessage
+ RELATIVE_LINK_REGEX = %r{!\[[^\]]*\]\((/uploads/[^\)]*)\)}.freeze
+
+ attr_reader :markdown
+ attr_reader :user_full_name
+ attr_reader :user_name
+ attr_reader :user_avatar
+ attr_reader :project_name
+ attr_reader :project_url
+
+ def initialize(params)
+ @markdown = params[:markdown] || false
+ @project_name = params[:project_name] || params.dig(:project, :path_with_namespace)
+ @project_url = params.dig(:project, :web_url) || params[:project_url]
+ @user_full_name = params.dig(:user, :name) || params[:user_full_name]
+ @user_name = params.dig(:user, :username) || params[:user_name]
+ @user_avatar = params.dig(:user, :avatar_url) || params[:user_avatar]
+ end
+
+ def user_combined_name
+ if user_full_name.present?
+ "#{user_full_name} (#{user_name})"
+ else
+ user_name
+ end
+ end
+
+ def summary
+ return message if markdown
+
+ format(message)
+ end
+
+ def pretext
+ summary
+ end
+
+ def fallback
+ format(message)
+ end
+
+ def attachments
+ raise NotImplementedError
+ end
+
+ def activity
+ raise NotImplementedError
+ end
+
+ private
+
+ def message
+ raise NotImplementedError
+ end
+
+ def format(string)
+ Slack::Messenger::Util::LinkFormatter.format(format_relative_links(string))
+ end
+
+ def format_relative_links(string)
+ string.gsub(RELATIVE_LINK_REGEX, "#{project_url}\\1")
+ end
+
+ def attachment_color
+ '#345'
+ end
+
+ def link(text, url)
+ "[#{text}](#{url})"
+ end
+
+ def pretty_duration(seconds)
+ parse_string =
+ if duration < 1.hour
+ '%M:%S'
+ else
+ '%H:%M:%S'
+ end
+
+ Time.at(seconds).utc.strftime(parse_string)
+ end
+ end
+ end
+end
diff --git a/app/models/integrations/chat_message/deployment_message.rb b/app/models/integrations/chat_message/deployment_message.rb
new file mode 100644
index 00000000000..c4f3bf9610d
--- /dev/null
+++ b/app/models/integrations/chat_message/deployment_message.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+module Integrations
+ module ChatMessage
+ class DeploymentMessage < BaseMessage
+ attr_reader :commit_title
+ attr_reader :commit_url
+ attr_reader :deployable_id
+ attr_reader :deployable_url
+ attr_reader :environment
+ attr_reader :short_sha
+ attr_reader :status
+ attr_reader :user_url
+
+ def initialize(data)
+ super
+
+ @commit_title = data[:commit_title]
+ @commit_url = data[:commit_url]
+ @deployable_id = data[:deployable_id]
+ @deployable_url = data[:deployable_url]
+ @environment = data[:environment]
+ @short_sha = data[:short_sha]
+ @status = data[:status]
+ @user_url = data[:user_url]
+ end
+
+ def attachments
+ [{
+ text: "#{project_link} with job #{deployment_link} by #{user_link}\n#{commit_link}: #{commit_title}",
+ color: color
+ }]
+ end
+
+ def activity
+ {}
+ end
+
+ private
+
+ def message
+ if running?
+ "Starting deploy to #{environment}"
+ else
+ "Deploy to #{environment} #{humanized_status}"
+ end
+ end
+
+ def color
+ case status
+ when 'success'
+ 'good'
+ when 'canceled'
+ 'warning'
+ when 'failed'
+ 'danger'
+ else
+ '#334455'
+ end
+ end
+
+ def project_link
+ link(project_name, project_url)
+ end
+
+ def deployment_link
+ link("##{deployable_id}", deployable_url)
+ end
+
+ def user_link
+ link(user_combined_name, user_url)
+ end
+
+ def commit_link
+ link(short_sha, commit_url)
+ end
+
+ def humanized_status
+ status == 'success' ? 'succeeded' : status
+ end
+
+ def running?
+ status == 'running'
+ end
+ end
+ end
+end
diff --git a/app/models/integrations/chat_message/issue_message.rb b/app/models/integrations/chat_message/issue_message.rb
new file mode 100644
index 00000000000..5fa6bd4090f
--- /dev/null
+++ b/app/models/integrations/chat_message/issue_message.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+module Integrations
+ module ChatMessage
+ class IssueMessage < BaseMessage
+ attr_reader :title
+ attr_reader :issue_iid
+ attr_reader :issue_url
+ attr_reader :action
+ attr_reader :state
+ attr_reader :description
+
+ def initialize(params)
+ super
+
+ obj_attr = params[:object_attributes]
+ obj_attr = HashWithIndifferentAccess.new(obj_attr)
+ @title = obj_attr[:title]
+ @issue_iid = obj_attr[:iid]
+ @issue_url = obj_attr[:url]
+ @action = obj_attr[:action]
+ @state = obj_attr[:state]
+ @description = obj_attr[:description] || ''
+ end
+
+ def attachments
+ return [] unless opened_issue?
+ return description if markdown
+
+ description_message
+ end
+
+ def activity
+ {
+ title: "Issue #{state} by #{user_combined_name}",
+ subtitle: "in #{project_link}",
+ text: issue_link,
+ image: user_avatar
+ }
+ end
+
+ private
+
+ def message
+ "[#{project_link}] Issue #{issue_link} #{state} by #{user_combined_name}"
+ end
+
+ def opened_issue?
+ action == 'open'
+ end
+
+ def description_message
+ [{
+ title: issue_title,
+ title_link: issue_url,
+ text: format(description),
+ color: '#C95823'
+ }]
+ end
+
+ def project_link
+ link(project_name, project_url)
+ end
+
+ def issue_link
+ link(issue_title, issue_url)
+ end
+
+ def issue_title
+ "#{Issue.reference_prefix}#{issue_iid} #{title}"
+ end
+ end
+ end
+end
diff --git a/app/models/integrations/chat_message/merge_message.rb b/app/models/integrations/chat_message/merge_message.rb
new file mode 100644
index 00000000000..d2f48699f50
--- /dev/null
+++ b/app/models/integrations/chat_message/merge_message.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+module Integrations
+ module ChatMessage
+ class MergeMessage < BaseMessage
+ attr_reader :merge_request_iid
+ attr_reader :source_branch
+ attr_reader :target_branch
+ attr_reader :action
+ attr_reader :state
+ attr_reader :title
+
+ def initialize(params)
+ super
+
+ obj_attr = params[:object_attributes]
+ obj_attr = HashWithIndifferentAccess.new(obj_attr)
+ @merge_request_iid = obj_attr[:iid]
+ @source_branch = obj_attr[:source_branch]
+ @target_branch = obj_attr[:target_branch]
+ @action = obj_attr[:action]
+ @state = obj_attr[:state]
+ @title = format_title(obj_attr[:title])
+ end
+
+ def attachments
+ []
+ end
+
+ def activity
+ {
+ title: "Merge request #{state_or_action_text} by #{user_combined_name}",
+ subtitle: "in #{project_link}",
+ text: merge_request_link,
+ image: user_avatar
+ }
+ end
+
+ private
+
+ def format_title(title)
+ '*' + title.lines.first.chomp + '*'
+ end
+
+ def message
+ merge_request_message
+ end
+
+ def project_link
+ link(project_name, project_url)
+ end
+
+ def merge_request_message
+ "#{user_combined_name} #{state_or_action_text} merge request #{merge_request_link} in #{project_link}"
+ end
+
+ def merge_request_link
+ link(merge_request_title, merge_request_url)
+ end
+
+ def merge_request_title
+ "#{MergeRequest.reference_prefix}#{merge_request_iid} #{title}"
+ end
+
+ def merge_request_url
+ "#{project_url}/-/merge_requests/#{merge_request_iid}"
+ end
+
+ def state_or_action_text
+ case action
+ when 'approved', 'unapproved'
+ action
+ when 'approval'
+ 'added their approval to'
+ when 'unapproval'
+ 'removed their approval from'
+ else
+ state
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/integrations/chat_message/note_message.rb b/app/models/integrations/chat_message/note_message.rb
new file mode 100644
index 00000000000..96675d2b27c
--- /dev/null
+++ b/app/models/integrations/chat_message/note_message.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+module Integrations
+ module ChatMessage
+ class NoteMessage < BaseMessage
+ attr_reader :note
+ attr_reader :note_url
+ attr_reader :title
+ attr_reader :target
+
+ def initialize(params)
+ super
+
+ params = HashWithIndifferentAccess.new(params)
+ obj_attr = params[:object_attributes]
+ @note = obj_attr[:note]
+ @note_url = obj_attr[:url]
+ @target, @title = case obj_attr[:noteable_type]
+ when "Commit"
+ create_commit_note(params[:commit])
+ when "Issue"
+ create_issue_note(params[:issue])
+ when "MergeRequest"
+ create_merge_note(params[:merge_request])
+ when "Snippet"
+ create_snippet_note(params[:snippet])
+ end
+ end
+
+ def attachments
+ return note if markdown
+
+ description_message
+ end
+
+ def activity
+ {
+ title: "#{user_combined_name} #{link('commented on ' + target, note_url)}",
+ subtitle: "in #{project_link}",
+ text: formatted_title,
+ image: user_avatar
+ }
+ end
+
+ private
+
+ def message
+ "#{user_combined_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{formatted_title}*"
+ end
+
+ def format_title(title)
+ title.lines.first.chomp
+ end
+
+ def formatted_title
+ format_title(title)
+ end
+
+ def create_issue_note(issue)
+ ["issue #{Issue.reference_prefix}#{issue[:iid]}", issue[:title]]
+ end
+
+ def create_commit_note(commit)
+ commit_sha = Commit.truncate_sha(commit[:id])
+
+ ["commit #{commit_sha}", commit[:message]]
+ end
+
+ def create_merge_note(merge_request)
+ ["merge request #{MergeRequest.reference_prefix}#{merge_request[:iid]}", merge_request[:title]]
+ end
+
+ def create_snippet_note(snippet)
+ ["snippet #{Snippet.reference_prefix}#{snippet[:id]}", snippet[:title]]
+ end
+
+ def description_message
+ [{ text: format(note), color: attachment_color }]
+ end
+
+ def project_link
+ link(project_name, project_url)
+ end
+ end
+ end
+end
diff --git a/app/models/integrations/chat_message/pipeline_message.rb b/app/models/integrations/chat_message/pipeline_message.rb
new file mode 100644
index 00000000000..a0f6f582e4c
--- /dev/null
+++ b/app/models/integrations/chat_message/pipeline_message.rb
@@ -0,0 +1,267 @@
+# frozen_string_literal: true
+
+module Integrations
+ module ChatMessage
+ class PipelineMessage < BaseMessage
+ MAX_VISIBLE_JOBS = 10
+
+ attr_reader :user
+ attr_reader :ref_type
+ attr_reader :ref
+ attr_reader :status
+ attr_reader :detailed_status
+ attr_reader :duration
+ attr_reader :finished_at
+ attr_reader :pipeline_id
+ attr_reader :failed_stages
+ attr_reader :failed_jobs
+
+ attr_reader :project
+ attr_reader :commit
+ attr_reader :committer
+ attr_reader :pipeline
+
+ def initialize(data)
+ super
+
+ @user = data[:user]
+ @user_name = data.dig(:user, :username) || 'API'
+
+ pipeline_attributes = data[:object_attributes]
+ @ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch'
+ @ref = pipeline_attributes[:ref]
+ @status = pipeline_attributes[:status]
+ @detailed_status = pipeline_attributes[:detailed_status]
+ @duration = pipeline_attributes[:duration].to_i
+ @finished_at = pipeline_attributes[:finished_at] ? Time.parse(pipeline_attributes[:finished_at]).to_i : nil
+ @pipeline_id = pipeline_attributes[:id]
+
+ # Get list of jobs that have actually failed (after exhausting all retries)
+ @failed_jobs = actually_failed_jobs(Array(data[:builds]))
+ @failed_stages = @failed_jobs.map { |j| j[:stage] }.uniq
+
+ @project = Project.find(data[:project][:id])
+ @commit = project.commit_by(oid: data[:commit][:id])
+ @committer = commit.committer
+ @pipeline = Ci::Pipeline.find(pipeline_id)
+ end
+
+ def pretext
+ ''
+ end
+
+ def attachments
+ return message if markdown
+
+ [{
+ fallback: format(message),
+ color: attachment_color,
+ author_name: user_combined_name,
+ author_icon: user_avatar,
+ author_link: author_url,
+ title: s_("ChatMessage|Pipeline #%{pipeline_id} %{humanized_status} in %{duration}") %
+ {
+ pipeline_id: pipeline_id,
+ humanized_status: humanized_status,
+ duration: pretty_duration(duration)
+ },
+ title_link: pipeline_url,
+ fields: attachments_fields,
+ footer: project.name,
+ footer_icon: project.avatar_url(only_path: false),
+ ts: finished_at
+ }]
+ end
+
+ def activity
+ {
+ title: s_("ChatMessage|Pipeline %{pipeline_link} of %{ref_type} %{ref_link} by %{user_combined_name} %{humanized_status}") %
+ {
+ pipeline_link: pipeline_link,
+ ref_type: ref_type,
+ ref_link: ref_link,
+ user_combined_name: user_combined_name,
+ humanized_status: humanized_status
+ },
+ subtitle: s_("ChatMessage|in %{project_link}") % { project_link: project_link },
+ text: s_("ChatMessage|in %{duration}") % { duration: pretty_duration(duration) },
+ image: user_avatar || ''
+ }
+ end
+
+ private
+
+ def actually_failed_jobs(builds)
+ succeeded_job_names = builds.map { |b| b[:name] if b[:status] == 'success' }.compact.uniq
+
+ failed_jobs = builds.select do |build|
+ # Select jobs which doesn't have a successful retry
+ build[:status] == 'failed' && !succeeded_job_names.include?(build[:name])
+ end
+
+ failed_jobs.uniq { |job| job[:name] }.reverse
+ end
+
+ def failed_stages_field
+ {
+ title: s_("ChatMessage|Failed stage").pluralize(failed_stages.length),
+ value: Slack::Messenger::Util::LinkFormatter.format(failed_stages_links),
+ short: true
+ }
+ end
+
+ def failed_jobs_field
+ {
+ title: s_("ChatMessage|Failed job").pluralize(failed_jobs.length),
+ value: Slack::Messenger::Util::LinkFormatter.format(failed_jobs_links),
+ short: true
+ }
+ end
+
+ def yaml_error_field
+ {
+ title: s_("ChatMessage|Invalid CI config YAML file"),
+ value: pipeline.yaml_errors,
+ short: false
+ }
+ end
+
+ def attachments_fields
+ fields = [
+ {
+ title: ref_type == "tag" ? s_("ChatMessage|Tag") : s_("ChatMessage|Branch"),
+ value: Slack::Messenger::Util::LinkFormatter.format(ref_link),
+ short: true
+ },
+ {
+ title: s_("ChatMessage|Commit"),
+ value: Slack::Messenger::Util::LinkFormatter.format(commit_link),
+ short: true
+ }
+ ]
+
+ fields << failed_stages_field if failed_stages.any?
+ fields << failed_jobs_field if failed_jobs.any?
+ fields << yaml_error_field if pipeline.has_yaml_errors?
+
+ fields
+ end
+
+ def message
+ s_("ChatMessage|%{project_link}: Pipeline %{pipeline_link} of %{ref_type} %{ref_link} by %{user_combined_name} %{humanized_status} in %{duration}") %
+ {
+ project_link: project_link,
+ pipeline_link: pipeline_link,
+ ref_type: ref_type,
+ ref_link: ref_link,
+ user_combined_name: user_combined_name,
+ humanized_status: humanized_status,
+ duration: pretty_duration(duration)
+ }
+ end
+
+ def humanized_status
+ case status
+ when 'success'
+ detailed_status == "passed with warnings" ? s_("ChatMessage|has passed with warnings") : s_("ChatMessage|has passed")
+ when 'failed'
+ s_("ChatMessage|has failed")
+ else
+ status
+ end
+ end
+
+ def attachment_color
+ case status
+ when 'success'
+ detailed_status == 'passed with warnings' ? 'warning' : 'good'
+ else
+ 'danger'
+ end
+ end
+
+ def ref_url
+ if ref_type == 'tag'
+ "#{project_url}/-/tags/#{ref}"
+ else
+ "#{project_url}/-/commits/#{ref}"
+ end
+ end
+
+ def ref_link
+ "[#{ref}](#{ref_url})"
+ end
+
+ def project_url
+ project.web_url
+ end
+
+ def project_link
+ "[#{project.name}](#{project_url})"
+ end
+
+ def pipeline_failed_jobs_url
+ "#{project_url}/-/pipelines/#{pipeline_id}/failures"
+ end
+
+ def pipeline_url
+ if failed_jobs.any?
+ pipeline_failed_jobs_url
+ else
+ "#{project_url}/-/pipelines/#{pipeline_id}"
+ end
+ end
+
+ def pipeline_link
+ "[##{pipeline_id}](#{pipeline_url})"
+ end
+
+ def job_url(job)
+ "#{project_url}/-/jobs/#{job[:id]}"
+ end
+
+ def job_link(job)
+ "[#{job[:name]}](#{job_url(job)})"
+ end
+
+ def failed_jobs_links
+ failed = failed_jobs.slice(0, MAX_VISIBLE_JOBS)
+ truncated = failed_jobs.slice(MAX_VISIBLE_JOBS, failed_jobs.size)
+
+ failed_links = failed.map { |job| job_link(job) }
+
+ unless truncated.blank?
+ failed_links << s_("ChatMessage|and [%{count} more](%{pipeline_failed_jobs_url})") % {
+ count: truncated.size,
+ pipeline_failed_jobs_url: pipeline_failed_jobs_url
+ }
+ end
+
+ failed_links.join(I18n.t(:'support.array.words_connector'))
+ end
+
+ def stage_link(stage)
+ # All stages link to the pipeline page
+ "[#{stage}](#{pipeline_url})"
+ end
+
+ def failed_stages_links
+ failed_stages.map { |s| stage_link(s) }.join(I18n.t(:'support.array.words_connector'))
+ end
+
+ def commit_url
+ Gitlab::UrlBuilder.build(commit)
+ end
+
+ def commit_link
+ "[#{commit.title}](#{commit_url})"
+ end
+
+ def author_url
+ return unless user && committer
+
+ Gitlab::UrlBuilder.build(committer)
+ end
+ end
+ end
+end
diff --git a/app/models/integrations/chat_message/push_message.rb b/app/models/integrations/chat_message/push_message.rb
new file mode 100644
index 00000000000..0952986e923
--- /dev/null
+++ b/app/models/integrations/chat_message/push_message.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+module Integrations
+ module ChatMessage
+ class PushMessage < BaseMessage
+ attr_reader :after
+ attr_reader :before
+ attr_reader :commits
+ attr_reader :ref
+ attr_reader :ref_type
+
+ def initialize(params)
+ super
+
+ @after = params[:after]
+ @before = params[:before]
+ @commits = params.fetch(:commits, [])
+ @ref_type = Gitlab::Git.tag_ref?(params[:ref]) ? 'tag' : 'branch'
+ @ref = Gitlab::Git.ref_name(params[:ref])
+ end
+
+ def attachments
+ return [] if new_branch? || removed_branch?
+ return commit_messages if markdown
+
+ commit_message_attachments
+ end
+
+ def activity
+ {
+ title: humanized_action(short: true),
+ subtitle: "in #{project_link}",
+ text: compare_link,
+ image: user_avatar
+ }
+ end
+
+ private
+
+ def humanized_action(short: false)
+ action, ref_link, target_link = compose_action_details
+ text = [user_combined_name, action, ref_type, ref_link]
+ text << target_link unless short
+ text.join(' ')
+ end
+
+ def message
+ humanized_action
+ end
+
+ def format(string)
+ Slack::Messenger::Util::LinkFormatter.format(string)
+ end
+
+ def commit_messages
+ commits.map { |commit| compose_commit_message(commit) }.join("\n\n")
+ end
+
+ def commit_message_attachments
+ [{ text: format(commit_messages), color: attachment_color }]
+ end
+
+ def compose_commit_message(commit)
+ author = commit[:author][:name]
+ id = Commit.truncate_sha(commit[:id])
+ title = commit[:title]
+
+ url = commit[:url]
+
+ "[#{id}](#{url}): #{title} - #{author}"
+ end
+
+ def new_branch?
+ Gitlab::Git.blank_ref?(before)
+ end
+
+ def removed_branch?
+ Gitlab::Git.blank_ref?(after)
+ end
+
+ def ref_url
+ if ref_type == 'tag'
+ "#{project_url}/-/tags/#{ref}"
+ else
+ "#{project_url}/commits/#{ref}"
+ end
+ end
+
+ def compare_url
+ "#{project_url}/compare/#{before}...#{after}"
+ end
+
+ def ref_link
+ "[#{ref}](#{ref_url})"
+ end
+
+ def project_link
+ "[#{project_name}](#{project_url})"
+ end
+
+ def compare_link
+ "[Compare changes](#{compare_url})"
+ end
+
+ def compose_action_details
+ if new_branch?
+ ['pushed new', ref_link, "to #{project_link}"]
+ elsif removed_branch?
+ ['removed', ref, "from #{project_link}"]
+ else
+ ['pushed to', ref_link, "of #{project_link} (#{compare_link})"]
+ end
+ end
+
+ def attachment_color
+ '#345'
+ end
+ end
+ end
+end
diff --git a/app/models/integrations/chat_message/wiki_page_message.rb b/app/models/integrations/chat_message/wiki_page_message.rb
new file mode 100644
index 00000000000..9b5275b8c03
--- /dev/null
+++ b/app/models/integrations/chat_message/wiki_page_message.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module Integrations
+ module ChatMessage
+ class WikiPageMessage < BaseMessage
+ attr_reader :title
+ attr_reader :wiki_page_url
+ attr_reader :action
+ attr_reader :description
+
+ def initialize(params)
+ super
+
+ obj_attr = params[:object_attributes]
+ obj_attr = HashWithIndifferentAccess.new(obj_attr)
+ @title = obj_attr[:title]
+ @wiki_page_url = obj_attr[:url]
+ @description = obj_attr[:message]
+
+ @action =
+ case obj_attr[:action]
+ when "create"
+ "created"
+ when "update"
+ "edited"
+ end
+ end
+
+ def attachments
+ return description if markdown
+
+ description_message
+ end
+
+ def activity
+ {
+ title: "#{user_combined_name} #{action} #{wiki_page_link}",
+ subtitle: "in #{project_link}",
+ text: title,
+ image: user_avatar
+ }
+ end
+
+ private
+
+ def message
+ "#{user_combined_name} #{action} #{wiki_page_link} in #{project_link}: *#{title}*"
+ end
+
+ def description_message
+ [{ text: format(@description), color: attachment_color }]
+ end
+
+ def project_link
+ "[#{project_name}](#{project_url})"
+ end
+
+ def wiki_page_link
+ "[wiki page](#{wiki_page_url})"
+ end
+ end
+ end
+end
diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb
new file mode 100644
index 00000000000..30f73496993
--- /dev/null
+++ b/app/models/integrations/confluence.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+module Integrations
+ class Confluence < Integration
+ include ActionView::Helpers::UrlHelper
+
+ VALID_SCHEME_MATCH = %r{\Ahttps?\Z}.freeze
+ VALID_HOST_MATCH = %r{\A.+\.atlassian\.net\Z}.freeze
+ VALID_PATH_MATCH = %r{\A/wiki(/|\Z)}.freeze
+
+ prop_accessor :confluence_url
+
+ validates :confluence_url, presence: true, if: :activated?
+ validate :validate_confluence_url_is_cloud, if: :activated?
+
+ after_commit :cache_project_has_confluence
+
+ def self.to_param
+ 'confluence'
+ end
+
+ def self.supported_events
+ %w()
+ end
+
+ def title
+ s_('ConfluenceService|Confluence Workspace')
+ end
+
+ def description
+ s_('ConfluenceService|Link to a Confluence Workspace from the sidebar.')
+ end
+
+ def help
+ return unless project&.wiki_enabled?
+
+ if activated?
+ wiki_url = project.wiki.web_url
+
+ s_(
+ 'ConfluenceService|Your GitLab wiki is still available at %{wiki_link}. To re-enable the link to the GitLab wiki, disable this integration.' %
+ { wiki_link: link_to(wiki_url, wiki_url) }
+ ).html_safe
+ else
+ s_('ConfluenceService|Link to a Confluence Workspace from the sidebar. Enabling this integration replaces the "Wiki" sidebar link with a link to the Confluence Workspace. The GitLab wiki is still available at the original URL.').html_safe
+ end
+ end
+
+ def fields
+ [
+ {
+ type: 'text',
+ name: 'confluence_url',
+ title: s_('Confluence Cloud Workspace URL'),
+ placeholder: 'https://example.atlassian.net/wiki',
+ required: true
+ }
+ ]
+ end
+
+ def can_test?
+ false
+ end
+
+ private
+
+ def validate_confluence_url_is_cloud
+ unless confluence_uri_valid?
+ errors.add(:confluence_url, 'URL must be to a Confluence Cloud Workspace hosted on atlassian.net')
+ end
+ end
+
+ def confluence_uri_valid?
+ return false unless confluence_url
+
+ uri = URI.parse(confluence_url)
+
+ (uri.scheme&.match(VALID_SCHEME_MATCH) &&
+ uri.host&.match(VALID_HOST_MATCH) &&
+ uri.path&.match(VALID_PATH_MATCH)).present?
+
+ rescue URI::InvalidURIError
+ false
+ end
+
+ def cache_project_has_confluence
+ return unless project && !project.destroyed?
+
+ project.project_setting.save! unless project.project_setting.persisted?
+ project.project_setting.update_column(:has_confluence, active?)
+ end
+ end
+end
diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb
new file mode 100644
index 00000000000..dd4b0664d52
--- /dev/null
+++ b/app/models/integrations/datadog.rb
@@ -0,0 +1,143 @@
+# frozen_string_literal: true
+
+module Integrations
+ class Datadog < Integration
+ DEFAULT_SITE = 'datadoghq.com'
+ URL_TEMPLATE = 'https://webhooks-http-intake.logs.%{datadog_site}/v1/input/'
+ URL_TEMPLATE_API_KEYS = 'https://app.%{datadog_site}/account/settings#api'
+ URL_API_KEYS_DOCS = "https://docs.#{DEFAULT_SITE}/account_management/api-app-keys/"
+
+ SUPPORTED_EVENTS = %w[
+ pipeline job
+ ].freeze
+
+ prop_accessor :datadog_site, :api_url, :api_key, :datadog_service, :datadog_env
+
+ with_options if: :activated? do
+ validates :api_key, presence: true, format: { with: /\A\w+\z/ }
+ validates :datadog_site, format: { with: /\A[\w\.]+\z/, allow_blank: true }
+ validates :api_url, public_url: { allow_blank: true }
+ validates :datadog_site, presence: true, unless: -> (obj) { obj.api_url.present? }
+ validates :api_url, presence: true, unless: -> (obj) { obj.datadog_site.present? }
+ end
+
+ after_save :compose_service_hook, if: :activated?
+
+ def initialize_properties
+ super
+
+ self.datadog_site ||= DEFAULT_SITE
+ end
+
+ def self.supported_events
+ SUPPORTED_EVENTS
+ end
+
+ def self.default_test_event
+ 'pipeline'
+ end
+
+ def configurable_events
+ [] # do not allow to opt out of required hooks
+ end
+
+ def title
+ 'Datadog'
+ end
+
+ def description
+ 'Trace your GitLab pipelines with Datadog'
+ end
+
+ def help
+ nil
+ end
+
+ def self.to_param
+ 'datadog'
+ end
+
+ def fields
+ [
+ {
+ type: 'text',
+ name: 'datadog_site',
+ placeholder: DEFAULT_SITE,
+ help: 'Choose the Datadog site to send data to. Set to "datadoghq.eu" to send data to the EU site',
+ required: false
+ },
+ {
+ type: 'text',
+ name: 'api_url',
+ title: 'API URL',
+ help: '(Advanced) Define the full URL for your Datadog site directly',
+ required: false
+ },
+ {
+ type: 'password',
+ name: 'api_key',
+ title: _('API key'),
+ non_empty_password_title: s_('ProjectService|Enter new API key'),
+ non_empty_password_help: s_('ProjectService|Leave blank to use your current API key'),
+ help: "<a href=\"#{api_keys_url}\" target=\"_blank\">API key</a> used for authentication with Datadog",
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'datadog_service',
+ title: 'Service',
+ placeholder: 'gitlab-ci',
+ help: 'Name of this GitLab instance that all data will be tagged with'
+ },
+ {
+ type: 'text',
+ name: 'datadog_env',
+ title: 'Env',
+ help: 'The environment tag that traces will be tagged with'
+ }
+ ]
+ end
+
+ def compose_service_hook
+ hook = service_hook || build_service_hook
+ hook.url = hook_url
+ hook.save
+ end
+
+ def hook_url
+ url = api_url.presence || sprintf(URL_TEMPLATE, datadog_site: datadog_site)
+ url = URI.parse(url)
+ url.path = File.join(url.path || '/', api_key)
+ query = { service: datadog_service.presence, env: datadog_env.presence }.compact
+ url.query = query.to_query unless query.empty?
+ url.to_s
+ end
+
+ def api_keys_url
+ return URL_API_KEYS_DOCS unless datadog_site.presence
+
+ sprintf(URL_TEMPLATE_API_KEYS, datadog_site: datadog_site)
+ end
+
+ def execute(data)
+ return if project.disabled_services.include?(to_param)
+
+ object_kind = data[:object_kind]
+ object_kind = 'job' if object_kind == 'build'
+ return unless supported_events.include?(object_kind)
+
+ service_hook.execute(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
+ end
+end
diff --git a/app/models/integrations/emails_on_push.rb b/app/models/integrations/emails_on_push.rb
new file mode 100644
index 00000000000..e277633664f
--- /dev/null
+++ b/app/models/integrations/emails_on_push.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+module Integrations
+ class EmailsOnPush < Integration
+ include NotificationBranchSelection
+
+ RECIPIENTS_LIMIT = 750
+
+ boolean_accessor :send_from_committer_email
+ boolean_accessor :disable_diffs
+ prop_accessor :recipients, :branches_to_be_notified
+ validates :recipients, presence: true, if: :validate_recipients?
+ validate :number_of_recipients_within_limit, if: :validate_recipients?
+
+ def self.valid_recipients(recipients)
+ recipients.split.select do |recipient|
+ recipient.include?('@')
+ end.uniq(&:downcase)
+ end
+
+ def title
+ s_('EmailsOnPushService|Emails on push')
+ end
+
+ def description
+ s_('EmailsOnPushService|Email the commits and diff of each push to a list of recipients.')
+ end
+
+ def self.to_param
+ 'emails_on_push'
+ end
+
+ def self.supported_events
+ %w(push tag_push)
+ end
+
+ def initialize_properties
+ super
+
+ self.branches_to_be_notified = 'all' if branches_to_be_notified.nil?
+ end
+
+ def execute(push_data)
+ return unless supported_events.include?(push_data[:object_kind])
+ return if project.emails_disabled?
+ return unless notify_for_ref?(push_data)
+
+ EmailsOnPushWorker.perform_async(
+ project_id,
+ recipients,
+ push_data,
+ send_from_committer_email: send_from_committer_email?,
+ disable_diffs: disable_diffs?
+ )
+ end
+
+ def notify_for_ref?(push_data)
+ return true if push_data[:object_kind] == 'tag_push'
+ return true if push_data.dig(:object_attributes, :tag)
+
+ notify_for_branch?(push_data)
+ end
+
+ def send_from_committer_email?
+ Gitlab::Utils.to_boolean(self.send_from_committer_email)
+ end
+
+ def disable_diffs?
+ Gitlab::Utils.to_boolean(self.disable_diffs)
+ end
+
+ def fields
+ domains = Notify.allowed_email_domains.map { |domain| "user@#{domain}" }.join(", ")
+ [
+ { type: 'checkbox', name: 'send_from_committer_email', title: s_("EmailsOnPushService|Send from committer"),
+ help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain matches the domain used by your GitLab instance (such as %{domains}).") % { domains: domains } },
+ { type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"),
+ help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") },
+ { type: 'select', name: 'branches_to_be_notified', choices: branch_choices },
+ {
+ type: 'textarea',
+ name: 'recipients',
+ placeholder: s_('EmailsOnPushService|tanuki@example.com gitlab@example.com'),
+ help: s_('EmailsOnPushService|Emails separated by whitespace.')
+ }
+ ]
+ end
+
+ private
+
+ def number_of_recipients_within_limit
+ return if recipients.blank?
+
+ if self.class.valid_recipients(recipients).size > RECIPIENTS_LIMIT
+ errors.add(:recipients, s_("EmailsOnPushService|can't exceed %{recipients_limit}") % { recipients_limit: RECIPIENTS_LIMIT })
+ end
+ end
+ end
+end