jirametrics 2.31 → 3.0
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 +50 -64
- data/lib/jirametrics/cumulative_flow_diagram.rb +59 -46
- 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 +61 -63
- data/lib/jirametrics/dependency_chart.rb +40 -31
- data/lib/jirametrics/download_config.rb +0 -3
- data/lib/jirametrics/downloader.rb +8 -7
- data/lib/jirametrics/downloader_for_cloud.rb +108 -72
- 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 +19 -10
- 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 +23 -9
- data/lib/jirametrics/groupable_issue_chart.rb +3 -0
- data/lib/jirametrics/html/index.css +6 -0
- data/lib/jirametrics/html_report_config.rb +9 -29
- data/lib/jirametrics/issue.rb +290 -389
- data/lib/jirametrics/issue_collection.rb +1 -0
- data/lib/jirametrics/issue_printer.rb +60 -23
- data/lib/jirametrics/jira_gateway.rb +29 -18
- data/lib/jirametrics/mcp_server.rb +225 -209
- data/lib/jirametrics/project_config.rb +158 -134
- 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 +2 -2
- 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 +73 -32
- data/lib/jirametrics.rb +6 -2
- 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:
|
|
@@ -348,18 +382,20 @@ 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
|
|
@@ -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
|
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -8,7 +8,9 @@ class Exporter
|
|
|
8
8
|
|
|
9
9
|
def self.configure &block
|
|
10
10
|
logfile_name = 'jirametrics.log'
|
|
11
|
-
|
|
11
|
+
# No block form: FileSystem holds this descriptor open for the whole run and writes to it as we
|
|
12
|
+
# go, so it must outlive this method.
|
|
13
|
+
logfile = File.open(logfile_name, 'w') # rubocop:disable Style/FileOpen
|
|
12
14
|
rescue Errno::EACCES
|
|
13
15
|
# FileSystem can't be used here — it hasn't been created yet (it depends on this logfile).
|
|
14
16
|
warn "Error: Cannot write to #{File.expand_path(logfile_name)}. " \
|
|
@@ -78,12 +80,7 @@ class Exporter
|
|
|
78
80
|
project.evaluate_next_level
|
|
79
81
|
|
|
80
82
|
project.run load_only: true
|
|
81
|
-
|
|
82
|
-
selected << [project, issue] if key == issue.key
|
|
83
|
-
issue.subtasks.each do |subtask|
|
|
84
|
-
selected << [project, subtask] if key == subtask.key
|
|
85
|
-
end
|
|
86
|
-
end
|
|
83
|
+
selected.concat matching_issues_in(project, key)
|
|
87
84
|
rescue => e # rubocop:disable Style/RescueStandardError
|
|
88
85
|
# This happens when we're attempting to load an aggregated project because it hasn't been
|
|
89
86
|
# properly initialized. Since we don't care about aggregated projects, we just ignore it.
|
|
@@ -101,6 +98,17 @@ class Exporter
|
|
|
101
98
|
end
|
|
102
99
|
end
|
|
103
100
|
|
|
101
|
+
def matching_issues_in project, key
|
|
102
|
+
matches = []
|
|
103
|
+
project.issues.each do |issue|
|
|
104
|
+
matches << [project, issue] if key == issue.key
|
|
105
|
+
issue.subtasks.each do |subtask|
|
|
106
|
+
matches << [project, subtask] if key == subtask.key
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
matches
|
|
110
|
+
end
|
|
111
|
+
|
|
104
112
|
def stitch stitch_file
|
|
105
113
|
Stitcher.new(file_system: file_system).run(stitch_file: stitch_file)
|
|
106
114
|
end
|
|
@@ -139,7 +147,8 @@ class Exporter
|
|
|
139
147
|
@jira_config = file_system.load_json(filename, fail_on_error: false)
|
|
140
148
|
raise "Unable to load Jira configuration file and cannot continue: #{filename.inspect}" if @jira_config.nil?
|
|
141
149
|
|
|
142
|
-
|
|
150
|
+
match = %r{^(?<base_url>.+)/+$}.match(@jira_config['url'])
|
|
151
|
+
@jira_config['url'] = match[:base_url] if match
|
|
143
152
|
end
|
|
144
153
|
@jira_config
|
|
145
154
|
end
|
|
@@ -153,8 +162,8 @@ class Exporter
|
|
|
153
162
|
unless args.empty?
|
|
154
163
|
dates = []
|
|
155
164
|
args.each do |arg|
|
|
156
|
-
if
|
|
157
|
-
Date.parse(
|
|
165
|
+
if /^(?<from>\d{4}-\d{2}-\d{2})\.\.(?<to>\d{4}-\d{2}-\d{2})$/ =~ arg
|
|
166
|
+
Date.parse(from).upto(Date.parse(to)).each { |date| dates << date }
|
|
158
167
|
else
|
|
159
168
|
dates << Date.parse(arg)
|
|
160
169
|
end
|
|
@@ -5,11 +5,12 @@ require 'csv'
|
|
|
5
5
|
class FileConfig
|
|
6
6
|
attr_reader :project_config, :issues
|
|
7
7
|
|
|
8
|
-
def initialize project_config:, block:, today: Date.today
|
|
8
|
+
def initialize project_config:, block:, today: Date.today, issues: nil
|
|
9
9
|
@project_config = project_config
|
|
10
10
|
@block = block
|
|
11
11
|
@columns = nil
|
|
12
12
|
@today = today
|
|
13
|
+
@issues = issues
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def run
|
|
@@ -65,22 +66,28 @@ class FileConfig
|
|
|
65
66
|
# most common usecase - the Team Dashboard from FocusedObjective.com. The rule for that one
|
|
66
67
|
# is that all empty values in the first column should be at the bottom.
|
|
67
68
|
def sort_output all_lines
|
|
68
|
-
all_lines.each_with_index.sort do |(
|
|
69
|
-
result =
|
|
70
|
-
a[1..] <=> b[1..]
|
|
71
|
-
elsif a[0].nil?
|
|
72
|
-
1
|
|
73
|
-
elsif b[0].nil?
|
|
74
|
-
-1
|
|
75
|
-
else
|
|
76
|
-
a[0] <=> b[0]
|
|
77
|
-
end
|
|
69
|
+
all_lines.each_with_index.sort do |(left, left_idx), (right, right_idx)|
|
|
70
|
+
result = compare_rows left, right
|
|
78
71
|
|
|
79
72
|
# When objects aren't comparable, preserve original order for a stable sort.
|
|
80
|
-
result.nil? || result.zero? ?
|
|
73
|
+
result.nil? || result.zero? ? left_idx <=> right_idx : result
|
|
81
74
|
end.map(&:first)
|
|
82
75
|
end
|
|
83
76
|
|
|
77
|
+
# Compare two rows by their first column, falling back to the remaining columns on a tie, and
|
|
78
|
+
# sorting nil first columns to the bottom.
|
|
79
|
+
def compare_rows left, right
|
|
80
|
+
if left[0] == right[0]
|
|
81
|
+
left[1..] <=> right[1..]
|
|
82
|
+
elsif left[0].nil?
|
|
83
|
+
1
|
|
84
|
+
elsif right[0].nil?
|
|
85
|
+
-1
|
|
86
|
+
else
|
|
87
|
+
left[0] <=> right[0]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
84
91
|
def columns &block
|
|
85
92
|
assert_only_one_filetype_config_set
|
|
86
93
|
@columns = ColumnsConfig.new file_config: self, block: block
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
|
|
5
|
+
# This is the stderr display/logging layer: it writes progress and log output straight to $stderr
|
|
6
|
+
# (see the $stderr.print/.flush calls). That output isn't warnings, so $stderr.puts is intentional
|
|
7
|
+
# throughout rather than warn.
|
|
8
|
+
# rubocop:disable Style/StderrPuts
|
|
5
9
|
class FileSystem
|
|
6
10
|
attr_accessor :logfile, :logfile_name, :log_only
|
|
7
11
|
|
|
@@ -14,18 +18,14 @@ class FileSystem
|
|
|
14
18
|
end
|
|
15
19
|
|
|
16
20
|
# Effectively the same as File.read except it forces the encoding to UTF-8
|
|
17
|
-
def load filename
|
|
18
|
-
if filename.end_with?('.json') && !supress_deprecation
|
|
19
|
-
deprecated(message: 'call load_json instead', date: '2024-11-13')
|
|
20
|
-
end
|
|
21
|
-
|
|
21
|
+
def load filename
|
|
22
22
|
File.read filename, encoding: 'UTF-8'
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def load_json filename, fail_on_error: true
|
|
26
26
|
return nil if fail_on_error == false && File.exist?(filename) == false
|
|
27
27
|
|
|
28
|
-
JSON.parse load(filename
|
|
28
|
+
JSON.parse load(filename)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def save_json json:, filename:
|
|
@@ -70,7 +70,7 @@ class FileSystem
|
|
|
70
70
|
# set up. Quick escape here so that we don't dump the error twice.
|
|
71
71
|
return if logfile == $stdout
|
|
72
72
|
|
|
73
|
-
$stderr.puts message
|
|
73
|
+
$stderr.puts message
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
def log_start message
|
|
@@ -98,21 +98,26 @@ class FileSystem
|
|
|
98
98
|
def end_progress
|
|
99
99
|
return if log_only
|
|
100
100
|
|
|
101
|
-
$stderr.puts ''
|
|
101
|
+
$stderr.puts ''
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
# In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
|
|
105
105
|
# cases where this simple compression will drop the filesize by half.
|
|
106
106
|
def compress node
|
|
107
107
|
if node.is_a? Hash
|
|
108
|
-
node.reject! { |_key, value|
|
|
108
|
+
node.reject! { |_key, value| prunable? value }
|
|
109
109
|
node.each_value { |value| compress value }
|
|
110
110
|
elsif node.is_a? Array
|
|
111
|
-
node.each { |
|
|
111
|
+
node.each { |element| compress element }
|
|
112
112
|
end
|
|
113
113
|
node
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
+
# A value worth dropping from the compressed output: nothing there, or an empty array.
|
|
117
|
+
def prunable? value
|
|
118
|
+
value.nil? || (value.is_a?(Array) && value.empty?)
|
|
119
|
+
end
|
|
120
|
+
|
|
116
121
|
def foreach root, &block
|
|
117
122
|
Dir.foreach root, &block
|
|
118
123
|
end
|
|
@@ -139,3 +144,4 @@ class FileSystem
|
|
|
139
144
|
log text, also_write_to_stderr: true
|
|
140
145
|
end
|
|
141
146
|
end
|
|
147
|
+
# rubocop:enable Style/StderrPuts
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Walks a stream of BlockedStalledChange entries and totals up the value-add (active) time between
|
|
4
|
+
# issue_start and end_time. It's handed the already-computed stream and the resolved start/end so it
|
|
5
|
+
# never reaches back through the issue's collaborators.
|
|
6
|
+
#
|
|
7
|
+
# Returns [value_add_seconds, total_seconds].
|
|
8
|
+
class FlowEfficiencyCalculator
|
|
9
|
+
def initialize blocked_stalled_changes:, issue_start:, end_time:
|
|
10
|
+
@blocked_stalled_changes = blocked_stalled_changes
|
|
11
|
+
@issue_start = issue_start
|
|
12
|
+
@end_time = end_time
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def calculate
|
|
16
|
+
@active_start = nil
|
|
17
|
+
@value_add_time = 0.0
|
|
18
|
+
|
|
19
|
+
@blocked_stalled_changes.each_with_index do |change, index|
|
|
20
|
+
break if change.time > @end_time
|
|
21
|
+
|
|
22
|
+
process change, index
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
close_final_active_period
|
|
26
|
+
|
|
27
|
+
[@value_add_time, @end_time - @issue_start]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def process change, index
|
|
31
|
+
if index.zero?
|
|
32
|
+
@active_start = change.time if change.active?
|
|
33
|
+
return
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
process_transition change
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def process_transition change
|
|
40
|
+
# Already active and we just got another active.
|
|
41
|
+
return if @active_start && change.active?
|
|
42
|
+
|
|
43
|
+
if change.active?
|
|
44
|
+
@active_start = change.time
|
|
45
|
+
elsif @active_start && change.time >= @issue_start
|
|
46
|
+
# Not active now but we have been. Record the active time.
|
|
47
|
+
record_active_period ending_at: change.time
|
|
48
|
+
@active_start = nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def record_active_period ending_at:
|
|
53
|
+
@value_add_time += ending_at - [@issue_start, @active_start].max
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def close_final_active_period
|
|
57
|
+
return unless @active_start
|
|
58
|
+
|
|
59
|
+
change_delta = @end_time - [@issue_start, @active_start].max
|
|
60
|
+
@value_add_time += change_delta if change_delta.positive?
|
|
61
|
+
end
|
|
62
|
+
end
|