jirametrics 2.0 → 2.11

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 (68) 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 +78 -43
  6. data/lib/jirametrics/anonymizer.rb +6 -5
  7. data/lib/jirametrics/blocked_stalled_change.rb +24 -4
  8. data/lib/jirametrics/board.rb +44 -15
  9. data/lib/jirametrics/board_config.rb +8 -4
  10. data/lib/jirametrics/board_movement_calculator.rb +147 -0
  11. data/lib/jirametrics/change_item.rb +31 -10
  12. data/lib/jirametrics/chart_base.rb +102 -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_wip_by_age_chart.rb +44 -20
  19. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +37 -35
  20. data/lib/jirametrics/daily_wip_by_parent_chart.rb +38 -0
  21. data/lib/jirametrics/daily_wip_chart.rb +61 -14
  22. data/lib/jirametrics/data_quality_report.rb +222 -41
  23. data/lib/jirametrics/dependency_chart.rb +54 -23
  24. data/lib/jirametrics/download_config.rb +12 -0
  25. data/lib/jirametrics/downloader.rb +76 -57
  26. data/lib/jirametrics/{story_point_accuracy_chart.rb → estimate_accuracy_chart.rb} +48 -33
  27. data/lib/jirametrics/examples/aggregated_project.rb +22 -39
  28. data/lib/jirametrics/examples/standard_project.rb +25 -49
  29. data/lib/jirametrics/expedited_chart.rb +28 -25
  30. data/lib/jirametrics/exporter.rb +59 -32
  31. data/lib/jirametrics/file_config.rb +34 -13
  32. data/lib/jirametrics/file_system.rb +48 -3
  33. data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
  34. data/lib/jirametrics/groupable_issue_chart.rb +2 -6
  35. data/lib/jirametrics/grouping_rules.rb +7 -1
  36. data/lib/jirametrics/hierarchy_table.rb +4 -4
  37. data/lib/jirametrics/html/aging_work_bar_chart.erb +13 -16
  38. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +28 -5
  39. data/lib/jirametrics/html/aging_work_table.erb +19 -25
  40. data/lib/jirametrics/html/cycletime_histogram.erb +83 -3
  41. data/lib/jirametrics/html/cycletime_scatterplot.erb +9 -12
  42. data/lib/jirametrics/html/daily_wip_chart.erb +17 -13
  43. data/lib/jirametrics/html/{story_point_accuracy_chart.erb → estimate_accuracy_chart.erb} +9 -4
  44. data/lib/jirametrics/html/expedited_chart.erb +10 -13
  45. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
  46. data/lib/jirametrics/html/hierarchy_table.erb +2 -2
  47. data/lib/jirametrics/html/index.css +209 -0
  48. data/lib/jirametrics/html/index.erb +16 -39
  49. data/lib/jirametrics/html/sprint_burndown.erb +10 -14
  50. data/lib/jirametrics/html/throughput_chart.erb +10 -13
  51. data/lib/jirametrics/html_report_config.rb +108 -86
  52. data/lib/jirametrics/issue.rb +357 -96
  53. data/lib/jirametrics/jira_gateway.rb +29 -11
  54. data/lib/jirametrics/project_config.rb +256 -144
  55. data/lib/jirametrics/rules.rb +2 -2
  56. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  57. data/lib/jirametrics/settings.json +10 -0
  58. data/lib/jirametrics/sprint_burndown.rb +24 -7
  59. data/lib/jirametrics/status.rb +84 -19
  60. data/lib/jirametrics/status_collection.rb +80 -39
  61. data/lib/jirametrics/throughput_chart.rb +12 -4
  62. data/lib/jirametrics/value_equality.rb +2 -2
  63. data/lib/jirametrics.rb +25 -7
  64. metadata +16 -17
  65. data/lib/jirametrics/discard_changes_before.rb +0 -37
  66. data/lib/jirametrics/experimental/generator.rb +0 -210
  67. data/lib/jirametrics/experimental/info.rb +0 -77
  68. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -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,10 +39,11 @@ 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
47
48
 
48
49
  save_metadata
@@ -54,8 +55,7 @@ class Downloader
54
55
  end
55
56
 
56
57
  def log text, both: false
57
- @file_system.log text
58
- puts text if both
58
+ @file_system.log text, also_write_to_stderr: both
59
59
  end
60
60
 
61
61
  def find_board_ids
@@ -65,19 +65,19 @@ class Downloader
65
65
  ids
66
66
  end
67
67
 
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/"
68
+ def download_issues board:
69
+ log " Downloading primary issues for board #{board.id}", both: true
70
+ path = File.join(@target_path, "#{file_prefix}_issues/")
71
71
  unless Dir.exist?(path)
72
72
  log " Creating path #{path}"
73
73
  Dir.mkdir(path)
74
74
  end
75
75
 
76
- filter_id = @board_id_to_filter_id[board_id]
76
+ filter_id = @board_id_to_filter_id[board.id]
77
77
  jql = make_jql(filter_id: filter_id)
78
- jira_search_by_jql(jql: jql, initial_query: true, board_id: board_id, path: path)
78
+ jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
79
79
 
80
- log " Downloading linked issues for board #{board_id}", both: true
80
+ log " Downloading linked issues for board #{board.id}", both: true
81
81
  loop do
82
82
  @issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
83
83
  break if @issue_keys_pending_download.empty?
@@ -85,15 +85,15 @@ class Downloader
85
85
  keys_to_request = @issue_keys_pending_download[0..99]
86
86
  @issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
87
87
  jql = "key in (#{keys_to_request.join(', ')})"
88
- jira_search_by_jql(jql: jql, initial_query: false, board_id: board_id, path: path)
88
+ jira_search_by_jql(jql: jql, initial_query: false, board: board, path: path)
89
89
  end
90
90
  end
91
91
 
92
- def jira_search_by_jql jql:, initial_query:, board_id:, path:
92
+ def jira_search_by_jql jql:, initial_query:, board:, path:
93
93
  intercept_jql = @download_config.project_config.settings['intercept_jql']
94
94
  jql = intercept_jql.call jql if intercept_jql
95
95
 
96
- log " #{jql}"
96
+ log " JQL: #{jql}"
97
97
  escaped_jql = CGI.escape jql
98
98
 
99
99
  max_results = 100
@@ -103,14 +103,12 @@ class Downloader
103
103
  json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
104
104
  "?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
105
105
 
106
- exit_if_call_failed json
107
-
108
106
  json['issues'].each do |issue_json|
109
107
  issue_json['exporter'] = {
110
108
  'in_initial_query' => initial_query
111
109
  }
112
- identify_other_issues_to_be_downloaded issue_json
113
- file = "#{issue_json['key']}-#{board_id}.json"
110
+ identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
111
+ file = "#{issue_json['key']}-#{board.id}.json"
114
112
 
115
113
  @file_system.save_json(json: issue_json, filename: File.join(path, file))
116
114
  end
@@ -125,8 +123,8 @@ class Downloader
125
123
  end
126
124
  end
127
125
 
128
- def identify_other_issues_to_be_downloaded raw_issue
129
- issue = Issue.new raw: raw_issue, board: nil
126
+ def identify_other_issues_to_be_downloaded raw_issue:, board:
127
+ issue = Issue.new raw: raw_issue, board: board
130
128
  @issue_keys_downloaded_in_current_run << issue.key
131
129
 
132
130
  # Parent
@@ -137,51 +135,64 @@ class Downloader
137
135
  issue.raw['fields']['subtasks']&.each do |raw_subtask|
138
136
  @issue_keys_pending_download << raw_subtask['key']
139
137
  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
- end
148
-
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']
152
-
153
- log "Download failed. See #{@logfile_name} for details.", both: true
154
- log " #{JSON.pretty_generate(json)}"
155
- exit 1
156
138
  end
157
139
 
158
140
  def download_statuses
159
141
  log ' Downloading all statuses', both: true
160
- json = @jira_gateway.call_url relative_url: "/rest/api/2/status"
142
+ json = @jira_gateway.call_url relative_url: '/rest/api/2/status'
161
143
 
162
144
  @file_system.save_json(
163
- json: json,
164
- filename: "#{@target_path}#{@download_config.project_config.file_prefix}_statuses.json"
145
+ json: json,
146
+ filename: File.join(@target_path, "#{file_prefix}_statuses.json")
165
147
  )
166
148
  end
167
149
 
150
+ def update_status_history_file
151
+ status_filename = File.join(@target_path, "#{file_prefix}_statuses.json")
152
+ return unless file_system.file_exist? status_filename
153
+
154
+ status_json = file_system.load_json(status_filename)
155
+
156
+ history_filename = File.join(@target_path, "#{file_prefix}_status_history.json")
157
+ history_json = file_system.load_json(history_filename) if file_system.file_exist? history_filename
158
+
159
+ if history_json
160
+ file_system.log ' Updating status history file', also_write_to_stderr: true
161
+ else
162
+ file_system.log ' Creating status history file', also_write_to_stderr: true
163
+ history_json = []
164
+ end
165
+
166
+ status_json.each do |status_item|
167
+ id = status_item['id']
168
+ history_item = history_json.find { |s| s['id'] == id }
169
+ history_json.delete(history_item) if history_item
170
+ history_json << status_item
171
+ end
172
+
173
+ file_system.save_json(filename: history_filename, json: history_json)
174
+ end
175
+
168
176
  def download_board_configuration board_id:
169
177
  log " Downloading board configuration for board #{board_id}", both: true
170
178
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
171
- exit_if_call_failed json
172
179
 
173
- @board_id_to_filter_id[board_id] = json['filter']['id'].to_i
174
- # @board_configuration = json if @download_config.board_ids.size == 1
180
+ @file_system.save_json(
181
+ json: json,
182
+ filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_configuration.json")
183
+ )
175
184
 
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"
185
+ # We have a reported bug that blew up on this line. Moved it after the save so we can
186
+ # actually look at the returned json.
187
+ @board_id_to_filter_id[board_id] = json['filter']['id'].to_i
178
188
 
179
189
  download_sprints board_id: board_id if json['type'] == 'scrum'
190
+ # TODO: Should be passing actual statuses, not empty list
191
+ Board.new raw: json, possible_statuses: StatusCollection.new
180
192
  end
181
193
 
182
194
  def download_sprints board_id:
183
195
  log " Downloading sprints for board #{board_id}", both: true
184
- file_prefix = @download_config.project_config.file_prefix
185
196
  max_results = 100
186
197
  start_at = 0
187
198
  is_last = false
@@ -189,20 +200,23 @@ class Downloader
189
200
  while is_last == false
190
201
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/sprint?" \
191
202
  "maxResults=#{max_results}&startAt=#{start_at}"
192
- exit_if_call_failed json
193
203
 
194
204
  @file_system.save_json(
195
205
  json: json,
196
- filename: "#{@target_path}#{file_prefix}_board_#{board_id}_sprints_#{start_at}.json"
206
+ filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_sprints_#{start_at}.json")
197
207
  )
198
208
  is_last = json['isLast']
199
209
  max_results = json['maxResults']
200
- start_at += json['values'].size
210
+ if json['values']
211
+ start_at += json['values'].size
212
+ else
213
+ log " No sprints found for board #{board_id}"
214
+ end
201
215
  end
202
216
  end
203
217
 
204
218
  def metadata_pathname
205
- "#{@target_path}#{@download_config.project_config.file_prefix}_meta.json"
219
+ File.join(@target_path, "#{file_prefix}_meta.json")
206
220
  end
207
221
 
208
222
  def load_metadata
@@ -245,17 +259,17 @@ class Downloader
245
259
  end
246
260
 
247
261
  def remove_old_files
248
- file_prefix = @download_config.project_config.file_prefix
249
262
  Dir.foreach @target_path do |file|
250
263
  next unless file.match?(/^#{file_prefix}_\d+\.json$/)
264
+ next if file == "#{file_prefix}_status_history.json"
251
265
 
252
- File.unlink "#{@target_path}#{file}"
266
+ File.unlink File.join(@target_path, file)
253
267
  end
254
268
 
255
269
  return if @cached_data_format_is_current
256
270
 
257
271
  # Also throw away all the previously downloaded issues.
258
- path = File.join @target_path, "#{file_prefix}_issues"
272
+ path = File.join(@target_path, "#{file_prefix}_issues")
259
273
  return unless File.exist? path
260
274
 
261
275
  Dir.foreach path do |file|
@@ -269,8 +283,10 @@ class Downloader
269
283
  segments = []
270
284
  segments << "filter=#{filter_id}"
271
285
 
272
- unless @download_config.rolling_date_count.nil?
273
- @download_date_range = (today.to_date - @download_config.rolling_date_count)..today.to_date
286
+ start_date = @download_config.start_date today: today
287
+
288
+ if start_date
289
+ @download_date_range = start_date..today.to_date
274
290
 
275
291
  # For an incremental download, we want to query from the end of the previous one, not from the
276
292
  # beginning of the full range.
@@ -283,13 +299,16 @@ class Downloader
283
299
 
284
300
  # Pick up any issues that had a status change in the range
285
301
  start_date_text = @start_date_in_query.strftime '%Y-%m-%d'
286
- end_date_text = today.strftime '%Y-%m-%d'
287
302
  # 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"))
303
+ find_in_range = %(updated >= "#{start_date_text} 00:00")
289
304
 
290
305
  segments << "(#{find_in_range} OR #{catch_all})"
291
306
  end
292
307
 
293
308
  segments.join ' AND '
294
309
  end
310
+
311
+ def file_prefix
312
+ @download_config.project_config.get_file_prefix
313
+ end
295
314
  end
@@ -1,20 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class StoryPointAccuracyChart < ChartBase
4
- def initialize configuration_block = nil
3
+ class EstimateAccuracyChart < ChartBase
4
+ def initialize configuration_block
5
5
  super()
6
6
 
7
7
  header_text 'Estimate Accuracy'
8
8
  description_text <<-HTML
9
- <p>
9
+ <div class="p">
10
10
  This chart graphs estimates against actual recorded cycle times. Since
11
11
  estimates can change over time, we're graphing the estimate at the time that the story started.
12
- </p>
13
- <p>
14
- The completed dots indicate cycletimes. The aging dots (if you turn them on) show the current
15
- age of items, which will give you a hint as to where they might end up. If they're already
16
- far to the right then you know you have a problem.
17
- </p>
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>
18
23
  HTML
19
24
 
20
25
  @y_axis_label = 'Story Point Estimates'
@@ -22,7 +27,7 @@ class StoryPointAccuracyChart < ChartBase
22
27
  @y_axis_block = ->(issue, start_time) { story_points_at(issue: issue, start_time: start_time)&.to_f }
23
28
  @y_axis_sort_order = nil
24
29
 
25
- instance_eval(&configuration_block) if configuration_block
30
+ instance_eval(&configuration_block)
26
31
  end
27
32
 
28
33
  def run
@@ -34,31 +39,17 @@ class StoryPointAccuracyChart < ChartBase
34
39
  end
35
40
 
36
41
  def scan_issues
37
- aging_hash = {}
38
- completed_hash = {}
39
-
40
- issues.each do |issue|
41
- cycletime = issue.board.cycletime
42
- start_time = cycletime.started_time(issue)
43
- stop_time = cycletime.stopped_time(issue)
44
-
45
- next unless start_time
46
-
47
- hash = stop_time ? completed_hash : aging_hash
48
-
49
- estimate = @y_axis_block.call issue, start_time
50
- cycle_time = ((stop_time&.to_date || date_range.end) - start_time.to_date).to_i + 1
42
+ completed_hash, aging_hash = split_into_completed_and_aging issues: issues
51
43
 
52
- next if estimate.nil?
53
-
54
- key = [estimate, cycle_time]
55
- (hash[key] ||= []) << issue
56
- end
44
+ @has_aging_data = !aging_hash.empty?
57
45
 
58
46
  [
59
- [completed_hash, 'Completed', '#66FF99', 'green', false],
60
- [aging_hash, 'Still in progress', '#FFCCCB', 'red', true]
61
- ].filter_map do |hash, label, fill_color, border_color, starts_hidden|
47
+ [completed_hash, 'Completed', 'completed', false],
48
+ [aging_hash, 'Still in progress', 'active', true]
49
+ ].filter_map do |hash, label, completed_or_active, starts_hidden|
50
+ fill_color = CssVariable["--estimate-accuracy-chart-#{completed_or_active}-fill-color"]
51
+ border_color = CssVariable["--estimate-accuracy-chart-#{completed_or_active}-border-color"]
52
+
62
53
  # We sort so that the smaller circles are in front of the bigger circles.
63
54
  data = hash.sort(&hash_sorter).collect do |key, values|
64
55
  estimate, cycle_time = *key
@@ -83,7 +74,31 @@ class StoryPointAccuracyChart < ChartBase
83
74
  'borderColor' => border_color,
84
75
  'hidden' => starts_hidden
85
76
  }
86
- end.compact
77
+ end
78
+ end
79
+
80
+ def split_into_completed_and_aging issues:
81
+ aging_hash = {}
82
+ completed_hash = {}
83
+
84
+ issues.each do |issue|
85
+ cycletime = issue.board.cycletime
86
+ start_time, stop_time = cycletime.started_stopped_times(issue)
87
+
88
+ next unless start_time
89
+
90
+ hash = stop_time ? completed_hash : aging_hash
91
+
92
+ estimate = @y_axis_block.call issue, start_time
93
+ cycle_time = ((stop_time&.to_date || date_range.end) - start_time.to_date).to_i + 1
94
+
95
+ next if estimate.nil?
96
+
97
+ key = [estimate, cycle_time]
98
+ (hash[key] ||= []) << issue
99
+ end
100
+
101
+ [completed_hash, aging_hash]
87
102
  end
88
103
 
89
104
  def hash_sorter
@@ -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
@@ -2,41 +2,49 @@
2
2
 
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
- #
6
- # See https://github.com/mikebowler/jirametrics/wiki/Examples-folder for more
7
5
  class Exporter
8
6
  def standard_project name:, file_prefix:, ignore_issues: nil, starting_status: nil, boards: {},
9
- default_board: nil, anonymize: false
7
+ default_board: nil, anonymize: false, settings: {}, status_category_mappings: {},
8
+ rolling_date_count: 90, no_earlier_than: nil, ignore_types: %w[Sub-task Subtask Epic],
9
+ show_experimental_charts: false
10
10
 
11
11
  project name: name do
12
12
  puts name
13
+ file_prefix file_prefix
14
+
13
15
  self.anonymize if anonymize
16
+ self.settings.merge! settings
17
+
18
+ status_category_mappings.each do |status, category|
19
+ status_category_mapping status: status, category: category
20
+ end
14
21
 
15
- settings['blocked_link_text'] = ['is blocked by']
16
- file_prefix file_prefix
17
22
  download do
18
- rolling_date_count 90
23
+ self.rolling_date_count(rolling_date_count) if rolling_date_count
24
+ self.no_earlier_than(no_earlier_than) if no_earlier_than
19
25
  end
20
26
 
21
27
  boards.each_key do |board_id|
22
28
  block = boards[board_id]
23
29
  if block == :default
24
30
  block = lambda do |_|
25
- start_at first_time_in_status_category('In Progress')
26
- stop_at still_in_status_category('Done')
31
+ start_at first_time_in_status_category(:indeterminate)
32
+ stop_at still_in_status_category(:done)
27
33
  end
28
34
  end
29
35
  board id: board_id do
30
36
  cycletime(&block)
31
- expedited_priority_names 'Critical', 'Highest', 'Immediate Gating'
32
37
  end
33
38
  end
34
39
 
40
+ issues.reject! do |issue|
41
+ ignore_types.include? issue.type
42
+ end
43
+
44
+ discard_changes_before status_becomes: (starting_status || :backlog) # rubocop:disable Style/RedundantParentheses
45
+
35
46
  file do
36
47
  file_suffix '.html'
37
- issues.reject! do |issue|
38
- %w[Sub-task Epic].include? issue.type
39
- end
40
48
 
41
49
  issues.reject! { |issue| ignore_issues.include? issue.key } if ignore_issues
42
50
 
@@ -46,12 +54,10 @@ class Exporter
46
54
  html "<H1>#{name}</H1>", type: :header
47
55
  boards.each_key do |id|
48
56
  board = find_board id
49
- html "<div><a href='#{board.url}'>#{id} #{board.name}</a></div>",
57
+ html "<div><a href='#{board.url}'>#{id} #{board.name}</a> (#{board.board_type})</div>",
50
58
  type: :header
51
59
  end
52
60
 
53
- discard_changes_before status_becomes: (starting_status || :backlog)
54
-
55
61
  cycletime_scatterplot do
56
62
  show_trend_lines
57
63
  end
@@ -77,42 +83,12 @@ class Exporter
77
83
  aging_work_table
78
84
  daily_wip_by_age_chart
79
85
  daily_wip_by_blocked_stalled_chart
80
- daily_wip_chart do
81
- header_text 'Daily WIP by Parent'
82
- description_text <<-TEXT
83
- How much work is in progress, grouped by the parent of the issue. This will give us an
84
- indication of how focused we are on higher level objectives. If there are many parent
85
- tickets in progress at the same time, either this team has their focus scattered or we
86
- aren't doing a good job of
87
- <a href="https://improvingflow.com/2024/02/21/slicing-epics.html">splitting those parent
88
- tickets</a>. Neither of those is desirable.
89
- TEXT
90
- grouping_rules do |issue, rules|
91
- rules.label = issue.parent&.key || 'No parent'
92
- rules.color = 'white' if rules.label == 'No parent'
93
- end
94
- end
86
+ daily_wip_by_parent_chart
87
+ flow_efficiency_scatterplot if show_experimental_charts
95
88
  expedited_chart
96
89
  sprint_burndown
97
- story_point_accuracy_chart
98
-
99
- dependency_chart do
100
- link_rules do |link, rules|
101
- case link.name
102
- when 'Cloners'
103
- rules.ignore
104
- when 'Dependency', 'Blocks', 'Parent/Child', 'Cause', 'Satisfy Requirement', 'Relates'
105
- rules.merge_bidirectional keep: 'outward'
106
- rules.merge_bidirectional keep: 'outward'
107
- when 'Sync'
108
- rules.use_bidirectional_arrows
109
- else
110
- # This is a link type that we don't recognized. Dump it to standard out to draw attention
111
- # to it.
112
- puts "name=#{link.name}, label=#{link.label}"
113
- end
114
- end
115
- end
90
+ estimate_accuracy_chart
91
+ dependency_chart
116
92
  end
117
93
  end
118
94
  end