jirametrics 2.9 → 2.12.1

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +1 -1
  3. data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
  4. data/lib/jirametrics/aging_work_table.rb +56 -13
  5. data/lib/jirametrics/board.rb +38 -10
  6. data/lib/jirametrics/board_config.rb +1 -0
  7. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  8. data/lib/jirametrics/change_item.rb +37 -16
  9. data/lib/jirametrics/chart_base.rb +22 -5
  10. data/lib/jirametrics/css_variable.rb +1 -1
  11. data/lib/jirametrics/cycletime_config.rb +1 -1
  12. data/lib/jirametrics/cycletime_histogram.rb +65 -2
  13. data/lib/jirametrics/daily_view.rb +277 -0
  14. data/lib/jirametrics/data_quality_report.rb +1 -1
  15. data/lib/jirametrics/downloader.rb +11 -14
  16. data/lib/jirametrics/estimate_accuracy_chart.rb +34 -10
  17. data/lib/jirametrics/estimation_configuration.rb +25 -0
  18. data/lib/jirametrics/examples/standard_project.rb +2 -0
  19. data/lib/jirametrics/exporter.rb +10 -8
  20. data/lib/jirametrics/file_config.rb +10 -5
  21. data/lib/jirametrics/file_system.rb +4 -0
  22. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  23. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
  24. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
  25. data/lib/jirametrics/html/aging_work_table.erb +7 -3
  26. data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
  27. data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
  28. data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
  29. data/lib/jirametrics/html/expedited_chart.erb +1 -10
  30. data/lib/jirametrics/html/index.css +82 -2
  31. data/lib/jirametrics/html/index.erb +25 -1
  32. data/lib/jirametrics/html/sprint_burndown.erb +1 -10
  33. data/lib/jirametrics/html/throughput_chart.erb +1 -10
  34. data/lib/jirametrics/html_report_config.rb +2 -0
  35. data/lib/jirametrics/issue.rb +68 -27
  36. data/lib/jirametrics/issue_collection.rb +33 -0
  37. data/lib/jirametrics/jira_gateway.rb +20 -4
  38. data/lib/jirametrics/project_config.rb +25 -8
  39. data/lib/jirametrics/settings.json +2 -1
  40. data/lib/jirametrics/sprint.rb +1 -0
  41. data/lib/jirametrics/sprint_burndown.rb +35 -33
  42. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  43. data/lib/jirametrics/status.rb +3 -6
  44. data/lib/jirametrics/status_collection.rb +6 -0
  45. data/lib/jirametrics/user.rb +12 -0
  46. data/lib/jirametrics.rb +4 -0
  47. metadata +7 -2
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ChangeItem
4
- attr_reader :field, :value_id, :old_value_id, :raw, :author, :time
4
+ attr_reader :field, :value_id, :old_value_id, :raw, :time, :author_raw
5
5
  attr_accessor :value, :old_value
6
6
 
7
- def initialize raw:, time:, author:, artificial: false
7
+ def initialize raw:, author_raw:, time:, artificial: false
8
8
  @raw = raw
9
+ @author_raw = author_raw
9
10
  @time = time
10
11
  raise 'ChangeItem.new() time cannot be nil' if time.nil?
11
12
  raise "Time must be an object of type Time in the correct timezone: #{@time.inspect}" unless @time.is_a? Time
@@ -16,33 +17,42 @@ class ChangeItem
16
17
  @old_value = @raw['fromString']
17
18
  @old_value_id = @raw['from']&.to_i
18
19
  @artificial = artificial
19
- @author = author
20
20
  end
21
21
 
22
- def status? = (field == 'status')
22
+ def author
23
+ @author_raw&.[]('displayName') || @author_raw&.[]('name') || 'Unknown author'
24
+ end
23
25
 
24
- def flagged? = (field == 'Flagged')
26
+ def author_icon_url
27
+ @author_raw&.[]('avatarUrls')&.[]('16x16')
28
+ end
25
29
 
30
+ def artificial? = @artificial
31
+ def assignee? = (field == 'assignee')
32
+ def comment? = (field == 'comment')
33
+ def due_date? = (field == 'duedate')
34
+ def flagged? = (field == 'Flagged')
35
+ def issue_type? = field == 'issuetype'
36
+ def labels? = (field == 'labels')
37
+ def link? = (field == 'Link')
26
38
  def priority? = (field == 'priority')
27
-
28
39
  def resolution? = (field == 'resolution')
29
-
30
- def artificial? = @artificial
31
-
32
40
  def sprint? = (field == 'Sprint')
33
-
34
- def story_points? = (field == 'Story Points')
35
-
36
- def link? = (field == 'Link')
37
-
38
- def labels? = (field == 'labels')
41
+ def status? = (field == 'status')
39
42
 
40
43
  # An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
41
44
  def to_time = @time
42
45
 
43
46
  def to_s
44
47
  message = +''
45
- message << "ChangeItem(field: #{field.inspect}, value: #{value.inspect}, time: #{time_to_s(@time).inspect}"
48
+ message << "ChangeItem(field: #{field.inspect}"
49
+ message << ", value: #{value.inspect}"
50
+ message << ':' << value_id.inspect if status?
51
+ if old_value
52
+ message << ", old_value: #{old_value.inspect}"
53
+ message << ':' << old_value_id.inspect if status?
54
+ end
55
+ message << ", time: #{time_to_s(@time).inspect}"
46
56
  message << ', artificial' if artificial?
47
57
  message << ')'
48
58
  message
@@ -84,6 +94,17 @@ class ChangeItem
84
94
  end
85
95
  end
86
96
 
97
+ def field_as_human_readable
98
+ case @field
99
+ when 'duedate' then 'Due date'
100
+ when 'timeestimate' then 'Time estimate'
101
+ when 'timeoriginalestimate' then 'Time original estimate'
102
+ when 'issuetype' then 'Issue type'
103
+ when 'IssueParentAssociation' then 'Issue parent association'
104
+ else @field.capitalize
105
+ end
106
+ end
107
+
87
108
  private
88
109
 
89
110
  def time_to_s time
@@ -2,7 +2,7 @@
2
2
 
3
3
  class ChartBase
4
4
  attr_accessor :timezone_offset, :board_id, :all_boards, :date_range,
5
- :time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system
5
+ :time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system, :users
6
6
  attr_writer :aggregated_project
7
7
  attr_reader :canvas_width, :canvas_height
8
8
 
@@ -38,7 +38,6 @@ class ChartBase
38
38
  # Insert a incrementing chart_id so that all the chart names on the page are unique
39
39
  caller_binding.eval "chart_id='chart#{next_id}'" # chart_id=chart3
40
40
 
41
- # @html_directory = "#{pathname.dirname}/html"
42
41
  erb = ERB.new file_system.load "#{html_directory}/#{$1}.erb"
43
42
  erb.result(caller_binding)
44
43
  end
@@ -130,6 +129,21 @@ class ChartBase
130
129
  result
131
130
  end
132
131
 
132
+ def working_days_annotation
133
+ holidays.each_with_index.collect do |range, index|
134
+ <<~TEXT
135
+ holiday#{index}: {
136
+ drawTime: 'beforeDraw',
137
+ type: 'box',
138
+ xMin: '#{range.begin}T00:00:00',
139
+ xMax: '#{range.end}T23:59:59',
140
+ backgroundColor: #{CssVariable.new('--non-working-days-color').to_json},
141
+ borderColor: #{CssVariable.new('--non-working-days-color').to_json}
142
+ },
143
+ TEXT
144
+ end.join
145
+ end
146
+
133
147
  # Return only the board columns for the current board.
134
148
  def current_board
135
149
  if @board_id.nil?
@@ -212,8 +226,8 @@ class ChartBase
212
226
  icon: ' 👀'
213
227
  )
214
228
  end
215
- text = is_category ? status.category.name : status.name
216
- "<span title='Category: #{status.category.name}'>#{color_block color.name} #{text}</span>#{visibility}"
229
+ text = is_category ? status.category : status
230
+ "<span title='Category: #{status.category}'>#{color_block color.name} #{text}</span>#{visibility}"
217
231
  end
218
232
 
219
233
  def icon_span title:, icon:
@@ -245,7 +259,10 @@ class ChartBase
245
259
 
246
260
  def color_block color, title: nil
247
261
  result = +''
248
- result << "<div class='color_block' style='background: var(#{color});'"
262
+ result << "<div class='color_block' style='"
263
+ result << "background: #{CssVariable[color]};" if color
264
+ result << 'visibility: hidden;' unless color
265
+ result << "'"
249
266
  result << " title=#{title.inspect}" if title
250
267
  result << '></div>'
251
268
  result
@@ -4,7 +4,7 @@ class CssVariable
4
4
  attr_reader :name
5
5
 
6
6
  def self.[](name)
7
- if name.start_with? '--'
7
+ if name.is_a?(String) && name.start_with?('--')
8
8
  CssVariable.new name
9
9
  else
10
10
  name
@@ -59,7 +59,7 @@ class CycleTimeConfig
59
59
  'from' => '0',
60
60
  'fromString' => ''
61
61
  }
62
- ChangeItem.new raw: raw, time: time, author: 'unknown', artificial: true
62
+ ChangeItem.new raw: raw, time: time, artificial: true, author_raw: nil
63
63
  end
64
64
 
65
65
  def started_stopped_changes issue
@@ -5,10 +5,14 @@ require 'jirametrics/groupable_issue_chart'
5
5
  class CycletimeHistogram < ChartBase
6
6
  include GroupableIssueChart
7
7
  attr_accessor :possible_statuses
8
+ attr_reader :show_stats
8
9
 
9
10
  def initialize block
10
11
  super()
11
12
 
13
+ percentiles [50, 85, 98]
14
+ @show_stats = true
15
+
12
16
  header_text 'Cycletime Histogram'
13
17
  description_text <<-HTML
14
18
  <p>
@@ -26,6 +30,15 @@ class CycletimeHistogram < ChartBase
26
30
  end
27
31
  end
28
32
 
33
+ def percentiles percs = nil
34
+ @percentiles = percs unless percs.nil?
35
+ @percentiles
36
+ end
37
+
38
+ def disable_stats
39
+ @show_stats = false
40
+ end
41
+
29
42
  def run
30
43
  stopped_issues = completed_issues_in_range include_unstarted: true
31
44
 
@@ -33,10 +46,18 @@ class CycletimeHistogram < ChartBase
33
46
  histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
34
47
  rules_to_issues = group_issues histogram_issues
35
48
 
49
+ the_stats = {}
50
+
51
+ overall_stats = stats_for histogram_data: histogram_data_for(issues: histogram_issues), percentiles: @percentiles
52
+ the_stats[:all] = overall_stats
36
53
  data_sets = rules_to_issues.keys.collect do |rules|
54
+ the_issue_type = rules.label
55
+ the_histogram = histogram_data_for(issues: rules_to_issues[rules])
56
+ the_stats[the_issue_type] = stats_for histogram_data: the_histogram, percentiles: @percentiles if @show_stats
57
+
37
58
  data_set_for(
38
- histogram_data: histogram_data_for(issues: rules_to_issues[rules]),
39
- label: rules.label,
59
+ histogram_data: the_histogram,
60
+ label: the_issue_type,
40
61
  color: rules.color
41
62
  )
42
63
  end
@@ -55,6 +76,48 @@ class CycletimeHistogram < ChartBase
55
76
  count_hash
56
77
  end
57
78
 
79
+ def stats_for histogram_data:, percentiles:
80
+ return {} if histogram_data.empty?
81
+
82
+ total_values = histogram_data.values.sum
83
+
84
+ # Calculate the average
85
+ weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
86
+ average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
87
+
88
+ # Find the mode (or modes!) and the spread of the distribution
89
+ sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
90
+ max_freq = sorted_histogram[-1][1]
91
+ mode = sorted_histogram.select { |_v, f| f == max_freq }
92
+
93
+ minmax = histogram_data.keys.minmax
94
+
95
+ # Calculate percentiles
96
+ sorted_values = histogram_data.keys.sort
97
+ cumulative_counts = {}
98
+ cumulative_sum = 0
99
+
100
+ sorted_values.each do |value|
101
+ cumulative_sum += histogram_data[value]
102
+ cumulative_counts[value] = cumulative_sum
103
+ end
104
+
105
+ percentile_results = {}
106
+ percentiles.each do |percentile|
107
+ rank = (percentile / 100.0) * total_values
108
+ percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
109
+ percentile_results[percentile] = percentile_value
110
+ end
111
+
112
+ {
113
+ average: average,
114
+ mode: mode.collect(&:first).sort,
115
+ min: minmax[0],
116
+ max: minmax[1],
117
+ percentiles: percentile_results
118
+ }
119
+ end
120
+
58
121
  def data_set_for histogram_data:, label:, color:
59
122
  keys = histogram_data.keys.sort
60
123
  {
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DailyView < ChartBase
4
+ attr_accessor :possible_statuses
5
+
6
+ def initialize _block
7
+ super()
8
+
9
+ header_text 'Daily View'
10
+ description_text <<-HTML
11
+ <div class="p">
12
+ This view shows all the items you'll want to discuss during your daily coordination meeting
13
+ (aka daily scrum, standup), in the order that you should be discussing them. The most important
14
+ items are at the top, and the least at the bottom.
15
+ </div>
16
+ <div class="p">
17
+ By default, we sort by priority first and then by age within each of those priorities.
18
+ Hover over the issue to make it stand out more.
19
+ </div>
20
+ HTML
21
+ end
22
+
23
+ def run
24
+ aging_issues = select_aging_issues
25
+
26
+ return "<h1>#{@header_text}</h1>There are no items currently in progress" if aging_issues.empty?
27
+
28
+ result = +''
29
+ result << render_top_text(binding)
30
+ aging_issues.each do |issue|
31
+ result << render_issue(issue, child: false)
32
+ end
33
+ result
34
+ end
35
+
36
+ def select_aging_issues
37
+ aging_issues = issues.select do |issue|
38
+ started_at, stopped_at = issue.board.cycletime.started_stopped_times(issue)
39
+ started_at && !stopped_at
40
+ end
41
+
42
+ today = date_range.end
43
+ aging_issues.collect do |issue|
44
+ [issue, issue.priority_name, issue.board.cycletime.age(issue, today: today)]
45
+ end.sort(&issue_sorter).collect(&:first)
46
+ end
47
+
48
+ def issue_sorter
49
+ priority_names = settings['priority_order']
50
+ lambda do |a, b|
51
+ a_issue, a_priority, a_age = *a
52
+ b_issue, b_priority, b_age = *b
53
+
54
+ a_priority_index = priority_names.index(a_priority)
55
+ b_priority_index = priority_names.index(b_priority)
56
+
57
+ if a_priority_index.nil? && b_priority_index.nil?
58
+ result = a_priority <=> b_priority
59
+ elsif a_priority_index.nil?
60
+ result = 1
61
+ elsif b_priority_index.nil?
62
+ result = -1
63
+ else
64
+ result = b_priority_index <=> a_priority_index
65
+ end
66
+
67
+ result = b_age <=> a_age if result.zero?
68
+ result = a_issue <=> b_issue if result.zero?
69
+ result
70
+ end
71
+ end
72
+
73
+ def make_blocked_stalled_lines issue
74
+ today = date_range.end
75
+ started_date = issue.board.cycletime.started_stopped_times(issue).first&.to_date
76
+ return [] unless started_date
77
+
78
+ blocked_stalled = issue.blocked_stalled_by_date(
79
+ date_range: today..today, chart_end_time: time_range.end, settings: settings
80
+ )[today]
81
+ return [] unless blocked_stalled
82
+
83
+ lines = []
84
+ if blocked_stalled.blocked?
85
+ marker = color_block '--blocked-color'
86
+ lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
87
+ lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
88
+ blocked_stalled.blocking_issue_keys&.each do |key|
89
+ lines << ["#{marker} Blocked by issue: #{key}"]
90
+ blocking_issue = issues.find { |i| i.key == key }
91
+ lines << blocking_issue if blocking_issue
92
+ end
93
+ elsif blocked_stalled.stalled_by_status?
94
+ lines << ["#{color_block '--stalled-color'} Stalled by status: #{blocked_stalled.status}"]
95
+ else
96
+ lines << ["#{color_block '--stalled-color'} Stalled by inactivity: #{blocked_stalled.stalled_days} days"]
97
+ end
98
+ lines
99
+ end
100
+
101
+ def make_issue_label issue
102
+ "<img src='#{issue.type_icon_url}' title='#{issue.type}' class='icon' /> " \
103
+ "<b><a href='#{issue.url}'>#{issue.key}</a></b> &nbsp;<i>#{issue.summary}</i>"
104
+ end
105
+
106
+ def make_title_line issue
107
+ title_line = +''
108
+ title_line << color_block('--expedited-color', title: 'Expedited') if issue.expedited?
109
+ title_line << make_issue_label(issue)
110
+ title_line
111
+ end
112
+
113
+ def make_parent_lines issue
114
+ lines = []
115
+ parent_key = issue.parent_key
116
+ if parent_key
117
+ parent = issues.find_by_key key: parent_key, include_hidden: true
118
+ text = parent ? make_issue_label(parent) : parent_key
119
+ lines << ["Parent: #{text}"]
120
+ end
121
+ lines
122
+ end
123
+
124
+ def make_stats_lines issue
125
+ line = []
126
+
127
+ line << "<img src='#{issue.priority_url}' class='icon' /> <b>#{issue.priority_name}</b>"
128
+
129
+ age = issue.board.cycletime.age(issue, today: date_range.end)
130
+ line << "Age: <b>#{age ? label_days(age) : '(Not Started)'}</b>"
131
+
132
+ line << "Status: <b>#{format_status issue.status, board: issue.board}</b>"
133
+
134
+ column = issue.board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
135
+ line << "Column: <b>#{column&.name || '(not visible on board)'}</b>"
136
+
137
+ if issue.assigned_to
138
+ line << "Assignee: <img src='#{issue.assigned_to_icon_url}' class='icon' /> <b>#{issue.assigned_to}</b>"
139
+ end
140
+
141
+ line << "Due: <b>#{issue.due_date}</b>" if issue.due_date
142
+
143
+ block = lambda do |collection, label|
144
+ unless collection.empty?
145
+ text = collection.collect { |l| "<span class='label'>#{l}</span>" }.join(' ')
146
+ line << "#{label} #{text}"
147
+ end
148
+ end
149
+ block.call issue.labels, 'Labels:'
150
+ block.call issue.component_names, 'Components:'
151
+
152
+ [line]
153
+ end
154
+
155
+ def make_child_lines issue
156
+ lines = []
157
+ subtasks = issue.subtasks.reject { |i| i.done? }
158
+
159
+ unless subtasks.empty?
160
+ icon_urls = subtasks.collect(&:type_icon_url).uniq.collect { |url| "<img src='#{url}' class='icon' />" }
161
+ lines << (icon_urls << 'Incomplete child issues')
162
+ lines += subtasks
163
+ end
164
+ lines
165
+ end
166
+
167
+ def jira_rich_text_to_html text
168
+ text
169
+ .gsub(/{color:(#\w{6})}([^{]+){color}/, '<span style="color: \1">\2</span>') # Colours
170
+ .gsub(/\[~accountid:([^\]]+)\]/) { expand_account_id $1 } # Tagged people
171
+ .gsub(/\[([^\|]+)\|(https?[^\]]+)\]/, '<a href="\2">\1</a>') # URLs
172
+ .gsub("\n", '<br />')
173
+ end
174
+
175
+ def expand_account_id account_id
176
+ user = @users.find { |u| u.account_id == account_id }
177
+ text = account_id
178
+ text = "@#{user.display_name}" if user
179
+ "<span class='account_id'>#{text}</span>"
180
+ end
181
+
182
+ def make_history_lines issue
183
+ history = issue.changes.reverse
184
+ lines = []
185
+
186
+ id = next_id
187
+ lines << [
188
+ "<a href=\"javascript:toggle_visibility('open#{id}', 'close#{id}', 'table#{id}');\">" \
189
+ "<span id='open#{id}'>▶ Issue History</span>" \
190
+ "<span id='close#{id}' style='display: none'>▼ Issue History</span></a>"
191
+ ]
192
+ table = +''
193
+ table << "<table id='table#{id}' style='display: none'>"
194
+ history.each do |c|
195
+ time = c.time.strftime '%b %d, %I:%M%P'
196
+
197
+ table << '<tr>'
198
+ table << "<td><span class='time' title='Timestamp: #{c.time}'>#{time}</span></td>"
199
+ table << "<td><img src='#{c.author_icon_url}' class='icon' title='#{c.author}' /></td>"
200
+ text = history_text change: c, board: issue.board
201
+ table << "<td><span class='field'>#{c.field_as_human_readable}</span> #{text}</td>"
202
+ table << '</tr>'
203
+ end
204
+ table << '</table>'
205
+ lines << [table]
206
+ lines
207
+ end
208
+
209
+ def history_text change:, board:
210
+ if change.comment?
211
+ jira_rich_text_to_html(change.value)
212
+ elsif change.status?
213
+ convertor = ->(id) { format_status(board.possible_statuses.find_by_id(id), board: board) }
214
+ to = convertor.call(change.value_id)
215
+ if change.old_value
216
+ from = convertor.call(change.old_value_id)
217
+ "Changed from #{from} to #{to}"
218
+ else
219
+ "Set to #{to}"
220
+ end
221
+ elsif %w[priority assignee duedate issuetype].include?(change.field)
222
+ "Changed from \"#{change.old_value}\" to \"#{change.value}\""
223
+ elsif change.flagged?
224
+ change.value == '' ? 'Off' : 'On'
225
+ else
226
+ change.value
227
+ end
228
+ end
229
+
230
+ def make_sprints_lines issue
231
+ return [] unless issue.board.scrum?
232
+
233
+ sprint_names = issue.sprints.collect do |sprint|
234
+ if sprint.closed?
235
+ "<s>#{sprint.name}</s>"
236
+ else
237
+ sprint.name
238
+ end
239
+ end
240
+
241
+ return [['Sprints: NONE']] if sprint_names.empty?
242
+
243
+ [[+'Sprints: ' << sprint_names
244
+ .collect { |name| "<span class='label'>#{name}</span>" }
245
+ .join(' ')]]
246
+ end
247
+
248
+ def assemble_issue_lines issue, child:
249
+ lines = []
250
+ lines << [make_title_line(issue)]
251
+ lines += make_parent_lines(issue) unless child
252
+ lines += make_stats_lines(issue)
253
+ lines += make_sprints_lines(issue)
254
+ lines += make_blocked_stalled_lines(issue)
255
+ lines += make_child_lines(issue)
256
+ lines += make_history_lines(issue)
257
+ lines
258
+ end
259
+
260
+ def render_issue issue, child:
261
+ css_class = child ? 'child_issue' : 'daily_issue'
262
+ result = +''
263
+ result << "<div class='#{css_class}'>"
264
+ assemble_issue_lines(issue, child: child).each do |row|
265
+ if row.is_a? Issue
266
+ result << render_issue(row, child: true)
267
+ else
268
+ result << '<div class="heading">'
269
+ row.each do |chunk|
270
+ result << "<div>#{chunk}</div>"
271
+ end
272
+ result << '</div>'
273
+ end
274
+ end
275
+ result << '</div>'
276
+ end
277
+ end
@@ -272,7 +272,7 @@ class DataQualityReport < ChartBase
272
272
 
273
273
  entry.report(
274
274
  problem_key: :items_blocked_on_closed_tickets,
275
- detail: "#{entry.issue.key} thinks it's blocked on #{link.other_issue.key}, " \
275
+ detail: "#{entry.issue.key} thinks it's blocked by #{link.other_issue.key}, " \
276
276
  "except #{link.other_issue.key} is closed."
277
277
  )
278
278
  end
@@ -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
@@ -103,8 +104,6 @@ class Downloader
103
104
  json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
104
105
  "?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
105
106
 
106
- exit_if_call_failed json
107
-
108
107
  json['issues'].each do |issue_json|
109
108
  issue_json['exporter'] = {
110
109
  'in_initial_query' => initial_query
@@ -139,15 +138,6 @@ class Downloader
139
138
  end
140
139
  end
141
140
 
142
- def exit_if_call_failed json
143
- # Sometimes Jira returns the singular form of errorMessage and sometimes the plural. Consistency FTW.
144
- return unless json['error'] || json['errorMessages'] || json['errorMessage']
145
-
146
- log "Download failed. See #{@file_system.logfile_name} for details.", both: true
147
- log " #{JSON.pretty_generate(json)}"
148
- exit 1
149
- end
150
-
151
141
  def download_statuses
152
142
  log ' Downloading all statuses', both: true
153
143
  json = @jira_gateway.call_url relative_url: '/rest/api/2/status'
@@ -158,6 +148,16 @@ class Downloader
158
148
  )
159
149
  end
160
150
 
151
+ def download_users
152
+ log ' Downloading all users', both: true
153
+ json = @jira_gateway.call_url relative_url: '/rest/api/2/users'
154
+
155
+ @file_system.save_json(
156
+ json: json,
157
+ filename: File.join(@target_path, "#{file_prefix}_users.json")
158
+ )
159
+ end
160
+
161
161
  def update_status_history_file
162
162
  status_filename = File.join(@target_path, "#{file_prefix}_statuses.json")
163
163
  return unless file_system.file_exist? status_filename
@@ -188,8 +188,6 @@ class Downloader
188
188
  log " Downloading board configuration for board #{board_id}", both: true
189
189
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
190
190
 
191
- exit_if_call_failed json
192
-
193
191
  @file_system.save_json(
194
192
  json: json,
195
193
  filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_configuration.json")
@@ -213,7 +211,6 @@ class Downloader
213
211
  while is_last == false
214
212
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/sprint?" \
215
213
  "maxResults=#{max_results}&startAt=#{start_at}"
216
- exit_if_call_failed json
217
214
 
218
215
  @file_system.save_json(
219
216
  json: json,