jirametrics 2.8 → 2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +1 -1
  3. data/lib/jirametrics/aging_work_bar_chart.rb +7 -5
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
  5. data/lib/jirametrics/aging_work_table.rb +50 -2
  6. data/lib/jirametrics/board.rb +32 -4
  7. data/lib/jirametrics/board_config.rb +6 -2
  8. data/lib/jirametrics/board_movement_calculator.rb +147 -0
  9. data/lib/jirametrics/change_item.rb +8 -1
  10. data/lib/jirametrics/chart_base.rb +40 -10
  11. data/lib/jirametrics/css_variable.rb +1 -1
  12. data/lib/jirametrics/cycletime_config.rb +10 -4
  13. data/lib/jirametrics/cycletime_histogram.rb +65 -2
  14. data/lib/jirametrics/data_quality_report.rb +53 -34
  15. data/lib/jirametrics/downloader.rb +0 -14
  16. data/lib/jirametrics/examples/standard_project.rb +2 -2
  17. data/lib/jirametrics/exporter.rb +10 -20
  18. data/lib/jirametrics/file_config.rb +21 -4
  19. data/lib/jirametrics/file_system.rb +23 -4
  20. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  21. data/lib/jirametrics/groupable_issue_chart.rb +1 -3
  22. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
  23. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
  24. data/lib/jirametrics/html/aging_work_table.erb +6 -4
  25. data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
  26. data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
  27. data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
  28. data/lib/jirametrics/html/expedited_chart.erb +1 -10
  29. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  30. data/lib/jirametrics/html/index.css +11 -2
  31. data/lib/jirametrics/html/index.erb +8 -1
  32. data/lib/jirametrics/html/sprint_burndown.erb +1 -10
  33. data/lib/jirametrics/html/throughput_chart.erb +1 -10
  34. data/lib/jirametrics/html_report_config.rb +18 -23
  35. data/lib/jirametrics/issue.rb +77 -32
  36. data/lib/jirametrics/jira_gateway.rb +16 -3
  37. data/lib/jirametrics/project_config.rb +102 -45
  38. data/lib/jirametrics/status.rb +26 -7
  39. data/lib/jirametrics/status_collection.rb +69 -68
  40. data/lib/jirametrics/value_equality.rb +2 -2
  41. data/lib/jirametrics.rb +0 -1
  42. metadata +5 -9
  43. data/lib/jirametrics/discard_changes_before.rb +0 -37
@@ -27,9 +27,6 @@ class ChartBase
27
27
 
28
28
  def html_directory
29
29
  pathname = Pathname.new(File.realpath(__FILE__))
30
- # basename = pathname.basename.to_s
31
- # raise "Unexpected filename #{basename.inspect}" unless basename.match?(/^(.+)\.rb$/)
32
-
33
30
  "#{pathname.dirname}/html"
34
31
  end
35
32
 
@@ -133,6 +130,21 @@ class ChartBase
133
130
  result
134
131
  end
135
132
 
133
+ def working_days_annotation
134
+ holidays.each_with_index.collect do |range, index|
135
+ <<~TEXT
136
+ holiday#{index}: {
137
+ drawTime: 'beforeDraw',
138
+ type: 'box',
139
+ xMin: '#{range.begin}T00:00:00',
140
+ xMax: '#{range.end}T23:59:59',
141
+ backgroundColor: #{CssVariable.new('--non-working-days-color').to_json},
142
+ borderColor: #{CssVariable.new('--non-working-days-color').to_json}
143
+ },
144
+ TEXT
145
+ end.join
146
+ end
147
+
136
148
  # Return only the board columns for the current board.
137
149
  def current_board
138
150
  if @board_id.nil?
@@ -179,18 +191,33 @@ class ChartBase
179
191
  @description_text
180
192
  end
181
193
 
194
+ # Convert a number like 1234567 into the string "1,234,567"
182
195
  def format_integer number
183
196
  number.to_s.reverse.scan(/.{1,3}/).join(',').reverse
184
197
  end
185
198
 
186
- def format_status name_or_id, board:, is_category: false
187
- begin
188
- statuses = board.possible_statuses.expand_statuses([name_or_id])
189
- rescue StatusNotFoundError
190
- return "<span style='color: red'>#{name_or_id}</span>"
199
+ # object will be either a Status or a ChangeItem
200
+ # if it's a ChangeItem then use_old_status will specify whether we're using the new or old
201
+ # Either way, is_category will format the category rather than the status
202
+ def format_status object, board:, is_category: false, use_old_status: false
203
+ status = nil
204
+ error_message = nil
205
+
206
+ case object
207
+ when ChangeItem
208
+ id = use_old_status ? object.old_value_id : object.value_id
209
+ status = board.possible_statuses.find_by_id(id)
210
+ if status.nil?
211
+ error_message = use_old_status ? object.old_value : object.value
212
+ end
213
+ when Status
214
+ status = object
215
+ else
216
+ raise "Unexpected type: #{object.class}"
191
217
  end
192
218
 
193
- status = statuses.first
219
+ return "<span style='color: red'>#{error_message}</span>" if error_message
220
+
194
221
  color = status_category_color status
195
222
 
196
223
  visibility = ''
@@ -233,7 +260,10 @@ class ChartBase
233
260
 
234
261
  def color_block color, title: nil
235
262
  result = +''
236
- result << "<div class='color_block' style='background: var(#{color});'"
263
+ result << "<div class='color_block' style='"
264
+ result << "background: #{CssVariable[color]};" if color
265
+ result << 'visibility: hidden;' unless color
266
+ result << "'"
237
267
  result << " title=#{title.inspect}" if title
238
268
  result << '></div>'
239
269
  result
@@ -4,7 +4,7 @@ class CssVariable
4
4
  attr_reader :name
5
5
 
6
6
  def self.[](name)
7
- if name.start_with? '--'
7
+ if name.is_a?(String) && name.start_with?('--')
8
8
  CssVariable.new name
9
9
  else
10
10
  name
@@ -8,10 +8,14 @@ class CycleTimeConfig
8
8
 
9
9
  attr_reader :label, :parent_config
10
10
 
11
- def initialize parent_config:, label:, block:, today: Date.today
11
+ def initialize parent_config:, label:, block:, file_system: nil, today: Date.today
12
12
  @parent_config = parent_config
13
13
  @label = label
14
14
  @today = today
15
+
16
+ # If we hit something deprecated and this is nil then we'll blow up. Although it's ugly, this
17
+ # may make it easier to find problems in the test code ;-)
18
+ @file_system = file_system
15
19
  instance_eval(&block) unless block.nil?
16
20
  end
17
21
 
@@ -35,17 +39,19 @@ class CycleTimeConfig
35
39
  end
36
40
 
37
41
  def started_time issue
38
- deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
42
+ @file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
39
43
  started_stopped_times(issue).first
40
44
  end
41
45
 
42
46
  def stopped_time issue
43
- deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
47
+ @file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
44
48
  started_stopped_times(issue).last
45
49
  end
46
50
 
47
51
  def fabricate_change_item time
48
- deprecated date: '2024-12-16', message: 'This method should now return a ChangeItem not a Time', depth: 4
52
+ @file_system.deprecated(
53
+ date: '2024-12-16', message: "This method should now return a ChangeItem not a #{time.class}", depth: 4
54
+ )
49
55
  raw = {
50
56
  'field' => 'Fabricated change',
51
57
  'to' => '0',
@@ -5,10 +5,14 @@ require 'jirametrics/groupable_issue_chart'
5
5
  class CycletimeHistogram < ChartBase
6
6
  include GroupableIssueChart
7
7
  attr_accessor :possible_statuses
8
+ attr_reader :show_stats
8
9
 
9
10
  def initialize block
10
11
  super()
11
12
 
13
+ percentiles [50, 85, 98]
14
+ @show_stats = true
15
+
12
16
  header_text 'Cycletime Histogram'
13
17
  description_text <<-HTML
14
18
  <p>
@@ -26,6 +30,15 @@ class CycletimeHistogram < ChartBase
26
30
  end
27
31
  end
28
32
 
33
+ def percentiles percs = nil
34
+ @percentiles = percs unless percs.nil?
35
+ @percentiles
36
+ end
37
+
38
+ def disable_stats
39
+ @show_stats = false
40
+ end
41
+
29
42
  def run
30
43
  stopped_issues = completed_issues_in_range include_unstarted: true
31
44
 
@@ -33,10 +46,18 @@ class CycletimeHistogram < ChartBase
33
46
  histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
34
47
  rules_to_issues = group_issues histogram_issues
35
48
 
49
+ the_stats = {}
50
+
51
+ overall_stats = stats_for histogram_data: histogram_data_for(issues: histogram_issues), percentiles: @percentiles
52
+ the_stats[:all] = overall_stats
36
53
  data_sets = rules_to_issues.keys.collect do |rules|
54
+ the_issue_type = rules.label
55
+ the_histogram = histogram_data_for(issues: rules_to_issues[rules])
56
+ the_stats[the_issue_type] = stats_for histogram_data: the_histogram, percentiles: @percentiles if @show_stats
57
+
37
58
  data_set_for(
38
- histogram_data: histogram_data_for(issues: rules_to_issues[rules]),
39
- label: rules.label,
59
+ histogram_data: the_histogram,
60
+ label: the_issue_type,
40
61
  color: rules.color
41
62
  )
42
63
  end
@@ -55,6 +76,48 @@ class CycletimeHistogram < ChartBase
55
76
  count_hash
56
77
  end
57
78
 
79
+ def stats_for histogram_data:, percentiles:
80
+ return {} if histogram_data.empty?
81
+
82
+ total_values = histogram_data.values.sum
83
+
84
+ # Calculate the average
85
+ weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
86
+ average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
87
+
88
+ # Find the mode (or modes!) and the spread of the distribution
89
+ sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
90
+ max_freq = sorted_histogram[-1][1]
91
+ mode = sorted_histogram.select { |_v, f| f == max_freq }
92
+
93
+ minmax = histogram_data.keys.minmax
94
+
95
+ # Calculate percentiles
96
+ sorted_values = histogram_data.keys.sort
97
+ cumulative_counts = {}
98
+ cumulative_sum = 0
99
+
100
+ sorted_values.each do |value|
101
+ cumulative_sum += histogram_data[value]
102
+ cumulative_counts[value] = cumulative_sum
103
+ end
104
+
105
+ percentile_results = {}
106
+ percentiles.each do |percentile|
107
+ rank = (percentile / 100.0) * total_values
108
+ percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
109
+ percentile_results[percentile] = percentile_value
110
+ end
111
+
112
+ {
113
+ average: average,
114
+ mode: mode.collect(&:first).sort,
115
+ min: minmax[0],
116
+ max: minmax[1],
117
+ percentiles: percentile_results
118
+ }
119
+ end
120
+
58
121
  def data_set_for histogram_data:, label:, color:
59
122
  keys = histogram_data.keys.sort
60
123
  {
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class DataQualityReport < ChartBase
4
- attr_reader :original_issue_times # For testing purposes only
4
+ attr_reader :discarded_changes_data, :entries # Both for testing purposes only
5
5
  attr_accessor :board_id
6
6
 
7
7
  class Entry
@@ -19,10 +19,10 @@ class DataQualityReport < ChartBase
19
19
  end
20
20
  end
21
21
 
22
- def initialize original_issue_times
22
+ def initialize discarded_changes_data
23
23
  super()
24
24
 
25
- @original_issue_times = original_issue_times
25
+ @discarded_changes_data = discarded_changes_data
26
26
 
27
27
  header_text 'Data Quality Report'
28
28
  description_text <<-HTML
@@ -50,6 +50,7 @@ class DataQualityReport < ChartBase
50
50
  scan_for_issues_not_started_with_subtasks_that_have entry: entry
51
51
  scan_for_incomplete_subtasks_when_issue_done entry: entry
52
52
  scan_for_discarded_data entry: entry
53
+ scan_for_items_blocked_on_closed_tickets entry: entry
53
54
  end
54
55
 
55
56
  scan_for_issues_on_multiple_boards entries: @entries
@@ -73,6 +74,7 @@ class DataQualityReport < ChartBase
73
74
  result << render_problem_type(:issue_not_started_but_subtasks_have)
74
75
  result << render_problem_type(:incomplete_subtasks_when_issue_done)
75
76
  result << render_problem_type(:issue_on_multiple_boards)
77
+ result << render_problem_type(:items_blocked_on_closed_tickets)
76
78
  result << '</ul>'
77
79
 
78
80
  result
@@ -102,9 +104,13 @@ class DataQualityReport < ChartBase
102
104
 
103
105
  # Return a format that's easier to assert against
104
106
  def testable_entries
105
- format = '%Y-%m-%d %H:%M:%S %z'
107
+ formatter = ->(time) { time&.strftime('%Y-%m-%d %H:%M:%S %z') || '' }
106
108
  @entries.collect do |entry|
107
- [entry.started&.strftime(format) || '', entry.stopped&.strftime(format) || '', entry.issue]
109
+ [
110
+ formatter.call(entry.started),
111
+ formatter.call(entry.stopped),
112
+ entry.issue
113
+ ]
108
114
  end
109
115
  end
110
116
 
@@ -112,10 +118,6 @@ class DataQualityReport < ChartBase
112
118
  @entries.reject { |entry| entry.problems.empty? }
113
119
  end
114
120
 
115
- def category_name_for status_id:, board:
116
- board.possible_statuses.find_by_id(status_id)&.category&.name
117
- end
118
-
119
121
  def initialize_entries
120
122
  @entries = @issues.filter_map do |issue|
121
123
  started, stopped = issue.board.cycletime.started_stopped_times(issue)
@@ -139,10 +141,8 @@ class DataQualityReport < ChartBase
139
141
  def scan_for_completed_issues_without_a_start_time entry:
140
142
  return unless entry.stopped && entry.started.nil?
141
143
 
142
- status_names = entry.issue.changes.filter_map do |change|
143
- next unless change.status?
144
-
145
- format_status change.value, board: entry.issue.board
144
+ status_names = entry.issue.status_changes.filter_map do |change|
145
+ format_status change, board: entry.issue.board
146
146
  end
147
147
 
148
148
  entry.report(
@@ -157,14 +157,14 @@ class DataQualityReport < ChartBase
157
157
  changes_after_done = entry.issue.changes.select do |change|
158
158
  change.status? && change.time >= entry.stopped
159
159
  end
160
- done_status = changes_after_done.shift.value
160
+ done_status = changes_after_done.shift
161
161
 
162
162
  return if changes_after_done.empty?
163
163
 
164
164
  board = entry.issue.board
165
165
  problem = "Completed on #{entry.stopped.to_date} with status #{format_status done_status, board: board}."
166
166
  changes_after_done.each do |change|
167
- problem << " Changed to #{format_status change.value, board: board} on #{change.time.to_date}."
167
+ problem << " Changed to #{format_status change, board: board} on #{change.time.to_date}."
168
168
  end
169
169
  entry.report(
170
170
  problem_key: :status_changes_after_done,
@@ -186,9 +186,9 @@ class DataQualityReport < ChartBase
186
186
  # If it's a backlog status then ignore it. Not supposed to be visible.
187
187
  next if entry.issue.board.backlog_statuses.include?(board.possible_statuses.find_by_id(change.value_id))
188
188
 
189
- detail = "Status #{format_status change.value, board: board} is not on the board"
190
- if issue.board.possible_statuses.expand_statuses(change.value).empty?
191
- detail = "Status #{format_status change.value, board: board} cannot be found at all. Was it deleted?"
189
+ detail = "Status #{format_status change, board: board} is not on the board"
190
+ if issue.board.possible_statuses.find_by_id(change.value_id).nil?
191
+ detail = "Status #{format_status change, board: board} cannot be found at all. Was it deleted?"
192
192
  end
193
193
 
194
194
  # If it's been moved back to backlog then it's on a different report. Ignore it here.
@@ -198,24 +198,24 @@ 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_id: change.value_id, board: board)
202
- old_category = category_name_for(status_id: change.old_value_id, board: board)
201
+ new_category = board.possible_statuses.find_by_id(change.value_id).category.name
202
+ old_category = board.possible_statuses.find_by_id(change.old_value_id).category.name
203
203
 
204
204
  if new_category == old_category
205
205
  entry.report(
206
206
  problem_key: :backwords_through_statuses,
207
- detail: "Moved from #{format_status change.old_value, board: board}" \
208
- " to #{format_status change.value, board: board}" \
207
+ detail: "Moved from #{format_status change, use_old_status: true, board: board}" \
208
+ " to #{format_status change, board: board}" \
209
209
  " on #{change.time.to_date}"
210
210
  )
211
211
  else
212
212
  entry.report(
213
213
  problem_key: :backwards_through_status_categories,
214
- detail: "Moved from #{format_status change.old_value, board: board}" \
215
- " to #{format_status change.value, board: board}" \
216
- " on #{change.time.to_date}, " \
217
- " crossing from category #{format_status old_category, board: board, is_category: true}" \
218
- " to #{format_status new_category, board: board, is_category: true}."
214
+ detail: "Moved from #{format_status change, use_old_status: true, board: board}" \
215
+ " to #{format_status change, board: board}" \
216
+ " on #{change.time.to_date}," \
217
+ " crossing from category #{format_status change, use_old_status: true, board: board, is_category: true}" \
218
+ " to #{format_status change, board: board, is_category: true}."
219
219
  )
220
220
  end
221
221
  end
@@ -224,16 +224,14 @@ class DataQualityReport < ChartBase
224
224
  end
225
225
 
226
226
  def scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
227
- return if backlog_statuses.empty?
228
-
229
227
  creation_change = entry.issue.changes.find { |issue| issue.status? }
230
228
 
231
229
  return if backlog_statuses.any? { |status| status.id == creation_change.value_id }
232
230
 
233
- status_string = backlog_statuses.collect { |s| format_status s.name, board: entry.issue.board }.join(', ')
231
+ status_string = backlog_statuses.collect { |s| format_status s, board: entry.issue.board }.join(', ')
234
232
  entry.report(
235
233
  problem_key: :created_in_wrong_status,
236
- detail: "Created in #{format_status creation_change.value, board: entry.issue.board}, " \
234
+ detail: "Created in #{format_status creation_change, board: entry.issue.board}, " \
237
235
  "which is not one of the backlog statuses for this board: #{status_string}"
238
236
  )
239
237
  end
@@ -266,6 +264,20 @@ class DataQualityReport < ChartBase
266
264
  )
267
265
  end
268
266
 
267
+ def scan_for_items_blocked_on_closed_tickets entry:
268
+ entry.issue.issue_links.each do |link|
269
+ this_active = !entry.stopped
270
+ other_active = !link.other_issue.board.cycletime.started_stopped_times(link.other_issue).last
271
+ next unless this_active && !other_active
272
+
273
+ entry.report(
274
+ problem_key: :items_blocked_on_closed_tickets,
275
+ detail: "#{entry.issue.key} thinks it's blocked by #{link.other_issue.key}, " \
276
+ "except #{link.other_issue.key} is closed."
277
+ )
278
+ end
279
+ end
280
+
269
281
  def subtask_label subtask
270
282
  "<img src='#{subtask.type_icon_url}' /> #{link_to_issue(subtask)} #{subtask.summary[..50].inspect}"
271
283
  end
@@ -314,10 +326,10 @@ class DataQualityReport < ChartBase
314
326
  end
315
327
 
316
328
  def scan_for_discarded_data entry:
317
- hash = @original_issue_times[entry.issue]
329
+ hash = @discarded_changes_data&.find { |a| a[:issue] == entry.issue }
318
330
  return if hash.nil?
319
331
 
320
- old_start_time = hash[:started_time]
332
+ old_start_time = hash[:original_start_time]
321
333
  cutoff_time = hash[:cutoff_time]
322
334
 
323
335
  old_start_date = old_start_time.to_date
@@ -352,7 +364,7 @@ class DataQualityReport < ChartBase
352
364
  <<-HTML
353
365
  #{label_issues problems.size} have had information discarded. This configuration is set
354
366
  to "reset the clock" if an item is moved back to the backlog after it's been started. This hides important
355
- information and makes the data less accurate. <b>Moving items back to the backlog is strongly discouraged.</b> HTML
367
+ information and makes the data less accurate. <b>Moving items back to the backlog is strongly discouraged.</b>
356
368
  HTML
357
369
  end
358
370
 
@@ -437,4 +449,11 @@ class DataQualityReport < ChartBase
437
449
  could result in more data points showing up on a chart then there really should be.
438
450
  HTML
439
451
  end
452
+
453
+ def render_items_blocked_on_closed_tickets problems
454
+ <<-HTML
455
+ For #{label_issues problems.size}, the issue is identified as being blocked by another issue. Yet,
456
+ that other issue is already completed so, by definition, it can't still be blocking.
457
+ HTML
458
+ end
440
459
  end
@@ -103,8 +103,6 @@ class Downloader
103
103
  json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
104
104
  "?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
105
105
 
106
- exit_if_call_failed json
107
-
108
106
  json['issues'].each do |issue_json|
109
107
  issue_json['exporter'] = {
110
108
  'in_initial_query' => initial_query
@@ -139,15 +137,6 @@ class Downloader
139
137
  end
140
138
  end
141
139
 
142
- def exit_if_call_failed json
143
- # Sometimes Jira returns the singular form of errorMessage and sometimes the plural. Consistency FTW.
144
- return unless json['error'] || json['errorMessages'] || json['errorMessage']
145
-
146
- log "Download failed. See #{@file_system.logfile_name} for details.", both: true
147
- log " #{JSON.pretty_generate(json)}"
148
- exit 1
149
- end
150
-
151
140
  def download_statuses
152
141
  log ' Downloading all statuses', both: true
153
142
  json = @jira_gateway.call_url relative_url: '/rest/api/2/status'
@@ -188,8 +177,6 @@ class Downloader
188
177
  log " Downloading board configuration for board #{board_id}", both: true
189
178
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
190
179
 
191
- exit_if_call_failed json
192
-
193
180
  @file_system.save_json(
194
181
  json: json,
195
182
  filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_configuration.json")
@@ -213,7 +200,6 @@ class Downloader
213
200
  while is_last == false
214
201
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/sprint?" \
215
202
  "maxResults=#{max_results}&startAt=#{start_at}"
216
- exit_if_call_failed json
217
203
 
218
204
  @file_system.save_json(
219
205
  json: json,
@@ -28,8 +28,8 @@ class Exporter
28
28
  block = boards[board_id]
29
29
  if block == :default
30
30
  block = lambda do |_|
31
- start_at first_time_in_status_category('In Progress')
32
- stop_at still_in_status_category('Done')
31
+ start_at first_time_in_status_category(:indeterminate)
32
+ stop_at still_in_status_category(:done)
33
33
  end
34
34
  end
35
35
  board id: board_id do
@@ -2,18 +2,6 @@
2
2
 
3
3
  require 'fileutils'
4
4
 
5
- class Object
6
- def deprecated message:, date:, depth: 2
7
- text = +''
8
- text << "Deprecated(#{date}): "
9
- text << message
10
- caller(1..depth).each do |line|
11
- text << "\n-> Called from #{line}"
12
- end
13
- warn text
14
- end
15
- end
16
-
17
5
  class Exporter
18
6
  attr_reader :project_configs
19
7
  attr_accessor :file_system
@@ -76,12 +64,8 @@ class Exporter
76
64
  selected = []
77
65
  each_project_config(name_filter: name_filter) do |project|
78
66
  project.evaluate_next_level
79
- # next if project.aggregated_project?
80
67
 
81
68
  project.run load_only: true
82
- project.board_configs.each do |board_config|
83
- board_config.run
84
- end
85
69
  project.issues.each do |issue|
86
70
  selected << [project, issue] if keys.include? issue.key
87
71
  end
@@ -91,9 +75,13 @@ class Exporter
91
75
  raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
92
76
  end
93
77
 
94
- selected.each do |project, issue|
95
- puts "\nProject #{project.name}"
96
- puts issue.dump
78
+ if selected.empty?
79
+ file_system.log "No issues found to match #{keys.collect(&:inspect).join(', ')}"
80
+ else
81
+ selected.each do |project, issue|
82
+ file_system.log "\nProject #{project.name}", also_write_to_stderr: true
83
+ file_system.log issue.dump, also_write_to_stderr: true
84
+ end
97
85
  end
98
86
  end
99
87
 
@@ -128,7 +116,9 @@ class Exporter
128
116
 
129
117
  def jira_config filename = nil
130
118
  if filename
131
- @jira_config = file_system.load_json(filename)
119
+ @jira_config = file_system.load_json(filename, fail_on_error: false)
120
+ raise "Unable to load Jira configuration file and cannot continue: #{filename.inspect}" if @jira_config.nil?
121
+
132
122
  @jira_config['url'] = $1 if @jira_config['url'] =~ /^(.+)\/+$/
133
123
  end
134
124
  @jira_config
@@ -66,15 +66,20 @@ class FileConfig
66
66
  # is that all empty values in the first column should be at the bottom.
67
67
  def sort_output all_lines
68
68
  all_lines.sort do |a, b|
69
+ result = nil
69
70
  if a[0] == b[0]
70
- a[1..] <=> b[1..]
71
+ result = a[1..] <=> b[1..]
71
72
  elsif a[0].nil?
72
- 1
73
+ result = 1
73
74
  elsif b[0].nil?
74
- -1
75
+ result = -1
75
76
  else
76
- a[0] <=> b[0]
77
+ result = a[0] <=> b[0]
77
78
  end
79
+
80
+ # This will only happen if one of the objects isn't comparable. Seen in production.
81
+ result = -1 if result.nil?
82
+ result
78
83
  end
79
84
  end
80
85
 
@@ -85,6 +90,11 @@ class FileConfig
85
90
 
86
91
  def html_report &block
87
92
  assert_only_one_filetype_config_set
93
+ if block.nil?
94
+ project_config.file_system.warning 'No charts were specified for the report. This is almost certainly a mistake.'
95
+ block = ->(_) {}
96
+ end
97
+
88
98
  @html_report = HtmlReportConfig.new file_config: self, block: block
89
99
  end
90
100
 
@@ -120,4 +130,11 @@ class FileConfig
120
130
  @file_suffix = suffix unless suffix.nil?
121
131
  @file_suffix
122
132
  end
133
+
134
+ def children
135
+ result = []
136
+ result << @columns if @columns
137
+ result << @html_report if @html_report
138
+ result
139
+ end
123
140
  end
@@ -31,13 +31,22 @@ 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
34
+ def warning message, more: nil
35
+ log "Warning: #{message}", more: more, also_write_to_stderr: true
36
36
  end
37
37
 
38
- def log message, also_write_to_stderr: false
38
+ def error message, more: nil
39
+ log "Error: #{message}", more: more, also_write_to_stderr: true
40
+ end
41
+
42
+ def log message, more: nil, also_write_to_stderr: false
43
+ message += " See #{logfile_name} for more details about this message." if more
44
+
39
45
  logfile.puts message
40
- $stderr.puts message if also_write_to_stderr # rubocop:disable Style/StderrPuts
46
+ logfile.puts more if more
47
+ return unless also_write_to_stderr
48
+
49
+ $stderr.puts message # rubocop:disable Style/StderrPuts
41
50
  end
42
51
 
43
52
  # In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
@@ -59,4 +68,14 @@ class FileSystem
59
68
  def file_exist? filename
60
69
  File.exist? filename
61
70
  end
71
+
72
+ def deprecated message:, date:, depth: 2
73
+ text = +''
74
+ text << "Deprecated(#{date}): "
75
+ text << message
76
+ caller(1..depth).each do |line|
77
+ text << "\n-> Called from #{line}"
78
+ end
79
+ log text, also_write_to_stderr: true
80
+ end
62
81
  end
@@ -27,7 +27,7 @@ class FlowEfficiencyScatterplot < ChartBase
27
27
  </mfrac>
28
28
  </math>
29
29
  </div>
30
- <div style="background: yellow">Note that for this calculation to be accurate, we must be moving items into a
30
+ <div style="background: var(--warning-banner)">Note that for this calculation to be accurate, we must be moving items into a
31
31
  blocked or stalled state the moment we stop working on it, and most teams don't do that.
32
32
  So be aware that your team may have to change their behaviours if you want this chart to be useful.
33
33
  </div>
@@ -6,9 +6,7 @@ require 'jirametrics/grouping_rules'
6
6
  module GroupableIssueChart
7
7
  def init_configuration_block user_provided_block, &default_block
8
8
  instance_eval(&user_provided_block)
9
- return if @group_by_block
10
-
11
- instance_eval(&default_block)
9
+ instance_eval(&default_block) unless @group_by_block
12
10
  end
13
11
 
14
12
  def grouping_rules &block