diff options
Diffstat (limited to 'app/models/integrations')
-rw-r--r-- | app/models/integrations/asana.rb | 109 | ||||
-rw-r--r-- | app/models/integrations/assembla.rb | 38 | ||||
-rw-r--r-- | app/models/integrations/bamboo.rb | 183 | ||||
-rw-r--r-- | app/models/integrations/builds_email.rb | 16 | ||||
-rw-r--r-- | app/models/integrations/campfire.rb | 104 | ||||
-rw-r--r-- | app/models/integrations/chat_message/alert_message.rb | 76 | ||||
-rw-r--r-- | app/models/integrations/chat_message/base_message.rb | 88 | ||||
-rw-r--r-- | app/models/integrations/chat_message/deployment_message.rb | 87 | ||||
-rw-r--r-- | app/models/integrations/chat_message/issue_message.rb | 74 | ||||
-rw-r--r-- | app/models/integrations/chat_message/merge_message.rb | 83 | ||||
-rw-r--r-- | app/models/integrations/chat_message/note_message.rb | 86 | ||||
-rw-r--r-- | app/models/integrations/chat_message/pipeline_message.rb | 267 | ||||
-rw-r--r-- | app/models/integrations/chat_message/push_message.rb | 120 | ||||
-rw-r--r-- | app/models/integrations/chat_message/wiki_page_message.rb | 63 | ||||
-rw-r--r-- | app/models/integrations/confluence.rb | 93 | ||||
-rw-r--r-- | app/models/integrations/datadog.rb | 143 | ||||
-rw-r--r-- | app/models/integrations/emails_on_push.rb | 99 |
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 |