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
@@ -4,11 +4,9 @@ require 'time'
4
4
  require 'jirametrics/status_collection'
5
5
 
6
6
  class ProjectConfig
7
- include DiscardChangesBefore
8
-
9
7
  attr_reader :target_path, :jira_config, :all_boards, :possible_statuses,
10
8
  :download_config, :file_configs, :exporter, :data_version, :name, :board_configs,
11
- :settings, :aggregate_config
9
+ :settings, :aggregate_config, :discarded_changes_data, :users, :fix_versions
12
10
  attr_accessor :time_range, :jira_url, :id
13
11
 
14
12
  def initialize exporter:, jira_config:, block:, target_path: '.', name: '', id: nil
@@ -24,32 +22,59 @@ class ProjectConfig
24
22
  @all_boards = {}
25
23
  @settings = load_settings
26
24
  @id = id
25
+ @has_loaded_data = false
26
+ @fix_versions = []
27
27
  end
28
28
 
29
29
  def evaluate_next_level
30
30
  instance_eval(&@block) if @block
31
31
  end
32
32
 
33
- def run
34
- unless aggregated_project?
35
- load_all_boards
36
- @id = guess_project_id
37
- load_status_category_mappings
38
- load_project_metadata
39
- load_sprints
40
- end
33
+ def data_downloaded?
34
+ file_system.file_exist? File.join(@target_path, "#{get_file_prefix}_meta.json")
35
+ end
36
+
37
+ def load_data
38
+ return if @has_loaded_data
39
+
40
+ @has_loaded_data = true
41
+ @id = guess_project_id
42
+ load_project_metadata
43
+ load_sprints
44
+ load_fix_versions
45
+ load_users
46
+ resolve_blocked_stalled_status_settings
47
+ end
48
+
49
+ def run load_only: false
50
+ return if @exporter.downloading?
51
+
52
+ load_data unless aggregated_project?
53
+
54
+ return if load_only
55
+
41
56
  anonymize_data if @anonymizer_needed
42
57
 
43
- @board_configs.each do |board_config|
44
- board_config.run
45
- end
46
58
  @file_configs.each do |file_config|
47
59
  file_config.run
48
60
  end
49
61
  end
50
62
 
51
63
  def load_settings
52
- JSON.parse(file_system.load(File.join(__dir__, 'settings.json')))
64
+ # This is the weird exception that we don't ever want mocked out so we skip FileSystem entirely.
65
+ settings = JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
66
+
67
+ if settings['blocked_color']
68
+ file_system.deprecated message: 'blocked color should be set via css now', date: '2024-05-03'
69
+ end
70
+ if settings['stalled_color']
71
+ file_system.deprecated message: 'stalled color should be set via css now', date: '2024-05-03'
72
+ end
73
+
74
+ settings['blocked_statuses'] = StatusCollection.new
75
+ settings['stalled_statuses'] = StatusCollection.new
76
+
77
+ stringify_keys(settings)
53
78
  end
54
79
 
55
80
  def guess_project_id
@@ -73,6 +98,12 @@ class ProjectConfig
73
98
  !!@aggregate_config
74
99
  end
75
100
 
101
+ def aggregate_project_names
102
+ return [] unless aggregated_project?
103
+
104
+ @aggregate_config.included_projects.filter_map(&:name)
105
+ end
106
+
76
107
  def download &block
77
108
  raise 'Not allowed to have multiple download blocks in one project' if @download_config
78
109
  raise 'Not allowed to have both an aggregate and a download section. Pick only one.' if @aggregate_config
@@ -98,90 +129,226 @@ class ProjectConfig
98
129
 
99
130
  def board id:, &block
100
131
  config = BoardConfig.new(id: id, block: block, project_config: self)
132
+ config.run if data_downloaded?
101
133
  @board_configs << config
102
134
  end
103
135
 
104
- def file_prefix prefix = nil
105
- @file_prefix = prefix unless prefix.nil?
136
+ def file_prefix prefix
137
+ # The file_prefix has to be set before almost everything else. It really should have been an attribute
138
+ # on the project declaration itself. Hindsight is 20/20.
139
+
140
+ # There can only be one of these
141
+ if @file_prefix
142
+ raise "file_prefix can only be set once. Was #{@file_prefix.inspect} and now changed to #{prefix.inspect}."
143
+ end
144
+
145
+ raise_if_prefix_already_used(prefix)
146
+
147
+ @file_prefix = prefix
148
+
149
+ # Yes, this is a wierd place to be initializing this. Unfortunately, it has to happen after the file_prefix
150
+ # is set but before anything inside the project block is run. If only we had made file_prefix an attribute
151
+ # on project, we wouldn't have this ugliness. 🤷‍♂️
152
+ load_status_category_mappings
153
+ load_status_history
154
+ load_all_boards
155
+
106
156
  @file_prefix
107
157
  end
108
158
 
109
- def status_category_mapping status:, category:
110
- add_possible_status Status.new(name: status, category_name: category)
159
+ def validate_discard_status status_name
160
+ return if status_name == :backlog
161
+ return if possible_statuses.empty? # not yet downloaded; skip validation
162
+
163
+ found = possible_statuses.find_all_by_name status_name
164
+ return unless found.empty?
165
+
166
+ raise "discard_changes_before: Status #{status_name.inspect} not found. " \
167
+ "Possible statuses are: #{possible_statuses}"
111
168
  end
112
169
 
113
- def load_all_boards
114
- Dir.foreach(@target_path) do |file|
115
- next unless file =~ /^#{@file_prefix}_board_(\d+)_configuration\.json$/
170
+ def raise_if_prefix_already_used prefix
171
+ @exporter.project_configs.each do |project|
172
+ next unless project.get_file_prefix(raise_if_not_set: false) == prefix && project.target_path == target_path
116
173
 
117
- board_id = $1.to_i
118
- load_board board_id: board_id, filename: "#{@target_path}#{file}"
174
+ raise "Project #{name.inspect} specifies file prefix #{prefix.inspect}, " \
175
+ "but that is already used by project #{project.name.inspect} in the same target path #{target_path.inspect}. " \
176
+ 'This is almost guaranteed to be too much copy and paste in your configuration. ' \
177
+ 'File prefixes must be unique within a directory.'
119
178
  end
120
- raise "No boards found for #{@file_prefix} in #{@target_path.inspect}" if @all_boards.empty?
121
179
  end
122
180
 
123
- def load_board board_id:, filename:
124
- board = Board.new(
125
- raw: JSON.parse(file_system.load(filename)), possible_statuses: @possible_statuses
181
+ def get_file_prefix raise_if_not_set: true
182
+ if @file_prefix.nil? && raise_if_not_set
183
+ raise 'file_prefix has not been set yet. Move it to the top of the project declaration.'
184
+ end
185
+
186
+ @file_prefix
187
+ end
188
+
189
+ # Walk across all the issues and find any status with that name. Return a list of ids that match.
190
+ def find_ids_by_status_name_across_all_issues name
191
+ ids = Set.new
192
+
193
+ issues.each do |issue|
194
+ issue.changes.each do |change|
195
+ next unless change.status?
196
+
197
+ ids << change.value_id.to_i if change.value == name
198
+ ids << change.old_value_id.to_i if change.old_value == name
199
+ end
200
+ end
201
+ ids.to_a
202
+ end
203
+
204
+ def status_category_mapping status:, category:
205
+ return if @exporter.downloading?
206
+
207
+ status, status_id = possible_statuses.parse_name_id status
208
+ category, category_id = possible_statuses.parse_name_id category
209
+
210
+ if status_id.nil?
211
+ guesses = find_ids_by_status_name_across_all_issues status
212
+ if guesses.empty?
213
+ file_system.warning "For status_category_mapping status: #{status.inspect}, category: #{category.inspect}\n" \
214
+ "Cannot guess status id for #{status.inspect} as no statuses found anywhere in the issues " \
215
+ "histories with that name. Since we can't find it, you probably don't need this mapping anymore so we're " \
216
+ "going to ignore it. If you really want it, then you'll need to specify a status id."
217
+ return
218
+ end
219
+
220
+ if guesses.size > 1
221
+ raise "Cannot guess status id as there are multiple ids for the name #{status.inspect}. Perhaps it's one " \
222
+ "of #{guesses.to_a.sort.inspect}. If you need this mapping then you must specify the status_id."
223
+ end
224
+
225
+ status_id = guesses.first
226
+ file_system.log "status_category_mapping for #{status.inspect} has been mapped to id #{status_id}. " \
227
+ "If that's incorrect then specify the status_id."
228
+ end
229
+
230
+ possible_categories = possible_statuses.find_all_categories_by_name category
231
+ if possible_categories.empty?
232
+ all = possible_statuses.find_all_categories.join(', ')
233
+ raise "No status categories found for name #{category.inspect} in [#{all}]. " \
234
+ 'Either fix the name or add an ID.'
235
+ elsif possible_categories.size > 1
236
+ # Theoretically impossible and yet we've seen wierder things out of Jira so we're prepared.
237
+ raise "More than one status category found with the name #{category.inspect} in " \
238
+ "[#{possible_categories.join(', ')}]. Either fix the name or add an ID"
239
+ end
240
+
241
+ found_category = possible_categories.first
242
+
243
+ if category_id && category_id != found_category.id
244
+ raise "ID is incorrect for status category #{category.inspect}. Did you mean #{found_category.id}?"
245
+ end
246
+
247
+ add_possible_status(
248
+ Status.new(
249
+ name: status, id: status_id,
250
+ category_name: category, category_id: found_category.id, category_key: found_category.key
251
+ )
126
252
  )
127
- board.project_config = self
128
- @all_boards[board_id] = board
129
253
  end
130
254
 
131
- def raise_with_message_about_missing_category_information all_issues = @issues
132
- message = +''
133
- message << "Could not determine categories for some of the statuses used in this data set.\n" \
134
- "Use the 'status_category_mapping' declaration in your config to manually add one.\n" \
135
- 'The mappings we do know about are below:'
255
+ def add_possible_status status
256
+ existing_status = @possible_statuses.find_by_id status.id
136
257
 
137
- @possible_statuses.each do |status|
138
- message << "\n status: #{status.name.inspect}, category: #{status.category_name.inspect}"
258
+ if existing_status && existing_status.name != status.name
259
+ raise "Attempting to redefine the name for status #{status.id} from " \
260
+ "#{existing_status.name.inspect} to #{status.name.inspect}"
139
261
  end
140
262
 
141
- message << "\n\nThe ones we're missing are the following:"
263
+ # If it isn't there, add it and go.
264
+ return @possible_statuses << status unless existing_status
142
265
 
143
- find_statuses_with_no_category_information(all_issues).each do |status_name|
144
- message << "\n status: #{status_name.inspect}, category: <unknown>"
266
+ unless status == existing_status
267
+ raise "Redefining status category for status #{status}. " \
268
+ "original: #{existing_status.category}, " \
269
+ "new: #{status.category}"
145
270
  end
146
271
 
147
- raise message
272
+ # We're registering one we already knew about. This may happen if someone specified a status_category_mapping
273
+ # for something that was already returned from jira.
274
+ #
275
+ # You may be looking at this code and thinking of changing it to spit out a warning since obviously
276
+ # the user has made a mistake. Unfortunately, they may not have made any mistake. Due to inconsistency with the
277
+ # status API, it's possible for two different people to make a request to the same API at the same time and get
278
+ # back a different set of statuses. So that means that some people might need more status/categories mappings than
279
+ # other people for exactly the same instance. See this article for more on that API:
280
+ # https://agiletechnicalexcellence.com/2024/04/12/jira-api-statuses.html
281
+ existing_status
148
282
  end
149
283
 
150
- def find_statuses_with_no_category_information all_issues
151
- missing_statuses = []
152
- all_issues.each do |issue|
153
- issue.changes.each do |change|
154
- next unless change.status?
284
+ def load_all_boards
285
+ Dir.foreach(@target_path) do |file|
286
+ next unless file =~ /^#{get_file_prefix}_board_(\d+)_configuration\.json$/
155
287
 
156
- missing_statuses << change.value unless find_status(name: change.value)
157
- end
288
+ board_id = $1.to_i
289
+ load_board board_id: board_id, filename: "#{@target_path}#{file}"
158
290
  end
159
- missing_statuses.uniq
291
+ end
292
+
293
+ def load_board board_id:, filename:
294
+ raw = file_system.load_json(filename)
295
+
296
+ features_filename = File.join(@target_path, "#{get_file_prefix}_board_#{board_id}_features.json")
297
+ features = if file_system.file_exist?(features_filename)
298
+ BoardFeature.from_raw(file_system.load_json(features_filename))
299
+ else
300
+ []
301
+ end
302
+
303
+ board = Board.new(raw: raw, possible_statuses: @possible_statuses, features: features)
304
+ board.project_config = self
305
+ @all_boards[board_id] = board
160
306
  end
161
307
 
162
308
  def load_status_category_mappings
163
- filename = "#{@target_path}/#{file_prefix}_statuses.json"
164
- # We may not always have this file. Load it if we can.
165
- return unless File.exist? filename
309
+ filename = File.join @target_path, "#{get_file_prefix}_statuses.json"
310
+ return unless file_system.file_exist? filename
166
311
 
167
- statuses = JSON.parse(file_system.load(filename))
168
- .map { |snippet| Status.new(raw: snippet) }
169
- statuses
170
- .find_all { |status| status.global? }
171
- .each { |status| add_possible_status status }
172
- statuses
173
- .find_all { |status| status.project_scoped? }
312
+ file_system
313
+ .load_json(filename)
314
+ .map { |snippet| Status.from_raw(snippet) }
174
315
  .each { |status| add_possible_status status }
175
316
  end
176
317
 
318
+ def load_status_history
319
+ filename = File.join @target_path, "#{get_file_prefix}_status_history.json"
320
+ return unless file_system.file_exist? filename
321
+
322
+ file_system.log ' Loading historical statuses', also_write_to_stderr: true
323
+ file_system
324
+ .load_json(filename)
325
+ .map { |snippet| Status.from_raw(snippet) }
326
+ .each { |status| possible_statuses.historical_status_mappings[status.to_s] = status.category }
327
+
328
+ possible_statuses
329
+ rescue => e # rubocop:disable Style/RescueStandardError
330
+ file_system.warning "Unable to load status history due to #{e.message.inspect}. If this is because of a " \
331
+ 'malformed file then it should be fixed on the next download.'
332
+ end
333
+
177
334
  def load_sprints
178
- Dir.foreach(@target_path) do |file|
179
- next unless file =~ /#{file_prefix}_board_(\d+)_sprints_\d+/
335
+ file_system.foreach(@target_path) do |file|
336
+ next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
180
337
 
181
338
  board_id = $1.to_i
339
+ file_path = File.join(@target_path, file)
340
+ board = @all_boards[board_id]
341
+ unless board
342
+ @exporter.file_system.log(
343
+ 'Found sprint data but can\'t find a matching board in config. ' \
344
+ "File: #{file_path}, Boards: #{@all_boards.keys.sort}"
345
+ )
346
+ next
347
+ end
348
+
182
349
  timezone_offset = exporter.timezone_offset
183
- JSON.parse(file_system.load("#{target_path}#{file}"))['values'].each do |json|
184
- @all_boards[board_id].sprints << Sprint.new(raw: json, timezone_offset: timezone_offset)
350
+ file_system.load_json(file_path)['values']&.each do |json|
351
+ board.sprints << Sprint.new(raw: json, timezone_offset: timezone_offset)
185
352
  end
186
353
  end
187
354
 
@@ -190,61 +357,66 @@ class ProjectConfig
190
357
  end
191
358
  end
192
359
 
193
- def add_possible_status status
194
- existing_status = find_status(name: status.name)
360
+ def load_fix_versions
361
+ filename = File.join(@target_path, "#{get_file_prefix}_fix_versions.json")
362
+ return unless file_system.file_exist?(filename)
195
363
 
196
- if status.project_scoped?
197
- # If the project specific status doesn't change anything then we don't care whether it's
198
- # our project or not.
199
- return if existing_status && existing_status.category_name == status.category_name
364
+ @fix_versions = file_system.load_json(filename).map { |raw| FixVersion.new(raw) }
365
+ end
200
366
 
201
- raise_ambiguous_project_id if @id.nil?
367
+ def load_project_metadata
368
+ filename = File.join @target_path, "#{get_file_prefix}_meta.json"
369
+ json = file_system.load_json(filename)
202
370
 
203
- # Not our project, ignore it.
204
- return unless status.project_id == @id
371
+ @data_version = json['version'] || 1
205
372
 
206
- # Replace the old one with this
207
- @possible_statuses.delete(existing_status)
208
- @possible_statuses << status
209
- return
210
- end
373
+ start = to_time(json['date_start'] || json['time_start']) # date_start is the current format. Time is the old.
374
+ stop = to_time(json['date_end'] || json['time_end'], end_of_day: true)
211
375
 
212
- # If it isn't there, add it and go.
213
- return @possible_statuses << status unless existing_status
376
+ # If no_earlier_than was set then make sure it's applied here.
377
+ if download_config
378
+ download_config.run
379
+ no_earlier = download_config.no_earlier_than
380
+ if no_earlier
381
+ no_earlier = to_time(no_earlier.to_s)
382
+ start = no_earlier if start < no_earlier
383
+ end
384
+ end
214
385
 
215
- # We're registering the same one twice. Shouldn't be possible with the new status API but it
216
- # did happen with the project specific one.
217
- return if status.category_name == existing_status.category_name
386
+ @time_range = start..stop
218
387
 
219
- # If we got this far then someone has called status_category_mapping and is attempting to
220
- # change the category.
221
- raise "Redefining status category #{status} with #{existing_status}. Was one set in the config?"
388
+ @jira_url = json['jira_url']
389
+ rescue Errno::ENOENT
390
+ file_system.log "Can't load #{filename}. Have you done a download?", also_write_to_stderr: true
391
+ raise
222
392
  end
223
393
 
224
- def raise_ambiguous_project_id
225
- raise 'Ambiguous project id: There is a project specific status that could affect out calculations. ' \
226
- 'We are unable to automatically detect the id of the project so you will have to set it manually ' \
227
- 'in the configuration like: "project id: 5"'
228
- end
394
+ def load_users
395
+ @users = []
396
+ filename = File.join @target_path, "#{get_file_prefix}_users.json"
397
+ return unless File.exist? filename
229
398
 
230
- def find_status name:
231
- @possible_statuses.find_by_name name
399
+ json = file_system.load_json(filename)
400
+ json.each { |user_data| @users << User.new(raw: user_data) }
232
401
  end
233
402
 
234
- def load_project_metadata
235
- filename = "#{@target_path}/#{file_prefix}_meta.json"
236
- json = JSON.parse(file_system.load(filename))
403
+ def attach_github_prs
404
+ filename = File.join(@target_path, "#{get_file_prefix}_github_prs.json")
405
+ return unless File.exist?(filename)
237
406
 
238
- @data_version = json['version'] || 1
407
+ prs_by_issue_key = Hash.new { |h, k| h[k] = [] }
408
+ file_system.load_json(filename).each do |raw|
409
+ pr = PullRequest.new(raw: raw)
410
+ pr.issue_keys.each { |key| prs_by_issue_key[key] << pr }
411
+ end
239
412
 
240
- start = json['date_start'] || json['time_start'] # date_start is the current format. Time is the old.
241
- stop = json['date_end'] || json['time_end']
242
- @time_range = to_time(start)..to_time(stop, end_of_day: true)
413
+ @issues.each { |issue| issue.github_prs = prs_by_issue_key[issue.key] }
414
+ end
243
415
 
244
- @jira_url = json['jira_url']
245
- rescue Errno::ENOENT
246
- puts "== Can't load files from the target directory. Did you forget to download first? =="
247
- raise
416
+ def atlassian_document_format
417
+ @atlassian_document_format ||= AtlassianDocumentFormat.new(
418
+ users: @users, timezone_offset: exporter.timezone_offset
419
+ )
248
420
  end
249
421
 
250
422
  def to_time string, end_of_day: false
@@ -259,7 +431,7 @@ class ProjectConfig
259
431
  unless all_boards&.size == 1
260
432
  message = "If the board_id isn't set then we look for all board configurations in the target" \
261
433
  ' directory. '
262
- if all_boards.nil? || all_boards.empty?
434
+ if all_boards.empty?
263
435
  message += ' In this case, we couldn\'t find any configuration files in the target directory.'
264
436
  else
265
437
  message += 'If there is only one, we use that. In this case we found configurations for' \
@@ -280,8 +452,8 @@ class ProjectConfig
280
452
 
281
453
  # To be used by the aggregate_config only. Not intended to be part of the public API
282
454
  def add_issues issues_list
283
- @issues = [] if @issues.nil?
284
- @all_boards = {}
455
+ @issues = IssueCollection.new if @issues.nil?
456
+ @all_boards ||= {}
285
457
 
286
458
  issues_list.each do |issue|
287
459
  @issues << issue
@@ -291,21 +463,25 @@ class ProjectConfig
291
463
  end
292
464
 
293
465
  def issues
294
- raise "issues are being loaded before boards in project #{name.inspect}" if all_boards.nil? && !aggregated_project?
295
-
296
466
  unless @issues
297
- if @aggregate_config
467
+ if aggregated_project?
298
468
  raise 'This is an aggregated project and issues should have been included with the include_issues_from ' \
299
469
  'declaration but none are here. Check your config.'
300
470
  end
301
471
 
472
+ return @issues = IssueCollection.new if @exporter.downloading?
473
+ raise 'No data found. Must do a download before an export' unless data_downloaded?
474
+
475
+ load_data if all_boards.empty?
476
+
302
477
  timezone_offset = exporter.timezone_offset
303
478
 
304
- issues_path = "#{@target_path}#{file_prefix}_issues/"
479
+ issues_path = File.join @target_path, "#{get_file_prefix}_issues"
305
480
  if File.exist?(issues_path) && File.directory?(issues_path)
306
481
  issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
307
482
  else
308
- puts "Can't find issues in #{path}. Has a download been done?"
483
+ file_system.log "Can't find directory #{issues_path}. Has a download been done?", also_write_to_stderr: true
484
+ return IssueCollection.new
309
485
  end
310
486
 
311
487
  # Attach related issues
@@ -317,7 +493,9 @@ class ProjectConfig
317
493
 
318
494
  # We'll have some issues that are in the list that weren't part of the initial query. Once we've
319
495
  # attached them in the appropriate places, remove any that aren't part of that initial set.
320
- @issues = issues.select { |i| i.in_initial_query? }
496
+ issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
497
+ @issues = issues
498
+ attach_github_prs
321
499
  end
322
500
 
323
501
  @issues
@@ -351,18 +529,18 @@ class ProjectConfig
351
529
  raise "No boards found for project #{name.inspect}" if all_boards.empty?
352
530
 
353
531
  if all_boards.size != 1
354
- puts "Multiple boards are in use for project #{name.inspect}. " \
355
- "Picked #{default_board.name.inspect} to attach issues to."
532
+ file_system.log "Multiple boards are in use for project #{name.inspect}. " \
533
+ "Picked #{default_board.name.inspect} to attach issues to.", also_write_to_stderr: true
356
534
  end
357
535
  default_board
358
536
  end
359
537
 
360
538
  def load_issues_from_issues_directory path:, timezone_offset:
361
- issues = []
539
+ issues = IssueCollection.new
362
540
  default_board = nil
363
541
 
364
542
  group_filenames_and_board_ids(path: path).each do |filename, board_ids|
365
- content = file_system.load(File.join(path, filename))
543
+ content = file_system.load_json(File.join(path, filename))
366
544
  if board_ids == :unknown
367
545
  boards = [(default_board ||= find_default_board)]
368
546
  else
@@ -370,7 +548,11 @@ class ProjectConfig
370
548
  end
371
549
 
372
550
  boards.each do |board|
373
- issues << Issue.new(raw: JSON.parse(content), timezone_offset: timezone_offset, board: board)
551
+ if board.cycletime.nil?
552
+ raise "The board declaration for board #{board.id} must come before the " \
553
+ "first usage of 'issues' in the configuration"
554
+ end
555
+ issues << Issue.new(raw: content, timezone_offset: timezone_offset, board: board)
374
556
  end
375
557
  end
376
558
 
@@ -382,7 +564,7 @@ class ProjectConfig
382
564
  # board ids appropriately.
383
565
  def group_filenames_and_board_ids path:
384
566
  hash = {}
385
- Dir.foreach(path) do |filename|
567
+ file_system.foreach(path) do |filename|
386
568
  # Matches either FAKE-123.json or FAKE-123-456.json
387
569
  if /^(?<key>[^-]+-\d+)(?<_>-(?<board_id>\d+))?\.json$/ =~ filename
388
570
  (hash[key] ||= []) << [filename, board_id&.to_i || :unknown]
@@ -424,21 +606,83 @@ class ProjectConfig
424
606
  Anonymizer.new(project_config: self).run
425
607
  end
426
608
 
427
- def discard_changes_before_hook issues_cutoff_times
428
- issues_cutoff_times.each do |issue, cutoff_time|
429
- days = (cutoff_time.to_date - issue.changes.first.time.to_date).to_i + 1
430
- message = "#{issue.key}(#{issue.type}) discarding #{days} "
431
- if days == 1
432
- message << "day of data on #{cutoff_time.to_date}"
433
- else
434
- message << "days of data from #{issue.changes.first.time.to_date} to #{cutoff_time.to_date}"
609
+ def file_system
610
+ @exporter.file_system
611
+ end
612
+
613
+ def discard_changes_before status_becomes: nil, &block
614
+ cycletimes_touched = Set.new
615
+ if status_becomes
616
+ status_becomes = [status_becomes] unless status_becomes.is_a? Array
617
+
618
+ status_becomes.each { |status_name| validate_discard_status status_name }
619
+
620
+ block = lambda do |issue|
621
+ trigger_statuses = status_becomes.collect do |status_name|
622
+ if status_name == :backlog
623
+ issue.board.backlog_statuses
624
+ else
625
+ possible_statuses.find_all_by_name status_name
626
+ end
627
+ end.flatten
628
+
629
+ next if trigger_statuses.empty?
630
+
631
+ trigger_status_ids = trigger_statuses.collect(&:id)
632
+
633
+ time = nil
634
+ issue.status_changes.each do |change|
635
+ time = change.time if trigger_status_ids.include?(change.value_id) # && change.artificial? == false
636
+ end
637
+ time
435
638
  end
436
- exporter.file_system.log message
437
639
  end
438
- exporter.file_system.log "Discarded data from #{issues_cutoff_times.count} issues out of a total #{issues.size}"
640
+
641
+ issues.each do |issue|
642
+ cutoff_time = block.call(issue)
643
+ next if cutoff_time.nil?
644
+
645
+ original_start_time = issue.started_stopped_times.first
646
+ next if original_start_time.nil?
647
+
648
+ issue.discard_changes_before cutoff_time
649
+ cycletimes_touched << issue.board.cycletime
650
+
651
+ next unless cutoff_time
652
+ next if original_start_time > cutoff_time # ie the cutoff would have made no difference.
653
+
654
+ (@discarded_changes_data ||= []) << {
655
+ cutoff_time: cutoff_time,
656
+ original_start_time: original_start_time,
657
+ issue: issue
658
+ }
659
+ end
660
+
661
+ cycletimes_touched.each { |c| c.flush_cache }
662
+ end
663
+
664
+ def stringify_keys value
665
+ case value
666
+ when Hash then value.transform_keys(&:to_s).transform_values { |v| stringify_keys(v) }
667
+ when Array then value.map { |v| stringify_keys(v) }
668
+ else value
669
+ end
439
670
  end
440
671
 
441
- def file_system
442
- @exporter.file_system
672
+ def resolve_blocked_stalled_status_settings
673
+ %w[blocked_statuses stalled_statuses].each do |key|
674
+ next if @settings[key].is_a?(StatusCollection)
675
+
676
+ collection = StatusCollection.new
677
+ @settings[key].each do |identifier|
678
+ statuses = @possible_statuses.find_all_by_name(identifier)
679
+ if statuses.empty?
680
+ file_system.warning "Status #{identifier.inspect} in #{key} not found. Ignoring."
681
+ else
682
+ statuses.each { |status| collection << status }
683
+ end
684
+ end
685
+ @settings[key] = collection
686
+ end
443
687
  end
444
688
  end