jirametrics 2.10 → 2.30
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/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/aggregate_config.rb +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
- data/lib/jirametrics/aging_work_table.rb +62 -17
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +160 -0
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +63 -11
- data/lib/jirametrics/board_config.rb +5 -1
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +49 -19
- data/lib/jirametrics/chart_base.rb +147 -7
- data/lib/jirametrics/css_variable.rb +2 -2
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +22 -5
- data/lib/jirametrics/cycletime_histogram.rb +15 -101
- data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
- data/lib/jirametrics/daily_view.rb +306 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +30 -8
- data/lib/jirametrics/data_quality_report.rb +43 -12
- data/lib/jirametrics/dependency_chart.rb +6 -3
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +128 -71
- data/lib/jirametrics/downloader_for_cloud.rb +287 -0
- data/lib/jirametrics/downloader_for_data_center.rb +95 -0
- data/lib/jirametrics/estimate_accuracy_chart.rb +74 -12
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +2 -2
- data/lib/jirametrics/examples/standard_project.rb +42 -27
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +28 -8
- data/lib/jirametrics/file_config.rb +10 -12
- data/lib/jirametrics/file_system.rb +59 -3
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +6 -2
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +11 -1
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
- data/lib/jirametrics/html/aging_work_table.erb +12 -3
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +6 -14
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
- data/lib/jirametrics/html/index.css +323 -63
- data/lib/jirametrics/html/index.erb +17 -19
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +17 -15
- data/lib/jirametrics/html/throughput_chart.erb +42 -11
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +32 -0
- data/lib/jirametrics/html_report_config.rb +52 -55
- data/lib/jirametrics/issue.rb +347 -103
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +81 -14
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +151 -18
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +17 -0
- data/lib/jirametrics/settings.json +6 -1
- data/lib/jirametrics/sprint.rb +13 -0
- data/lib/jirametrics/sprint_burndown.rb +45 -37
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +3 -0
- data/lib/jirametrics/status_collection.rb +7 -0
- data/lib/jirametrics/stitcher.rb +81 -0
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +73 -23
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +107 -0
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +83 -64
- metadata +66 -6
|
@@ -29,6 +29,8 @@ class SprintBurndown < ChartBase
|
|
|
29
29
|
</div>
|
|
30
30
|
#{describe_non_working_days}
|
|
31
31
|
TEXT
|
|
32
|
+
@x_axis_title = 'Date'
|
|
33
|
+
@y_axis_title = 'Items remaining'
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
def options= arg
|
|
@@ -48,8 +50,9 @@ class SprintBurndown < ChartBase
|
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
def run
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
return nil unless current_board.scrum?
|
|
54
|
+
|
|
55
|
+
sprints = sprints_in_time_range current_board
|
|
53
56
|
|
|
54
57
|
change_data_by_sprint = {}
|
|
55
58
|
sprints.each do |sprint|
|
|
@@ -63,7 +66,7 @@ class SprintBurndown < ChartBase
|
|
|
63
66
|
result = +''
|
|
64
67
|
result << render_top_text(binding)
|
|
65
68
|
|
|
66
|
-
possible_colours = (1..
|
|
69
|
+
possible_colours = (1..ChartBase::OKABE_ITO_PALETTE.size).collect { |i| CssVariable["--sprint-burndown-sprint-color-#{i}"] }
|
|
67
70
|
charts_to_generate = []
|
|
68
71
|
charts_to_generate << [:data_set_by_story_points, 'Story Points'] if @use_story_points
|
|
69
72
|
charts_to_generate << [:data_set_by_story_counts, 'Story Count'] if @use_story_counts
|
|
@@ -110,6 +113,9 @@ class SprintBurndown < ChartBase
|
|
|
110
113
|
|
|
111
114
|
def sprints_in_time_range board
|
|
112
115
|
board.sprints.select do |sprint|
|
|
116
|
+
# If it's never been started then it's just a holding area. Ignore it.
|
|
117
|
+
next if sprint.future?
|
|
118
|
+
|
|
113
119
|
sprint_end_time = sprint.completed_time || sprint.end_time
|
|
114
120
|
sprint_start_time = sprint.start_time
|
|
115
121
|
next false if sprint_start_time.nil?
|
|
@@ -121,12 +127,14 @@ class SprintBurndown < ChartBase
|
|
|
121
127
|
|
|
122
128
|
# select all the changes that are relevant for the sprint. If this issue never appears in this sprint then return [].
|
|
123
129
|
def changes_for_one_issue issue:, sprint:
|
|
124
|
-
|
|
130
|
+
estimate = 0.0
|
|
125
131
|
ever_in_sprint = false
|
|
126
132
|
currently_in_sprint = false
|
|
127
133
|
change_data = []
|
|
128
134
|
|
|
129
|
-
|
|
135
|
+
estimate_display_name = current_board.estimation_configuration.display_name
|
|
136
|
+
|
|
137
|
+
issue_completed_time = issue.started_stopped_times.last
|
|
130
138
|
completed_has_been_tracked = false
|
|
131
139
|
|
|
132
140
|
issue.changes.each do |change|
|
|
@@ -140,26 +148,26 @@ class SprintBurndown < ChartBase
|
|
|
140
148
|
if currently_in_sprint == false && in_change_item
|
|
141
149
|
action = :enter_sprint
|
|
142
150
|
ever_in_sprint = true
|
|
143
|
-
value =
|
|
151
|
+
value = estimate
|
|
144
152
|
elsif currently_in_sprint && in_change_item == false
|
|
145
153
|
action = :leave_sprint
|
|
146
|
-
value = -
|
|
154
|
+
value = -estimate
|
|
147
155
|
end
|
|
148
156
|
currently_in_sprint = in_change_item
|
|
149
|
-
elsif change.
|
|
157
|
+
elsif change.field == estimate_display_name && (issue_completed_time.nil? || change.time < issue_completed_time)
|
|
150
158
|
action = :story_points
|
|
151
|
-
|
|
152
|
-
value =
|
|
159
|
+
estimate = change.value.to_f
|
|
160
|
+
value = estimate - change.old_value.to_f
|
|
153
161
|
elsif completed_has_been_tracked == false && change.time == issue_completed_time
|
|
154
162
|
completed_has_been_tracked = true
|
|
155
163
|
action = :issue_stopped
|
|
156
|
-
value = -
|
|
164
|
+
value = -estimate
|
|
157
165
|
end
|
|
158
166
|
|
|
159
167
|
next unless action
|
|
160
168
|
|
|
161
169
|
change_data << SprintIssueChangeData.new(
|
|
162
|
-
time: change.time, issue: issue, action: action, value: value,
|
|
170
|
+
time: change.time, issue: issue, action: action, value: value, estimate: estimate
|
|
163
171
|
)
|
|
164
172
|
end
|
|
165
173
|
|
|
@@ -176,7 +184,7 @@ class SprintBurndown < ChartBase
|
|
|
176
184
|
summary_stats = SprintSummaryStats.new
|
|
177
185
|
summary_stats.completed = 0.0
|
|
178
186
|
|
|
179
|
-
|
|
187
|
+
estimate = 0.0
|
|
180
188
|
start_data_written = false
|
|
181
189
|
data_set = []
|
|
182
190
|
|
|
@@ -185,11 +193,11 @@ class SprintBurndown < ChartBase
|
|
|
185
193
|
change_data_for_sprint.each do |change_data|
|
|
186
194
|
if start_data_written == false && change_data.time >= sprint.start_time
|
|
187
195
|
data_set << {
|
|
188
|
-
y:
|
|
196
|
+
y: estimate,
|
|
189
197
|
x: chart_format(sprint.start_time),
|
|
190
|
-
title: "Sprint started with #{
|
|
198
|
+
title: "Sprint started with #{estimate} points"
|
|
191
199
|
}
|
|
192
|
-
summary_stats.started =
|
|
200
|
+
summary_stats.started = estimate
|
|
193
201
|
start_data_written = true
|
|
194
202
|
end
|
|
195
203
|
|
|
@@ -198,12 +206,12 @@ class SprintBurndown < ChartBase
|
|
|
198
206
|
case change_data.action
|
|
199
207
|
when :enter_sprint
|
|
200
208
|
issues_currently_in_sprint << change_data.issue.key
|
|
201
|
-
|
|
209
|
+
estimate += change_data.estimate
|
|
202
210
|
when :leave_sprint
|
|
203
211
|
issues_currently_in_sprint.delete change_data.issue.key
|
|
204
|
-
|
|
212
|
+
estimate -= change_data.estimate
|
|
205
213
|
when :story_points
|
|
206
|
-
|
|
214
|
+
estimate += change_data.value if issues_currently_in_sprint.include? change_data.issue.key
|
|
207
215
|
end
|
|
208
216
|
|
|
209
217
|
next unless change_data.time >= sprint.start_time
|
|
@@ -213,26 +221,26 @@ class SprintBurndown < ChartBase
|
|
|
213
221
|
when :story_points
|
|
214
222
|
next unless issues_currently_in_sprint.include? change_data.issue.key
|
|
215
223
|
|
|
216
|
-
|
|
217
|
-
message = "Story points changed from #{
|
|
224
|
+
old_estimate = change_data.estimate - change_data.value
|
|
225
|
+
message = "Story points changed from #{old_estimate} points to #{change_data.estimate} points"
|
|
218
226
|
summary_stats.points_values_changed = true
|
|
219
227
|
when :enter_sprint
|
|
220
|
-
message = "Added to sprint with #{change_data.
|
|
221
|
-
summary_stats.added += change_data.
|
|
228
|
+
message = "Added to sprint with #{change_data.estimate || 'no'} points"
|
|
229
|
+
summary_stats.added += change_data.estimate
|
|
222
230
|
when :issue_stopped
|
|
223
|
-
|
|
224
|
-
message = "Completed with #{change_data.
|
|
231
|
+
estimate -= change_data.estimate
|
|
232
|
+
message = "Completed with #{change_data.estimate || 'no'} points"
|
|
225
233
|
issues_currently_in_sprint.delete change_data.issue.key
|
|
226
|
-
summary_stats.completed += change_data.
|
|
234
|
+
summary_stats.completed += change_data.estimate
|
|
227
235
|
when :leave_sprint
|
|
228
|
-
message = "Removed from sprint with #{change_data.
|
|
229
|
-
summary_stats.removed += change_data.
|
|
236
|
+
message = "Removed from sprint with #{change_data.estimate || 'no'} points"
|
|
237
|
+
summary_stats.removed += change_data.estimate
|
|
230
238
|
else
|
|
231
239
|
raise "Unexpected action: #{change_data.action}"
|
|
232
240
|
end
|
|
233
241
|
|
|
234
242
|
data_set << {
|
|
235
|
-
y:
|
|
243
|
+
y: estimate,
|
|
236
244
|
x: chart_format(change_data.time),
|
|
237
245
|
title: "#{change_data.issue.key} #{message}"
|
|
238
246
|
}
|
|
@@ -241,27 +249,27 @@ class SprintBurndown < ChartBase
|
|
|
241
249
|
unless start_data_written
|
|
242
250
|
# There was nothing that triggered us to write the sprint started block so do it now.
|
|
243
251
|
data_set << {
|
|
244
|
-
y:
|
|
252
|
+
y: estimate,
|
|
245
253
|
x: chart_format(sprint.start_time),
|
|
246
|
-
title: "Sprint started with #{
|
|
254
|
+
title: "Sprint started with #{estimate} points"
|
|
247
255
|
}
|
|
248
|
-
summary_stats.started =
|
|
256
|
+
summary_stats.started = estimate
|
|
249
257
|
end
|
|
250
258
|
|
|
251
259
|
if sprint.completed_time
|
|
252
260
|
data_set << {
|
|
253
|
-
y:
|
|
261
|
+
y: estimate,
|
|
254
262
|
x: chart_format(sprint.completed_time),
|
|
255
|
-
title: "Sprint ended with #{
|
|
263
|
+
title: "Sprint ended with #{estimate} points unfinished"
|
|
256
264
|
}
|
|
257
|
-
summary_stats.remaining =
|
|
265
|
+
summary_stats.remaining = estimate
|
|
258
266
|
end
|
|
259
267
|
|
|
260
268
|
unless sprint.completed_at?(time_range.end)
|
|
261
269
|
data_set << {
|
|
262
|
-
y:
|
|
270
|
+
y: estimate,
|
|
263
271
|
x: chart_format(time_range.end),
|
|
264
|
-
title: "Sprint still active. #{
|
|
272
|
+
title: "Sprint still active. #{estimate} points still in progress."
|
|
265
273
|
}
|
|
266
274
|
end
|
|
267
275
|
|
|
@@ -4,14 +4,14 @@ require 'jirametrics/value_equality'
|
|
|
4
4
|
|
|
5
5
|
class SprintIssueChangeData
|
|
6
6
|
include ValueEquality
|
|
7
|
-
attr_reader :time, :action, :value, :issue, :
|
|
7
|
+
attr_reader :time, :action, :value, :issue, :estimate
|
|
8
8
|
|
|
9
|
-
def initialize time:, action:, value:, issue:,
|
|
9
|
+
def initialize time:, action:, value:, issue:, estimate:
|
|
10
10
|
@time = time
|
|
11
11
|
@action = action
|
|
12
12
|
@value = value
|
|
13
13
|
@issue = issue
|
|
14
|
-
@
|
|
14
|
+
@estimate = estimate
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def inspect
|
data/lib/jirametrics/status.rb
CHANGED
|
@@ -36,7 +36,10 @@ class Status
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def self.from_raw raw
|
|
39
|
+
raise 'raw cannot be nil' if raw.nil?
|
|
40
|
+
|
|
39
41
|
category_config = raw['statusCategory']
|
|
42
|
+
raise "statusCategory can't be nil in #{category_config.inspect}" if category_config.nil?
|
|
40
43
|
|
|
41
44
|
Status.new(
|
|
42
45
|
name: raw['name'],
|
|
@@ -16,6 +16,13 @@ class StatusCollection
|
|
|
16
16
|
@list.find { |status| status.id == id }
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
def find_by_id! id
|
|
20
|
+
status = @list.find { |status| status.id == id }
|
|
21
|
+
raise "Can't find any status for id #{id} in #{self}" unless status
|
|
22
|
+
|
|
23
|
+
status
|
|
24
|
+
end
|
|
25
|
+
|
|
19
26
|
def find_all_by_name identifier
|
|
20
27
|
name, id = parse_name_id identifier
|
|
21
28
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Stitcher < HtmlGenerator
|
|
4
|
+
class StitchContent
|
|
5
|
+
include ValueEquality
|
|
6
|
+
|
|
7
|
+
attr_reader :file, :title, :content, :type
|
|
8
|
+
|
|
9
|
+
def initialize file:, title:, type:, content:
|
|
10
|
+
@file = file
|
|
11
|
+
@title = title
|
|
12
|
+
@content = content
|
|
13
|
+
@type = type
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :loaded_files, :all_stitches
|
|
18
|
+
|
|
19
|
+
def initialize file_system:
|
|
20
|
+
super()
|
|
21
|
+
self.file_system = file_system
|
|
22
|
+
@all_stitches = []
|
|
23
|
+
@loaded_files = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run stitch_file:
|
|
27
|
+
output_filename = make_output_filename stitch_file
|
|
28
|
+
file_system.log "Creating file #{output_filename.inspect}", also_write_to_stderr: true
|
|
29
|
+
erb = ERB.new file_system.load(stitch_file)
|
|
30
|
+
@sections = [[erb.result(binding), :body]]
|
|
31
|
+
create_html output_filename: output_filename, settings: {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def make_output_filename input_filename
|
|
35
|
+
if /^(.+)\.erb$/ =~ input_filename
|
|
36
|
+
"#{$1}.html"
|
|
37
|
+
else
|
|
38
|
+
"#{input_filename}.html"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def grab_by_title title, from_file:, type: 'chart'
|
|
43
|
+
parse_file from_file
|
|
44
|
+
stitch_content = @all_stitches.find { |s| s.file == from_file && s.title == title && s.type == type }
|
|
45
|
+
return stitch_content.content if stitch_content
|
|
46
|
+
|
|
47
|
+
file_system.error "Unable to find content in file #{from_file.inspect} matching title: #{title.inspect}"
|
|
48
|
+
''
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parse_file filename
|
|
52
|
+
return false if @loaded_files.include? filename
|
|
53
|
+
|
|
54
|
+
# To match: <!-- seam-start | chart78 | GithubPrScatterplot | PR Scatterplot | chart -->
|
|
55
|
+
regex = /^<!-- seam-(?<seam>start|end) \| (?<id>[^|]+) \| (?<clazz>[^|]+) \| (?<title>[^|]+) \| (?<type>[^|]+) -->$/
|
|
56
|
+
content = nil
|
|
57
|
+
file_system.load(filename).lines do |line|
|
|
58
|
+
matches = line.match(regex)
|
|
59
|
+
if matches
|
|
60
|
+
if matches[:seam] == 'start'
|
|
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
|
|
73
|
+
elsif content
|
|
74
|
+
content << line
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@loaded_files << filename
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -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,42 +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
|
|
24
|
+
@x_axis_title = nil
|
|
25
|
+
@y_axis_title = 'Count of items'
|
|
18
26
|
|
|
19
27
|
init_configuration_block(block) do
|
|
20
|
-
grouping_rules
|
|
21
|
-
rule.label = issue.type
|
|
22
|
-
rule.color = color_for type: issue.type
|
|
23
|
-
end
|
|
28
|
+
grouping_rules { |issue, rule| default_grouping_rules(issue, rule) }
|
|
24
29
|
end
|
|
25
30
|
end
|
|
26
31
|
|
|
27
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
|
+
|
|
28
36
|
completed_issues = completed_issues_in_range include_unstarted: true
|
|
29
37
|
rules_to_issues = group_issues completed_issues
|
|
30
38
|
data_sets = []
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
39
47
|
|
|
40
48
|
rules_to_issues.each_key do |rules|
|
|
41
49
|
data_sets << weekly_throughput_dataset(
|
|
42
|
-
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
|
|
43
52
|
)
|
|
44
53
|
end
|
|
45
54
|
|
|
46
55
|
wrap_and_render(binding, __FILE__)
|
|
47
56
|
end
|
|
48
57
|
|
|
58
|
+
def default_grouping_rules issue, rule
|
|
59
|
+
rule.label = issue.type
|
|
60
|
+
rule.color = color_for type: issue.type
|
|
61
|
+
end
|
|
62
|
+
|
|
49
63
|
def calculate_time_periods
|
|
50
64
|
first_day = @date_range.begin
|
|
51
65
|
first_day = case first_day.wday
|
|
@@ -65,10 +79,22 @@ class ThroughputChart < ChartBase
|
|
|
65
79
|
end
|
|
66
80
|
end
|
|
67
81
|
|
|
68
|
-
def
|
|
82
|
+
def calculate_custom_periods
|
|
83
|
+
last_days = @issue_periods.values.compact.uniq.sort
|
|
84
|
+
last_days.each_with_index.map do |last_day, i|
|
|
85
|
+
first_day = i.zero? ? @date_range.begin : last_days[i - 1] + 1
|
|
86
|
+
first_day..last_day
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def weekly_throughput_dataset completed_issues:, label:, color:, dashed: false, label_hint: nil
|
|
91
|
+
periods = @issue_periods&.values&.any? ? calculate_custom_periods : calculate_time_periods
|
|
69
92
|
result = {
|
|
70
93
|
label: label,
|
|
71
|
-
|
|
94
|
+
label_hint: label_hint,
|
|
95
|
+
data: throughput_dataset(
|
|
96
|
+
periods: periods, completed_issues: completed_issues, label_hint: label_hint
|
|
97
|
+
),
|
|
72
98
|
fill: false,
|
|
73
99
|
showLine: true,
|
|
74
100
|
borderColor: color,
|
|
@@ -79,20 +105,44 @@ class ThroughputChart < ChartBase
|
|
|
79
105
|
result
|
|
80
106
|
end
|
|
81
107
|
|
|
82
|
-
def
|
|
108
|
+
def throughput_forecaster_url
|
|
109
|
+
params = {
|
|
110
|
+
throughputMode: 'data',
|
|
111
|
+
samplesText: @throughput_samples.join(','),
|
|
112
|
+
storyLow: @not_started_count,
|
|
113
|
+
storyHigh: @not_started_count
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
query = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join('&')
|
|
117
|
+
"https://focusedobjective.com/throughput?#{query}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def throughput_dataset periods:, completed_issues:, label_hint: nil
|
|
121
|
+
custom_mode = @issue_periods&.values&.any?
|
|
83
122
|
periods.collect do |period|
|
|
84
123
|
closed_issues = completed_issues.filter_map do |issue|
|
|
85
|
-
stop_date = issue.
|
|
86
|
-
|
|
124
|
+
stop_date = issue.started_stopped_dates.last
|
|
125
|
+
next unless stop_date
|
|
126
|
+
|
|
127
|
+
if custom_mode
|
|
128
|
+
[stop_date, issue] if @issue_periods[issue] == period.end
|
|
129
|
+
elsif period.include?(stop_date)
|
|
130
|
+
[stop_date, issue]
|
|
131
|
+
end
|
|
87
132
|
end
|
|
88
133
|
|
|
89
134
|
date_label = "on #{period.end}"
|
|
90
135
|
date_label = "between #{period.begin} and #{period.end}" unless period.begin == period.end
|
|
91
136
|
|
|
92
|
-
{
|
|
137
|
+
with_label_hint = label_hint ? " with #{label_hint}" : ''
|
|
138
|
+
{
|
|
139
|
+
y: closed_issues.size,
|
|
93
140
|
x: "#{period.end}T23:59:59",
|
|
94
|
-
title: ["#{closed_issues.size} items
|
|
95
|
-
closed_issues.collect
|
|
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
|
|
96
146
|
}
|
|
97
147
|
end
|
|
98
148
|
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/groupable_issue_chart'
|
|
4
|
+
|
|
5
|
+
class TimeBasedHistogram < ChartBase
|
|
6
|
+
include GroupableIssueChart
|
|
7
|
+
|
|
8
|
+
attr_reader :show_stats
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
super
|
|
12
|
+
|
|
13
|
+
percentiles [50, 85, 98]
|
|
14
|
+
@show_stats = true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def percentiles percs = nil
|
|
18
|
+
@percentiles = percs unless percs.nil?
|
|
19
|
+
@percentiles
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def disable_stats
|
|
23
|
+
@show_stats = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run
|
|
27
|
+
histogram_items = all_items
|
|
28
|
+
rules_to_items = group_issues histogram_items
|
|
29
|
+
|
|
30
|
+
the_stats = {}
|
|
31
|
+
|
|
32
|
+
overall_histogram = histogram_data_for(items: histogram_items).transform_values(&:size)
|
|
33
|
+
the_stats[:all] = stats_for histogram_data: overall_histogram, percentiles: @percentiles
|
|
34
|
+
data_sets = rules_to_items.keys.collect do |rules|
|
|
35
|
+
the_label = rules.label
|
|
36
|
+
the_histogram = histogram_data_for(items: rules_to_items[rules])
|
|
37
|
+
if @show_stats
|
|
38
|
+
the_stats[the_label] = stats_for(
|
|
39
|
+
histogram_data: the_histogram.transform_values(&:size), percentiles: @percentiles
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
data_set_for(
|
|
44
|
+
histogram_data: the_histogram,
|
|
45
|
+
label: the_label,
|
|
46
|
+
color: rules.color
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if data_sets.empty?
|
|
51
|
+
return "<h1 class='foldable'>#{@header_text}</h1>" \
|
|
52
|
+
'<div>No data matched the selected criteria. Nothing to show.</div>'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
wrap_and_render(binding, __FILE__)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def histogram_data_for items:
|
|
59
|
+
items_hash = {}
|
|
60
|
+
items.each do |item|
|
|
61
|
+
days = value_for_item item
|
|
62
|
+
(items_hash[days] ||= []) << item if days.positive?
|
|
63
|
+
end
|
|
64
|
+
items_hash
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stats_for histogram_data:, percentiles:
|
|
68
|
+
return {} if histogram_data.empty?
|
|
69
|
+
|
|
70
|
+
total_values = histogram_data.values.sum
|
|
71
|
+
|
|
72
|
+
# Calculate the average
|
|
73
|
+
weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
|
|
74
|
+
average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
|
|
75
|
+
|
|
76
|
+
# Find the mode (or modes!) and the spread of the distribution
|
|
77
|
+
sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
|
|
78
|
+
max_freq = sorted_histogram[-1][1]
|
|
79
|
+
mode = sorted_histogram.select { |_v, f| f == max_freq }
|
|
80
|
+
|
|
81
|
+
minmax = histogram_data.keys.minmax
|
|
82
|
+
|
|
83
|
+
# Calculate percentiles
|
|
84
|
+
sorted_values = histogram_data.keys.sort
|
|
85
|
+
cumulative_counts = {}
|
|
86
|
+
cumulative_sum = 0
|
|
87
|
+
|
|
88
|
+
sorted_values.each do |value|
|
|
89
|
+
cumulative_sum += histogram_data[value]
|
|
90
|
+
cumulative_counts[value] = cumulative_sum
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
percentile_results = {}
|
|
94
|
+
percentiles.each do |percentile|
|
|
95
|
+
rank = (percentile / 100.0) * total_values
|
|
96
|
+
percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
|
|
97
|
+
percentile_results[percentile] = percentile_value
|
|
98
|
+
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
|
+
end
|
|
108
|
+
|
|
109
|
+
def sort_items items
|
|
110
|
+
items
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def label_for_item item, hint:
|
|
114
|
+
raise NotImplementedError, "#{self.class} must implement label_for_item"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def data_set_for histogram_data:, label:, color:
|
|
118
|
+
{
|
|
119
|
+
type: 'bar',
|
|
120
|
+
label: label,
|
|
121
|
+
data: histogram_data.keys.sort.filter_map do |days|
|
|
122
|
+
items = histogram_data[days]
|
|
123
|
+
next if items.empty?
|
|
124
|
+
|
|
125
|
+
{
|
|
126
|
+
x: days,
|
|
127
|
+
y: items.size,
|
|
128
|
+
title: [title_for_item(count: items.size, value: days)] +
|
|
129
|
+
sort_items(items).collect do |item|
|
|
130
|
+
hint = @issue_hints&.fetch(item, nil)
|
|
131
|
+
label_for_item(item, hint: hint)
|
|
132
|
+
end
|
|
133
|
+
}
|
|
134
|
+
end,
|
|
135
|
+
backgroundColor: color,
|
|
136
|
+
borderRadius: 0
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|