jirametrics 2.24 → 2.25pre3
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.
Potentially problematic release.
This version of jirametrics might be problematic. Click here for more details.
- 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/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 +2 -2
- 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 +10 -1
- data/lib/jirametrics/grouping_rules.rb +1 -1
- data/lib/jirametrics/html/daily_wip_chart.erb +33 -1
- data/lib/jirametrics/html_report_config.rb +19 -15
- data/lib/jirametrics/issue.rb +11 -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/throughput_chart.rb +34 -11
- data/lib/jirametrics/time_based_scatterplot.rb +3 -3
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 991cf846f49ecb45b1a6cb1a3ce72b3e7c3a3ed528c13ba350ea2d5df6a61da8
|
|
4
|
+
data.tar.gz: bb73e19712bf24aa768061df48ce7d5eae4b15fa7a319705ff005ca4c722fcea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1d311c4c3ad63a419d46b084b1f2b4c854f14346a9efefee09db758563fef949d267e54638743dbeb20161d6f75befbaf07f578ff23da3686710421b464a60d9
|
|
7
|
+
data.tar.gz: 703484af7bcbf9c14af8e864230399d78d283dcab9db4258ad3ed97055797882b53c0f94b2b246536b78267fc386180a5507f6c18961583bd967e448c24c90eb
|
|
@@ -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]
|
|
@@ -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(
|
|
@@ -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,7 +67,16 @@ 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
81
|
throughput_chart do
|
|
73
82
|
header_text nil
|
|
@@ -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
|
|
@@ -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
|
@@ -202,6 +202,7 @@ class Issue
|
|
|
202
202
|
category_ids = find_status_category_ids_by_names category_names
|
|
203
203
|
|
|
204
204
|
status_changes.each do |change|
|
|
205
|
+
puts "Debugging #{self.key} #{change.inspect}" if change.value_id.nil?
|
|
205
206
|
to_status = find_or_create_status(id: change.value_id, name: change.value)
|
|
206
207
|
id = to_status.category.id
|
|
207
208
|
return change if category_ids.include? id
|
|
@@ -560,7 +561,7 @@ class Issue
|
|
|
560
561
|
# return [number of active seconds, total seconds] that this issue had up to the end_time.
|
|
561
562
|
# It does not include data before issue start or after issue end
|
|
562
563
|
def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
|
|
563
|
-
issue_start, issue_stop =
|
|
564
|
+
issue_start, issue_stop = started_stopped_times
|
|
564
565
|
return [0.0, 0.0] if !issue_start || issue_start > end_time
|
|
565
566
|
|
|
566
567
|
value_add_time = 0.0
|
|
@@ -753,12 +754,20 @@ class Issue
|
|
|
753
754
|
end
|
|
754
755
|
end
|
|
755
756
|
|
|
757
|
+
def started_stopped_times
|
|
758
|
+
board.cycletime.started_stopped_times(self)
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def started_stopped_dates
|
|
762
|
+
board.cycletime.started_stopped_dates(self)
|
|
763
|
+
end
|
|
764
|
+
|
|
756
765
|
def status_changes
|
|
757
766
|
@changes.select { |change| change.status? }
|
|
758
767
|
end
|
|
759
768
|
|
|
760
769
|
def status_resolution_at_done
|
|
761
|
-
done_time =
|
|
770
|
+
done_time = started_stopped_times.last
|
|
762
771
|
return [nil, nil] if done_time.nil?
|
|
763
772
|
|
|
764
773
|
status_change = nil
|
|
@@ -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 == 0
|
|
75
|
+
text << ' ' if additions != 0 && deletions != 0
|
|
76
|
+
text << "-#{to_human_readable deletions}" unless deletions == 0
|
|
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|
|
|
@@ -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,15 +12,18 @@ 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
28
|
grouping_rules do |issue, rule|
|
|
24
29
|
rule.label = issue.type
|
|
@@ -28,16 +33,21 @@ class ThroughputChart < ChartBase
|
|
|
28
33
|
end
|
|
29
34
|
|
|
30
35
|
def run
|
|
36
|
+
# This is saved as an instance variable so that it's accessible later when rendering the description text
|
|
37
|
+
@not_started_count = issues.count { |issue| issue.started_stopped_times.first.nil? }
|
|
38
|
+
|
|
31
39
|
completed_issues = completed_issues_in_range include_unstarted: true
|
|
32
40
|
rules_to_issues = group_issues completed_issues
|
|
33
41
|
data_sets = []
|
|
42
|
+
total_data_set = weekly_throughput_dataset(
|
|
43
|
+
completed_issues: completed_issues,
|
|
44
|
+
label: 'Totals',
|
|
45
|
+
color: CssVariable['--throughput_chart_total_line_color'],
|
|
46
|
+
dashed: true
|
|
47
|
+
)
|
|
48
|
+
@throughput_samples = total_data_set[:data].collect { |d| d[:y] }
|
|
34
49
|
if rules_to_issues.size > 1
|
|
35
|
-
data_sets <<
|
|
36
|
-
completed_issues: completed_issues,
|
|
37
|
-
label: 'Totals',
|
|
38
|
-
color: CssVariable['--throughput_chart_total_line_color'],
|
|
39
|
-
dashed: true
|
|
40
|
-
)
|
|
50
|
+
data_sets << total_data_set
|
|
41
51
|
end
|
|
42
52
|
|
|
43
53
|
rules_to_issues.each_key do |rules|
|
|
@@ -82,17 +92,30 @@ class ThroughputChart < ChartBase
|
|
|
82
92
|
result
|
|
83
93
|
end
|
|
84
94
|
|
|
95
|
+
def throughput_forecaster_url
|
|
96
|
+
params = {
|
|
97
|
+
throughputMode: 'data',
|
|
98
|
+
samplesText: @throughput_samples.join(','),
|
|
99
|
+
storyLow: @not_started_count,
|
|
100
|
+
storyHigh: @not_started_count
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
query = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join('&')
|
|
104
|
+
"https://focusedobjective.com/throughput?#{query}"
|
|
105
|
+
end
|
|
106
|
+
|
|
85
107
|
def throughput_dataset periods:, completed_issues:
|
|
86
108
|
periods.collect do |period|
|
|
87
109
|
closed_issues = completed_issues.filter_map do |issue|
|
|
88
|
-
stop_date = issue.
|
|
110
|
+
stop_date = issue.started_stopped_dates.last
|
|
89
111
|
[stop_date, issue] if stop_date && period.include?(stop_date)
|
|
90
112
|
end
|
|
91
113
|
|
|
92
114
|
date_label = "on #{period.end}"
|
|
93
115
|
date_label = "between #{period.begin} and #{period.end}" unless period.begin == period.end
|
|
94
116
|
|
|
95
|
-
{
|
|
117
|
+
{
|
|
118
|
+
y: closed_issues.size,
|
|
96
119
|
x: "#{period.end}T23:59:59",
|
|
97
120
|
title: ["#{closed_issues.size} items completed #{date_label}"] +
|
|
98
121
|
closed_issues.collect do |_stop_date, issue|
|
|
@@ -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.25pre3
|
|
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
|