jirametrics 2.4 → 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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +9 -4
  3. data/lib/jirametrics/aging_work_bar_chart.rb +13 -11
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
  5. data/lib/jirametrics/aging_work_table.rb +54 -7
  6. data/lib/jirametrics/blocked_stalled_change.rb +1 -1
  7. data/lib/jirametrics/board.rb +44 -15
  8. data/lib/jirametrics/board_config.rb +7 -3
  9. data/lib/jirametrics/board_movement_calculator.rb +147 -0
  10. data/lib/jirametrics/change_item.rb +19 -6
  11. data/lib/jirametrics/chart_base.rb +63 -27
  12. data/lib/jirametrics/css_variable.rb +1 -1
  13. data/lib/jirametrics/cycletime_config.rb +59 -8
  14. data/lib/jirametrics/cycletime_histogram.rb +68 -3
  15. data/lib/jirametrics/cycletime_scatterplot.rb +3 -6
  16. data/lib/jirametrics/daily_wip_by_age_chart.rb +2 -4
  17. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +2 -2
  18. data/lib/jirametrics/daily_wip_by_parent_chart.rb +0 -4
  19. data/lib/jirametrics/daily_wip_chart.rb +7 -9
  20. data/lib/jirametrics/data_quality_report.rb +219 -41
  21. data/lib/jirametrics/dependency_chart.rb +37 -10
  22. data/lib/jirametrics/download_config.rb +12 -0
  23. data/lib/jirametrics/downloader.rb +68 -50
  24. data/lib/jirametrics/estimate_accuracy_chart.rb +1 -2
  25. data/lib/jirametrics/examples/aggregated_project.rb +7 -21
  26. data/lib/jirametrics/examples/standard_project.rb +18 -34
  27. data/lib/jirametrics/expedited_chart.rb +8 -9
  28. data/lib/jirametrics/exporter.rb +28 -11
  29. data/lib/jirametrics/file_config.rb +23 -6
  30. data/lib/jirametrics/file_system.rb +39 -3
  31. data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
  32. data/lib/jirametrics/groupable_issue_chart.rb +1 -3
  33. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
  34. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
  35. data/lib/jirametrics/html/aging_work_table.erb +6 -4
  36. data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
  37. data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
  38. data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
  39. data/lib/jirametrics/html/expedited_chart.erb +1 -10
  40. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
  41. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  42. data/lib/jirametrics/html/index.css +28 -5
  43. data/lib/jirametrics/html/index.erb +8 -4
  44. data/lib/jirametrics/html/sprint_burndown.erb +1 -10
  45. data/lib/jirametrics/html/throughput_chart.erb +1 -10
  46. data/lib/jirametrics/html_report_config.rb +33 -23
  47. data/lib/jirametrics/issue.rb +232 -47
  48. data/lib/jirametrics/jira_gateway.rb +16 -3
  49. data/lib/jirametrics/project_config.rb +245 -134
  50. data/lib/jirametrics/rules.rb +2 -2
  51. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  52. data/lib/jirametrics/settings.json +5 -2
  53. data/lib/jirametrics/sprint_burndown.rb +3 -3
  54. data/lib/jirametrics/status.rb +84 -19
  55. data/lib/jirametrics/status_collection.rb +77 -39
  56. data/lib/jirametrics/throughput_chart.rb +1 -1
  57. data/lib/jirametrics/value_equality.rb +2 -2
  58. data/lib/jirametrics.rb +22 -6
  59. metadata +10 -13
  60. data/lib/jirametrics/discard_changes_before.rb +0 -37
  61. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -6,7 +6,7 @@ require 'json'
6
6
  class Downloader
7
7
  CURRENT_METADATA_VERSION = 4
8
8
 
9
- attr_accessor :metadata, :quiet_mode
9
+ attr_accessor :metadata
10
10
  attr_reader :file_system
11
11
 
12
12
  # For testing only
@@ -39,10 +39,11 @@ 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
- download_board_configuration board_id: id
45
- download_issues board_id: id
45
+ board = download_board_configuration board_id: id
46
+ download_issues board: board
46
47
  end
47
48
 
48
49
  save_metadata
@@ -54,8 +55,7 @@ class Downloader
54
55
  end
55
56
 
56
57
  def log text, both: false
57
- @file_system.log text
58
- puts text if both && !@quiet_mode
58
+ @file_system.log text, also_write_to_stderr: both
59
59
  end
60
60
 
61
61
  def find_board_ids
@@ -65,19 +65,19 @@ class Downloader
65
65
  ids
66
66
  end
67
67
 
68
- def download_issues board_id:
69
- log " Downloading primary issues for board #{board_id}", both: true
70
- path = "#{@target_path}#{@download_config.project_config.file_prefix}_issues/"
68
+ def download_issues board:
69
+ log " Downloading primary issues for board #{board.id}", both: true
70
+ path = File.join(@target_path, "#{file_prefix}_issues/")
71
71
  unless Dir.exist?(path)
72
72
  log " Creating path #{path}"
73
73
  Dir.mkdir(path)
74
74
  end
75
75
 
76
- filter_id = @board_id_to_filter_id[board_id]
76
+ filter_id = @board_id_to_filter_id[board.id]
77
77
  jql = make_jql(filter_id: filter_id)
78
- jira_search_by_jql(jql: jql, initial_query: true, board_id: board_id, path: path)
78
+ jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
79
79
 
80
- log " Downloading linked issues for board #{board_id}", both: true
80
+ log " Downloading linked issues for board #{board.id}", both: true
81
81
  loop do
82
82
  @issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
83
83
  break if @issue_keys_pending_download.empty?
@@ -85,11 +85,11 @@ class Downloader
85
85
  keys_to_request = @issue_keys_pending_download[0..99]
86
86
  @issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
87
87
  jql = "key in (#{keys_to_request.join(', ')})"
88
- jira_search_by_jql(jql: jql, initial_query: false, board_id: board_id, path: path)
88
+ jira_search_by_jql(jql: jql, initial_query: false, board: board, path: path)
89
89
  end
90
90
  end
91
91
 
92
- def jira_search_by_jql jql:, initial_query:, board_id:, path:
92
+ def jira_search_by_jql jql:, initial_query:, board:, path:
93
93
  intercept_jql = @download_config.project_config.settings['intercept_jql']
94
94
  jql = intercept_jql.call jql if intercept_jql
95
95
 
@@ -103,14 +103,12 @@ 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
111
109
  }
112
- identify_other_issues_to_be_downloaded issue_json
113
- file = "#{issue_json['key']}-#{board_id}.json"
110
+ identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
111
+ file = "#{issue_json['key']}-#{board.id}.json"
114
112
 
115
113
  @file_system.save_json(json: issue_json, filename: File.join(path, file))
116
114
  end
@@ -125,8 +123,8 @@ class Downloader
125
123
  end
126
124
  end
127
125
 
128
- def identify_other_issues_to_be_downloaded raw_issue
129
- issue = Issue.new raw: raw_issue, board: nil
126
+ def identify_other_issues_to_be_downloaded raw_issue:, board:
127
+ issue = Issue.new raw: raw_issue, board: board
130
128
  @issue_keys_downloaded_in_current_run << issue.key
131
129
 
132
130
  # Parent
@@ -137,22 +135,6 @@ class Downloader
137
135
  issue.raw['fields']['subtasks']&.each do |raw_subtask|
138
136
  @issue_keys_pending_download << raw_subtask['key']
139
137
  end
140
-
141
- # Links
142
- # We shouldn't blindly follow links as some, like cloners, aren't valuable and are just wasting time/effort
143
- # to download
144
- # issue.raw['fields']['issuelinks'].each do |raw_link|
145
- # @issue_keys_pending_download << IssueLink(raw: raw_link).other_issue.key
146
- # end
147
- end
148
-
149
- def exit_if_call_failed json
150
- # Sometimes Jira returns the singular form of errorMessage and sometimes the plural. Consistency FTW.
151
- return unless json['error'] || json['errorMessages'] || json['errorMessage']
152
-
153
- log "Download failed. See #{@file_system.logfile_name} for details.", both: true
154
- log " #{JSON.pretty_generate(json)}"
155
- exit 1
156
138
  end
157
139
 
158
140
  def download_statuses
@@ -161,29 +143,56 @@ class Downloader
161
143
 
162
144
  @file_system.save_json(
163
145
  json: json,
164
- filename: "#{@target_path}#{@download_config.project_config.file_prefix}_statuses.json"
146
+ filename: File.join(@target_path, "#{file_prefix}_statuses.json")
165
147
  )
166
148
  end
167
149
 
150
+ def update_status_history_file
151
+ status_filename = File.join(@target_path, "#{file_prefix}_statuses.json")
152
+ return unless file_system.file_exist? status_filename
153
+
154
+ status_json = file_system.load_json(status_filename)
155
+
156
+ history_filename = File.join(@target_path, "#{file_prefix}_status_history.json")
157
+ history_json = file_system.load_json(history_filename) if file_system.file_exist? history_filename
158
+
159
+ if history_json
160
+ file_system.log ' Updating status history file', also_write_to_stderr: true
161
+ else
162
+ file_system.log ' Creating status history file', also_write_to_stderr: true
163
+ history_json = []
164
+ end
165
+
166
+ status_json.each do |status_item|
167
+ id = status_item['id']
168
+ history_item = history_json.find { |s| s['id'] == id }
169
+ history_json.delete(history_item) if history_item
170
+ history_json << status_item
171
+ end
172
+
173
+ file_system.save_json(filename: history_filename, json: history_json)
174
+ end
175
+
168
176
  def download_board_configuration board_id:
169
177
  log " Downloading board configuration for board #{board_id}", both: true
170
178
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
171
179
 
172
- exit_if_call_failed json
173
-
174
- file_prefix = @download_config.project_config.file_prefix
175
- @file_system.save_json json: json, filename: "#{@target_path}#{file_prefix}_board_#{board_id}_configuration.json"
180
+ @file_system.save_json(
181
+ json: json,
182
+ filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_configuration.json")
183
+ )
176
184
 
177
185
  # We have a reported bug that blew up on this line. Moved it after the save so we can
178
186
  # actually look at the returned json.
179
187
  @board_id_to_filter_id[board_id] = json['filter']['id'].to_i
180
188
 
181
189
  download_sprints board_id: board_id if json['type'] == 'scrum'
190
+ # TODO: Should be passing actual statuses, not empty list
191
+ Board.new raw: json, possible_statuses: StatusCollection.new
182
192
  end
183
193
 
184
194
  def download_sprints board_id:
185
195
  log " Downloading sprints for board #{board_id}", both: true
186
- file_prefix = @download_config.project_config.file_prefix
187
196
  max_results = 100
188
197
  start_at = 0
189
198
  is_last = false
@@ -191,20 +200,23 @@ class Downloader
191
200
  while is_last == false
192
201
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/sprint?" \
193
202
  "maxResults=#{max_results}&startAt=#{start_at}"
194
- exit_if_call_failed json
195
203
 
196
204
  @file_system.save_json(
197
205
  json: json,
198
- filename: "#{@target_path}#{file_prefix}_board_#{board_id}_sprints_#{start_at}.json"
206
+ filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_sprints_#{start_at}.json")
199
207
  )
200
208
  is_last = json['isLast']
201
209
  max_results = json['maxResults']
202
- start_at += json['values'].size
210
+ if json['values']
211
+ start_at += json['values'].size
212
+ else
213
+ log " No sprints found for board #{board_id}"
214
+ end
203
215
  end
204
216
  end
205
217
 
206
218
  def metadata_pathname
207
- "#{@target_path}#{@download_config.project_config.file_prefix}_meta.json"
219
+ File.join(@target_path, "#{file_prefix}_meta.json")
208
220
  end
209
221
 
210
222
  def load_metadata
@@ -247,17 +259,17 @@ class Downloader
247
259
  end
248
260
 
249
261
  def remove_old_files
250
- file_prefix = @download_config.project_config.file_prefix
251
262
  Dir.foreach @target_path do |file|
252
263
  next unless file.match?(/^#{file_prefix}_\d+\.json$/)
264
+ next if file == "#{file_prefix}_status_history.json"
253
265
 
254
- File.unlink "#{@target_path}#{file}"
266
+ File.unlink File.join(@target_path, file)
255
267
  end
256
268
 
257
269
  return if @cached_data_format_is_current
258
270
 
259
271
  # Also throw away all the previously downloaded issues.
260
- path = File.join @target_path, "#{file_prefix}_issues"
272
+ path = File.join(@target_path, "#{file_prefix}_issues")
261
273
  return unless File.exist? path
262
274
 
263
275
  Dir.foreach path do |file|
@@ -271,8 +283,10 @@ class Downloader
271
283
  segments = []
272
284
  segments << "filter=#{filter_id}"
273
285
 
274
- unless @download_config.rolling_date_count.nil?
275
- @download_date_range = (today.to_date - @download_config.rolling_date_count)..today.to_date
286
+ start_date = @download_config.start_date today: today
287
+
288
+ if start_date
289
+ @download_date_range = start_date..today.to_date
276
290
 
277
291
  # For an incremental download, we want to query from the end of the previous one, not from the
278
292
  # beginning of the full range.
@@ -293,4 +307,8 @@ class Downloader
293
307
 
294
308
  segments.join ' AND '
295
309
  end
310
+
311
+ def file_prefix
312
+ @download_config.project_config.get_file_prefix
313
+ end
296
314
  end
@@ -83,8 +83,7 @@ class EstimateAccuracyChart < ChartBase
83
83
 
84
84
  issues.each do |issue|
85
85
  cycletime = issue.board.cycletime
86
- start_time = cycletime.started_time(issue)
87
- stop_time = cycletime.stopped_time(issue)
86
+ start_time, stop_time = cycletime.started_stopped_times(issue)
88
87
 
89
88
  next unless start_time
90
89
 
@@ -3,8 +3,6 @@
3
3
  # This file is really intended to give you ideas about how you might configure your own reports, not
4
4
  # as a complete setup that will work in every case.
5
5
  #
6
- # See https://github.com/mikebowler/jirametrics/wiki/Examples-folder for more details
7
- #
8
6
  # The point of an AGGREGATED report is that we're now looking at a higher level. We might use this in a
9
7
  # S2 meeting (Scrum of Scrums) to talk about the things that are happening across teams, not within a
10
8
  # single team. For that reason, we look at slightly different things that we would on a single team board.
@@ -13,6 +11,7 @@ class Exporter
13
11
  def aggregated_project name:, project_names:, settings: {}
14
12
  project name: name do
15
13
  puts name
14
+ file_prefix name
16
15
  self.settings.merge! settings
17
16
 
18
17
  aggregate do
@@ -21,8 +20,6 @@ class Exporter
21
20
  end
22
21
  end
23
22
 
24
- file_prefix name
25
-
26
23
  file do
27
24
  file_suffix '.html'
28
25
  issues.reject! do |issue|
@@ -33,8 +30,8 @@ class Exporter
33
30
  html '<h1>Boards included in this report</h1><ul>', type: :header
34
31
  board_lines = []
35
32
  included_projects.each do |project|
36
- project.all_boards.values.each do |board|
37
- board_lines << "<a href='#{project.file_prefix}.html'>#{board.name}</a> from project #{project.name}"
33
+ project.all_boards.each_value do |board|
34
+ board_lines << "<a href='#{project.get_file_prefix}.html'>#{board.name}</a> from project #{project.name}"
38
35
  end
39
36
  end
40
37
  board_lines.sort.each { |line| html "<li>#{line}</li>", type: :header }
@@ -64,25 +61,14 @@ class Exporter
64
61
 
65
62
  # By default, the issue doesn't show what board it's on and this is important for an
66
63
  # aggregated view
64
+ chart = self
67
65
  issue_rules do |issue, rules|
68
- key = issue.key
69
- key = "<S>#{key} </S> " if issue.status.category_name == 'Done'
70
- rules.label = "<#{key} [#{issue.type}]<BR/>#{issue.board.name}<BR/>#{word_wrap issue.summary}>"
66
+ chart.default_issue_rules.call(issue, rules)
67
+ rules.label = rules.label.split('<BR/>').insert(1, "Board: #{issue.board.name}").join('<BR/>')
71
68
  end
72
69
 
73
70
  link_rules do |link, rules|
74
- # By default, the dependency chart shows everything. Clean it up a bit.
75
- case link.name
76
- when 'Cloners'
77
- # We don't want to see any clone links at all.
78
- rules.ignore
79
- when 'Blocks'
80
- # For blocks, by default Jira will have links going both
81
- # ways and we want them only going one way. Also make the
82
- # link red.
83
- rules.merge_bidirectional keep: 'outward'
84
- rules.line_color = 'red'
85
- end
71
+ chart.default_link_rules.call(link, rules)
86
72
 
87
73
  # Because this is the aggregated view, let's hide any link that doesn't cross boards.
88
74
  rules.ignore if link.origin.board == link.other_issue.board
@@ -2,47 +2,49 @@
2
2
 
3
3
  # This file is really intended to give you ideas about how you might configure your own reports, not
4
4
  # as a complete setup that will work in every case.
5
- #
6
- # See https://github.com/mikebowler/jirametrics/wiki/Examples-folder for more
7
5
  class Exporter
8
6
  def standard_project name:, file_prefix:, ignore_issues: nil, starting_status: nil, boards: {},
9
- default_board: nil, anonymize: false, settings: {}, status_category_mappings: {}
7
+ default_board: nil, anonymize: false, settings: {}, status_category_mappings: {},
8
+ rolling_date_count: 90, no_earlier_than: nil, ignore_types: %w[Sub-task Subtask Epic],
9
+ show_experimental_charts: false
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
- settings['blocked_link_text'] = ['is blocked by']
15
+ self.anonymize if anonymize
16
16
  self.settings.merge! settings
17
17
 
18
18
  status_category_mappings.each do |status, category|
19
19
  status_category_mapping status: status, category: category
20
20
  end
21
21
 
22
- file_prefix file_prefix
23
22
  download do
24
- rolling_date_count 90
23
+ self.rolling_date_count(rolling_date_count) if rolling_date_count
24
+ self.no_earlier_than(no_earlier_than) if no_earlier_than
25
25
  end
26
26
 
27
27
  boards.each_key do |board_id|
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
36
36
  cycletime(&block)
37
- expedited_priority_names 'Critical', 'Highest', 'Immediate Gating'
38
37
  end
39
38
  end
40
39
 
40
+ issues.reject! do |issue|
41
+ ignore_types.include? issue.type
42
+ end
43
+
44
+ discard_changes_before status_becomes: (starting_status || :backlog) # rubocop:disable Style/RedundantParentheses
45
+
41
46
  file do
42
47
  file_suffix '.html'
43
- issues.reject! do |issue|
44
- %w[Sub-task Epic].include? issue.type
45
- end
46
48
 
47
49
  issues.reject! { |issue| ignore_issues.include? issue.key } if ignore_issues
48
50
 
@@ -52,12 +54,10 @@ class Exporter
52
54
  html "<H1>#{name}</H1>", type: :header
53
55
  boards.each_key do |id|
54
56
  board = find_board id
55
- html "<div><a href='#{board.url}'>#{id} #{board.name}</a></div>",
57
+ html "<div><a href='#{board.url}'>#{id} #{board.name}</a> (#{board.board_type})</div>",
56
58
  type: :header
57
59
  end
58
60
 
59
- discard_changes_before status_becomes: (starting_status || :backlog) # rubocop:disable Style/RedundantParentheses
60
-
61
61
  cycletime_scatterplot do
62
62
  show_trend_lines
63
63
  end
@@ -84,27 +84,11 @@ class Exporter
84
84
  daily_wip_by_age_chart
85
85
  daily_wip_by_blocked_stalled_chart
86
86
  daily_wip_by_parent_chart
87
+ flow_efficiency_scatterplot if show_experimental_charts
87
88
  expedited_chart
88
89
  sprint_burndown
89
90
  estimate_accuracy_chart
90
-
91
- dependency_chart do
92
- link_rules do |link, rules|
93
- case link.name
94
- when 'Cloners'
95
- rules.ignore
96
- when 'Dependency', 'Blocks', 'Parent/Child', 'Cause', 'Satisfy Requirement', 'Relates'
97
- rules.merge_bidirectional keep: 'outward'
98
- rules.merge_bidirectional keep: 'outward'
99
- when 'Sync'
100
- rules.use_bidirectional_arrows
101
- else
102
- # This is a link type that we don't recognize. Dump it to standard out to draw attention
103
- # to it.
104
- puts "name=#{link.name}, label=#{link.label}"
105
- end
106
- end
107
- end
91
+ dependency_chart
108
92
  end
109
93
  end
110
94
  end
@@ -57,13 +57,13 @@ class ExpeditedChart < ChartBase
57
57
  def prepare_expedite_data issue
58
58
  expedite_start = nil
59
59
  result = []
60
- expedited_priority_names = issue.board.expedited_priority_names
60
+ expedited_priority_names = issue.board.project_config.settings['expedited_priority_names']
61
61
 
62
62
  issue.changes.each do |change|
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,12 +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 = cycletime.started_time(issue)
113
- stopped_time = cycletime.stopped_time(issue)
112
+ started_date, stopped_date = cycletime.started_stopped_dates(issue)
114
113
 
115
- expedite_data << [started_time, :issue_started] if started_time
116
- expedite_data << [stopped_time, :issue_stopped] if stopped_time
117
- 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)
118
117
 
119
118
  # If none of the data would be visible on the chart then skip it.
120
119
  return nil unless expedite_data.any? { |time, _action| time.to_date >= date_range.begin }
@@ -151,7 +150,7 @@ class ExpeditedChart < ChartBase
151
150
 
152
151
  unless expedite_data.empty?
153
152
  last_change_time = expedite_data[-1][0].to_date
154
- 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?
155
154
  data << make_point(issue: issue, time: date_range.end, label: 'Still ongoing', expedited: expedited)
156
155
  dot_colors << '' # It won't be visible so it doesn't matter
157
156
  point_styles << 'dash'
@@ -2,16 +2,6 @@
2
2
 
3
3
  require 'fileutils'
4
4
 
5
- class Object
6
- def deprecated message:, date:
7
- text = +''
8
- text << "Deprecated(#{date}): "
9
- text << message
10
- text << "\n-> Called from #{caller(1..1).first}"
11
- warn text
12
- end
13
- end
14
-
15
5
  class Exporter
16
6
  attr_reader :project_configs
17
7
  attr_accessor :file_system
@@ -70,6 +60,31 @@ class Exporter
70
60
  puts "Full output from downloader in #{file_system.logfile_name}"
71
61
  end
72
62
 
63
+ def info keys, name_filter:
64
+ selected = []
65
+ each_project_config(name_filter: name_filter) do |project|
66
+ project.evaluate_next_level
67
+
68
+ project.run load_only: true
69
+ project.issues.each do |issue|
70
+ selected << [project, issue] if keys.include? issue.key
71
+ end
72
+ rescue => e # rubocop:disable Style/RescueStandardError
73
+ # This happens when we're attempting to load an aggregated project because it hasn't been
74
+ # properly initialized. Since we don't care about aggregated projects, we just ignore it.
75
+ raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
76
+ end
77
+
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
85
+ end
86
+ end
87
+
73
88
  def each_project_config name_filter:
74
89
  @project_configs.each do |project|
75
90
  yield project if project.name.nil? || File.fnmatch(name_filter, project.name)
@@ -101,7 +116,9 @@ class Exporter
101
116
 
102
117
  def jira_config filename = nil
103
118
  if filename
104
- @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
+
105
122
  @jira_config['url'] = $1 if @jira_config['url'] =~ /^(.+)\/+$/
106
123
  end
107
124
  @jira_config
@@ -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
@@ -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
 
@@ -103,7 +113,7 @@ class FileConfig
103
113
  def to_datetime object
104
114
  return nil if object.nil?
105
115
 
106
- object = object.to_datetime
116
+ object = object.to_time.to_datetime
107
117
  object = object.new_offset(@timezone_offset) if @timezone_offset
108
118
  object
109
119
  end
@@ -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
@@ -6,14 +6,18 @@ class FileSystem
6
6
  attr_accessor :logfile, :logfile_name
7
7
 
8
8
  # Effectively the same as File.read except it forces the encoding to UTF-8
9
- def load filename
9
+ def load filename, supress_deprecation: false
10
+ if filename.end_with?('.json') && !supress_deprecation
11
+ deprecated(message: 'call load_json instead', date: '2024-11-13')
12
+ end
13
+
10
14
  File.read filename, encoding: 'UTF-8'
11
15
  end
12
16
 
13
17
  def load_json filename, fail_on_error: true
14
18
  return nil if fail_on_error == false && File.exist?(filename) == false
15
19
 
16
- JSON.parse load(filename)
20
+ JSON.parse load(filename, supress_deprecation: true)
17
21
  end
18
22
 
19
23
  def save_json json:, filename:
@@ -27,8 +31,22 @@ class FileSystem
27
31
  File.write(filename, content)
28
32
  end
29
33
 
30
- def log message
34
+ def warning message, more: nil
35
+ log "Warning: #{message}", more: more, also_write_to_stderr: true
36
+ end
37
+
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
+
31
45
  logfile.puts message
46
+ logfile.puts more if more
47
+ return unless also_write_to_stderr
48
+
49
+ $stderr.puts message # rubocop:disable Style/StderrPuts
32
50
  end
33
51
 
34
52
  # In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
@@ -42,4 +60,22 @@ class FileSystem
42
60
  end
43
61
  node
44
62
  end
63
+
64
+ def foreach root, &block
65
+ Dir.foreach root, &block
66
+ end
67
+
68
+ def file_exist? filename
69
+ File.exist? filename
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
45
81
  end