jirametrics 2.4 → 2.30

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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +16 -3
  4. data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
  6. data/lib/jirametrics/aging_work_table.rb +63 -19
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +160 -0
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +6 -4
  11. data/lib/jirametrics/board.rb +74 -22
  12. data/lib/jirametrics/board_config.rb +11 -3
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +54 -18
  17. data/lib/jirametrics/chart_base.rb +203 -30
  18. data/lib/jirametrics/css_variable.rb +2 -2
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/cycle_time_config.rb +137 -0
  21. data/lib/jirametrics/cycletime_histogram.rb +17 -38
  22. data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
  23. data/lib/jirametrics/daily_view.rb +306 -0
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
  27. data/lib/jirametrics/daily_wip_chart.rb +36 -16
  28. data/lib/jirametrics/data_quality_report.rb +251 -42
  29. data/lib/jirametrics/dependency_chart.rb +42 -12
  30. data/lib/jirametrics/download_config.rb +27 -0
  31. data/lib/jirametrics/downloader.rb +185 -110
  32. data/lib/jirametrics/downloader_for_cloud.rb +287 -0
  33. data/lib/jirametrics/downloader_for_data_center.rb +95 -0
  34. data/lib/jirametrics/estimate_accuracy_chart.rb +75 -14
  35. data/lib/jirametrics/estimation_configuration.rb +25 -0
  36. data/lib/jirametrics/examples/aggregated_project.rb +9 -23
  37. data/lib/jirametrics/examples/standard_project.rb +57 -58
  38. data/lib/jirametrics/expedited_chart.rb +11 -10
  39. data/lib/jirametrics/exporter.rb +51 -14
  40. data/lib/jirametrics/file_config.rb +21 -6
  41. data/lib/jirametrics/file_system.rb +96 -4
  42. data/lib/jirametrics/fix_version.rb +13 -0
  43. data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
  44. data/lib/jirametrics/github_gateway.rb +115 -0
  45. data/lib/jirametrics/groupable_issue_chart.rb +12 -4
  46. data/lib/jirametrics/grouping_rules.rb +26 -4
  47. data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
  48. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
  49. data/lib/jirametrics/html/aging_work_table.erb +13 -4
  50. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  51. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  52. data/lib/jirametrics/html/daily_wip_chart.erb +41 -15
  53. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  54. data/lib/jirametrics/html/expedited_chart.erb +7 -24
  55. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
  56. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  57. data/lib/jirametrics/html/index.css +336 -62
  58. data/lib/jirametrics/html/index.erb +16 -21
  59. data/lib/jirametrics/html/index.js +164 -0
  60. data/lib/jirametrics/html/legacy_colors.css +174 -0
  61. data/lib/jirametrics/html/sprint_burndown.erb +18 -25
  62. data/lib/jirametrics/html/throughput_chart.erb +43 -21
  63. data/lib/jirametrics/html/time_based_histogram.erb +123 -0
  64. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
  65. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  66. data/lib/jirametrics/html_generator.rb +32 -0
  67. data/lib/jirametrics/html_report_config.rb +83 -76
  68. data/lib/jirametrics/issue.rb +499 -91
  69. data/lib/jirametrics/issue_collection.rb +33 -0
  70. data/lib/jirametrics/issue_printer.rb +97 -0
  71. data/lib/jirametrics/jira_gateway.rb +96 -16
  72. data/lib/jirametrics/mcp_server.rb +531 -0
  73. data/lib/jirametrics/project_config.rb +374 -130
  74. data/lib/jirametrics/pull_request.rb +30 -0
  75. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  76. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  77. data/lib/jirametrics/pull_request_review.rb +13 -0
  78. data/lib/jirametrics/raw_javascript.rb +17 -0
  79. data/lib/jirametrics/rules.rb +2 -2
  80. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  81. data/lib/jirametrics/settings.json +10 -2
  82. data/lib/jirametrics/sprint.rb +13 -0
  83. data/lib/jirametrics/sprint_burndown.rb +47 -39
  84. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  85. data/lib/jirametrics/status.rb +84 -19
  86. data/lib/jirametrics/status_collection.rb +83 -38
  87. data/lib/jirametrics/stitcher.rb +81 -0
  88. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  89. data/lib/jirametrics/throughput_chart.rb +73 -23
  90. data/lib/jirametrics/time_based_histogram.rb +139 -0
  91. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  92. data/lib/jirametrics/user.rb +12 -0
  93. data/lib/jirametrics/value_equality.rb +2 -2
  94. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  95. data/lib/jirametrics.rb +101 -66
  96. metadata +72 -16
  97. data/lib/jirametrics/cycletime_config.rb +0 -69
  98. data/lib/jirametrics/discard_changes_before.rb +0 -37
  99. data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
  100. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -2,9 +2,11 @@
2
2
 
3
3
  require 'jirametrics/chart_base'
4
4
  require 'jirametrics/groupable_issue_chart'
5
+ require 'jirametrics/board_movement_calculator'
5
6
 
6
7
  class AgingWorkInProgressChart < ChartBase
7
8
  include GroupableIssueChart
9
+
8
10
  attr_accessor :possible_statuses, :board_id
9
11
  attr_reader :board_columns
10
12
 
@@ -16,13 +18,33 @@ class AgingWorkInProgressChart < ChartBase
16
18
  This chart shows only work items that have started but not completed, grouped by the column
17
19
  they're currently in. Hovering over a dot will show you the ID of that work item.
18
20
  </p>
19
- <div>
20
- The #{color_block '--non-working-days-color'} shaded area indicates the 85%
21
- mark for work items that have passed through here; 85% of
22
- previous work items left this column while still inside the shaded area. Any work items above
23
- the shading are outliers and they are the items that you should pay special attention to.
21
+ <p>
22
+ The shaded areas indicate what percentage of the work has passed that column within that time.
23
+ Notes:
24
+ <ul>
25
+ <li>It only shows columns that are considered "in progress". If you see a column that wouldn't normally
26
+ be thought of that way, then likely issues were moving backwards or continued to progress after hitting
27
+ that column.</li>
28
+ <li>If you see a colour group that drops as it moves to the right, that generally indicates that
29
+ a different number of data points is being included in each column. Probably because tickets moved
30
+ backwards athough it could also indicate that a ticket jumped over columns as it moved to the right.
31
+ </li>
32
+ </ul>
33
+ </p>
34
+ <div style="border: 1px solid gray; padding: 0.2em">
35
+ <% @percentiles.keys.sort.reverse.each do |percent| %>
36
+ <span style="padding-left: 0.5em; padding-right: 0.5em; vertical-align: middle;"><%= color_block @percentiles[percent] %> <%= percent %>%</span>
37
+ <% end %>
24
38
  </div>
25
39
  HTML
40
+ percentiles(
41
+ 50 => '--aging-work-in-progress-chart-shading-50-color',
42
+ 85 => '--aging-work-in-progress-chart-shading-85-color',
43
+ 98 => '--aging-work-in-progress-chart-shading-98-color',
44
+ 100 => '--aging-work-in-progress-chart-shading-100-color'
45
+ )
46
+ show_all_columns false
47
+
26
48
  init_configuration_block(block) do
27
49
  grouping_rules do |issue, rule|
28
50
  rule.label = issue.type
@@ -34,15 +56,19 @@ class AgingWorkInProgressChart < ChartBase
34
56
  def run
35
57
  determine_board_columns
36
58
 
37
- @header_text += " on board: #{@all_boards[@board_id].name}"
59
+ @header_text += " on board: #{current_board.name}"
38
60
  data_sets = make_data_sets
39
- column_headings = @board_columns.collect(&:name)
40
61
 
41
- adjust_visibility_of_unmapped_status_column data_sets: data_sets, column_headings: column_headings
62
+ adjust_visibility_of_unmapped_status_column data_sets: data_sets
63
+ adjust_chart_height
42
64
 
43
65
  wrap_and_render(binding, __FILE__)
44
66
  end
45
67
 
68
+ def show_all_columns show = true # rubocop:disable Style/OptionalBooleanParameter
69
+ @show_all_columns = show
70
+ end
71
+
46
72
  def determine_board_columns
47
73
  unmapped_statuses = current_board.possible_statuses.collect(&:id)
48
74
 
@@ -62,7 +88,7 @@ class AgingWorkInProgressChart < ChartBase
62
88
  board.id == @board_id && board.cycletime.in_progress?(issue)
63
89
  end
64
90
 
65
- percentage = 85
91
+ @max_age = 20
66
92
  rules_to_issues = group_issues aging_issues
67
93
  data_sets = rules_to_issues.keys.collect do |rules|
68
94
  {
@@ -73,7 +99,10 @@ class AgingWorkInProgressChart < ChartBase
73
99
  column = column_for issue: issue
74
100
  next if column.nil?
75
101
 
76
- { 'y' => age,
102
+ @max_age = age if age > @max_age
103
+
104
+ {
105
+ 'y' => age,
77
106
  'x' => column.name,
78
107
  'title' => ["#{issue.key} : #{issue.summary} (#{label_days age})"]
79
108
  }
@@ -83,45 +112,101 @@ class AgingWorkInProgressChart < ChartBase
83
112
  'backgroundColor' => rules.color
84
113
  }
85
114
  end
86
- data_sets << {
87
- 'type' => 'bar',
88
- 'label' => "#{percentage}%",
89
- 'barPercentage' => 1.0,
90
- 'categoryPercentage' => 1.0,
91
- 'backgroundColor' => CssVariable['--aging-work-in-progress-chart-shading-color'],
92
- 'data' => days_at_percentage_threshold_for_all_columns(percentage: percentage, issues: @issues).drop(1)
93
- }
94
- end
95
115
 
96
- def days_at_percentage_threshold_for_all_columns percentage:, issues:
97
- accumulated_status_ids_per_column.collect do |_column, status_ids|
98
- ages = ages_of_issues_that_crossed_column_boundary issues: issues, status_ids: status_ids
99
- index = ages.size * percentage / 100
100
- ages.sort[index.to_i] || 0
116
+ calculator = BoardMovementCalculator.new board: @all_boards[@board_id], issues: issues, today: date_range.end
117
+
118
+ column_indexes_to_remove = trim_board_columns data_sets: data_sets, calculator: calculator
119
+
120
+ @row_index_offset = data_sets.size
121
+
122
+ bar_data = []
123
+ calculator.stacked_age_data_for(percentages: @percentiles.keys).each do |percentage, data|
124
+ column_indexes_to_remove.reverse_each { |index| data.delete_at index }
125
+ color = @percentiles[percentage]
126
+
127
+ data_sets << {
128
+ 'type' => 'bar',
129
+ 'label' => "#{percentage}%",
130
+ 'barPercentage' => 1.0,
131
+ 'categoryPercentage' => 1.0,
132
+ 'backgroundColor' => color,
133
+ 'data' => data
134
+ }
135
+ bar_data << data
101
136
  end
137
+ @bar_data = adjust_bar_data bar_data
138
+
139
+ data_sets
102
140
  end
103
141
 
104
- def accumulated_status_ids_per_column
105
- accumulated_status_ids = []
106
- @board_columns.reverse.filter_map do |column|
107
- next if column == @fake_column
142
+ def adjust_bar_data input
143
+ return [] if input.empty?
108
144
 
109
- accumulated_status_ids += column.status_ids
110
- [column.name, accumulated_status_ids.dup]
111
- end.reverse
145
+ row_size = input.first.size
146
+
147
+ output = []
148
+ output << input.first
149
+ input.drop(1).each do |row|
150
+ previous_row = output.last
151
+ output << 0.upto(row_size - 1).collect { |i| row[i] + previous_row[i] }
152
+ end
153
+
154
+ output
112
155
  end
113
156
 
114
- def ages_of_issues_that_crossed_column_boundary issues:, status_ids:
115
- issues.filter_map do |issue|
116
- stop = issue.first_time_in_status(*status_ids)
117
- start = issue.board.cycletime.started_time(issue)
157
+ def indexes_of_leading_and_trailing_zeros list
158
+ result = []
159
+ 0.upto(list.size - 1) do |index|
160
+ break unless list[index].zero?
118
161
 
119
- # Skip if either it hasn't crossed the boundary or we can't tell when it started.
120
- next if stop.nil? || start.nil?
121
- next if stop < start
162
+ result << index
163
+ end
122
164
 
123
- (stop.to_date - start.to_date).to_i + 1
165
+ stop_at = result.empty? ? 0 : (result.last + 1)
166
+ (list.size - 1).downto(stop_at).each do |index|
167
+ break unless list[index].zero?
168
+
169
+ result << index if list[index].zero?
124
170
  end
171
+ result
172
+ end
173
+
174
+ def trim_board_columns data_sets:, calculator:
175
+ return [] if @show_all_columns
176
+
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
+ # @fake_column is always the last element and is handled separately.
182
+ 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
200
+
201
+ indexes_to_remove =
202
+ if left_bound && right_bound
203
+ (0...left_bound).to_a + ((right_bound + 1)...real_column_count).to_a
204
+ else
205
+ (0...real_column_count).to_a
206
+ end
207
+
208
+ indexes_to_remove.reverse_each { |index| @board_columns.delete_at index }
209
+ indexes_to_remove
125
210
  end
126
211
 
127
212
  def column_for issue:
@@ -130,7 +215,7 @@ class AgingWorkInProgressChart < ChartBase
130
215
  end
131
216
  end
132
217
 
133
- def adjust_visibility_of_unmapped_status_column data_sets:, column_headings:
218
+ def adjust_visibility_of_unmapped_status_column data_sets:
134
219
  column_name = @fake_column.name
135
220
 
136
221
  has_unmapped = data_sets.any? do |set|
@@ -139,12 +224,23 @@ class AgingWorkInProgressChart < ChartBase
139
224
  end
140
225
  end
141
226
 
142
- if has_unmapped
227
+ if has_unmapped && @description_text
143
228
  @description_text += "<p>The items shown in #{column_name.inspect} are not visible on the " \
144
229
  'board but are still active. Most likely everyone has forgotten about them.</p>'
145
230
  else
146
- column_headings.pop
231
+ # @column_headings.pop
147
232
  @board_columns.pop
148
233
  end
149
234
  end
235
+
236
+ def percentiles percentile_color_hash
237
+ @percentiles = percentile_color_hash.transform_values { |value| CssVariable[value] }
238
+ end
239
+
240
+ def adjust_chart_height
241
+ min_height = @max_age * 5
242
+
243
+ @canvas_height = min_height if min_height > @canvas_height
244
+ @canvas_height = 400 if min_height > 400
245
+ end
150
246
  end
@@ -3,7 +3,7 @@
3
3
  require 'jirametrics/chart_base'
4
4
 
5
5
  class AgingWorkTable < ChartBase
6
- attr_accessor :today, :board_id
6
+ attr_accessor :today
7
7
  attr_reader :any_scrum_boards
8
8
 
9
9
  def initialize block
@@ -22,30 +22,44 @@ class AgingWorkTable < ChartBase
22
22
  If there are expedited items that haven't yet started then they're at the bottom of the table.
23
23
  By the very definition of expedited, if we haven't started them already, we'd better get on that.
24
24
  </p>
25
+ <p>
26
+ Legend:
27
+ <ul>
28
+ <li><b>E:</b> Whether this item is <b>E</b>xpedited.</li>
29
+ <li><b>B/S:</b> Whether this item is either <b>B</b>locked or <b>S</b>talled.</li>
30
+ <li><b>Forecast:</b> A forecast of how long it is likely to take to finish this work item.</li>
31
+ </ul>
32
+ </p>
25
33
  TEXT
26
34
 
27
35
  instance_eval(&block)
28
36
  end
29
37
 
30
38
  def run
31
- @today = date_range.end
39
+ initialize_calculator
32
40
  aging_issues = select_aging_issues + expedited_but_not_started
33
41
 
34
42
  wrap_and_render(binding, __FILE__)
35
43
  end
36
44
 
45
+ # This is its own method simply so the tests can initialize the calculator without doing a full run.
46
+ def initialize_calculator
47
+ @today = date_range.end
48
+ @calculators = @all_boards.transform_values do |board|
49
+ BoardMovementCalculator.new board: board, issues: issues, today: @today
50
+ end
51
+ end
52
+
37
53
  def expedited_but_not_started
38
54
  @issues.select do |issue|
39
- cycletime = issue.board.cycletime
40
- cycletime.started_time(issue).nil? && cycletime.stopped_time(issue).nil? && issue.expedited?
55
+ started_time, stopped_time = issue.started_stopped_times
56
+ started_time.nil? && stopped_time.nil? && issue.expedited?
41
57
  end.sort_by(&:created)
42
58
  end
43
59
 
44
60
  def select_aging_issues
45
61
  aging_issues = @issues.select do |issue|
46
- cycletime = issue.board.cycletime
47
- started = cycletime.started_time(issue)
48
- stopped = cycletime.stopped_time(issue)
62
+ started, stopped = issue.started_stopped_times
49
63
  next false if started.nil? || stopped
50
64
  next true if issue.blocked_on_date?(@today, end_time: time_range.end) || issue.expedited?
51
65
 
@@ -64,7 +78,7 @@ class AgingWorkTable < ChartBase
64
78
  end
65
79
 
66
80
  def blocked_text issue
67
- started_time = issue.board.cycletime.started_time(issue)
81
+ started_time, _stopped_time = issue.started_stopped_times
68
82
  return nil if started_time.nil?
69
83
 
70
84
  current = issue.blocked_stalled_changes(end_time: time_range.end)[-1]
@@ -95,26 +109,52 @@ class AgingWorkTable < ChartBase
95
109
  end
96
110
 
97
111
  def sprints_text issue
98
- sprint_ids = []
99
-
100
- issue.changes.each do |change|
101
- next unless change.sprint?
102
-
103
- sprint_ids << change.raw['to'].split(/\s*,\s*/).collect { |id| id.to_i }
104
- end
105
- sprint_ids.flatten!
106
-
107
- issue.board.sprints.select { |s| sprint_ids.include? s.id }.collect do |sprint|
112
+ issue.sprints.collect do |sprint|
108
113
  icon_text = nil
109
114
  if sprint.active?
110
115
  icon_text = icon_span title: 'Active sprint', icon: '➡️'
111
- else
116
+ elsif sprint.closed?
112
117
  icon_text = icon_span title: 'Sprint closed', icon: '✅'
113
118
  end
114
119
  "#{sprint.name} #{icon_text}"
115
120
  end.join('<br />')
116
121
  end
117
122
 
123
+ def dates_text issue
124
+ date = date_range.end
125
+ due = issue.due_date
126
+ message = nil
127
+
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
145
+ end
146
+
147
+ text = +''
148
+ 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
154
+ text << ' | ' << message if message
155
+ text
156
+ end
157
+
118
158
  def age_cutoff age = nil
119
159
  @age_cutoff = age.to_i if age
120
160
  @age_cutoff
@@ -134,4 +174,8 @@ class AgingWorkTable < ChartBase
134
174
 
135
175
  result.reverse
136
176
  end
177
+
178
+ def priority_text issue
179
+ "<img src='#{issue.priority_url}' title='Priority: #{issue.priority_name}' style='max-width: 1em;'/>"
180
+ end
137
181
  end
@@ -2,11 +2,12 @@
2
2
 
3
3
  require 'random-word'
4
4
 
5
- class Anonymizer
5
+ class Anonymizer < ChartBase
6
6
  # needed for testing
7
7
  attr_reader :project_config, :issues
8
8
 
9
9
  def initialize project_config:, date_adjustment: -200
10
+ super()
10
11
  @project_config = project_config
11
12
  @issues = @project_config.issues
12
13
  @all_boards = @project_config.all_boards
@@ -20,6 +21,10 @@ class Anonymizer
20
21
  anonymize_column_names
21
22
  # anonymize_issue_statuses
22
23
  anonymize_board_names
24
+ anonymize_labels_and_components
25
+ anonymize_sprints
26
+ anonymize_fix_versions
27
+ anonymize_server_url
23
28
  shift_all_dates unless @date_adjustment.zero?
24
29
  @file_system.log 'Anonymize done'
25
30
  end
@@ -37,13 +42,25 @@ class Anonymizer
37
42
 
38
43
  def anonymize_issue_keys_and_titles issues: @issues
39
44
  counter = 0
45
+ seen_author_raws = {}
40
46
  issues.each do |issue|
41
47
  new_key = "ANON-#{counter += 1}"
42
48
 
43
49
  issue.raw['key'] = new_key
44
50
  issue.raw['fields']['summary'] = random_phrase
51
+ issue.raw['fields']['description'] = nil
45
52
  issue.raw['fields']['assignee']['displayName'] = random_name unless issue.raw['fields']['assignee'].nil?
46
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
63
+
47
64
  issue.issue_links.each do |link|
48
65
  other_issue = link.other_issue
49
66
  next if other_issue.key.match?(/^ANON-\d+$/) # Already anonymized?
@@ -54,6 +71,49 @@ class Anonymizer
54
71
  end
55
72
  end
56
73
 
74
+ def anonymize_labels_and_components
75
+ @issues.each do |issue|
76
+ issue.raw['fields']['labels'] = []
77
+ issue.raw['fields']['components'] = []
78
+ end
79
+ end
80
+
81
+ def anonymize_sprints
82
+ sprint_counter = 0
83
+ sprint_name_map = {}
84
+ @all_boards.each_value do |board|
85
+ board.sprints.each do |sprint|
86
+ name = sprint.raw['name']
87
+ unless sprint_name_map[name]
88
+ sprint_counter += 1
89
+ sprint_name_map[name] = "Sprint-#{sprint_counter}"
90
+ end
91
+ sprint.raw['name'] = sprint_name_map[name]
92
+ end
93
+ end
94
+ end
95
+
96
+ def anonymize_fix_versions
97
+ version_counter = 0
98
+ version_name_map = {}
99
+ @issues.each do |issue|
100
+ issue.raw['fields']['fixVersions']&.each do |fix_version|
101
+ name = fix_version['name']
102
+ unless version_name_map[name]
103
+ version_counter += 1
104
+ version_name_map[name] = "Version-#{version_counter}"
105
+ end
106
+ fix_version['name'] = version_name_map[name]
107
+ end
108
+ end
109
+ end
110
+
111
+ def anonymize_server_url
112
+ @all_boards.each_value do |board|
113
+ board.raw['self'] = board.raw['self']&.sub(/^https?:\/\/[^\/]+/, 'https://anon.example.com')
114
+ end
115
+ end
116
+
57
117
  def anonymize_column_names
58
118
  @all_boards.each_key do |board_id|
59
119
  @file_system.log "Anonymizing column names for board #{board_id}"
@@ -130,18 +190,19 @@ class Anonymizer
130
190
  end
131
191
  end
132
192
 
133
- def shift_all_dates
134
- @file_system.log "Shifting all dates by #{@date_adjustment} days"
193
+ def shift_all_dates date_adjustment: @date_adjustment
194
+ adjustment_in_seconds = 60 * 60 * 24 * date_adjustment
195
+ @file_system.log "Shifting all dates by #{label_days date_adjustment}"
135
196
  @issues.each do |issue|
136
197
  issue.changes.each do |change|
137
- change.time = change.time + @date_adjustment
198
+ change.time = change.time + adjustment_in_seconds
138
199
  end
139
200
 
140
- issue.raw['fields']['updated'] = (issue.updated + @date_adjustment).to_s
201
+ issue.raw['fields']['updated'] = (issue.updated + adjustment_in_seconds).to_s
141
202
  end
142
203
 
143
204
  range = @project_config.time_range
144
- @project_config.time_range = (range.begin + @date_adjustment)..(range.end + @date_adjustment)
205
+ @project_config.time_range = (range.begin + adjustment_in_seconds)..(range.end + adjustment_in_seconds)
145
206
  end
146
207
 
147
208
  def random_name
@@ -184,4 +245,18 @@ class Anonymizer
184
245
  board.raw['name'] = "#{random_phrase} board"
185
246
  end
186
247
  end
248
+
249
+ private
250
+
251
+ def anonymize_author_raw author_raw, seen
252
+ return unless author_raw
253
+ return if seen[author_raw.object_id]
254
+
255
+ seen[author_raw.object_id] = true
256
+ name = random_name
257
+ author_raw['displayName'] = name
258
+ author_raw['name'] = name
259
+ author_raw.delete('emailAddress')
260
+ author_raw.delete('avatarUrls')
261
+ end
187
262
  end