1# frozen_string_literal: true
2
3# Accessible as Project#external_issue_tracker
4module Integrations
5  class Jira < BaseIssueTracker
6    extend ::Gitlab::Utils::Override
7    include Gitlab::Routing
8    include ApplicationHelper
9    include ActionView::Helpers::AssetUrlHelper
10    include Gitlab::Utils::StrongMemoize
11
12    PROJECTS_PER_PAGE = 50
13    JIRA_CLOUD_HOST = '.atlassian.net'
14
15    ATLASSIAN_REFERRER_GITLAB_COM = { atlOrigin: 'eyJpIjoiY2QyZTJiZDRkNGZhNGZlMWI3NzRkNTBmZmVlNzNiZTkiLCJwIjoianN3LWdpdGxhYi1pbnQifQ' }.freeze
16    ATLASSIAN_REFERRER_SELF_MANAGED = { atlOrigin: 'eyJpIjoiYjM0MTA4MzUyYTYxNDVkY2IwMzVjOGQ3ZWQ3NzMwM2QiLCJwIjoianN3LWdpdGxhYlNNLWludCJ9' }.freeze
17
18    validates :url, public_url: true, presence: true, if: :activated?
19    validates :api_url, public_url: true, allow_blank: true
20    validates :username, presence: true, if: :activated?
21    validates :password, presence: true, if: :activated?
22
23    validates :jira_issue_transition_id,
24              format: { with: Gitlab::Regex.jira_transition_id_regex, message: s_("JiraService|IDs must be a list of numbers that can be split with , or ;") },
25              allow_blank: true
26
27    # Jira Cloud version is deprecating authentication via username and password.
28    # We should use username/password for Jira Server and email/api_token for Jira Cloud,
29    # for more information check: https://gitlab.com/gitlab-org/gitlab-foss/issues/49936.
30
31    # TODO: we can probably just delegate as part of
32    # https://gitlab.com/gitlab-org/gitlab/issues/29404
33    data_field :username, :password, :url, :api_url, :jira_issue_transition_automatic, :jira_issue_transition_id, :project_key, :issues_enabled,
34      :vulnerabilities_enabled, :vulnerabilities_issuetype
35
36    before_validation :reset_password
37    after_commit :update_deployment_type, on: [:create, :update], if: :update_deployment_type?
38
39    enum comment_detail: {
40      standard: 1,
41      all_details: 2
42    }
43
44    # When these are false GitLab does not create cross reference
45    # comments on Jira except when an issue gets transitioned.
46    def self.supported_events
47      %w(commit merge_request)
48    end
49
50    def self.supported_event_actions
51      %w(comment)
52    end
53
54    # {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1
55    def self.reference_pattern(only_long: true)
56      @reference_pattern ||= /(?<issue>\b#{Gitlab::Regex.jira_issue_key_regex})/
57    end
58
59    def initialize_properties
60      {}
61    end
62
63    def data_fields
64      jira_tracker_data || self.build_jira_tracker_data
65    end
66
67    def reset_password
68      return unless reset_password?
69
70      data_fields.password = nil
71      properties.delete('password') if properties
72    end
73
74    def set_default_data
75      return unless issues_tracker.present?
76
77      return if url
78
79      data_fields.url ||= issues_tracker['url']
80      data_fields.api_url ||= issues_tracker['api_url']
81    end
82
83    def options
84      url = URI.parse(client_url)
85
86      {
87        username: username&.strip,
88        password: password,
89        site: URI.join(url, '/').to_s.delete_suffix('/'), # Intended to find the root
90        context_path: (url.path.presence || '/').delete_suffix('/'),
91        auth_type: :basic,
92        use_cookies: true,
93        additional_cookies: ['OBBasicAuth=fromDialog'],
94        use_ssl: url.scheme == 'https'
95      }
96    end
97
98    def client
99      @client ||= begin
100        JIRA::Client.new(options).tap do |client|
101          # Replaces JIRA default http client with our implementation
102          client.request_client = Gitlab::Jira::HttpClient.new(client.options)
103        end
104      end
105    end
106
107    def help
108      jira_doc_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_url('integration/jira/index.html') }
109      s_("JiraService|You need to configure Jira before enabling this integration. For more details, read the %{jira_doc_link_start}Jira integration documentation%{link_end}.") % { jira_doc_link_start: jira_doc_link_start, link_end: '</a>'.html_safe }
110    end
111
112    def title
113      'Jira'
114    end
115
116    def description
117      s_("JiraService|Use Jira as this project's issue tracker.")
118    end
119
120    def self.to_param
121      'jira'
122    end
123
124    def fields
125      [
126        {
127          type: 'text',
128          name: 'url',
129          title: s_('JiraService|Web URL'),
130          placeholder: 'https://jira.example.com',
131          help: s_('JiraService|Base URL of the Jira instance.'),
132          required: true
133        },
134        {
135          type: 'text',
136          name: 'api_url',
137          title: s_('JiraService|Jira API URL'),
138          help: s_('JiraService|If different from Web URL.')
139        },
140        {
141          type: 'text',
142          name: 'username',
143          title: s_('JiraService|Username or Email'),
144          help: s_('JiraService|Use a username for server version and an email for cloud version.'),
145          required: true
146        },
147        {
148          type: 'password',
149          name: 'password',
150          title: s_('JiraService|Password or API token'),
151          non_empty_password_title: s_('JiraService|Enter new password or API token'),
152          non_empty_password_help: s_('JiraService|Leave blank to use your current password or API token.'),
153          help: s_('JiraService|Use a password for server version and an API token for cloud version.'),
154          required: true
155        }
156      ]
157    end
158
159    def web_url(path = nil, **params)
160      return '' unless url.present?
161
162      if Gitlab.com?
163        params.merge!(ATLASSIAN_REFERRER_GITLAB_COM) unless Gitlab.staging?
164      else
165        params.merge!(ATLASSIAN_REFERRER_SELF_MANAGED) unless Gitlab.dev_or_test_env?
166      end
167
168      url = Addressable::URI.parse(self.url)
169      url.path = url.path.delete_suffix('/')
170      url.path << "/#{path.delete_prefix('/').delete_suffix('/')}" if path.present?
171      url.query_values = (url.query_values || {}).merge(params)
172      url.query_values = nil if url.query_values.empty?
173
174      url.to_s
175    end
176
177    override :project_url
178    def project_url
179      web_url
180    end
181
182    override :issues_url
183    def issues_url
184      web_url('browse/:id')
185    end
186
187    override :new_issue_url
188    def new_issue_url
189      web_url('secure/CreateIssue!default.jspa')
190    end
191
192    alias_method :original_url, :url
193    def url
194      original_url&.delete_suffix('/')
195    end
196
197    alias_method :original_api_url, :api_url
198    def api_url
199      original_api_url&.delete_suffix('/')
200    end
201
202    def execute(push)
203      # This method is a no-op, because currently Integrations::Jira does not
204      # support any events.
205    end
206
207    def find_issue(issue_key, rendered_fields: false, transitions: false)
208      expands = []
209      expands << 'renderedFields' if rendered_fields
210      expands << 'transitions' if transitions
211      options = { expand: expands.join(',') } if expands.any?
212
213      jira_request { client.Issue.find(issue_key, options || {}) }
214    end
215
216    def close_issue(entity, external_issue, current_user)
217      issue = find_issue(external_issue.iid, transitions: jira_issue_transition_automatic)
218
219      return if issue.nil? || has_resolution?(issue) || !issue_transition_enabled?
220
221      commit_id = case entity
222                  when Commit then entity.id
223                  when MergeRequest then entity.diff_head_sha
224                  end
225
226      commit_url = build_entity_url(:commit, commit_id)
227
228      # Depending on the Jira project's workflow, a comment during transition
229      # may or may not be allowed. Refresh the issue after transition and check
230      # if it is closed, so we don't have one comment for every commit.
231      issue = find_issue(issue.key) if transition_issue(issue)
232      add_issue_solved_comment(issue, commit_id, commit_url) if has_resolution?(issue)
233      log_usage(:close_issue, current_user)
234    end
235
236    override :create_cross_reference_note
237    def create_cross_reference_note(external_issue, mentioned_in, author)
238      unless can_cross_reference?(mentioned_in)
239        return s_("JiraService|Events for %{noteable_model_name} are disabled.") % { noteable_model_name: mentioned_in.model_name.plural.humanize(capitalize: false) }
240      end
241
242      jira_issue = find_issue(external_issue.id)
243
244      return unless jira_issue.present?
245
246      mentioned_in_id = mentioned_in.respond_to?(:iid) ? mentioned_in.iid : mentioned_in.id
247      mentioned_in_type = mentionable_name(mentioned_in)
248      entity_url = build_entity_url(mentioned_in_type, mentioned_in_id)
249      entity_meta = build_entity_meta(mentioned_in)
250
251      data = {
252        user: {
253          name: author.name,
254          url: resource_url(user_path(author))
255        },
256        project: {
257          name: project.full_path,
258          url: resource_url(project_path(project))
259        },
260        entity: {
261          id: entity_meta[:id],
262          name: mentioned_in_type.humanize.downcase,
263          url: entity_url,
264          title: mentioned_in.title,
265          description: entity_meta[:description],
266          branch: entity_meta[:branch]
267        }
268      }
269
270      add_comment(data, jira_issue).tap { log_usage(:cross_reference, author) }
271    end
272
273    def valid_connection?
274      test(nil)[:success]
275    end
276
277    def configured?
278      active? && valid_connection?
279    end
280
281    def test(_)
282      result = server_info
283      success = result.present?
284      result = @error&.message unless success
285
286      { success: success, result: result }
287    end
288
289    override :support_close_issue?
290    def support_close_issue?
291      true
292    end
293
294    override :support_cross_reference?
295    def support_cross_reference?
296      true
297    end
298
299    def issue_transition_enabled?
300      jira_issue_transition_automatic || jira_issue_transition_id.present?
301    end
302
303    private
304
305    def branch_name(commit)
306      if Feature.enabled?(:jira_use_first_ref_by_oid, project, default_enabled: :yaml)
307        commit.first_ref_by_oid(project.repository)
308      else
309        commit.ref_names(project.repository).first
310      end
311    end
312
313    def server_info
314      strong_memoize(:server_info) do
315        client_url.present? ? jira_request { client.ServerInfo.all.attrs } : nil
316      end
317    end
318
319    def can_cross_reference?(mentioned_in)
320      case mentioned_in
321      when Commit then commit_events
322      when MergeRequest then merge_requests_events
323      else true
324      end
325    end
326
327    # jira_issue_transition_id can have multiple values split by , or ;
328    # the issue is transitioned at the order given by the user
329    # if any transition fails it will log the error message and stop the transition sequence
330    def transition_issue(issue)
331      return transition_issue_to_done(issue) if jira_issue_transition_automatic
332
333      jira_issue_transition_id.scan(Gitlab::Regex.jira_transition_id_regex).all? do |transition_id|
334        transition_issue_to_id(issue, transition_id)
335      end
336    end
337
338    def transition_issue_to_id(issue, transition_id)
339      issue.transitions.build.save!(
340        transition: { id: transition_id }
341      )
342
343      true
344    rescue StandardError => error
345      log_error(
346        "Issue transition failed",
347          error: {
348            exception_class: error.class.name,
349            exception_message: error.message,
350            exception_backtrace: Gitlab::BacktraceCleaner.clean_backtrace(error.backtrace)
351          },
352          client_url: client_url
353      )
354
355      false
356    end
357
358    def transition_issue_to_done(issue)
359      transitions = issue.transitions rescue []
360
361      transition = transitions.find do |transition|
362        status = transition&.to&.statusCategory
363        status && status['key'] == 'done'
364      end
365
366      return false unless transition
367
368      transition_issue_to_id(issue, transition.id)
369    end
370
371    def log_usage(action, user)
372      key = "i_ecosystem_jira_service_#{action}"
373
374      Gitlab::UsageDataCounters::HLLRedisCounter.track_event(key, values: user.id)
375    end
376
377    def add_issue_solved_comment(issue, commit_id, commit_url)
378      link_title   = "Solved by commit #{commit_id}."
379      comment      = "Issue solved with [#{commit_id}|#{commit_url}]."
380      link_props   = build_remote_link_props(url: commit_url, title: link_title, resolved: true)
381      send_message(issue, comment, link_props)
382    end
383
384    def add_comment(data, issue)
385      entity_name  = data[:entity][:name]
386      entity_url   = data[:entity][:url]
387      entity_title = data[:entity][:title]
388
389      message      = comment_message(data)
390      link_title   = "#{entity_name.capitalize} - #{entity_title}"
391      link_props   = build_remote_link_props(url: entity_url, title: link_title)
392
393      unless comment_exists?(issue, message)
394        send_message(issue, message, link_props)
395      end
396    end
397
398    def comment_message(data)
399      user_link = build_jira_link(data[:user][:name], data[:user][:url])
400
401      entity = data[:entity]
402      entity_ref = all_details? ? "#{entity[:name]} #{entity[:id]}" : "a #{entity[:name]}"
403      entity_link = build_jira_link(entity_ref, entity[:url])
404
405      project_link = build_jira_link(project.full_name, Gitlab::Routing.url_helpers.project_url(project))
406      branch =
407        if entity[:branch].present?
408          s_('JiraService| on branch %{branch_link}') % {
409            branch_link: build_jira_link(entity[:branch], project_tree_url(project, entity[:branch]))
410          }
411        end
412
413      entity_message = entity[:description].presence if all_details?
414      entity_message ||= entity[:title].chomp
415
416      s_('JiraService|%{user_link} mentioned this issue in %{entity_link} of %{project_link}%{branch}:{quote}%{entity_message}{quote}') % {
417        user_link: user_link,
418        entity_link: entity_link,
419        project_link: project_link,
420        branch: branch,
421        entity_message: entity_message
422      }
423    end
424
425    def build_jira_link(title, url)
426      "[#{title}|#{url}]"
427    end
428
429    def has_resolution?(issue)
430      issue.respond_to?(:resolution) && issue.resolution.present?
431    end
432
433    def comment_exists?(issue, message)
434      comments = jira_request { issue.comments }
435
436      comments.present? && comments.any? { |comment| comment.body.include?(message) }
437    end
438
439    def send_message(issue, message, remote_link_props)
440      return unless client_url.present?
441
442      jira_request do
443        remote_link = find_remote_link(issue, remote_link_props[:object][:url])
444
445        create_issue_comment(issue, message) unless remote_link
446        remote_link ||= issue.remotelink.build
447        remote_link.save!(remote_link_props)
448
449        log_info("Successfully posted", client_url: client_url)
450        "SUCCESS: Successfully posted to #{client_url}."
451      end
452    end
453
454    def create_issue_comment(issue, message)
455      return unless comment_on_event_enabled
456
457      issue.comments.build.save!(body: message)
458    end
459
460    def find_remote_link(issue, url)
461      links = jira_request { issue.remotelink.all }
462      return unless links
463
464      links.find { |link| link.object["url"] == url }
465    end
466
467    def build_remote_link_props(url:, title:, resolved: false)
468      status = {
469        resolved: resolved
470      }
471
472      {
473        GlobalID: 'GitLab',
474        relationship: 'mentioned on',
475        object: {
476          url: url,
477          title: title,
478          status: status,
479          icon: {
480            title: 'GitLab', url16x16: asset_url(Gitlab::Favicon.main, host: gitlab_config.base_url)
481          }
482        }
483      }
484    end
485
486    def resource_url(resource)
487      "#{Settings.gitlab.base_url.chomp("/")}#{resource}"
488    end
489
490    def build_entity_url(entity_type, entity_id)
491      polymorphic_url(
492        [
493          self.project,
494          entity_type.to_sym
495        ],
496        id:   entity_id,
497        host: Settings.gitlab.base_url
498      )
499    end
500
501    def build_entity_meta(entity)
502      if entity.is_a?(Commit)
503        {
504          id: entity.short_id,
505          description: entity.safe_message,
506          branch: branch_name(entity)
507        }
508      elsif entity.is_a?(MergeRequest)
509        {
510          id: entity.to_reference,
511          branch: entity.source_branch
512        }
513      else
514        {}
515      end
516    end
517
518    def mentionable_name(mentionable)
519      name = mentionable.model_name.singular
520
521      # ProjectSnippet inherits from Snippet class so it causes
522      # routing error building the URL.
523      name == "project_snippet" ? "snippet" : name
524    end
525
526    # Handle errors when doing Jira API calls
527    def jira_request
528      yield
529    rescue StandardError => error
530      @error = error
531      payload = { client_url: client_url }
532      Gitlab::ExceptionLogFormatter.format!(error, payload)
533      log_error("Error sending message", payload)
534      nil
535    end
536
537    def client_url
538      api_url.presence || url
539    end
540
541    def reset_password?
542      # don't reset the password if a new one is provided
543      return false if password_touched?
544      return true if api_url_changed?
545      return false if api_url.present?
546
547      url_changed?
548    end
549
550    def update_deployment_type?
551      api_url_changed? || url_changed? || username_changed? || password_changed?
552    end
553
554    def update_deployment_type
555      clear_memoization(:server_info) # ensure we run the request when we try to update deployment type
556      results = server_info
557
558      unless results.present?
559        Gitlab::AppLogger.warn(message: "Jira API returned no ServerInfo, setting deployment_type from URL", server_info: results, url: client_url)
560
561        return set_deployment_type_from_url
562      end
563
564      if jira_cloud?
565        data_fields.deployment_cloud!
566      else
567        data_fields.deployment_server!
568      end
569    end
570
571    def jira_cloud?
572      server_info['deploymentType'] == 'Cloud' || URI(client_url).hostname.end_with?(JIRA_CLOUD_HOST)
573    end
574
575    def set_deployment_type_from_url
576      # This shouldn't happen but of course it will happen when an integration is removed.
577      # Instead of deleting the integration we set all fields to null
578      # and mark it as inactive
579      return data_fields.deployment_unknown! unless client_url
580
581      # If API-based detection methods fail here then
582      # we can only assume it's either Cloud or Server
583      # based on the URL being *.atlassian.net
584
585      if URI(client_url).hostname.end_with?(JIRA_CLOUD_HOST)
586        data_fields.deployment_cloud!
587      else
588        data_fields.deployment_server!
589      end
590    end
591  end
592end
593
594Integrations::Jira.prepend_mod_with('Integrations::Jira')
595