jira_service.rb 10.6 KB
Newer Older
1 2
# frozen_string_literal: true

3
class JiraService < IssueTrackerService
4
  include Gitlab::Routing
5 6
  include ApplicationHelper
  include ActionView::Helpers::AssetUrlHelper
7

8 9
  validates :url, public_url: true, presence: true, if: :activated?
  validates :api_url, public_url: true, allow_blank: true
10 11
  validates :username, presence: true, if: :activated?
  validates :password, presence: true, if: :activated?
Drew Blessing's avatar
Drew Blessing committed
12

13
  validates :jira_issue_transition_id,
14
            format: { with: Gitlab::Regex.jira_transition_id_regex, message: s_("JiraService|transition ids can have only numbers which can be split with , or ;") },
15 16
            allow_blank: true

17 18
  # 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,
19
  # for more information check: https://gitlab.com/gitlab-org/gitlab-ce/issues/49936.
20
  prop_accessor :username, :password, :url, :api_url, :jira_issue_transition_id
21

Drew Blessing's avatar
Drew Blessing committed
22 23
  before_update :reset_password

24 25
  alias_method :project_url, :url

26
  # When these are false GitLab does not create cross reference
27
  # comments on Jira except when an issue gets transitioned.
28
  def self.supported_events
29 30 31
    %w(commit merge_request)
  end

32
  # {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1
33
  def self.reference_pattern(only_long: true)
34
    @reference_pattern ||= /(?<issue>\b#{Gitlab::Regex.jira_issue_key_regex})/
35 36
  end

37 38 39
  def initialize_properties
    super do
      self.properties = {
40 41
        url: issues_tracker['url'],
        api_url: issues_tracker['api_url']
42 43 44 45
      }
    end
  end

Drew Blessing's avatar
Drew Blessing committed
46
  def reset_password
47
    self.password = nil if reset_password?
Drew Blessing's avatar
Drew Blessing committed
48
  end
49

50
  def options
51
    url = URI.parse(client_url)
52

53
    {
54 55
      username: self.username,
      password: self.password,
56
      site: URI.join(url, '/').to_s, # Intended to find the root
57
      context_path: url.path,
58 59
      auth_type: :basic,
      read_timeout: 120,
60 61
      use_cookies: true,
      additional_cookies: ['OBBasicAuth=fromDialog'],
62
      use_ssl: url.scheme == 'https'
63 64 65 66
    }
  end

  def client
67 68 69 70 71 72
    @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
73 74
  end

75
  def help
76
    "You need to configure Jira before enabling this service. For more details
77
    read the
78
    [Jira service documentation](#{help_page_url('user/project/integrations/jira')})."
79 80
  end

81 82
  def default_title
    'Jira'
83 84
  end

85 86
  def default_description
    s_('JiraService|Jira issue tracker')
87 88
  end

89
  def self.to_param
90 91
    'jira'
  end
Drew Blessing's avatar
Drew Blessing committed
92 93

  def fields
94
    [
95
      { type: 'text', name: 'url', title: s_('JiraService|Web URL'), placeholder: 'https://jira.example.com', required: true },
96
      { type: 'text', name: 'api_url', title: s_('JiraService|Jira API URL'), placeholder: s_('JiraService|If different from Web URL') },
97 98 99
      { type: 'text', name: 'username', title: s_('JiraService|Username or Email'), placeholder: s_('JiraService|Use a username for server version and an email for cloud version'), required: true },
      { type: 'password', name: 'password', title: s_('JiraService|Password or API token'), placeholder: s_('JiraService|Use a password for server version and an API token for cloud version'), required: true },
      { type: 'text', name: 'jira_issue_transition_id', title: s_('JiraService|Transition ID(s)'), placeholder: s_('JiraService|Use , or ; to separate multiple transition IDs') }
100 101 102 103 104 105 106 107 108
    ]
  end

  def issues_url
    "#{url}/browse/:id"
  end

  def new_issue_url
    "#{url}/secure/CreateIssue.jspa"
Drew Blessing's avatar
Drew Blessing committed
109 110
  end

111 112 113 114 115 116
  alias_method :original_url, :url

  def url
    original_url&.chomp('/')
  end

117 118 119 120
  def execute(push)
    # This method is a no-op, because currently JiraService does not
    # support any events.
  end
121

122 123
  def close_issue(entity, external_issue)
    issue = jira_request { client.Issue.find(external_issue.iid) }
124

125
    return if issue.nil? || has_resolution?(issue) || !jira_issue_transition_id.present?
126 127 128 129 130 131 132 133 134

    commit_id = if entity.is_a?(Commit)
                  entity.id
                elsif entity.is_a?(MergeRequest)
                  entity.diff_head_sha
                end

    commit_url = build_entity_url(:commit, commit_id)

135
    # Depending on the Jira project's workflow, a comment during transition
136 137 138
    # 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 = jira_request { client.Issue.find(issue.key) } if transition_issue(issue)
139
    add_issue_solved_comment(issue, commit_id, commit_url) if has_resolution?(issue)
Drew Blessing's avatar
Drew Blessing committed
140 141 142
  end

  def create_cross_reference_note(mentioned, noteable, author)
143
    unless can_cross_reference?(noteable)
144
      return s_("JiraService|Events for %{noteable_model_name} are disabled.") % { noteable_model_name: noteable.model_name.plural.humanize(capitalize: false) }
145 146
    end

147 148
    jira_issue = jira_request { client.Issue.find(mentioned.id) }

149
    return unless jira_issue.present?
150

151 152 153
    noteable_id   = noteable.respond_to?(:iid) ? noteable.iid : noteable.id
    noteable_type = noteable_name(noteable)
    entity_url    = build_entity_url(noteable_type, noteable_id)
Drew Blessing's avatar
Drew Blessing committed
154 155 156 157

    data = {
      user: {
        name: author.name,
158
        url: resource_url(user_path(author))
Drew Blessing's avatar
Drew Blessing committed
159 160
      },
      project: {
161
        name: project.full_path,
162
        url: resource_url(namespace_project_path(project.namespace, project)) # rubocop:disable Cop/ProjectPathHelper
Drew Blessing's avatar
Drew Blessing committed
163 164
      },
      entity: {
165
        name: noteable_type.humanize.downcase,
166 167
        url: entity_url,
        title: noteable.title
Drew Blessing's avatar
Drew Blessing committed
168 169 170
      }
    }

171
    add_comment(data, jira_issue)
Drew Blessing's avatar
Drew Blessing committed
172 173
  end

174 175
  def test(_)
    result = test_settings
176 177 178 179
    success = result.present?
    result = @error if @error && !success

    { success: success, result: result }
180 181
  end

182
  # Jira does not need test data.
183 184 185 186 187
  # We are requesting the project that belongs to the project key.
  def test_data(user = nil, project = nil)
    nil
  end

Drew Blessing's avatar
Drew Blessing committed
188
  def test_settings
189
    return unless client_url.present?
190

191
    # Test settings by getting the project
192
    jira_request { client.ServerInfo.all.attrs }
Drew Blessing's avatar
Drew Blessing committed
193 194 195 196
  end

  private

197 198 199 200 201 202 203 204
  def can_cross_reference?(noteable)
    case noteable
    when Commit then commit_events
    when MergeRequest then merge_requests_events
    else true
    end
  end

205 206 207
  # 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
Drew Blessing's avatar
Drew Blessing committed
208
  def transition_issue(issue)
209
    jira_issue_transition_id.scan(Gitlab::Regex.jira_transition_id_regex).each do |transition_id|
Nick Thomas's avatar
Nick Thomas committed
210 211 212 213
      issue.transitions.build.save!(transition: { id: transition_id })
    rescue => error
      log_error("Issue transition failed", error: error.message, client_url: client_url)
      return false
214
    end
Drew Blessing's avatar
Drew Blessing committed
215 216 217
  end

  def add_issue_solved_comment(issue, commit_id, commit_url)
218
    link_title   = "Solved by commit #{commit_id}."
219 220 221
    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)
Drew Blessing's avatar
Drew Blessing committed
222 223
  end

224 225 226 227 228
  def add_comment(data, issue)
    user_name    = data[:user][:name]
    user_url     = data[:user][:url]
    entity_name  = data[:entity][:name]
    entity_url   = data[:entity][:url]
229
    entity_title = data[:entity][:title]
Drew Blessing's avatar
Drew Blessing committed
230 231
    project_name = data[:project][:name]

232
    message      = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title.chomp}'"
233
    link_title   = "#{entity_name.capitalize} - #{entity_title}"
234
    link_props   = build_remote_link_props(url: entity_url, title: link_title)
Drew Blessing's avatar
Drew Blessing committed
235

236 237
    unless comment_exists?(issue, message)
      send_message(issue, message, link_props)
238
    end
Drew Blessing's avatar
Drew Blessing committed
239 240
  end

241 242 243 244
  def has_resolution?(issue)
    issue.respond_to?(:resolution) && issue.resolution.present?
  end

245 246 247 248
  def comment_exists?(issue, message)
    comments = jira_request { issue.comments }

    comments.present? && comments.any? { |comment| comment.body.include?(message) }
Drew Blessing's avatar
Drew Blessing committed
249 250
  end

251
  def send_message(issue, message, remote_link_props)
252
    return unless client_url.present?
Drew Blessing's avatar
Drew Blessing committed
253

254
    jira_request do
255 256
      remote_link = find_remote_link(issue, remote_link_props[:object][:url])
      if remote_link
257
        remote_link.save!(remote_link_props)
258 259 260
      elsif issue.comments.build.save!(body: message)
        new_remote_link = issue.remotelink.build
        new_remote_link.save!(remote_link_props)
261
      end
Drew Blessing's avatar
Drew Blessing committed
262

263
      log_info("Successfully posted", client_url: client_url)
264
      "SUCCESS: Successfully posted to #{client_url}."
Drew Blessing's avatar
Drew Blessing committed
265
    end
266
  end
Drew Blessing's avatar
Drew Blessing committed
267

268 269
  def find_remote_link(issue, url)
    links = jira_request { issue.remotelink.all }
270
    return unless links
271 272 273 274

    links.find { |link| link.object["url"] == url }
  end

275 276 277 278 279 280 281
  def build_remote_link_props(url:, title:, resolved: false)
    status = {
      resolved: resolved
    }

    {
      GlobalID: 'GitLab',
282
      relationship: 'mentioned on',
283 284 285 286
      object: {
        url: url,
        title: title,
        status: status,
287
        icon: {
288
          title: 'GitLab', url16x16: asset_url(Gitlab::Favicon.main, host: gitlab_config.url)
289
        }
290 291
      }
    }
Drew Blessing's avatar
Drew Blessing committed
292 293 294
  end

  def resource_url(resource)
295
    "#{Settings.gitlab.base_url.chomp("/")}#{resource}"
Drew Blessing's avatar
Drew Blessing committed
296 297
  end

298
  def build_entity_url(noteable_type, entity_id)
Jarka Kadlecova's avatar
Jarka Kadlecova committed
299 300 301 302
    polymorphic_url(
      [
        self.project.namespace.becomes(Namespace),
        self.project,
303
        noteable_type.to_sym
Jarka Kadlecova's avatar
Jarka Kadlecova committed
304 305 306
      ],
      id:   entity_id,
      host: Settings.gitlab.base_url
Drew Blessing's avatar
Drew Blessing committed
307 308
    )
  end
309

310 311 312 313 314 315 316 317
  def noteable_name(noteable)
    name = noteable.model_name.singular

    # ProjectSnippet inherits from Snippet class so it causes
    # routing error building the URL.
    name == "project_snippet" ? "snippet" : name
  end

318
  # Handle errors when doing Jira API calls
319 320 321
  def jira_request
    yield

322
  rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => e
323
    @error = e.message
324
    log_error("Error sending message", client_url: client_url, error: @error)
325 326
    nil
  end
327 328 329 330 331 332 333 334 335 336 337 338 339

  def client_url
    api_url.present? ? api_url : 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
340 341 342 343

  def self.event_description(event)
    case event
    when "merge_request", "merge_request_events"
344
      s_("JiraService|Jira comments will be created when an issue gets referenced in a merge request.")
345
    when "commit", "commit_events"
346
      s_("JiraService|Jira comments will be created when an issue gets referenced in a commit.")
347 348
    end
  end
349
end