jirametrics 2.8 → 2.10

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +1 -1
  3. data/lib/jirametrics/aging_work_bar_chart.rb +7 -5
  4. data/lib/jirametrics/board.rb +2 -3
  5. data/lib/jirametrics/board_config.rb +6 -2
  6. data/lib/jirametrics/chart_base.rb +36 -9
  7. data/lib/jirametrics/cycletime_config.rb +10 -4
  8. data/lib/jirametrics/cycletime_histogram.rb +65 -2
  9. data/lib/jirametrics/data_quality_report.rb +53 -34
  10. data/lib/jirametrics/downloader.rb +0 -14
  11. data/lib/jirametrics/examples/standard_project.rb +2 -2
  12. data/lib/jirametrics/exporter.rb +10 -20
  13. data/lib/jirametrics/file_config.rb +21 -4
  14. data/lib/jirametrics/file_system.rb +23 -4
  15. data/lib/jirametrics/groupable_issue_chart.rb +1 -3
  16. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
  17. data/lib/jirametrics/html/aging_work_table.erb +1 -1
  18. data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
  19. data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
  20. data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
  21. data/lib/jirametrics/html/expedited_chart.erb +1 -10
  22. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  23. data/lib/jirametrics/html/sprint_burndown.erb +1 -10
  24. data/lib/jirametrics/html/throughput_chart.erb +1 -10
  25. data/lib/jirametrics/html_report_config.rb +18 -23
  26. data/lib/jirametrics/issue.rb +51 -27
  27. data/lib/jirametrics/jira_gateway.rb +16 -3
  28. data/lib/jirametrics/project_config.rb +102 -45
  29. data/lib/jirametrics/status.rb +23 -7
  30. data/lib/jirametrics/status_collection.rb +69 -68
  31. data/lib/jirametrics/value_equality.rb +2 -2
  32. data/lib/jirametrics.rb +0 -1
  33. metadata +4 -9
  34. data/lib/jirametrics/discard_changes_before.rb +0 -37
@@ -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')
@@ -28,12 +38,6 @@ class Status
28
38
  def self.from_raw raw
29
39
  category_config = raw['statusCategory']
30
40
 
31
- legal_keys = %w[new indeterminate done]
32
- unless legal_keys.include? category_config['key']
33
- puts "Category key #{category_config['key'].inspect} should be one of #{legal_keys.inspect}. Found:\n" \
34
- "#{category_config}"
35
- end
36
-
37
41
  Status.new(
38
42
  name: raw['name'],
39
43
  id: raw['id'].to_i,
@@ -74,9 +78,21 @@ class Status
74
78
  end
75
79
 
76
80
  def == other
81
+ return false unless other.is_a? Status
82
+
77
83
  @id == other.id && @name == other.name && @category.id == other.category.id && @category.name == other.category.name
78
84
  end
79
85
 
86
+ def eql?(other)
87
+ self == other
88
+ end
89
+
90
+ def <=> other
91
+ result = @name.casecmp(other.name)
92
+ result = @id <=> other.id if result.zero?
93
+ result
94
+ end
95
+
80
96
  def inspect
81
97
  result = []
82
98
  result << "Status(name: #{@name.inspect}"
@@ -4,105 +4,106 @@ class StatusNotFoundError < StandardError
4
4
  end
5
5
 
6
6
  class StatusCollection
7
+ attr_reader :historical_status_mappings
8
+
7
9
  def initialize
8
10
  @list = []
11
+ @historical_status_mappings = {} # 'name:id' => category
9
12
  end
10
13
 
11
- def filter_status_names category_name:, including: nil, excluding: nil
12
- including = expand_statuses including
13
- excluding = expand_statuses excluding
14
+ # Return the status matching this id or nil if it can't be found.
15
+ def find_by_id id
16
+ @list.find { |status| status.id == id }
17
+ end
14
18
 
15
- @list.filter_map do |status|
16
- keep = status.category.name == category_name ||
17
- including.any? { |s| s.name == status.name }
18
- keep = false if excluding.any? { |s| s.name == status.name }
19
+ def find_all_by_name identifier
20
+ name, id = parse_name_id identifier
19
21
 
20
- status.name if keep
21
- end
22
- end
22
+ if id
23
+ status = find_by_id id
24
+ return [] if status.nil?
23
25
 
24
- def expand_statuses names_or_ids
25
- result = []
26
- return result if names_or_ids.nil?
27
-
28
- names_or_ids = [names_or_ids] unless names_or_ids.is_a? Array
29
-
30
- names_or_ids.each do |name_or_id|
31
- status = @list.find { |s| s.name == name_or_id || s.id == name_or_id }
32
- if status.nil?
33
- if block_given?
34
- yield name_or_id
35
- next
36
- else
37
- all_status_names = @list.collect { |s| "#{s.name.inspect}:#{s.id.inspect}" }.uniq.sort.join(', ')
38
- raise StatusNotFoundError, "Status not found: \"#{name_or_id}\". Possible statuses are: #{all_status_names}"
39
- end
26
+ if name && status.name != name
27
+ raise "Specified status ID of #{id} does not match specified name #{name.inspect}. " \
28
+ "You might have meant one of these: #{self}."
40
29
  end
41
-
42
- result << status
30
+ [status]
31
+ else
32
+ @list.select { |status| status.name == name }
43
33
  end
44
- result
45
34
  end
46
35
 
47
- def todo including: nil, excluding: nil
48
- filter_status_names category_name: 'To Do', including: including, excluding: excluding
36
+ def find_all_categories
37
+ @list
38
+ .collect(&:category)
39
+ .uniq
40
+ .sort_by(&:id)
49
41
  end
50
42
 
51
- def in_progress including: nil, excluding: nil
52
- filter_status_names category_name: 'In Progress', including: including, excluding: excluding
43
+ def parse_name_id name
44
+ # Names could arrive in one of the following formats: "Done:3", "3", "Done"
45
+ if name =~ /^(.*):(\d+)$/
46
+ [$1, $2.to_i]
47
+ elsif name.match?(/^\d+$/)
48
+ [nil, name.to_i]
49
+ else
50
+ [name, nil]
51
+ end
53
52
  end
54
53
 
55
- def done including: nil, excluding: nil
56
- filter_status_names category_name: 'Done', including: including, excluding: excluding
57
- end
54
+ def find_all_categories_by_name identifier
55
+ key = nil
56
+ id = nil
58
57
 
59
- # Return the status matching this id or nil if it can't be found.
60
- def find_by_id id
61
- @list.find { |status| status.id == id }
58
+ if identifier.is_a? Symbol
59
+ key = identifier.to_s
60
+ else
61
+ name, id = parse_name_id identifier
62
+ end
63
+
64
+ find_all_categories.select { |c| c.id == id || c.name == name || c.key == key }
62
65
  end
63
66
 
64
- def find_all_by_name name
65
- @list.select { |status| status.name == name }
67
+ def collect(&block) = @list.collect(&block)
68
+ def find(&block) = @list.find(&block)
69
+ def each(&block) = @list.each(&block)
70
+ def select(&block) = @list.select(&block)
71
+ def <<(arg) = @list << arg
72
+ def empty? = @list.empty?
73
+ def clear = @list.clear
74
+ def delete(object) = @list.delete(object)
75
+
76
+ def to_s
77
+ "[#{@list.sort.join(', ')}]"
66
78
  end
67
79
 
68
- def find_category_by_name name
69
- category = @list.find { |status| status.category.name == name }&.category
70
- unless category
71
- set = Set.new
72
- @list.each do |status|
73
- set << status.category.to_s
74
- end
75
- raise "Unable to find status category #{name.inspect} in [#{set.to_a.sort.join(', ')}]"
76
- end
77
- category
80
+ def inspect
81
+ "StatusCollection#{self}"
78
82
  end
79
83
 
80
- # This is used to create a status that was found in the history but has since been deleted.
81
84
  def fabricate_status_for id:, name:
82
- first_in_progress_status = @list.find { |s| s.category.indeterminate? }
83
- raise "Can't find even one in-progress status in [#{set.to_a.sort.join(', ')}]" unless first_in_progress_status
85
+ category = @historical_status_mappings["#{name.inspect}:#{id.inspect}"]
86
+ category = in_progress_category if category.nil?
84
87
 
85
88
  status = Status.new(
86
89
  name: name,
87
90
  id: id,
88
- category_name: first_in_progress_status.category.name,
89
- category_id: first_in_progress_status.category.id,
90
- category_key: first_in_progress_status.category.key
91
+ category_name: category.name,
92
+ category_id: category.id,
93
+ category_key: category.key,
94
+ artificial: true
91
95
  )
92
- self << status
96
+ @list << status
93
97
  status
94
98
  end
95
99
 
96
- def collect(&block) = @list.collect(&block)
97
- def find(&block) = @list.find(&block)
98
- def each(&block) = @list.each(&block)
99
- def select(&block) = @list.select(&block)
100
- def <<(arg) = @list << arg
101
- def empty? = @list.empty?
102
- def clear = @list.clear
103
- def delete(object) = @list.delete(object)
100
+ private
104
101
 
105
- def inspect
106
- "StatusCollection(#{@list.join(', ')})"
102
+ # Return the in-progress category or raise an error if we can't find one.
103
+ def in_progress_category
104
+ first_in_progress_status = find { |s| s.category.indeterminate? }
105
+ raise "Can't find even one in-progress status in #{self}" unless first_in_progress_status
106
+
107
+ first_in_progress_status.category
107
108
  end
108
109
  end
@@ -9,9 +9,9 @@ module ValueEquality
9
9
  names = object.instance_variables
10
10
  if object.respond_to? :value_equality_ignored_variables
11
11
  ignored_variables = object.value_equality_ignored_variables
12
- names.reject! { |n| ignored_variables.include? n }
12
+ names.reject! { |n| ignored_variables.include? n.to_sym }
13
13
  end
14
- names.map { |variable| instance_variable_get variable }
14
+ names.map { |variable| object.instance_variable_get variable }
15
15
  end
16
16
 
17
17
  code.call(self) == code.call(other)
data/lib/jirametrics.rb CHANGED
@@ -68,7 +68,6 @@ class JiraMetrics < Thor
68
68
  require 'jirametrics/grouping_rules'
69
69
  require 'jirametrics/daily_wip_chart'
70
70
  require 'jirametrics/groupable_issue_chart'
71
- require 'jirametrics/discard_changes_before'
72
71
  require 'jirametrics/css_variable'
73
72
 
74
73
  require 'jirametrics/aggregate_config'
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jirametrics
3
3
  version: !ruby/object:Gem::Version
4
- version: '2.8'
4
+ version: '2.10'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Bowler
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-12-30 00:00:00.000000000 Z
10
+ date: 2025-02-06 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: random-word
@@ -52,8 +51,7 @@ dependencies:
52
51
  - - "~>"
53
52
  - !ruby/object:Gem::Version
54
53
  version: 1.2.2
55
- description: Tool to extract metrics from Jira and export to either a report or to
56
- CSV files
54
+ description: Extract metrics from Jira and export to either a report or to CSV files
57
55
  email: mbowler@gargoylesoftware.com
58
56
  executables:
59
57
  - jirametrics
@@ -84,7 +82,6 @@ files:
84
82
  - lib/jirametrics/daily_wip_chart.rb
85
83
  - lib/jirametrics/data_quality_report.rb
86
84
  - lib/jirametrics/dependency_chart.rb
87
- - lib/jirametrics/discard_changes_before.rb
88
85
  - lib/jirametrics/download_config.rb
89
86
  - lib/jirametrics/downloader.rb
90
87
  - lib/jirametrics/estimate_accuracy_chart.rb
@@ -139,7 +136,6 @@ metadata:
139
136
  bug_tracker_uri: https://github.com/mikebowler/jirametrics/issues
140
137
  changelog_uri: https://jirametrics.org/changes
141
138
  documentation_uri: https://jirametrics.org
142
- post_install_message:
143
139
  rdoc_options: []
144
140
  require_paths:
145
141
  - lib
@@ -154,8 +150,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
154
150
  - !ruby/object:Gem::Version
155
151
  version: '0'
156
152
  requirements: []
157
- rubygems_version: 3.5.21
158
- signing_key:
153
+ rubygems_version: 3.6.2
159
154
  specification_version: 4
160
155
  summary: Extract Jira metrics
161
156
  test_files: []
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module DiscardChangesBefore
4
- def discard_changes_before status_becomes: nil, &block
5
- if status_becomes
6
- status_becomes = [status_becomes] unless status_becomes.is_a? Array
7
-
8
- block = lambda do |issue|
9
- trigger_statuses = status_becomes.collect do |status_name|
10
- if status_name == :backlog
11
- issue.board.backlog_statuses.collect(&:name)
12
- else
13
- status_name
14
- end
15
- end.flatten
16
-
17
- time = nil
18
- issue.changes.each do |change|
19
- time = change.time if change.status? && trigger_statuses.include?(change.value) && change.artificial? == false
20
- end
21
- time
22
- end
23
- end
24
-
25
- issues_cutoff_times = []
26
- issues.each do |issue|
27
- cutoff_time = block.call(issue)
28
- issues_cutoff_times << [issue, cutoff_time] if cutoff_time
29
- end
30
-
31
- discard_changes_before_hook issues_cutoff_times
32
-
33
- issues_cutoff_times.each do |issue, cutoff_time|
34
- issue.discard_changes_before cutoff_time
35
- end
36
- end
37
- end