jirametrics 2.5 → 2.30
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/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/aggregate_config.rb +16 -3
- data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
- data/lib/jirametrics/aging_work_table.rb +63 -19
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +160 -0
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +6 -4
- data/lib/jirametrics/board.rb +73 -20
- data/lib/jirametrics/board_config.rb +10 -2
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +54 -18
- data/lib/jirametrics/chart_base.rb +203 -30
- data/lib/jirametrics/css_variable.rb +2 -2
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/cycle_time_config.rb +137 -0
- data/lib/jirametrics/cycletime_histogram.rb +17 -38
- data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
- data/lib/jirametrics/daily_view.rb +306 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
- data/lib/jirametrics/daily_wip_chart.rb +36 -16
- data/lib/jirametrics/data_quality_report.rb +251 -42
- data/lib/jirametrics/dependency_chart.rb +8 -6
- data/lib/jirametrics/download_config.rb +17 -2
- data/lib/jirametrics/downloader.rb +177 -108
- data/lib/jirametrics/downloader_for_cloud.rb +287 -0
- data/lib/jirametrics/downloader_for_data_center.rb +95 -0
- data/lib/jirametrics/estimate_accuracy_chart.rb +75 -14
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +5 -8
- data/lib/jirametrics/examples/standard_project.rb +54 -38
- data/lib/jirametrics/expedited_chart.rb +10 -9
- data/lib/jirametrics/exporter.rb +51 -16
- data/lib/jirametrics/file_config.rb +21 -6
- data/lib/jirametrics/file_system.rb +96 -4
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +12 -4
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
- data/lib/jirametrics/html/aging_work_table.erb +13 -4
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +41 -15
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +7 -24
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
- data/lib/jirametrics/html/hierarchy_table.erb +1 -1
- data/lib/jirametrics/html/index.css +336 -62
- data/lib/jirametrics/html/index.erb +16 -21
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +18 -25
- data/lib/jirametrics/html/throughput_chart.erb +43 -21
- data/lib/jirametrics/html/time_based_histogram.erb +123 -0
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +32 -0
- data/lib/jirametrics/html_report_config.rb +83 -76
- data/lib/jirametrics/issue.rb +481 -97
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +96 -16
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +374 -130
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +17 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +7 -1
- data/lib/jirametrics/sprint.rb +13 -0
- data/lib/jirametrics/sprint_burndown.rb +47 -39
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +83 -38
- data/lib/jirametrics/stitcher.rb +81 -0
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +73 -23
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +107 -0
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +101 -66
- metadata +72 -16
- data/lib/jirametrics/cycletime_config.rb +0 -69
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
|
@@ -4,68 +4,113 @@ class StatusNotFoundError < StandardError
|
|
|
4
4
|
end
|
|
5
5
|
|
|
6
6
|
class StatusCollection
|
|
7
|
+
attr_reader :historical_status_mappings
|
|
8
|
+
|
|
7
9
|
def initialize
|
|
8
10
|
@list = []
|
|
11
|
+
@historical_status_mappings = {} # 'name:id' => category
|
|
9
12
|
end
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
# Return the status matching this id or nil if it can't be found.
|
|
15
|
+
def find_by_id id
|
|
16
|
+
@list.find { |status| status.id == id }
|
|
17
|
+
end
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
keep = false if excluding.any? { |s| s.name == status.name }
|
|
19
|
+
def find_by_id! id
|
|
20
|
+
status = @list.find { |status| status.id == id }
|
|
21
|
+
raise "Can't find any status for id #{id} in #{self}" unless status
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
end
|
|
23
|
+
status
|
|
22
24
|
end
|
|
23
25
|
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
names_or_ids.each do |name_or_id|
|
|
31
|
-
status = @list.find { |s| s.name == name_or_id || s.id == name_or_id }
|
|
32
|
-
if status.nil?
|
|
33
|
-
if block_given?
|
|
34
|
-
yield name_or_id
|
|
35
|
-
next
|
|
36
|
-
else
|
|
37
|
-
all_status_names = @list.collect { |s| "#{s.name.inspect}:#{s.id.inspect}" }.uniq.sort.join(', ')
|
|
38
|
-
raise StatusNotFoundError, "Status not found: \"#{name_or_id}\". Possible statuses are: #{all_status_names}"
|
|
39
|
-
end
|
|
40
|
-
end
|
|
26
|
+
def find_all_by_name identifier
|
|
27
|
+
name, id = parse_name_id identifier
|
|
28
|
+
|
|
29
|
+
if id
|
|
30
|
+
status = find_by_id id
|
|
31
|
+
return [] if status.nil?
|
|
41
32
|
|
|
42
|
-
|
|
33
|
+
if name && status.name != name
|
|
34
|
+
raise "Specified status ID of #{id} does not match specified name #{name.inspect}. " \
|
|
35
|
+
"You might have meant one of these: #{self}."
|
|
36
|
+
end
|
|
37
|
+
[status]
|
|
38
|
+
else
|
|
39
|
+
@list.select { |status| status.name == name }
|
|
43
40
|
end
|
|
44
|
-
result
|
|
45
41
|
end
|
|
46
42
|
|
|
47
|
-
def
|
|
48
|
-
|
|
43
|
+
def find_all_categories
|
|
44
|
+
@list
|
|
45
|
+
.collect(&:category)
|
|
46
|
+
.uniq
|
|
47
|
+
.sort_by(&:id)
|
|
49
48
|
end
|
|
50
49
|
|
|
51
|
-
def
|
|
52
|
-
|
|
50
|
+
def parse_name_id name
|
|
51
|
+
# Names could arrive in one of the following formats: "Done:3", "3", "Done"
|
|
52
|
+
if name =~ /^(.*):(\d+)$/
|
|
53
|
+
[$1, $2.to_i]
|
|
54
|
+
elsif name.match?(/^\d+$/)
|
|
55
|
+
[nil, name.to_i]
|
|
56
|
+
else
|
|
57
|
+
[name, nil]
|
|
58
|
+
end
|
|
53
59
|
end
|
|
54
60
|
|
|
55
|
-
def
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
def find_all_categories_by_name identifier
|
|
62
|
+
key = nil
|
|
63
|
+
id = nil
|
|
58
64
|
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
if identifier.is_a? Symbol
|
|
66
|
+
key = identifier.to_s
|
|
67
|
+
else
|
|
68
|
+
name, id = parse_name_id identifier
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
find_all_categories.select { |c| c.id == id || c.name == name || c.key == key }
|
|
61
72
|
end
|
|
62
73
|
|
|
63
|
-
def find(&block)= @list.find(&block)
|
|
64
74
|
def collect(&block) = @list.collect(&block)
|
|
75
|
+
def find(&block) = @list.find(&block)
|
|
65
76
|
def each(&block) = @list.each(&block)
|
|
66
77
|
def select(&block) = @list.select(&block)
|
|
67
78
|
def <<(arg) = @list << arg
|
|
68
79
|
def empty? = @list.empty?
|
|
69
80
|
def clear = @list.clear
|
|
70
81
|
def delete(object) = @list.delete(object)
|
|
82
|
+
|
|
83
|
+
def to_s
|
|
84
|
+
"[#{@list.sort.join(', ')}]"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def inspect
|
|
88
|
+
"StatusCollection#{self}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def fabricate_status_for id:, name:
|
|
92
|
+
category = @historical_status_mappings["#{name.inspect}:#{id.inspect}"]
|
|
93
|
+
category = in_progress_category if category.nil?
|
|
94
|
+
|
|
95
|
+
status = Status.new(
|
|
96
|
+
name: name,
|
|
97
|
+
id: id,
|
|
98
|
+
category_name: category.name,
|
|
99
|
+
category_id: category.id,
|
|
100
|
+
category_key: category.key,
|
|
101
|
+
artificial: true
|
|
102
|
+
)
|
|
103
|
+
@list << status
|
|
104
|
+
status
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# Return the in-progress category or raise an error if we can't find one.
|
|
110
|
+
def in_progress_category
|
|
111
|
+
first_in_progress_status = find { |s| s.category.indeterminate? }
|
|
112
|
+
raise "Can't find even one in-progress status in #{self}" unless first_in_progress_status
|
|
113
|
+
|
|
114
|
+
first_in_progress_status.category
|
|
115
|
+
end
|
|
71
116
|
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Stitcher < HtmlGenerator
|
|
4
|
+
class StitchContent
|
|
5
|
+
include ValueEquality
|
|
6
|
+
|
|
7
|
+
attr_reader :file, :title, :content, :type
|
|
8
|
+
|
|
9
|
+
def initialize file:, title:, type:, content:
|
|
10
|
+
@file = file
|
|
11
|
+
@title = title
|
|
12
|
+
@content = content
|
|
13
|
+
@type = type
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :loaded_files, :all_stitches
|
|
18
|
+
|
|
19
|
+
def initialize file_system:
|
|
20
|
+
super()
|
|
21
|
+
self.file_system = file_system
|
|
22
|
+
@all_stitches = []
|
|
23
|
+
@loaded_files = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run stitch_file:
|
|
27
|
+
output_filename = make_output_filename stitch_file
|
|
28
|
+
file_system.log "Creating file #{output_filename.inspect}", also_write_to_stderr: true
|
|
29
|
+
erb = ERB.new file_system.load(stitch_file)
|
|
30
|
+
@sections = [[erb.result(binding), :body]]
|
|
31
|
+
create_html output_filename: output_filename, settings: {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def make_output_filename input_filename
|
|
35
|
+
if /^(.+)\.erb$/ =~ input_filename
|
|
36
|
+
"#{$1}.html"
|
|
37
|
+
else
|
|
38
|
+
"#{input_filename}.html"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def grab_by_title title, from_file:, type: 'chart'
|
|
43
|
+
parse_file from_file
|
|
44
|
+
stitch_content = @all_stitches.find { |s| s.file == from_file && s.title == title && s.type == type }
|
|
45
|
+
return stitch_content.content if stitch_content
|
|
46
|
+
|
|
47
|
+
file_system.error "Unable to find content in file #{from_file.inspect} matching title: #{title.inspect}"
|
|
48
|
+
''
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parse_file filename
|
|
52
|
+
return false if @loaded_files.include? filename
|
|
53
|
+
|
|
54
|
+
# To match: <!-- seam-start | chart78 | GithubPrScatterplot | PR Scatterplot | chart -->
|
|
55
|
+
regex = /^<!-- seam-(?<seam>start|end) \| (?<id>[^|]+) \| (?<clazz>[^|]+) \| (?<title>[^|]+) \| (?<type>[^|]+) -->$/
|
|
56
|
+
content = nil
|
|
57
|
+
file_system.load(filename).lines do |line|
|
|
58
|
+
matches = line.match(regex)
|
|
59
|
+
if matches
|
|
60
|
+
if matches[:seam] == 'start'
|
|
61
|
+
content = +''
|
|
62
|
+
else
|
|
63
|
+
if content.nil? || content.strip.empty?
|
|
64
|
+
file_system.warning "Seam found with no content in #{filename.inspect}: " \
|
|
65
|
+
"id=#{matches[:id].strip.inspect}, class=#{matches[:clazz].strip.inspect}, " \
|
|
66
|
+
"title=#{matches[:title].strip.inspect}"
|
|
67
|
+
end
|
|
68
|
+
@all_stitches << Stitcher::StitchContent.new(
|
|
69
|
+
file: filename, title: matches[:title], type: matches[:type], content: content
|
|
70
|
+
)
|
|
71
|
+
content = nil
|
|
72
|
+
end
|
|
73
|
+
elsif content
|
|
74
|
+
content << line
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@loaded_files << filename
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/throughput_chart'
|
|
4
|
+
|
|
5
|
+
class ThroughputByCompletedResolutionChart < ThroughputChart
|
|
6
|
+
def initialize block
|
|
7
|
+
super
|
|
8
|
+
header_text 'Throughput, grouped by completion status and resolution'
|
|
9
|
+
description_text nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def default_grouping_rules issue, rules
|
|
13
|
+
status, resolution = issue.status_resolution_at_done
|
|
14
|
+
if resolution
|
|
15
|
+
rules.label = "#{status.name}:#{resolution}"
|
|
16
|
+
rules.label_hint = "Status: #{status.name.inspect}:#{status.id}, resolution: #{resolution.inspect}"
|
|
17
|
+
else
|
|
18
|
+
rules.label = status.name
|
|
19
|
+
rules.label_hint = "Status: #{status.name.inspect}:#{status.id}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
3
5
|
class ThroughputChart < ChartBase
|
|
4
6
|
include GroupableIssueChart
|
|
5
7
|
|
|
@@ -10,42 +12,54 @@ class ThroughputChart < ChartBase
|
|
|
10
12
|
|
|
11
13
|
header_text 'Throughput Chart'
|
|
12
14
|
description_text <<-TEXT
|
|
13
|
-
<div
|
|
14
|
-
|
|
15
|
+
<div>Throughput data is very useful for#{' '}
|
|
16
|
+
<a href="https://blog.mikebowler.ca/2024/06/02/probabilistic-forecasting/">probabilistic forecasting</a>,
|
|
17
|
+
to determine when we'll be done. Try it now with the
|
|
18
|
+
<a href="<%= throughput_forecaster_url %>" target="_blank" rel="noopener noreferrer">
|
|
19
|
+
Focused Objective throughput forecaster,</a> to see how long it would take to complete all of the
|
|
20
|
+
<%= @not_started_count %> items you currently have in your backlog.
|
|
15
21
|
</div>
|
|
16
22
|
#{describe_non_working_days}
|
|
17
23
|
TEXT
|
|
24
|
+
@x_axis_title = nil
|
|
25
|
+
@y_axis_title = 'Count of items'
|
|
18
26
|
|
|
19
27
|
init_configuration_block(block) do
|
|
20
|
-
grouping_rules
|
|
21
|
-
rule.label = issue.type
|
|
22
|
-
rule.color = color_for type: issue.type
|
|
23
|
-
end
|
|
28
|
+
grouping_rules { |issue, rule| default_grouping_rules(issue, rule) }
|
|
24
29
|
end
|
|
25
30
|
end
|
|
26
31
|
|
|
27
32
|
def run
|
|
33
|
+
# This is saved as an instance variable so that it's accessible later when rendering the description text
|
|
34
|
+
@not_started_count = issues.count { |issue| issue.started_stopped_times.first.nil? }
|
|
35
|
+
|
|
28
36
|
completed_issues = completed_issues_in_range include_unstarted: true
|
|
29
37
|
rules_to_issues = group_issues completed_issues
|
|
30
38
|
data_sets = []
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
total_data_set = weekly_throughput_dataset(
|
|
40
|
+
completed_issues: completed_issues,
|
|
41
|
+
label: 'Totals',
|
|
42
|
+
color: CssVariable['--throughput_chart_total_line_color'],
|
|
43
|
+
dashed: true
|
|
44
|
+
)
|
|
45
|
+
@throughput_samples = total_data_set[:data].collect { |d| d[:y] }
|
|
46
|
+
data_sets << total_data_set if rules_to_issues.size > 1
|
|
39
47
|
|
|
40
48
|
rules_to_issues.each_key do |rules|
|
|
41
49
|
data_sets << weekly_throughput_dataset(
|
|
42
|
-
completed_issues: rules_to_issues[rules], label: rules.label, color: rules.color
|
|
50
|
+
completed_issues: rules_to_issues[rules], label: rules.label, color: rules.color,
|
|
51
|
+
label_hint: rules.label_hint
|
|
43
52
|
)
|
|
44
53
|
end
|
|
45
54
|
|
|
46
55
|
wrap_and_render(binding, __FILE__)
|
|
47
56
|
end
|
|
48
57
|
|
|
58
|
+
def default_grouping_rules issue, rule
|
|
59
|
+
rule.label = issue.type
|
|
60
|
+
rule.color = color_for type: issue.type
|
|
61
|
+
end
|
|
62
|
+
|
|
49
63
|
def calculate_time_periods
|
|
50
64
|
first_day = @date_range.begin
|
|
51
65
|
first_day = case first_day.wday
|
|
@@ -65,10 +79,22 @@ class ThroughputChart < ChartBase
|
|
|
65
79
|
end
|
|
66
80
|
end
|
|
67
81
|
|
|
68
|
-
def
|
|
82
|
+
def calculate_custom_periods
|
|
83
|
+
last_days = @issue_periods.values.compact.uniq.sort
|
|
84
|
+
last_days.each_with_index.map do |last_day, i|
|
|
85
|
+
first_day = i.zero? ? @date_range.begin : last_days[i - 1] + 1
|
|
86
|
+
first_day..last_day
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def weekly_throughput_dataset completed_issues:, label:, color:, dashed: false, label_hint: nil
|
|
91
|
+
periods = @issue_periods&.values&.any? ? calculate_custom_periods : calculate_time_periods
|
|
69
92
|
result = {
|
|
70
93
|
label: label,
|
|
71
|
-
|
|
94
|
+
label_hint: label_hint,
|
|
95
|
+
data: throughput_dataset(
|
|
96
|
+
periods: periods, completed_issues: completed_issues, label_hint: label_hint
|
|
97
|
+
),
|
|
72
98
|
fill: false,
|
|
73
99
|
showLine: true,
|
|
74
100
|
borderColor: color,
|
|
@@ -79,20 +105,44 @@ class ThroughputChart < ChartBase
|
|
|
79
105
|
result
|
|
80
106
|
end
|
|
81
107
|
|
|
82
|
-
def
|
|
108
|
+
def throughput_forecaster_url
|
|
109
|
+
params = {
|
|
110
|
+
throughputMode: 'data',
|
|
111
|
+
samplesText: @throughput_samples.join(','),
|
|
112
|
+
storyLow: @not_started_count,
|
|
113
|
+
storyHigh: @not_started_count
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
query = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join('&')
|
|
117
|
+
"https://focusedobjective.com/throughput?#{query}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def throughput_dataset periods:, completed_issues:, label_hint: nil
|
|
121
|
+
custom_mode = @issue_periods&.values&.any?
|
|
83
122
|
periods.collect do |period|
|
|
84
123
|
closed_issues = completed_issues.filter_map do |issue|
|
|
85
|
-
stop_date = issue.
|
|
86
|
-
|
|
124
|
+
stop_date = issue.started_stopped_dates.last
|
|
125
|
+
next unless stop_date
|
|
126
|
+
|
|
127
|
+
if custom_mode
|
|
128
|
+
[stop_date, issue] if @issue_periods[issue] == period.end
|
|
129
|
+
elsif period.include?(stop_date)
|
|
130
|
+
[stop_date, issue]
|
|
131
|
+
end
|
|
87
132
|
end
|
|
88
133
|
|
|
89
134
|
date_label = "on #{period.end}"
|
|
90
135
|
date_label = "between #{period.begin} and #{period.end}" unless period.begin == period.end
|
|
91
136
|
|
|
92
|
-
{
|
|
137
|
+
with_label_hint = label_hint ? " with #{label_hint}" : ''
|
|
138
|
+
{
|
|
139
|
+
y: closed_issues.size,
|
|
93
140
|
x: "#{period.end}T23:59:59",
|
|
94
|
-
title: ["#{closed_issues.size} items
|
|
95
|
-
closed_issues.collect
|
|
141
|
+
title: ["#{closed_issues.size} items closed#{with_label_hint} #{date_label}"] +
|
|
142
|
+
closed_issues.collect do |_stop_date, issue|
|
|
143
|
+
hint = @issue_hints&.fetch(issue, nil)
|
|
144
|
+
"#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
|
|
145
|
+
end
|
|
96
146
|
}
|
|
97
147
|
end
|
|
98
148
|
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
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/groupable_issue_chart'
|
|
4
|
+
|
|
5
|
+
class TimeBasedScatterplot < ChartBase
|
|
6
|
+
include GroupableIssueChart
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
super
|
|
10
|
+
|
|
11
|
+
@percentage_lines = []
|
|
12
|
+
@highest_y_value = 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
items = all_items
|
|
17
|
+
data_sets = create_datasets items
|
|
18
|
+
overall_percent_line = calculate_percent_line(items)
|
|
19
|
+
@percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
|
|
20
|
+
|
|
21
|
+
return "<h1 class='foldable'>#{@header_text}</h1><div>No data matched the selected criteria. Nothing to show.</div>" if data_sets.empty?
|
|
22
|
+
|
|
23
|
+
wrap_and_render(binding, __FILE__)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def create_datasets items
|
|
27
|
+
data_sets = []
|
|
28
|
+
|
|
29
|
+
group_issues(items).each do |rules, items_by_type|
|
|
30
|
+
label = rules.label
|
|
31
|
+
color = rules.color
|
|
32
|
+
percent_line = calculate_percent_line items_by_type
|
|
33
|
+
data = items_by_type.filter_map { |item| data_for_item(item, rules: rules) }
|
|
34
|
+
data_sets << {
|
|
35
|
+
label: "#{label} (85% at #{label_days(percent_line)})",
|
|
36
|
+
data: data,
|
|
37
|
+
fill: false,
|
|
38
|
+
showLine: false,
|
|
39
|
+
backgroundColor: color
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
data_sets << trend_line_data_set(label: label, data: data, color: color)
|
|
43
|
+
|
|
44
|
+
@percentage_lines << [percent_line, color]
|
|
45
|
+
end
|
|
46
|
+
data_sets
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def show_trend_lines
|
|
50
|
+
@show_trend_lines = true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def trend_line_data_set label:, data:, color:
|
|
54
|
+
points = data.collect do |hash|
|
|
55
|
+
[Time.parse(hash[:x]).to_i, hash[:y]]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The trend calculation works with numbers only so convert Time to an int and back
|
|
59
|
+
calculator = TrendLineCalculator.new(points)
|
|
60
|
+
data_points = calculator.chart_datapoints(
|
|
61
|
+
range: time_range.begin.to_i..time_range.end.to_i,
|
|
62
|
+
max_y: @highest_y_value
|
|
63
|
+
)
|
|
64
|
+
data_points.each do |point_hash|
|
|
65
|
+
point_hash[:x] = chart_format Time.at(point_hash[:x])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
type: 'line',
|
|
70
|
+
label: "#{label} Trendline",
|
|
71
|
+
data: data_points,
|
|
72
|
+
fill: false,
|
|
73
|
+
borderWidth: 1,
|
|
74
|
+
markerType: 'none',
|
|
75
|
+
borderColor: color,
|
|
76
|
+
borderDash: [6, 3],
|
|
77
|
+
pointStyle: 'dash',
|
|
78
|
+
hidden: !@show_trend_lines
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def minimum_y_value
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def data_for_item item, rules: nil
|
|
87
|
+
y = y_value(item)
|
|
88
|
+
min = minimum_y_value
|
|
89
|
+
return nil if min && y < min
|
|
90
|
+
|
|
91
|
+
@highest_y_value = y if @highest_y_value < y
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
y: y,
|
|
95
|
+
x: chart_format(x_value(item)),
|
|
96
|
+
title: [title_value(item, rules: rules)]
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def calculate_percent_line items
|
|
101
|
+
min = minimum_y_value
|
|
102
|
+
times = items.collect { |item| y_value(item) }
|
|
103
|
+
times.reject! { |y| min && y < min }
|
|
104
|
+
index = times.size * 85 / 100
|
|
105
|
+
times.sort[index]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -9,9 +9,9 @@ module ValueEquality
|
|
|
9
9
|
names = object.instance_variables
|
|
10
10
|
if object.respond_to? :value_equality_ignored_variables
|
|
11
11
|
ignored_variables = object.value_equality_ignored_variables
|
|
12
|
-
names.reject! { |n| ignored_variables.include? n }
|
|
12
|
+
names.reject! { |n| ignored_variables.include? n.to_sym }
|
|
13
13
|
end
|
|
14
|
-
names.map { |variable| instance_variable_get variable }
|
|
14
|
+
names.map { |variable| object.instance_variable_get variable }
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
code.call(self) == code.call(other)
|