jirametrics 2.22 → 2.23
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 +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +11 -0
- data/lib/jirametrics/aging_work_table.rb +1 -1
- data/lib/jirametrics/anonymizer.rb +74 -1
- data/lib/jirametrics/atlassian_document_format.rb +104 -93
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +17 -3
- data/lib/jirametrics/change_item.rb +4 -3
- data/lib/jirametrics/chart_base.rb +80 -1
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +1 -2
- data/lib/jirametrics/cycletime_histogram.rb +15 -103
- data/lib/jirametrics/cycletime_scatterplot.rb +8 -97
- data/lib/jirametrics/daily_wip_chart.rb +27 -7
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +76 -5
- data/lib/jirametrics/downloader_for_cloud.rb +39 -0
- data/lib/jirametrics/downloader_for_data_center.rb +2 -1
- data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
- data/lib/jirametrics/examples/standard_project.rb +15 -5
- data/lib/jirametrics/expedited_chart.rb +2 -0
- data/lib/jirametrics/exporter.rb +3 -1
- data/lib/jirametrics/file_system.rb +4 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +2 -0
- data/lib/jirametrics/github_gateway.rb +99 -0
- data/lib/jirametrics/groupable_issue_chart.rb +2 -0
- data/lib/jirametrics/grouping_rules.rb +1 -1
- data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
- data/lib/jirametrics/html/daily_wip_chart.erb +5 -4
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
- data/lib/jirametrics/html/expedited_chart.erb +3 -13
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
- data/lib/jirametrics/html/sprint_burndown.erb +7 -13
- data/lib/jirametrics/html/throughput_chart.erb +5 -8
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +57 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +3 -4
- data/lib/jirametrics/html_report_config.rb +1 -0
- data/lib/jirametrics/issue.rb +37 -74
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/project_config.rb +32 -5
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +4 -0
- data/lib/jirametrics/settings.json +3 -1
- data/lib/jirametrics/sprint_burndown.rb +2 -0
- data/lib/jirametrics/stitcher.rb +2 -1
- data/lib/jirametrics/throughput_chart.rb +7 -1
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +100 -0
- metadata +11 -5
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
require 'time'
|
|
4
4
|
|
|
5
5
|
class Issue
|
|
6
|
-
attr_reader :changes, :raw, :subtasks, :board
|
|
7
|
-
attr_accessor :parent
|
|
6
|
+
attr_reader :changes, :raw, :subtasks, :board, :discarded_changes, :discarded_change_times
|
|
7
|
+
attr_accessor :parent, :github_prs
|
|
8
8
|
|
|
9
9
|
def initialize raw:, board:, timezone_offset: '+00:00'
|
|
10
10
|
@raw = raw
|
|
11
11
|
@timezone_offset = timezone_offset
|
|
12
12
|
@subtasks = []
|
|
13
13
|
@changes = []
|
|
14
|
+
@github_prs = []
|
|
14
15
|
@board = board
|
|
15
16
|
|
|
16
17
|
# We only check for this here because if a board isn't passed in then things will fail much
|
|
@@ -270,13 +271,19 @@ class Issue
|
|
|
270
271
|
# First look in the actual sprints json. If any issues are in this sprint then it should
|
|
271
272
|
# be here.
|
|
272
273
|
sprint = board.sprints.find { |s| s.id == sprint_id }
|
|
273
|
-
|
|
274
|
+
if sprint
|
|
275
|
+
return [nil, nil] if sprint.future?
|
|
276
|
+
|
|
277
|
+
return [sprint.start_time, sprint.completed_time]
|
|
278
|
+
end
|
|
274
279
|
|
|
275
280
|
# Then look at the sprints inside the issue. Even though the field id may be specified,
|
|
276
281
|
# that custom field may not be present. This happens if it was in that sprint but was
|
|
277
282
|
# then removed, whether or not that sprint had ever started.
|
|
278
283
|
sprint_data = raw['fields'][change.field_id]&.find { |sd| sd['id'].to_i == sprint_id }
|
|
279
284
|
if sprint_data
|
|
285
|
+
return [nil, nil] if sprint_data['state'] == 'future'
|
|
286
|
+
|
|
280
287
|
start = parse_time(sprint_data['startDate'])
|
|
281
288
|
stop = parse_time(sprint_data['completeDate'])
|
|
282
289
|
return [start, stop]
|
|
@@ -416,6 +423,7 @@ class Issue
|
|
|
416
423
|
|
|
417
424
|
blocking_status = nil
|
|
418
425
|
flag = nil
|
|
426
|
+
flag_reason = nil
|
|
419
427
|
|
|
420
428
|
# This mock change is to force the writing of one last entry at the end of the time range.
|
|
421
429
|
# By doing this, we're able to eliminate a lot of duplicated code in charts.
|
|
@@ -430,8 +438,7 @@ class Issue
|
|
|
430
438
|
)
|
|
431
439
|
|
|
432
440
|
if change.flagged? && flagged_means_blocked
|
|
433
|
-
flag = change
|
|
434
|
-
flag = nil if change.value == ''
|
|
441
|
+
flag, flag_reason = blocked_stalled_changes_flag_logic change
|
|
435
442
|
elsif change.status?
|
|
436
443
|
blocking_status = nil
|
|
437
444
|
if blocked_statuses.include?(change.value) || stalled_statuses.include?(change.value)
|
|
@@ -455,6 +462,7 @@ class Issue
|
|
|
455
462
|
|
|
456
463
|
new_change = BlockedStalledChange.new(
|
|
457
464
|
flagged: flag,
|
|
465
|
+
flag_reason: flag_reason,
|
|
458
466
|
status: blocking_status,
|
|
459
467
|
status_is_blocking: blocking_status.nil? || blocked_statuses.include?(blocking_status),
|
|
460
468
|
blocking_issue_keys: (blocking_issue_keys.empty? ? nil : blocking_issue_keys.dup),
|
|
@@ -475,6 +483,7 @@ class Issue
|
|
|
475
483
|
hack = result.pop
|
|
476
484
|
result << BlockedStalledChange.new(
|
|
477
485
|
flagged: hack.flag,
|
|
486
|
+
flag_reason: hack.flag_reason,
|
|
478
487
|
status: hack.status,
|
|
479
488
|
status_is_blocking: hack.status_is_blocking,
|
|
480
489
|
blocking_issue_keys: hack.blocking_issue_keys,
|
|
@@ -486,6 +495,28 @@ class Issue
|
|
|
486
495
|
result
|
|
487
496
|
end
|
|
488
497
|
|
|
498
|
+
def blocked_stalled_changes_flag_logic change
|
|
499
|
+
flag = change.value
|
|
500
|
+
flag = nil if change.value == ''
|
|
501
|
+
if flag
|
|
502
|
+
# When the user is adding a comment to explain why a flag was set, the flag is set immediately
|
|
503
|
+
# and the comment is inserted after the user hits enter, which means that there is some time
|
|
504
|
+
# gap. If a comment happened shortly after the flag was set, we assume they're linked. This
|
|
505
|
+
# won't always be true and so there will be false positives, but it's a reasonable assumption.
|
|
506
|
+
max_seconds_between_flag_and_comment = 30
|
|
507
|
+
comment_change = changes.find do |c|
|
|
508
|
+
c.comment? && c.time >= change.time && (c.time - change.time) <= max_seconds_between_flag_and_comment
|
|
509
|
+
end
|
|
510
|
+
flag_reason = comment_change && @board.project_config.atlassian_document_format.to_text(comment_change.value)
|
|
511
|
+
# Newer Jira instances may add this extra text but older instances did not. Strip it out if found.
|
|
512
|
+
flag_reason = flag_reason&.sub(/\A:flag_on: Flag added\s*/m, '')&.strip
|
|
513
|
+
flag_reason = nil if flag_reason&.empty?
|
|
514
|
+
else
|
|
515
|
+
flag_reason = nil
|
|
516
|
+
end
|
|
517
|
+
[flag, flag_reason]
|
|
518
|
+
end
|
|
519
|
+
|
|
489
520
|
def check_for_stalled change_time:, previous_change_time:, stalled_threshold:, blocking_stalled_changes:
|
|
490
521
|
stalled_threshold_seconds = stalled_threshold * 60 * 60 * 24
|
|
491
522
|
|
|
@@ -701,75 +732,7 @@ class Issue
|
|
|
701
732
|
end
|
|
702
733
|
|
|
703
734
|
def dump
|
|
704
|
-
|
|
705
|
-
result << "#{key} (#{type}): #{compact_text summary, max: 200}\n"
|
|
706
|
-
|
|
707
|
-
assignee = raw['fields']['assignee']
|
|
708
|
-
result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
|
|
709
|
-
|
|
710
|
-
raw['fields']['issuelinks']&.each do |link|
|
|
711
|
-
result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
|
|
712
|
-
result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
|
|
713
|
-
end
|
|
714
|
-
history = [] # time, type, detail
|
|
715
|
-
|
|
716
|
-
if board.cycletime
|
|
717
|
-
started_at, stopped_at = board.cycletime.started_stopped_times(self)
|
|
718
|
-
history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
|
|
719
|
-
history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
|
|
720
|
-
else
|
|
721
|
-
result << " Unable to determine start/end times as board #{board.id} has no cycletime specified\n"
|
|
722
|
-
end
|
|
723
|
-
|
|
724
|
-
@discarded_change_times&.each do |time|
|
|
725
|
-
history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
|
|
726
|
-
end
|
|
727
|
-
|
|
728
|
-
(changes + (@discarded_changes || [])).each do |change|
|
|
729
|
-
if change.status?
|
|
730
|
-
value = "#{change.value.inspect}:#{change.value_id.inspect}"
|
|
731
|
-
old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
|
|
732
|
-
else
|
|
733
|
-
value = compact_text(change.value).inspect
|
|
734
|
-
old_value = change.old_value ? compact_text(change.old_value).inspect : nil
|
|
735
|
-
end
|
|
736
|
-
|
|
737
|
-
message = +''
|
|
738
|
-
message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
|
|
739
|
-
message << value
|
|
740
|
-
if change.artificial?
|
|
741
|
-
message << ' (Artificial entry)' if change.artificial?
|
|
742
|
-
else
|
|
743
|
-
message << " (Author: #{change.author})"
|
|
744
|
-
end
|
|
745
|
-
history << [change.time, change.field, message, change.artificial?]
|
|
746
|
-
end
|
|
747
|
-
|
|
748
|
-
result << " History:\n"
|
|
749
|
-
type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
|
|
750
|
-
history.sort! do |a, b|
|
|
751
|
-
if a[0] == b[0]
|
|
752
|
-
if a[1].nil?
|
|
753
|
-
1
|
|
754
|
-
elsif b[1].nil?
|
|
755
|
-
-1
|
|
756
|
-
else
|
|
757
|
-
a[1] <=> b[1]
|
|
758
|
-
end
|
|
759
|
-
else
|
|
760
|
-
a[0] <=> b[0]
|
|
761
|
-
end
|
|
762
|
-
end
|
|
763
|
-
history.each do |time, type, detail, _artificial|
|
|
764
|
-
if type.nil?
|
|
765
|
-
type = '-' * type_width
|
|
766
|
-
else
|
|
767
|
-
type = (' ' * (type_width - type.length)) << type
|
|
768
|
-
end
|
|
769
|
-
result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
|
|
770
|
-
end
|
|
771
|
-
|
|
772
|
-
result
|
|
735
|
+
IssuePrinter.new(self).to_s
|
|
773
736
|
end
|
|
774
737
|
|
|
775
738
|
def done?
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class IssuePrinter
|
|
4
|
+
def initialize issue
|
|
5
|
+
@issue = issue
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def to_s
|
|
9
|
+
issue = @issue
|
|
10
|
+
result = +''
|
|
11
|
+
result << "#{issue.key} (#{issue.type}): #{issue.compact_text issue.summary, max: 200}\n"
|
|
12
|
+
|
|
13
|
+
assignee = issue.raw['fields']['assignee']
|
|
14
|
+
result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
|
|
15
|
+
|
|
16
|
+
issue.raw['fields']['issuelinks']&.each do |link|
|
|
17
|
+
result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
|
|
18
|
+
result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
history = [] # time, type, detail
|
|
22
|
+
|
|
23
|
+
if issue.board.cycletime
|
|
24
|
+
started_at, stopped_at = issue.board.cycletime.started_stopped_times(issue)
|
|
25
|
+
history << [started_at, nil, 'vvvv Started here vvvv', true] if started_at
|
|
26
|
+
history << [stopped_at, nil, '^^^^ Finished here ^^^^', true] if stopped_at
|
|
27
|
+
else
|
|
28
|
+
result << " Unable to determine start/end times as board #{issue.board.id} has no cycletime specified\n"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
issue.discarded_change_times&.each do |time|
|
|
32
|
+
history << [time, nil, '^^^^ Changes discarded ^^^^', true]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
(issue.changes + (issue.discarded_changes || [])).each do |change|
|
|
36
|
+
history << [change.time, change.field, create_change_message(change: change, issue: issue), change.artificial?]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
result << " History:\n"
|
|
40
|
+
type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
|
|
41
|
+
sort_history!(history)
|
|
42
|
+
history.each do |time, type, detail, _artificial|
|
|
43
|
+
type = type.nil? ? '-' * type_width : type.rjust(type_width)
|
|
44
|
+
result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
result
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def create_change_message change:, issue:
|
|
51
|
+
value, old_value = format_change_values(change: change, issue: issue)
|
|
52
|
+
|
|
53
|
+
message = +''
|
|
54
|
+
message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
|
|
55
|
+
message << value
|
|
56
|
+
if change.artificial?
|
|
57
|
+
message << ' (Artificial entry)'
|
|
58
|
+
else
|
|
59
|
+
message << " (Author: #{change.author})"
|
|
60
|
+
end
|
|
61
|
+
message
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def format_change_values change:, issue:
|
|
65
|
+
if change.status?
|
|
66
|
+
value = "#{change.value.inspect}:#{change.value_id.inspect}"
|
|
67
|
+
old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
|
|
68
|
+
elsif change.sprint?
|
|
69
|
+
added = change.value_id - change.old_value_id
|
|
70
|
+
removed = change.old_value_id - change.value_id
|
|
71
|
+
value = "#{change.value.inspect} #{change.value_id}"
|
|
72
|
+
value << " (added: #{added})" unless added.empty?
|
|
73
|
+
value << " (removed: #{removed})" unless removed.empty?
|
|
74
|
+
old_value = nil
|
|
75
|
+
else
|
|
76
|
+
value = issue.compact_text(change.value).inspect
|
|
77
|
+
old_value = change.old_value ? issue.compact_text(change.old_value).inspect : nil
|
|
78
|
+
end
|
|
79
|
+
[value, old_value]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def sort_history! history
|
|
83
|
+
history.sort! do |a, b|
|
|
84
|
+
if a[0] == b[0]
|
|
85
|
+
if a[1].nil?
|
|
86
|
+
1
|
|
87
|
+
elsif b[1].nil?
|
|
88
|
+
-1
|
|
89
|
+
else
|
|
90
|
+
a[1] <=> b[1]
|
|
91
|
+
end
|
|
92
|
+
else
|
|
93
|
+
a[0] <=> b[0]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -6,7 +6,7 @@ require 'jirametrics/status_collection'
|
|
|
6
6
|
class ProjectConfig
|
|
7
7
|
attr_reader :target_path, :jira_config, :all_boards, :possible_statuses,
|
|
8
8
|
:download_config, :file_configs, :exporter, :data_version, :name, :board_configs,
|
|
9
|
-
:settings, :aggregate_config, :discarded_changes_data, :users
|
|
9
|
+
:settings, :aggregate_config, :discarded_changes_data, :users, :fix_versions
|
|
10
10
|
attr_accessor :time_range, :jira_url, :id
|
|
11
11
|
|
|
12
12
|
def initialize exporter:, jira_config:, block:, target_path: '.', name: '', id: nil
|
|
@@ -23,6 +23,7 @@ class ProjectConfig
|
|
|
23
23
|
@settings = load_settings
|
|
24
24
|
@id = id
|
|
25
25
|
@has_loaded_data = false
|
|
26
|
+
@fix_versions = []
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def evaluate_next_level
|
|
@@ -40,6 +41,7 @@ class ProjectConfig
|
|
|
40
41
|
@id = guess_project_id
|
|
41
42
|
load_project_metadata
|
|
42
43
|
load_sprints
|
|
44
|
+
load_fix_versions
|
|
43
45
|
load_users
|
|
44
46
|
end
|
|
45
47
|
|
|
@@ -47,10 +49,11 @@ class ProjectConfig
|
|
|
47
49
|
return if @exporter.downloading?
|
|
48
50
|
|
|
49
51
|
load_data unless aggregated_project?
|
|
50
|
-
anonymize_data if @anonymizer_needed
|
|
51
52
|
|
|
52
53
|
return if load_only
|
|
53
54
|
|
|
55
|
+
anonymize_data if @anonymizer_needed
|
|
56
|
+
|
|
54
57
|
@file_configs.each do |file_config|
|
|
55
58
|
file_config.run
|
|
56
59
|
end
|
|
@@ -267,9 +270,12 @@ class ProjectConfig
|
|
|
267
270
|
end
|
|
268
271
|
|
|
269
272
|
def load_board board_id:, filename:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
)
|
|
273
|
+
raw = file_system.load_json(filename)
|
|
274
|
+
|
|
275
|
+
features_filename = File.join(@target_path, "#{get_file_prefix}_board_#{board_id}_features.json")
|
|
276
|
+
features_raw = file_system.load_json(features_filename) if file_system.file_exist?(features_filename)
|
|
277
|
+
|
|
278
|
+
board = Board.new(raw: raw, possible_statuses: @possible_statuses, features_raw: features_raw)
|
|
273
279
|
board.project_config = self
|
|
274
280
|
@all_boards[board_id] = board
|
|
275
281
|
end
|
|
@@ -326,6 +332,13 @@ class ProjectConfig
|
|
|
326
332
|
end
|
|
327
333
|
end
|
|
328
334
|
|
|
335
|
+
def load_fix_versions
|
|
336
|
+
filename = File.join(@target_path, "#{get_file_prefix}_fix_versions.json")
|
|
337
|
+
return unless file_system.file_exist?(filename)
|
|
338
|
+
|
|
339
|
+
@fix_versions = file_system.load_json(filename).map { |raw| FixVersion.new(raw) }
|
|
340
|
+
end
|
|
341
|
+
|
|
329
342
|
def load_project_metadata
|
|
330
343
|
filename = File.join @target_path, "#{get_file_prefix}_meta.json"
|
|
331
344
|
json = file_system.load_json(filename)
|
|
@@ -362,6 +375,19 @@ class ProjectConfig
|
|
|
362
375
|
json.each { |user_data| @users << User.new(raw: user_data) }
|
|
363
376
|
end
|
|
364
377
|
|
|
378
|
+
def attach_github_prs
|
|
379
|
+
filename = File.join(@target_path, "#{get_file_prefix}_github_prs.json")
|
|
380
|
+
return unless File.exist?(filename)
|
|
381
|
+
|
|
382
|
+
prs_by_issue_key = Hash.new { |h, k| h[k] = [] }
|
|
383
|
+
file_system.load_json(filename).each do |raw|
|
|
384
|
+
pr = PullRequest.new(raw: raw)
|
|
385
|
+
pr.issue_keys.each { |key| prs_by_issue_key[key] << pr }
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
@issues.each { |issue| issue.github_prs = prs_by_issue_key[issue.key] }
|
|
389
|
+
end
|
|
390
|
+
|
|
365
391
|
def atlassian_document_format
|
|
366
392
|
@atlassian_document_format ||= AtlassianDocumentFormat.new(
|
|
367
393
|
users: @users, timezone_offset: exporter.timezone_offset
|
|
@@ -444,6 +470,7 @@ class ProjectConfig
|
|
|
444
470
|
# attached them in the appropriate places, remove any that aren't part of that initial set.
|
|
445
471
|
issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
|
|
446
472
|
@issues = issues
|
|
473
|
+
attach_github_prs
|
|
447
474
|
end
|
|
448
475
|
|
|
449
476
|
@issues
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'jirametrics/pull_request_review'
|
|
5
|
+
|
|
6
|
+
class PullRequest
|
|
7
|
+
attr_reader :raw
|
|
8
|
+
|
|
9
|
+
def initialize raw:
|
|
10
|
+
@raw = raw
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def number = @raw['number']
|
|
14
|
+
def repo = @raw['repo']
|
|
15
|
+
def url = @raw['url']
|
|
16
|
+
def title = @raw['title']
|
|
17
|
+
def branch = @raw['branch']
|
|
18
|
+
def state = @raw['state']
|
|
19
|
+
def issue_keys = @raw['issue_keys']
|
|
20
|
+
|
|
21
|
+
def opened_at = Time.parse(@raw['opened_at'])
|
|
22
|
+
def closed_at = @raw['closed_at'] ? Time.parse(@raw['closed_at']) : nil
|
|
23
|
+
def merged_at = @raw['merged_at'] ? Time.parse(@raw['merged_at']) : nil
|
|
24
|
+
|
|
25
|
+
def reviews = (@raw['reviews'] || []).map { |r| PullRequestReview.new(raw: r) }
|
|
26
|
+
def additions = @raw['additions']
|
|
27
|
+
def deletions = @raw['deletions']
|
|
28
|
+
def changed_files = @raw['changed_files']
|
|
29
|
+
def lines_changed = (additions || 0) + (deletions || 0)
|
|
30
|
+
end
|
data/lib/jirametrics/stitcher.rb
CHANGED
|
@@ -44,7 +44,8 @@ class Stitcher < HtmlGenerator
|
|
|
44
44
|
stitch_content = @all_stitches.find { |s| s.file == from_file && s.title == title && s.type == type }
|
|
45
45
|
return stitch_content.content if stitch_content
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
file_system.error "Unable to find content in file #{from_file.inspect} matching title: #{title.inspect}"
|
|
48
|
+
''
|
|
48
49
|
end
|
|
49
50
|
|
|
50
51
|
def parse_file filename
|
|
@@ -15,6 +15,9 @@ class ThroughputChart < ChartBase
|
|
|
15
15
|
</div>
|
|
16
16
|
#{describe_non_working_days}
|
|
17
17
|
TEXT
|
|
18
|
+
@x_axis_title = nil
|
|
19
|
+
@y_axis_title = 'Count of items'
|
|
20
|
+
|
|
18
21
|
|
|
19
22
|
init_configuration_block(block) do
|
|
20
23
|
grouping_rules do |issue, rule|
|
|
@@ -92,7 +95,10 @@ class ThroughputChart < ChartBase
|
|
|
92
95
|
{ y: closed_issues.size,
|
|
93
96
|
x: "#{period.end}T23:59:59",
|
|
94
97
|
title: ["#{closed_issues.size} items completed #{date_label}"] +
|
|
95
|
-
closed_issues.collect
|
|
98
|
+
closed_issues.collect do |_stop_date, issue|
|
|
99
|
+
hint = @issue_hints&.fetch(issue, nil)
|
|
100
|
+
"#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
|
|
101
|
+
end
|
|
96
102
|
}
|
|
97
103
|
end
|
|
98
104
|
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/groupable_issue_chart'
|
|
4
|
+
|
|
5
|
+
class TimeBasedHistogram < ChartBase
|
|
6
|
+
include GroupableIssueChart
|
|
7
|
+
|
|
8
|
+
attr_reader :show_stats
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
super
|
|
12
|
+
|
|
13
|
+
percentiles [50, 85, 98]
|
|
14
|
+
@show_stats = true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def percentiles percs = nil
|
|
18
|
+
@percentiles = percs unless percs.nil?
|
|
19
|
+
@percentiles
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def disable_stats
|
|
23
|
+
@show_stats = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run
|
|
27
|
+
histogram_items = all_items
|
|
28
|
+
rules_to_items = group_issues histogram_items
|
|
29
|
+
|
|
30
|
+
the_stats = {}
|
|
31
|
+
|
|
32
|
+
overall_histogram = histogram_data_for(items: histogram_items).transform_values(&:size)
|
|
33
|
+
the_stats[:all] = stats_for histogram_data: overall_histogram, percentiles: @percentiles
|
|
34
|
+
data_sets = rules_to_items.keys.collect do |rules|
|
|
35
|
+
the_label = rules.label
|
|
36
|
+
the_histogram = histogram_data_for(items: rules_to_items[rules])
|
|
37
|
+
if @show_stats
|
|
38
|
+
the_stats[the_label] = stats_for(
|
|
39
|
+
histogram_data: the_histogram.transform_values(&:size), percentiles: @percentiles
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
data_set_for(
|
|
44
|
+
histogram_data: the_histogram,
|
|
45
|
+
label: the_label,
|
|
46
|
+
color: rules.color
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if data_sets.empty?
|
|
51
|
+
return "<h1 class='foldable'>#{@header_text}</h1>" \
|
|
52
|
+
'<div>No data matched the selected criteria. Nothing to show.</div>'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
wrap_and_render(binding, __FILE__)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def histogram_data_for items:
|
|
59
|
+
items_hash = {}
|
|
60
|
+
items.each do |item|
|
|
61
|
+
days = value_for_item item
|
|
62
|
+
(items_hash[days] ||= []) << item if days.positive?
|
|
63
|
+
end
|
|
64
|
+
items_hash
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stats_for histogram_data:, percentiles:
|
|
68
|
+
return {} if histogram_data.empty?
|
|
69
|
+
|
|
70
|
+
total_values = histogram_data.values.sum
|
|
71
|
+
|
|
72
|
+
# Calculate the average
|
|
73
|
+
weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
|
|
74
|
+
average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
|
|
75
|
+
|
|
76
|
+
# Find the mode (or modes!) and the spread of the distribution
|
|
77
|
+
sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
|
|
78
|
+
max_freq = sorted_histogram[-1][1]
|
|
79
|
+
mode = sorted_histogram.select { |_v, f| f == max_freq }
|
|
80
|
+
|
|
81
|
+
minmax = histogram_data.keys.minmax
|
|
82
|
+
|
|
83
|
+
# Calculate percentiles
|
|
84
|
+
sorted_values = histogram_data.keys.sort
|
|
85
|
+
cumulative_counts = {}
|
|
86
|
+
cumulative_sum = 0
|
|
87
|
+
|
|
88
|
+
sorted_values.each do |value|
|
|
89
|
+
cumulative_sum += histogram_data[value]
|
|
90
|
+
cumulative_counts[value] = cumulative_sum
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
percentile_results = {}
|
|
94
|
+
percentiles.each do |percentile|
|
|
95
|
+
rank = (percentile / 100.0) * total_values
|
|
96
|
+
percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
|
|
97
|
+
percentile_results[percentile] = percentile_value
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
average: average,
|
|
102
|
+
mode: mode.collect(&:first).sort,
|
|
103
|
+
min: minmax[0],
|
|
104
|
+
max: minmax[1],
|
|
105
|
+
percentiles: percentile_results
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def sort_items items
|
|
110
|
+
items
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def label_for_item item, hint:
|
|
114
|
+
raise NotImplementedError, "#{self.class} must implement label_for_item"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def data_set_for histogram_data:, label:, color:
|
|
118
|
+
{
|
|
119
|
+
type: 'bar',
|
|
120
|
+
label: label,
|
|
121
|
+
data: histogram_data.keys.sort.filter_map do |days|
|
|
122
|
+
items = histogram_data[days]
|
|
123
|
+
next if items.empty?
|
|
124
|
+
|
|
125
|
+
{
|
|
126
|
+
x: days,
|
|
127
|
+
y: items.size,
|
|
128
|
+
title: [title_for_item(count: items.size, value: days)] +
|
|
129
|
+
sort_items(items).collect do |item|
|
|
130
|
+
hint = @issue_hints&.fetch(item, nil)
|
|
131
|
+
label_for_item(item, hint: hint)
|
|
132
|
+
end
|
|
133
|
+
}
|
|
134
|
+
end,
|
|
135
|
+
backgroundColor: color,
|
|
136
|
+
borderRadius: 0
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|