jirametrics 2.7.3 → 2.8

Sign up to get free protection for your applications and to get access to all the features.
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