2021-05-22 00:10:27 +00:00
# frozen_string_literal: true
# Accessible as Project#external_issue_tracker
module Integrations
2021-06-04 03:10:08 +00:00
class Jira < BaseIssueTracker
2021-05-22 00:10:27 +00:00
extend :: Gitlab :: Utils :: Override
include Gitlab :: Routing
include ApplicationHelper
include ActionView :: Helpers :: AssetUrlHelper
include Gitlab :: Utils :: StrongMemoize
PROJECTS_PER_PAGE = 50
2021-05-27 12:10:56 +00:00
JIRA_CLOUD_HOST = '.atlassian.net'
2021-05-22 00:10:27 +00:00
2021-06-15 18:09:57 +00:00
ATLASSIAN_REFERRER_GITLAB_COM = { atlOrigin : 'eyJpIjoiY2QyZTJiZDRkNGZhNGZlMWI3NzRkNTBmZmVlNzNiZTkiLCJwIjoianN3LWdpdGxhYi1pbnQifQ' } . freeze
ATLASSIAN_REFERRER_SELF_MANAGED = { atlOrigin : 'eyJpIjoiYjM0MTA4MzUyYTYxNDVkY2IwMzVjOGQ3ZWQ3NzMwM2QiLCJwIjoianN3LWdpdGxhYlNNLWludCJ9' } . freeze
2021-05-22 00:10:27 +00:00
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
2021-07-21 12:09:35 +00:00
before_validation :reset_password
2021-05-22 00:10:27 +00:00
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
2022-02-04 00:13:53 +00:00
def self . valid_jira_cloud_url? ( url )
return false unless url . present?
! ! URI ( url ) . hostname & . end_with? ( JIRA_CLOUD_HOST )
end
2021-05-22 00:10:27 +00:00
def initialize_properties
{ }
end
def data_fields
jira_tracker_data || self . build_jira_tracker_data
end
def reset_password
2021-07-21 12:09:35 +00:00
return unless reset_password?
data_fields . password = nil
properties . delete ( 'password' ) if properties
2021-05-22 00:10:27 +00:00
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 ,
2021-06-15 18:09:57 +00:00
site : URI . join ( url , '/' ) . to_s . delete_suffix ( '/' ) , # Intended to find the root
context_path : ( url . path . presence || '/' ) . delete_suffix ( '/' ) ,
2021-05-22 00:10:27 +00:00
auth_type : :basic ,
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
2022-03-01 15:22:06 +00:00
jira_doc_link_start = '<a href="%{url}">' . html_safe % { url : help_page_url ( 'integration/jira/index.html' ) }
s_ ( " JiraService|You must configure Jira before enabling this integration. %{jira_doc_link_start}Learn more.%{link_end} " ) % { jira_doc_link_start : jira_doc_link_start , link_end : '</a>' . html_safe }
2021-05-22 00:10:27 +00:00
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
[
{
2022-02-18 15:15:51 +00:00
section : SECTION_TYPE_CONNECTION ,
2021-05-22 00:10:27 +00:00
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
} ,
{
2022-02-18 15:15:51 +00:00
section : SECTION_TYPE_CONNECTION ,
2021-05-22 00:10:27 +00:00
type : 'text' ,
name : 'api_url' ,
title : s_ ( 'JiraService|Jira API URL' ) ,
help : s_ ( 'JiraService|If different from Web URL.' )
} ,
{
2022-02-18 15:15:51 +00:00
section : SECTION_TYPE_CONNECTION ,
2021-05-22 00:10:27 +00:00
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
} ,
{
2022-02-18 15:15:51 +00:00
section : SECTION_TYPE_CONNECTION ,
2021-05-22 00:10:27 +00:00
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
2022-02-18 15:15:51 +00:00
def sections
[
{
type : SECTION_TYPE_CONNECTION ,
title : s_ ( 'Integrations|Connection details' ) ,
description : help
}
] . freeze
end
2021-06-15 18:09:57 +00:00
def web_url ( path = nil , ** params )
2021-07-07 09:08:35 +00:00
return '' unless url . present?
2021-06-15 18:09:57 +00:00
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
2021-05-22 00:10:27 +00:00
def issues_url
2021-06-15 18:09:57 +00:00
web_url ( 'browse/:id' )
2021-05-22 00:10:27 +00:00
end
2021-06-15 18:09:57 +00:00
override :new_issue_url
2021-05-22 00:10:27 +00:00
def new_issue_url
2021-06-15 18:09:57 +00:00
web_url ( 'secure/CreateIssue!default.jspa' )
2021-05-22 00:10:27 +00:00
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
2021-06-04 03:10:08 +00:00
override :create_cross_reference_note
2021-11-23 12:14:32 +00:00
def create_cross_reference_note ( external_issue , mentioned_in , author )
unless can_cross_reference? ( mentioned_in )
return s_ ( " JiraService|Events for %{noteable_model_name} are disabled. " ) % { noteable_model_name : mentioned_in . model_name . plural . humanize ( capitalize : false ) }
2021-05-22 00:10:27 +00:00
end
2021-11-23 12:14:32 +00:00
jira_issue = find_issue ( external_issue . id )
2021-05-22 00:10:27 +00:00
return unless jira_issue . present?
2021-11-23 12:14:32 +00:00
mentioned_in_id = mentioned_in . respond_to? ( :iid ) ? mentioned_in . iid : mentioned_in . id
mentioned_in_type = mentionable_name ( mentioned_in )
entity_url = build_entity_url ( mentioned_in_type , mentioned_in_id )
entity_meta = build_entity_meta ( mentioned_in )
2021-05-22 00:10:27 +00:00
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 ] ,
2021-11-23 12:14:32 +00:00
name : mentioned_in_type . humanize . downcase ,
2021-05-22 00:10:27 +00:00
url : entity_url ,
2021-11-23 12:14:32 +00:00
title : mentioned_in . title ,
2021-05-22 00:10:27 +00:00
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
2021-06-18 15:10:16 +00:00
def configured?
active? && valid_connection?
end
2021-05-22 00:10:27 +00:00
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
2021-11-23 12:14:32 +00:00
def branch_name ( commit )
2021-12-20 15:12:25 +00:00
commit . first_ref_by_oid ( project . repository )
2021-10-25 21:12:12 +00:00
end
2021-05-22 00:10:27 +00:00
def server_info
strong_memoize ( :server_info ) do
client_url . present? ? jira_request { client . ServerInfo . all . attrs } : nil
end
end
2021-11-23 12:14:32 +00:00
def can_cross_reference? ( mentioned_in )
case mentioned_in
2021-05-22 00:10:27 +00:00
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
2021-11-23 12:14:32 +00:00
def build_entity_url ( entity_type , entity_id )
2021-05-22 00:10:27 +00:00
polymorphic_url (
[
self . project ,
2021-11-23 12:14:32 +00:00
entity_type . to_sym
2021-05-22 00:10:27 +00:00
] ,
id : entity_id ,
host : Settings . gitlab . base_url
)
end
2021-11-23 12:14:32 +00:00
def build_entity_meta ( entity )
if entity . is_a? ( Commit )
2021-05-22 00:10:27 +00:00
{
2021-11-23 12:14:32 +00:00
id : entity . short_id ,
description : entity . safe_message ,
branch : branch_name ( entity )
2021-05-22 00:10:27 +00:00
}
2021-11-23 12:14:32 +00:00
elsif entity . is_a? ( MergeRequest )
2021-05-22 00:10:27 +00:00
{
2021-11-23 12:14:32 +00:00
id : entity . to_reference ,
branch : entity . source_branch
2021-05-22 00:10:27 +00:00
}
else
{ }
end
end
2021-11-23 12:14:32 +00:00
def mentionable_name ( mentionable )
name = mentionable . model_name . singular
2021-05-22 00:10:27 +00:00
# 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
2021-10-28 06:09:29 +00:00
payload = { client_url : client_url }
Gitlab :: ExceptionLogFormatter . format! ( error , payload )
log_error ( " Error sending message " , payload )
2021-05-22 00:10:27 +00:00
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?
2021-07-27 12:10:54 +00:00
api_url_changed? || url_changed? || username_changed? || password_changed?
2021-05-22 00:10:27 +00:00
end
def update_deployment_type
clear_memoization ( :server_info ) # ensure we run the request when we try to update deployment type
results = server_info
2021-05-27 12:10:56 +00:00
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
2021-05-22 00:10:27 +00:00
data_fields . deployment_server!
2021-05-27 12:10:56 +00:00
end
end
def jira_cloud?
2022-02-04 00:13:53 +00:00
server_info [ 'deploymentType' ] == 'Cloud' || self . class . valid_jira_cloud_url? ( client_url )
2021-05-27 12:10:56 +00:00
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
2022-02-04 00:13:53 +00:00
if self . class . valid_jira_cloud_url? ( client_url )
2021-05-22 00:10:27 +00:00
data_fields . deployment_cloud!
else
2021-05-27 12:10:56 +00:00
data_fields . deployment_server!
2021-05-22 00:10:27 +00:00
end
end
end
end
Integrations :: Jira . prepend_mod_with ( 'Integrations::Jira' )