jirametrics 2.8 → 2.9.1pre1

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.
@@ -5,16 +5,15 @@ require 'jirametrics/self_or_issue_dispatcher'
5
5
 
6
6
  class HtmlReportConfig
7
7
  include SelfOrIssueDispatcher
8
- include DiscardChangesBefore
9
8
 
10
- attr_reader :file_config, :sections
9
+ attr_reader :file_config, :sections, :charts
11
10
 
12
11
  def self.define_chart name:, classname:, deprecated_warning: nil, deprecated_date: nil
13
12
  lines = []
14
13
  lines << "def #{name} &block"
15
14
  lines << ' block = ->(_) {} unless block'
16
15
  if deprecated_warning
17
- lines << " deprecated date: #{deprecated_date.inspect}, message: #{deprecated_warning.inspect}"
16
+ lines << " file_system.deprecated date: #{deprecated_date.inspect}, message: #{deprecated_warning.inspect}"
18
17
  end
19
18
  lines << " execute_chart #{classname}.new(block)"
20
19
  lines << 'end'
@@ -43,14 +42,15 @@ class HtmlReportConfig
43
42
  def initialize file_config:, block:
44
43
  @file_config = file_config
45
44
  @block = block
46
- @sections = []
45
+ @sections = [] # Where we store the chunks of text that will be assembled into the HTML
46
+ @charts = [] # Where we store all the charts we executed so we can assert against them.
47
47
  end
48
48
 
49
49
  def cycletime label = nil, &block
50
50
  @file_config.project_config.all_boards.each_value do |board|
51
51
  raise 'Multiple cycletimes not supported' if board.cycletime
52
52
 
53
- board.cycletime = CycleTimeConfig.new(parent_config: self, label: label, block: block)
53
+ board.cycletime = CycleTimeConfig.new(parent_config: self, label: label, block: block, file_system: file_system)
54
54
  end
55
55
  end
56
56
 
@@ -64,7 +64,7 @@ class HtmlReportConfig
64
64
 
65
65
  # The quality report has to be generated last because otherwise cycletime won't have been
66
66
  # set. Then we have to rotate it to the first position so it's at the top of the report.
67
- execute_chart DataQualityReport.new(@original_issue_times || {})
67
+ execute_chart DataQualityReport.new(file_config.project_config.discarded_changes_data)
68
68
  @sections.rotate!(-1)
69
69
 
70
70
  html create_footer
@@ -101,9 +101,8 @@ class HtmlReportConfig
101
101
  base_css
102
102
  end
103
103
 
104
- def board_id id = nil
105
- @board_id = id unless id.nil?
106
- @board_id
104
+ def board_id id
105
+ @board_id = id
107
106
  end
108
107
 
109
108
  def timezone_offset
@@ -143,19 +142,6 @@ class HtmlReportConfig
143
142
  end
144
143
  end
145
144
 
146
- def discard_changes_before_hook issues_cutoff_times
147
- # raise 'Cycletime must be defined before using discard_changes_before' unless @cycletime
148
-
149
- @original_issue_times = {}
150
- issues_cutoff_times.each do |issue, cutoff_time|
151
- started = issue.board.cycletime.started_stopped_times(issue).first
152
- if started && started <= cutoff_time
153
- # We only need to log this if data was discarded
154
- @original_issue_times[issue] = { cutoff_time: cutoff_time, started_time: started }
155
- end
156
- end
157
- end
158
-
159
145
  def dependency_chart &block
160
146
  execute_chart DependencyChart.new block
161
147
  end
@@ -175,7 +161,7 @@ class HtmlReportConfig
175
161
  chart.settings = settings
176
162
 
177
163
  chart.all_boards = project_config.all_boards
178
- chart.board_id = find_board_id if chart.respond_to? :board_id=
164
+ chart.board_id = find_board_id
179
165
  chart.holiday_dates = project_config.exporter.holiday_dates
180
166
 
181
167
  time_range = @file_config.project_config.time_range
@@ -184,6 +170,7 @@ class HtmlReportConfig
184
170
 
185
171
  after_init_block&.call chart
186
172
 
173
+ @charts << chart
187
174
  html chart.run
188
175
  end
189
176
 
@@ -216,4 +203,12 @@ class HtmlReportConfig
216
203
  </section>
217
204
  HTML
218
205
  end
206
+
207
+ def discard_changes_before status_becomes: nil, &block
208
+ file_system.deprecated(
209
+ date: '2025-01-09',
210
+ message: 'discard_changes_before is now only supported at the project level'
211
+ )
212
+ file_config.project_config.discard_changes_before status_becomes: status_becomes, &block
213
+ end
219
214
  end
@@ -13,7 +13,12 @@ class Issue
13
13
  @changes = []
14
14
  @board = board
15
15
 
16
+ # We only check for this here because if a board isn't passed in then things will fail much
17
+ # later and be hard to find. Let's find out early.
16
18
  raise "No board for issue #{key}" if board.nil?
19
+
20
+ # There are cases where we create an Issue of fragments like linked issues and those won't have
21
+ # changelogs.
17
22
  return unless @raw['changelog']
18
23
 
19
24
  load_history_into_changes
@@ -93,9 +98,7 @@ class Issue
93
98
 
94
99
  def still_in
95
100
  result = nil
96
- @changes.each do |change|
97
- next unless change.status?
98
-
101
+ status_changes.each do |change|
99
102
  current_status_matched = yield change
100
103
 
101
104
  if current_status_matched && result.nil?
@@ -117,63 +120,71 @@ class Issue
117
120
 
118
121
  # If it ever entered one of these categories and it's still there then what was the last time it entered
119
122
  def still_in_status_category *category_names
123
+ category_ids = find_status_category_ids_by_names category_names
124
+
120
125
  still_in do |change|
121
- status = find_status_by_id change.value_id, name: change.value
122
- category_names.include?(status.category.name) || category_names.include?(status.category.id)
126
+ status = find_or_create_status id: change.value_id, name: change.value
127
+ category_ids.include? status.category.id
123
128
  end
124
129
  end
125
130
 
126
131
  def most_recent_status_change
132
+ # We artificially insert a status change to represent creation so by definition there will always be at least one.
127
133
  changes.reverse.find { |change| change.status? }
128
134
  end
129
135
 
130
136
  # Are we currently in this status? If yes, then return the most recent status change.
131
137
  def currently_in_status *status_names
132
138
  change = most_recent_status_change
133
- return false if change.nil?
134
139
 
135
140
  change if change.current_status_matches(*status_names)
136
141
  end
137
142
 
138
143
  # Are we currently in this status category? If yes, then return the most recent status change.
139
144
  def currently_in_status_category *category_names
145
+ category_ids = find_status_category_ids_by_names category_names
146
+
140
147
  change = most_recent_status_change
141
- return false if change.nil?
142
148
 
143
- status = find_status_by_id change.value_id, name: change.value
144
- change if status && category_names.include?(status.category.name)
149
+ status = find_or_create_status id: change.value_id, name: change.value
150
+ change if status && category_ids.include?(status.category.id)
145
151
  end
146
152
 
147
- def find_status_by_id id, name: nil
153
+ def find_or_create_status id:, name:
148
154
  status = board.possible_statuses.find_by_id(id)
149
- return status if status
150
155
 
151
- status = board.possible_statuses.fabricate_status_for id: id, name: name
156
+ unless status
157
+ # Have to pull this list before the call to fabricate or else the warning will incorrectly
158
+ # list this status as one it actually found
159
+ found_statuses = board.possible_statuses.to_s
160
+
161
+ status = board.possible_statuses.fabricate_status_for id: id, name: name
152
162
 
153
- message = +'The history for issue '
154
- message << key
155
- message << ' references a status ('
156
- message << name.inspect << ':' if name
157
- message << id.to_s
158
- message << ') that can\'t be found in ['
159
- message << board.possible_statuses.collect(&:to_s).join(', ')
160
- message << "]. We are guessing that this belongs to the #{status.category} status category "
161
- message << 'and that may be wrong. See https://jirametrics.org/faq/#q1 for more details'
162
- board.project_config.file_system.warning message
163
+ message = +'The history for issue '
164
+ message << key
165
+ message << ' references the status ('
166
+ message << "#{name.inspect}:#{id.inspect}"
167
+ message << ') that can\'t be found. We are guessing that this belongs to the '
168
+ message << status.category.to_s
169
+ message << ' status category but that may be wrong. See https://jirametrics.org/faq/#q1 for more '
170
+ message << 'details on defining statuses.'
171
+ board.project_config.file_system.warning message, more: "The statuses we did find are: #{found_statuses}"
172
+ end
163
173
 
164
174
  status
165
175
  end
166
176
 
167
177
  def first_status_change_after_created
168
- @changes.find { |change| change.status? && change.artificial? == false }
178
+ status_changes.find { |change| change.artificial? == false }
169
179
  end
170
180
 
171
181
  def first_time_in_status_category *category_names
172
- @changes.each do |change|
173
- next unless change.status?
182
+ category_ids = find_status_category_ids_by_names category_names
174
183
 
175
- category = find_status_by_id(change.value_id).category.name
176
- return change if category_names.include? category
184
+ status_changes.each do |change|
185
+ to_status = find_or_create_status(id: change.value_id, name: change.value)
186
+ id = to_status.category.id
187
+ return change if category_ids.include? id
177
188
  end
178
189
  nil
179
190
  end
@@ -645,6 +656,10 @@ class Issue
645
656
  end
646
657
  end
647
658
 
659
+ def status_changes
660
+ @changes.select { |change| change.status? }
661
+ end
662
+
648
663
  private
649
664
 
650
665
  def assemble_author raw
@@ -720,4 +735,13 @@ class Issue
720
735
  'toString' => first_status
721
736
  }
722
737
  end
738
+
739
+ def find_status_category_ids_by_names category_names
740
+ category_names.filter_map do |name|
741
+ list = board.possible_statuses.find_all_categories_by_name name
742
+ raise "No status categories found for name: #{name}" if list.empty?
743
+
744
+ list
745
+ end.flatten.collect(&:id)
746
+ end
723
747
  end
@@ -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
12
10
  attr_accessor :time_range, :jira_url, :id
13
11
 
14
12
  def initialize exporter:, jira_config:, block:, target_path: '.', name: '', id: nil
@@ -32,14 +30,13 @@ class ProjectConfig
32
30
  end
33
31
 
34
32
  def data_downloaded?
35
- File.exist? File.join(@target_path, "#{get_file_prefix}_meta.json")
33
+ file_system.file_exist? File.join(@target_path, "#{get_file_prefix}_meta.json")
36
34
  end
37
35
 
38
36
  def load_data
39
37
  return if @has_loaded_data
40
38
 
41
39
  @has_loaded_data = true
42
- load_all_boards
43
40
  @id = guess_project_id
44
41
  load_project_metadata
45
42
  load_sprints
@@ -53,16 +50,13 @@ class ProjectConfig
53
50
 
54
51
  return if load_only
55
52
 
56
- @board_configs.each do |board_config|
57
- board_config.run
58
- end
59
53
  @file_configs.each do |file_config|
60
54
  file_config.run
61
55
  end
62
56
  end
63
57
 
64
58
  def load_settings
65
- # This is the wierd exception that we don't ever want mocked out so we skip FileSystem entirely.
59
+ # This is the weird exception that we don't ever want mocked out so we skip FileSystem entirely.
66
60
  JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
67
61
  end
68
62
 
@@ -112,6 +106,7 @@ class ProjectConfig
112
106
 
113
107
  def board id:, &block
114
108
  config = BoardConfig.new(id: id, block: block, project_config: self)
109
+ config.run if data_downloaded?
115
110
  @board_configs << config
116
111
  end
117
112
 
@@ -128,6 +123,8 @@ class ProjectConfig
128
123
  # is set but before anything inside the project block is run. If only we had made file_prefix an attribute
129
124
  # on project, we wouldn't have this ugliness. 🤷‍♂️
130
125
  load_status_category_mappings
126
+ load_status_history
127
+ load_all_boards
131
128
 
132
129
  @file_prefix
133
130
  end
@@ -138,29 +135,28 @@ class ProjectConfig
138
135
  @file_prefix
139
136
  end
140
137
 
141
- def status_guesses
142
- statuses = {}
138
+ # Walk across all the issues and find any status with that name. Return a list of ids that match.
139
+ def find_ids_by_status_name_across_all_issues name
140
+ ids = Set.new
141
+
143
142
  issues.each do |issue|
144
143
  issue.changes.each do |change|
145
144
  next unless change.status?
146
145
 
147
- (statuses[change.value] ||= Set.new) << change.value_id
148
- (statuses[change.old_value] ||= Set.new) << change.old_value_id if change.old_value
146
+ ids << change.value_id.to_i if change.value == name
147
+ ids << change.old_value_id.to_i if change.old_value == name
149
148
  end
150
149
  end
151
- statuses
150
+ ids.to_a
152
151
  end
153
152
 
154
153
  def status_category_mapping status:, category:
155
- # Status might just be a name like "Review" or it might be a name:id pair like "Review:4"
156
- parse_status = ->(line) { line =~ /^(.+):(\d+)$/ ? [$1, $2.to_i] : [line, nil] }
157
-
158
- status, status_id = parse_status.call status
159
- category, category_id = parse_status.call category
154
+ status, status_id = possible_statuses.parse_name_id status
155
+ category, category_id = possible_statuses.parse_name_id category
160
156
 
161
157
  if status_id.nil?
162
- guesses = status_guesses[status]
163
- if guesses.nil?
158
+ guesses = find_ids_by_status_name_across_all_issues status
159
+ if guesses.empty?
164
160
  file_system.warning "For status_category_mapping status: #{status.inspect}, category: #{category.inspect}\n" \
165
161
  "Cannot guess status id for #{status.inspect} as no statuses found anywhere in the issues " \
166
162
  "histories with that name. Since we can't find it, you probably don't need this mapping anymore so we're " \
@@ -174,21 +170,31 @@ class ProjectConfig
174
170
  end
175
171
 
176
172
  status_id = guesses.first
177
- # TODO: This should be a deprecation instead but we can't currently assert against those
178
173
  file_system.log "status_category_mapping for #{status.inspect} has been mapped to id #{status_id}. " \
179
174
  "If that's incorrect then specify the status_id."
180
175
  end
181
176
 
182
- found_category = possible_statuses.find_category_by_name category
183
- found_category_id = found_category&.id # possible_statuses.find_category_id_by_name category
184
- if category_id && category_id != found_category_id
185
- raise "ID is incorrect for status category #{category.inspect}. Did you mean #{found_category_id}?"
177
+ possible_categories = possible_statuses.find_all_categories_by_name category
178
+ if possible_categories.empty?
179
+ all = possible_statuses.find_all_categories.join(', ')
180
+ raise "No status categories found for name #{category.inspect} in [#{all}]. " \
181
+ 'Either fix the name or add an ID.'
182
+ elsif possible_categories.size > 1
183
+ # Theoretically impossible and yet we've seen wierder things out of Jira so we're prepared.
184
+ raise "More than one status category found with the name #{category.inspect} in " \
185
+ "[#{possible_categories.join(', ')}]. Either fix the name or add an ID"
186
+ end
187
+
188
+ found_category = possible_categories.first
189
+
190
+ if category_id && category_id != found_category.id
191
+ raise "ID is incorrect for status category #{category.inspect}. Did you mean #{found_category.id}?"
186
192
  end
187
193
 
188
194
  add_possible_status(
189
195
  Status.new(
190
196
  name: status, id: status_id,
191
- category_name: category, category_id: found_category_id, category_key: found_category.key
197
+ category_name: category, category_id: found_category.id, category_key: found_category.key
192
198
  )
193
199
  )
194
200
  end
@@ -211,7 +217,14 @@ class ProjectConfig
211
217
  end
212
218
 
213
219
  # We're registering one we already knew about. This may happen if someone specified a status_category_mapping
214
- # for something that was already returned from jira. This isn't problem so just ignore it.
220
+ # for something that was already returned from jira.
221
+ #
222
+ # You may be looking at this code and thinking of changing it to spit out a warning since obviously
223
+ # the user has made a mistake. Unfortunately, they may not have made any mistake. Due to inconsistency with the
224
+ # status API, it's possible for two different people to make a request to the same API at the same time and get
225
+ # back a different set of statuses. So that means that some people might need more status/categories mappings than
226
+ # other people for exactly the same instance. See this article for more on that API:
227
+ # https://agiletechnicalexcellence.com/2024/04/12/jira-api-statuses.html
215
228
  existing_status
216
229
  end
217
230
 
@@ -222,7 +235,6 @@ class ProjectConfig
222
235
  board_id = $1.to_i
223
236
  load_board board_id: board_id, filename: "#{@target_path}#{file}"
224
237
  end
225
- raise "No boards found for #{get_file_prefix.inspect} in #{@target_path.inspect}" if @all_boards.empty?
226
238
  end
227
239
 
228
240
  def load_board board_id:, filename:
@@ -235,9 +247,7 @@ class ProjectConfig
235
247
 
236
248
  def load_status_category_mappings
237
249
  filename = File.join @target_path, "#{get_file_prefix}_statuses.json"
238
-
239
- # We may not always have this file. Load it if we can.
240
- return unless File.exist? filename
250
+ return unless file_system.file_exist? filename
241
251
 
242
252
  file_system
243
253
  .load_json(filename)
@@ -245,6 +255,22 @@ class ProjectConfig
245
255
  .each { |status| add_possible_status status }
246
256
  end
247
257
 
258
+ def load_status_history
259
+ filename = File.join @target_path, "#{get_file_prefix}_status_history.json"
260
+ return unless file_system.file_exist? filename
261
+
262
+ file_system.log ' Loading historical statuses', also_write_to_stderr: true
263
+ file_system
264
+ .load_json(filename)
265
+ .map { |snippet| Status.from_raw(snippet) }
266
+ .each { |status| possible_statuses.historical_status_mappings[status.to_s] = status.category }
267
+
268
+ possible_statuses
269
+ rescue => e # rubocop:disable Style/RescueStandardError
270
+ file_system.warning "Unable to load status history due to #{e.message.inspect}. If this is because of a " \
271
+ 'malformed file then it should be fixed on the next download.'
272
+ end
273
+
248
274
  def load_sprints
249
275
  file_system.foreach(@target_path) do |file|
250
276
  next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
@@ -478,21 +504,52 @@ class ProjectConfig
478
504
  Anonymizer.new(project_config: self).run
479
505
  end
480
506
 
481
- def discard_changes_before_hook issues_cutoff_times
482
- issues_cutoff_times.each do |issue, cutoff_time|
483
- days = (cutoff_time.to_date - issue.changes.first.time.to_date).to_i + 1
484
- message = "#{issue.key}(#{issue.type}) discarding #{days} "
485
- if days == 1
486
- message << "day of data on #{cutoff_time.to_date}"
487
- else
488
- message << "days of data from #{issue.changes.first.time.to_date} to #{cutoff_time.to_date}"
507
+ def file_system
508
+ @exporter.file_system
509
+ end
510
+
511
+ def discard_changes_before status_becomes: nil, &block
512
+ if status_becomes
513
+ status_becomes = [status_becomes] unless status_becomes.is_a? Array
514
+
515
+ block = lambda do |issue|
516
+ trigger_statuses = status_becomes.collect do |status_name|
517
+ if status_name == :backlog
518
+ issue.board.backlog_statuses
519
+ else
520
+ possible_statuses.find_all_by_name status_name
521
+ end
522
+ end.flatten
523
+
524
+ next if trigger_statuses.empty?
525
+
526
+ trigger_status_ids = trigger_statuses.collect(&:id)
527
+
528
+ time = nil
529
+ issue.status_changes.each do |change|
530
+ time = change.time if trigger_status_ids.include?(change.value_id) # && change.artificial? == false
531
+ end
532
+ time
489
533
  end
490
- exporter.file_system.log message
491
534
  end
492
- exporter.file_system.log "Discarded data from #{issues_cutoff_times.count} issues out of a total #{issues.size}"
493
- end
494
535
 
495
- def file_system
496
- @exporter.file_system
536
+ issues.each do |issue|
537
+ cutoff_time = block.call(issue)
538
+ next if cutoff_time.nil?
539
+
540
+ original_start_time = issue.board.cycletime.started_stopped_times(issue).first
541
+ next if original_start_time.nil?
542
+
543
+ issue.discard_changes_before cutoff_time
544
+
545
+ next unless cutoff_time
546
+ next if original_start_time > cutoff_time # ie the cutoff would have made no difference.
547
+
548
+ (@discarded_changes_data ||= []) << {
549
+ cutoff_time: cutoff_time,
550
+ original_start_time: original_start_time,
551
+ issue: issue
552
+ }
553
+ end
497
554
  end
498
555
  end
@@ -3,7 +3,6 @@
3
3
  require 'jirametrics/value_equality'
4
4
 
5
5
  class Status
6
- include ValueEquality
7
6
  attr_reader :id, :project_id, :category
8
7
  attr_accessor :name
9
8
 
@@ -20,6 +19,17 @@ class Status
20
19
  "#{name.inspect}:#{id.inspect}"
21
20
  end
22
21
 
22
+ def <=> other
23
+ id <=> other.id
24
+ end
25
+
26
+ def == other
27
+ id == other.id
28
+ end
29
+
30
+ def eql?(other) = id.eql?(other.id)
31
+ def hash = id.hash
32
+
23
33
  def new? = (@key == 'new')
24
34
  def indeterminate? = (@key == 'indeterminate')
25
35
  def done? = (@key == 'done')
@@ -74,9 +84,21 @@ class Status
74
84
  end
75
85
 
76
86
  def == other
87
+ return false unless other.is_a? Status
88
+
77
89
  @id == other.id && @name == other.name && @category.id == other.category.id && @category.name == other.category.name
78
90
  end
79
91
 
92
+ def eql?(other)
93
+ self == other
94
+ end
95
+
96
+ def <=> other
97
+ result = @name.casecmp(other.name)
98
+ result = @id <=> other.id if result.zero?
99
+ result
100
+ end
101
+
80
102
  def inspect
81
103
  result = []
82
104
  result << "Status(name: #{@name.inspect}"