jirametrics 2.11 → 2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,6 +45,7 @@ class Downloader
45
45
  board = download_board_configuration board_id: id
46
46
  download_issues board: board
47
47
  end
48
+ download_users
48
49
 
49
50
  save_metadata
50
51
  end
@@ -96,30 +97,59 @@ class Downloader
96
97
  log " JQL: #{jql}"
97
98
  escaped_jql = CGI.escape jql
98
99
 
99
- max_results = 100
100
- start_at = 0
101
- total = 1
102
- while start_at < total
103
- json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
104
- "?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
105
-
106
- json['issues'].each do |issue_json|
107
- issue_json['exporter'] = {
108
- 'in_initial_query' => initial_query
109
- }
110
- identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
111
- file = "#{issue_json['key']}-#{board.id}.json"
112
-
113
- @file_system.save_json(json: issue_json, filename: File.join(path, file))
114
- end
100
+ if @jira_gateway.cloud?
101
+ max_results = 5_000 # The maximum allowed by Jira
102
+ next_page_token = nil
103
+ issue_count = 0
115
104
 
116
- total = json['total'].to_i
117
- max_results = json['maxResults']
105
+ loop do
106
+ json = @jira_gateway.call_url relative_url: '/rest/api/3/search/jql' \
107
+ "?jql=#{escaped_jql}&maxResults=#{max_results}&" \
108
+ "nextPageToken=#{next_page_token}&expand=changelog&fields=*all"
109
+ next_page_token = json['nextPageToken']
118
110
 
119
- message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
120
- log message, both: true
111
+ json['issues'].each do |issue_json|
112
+ issue_json['exporter'] = {
113
+ 'in_initial_query' => initial_query
114
+ }
115
+ identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
116
+ file = "#{issue_json['key']}-#{board.id}.json"
121
117
 
122
- start_at += json['issues'].size
118
+ @file_system.save_json(json: issue_json, filename: File.join(path, file))
119
+ issue_count += 1
120
+ end
121
+
122
+ message = " Downloaded #{issue_count} issues"
123
+ log message, both: true
124
+
125
+ break unless next_page_token
126
+ end
127
+ else
128
+ max_results = 100
129
+ start_at = 0
130
+ total = 1
131
+ while start_at < total
132
+ json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
133
+ "?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
134
+
135
+ json['issues'].each do |issue_json|
136
+ issue_json['exporter'] = {
137
+ 'in_initial_query' => initial_query
138
+ }
139
+ identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
140
+ file = "#{issue_json['key']}-#{board.id}.json"
141
+
142
+ @file_system.save_json(json: issue_json, filename: File.join(path, file))
143
+ end
144
+
145
+ total = json['total'].to_i
146
+ max_results = json['maxResults']
147
+
148
+ message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
149
+ log message, both: true
150
+
151
+ start_at += json['issues'].size
152
+ end
123
153
  end
124
154
  end
125
155
 
@@ -147,6 +177,16 @@ class Downloader
147
177
  )
148
178
  end
149
179
 
180
+ def download_users
181
+ log ' Downloading all users', both: true
182
+ json = @jira_gateway.call_url relative_url: '/rest/api/2/users'
183
+
184
+ @file_system.save_json(
185
+ json: json,
186
+ filename: File.join(@target_path, "#{file_prefix}_users.json")
187
+ )
188
+ end
189
+
150
190
  def update_status_history_file
151
191
  status_filename = File.join(@target_path, "#{file_prefix}_statuses.json")
152
192
  return unless file_system.file_exist? status_filename
@@ -22,15 +22,18 @@ class EstimateAccuracyChart < ChartBase
22
22
  </div>
23
23
  HTML
24
24
 
25
- @y_axis_label = 'Story Point Estimates'
26
25
  @y_axis_type = 'linear'
27
- @y_axis_block = ->(issue, start_time) { story_points_at(issue: issue, start_time: start_time)&.to_f }
26
+ @y_axis_block = ->(issue, start_time) { estimate_at(issue: issue, start_time: start_time)&.to_f }
28
27
  @y_axis_sort_order = nil
29
28
 
30
29
  instance_eval(&configuration_block)
31
30
  end
32
31
 
33
32
  def run
33
+ if @y_axis_label.nil?
34
+ text = current_board.estimation_configuration.units == :story_points ? 'Story Points' : 'Days'
35
+ @y_axis_label = "Estimated #{text}"
36
+ end
34
37
  data_sets = scan_issues
35
38
 
36
39
  return '' if data_sets.empty?
@@ -41,6 +44,7 @@ class EstimateAccuracyChart < ChartBase
41
44
  def scan_issues
42
45
  completed_hash, aging_hash = split_into_completed_and_aging issues: issues
43
46
 
47
+ estimation_units = current_board.estimation_configuration.units
44
48
  @has_aging_data = !aging_hash.empty?
45
49
 
46
50
  [
@@ -53,9 +57,13 @@ class EstimateAccuracyChart < ChartBase
53
57
  # We sort so that the smaller circles are in front of the bigger circles.
54
58
  data = hash.sort(&hash_sorter).collect do |key, values|
55
59
  estimate, cycle_time = *key
56
- estimate_label = "#{estimate}#{'pts' if @y_axis_type == 'linear'}"
57
- title = ["Estimate: #{estimate_label}, Cycletime: #{label_days(cycle_time)}, #{values.size} issues"] +
58
- values.collect { |issue| "#{issue.key}: #{issue.summary}" }
60
+
61
+ title = [
62
+ "Estimate: #{estimate_label(estimate: estimate, estimation_units: estimation_units)}, " \
63
+ "Cycletime: #{label_days(cycle_time)}, " \
64
+ "#{values.size} issues"
65
+ ] + values.collect { |issue| "#{issue.key}: #{issue.summary}" }
66
+
59
67
  {
60
68
  'x' => cycle_time,
61
69
  'y' => estimate,
@@ -77,6 +85,18 @@ class EstimateAccuracyChart < ChartBase
77
85
  end
78
86
  end
79
87
 
88
+ def estimate_label estimate:, estimation_units:
89
+ if @y_axis_type == 'linear'
90
+ if estimation_units == :story_points
91
+ estimate_label = "#{estimate}pts"
92
+ elsif estimation_units == :seconds
93
+ estimate_label = label_days estimate
94
+ end
95
+ end
96
+ estimate_label = estimate.to_s if estimate_label.nil?
97
+ estimate_label
98
+ end
99
+
80
100
  def split_into_completed_and_aging issues:
81
101
  aging_hash = {}
82
102
  completed_hash = {}
@@ -126,14 +146,18 @@ class EstimateAccuracyChart < ChartBase
126
146
  end
127
147
  end
128
148
 
129
- def story_points_at issue:, start_time:
130
- story_points = nil
149
+ def estimate_at issue:, start_time:, estimation_configuration: current_board.estimation_configuration
150
+ estimate = nil
151
+
131
152
  issue.changes.each do |change|
132
- return story_points if change.time >= start_time
153
+ return estimate if change.time >= start_time
133
154
 
134
- story_points = change.value if change.story_points?
155
+ if change.field == estimation_configuration.display_name || change.field == estimation_configuration.field_id
156
+ estimate = change.value
157
+ estimate = estimate.to_f / (24 * 60 * 60) if estimation_configuration.units == :seconds
158
+ end
135
159
  end
136
- story_points
160
+ estimate
137
161
  end
138
162
 
139
163
  def y_axis label:, sort_order: nil, &block
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class EstimationConfiguration
4
+ attr_reader :units, :display_name, :field_id
5
+
6
+ def initialize raw:
7
+ @units = :story_points
8
+ @display_name = 'Story Points'
9
+
10
+ # If there wasn't an estimation section they rely on all defaults
11
+ return if raw.nil?
12
+
13
+ if raw['type'] == 'field'
14
+ @field_id = raw['field']['fieldId']
15
+ @display_name = raw['field']['displayName']
16
+ if @field_id == 'timeoriginalestimate'
17
+ @units = :seconds
18
+ @display_name = 'Original estimate'
19
+ end
20
+ elsif raw['type'] == 'issueCount'
21
+ @display_name = 'Issue Count'
22
+ @units = :issue_count
23
+ end
24
+ end
25
+ end
@@ -58,6 +58,8 @@ class Exporter
58
58
  type: :header
59
59
  end
60
60
 
61
+ daily_view
62
+
61
63
  cycletime_scatterplot do
62
64
  show_trend_lines
63
65
  end
@@ -13,7 +13,7 @@ class FileConfig
13
13
  end
14
14
 
15
15
  def run
16
- @issues = project_config.issues.dup
16
+ @issues = project_config.issues
17
17
  instance_eval(&@block)
18
18
 
19
19
  if @columns
@@ -4,6 +4,7 @@
4
4
  <th title="Age in days">Age</th>
5
5
  <th title="Expedited">E</th>
6
6
  <th title="Blocked / Stalled">B/S</th>
7
+ <th title="Priority">P</th>
7
8
  <th>Issue</th>
8
9
  <th>Status</th>
9
10
  <th>Forecast</th>
@@ -29,6 +30,7 @@
29
30
  <td style="text-align: right;"><%= issue_age || 'Not started' %></td>
30
31
  <td><%= expedited_text(issue) %></td>
31
32
  <td><%= blocked_text(issue) %></td>
33
+ <td><%= priority_text(issue) %></td>
32
34
  <td>
33
35
  <% parent_hierarchy(issue).each_with_index do |parent, index| %>
34
36
  <% color = parent != issue ? "var(--hierarchy-table-inactive-item-text-color)" : 'var(--default-text-color)' %>
@@ -67,6 +67,9 @@
67
67
  --sprint-burndown-sprint-color-4: red;
68
68
  --sprint-burndown-sprint-color-5: brown;
69
69
 
70
+ --daily-view-selected-issue-background: lightgray;
71
+ --daily-view-issue-border: green;
72
+ --daily-view-selected-issue-border: red;
70
73
 
71
74
  }
72
75
 
@@ -142,6 +145,69 @@ ul.quality_report {
142
145
  border-top: 1px solid gray;
143
146
  }
144
147
 
148
+ div.daily_issue:hover {
149
+ background: var(--daily-view-selected-issue-background);
150
+ border-color: var(--daily-view-selected-issue-border);
151
+ }
152
+
153
+ div.daily_issue {
154
+ border: 1px solid var(--daily-view-issue-border);
155
+ padding: 0.5em;
156
+ .heading {
157
+ vertical-align: middle;
158
+ display: flex;
159
+ flex-wrap: wrap;
160
+ column-gap: 0.5em;
161
+ align-items: center;
162
+ }
163
+ table {
164
+ margin-left: 1em;
165
+ td {
166
+ vertical-align: top;
167
+ }
168
+ .time {
169
+ white-space: nowrap;
170
+ font-size: 0.8em;
171
+ }
172
+ }
173
+ .icon {
174
+ width: 1em;
175
+ height: 1em;
176
+ }
177
+ .account_id {
178
+ font-weight: bold;
179
+ }
180
+ .field {
181
+ border: 1px solid black;
182
+ color: white;
183
+ background: black;
184
+ padding-left: 0.2em;
185
+ padding-right: 0.2em;
186
+ border-radius: 0.2em;
187
+ }
188
+ .label {
189
+ border: 1px solid black;
190
+ padding-left: 0.2em;
191
+ padding-right: 0.2em;
192
+ border-radius: 0.2em;
193
+ }
194
+ h1 {
195
+ border: none;
196
+ background: none;
197
+ padding-left: 0;
198
+ }
199
+ margin-bottom: 0.5em;
200
+ }
201
+ div.child_issue:hover {
202
+ background: var(--body-background);
203
+ }
204
+ div.child_issue {
205
+ border: 1px dashed green;
206
+ margin: 0.2em;
207
+ margin-left: 1.5em;
208
+ padding: 0.5em;
209
+ }
210
+
145
211
  @media screen and (prefers-color-scheme: dark) {
146
212
  :root {
147
213
  --warning-banner: #9F2B00;
@@ -174,6 +240,8 @@ ul.quality_report {
174
240
  --wip-chart-duration-two-weeks-or-less-color: #cf9400;
175
241
  --wip-chart-duration-four-weeks-or-less-color: #c25e00;
176
242
  --wip-chart-duration-more-than-four-weeks-color: #8e0000;
243
+
244
+ --daily-view-selected-issue-background: #474747;
177
245
  }
178
246
 
179
247
  h1 {
@@ -206,4 +274,12 @@ ul.quality_report {
206
274
  div.color_block {
207
275
  border: 1px solid lightgray;
208
276
  }
277
+
278
+ div.daily_issue {
279
+ .field {
280
+ color: var(--default-text-color);
281
+ }
282
+ }
283
+ }
284
+
209
285
  }
@@ -18,6 +18,23 @@
18
18
  document.getElementById(issues_id).style.display = 'none'
19
19
  }
20
20
  }
21
+
22
+ function toggle_visibility(open_link_id, close_link_id, toggleable_id) {
23
+ let open_link = document.getElementById(open_link_id)
24
+ let close_link = document.getElementById(close_link_id)
25
+ let toggleable_element = document.getElementById(toggleable_id)
26
+
27
+ if(open_link.style.display == 'none') {
28
+ open_link.style.display = 'block'
29
+ close_link.style.display = 'none'
30
+ toggleable_element.style.display = 'none'
31
+ }
32
+ else {
33
+ open_link.style.display = 'none'
34
+ close_link.style.display = 'block'
35
+ toggleable_element.style.display = 'block'
36
+ }
37
+ }
21
38
  // If we switch between light/dark mode then force a refresh so all charts will redraw correctly
22
39
  // in the other colour scheme.
23
40
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
@@ -33,8 +50,8 @@
33
50
  </head>
34
51
  <body>
35
52
  <noscript>
36
- <div style="padding: 1em; background: gray; color: white; font-size: 2em;">
37
- Javascript is currently disabled and that means that almost all of the charts in this report won't render. If you'd loaded this from a folder on SharePoint then save it locally and load it again.
53
+ <div style="padding: 1em; background: red; color: white; font-size: 2em;">
54
+ Javascript is currently disabled and that means that almost all of the charts in this report won't render. If you've loaded this from a folder on SharePoint then save it locally and load it again.
38
55
  </div>
39
56
  </noscript>
40
57
  <%= "\n" + @sections.collect { |text, type| text if type == :header }.compact.join("\n\n") %>
@@ -33,6 +33,7 @@ class HtmlReportConfig
33
33
  define_chart name: 'estimate_accuracy_chart', classname: 'EstimateAccuracyChart'
34
34
  define_chart name: 'hierarchy_table', classname: 'HierarchyTable'
35
35
  define_chart name: 'flow_efficiency_scatterplot', classname: 'FlowEfficiencyScatterplot'
36
+ define_chart name: 'daily_view', classname: 'DailyView'
36
37
 
37
38
  define_chart name: 'daily_wip_by_type', classname: 'DailyWipChart',
38
39
  deprecated_warning: 'This is the same as daily_wip_chart. Please use that one', deprecated_date: '2024-05-23'
@@ -159,6 +160,7 @@ class HtmlReportConfig
159
160
  chart.time_range = project_config.time_range
160
161
  chart.timezone_offset = timezone_offset
161
162
  chart.settings = settings
163
+ chart.users = project_config.users
162
164
 
163
165
  chart.all_boards = project_config.all_boards
164
166
  chart.board_id = find_board_id
@@ -44,9 +44,11 @@ class Issue
44
44
  def key = @raw['key']
45
45
 
46
46
  def type = @raw['fields']['issuetype']['name']
47
-
48
47
  def type_icon_url = @raw['fields']['issuetype']['iconUrl']
49
48
 
49
+ def priority_name = @raw['fields']['priority']['name']
50
+ def priority_url = @raw['fields']['priority']['iconUrl']
51
+
50
52
  def summary = @raw['fields']['summary']
51
53
 
52
54
  def labels = @raw['fields']['labels'] || []
@@ -205,6 +207,10 @@ class Issue
205
207
  nil
206
208
  end
207
209
 
210
+ def first_time_visible_on_board
211
+ first_time_in_status(*board.visible_columns.collect(&:status_ids).flatten)
212
+ end
213
+
208
214
  def parse_time text
209
215
  Time.parse(text).getlocal(@timezone_offset)
210
216
  end
@@ -230,6 +236,10 @@ class Issue
230
236
  @raw['fields']&.[]('assignee')&.[]('displayName')
231
237
  end
232
238
 
239
+ def assigned_to_icon_url
240
+ @raw['fields']&.[]('assignee')&.[]('avatarUrls')&.[]('16x16')
241
+ end
242
+
233
243
  # Many test failures are simply unreadable because the default inspect on this class goes
234
244
  # on for pages. Shorten it up.
235
245
  def inspect
@@ -315,7 +325,7 @@ class Issue
315
325
 
316
326
  # This mock change is to force the writing of one last entry at the end of the time range.
317
327
  # By doing this, we're able to eliminate a lot of duplicated code in charts.
318
- mock_change = ChangeItem.new time: end_time, author: '', artificial: true, raw: { 'field' => '' }
328
+ mock_change = ChangeItem.new time: end_time, artificial: true, raw: { 'field' => '' }, author_raw: nil
319
329
 
320
330
  (changes + [mock_change]).each do |change|
321
331
  previous_was_active = false if check_for_stalled(
@@ -335,7 +345,7 @@ class Issue
335
345
  end
336
346
  elsif change.link?
337
347
  # Example: "This issue is satisfied by ANON-30465"
338
- unless /^This issue (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
348
+ unless /^This (?<_>issue|work item) (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
339
349
  puts "Issue(#{key}) Can't parse link text: #{change.value || change.old_value}"
340
350
  next
341
351
  end
@@ -462,8 +472,6 @@ class Issue
462
472
  end
463
473
 
464
474
  def expedited?
465
- return false unless @board&.project_config
466
-
467
475
  names = @board.project_config.settings['expedited_priority_names']
468
476
  return false unless names
469
477
 
@@ -580,7 +588,7 @@ class Issue
580
588
  /(?<project_code1>[^-]+)-(?<id1>.+)/ =~ key
581
589
  /(?<project_code2>[^-]+)-(?<id2>.+)/ =~ other.key
582
590
  comparison = project_code1 <=> project_code2
583
- comparison = id1 <=> id2 if comparison.zero?
591
+ comparison = id1.to_i <=> id2.to_i if comparison.zero?
584
592
  comparison
585
593
  end
586
594
 
@@ -611,9 +619,13 @@ class Issue
611
619
  end
612
620
  history = [] # time, type, detail
613
621
 
614
- started_at, stopped_at = board.cycletime.started_stopped_times(self)
615
- history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
616
- history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
622
+ if board.cycletime
623
+ started_at, stopped_at = board.cycletime.started_stopped_times(self)
624
+ history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
625
+ history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
626
+ else
627
+ result << " Unable to determine start/end times as board #{board.id} has no cycletime specified\n"
628
+ end
617
629
 
618
630
  @discarded_change_times&.each do |time|
619
631
  history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
@@ -669,9 +681,8 @@ class Issue
669
681
  def done?
670
682
  if artificial? || board.cycletime.nil?
671
683
  # This was probably loaded as a linked issue, which means we don't know what board it really
672
- # belonged to. The best we can do is look at the status category. This case should be rare but
673
- # it can happen.
674
- status.category.name == 'Done'
684
+ # belonged to. The best we can do is look at the status key
685
+ status.category.done?
675
686
  else
676
687
  board.cycletime.done? self
677
688
  end
@@ -681,34 +692,40 @@ class Issue
681
692
  @changes.select { |change| change.status? }
682
693
  end
683
694
 
684
- private
695
+ def sprints
696
+ sprint_ids = []
685
697
 
686
- def assemble_author raw
687
- raw['author']&.[]('displayName') || raw['author']&.[]('name') || 'Unknown author'
698
+ changes.each do |change|
699
+ next unless change.sprint?
700
+
701
+ sprint_ids << change.raw['to'].split(/\s*,\s*/).collect { |id| id.to_i }
702
+ end
703
+ sprint_ids.flatten!
704
+
705
+ board.sprints.select { |s| sprint_ids.include? s.id }
688
706
  end
689
707
 
708
+ private
709
+
690
710
  def load_history_into_changes
691
711
  @raw['changelog']['histories']&.each do |history|
692
712
  created = parse_time(history['created'])
693
713
 
694
- # It should be impossible to not have an author but we've seen it in production
695
- author = assemble_author history
696
714
  history['items']&.each do |item|
697
- @changes << ChangeItem.new(raw: item, time: created, author: author)
715
+ @changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
698
716
  end
699
717
  end
700
718
  end
701
719
 
702
720
  def load_comments_into_changes
703
721
  @raw['fields']['comment']['comments']&.each do |comment|
704
- raw = {
722
+ raw = comment.merge({
705
723
  'field' => 'comment',
706
724
  'to' => comment['id'],
707
725
  'toString' => comment['body']
708
- }
709
- author = assemble_author comment
726
+ })
710
727
  created = parse_time(comment['created'])
711
- @changes << ChangeItem.new(raw: raw, time: created, author: author, artificial: true)
728
+ @changes << ChangeItem.new(raw: raw, time: created, artificial: true, author_raw: comment['author'])
712
729
  end
713
730
  end
714
731
 
@@ -750,7 +767,9 @@ class Issue
750
767
  first_status = first_change.old_value
751
768
  first_status_id = first_change.old_value_id
752
769
  end
753
- ChangeItem.new time: created_time, artificial: true, author: author, raw: {
770
+
771
+ creator = raw['fields']['creator']
772
+ ChangeItem.new time: created_time, artificial: true, author_raw: creator, raw: {
754
773
  'field' => field_name,
755
774
  'to' => first_status_id,
756
775
  'toString' => first_status
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class IssueCollection < Array
4
+ attr_reader :hidden
5
+
6
+ def self.[] *issues
7
+ collection = new
8
+ issues.each { |i| collection << i }
9
+ collection
10
+ end
11
+
12
+ def initialize
13
+ super
14
+ @hidden = []
15
+ end
16
+
17
+ def reject! &block
18
+ select(&block).each do |issue|
19
+ @hidden << issue
20
+ end
21
+ super
22
+ end
23
+
24
+ def find_by_key key:, include_hidden: false
25
+ block = ->(issue) { issue.key == key }
26
+ issue = find(&block)
27
+ issue = hidden.find(&block) if issue.nil? && include_hidden
28
+ issue
29
+ end
30
+ def clone
31
+ raise 'baboom'
32
+ end
33
+ end
@@ -26,7 +26,10 @@ class JiraGateway
26
26
  end
27
27
 
28
28
  def call_command command
29
- @file_system.log " #{command.gsub(/\s+/, ' ')}"
29
+ log_entry = " #{command.gsub(/\s+/, ' ')}"
30
+ log_entry = log_entry.gsub(@jira_api_token, '[API_TOKEN]') if @jira_api_token
31
+ @file_system.log log_entry
32
+
30
33
  result = `#{command}`
31
34
  @file_system.log result unless $CHILD_STATUS.success?
32
35
  return result if $CHILD_STATUS.success?
@@ -74,4 +77,8 @@ class JiraGateway
74
77
 
75
78
  true
76
79
  end
80
+
81
+ def cloud?
82
+ @jira_url.downcase.end_with? '.atlassian.net'
83
+ end
77
84
  end