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
|
@@ -55,22 +55,62 @@ class ProjectConfig
|
|
|
55
55
|
|
|
56
56
|
anonymize_data if @anonymizer_needed
|
|
57
57
|
|
|
58
|
-
@file_configs.each
|
|
59
|
-
|
|
58
|
+
@file_configs.each(&:run)
|
|
59
|
+
|
|
60
|
+
# By this point the charts have walked the issue histories, so every deleted status has already
|
|
61
|
+
# been fabricated lazily. Just report them. (The mcp path has no chart phase, so it resolves them
|
|
62
|
+
# explicitly before reporting.)
|
|
63
|
+
report_fabricated_statuses
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Walk every issue's status history so that any status that no longer exists in Jira gets fabricated
|
|
67
|
+
# now. Fabrication is otherwise lazy (it happens the first time a status is touched, which for some
|
|
68
|
+
# code paths is mid-render), so this gives us a single deterministic point where we know they all
|
|
69
|
+
# exist before we report them.
|
|
70
|
+
def resolve_all_status_changes
|
|
71
|
+
issues.each do |issue|
|
|
72
|
+
issue.status_changes.each do |change|
|
|
73
|
+
issue.find_or_create_status id: change.value_id, name: change.value
|
|
74
|
+
end
|
|
60
75
|
end
|
|
61
76
|
end
|
|
62
77
|
|
|
78
|
+
# Print one consolidated warning covering every status we had to fabricate (a status that an issue
|
|
79
|
+
# passed through but that no longer exists in Jira). It fires often -- any workflow rework leaves
|
|
80
|
+
# these behind -- so we say the explanation once and hand over a single ready-to-paste block of
|
|
81
|
+
# mappings rather than one repetitive paragraph per status.
|
|
82
|
+
def report_fabricated_statuses
|
|
83
|
+
statuses = possible_statuses.fabricated_statuses
|
|
84
|
+
return if statuses.empty? || @fabricated_statuses_reported
|
|
85
|
+
|
|
86
|
+
@fabricated_statuses_reported = true
|
|
87
|
+
|
|
88
|
+
width = statuses.map { |status| "'#{status.name}:#{status.id}'".length }.max
|
|
89
|
+
mappings = statuses.map do |status|
|
|
90
|
+
" #{"'#{status.name}:#{status.id}'".ljust(width)} => '#{status.category.name}',"
|
|
91
|
+
end.join("\n")
|
|
92
|
+
|
|
93
|
+
example_key = possible_statuses.example_issue_key
|
|
94
|
+
info_line =
|
|
95
|
+
if example_key
|
|
96
|
+
"\nRun `jirametrics info #{example_key}` on an affected issue to see how it moved through its workflow."
|
|
97
|
+
else
|
|
98
|
+
''
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
file_system.warning(
|
|
102
|
+
'Some statuses in your issue history no longer exist in Jira (they were probably deleted). ' \
|
|
103
|
+
"We've guessed each one's category from its name. If a guess is right the mapping just confirms " \
|
|
104
|
+
'it and silences this warning; if it is wrong, change the category. Add these to your ' \
|
|
105
|
+
"config's status category mappings; with standard_project that's:\n" \
|
|
106
|
+
" status_category_mappings: {\n#{mappings}\n }\n#{info_line}"
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
63
110
|
def load_settings
|
|
64
111
|
# This is the weird exception that we don't ever want mocked out so we skip FileSystem entirely.
|
|
65
112
|
settings = JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
|
|
66
113
|
|
|
67
|
-
if settings['blocked_color']
|
|
68
|
-
file_system.deprecated message: 'blocked color should be set via css now', date: '2024-05-03'
|
|
69
|
-
end
|
|
70
|
-
if settings['stalled_color']
|
|
71
|
-
file_system.deprecated message: 'stalled color should be set via css now', date: '2024-05-03'
|
|
72
|
-
end
|
|
73
|
-
|
|
74
114
|
settings['blocked_statuses'] = StatusCollection.new
|
|
75
115
|
settings['stalled_statuses'] = StatusCollection.new
|
|
76
116
|
|
|
@@ -208,25 +248,45 @@ class ProjectConfig
|
|
|
208
248
|
category, category_id = possible_statuses.parse_name_id category
|
|
209
249
|
|
|
210
250
|
if status_id.nil?
|
|
211
|
-
|
|
212
|
-
if
|
|
213
|
-
|
|
214
|
-
"Cannot guess status id for #{status.inspect} as no statuses found anywhere in the issues " \
|
|
215
|
-
"histories with that name. Since we can't find it, you probably don't need this mapping anymore so we're " \
|
|
216
|
-
"going to ignore it. If you really want it, then you'll need to specify a status id."
|
|
217
|
-
return
|
|
218
|
-
end
|
|
251
|
+
status_id = guess_status_id_for status, category
|
|
252
|
+
return if status_id.nil? # no id could be guessed; the mapping is ignored (already warned)
|
|
253
|
+
end
|
|
219
254
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
255
|
+
found_category = resolve_category category, category_id
|
|
256
|
+
add_possible_status(
|
|
257
|
+
Status.new(
|
|
258
|
+
name: status, id: status_id,
|
|
259
|
+
category_name: category, category_id: found_category.id, category_key: found_category.key
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# When a status is named but not given an id, guess it from the ids seen under that name across all
|
|
265
|
+
# issue histories. Returns nil (after warning) when none is found, or raises when it's ambiguous.
|
|
266
|
+
def guess_status_id_for status, category
|
|
267
|
+
guesses = find_ids_by_status_name_across_all_issues status
|
|
268
|
+
if guesses.empty?
|
|
269
|
+
file_system.warning "For status_category_mapping status: #{status.inspect}, category: #{category.inspect}\n" \
|
|
270
|
+
"Cannot guess status id for #{status.inspect} as no statuses found anywhere in the issues " \
|
|
271
|
+
"histories with that name. Since we can't find it, you probably don't need this mapping anymore so we're " \
|
|
272
|
+
"going to ignore it. If you really want it, then you'll need to specify a status id."
|
|
273
|
+
return nil
|
|
274
|
+
end
|
|
224
275
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
"If
|
|
276
|
+
if guesses.size > 1
|
|
277
|
+
raise "Cannot guess status id as there are multiple ids for the name #{status.inspect}. Perhaps it's one " \
|
|
278
|
+
"of #{guesses.to_a.sort.inspect}. If you need this mapping then you must specify the status_id."
|
|
228
279
|
end
|
|
229
280
|
|
|
281
|
+
status_id = guesses.first
|
|
282
|
+
file_system.log "status_category_mapping for #{status.inspect} has been mapped to id #{status_id}. " \
|
|
283
|
+
"If that's incorrect then specify the status_id."
|
|
284
|
+
status_id
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Find the single status category with the given name, raising if there are none, several, or a
|
|
288
|
+
# supplied id that disagrees with the one we found.
|
|
289
|
+
def resolve_category category, category_id
|
|
230
290
|
possible_categories = possible_statuses.find_all_categories_by_name category
|
|
231
291
|
if possible_categories.empty?
|
|
232
292
|
all = possible_statuses.find_all_categories.join(', ')
|
|
@@ -239,17 +299,11 @@ class ProjectConfig
|
|
|
239
299
|
end
|
|
240
300
|
|
|
241
301
|
found_category = possible_categories.first
|
|
242
|
-
|
|
243
302
|
if category_id && category_id != found_category.id
|
|
244
303
|
raise "ID is incorrect for status category #{category.inspect}. Did you mean #{found_category.id}?"
|
|
245
304
|
end
|
|
246
305
|
|
|
247
|
-
|
|
248
|
-
Status.new(
|
|
249
|
-
name: status, id: status_id,
|
|
250
|
-
category_name: category, category_id: found_category.id, category_key: found_category.key
|
|
251
|
-
)
|
|
252
|
-
)
|
|
306
|
+
found_category
|
|
253
307
|
end
|
|
254
308
|
|
|
255
309
|
def add_possible_status status
|
|
@@ -283,9 +337,10 @@ class ProjectConfig
|
|
|
283
337
|
|
|
284
338
|
def load_all_boards
|
|
285
339
|
Dir.foreach(@target_path) do |file|
|
|
286
|
-
|
|
340
|
+
match = file.match(/^#{get_file_prefix}_board_(?<board_id>\d+)_configuration\.json$/)
|
|
341
|
+
next unless match
|
|
287
342
|
|
|
288
|
-
board_id =
|
|
343
|
+
board_id = match[:board_id].to_i
|
|
289
344
|
load_board board_id: board_id, filename: "#{@target_path}#{file}"
|
|
290
345
|
end
|
|
291
346
|
end
|
|
@@ -326,16 +381,24 @@ class ProjectConfig
|
|
|
326
381
|
.each { |status| possible_statuses.historical_status_mappings[status.to_s] = status.category }
|
|
327
382
|
|
|
328
383
|
possible_statuses
|
|
384
|
+
# This is an optional enrichment file. A malformed one surfaces as anything from a JSON::ParserError to
|
|
385
|
+
# a TypeError/NoMethodError out of Status.from_raw, so we deliberately catch broadly, warn, and carry on
|
|
386
|
+
# without it rather than fail the whole export. The exception itself goes to the log file only (via
|
|
387
|
+
# `more`) so we don't lose it, while the console stays uncluttered.
|
|
329
388
|
rescue => e # rubocop:disable Style/RescueStandardError
|
|
330
|
-
file_system.warning
|
|
331
|
-
'malformed file then it should be
|
|
389
|
+
file_system.warning(
|
|
390
|
+
'Unable to load status history. If this is because of a malformed file then it should be ' \
|
|
391
|
+
'fixed on the next download.',
|
|
392
|
+
more: [e.message, *e.backtrace].join("\n")
|
|
393
|
+
)
|
|
332
394
|
end
|
|
333
395
|
|
|
334
396
|
def load_sprints
|
|
335
397
|
file_system.foreach(@target_path) do |file|
|
|
336
|
-
|
|
398
|
+
match = file.match(/^#{get_file_prefix}_board_(?<board_id>\d+)_sprints_\d+.json$/)
|
|
399
|
+
next unless match
|
|
337
400
|
|
|
338
|
-
board_id =
|
|
401
|
+
board_id = match[:board_id].to_i
|
|
339
402
|
file_path = File.join(@target_path, file)
|
|
340
403
|
board = @all_boards[board_id]
|
|
341
404
|
unless board
|
|
@@ -373,24 +436,25 @@ class ProjectConfig
|
|
|
373
436
|
start = to_time(json['date_start'] || json['time_start']) # date_start is the current format. Time is the old.
|
|
374
437
|
stop = to_time(json['date_end'] || json['time_end'], end_of_day: true)
|
|
375
438
|
|
|
376
|
-
|
|
377
|
-
if download_config
|
|
378
|
-
download_config.run
|
|
379
|
-
no_earlier = download_config.no_earlier_than
|
|
380
|
-
if no_earlier
|
|
381
|
-
no_earlier = to_time(no_earlier.to_s)
|
|
382
|
-
start = no_earlier if start < no_earlier
|
|
383
|
-
end
|
|
384
|
-
end
|
|
385
|
-
|
|
386
|
-
@time_range = start..stop
|
|
387
|
-
|
|
439
|
+
@time_range = clamp_to_no_earlier_than(start)..stop
|
|
388
440
|
@jira_url = json['jira_url']
|
|
389
441
|
rescue Errno::ENOENT
|
|
390
442
|
file_system.log "Can't load #{filename}. Have you done a download?", also_write_to_stderr: true
|
|
391
443
|
raise
|
|
392
444
|
end
|
|
393
445
|
|
|
446
|
+
# If the download was configured with a no_earlier_than, the data can't start before it.
|
|
447
|
+
def clamp_to_no_earlier_than start
|
|
448
|
+
return start unless download_config
|
|
449
|
+
|
|
450
|
+
download_config.run
|
|
451
|
+
no_earlier = download_config.no_earlier_than
|
|
452
|
+
return start unless no_earlier
|
|
453
|
+
|
|
454
|
+
no_earlier = to_time(no_earlier.to_s)
|
|
455
|
+
[start, no_earlier].max
|
|
456
|
+
end
|
|
457
|
+
|
|
394
458
|
def load_users
|
|
395
459
|
@users = []
|
|
396
460
|
filename = File.join @target_path, "#{get_file_prefix}_users.json"
|
|
@@ -474,39 +538,46 @@ class ProjectConfig
|
|
|
474
538
|
|
|
475
539
|
load_data if all_boards.empty?
|
|
476
540
|
|
|
477
|
-
timezone_offset = exporter.timezone_offset
|
|
478
|
-
|
|
479
541
|
issues_path = File.join @target_path, "#{get_file_prefix}_issues"
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
|
|
483
|
-
file_system.diagnostic "Loaded #{issues.size} issues from disk"
|
|
484
|
-
else
|
|
542
|
+
# File.directory? is already false for a path that doesn't exist, so no need to also check File.exist?.
|
|
543
|
+
unless File.directory?(issues_path)
|
|
485
544
|
file_system.log "Can't find directory #{issues_path}. Has a download been done?", also_write_to_stderr: true
|
|
486
545
|
return IssueCollection.new
|
|
487
546
|
end
|
|
488
547
|
|
|
489
|
-
|
|
490
|
-
file_system.diagnostic 'Starting attach phase'
|
|
491
|
-
issues_by_key = issues.to_h { |i| [i.key, i] }
|
|
492
|
-
issues.each do |i|
|
|
493
|
-
attach_subtasks issue: i, issues_by_key: issues_by_key
|
|
494
|
-
attach_parent issue: i, issues_by_key: issues_by_key
|
|
495
|
-
attach_linked_issues issue: i, issues_by_key: issues_by_key
|
|
496
|
-
end
|
|
497
|
-
file_system.diagnostic 'Attach phase complete'
|
|
498
|
-
|
|
499
|
-
# We'll have some issues that are in the list that weren't part of the initial query. Once we've
|
|
500
|
-
# attached them in the appropriate places, remove any that aren't part of that initial set.
|
|
501
|
-
issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
|
|
502
|
-
file_system.diagnostic "Retained #{issues.size} primary issues"
|
|
503
|
-
@issues = issues
|
|
548
|
+
@issues = build_issues_from_directory(issues_path)
|
|
504
549
|
attach_github_prs
|
|
505
550
|
end
|
|
506
551
|
|
|
507
552
|
@issues
|
|
508
553
|
end
|
|
509
554
|
|
|
555
|
+
def build_issues_from_directory issues_path
|
|
556
|
+
file_system.diagnostic "Loading issues from #{issues_path}"
|
|
557
|
+
issues = load_issues_from_issues_directory path: issues_path, timezone_offset: exporter.timezone_offset
|
|
558
|
+
file_system.diagnostic "Loaded #{issues.size} issues from disk"
|
|
559
|
+
|
|
560
|
+
attach_related_issues issues
|
|
561
|
+
|
|
562
|
+
# We'll have some issues that are in the list that weren't part of the initial query. Once we've
|
|
563
|
+
# attached them in the appropriate places, remove any that aren't part of that initial set.
|
|
564
|
+
issues.select!(&:in_initial_query?)
|
|
565
|
+
file_system.diagnostic "Retained #{issues.size} primary issues"
|
|
566
|
+
issues
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# Wire up subtasks, parents and linked issues now that we have the whole set loaded.
|
|
570
|
+
def attach_related_issues issues
|
|
571
|
+
file_system.diagnostic 'Starting attach phase'
|
|
572
|
+
issues_by_key = issues.to_h { |issue| [issue.key, issue] }
|
|
573
|
+
issues.each do |issue|
|
|
574
|
+
attach_subtasks issue: issue, issues_by_key: issues_by_key
|
|
575
|
+
attach_parent issue: issue, issues_by_key: issues_by_key
|
|
576
|
+
attach_linked_issues issue: issue, issues_by_key: issues_by_key
|
|
577
|
+
end
|
|
578
|
+
file_system.diagnostic 'Attach phase complete'
|
|
579
|
+
end
|
|
580
|
+
|
|
510
581
|
def attach_subtasks issue:, issues_by_key:
|
|
511
582
|
issue.raw['fields']['subtasks']&.each do |subtask_element|
|
|
512
583
|
subtask = issues_by_key[subtask_element['key']]
|
|
@@ -567,6 +638,10 @@ class ProjectConfig
|
|
|
567
638
|
# It's ok if there are multiple files for the same issue. We load the newest one and map all the other
|
|
568
639
|
# board ids appropriately.
|
|
569
640
|
def group_filenames_and_board_ids path:
|
|
641
|
+
group_files_by_issue_key(path).values.to_h { |list| resolve_key_files(list, path) }
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def group_files_by_issue_key path
|
|
570
645
|
hash = {}
|
|
571
646
|
file_system.foreach(path) do |filename|
|
|
572
647
|
# Matches either FAKE-123.json or FAKE-123-456.json
|
|
@@ -574,32 +649,21 @@ class ProjectConfig
|
|
|
574
649
|
(hash[key] ||= []) << [filename, board_id&.to_i || :unknown]
|
|
575
650
|
end
|
|
576
651
|
end
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
if max_time.nil? || mtime > max_time
|
|
592
|
-
max_time = mtime
|
|
593
|
-
max_board_id = board_id
|
|
594
|
-
max_filename = filename
|
|
595
|
-
end
|
|
596
|
-
all_board_ids << board_id unless board_id == :unknown
|
|
597
|
-
end
|
|
598
|
-
|
|
599
|
-
result[max_filename] = all_board_ids
|
|
600
|
-
end
|
|
652
|
+
hash
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# Given all the files that share an issue key, returns the [filename, board_ids] pair for the result.
|
|
656
|
+
# A lone file keeps its own board id; when the same issue was exported for several boards we keep the
|
|
657
|
+
# newest file and associate every known board id with it.
|
|
658
|
+
def resolve_key_files list, path
|
|
659
|
+
if list.size == 1
|
|
660
|
+
filename, board_id = *list.first
|
|
661
|
+
[filename, board_id == :unknown ? board_id : [board_id]]
|
|
662
|
+
else
|
|
663
|
+
newest_filename, = list.max_by { |filename, _| File.mtime(File.join(path, filename)) }
|
|
664
|
+
board_ids = list.filter_map { |_, board_id| board_id unless board_id == :unknown }
|
|
665
|
+
[newest_filename, board_ids]
|
|
601
666
|
end
|
|
602
|
-
result
|
|
603
667
|
end
|
|
604
668
|
|
|
605
669
|
def anonymize
|
|
@@ -615,55 +679,64 @@ class ProjectConfig
|
|
|
615
679
|
end
|
|
616
680
|
|
|
617
681
|
def discard_changes_before status_becomes: nil, &block
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
status_becomes.each { |status_name| validate_discard_status status_name }
|
|
682
|
+
block = discard_block_for(status_becomes) if status_becomes
|
|
683
|
+
apply_discard_block block
|
|
684
|
+
end
|
|
623
685
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
end.flatten
|
|
686
|
+
# Build the block that, for an issue, returns the time it most recently entered one of the
|
|
687
|
+
# status_becomes statuses (or nil).
|
|
688
|
+
def discard_block_for status_becomes
|
|
689
|
+
status_becomes = [status_becomes] unless status_becomes.is_a? Array
|
|
690
|
+
status_becomes.each { |status_name| validate_discard_status status_name }
|
|
691
|
+
->(issue) { last_matching_status_time issue, status_becomes }
|
|
692
|
+
end
|
|
632
693
|
|
|
633
|
-
|
|
694
|
+
def last_matching_status_time issue, status_becomes
|
|
695
|
+
trigger_status_ids = trigger_status_ids_for issue, status_becomes
|
|
696
|
+
return if trigger_status_ids.empty?
|
|
634
697
|
|
|
635
|
-
|
|
698
|
+
time = nil
|
|
699
|
+
issue.status_changes.each do |change|
|
|
700
|
+
time = change.time if trigger_status_ids.include?(change.value_id) # && change.artificial? == false
|
|
701
|
+
end
|
|
702
|
+
time
|
|
703
|
+
end
|
|
636
704
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
705
|
+
def trigger_status_ids_for issue, status_becomes
|
|
706
|
+
status_becomes.collect do |status_name|
|
|
707
|
+
if status_name == :backlog
|
|
708
|
+
issue.board.backlog_statuses
|
|
709
|
+
else
|
|
710
|
+
possible_statuses.find_all_by_name status_name
|
|
642
711
|
end
|
|
643
|
-
end
|
|
712
|
+
end.flatten.collect(&:id)
|
|
713
|
+
end
|
|
644
714
|
|
|
715
|
+
def apply_discard_block block
|
|
716
|
+
cycletimes_touched = Set.new
|
|
645
717
|
file_system.diagnostic "discard_changes_before: processing #{issues.size} issues"
|
|
646
|
-
issues.each
|
|
647
|
-
|
|
648
|
-
|
|
718
|
+
issues.each { |issue| discard_changes_for_issue issue, block, cycletimes_touched }
|
|
719
|
+
cycletimes_touched.each(&:flush_cache)
|
|
720
|
+
end
|
|
649
721
|
|
|
650
|
-
|
|
651
|
-
|
|
722
|
+
def discard_changes_for_issue issue, block, cycletimes_touched
|
|
723
|
+
cutoff_time = block.call(issue)
|
|
724
|
+
return if cutoff_time.nil?
|
|
652
725
|
|
|
653
|
-
|
|
654
|
-
|
|
726
|
+
original_start_time = issue.started_stopped_times.first
|
|
727
|
+
return if original_start_time.nil?
|
|
655
728
|
|
|
656
|
-
|
|
657
|
-
|
|
729
|
+
issue.discard_changes_before cutoff_time
|
|
730
|
+
cycletimes_touched << issue.board.cycletime
|
|
658
731
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
original_start_time: original_start_time,
|
|
662
|
-
issue: issue
|
|
663
|
-
}
|
|
664
|
-
end
|
|
732
|
+
return unless cutoff_time # a user-supplied block may return false rather than nil
|
|
733
|
+
return if original_start_time > cutoff_time # ie the cutoff would have made no difference.
|
|
665
734
|
|
|
666
|
-
|
|
735
|
+
(@discarded_changes_data ||= []) << {
|
|
736
|
+
cutoff_time: cutoff_time,
|
|
737
|
+
original_start_time: original_start_time,
|
|
738
|
+
issue: issue
|
|
739
|
+
}
|
|
667
740
|
end
|
|
668
741
|
|
|
669
742
|
def stringify_keys value
|
|
@@ -6,7 +6,6 @@ class PullRequestCycleTimeHistogram < TimeBasedHistogram
|
|
|
6
6
|
def initialize block
|
|
7
7
|
super()
|
|
8
8
|
|
|
9
|
-
@cycletime_unit = :days
|
|
10
9
|
@x_axis_title = 'Cycle time in days'
|
|
11
10
|
|
|
12
11
|
header_text 'PR Histogram'
|
|
@@ -25,15 +24,6 @@ class PullRequestCycleTimeHistogram < TimeBasedHistogram
|
|
|
25
24
|
end
|
|
26
25
|
end
|
|
27
26
|
|
|
28
|
-
def cycletime_unit unit
|
|
29
|
-
unless %i[minutes hours days].include?(unit)
|
|
30
|
-
raise ArgumentError, "cycletime_unit must be :minutes, :hours, or :days, got #{unit.inspect}"
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
@cycletime_unit = unit
|
|
34
|
-
@x_axis_title = "Cycle time in #{unit}"
|
|
35
|
-
end
|
|
36
|
-
|
|
37
27
|
def all_items
|
|
38
28
|
result = []
|
|
39
29
|
issues.each do |issue|
|
|
@@ -49,16 +39,7 @@ class PullRequestCycleTimeHistogram < TimeBasedHistogram
|
|
|
49
39
|
end
|
|
50
40
|
|
|
51
41
|
def value_for_item item
|
|
52
|
-
|
|
53
|
-
((item.closed_at - item.opened_at) / divisor).ceil
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def label_cycletime value
|
|
57
|
-
case @cycletime_unit
|
|
58
|
-
when :minutes then label_minutes(value)
|
|
59
|
-
when :hours then label_hours(value)
|
|
60
|
-
when :days then label_days(value)
|
|
61
|
-
end
|
|
42
|
+
duration_in_unit item.opened_at, item.closed_at
|
|
62
43
|
end
|
|
63
44
|
|
|
64
45
|
def title_for_item count:, value:
|
|
@@ -6,7 +6,6 @@ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
|
|
|
6
6
|
def initialize block
|
|
7
7
|
super()
|
|
8
8
|
|
|
9
|
-
@cycletime_unit = :days
|
|
10
9
|
@y_axis_title = 'Cycle time in days'
|
|
11
10
|
|
|
12
11
|
header_text 'Pull Request (PR) Scatterplot'
|
|
@@ -24,15 +23,6 @@ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
|
|
|
24
23
|
end
|
|
25
24
|
end
|
|
26
25
|
|
|
27
|
-
def cycletime_unit unit
|
|
28
|
-
unless %i[minutes hours days].include?(unit)
|
|
29
|
-
raise ArgumentError, "cycletime_unit must be :minutes, :hours, or :days, got #{unit.inspect}"
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
@cycletime_unit = unit
|
|
33
|
-
@y_axis_title = "Cycle time in #{unit}"
|
|
34
|
-
end
|
|
35
|
-
|
|
36
26
|
def all_items
|
|
37
27
|
result = []
|
|
38
28
|
issues.each do |issue|
|
|
@@ -48,23 +38,7 @@ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
|
|
|
48
38
|
end
|
|
49
39
|
|
|
50
40
|
def y_value pull_request
|
|
51
|
-
|
|
52
|
-
tz = timezone_offset || '+00:00'
|
|
53
|
-
opened = pull_request.opened_at.getlocal(tz).to_date
|
|
54
|
-
closed = pull_request.closed_at.getlocal(tz).to_date
|
|
55
|
-
(closed - opened).to_i + 1
|
|
56
|
-
else
|
|
57
|
-
divisor = { minutes: 60, hours: 3600 }[@cycletime_unit]
|
|
58
|
-
((pull_request.closed_at - pull_request.opened_at) / divisor).round
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def label_cycletime value
|
|
63
|
-
case @cycletime_unit
|
|
64
|
-
when :minutes then label_minutes(value)
|
|
65
|
-
when :hours then label_hours(value)
|
|
66
|
-
when :days then label_days(value)
|
|
67
|
-
end
|
|
41
|
+
duration_in_unit pull_request.opened_at, pull_request.closed_at
|
|
68
42
|
end
|
|
69
43
|
|
|
70
44
|
def title_value pull_request, rules: nil
|
|
@@ -76,13 +50,17 @@ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
|
|
|
76
50
|
def lines_changed_text pull_request
|
|
77
51
|
return '' unless pull_request.changed_files
|
|
78
52
|
|
|
53
|
+
summary = additions_deletions_summary pull_request
|
|
54
|
+
" | Lines changed: [#{summary}], Files changed: #{to_human_readable pull_request.changed_files}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The "+10 -4" bit: each side only appears when non-zero, joined by a space only when both do.
|
|
58
|
+
def additions_deletions_summary pull_request
|
|
79
59
|
additions = pull_request.additions || 0
|
|
80
60
|
deletions = pull_request.deletions || 0
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
text << "], Files changed: #{to_human_readable pull_request.changed_files}"
|
|
86
|
-
text
|
|
61
|
+
parts = []
|
|
62
|
+
parts << "+#{to_human_readable additions}" unless additions.zero?
|
|
63
|
+
parts << "-#{to_human_readable deletions}" unless deletions.zero?
|
|
64
|
+
parts.join(' ')
|
|
87
65
|
end
|
|
88
66
|
end
|
data/lib/jirametrics/rules.rb
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SelfOrIssueDispatcher
|
|
4
|
-
# rubocop:disable Style/ArgumentsForwarding
|
|
5
4
|
def method_missing method_name, *args, &block
|
|
6
5
|
raise "#{method_name} isn't a method on Issue or #{self.class}" unless ::Issue.method_defined? method_name.to_sym
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
lambda do |issue|
|
|
9
8
|
issue.__send__ method_name, *args, &block
|
|
10
9
|
end
|
|
11
10
|
end
|
|
12
|
-
# rubocop:enable Style/ArgumentsForwarding
|
|
13
11
|
|
|
14
12
|
def respond_to_missing?(method_name, include_all = false)
|
|
15
13
|
::Issue.method_defined?(method_name.to_sym) || super
|