jirametrics 2.24 → 2.25pre7
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/aging_work_bar_chart.rb +3 -3
- data/lib/jirametrics/aging_work_table.rb +3 -4
- data/lib/jirametrics/atlassian_document_format.rb +8 -19
- data/lib/jirametrics/board_movement_calculator.rb +2 -2
- data/lib/jirametrics/chart_base.rb +6 -0
- data/lib/jirametrics/cycletime_histogram.rb +1 -1
- data/lib/jirametrics/cycletime_scatterplot.rb +2 -2
- data/lib/jirametrics/daily_view.rb +4 -3
- data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +1 -1
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +3 -1
- data/lib/jirametrics/data_quality_report.rb +4 -4
- data/lib/jirametrics/dependency_chart.rb +1 -1
- data/lib/jirametrics/examples/standard_project.rb +12 -11
- data/lib/jirametrics/expedited_chart.rb +1 -1
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +3 -1
- data/lib/jirametrics/github_gateway.rb +1 -1
- data/lib/jirametrics/grouping_rules.rb +1 -1
- data/lib/jirametrics/html/daily_wip_chart.erb +33 -1
- data/lib/jirametrics/html/index.css +3 -0
- data/lib/jirametrics/html/throughput_chart.erb +35 -1
- data/lib/jirametrics/html/time_based_histogram.erb +2 -0
- data/lib/jirametrics/html_report_config.rb +19 -15
- data/lib/jirametrics/issue.rb +18 -2
- data/lib/jirametrics/issue_printer.rb +1 -1
- data/lib/jirametrics/project_config.rb +1 -1
- 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/sprint_burndown.rb +1 -1
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +50 -22
- data/lib/jirametrics/time_based_scatterplot.rb +3 -3
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98bcc110a47cd8dfe55eafb58b699d27604f2fb8d24a7ed357e6dd970998af8d
|
|
4
|
+
data.tar.gz: 2501f1064f33999d31ce42a1dcdf29fd023fb5b60fc06f01294d064d60210fc4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0761c74f71511217ed38496578d2863df6e5c1fe38d2932fd6fa3c1fed6dd626f5bcbc8bc015ce06aa91d098c55dbff5f1944daba386e39cdb9319f66d658cee
|
|
7
|
+
data.tar.gz: b3657601410caceb3b186994785609f55d67f80094c945cd34203f68ff8dcdd494ff3d51375498071cfe0f316bf69aef38eac75e3a0e3acfb1462c8b797dc5cd
|
|
@@ -66,7 +66,7 @@ class AgingWorkBarChart < ChartBase
|
|
|
66
66
|
|
|
67
67
|
def adjust_time_date_ranges_to_start_from_earliest_issue_start aging_issues
|
|
68
68
|
earliest_start_time = aging_issues.collect do |issue|
|
|
69
|
-
issue.
|
|
69
|
+
issue.started_stopped_times.first
|
|
70
70
|
end.min
|
|
71
71
|
return if earliest_start_time.nil? || earliest_start_time >= @time_range.begin
|
|
72
72
|
|
|
@@ -102,7 +102,7 @@ class AgingWorkBarChart < ChartBase
|
|
|
102
102
|
|
|
103
103
|
def select_aging_issues issues:
|
|
104
104
|
issues.select do |issue|
|
|
105
|
-
started_time, stopped_time = issue.
|
|
105
|
+
started_time, stopped_time = issue.started_stopped_times
|
|
106
106
|
next false unless started_time && stopped_time.nil?
|
|
107
107
|
|
|
108
108
|
age = (date_range.end - started_time.to_date).to_i + 1
|
|
@@ -128,7 +128,7 @@ class AgingWorkBarChart < ChartBase
|
|
|
128
128
|
|
|
129
129
|
def collect_status_ranges issue:, now:
|
|
130
130
|
ranges = []
|
|
131
|
-
issue_started_time = issue.
|
|
131
|
+
issue_started_time = issue.started_stopped_times.first
|
|
132
132
|
previous_start = nil
|
|
133
133
|
previous_status = nil
|
|
134
134
|
issue.status_changes.each do |change|
|
|
@@ -50,15 +50,14 @@ class AgingWorkTable < ChartBase
|
|
|
50
50
|
|
|
51
51
|
def expedited_but_not_started
|
|
52
52
|
@issues.select do |issue|
|
|
53
|
-
started_time, stopped_time = issue.
|
|
53
|
+
started_time, stopped_time = issue.started_stopped_times
|
|
54
54
|
started_time.nil? && stopped_time.nil? && issue.expedited?
|
|
55
55
|
end.sort_by(&:created)
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
def select_aging_issues
|
|
59
59
|
aging_issues = @issues.select do |issue|
|
|
60
|
-
|
|
61
|
-
started, stopped = cycletime.started_stopped_times(issue)
|
|
60
|
+
started, stopped = issue.started_stopped_times
|
|
62
61
|
next false if started.nil? || stopped
|
|
63
62
|
next true if issue.blocked_on_date?(@today, end_time: time_range.end) || issue.expedited?
|
|
64
63
|
|
|
@@ -77,7 +76,7 @@ class AgingWorkTable < ChartBase
|
|
|
77
76
|
end
|
|
78
77
|
|
|
79
78
|
def blocked_text issue
|
|
80
|
-
started_time, _stopped_time = issue.
|
|
79
|
+
started_time, _stopped_time = issue.started_stopped_times
|
|
81
80
|
return nil if started_time.nil?
|
|
82
81
|
|
|
83
82
|
current = issue.blocked_stalled_changes(end_time: time_range.end)[-1]
|
|
@@ -44,9 +44,9 @@ class AtlassianDocumentFormat
|
|
|
44
44
|
when 'codeBlock' then ['<code>', '</code>']
|
|
45
45
|
when 'date'
|
|
46
46
|
[Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
|
|
47
|
-
when 'decisionItem'
|
|
47
|
+
when 'decisionItem', 'listItem' then ['<li>', '</li>']
|
|
48
48
|
when 'decisionList' then ['<div>Decisions<ul>', '</ul></div>']
|
|
49
|
-
when 'emoji'
|
|
49
|
+
when 'emoji', 'status' then [node_attrs['text'], nil]
|
|
50
50
|
when 'expand' then ["<div>#{node_attrs['title']}</div>", nil]
|
|
51
51
|
when 'hardBreak' then ['<br />', nil]
|
|
52
52
|
when 'heading'
|
|
@@ -55,7 +55,6 @@ class AtlassianDocumentFormat
|
|
|
55
55
|
when 'inlineCard'
|
|
56
56
|
url = node_attrs['url']
|
|
57
57
|
["[Inline card]: <a href='#{url}'>#{url}</a>", nil]
|
|
58
|
-
when 'listItem' then ['<li>', '</li>']
|
|
59
58
|
when 'media'
|
|
60
59
|
text = node_attrs['alt'] || node_attrs['id']
|
|
61
60
|
["Media: #{text}", nil]
|
|
@@ -65,7 +64,6 @@ class AtlassianDocumentFormat
|
|
|
65
64
|
when 'panel' then ["<div>#{node_attrs['panelType'].upcase}</div>", nil]
|
|
66
65
|
when 'paragraph' then ['<p>', '</p>']
|
|
67
66
|
when 'rule' then ['<hr />', nil]
|
|
68
|
-
when 'status' then [node_attrs['text'], nil]
|
|
69
67
|
when 'table' then ['<table>', '</table>']
|
|
70
68
|
when 'tableCell' then ['<td>', '</td>']
|
|
71
69
|
when 'tableHeader' then ['<th>', '</th>']
|
|
@@ -87,38 +85,29 @@ class AtlassianDocumentFormat
|
|
|
87
85
|
adf_node_render(node) do |n|
|
|
88
86
|
node_attrs = n['attrs']
|
|
89
87
|
case n['type']
|
|
90
|
-
when 'blockquote'
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
when 'blockquote', 'bulletList', 'codeBlock',
|
|
89
|
+
'mediaSingle', 'mediaGroup',
|
|
90
|
+
'orderedList', 'table', 'taskList' then ['', nil]
|
|
93
91
|
when 'date'
|
|
94
92
|
[Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
|
|
95
93
|
when 'decisionItem' then ['- ', "\n"]
|
|
96
94
|
when 'decisionList' then ["Decisions:\n", nil]
|
|
97
|
-
when 'emoji'
|
|
95
|
+
when 'emoji', 'mention', 'status' then [node_attrs['text'], nil]
|
|
98
96
|
when 'expand' then ["#{node_attrs['title']}\n", nil]
|
|
99
97
|
when 'hardBreak' then ["\n", nil]
|
|
100
|
-
when 'heading'
|
|
98
|
+
when 'heading', 'paragraph', 'tableRow' then ['', "\n"]
|
|
101
99
|
when 'inlineCard' then [node_attrs['url'], nil]
|
|
102
100
|
when 'listItem' then ['- ', nil]
|
|
103
101
|
when 'media'
|
|
104
102
|
text = node_attrs['alt'] || node_attrs['id']
|
|
105
103
|
["Media: #{text}", nil]
|
|
106
|
-
when 'mediaSingle', 'mediaGroup' then ['', nil]
|
|
107
|
-
when 'mention' then [node_attrs['text'], nil]
|
|
108
|
-
when 'orderedList' then ['', nil]
|
|
109
104
|
when 'panel' then ["#{node_attrs['panelType'].upcase}\n", nil]
|
|
110
|
-
when 'paragraph' then ['', "\n"]
|
|
111
105
|
when 'rule' then ["---\n", nil]
|
|
112
|
-
when '
|
|
113
|
-
when 'table' then ['', nil]
|
|
114
|
-
when 'tableCell' then ['', "\t"]
|
|
115
|
-
when 'tableHeader' then ['', "\t"]
|
|
116
|
-
when 'tableRow' then ['', "\n"]
|
|
106
|
+
when 'tableCell', 'tableHeader' then ['', "\t"]
|
|
117
107
|
when 'text' then [n['text'], nil]
|
|
118
108
|
when 'taskItem'
|
|
119
109
|
state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
|
|
120
110
|
["#{state} ", "\n"]
|
|
121
|
-
when 'taskList' then ['', nil]
|
|
122
111
|
else
|
|
123
112
|
["[Unparseable: #{n['type']}]\n", nil]
|
|
124
113
|
end
|
|
@@ -10,7 +10,7 @@ class BoardMovementCalculator
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def moves_backwards? issue
|
|
13
|
-
started, stopped = issue.
|
|
13
|
+
started, stopped = issue.started_stopped_times
|
|
14
14
|
return false unless started
|
|
15
15
|
|
|
16
16
|
previous_column = nil
|
|
@@ -70,7 +70,7 @@ class BoardMovementCalculator
|
|
|
70
70
|
@issues.filter_map do |issue|
|
|
71
71
|
this_column_start = issue.first_time_in_or_right_of_column(this_column.name)&.time
|
|
72
72
|
next_column_start = next_column.nil? ? nil : issue.first_time_in_or_right_of_column(next_column.name)&.time
|
|
73
|
-
issue_start, issue_done = issue.
|
|
73
|
+
issue_start, issue_done = issue.started_stopped_times
|
|
74
74
|
|
|
75
75
|
# Skip if we can't tell when it started.
|
|
76
76
|
next if issue_start.nil?
|
|
@@ -86,6 +86,12 @@ class ChartBase
|
|
|
86
86
|
"#{hours} hour#{'s' unless hours == 1}"
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
+
def label_minutes minutes
|
|
90
|
+
return 'unknown' if minutes.nil?
|
|
91
|
+
|
|
92
|
+
"#{minutes} minute#{'s' unless minutes == 1}"
|
|
93
|
+
end
|
|
94
|
+
|
|
89
95
|
def label_issues count
|
|
90
96
|
"#{count} issue#{'s' unless count == 1}"
|
|
91
97
|
end
|
|
@@ -32,7 +32,7 @@ class CycletimeHistogram < TimeBasedHistogram
|
|
|
32
32
|
stopped_issues = completed_issues_in_range include_unstarted: true
|
|
33
33
|
|
|
34
34
|
# For the histogram, we only want to consider items that have both a start and a stop time.
|
|
35
|
-
stopped_issues.select { |issue| issue.
|
|
35
|
+
stopped_issues.select { |issue| issue.started_stopped_times.first }
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def value_for_item issue
|
|
@@ -40,14 +40,14 @@ class CycletimeScatterplot < TimeBasedScatterplot
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def x_value item
|
|
43
|
-
item.
|
|
43
|
+
item.started_stopped_times.last
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def y_value item
|
|
47
47
|
item.board.cycletime.cycletime(item)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
def title_value item
|
|
50
|
+
def title_value item, rules: nil
|
|
51
51
|
hint = @issue_hints&.fetch(item, nil)
|
|
52
52
|
"#{item.key} : #{item.summary} (#{label_days(y_value(item))})#{" #{hint}" if hint}"
|
|
53
53
|
end
|
|
@@ -36,7 +36,7 @@ class DailyView < ChartBase
|
|
|
36
36
|
|
|
37
37
|
def select_aging_issues
|
|
38
38
|
aging_issues = issues.select do |issue|
|
|
39
|
-
started_at, stopped_at = issue.
|
|
39
|
+
started_at, stopped_at = issue.started_stopped_times
|
|
40
40
|
started_at && !stopped_at
|
|
41
41
|
end
|
|
42
42
|
|
|
@@ -73,7 +73,7 @@ class DailyView < ChartBase
|
|
|
73
73
|
|
|
74
74
|
def make_blocked_stalled_lines issue
|
|
75
75
|
today = date_range.end
|
|
76
|
-
started_date = issue.
|
|
76
|
+
started_date = issue.started_stopped_times.first&.to_date
|
|
77
77
|
return [] unless started_date
|
|
78
78
|
|
|
79
79
|
blocked_stalled = issue.blocked_stalled_by_date(
|
|
@@ -256,7 +256,8 @@ class DailyView < ChartBase
|
|
|
256
256
|
description = issue.raw['fields']['description']
|
|
257
257
|
return [] unless description
|
|
258
258
|
|
|
259
|
-
text = "<div class='foldable startFolded'>Description</div
|
|
259
|
+
text = "<div class='foldable startFolded'>Description</div>" \
|
|
260
|
+
"<div>#{atlassian_document_format.to_html(description)}</div>"
|
|
260
261
|
[[text]]
|
|
261
262
|
end
|
|
262
263
|
|
|
@@ -49,7 +49,7 @@ class DailyWipByAgeChart < DailyWipChart
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def default_grouping_rules issue:, rules:
|
|
52
|
-
started, stopped = issue.
|
|
52
|
+
started, stopped = issue.started_stopped_dates
|
|
53
53
|
|
|
54
54
|
if stopped && started.nil? # We can't tell when it started
|
|
55
55
|
@has_completed_but_not_started = true
|
|
@@ -39,7 +39,7 @@ 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
44
|
started_date = started&.to_date
|
|
45
45
|
|
|
@@ -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
|
|
@@ -104,7 +104,8 @@ class DailyWipChart < ChartBase
|
|
|
104
104
|
.select { |_issue, rules| rules.group == grouping_rule.group }
|
|
105
105
|
.sort_by { |issue, _rules| issue.key_as_i }
|
|
106
106
|
.collect { |issue, rules| "#{issue.key} : #{issue.summary.strip} #{rules.issue_hint}" }
|
|
107
|
-
|
|
107
|
+
title_label = grouping_rule.label_hint || display_label
|
|
108
|
+
title = ["#{title_label} (#{label_issues issue_strings.size})"] + issue_strings
|
|
108
109
|
|
|
109
110
|
{
|
|
110
111
|
x: date,
|
|
@@ -123,6 +124,7 @@ class DailyWipChart < ChartBase
|
|
|
123
124
|
{
|
|
124
125
|
type: 'bar',
|
|
125
126
|
label: display_label,
|
|
127
|
+
label_hint: grouping_rule.label_hint,
|
|
126
128
|
data: data,
|
|
127
129
|
backgroundColor: background_color,
|
|
128
130
|
borderColor: CssVariable['--wip-chart-border-color'],
|
|
@@ -121,7 +121,7 @@ class DataQualityReport < ChartBase
|
|
|
121
121
|
|
|
122
122
|
def initialize_entries
|
|
123
123
|
@entries = @issues.filter_map do |issue|
|
|
124
|
-
started, stopped = issue.
|
|
124
|
+
started, stopped = issue.started_stopped_times
|
|
125
125
|
next if stopped && stopped < time_range.begin
|
|
126
126
|
next if started && started > time_range.end
|
|
127
127
|
|
|
@@ -274,7 +274,7 @@ class DataQualityReport < ChartBase
|
|
|
274
274
|
|
|
275
275
|
started_subtasks = []
|
|
276
276
|
entry.issue.subtasks.each do |subtask|
|
|
277
|
-
started_subtasks << subtask if subtask.
|
|
277
|
+
started_subtasks << subtask if subtask.started_stopped_times.first
|
|
278
278
|
end
|
|
279
279
|
|
|
280
280
|
return if started_subtasks.empty?
|
|
@@ -293,7 +293,7 @@ class DataQualityReport < ChartBase
|
|
|
293
293
|
next unless settings['blocked_link_text'].include?(link.label)
|
|
294
294
|
|
|
295
295
|
this_active = !entry.stopped
|
|
296
|
-
other_active = !link.other_issue.
|
|
296
|
+
other_active = !link.other_issue.started_stopped_times.last
|
|
297
297
|
next unless this_active && !other_active
|
|
298
298
|
|
|
299
299
|
entry.report(
|
|
@@ -326,7 +326,7 @@ class DataQualityReport < ChartBase
|
|
|
326
326
|
return unless entry.stopped
|
|
327
327
|
|
|
328
328
|
subtask_labels = entry.issue.subtasks.filter_map do |subtask|
|
|
329
|
-
subtask_started, subtask_stopped = subtask.
|
|
329
|
+
subtask_started, subtask_stopped = subtask.started_stopped_times
|
|
330
330
|
|
|
331
331
|
if !subtask_started && !subtask_stopped
|
|
332
332
|
"#{subtask_label subtask} (Not even started)"
|
|
@@ -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
|
|
@@ -67,19 +67,19 @@ class Exporter
|
|
|
67
67
|
cycletime_histogram
|
|
68
68
|
|
|
69
69
|
throughput_chart do
|
|
70
|
-
description_text
|
|
70
|
+
description_text <<~TEXT
|
|
71
|
+
<div>Throughput data is very useful for#{' '}
|
|
72
|
+
<a href="https://blog.mikebowler.ca/2024/06/02/probabilistic-forecasting/">probabilistic forecasting</a>,
|
|
73
|
+
to determine when we'll be done. Try it now with the
|
|
74
|
+
<a href="<%= throughput_forecaster_url %>" target="_blank" rel="noopener noreferrer">
|
|
75
|
+
Focused Objective throughput forecaster,</a> to see how long it would take to complete all of the
|
|
76
|
+
<%= @not_started_count %> items you currently have in your backlog.
|
|
77
|
+
</div>
|
|
78
|
+
<h2>Number of items completed, grouped by issue type</h2>'
|
|
79
|
+
TEXT
|
|
71
80
|
end
|
|
72
|
-
|
|
73
|
-
header_text nil
|
|
81
|
+
throughput_by_completed_resolution_chart do
|
|
74
82
|
description_text '<h2>Number of items completed, grouped by completion status and resolution</h2>'
|
|
75
|
-
grouping_rules do |issue, rules|
|
|
76
|
-
status, resolution = issue.status_resolution_at_done
|
|
77
|
-
if resolution
|
|
78
|
-
rules.label = "#{status.name}:#{resolution}"
|
|
79
|
-
else
|
|
80
|
-
rules.label = status.name
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
aging_work_in_progress_chart
|
|
@@ -91,6 +91,7 @@ class Exporter
|
|
|
91
91
|
flow_efficiency_scatterplot if show_experimental_charts
|
|
92
92
|
sprint_burndown
|
|
93
93
|
estimate_accuracy_chart
|
|
94
|
+
expedited_chart
|
|
94
95
|
dependency_chart
|
|
95
96
|
end
|
|
96
97
|
end
|
|
@@ -50,7 +50,7 @@ class ExpeditedChart < ChartBase
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
if data_sets.empty?
|
|
53
|
-
'<h1 class="foldable">Expedited work</h1><
|
|
53
|
+
'<h1 class="foldable">Expedited work</h1><div>There is no expedited work in this time period.</div>'
|
|
54
54
|
else
|
|
55
55
|
wrap_and_render(binding, __FILE__)
|
|
56
56
|
end
|
|
@@ -62,7 +62,9 @@ class FlowEfficiencyScatterplot < ChartBase
|
|
|
62
62
|
create_dataset(issues: issues, label: rules.label, color: rules.color)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
if data_sets.empty?
|
|
66
|
+
return "<h1 class='foldable'>#{@header_text}</h1>No data matched the selected criteria. Nothing to show."
|
|
67
|
+
end
|
|
66
68
|
|
|
67
69
|
wrap_and_render(binding, __FILE__)
|
|
68
70
|
end
|
|
@@ -96,7 +96,7 @@ class GithubGateway
|
|
|
96
96
|
# This extra check seems to only matter on Windows. On the mac, auth failures don't pass status.success?
|
|
97
97
|
if stderr.include?('SAML enforcement')
|
|
98
98
|
raise "GitHub CLI is not authorized to access #{@repo}. " \
|
|
99
|
-
|
|
99
|
+
'Run: gh auth refresh -h github.com -s read:org'
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
raise "GitHub CLI command failed for #{@repo}: #{stderr}" unless status.success?
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
<canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
|
|
4
4
|
</div>
|
|
5
5
|
<script>
|
|
6
|
+
if (!Chart.Tooltip.positioners.legendItem) {
|
|
7
|
+
Chart.Tooltip.positioners.legendItem = function(items) {
|
|
8
|
+
return this.chart._legendHoverPosition || Chart.Tooltip.positioners.average.call(this, items);
|
|
9
|
+
};
|
|
10
|
+
}
|
|
6
11
|
new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
7
12
|
{
|
|
8
13
|
type: 'bar',
|
|
@@ -43,9 +48,16 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
|
43
48
|
},
|
|
44
49
|
plugins: {
|
|
45
50
|
tooltip: {
|
|
51
|
+
position: 'legendItem',
|
|
46
52
|
callbacks: {
|
|
53
|
+
title: function(contexts) {
|
|
54
|
+
if (contexts[0]?.chart._legendHoverIndex != null) return '';
|
|
55
|
+
},
|
|
47
56
|
label: function(context) {
|
|
48
|
-
|
|
57
|
+
if (context.chart._legendHoverIndex != null) {
|
|
58
|
+
return context.dataset.label_hint || '';
|
|
59
|
+
}
|
|
60
|
+
return context.dataset.data[context.dataIndex].title;
|
|
49
61
|
}
|
|
50
62
|
}
|
|
51
63
|
},
|
|
@@ -56,6 +68,26 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
|
56
68
|
}
|
|
57
69
|
},
|
|
58
70
|
legend: {
|
|
71
|
+
onHover: function(event, legendItem, legend) {
|
|
72
|
+
const chart = legend.chart;
|
|
73
|
+
const dataset = chart.data.datasets[legendItem.datasetIndex];
|
|
74
|
+
if (!dataset?.label_hint) return;
|
|
75
|
+
chart._legendHoverIndex = legendItem.datasetIndex;
|
|
76
|
+
chart._legendHoverPosition = { x: event.x, y: event.y };
|
|
77
|
+
const firstNonZero = dataset.data.findIndex(d => d?.y !== 0);
|
|
78
|
+
if (firstNonZero === -1) return;
|
|
79
|
+
chart.tooltip.setActiveElements(
|
|
80
|
+
[{ datasetIndex: legendItem.datasetIndex, index: firstNonZero }],
|
|
81
|
+
{ x: event.x, y: event.y }
|
|
82
|
+
);
|
|
83
|
+
chart.update();
|
|
84
|
+
},
|
|
85
|
+
onLeave: function(event, legendItem, legend) {
|
|
86
|
+
legend.chart._legendHoverIndex = null;
|
|
87
|
+
legend.chart._legendHoverPosition = null;
|
|
88
|
+
legend.chart.tooltip.setActiveElements([], { x: 0, y: 0 });
|
|
89
|
+
legend.chart.update();
|
|
90
|
+
},
|
|
59
91
|
labels: {
|
|
60
92
|
filter: function(item, chart) {
|
|
61
93
|
// Logic to remove a particular legend item goes here
|
|
@@ -87,6 +87,9 @@
|
|
|
87
87
|
body {
|
|
88
88
|
background-color: var(--body-background);
|
|
89
89
|
color: var(--default-text-color);
|
|
90
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
|
91
|
+
font-size: 14px;
|
|
92
|
+
line-height: 1.5;
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
dl, dd, dt {
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
<canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
|
|
4
4
|
</div>
|
|
5
5
|
<script>
|
|
6
|
+
if (!Chart.Tooltip.positioners.legendItem) {
|
|
7
|
+
Chart.Tooltip.positioners.legendItem = function(items) {
|
|
8
|
+
return this.chart._legendHoverPosition || Chart.Tooltip.positioners.average.call(this, items);
|
|
9
|
+
};
|
|
10
|
+
}
|
|
6
11
|
new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
7
12
|
type: 'scatter',
|
|
8
13
|
data: {
|
|
@@ -40,9 +45,16 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
40
45
|
},
|
|
41
46
|
plugins: {
|
|
42
47
|
tooltip: {
|
|
48
|
+
position: 'legendItem',
|
|
43
49
|
callbacks: {
|
|
50
|
+
title: function(contexts) {
|
|
51
|
+
if (contexts[0]?.chart._legendHoverIndex != null) return '';
|
|
52
|
+
},
|
|
44
53
|
label: function(context) {
|
|
45
|
-
|
|
54
|
+
if (context.chart._legendHoverIndex != null) {
|
|
55
|
+
return context.dataset.label_hint || '';
|
|
56
|
+
}
|
|
57
|
+
return context.dataset.data[context.dataIndex].title;
|
|
46
58
|
}
|
|
47
59
|
}
|
|
48
60
|
},
|
|
@@ -51,6 +63,28 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
51
63
|
<%= working_days_annotation %>
|
|
52
64
|
<%= date_annotation %>
|
|
53
65
|
}
|
|
66
|
+
},
|
|
67
|
+
legend: {
|
|
68
|
+
onHover: function(event, legendItem, legend) {
|
|
69
|
+
const chart = legend.chart;
|
|
70
|
+
const dataset = chart.data.datasets[legendItem.datasetIndex];
|
|
71
|
+
if (!dataset?.label_hint) return;
|
|
72
|
+
chart._legendHoverIndex = legendItem.datasetIndex;
|
|
73
|
+
chart._legendHoverPosition = { x: event.x, y: event.y };
|
|
74
|
+
const firstNonZero = dataset.data.findIndex(d => d?.y !== 0);
|
|
75
|
+
if (firstNonZero === -1) return;
|
|
76
|
+
chart.tooltip.setActiveElements(
|
|
77
|
+
[{ datasetIndex: legendItem.datasetIndex, index: firstNonZero }],
|
|
78
|
+
{ x: event.x, y: event.y }
|
|
79
|
+
);
|
|
80
|
+
chart.update();
|
|
81
|
+
},
|
|
82
|
+
onLeave: function(event, legendItem, legend) {
|
|
83
|
+
legend.chart._legendHoverIndex = null;
|
|
84
|
+
legend.chart._legendHoverPosition = null;
|
|
85
|
+
legend.chart.tooltip.setActiveElements([], { x: 0, y: 0 });
|
|
86
|
+
legend.chart.update();
|
|
87
|
+
}
|
|
54
88
|
}
|
|
55
89
|
}
|
|
56
90
|
}
|
|
@@ -20,21 +20,6 @@ class HtmlReportConfig < HtmlGenerator
|
|
|
20
20
|
module_eval lines.join("\n"), __FILE__, __LINE__
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
define_chart name: 'aging_work_bar_chart', classname: 'AgingWorkBarChart'
|
|
24
|
-
define_chart name: 'aging_work_table', classname: 'AgingWorkTable'
|
|
25
|
-
define_chart name: 'cycletime_scatterplot', classname: 'CycletimeScatterplot'
|
|
26
|
-
define_chart name: 'daily_wip_chart', classname: 'DailyWipChart'
|
|
27
|
-
define_chart name: 'daily_wip_by_age_chart', classname: 'DailyWipByAgeChart'
|
|
28
|
-
define_chart name: 'daily_wip_by_blocked_stalled_chart', classname: 'DailyWipByBlockedStalledChart'
|
|
29
|
-
define_chart name: 'daily_wip_by_parent_chart', classname: 'DailyWipByParentChart'
|
|
30
|
-
define_chart name: 'throughput_chart', classname: 'ThroughputChart'
|
|
31
|
-
define_chart name: 'expedited_chart', classname: 'ExpeditedChart'
|
|
32
|
-
define_chart name: 'cycletime_histogram', classname: 'CycletimeHistogram'
|
|
33
|
-
define_chart name: 'estimate_accuracy_chart', classname: 'EstimateAccuracyChart'
|
|
34
|
-
define_chart name: 'hierarchy_table', classname: 'HierarchyTable'
|
|
35
|
-
define_chart name: 'flow_efficiency_scatterplot', classname: 'FlowEfficiencyScatterplot'
|
|
36
|
-
define_chart name: 'daily_view', classname: 'DailyView'
|
|
37
|
-
|
|
38
23
|
define_chart name: 'daily_wip_by_type', classname: 'DailyWipChart',
|
|
39
24
|
deprecated_warning: 'This is the same as daily_wip_chart. Please use that one', deprecated_date: '2024-05-23'
|
|
40
25
|
define_chart name: 'story_point_accuracy_chart', classname: 'EstimateAccuracyChart',
|
|
@@ -48,6 +33,25 @@ class HtmlReportConfig < HtmlGenerator
|
|
|
48
33
|
@charts = [] # Where we store all the charts we executed so we can assert against them.
|
|
49
34
|
end
|
|
50
35
|
|
|
36
|
+
def method_missing name, &block
|
|
37
|
+
class_name = name.to_s.split('_').map(&:capitalize).join
|
|
38
|
+
klass = Object.const_get(class_name)
|
|
39
|
+
raise NameError unless klass < ChartBase
|
|
40
|
+
|
|
41
|
+
block ||= ->(_) {}
|
|
42
|
+
execute_chart klass.new(block)
|
|
43
|
+
rescue NameError
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def respond_to_missing? name, include_private = false
|
|
48
|
+
class_name = name.to_s.split('_').map(&:capitalize).join
|
|
49
|
+
klass = Object.const_get(class_name)
|
|
50
|
+
klass < ChartBase
|
|
51
|
+
rescue NameError
|
|
52
|
+
super
|
|
53
|
+
end
|
|
54
|
+
|
|
51
55
|
def cycletime label = nil, &block
|
|
52
56
|
@file_config.project_config.all_boards.each_value do |board|
|
|
53
57
|
raise 'Multiple cycletimes not supported' if board.cycletime
|
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -560,7 +560,7 @@ class Issue
|
|
|
560
560
|
# return [number of active seconds, total seconds] that this issue had up to the end_time.
|
|
561
561
|
# It does not include data before issue start or after issue end
|
|
562
562
|
def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
|
|
563
|
-
issue_start, issue_stop =
|
|
563
|
+
issue_start, issue_stop = started_stopped_times
|
|
564
564
|
return [0.0, 0.0] if !issue_start || issue_start > end_time
|
|
565
565
|
|
|
566
566
|
value_add_time = 0.0
|
|
@@ -753,12 +753,20 @@ class Issue
|
|
|
753
753
|
end
|
|
754
754
|
end
|
|
755
755
|
|
|
756
|
+
def started_stopped_times
|
|
757
|
+
board.cycletime.started_stopped_times(self)
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def started_stopped_dates
|
|
761
|
+
board.cycletime.started_stopped_dates(self)
|
|
762
|
+
end
|
|
763
|
+
|
|
756
764
|
def status_changes
|
|
757
765
|
@changes.select { |change| change.status? }
|
|
758
766
|
end
|
|
759
767
|
|
|
760
768
|
def status_resolution_at_done
|
|
761
|
-
done_time =
|
|
769
|
+
done_time = started_stopped_times.last
|
|
762
770
|
return [nil, nil] if done_time.nil?
|
|
763
771
|
|
|
764
772
|
status_change = nil
|
|
@@ -812,6 +820,14 @@ class Issue
|
|
|
812
820
|
created = parse_time(history['created'])
|
|
813
821
|
|
|
814
822
|
history['items']&.each do |item|
|
|
823
|
+
if item['field'] == 'status' && item['to'].nil?
|
|
824
|
+
board.project_config.file_system.log(
|
|
825
|
+
"Issue #{key} has a status change without a 'to' id " \
|
|
826
|
+
"(from #{item['fromString'].inspect} to #{item['toString'].inspect}). Using id 0."
|
|
827
|
+
)
|
|
828
|
+
item = item.merge('to' => '0')
|
|
829
|
+
end
|
|
830
|
+
|
|
815
831
|
@changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
|
|
816
832
|
end
|
|
817
833
|
end
|
|
@@ -21,7 +21,7 @@ class IssuePrinter
|
|
|
21
21
|
history = [] # time, type, detail
|
|
22
22
|
|
|
23
23
|
if issue.board.cycletime
|
|
24
|
-
started_at, stopped_at = issue.
|
|
24
|
+
started_at, stopped_at = issue.started_stopped_times
|
|
25
25
|
history << [started_at, nil, 'vvvv Started here vvvv', true] if started_at
|
|
26
26
|
history << [stopped_at, nil, '^^^^ Finished here ^^^^', true] if stopped_at
|
|
27
27
|
else
|
|
@@ -623,7 +623,7 @@ class ProjectConfig
|
|
|
623
623
|
cutoff_time = block.call(issue)
|
|
624
624
|
next if cutoff_time.nil?
|
|
625
625
|
|
|
626
|
-
original_start_time = issue.
|
|
626
|
+
original_start_time = issue.started_stopped_times.first
|
|
627
627
|
next if original_start_time.nil?
|
|
628
628
|
|
|
629
629
|
issue.discard_changes_before cutoff_time
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/groupable_issue_chart'
|
|
4
|
+
|
|
5
|
+
class PullRequestCycleTimeHistogram < TimeBasedHistogram
|
|
6
|
+
def initialize block
|
|
7
|
+
super()
|
|
8
|
+
|
|
9
|
+
@cycletime_unit = :days
|
|
10
|
+
@x_axis_title = 'Cycle time in days'
|
|
11
|
+
|
|
12
|
+
header_text 'PR Histogram'
|
|
13
|
+
description_text <<-HTML
|
|
14
|
+
<div class="p">
|
|
15
|
+
This cycletime Histogram shows how many pull requests completed in a certain timeframe. This can be
|
|
16
|
+
useful for determining how many different types of work are flowing through, based on the
|
|
17
|
+
lengths of time they take.
|
|
18
|
+
</div>
|
|
19
|
+
HTML
|
|
20
|
+
|
|
21
|
+
init_configuration_block(block) do
|
|
22
|
+
grouping_rules do |pull_request, _rule|
|
|
23
|
+
rules.label = pull_request.repo
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def cycletime_unit unit
|
|
29
|
+
unless %i[minutes hours days].include?(unit)
|
|
30
|
+
raise ArgumentError, "cycletime_unit must be :minutes, :hours, or :days, got #{unit.inspect}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@cycletime_unit = unit
|
|
34
|
+
@x_axis_title = "Cycle time in #{unit}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def all_items
|
|
38
|
+
result = []
|
|
39
|
+
issues.each do |issue|
|
|
40
|
+
next unless issue.github_prs
|
|
41
|
+
|
|
42
|
+
issue.github_prs.each do |pr|
|
|
43
|
+
next unless pr.closed_at
|
|
44
|
+
|
|
45
|
+
result << pr
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
result.uniq
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def value_for_item item
|
|
52
|
+
divisor = { minutes: 60.0, hours: 3600.0, days: 86_400.0 }[@cycletime_unit]
|
|
53
|
+
((item.closed_at - item.opened_at) / divisor).ceil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def label_cycletime value
|
|
57
|
+
case @cycletime_unit
|
|
58
|
+
when :minutes then label_minutes(value)
|
|
59
|
+
when :hours then label_hours(value)
|
|
60
|
+
when :days then label_days(value)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def title_for_item count:, value:
|
|
65
|
+
"#{count} PR#{'s' unless count == 1} closed in #{label_cycletime value}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def sort_items items
|
|
69
|
+
items.sort_by(&:opened_at)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def label_for_item item, hint:
|
|
73
|
+
label = "#{item.number} #{item.title}"
|
|
74
|
+
label << hint if hint
|
|
75
|
+
label
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/groupable_issue_chart'
|
|
4
|
+
|
|
5
|
+
class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
|
|
6
|
+
def initialize block
|
|
7
|
+
super()
|
|
8
|
+
|
|
9
|
+
@cycletime_unit = :days
|
|
10
|
+
@y_axis_title = 'Cycle time in days'
|
|
11
|
+
|
|
12
|
+
header_text 'Pull Request (PR) Scatterplot'
|
|
13
|
+
description_text <<-HTML
|
|
14
|
+
<div class="p">
|
|
15
|
+
This graph shows the cycle time for all closed pull requests (time from opened to closed).
|
|
16
|
+
</div>
|
|
17
|
+
#{describe_non_working_days}
|
|
18
|
+
HTML
|
|
19
|
+
|
|
20
|
+
init_configuration_block(block) do
|
|
21
|
+
grouping_rules do |pull_request, _rule|
|
|
22
|
+
rules.label = pull_request.repo
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def cycletime_unit unit
|
|
28
|
+
unless %i[minutes hours days].include?(unit)
|
|
29
|
+
raise ArgumentError, "cycletime_unit must be :minutes, :hours, or :days, got #{unit.inspect}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@cycletime_unit = unit
|
|
33
|
+
@y_axis_title = "Cycle time in #{unit}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def all_items
|
|
37
|
+
result = []
|
|
38
|
+
issues.each do |issue|
|
|
39
|
+
issue.github_prs&.each do |pr|
|
|
40
|
+
result << pr if pr.closed_at
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def x_value pull_request
|
|
47
|
+
pull_request.closed_at
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def y_value pull_request
|
|
51
|
+
divisor = { minutes: 60, hours: 3600, days: 86_400 }[@cycletime_unit]
|
|
52
|
+
((pull_request.closed_at - pull_request.opened_at) / divisor).round
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def label_cycletime value
|
|
56
|
+
case @cycletime_unit
|
|
57
|
+
when :minutes then label_minutes(value)
|
|
58
|
+
when :hours then label_hours(value)
|
|
59
|
+
when :days then label_days(value)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def title_value pull_request, rules: nil
|
|
64
|
+
age_label = label_cycletime y_value(pull_request)
|
|
65
|
+
"#{pull_request.title} | #{rules.label} | Age:#{age_label}#{lines_changed_text(pull_request)}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def lines_changed_text pull_request
|
|
69
|
+
return '' unless pull_request.changed_files
|
|
70
|
+
|
|
71
|
+
additions = pull_request.additions || 0
|
|
72
|
+
deletions = pull_request.deletions || 0
|
|
73
|
+
text = +' | Lines changed: ['
|
|
74
|
+
text << "+#{to_human_readable additions}" unless additions.zero?
|
|
75
|
+
text << ' ' if additions != 0 && deletions != 0
|
|
76
|
+
text << "-#{to_human_readable deletions}" unless deletions.zero?
|
|
77
|
+
text << "], Files changed: #{to_human_readable pull_request.changed_files}"
|
|
78
|
+
text
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
end
|
|
@@ -134,7 +134,7 @@ class SprintBurndown < ChartBase
|
|
|
134
134
|
|
|
135
135
|
estimate_display_name = current_board.estimation_configuration.display_name
|
|
136
136
|
|
|
137
|
-
issue_completed_time = issue.
|
|
137
|
+
issue_completed_time = issue.started_stopped_times.last
|
|
138
138
|
completed_has_been_tracked = false
|
|
139
139
|
|
|
140
140
|
issue.changes.each do |change|
|
data/lib/jirametrics/status.rb
CHANGED
|
@@ -36,7 +36,7 @@ class Status
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def self.from_raw raw
|
|
39
|
-
raise
|
|
39
|
+
raise 'raw cannot be nil' if raw.nil?
|
|
40
40
|
|
|
41
41
|
category_config = raw['statusCategory']
|
|
42
42
|
raise "statusCategory can't be nil in #{category_config.inspect}" if category_config.nil?
|
|
@@ -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,45 +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
|
|
18
24
|
@x_axis_title = nil
|
|
19
25
|
@y_axis_title = 'Count of items'
|
|
20
26
|
|
|
21
|
-
|
|
22
27
|
init_configuration_block(block) do
|
|
23
|
-
grouping_rules
|
|
24
|
-
rule.label = issue.type
|
|
25
|
-
rule.color = color_for type: issue.type
|
|
26
|
-
end
|
|
28
|
+
grouping_rules { |issue, rule| default_grouping_rules(issue, rule) }
|
|
27
29
|
end
|
|
28
30
|
end
|
|
29
31
|
|
|
30
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
|
+
|
|
31
36
|
completed_issues = completed_issues_in_range include_unstarted: true
|
|
32
37
|
rules_to_issues = group_issues completed_issues
|
|
33
38
|
data_sets = []
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
42
47
|
|
|
43
48
|
rules_to_issues.each_key do |rules|
|
|
44
49
|
data_sets << weekly_throughput_dataset(
|
|
45
|
-
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
|
|
46
52
|
)
|
|
47
53
|
end
|
|
48
54
|
|
|
49
55
|
wrap_and_render(binding, __FILE__)
|
|
50
56
|
end
|
|
51
57
|
|
|
58
|
+
def default_grouping_rules issue, rule
|
|
59
|
+
rule.label = issue.type
|
|
60
|
+
rule.color = color_for type: issue.type
|
|
61
|
+
end
|
|
62
|
+
|
|
52
63
|
def calculate_time_periods
|
|
53
64
|
first_day = @date_range.begin
|
|
54
65
|
first_day = case first_day.wday
|
|
@@ -68,10 +79,13 @@ class ThroughputChart < ChartBase
|
|
|
68
79
|
end
|
|
69
80
|
end
|
|
70
81
|
|
|
71
|
-
def weekly_throughput_dataset completed_issues:, label:, color:, dashed: false
|
|
82
|
+
def weekly_throughput_dataset completed_issues:, label:, color:, dashed: false, label_hint: nil
|
|
72
83
|
result = {
|
|
73
84
|
label: label,
|
|
74
|
-
|
|
85
|
+
label_hint: label_hint,
|
|
86
|
+
data: throughput_dataset(
|
|
87
|
+
periods: calculate_time_periods, completed_issues: completed_issues, label_hint: label_hint
|
|
88
|
+
),
|
|
75
89
|
fill: false,
|
|
76
90
|
showLine: true,
|
|
77
91
|
borderColor: color,
|
|
@@ -82,19 +96,33 @@ class ThroughputChart < ChartBase
|
|
|
82
96
|
result
|
|
83
97
|
end
|
|
84
98
|
|
|
85
|
-
def
|
|
99
|
+
def throughput_forecaster_url
|
|
100
|
+
params = {
|
|
101
|
+
throughputMode: 'data',
|
|
102
|
+
samplesText: @throughput_samples.join(','),
|
|
103
|
+
storyLow: @not_started_count,
|
|
104
|
+
storyHigh: @not_started_count
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
query = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join('&')
|
|
108
|
+
"https://focusedobjective.com/throughput?#{query}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def throughput_dataset periods:, completed_issues:, label_hint: nil
|
|
86
112
|
periods.collect do |period|
|
|
87
113
|
closed_issues = completed_issues.filter_map do |issue|
|
|
88
|
-
stop_date = issue.
|
|
114
|
+
stop_date = issue.started_stopped_dates.last
|
|
89
115
|
[stop_date, issue] if stop_date && period.include?(stop_date)
|
|
90
116
|
end
|
|
91
117
|
|
|
92
118
|
date_label = "on #{period.end}"
|
|
93
119
|
date_label = "between #{period.begin} and #{period.end}" unless period.begin == period.end
|
|
94
120
|
|
|
95
|
-
{
|
|
121
|
+
with_label_hint = label_hint ? " with #{label_hint}" : ''
|
|
122
|
+
{
|
|
123
|
+
y: closed_issues.size,
|
|
96
124
|
x: "#{period.end}T23:59:59",
|
|
97
|
-
title: ["#{closed_issues.size} items
|
|
125
|
+
title: ["#{closed_issues.size} items closed#{with_label_hint} #{date_label}"] +
|
|
98
126
|
closed_issues.collect do |_stop_date, issue|
|
|
99
127
|
hint = @issue_hints&.fetch(issue, nil)
|
|
100
128
|
"#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
|
|
@@ -30,7 +30,7 @@ class TimeBasedScatterplot < ChartBase
|
|
|
30
30
|
label = rules.label
|
|
31
31
|
color = rules.color
|
|
32
32
|
percent_line = calculate_percent_line items_by_type
|
|
33
|
-
data = items_by_type.filter_map { |item| data_for_item(item) }
|
|
33
|
+
data = items_by_type.filter_map { |item| data_for_item(item, rules: rules) }
|
|
34
34
|
data_sets << {
|
|
35
35
|
label: "#{label} (85% at #{label_days(percent_line)})",
|
|
36
36
|
data: data,
|
|
@@ -79,7 +79,7 @@ class TimeBasedScatterplot < ChartBase
|
|
|
79
79
|
}
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
def data_for_item item
|
|
82
|
+
def data_for_item item, rules: nil
|
|
83
83
|
y = y_value(item)
|
|
84
84
|
return nil if y < 1 # These will get called out on the quality report
|
|
85
85
|
|
|
@@ -88,7 +88,7 @@ class TimeBasedScatterplot < ChartBase
|
|
|
88
88
|
{
|
|
89
89
|
y: y,
|
|
90
90
|
x: chart_format(x_value(item)),
|
|
91
|
-
title: [title_value(item)]
|
|
91
|
+
title: [title_value(item, rules: rules)]
|
|
92
92
|
}
|
|
93
93
|
end
|
|
94
94
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jirametrics
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.25pre7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Bowler
|
|
@@ -130,6 +130,8 @@ files:
|
|
|
130
130
|
- lib/jirametrics/jira_gateway.rb
|
|
131
131
|
- lib/jirametrics/project_config.rb
|
|
132
132
|
- lib/jirametrics/pull_request.rb
|
|
133
|
+
- lib/jirametrics/pull_request_cycle_time_histogram.rb
|
|
134
|
+
- lib/jirametrics/pull_request_cycle_time_scatterplot.rb
|
|
133
135
|
- lib/jirametrics/pull_request_review.rb
|
|
134
136
|
- lib/jirametrics/raw_javascript.rb
|
|
135
137
|
- lib/jirametrics/rules.rb
|
|
@@ -141,6 +143,7 @@ files:
|
|
|
141
143
|
- lib/jirametrics/status.rb
|
|
142
144
|
- lib/jirametrics/status_collection.rb
|
|
143
145
|
- lib/jirametrics/stitcher.rb
|
|
146
|
+
- lib/jirametrics/throughput_by_completed_resolution_chart.rb
|
|
144
147
|
- lib/jirametrics/throughput_chart.rb
|
|
145
148
|
- lib/jirametrics/time_based_histogram.rb
|
|
146
149
|
- lib/jirametrics/time_based_scatterplot.rb
|