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
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'jirametrics/
|
|
3
|
+
require 'jirametrics/time_based_histogram'
|
|
4
4
|
|
|
5
|
-
class CycletimeHistogram <
|
|
6
|
-
include GroupableIssueChart
|
|
5
|
+
class CycletimeHistogram < TimeBasedHistogram
|
|
7
6
|
attr_accessor :possible_statuses
|
|
8
|
-
attr_reader :show_stats
|
|
9
7
|
|
|
10
8
|
def initialize block
|
|
11
9
|
super()
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
@
|
|
11
|
+
@x_axis_title = 'Cycletime in days'
|
|
12
|
+
@y_axis_title = 'Count'
|
|
15
13
|
|
|
16
14
|
header_text 'Cycletime Histogram'
|
|
17
15
|
description_text <<-HTML
|
|
@@ -30,112 +28,26 @@ class CycletimeHistogram < ChartBase
|
|
|
30
28
|
end
|
|
31
29
|
end
|
|
32
30
|
|
|
33
|
-
def
|
|
34
|
-
@percentiles = percs unless percs.nil?
|
|
35
|
-
@percentiles
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def disable_stats
|
|
39
|
-
@show_stats = false
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def run
|
|
31
|
+
def all_items
|
|
43
32
|
stopped_issues = completed_issues_in_range include_unstarted: true
|
|
44
33
|
|
|
45
34
|
# For the histogram, we only want to consider items that have both a start and a stop time.
|
|
46
|
-
|
|
47
|
-
rules_to_issues = group_issues histogram_issues
|
|
48
|
-
|
|
49
|
-
the_stats = {}
|
|
50
|
-
|
|
51
|
-
overall_stats = stats_for histogram_data: histogram_data_for(issues: histogram_issues), percentiles: @percentiles
|
|
52
|
-
the_stats[:all] = overall_stats
|
|
53
|
-
data_sets = rules_to_issues.keys.collect do |rules|
|
|
54
|
-
the_issue_type = rules.label
|
|
55
|
-
the_histogram = histogram_data_for(issues: rules_to_issues[rules])
|
|
56
|
-
the_stats[the_issue_type] = stats_for histogram_data: the_histogram, percentiles: @percentiles if @show_stats
|
|
57
|
-
|
|
58
|
-
data_set_for(
|
|
59
|
-
histogram_data: the_histogram,
|
|
60
|
-
label: the_issue_type,
|
|
61
|
-
color: rules.color
|
|
62
|
-
)
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
if data_sets.empty?
|
|
66
|
-
return "<h1 class='foldable'>#{@header_text}</h1><div>No data matched the selected criteria. Nothing to show.</div>"
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
wrap_and_render(binding, __FILE__)
|
|
35
|
+
stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
|
|
70
36
|
end
|
|
71
37
|
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
issues.each do |issue|
|
|
75
|
-
days = issue.board.cycletime.cycletime(issue)
|
|
76
|
-
count_hash[days] = (count_hash[days] || 0) + 1 if days.positive?
|
|
77
|
-
end
|
|
78
|
-
count_hash
|
|
38
|
+
def value_for_item issue
|
|
39
|
+
issue.board.cycletime.cycletime(issue)
|
|
79
40
|
end
|
|
80
41
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
total_values = histogram_data.values.sum
|
|
85
|
-
|
|
86
|
-
# Calculate the average
|
|
87
|
-
weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
|
|
88
|
-
average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
|
|
89
|
-
|
|
90
|
-
# Find the mode (or modes!) and the spread of the distribution
|
|
91
|
-
sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
|
|
92
|
-
max_freq = sorted_histogram[-1][1]
|
|
93
|
-
mode = sorted_histogram.select { |_v, f| f == max_freq }
|
|
94
|
-
|
|
95
|
-
minmax = histogram_data.keys.minmax
|
|
96
|
-
|
|
97
|
-
# Calculate percentiles
|
|
98
|
-
sorted_values = histogram_data.keys.sort
|
|
99
|
-
cumulative_counts = {}
|
|
100
|
-
cumulative_sum = 0
|
|
101
|
-
|
|
102
|
-
sorted_values.each do |value|
|
|
103
|
-
cumulative_sum += histogram_data[value]
|
|
104
|
-
cumulative_counts[value] = cumulative_sum
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
percentile_results = {}
|
|
108
|
-
percentiles.each do |percentile|
|
|
109
|
-
rank = (percentile / 100.0) * total_values
|
|
110
|
-
percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
|
|
111
|
-
percentile_results[percentile] = percentile_value
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
{
|
|
115
|
-
average: average,
|
|
116
|
-
mode: mode.collect(&:first).sort,
|
|
117
|
-
min: minmax[0],
|
|
118
|
-
max: minmax[1],
|
|
119
|
-
percentiles: percentile_results
|
|
120
|
-
}
|
|
42
|
+
def title_for_item count:, value:
|
|
43
|
+
"#{count} items completed in #{label_days value}"
|
|
121
44
|
end
|
|
122
45
|
|
|
123
|
-
def
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
type: 'bar',
|
|
127
|
-
label: label,
|
|
128
|
-
data: keys.sort.filter_map do |key|
|
|
129
|
-
next if histogram_data[key].zero?
|
|
46
|
+
def sort_items items
|
|
47
|
+
items.sort_by(&:key_as_i)
|
|
48
|
+
end
|
|
130
49
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
y: histogram_data[key],
|
|
134
|
-
title: "#{histogram_data[key]} items completed in #{label_days key}"
|
|
135
|
-
}
|
|
136
|
-
end,
|
|
137
|
-
backgroundColor: color,
|
|
138
|
-
borderRadius: 0
|
|
139
|
-
}
|
|
50
|
+
def label_for_item issue, hint:
|
|
51
|
+
"#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
|
|
140
52
|
end
|
|
141
53
|
end
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'jirametrics/
|
|
4
|
-
|
|
5
|
-
class CycletimeScatterplot < ChartBase
|
|
6
|
-
include GroupableIssueChart
|
|
3
|
+
require 'jirametrics/time_based_scatterplot'
|
|
7
4
|
|
|
5
|
+
class CycletimeScatterplot < TimeBasedScatterplot
|
|
8
6
|
attr_accessor :possible_statuses
|
|
9
7
|
|
|
10
8
|
def initialize block
|
|
@@ -26,6 +24,8 @@ class CycletimeScatterplot < ChartBase
|
|
|
26
24
|
</div>
|
|
27
25
|
#{describe_non_working_days}
|
|
28
26
|
HTML
|
|
27
|
+
@x_axis_title = 'Date completed'
|
|
28
|
+
@y_axis_title = 'Cycletime in days'
|
|
29
29
|
|
|
30
30
|
init_configuration_block block do
|
|
31
31
|
grouping_rules do |issue, rule|
|
|
@@ -33,9 +33,6 @@ class CycletimeScatterplot < ChartBase
|
|
|
33
33
|
rule.color = color_for type: issue.type
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
|
-
|
|
37
|
-
@percentage_lines = []
|
|
38
|
-
@highest_y_value = 0
|
|
39
36
|
end
|
|
40
37
|
|
|
41
38
|
def all_items
|
|
@@ -51,96 +48,10 @@ class CycletimeScatterplot < ChartBase
|
|
|
51
48
|
end
|
|
52
49
|
|
|
53
50
|
def title_value item
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def y_axis_heading
|
|
58
|
-
'Cycle time in days'
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def run
|
|
62
|
-
items = all_items
|
|
63
|
-
data_sets = create_datasets items
|
|
64
|
-
overall_percent_line = calculate_percent_line(items)
|
|
65
|
-
@percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
|
|
66
|
-
|
|
67
|
-
return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
|
|
68
|
-
|
|
69
|
-
wrap_and_render(binding, __FILE__)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def create_datasets items
|
|
73
|
-
data_sets = []
|
|
74
|
-
|
|
75
|
-
group_issues(items).each do |rules, completed_items_by_type|
|
|
76
|
-
label = rules.label
|
|
77
|
-
color = rules.color
|
|
78
|
-
percent_line = calculate_percent_line completed_items_by_type
|
|
79
|
-
data = completed_items_by_type.filter_map { |issue| data_for_issue(issue) }
|
|
80
|
-
data_sets << {
|
|
81
|
-
label: "#{label} (85% at #{label_days(percent_line)})",
|
|
82
|
-
data: data,
|
|
83
|
-
fill: false,
|
|
84
|
-
showLine: false,
|
|
85
|
-
backgroundColor: color
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
data_sets << trend_line_data_set(label: label, data: data, color: color)
|
|
89
|
-
|
|
90
|
-
@percentage_lines << [percent_line, color]
|
|
91
|
-
end
|
|
92
|
-
data_sets
|
|
51
|
+
hint = @issue_hints&.fetch(item, nil)
|
|
52
|
+
"#{item.key} : #{item.summary} (#{label_days(y_value(item))})#{" #{hint}" if hint}"
|
|
93
53
|
end
|
|
94
54
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def trend_line_data_set label:, data:, color:
|
|
100
|
-
points = data.collect do |hash|
|
|
101
|
-
[Time.parse(hash[:x]).to_i, hash[:y]]
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# The trend calculation works with numbers only so convert Time to an int and back
|
|
105
|
-
calculator = TrendLineCalculator.new(points)
|
|
106
|
-
data_points = calculator.chart_datapoints(
|
|
107
|
-
range: time_range.begin.to_i..time_range.end.to_i,
|
|
108
|
-
max_y: @highest_y_value
|
|
109
|
-
)
|
|
110
|
-
data_points.each do |point_hash|
|
|
111
|
-
point_hash[:x] = chart_format Time.at(point_hash[:x])
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
{
|
|
115
|
-
type: 'line',
|
|
116
|
-
label: "#{label} Trendline",
|
|
117
|
-
data: data_points,
|
|
118
|
-
fill: false,
|
|
119
|
-
borderWidth: 1,
|
|
120
|
-
markerType: 'none',
|
|
121
|
-
borderColor: color,
|
|
122
|
-
borderDash: [6, 3],
|
|
123
|
-
pointStyle: 'dash',
|
|
124
|
-
hidden: !@show_trend_lines
|
|
125
|
-
}
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
def data_for_issue item
|
|
129
|
-
cycle_time = y_value(item)
|
|
130
|
-
return nil if cycle_time < 1 # These will get called out on the quality report
|
|
131
|
-
|
|
132
|
-
@highest_y_value = cycle_time if @highest_y_value < cycle_time
|
|
133
|
-
|
|
134
|
-
{
|
|
135
|
-
y: cycle_time,
|
|
136
|
-
x: chart_format(x_value(item)),
|
|
137
|
-
title: [title_value(item)]
|
|
138
|
-
}
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def calculate_percent_line items
|
|
142
|
-
times = items.collect { |item| y_value(item) }
|
|
143
|
-
index = times.size * 85 / 100
|
|
144
|
-
times.sort[index]
|
|
145
|
-
end
|
|
55
|
+
# Kept for backwards compatibility with existing callers and specs
|
|
56
|
+
alias data_for_issue data_for_item
|
|
146
57
|
end
|
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
require 'jirametrics/chart_base'
|
|
4
4
|
|
|
5
5
|
class DailyGroupingRules < GroupingRules
|
|
6
|
-
attr_accessor :current_date, :group_priority, :issue_hint
|
|
6
|
+
attr_accessor :current_date, :group_priority, :issue_hint, :highlight
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
9
9
|
super
|
|
10
10
|
@group_priority = 0
|
|
11
11
|
end
|
|
12
|
+
|
|
13
|
+
def group
|
|
14
|
+
[@label, @color, @highlight ? true : false]
|
|
15
|
+
end
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
class DailyWipChart < ChartBase
|
|
@@ -19,6 +23,8 @@ class DailyWipChart < ChartBase
|
|
|
19
23
|
|
|
20
24
|
header_text default_header_text
|
|
21
25
|
description_text default_description_text
|
|
26
|
+
@x_axis_title = nil
|
|
27
|
+
@y_axis_title = 'Count of items'
|
|
22
28
|
|
|
23
29
|
instance_eval(&block) if block
|
|
24
30
|
|
|
@@ -33,8 +39,15 @@ class DailyWipChart < ChartBase
|
|
|
33
39
|
issue_rules_by_active_date = group_issues_by_active_dates
|
|
34
40
|
possible_rules = select_possible_rules issue_rules_by_active_date
|
|
35
41
|
|
|
42
|
+
conflicting_labels = possible_rules
|
|
43
|
+
.group_by(&:label)
|
|
44
|
+
.select { |_label, rules| rules.any?(&:highlight) && rules.any? { |r| !r.highlight } }
|
|
45
|
+
.keys
|
|
46
|
+
|
|
36
47
|
data_sets = possible_rules.collect do |grouping_rule|
|
|
37
|
-
|
|
48
|
+
suffix = conflicting_labels.include?(grouping_rule.label) && grouping_rule.highlight ? '*' : ''
|
|
49
|
+
make_data_set grouping_rule: grouping_rule, issue_rules_by_active_date: issue_rules_by_active_date,
|
|
50
|
+
label_suffix: suffix
|
|
38
51
|
end
|
|
39
52
|
if @trend_lines
|
|
40
53
|
data_sets = @trend_lines.filter_map do |group_labels, line_color|
|
|
@@ -82,16 +95,16 @@ class DailyWipChart < ChartBase
|
|
|
82
95
|
hash
|
|
83
96
|
end
|
|
84
97
|
|
|
85
|
-
def make_data_set grouping_rule:, issue_rules_by_active_date:
|
|
98
|
+
def make_data_set grouping_rule:, issue_rules_by_active_date:, label_suffix: ''
|
|
86
99
|
positive = grouping_rule.group_priority >= 0
|
|
100
|
+
display_label = "#{grouping_rule.label}#{label_suffix}"
|
|
87
101
|
|
|
88
102
|
data = issue_rules_by_active_date.collect do |date, issue_rules|
|
|
89
|
-
# issues = []
|
|
90
103
|
issue_strings = issue_rules
|
|
91
104
|
.select { |_issue, rules| rules.group == grouping_rule.group }
|
|
92
105
|
.sort_by { |issue, _rules| issue.key_as_i }
|
|
93
106
|
.collect { |issue, rules| "#{issue.key} : #{issue.summary.strip} #{rules.issue_hint}" }
|
|
94
|
-
title = ["#{
|
|
107
|
+
title = ["#{display_label} (#{label_issues issue_strings.size})"] + issue_strings
|
|
95
108
|
|
|
96
109
|
{
|
|
97
110
|
x: date,
|
|
@@ -100,11 +113,18 @@ class DailyWipChart < ChartBase
|
|
|
100
113
|
}
|
|
101
114
|
end
|
|
102
115
|
|
|
116
|
+
color = grouping_rule.color || random_color
|
|
117
|
+
background_color = if grouping_rule.highlight
|
|
118
|
+
RawJavascript.new("createDiagonalPattern(#{color.to_json})")
|
|
119
|
+
else
|
|
120
|
+
color
|
|
121
|
+
end
|
|
122
|
+
|
|
103
123
|
{
|
|
104
124
|
type: 'bar',
|
|
105
|
-
label:
|
|
125
|
+
label: display_label,
|
|
106
126
|
data: data,
|
|
107
|
-
backgroundColor:
|
|
127
|
+
backgroundColor: background_color,
|
|
108
128
|
borderColor: CssVariable['--wip-chart-border-color'],
|
|
109
129
|
borderWidth: grouping_rule.color.to_s == 'var(--body-background)' ? 1 : 0,
|
|
110
130
|
borderRadius: positive ? 0 : 5
|
|
@@ -25,10 +25,25 @@ class DownloadConfig
|
|
|
25
25
|
@no_earlier_than
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
def github_repos
|
|
29
|
+
@github_repos ||= []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def github_repo *repos
|
|
33
|
+
github_repos.concat(repos.map { |r| normalize_github_repo(r) })
|
|
34
|
+
end
|
|
35
|
+
|
|
28
36
|
def start_date today:
|
|
29
37
|
date = today.to_date - @rolling_date_count if @rolling_date_count
|
|
30
38
|
date = [date, @no_earlier_than].max if date && @no_earlier_than
|
|
31
39
|
date = @no_earlier_than if date.nil? && @no_earlier_than
|
|
32
40
|
date
|
|
33
41
|
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def normalize_github_repo repo
|
|
46
|
+
match = repo.match(%r{github\.com/([^/]+/[^/]+?)/?$})
|
|
47
|
+
match ? match[1] : repo
|
|
48
|
+
end
|
|
34
49
|
end
|
|
@@ -33,21 +33,23 @@ class Downloader
|
|
|
33
33
|
# For testing only
|
|
34
34
|
attr_reader :start_date_in_query, :board_id_to_filter_id
|
|
35
35
|
|
|
36
|
-
def self.create download_config:, file_system:, jira_gateway:
|
|
36
|
+
def self.create download_config:, file_system:, jira_gateway:, github_pr_cache: {}
|
|
37
37
|
is_cloud = jira_gateway.settings['jira_cloud'] || jira_gateway.cloud?
|
|
38
38
|
(is_cloud ? DownloaderForCloud : DownloaderForDataCenter).new(
|
|
39
39
|
download_config: download_config,
|
|
40
40
|
file_system: file_system,
|
|
41
|
-
jira_gateway: jira_gateway
|
|
41
|
+
jira_gateway: jira_gateway,
|
|
42
|
+
github_pr_cache: github_pr_cache
|
|
42
43
|
)
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
def initialize download_config:, file_system:, jira_gateway:
|
|
46
|
+
def initialize download_config:, file_system:, jira_gateway:, github_pr_cache: {}
|
|
46
47
|
@metadata = {}
|
|
47
48
|
@download_config = download_config
|
|
48
49
|
@target_path = @download_config.project_config.target_path
|
|
49
50
|
@file_system = file_system
|
|
50
51
|
@jira_gateway = jira_gateway
|
|
52
|
+
@github_pr_cache = github_pr_cache
|
|
51
53
|
@board_id_to_filter_id = {}
|
|
52
54
|
|
|
53
55
|
@issue_keys_downloaded_in_current_run = []
|
|
@@ -77,6 +79,7 @@ class Downloader
|
|
|
77
79
|
download_users
|
|
78
80
|
|
|
79
81
|
save_metadata
|
|
82
|
+
download_github_prs if @download_config.github_repos.any?
|
|
80
83
|
end
|
|
81
84
|
|
|
82
85
|
def log text, both: false
|
|
@@ -165,11 +168,28 @@ class Downloader
|
|
|
165
168
|
# actually look at the returned json.
|
|
166
169
|
@board_id_to_filter_id[board_id] = json['filter']['id'].to_i
|
|
167
170
|
|
|
171
|
+
if json['type'] == 'simple'
|
|
172
|
+
features_json = download_features board_id: board_id
|
|
173
|
+
if features_json['features']&.any? { |f| f['feature'] == 'jsw.agility.sprints' && f['state'] == 'ENABLED' }
|
|
174
|
+
download_sprints board_id: board_id
|
|
175
|
+
end
|
|
176
|
+
end
|
|
168
177
|
download_sprints board_id: board_id if json['type'] == 'scrum'
|
|
169
178
|
# TODO: Should be passing actual statuses, not empty list
|
|
170
179
|
Board.new raw: json, possible_statuses: StatusCollection.new
|
|
171
180
|
end
|
|
172
181
|
|
|
182
|
+
def download_features board_id:
|
|
183
|
+
log " Downloading features for board #{board_id}", both: true
|
|
184
|
+
json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/features"
|
|
185
|
+
|
|
186
|
+
@file_system.save_json(
|
|
187
|
+
json: json,
|
|
188
|
+
filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_features.json")
|
|
189
|
+
)
|
|
190
|
+
json
|
|
191
|
+
end
|
|
192
|
+
|
|
173
193
|
def download_sprints board_id:
|
|
174
194
|
log " Downloading sprints for board #{board_id}", both: true
|
|
175
195
|
max_results = 100
|
|
@@ -211,19 +231,36 @@ class Downloader
|
|
|
211
231
|
value = Date.parse(value) if value.is_a?(String) && value =~ /^\d{4}-\d{2}-\d{2}$/
|
|
212
232
|
@metadata[key] = value
|
|
213
233
|
end
|
|
234
|
+
|
|
235
|
+
# If rolling_date_count has changed, we may be missing data outside the previous range,
|
|
236
|
+
# so force a full re-download.
|
|
237
|
+
if @metadata['rolling_date_count'] != @download_config.rolling_date_count
|
|
238
|
+
log ' rolling_date_count has changed. Forcing a full download.', both: true
|
|
239
|
+
@cached_data_format_is_current = false
|
|
240
|
+
@metadata = {}
|
|
241
|
+
end
|
|
214
242
|
end
|
|
215
243
|
|
|
216
244
|
# Even if this is the old format, we want to obey this one tag
|
|
217
245
|
@metadata['no-download'] = hash['no-download'] if hash['no-download']
|
|
218
246
|
end
|
|
219
247
|
|
|
248
|
+
def timezone_offset
|
|
249
|
+
@download_config.project_config.exporter.timezone_offset
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def today_in_project_timezone
|
|
253
|
+
Time.now.getlocal(timezone_offset).to_date
|
|
254
|
+
end
|
|
255
|
+
|
|
220
256
|
def save_metadata
|
|
221
257
|
@metadata['version'] = CURRENT_METADATA_VERSION
|
|
258
|
+
@metadata['rolling_date_count'] = @download_config.rolling_date_count
|
|
222
259
|
@metadata['date_start_from_last_query'] = @start_date_in_query if @start_date_in_query
|
|
223
260
|
|
|
224
261
|
if @download_date_range.nil?
|
|
225
262
|
log "Making up a date range in meta since one wasn't specified. You'll want to change that.", both: true
|
|
226
|
-
today =
|
|
263
|
+
today = today_in_project_timezone
|
|
227
264
|
@download_date_range = (today - 7)..today
|
|
228
265
|
end
|
|
229
266
|
|
|
@@ -258,7 +295,8 @@ class Downloader
|
|
|
258
295
|
end
|
|
259
296
|
end
|
|
260
297
|
|
|
261
|
-
def make_jql filter_id:, today:
|
|
298
|
+
def make_jql filter_id:, today: nil
|
|
299
|
+
today ||= today_in_project_timezone
|
|
262
300
|
segments = []
|
|
263
301
|
segments << "filter=#{filter_id}"
|
|
264
302
|
|
|
@@ -283,6 +321,39 @@ class Downloader
|
|
|
283
321
|
segments.join ' AND '
|
|
284
322
|
end
|
|
285
323
|
|
|
324
|
+
def download_github_prs
|
|
325
|
+
project_keys = extract_project_keys_from_downloaded_issues
|
|
326
|
+
if project_keys.empty?
|
|
327
|
+
log ' No project keys found in downloaded issues, skipping GitHub PR download', both: true
|
|
328
|
+
return
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
prs = @download_config.github_repos.flat_map do |repo|
|
|
332
|
+
GithubGateway.new(
|
|
333
|
+
repo: repo,
|
|
334
|
+
project_keys: project_keys,
|
|
335
|
+
file_system: @file_system,
|
|
336
|
+
raw_pr_cache: @github_pr_cache
|
|
337
|
+
).fetch_pull_requests(since: @download_date_range&.begin)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
@file_system.save_json(
|
|
341
|
+
json: prs.map(&:raw),
|
|
342
|
+
filename: File.join(@target_path, "#{file_prefix}_github_prs.json")
|
|
343
|
+
)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def extract_project_keys_from_downloaded_issues
|
|
347
|
+
path = File.join(@target_path, "#{file_prefix}_issues")
|
|
348
|
+
return [] unless @file_system.dir_exist?(path)
|
|
349
|
+
|
|
350
|
+
keys = []
|
|
351
|
+
@file_system.foreach(path) do |filename|
|
|
352
|
+
keys << filename.split('-').first if filename.match?(/^[A-Z][A-Z_0-9]+-\d+-\d+\.json$/)
|
|
353
|
+
end
|
|
354
|
+
keys.uniq
|
|
355
|
+
end
|
|
356
|
+
|
|
286
357
|
def file_prefix
|
|
287
358
|
@download_config.project_config.get_file_prefix
|
|
288
359
|
end
|
|
@@ -5,6 +5,45 @@ class DownloaderForCloud < Downloader
|
|
|
5
5
|
'Jira Cloud'
|
|
6
6
|
end
|
|
7
7
|
|
|
8
|
+
def run
|
|
9
|
+
super
|
|
10
|
+
download_fix_versions
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def download_board_configuration board_id:
|
|
14
|
+
board = super
|
|
15
|
+
location = board.raw['location']
|
|
16
|
+
@project_key ||= location['key'] if location&.[]('type') == 'project'
|
|
17
|
+
board
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def download_fix_versions
|
|
21
|
+
return unless @project_key
|
|
22
|
+
|
|
23
|
+
log " Downloading fix versions for project #{@project_key}", both: true
|
|
24
|
+
max_results = 50
|
|
25
|
+
start_at = 0
|
|
26
|
+
all_versions = []
|
|
27
|
+
|
|
28
|
+
loop do
|
|
29
|
+
json = @jira_gateway.call_url(
|
|
30
|
+
relative_url: "/rest/api/3/project/#{@project_key}/version?" \
|
|
31
|
+
"startAt=#{start_at}&maxResults=#{max_results}"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
values = json['values'] || []
|
|
35
|
+
all_versions.concat(values)
|
|
36
|
+
break if json['isLast'] || values.empty?
|
|
37
|
+
|
|
38
|
+
start_at += values.size
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@file_system.save_json(
|
|
42
|
+
json: all_versions,
|
|
43
|
+
filename: File.join(@target_path, "#{file_prefix}_fix_versions.json")
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
8
47
|
def search_for_issues jql:, board_id:, path:
|
|
9
48
|
log " JQL: #{jql}"
|
|
10
49
|
escaped_jql = CGI.escape jql
|
|
@@ -5,7 +5,7 @@ class EstimateAccuracyChart < ChartBase
|
|
|
5
5
|
super()
|
|
6
6
|
|
|
7
7
|
header_text 'Estimate Accuracy'
|
|
8
|
-
description_text
|
|
8
|
+
description_text <<~HTML
|
|
9
9
|
<div class="p">
|
|
10
10
|
This chart graphs estimates against actual recorded cycle times. Since
|
|
11
11
|
estimates can change over time, we're graphing the estimate at the time that the story started.
|
|
@@ -20,8 +20,18 @@ class EstimateAccuracyChart < ChartBase
|
|
|
20
20
|
far to the right then you know you have a problem.
|
|
21
21
|
<% end %>
|
|
22
22
|
</div>
|
|
23
|
+
<% if @correlation_coefficient %>
|
|
24
|
+
<div class="p">
|
|
25
|
+
The completed items here have a correlation coefficient of <b><%= @correlation_coefficient.round(3) %></b>.
|
|
26
|
+
The closer it is to +1, the stronger the positive correlation. The closer it is to -1,
|
|
27
|
+
the stronger the negative collalation. Zero would mean no correlation at all.
|
|
28
|
+
</div>
|
|
29
|
+
<% end %>
|
|
23
30
|
HTML
|
|
24
31
|
|
|
32
|
+
@x_axis_title = 'Cycletime (days)'
|
|
33
|
+
@y_axis_title = 'Count of items'
|
|
34
|
+
|
|
25
35
|
@y_axis_type = 'linear'
|
|
26
36
|
@y_axis_block = ->(issue, start_time) { estimate_at(issue: issue, start_time: start_time)&.to_f }
|
|
27
37
|
@y_axis_sort_order = nil
|
|
@@ -30,9 +40,9 @@ class EstimateAccuracyChart < ChartBase
|
|
|
30
40
|
end
|
|
31
41
|
|
|
32
42
|
def run
|
|
33
|
-
if @
|
|
43
|
+
if @y_axis_title.nil?
|
|
34
44
|
text = current_board.estimation_configuration.units == :story_points ? 'Story Points' : 'Days'
|
|
35
|
-
@
|
|
45
|
+
@y_axis_title = "Estimated #{text}"
|
|
36
46
|
end
|
|
37
47
|
data_sets = scan_issues
|
|
38
48
|
|
|
@@ -43,7 +53,7 @@ class EstimateAccuracyChart < ChartBase
|
|
|
43
53
|
|
|
44
54
|
def scan_issues
|
|
45
55
|
completed_hash, aging_hash = split_into_completed_and_aging issues: issues
|
|
46
|
-
|
|
56
|
+
@correlation_coefficient = correlation_coefficient(completed_hash) unless completed_hash.empty?
|
|
47
57
|
estimation_units = current_board.estimation_configuration.units
|
|
48
58
|
@has_aging_data = !aging_hash.empty?
|
|
49
59
|
|
|
@@ -170,4 +180,32 @@ class EstimateAccuracyChart < ChartBase
|
|
|
170
180
|
end
|
|
171
181
|
@y_axis_block = block
|
|
172
182
|
end
|
|
183
|
+
|
|
184
|
+
# Correlation coefficient is calculated using the Pearson Correlation Coefficient
|
|
185
|
+
# r = Σ((xi - x̄)(yi - ȳ)) / sqrt(Σ(xi - x̄)² · Σ(yi - ȳ)²)
|
|
186
|
+
def correlation_coefficient completed_hash
|
|
187
|
+
list1 = []
|
|
188
|
+
list2 = []
|
|
189
|
+
completed_hash.each do |(estimate, cycle_time), issues|
|
|
190
|
+
issues.size.times do
|
|
191
|
+
list1 << estimate
|
|
192
|
+
list2 << cycle_time
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
n = list1.size
|
|
197
|
+
return nil if n < 2
|
|
198
|
+
|
|
199
|
+
mean1 = list1.sum.to_f / n
|
|
200
|
+
mean2 = list2.sum.to_f / n
|
|
201
|
+
|
|
202
|
+
numerator = list1.zip(list2).sum { |x, y| (x - mean1) * (y - mean2) }
|
|
203
|
+
sum_sq1 = list1.sum { |x| (x - mean1)**2 }
|
|
204
|
+
sum_sq2 = list2.sum { |y| (y - mean2)**2 }
|
|
205
|
+
|
|
206
|
+
denominator = Math.sqrt(sum_sq1 * sum_sq2)
|
|
207
|
+
return nil if denominator.zero?
|
|
208
|
+
|
|
209
|
+
numerator / denominator
|
|
210
|
+
end
|
|
173
211
|
end
|