jirametrics 2.31 → 3.1

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +42 -29
  3. data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
  5. data/lib/jirametrics/aging_work_table.rb +36 -33
  6. data/lib/jirametrics/anonymizer.rb +31 -86
  7. data/lib/jirametrics/atlassian_document_format.rb +11 -4
  8. data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
  9. data/lib/jirametrics/blocked_stalled_change.rb +5 -1
  10. data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
  11. data/lib/jirametrics/board.rb +23 -9
  12. data/lib/jirametrics/board_config.rb +0 -7
  13. data/lib/jirametrics/board_movement_calculator.rb +24 -21
  14. data/lib/jirametrics/cfd_data_builder.rb +46 -44
  15. data/lib/jirametrics/change_item.rb +19 -7
  16. data/lib/jirametrics/chart_base.rb +51 -65
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +60 -47
  18. data/lib/jirametrics/cycle_time_config.rb +35 -46
  19. data/lib/jirametrics/cycletime_histogram.rb +7 -0
  20. data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
  21. data/lib/jirametrics/daily_view.rb +83 -61
  22. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
  23. data/lib/jirametrics/daily_wip_chart.rb +72 -45
  24. data/lib/jirametrics/data_quality_report.rb +70 -66
  25. data/lib/jirametrics/dependency_chart.rb +40 -31
  26. data/lib/jirametrics/download_config.rb +0 -3
  27. data/lib/jirametrics/downloader.rb +39 -14
  28. data/lib/jirametrics/downloader_for_cloud.rb +110 -74
  29. data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
  30. data/lib/jirametrics/examples/standard_project.rb +2 -2
  31. data/lib/jirametrics/expedited_chart.rb +47 -37
  32. data/lib/jirametrics/exporter.rb +203 -12
  33. data/lib/jirametrics/file_config.rb +19 -12
  34. data/lib/jirametrics/file_system.rb +16 -10
  35. data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
  36. data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
  37. data/lib/jirametrics/github_gateway.rb +52 -12
  38. data/lib/jirametrics/groupable_issue_chart.rb +3 -0
  39. data/lib/jirametrics/html/cumulative_flow_diagram.erb +3 -3
  40. data/lib/jirametrics/html/index.css +10 -4
  41. data/lib/jirametrics/html_report_config.rb +9 -29
  42. data/lib/jirametrics/issue.rb +293 -411
  43. data/lib/jirametrics/issue_collection.rb +1 -0
  44. data/lib/jirametrics/issue_printer.rb +60 -23
  45. data/lib/jirametrics/jira_gateway.rb +56 -18
  46. data/lib/jirametrics/mcp_server.rb +226 -210
  47. data/lib/jirametrics/project_config.rb +206 -133
  48. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
  49. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
  50. data/lib/jirametrics/rules.rb +1 -0
  51. data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
  52. data/lib/jirametrics/sprint_burndown.rb +142 -242
  53. data/lib/jirametrics/sprint_count_measure.rb +42 -0
  54. data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
  55. data/lib/jirametrics/sprint_points_measure.rb +62 -0
  56. data/lib/jirametrics/sprint_summary_stats.rb +16 -0
  57. data/lib/jirametrics/status_collection.rb +29 -5
  58. data/lib/jirametrics/stitcher.rb +21 -16
  59. data/lib/jirametrics/throughput_chart.rb +38 -24
  60. data/lib/jirametrics/time_based_chart.rb +65 -0
  61. data/lib/jirametrics/time_based_histogram.rb +30 -23
  62. data/lib/jirametrics/time_based_scatterplot.rb +11 -2
  63. data/lib/jirametrics/trend_line_calculator.rb +2 -2
  64. data/lib/jirametrics/wip_by_column_chart.rb +75 -34
  65. data/lib/jirametrics.rb +35 -4
  66. metadata +13 -20
@@ -130,7 +130,7 @@ class DownloaderForCloud < Downloader
130
130
  issue_datas
131
131
  end
132
132
 
133
- def attach_worklogs_to_issues issue_datas:, issue_jsons:, max_results: 100 # rubocop:disable Lint/UnusedMethodArgument
133
+ def attach_worklogs_to_issues issue_datas:, issue_jsons:, max_results: 100
134
134
  issue_jsons.each do |issue_json|
135
135
  worklog = issue_json['fields']['worklog']
136
136
  next unless worklog
@@ -140,27 +140,7 @@ class DownloaderForCloud < Downloader
140
140
  next if all_worklogs.size >= total
141
141
 
142
142
  key = issue_json['key']
143
- start_at = all_worklogs.size
144
-
145
- loop do
146
- response = @jira_gateway.call_url(
147
- relative_url: "/rest/api/3/issue/#{CGI.escape(key)}/worklog?startAt=#{start_at}&maxResults=#{max_results}"
148
- )
149
-
150
- worklogs = response['worklogs'] || []
151
- all_worklogs.concat(worklogs)
152
-
153
- total = response['total'].to_i
154
- log " #{key} worklogs: page startAt=#{start_at}, " \
155
- "received=#{worklogs.size}, fetched=#{all_worklogs.size}/#{total}"
156
- break if all_worklogs.size >= total
157
- # Guard against Jira reporting a higher total than it will actually return — seen when
158
- # worklogs are deleted or access-restricted after the initial fetch. Without this,
159
- # start_at never advances and we loop forever requesting the same empty page.
160
- break if worklogs.empty?
161
-
162
- start_at += worklogs.size
163
- end
143
+ fetch_remaining_worklogs key: key, all_worklogs: all_worklogs, max_results: max_results
164
144
 
165
145
  issue_json['fields']['worklog'] = {
166
146
  'startAt' => 0,
@@ -173,6 +153,30 @@ class DownloaderForCloud < Downloader
173
153
  end
174
154
  end
175
155
 
156
+ # Page through the worklogs Jira didn't include inline, appending each page to all_worklogs.
157
+ def fetch_remaining_worklogs key:, all_worklogs:, max_results:
158
+ start_at = all_worklogs.size
159
+ loop do
160
+ response = @jira_gateway.call_url(
161
+ relative_url: "/rest/api/3/issue/#{CGI.escape(key)}/worklog?startAt=#{start_at}&maxResults=#{max_results}"
162
+ )
163
+
164
+ worklogs = response['worklogs'] || []
165
+ all_worklogs.concat(worklogs)
166
+
167
+ total = response['total'].to_i
168
+ log " #{key} worklogs: page startAt=#{start_at}, " \
169
+ "received=#{worklogs.size}, fetched=#{all_worklogs.size}/#{total}"
170
+ break if all_worklogs.size >= total
171
+ # Guard against Jira reporting a higher total than it will actually return - seen when
172
+ # worklogs are deleted or access-restricted after the initial fetch. Without this,
173
+ # start_at never advances and we loop forever requesting the same empty page.
174
+ break if worklogs.empty?
175
+
176
+ start_at += worklogs.size
177
+ end
178
+ end
179
+
176
180
  def attach_changelog_to_issues issue_datas:, issue_jsons:
177
181
  max_results = 10_000 # The max jira accepts is 10K
178
182
  payload = {
@@ -212,71 +216,59 @@ class DownloaderForCloud < Downloader
212
216
 
213
217
  def download_issues board:
214
218
  log " Downloading primary issues for board #{board.id} from #{jira_instance_type}", both: true
219
+ path = ensure_issues_directory
220
+ issue_data_hash = search_for_issues jql: build_jql(board), board_id: board.id, path: path
221
+
222
+ download_all_issues board: board, path: path, issue_data_hash: issue_data_hash
223
+
224
+ delete_issues_from_cache_that_are_not_in_server(
225
+ issue_data_hash: issue_data_hash, path: path
226
+ )
227
+ end
228
+
229
+ def ensure_issues_directory
215
230
  path = File.join(@target_path, "#{file_prefix}_issues/")
216
231
  unless @file_system.dir_exist?(path)
217
232
  log " Creating path #{path}"
218
233
  @file_system.mkdir(path)
219
234
  end
235
+ path
236
+ end
220
237
 
221
- filter_id = @board_id_to_filter_id[board.id]
222
- jql = make_jql(filter_id: filter_id)
238
+ def build_jql board
239
+ jql = make_jql(filter_id: @board_id_to_filter_id[board.id])
223
240
  intercept_jql = @download_config.project_config.settings['intercept_jql']
224
- jql = intercept_jql.call jql if intercept_jql
225
-
226
- issue_data_hash = search_for_issues jql: jql, board_id: board.id, path: path
241
+ intercept_jql ? intercept_jql.call(jql) : jql
242
+ end
227
243
 
244
+ # Downloads the primary issues, then repeatedly follows related issues (parents, subtasks, links)
245
+ # until no new ones turn up. `in_related_phase` is false for the first pass and true thereafter,
246
+ # which controls the once-only "Identifying related issues" announcement and the loop diagnostics.
247
+ def download_all_issues board:, path:, issue_data_hash:
228
248
  checked_for_related = Set.new
229
249
  in_related_phase = false
230
250
 
231
251
  loop do
232
252
  related_issue_keys = Set.new
233
- stale = issue_data_hash.values.reject { |data| data.up_to_date }
253
+ stale = issue_data_hash.values.reject(&:up_to_date)
234
254
  if in_related_phase
235
255
  @file_system.diagnostic "Download loop: #{issue_data_hash.size} total known, " \
236
256
  "#{stale.size} stale, #{checked_for_related.size} link-scanned"
237
257
  end
238
- unless stale.empty?
239
- log_start ' Downloading more issues ' unless in_related_phase
240
- stale.each_slice(100) do |slice|
241
- slice = bulk_fetch_issues(issue_datas: slice, board: board, in_initial_query: !in_related_phase)
242
- progress_dot
243
- slice.each do |data|
244
- next unless data.issue
245
-
246
- @file_system.save_json(
247
- json: data.issue.raw, filename: data.cache_path
248
- )
249
- # Set the timestamp on the file to match the updated one so that we don't have
250
- # to parse the file just to find the timestamp
251
- @file_system.utime time: data.issue.updated, file: data.cache_path
252
-
253
- collect_or_log_related(
254
- issue: data.issue, found_in_primary_query: data.found_in_primary_query,
255
- related_issue_keys: related_issue_keys, issue_data_hash: issue_data_hash
256
- )
257
- checked_for_related << data.key
258
- end
259
- end
260
- end_progress unless in_related_phase
261
- end
258
+ download_stale_issues(
259
+ stale: stale, board: board, in_related_phase: in_related_phase,
260
+ checked_for_related: checked_for_related, related_issue_keys: related_issue_keys,
261
+ issue_data_hash: issue_data_hash
262
+ )
262
263
 
263
264
  scan_cached_issues_for_related(
264
265
  issue_data_hash: issue_data_hash, board: board,
265
266
  checked_for_related: checked_for_related, related_issue_keys: related_issue_keys
266
267
  )
267
-
268
- # Remove all the ones we already have
269
- related_issue_keys.reject! { |key| issue_data_hash[key] }
270
-
271
- related_issue_keys.each do |key|
272
- data = DownloadIssueData.new key: key
273
- data.found_in_primary_query = false
274
- data.up_to_date = false
275
- data.cache_path = File.join(path, "#{key}-#{board.id}.json")
276
- issue_data_hash[key] = data
277
- end
268
+ register_related_issues(
269
+ related_issue_keys: related_issue_keys, issue_data_hash: issue_data_hash, path: path, board: board
270
+ )
278
271
  break if related_issue_keys.empty?
279
-
280
272
  next if in_related_phase
281
273
 
282
274
  in_related_phase = true
@@ -285,10 +277,52 @@ class DownloaderForCloud < Downloader
285
277
  end
286
278
 
287
279
  end_progress if in_related_phase
280
+ end
288
281
 
289
- delete_issues_from_cache_that_are_not_in_server(
290
- issue_data_hash: issue_data_hash, path: path
291
- )
282
+ def download_stale_issues stale:, board:, in_related_phase:, checked_for_related:, related_issue_keys:,
283
+ issue_data_hash:
284
+ return if stale.empty?
285
+
286
+ log_start ' Downloading more issues ' unless in_related_phase
287
+ stale.each_slice(100) do |slice|
288
+ slice = bulk_fetch_issues(issue_datas: slice, board: board, in_initial_query: !in_related_phase)
289
+ progress_dot
290
+ save_fetched_issues(
291
+ slice: slice, checked_for_related: checked_for_related,
292
+ related_issue_keys: related_issue_keys, issue_data_hash: issue_data_hash
293
+ )
294
+ end
295
+ end_progress unless in_related_phase
296
+ end
297
+
298
+ def save_fetched_issues slice:, checked_for_related:, related_issue_keys:, issue_data_hash:
299
+ slice.each do |data|
300
+ next unless data.issue
301
+
302
+ @file_system.save_json(json: data.issue.raw, filename: data.cache_path)
303
+ # Set the timestamp on the file to match the updated one so that we don't have
304
+ # to parse the file just to find the timestamp
305
+ @file_system.utime time: data.issue.updated, file: data.cache_path
306
+
307
+ collect_or_log_related(
308
+ issue: data.issue, found_in_primary_query: data.found_in_primary_query,
309
+ related_issue_keys: related_issue_keys, issue_data_hash: issue_data_hash
310
+ )
311
+ checked_for_related << data.key
312
+ end
313
+ end
314
+
315
+ def register_related_issues related_issue_keys:, issue_data_hash:, path:, board:
316
+ # Remove all the ones we already have
317
+ related_issue_keys.reject! { |key| issue_data_hash[key] }
318
+
319
+ related_issue_keys.each do |key|
320
+ data = DownloadIssueData.new key: key
321
+ data.found_in_primary_query = false
322
+ data.up_to_date = false
323
+ data.cache_path = File.join(path, "#{key}-#{board.id}.json")
324
+ issue_data_hash[key] = data
325
+ end
292
326
  end
293
327
 
294
328
  def delete_issues_from_cache_that_are_not_in_server issue_data_hash:, path:
@@ -310,7 +344,7 @@ class DownloaderForCloud < Downloader
310
344
  end
311
345
  end
312
346
 
313
- # Scan up-to-date cached primary issues we haven't checked yet — they may reference related
347
+ # Scan up-to-date cached primary issues we haven't checked yet - they may reference related
314
348
  # issues that are not in the primary query result. We only follow links one hop out from the
315
349
  # primary issues, so related (non-primary) cached issues are not followed (just logged).
316
350
  def scan_cached_issues_for_related issue_data_hash:, board:, checked_for_related:, related_issue_keys:
@@ -348,23 +382,25 @@ class DownloaderForCloud < Downloader
348
382
  parent_key = issue.parent_key(project_config: @download_config.project_config)
349
383
  keys << parent_key if parent_key
350
384
 
351
- issue.raw['fields']['subtasks']&.each do |raw_subtask|
352
- keys << raw_subtask['key']
353
- end
385
+ issue.raw['fields']['subtasks']&.each { |raw_subtask| keys << raw_subtask['key'] }
386
+ add_linked_issue_keys issue, keys
387
+
388
+ keys
389
+ end
354
390
 
391
+ # Add the keys of issues linked to this one, skipping clone links (a clone isn't a dependency).
392
+ def add_linked_issue_keys issue, keys
355
393
  issue.raw['fields']['issuelinks']&.each do |link|
356
394
  next if link['type']['name'] == 'Cloners'
357
395
 
358
396
  linked = link['inwardIssue'] || link['outwardIssue']
359
397
  keys << linked['key'] if linked
360
398
  end
361
-
362
- keys
363
399
  end
364
400
 
365
401
  # We only follow links one hop out from the primary (board) issues. If a related issue
366
402
  # itself references further issues we haven't already downloaded, we deliberately don't
367
- # follow them — but log it so we can diagnose later if an export fails because a
403
+ # follow them - but log it so we can diagnose later if an export fails because a
368
404
  # second-hop issue was missing. See GitHub #72.
369
405
  def log_unfollowed_related_keys issue:, issue_data_hash:
370
406
  onward = related_keys_for(issue).reject { |key| issue_data_hash[key] }
@@ -110,25 +110,25 @@ class EstimateAccuracyChart < ChartBase
110
110
  def split_into_completed_and_aging issues:
111
111
  aging_hash = {}
112
112
  completed_hash = {}
113
+ issues.each { |issue| bucket_issue_by_estimate issue, completed_hash: completed_hash, aging_hash: aging_hash }
114
+ [completed_hash, aging_hash]
115
+ end
113
116
 
114
- issues.each do |issue|
115
- cycletime = issue.board.cycletime
116
- start_time, stop_time = cycletime.started_stopped_times(issue)
117
+ def bucket_issue_by_estimate issue, completed_hash:, aging_hash:
118
+ cycletime = issue.board.cycletime
119
+ start_time, stop_time = cycletime.started_stopped_times(issue)
117
120
 
118
- next unless start_time
121
+ return unless start_time
119
122
 
120
- hash = stop_time ? completed_hash : aging_hash
123
+ hash = stop_time ? completed_hash : aging_hash
121
124
 
122
- estimate = @y_axis_block.call issue, start_time
123
- cycle_time = ((stop_time&.to_date || date_range.end) - start_time.to_date).to_i + 1
125
+ estimate = @y_axis_block.call issue, start_time
126
+ cycle_time = ((stop_time&.to_date || date_range.end) - start_time.to_date).to_i + 1
124
127
 
125
- next if estimate.nil?
128
+ return if estimate.nil?
126
129
 
127
- key = [estimate, cycle_time]
128
- (hash[key] ||= []) << issue
129
- end
130
-
131
- [completed_hash, aging_hash]
130
+ key = [estimate, cycle_time]
131
+ (hash[key] ||= []) << issue
132
132
  end
133
133
 
134
134
  def hash_sorter
@@ -35,7 +35,7 @@ class Exporter
35
35
  download do
36
36
  self.rolling_date_count(rolling_date_count) if rolling_date_count
37
37
  self.no_earlier_than(no_earlier_than) if no_earlier_than
38
- github_repo *github_repos if github_repos
38
+ github_repo(*github_repos) if github_repos
39
39
  end
40
40
 
41
41
  issues.reject! do |issue|
@@ -44,7 +44,7 @@ class Exporter
44
44
 
45
45
  exporter.filter_issues issues, ignore_issues
46
46
 
47
- discard_changes_before status_becomes: (starting_status || :backlog) # rubocop:disable Style/RedundantParentheses
47
+ discard_changes_before status_becomes: (starting_status || :backlog)
48
48
 
49
49
  file do
50
50
  file_suffix '.html'
@@ -67,12 +67,7 @@ class ExpeditedChart < ChartBase
67
67
  if expedited_priority_names.include? change.value
68
68
  expedite_start = change.time.to_date
69
69
  elsif expedite_start
70
- start_date = expedite_start.to_date
71
- stop_date = change.time.to_date
72
-
73
- if date_range.include?(start_date) || date_range.include?(stop_date) ||
74
- (start_date < date_range.begin && stop_date > date_range.end)
75
-
70
+ if expedite_visible?(expedite_start, change.time.to_date)
76
71
  result << [expedite_start, :expedite_start]
77
72
  result << [change.time.to_date, :expedite_stop]
78
73
  end
@@ -85,6 +80,13 @@ class ExpeditedChart < ChartBase
85
80
  result
86
81
  end
87
82
 
83
+ # True when an expedite span overlaps the chart's date range: either endpoint falls inside it, or it
84
+ # starts before and ends after (spanning the whole range).
85
+ def expedite_visible? start_date, stop_date
86
+ date_range.include?(start_date) || date_range.include?(stop_date) ||
87
+ (start_date < date_range.begin && stop_date > date_range.end)
88
+ end
89
+
88
90
  def find_expedited_issues
89
91
  expedited_issues = @issues.reject do |issue|
90
92
  prepare_expedite_data(issue).empty?
@@ -110,8 +112,7 @@ class ExpeditedChart < ChartBase
110
112
  end
111
113
 
112
114
  def make_expedite_lines_data_set issue:, expedite_data:
113
- cycletime = issue.board.cycletime
114
- started_date, stopped_date = cycletime.started_stopped_dates(issue)
115
+ started_date, stopped_date = issue.board.cycletime.started_stopped_dates(issue)
115
116
 
116
117
  expedite_data << [started_date, :issue_started] if started_date
117
118
  expedite_data << [stopped_date, :issue_stopped] if stopped_date
@@ -126,37 +127,16 @@ class ExpeditedChart < ChartBase
126
127
  expedited = false
127
128
 
128
129
  expedite_data.each do |time, action|
129
- case action
130
- when :issue_started
131
- data << make_point(issue: issue, time: time, label: 'Started', expedited: expedited)
132
- dot_colors << CssVariable['--expedited-chart-dot-issue-started-color']
133
- point_styles << 'rect'
134
- when :issue_stopped
135
- data << make_point(issue: issue, time: time, label: 'Completed', expedited: expedited)
136
- dot_colors << CssVariable['--expedited-chart-dot-issue-stopped-color']
137
- point_styles << 'rect'
138
- when :expedite_start
139
- data << make_point(issue: issue, time: time, label: 'Expedited', expedited: true)
140
- dot_colors << CssVariable['--expedited-chart-dot-expedite-started-color']
141
- point_styles << 'circle'
142
- expedited = true
143
- when :expedite_stop
144
- data << make_point(issue: issue, time: time, label: 'Not expedited', expedited: false)
145
- dot_colors << CssVariable['--expedited-chart-dot-expedite-stopped-color']
146
- point_styles << 'circle'
147
- expedited = false
148
- else
149
- raise "Unexpected action: #{action}"
150
- end
130
+ point, color, style, expedited = expedite_point(issue, time, action, expedited)
131
+ data << point
132
+ dot_colors << color
133
+ point_styles << style
151
134
  end
152
135
 
153
- unless expedite_data.empty?
154
- last_change_time = expedite_data[-1][0].to_date
155
- if last_change_time && last_change_time <= date_range.end && stopped_date.nil?
156
- data << make_point(issue: issue, time: date_range.end, label: 'Still ongoing', expedited: expedited)
157
- dot_colors << '' # It won't be visible so it doesn't matter
158
- point_styles << 'dash'
159
- end
136
+ if still_ongoing?(expedite_data, stopped_date)
137
+ data << make_point(issue: issue, time: date_range.end, label: 'Still ongoing', expedited: expedited)
138
+ dot_colors << '' # It won't be visible so it doesn't matter
139
+ point_styles << 'dash'
160
140
  end
161
141
 
162
142
  {
@@ -171,4 +151,34 @@ class ExpeditedChart < ChartBase
171
151
  segment: EXPEDITED_SEGMENT
172
152
  }
173
153
  end
154
+
155
+ # Returns [point, dot_color, point_style, expedited] for one timeline event. The returned expedited
156
+ # flag carries the (possibly changed) state forward to the next event.
157
+ def expedite_point issue, time, action, expedited
158
+ case action
159
+ when :issue_started
160
+ [make_point(issue: issue, time: time, label: 'Started', expedited: expedited),
161
+ CssVariable['--expedited-chart-dot-issue-started-color'], 'rect', expedited]
162
+ when :issue_stopped
163
+ [make_point(issue: issue, time: time, label: 'Completed', expedited: expedited),
164
+ CssVariable['--expedited-chart-dot-issue-stopped-color'], 'rect', expedited]
165
+ when :expedite_start
166
+ [make_point(issue: issue, time: time, label: 'Expedited', expedited: true),
167
+ CssVariable['--expedited-chart-dot-expedite-started-color'], 'circle', true]
168
+ when :expedite_stop
169
+ [make_point(issue: issue, time: time, label: 'Not expedited', expedited: false),
170
+ CssVariable['--expedited-chart-dot-expedite-stopped-color'], 'circle', false]
171
+ else
172
+ raise "Unexpected action: #{action}"
173
+ end
174
+ end
175
+
176
+ # The issue is still expedited/open at the end of the chart: it hasn't stopped and its last recorded
177
+ # change is on or before the end of the range.
178
+ def still_ongoing? expedite_data, stopped_date
179
+ return false if expedite_data.empty?
180
+
181
+ last_change_time = expedite_data[-1][0].to_date
182
+ last_change_time && last_change_time <= date_range.end && stopped_date.nil?
183
+ end
174
184
  end