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.
- checksums.yaml +4 -4
- data/lib/jirametrics/aggregate_config.rb +42 -29
- data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
- data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
- data/lib/jirametrics/aging_work_table.rb +36 -33
- data/lib/jirametrics/anonymizer.rb +31 -86
- data/lib/jirametrics/atlassian_document_format.rb +11 -4
- data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -1
- data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
- data/lib/jirametrics/board.rb +23 -9
- data/lib/jirametrics/board_config.rb +0 -7
- data/lib/jirametrics/board_movement_calculator.rb +24 -21
- data/lib/jirametrics/cfd_data_builder.rb +46 -44
- data/lib/jirametrics/change_item.rb +19 -7
- data/lib/jirametrics/chart_base.rb +51 -65
- data/lib/jirametrics/cumulative_flow_diagram.rb +60 -47
- data/lib/jirametrics/cycle_time_config.rb +35 -46
- data/lib/jirametrics/cycletime_histogram.rb +7 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
- data/lib/jirametrics/daily_view.rb +83 -61
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
- data/lib/jirametrics/daily_wip_chart.rb +72 -45
- data/lib/jirametrics/data_quality_report.rb +70 -66
- data/lib/jirametrics/dependency_chart.rb +40 -31
- data/lib/jirametrics/download_config.rb +0 -3
- data/lib/jirametrics/downloader.rb +39 -14
- data/lib/jirametrics/downloader_for_cloud.rb +110 -74
- data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
- data/lib/jirametrics/examples/standard_project.rb +2 -2
- data/lib/jirametrics/expedited_chart.rb +47 -37
- data/lib/jirametrics/exporter.rb +203 -12
- data/lib/jirametrics/file_config.rb +19 -12
- data/lib/jirametrics/file_system.rb +16 -10
- data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
- data/lib/jirametrics/github_gateway.rb +52 -12
- data/lib/jirametrics/groupable_issue_chart.rb +3 -0
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +3 -3
- data/lib/jirametrics/html/index.css +10 -4
- data/lib/jirametrics/html_report_config.rb +9 -29
- data/lib/jirametrics/issue.rb +293 -411
- data/lib/jirametrics/issue_collection.rb +1 -0
- data/lib/jirametrics/issue_printer.rb +60 -23
- data/lib/jirametrics/jira_gateway.rb +56 -18
- data/lib/jirametrics/mcp_server.rb +226 -210
- data/lib/jirametrics/project_config.rb +206 -133
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
- data/lib/jirametrics/rules.rb +1 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
- data/lib/jirametrics/sprint_burndown.rb +142 -242
- data/lib/jirametrics/sprint_count_measure.rb +42 -0
- data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
- data/lib/jirametrics/sprint_points_measure.rb +62 -0
- data/lib/jirametrics/sprint_summary_stats.rb +16 -0
- data/lib/jirametrics/status_collection.rb +29 -5
- data/lib/jirametrics/stitcher.rb +21 -16
- data/lib/jirametrics/throughput_chart.rb +38 -24
- data/lib/jirametrics/time_based_chart.rb +65 -0
- data/lib/jirametrics/time_based_histogram.rb +30 -23
- data/lib/jirametrics/time_based_scatterplot.rb +11 -2
- data/lib/jirametrics/trend_line_calculator.rb +2 -2
- data/lib/jirametrics/wip_by_column_chart.rb +75 -34
- data/lib/jirametrics.rb +35 -4
- 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
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
jql = make_jql(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
|
-
|
|
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
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
|
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
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
121
|
+
return unless start_time
|
|
119
122
|
|
|
120
|
-
|
|
123
|
+
hash = stop_time ? completed_hash : aging_hash
|
|
121
124
|
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
128
|
+
return if estimate.nil?
|
|
126
129
|
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|