jirametrics 2.20.1 → 2.25
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 +189 -133
- data/lib/jirametrics/aging_work_table.rb +4 -5
- data/lib/jirametrics/anonymizer.rb +74 -1
- data/lib/jirametrics/atlassian_document_format.rb +93 -93
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +24 -8
- data/lib/jirametrics/board_config.rb +2 -1
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +2 -2
- data/lib/jirametrics/cfd_data_builder.rb +103 -0
- data/lib/jirametrics/change_item.rb +13 -5
- data/lib/jirametrics/chart_base.rb +124 -1
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cumulative_flow_diagram.rb +200 -0
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +4 -6
- data/lib/jirametrics/cycletime_histogram.rb +15 -103
- data/lib/jirametrics/cycletime_scatterplot.rb +15 -85
- data/lib/jirametrics/daily_view.rb +35 -11
- data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +30 -8
- data/lib/jirametrics/data_quality_report.rb +37 -11
- data/lib/jirametrics/dependency_chart.rb +1 -1
- 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/aggregated_project.rb +1 -1
- data/lib/jirametrics/examples/standard_project.rb +28 -18
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +7 -3
- data/lib/jirametrics/file_system.rb +4 -0
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
- data/lib/jirametrics/github_gateway.rb +106 -0
- data/lib/jirametrics/groupable_issue_chart.rb +9 -1
- data/lib/jirametrics/grouping_rules.rb +21 -3
- data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +2 -0
- data/lib/jirametrics/html/aging_work_table.erb +5 -0
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +504 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +6 -14
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
- data/lib/jirametrics/html/index.css +134 -0
- data/lib/jirametrics/html/index.erb +6 -1
- data/lib/jirametrics/html/index.js +76 -2
- data/lib/jirametrics/html/sprint_burndown.erb +12 -12
- data/lib/jirametrics/html/throughput_chart.erb +42 -11
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +8 -9
- data/lib/jirametrics/html_generator.rb +31 -0
- data/lib/jirametrics/html_report_config.rb +26 -39
- data/lib/jirametrics/issue.rb +186 -88
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +6 -3
- data/lib/jirametrics/project_config.rb +78 -8
- 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 +81 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +17 -0
- data/lib/jirametrics/settings.json +3 -1
- data/lib/jirametrics/sprint.rb +12 -0
- data/lib/jirametrics/sprint_burndown.rb +9 -3
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/stitcher.rb +76 -0
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +56 -22
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +100 -0
- data/lib/jirametrics.rb +8 -1
- metadata +22 -5
|
@@ -39,23 +39,32 @@ class DailyWipByBlockedStalledChart < DailyWipChart
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def default_grouping_rules issue:, rules:
|
|
42
|
-
started, stopped = issue.
|
|
42
|
+
started, stopped = issue.started_stopped_times
|
|
43
43
|
stopped_date = stopped&.to_date
|
|
44
|
+
started_date = started&.to_date
|
|
44
45
|
|
|
45
46
|
date = rules.current_date
|
|
46
47
|
change = issue.blocked_stalled_by_date(date_range: date..date, chart_end_time: time_range.end)[date]
|
|
47
|
-
|
|
48
48
|
stopped_today = stopped_date == rules.current_date
|
|
49
49
|
|
|
50
|
+
days = nil
|
|
51
|
+
if started_date && stopped_date
|
|
52
|
+
days = (stopped_date - started_date).to_i + 1 # cycletime
|
|
53
|
+
elsif started_date
|
|
54
|
+
days = (time_range.end.to_date - started_date).to_i + 1 # age
|
|
55
|
+
end
|
|
56
|
+
|
|
50
57
|
if stopped_today && started.nil?
|
|
51
58
|
@has_completed_but_not_started = true
|
|
52
59
|
rules.label = 'Completed but not started'
|
|
53
60
|
rules.color = '--wip-chart-completed-but-not-started-color'
|
|
54
61
|
rules.group_priority = -1
|
|
62
|
+
rules.issue_hint = '(Cycle time: Unknown)'
|
|
55
63
|
elsif stopped_today
|
|
56
64
|
rules.label = 'Completed'
|
|
57
65
|
rules.color = '--wip-chart-completed-color'
|
|
58
66
|
rules.group_priority = -2
|
|
67
|
+
rules.issue_hint = "(Cycle time: #{label_days days})"
|
|
59
68
|
elsif started.nil?
|
|
60
69
|
rules.label = 'Start date unknown'
|
|
61
70
|
rules.color = '--body-background'
|
|
@@ -64,16 +73,17 @@ class DailyWipByBlockedStalledChart < DailyWipChart
|
|
|
64
73
|
rules.label = 'Blocked'
|
|
65
74
|
rules.color = '--blocked-color'
|
|
66
75
|
rules.group_priority = 1
|
|
67
|
-
rules.issue_hint = "(#{change.reasons})"
|
|
76
|
+
rules.issue_hint = "(Age: #{label_days days}, #{change.reasons})"
|
|
68
77
|
elsif change&.stalled?
|
|
69
78
|
rules.label = 'Stalled'
|
|
70
79
|
rules.color = '--stalled-color'
|
|
71
80
|
rules.group_priority = 2
|
|
72
|
-
rules.issue_hint = "(#{change.reasons})"
|
|
81
|
+
rules.issue_hint = "(Age: #{label_days days}, #{change.reasons})"
|
|
73
82
|
else
|
|
74
83
|
rules.label = 'Active'
|
|
75
84
|
rules.color = '--wip-chart-active-color'
|
|
76
85
|
rules.group_priority = 3
|
|
86
|
+
rules.issue_hint = "(Age: #{label_days days})"
|
|
77
87
|
end
|
|
78
88
|
end
|
|
79
89
|
end
|
|
@@ -26,11 +26,13 @@ class DailyWipByParentChart < DailyWipChart
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def default_grouping_rules issue:, rules:
|
|
29
|
-
parent = issue.parent
|
|
29
|
+
parent = issue.parent
|
|
30
30
|
if parent
|
|
31
|
-
rules.label = parent
|
|
31
|
+
rules.label = parent.key
|
|
32
|
+
rules.label_hint = "#{parent.key} : #{parent.summary}"
|
|
32
33
|
else
|
|
33
34
|
rules.label = 'No parent'
|
|
35
|
+
rules.label_hint = 'No parent'
|
|
34
36
|
rules.group_priority = 1000
|
|
35
37
|
rules.color = '--body-background'
|
|
36
38
|
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|
|
|
@@ -66,7 +79,7 @@ class DailyWipChart < ChartBase
|
|
|
66
79
|
hash = {}
|
|
67
80
|
|
|
68
81
|
@issues.each do |issue|
|
|
69
|
-
start, stop = issue.
|
|
82
|
+
start, stop = cycletime_for_issue(issue).started_stopped_dates(issue)
|
|
70
83
|
next if start.nil? && stop.nil?
|
|
71
84
|
|
|
72
85
|
# If it stopped but never started then assume it started at creation so the data points
|
|
@@ -82,16 +95,17 @@ 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
|
-
|
|
107
|
+
title_label = grouping_rule.label_hint || display_label
|
|
108
|
+
title = ["#{title_label} (#{label_issues issue_strings.size})"] + issue_strings
|
|
95
109
|
|
|
96
110
|
{
|
|
97
111
|
x: date,
|
|
@@ -100,11 +114,19 @@ class DailyWipChart < ChartBase
|
|
|
100
114
|
}
|
|
101
115
|
end
|
|
102
116
|
|
|
117
|
+
color = grouping_rule.color || random_color
|
|
118
|
+
background_color = if grouping_rule.highlight
|
|
119
|
+
RawJavascript.new("createDiagonalPattern(#{color.to_json})")
|
|
120
|
+
else
|
|
121
|
+
color
|
|
122
|
+
end
|
|
123
|
+
|
|
103
124
|
{
|
|
104
125
|
type: 'bar',
|
|
105
|
-
label:
|
|
126
|
+
label: display_label,
|
|
127
|
+
label_hint: grouping_rule.label_hint,
|
|
106
128
|
data: data,
|
|
107
|
-
backgroundColor:
|
|
129
|
+
backgroundColor: background_color,
|
|
108
130
|
borderColor: CssVariable['--wip-chart-border-color'],
|
|
109
131
|
borderWidth: grouping_rule.color.to_s == 'var(--body-background)' ? 1 : 0,
|
|
110
132
|
borderRadius: positive ? 0 : 5
|
|
@@ -45,6 +45,7 @@ class DataQualityReport < ChartBase
|
|
|
45
45
|
scan_for_completed_issues_without_a_start_time entry: entry
|
|
46
46
|
scan_for_status_change_after_done entry: entry
|
|
47
47
|
scan_for_backwards_movement entry: entry, backlog_statuses: backlog_statuses
|
|
48
|
+
scan_for_issue_not_in_active_sprint entry: entry
|
|
48
49
|
scan_for_issues_not_created_in_a_backlog_status entry: entry, backlog_statuses: backlog_statuses
|
|
49
50
|
scan_for_stopped_before_started entry: entry
|
|
50
51
|
scan_for_issues_not_started_with_subtasks_that_have entry: entry
|
|
@@ -68,7 +69,7 @@ class DataQualityReport < ChartBase
|
|
|
68
69
|
result << render_problem_type(:status_changes_after_done)
|
|
69
70
|
result << render_problem_type(:backwards_through_status_categories)
|
|
70
71
|
result << render_problem_type(:backwords_through_statuses)
|
|
71
|
-
result << render_problem_type(:
|
|
72
|
+
result << render_problem_type(:issue_not_visible_on_board)
|
|
72
73
|
result << render_problem_type(:created_in_wrong_status)
|
|
73
74
|
result << render_problem_type(:stopped_before_started)
|
|
74
75
|
result << render_problem_type(:issue_not_started_but_subtasks_have)
|
|
@@ -120,7 +121,7 @@ class DataQualityReport < ChartBase
|
|
|
120
121
|
|
|
121
122
|
def initialize_entries
|
|
122
123
|
@entries = @issues.filter_map do |issue|
|
|
123
|
-
started, stopped = issue.
|
|
124
|
+
started, stopped = issue.started_stopped_times
|
|
124
125
|
next if stopped && stopped < time_range.begin
|
|
125
126
|
next if started && started > time_range.end
|
|
126
127
|
|
|
@@ -194,7 +195,7 @@ class DataQualityReport < ChartBase
|
|
|
194
195
|
# If it's been moved back to backlog then it's on a different report. Ignore it here.
|
|
195
196
|
detail = nil if backlog_statuses.any? { |s| s.name == change.value }
|
|
196
197
|
|
|
197
|
-
entry.report(problem_key: :
|
|
198
|
+
entry.report(problem_key: :issue_not_visible_on_board, detail: detail) unless detail.nil?
|
|
198
199
|
elsif change.old_value.nil?
|
|
199
200
|
# Do nothing
|
|
200
201
|
elsif index < last_index
|
|
@@ -223,6 +224,29 @@ class DataQualityReport < ChartBase
|
|
|
223
224
|
end
|
|
224
225
|
end
|
|
225
226
|
|
|
227
|
+
def scan_for_issue_not_in_active_sprint entry:
|
|
228
|
+
issue = entry.issue
|
|
229
|
+
return unless issue.board.scrum?
|
|
230
|
+
return if issue.sprints.any?(&:active?)
|
|
231
|
+
|
|
232
|
+
entry.report(problem_key: :issue_not_visible_on_board, detail: 'Issue is not in an active sprint')
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def scan_for_issue_never_visible_on_board entry:
|
|
236
|
+
issue = entry.issue
|
|
237
|
+
ever_visible = issue.changes.any? do |change|
|
|
238
|
+
next unless change.status?
|
|
239
|
+
|
|
240
|
+
issue.board.visible_columns.any? { |col| col.status_ids.include?(change.value_id) }
|
|
241
|
+
end
|
|
242
|
+
return if ever_visible
|
|
243
|
+
|
|
244
|
+
entry.report(
|
|
245
|
+
problem_key: :issue_not_visible_on_board,
|
|
246
|
+
detail: 'Issue has never been in a status mapped to a visible column on the board'
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
|
|
226
250
|
def scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
|
|
227
251
|
creation_change = entry.issue.changes.find { |issue| issue.status? }
|
|
228
252
|
|
|
@@ -250,7 +274,7 @@ class DataQualityReport < ChartBase
|
|
|
250
274
|
|
|
251
275
|
started_subtasks = []
|
|
252
276
|
entry.issue.subtasks.each do |subtask|
|
|
253
|
-
started_subtasks << subtask if subtask.
|
|
277
|
+
started_subtasks << subtask if subtask.started_stopped_times.first
|
|
254
278
|
end
|
|
255
279
|
|
|
256
280
|
return if started_subtasks.empty?
|
|
@@ -266,8 +290,10 @@ class DataQualityReport < ChartBase
|
|
|
266
290
|
|
|
267
291
|
def scan_for_items_blocked_on_closed_tickets entry:
|
|
268
292
|
entry.issue.issue_links.each do |link|
|
|
293
|
+
next unless settings['blocked_link_text'].include?(link.label)
|
|
294
|
+
|
|
269
295
|
this_active = !entry.stopped
|
|
270
|
-
other_active = !link.other_issue.
|
|
296
|
+
other_active = !link.other_issue.started_stopped_times.last
|
|
271
297
|
next unless this_active && !other_active
|
|
272
298
|
|
|
273
299
|
entry.report(
|
|
@@ -293,14 +319,14 @@ class DataQualityReport < ChartBase
|
|
|
293
319
|
return "#{delta} hours" if delta < 24
|
|
294
320
|
|
|
295
321
|
delta /= 24
|
|
296
|
-
|
|
322
|
+
label_days delta
|
|
297
323
|
end
|
|
298
324
|
|
|
299
325
|
def scan_for_incomplete_subtasks_when_issue_done entry:
|
|
300
326
|
return unless entry.stopped
|
|
301
327
|
|
|
302
328
|
subtask_labels = entry.issue.subtasks.filter_map do |subtask|
|
|
303
|
-
subtask_started, subtask_stopped = subtask.
|
|
329
|
+
subtask_started, subtask_stopped = subtask.started_stopped_times
|
|
304
330
|
|
|
305
331
|
if !subtask_started && !subtask_stopped
|
|
306
332
|
"#{subtask_label subtask} (Not even started)"
|
|
@@ -407,12 +433,12 @@ class DataQualityReport < ChartBase
|
|
|
407
433
|
HTML
|
|
408
434
|
end
|
|
409
435
|
|
|
410
|
-
def
|
|
436
|
+
def render_issue_not_visible_on_board problems
|
|
411
437
|
<<-HTML
|
|
412
438
|
#{label_issues problems.size} were not visible on the board for some period of time. This may impact
|
|
413
|
-
timings as the work was likely to have been forgotten if it wasn't visible.
|
|
414
|
-
|
|
415
|
-
|
|
439
|
+
timings as the work was likely to have been forgotten if it wasn't visible. An issue can be not visible
|
|
440
|
+
for two reasons: the issue was in a status that is not mapped to any visible column on the board
|
|
441
|
+
(look in "unmapped statuses" on your board), or for scrum boards, the issue was not in an active sprint.
|
|
416
442
|
HTML
|
|
417
443
|
end
|
|
418
444
|
|
|
@@ -231,7 +231,7 @@ class DependencyChart < ChartBase
|
|
|
231
231
|
elsif is_done
|
|
232
232
|
line2 << 'Done'
|
|
233
233
|
else
|
|
234
|
-
started_at = issue.
|
|
234
|
+
started_at = issue.started_stopped_times.first
|
|
235
235
|
if started_at.nil?
|
|
236
236
|
line2 << 'Not started'
|
|
237
237
|
else
|
|
@@ -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 BoardFeature.from_raw(features_json).any? { |f| f.name == 'jsw.agility.sprints' && f.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 = 'Estimate'
|
|
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
|