jirametrics 2.31 → 3.0

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +42 -29
  3. data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
  5. data/lib/jirametrics/aging_work_table.rb +36 -33
  6. data/lib/jirametrics/anonymizer.rb +31 -86
  7. data/lib/jirametrics/atlassian_document_format.rb +11 -4
  8. data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
  9. data/lib/jirametrics/blocked_stalled_change.rb +5 -1
  10. data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
  11. data/lib/jirametrics/board.rb +23 -9
  12. data/lib/jirametrics/board_config.rb +0 -7
  13. data/lib/jirametrics/board_movement_calculator.rb +24 -21
  14. data/lib/jirametrics/cfd_data_builder.rb +46 -44
  15. data/lib/jirametrics/change_item.rb +19 -7
  16. data/lib/jirametrics/chart_base.rb +50 -64
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +59 -46
  18. data/lib/jirametrics/cycle_time_config.rb +35 -46
  19. data/lib/jirametrics/cycletime_histogram.rb +7 -0
  20. data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
  21. data/lib/jirametrics/daily_view.rb +83 -61
  22. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
  23. data/lib/jirametrics/daily_wip_chart.rb +72 -45
  24. data/lib/jirametrics/data_quality_report.rb +61 -63
  25. data/lib/jirametrics/dependency_chart.rb +40 -31
  26. data/lib/jirametrics/download_config.rb +0 -3
  27. data/lib/jirametrics/downloader.rb +8 -7
  28. data/lib/jirametrics/downloader_for_cloud.rb +108 -72
  29. data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
  30. data/lib/jirametrics/examples/standard_project.rb +2 -2
  31. data/lib/jirametrics/expedited_chart.rb +47 -37
  32. data/lib/jirametrics/exporter.rb +19 -10
  33. data/lib/jirametrics/file_config.rb +19 -12
  34. data/lib/jirametrics/file_system.rb +16 -10
  35. data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
  36. data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
  37. data/lib/jirametrics/github_gateway.rb +23 -9
  38. data/lib/jirametrics/groupable_issue_chart.rb +3 -0
  39. data/lib/jirametrics/html/index.css +6 -0
  40. data/lib/jirametrics/html_report_config.rb +9 -29
  41. data/lib/jirametrics/issue.rb +290 -389
  42. data/lib/jirametrics/issue_collection.rb +1 -0
  43. data/lib/jirametrics/issue_printer.rb +60 -23
  44. data/lib/jirametrics/jira_gateway.rb +29 -18
  45. data/lib/jirametrics/mcp_server.rb +225 -209
  46. data/lib/jirametrics/project_config.rb +158 -134
  47. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
  48. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
  49. data/lib/jirametrics/rules.rb +1 -0
  50. data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
  51. data/lib/jirametrics/sprint_burndown.rb +142 -242
  52. data/lib/jirametrics/sprint_count_measure.rb +42 -0
  53. data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
  54. data/lib/jirametrics/sprint_points_measure.rb +62 -0
  55. data/lib/jirametrics/sprint_summary_stats.rb +16 -0
  56. data/lib/jirametrics/status_collection.rb +2 -2
  57. data/lib/jirametrics/stitcher.rb +21 -16
  58. data/lib/jirametrics/throughput_chart.rb +38 -24
  59. data/lib/jirametrics/time_based_chart.rb +65 -0
  60. data/lib/jirametrics/time_based_histogram.rb +30 -23
  61. data/lib/jirametrics/time_based_scatterplot.rb +11 -2
  62. data/lib/jirametrics/trend_line_calculator.rb +2 -2
  63. data/lib/jirametrics/wip_by_column_chart.rb +73 -32
  64. data/lib/jirametrics.rb +6 -2
  65. metadata +13 -20
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5d255e03d686d0bf827ab0cd09191777b9586574971d6e274704a85df1059a6
4
- data.tar.gz: 60fbf1432b7399186a5e854ef58e83ee59ae96b5bee859371b09f992b4854db7
3
+ metadata.gz: 8411199c48209ed18008a509523bcd28c7d22c1145b492137ac5219972f35245
4
+ data.tar.gz: 3074f95dfc4b401408e615d71b957f5e5626514a6172de5855ec80a15a9bc82a
5
5
  SHA512:
6
- metadata.gz: 79003fc0ab19a3253e9611c74e5d5772b7e0cf17250ec668c9c21cf871056020fa1d6af979387b4e76395b47a687e300b288555d0709c9fa50a7c227d359dbe2
7
- data.tar.gz: 7587f1a620a1015f174bfa5b2b2e31251f3b0c0e2be0a688aa31e1da423bdefa2deaab7a8d40c4e3a8fd54f8c495a3b43db1a0ad231feab6624d8cf74b08adff
6
+ metadata.gz: 1b7dc5f5abea1401da94464b80cb8b346f503055d5df92bac596af7c48b3021d4deb641af804710186a964f4d750b6d03a542cb9236c852b12e174887ba2def9
7
+ data.tar.gz: d9b6de864972518ae2a3ab91c92fc4eba3d411232b252afb31c041ea9575cfa1cebee4085211d0d1eb091335240da5a13038622765dc0ef3fd8c73f874ee4222
@@ -39,43 +39,18 @@ class AggregateConfig
39
39
  end
40
40
 
41
41
  def include_issues_from project_name
42
- project = @project_config.exporter.project_configs.find { |p| p.name == project_name }
43
- if project.nil?
44
- file_system.warning "Aggregated project #{@project_config.name.inspect} is attempting to load " \
45
- "project #{project_name.inspect} but it can't be found. Is it disabled?"
46
- return
47
- end
48
-
49
- @project_config.jira_url = project.jira_url if @project_config.jira_url.nil?
50
- unless @project_config.jira_url == project.jira_url
51
- raise 'Not allowed to aggregate projects from different Jira instances: ' \
52
- "#{@project_config.jira_url.inspect} and #{project.jira_url.inspect}. For project #{project_name}"
53
- end
42
+ project = find_included_project project_name
43
+ return if project.nil?
54
44
 
55
45
  @included_projects << project
56
- if project.file_configs.empty?
57
- issues = project.issues
58
- else
59
- issues = project.file_configs.first.issues
60
- if project.file_configs.size > 1
61
- log 'More than one file section is defined. For the aggregated view, we always use ' \
62
- 'the first file section'
63
- end
64
- end
65
-
46
+ issues = issues_for project
66
47
  if issues.nil?
67
48
  file_system.warning "No issues found for #{project_name}"
68
49
  return
69
50
  end
70
51
 
71
52
  @project_config.add_issues issues
72
-
73
- # Bring fix versions over
74
- project.fix_versions.each do |fix_version|
75
- unless @project_config.fix_versions.find { |fv| fv.id == fix_version.id }
76
- @project_config.fix_versions << fix_version
77
- end
78
- end
53
+ merge_fix_versions_from project
79
54
  end
80
55
 
81
56
  def find_time_range projects:
@@ -94,6 +69,44 @@ class AggregateConfig
94
69
 
95
70
  private
96
71
 
72
+ # Resolve the named project and confirm it can be aggregated. Returns nil (with a warning) when the
73
+ # project can't be found; raises when it lives on a different Jira instance than the ones already
74
+ # included. The first included project's URL seeds the aggregate's URL.
75
+ def find_included_project project_name
76
+ project = @project_config.exporter.project_configs.find { |p| p.name == project_name }
77
+ if project.nil?
78
+ file_system.warning "Aggregated project #{@project_config.name.inspect} is attempting to load " \
79
+ "project #{project_name.inspect} but it can't be found. Is it disabled?"
80
+ return nil
81
+ end
82
+
83
+ @project_config.jira_url = project.jira_url if @project_config.jira_url.nil?
84
+ unless @project_config.jira_url == project.jira_url
85
+ raise 'Not allowed to aggregate projects from different Jira instances: ' \
86
+ "#{@project_config.jira_url.inspect} and #{project.jira_url.inspect}. For project #{project_name}"
87
+ end
88
+
89
+ project
90
+ end
91
+
92
+ def issues_for project
93
+ return project.issues if project.file_configs.empty?
94
+
95
+ if project.file_configs.size > 1
96
+ file_system.warning 'More than one file section is defined. For the aggregated view, we always use ' \
97
+ 'the first file section'
98
+ end
99
+ project.file_configs.first.issues
100
+ end
101
+
102
+ def merge_fix_versions_from project
103
+ project.fix_versions.each do |fix_version|
104
+ next if @project_config.fix_versions.find { |fv| fv.id == fix_version.id }
105
+
106
+ @project_config.fix_versions << fix_version
107
+ end
108
+ end
109
+
97
110
  def file_system
98
111
  @project_config.exporter.file_system
99
112
  end
@@ -102,10 +102,10 @@ class AgingWorkBarChart < ChartBase
102
102
 
103
103
  def select_aging_issues issues:
104
104
  issues.select do |issue|
105
- started_time, stopped_time = issue.started_stopped_times
106
- next false unless started_time && stopped_time.nil?
105
+ cycletime = issue.board.cycletime
106
+ next false unless cycletime.in_progress?(issue)
107
107
 
108
- age = (date_range.end - started_time.to_date).to_i + 1
108
+ age = cycletime.age(issue, today: date_range.end)
109
109
  !(@age_cutoff && @age_cutoff >= age)
110
110
  end
111
111
  end
@@ -200,20 +200,21 @@ class AgingWorkBarChart < ChartBase
200
200
  next
201
201
  end
202
202
 
203
- if change.time >= issue_start_time
204
- color = settings['blocked_color'] || '--blocked-color'
205
- color = settings['stalled_color'] || '--stalled-color' if starting_change.stalled?
206
-
207
- results << BarChartRange.new(
208
- start: starting_change.time, stop: change.time, color: CssVariable[color], title: starting_change.reasons
209
- )
210
- end
211
-
203
+ results << blocked_stalled_range(starting_change, change) if change.time >= issue_start_time
212
204
  starting_change = change
213
205
  end
214
206
  results
215
207
  end
216
208
 
209
+ def blocked_stalled_range starting_change, change
210
+ color = '--blocked-color'
211
+ color = '--stalled-color' if starting_change.stalled?
212
+
213
+ BarChartRange.new(
214
+ start: starting_change.time, stop: change.time, color: CssVariable[color], title: starting_change.reasons
215
+ )
216
+ end
217
+
217
218
  def collect_priority_ranges issue:
218
219
  expedited_priority_names = settings['expedited_priority_names']
219
220
 
@@ -251,44 +252,55 @@ class AgingWorkBarChart < ChartBase
251
252
  issue.changes.each do |change|
252
253
  next unless change.sprint?
253
254
 
254
- removed_sprint_ids = change.old_value_id - change.value_id
255
- added_sprint_ids = change.value_id - change.old_value_id
256
-
257
- removed_sprint_ids.each do |id|
258
- data = open_sprints.delete(id)
259
- next unless data
260
-
261
- completed = data[:sprint].completed_time
262
- stop = completed ? [change.time, completed].min : change.time
263
- results << BarChartRange.new(
264
- start: data[:start_time], stop: stop,
265
- color: CssVariable['--sprint-color'], title: data[:sprint].name
266
- )
267
- end
268
-
269
- added_sprint_ids.each do |id|
270
- sprint = issue.board.sprints.find { |s| s.id == id }
271
- next unless sprint
272
- next if sprint.future?
273
-
274
- start_time = [sprint.start_time, change.time].max
275
- open_sprints[id] = { start_time: start_time, sprint: sprint }
276
- end
255
+ close_removed_sprints(change, open_sprints, results)
256
+ open_added_sprints(change, issue, open_sprints)
277
257
  end
278
258
 
259
+ # Anything still open at the end runs to the sprint's completion, or the end of the range if it's
260
+ # still active.
279
261
  open_sprints.each_value do |data|
280
262
  next if data[:sprint].future?
281
263
 
282
- stop = data[:sprint].completed_time || time_range.end
283
- results << BarChartRange.new(
284
- start: data[:start_time], stop: stop,
285
- color: CssVariable['--sprint-color'], title: data[:sprint].name
286
- )
264
+ results << sprint_range(data, data[:sprint].completed_time || time_range.end)
287
265
  end
288
266
 
289
267
  results
290
268
  end
291
269
 
270
+ # For each sprint this change leaves, close its open range. The range stops when the issue left, or
271
+ # when the sprint completed if that came first.
272
+ def close_removed_sprints change, open_sprints, results
273
+ removed_sprint_ids = change.old_value_id - change.value_id
274
+ removed_sprint_ids.each do |id|
275
+ data = open_sprints.delete(id)
276
+ next unless data
277
+
278
+ completed = data[:sprint].completed_time
279
+ stop = completed ? [change.time, completed].min : change.time
280
+ results << sprint_range(data, stop)
281
+ end
282
+ end
283
+
284
+ # For each sprint this change joins (that exists and has already started), open a range. It starts
285
+ # when the issue joined, or when the sprint began if the issue was added beforehand.
286
+ def open_added_sprints change, issue, open_sprints
287
+ added_sprint_ids = change.value_id - change.old_value_id
288
+ added_sprint_ids.each do |id|
289
+ sprint = issue.board.sprints.find { |s| s.id == id }
290
+ next unless sprint
291
+ next if sprint.future?
292
+
293
+ start_time = [sprint.start_time, change.time].max
294
+ open_sprints[id] = { start_time: start_time, sprint: sprint }
295
+ end
296
+ end
297
+
298
+ def sprint_range data, stop
299
+ BarChartRange.new(
300
+ start: data[:start_time], stop: stop, color: CssVariable['--sprint-color'], title: data[:sprint].name
301
+ )
302
+ end
303
+
292
304
  def create_range_for_priority previous_change:, stop_time:, expedited_priority_names:
293
305
  expedited = expedited_priority_names.include?(previous_change.value)
294
306
  title = "Priority: #{previous_change.value}"
@@ -65,6 +65,7 @@ class AgingWorkInProgressChart < ChartBase
65
65
  wrap_and_render(binding, __FILE__)
66
66
  end
67
67
 
68
+ # Boolean positional is the config-block DSL's public API (show_all_columns false).
68
69
  def show_all_columns show = true # rubocop:disable Style/OptionalBooleanParameter
69
70
  @show_all_columns = show
70
71
  end
@@ -83,60 +84,58 @@ class AgingWorkInProgressChart < ChartBase
83
84
  end
84
85
 
85
86
  def make_data_sets
86
- aging_issues = @issues.select do |issue|
87
- board = issue.board
88
- board.id == @board_id && board.cycletime.in_progress?(issue)
89
- end
87
+ aging_issues = @issues.select { |issue| aging_issue_on_board? issue }
90
88
 
91
- @max_age = 20
92
89
  rules_to_issues = group_issues aging_issues
93
- data_sets = rules_to_issues.keys.collect do |rules|
94
- {
95
- 'type' => 'line',
96
- 'label' => rules.label,
97
- 'data' => rules_to_issues[rules].filter_map do |issue|
98
- age = issue.board.cycletime.age(issue, today: date_range.end)
99
- column = column_for issue: issue
100
- next if column.nil?
101
-
102
- @max_age = age if age > @max_age
103
-
104
- {
105
- 'y' => age,
106
- 'x' => column.name,
107
- 'title' => ["#{issue.key} : #{issue.summary} (#{label_days age})"]
108
- }
109
- end,
110
- 'fill' => false,
111
- 'showLine' => false,
112
- 'backgroundColor' => rules.color
113
- }
114
- end
90
+ data_sets = rules_to_issues.keys.collect { |rules| line_data_set(rules, rules_to_issues[rules]) }
91
+ # The y-axis is scaled to the oldest item, but never shorter than 20 days.
92
+ @max_age = data_sets.flat_map { |set| set['data'].collect { |point| point['y'] } }.push(20).max
115
93
 
116
94
  calculator = BoardMovementCalculator.new board: @all_boards[@board_id], issues: issues, today: date_range.end
117
-
118
95
  column_indexes_to_remove = trim_board_columns data_sets: data_sets, calculator: calculator
119
-
120
96
  @row_index_offset = data_sets.size
121
97
 
98
+ append_bar_data_sets data_sets, calculator, column_indexes_to_remove
99
+ data_sets
100
+ end
101
+
102
+ def aging_issue_on_board? issue
103
+ issue.board.id == @board_id && issue.board.cycletime.in_progress?(issue)
104
+ end
105
+
106
+ def line_data_set rules, issues
107
+ {
108
+ 'type' => 'line',
109
+ 'label' => rules.label,
110
+ 'data' => aging_datapoints(issues),
111
+ 'fill' => false,
112
+ 'showLine' => false,
113
+ 'backgroundColor' => rules.color
114
+ }
115
+ end
116
+
117
+ # One {x: column, y: age, title} point per issue that's currently visible on the board.
118
+ def aging_datapoints issues
119
+ issues.filter_map do |issue|
120
+ age = issue.board.cycletime.age(issue, today: date_range.end)
121
+ column = column_for issue: issue
122
+ next if column.nil?
123
+
124
+ { 'y' => age, 'x' => column.name, 'title' => ["#{issue.key} : #{issue.summary} (#{label_days age})"] }
125
+ end
126
+ end
127
+
128
+ def append_bar_data_sets data_sets, calculator, column_indexes_to_remove
122
129
  bar_data = []
123
130
  calculator.stacked_age_data_for(percentages: @percentiles.keys).each do |percentage, data|
124
131
  column_indexes_to_remove.reverse_each { |index| data.delete_at index }
125
- color = @percentiles[percentage]
126
-
127
132
  data_sets << {
128
- 'type' => 'bar',
129
- 'label' => "#{percentage}%",
130
- 'barPercentage' => 1.0,
131
- 'categoryPercentage' => 1.0,
132
- 'backgroundColor' => color,
133
- 'data' => data
133
+ 'type' => 'bar', 'label' => "#{percentage}%", 'barPercentage' => 1.0, 'categoryPercentage' => 1.0,
134
+ 'backgroundColor' => @percentiles[percentage], 'data' => data
134
135
  }
135
136
  bar_data << data
136
137
  end
137
138
  @bar_data = adjust_bar_data bar_data
138
-
139
- data_sets
140
139
  end
141
140
 
142
141
  def adjust_bar_data input
@@ -174,29 +173,13 @@ class AgingWorkInProgressChart < ChartBase
174
173
  def trim_board_columns data_sets:, calculator:
175
174
  return [] if @show_all_columns
176
175
 
177
- columns_with_aging_items = data_sets.flat_map do |set|
178
- set['data'].filter_map { |d| d['x'] if d.is_a? Hash }
179
- end.uniq
180
-
181
176
  # @fake_column is always the last element and is handled separately.
182
177
  real_column_count = @board_columns.size - 1
183
-
184
- # The last visible column always has artificially inflated age_data because
185
- # ages_of_issues_when_leaving_column uses `today` as end_date when there is no
186
- # next column. Exclude it from the right-boundary search so it is only kept when
187
- # it has current aging items (handled by the last_aging fallback below).
188
- age_data = calculator.age_data_for(percentage: 100)
189
- last_data = (0...(real_column_count - 1)).to_a.reverse.find { |i| !age_data[i].zero? }
190
-
191
- in_current = ->(i) { columns_with_aging_items.include?(@board_columns[i].name) }
192
- first_aging = (0...real_column_count).find(&in_current)
193
- last_aging = (0...real_column_count).to_a.reverse.find(&in_current)
194
-
195
- # Combine: include any column with age_data (up to but not including the last visible
196
- # column) and any column with current aging items.
197
- first_data = (0...real_column_count).find { |i| !age_data[i].zero? }
198
- left_bound = [first_data, first_aging].compact.min
199
- right_bound = [last_data, last_aging].compact.max
178
+ left_bound, right_bound = column_trim_bounds(
179
+ aging_columns: aging_column_names(data_sets),
180
+ age_data: calculator.age_data_for(percentage: 100),
181
+ real_column_count: real_column_count
182
+ )
200
183
 
201
184
  indexes_to_remove =
202
185
  if left_bound && right_bound
@@ -209,6 +192,28 @@ class AgingWorkInProgressChart < ChartBase
209
192
  indexes_to_remove
210
193
  end
211
194
 
195
+ # The distinct column names that currently have aging items on the chart.
196
+ def aging_column_names data_sets
197
+ data_sets.flat_map do |set|
198
+ set['data'].filter_map { |d| d['x'] if d.is_a? Hash }
199
+ end.uniq
200
+ end
201
+
202
+ # The [left, right] column indexes to keep, combining columns with current aging items and columns
203
+ # with historical age data. The last visible column is excluded from the right-boundary history search
204
+ # because ages_of_issues_when_leaving_column inflates its age_data (it uses `today` as the end date
205
+ # when there's no next column), so it's kept only when it has a current aging item.
206
+ def column_trim_bounds aging_columns:, age_data:, real_column_count:
207
+ in_current = ->(i) { aging_columns.include?(@board_columns[i].name) }
208
+ first_aging = (0...real_column_count).find(&in_current)
209
+ last_aging = (0...real_column_count).to_a.reverse.find(&in_current)
210
+
211
+ first_data = (0...real_column_count).find { |i| !age_data[i].zero? }
212
+ last_data = (0...(real_column_count - 1)).to_a.reverse.find { |i| !age_data[i].zero? }
213
+
214
+ [[first_data, first_aging].compact.min, [last_data, last_aging].compact.max]
215
+ end
216
+
212
217
  def column_for issue:
213
218
  @board_columns.find do |board_column|
214
219
  board_column.status_ids.include? issue.status.id
@@ -58,16 +58,19 @@ class AgingWorkTable < ChartBase
58
58
  end
59
59
 
60
60
  def select_aging_issues
61
- aging_issues = @issues.select do |issue|
62
- started, stopped = issue.started_stopped_times
63
- next false if started.nil? || stopped
64
- next true if issue.blocked_on_date?(@today, end_time: time_range.end) || issue.expedited?
65
-
66
- age = (@today - started.to_date).to_i + 1
67
- age > @age_cutoff
68
- end
61
+ aging_issues = @issues.select { |issue| aging_issue? issue }
69
62
  @any_scrum_boards = aging_issues.any? { |issue| issue.board.scrum? }
70
- aging_issues.sort { |a, b| b.board.cycletime.age(b, today: @today) <=> a.board.cycletime.age(a, today: @today) }
63
+ aging_issues.sort_by { |issue| -issue.board.cycletime.age(issue, today: @today) }
64
+ end
65
+
66
+ # An issue is "aging" if it's in progress and either flagged as needing attention (blocked or
67
+ # expedited) or older than the configured cutoff.
68
+ def aging_issue? issue
69
+ cycletime = issue.board.cycletime
70
+ return false unless cycletime.in_progress?(issue)
71
+ return true if issue.blocked_on_date?(@today, end_time: time_range.end) || issue.expedited?
72
+
73
+ cycletime.age(issue, today: @today) > @age_cutoff
71
74
  end
72
75
 
73
76
  def expedited_text issue
@@ -121,36 +124,36 @@ class AgingWorkTable < ChartBase
121
124
  end
122
125
 
123
126
  def dates_text issue
124
- date = date_range.end
125
- due = issue.due_date
127
+ days_remaining, error = @calculators[issue.board.id].forecasted_days_remaining_and_message(
128
+ issue: issue, today: @today
129
+ )
126
130
  message = nil
131
+ message, error = due_date_status(issue, days_remaining, error) unless error
127
132
 
128
- calculator = @calculators[issue.board.id]
129
- days_remaining, error = calculator.forecasted_days_remaining_and_message issue: issue, today: @today
130
-
131
- unless error
132
- if due
133
- if due < date
134
- message = "Due: <b>#{due}</b> (#{label_days (@today - due).to_i} ago)"
135
- error = 'Overdue'
136
- elsif due == date
137
- message = 'Due: <b>today</b>'
138
- else
139
- error = 'Due date at risk' if date_range.end + days_remaining > due
140
- message = "Due: <b>#{due}</b> (#{label_days (due - @today).to_i})"
141
- end
142
- else
143
- "#{label_days days_remaining} left."
144
- end
133
+ forecast_line days_remaining, error, message
134
+ end
135
+
136
+ # Returns [message, error] describing where the due date sits relative to today and the forecast:
137
+ # overdue, due today, or a future date that may be at risk. Only called when the forecast succeeded.
138
+ def due_date_status issue, days_remaining, error
139
+ due = issue.due_date
140
+ return [nil, error] unless due
141
+
142
+ date = date_range.end
143
+ if due < date
144
+ ["Due: <b>#{due}</b> (#{label_days (@today - due).to_i} ago)", 'Overdue']
145
+ elsif due == date
146
+ ['Due: <b>today</b>', error]
147
+ else
148
+ at_risk = date_range.end + days_remaining > due
149
+ ["Due: <b>#{due}</b> (#{label_days (due - @today).to_i})", at_risk ? 'Due date at risk' : error]
145
150
  end
151
+ end
146
152
 
153
+ def forecast_line days_remaining, error, message
147
154
  text = +''
148
155
  text << "<span title='#{error}' style='color: red'>ⓘ </span>" if error
149
- if days_remaining
150
- text << "#{label_days days_remaining} left"
151
- else
152
- text << 'Unable to forecast'
153
- end
156
+ text << (days_remaining ? "#{label_days days_remaining} left" : 'Unable to forecast')
154
157
  text << ' | ' << message if message
155
158
  text
156
159
  end
@@ -19,7 +19,10 @@ class Anonymizer < ChartBase
19
19
  def run
20
20
  anonymize_issue_keys_and_titles
21
21
  anonymize_column_names
22
- # anonymize_issue_statuses
22
+ # Status names are deliberately left alone. They show up throughout the reports (hover text, aging
23
+ # tables, the board columns themselves), so scrubbing them to tokens would make those displays
24
+ # useless -- you'd lose the workflow story that makes the report worth reading. If a client's status
25
+ # vocabulary is itself confidential, rename the statuses in Jira rather than anonymizing here.
23
26
  anonymize_board_names
24
27
  anonymize_labels_and_components
25
28
  anonymize_sprints
@@ -35,31 +38,19 @@ class Anonymizer < ChartBase
35
38
  # cautious and try five times.
36
39
  5.times do |i|
37
40
  return RandomWord.phrases.next.tr('_', ' ')
38
- rescue # rubocop:disable Style/RescueStandardError We don't care what exception was thrown.
41
+ rescue # rubocop:disable Style/RescueStandardError
39
42
  @file_system.log "Random word blew up on attempt #{i + 1}"
40
43
  end
41
44
  end
42
45
 
43
46
  def anonymize_issue_keys_and_titles issues: @issues
44
47
  counter = 0
45
- seen_author_raws = {}
48
+ seen_author_raws = {}.compare_by_identity
46
49
  issues.each do |issue|
47
- new_key = "ANON-#{counter += 1}"
48
-
49
- issue.raw['key'] = new_key
50
- issue.raw['fields']['summary'] = random_phrase
51
- issue.raw['fields']['description'] = nil
52
- issue.raw['fields']['assignee']['displayName'] = random_name unless issue.raw['fields']['assignee'].nil?
53
-
54
- anonymize_author_raw(issue.raw['fields']['creator'], seen_author_raws)
55
-
56
- issue.changes.each do |change|
57
- anonymize_author_raw(change.author_raw, seen_author_raws)
58
- if change.comment? || change.description?
59
- change.value = nil
60
- change.old_value = nil
61
- end
62
- end
50
+ issue.raw['key'] = "ANON-#{counter += 1}"
51
+ anonymize_issue_summary_fields issue
52
+ anonymize_author_raw issue.raw['fields']['creator'], seen_author_raws
53
+ anonymize_change_content issue, seen_author_raws
63
54
 
64
55
  issue.issue_links.each do |link|
65
56
  other_issue = link.other_issue
@@ -71,6 +62,24 @@ class Anonymizer < ChartBase
71
62
  end
72
63
  end
73
64
 
65
+ def anonymize_issue_summary_fields issue
66
+ issue.raw['fields']['summary'] = random_phrase
67
+ issue.raw['fields']['description'] = nil
68
+ return if issue.raw['fields']['assignee'].nil?
69
+
70
+ issue.raw['fields']['assignee']['displayName'] = random_name
71
+ end
72
+
73
+ def anonymize_change_content issue, seen_author_raws
74
+ issue.changes.each do |change|
75
+ anonymize_author_raw change.author_raw, seen_author_raws
76
+ next unless change.comment? || change.description?
77
+
78
+ change.value = nil
79
+ change.old_value = nil
80
+ end
81
+ end
82
+
74
83
  def anonymize_labels_and_components
75
84
  @issues.each do |issue|
76
85
  issue.raw['fields']['labels'] = []
@@ -110,7 +119,7 @@ class Anonymizer < ChartBase
110
119
 
111
120
  def anonymize_server_url
112
121
  @all_boards.each_value do |board|
113
- board.raw['self'] = board.raw['self']&.sub(/^https?:\/\/[^\/]+/, 'https://anon.example.com')
122
+ board.raw['self'] = board.raw['self']&.sub(%r{^https?://[^/]+}, 'https://anon.example.com')
114
123
  end
115
124
  end
116
125
 
@@ -126,70 +135,6 @@ class Anonymizer < ChartBase
126
135
  end
127
136
  end
128
137
 
129
- def build_status_name_hash
130
- next_status = 'a'
131
- status_name_hash = {}
132
- @issues.each do |issue|
133
- issue.changes.each do |change|
134
- next unless change.status?
135
-
136
- # TODO: Do old value too
137
- status_key = change.value
138
- if status_name_hash[status_key].nil?
139
- status_name_hash[status_key] = "status-#{next_status}"
140
- next_status = next_status.next
141
- end
142
- end
143
- end
144
-
145
- @possible_statuses.each do |status|
146
- status_key = status.name
147
- if status_name_hash[status_key].nil?
148
- status_name_hash[status_key] = "status-#{next_status}"
149
- next_status = next_status.next
150
- end
151
- end
152
-
153
- status_name_hash
154
- end
155
-
156
- def anonymize_issue_statuses
157
- @file_system.log 'Anonymizing issue statuses and status categories'
158
- status_name_hash = build_status_name_hash
159
-
160
- @issues.each do |issue|
161
- # This is where we create URL's from
162
- issue.raw['self'] = nil
163
-
164
- issue.changes.each do |change|
165
- next unless change.status?
166
-
167
- status_key = change.value
168
- anonymized_value = status_name_hash[status_key]
169
- raise "status_name_hash[#{status_key.inspect} is nil" if anonymized_value.nil?
170
-
171
- change.value = anonymized_value
172
-
173
- next if change.old_value.nil?
174
-
175
- status_key = change.old_value
176
- anonymized_value = status_name_hash[status_key]
177
- raise "status_name_hash[#{status_key.inspect} is nil" if anonymized_value.nil?
178
-
179
- change.old_value = anonymized_value
180
- end
181
- end
182
-
183
- @possible_statuses.each do |status|
184
- status_key = status.name
185
- if status_name_hash[status_key].nil?
186
- raise "Can't find status_key #{status_key.inspect} in #{status_name_hash.inspect}"
187
- end
188
-
189
- status.name = status_name_hash[status_key] unless status_name_hash[status_key].nil?
190
- end
191
- end
192
-
193
138
  def shift_all_dates date_adjustment: @date_adjustment
194
139
  adjustment_in_seconds = 60 * 60 * 24 * date_adjustment
195
140
  @file_system.log "Shifting all dates by #{label_days date_adjustment}"
@@ -250,9 +195,9 @@ class Anonymizer < ChartBase
250
195
 
251
196
  def anonymize_author_raw author_raw, seen
252
197
  return unless author_raw
253
- return if seen[author_raw.object_id]
198
+ return if seen[author_raw]
254
199
 
255
- seen[author_raw.object_id] = true
200
+ seen[author_raw] = true
256
201
  name = random_name
257
202
  author_raw['displayName'] = name
258
203
  author_raw['name'] = name