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
|
@@ -55,22 +55,13 @@ class ProjectConfig
|
|
|
55
55
|
|
|
56
56
|
anonymize_data if @anonymizer_needed
|
|
57
57
|
|
|
58
|
-
@file_configs.each
|
|
59
|
-
file_config.run
|
|
60
|
-
end
|
|
58
|
+
@file_configs.each(&:run)
|
|
61
59
|
end
|
|
62
60
|
|
|
63
61
|
def load_settings
|
|
64
62
|
# This is the weird exception that we don't ever want mocked out so we skip FileSystem entirely.
|
|
65
63
|
settings = JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
|
|
66
64
|
|
|
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
65
|
settings['blocked_statuses'] = StatusCollection.new
|
|
75
66
|
settings['stalled_statuses'] = StatusCollection.new
|
|
76
67
|
|
|
@@ -208,25 +199,45 @@ class ProjectConfig
|
|
|
208
199
|
category, category_id = possible_statuses.parse_name_id category
|
|
209
200
|
|
|
210
201
|
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
|
|
202
|
+
status_id = guess_status_id_for status, category
|
|
203
|
+
return if status_id.nil? # no id could be guessed; the mapping is ignored (already warned)
|
|
204
|
+
end
|
|
219
205
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
206
|
+
found_category = resolve_category category, category_id
|
|
207
|
+
add_possible_status(
|
|
208
|
+
Status.new(
|
|
209
|
+
name: status, id: status_id,
|
|
210
|
+
category_name: category, category_id: found_category.id, category_key: found_category.key
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
end
|
|
224
214
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
215
|
+
# When a status is named but not given an id, guess it from the ids seen under that name across all
|
|
216
|
+
# issue histories. Returns nil (after warning) when none is found, or raises when it's ambiguous.
|
|
217
|
+
def guess_status_id_for status, category
|
|
218
|
+
guesses = find_ids_by_status_name_across_all_issues status
|
|
219
|
+
if guesses.empty?
|
|
220
|
+
file_system.warning "For status_category_mapping status: #{status.inspect}, category: #{category.inspect}\n" \
|
|
221
|
+
"Cannot guess status id for #{status.inspect} as no statuses found anywhere in the issues " \
|
|
222
|
+
"histories with that name. Since we can't find it, you probably don't need this mapping anymore so we're " \
|
|
223
|
+
"going to ignore it. If you really want it, then you'll need to specify a status id."
|
|
224
|
+
return nil
|
|
228
225
|
end
|
|
229
226
|
|
|
227
|
+
if guesses.size > 1
|
|
228
|
+
raise "Cannot guess status id as there are multiple ids for the name #{status.inspect}. Perhaps it's one " \
|
|
229
|
+
"of #{guesses.to_a.sort.inspect}. If you need this mapping then you must specify the status_id."
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
status_id = guesses.first
|
|
233
|
+
file_system.log "status_category_mapping for #{status.inspect} has been mapped to id #{status_id}. " \
|
|
234
|
+
"If that's incorrect then specify the status_id."
|
|
235
|
+
status_id
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Find the single status category with the given name, raising if there are none, several, or a
|
|
239
|
+
# supplied id that disagrees with the one we found.
|
|
240
|
+
def resolve_category category, category_id
|
|
230
241
|
possible_categories = possible_statuses.find_all_categories_by_name category
|
|
231
242
|
if possible_categories.empty?
|
|
232
243
|
all = possible_statuses.find_all_categories.join(', ')
|
|
@@ -239,17 +250,11 @@ class ProjectConfig
|
|
|
239
250
|
end
|
|
240
251
|
|
|
241
252
|
found_category = possible_categories.first
|
|
242
|
-
|
|
243
253
|
if category_id && category_id != found_category.id
|
|
244
254
|
raise "ID is incorrect for status category #{category.inspect}. Did you mean #{found_category.id}?"
|
|
245
255
|
end
|
|
246
256
|
|
|
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
|
-
)
|
|
257
|
+
found_category
|
|
253
258
|
end
|
|
254
259
|
|
|
255
260
|
def add_possible_status status
|
|
@@ -283,9 +288,10 @@ class ProjectConfig
|
|
|
283
288
|
|
|
284
289
|
def load_all_boards
|
|
285
290
|
Dir.foreach(@target_path) do |file|
|
|
286
|
-
|
|
291
|
+
match = file.match(/^#{get_file_prefix}_board_(?<board_id>\d+)_configuration\.json$/)
|
|
292
|
+
next unless match
|
|
287
293
|
|
|
288
|
-
board_id =
|
|
294
|
+
board_id = match[:board_id].to_i
|
|
289
295
|
load_board board_id: board_id, filename: "#{@target_path}#{file}"
|
|
290
296
|
end
|
|
291
297
|
end
|
|
@@ -326,16 +332,24 @@ class ProjectConfig
|
|
|
326
332
|
.each { |status| possible_statuses.historical_status_mappings[status.to_s] = status.category }
|
|
327
333
|
|
|
328
334
|
possible_statuses
|
|
335
|
+
# This is an optional enrichment file. A malformed one surfaces as anything from a JSON::ParserError to
|
|
336
|
+
# a TypeError/NoMethodError out of Status.from_raw, so we deliberately catch broadly, warn, and carry on
|
|
337
|
+
# without it rather than fail the whole export. The exception itself goes to the log file only (via
|
|
338
|
+
# `more`) so we don't lose it, while the console stays uncluttered.
|
|
329
339
|
rescue => e # rubocop:disable Style/RescueStandardError
|
|
330
|
-
file_system.warning
|
|
331
|
-
'malformed file then it should be
|
|
340
|
+
file_system.warning(
|
|
341
|
+
'Unable to load status history. If this is because of a malformed file then it should be ' \
|
|
342
|
+
'fixed on the next download.',
|
|
343
|
+
more: [e.message, *e.backtrace].join("\n")
|
|
344
|
+
)
|
|
332
345
|
end
|
|
333
346
|
|
|
334
347
|
def load_sprints
|
|
335
348
|
file_system.foreach(@target_path) do |file|
|
|
336
|
-
|
|
349
|
+
match = file.match(/^#{get_file_prefix}_board_(?<board_id>\d+)_sprints_\d+.json$/)
|
|
350
|
+
next unless match
|
|
337
351
|
|
|
338
|
-
board_id =
|
|
352
|
+
board_id = match[:board_id].to_i
|
|
339
353
|
file_path = File.join(@target_path, file)
|
|
340
354
|
board = @all_boards[board_id]
|
|
341
355
|
unless board
|
|
@@ -373,24 +387,25 @@ class ProjectConfig
|
|
|
373
387
|
start = to_time(json['date_start'] || json['time_start']) # date_start is the current format. Time is the old.
|
|
374
388
|
stop = to_time(json['date_end'] || json['time_end'], end_of_day: true)
|
|
375
389
|
|
|
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
|
-
|
|
390
|
+
@time_range = clamp_to_no_earlier_than(start)..stop
|
|
388
391
|
@jira_url = json['jira_url']
|
|
389
392
|
rescue Errno::ENOENT
|
|
390
393
|
file_system.log "Can't load #{filename}. Have you done a download?", also_write_to_stderr: true
|
|
391
394
|
raise
|
|
392
395
|
end
|
|
393
396
|
|
|
397
|
+
# If the download was configured with a no_earlier_than, the data can't start before it.
|
|
398
|
+
def clamp_to_no_earlier_than start
|
|
399
|
+
return start unless download_config
|
|
400
|
+
|
|
401
|
+
download_config.run
|
|
402
|
+
no_earlier = download_config.no_earlier_than
|
|
403
|
+
return start unless no_earlier
|
|
404
|
+
|
|
405
|
+
no_earlier = to_time(no_earlier.to_s)
|
|
406
|
+
[start, no_earlier].max
|
|
407
|
+
end
|
|
408
|
+
|
|
394
409
|
def load_users
|
|
395
410
|
@users = []
|
|
396
411
|
filename = File.join @target_path, "#{get_file_prefix}_users.json"
|
|
@@ -474,39 +489,46 @@ class ProjectConfig
|
|
|
474
489
|
|
|
475
490
|
load_data if all_boards.empty?
|
|
476
491
|
|
|
477
|
-
timezone_offset = exporter.timezone_offset
|
|
478
|
-
|
|
479
492
|
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
|
|
493
|
+
# File.directory? is already false for a path that doesn't exist, so no need to also check File.exist?.
|
|
494
|
+
unless File.directory?(issues_path)
|
|
485
495
|
file_system.log "Can't find directory #{issues_path}. Has a download been done?", also_write_to_stderr: true
|
|
486
496
|
return IssueCollection.new
|
|
487
497
|
end
|
|
488
498
|
|
|
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
|
|
499
|
+
@issues = build_issues_from_directory(issues_path)
|
|
504
500
|
attach_github_prs
|
|
505
501
|
end
|
|
506
502
|
|
|
507
503
|
@issues
|
|
508
504
|
end
|
|
509
505
|
|
|
506
|
+
def build_issues_from_directory issues_path
|
|
507
|
+
file_system.diagnostic "Loading issues from #{issues_path}"
|
|
508
|
+
issues = load_issues_from_issues_directory path: issues_path, timezone_offset: exporter.timezone_offset
|
|
509
|
+
file_system.diagnostic "Loaded #{issues.size} issues from disk"
|
|
510
|
+
|
|
511
|
+
attach_related_issues issues
|
|
512
|
+
|
|
513
|
+
# We'll have some issues that are in the list that weren't part of the initial query. Once we've
|
|
514
|
+
# attached them in the appropriate places, remove any that aren't part of that initial set.
|
|
515
|
+
issues.select!(&:in_initial_query?)
|
|
516
|
+
file_system.diagnostic "Retained #{issues.size} primary issues"
|
|
517
|
+
issues
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Wire up subtasks, parents and linked issues now that we have the whole set loaded.
|
|
521
|
+
def attach_related_issues issues
|
|
522
|
+
file_system.diagnostic 'Starting attach phase'
|
|
523
|
+
issues_by_key = issues.to_h { |issue| [issue.key, issue] }
|
|
524
|
+
issues.each do |issue|
|
|
525
|
+
attach_subtasks issue: issue, issues_by_key: issues_by_key
|
|
526
|
+
attach_parent issue: issue, issues_by_key: issues_by_key
|
|
527
|
+
attach_linked_issues issue: issue, issues_by_key: issues_by_key
|
|
528
|
+
end
|
|
529
|
+
file_system.diagnostic 'Attach phase complete'
|
|
530
|
+
end
|
|
531
|
+
|
|
510
532
|
def attach_subtasks issue:, issues_by_key:
|
|
511
533
|
issue.raw['fields']['subtasks']&.each do |subtask_element|
|
|
512
534
|
subtask = issues_by_key[subtask_element['key']]
|
|
@@ -567,6 +589,10 @@ class ProjectConfig
|
|
|
567
589
|
# It's ok if there are multiple files for the same issue. We load the newest one and map all the other
|
|
568
590
|
# board ids appropriately.
|
|
569
591
|
def group_filenames_and_board_ids path:
|
|
592
|
+
group_files_by_issue_key(path).values.to_h { |list| resolve_key_files(list, path) }
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def group_files_by_issue_key path
|
|
570
596
|
hash = {}
|
|
571
597
|
file_system.foreach(path) do |filename|
|
|
572
598
|
# Matches either FAKE-123.json or FAKE-123-456.json
|
|
@@ -574,32 +600,21 @@ class ProjectConfig
|
|
|
574
600
|
(hash[key] ||= []) << [filename, board_id&.to_i || :unknown]
|
|
575
601
|
end
|
|
576
602
|
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
|
|
603
|
+
hash
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# Given all the files that share an issue key, returns the [filename, board_ids] pair for the result.
|
|
607
|
+
# A lone file keeps its own board id; when the same issue was exported for several boards we keep the
|
|
608
|
+
# newest file and associate every known board id with it.
|
|
609
|
+
def resolve_key_files list, path
|
|
610
|
+
if list.size == 1
|
|
611
|
+
filename, board_id = *list.first
|
|
612
|
+
[filename, board_id == :unknown ? board_id : [board_id]]
|
|
613
|
+
else
|
|
614
|
+
newest_filename, = list.max_by { |filename, _| File.mtime(File.join(path, filename)) }
|
|
615
|
+
board_ids = list.filter_map { |_, board_id| board_id unless board_id == :unknown }
|
|
616
|
+
[newest_filename, board_ids]
|
|
601
617
|
end
|
|
602
|
-
result
|
|
603
618
|
end
|
|
604
619
|
|
|
605
620
|
def anonymize
|
|
@@ -615,55 +630,64 @@ class ProjectConfig
|
|
|
615
630
|
end
|
|
616
631
|
|
|
617
632
|
def discard_changes_before status_becomes: nil, &block
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
status_becomes.each { |status_name| validate_discard_status status_name }
|
|
633
|
+
block = discard_block_for(status_becomes) if status_becomes
|
|
634
|
+
apply_discard_block block
|
|
635
|
+
end
|
|
623
636
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
end.flatten
|
|
637
|
+
# Build the block that, for an issue, returns the time it most recently entered one of the
|
|
638
|
+
# status_becomes statuses (or nil).
|
|
639
|
+
def discard_block_for status_becomes
|
|
640
|
+
status_becomes = [status_becomes] unless status_becomes.is_a? Array
|
|
641
|
+
status_becomes.each { |status_name| validate_discard_status status_name }
|
|
642
|
+
->(issue) { last_matching_status_time issue, status_becomes }
|
|
643
|
+
end
|
|
632
644
|
|
|
633
|
-
|
|
645
|
+
def last_matching_status_time issue, status_becomes
|
|
646
|
+
trigger_status_ids = trigger_status_ids_for issue, status_becomes
|
|
647
|
+
return if trigger_status_ids.empty?
|
|
634
648
|
|
|
635
|
-
|
|
649
|
+
time = nil
|
|
650
|
+
issue.status_changes.each do |change|
|
|
651
|
+
time = change.time if trigger_status_ids.include?(change.value_id) # && change.artificial? == false
|
|
652
|
+
end
|
|
653
|
+
time
|
|
654
|
+
end
|
|
636
655
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
656
|
+
def trigger_status_ids_for issue, status_becomes
|
|
657
|
+
status_becomes.collect do |status_name|
|
|
658
|
+
if status_name == :backlog
|
|
659
|
+
issue.board.backlog_statuses
|
|
660
|
+
else
|
|
661
|
+
possible_statuses.find_all_by_name status_name
|
|
642
662
|
end
|
|
643
|
-
end
|
|
663
|
+
end.flatten.collect(&:id)
|
|
664
|
+
end
|
|
644
665
|
|
|
666
|
+
def apply_discard_block block
|
|
667
|
+
cycletimes_touched = Set.new
|
|
645
668
|
file_system.diagnostic "discard_changes_before: processing #{issues.size} issues"
|
|
646
|
-
issues.each
|
|
647
|
-
|
|
648
|
-
|
|
669
|
+
issues.each { |issue| discard_changes_for_issue issue, block, cycletimes_touched }
|
|
670
|
+
cycletimes_touched.each(&:flush_cache)
|
|
671
|
+
end
|
|
649
672
|
|
|
650
|
-
|
|
651
|
-
|
|
673
|
+
def discard_changes_for_issue issue, block, cycletimes_touched
|
|
674
|
+
cutoff_time = block.call(issue)
|
|
675
|
+
return if cutoff_time.nil?
|
|
652
676
|
|
|
653
|
-
|
|
654
|
-
|
|
677
|
+
original_start_time = issue.started_stopped_times.first
|
|
678
|
+
return if original_start_time.nil?
|
|
655
679
|
|
|
656
|
-
|
|
657
|
-
|
|
680
|
+
issue.discard_changes_before cutoff_time
|
|
681
|
+
cycletimes_touched << issue.board.cycletime
|
|
658
682
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
original_start_time: original_start_time,
|
|
662
|
-
issue: issue
|
|
663
|
-
}
|
|
664
|
-
end
|
|
683
|
+
return unless cutoff_time # a user-supplied block may return false rather than nil
|
|
684
|
+
return if original_start_time > cutoff_time # ie the cutoff would have made no difference.
|
|
665
685
|
|
|
666
|
-
|
|
686
|
+
(@discarded_changes_data ||= []) << {
|
|
687
|
+
cutoff_time: cutoff_time,
|
|
688
|
+
original_start_time: original_start_time,
|
|
689
|
+
issue: issue
|
|
690
|
+
}
|
|
667
691
|
end
|
|
668
692
|
|
|
669
693
|
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
|