jirametrics 2.31 → 3.0
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 +42 -29
- data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
- data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
- data/lib/jirametrics/aging_work_table.rb +36 -33
- data/lib/jirametrics/anonymizer.rb +31 -86
- data/lib/jirametrics/atlassian_document_format.rb +11 -4
- data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -1
- data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
- data/lib/jirametrics/board.rb +23 -9
- data/lib/jirametrics/board_config.rb +0 -7
- data/lib/jirametrics/board_movement_calculator.rb +24 -21
- data/lib/jirametrics/cfd_data_builder.rb +46 -44
- data/lib/jirametrics/change_item.rb +19 -7
- data/lib/jirametrics/chart_base.rb +50 -64
- data/lib/jirametrics/cumulative_flow_diagram.rb +59 -46
- data/lib/jirametrics/cycle_time_config.rb +35 -46
- data/lib/jirametrics/cycletime_histogram.rb +7 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
- data/lib/jirametrics/daily_view.rb +83 -61
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
- data/lib/jirametrics/daily_wip_chart.rb +72 -45
- data/lib/jirametrics/data_quality_report.rb +61 -63
- data/lib/jirametrics/dependency_chart.rb +40 -31
- data/lib/jirametrics/download_config.rb +0 -3
- data/lib/jirametrics/downloader.rb +8 -7
- data/lib/jirametrics/downloader_for_cloud.rb +108 -72
- data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
- data/lib/jirametrics/examples/standard_project.rb +2 -2
- data/lib/jirametrics/expedited_chart.rb +47 -37
- data/lib/jirametrics/exporter.rb +19 -10
- data/lib/jirametrics/file_config.rb +19 -12
- data/lib/jirametrics/file_system.rb +16 -10
- data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
- data/lib/jirametrics/github_gateway.rb +23 -9
- data/lib/jirametrics/groupable_issue_chart.rb +3 -0
- data/lib/jirametrics/html/index.css +6 -0
- data/lib/jirametrics/html_report_config.rb +9 -29
- data/lib/jirametrics/issue.rb +290 -389
- data/lib/jirametrics/issue_collection.rb +1 -0
- data/lib/jirametrics/issue_printer.rb +60 -23
- data/lib/jirametrics/jira_gateway.rb +29 -18
- data/lib/jirametrics/mcp_server.rb +225 -209
- data/lib/jirametrics/project_config.rb +158 -134
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
- data/lib/jirametrics/rules.rb +1 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
- data/lib/jirametrics/sprint_burndown.rb +142 -242
- data/lib/jirametrics/sprint_count_measure.rb +42 -0
- data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
- data/lib/jirametrics/sprint_points_measure.rb +62 -0
- data/lib/jirametrics/sprint_summary_stats.rb +16 -0
- data/lib/jirametrics/status_collection.rb +2 -2
- data/lib/jirametrics/stitcher.rb +21 -16
- data/lib/jirametrics/throughput_chart.rb +38 -24
- data/lib/jirametrics/time_based_chart.rb +65 -0
- data/lib/jirametrics/time_based_histogram.rb +30 -23
- data/lib/jirametrics/time_based_scatterplot.rb +11 -2
- data/lib/jirametrics/trend_line_calculator.rb +2 -2
- data/lib/jirametrics/wip_by_column_chart.rb +73 -32
- data/lib/jirametrics.rb +6 -2
- metadata +13 -20
|
@@ -7,8 +7,10 @@ class ThroughputChart < ChartBase
|
|
|
7
7
|
|
|
8
8
|
attr_accessor :possible_statuses
|
|
9
9
|
|
|
10
|
-
def initialize block
|
|
10
|
+
def initialize block, throughput_samples: nil, not_started_count: nil
|
|
11
11
|
super()
|
|
12
|
+
@throughput_samples = throughput_samples
|
|
13
|
+
@not_started_count = not_started_count
|
|
12
14
|
|
|
13
15
|
header_text 'Throughput Chart'
|
|
14
16
|
description_text <<-TEXT
|
|
@@ -120,30 +122,42 @@ class ThroughputChart < ChartBase
|
|
|
120
122
|
def throughput_dataset periods:, completed_issues:, label_hint: nil
|
|
121
123
|
custom_mode = @issue_periods&.values&.any?
|
|
122
124
|
periods.collect do |period|
|
|
123
|
-
closed_issues = completed_issues
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
end
|
|
125
|
+
closed_issues = closed_issues_in_period(period, completed_issues, custom_mode)
|
|
126
|
+
period_dataset(period: period, closed_issues: closed_issues, label_hint: label_hint)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def closed_issues_in_period period, completed_issues, custom_mode
|
|
131
|
+
completed_issues.filter_map do |issue|
|
|
132
|
+
stop_date = issue.started_stopped_dates.last
|
|
133
|
+
next unless stop_date
|
|
133
134
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
y: closed_issues.size,
|
|
140
|
-
x: "#{period.end}T23:59:59",
|
|
141
|
-
title: ["#{closed_issues.size} items closed#{with_label_hint} #{date_label}"] +
|
|
142
|
-
closed_issues.collect do |_stop_date, issue|
|
|
143
|
-
hint = @issue_hints&.fetch(issue, nil)
|
|
144
|
-
"#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
|
|
145
|
-
end
|
|
146
|
-
}
|
|
135
|
+
if custom_mode
|
|
136
|
+
issue if @issue_periods[issue] == period.end
|
|
137
|
+
elsif period.include?(stop_date)
|
|
138
|
+
issue
|
|
139
|
+
end
|
|
147
140
|
end
|
|
148
141
|
end
|
|
142
|
+
|
|
143
|
+
def period_dataset period:, closed_issues:, label_hint:
|
|
144
|
+
label_hint_text = label_hint ? " with #{label_hint}" : ''
|
|
145
|
+
header = "#{closed_issues.size} items closed#{label_hint_text} #{period_label(period)}"
|
|
146
|
+
{
|
|
147
|
+
y: closed_issues.size,
|
|
148
|
+
x: "#{period.end}T23:59:59",
|
|
149
|
+
title: [header] + closed_issues.collect { |issue| issue_title_line(issue) }
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def period_label period
|
|
154
|
+
return "on #{period.end}" if period.begin == period.end
|
|
155
|
+
|
|
156
|
+
"between #{period.begin} and #{period.end}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def issue_title_line issue
|
|
160
|
+
hint = @issue_hints&.fetch(issue, nil)
|
|
161
|
+
"#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
|
|
162
|
+
end
|
|
149
163
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/chart_base'
|
|
4
|
+
|
|
5
|
+
# Common base for every cycle-time chart. It owns the cycletime *unit system* --
|
|
6
|
+
# which unit the value axis is expressed in and how a raw value is labelled --
|
|
7
|
+
# because that concern is core to any chart that plots a cycle time, not a
|
|
8
|
+
# cross-cutting add-on. The scatter/histo split lives one level down (in
|
|
9
|
+
# TimeBasedScatterplot / TimeBasedHistogram, which supply the value_axis_title=
|
|
10
|
+
# hook so this class doesn't need to know which axis carries the value); the
|
|
11
|
+
# PR-vs-issue split lives one level below that.
|
|
12
|
+
class TimeBasedChart < ChartBase
|
|
13
|
+
# The cycletime units we support, mapped to how each reads in an axis title.
|
|
14
|
+
# :days counts calendar days; :hours24 counts elapsed 24-hour clock periods
|
|
15
|
+
# (the two differ for anything that crosses midnight).
|
|
16
|
+
VALUE_AXIS_LABELS = {
|
|
17
|
+
minutes: 'minutes',
|
|
18
|
+
hours: 'hours',
|
|
19
|
+
days: 'days',
|
|
20
|
+
hours24: '24-hour periods'
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
super
|
|
25
|
+
|
|
26
|
+
@cycletime_unit = :days
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def cycletime_unit unit
|
|
30
|
+
axis_label = VALUE_AXIS_LABELS[unit]
|
|
31
|
+
unless axis_label
|
|
32
|
+
raise ArgumentError,
|
|
33
|
+
"cycletime_unit must be one of #{VALUE_AXIS_LABELS.keys.map(&:inspect).join(', ')}, got #{unit.inspect}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@cycletime_unit = unit
|
|
37
|
+
self.value_axis_title = "Cycle time in #{axis_label}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def label_cycletime value
|
|
41
|
+
case @cycletime_unit
|
|
42
|
+
when :minutes then label_minutes(value)
|
|
43
|
+
when :hours then label_hours(value)
|
|
44
|
+
when :days then label_days(value)
|
|
45
|
+
when :hours24 then "#{value}x 24h periods"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Converts the span between two Times into the selected cycletime unit.
|
|
50
|
+
#
|
|
51
|
+
# :days is deliberately different from the other units: it counts calendar days
|
|
52
|
+
# inclusively in the configured timezone (opened and closed on the same day is
|
|
53
|
+
# 1 day, crossing one midnight is 2), matching the working-days cycletime engine.
|
|
54
|
+
# The elapsed units (:hours, :minutes) divide the wall-clock span and round up --
|
|
55
|
+
# a partial unit still counts as one, so a 20-minute PR is 1 hour, never 0.
|
|
56
|
+
def duration_in_unit from, to
|
|
57
|
+
if @cycletime_unit == :days
|
|
58
|
+
tz = timezone_offset || '+00:00'
|
|
59
|
+
(to.getlocal(tz).to_date - from.getlocal(tz).to_date).to_i + 1
|
|
60
|
+
else
|
|
61
|
+
seconds_per_unit = { minutes: 60, hours: 3600, hours24: 86_400 }[@cycletime_unit]
|
|
62
|
+
((to - from) / seconds_per_unit).ceil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'jirametrics/groupable_issue_chart'
|
|
4
|
+
require 'jirametrics/time_based_chart'
|
|
4
5
|
|
|
5
|
-
class TimeBasedHistogram <
|
|
6
|
+
class TimeBasedHistogram < TimeBasedChart
|
|
6
7
|
include GroupableIssueChart
|
|
7
8
|
|
|
8
9
|
attr_reader :show_stats
|
|
@@ -14,6 +15,11 @@ class TimeBasedHistogram < ChartBase
|
|
|
14
15
|
@show_stats = true
|
|
15
16
|
end
|
|
16
17
|
|
|
18
|
+
# On a histogram the cycle time is plotted along the x-axis (count is on the y-axis).
|
|
19
|
+
def value_axis_title= title
|
|
20
|
+
@x_axis_title = title
|
|
21
|
+
end
|
|
22
|
+
|
|
17
23
|
def percentiles percs = nil
|
|
18
24
|
@percentiles = percs unless percs.nil?
|
|
19
25
|
@percentiles
|
|
@@ -68,42 +74,43 @@ class TimeBasedHistogram < ChartBase
|
|
|
68
74
|
return {} if histogram_data.empty?
|
|
69
75
|
|
|
70
76
|
total_values = histogram_data.values.sum
|
|
77
|
+
min, max = histogram_data.keys.minmax
|
|
78
|
+
{
|
|
79
|
+
average: average_for(histogram_data, total_values),
|
|
80
|
+
mode: modes_for(histogram_data),
|
|
81
|
+
min: min,
|
|
82
|
+
max: max,
|
|
83
|
+
percentiles: percentiles_for(histogram_data, percentiles, total_values)
|
|
84
|
+
}
|
|
85
|
+
end
|
|
71
86
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
|
|
87
|
+
def average_for histogram_data, total_values
|
|
88
|
+
return 0 if total_values.zero?
|
|
75
89
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
mode = sorted_histogram.select { |_v, f| f == max_freq }
|
|
90
|
+
weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
|
|
91
|
+
weighted_sum.to_f / total_values
|
|
92
|
+
end
|
|
80
93
|
|
|
81
|
-
|
|
94
|
+
# Every value that ties for the highest frequency, so a flat or multi-modal distribution returns them all.
|
|
95
|
+
def modes_for histogram_data
|
|
96
|
+
sorted_by_frequency = histogram_data.sort_by { |_value, frequency| frequency }
|
|
97
|
+
max_frequency = sorted_by_frequency[-1][1]
|
|
98
|
+
sorted_by_frequency.select { |_value, frequency| frequency == max_frequency }.collect(&:first).sort
|
|
99
|
+
end
|
|
82
100
|
|
|
83
|
-
|
|
101
|
+
def percentiles_for histogram_data, percentiles, total_values
|
|
84
102
|
sorted_values = histogram_data.keys.sort
|
|
85
103
|
cumulative_counts = {}
|
|
86
104
|
cumulative_sum = 0
|
|
87
|
-
|
|
88
105
|
sorted_values.each do |value|
|
|
89
106
|
cumulative_sum += histogram_data[value]
|
|
90
107
|
cumulative_counts[value] = cumulative_sum
|
|
91
108
|
end
|
|
92
109
|
|
|
93
|
-
|
|
94
|
-
percentiles.each do |percentile|
|
|
110
|
+
percentiles.to_h do |percentile|
|
|
95
111
|
rank = (percentile / 100.0) * total_values
|
|
96
|
-
|
|
97
|
-
percentile_results[percentile] = percentile_value
|
|
112
|
+
[percentile, sorted_values.find { |value| cumulative_counts[value] >= rank }]
|
|
98
113
|
end
|
|
99
|
-
|
|
100
|
-
{
|
|
101
|
-
average: average,
|
|
102
|
-
mode: mode.collect(&:first).sort,
|
|
103
|
-
min: minmax[0],
|
|
104
|
-
max: minmax[1],
|
|
105
|
-
percentiles: percentile_results
|
|
106
|
-
}
|
|
107
114
|
end
|
|
108
115
|
|
|
109
116
|
def sort_items items
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'jirametrics/groupable_issue_chart'
|
|
4
|
+
require 'jirametrics/time_based_chart'
|
|
4
5
|
|
|
5
|
-
class TimeBasedScatterplot <
|
|
6
|
+
class TimeBasedScatterplot < TimeBasedChart
|
|
6
7
|
include GroupableIssueChart
|
|
7
8
|
|
|
8
9
|
def initialize
|
|
@@ -12,13 +13,21 @@ class TimeBasedScatterplot < ChartBase
|
|
|
12
13
|
@highest_y_value = 0
|
|
13
14
|
end
|
|
14
15
|
|
|
16
|
+
# On a scatterplot the cycle time is plotted up the y-axis.
|
|
17
|
+
def value_axis_title= title
|
|
18
|
+
@y_axis_title = title
|
|
19
|
+
end
|
|
20
|
+
|
|
15
21
|
def run
|
|
16
22
|
items = all_items
|
|
17
23
|
data_sets = create_datasets items
|
|
18
24
|
overall_percent_line = calculate_percent_line(items)
|
|
19
25
|
@percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
|
|
20
26
|
|
|
21
|
-
|
|
27
|
+
if data_sets.empty?
|
|
28
|
+
return "<h1 class='foldable'>#{@header_text}</h1>" \
|
|
29
|
+
'<div>No data matched the selected criteria. Nothing to show.</div>'
|
|
30
|
+
end
|
|
22
31
|
|
|
23
32
|
wrap_and_render(binding, __FILE__)
|
|
24
33
|
end
|
|
@@ -24,11 +24,11 @@ class TrendLineCalculator
|
|
|
24
24
|
def horizontal? = @slope.zero?
|
|
25
25
|
def vertical? = @slope.nan? || @slope.infinite?
|
|
26
26
|
|
|
27
|
-
def calc_y x:
|
|
27
|
+
def calc_y x:
|
|
28
28
|
((x * @slope) + @offset).to_i
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def line_crosses_at y:
|
|
31
|
+
def line_crosses_at y:
|
|
32
32
|
raise "line will never cross #{y}. Trend is perfectly horizontal" if horizontal?
|
|
33
33
|
|
|
34
34
|
((y.to_f - @offset) / @slope).to_i
|
|
@@ -7,7 +7,8 @@ class WipByColumnChart < ChartBase
|
|
|
7
7
|
|
|
8
8
|
ColumnStats = Struct.new(:name, :min_wip_limit, :max_wip_limit, :wip_history, keyword_init: true)
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
# Long only because of the inline description_text heredoc and one-time setup; splitting wouldn't help.
|
|
11
|
+
def initialize block # rubocop:disable Metrics/MethodLength
|
|
11
12
|
super()
|
|
12
13
|
header_text 'WIP by column'
|
|
13
14
|
description_text <<-HTML
|
|
@@ -64,10 +65,10 @@ class WipByColumnChart < ChartBase
|
|
|
64
65
|
total = stat.wip_history.sum { |_wip, seconds| seconds }.to_f
|
|
65
66
|
next [] if total.zero?
|
|
66
67
|
|
|
67
|
-
stat.wip_history
|
|
68
|
+
wip_percentages(stat.wip_history, total)
|
|
68
69
|
end
|
|
69
|
-
@max_wip = stats
|
|
70
|
-
@wip_limits = stats
|
|
70
|
+
@max_wip = max_wip_level(stats)
|
|
71
|
+
@wip_limits = wip_limits(stats)
|
|
71
72
|
@recommendations = @show_recommendations ? compute_recommendations(stats) : Array.new(stats.size)
|
|
72
73
|
|
|
73
74
|
trim_zero_end_columns
|
|
@@ -144,6 +145,22 @@ class WipByColumnChart < ChartBase
|
|
|
144
145
|
end
|
|
145
146
|
end
|
|
146
147
|
|
|
148
|
+
# Turn a column's WIP history into render rows: each WIP level with the percentage of the column's
|
|
149
|
+
# total time spent there.
|
|
150
|
+
def wip_percentages wip_history, total
|
|
151
|
+
wip_history.collect { |wip, seconds| { 'wip' => wip, 'pct' => format_pct(seconds, total) } }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The highest WIP level reached in any column, or 0 when there's no data.
|
|
155
|
+
def max_wip_level stats
|
|
156
|
+
stats.flat_map { |stat| stat.wip_history.collect { |wip, _seconds| wip } }.max || 0
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The configured min/max WIP limits for each column, in column order.
|
|
160
|
+
def wip_limits stats
|
|
161
|
+
stats.collect { |stat| { 'min' => stat.min_wip_limit, 'max' => stat.max_wip_limit } }
|
|
162
|
+
end
|
|
163
|
+
|
|
147
164
|
def format_pct seconds, total
|
|
148
165
|
raw = seconds / total * 100.0
|
|
149
166
|
(1..10).each do |decimals|
|
|
@@ -173,8 +190,7 @@ class WipByColumnChart < ChartBase
|
|
|
173
190
|
next
|
|
174
191
|
end
|
|
175
192
|
|
|
176
|
-
|
|
177
|
-
hash[issue] = last_change ? status_to_column[last_change.value_id] : nil
|
|
193
|
+
hash[issue] = column_as_of(issue, time_range.begin, status_to_column)
|
|
178
194
|
end
|
|
179
195
|
end
|
|
180
196
|
|
|
@@ -184,30 +200,46 @@ class WipByColumnChart < ChartBase
|
|
|
184
200
|
started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
|
|
185
201
|
next unless started_time
|
|
186
202
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
events << [started_time, issue, last_change ? status_to_column[last_change.value_id] : nil]
|
|
203
|
+
if within_window?(started_time)
|
|
204
|
+
# add an explicit event to enter WIP in its current column
|
|
205
|
+
events << [started_time, issue, column_as_of(issue, started_time, status_to_column)]
|
|
191
206
|
end
|
|
192
207
|
|
|
193
|
-
# Status changes while the issue is actively in WIP and within the window
|
|
194
208
|
issue.status_changes.each do |change|
|
|
195
|
-
next unless change
|
|
196
|
-
next if change.time > time_range.end
|
|
197
|
-
next unless change.time >= started_time
|
|
198
|
-
next if stopped_time && change.time >= stopped_time
|
|
209
|
+
next unless in_wip_and_within_window?(change, started_time, stopped_time)
|
|
199
210
|
|
|
200
211
|
events << [change.time, issue, status_to_column[change.value_id]]
|
|
201
212
|
end
|
|
202
213
|
|
|
203
|
-
|
|
204
|
-
if stopped_time && stopped_time > time_range.begin && stopped_time <= time_range.end
|
|
205
|
-
events << [stopped_time, issue, nil]
|
|
206
|
-
end
|
|
214
|
+
events << [stopped_time, issue, nil] if within_window?(stopped_time)
|
|
207
215
|
end
|
|
208
216
|
events.sort_by!(&:first)
|
|
209
217
|
end
|
|
210
218
|
|
|
219
|
+
# True when a (present) time falls inside the chart window: after it opens, up to and including
|
|
220
|
+
# when it closes.
|
|
221
|
+
def within_window? time
|
|
222
|
+
time && time > time_range.begin && time <= time_range.end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# A status change counts if it happened inside the window and while the issue was actively in WIP:
|
|
226
|
+
# after the window opens, up to and including when it closes, at or after the issue started, and
|
|
227
|
+
# strictly before it stopped.
|
|
228
|
+
def in_wip_and_within_window? change, started_time, stopped_time
|
|
229
|
+
return false unless within_window?(change.time)
|
|
230
|
+
return false unless change.time >= started_time
|
|
231
|
+
return false if stopped_time && change.time >= stopped_time
|
|
232
|
+
|
|
233
|
+
true
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# The column the issue was displayed in as of the given time: the column of its most recent
|
|
237
|
+
# status change at or before that time, or nil if it hadn't changed status yet.
|
|
238
|
+
def column_as_of issue, time, status_to_column
|
|
239
|
+
last_change = issue.status_changes.reverse.find { |change| change.time <= time }
|
|
240
|
+
last_change ? status_to_column[last_change.value_id] : nil
|
|
241
|
+
end
|
|
242
|
+
|
|
211
243
|
def compute_wip_seconds columns, current_column, events
|
|
212
244
|
wip_counts = Array.new(columns.size, 0)
|
|
213
245
|
current_column.each_value { |col| wip_counts[col] += 1 unless col.nil? }
|
|
@@ -216,21 +248,30 @@ class WipByColumnChart < ChartBase
|
|
|
216
248
|
prev_time = time_range.begin
|
|
217
249
|
|
|
218
250
|
events.each do |time, issue, new_col|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
wip_counts.each_with_index { |wip, idx| column_wip_seconds[idx][wip] += elapsed }
|
|
222
|
-
prev_time = time
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
old_col = current_column[issue]
|
|
226
|
-
wip_counts[old_col] -= 1 unless old_col.nil?
|
|
227
|
-
wip_counts[new_col] += 1 unless new_col.nil?
|
|
228
|
-
current_column[issue] = new_col
|
|
251
|
+
prev_time = accumulate_wip_seconds(column_wip_seconds, wip_counts, prev_time, time)
|
|
252
|
+
apply_event(wip_counts, current_column, issue, new_col)
|
|
229
253
|
end
|
|
230
|
-
|
|
231
|
-
elapsed = (time_range.end - prev_time).to_i
|
|
232
|
-
wip_counts.each_with_index { |wip, idx| column_wip_seconds[idx][wip] += elapsed } if elapsed.positive?
|
|
254
|
+
accumulate_wip_seconds(column_wip_seconds, wip_counts, prev_time, time_range.end)
|
|
233
255
|
|
|
234
256
|
column_wip_seconds
|
|
235
257
|
end
|
|
258
|
+
|
|
259
|
+
# Attribute the seconds between prev_time and time to each column's current WIP level, then return
|
|
260
|
+
# the new prev_time. A zero-length gap changes nothing and leaves prev_time where it was.
|
|
261
|
+
def accumulate_wip_seconds column_wip_seconds, wip_counts, prev_time, time
|
|
262
|
+
elapsed = (time - prev_time).to_i
|
|
263
|
+
return prev_time unless elapsed.positive?
|
|
264
|
+
|
|
265
|
+
wip_counts.each_with_index { |wip, idx| column_wip_seconds[idx][wip] += elapsed }
|
|
266
|
+
time
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Move the issue out of its old column and into new_col, keeping wip_counts and current_column in
|
|
270
|
+
# sync. A nil column means the issue isn't on the board — ie entering or leaving WIP.
|
|
271
|
+
def apply_event wip_counts, current_column, issue, new_col
|
|
272
|
+
old_col = current_column[issue]
|
|
273
|
+
wip_counts[old_col] -= 1 unless old_col.nil?
|
|
274
|
+
wip_counts[new_col] += 1 unless new_col.nil?
|
|
275
|
+
current_column[issue] = new_col
|
|
276
|
+
end
|
|
236
277
|
end
|
data/lib/jirametrics.rb
CHANGED
|
@@ -108,13 +108,17 @@ class JiraMetrics < Thor
|
|
|
108
108
|
file_system ||= Exporter.instance.file_system
|
|
109
109
|
return if file_system.logfile == $stdout
|
|
110
110
|
|
|
111
|
-
file_system.logfile
|
|
112
|
-
exception.backtrace&.each { |line| file_system.logfile.puts "\t#{line}" }
|
|
111
|
+
write_exception_to_logfile file_system.logfile, exception
|
|
113
112
|
rescue StandardError
|
|
114
113
|
# Exporter may not be initialized, or the logfile may already be closed
|
|
115
114
|
end
|
|
116
115
|
end
|
|
117
116
|
|
|
117
|
+
def self.write_exception_to_logfile logfile, exception
|
|
118
|
+
logfile.puts "#{exception.class}: #{exception.message}"
|
|
119
|
+
exception.backtrace&.each { |line| logfile.puts "\t#{line}" }
|
|
120
|
+
end
|
|
121
|
+
|
|
118
122
|
at_exit { JiraMetrics.log_uncaught_exception $ERROR_INFO }
|
|
119
123
|
|
|
120
124
|
no_commands do
|
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: '3.0'
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Bowler
|
|
@@ -9,20 +9,6 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
-
- !ruby/object:Gem::Dependency
|
|
13
|
-
name: mutant-rspec
|
|
14
|
-
requirement: !ruby/object:Gem::Requirement
|
|
15
|
-
requirements:
|
|
16
|
-
- - ">="
|
|
17
|
-
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0'
|
|
19
|
-
type: :development
|
|
20
|
-
prerelease: false
|
|
21
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
-
requirements:
|
|
23
|
-
- - ">="
|
|
24
|
-
- !ruby/object:Gem::Version
|
|
25
|
-
version: '0'
|
|
26
12
|
- !ruby/object:Gem::Dependency
|
|
27
13
|
name: mcp
|
|
28
14
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -69,16 +55,16 @@ dependencies:
|
|
|
69
55
|
name: thor
|
|
70
56
|
requirement: !ruby/object:Gem::Requirement
|
|
71
57
|
requirements:
|
|
72
|
-
- - "
|
|
58
|
+
- - ">="
|
|
73
59
|
- !ruby/object:Gem::Version
|
|
74
|
-
version:
|
|
60
|
+
version: '0'
|
|
75
61
|
type: :runtime
|
|
76
62
|
prerelease: false
|
|
77
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
78
64
|
requirements:
|
|
79
|
-
- - "
|
|
65
|
+
- - ">="
|
|
80
66
|
- !ruby/object:Gem::Version
|
|
81
|
-
version:
|
|
67
|
+
version: '0'
|
|
82
68
|
description: Extract metrics from Jira and export to either a report or to CSV files
|
|
83
69
|
email: mbowler@gargoylesoftware.com
|
|
84
70
|
executables:
|
|
@@ -97,7 +83,9 @@ files:
|
|
|
97
83
|
- lib/jirametrics/anonymizer.rb
|
|
98
84
|
- lib/jirametrics/atlassian_document_format.rb
|
|
99
85
|
- lib/jirametrics/bar_chart_range.rb
|
|
86
|
+
- lib/jirametrics/blocked_stalled_by_date_builder.rb
|
|
100
87
|
- lib/jirametrics/blocked_stalled_change.rb
|
|
88
|
+
- lib/jirametrics/blocked_stalled_change_stream_builder.rb
|
|
101
89
|
- lib/jirametrics/board.rb
|
|
102
90
|
- lib/jirametrics/board_column.rb
|
|
103
91
|
- lib/jirametrics/board_config.rb
|
|
@@ -132,6 +120,7 @@ files:
|
|
|
132
120
|
- lib/jirametrics/file_config.rb
|
|
133
121
|
- lib/jirametrics/file_system.rb
|
|
134
122
|
- lib/jirametrics/fix_version.rb
|
|
123
|
+
- lib/jirametrics/flow_efficiency_calculator.rb
|
|
135
124
|
- lib/jirametrics/flow_efficiency_scatterplot.rb
|
|
136
125
|
- lib/jirametrics/github_gateway.rb
|
|
137
126
|
- lib/jirametrics/groupable_issue_chart.rb
|
|
@@ -175,12 +164,16 @@ files:
|
|
|
175
164
|
- lib/jirametrics/settings.json
|
|
176
165
|
- lib/jirametrics/sprint.rb
|
|
177
166
|
- lib/jirametrics/sprint_burndown.rb
|
|
167
|
+
- lib/jirametrics/sprint_count_measure.rb
|
|
178
168
|
- lib/jirametrics/sprint_issue_change_data.rb
|
|
169
|
+
- lib/jirametrics/sprint_points_measure.rb
|
|
170
|
+
- lib/jirametrics/sprint_summary_stats.rb
|
|
179
171
|
- lib/jirametrics/status.rb
|
|
180
172
|
- lib/jirametrics/status_collection.rb
|
|
181
173
|
- lib/jirametrics/stitcher.rb
|
|
182
174
|
- lib/jirametrics/throughput_by_completed_resolution_chart.rb
|
|
183
175
|
- lib/jirametrics/throughput_chart.rb
|
|
176
|
+
- lib/jirametrics/time_based_chart.rb
|
|
184
177
|
- lib/jirametrics/time_based_histogram.rb
|
|
185
178
|
- lib/jirametrics/time_based_scatterplot.rb
|
|
186
179
|
- lib/jirametrics/tree_organizer.rb
|
|
@@ -203,7 +196,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
203
196
|
requirements:
|
|
204
197
|
- - ">="
|
|
205
198
|
- !ruby/object:Gem::Version
|
|
206
|
-
version: 3.
|
|
199
|
+
version: 3.4.0
|
|
207
200
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
208
201
|
requirements:
|
|
209
202
|
- - ">="
|