jirametrics 2.0 → 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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +19 -26
  3. data/lib/jirametrics/aging_work_bar_chart.rb +79 -54
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +106 -40
  5. data/lib/jirametrics/aging_work_table.rb +84 -54
  6. data/lib/jirametrics/anonymizer.rb +6 -5
  7. data/lib/jirametrics/blocked_stalled_change.rb +24 -4
  8. data/lib/jirametrics/board.rb +51 -23
  9. data/lib/jirametrics/board_config.rb +9 -4
  10. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  11. data/lib/jirametrics/change_item.rb +56 -21
  12. data/lib/jirametrics/chart_base.rb +101 -61
  13. data/lib/jirametrics/columns_config.rb +4 -0
  14. data/lib/jirametrics/css_variable.rb +33 -0
  15. data/lib/jirametrics/cycletime_config.rb +59 -8
  16. data/lib/jirametrics/cycletime_histogram.rb +69 -4
  17. data/lib/jirametrics/cycletime_scatterplot.rb +11 -15
  18. data/lib/jirametrics/daily_view.rb +277 -0
  19. data/lib/jirametrics/daily_wip_by_age_chart.rb +44 -20
  20. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +37 -35
  21. data/lib/jirametrics/daily_wip_by_parent_chart.rb +38 -0
  22. data/lib/jirametrics/daily_wip_chart.rb +61 -14
  23. data/lib/jirametrics/data_quality_report.rb +222 -41
  24. data/lib/jirametrics/dependency_chart.rb +54 -23
  25. data/lib/jirametrics/download_config.rb +12 -0
  26. data/lib/jirametrics/downloader.rb +86 -56
  27. data/lib/jirametrics/estimate_accuracy_chart.rb +173 -0
  28. data/lib/jirametrics/estimation_configuration.rb +25 -0
  29. data/lib/jirametrics/examples/aggregated_project.rb +22 -39
  30. data/lib/jirametrics/examples/standard_project.rb +26 -48
  31. data/lib/jirametrics/expedited_chart.rb +28 -25
  32. data/lib/jirametrics/exporter.rb +59 -32
  33. data/lib/jirametrics/file_config.rb +35 -14
  34. data/lib/jirametrics/file_system.rb +48 -3
  35. data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
  36. data/lib/jirametrics/groupable_issue_chart.rb +2 -6
  37. data/lib/jirametrics/grouping_rules.rb +7 -1
  38. data/lib/jirametrics/hierarchy_table.rb +4 -4
  39. data/lib/jirametrics/html/aging_work_bar_chart.erb +13 -16
  40. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +28 -5
  41. data/lib/jirametrics/html/aging_work_table.erb +21 -25
  42. data/lib/jirametrics/html/cycletime_histogram.erb +83 -3
  43. data/lib/jirametrics/html/cycletime_scatterplot.erb +9 -12
  44. data/lib/jirametrics/html/daily_wip_chart.erb +17 -13
  45. data/lib/jirametrics/html/{story_point_accuracy_chart.erb → estimate_accuracy_chart.erb} +9 -4
  46. data/lib/jirametrics/html/expedited_chart.erb +10 -13
  47. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
  48. data/lib/jirametrics/html/hierarchy_table.erb +2 -2
  49. data/lib/jirametrics/html/index.css +280 -0
  50. data/lib/jirametrics/html/index.erb +33 -39
  51. data/lib/jirametrics/html/sprint_burndown.erb +10 -14
  52. data/lib/jirametrics/html/throughput_chart.erb +10 -13
  53. data/lib/jirametrics/html_report_config.rb +110 -86
  54. data/lib/jirametrics/issue.rb +390 -109
  55. data/lib/jirametrics/issue_collection.rb +33 -0
  56. data/lib/jirametrics/jira_gateway.rb +33 -12
  57. data/lib/jirametrics/project_config.rb +276 -147
  58. data/lib/jirametrics/rules.rb +2 -2
  59. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  60. data/lib/jirametrics/settings.json +11 -0
  61. data/lib/jirametrics/sprint.rb +1 -0
  62. data/lib/jirametrics/sprint_burndown.rb +59 -40
  63. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  64. data/lib/jirametrics/status.rb +84 -19
  65. data/lib/jirametrics/status_collection.rb +86 -39
  66. data/lib/jirametrics/throughput_chart.rb +12 -4
  67. data/lib/jirametrics/user.rb +12 -0
  68. data/lib/jirametrics/value_equality.rb +2 -2
  69. data/lib/jirametrics.rb +29 -7
  70. metadata +20 -17
  71. data/lib/jirametrics/discard_changes_before.rb +0 -37
  72. data/lib/jirametrics/experimental/generator.rb +0 -210
  73. data/lib/jirametrics/experimental/info.rb +0 -77
  74. data/lib/jirametrics/html/data_quality_report.erb +0 -126
  75. data/lib/jirametrics/story_point_accuracy_chart.rb +0 -134
@@ -6,11 +6,11 @@ require 'json'
6
6
  class Downloader
7
7
  CURRENT_METADATA_VERSION = 4
8
8
 
9
- attr_accessor :metadata, :quiet_mode
9
+ attr_accessor :metadata
10
10
  attr_reader :file_system
11
11
 
12
12
  # For testing only
13
- attr_reader :start_date_in_query
13
+ attr_reader :start_date_in_query, :board_id_to_filter_id
14
14
 
15
15
  def initialize download_config:, file_system:, jira_gateway:
16
16
  @metadata = {}
@@ -39,11 +39,13 @@ class Downloader
39
39
  # board_ids = @download_config.board_ids
40
40
 
41
41
  remove_old_files
42
+ update_status_history_file
42
43
  download_statuses
43
44
  find_board_ids.each do |id|
44
- download_board_configuration board_id: id
45
- download_issues board_id: id
45
+ board = download_board_configuration board_id: id
46
+ download_issues board: board
46
47
  end
48
+ download_users
47
49
 
48
50
  save_metadata
49
51
  end
@@ -54,8 +56,7 @@ class Downloader
54
56
  end
55
57
 
56
58
  def log text, both: false
57
- @file_system.log text
58
- puts text if both
59
+ @file_system.log text, also_write_to_stderr: both
59
60
  end
60
61
 
61
62
  def find_board_ids
@@ -65,19 +66,19 @@ class Downloader
65
66
  ids
66
67
  end
67
68
 
68
- def download_issues board_id:
69
- log " Downloading primary issues for board #{board_id}", both: true
70
- path = "#{@target_path}#{@download_config.project_config.file_prefix}_issues/"
69
+ def download_issues board:
70
+ log " Downloading primary issues for board #{board.id}", both: true
71
+ path = File.join(@target_path, "#{file_prefix}_issues/")
71
72
  unless Dir.exist?(path)
72
73
  log " Creating path #{path}"
73
74
  Dir.mkdir(path)
74
75
  end
75
76
 
76
- filter_id = @board_id_to_filter_id[board_id]
77
+ filter_id = @board_id_to_filter_id[board.id]
77
78
  jql = make_jql(filter_id: filter_id)
78
- jira_search_by_jql(jql: jql, initial_query: true, board_id: board_id, path: path)
79
+ jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
79
80
 
80
- log " Downloading linked issues for board #{board_id}", both: true
81
+ log " Downloading linked issues for board #{board.id}", both: true
81
82
  loop do
82
83
  @issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
83
84
  break if @issue_keys_pending_download.empty?
@@ -85,15 +86,15 @@ class Downloader
85
86
  keys_to_request = @issue_keys_pending_download[0..99]
86
87
  @issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
87
88
  jql = "key in (#{keys_to_request.join(', ')})"
88
- jira_search_by_jql(jql: jql, initial_query: false, board_id: board_id, path: path)
89
+ jira_search_by_jql(jql: jql, initial_query: false, board: board, path: path)
89
90
  end
90
91
  end
91
92
 
92
- def jira_search_by_jql jql:, initial_query:, board_id:, path:
93
+ def jira_search_by_jql jql:, initial_query:, board:, path:
93
94
  intercept_jql = @download_config.project_config.settings['intercept_jql']
94
95
  jql = intercept_jql.call jql if intercept_jql
95
96
 
96
- log " #{jql}"
97
+ log " JQL: #{jql}"
97
98
  escaped_jql = CGI.escape jql
98
99
 
99
100
  max_results = 100
@@ -103,14 +104,12 @@ 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
111
110
  }
112
- identify_other_issues_to_be_downloaded issue_json
113
- file = "#{issue_json['key']}-#{board_id}.json"
111
+ identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
112
+ file = "#{issue_json['key']}-#{board.id}.json"
114
113
 
115
114
  @file_system.save_json(json: issue_json, filename: File.join(path, file))
116
115
  end
@@ -125,8 +124,8 @@ class Downloader
125
124
  end
126
125
  end
127
126
 
128
- def identify_other_issues_to_be_downloaded raw_issue
129
- issue = Issue.new raw: raw_issue, board: nil
127
+ def identify_other_issues_to_be_downloaded raw_issue:, board:
128
+ issue = Issue.new raw: raw_issue, board: board
130
129
  @issue_keys_downloaded_in_current_run << issue.key
131
130
 
132
131
  # Parent
@@ -137,51 +136,74 @@ class Downloader
137
136
  issue.raw['fields']['subtasks']&.each do |raw_subtask|
138
137
  @issue_keys_pending_download << raw_subtask['key']
139
138
  end
140
-
141
- # Links
142
- # We shouldn't blindly follow links as some, like cloners, aren't valuable and are just wasting time/effort
143
- # to download
144
- # issue.raw['fields']['issuelinks'].each do |raw_link|
145
- # @issue_keys_pending_download << IssueLink(raw: raw_link).other_issue.key
146
- # end
147
139
  end
148
140
 
149
- def exit_if_call_failed json
150
- # Sometimes Jira returns the singular form of errorMessage and sometimes the plural. Consistency FTW.
151
- return unless json['errorMessages'] || json['errorMessage']
141
+ def download_statuses
142
+ log ' Downloading all statuses', both: true
143
+ json = @jira_gateway.call_url relative_url: '/rest/api/2/status'
152
144
 
153
- log "Download failed. See #{@logfile_name} for details.", both: true
154
- log " #{JSON.pretty_generate(json)}"
155
- exit 1
145
+ @file_system.save_json(
146
+ json: json,
147
+ filename: File.join(@target_path, "#{file_prefix}_statuses.json")
148
+ )
156
149
  end
157
150
 
158
- def download_statuses
159
- log ' Downloading all statuses', both: true
160
- json = @jira_gateway.call_url relative_url: "/rest/api/2/status"
151
+ def download_users
152
+ log ' Downloading all users', both: true
153
+ json = @jira_gateway.call_url relative_url: '/rest/api/2/users'
161
154
 
162
155
  @file_system.save_json(
163
- json: json,
164
- filename: "#{@target_path}#{@download_config.project_config.file_prefix}_statuses.json"
156
+ json: json,
157
+ filename: File.join(@target_path, "#{file_prefix}_users.json")
165
158
  )
166
159
  end
167
160
 
161
+ def update_status_history_file
162
+ status_filename = File.join(@target_path, "#{file_prefix}_statuses.json")
163
+ return unless file_system.file_exist? status_filename
164
+
165
+ status_json = file_system.load_json(status_filename)
166
+
167
+ history_filename = File.join(@target_path, "#{file_prefix}_status_history.json")
168
+ history_json = file_system.load_json(history_filename) if file_system.file_exist? history_filename
169
+
170
+ if history_json
171
+ file_system.log ' Updating status history file', also_write_to_stderr: true
172
+ else
173
+ file_system.log ' Creating status history file', also_write_to_stderr: true
174
+ history_json = []
175
+ end
176
+
177
+ status_json.each do |status_item|
178
+ id = status_item['id']
179
+ history_item = history_json.find { |s| s['id'] == id }
180
+ history_json.delete(history_item) if history_item
181
+ history_json << status_item
182
+ end
183
+
184
+ file_system.save_json(filename: history_filename, json: history_json)
185
+ end
186
+
168
187
  def download_board_configuration board_id:
169
188
  log " Downloading board configuration for board #{board_id}", both: true
170
189
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
171
- exit_if_call_failed json
172
190
 
173
- @board_id_to_filter_id[board_id] = json['filter']['id'].to_i
174
- # @board_configuration = json if @download_config.board_ids.size == 1
191
+ @file_system.save_json(
192
+ json: json,
193
+ filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_configuration.json")
194
+ )
175
195
 
176
- file_prefix = @download_config.project_config.file_prefix
177
- @file_system.save_json json: json, filename: "#{@target_path}#{file_prefix}_board_#{board_id}_configuration.json"
196
+ # We have a reported bug that blew up on this line. Moved it after the save so we can
197
+ # actually look at the returned json.
198
+ @board_id_to_filter_id[board_id] = json['filter']['id'].to_i
178
199
 
179
200
  download_sprints board_id: board_id if json['type'] == 'scrum'
201
+ # TODO: Should be passing actual statuses, not empty list
202
+ Board.new raw: json, possible_statuses: StatusCollection.new
180
203
  end
181
204
 
182
205
  def download_sprints board_id:
183
206
  log " Downloading sprints for board #{board_id}", both: true
184
- file_prefix = @download_config.project_config.file_prefix
185
207
  max_results = 100
186
208
  start_at = 0
187
209
  is_last = false
@@ -189,20 +211,23 @@ class Downloader
189
211
  while is_last == false
190
212
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/sprint?" \
191
213
  "maxResults=#{max_results}&startAt=#{start_at}"
192
- exit_if_call_failed json
193
214
 
194
215
  @file_system.save_json(
195
216
  json: json,
196
- filename: "#{@target_path}#{file_prefix}_board_#{board_id}_sprints_#{start_at}.json"
217
+ filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_sprints_#{start_at}.json")
197
218
  )
198
219
  is_last = json['isLast']
199
220
  max_results = json['maxResults']
200
- start_at += json['values'].size
221
+ if json['values']
222
+ start_at += json['values'].size
223
+ else
224
+ log " No sprints found for board #{board_id}"
225
+ end
201
226
  end
202
227
  end
203
228
 
204
229
  def metadata_pathname
205
- "#{@target_path}#{@download_config.project_config.file_prefix}_meta.json"
230
+ File.join(@target_path, "#{file_prefix}_meta.json")
206
231
  end
207
232
 
208
233
  def load_metadata
@@ -245,17 +270,17 @@ class Downloader
245
270
  end
246
271
 
247
272
  def remove_old_files
248
- file_prefix = @download_config.project_config.file_prefix
249
273
  Dir.foreach @target_path do |file|
250
274
  next unless file.match?(/^#{file_prefix}_\d+\.json$/)
275
+ next if file == "#{file_prefix}_status_history.json"
251
276
 
252
- File.unlink "#{@target_path}#{file}"
277
+ File.unlink File.join(@target_path, file)
253
278
  end
254
279
 
255
280
  return if @cached_data_format_is_current
256
281
 
257
282
  # Also throw away all the previously downloaded issues.
258
- path = File.join @target_path, "#{file_prefix}_issues"
283
+ path = File.join(@target_path, "#{file_prefix}_issues")
259
284
  return unless File.exist? path
260
285
 
261
286
  Dir.foreach path do |file|
@@ -269,8 +294,10 @@ class Downloader
269
294
  segments = []
270
295
  segments << "filter=#{filter_id}"
271
296
 
272
- unless @download_config.rolling_date_count.nil?
273
- @download_date_range = (today.to_date - @download_config.rolling_date_count)..today.to_date
297
+ start_date = @download_config.start_date today: today
298
+
299
+ if start_date
300
+ @download_date_range = start_date..today.to_date
274
301
 
275
302
  # For an incremental download, we want to query from the end of the previous one, not from the
276
303
  # beginning of the full range.
@@ -283,13 +310,16 @@ class Downloader
283
310
 
284
311
  # Pick up any issues that had a status change in the range
285
312
  start_date_text = @start_date_in_query.strftime '%Y-%m-%d'
286
- end_date_text = today.strftime '%Y-%m-%d'
287
313
  # find_in_range = %((status changed DURING ("#{start_date_text} 00:00","#{end_date_text} 23:59")))
288
- find_in_range = %((updated >= "#{start_date_text} 00:00" AND updated <= "#{end_date_text} 23:59"))
314
+ find_in_range = %(updated >= "#{start_date_text} 00:00")
289
315
 
290
316
  segments << "(#{find_in_range} OR #{catch_all})"
291
317
  end
292
318
 
293
319
  segments.join ' AND '
294
320
  end
321
+
322
+ def file_prefix
323
+ @download_config.project_config.get_file_prefix
324
+ end
295
325
  end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ class EstimateAccuracyChart < ChartBase
4
+ def initialize configuration_block
5
+ super()
6
+
7
+ header_text 'Estimate Accuracy'
8
+ description_text <<-HTML
9
+ <div class="p">
10
+ This chart graphs estimates against actual recorded cycle times. Since
11
+ estimates can change over time, we're graphing the estimate at the time that the story started.
12
+ </div>
13
+ <div class="p">
14
+ The #{color_block '--estimate-accuracy-chart-completed-fill-color'} completed dots indicate
15
+ cycletimes.
16
+ <% if @has_aging_data %>
17
+ The #{color_block '--estimate-accuracy-chart-active-fill-color'} aging dots
18
+ (click on the legend to turn them on) show the current
19
+ age of items, which will give you a hint as to where they might end up. If they're already
20
+ far to the right then you know you have a problem.
21
+ <% end %>
22
+ </div>
23
+ HTML
24
+
25
+ @y_axis_type = 'linear'
26
+ @y_axis_block = ->(issue, start_time) { estimate_at(issue: issue, start_time: start_time)&.to_f }
27
+ @y_axis_sort_order = nil
28
+
29
+ instance_eval(&configuration_block)
30
+ end
31
+
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
37
+ data_sets = scan_issues
38
+
39
+ return '' if data_sets.empty?
40
+
41
+ wrap_and_render(binding, __FILE__)
42
+ end
43
+
44
+ def scan_issues
45
+ completed_hash, aging_hash = split_into_completed_and_aging issues: issues
46
+
47
+ estimation_units = current_board.estimation_configuration.units
48
+ @has_aging_data = !aging_hash.empty?
49
+
50
+ [
51
+ [completed_hash, 'Completed', 'completed', false],
52
+ [aging_hash, 'Still in progress', 'active', true]
53
+ ].filter_map do |hash, label, completed_or_active, starts_hidden|
54
+ fill_color = CssVariable["--estimate-accuracy-chart-#{completed_or_active}-fill-color"]
55
+ border_color = CssVariable["--estimate-accuracy-chart-#{completed_or_active}-border-color"]
56
+
57
+ # We sort so that the smaller circles are in front of the bigger circles.
58
+ data = hash.sort(&hash_sorter).collect do |key, values|
59
+ estimate, cycle_time = *key
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
+
67
+ {
68
+ 'x' => cycle_time,
69
+ 'y' => estimate,
70
+ 'r' => values.size * 2,
71
+ 'title' => title
72
+ }
73
+ end
74
+ next if data.empty?
75
+
76
+ {
77
+ 'label' => label,
78
+ 'data' => data,
79
+ 'fill' => false,
80
+ 'showLine' => false,
81
+ 'backgroundColor' => fill_color,
82
+ 'borderColor' => border_color,
83
+ 'hidden' => starts_hidden
84
+ }
85
+ end
86
+ end
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
+
100
+ def split_into_completed_and_aging issues:
101
+ aging_hash = {}
102
+ completed_hash = {}
103
+
104
+ issues.each do |issue|
105
+ cycletime = issue.board.cycletime
106
+ start_time, stop_time = cycletime.started_stopped_times(issue)
107
+
108
+ next unless start_time
109
+
110
+ hash = stop_time ? completed_hash : aging_hash
111
+
112
+ estimate = @y_axis_block.call issue, start_time
113
+ cycle_time = ((stop_time&.to_date || date_range.end) - start_time.to_date).to_i + 1
114
+
115
+ next if estimate.nil?
116
+
117
+ key = [estimate, cycle_time]
118
+ (hash[key] ||= []) << issue
119
+ end
120
+
121
+ [completed_hash, aging_hash]
122
+ end
123
+
124
+ def hash_sorter
125
+ lambda do |arg1, arg2|
126
+ estimate1 = arg1[0][0]
127
+ estimate2 = arg2[0][0]
128
+ sample_count1 = arg1.size
129
+ sample_count2 = arg2.size
130
+
131
+ if @y_axis_sort_order
132
+ index1 = @y_axis_sort_order.index estimate1
133
+ index2 = @y_axis_sort_order.index estimate2
134
+
135
+ if index1.nil?
136
+ comparison = 1
137
+ elsif index2.nil?
138
+ comparison = -1
139
+ else
140
+ comparison = index1 <=> index2
141
+ end
142
+ return comparison unless comparison.zero?
143
+ end
144
+
145
+ sample_count2 <=> sample_count1
146
+ end
147
+ end
148
+
149
+ def estimate_at issue:, start_time:, estimation_configuration: current_board.estimation_configuration
150
+ estimate = nil
151
+
152
+ issue.changes.each do |change|
153
+ return estimate if change.time >= start_time
154
+
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
159
+ end
160
+ estimate
161
+ end
162
+
163
+ def y_axis label:, sort_order: nil, &block
164
+ @y_axis_sort_order = sort_order
165
+ @y_axis_label = label
166
+ if sort_order
167
+ @y_axis_type = 'category'
168
+ else
169
+ @y_axis_type = 'linear'
170
+ end
171
+ @y_axis_block = block
172
+ end
173
+ end
@@ -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
@@ -3,24 +3,23 @@
3
3
  # This file is really intended to give you ideas about how you might configure your own reports, not
4
4
  # as a complete setup that will work in every case.
5
5
  #
6
- # See https://github.com/mikebowler/jirametrics/wiki/Examples-folder for more details
7
- #
8
6
  # The point of an AGGREGATED report is that we're now looking at a higher level. We might use this in a
9
7
  # S2 meeting (Scrum of Scrums) to talk about the things that are happening across teams, not within a
10
8
  # single team. For that reason, we look at slightly different things that we would on a single team board.
11
9
 
12
10
  class Exporter
13
- def aggregated_project name:, project_names:
11
+ def aggregated_project name:, project_names:, settings: {}
14
12
  project name: name do
15
13
  puts name
14
+ file_prefix name
15
+ self.settings.merge! settings
16
+
16
17
  aggregate do
17
18
  project_names.each do |project_name|
18
19
  include_issues_from project_name
19
20
  end
20
21
  end
21
22
 
22
- file_prefix name
23
-
24
23
  file do
25
24
  file_suffix '.html'
26
25
  issues.reject! do |issue|
@@ -28,32 +27,27 @@ class Exporter
28
27
  end
29
28
 
30
29
  html_report do
30
+ html '<h1>Boards included in this report</h1><ul>', type: :header
31
+ board_lines = []
32
+ included_projects.each do |project|
33
+ project.all_boards.each_value do |board|
34
+ board_lines << "<a href='#{project.get_file_prefix}.html'>#{board.name}</a> from project #{project.name}"
35
+ end
36
+ end
37
+ board_lines.sort.each { |line| html "<li>#{line}</li>", type: :header }
38
+ html '</ul>', type: :header
39
+
31
40
  cycletime_scatterplot do
32
41
  show_trend_lines
42
+ # For an aggregated report we group by board rather than by type
33
43
  grouping_rules do |issue, rules|
34
44
  rules.label = issue.board.name
35
45
  end
36
46
  end
37
47
  # aging_work_in_progress_chart
38
- daily_wip_chart do
39
- header_text 'Daily WIP by Parent'
40
- description_text <<-TEXT
41
- <p>How much work is in progress, grouped by the parent of the issue. This will give us an
42
- indication of how focused we are on higher level objectives. If there are many parent
43
- tickets in progress at the same time, either this team has their focus scattered or we
44
- aren't doing a good job of
45
- <a href="https://improvingflow.com/2024/02/21/slicing-epics.html">splitting those parent
46
- tickets</a>. Neither of those is desirable.</p>
47
- <p>If you're expecting all work items to have parents and there are a lot that don't,
48
- that's also something to look at. Consider whether there is even value in aggregating
49
- these projects if they don't share parent dependencies. Aggregation helps us when we're
50
- looking at related work and if there aren't parent dependencies then the work may not
51
- be related.</p>
52
- TEXT
53
- grouping_rules do |issue, rules|
54
- rules.label = issue.parent&.key || 'No parent'
55
- rules.color = 'white' if rules.label == 'No parent'
56
- end
48
+ daily_wip_by_parent_chart do
49
+ # When aggregating, the chart tends to need more vertical space
50
+ canvas height: 400, width: 800
57
51
  end
58
52
  aging_work_table do
59
53
  # In an aggregated report, we likely only care about items that are old so exclude anything
@@ -67,25 +61,14 @@ class Exporter
67
61
 
68
62
  # By default, the issue doesn't show what board it's on and this is important for an
69
63
  # aggregated view
64
+ chart = self
70
65
  issue_rules do |issue, rules|
71
- key = issue.key
72
- key = "<S>#{key} </S> " if issue.status.category_name == 'Done'
73
- rules.label = "<#{key} [#{issue.type}]<BR/>#{issue.board.name}<BR/>#{word_wrap issue.summary}>"
66
+ chart.default_issue_rules.call(issue, rules)
67
+ rules.label = rules.label.split('<BR/>').insert(1, "Board: #{issue.board.name}").join('<BR/>')
74
68
  end
75
69
 
76
70
  link_rules do |link, rules|
77
- # By default, the dependency chart shows everything. Clean it up a bit.
78
- case link.name
79
- when 'Cloners'
80
- # We don't want to see any clone links at all.
81
- rules.ignore
82
- when 'Blocks'
83
- # For blocks, by default Jira will have links going both
84
- # ways and we want them only going one way. Also make the
85
- # link red.
86
- rules.merge_bidirectional keep: 'outward'
87
- rules.line_color = 'red'
88
- end
71
+ chart.default_link_rules.call(link, rules)
89
72
 
90
73
  # Because this is the aggregated view, let's hide any link that doesn't cross boards.
91
74
  rules.ignore if link.origin.board == link.other_issue.board