jirametrics 2.8 → 2.10

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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +1 -1
  3. data/lib/jirametrics/aging_work_bar_chart.rb +7 -5
  4. data/lib/jirametrics/board.rb +2 -3
  5. data/lib/jirametrics/board_config.rb +6 -2
  6. data/lib/jirametrics/chart_base.rb +36 -9
  7. data/lib/jirametrics/cycletime_config.rb +10 -4
  8. data/lib/jirametrics/cycletime_histogram.rb +65 -2
  9. data/lib/jirametrics/data_quality_report.rb +53 -34
  10. data/lib/jirametrics/downloader.rb +0 -14
  11. data/lib/jirametrics/examples/standard_project.rb +2 -2
  12. data/lib/jirametrics/exporter.rb +10 -20
  13. data/lib/jirametrics/file_config.rb +21 -4
  14. data/lib/jirametrics/file_system.rb +23 -4
  15. data/lib/jirametrics/groupable_issue_chart.rb +1 -3
  16. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
  17. data/lib/jirametrics/html/aging_work_table.erb +1 -1
  18. data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
  19. data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
  20. data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
  21. data/lib/jirametrics/html/expedited_chart.erb +1 -10
  22. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  23. data/lib/jirametrics/html/sprint_burndown.erb +1 -10
  24. data/lib/jirametrics/html/throughput_chart.erb +1 -10
  25. data/lib/jirametrics/html_report_config.rb +18 -23
  26. data/lib/jirametrics/issue.rb +51 -27
  27. data/lib/jirametrics/jira_gateway.rb +16 -3
  28. data/lib/jirametrics/project_config.rb +102 -45
  29. data/lib/jirametrics/status.rb +23 -7
  30. data/lib/jirametrics/status_collection.rb +69 -68
  31. data/lib/jirametrics/value_equality.rb +2 -2
  32. data/lib/jirametrics.rb +0 -1
  33. metadata +4 -9
  34. data/lib/jirametrics/discard_changes_before.rb +0 -37
@@ -31,13 +31,22 @@ class FileSystem
31
31
  File.write(filename, content)
32
32
  end
33
33
 
34
- def warning message
35
- log "Warning: #{message}", also_write_to_stderr: true
34
+ def warning message, more: nil
35
+ log "Warning: #{message}", more: more, also_write_to_stderr: true
36
36
  end
37
37
 
38
- def log message, also_write_to_stderr: false
38
+ def error message, more: nil
39
+ log "Error: #{message}", more: more, also_write_to_stderr: true
40
+ end
41
+
42
+ def log message, more: nil, also_write_to_stderr: false
43
+ message += " See #{logfile_name} for more details about this message." if more
44
+
39
45
  logfile.puts message
40
- $stderr.puts message if also_write_to_stderr # rubocop:disable Style/StderrPuts
46
+ logfile.puts more if more
47
+ return unless also_write_to_stderr
48
+
49
+ $stderr.puts message # rubocop:disable Style/StderrPuts
41
50
  end
42
51
 
43
52
  # In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
@@ -59,4 +68,14 @@ class FileSystem
59
68
  def file_exist? filename
60
69
  File.exist? filename
61
70
  end
71
+
72
+ def deprecated message:, date:, depth: 2
73
+ text = +''
74
+ text << "Deprecated(#{date}): "
75
+ text << message
76
+ caller(1..depth).each do |line|
77
+ text << "\n-> Called from #{line}"
78
+ end
79
+ log text, also_write_to_stderr: true
80
+ end
62
81
  end
@@ -6,9 +6,7 @@ require 'jirametrics/grouping_rules'
6
6
  module GroupableIssueChart
7
7
  def init_configuration_block user_provided_block, &default_block
8
8
  instance_eval(&user_provided_block)
9
- return if @group_by_block
10
-
11
- instance_eval(&default_block)
9
+ instance_eval(&default_block) unless @group_by_block
12
10
  end
13
11
 
14
12
  def grouping_rules &block
@@ -38,22 +38,13 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
38
38
  plugins: {
39
39
  annotation: {
40
40
  annotations: {
41
- <% holidays.each_with_index do |range, index| %>
42
- holiday<%= index %>: {
43
- drawTime: 'beforeDraw',
44
- type: 'box',
45
- xMin: '<%= range.begin %>T00:00:00',
46
- xMax: '<%= range.end %>T23:59:59',
47
- backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
48
- borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
49
- },
50
- <% end %>
41
+ <%= working_days_annotation %>
51
42
 
52
43
  <% if percentage_line_x %>
53
44
  line: {
54
45
  type: 'line',
55
- xMin: '<%= percentage_line_x %>',
56
- xMax: '<%= percentage_line_x %>',
46
+ scaleID: 'x',
47
+ value: '<%= percentage_line_x %>',
57
48
  borderColor: <%= CssVariable.new('--aging-work-bar-chart-percentage-line-color').to_json %>,
58
49
  borderWidth: 1,
59
50
  drawTime: 'afterDraw'
@@ -40,7 +40,7 @@
40
40
  </div>
41
41
  <% end %>
42
42
  </td>
43
- <td><%= format_status issue.status.name, board: issue.board %></td>
43
+ <td><%= format_status issue.status, board: issue.board %></td>
44
44
  <td><%= fix_versions_text(issue) %></td>
45
45
  <% if any_scrum_boards %>
46
46
  <td><%= sprints_text(issue) %></td>
@@ -1,6 +1,57 @@
1
1
  <div class="chart">
2
2
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
3
  </div>
4
+ <%
5
+ if show_stats
6
+ link_id = next_id
7
+ issues_id = next_id
8
+ %>
9
+ [<a id='<%= link_id %>' href="#" onclick='expand_collapse("<%= link_id %>", "<%= issues_id %>"); return false;'>Show details</a>]
10
+ <div id="<%= issues_id %>" style="display: none;">
11
+ <div>
12
+ <table class="standard">
13
+ <tr>
14
+ <th>Issue Type</th>
15
+ <th>Min</th>
16
+ <th>Max</th>
17
+ <th>Avg</th>
18
+ <th>Mode</th>
19
+ <% percentiles.each do |p| %>
20
+ <th><%= p %>th</th>
21
+ <% end %>
22
+ </tr>
23
+ <% the_stats.each do |k, v| %>
24
+ <tr>
25
+ <td><%= k %></td>
26
+ <td style="text-align: right;"><%= v[:min] %></td>
27
+ <td style="text-align: right;"><%= v[:max] %></td>
28
+ <td style="text-align: right;"><%= sprintf('%.2f', v[:average]) %></td>
29
+ <td><%= v[:mode].join(', ') %></td>
30
+ <% percentiles.each do |p| %>
31
+ <td style="text-align: right;"><%= v[:percentiles][p] %></td>
32
+ <% end %>
33
+ </tr>
34
+ <% end %>
35
+ </table>
36
+ </div>
37
+ <div>
38
+ <p>These statistics help understand the <i>"shape"</i> of the cycletime histogram distribution, to help us with predictions.</p>
39
+ <ul>
40
+ <li><b>Min & Max:</b> the observed spread for the data set. Useful to judge how wide the variation is. </li>
41
+ <li><b>Average:</b> the arithmetic mean of the data set. Useful as a <i>"typical representative"</i> of the complete set.</li>
42
+ <li><b>Mode:</b> the most repeated value(s) in the data set. This is the value we're most likely to remember. </li>
43
+ <li><b>Percentiles:</b> they partition the data set. If X is the Nth percentile, it means that N% of cycletime values are X or less. Typical percentiles of interest are:</li>
44
+ <ul>
45
+ <li><b>50%</b>: also known as the <b>Median</b>. Useful to establish short feedback loops, to monitor that it's not drifting to the right.</li>
46
+ <li><b>85%</b>: useful to establish service level expectations, accounting for rare events..</li>
47
+ <li><b>98% (or higher)</b>: useful to gauge worst case expectations..</li>
48
+ </ul>
49
+ </ul>
50
+ </div>
51
+ </div>
52
+ <%
53
+ end
54
+ %>
4
55
  <script>
5
56
  new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
6
57
  {
@@ -21,6 +72,8 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
21
72
  grid: {
22
73
  color: <%= CssVariable['--grid-line-color'].to_json %>
23
74
  },
75
+ min: 0,
76
+ offset: false, // Gets rid of the ugly padding on left.
24
77
  },
25
78
  y: {
26
79
  stacked: true,
@@ -34,6 +87,27 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
34
87
  }
35
88
  },
36
89
  plugins: {
90
+ annotation: {
91
+ annotations: {
92
+ <%
93
+ results = the_stats[:all][:percentiles]
94
+ results.each do |percentile, value|
95
+ %>
96
+ percentile<%= percentile.to_s %>: {
97
+ type: 'line',
98
+ scaleID: 'x',
99
+ value: <%= value %>,
100
+ borderWidth: 1,
101
+ drawTime: 'beforeDatasetsDraw',
102
+ label: {
103
+ enabled: true,
104
+ content: '<%= "#{percentile}%" %>',
105
+ position: 'start',
106
+ }
107
+ },
108
+ <% end %>
109
+ },
110
+ },
37
111
  tooltip: {
38
112
  callbacks: {
39
113
  label: function(context) {
@@ -53,16 +53,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
53
53
  autocolors: false,
54
54
  annotation: {
55
55
  annotations: {
56
- <% holidays.each_with_index do |range, index| %>
57
- holiday<%= index %>: {
58
- drawTime: 'beforeDraw',
59
- type: 'box',
60
- xMin: '<%= range.begin %>T00:00:00',
61
- xMax: '<%= range.end %>T23:59:59',
62
- backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
63
- borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
64
- },
65
- <% end %>
56
+ <%= working_days_annotation %>
66
57
 
67
58
  <% @percentage_lines.each_with_index do |args, index| %>
68
59
  <% percent, color = args %>
@@ -50,16 +50,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
50
50
  },
51
51
  annotation: {
52
52
  annotations: {
53
- <% holidays.each_with_index do |range, index| %>
54
- holiday<%= index %>: {
55
- drawTime: 'beforeDraw',
56
- type: 'box',
57
- xMin: '<%= range.begin %>T00:00:00',
58
- xMax: '<%= range.end %>T23:59:59',
59
- backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
60
- borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
61
- },
62
- <% end %>
53
+ <%= working_days_annotation %>
63
54
  }
64
55
  },
65
56
  legend: {
@@ -55,16 +55,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
55
55
  autocolors: false,
56
56
  annotation: {
57
57
  annotations: {
58
- <% holidays.each_with_index do |range, index| %>
59
- holiday<%= index %>: {
60
- drawTime: 'beforeDraw',
61
- type: 'box',
62
- xMin: '<%= range.begin %>T00:00:00',
63
- xMax: '<%= range.end %>T23:59:59',
64
- backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
65
- borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
66
- },
67
- <% end %>
58
+ <%= working_days_annotation %>
68
59
  }
69
60
  }
70
61
  }
@@ -22,7 +22,7 @@
22
22
  </span>
23
23
  </td>
24
24
  <td><span style="color: <%= color %>; font-style: italic;"><%= issue.summary[0..80] %></span></td>
25
- <td><%= format_status issue.status.name, board: issue.board %></td>
25
+ <td><%= format_status issue.status, board: issue.board %></td>
26
26
  </tr>
27
27
  <% end %>
28
28
  </tbody>
@@ -56,16 +56,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
56
56
  },
57
57
  annotation: {
58
58
  annotations: {
59
- <% holidays().each_with_index do |range, index| %>
60
- holiday<%= index %>: {
61
- drawTime: 'beforeDraw',
62
- type: 'box',
63
- xMin: '<%= range.begin %>T00:00:00',
64
- xMax: '<%= range.end %>T23:59:59',
65
- backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
66
- borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
67
- },
68
- <% end %>
59
+ <%= working_days_annotation %>
69
60
  }
70
61
  }
71
62
  }
@@ -52,16 +52,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
52
52
  },
53
53
  annotation: {
54
54
  annotations: {
55
- <% holidays.each_with_index do |range, index| %>
56
- holiday<%= index %>: {
57
- drawTime: 'beforeDraw',
58
- type: 'box',
59
- xMin: '<%= range.begin %>T00:00:00',
60
- xMax: '<%= range.end %>T23:59:59',
61
- backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
62
- borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
63
- },
64
- <% end %>
55
+ <%= working_days_annotation %>
65
56
  }
66
57
  }
67
58
  }
@@ -5,16 +5,15 @@ require 'jirametrics/self_or_issue_dispatcher'
5
5
 
6
6
  class HtmlReportConfig
7
7
  include SelfOrIssueDispatcher
8
- include DiscardChangesBefore
9
8
 
10
- attr_reader :file_config, :sections
9
+ attr_reader :file_config, :sections, :charts
11
10
 
12
11
  def self.define_chart name:, classname:, deprecated_warning: nil, deprecated_date: nil
13
12
  lines = []
14
13
  lines << "def #{name} &block"
15
14
  lines << ' block = ->(_) {} unless block'
16
15
  if deprecated_warning
17
- lines << " deprecated date: #{deprecated_date.inspect}, message: #{deprecated_warning.inspect}"
16
+ lines << " file_system.deprecated date: #{deprecated_date.inspect}, message: #{deprecated_warning.inspect}"
18
17
  end
19
18
  lines << " execute_chart #{classname}.new(block)"
20
19
  lines << 'end'
@@ -43,14 +42,15 @@ class HtmlReportConfig
43
42
  def initialize file_config:, block:
44
43
  @file_config = file_config
45
44
  @block = block
46
- @sections = []
45
+ @sections = [] # Where we store the chunks of text that will be assembled into the HTML
46
+ @charts = [] # Where we store all the charts we executed so we can assert against them.
47
47
  end
48
48
 
49
49
  def cycletime label = nil, &block
50
50
  @file_config.project_config.all_boards.each_value do |board|
51
51
  raise 'Multiple cycletimes not supported' if board.cycletime
52
52
 
53
- board.cycletime = CycleTimeConfig.new(parent_config: self, label: label, block: block)
53
+ board.cycletime = CycleTimeConfig.new(parent_config: self, label: label, block: block, file_system: file_system)
54
54
  end
55
55
  end
56
56
 
@@ -64,7 +64,7 @@ class HtmlReportConfig
64
64
 
65
65
  # The quality report has to be generated last because otherwise cycletime won't have been
66
66
  # set. Then we have to rotate it to the first position so it's at the top of the report.
67
- execute_chart DataQualityReport.new(@original_issue_times || {})
67
+ execute_chart DataQualityReport.new(file_config.project_config.discarded_changes_data)
68
68
  @sections.rotate!(-1)
69
69
 
70
70
  html create_footer
@@ -101,9 +101,8 @@ class HtmlReportConfig
101
101
  base_css
102
102
  end
103
103
 
104
- def board_id id = nil
105
- @board_id = id unless id.nil?
106
- @board_id
104
+ def board_id id
105
+ @board_id = id
107
106
  end
108
107
 
109
108
  def timezone_offset
@@ -143,19 +142,6 @@ class HtmlReportConfig
143
142
  end
144
143
  end
145
144
 
146
- def discard_changes_before_hook issues_cutoff_times
147
- # raise 'Cycletime must be defined before using discard_changes_before' unless @cycletime
148
-
149
- @original_issue_times = {}
150
- issues_cutoff_times.each do |issue, cutoff_time|
151
- started = issue.board.cycletime.started_stopped_times(issue).first
152
- if started && started <= cutoff_time
153
- # We only need to log this if data was discarded
154
- @original_issue_times[issue] = { cutoff_time: cutoff_time, started_time: started }
155
- end
156
- end
157
- end
158
-
159
145
  def dependency_chart &block
160
146
  execute_chart DependencyChart.new block
161
147
  end
@@ -175,7 +161,7 @@ class HtmlReportConfig
175
161
  chart.settings = settings
176
162
 
177
163
  chart.all_boards = project_config.all_boards
178
- chart.board_id = find_board_id if chart.respond_to? :board_id=
164
+ chart.board_id = find_board_id
179
165
  chart.holiday_dates = project_config.exporter.holiday_dates
180
166
 
181
167
  time_range = @file_config.project_config.time_range
@@ -184,6 +170,7 @@ class HtmlReportConfig
184
170
 
185
171
  after_init_block&.call chart
186
172
 
173
+ @charts << chart
187
174
  html chart.run
188
175
  end
189
176
 
@@ -216,4 +203,12 @@ class HtmlReportConfig
216
203
  </section>
217
204
  HTML
218
205
  end
206
+
207
+ def discard_changes_before status_becomes: nil, &block
208
+ file_system.deprecated(
209
+ date: '2025-01-09',
210
+ message: 'discard_changes_before is now only supported at the project level'
211
+ )
212
+ file_config.project_config.discard_changes_before status_becomes: status_becomes, &block
213
+ end
219
214
  end
@@ -13,7 +13,12 @@ class Issue
13
13
  @changes = []
14
14
  @board = board
15
15
 
16
+ # We only check for this here because if a board isn't passed in then things will fail much
17
+ # later and be hard to find. Let's find out early.
16
18
  raise "No board for issue #{key}" if board.nil?
19
+
20
+ # There are cases where we create an Issue of fragments like linked issues and those won't have
21
+ # changelogs.
17
22
  return unless @raw['changelog']
18
23
 
19
24
  load_history_into_changes
@@ -93,9 +98,7 @@ class Issue
93
98
 
94
99
  def still_in
95
100
  result = nil
96
- @changes.each do |change|
97
- next unless change.status?
98
-
101
+ status_changes.each do |change|
99
102
  current_status_matched = yield change
100
103
 
101
104
  if current_status_matched && result.nil?
@@ -117,63 +120,71 @@ class Issue
117
120
 
118
121
  # If it ever entered one of these categories and it's still there then what was the last time it entered
119
122
  def still_in_status_category *category_names
123
+ category_ids = find_status_category_ids_by_names category_names
124
+
120
125
  still_in do |change|
121
- status = find_status_by_id change.value_id, name: change.value
122
- category_names.include?(status.category.name) || category_names.include?(status.category.id)
126
+ status = find_or_create_status id: change.value_id, name: change.value
127
+ category_ids.include? status.category.id
123
128
  end
124
129
  end
125
130
 
126
131
  def most_recent_status_change
132
+ # We artificially insert a status change to represent creation so by definition there will always be at least one.
127
133
  changes.reverse.find { |change| change.status? }
128
134
  end
129
135
 
130
136
  # Are we currently in this status? If yes, then return the most recent status change.
131
137
  def currently_in_status *status_names
132
138
  change = most_recent_status_change
133
- return false if change.nil?
134
139
 
135
140
  change if change.current_status_matches(*status_names)
136
141
  end
137
142
 
138
143
  # Are we currently in this status category? If yes, then return the most recent status change.
139
144
  def currently_in_status_category *category_names
145
+ category_ids = find_status_category_ids_by_names category_names
146
+
140
147
  change = most_recent_status_change
141
- return false if change.nil?
142
148
 
143
- status = find_status_by_id change.value_id, name: change.value
144
- change if status && category_names.include?(status.category.name)
149
+ status = find_or_create_status id: change.value_id, name: change.value
150
+ change if status && category_ids.include?(status.category.id)
145
151
  end
146
152
 
147
- def find_status_by_id id, name: nil
153
+ def find_or_create_status id:, name:
148
154
  status = board.possible_statuses.find_by_id(id)
149
- return status if status
150
155
 
151
- status = board.possible_statuses.fabricate_status_for id: id, name: name
156
+ unless status
157
+ # Have to pull this list before the call to fabricate or else the warning will incorrectly
158
+ # list this status as one it actually found
159
+ found_statuses = board.possible_statuses.to_s
160
+
161
+ status = board.possible_statuses.fabricate_status_for id: id, name: name
152
162
 
153
- message = +'The history for issue '
154
- message << key
155
- message << ' references a status ('
156
- message << name.inspect << ':' if name
157
- message << id.to_s
158
- message << ') that can\'t be found in ['
159
- message << board.possible_statuses.collect(&:to_s).join(', ')
160
- message << "]. We are guessing that this belongs to the #{status.category} status category "
161
- message << 'and that may be wrong. See https://jirametrics.org/faq/#q1 for more details'
162
- board.project_config.file_system.warning message
163
+ message = +'The history for issue '
164
+ message << key
165
+ message << ' references the status ('
166
+ message << "#{name.inspect}:#{id.inspect}"
167
+ message << ') that can\'t be found. We are guessing that this belongs to the '
168
+ message << status.category.to_s
169
+ message << ' status category but that may be wrong. See https://jirametrics.org/faq/#q1 for more '
170
+ message << 'details on defining statuses.'
171
+ board.project_config.file_system.warning message, more: "The statuses we did find are: #{found_statuses}"
172
+ end
163
173
 
164
174
  status
165
175
  end
166
176
 
167
177
  def first_status_change_after_created
168
- @changes.find { |change| change.status? && change.artificial? == false }
178
+ status_changes.find { |change| change.artificial? == false }
169
179
  end
170
180
 
171
181
  def first_time_in_status_category *category_names
172
- @changes.each do |change|
173
- next unless change.status?
182
+ category_ids = find_status_category_ids_by_names category_names
174
183
 
175
- category = find_status_by_id(change.value_id).category.name
176
- return change if category_names.include? category
184
+ status_changes.each do |change|
185
+ to_status = find_or_create_status(id: change.value_id, name: change.value)
186
+ id = to_status.category.id
187
+ return change if category_ids.include? id
177
188
  end
178
189
  nil
179
190
  end
@@ -645,6 +656,10 @@ class Issue
645
656
  end
646
657
  end
647
658
 
659
+ def status_changes
660
+ @changes.select { |change| change.status? }
661
+ end
662
+
648
663
  private
649
664
 
650
665
  def assemble_author raw
@@ -720,4 +735,13 @@ class Issue
720
735
  'toString' => first_status
721
736
  }
722
737
  end
738
+
739
+ def find_status_category_ids_by_names category_names
740
+ category_names.filter_map do |name|
741
+ list = board.possible_statuses.find_all_categories_by_name name
742
+ raise "No status categories found for name: #{name}" if list.empty?
743
+
744
+ list
745
+ end.flatten.collect(&:id)
746
+ end
723
747
  end
@@ -14,9 +14,15 @@ class JiraGateway
14
14
  def call_url relative_url:
15
15
  command = make_curl_command url: "#{@jira_url}#{relative_url}"
16
16
  result = call_command command
17
- JSON.parse result
18
- rescue => e # rubocop:disable Style/RescueStandardError
19
- raise "Error #{e.message.inspect} when parsing result: #{result.inspect}"
17
+ begin
18
+ json = JSON.parse(result)
19
+ rescue # rubocop:disable Style/RescueStandardError
20
+ raise "Error when parsing result: #{result.inspect}"
21
+ end
22
+
23
+ raise "Download failed with: #{JSON.pretty_generate(json)}" unless json_successful?(json)
24
+
25
+ json
20
26
  end
21
27
 
22
28
  def call_command command
@@ -61,4 +67,11 @@ class JiraGateway
61
67
  command << " --url \"#{url}\""
62
68
  command
63
69
  end
70
+
71
+ def json_successful? json
72
+ return false if json.is_a?(Hash) && (json['error'] || json['errorMessages'] || json['errorMessage'])
73
+ return false if json.is_a?(Array) && json.first == 'errorMessage'
74
+
75
+ true
76
+ end
64
77
  end