jirametrics 2.7.3 → 2.8

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed5733b75d3ab3091973a5919269c467e6657a1a7699a6e5e4acb4139f9a292b
4
- data.tar.gz: 3e750fe3a053fe9d8b5c30099afc42d1c50187c1d749f922d29bd91eb2f60241
3
+ metadata.gz: f5bec8084c47e2383d25676b969891c6fc8e427d06427a1caf195378a830ff4f
4
+ data.tar.gz: e9364c2b43f66d90a651f50e115751c144f8dfc6f7d247b47dfd17495bdf167e
5
5
  SHA512:
6
- metadata.gz: 9f97b93668b57a3f5876979daa14631c1f75f6d6294b49ae58f57b2d60e21b36020c64aef8d7affa59e03796e8c7b3e5480546e3a540372d20c3152764d8a6a0
7
- data.tar.gz: ed479b302392340ca02a7f58d4ac0e5f27178ff23fc35956ebeb8659af91454ca8d7e752aacbcad3bef942c49bc24f03dca332760b582b23c782cb5824681651
6
+ metadata.gz: 82c740fa8d23565eb33edf1e244cde1b8b8ef2d88f753091fd0f3cfb7e20aa67c32fc22dca70d4076c25ad40898a0872c13f8af14173a26b33054faf8838df14
7
+ data.tar.gz: 66d5db7495165aa4ac0ea36acd27e1b77d2d4b4d4ebb7912fbbffee57c9c160b8cee3fc919884564370db51618438d24fc6fde5e47e2343560aca52b53917d4b
@@ -41,7 +41,7 @@ class AggregateConfig
41
41
  def include_issues_from project_name
42
42
  project = @project_config.exporter.project_configs.find { |p| p.name == project_name }
43
43
  if project.nil?
44
- log "Warning: Aggregated project #{@project_config.name.inspect} is attempting to load " \
44
+ file_system.warning "Aggregated project #{@project_config.name.inspect} is attempting to load " \
45
45
  "project #{project_name.inspect} but it can't be found. Is it disabled?"
46
46
  return
47
47
  end
@@ -86,7 +86,7 @@ class AggregateConfig
86
86
 
87
87
  private
88
88
 
89
- def log message
90
- @project_config.exporter.file_system.log message
89
+ def file_system
90
+ @project_config.exporter.file_system
91
91
  end
92
92
  end
@@ -116,7 +116,7 @@ class AgingWorkBarChart < ChartBase
116
116
  issue.changes.each do |change|
117
117
  next unless change.status?
118
118
 
119
- status = issue.find_status_by_name change.value
119
+ status = issue.find_status_by_id change.value_id, name: change.value
120
120
 
121
121
  unless previous_start.nil? || previous_start < issue_started_time
122
122
  hash = {
@@ -113,7 +113,7 @@ class AgingWorkInProgressChart < ChartBase
113
113
 
114
114
  def ages_of_issues_that_crossed_column_boundary issues:, status_ids:
115
115
  issues.filter_map do |issue|
116
- stop = issue.first_time_in_status(*status_ids)
116
+ stop = issue.first_time_in_status(*status_ids)&.to_time
117
117
  start, = issue.board.cycletime.started_stopped_times(issue)
118
118
 
119
119
  # Skip if either it hasn't crossed the boundary or we can't tell when it started.
@@ -4,7 +4,7 @@ class Board
4
4
  attr_reader :visible_columns, :raw, :possible_statuses, :sprints, :board_type
5
5
  attr_accessor :cycletime, :project_config
6
6
 
7
- def initialize raw:, possible_statuses: StatusCollection.new
7
+ def initialize raw:, possible_statuses:
8
8
  @raw = raw
9
9
  @board_type = raw['type']
10
10
  @possible_statuses = possible_statuses
@@ -1,16 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ChangeItem
4
- attr_reader :field, :value_id, :old_value_id, :raw, :author
5
- attr_accessor :value, :old_value, :time
4
+ attr_reader :field, :value_id, :old_value_id, :raw, :author, :time
5
+ attr_accessor :value, :old_value
6
6
 
7
7
  def initialize raw:, time:, author:, artificial: false
8
8
  @raw = raw
9
9
  @time = time
10
- raise "Time must be an object of type Time in the correct timezone: #{@time}" if @time.is_a? String
10
+ raise 'ChangeItem.new() time cannot be nil' if time.nil?
11
+ raise "Time must be an object of type Time in the correct timezone: #{@time.inspect}" unless @time.is_a? Time
11
12
 
12
- @field = field || @raw['field']
13
- @value = value || @raw['toString']
13
+ @field = @raw['field']
14
+ @value = @raw['toString']
14
15
  @value_id = @raw['to'].to_i
15
16
  @old_value = @raw['fromString']
16
17
  @old_value_id = @raw['from']&.to_i
@@ -34,6 +35,11 @@ class ChangeItem
34
35
 
35
36
  def link? = (field == 'Link')
36
37
 
38
+ def labels? = (field == 'labels')
39
+
40
+ # An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
41
+ def to_time = @time
42
+
37
43
  def to_s
38
44
  message = +''
39
45
  message << "ChangeItem(field: #{field.inspect}, value: #{value.inspect}, time: #{time_to_s(@time).inspect}"
@@ -169,13 +169,13 @@ class ChartBase
169
169
  end
170
170
  end
171
171
 
172
- def header_text text = nil
173
- @header_text = text if text
172
+ def header_text text = :none
173
+ @header_text = text unless text == :none
174
174
  @header_text
175
175
  end
176
176
 
177
- def description_text text = nil
178
- @description_text = text if text
177
+ def description_text text = :none
178
+ @description_text = text unless text == :none
179
179
  @description_text
180
180
  end
181
181
 
@@ -200,8 +200,8 @@ class ChartBase
200
200
  icon: ' 👀'
201
201
  )
202
202
  end
203
- text = is_category ? status.category_name : status.name
204
- "<span title='Category: #{status.category_name}'>#{color_block color.name} #{text}</span>#{visibility}"
203
+ text = is_category ? status.category.name : status.name
204
+ "<span title='Category: #{status.category.name}'>#{color_block color.name} #{text}</span>#{visibility}"
205
205
  end
206
206
 
207
207
  def icon_span title:, icon:
@@ -209,11 +209,11 @@ class ChartBase
209
209
  end
210
210
 
211
211
  def status_category_color status
212
- case status.category_name
213
- when 'To Do' then CssVariable['--status-category-todo-color']
214
- when 'In Progress' then CssVariable['--status-category-inprogress-color']
215
- when 'Done' then CssVariable['--status-category-done-color']
216
- else 'black' # Theoretically impossible but seen in prod.
212
+ case status.category.key
213
+ when 'new' then CssVariable['--status-category-todo-color']
214
+ when 'indeterminate' then CssVariable['--status-category-inprogress-color']
215
+ when 'done' then CssVariable['--status-category-done-color']
216
+ else CssVariable['--status-category-unknown-color'] # Theoretically impossible but seen in prod.
217
217
  end
218
218
  end
219
219
 
@@ -44,18 +44,44 @@ class CycleTimeConfig
44
44
  started_stopped_times(issue).last
45
45
  end
46
46
 
47
- def started_stopped_times issue
47
+ def fabricate_change_item time
48
+ deprecated date: '2024-12-16', message: 'This method should now return a ChangeItem not a Time', depth: 4
49
+ raw = {
50
+ 'field' => 'Fabricated change',
51
+ 'to' => '0',
52
+ 'toString' => '',
53
+ 'from' => '0',
54
+ 'fromString' => ''
55
+ }
56
+ ChangeItem.new raw: raw, time: time, author: 'unknown', artificial: true
57
+ end
58
+
59
+ def started_stopped_changes issue
48
60
  started = @start_at.call(issue)
49
61
  stopped = @stop_at.call(issue)
50
62
 
63
+ # Obscure edge case where some of the start_at and stop_at blocks might return false in place of nil.
64
+ # If they are false then explicitly make them nil.
65
+ started ||= nil
66
+ stopped ||= nil
67
+
68
+ # These are only here for backwards compatibility. Hopefully nobody will ever need them.
69
+ started = fabricate_change_item(started) if !started.nil? && !started.is_a?(ChangeItem)
70
+ stopped = fabricate_change_item(stopped) if !stopped.nil? && !stopped.is_a?(ChangeItem)
71
+
51
72
  # In the case where started and stopped are exactly the same time, we pretend that
52
73
  # it just stopped and never started. This allows us to have logic like 'in or right of'
53
74
  # for the start and not have it conflict.
54
- started = nil if started == stopped
75
+ started = nil if started&.time == stopped&.time
55
76
 
56
77
  [started, stopped]
57
78
  end
58
79
 
80
+ def started_stopped_times issue
81
+ started, stopped = started_stopped_changes(issue)
82
+ [started&.time, stopped&.time]
83
+ end
84
+
59
85
  def started_stopped_dates issue
60
86
  started_time, stopped_time = started_stopped_times(issue)
61
87
  [started_time&.to_date, stopped_time&.to_date]
@@ -41,6 +41,8 @@ class CycletimeHistogram < ChartBase
41
41
  )
42
42
  end
43
43
 
44
+ return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
45
+
44
46
  wrap_and_render(binding, __FILE__)
45
47
  end
46
48
 
@@ -112,8 +112,8 @@ class DataQualityReport < ChartBase
112
112
  @entries.reject { |entry| entry.problems.empty? }
113
113
  end
114
114
 
115
- def category_name_for status_name:, board:
116
- board.possible_statuses.find { |status| status.name == status_name }&.category_name
115
+ def category_name_for status_id:, board:
116
+ board.possible_statuses.find_by_id(status_id)&.category&.name
117
117
  end
118
118
 
119
119
  def initialize_entries
@@ -184,7 +184,7 @@ class DataQualityReport < ChartBase
184
184
  index = entry.issue.board.visible_columns.find_index { |column| column.status_ids.include? change.value_id }
185
185
  if index.nil?
186
186
  # If it's a backlog status then ignore it. Not supposed to be visible.
187
- next if entry.issue.board.backlog_statuses.include? change.value_id
187
+ next if entry.issue.board.backlog_statuses.include?(board.possible_statuses.find_by_id(change.value_id))
188
188
 
189
189
  detail = "Status #{format_status change.value, board: board} is not on the board"
190
190
  if issue.board.possible_statuses.expand_statuses(change.value).empty?
@@ -198,8 +198,8 @@ class DataQualityReport < ChartBase
198
198
  elsif change.old_value.nil?
199
199
  # Do nothing
200
200
  elsif index < last_index
201
- new_category = category_name_for(status_name: change.value, board: board)
202
- old_category = category_name_for(status_name: change.old_value, board: board)
201
+ new_category = category_name_for(status_id: change.value_id, board: board)
202
+ old_category = category_name_for(status_id: change.old_value_id, board: board)
203
203
 
204
204
  if new_category == old_category
205
205
  entry.report(
@@ -365,8 +365,8 @@ class DataQualityReport < ChartBase
365
365
  if percentage_work_included < 85
366
366
  html << <<-HTML
367
367
  Consider whether looking at only #{percentage_work_included}% of the total data points is enough
368
- to come to any reasonable conclusions. See <a href="https://en.wikipedia.org/wiki/Survivorship_bias">
369
- Survivorship Bias</a>.
368
+ to come to any reasonable conclusions. See <a href="https://unconsciousagile.com/2024/11/19/survivor-bias.html">
369
+ Survivor Bias</a>.
370
370
  HTML
371
371
  end
372
372
  html
@@ -20,8 +20,8 @@ class DownloadConfig
20
20
  @rolling_date_count
21
21
  end
22
22
 
23
- def no_earlier_than date = nil
24
- @no_earlier_than = Date.parse(date) unless date.nil?
23
+ def no_earlier_than date = :not_set
24
+ @no_earlier_than = Date.parse(date) unless date == :not_set
25
25
  @no_earlier_than
26
26
  end
27
27
 
@@ -39,6 +39,7 @@ class Downloader
39
39
  # board_ids = @download_config.board_ids
40
40
 
41
41
  remove_old_files
42
+ update_status_history_file
42
43
  download_statuses
43
44
  find_board_ids.each do |id|
44
45
  board = download_board_configuration board_id: id
@@ -66,7 +67,7 @@ class Downloader
66
67
 
67
68
  def download_issues board:
68
69
  log " Downloading primary issues for board #{board.id}", both: true
69
- path = "#{@target_path}#{@download_config.project_config.file_prefix}_issues/"
70
+ path = File.join(@target_path, "#{file_prefix}_issues/")
70
71
  unless Dir.exist?(path)
71
72
  log " Creating path #{path}"
72
73
  Dir.mkdir(path)
@@ -153,30 +154,58 @@ class Downloader
153
154
 
154
155
  @file_system.save_json(
155
156
  json: json,
156
- filename: "#{@target_path}#{@download_config.project_config.file_prefix}_statuses.json"
157
+ filename: File.join(@target_path, "#{file_prefix}_statuses.json")
157
158
  )
158
159
  end
159
160
 
161
+ def update_status_history_file
162
+ status_filename = File.join(@target_path, "#{file_prefix}_statuses.json")
163
+ return unless file_system.file_exist? status_filename
164
+
165
+ status_json = file_system.load_json(status_filename)
166
+
167
+ history_filename = File.join(@target_path, "#{file_prefix}_status_history.json")
168
+ history_json = file_system.load_json(history_filename) if file_system.file_exist? history_filename
169
+
170
+ if history_json
171
+ file_system.log ' Updating status history file', also_write_to_stderr: true
172
+ else
173
+ file_system.log ' Creating status history file', also_write_to_stderr: true
174
+ history_json = []
175
+ end
176
+
177
+ status_json.each do |status_item|
178
+ id = status_item['id']
179
+ history_item = history_json.find { |s| s['id'] == id }
180
+ history_json.delete(history_item) if history_item
181
+ history_json << status_item
182
+ end
183
+
184
+ file_system.save_json(filename: history_filename, json: history_json)
185
+ end
186
+
160
187
  def download_board_configuration board_id:
161
188
  log " Downloading board configuration for board #{board_id}", both: true
162
189
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
163
190
 
164
191
  exit_if_call_failed json
165
192
 
166
- file_prefix = @download_config.project_config.file_prefix
167
- @file_system.save_json json: json, filename: "#{@target_path}#{file_prefix}_board_#{board_id}_configuration.json"
193
+ @file_system.save_json(
194
+ json: json,
195
+ filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_configuration.json")
196
+ )
168
197
 
169
198
  # We have a reported bug that blew up on this line. Moved it after the save so we can
170
199
  # actually look at the returned json.
171
200
  @board_id_to_filter_id[board_id] = json['filter']['id'].to_i
172
201
 
173
202
  download_sprints board_id: board_id if json['type'] == 'scrum'
174
- Board.new raw: json
203
+ # TODO: Should be passing actual statuses, not empty list
204
+ Board.new raw: json, possible_statuses: StatusCollection.new
175
205
  end
176
206
 
177
207
  def download_sprints board_id:
178
208
  log " Downloading sprints for board #{board_id}", both: true
179
- file_prefix = @download_config.project_config.file_prefix
180
209
  max_results = 100
181
210
  start_at = 0
182
211
  is_last = false
@@ -188,7 +217,7 @@ class Downloader
188
217
 
189
218
  @file_system.save_json(
190
219
  json: json,
191
- filename: "#{@target_path}#{file_prefix}_board_#{board_id}_sprints_#{start_at}.json"
220
+ filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_sprints_#{start_at}.json")
192
221
  )
193
222
  is_last = json['isLast']
194
223
  max_results = json['maxResults']
@@ -201,7 +230,7 @@ class Downloader
201
230
  end
202
231
 
203
232
  def metadata_pathname
204
- "#{@target_path}#{@download_config.project_config.file_prefix}_meta.json"
233
+ File.join(@target_path, "#{file_prefix}_meta.json")
205
234
  end
206
235
 
207
236
  def load_metadata
@@ -244,17 +273,17 @@ class Downloader
244
273
  end
245
274
 
246
275
  def remove_old_files
247
- file_prefix = @download_config.project_config.file_prefix
248
276
  Dir.foreach @target_path do |file|
249
277
  next unless file.match?(/^#{file_prefix}_\d+\.json$/)
278
+ next if file == "#{file_prefix}_status_history.json"
250
279
 
251
- File.unlink "#{@target_path}#{file}"
280
+ File.unlink File.join(@target_path, file)
252
281
  end
253
282
 
254
283
  return if @cached_data_format_is_current
255
284
 
256
285
  # Also throw away all the previously downloaded issues.
257
- path = File.join @target_path, "#{file_prefix}_issues"
286
+ path = File.join(@target_path, "#{file_prefix}_issues")
258
287
  return unless File.exist? path
259
288
 
260
289
  Dir.foreach path do |file|
@@ -292,4 +321,8 @@ class Downloader
292
321
 
293
322
  segments.join ' AND '
294
323
  end
324
+
325
+ def file_prefix
326
+ @download_config.project_config.get_file_prefix
327
+ end
295
328
  end
@@ -11,6 +11,7 @@ class Exporter
11
11
  def aggregated_project name:, project_names:, settings: {}
12
12
  project name: name do
13
13
  puts name
14
+ file_prefix name
14
15
  self.settings.merge! settings
15
16
 
16
17
  aggregate do
@@ -19,8 +20,6 @@ class Exporter
19
20
  end
20
21
  end
21
22
 
22
- file_prefix name
23
-
24
23
  file do
25
24
  file_suffix '.html'
26
25
  issues.reject! do |issue|
@@ -32,7 +31,7 @@ class Exporter
32
31
  board_lines = []
33
32
  included_projects.each do |project|
34
33
  project.all_boards.each_value do |board|
35
- board_lines << "<a href='#{project.file_prefix}.html'>#{board.name}</a> from project #{project.name}"
34
+ board_lines << "<a href='#{project.get_file_prefix}.html'>#{board.name}</a> from project #{project.name}"
36
35
  end
37
36
  end
38
37
  board_lines.sort.each { |line| html "<li>#{line}</li>", type: :header }
@@ -10,15 +10,15 @@ class Exporter
10
10
 
11
11
  project name: name do
12
12
  puts name
13
- self.anonymize if anonymize
13
+ file_prefix file_prefix
14
14
 
15
+ self.anonymize if anonymize
15
16
  self.settings.merge! settings
16
17
 
17
18
  status_category_mappings.each do |status, category|
18
19
  status_category_mapping status: status, category: category
19
20
  end
20
21
 
21
- file_prefix file_prefix
22
22
  download do
23
23
  self.rolling_date_count(rolling_date_count) if rolling_date_count
24
24
  self.no_earlier_than(no_earlier_than) if no_earlier_than
@@ -63,7 +63,7 @@ class ExpeditedChart < ChartBase
63
63
  next unless change.priority?
64
64
 
65
65
  if expedited_priority_names.include? change.value
66
- expedite_start = change.time
66
+ expedite_start = change.time.to_date
67
67
  elsif expedite_start
68
68
  start_date = expedite_start.to_date
69
69
  stop_date = change.time.to_date
@@ -72,7 +72,7 @@ class ExpeditedChart < ChartBase
72
72
  (start_date < date_range.begin && stop_date > date_range.end)
73
73
 
74
74
  result << [expedite_start, :expedite_start]
75
- result << [change.time, :expedite_stop]
75
+ result << [change.time.to_date, :expedite_stop]
76
76
  end
77
77
  expedite_start = nil
78
78
  end
@@ -109,11 +109,11 @@ class ExpeditedChart < ChartBase
109
109
 
110
110
  def make_expedite_lines_data_set issue:, expedite_data:
111
111
  cycletime = issue.board.cycletime
112
- started_time, stopped_time = cycletime.started_stopped_times(issue)
112
+ started_date, stopped_date = cycletime.started_stopped_dates(issue)
113
113
 
114
- expedite_data << [started_time, :issue_started] if started_time
115
- expedite_data << [stopped_time, :issue_stopped] if stopped_time
116
- expedite_data.sort_by! { |a| a[0] }
114
+ expedite_data << [started_date, :issue_started] if started_date
115
+ expedite_data << [stopped_date, :issue_stopped] if stopped_date
116
+ expedite_data.sort_by!(&:first)
117
117
 
118
118
  # If none of the data would be visible on the chart then skip it.
119
119
  return nil unless expedite_data.any? { |time, _action| time.to_date >= date_range.begin }
@@ -150,7 +150,7 @@ class ExpeditedChart < ChartBase
150
150
 
151
151
  unless expedite_data.empty?
152
152
  last_change_time = expedite_data[-1][0].to_date
153
- if last_change_time && last_change_time <= date_range.end && stopped_time.nil?
153
+ if last_change_time && last_change_time <= date_range.end && stopped_date.nil?
154
154
  data << make_point(issue: issue, time: date_range.end, label: 'Still ongoing', expedited: expedited)
155
155
  dot_colors << '' # It won't be visible so it doesn't matter
156
156
  point_styles << 'dash'
@@ -3,11 +3,11 @@
3
3
  require 'fileutils'
4
4
 
5
5
  class Object
6
- def deprecated message:, date:
6
+ def deprecated message:, date:, depth: 2
7
7
  text = +''
8
8
  text << "Deprecated(#{date}): "
9
9
  text << message
10
- caller(1..2).each do |line|
10
+ caller(1..depth).each do |line|
11
11
  text << "\n-> Called from #{line}"
12
12
  end
13
13
  warn text
@@ -56,7 +56,7 @@ class FileConfig
56
56
  def output_filename
57
57
  segments = []
58
58
  segments << project_config.target_path
59
- segments << project_config.file_prefix
59
+ segments << project_config.get_file_prefix
60
60
  segments << (@file_suffix || "-#{@today}.csv")
61
61
  segments.join
62
62
  end
@@ -103,7 +103,7 @@ class FileConfig
103
103
  def to_datetime object
104
104
  return nil if object.nil?
105
105
 
106
- object = object.to_datetime
106
+ object = object.to_time.to_datetime
107
107
  object = object.new_offset(@timezone_offset) if @timezone_offset
108
108
  object
109
109
  end
@@ -31,6 +31,10 @@ class FileSystem
31
31
  File.write(filename, content)
32
32
  end
33
33
 
34
+ def warning message
35
+ log "Warning: #{message}", also_write_to_stderr: true
36
+ end
37
+
34
38
  def log message, also_write_to_stderr: false
35
39
  logfile.puts message
36
40
  $stderr.puts message if also_write_to_stderr # rubocop:disable Style/StderrPuts
@@ -51,4 +55,8 @@ class FileSystem
51
55
  def foreach root, &block
52
56
  Dir.foreach root, &block
53
57
  end
58
+
59
+ def file_exist? filename
60
+ File.exist? filename
61
+ end
54
62
  end
@@ -19,6 +19,7 @@
19
19
  --status-category-todo-color: gray;
20
20
  --status-category-inprogress-color: #2663ff;
21
21
  --status-category-done-color: #00ff00;
22
+ --status-category-unknown-color: black;
22
23
 
23
24
  --aging-work-bar-chart-percentage-line-color: red;
24
25
  --aging-work-bar-chart-separator-color: white;
@@ -126,6 +127,12 @@ ul.quality_report {
126
127
  }
127
128
  }
128
129
 
130
+ #footer {
131
+ text-align: center;
132
+ margin-top: 1em;
133
+ border-top: 1px solid gray;
134
+ }
135
+
129
136
  @media screen and (prefers-color-scheme: dark) {
130
137
  :root {
131
138
  --non-working-days-color: #2f2f2f;
@@ -30,9 +30,6 @@
30
30
  </style>
31
31
  </head>
32
32
  <body>
33
- <div>
34
- Page generated <%= (timezone_offset.nil? ? DateTime.now : DateTime.now.new_offset(timezone_offset)).strftime('%Y-%b-%d at %I:%M:%S%P') %>
35
- </div>
36
33
  <%= "\n" + @sections.collect { |text, type| text if type == :header }.compact.join("\n\n") %>
37
34
  <%= "\n" + @sections.collect { |text, type| text if type == :body }.compact.join("\n\n") %>
38
35
  </body>
@@ -67,6 +67,8 @@ class HtmlReportConfig
67
67
  execute_chart DataQualityReport.new(@original_issue_times || {})
68
68
  @sections.rotate!(-1)
69
69
 
70
+ html create_footer
71
+
70
72
  html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
71
73
  css = load_css html_directory: html_directory
72
74
  erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
@@ -202,4 +204,16 @@ class HtmlReportConfig
202
204
  def boards
203
205
  @file_config.project_config.board_configs.collect(&:id).collect { |id| find_board id }
204
206
  end
207
+
208
+ def create_footer now: DateTime.now
209
+ now = now.new_offset(timezone_offset)
210
+ version = Gem.loaded_specs['jirametrics']&.version || 'Next'
211
+
212
+ <<~HTML
213
+ <section id="footer">
214
+ Report generated on <b>#{now.strftime('%Y-%b-%d')}</b> at <b>#{now.strftime('%I:%M:%S%P %Z')}</b>
215
+ with <a href="https://jirametrics.org">JiraMetrics</a> <b>v#{version}</b>
216
+ </section>
217
+ HTML
218
+ end
205
219
  end
@@ -44,7 +44,7 @@ class Issue
44
44
 
45
45
  def summary = @raw['fields']['summary']
46
46
 
47
- def status = Status.new(raw: @raw['fields']['status'])
47
+ def status = Status.from_raw(@raw['fields']['status'])
48
48
 
49
49
  def labels = @raw['fields']['labels'] || []
50
50
 
@@ -66,35 +66,45 @@ class Issue
66
66
  end
67
67
 
68
68
  def first_time_in_status *status_names
69
- @changes.find { |change| change.current_status_matches(*status_names) }&.time
69
+ @changes.find { |change| change.current_status_matches(*status_names) }
70
70
  end
71
71
 
72
72
  def first_time_not_in_status *status_names
73
- @changes.find { |change| change.status? && status_names.include?(change.value) == false }&.time
73
+ @changes.find { |change| change.status? && status_names.include?(change.value) == false }
74
74
  end
75
75
 
76
76
  def first_time_in_or_right_of_column column_name
77
77
  first_time_in_status(*board.status_ids_in_or_right_of_column(column_name))
78
78
  end
79
79
 
80
+ def first_time_label_added *labels
81
+ @changes.each do |change|
82
+ next unless change.labels?
83
+
84
+ change_labels = change.value.split
85
+ return change if change_labels.any? { |l| labels.include?(l) }
86
+ end
87
+ nil
88
+ end
89
+
80
90
  def still_in_or_right_of_column column_name
81
91
  still_in_status(*board.status_ids_in_or_right_of_column(column_name))
82
92
  end
83
93
 
84
94
  def still_in
85
- time = nil
95
+ result = nil
86
96
  @changes.each do |change|
87
97
  next unless change.status?
88
98
 
89
99
  current_status_matched = yield change
90
100
 
91
- if current_status_matched && time.nil?
92
- time = change.time
93
- elsif !current_status_matched && time
94
- time = nil
101
+ if current_status_matched && result.nil?
102
+ result = change
103
+ elsif !current_status_matched && result
104
+ result = nil
95
105
  end
96
106
  end
97
- time
107
+ result
98
108
  end
99
109
  private :still_in
100
110
 
@@ -108,8 +118,8 @@ class Issue
108
118
  # If it ever entered one of these categories and it's still there then what was the last time it entered
109
119
  def still_in_status_category *category_names
110
120
  still_in do |change|
111
- status = find_status_by_name change.value
112
- category_names.include?(status.category_name) || category_names.include?(status.category_id)
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)
113
123
  end
114
124
  end
115
125
 
@@ -117,48 +127,53 @@ class Issue
117
127
  changes.reverse.find { |change| change.status? }
118
128
  end
119
129
 
120
- # Are we currently in this status? If yes, then return the time of the most recent status change.
130
+ # Are we currently in this status? If yes, then return the most recent status change.
121
131
  def currently_in_status *status_names
122
132
  change = most_recent_status_change
123
133
  return false if change.nil?
124
134
 
125
- change.time if change.current_status_matches(*status_names)
135
+ change if change.current_status_matches(*status_names)
126
136
  end
127
137
 
128
- # Are we currently in this status category? If yes, then return the time of the most recent status change.
138
+ # Are we currently in this status category? If yes, then return the most recent status change.
129
139
  def currently_in_status_category *category_names
130
140
  change = most_recent_status_change
131
141
  return false if change.nil?
132
142
 
133
- status = find_status_by_name change.value
134
- change.time if status && category_names.include?(status.category_name)
143
+ status = find_status_by_id change.value_id, name: change.value
144
+ change if status && category_names.include?(status.category.name)
135
145
  end
136
146
 
137
- def find_status_by_name name
138
- status = board.possible_statuses.find_by_name(name)
147
+ def find_status_by_id id, name: nil
148
+ status = board.possible_statuses.find_by_id(id)
139
149
  return status if status
140
150
 
141
- @board.project_config.file_system.log(
142
- "Warning: Status name #{name.inspect} for issue #{key} not found in" \
143
- " #{board.possible_statuses.collect(&:name).inspect}" \
144
- "\n See https://jirametrics.org/faq/#q1\n",
145
- also_write_to_stderr: true
146
- )
147
- status = Status.new(name: name, category_name: 'In Progress')
148
- board.possible_statuses << status
151
+ status = board.possible_statuses.fabricate_status_for id: id, name: name
152
+
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
+
149
164
  status
150
165
  end
151
166
 
152
167
  def first_status_change_after_created
153
- @changes.find { |change| change.status? && change.artificial? == false }&.time
168
+ @changes.find { |change| change.status? && change.artificial? == false }
154
169
  end
155
170
 
156
171
  def first_time_in_status_category *category_names
157
172
  @changes.each do |change|
158
173
  next unless change.status?
159
174
 
160
- category = find_status_by_name(change.value).category_name
161
- return change.time if category_names.include? category
175
+ category = find_status_by_id(change.value_id).category.name
176
+ return change if category_names.include? category
162
177
  end
163
178
  nil
164
179
  end
@@ -177,11 +192,11 @@ class Issue
177
192
  end
178
193
 
179
194
  def first_resolution
180
- @changes.find { |change| change.resolution? }&.time
195
+ @changes.find { |change| change.resolution? }
181
196
  end
182
197
 
183
198
  def last_resolution
184
- @changes.reverse.find { |change| change.resolution? }&.time
199
+ @changes.reverse.find { |change| change.resolution? }
185
200
  end
186
201
 
187
202
  def assigned_to
@@ -624,7 +639,7 @@ class Issue
624
639
  # This was probably loaded as a linked issue, which means we don't know what board it really
625
640
  # belonged to. The best we can do is look at the status category. This case should be rare but
626
641
  # it can happen.
627
- status.category_name == 'Done'
642
+ status.category.name == 'Done'
628
643
  else
629
644
  board.cycletime.done? self
630
645
  end
@@ -32,7 +32,7 @@ class ProjectConfig
32
32
  end
33
33
 
34
34
  def data_downloaded?
35
- File.exist? File.join(@target_path, "#{file_prefix}_meta.json")
35
+ File.exist? File.join(@target_path, "#{get_file_prefix}_meta.json")
36
36
  end
37
37
 
38
38
  def load_data
@@ -41,7 +41,6 @@ class ProjectConfig
41
41
  @has_loaded_data = true
42
42
  load_all_boards
43
43
  @id = guess_project_id
44
- load_status_category_mappings
45
44
  load_project_metadata
46
45
  load_sprints
47
46
  end
@@ -116,82 +115,139 @@ class ProjectConfig
116
115
  @board_configs << config
117
116
  end
118
117
 
119
- def file_prefix prefix = nil
120
- @file_prefix = prefix unless prefix.nil?
118
+ def file_prefix prefix
119
+ # The file_prefix has to be set before almost everything else. It really should have been an attribute
120
+ # on the project declaration itself. Hindsight is 20/20.
121
+ if @file_prefix
122
+ raise "file_prefix should only be set once. Was #{@file_prefix.inspect} and now changed to #{prefix.inspect}."
123
+ end
124
+
125
+ @file_prefix = prefix
126
+
127
+ # Yes, this is a wierd place to be initializing this. Unfortunately, it has to happen after the file_prefix
128
+ # is set but before anything inside the project block is run. If only we had made file_prefix an attribute
129
+ # on project, we wouldn't have this ugliness. 🤷‍♂️
130
+ load_status_category_mappings
131
+
121
132
  @file_prefix
122
133
  end
123
134
 
124
- def status_category_mapping status:, category:
125
- add_possible_status Status.new(name: status, category_name: category)
135
+ def get_file_prefix # rubocop:disable Naming/AccessorMethodName
136
+ raise 'file_prefix has not been set yet. Move it to the top of the project declaration.' unless @file_prefix
137
+
138
+ @file_prefix
126
139
  end
127
140
 
128
- def load_all_boards
129
- Dir.foreach(@target_path) do |file|
130
- next unless file =~ /^#{@file_prefix}_board_(\d+)_configuration\.json$/
141
+ def status_guesses
142
+ statuses = {}
143
+ issues.each do |issue|
144
+ issue.changes.each do |change|
145
+ next unless change.status?
131
146
 
132
- board_id = $1.to_i
133
- load_board board_id: board_id, filename: "#{@target_path}#{file}"
147
+ (statuses[change.value] ||= Set.new) << change.value_id
148
+ (statuses[change.old_value] ||= Set.new) << change.old_value_id if change.old_value
149
+ end
134
150
  end
135
- raise "No boards found for #{@file_prefix.inspect} in #{@target_path.inspect}" if @all_boards.empty?
151
+ statuses
136
152
  end
137
153
 
138
- def load_board board_id:, filename:
139
- board = Board.new(
140
- raw: file_system.load_json(filename), possible_statuses: @possible_statuses
154
+ 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
160
+
161
+ if status_id.nil?
162
+ guesses = status_guesses[status]
163
+ if guesses.nil?
164
+ file_system.warning "For status_category_mapping status: #{status.inspect}, category: #{category.inspect}\n" \
165
+ "Cannot guess status id for #{status.inspect} as no statuses found anywhere in the issues " \
166
+ "histories with that name. Since we can't find it, you probably don't need this mapping anymore so we're " \
167
+ "going to ignore it. If you really want it, then you'll need to specify a status id."
168
+ return
169
+ end
170
+
171
+ if guesses.size > 1
172
+ raise "Cannot guess status id as there are multiple ids for the name #{status.inspect}. Perhaps it's one " \
173
+ "of #{guesses.to_a.sort.inspect}. If you need this mapping then you must specify the status_id."
174
+ end
175
+
176
+ status_id = guesses.first
177
+ # TODO: This should be a deprecation instead but we can't currently assert against those
178
+ file_system.log "status_category_mapping for #{status.inspect} has been mapped to id #{status_id}. " \
179
+ "If that's incorrect then specify the status_id."
180
+ end
181
+
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}?"
186
+ end
187
+
188
+ add_possible_status(
189
+ Status.new(
190
+ name: status, id: status_id,
191
+ category_name: category, category_id: found_category_id, category_key: found_category.key
192
+ )
141
193
  )
142
- board.project_config = self
143
- @all_boards[board_id] = board
144
194
  end
145
195
 
146
- def raise_with_message_about_missing_category_information all_issues = @issues
147
- message = +''
148
- message << "Could not determine categories for some of the statuses used in this data set.\n" \
149
- "Use the 'status_category_mapping' declaration in your config to manually add one.\n" \
150
- 'The mappings we do know about are below:'
196
+ def add_possible_status status
197
+ existing_status = @possible_statuses.find_by_id status.id
151
198
 
152
- @possible_statuses.each do |status|
153
- message << "\n status: #{status.name.inspect}, category: #{status.category_name.inspect}"
199
+ if existing_status && existing_status.name != status.name
200
+ raise "Attempting to redefine the name for status #{status.id} from " \
201
+ "#{existing_status.name.inspect} to #{status.name.inspect}"
154
202
  end
155
203
 
156
- message << "\n\nThe ones we're missing are the following:"
204
+ # If it isn't there, add it and go.
205
+ return @possible_statuses << status unless existing_status
157
206
 
158
- find_statuses_with_no_category_information(all_issues).each do |status_name|
159
- message << "\n status: #{status_name.inspect}, category: <unknown>"
207
+ unless status == existing_status
208
+ raise "Redefining status category for status #{status}. " \
209
+ "original: #{existing_status.category}, " \
210
+ "new: #{status.category}"
160
211
  end
161
212
 
162
- raise message
213
+ # 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.
215
+ existing_status
163
216
  end
164
217
 
165
- def find_statuses_with_no_category_information all_issues
166
- missing_statuses = []
167
- all_issues.each do |issue|
168
- issue.changes.each do |change|
169
- next unless change.status?
218
+ def load_all_boards
219
+ Dir.foreach(@target_path) do |file|
220
+ next unless file =~ /^#{get_file_prefix}_board_(\d+)_configuration\.json$/
170
221
 
171
- missing_statuses << change.value unless find_status(name: change.value)
172
- end
222
+ board_id = $1.to_i
223
+ load_board board_id: board_id, filename: "#{@target_path}#{file}"
173
224
  end
174
- missing_statuses.uniq
225
+ raise "No boards found for #{get_file_prefix.inspect} in #{@target_path.inspect}" if @all_boards.empty?
226
+ end
227
+
228
+ def load_board board_id:, filename:
229
+ board = Board.new(
230
+ raw: file_system.load_json(filename), possible_statuses: @possible_statuses
231
+ )
232
+ board.project_config = self
233
+ @all_boards[board_id] = board
175
234
  end
176
235
 
177
236
  def load_status_category_mappings
178
- filename = "#{@target_path}/#{file_prefix}_statuses.json"
237
+ filename = File.join @target_path, "#{get_file_prefix}_statuses.json"
238
+
179
239
  # We may not always have this file. Load it if we can.
180
240
  return unless File.exist? filename
181
241
 
182
- statuses = file_system.load_json(filename)
183
- .map { |snippet| Status.new(raw: snippet) }
184
- statuses
185
- .find_all { |status| status.global? }
186
- .each { |status| add_possible_status status }
187
- statuses
188
- .find_all { |status| status.project_scoped? }
242
+ file_system
243
+ .load_json(filename)
244
+ .map { |snippet| Status.from_raw(snippet) }
189
245
  .each { |status| add_possible_status status }
190
246
  end
191
247
 
192
248
  def load_sprints
193
249
  file_system.foreach(@target_path) do |file|
194
- next unless file =~ /^#{file_prefix}_board_(\d+)_sprints_\d+.json$/
250
+ next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
195
251
 
196
252
  file_path = File.join(@target_path, file)
197
253
  board = @all_boards[$1.to_i]
@@ -214,56 +270,26 @@ class ProjectConfig
214
270
  end
215
271
  end
216
272
 
217
- def add_possible_status status
218
- existing_status = find_status(name: status.name)
219
-
220
- if status.project_scoped?
221
- # If the project specific status doesn't change anything then we don't care whether it's
222
- # our project or not.
223
- return if existing_status && existing_status.category_name == status.category_name
224
-
225
- raise_ambiguous_project_id if @id.nil?
226
-
227
- # Not our project, ignore it.
228
- return unless status.project_id == @id
229
-
230
- # Replace the old one with this
231
- @possible_statuses.delete(existing_status)
232
- @possible_statuses << status
233
- return
234
- end
235
-
236
- # If it isn't there, add it and go.
237
- return @possible_statuses << status unless existing_status
238
-
239
- # We're registering the same one twice. Shouldn't be possible with the new status API but it
240
- # did happen with the project specific one.
241
- return if status.category_name == existing_status.category_name
242
-
243
- # If we got this far then someone has called status_category_mapping and is attempting to
244
- # change the category.
245
- raise "Redefining status category #{status} with #{existing_status}. Was one set in the config?"
246
- end
247
-
248
- def raise_ambiguous_project_id
249
- raise 'Ambiguous project id: There is a project specific status that could affect out calculations. ' \
250
- 'We are unable to automatically detect the id of the project so you will have to set it manually ' \
251
- 'in the configuration like: "project id: 5"'
252
- end
253
-
254
- def find_status name:
255
- @possible_statuses.find_by_name name
256
- end
257
-
258
273
  def load_project_metadata
259
- filename = File.join @target_path, "#{file_prefix}_meta.json"
274
+ filename = File.join @target_path, "#{get_file_prefix}_meta.json"
260
275
  json = file_system.load_json(filename)
261
276
 
262
277
  @data_version = json['version'] || 1
263
278
 
264
- start = json['date_start'] || json['time_start'] # date_start is the current format. Time is the old.
265
- stop = json['date_end'] || json['time_end']
266
- @time_range = to_time(start)..to_time(stop, end_of_day: true)
279
+ start = to_time(json['date_start'] || json['time_start']) # date_start is the current format. Time is the old.
280
+ stop = to_time(json['date_end'] || json['time_end'], end_of_day: true)
281
+
282
+ # If no_earlier_than was set then make sure it's applied here.
283
+ if download_config
284
+ download_config.run
285
+ no_earlier = download_config.no_earlier_than
286
+ if no_earlier
287
+ no_earlier = to_time(no_earlier.to_s)
288
+ start = no_earlier if start < no_earlier
289
+ end
290
+ end
291
+
292
+ @time_range = start..stop
267
293
 
268
294
  @jira_url = json['jira_url']
269
295
  rescue Errno::ENOENT
@@ -328,7 +354,7 @@ class ProjectConfig
328
354
 
329
355
  timezone_offset = exporter.timezone_offset
330
356
 
331
- issues_path = File.join @target_path, "#{file_prefix}_issues"
357
+ issues_path = File.join @target_path, "#{get_file_prefix}_issues"
332
358
  if File.exist?(issues_path) && File.directory?(issues_path)
333
359
  issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
334
360
  else
@@ -172,7 +172,7 @@ class SprintBurndown < ChartBase
172
172
  change_item.raw['to'].split(/\s*,\s*/).any? { |id| id.to_i == sprint.id }
173
173
  end
174
174
 
175
- def data_set_by_story_points sprint:, change_data_for_sprint:
175
+ def data_set_by_story_points sprint:, change_data_for_sprint: # rubocop:disable Metrics/CyclomaticComplexity
176
176
  summary_stats = SprintSummaryStats.new
177
177
  summary_stats.completed = 0.0
178
178
 
@@ -4,29 +4,57 @@ require 'jirametrics/value_equality'
4
4
 
5
5
  class Status
6
6
  include ValueEquality
7
- attr_reader :id, :category_name, :category_id, :project_id
7
+ attr_reader :id, :project_id, :category
8
8
  attr_accessor :name
9
9
 
10
- def initialize name: nil, id: nil, category_name: nil, category_id: nil, project_id: nil, raw: nil
11
- @name = name
12
- @id = id
13
- @category_name = category_name
14
- @category_id = category_id
15
- @project_id = project_id
10
+ class Category
11
+ attr_reader :id, :name, :key
12
+
13
+ def initialize id:, name:, key:
14
+ @id = id
15
+ @name = name
16
+ @key = key
17
+ end
16
18
 
17
- return unless raw
19
+ def to_s
20
+ "#{name.inspect}:#{id.inspect}"
21
+ end
18
22
 
19
- @raw = raw
20
- @name = raw['name']
21
- @id = raw['id'].to_i
23
+ def new? = (@key == 'new')
24
+ def indeterminate? = (@key == 'indeterminate')
25
+ def done? = (@key == 'done')
26
+ end
22
27
 
28
+ def self.from_raw raw
23
29
  category_config = raw['statusCategory']
24
- @category_name = category_config['name']
25
- @category_id = category_config['id'].to_i
26
30
 
27
- # If this is a NextGen project then this status may be project specific. When this field is
28
- # nil then the status is global.
29
- @project_id = raw['scope']&.[]('project')&.[]('id')
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
+ Status.new(
38
+ name: raw['name'],
39
+ id: raw['id'].to_i,
40
+ category_name: category_config['name'],
41
+ category_id: category_config['id'].to_i,
42
+ category_key: category_config['key'],
43
+ project_id: raw['scope']&.[]('project')&.[]('id'),
44
+ artificial: false
45
+ )
46
+ end
47
+
48
+ def initialize name:, id:, category_name:, category_id:, category_key:, project_id: nil, artificial: true
49
+ # These checks are needed because nils used to be possible and now they aren't.
50
+ raise 'id cannot be nil' if id.nil?
51
+ raise 'category_id cannot be nil' if category_id.nil?
52
+
53
+ @name = name
54
+ @id = id
55
+ @category = Category.new id: category_id, name: category_name, key: category_key
56
+ @project_id = project_id
57
+ @artificial = artificial
30
58
  end
31
59
 
32
60
  def project_scoped?
@@ -38,18 +66,26 @@ class Status
38
66
  end
39
67
 
40
68
  def to_s
41
- result = "Status(name=#{@name.inspect}," \
42
- " id=#{@id.inspect}," \
43
- " category_name=#{@category_name.inspect}," \
44
- " category_id=#{@category_id.inspect}," \
45
- " project_id=#{@project_id}"
46
- result << ' artificial' if artificial?
47
- result << ')'
48
- result
69
+ "#{name.inspect}:#{id.inspect}"
49
70
  end
50
71
 
51
72
  def artificial?
52
- @raw.nil?
73
+ @artificial
74
+ end
75
+
76
+ def == other
77
+ @id == other.id && @name == other.name && @category.id == other.category.id && @category.name == other.category.name
78
+ end
79
+
80
+ def inspect
81
+ result = []
82
+ result << "Status(name: #{@name.inspect}"
83
+ result << "id: #{@id.inspect}"
84
+ result << "project_id: #{@project_id}" if @project_id
85
+ category = self.category
86
+ result << "category: {name:#{category.name.inspect}, id: #{category.id.inspect}, key: #{category.key.inspect}}"
87
+ result << 'artificial' if artificial?
88
+ result.join(', ') << ')'
53
89
  end
54
90
 
55
91
  def value_equality_ignored_variables
@@ -13,7 +13,7 @@ class StatusCollection
13
13
  excluding = expand_statuses excluding
14
14
 
15
15
  @list.filter_map do |status|
16
- keep = status.category_name == category_name ||
16
+ keep = status.category.name == category_name ||
17
17
  including.any? { |s| s.name == status.name }
18
18
  keep = false if excluding.any? { |s| s.name == status.name }
19
19
 
@@ -56,12 +56,45 @@ class StatusCollection
56
56
  filter_status_names category_name: 'Done', including: including, excluding: excluding
57
57
  end
58
58
 
59
- def find_by_name name
60
- find { |status| status.name == name }
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 }
62
+ end
63
+
64
+ def find_all_by_name name
65
+ @list.select { |status| status.name == name }
66
+ end
67
+
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
78
+ end
79
+
80
+ # This is used to create a status that was found in the history but has since been deleted.
81
+ 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
84
+
85
+ status = Status.new(
86
+ name: name,
87
+ 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
+ )
92
+ self << status
93
+ status
61
94
  end
62
95
 
63
- def find(&block)= @list.find(&block)
64
96
  def collect(&block) = @list.collect(&block)
97
+ def find(&block) = @list.find(&block)
65
98
  def each(&block) = @list.each(&block)
66
99
  def select(&block) = @list.select(&block)
67
100
  def <<(arg) = @list << arg
@@ -70,6 +103,6 @@ class StatusCollection
70
103
  def delete(object) = @list.delete(object)
71
104
 
72
105
  def inspect
73
- "StatusCollection(#{@list.collect(&:inspect).join(', ')})"
106
+ "StatusCollection(#{@list.join(', ')})"
74
107
  end
75
108
  end
@@ -82,7 +82,7 @@ class ThroughputChart < ChartBase
82
82
  def throughput_dataset periods:, completed_issues:
83
83
  periods.collect do |period|
84
84
  closed_issues = completed_issues.filter_map do |issue|
85
- stop_date = issue.board.cycletime.started_stopped_times(issue).last&.to_date
85
+ stop_date = issue.board.cycletime.started_stopped_dates(issue).last
86
86
  [stop_date, issue] if stop_date && period.include?(stop_date)
87
87
  end
88
88
 
data/lib/jirametrics.rb CHANGED
@@ -7,6 +7,13 @@ class JiraMetrics < Thor
7
7
  true
8
8
  end
9
9
 
10
+ map %w[--version -v] => :__print_version
11
+
12
+ desc '--version, -v', 'print the version'
13
+ def __print_version
14
+ puts Gem.loaded_specs['jirametrics'].version
15
+ end
16
+
10
17
  option :config
11
18
  option :name
12
19
  desc 'export', "Export data into either reports or CSV's as per the configuration"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jirametrics
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.7.3
4
+ version: '2.8'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Bowler
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-11-13 00:00:00.000000000 Z
11
+ date: 2024-12-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: random-word