Class: Integrations::Jira

Inherits:
Integration show all
Includes:
ActionView::Helpers::AssetUrlHelper, ApplicationHelper, Gitlab::Routing, Gitlab::Utils::StrongMemoize, Base::IssueTracker, HasAvatar, SafeFormatHelper
Defined in:
app/models/integrations/jira.rb

Constant Summary collapse

PROJECTS_PER_PAGE =
50
JIRA_CLOUD_HOST =
'.atlassian.net'
ATLASSIAN_REFERRER_GITLAB_COM =
{
  atlOrigin: 'eyJpIjoiY2QyZTJiZDRkNGZhNGZlMWI3NzRkNTBmZmVlNzNiZTkiLCJwIjoianN3LWdpdGxhYi1pbnQifQ'
}.freeze
ATLASSIAN_REFERRER_SELF_MANAGED =
{
  atlOrigin: 'eyJpIjoiYjM0MTA4MzUyYTYxNDVkY2IwMzVjOGQ3ZWQ3NzMwM2QiLCJwIjoianN3LWdpdGxhYlNNLWludCJ9'
}.freeze
API_ENDPOINTS =
{
  find_issue: "/rest/api/2/issue/%s",
  server_info: "/rest/api/2/serverInfo",
  transition_issue: "/rest/api/2/issue/%s/transitions",
  issue_comments: "/rest/api/2/issue/%s/comment",
  link_remote_issue: "/rest/api/2/issue/%s/remotelink",
  client_info: "/rest/api/2/myself"
}.freeze
SECTION_TYPE_JIRA_TRIGGER =
'jira_trigger'
SECTION_TYPE_JIRA_ISSUES =
'jira_issues'
SECTION_TYPE_JIRA_ISSUE_CREATION =
'jira_issue_creation'
AUTH_TYPE_BASIC =
0
AUTH_TYPE_PAT =
1
SNOWPLOW_EVENT_CATEGORY =
name
RE2_SYNTAX_DOC_URL =
'https://github.com/google/re2/wiki/Syntax'

Constants included from Base::IssueTracker

Base::IssueTracker::REFERENCE_PATTERN_LONG_REGEXP, Base::IssueTracker::REFERENCE_PATTERN_REGEXP

Constants included from Base::Integration

Base::Integration::BASE_ATTRIBUTES, Base::Integration::BASE_CLASSES, Base::Integration::DEV_INTEGRATION_NAMES, Base::Integration::ENCRYPTED_PROPERTIES_MAX_SIZE, Base::Integration::INSTANCE_LEVEL_ONLY_INTEGRATION_NAMES, Base::Integration::INTEGRATION_NAMES, Base::Integration::PROJECT_AND_GROUP_LEVEL_ONLY_INTEGRATION_NAMES, Base::Integration::PROJECT_LEVEL_ONLY_INTEGRATION_NAMES, Base::Integration::SECTION_TYPE_CONFIGURATION, Base::Integration::SECTION_TYPE_CONNECTION, Base::Integration::SECTION_TYPE_TRIGGER, Base::Integration::SNOWPLOW_EVENT_ACTION, Base::Integration::SNOWPLOW_EVENT_LABEL, Base::Integration::UnknownType

Constants inherited from ApplicationRecord

ApplicationRecord::MAX_PLUCK

Constants included from HasCheckConstraints

HasCheckConstraints::NOT_NULL_CHECK_PATTERN

Constants included from ResetOnColumnErrors

ResetOnColumnErrors::MAX_RESET_PERIOD

Class Method Summary collapse

Instance Method Summary collapse

Methods included from HasAvatar

#avatar_url

Methods included from SafeFormatHelper

#safe_format, #tag_pair

Methods included from ApplicationHelper

#active_when, #add_issuable_stylesheet, #add_page_specific_style, #add_page_startup_api_call, #add_work_items_stylesheet, #admin_section?, #ai_panel_expanded?, #asset_to_string, #autocomplete_data_sources, #bluesky_url, #body_data, #body_data_page, #client_class_list, #client_js_flags, #collapsed_super_sidebar?, community_forum, #community_forum, #conditional_link_to, #current_action?, #current_controller?, #discord_url, #dispensable_render, #dispensable_render_if_exists, #edited_time_ago_with_tooltip, #error_css, #external_storage_url_or_path, #extra_config, #github_url, #gitlab_config, #gitlab_ui_form_for, #gitlab_ui_form_with, #hexdigest, #hidden_resource_icon, #instance_review_permitted?, #last_commit, #linkedin_name, #linkedin_url, #locale_path, #mastodon_url, #orcid_url, #outdated_browser?, #page_class, #page_filter_path, #page_startup_api_calls, #partial_exists?, #path_to_key, #project_data, #project_studio_enabled?, #read_only_message, #registry_config, #render_if_exists, #show_callout?, #show_last_push_widget?, #sign_in_with_redirect?, #simple_sanitize, #static_objects_external_storage_enabled?, #support_url, #system_message_class, #template_exists?, #time_ago_with_tooltip, #twitter_url, #university_url

Methods included from ViteHelper

#universal_path_to_stylesheet, #universal_stylesheet_link_tag, #vite_enabled?, #vite_page_entrypoint_paths

Methods included from Gitlab::Routing

includes_helpers, redirect_legacy_paths, url_helpers

Methods included from Base::IssueTracker

#activate_disabled_reason, #default?, #handle_properties, #issue_path, #issue_tracker_path, #issue_url, #legacy_properties_data, #new_issue_path, #supports_data_fields?

Methods included from Base::Integration

#activate!, #activate_disabled_reason, #activated?, #after_build_from_integration, #api_field_names, #async_execute, #attributes, #attribution_notice, #category, #chat?, #ci?, #configurable_events, #deactivate!, #default_test_event, #description, #dup, #editable?, #event_channel_names, #event_names, #fields, #form_fields, #group_level?, #help, #inheritable?, #initialize_properties, #instance_level?, #json_fields, #manual_activation?, #operating?, #organization_id_from_parent, #parent, #project_level?, #reencrypt_properties, #reset_updated_properties, #secret_fields, #supported_events, #supports_data_fields?, #title, #to_database_hash, #to_param, #toggle!, #updated_properties, #validate_encrypted_properties_size_limit

Methods included from Gitlab::Utils::Override

#extended, extensions, #included, #method_added, #override, #prepended, #queue_verification, verify!

Methods inherited from ApplicationRecord

===, cached_column_list, #create_or_load_association, current_transaction, declarative_enum, default_select_columns, delete_all_returning, #deleted_from_database?, id_in, id_not_in, iid_in, nullable_column?, primary_key_in, #readable_by?, safe_ensure_unique, safe_find_or_create_by, safe_find_or_create_by!, #to_ability_name, underscore, where_exists, where_not_exists, with_fast_read_statement_timeout, without_order

Methods included from Organizations::Sharding

#sharding_organization

Methods included from ResetOnColumnErrors

#reset_on_union_error, #reset_on_unknown_attribute_error

Methods included from Gitlab::SensitiveSerializableHash

#serializable_hash

Class Method Details

.descriptionObject



254
255
256
# File 'app/models/integrations/jira.rb', line 254

def self.description
  s_("JiraService|Use Jira as this project's issue tracker.")
end

.helpObject



258
259
260
261
262
263
264
265
266
# File 'app/models/integrations/jira.rb', line 258

def self.help
  jira_doc_link_start = format('<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe,
    url: Gitlab::Routing.url_helpers.help_page_path('integration/jira/_index.md'))
  format(
    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)
end

.supported_eventsObject

When these are false GitLab does not create cross reference comments on Jira except when an issue gets transitioned.



190
191
192
# File 'app/models/integrations/jira.rb', line 190

def self.supported_events
  %w[commit merge_request]
end

.titleObject



250
251
252
# File 'app/models/integrations/jira.rb', line 250

def self.title
  'Jira issues'
end

.to_paramObject



268
269
270
# File 'app/models/integrations/jira.rb', line 268

def self.to_param
  'jira'
end

.valid_jira_cloud_url?(url) ⇒ Boolean

Returns:

  • (Boolean)


199
200
201
202
203
204
205
206
# File 'app/models/integrations/jira.rb', line 199

def self.valid_jira_cloud_url?(url)
  return false unless url.present?

  uri = URI.parse(url)
  uri.is_a?(URI::HTTPS) && !!uri.hostname&.end_with?(JIRA_CLOUD_HOST)
rescue URI::InvalidURIError
  false
end

Instance Method Details

#api_urlObject



347
348
349
# File 'app/models/integrations/jira.rb', line 347

def api_url
  original_api_url&.delete_suffix('/')
end

#client(additional_options = {}) ⇒ Object



243
244
245
246
247
248
# File 'app/models/integrations/jira.rb', line 243

def client(additional_options = {})
  JIRA::Client.new(options.merge(additional_options)).tap do |client|
    # Replaces JIRA default http client with our implementation
    client.request_client = Gitlab::Jira::HttpClient.new(client.options)
  end
end

#close_issue(entity, external_issue, current_user) ⇒ Object



369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
# File 'app/models/integrations/jira.rb', line 369

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

#configured?Boolean

Returns:

  • (Boolean)


431
432
433
# File 'app/models/integrations/jira.rb', line 431

def configured?
  active? && valid_connection?
end

#create_cross_reference_note(external_issue, mentioned_in, author) ⇒ Object



390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
# File 'app/models/integrations/jira.rb', line 390

def create_cross_reference_note(external_issue, mentioned_in, author)
  unless can_cross_reference?(mentioned_in)
    return format(s_("JiraService|Events for %{noteable_model_name} are disabled."),
      noteable_model_name: mentioned_in.model_name.plural.humanize(capitalize: false))
  end

  jira_issue = find_issue(external_issue.id)

  return unless jira_issue.present?

  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)

  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],
      name: mentioned_in_type.humanize.downcase,
      url: entity_url,
      title: mentioned_in.title,
      description: entity_meta[:description],
      branch: entity_meta[:branch]
    }
  }

  add_comment(data, jira_issue).tap { log_usage(:cross_reference, author) }
end

#data_fieldsObject



208
209
210
# File 'app/models/integrations/jira.rb', line 208

def data_fields
  jira_tracker_data || build_jira_tracker_data
end

#execute(push) ⇒ Object



351
352
353
354
# File 'app/models/integrations/jira.rb', line 351

def execute(push)
  # This method is a no-op, because currently Integrations::Jira does not
  # support any events.
end

#find_issue(issue_key, rendered_fields: false, transitions: false, restrict_project_key: false) ⇒ Object



356
357
358
359
360
361
362
363
364
365
366
367
# File 'app/models/integrations/jira.rb', line 356

def find_issue(issue_key, rendered_fields: false, transitions: false, restrict_project_key: false)
  return if restrict_project_key && !issue_key_allowed?(issue_key)

  expands = []
  expands << 'renderedFields' if rendered_fields
  expands << 'transitions' if transitions
  options = { expand: expands.join(',') } if expands.any?

  path = API_ENDPOINTS[:find_issue] % issue_key

  jira_request(path) { client.Issue.find(issue_key, options || {}) }
end

#issue_transition_enabled?Boolean

Returns:

  • (Boolean)


453
454
455
# File 'app/models/integrations/jira.rb', line 453

def issue_transition_enabled?
  jira_issue_transition_automatic || jira_issue_transition_id.present?
end

#issues_urlObject



333
334
335
# File 'app/models/integrations/jira.rb', line 333

def issues_url
  web_url('browse/:id')
end

#new_issue_urlObject



337
338
339
# File 'app/models/integrations/jira.rb', line 337

def new_issue_url
  web_url('secure/CreateIssue!default.jspa')
end

#optionsObject



221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'app/models/integrations/jira.rb', line 221

def options
  url = URI.parse(client_url)

  options = {
    site: URI.join(url, '/').to_s.chomp('/'), # Find the root URL
    context_path: (url.path.presence || '/').delete_suffix('/'),
    auth_type: :basic,
    use_ssl: url.scheme == 'https'
  }

  if personal_access_token_authorization?
    options[:default_headers] = { 'Authorization' => "Bearer #{password}" }
  else
    options[:username] = username&.strip
    options[:password] = password
    options[:use_cookies] = true
    options[:additional_cookies] = ['OBBasicAuth=fromDialog']
  end

  options
end

#original_api_urlObject



346
# File 'app/models/integrations/jira.rb', line 346

alias_method :original_api_url, :api_url

#original_urlObject



341
# File 'app/models/integrations/jira.rb', line 341

alias_method :original_url, :url

#personal_access_token_authorization?Boolean

Returns:

  • (Boolean)


457
458
459
# File 'app/models/integrations/jira.rb', line 457

def personal_access_token_authorization?
  jira_auth_type == AUTH_TYPE_PAT
end

#project_keys_as_stringObject



465
466
467
# File 'app/models/integrations/jira.rb', line 465

def project_keys_as_string
  project_keys.join(',')
end

#reference_patternObject

PROJECT-KEY-NUMBER Examples: JIRA-1, PROJECT-1



195
196
197
# File 'app/models/integrations/jira.rb', line 195

def reference_pattern(*)
  @reference_pattern ||= jira_issue_match_regex
end

#sectionsObject



272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'app/models/integrations/jira.rb', line 272

def sections
  sections = [
    {
      type: SECTION_TYPE_CONNECTION,
      title: s_('Integrations|Connection details'),
      description: help
    },
    {
      type: SECTION_TYPE_JIRA_TRIGGER,
      title: _('Trigger'),
      description: s_('JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link ' \
                      'and comment (if enabled) will be created.')
    },
    {
      type: SECTION_TYPE_CONFIGURATION,
      title: _('Jira issue matching'),
      description: s_('Configure custom rules for Jira issue key matching')
    }
  ]

  # Currently, Jira issues are only configurable at the project and group levels.
  unless instance_level?
    sections.push({
      type: SECTION_TYPE_JIRA_ISSUES,
      title: s_('JiraService|Jira issues (optional)'),
      description: jira_issues_section_description,
      plan: 'premium'
    })

    sections.push({
      type: SECTION_TYPE_JIRA_ISSUE_CREATION,
      title: s_('JiraService|Jira issues for vulnerabilities (optional)'),
      description: s_('JiraService|Create Jira issues from GitLab to track any action taken ' \
                      'to resolve or mitigate vulnerabilities.'),
      plan: 'ultimate'
    })
  end

  sections
end

#set_default_dataObject



212
213
214
215
216
217
218
219
# File 'app/models/integrations/jira.rb', line 212

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

#support_close_issue?Boolean

Returns:

  • (Boolean)


444
445
446
# File 'app/models/integrations/jira.rb', line 444

def support_close_issue?
  true
end

#support_cross_reference?Boolean

Returns:

  • (Boolean)


449
450
451
# File 'app/models/integrations/jira.rb', line 449

def support_cross_reference?
  true
end

#test(_) ⇒ Object



435
436
437
438
439
440
441
# File 'app/models/integrations/jira.rb', line 435

def test(_)
  result = {}.merge!(server_info, client_info) if server_info && client_info

  success = server_info.present? && client_info.present?
  result = @error&.message unless success
  { success: success, result: result }
end

#testable?Boolean

Returns:

  • (Boolean)


461
462
463
# File 'app/models/integrations/jira.rb', line 461

def testable?
  group_level? || project_level?
end

#urlObject



342
343
344
# File 'app/models/integrations/jira.rb', line 342

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

#valid_connection?Boolean

Returns:

  • (Boolean)


427
428
429
# File 'app/models/integrations/jira.rb', line 427

def valid_connection?
  test(nil)[:success]
end

#web_url(path = nil, **params) ⇒ Object Also known as: project_url



313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
# File 'app/models/integrations/jira.rb', line 313

def web_url(path = nil, **params)
  return '' unless url.present?

  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