Class: Integrations::Jira

Inherits:
BaseIssueTracker show all
Includes:
ActionView::Helpers::AssetUrlHelper, ApplicationHelper, Gitlab::Routing, Gitlab::Utils::StrongMemoize
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"
}.freeze
SECTION_TYPE_JIRA_TRIGGER =
'jira_trigger'
SECTION_TYPE_JIRA_ISSUES =
'jira_issues'
AUTH_TYPE_BASIC =
0
AUTH_TYPE_PAT =
1
SNOWPLOW_EVENT_CATEGORY =
self.name

Constants inherited from Integration

Integration::BASE_CLASSES, Integration::DEV_INTEGRATION_NAMES, Integration::INTEGRATION_NAMES, Integration::PROJECT_SPECIFIC_INTEGRATION_NAMES, Integration::SECTION_TYPE_CONFIGURATION, Integration::SECTION_TYPE_CONNECTION, Integration::SECTION_TYPE_TRIGGER, Integration::SNOWPLOW_EVENT_ACTION, Integration::SNOWPLOW_EVENT_LABEL, Integration::UnknownType

Constants inherited from ApplicationRecord

ApplicationRecord::MAX_PLUCK

Constants included from ResetOnUnionError

ResetOnUnionError::MAX_RESET_PERIOD

Instance Attribute Summary

Attributes included from Importable

#imported, #importing

Class Method Summary collapse

Instance Method Summary collapse

Methods included from ApplicationHelper

#active_when, #add_page_specific_style, #add_page_startup_api_call, #admin_section?, #asset_to_string, #autocomplete_data_sources, #body_data, #body_data_page, #client_class_list, #client_js_flags, #collapsed_sidebar?, #collapsed_super_sidebar?, community_forum, #community_forum, #conditional_link_to, #controller_full_path, #current_action?, #current_controller?, #discord_url, #dispensable_render, #dispensable_render_if_exists, #edited_time_ago_with_tooltip, #external_storage_url_or_path, #extra_config, #gitlab_config, #gitlab_ui_form_for, #gitlab_ui_form_with, #hexdigest, #hidden_resource_icon, #instance_review_permitted?, #last_commit, #linkedin_url, #locale_path, #outdated_browser?, #page_class, #page_filter_path, #page_startup_api_calls, #partial_exists?, #path_to_key, #project_data, #promo_host, promo_host, #promo_url, #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?, #stylesheet_link_tag_defer, #support_url, #system_message_class, #template_exists?, #time_ago_with_tooltip, #truncate_first_line, #twitter_url

Methods included from Gitlab::Routing

includes_helpers, redirect_legacy_paths, url_helpers

Methods inherited from BaseIssueTracker

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

Methods inherited from Integration

#activate_disabled_reason, #activated?, #api_field_names, #async_execute, #attributes, available_integration_names, available_integration_types, boolean_accessor, build_from_integration, #category, #chat?, #ci?, #configurable_events, create_from_active_default_integrations, default_integration, default_test_event, #default_test_event, dev_integration_names, #dup, #editable?, #event_channel_names, event_description, event_names, #event_names, field, #fields, fields, find_or_initialize_all_non_project_specific, find_or_initialize_non_project_specific_integration, #form_fields, #group_level?, #inheritable?, inherited_descendants_from_self_or_ancestors_from, #initialize_properties, instance_exists_for?, #instance_level?, integration_name_to_model, integration_name_to_type, integration_names, #json_fields, #operating?, #parent, #project_level?, project_specific_integration_names, prop_accessor, #properties=, #reencrypt_properties, #reset_updated_properties, #secret_fields, #show_active_box?, #supported_events, #supports_data_fields?, #testable?, #to_database_hash, #to_param, #updated_properties

Methods included from Gitlab::Utils::Override

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

Methods included from ResetSecretFields

#exposing_secrets_fields

Methods included from Loggable

#build_message, #log_error, #log_exception, #log_info, #logger

Methods inherited from ApplicationRecord

cached_column_list, #create_or_load_association, declarative_enum, default_select_columns, id_in, id_not_in, iid_in, pluck_primary_key, 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 SensitiveSerializableHash

#serializable_hash

Class Method Details

.supported_eventsObject

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



128
129
130
# File 'app/models/integrations/jira.rb', line 128

def self.supported_events
  %w[commit merge_request]
end

.to_paramObject



201
202
203
# File 'app/models/integrations/jira.rb', line 201

def self.to_param
  'jira'
end

.valid_jira_cloud_url?(url) ⇒ Boolean

Returns:

  • (Boolean)


137
138
139
140
141
142
143
144
# File 'app/models/integrations/jira.rb', line 137

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



271
272
273
# File 'app/models/integrations/jira.rb', line 271

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

#clientObject



181
182
183
184
185
186
# File 'app/models/integrations/jira.rb', line 181

def client
  @client ||= 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

#close_issue(entity, external_issue, current_user) ⇒ Object



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 293

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)


354
355
356
# File 'app/models/integrations/jira.rb', line 354

def configured?
  active? && valid_connection?
end

#create_cross_reference_note(external_issue, mentioned_in, author) ⇒ Object



314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
# File 'app/models/integrations/jira.rb', line 314

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) }
  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



146
147
148
# File 'app/models/integrations/jira.rb', line 146

def data_fields
  jira_tracker_data || self.build_jira_tracker_data
end

#descriptionObject



197
198
199
# File 'app/models/integrations/jira.rb', line 197

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

#execute(push) ⇒ Object



275
276
277
278
# File 'app/models/integrations/jira.rb', line 275

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



280
281
282
283
284
285
286
287
288
289
290
291
# File 'app/models/integrations/jira.rb', line 280

def find_issue(issue_key, rendered_fields: false, transitions: false, restrict_project_key: false)
  return if restrict_project_key && parse_project_from_issue_key(issue_key) != project_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

#helpObject



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

def help
  jira_doc_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('integration/jira/index') }
  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

#issue_transition_enabled?Boolean

Returns:

  • (Boolean)


376
377
378
# File 'app/models/integrations/jira.rb', line 376

def issue_transition_enabled?
  jira_issue_transition_automatic || jira_issue_transition_id.present?
end

#issues_urlObject



257
258
259
# File 'app/models/integrations/jira.rb', line 257

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

#new_issue_urlObject



261
262
263
# File 'app/models/integrations/jira.rb', line 261

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

#optionsObject



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'app/models/integrations/jira.rb', line 159

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



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

alias_method :original_api_url, :api_url

#original_urlObject



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

alias_method :original_url, :url

#personal_access_token_authorization?Boolean

Returns:

  • (Boolean)


380
381
382
# File 'app/models/integrations/jira.rb', line 380

def personal_access_token_authorization?
  jira_auth_type == AUTH_TYPE_PAT
end

#reference_pattern(only_long: true) ⇒ Object

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



133
134
135
# File 'app/models/integrations/jira.rb', line 133

def reference_pattern(only_long: true)
  @reference_pattern ||= jira_issue_match_regex
end

#sectionsObject



205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'app/models/integrations/jira.rb', line 205

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')
    }
  ]

  # Jira issues is currently only configurable on the project level.
  if project_level?
    sections.push({
      type: SECTION_TYPE_JIRA_ISSUES,
      title: _('Issues'),
      description: jira_issues_section_description,
      plan: 'premium'
    })
  end

  sections
end

#set_default_dataObject



150
151
152
153
154
155
156
157
# File 'app/models/integrations/jira.rb', line 150

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)


367
368
369
# File 'app/models/integrations/jira.rb', line 367

def support_close_issue?
  true
end

#support_cross_reference?Boolean

Returns:

  • (Boolean)


372
373
374
# File 'app/models/integrations/jira.rb', line 372

def support_cross_reference?
  true
end

#test(_) ⇒ Object



358
359
360
361
362
363
364
# File 'app/models/integrations/jira.rb', line 358

def test(_)
  result = server_info
  success = result.present?
  result = @error&.message unless success

  { success: success, result: result }
end

#titleObject



193
194
195
# File 'app/models/integrations/jira.rb', line 193

def title
  'Jira'
end

#urlObject



266
267
268
# File 'app/models/integrations/jira.rb', line 266

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

#valid_connection?Boolean

Returns:

  • (Boolean)


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

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

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



237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'app/models/integrations/jira.rb', line 237

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