jirametrics 2.31 → 3.1
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 +51 -65
- data/lib/jirametrics/cumulative_flow_diagram.rb +60 -47
- 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 +70 -66
- data/lib/jirametrics/dependency_chart.rb +40 -31
- data/lib/jirametrics/download_config.rb +0 -3
- data/lib/jirametrics/downloader.rb +39 -14
- data/lib/jirametrics/downloader_for_cloud.rb +110 -74
- 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 +203 -12
- 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 +52 -12
- data/lib/jirametrics/groupable_issue_chart.rb +3 -0
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +3 -3
- data/lib/jirametrics/html/index.css +10 -4
- data/lib/jirametrics/html_report_config.rb +9 -29
- data/lib/jirametrics/issue.rb +293 -411
- data/lib/jirametrics/issue_collection.rb +1 -0
- data/lib/jirametrics/issue_printer.rb +60 -23
- data/lib/jirametrics/jira_gateway.rb +56 -18
- data/lib/jirametrics/mcp_server.rb +226 -210
- data/lib/jirametrics/project_config.rb +206 -133
- 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 +29 -5
- 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 +75 -34
- data/lib/jirametrics.rb +35 -4
- metadata +13 -20
data/lib/jirametrics/stitcher.rb
CHANGED
|
@@ -32,8 +32,8 @@ class Stitcher < HtmlGenerator
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def make_output_filename input_filename
|
|
35
|
-
if /^(
|
|
36
|
-
"#{
|
|
35
|
+
if /^(?<base_name>.+)\.erb$/ =~ input_filename
|
|
36
|
+
"#{base_name}.html"
|
|
37
37
|
else
|
|
38
38
|
"#{input_filename}.html"
|
|
39
39
|
end
|
|
@@ -48,7 +48,8 @@ class Stitcher < HtmlGenerator
|
|
|
48
48
|
''
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
# Imperative: it parses the file. The boolean is a "was it newly parsed" status, not a question.
|
|
52
|
+
def parse_file filename # rubocop:disable Naming/PredicateMethod
|
|
52
53
|
return false if @loaded_files.include? filename
|
|
53
54
|
|
|
54
55
|
# To match: <!-- seam-start | chart78 | GithubPrScatterplot | PR Scatterplot | chart -->
|
|
@@ -57,19 +58,7 @@ class Stitcher < HtmlGenerator
|
|
|
57
58
|
file_system.load(filename).lines do |line|
|
|
58
59
|
matches = line.match(regex)
|
|
59
60
|
if matches
|
|
60
|
-
|
|
61
|
-
content = +''
|
|
62
|
-
else
|
|
63
|
-
if content.nil? || content.strip.empty?
|
|
64
|
-
file_system.warning "Seam found with no content in #{filename.inspect}: " \
|
|
65
|
-
"id=#{matches[:id].strip.inspect}, class=#{matches[:clazz].strip.inspect}, " \
|
|
66
|
-
"title=#{matches[:title].strip.inspect}"
|
|
67
|
-
end
|
|
68
|
-
@all_stitches << Stitcher::StitchContent.new(
|
|
69
|
-
file: filename, title: matches[:title], type: matches[:type], content: content
|
|
70
|
-
)
|
|
71
|
-
content = nil
|
|
72
|
-
end
|
|
61
|
+
content = handle_seam matches, filename: filename, content: content
|
|
73
62
|
elsif content
|
|
74
63
|
content << line
|
|
75
64
|
end
|
|
@@ -78,4 +67,20 @@ class Stitcher < HtmlGenerator
|
|
|
78
67
|
@loaded_files << filename
|
|
79
68
|
true
|
|
80
69
|
end
|
|
70
|
+
|
|
71
|
+
# Handle one seam marker line, returning the new accumulator state: a fresh buffer for a start
|
|
72
|
+
# seam, or nil for an end seam (after recording the content collected since the start).
|
|
73
|
+
def handle_seam matches, filename:, content:
|
|
74
|
+
return +'' if matches[:seam] == 'start'
|
|
75
|
+
|
|
76
|
+
if content.nil? || content.strip.empty?
|
|
77
|
+
file_system.warning "Seam found with no content in #{filename.inspect}: " \
|
|
78
|
+
"id=#{matches[:id].strip.inspect}, class=#{matches[:clazz].strip.inspect}, " \
|
|
79
|
+
"title=#{matches[:title].strip.inspect}"
|
|
80
|
+
end
|
|
81
|
+
@all_stitches << Stitcher::StitchContent.new(
|
|
82
|
+
file: filename, title: matches[:title], type: matches[:type], content: content
|
|
83
|
+
)
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
81
86
|
end
|
|
@@ -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
|
|
@@ -17,7 +18,7 @@ class WipByColumnChart < ChartBase
|
|
|
17
18
|
<p>
|
|
18
19
|
Each row on the Y axis is a WIP level (the number of items in that column at the same time).
|
|
19
20
|
Each column on the X axis is a board column.
|
|
20
|
-
The horizontal bars show what percentage of the total time that column spent at that WIP level
|
|
21
|
+
The horizontal bars show what percentage of the total time that column spent at that WIP level -
|
|
21
22
|
a wider bar means more time was spent there.
|
|
22
23
|
</p>
|
|
23
24
|
<p>
|
|
@@ -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
|
|
@@ -135,7 +136,7 @@ class WipByColumnChart < ChartBase
|
|
|
135
136
|
|
|
136
137
|
max = @wip_limits[i]['max']
|
|
137
138
|
if max.nil?
|
|
138
|
-
"Add a WIP limit to column '#{name}'
|
|
139
|
+
"Add a WIP limit to column '#{name}' - suggested maximum: #{rec}"
|
|
139
140
|
elsif rec < max
|
|
140
141
|
"Lower the WIP limit for '#{name}' from #{max} to #{rec}"
|
|
141
142
|
elsif rec > max
|
|
@@ -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
|
@@ -46,6 +46,23 @@ class JiraMetrics < Thor
|
|
|
46
46
|
Exporter.instance.export(name_filter: options[:name] || '*')
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
option :config
|
|
50
|
+
option :name
|
|
51
|
+
desc 'verify', 'Check that JiraMetrics can authenticate to Jira, without downloading anything'
|
|
52
|
+
def verify
|
|
53
|
+
load_config options[:config]
|
|
54
|
+
results = Exporter.instance.verify_jira_connections(name_filter: options[:name] || '*')
|
|
55
|
+
exit 1 if results.empty? || results.any? { |result| !result.ok }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
option :config
|
|
59
|
+
option :name
|
|
60
|
+
desc 'boards', "Show a board's columns and statuses, to help choose cycletime start/stop points"
|
|
61
|
+
def boards board_id = nil
|
|
62
|
+
load_config options[:config]
|
|
63
|
+
exit 1 unless Exporter.instance.boards(board_id: board_id, name_filter: options[:name])
|
|
64
|
+
end
|
|
65
|
+
|
|
49
66
|
option :config
|
|
50
67
|
desc 'info', 'Dump information about one issue'
|
|
51
68
|
def info key
|
|
@@ -63,7 +80,9 @@ class JiraMetrics < Thor
|
|
|
63
80
|
original_stdout = $stdout.dup
|
|
64
81
|
$stdout.reopen($stderr)
|
|
65
82
|
|
|
66
|
-
|
|
83
|
+
# Log to a separate file so that starting the MCP server doesn't truncate jirametrics.log, which
|
|
84
|
+
# someone may be relying on to debug a preceding export run.
|
|
85
|
+
load_config options[:config], logfile_name: 'jirametrics-mcp.log'
|
|
67
86
|
require 'jirametrics/mcp_server'
|
|
68
87
|
|
|
69
88
|
Exporter.instance.file_system.log_only = true
|
|
@@ -78,6 +97,10 @@ class JiraMetrics < Thor
|
|
|
78
97
|
today: project.time_range.end.to_date,
|
|
79
98
|
end_time: project.time_range.end
|
|
80
99
|
}
|
|
100
|
+
# Fabrication is lazy, so force every historical status to resolve now and then emit the single
|
|
101
|
+
# consolidated warning into jirametrics-mcp.log (file_system is log_only here).
|
|
102
|
+
project.resolve_all_status_changes
|
|
103
|
+
project.report_fabricated_statuses
|
|
81
104
|
rescue StandardError => e
|
|
82
105
|
if e.message.start_with? 'This is an aggregated project'
|
|
83
106
|
names = project.aggregate_project_names
|
|
@@ -108,17 +131,21 @@ class JiraMetrics < Thor
|
|
|
108
131
|
file_system ||= Exporter.instance.file_system
|
|
109
132
|
return if file_system.logfile == $stdout
|
|
110
133
|
|
|
111
|
-
file_system.logfile
|
|
112
|
-
exception.backtrace&.each { |line| file_system.logfile.puts "\t#{line}" }
|
|
134
|
+
write_exception_to_logfile file_system.logfile, exception
|
|
113
135
|
rescue StandardError
|
|
114
136
|
# Exporter may not be initialized, or the logfile may already be closed
|
|
115
137
|
end
|
|
116
138
|
end
|
|
117
139
|
|
|
140
|
+
def self.write_exception_to_logfile logfile, exception
|
|
141
|
+
logfile.puts "#{exception.class}: #{exception.message}"
|
|
142
|
+
exception.backtrace&.each { |line| logfile.puts "\t#{line}" }
|
|
143
|
+
end
|
|
144
|
+
|
|
118
145
|
at_exit { JiraMetrics.log_uncaught_exception $ERROR_INFO }
|
|
119
146
|
|
|
120
147
|
no_commands do
|
|
121
|
-
def load_config config_file, file_system: FileSystem.new
|
|
148
|
+
def load_config config_file, logfile_name: nil, file_system: FileSystem.new
|
|
122
149
|
config_file = './config.rb' if config_file.nil?
|
|
123
150
|
|
|
124
151
|
if file_system.file_exist? config_file
|
|
@@ -131,6 +158,10 @@ class JiraMetrics < Thor
|
|
|
131
158
|
end
|
|
132
159
|
|
|
133
160
|
require_rel 'jirametrics'
|
|
161
|
+
# Set only after the require above, so Exporter is defined. The config file we load below calls
|
|
162
|
+
# Exporter.configure, which opens this log; the MCP server passes its own name so it doesn't
|
|
163
|
+
# truncate jirametrics.log.
|
|
164
|
+
Exporter.logfile_name = logfile_name if logfile_name
|
|
134
165
|
load config_file
|
|
135
166
|
end
|
|
136
167
|
end
|