jirametrics 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/bin/jirametrics +4 -0
- data/lib/jirametrics/aggregate_config.rb +89 -0
- data/lib/jirametrics/aging_work_bar_chart.rb +235 -0
- data/lib/jirametrics/aging_work_in_progress_chart.rb +148 -0
- data/lib/jirametrics/aging_work_table.rb +149 -0
- data/lib/jirametrics/anonymizer.rb +186 -0
- data/lib/jirametrics/blocked_stalled_change.rb +43 -0
- data/lib/jirametrics/board.rb +85 -0
- data/lib/jirametrics/board_column.rb +14 -0
- data/lib/jirametrics/board_config.rb +31 -0
- data/lib/jirametrics/change_item.rb +80 -0
- data/lib/jirametrics/chart_base.rb +239 -0
- data/lib/jirametrics/columns_config.rb +42 -0
- data/lib/jirametrics/cycletime_config.rb +69 -0
- data/lib/jirametrics/cycletime_histogram.rb +74 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +128 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +88 -0
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +77 -0
- data/lib/jirametrics/daily_wip_chart.rb +123 -0
- data/lib/jirametrics/data_quality_report.rb +278 -0
- data/lib/jirametrics/dependency_chart.rb +217 -0
- data/lib/jirametrics/discard_changes_before.rb +37 -0
- data/lib/jirametrics/download_config.rb +41 -0
- data/lib/jirametrics/downloader.rb +337 -0
- data/lib/jirametrics/examples/aggregated_project.rb +36 -0
- data/lib/jirametrics/examples/standard_project.rb +111 -0
- data/lib/jirametrics/expedited_chart.rb +169 -0
- data/lib/jirametrics/experimental/generator.rb +209 -0
- data/lib/jirametrics/experimental/info.rb +77 -0
- data/lib/jirametrics/exporter.rb +127 -0
- data/lib/jirametrics/file_config.rb +119 -0
- data/lib/jirametrics/fix_version.rb +21 -0
- data/lib/jirametrics/groupable_issue_chart.rb +44 -0
- data/lib/jirametrics/grouping_rules.rb +13 -0
- data/lib/jirametrics/hierarchy_table.rb +31 -0
- data/lib/jirametrics/html/aging_work_bar_chart.erb +72 -0
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +52 -0
- data/lib/jirametrics/html/aging_work_table.erb +60 -0
- data/lib/jirametrics/html/collapsible_issues_panel.erb +32 -0
- data/lib/jirametrics/html/cycletime_histogram.erb +41 -0
- data/lib/jirametrics/html/cycletime_scatterplot.erb +103 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +63 -0
- data/lib/jirametrics/html/data_quality_report.erb +126 -0
- data/lib/jirametrics/html/expedited_chart.erb +67 -0
- data/lib/jirametrics/html/hierarchy_table.erb +29 -0
- data/lib/jirametrics/html/index.erb +66 -0
- data/lib/jirametrics/html/sprint_burndown.erb +116 -0
- data/lib/jirametrics/html/story_point_accuracy_chart.erb +57 -0
- data/lib/jirametrics/html/throughput_chart.erb +65 -0
- data/lib/jirametrics/html_report_config.rb +217 -0
- data/lib/jirametrics/issue.rb +521 -0
- data/lib/jirametrics/issue_link.rb +60 -0
- data/lib/jirametrics/json_file_loader.rb +9 -0
- data/lib/jirametrics/project_config.rb +442 -0
- data/lib/jirametrics/rules.rb +34 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +15 -0
- data/lib/jirametrics/sprint.rb +43 -0
- data/lib/jirametrics/sprint_burndown.rb +335 -0
- data/lib/jirametrics/sprint_issue_change_data.rb +31 -0
- data/lib/jirametrics/status.rb +26 -0
- data/lib/jirametrics/status_collection.rb +67 -0
- data/lib/jirametrics/story_point_accuracy_chart.rb +139 -0
- data/lib/jirametrics/throughput_chart.rb +91 -0
- data/lib/jirametrics/tree_organizer.rb +96 -0
- data/lib/jirametrics/trend_line_calculator.rb +74 -0
- data/lib/jirametrics.rb +85 -0
- metadata +167 -0
@@ -0,0 +1,335 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class SprintSummaryStats
|
4
|
+
attr_accessor :started, :added, :changed, :removed, :completed, :remaining, :points_values_changed
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@added = 0
|
8
|
+
@completed = 0
|
9
|
+
@removed = 0
|
10
|
+
@started = 0
|
11
|
+
@remaining = 0
|
12
|
+
@points_values_changed = false
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
class SprintBurndown < ChartBase
|
17
|
+
attr_reader :use_story_points, :use_story_counts
|
18
|
+
attr_accessor :board_id
|
19
|
+
|
20
|
+
def initialize
|
21
|
+
super()
|
22
|
+
|
23
|
+
@summary_stats = {}
|
24
|
+
header_text 'Sprint burndown'
|
25
|
+
description_text ''
|
26
|
+
end
|
27
|
+
|
28
|
+
def options= arg
|
29
|
+
case arg
|
30
|
+
when :points_only
|
31
|
+
@use_story_points = true
|
32
|
+
@use_story_counts = false
|
33
|
+
when :counts_only
|
34
|
+
@use_story_points = false
|
35
|
+
@use_story_counts = true
|
36
|
+
when :points_and_counts
|
37
|
+
@use_story_points = true
|
38
|
+
@use_story_counts = true
|
39
|
+
else
|
40
|
+
raise "Unexpected option: #{arg}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def run
|
45
|
+
sprints = sprints_in_time_range all_boards[board_id]
|
46
|
+
return nil if sprints.empty?
|
47
|
+
|
48
|
+
change_data_by_sprint = {}
|
49
|
+
sprints.each do |sprint|
|
50
|
+
change_data = []
|
51
|
+
issues.each do |issue|
|
52
|
+
change_data += changes_for_one_issue(issue: issue, sprint: sprint)
|
53
|
+
end
|
54
|
+
change_data_by_sprint[sprint] = change_data.sort_by(&:time)
|
55
|
+
end
|
56
|
+
|
57
|
+
result = String.new
|
58
|
+
result << '<h1>Sprint Burndowns</h1>'
|
59
|
+
|
60
|
+
charts_to_generate = []
|
61
|
+
charts_to_generate << [:data_set_by_story_points, 'Story Points'] if @use_story_points
|
62
|
+
charts_to_generate << [:data_set_by_story_counts, 'Story Count'] if @use_story_counts
|
63
|
+
charts_to_generate.each do |data_method, y_axis_title|
|
64
|
+
@summary_stats.clear
|
65
|
+
data_sets = []
|
66
|
+
sprints.each_with_index do |sprint, index|
|
67
|
+
color = %w[blue orange green red brown][index % 5]
|
68
|
+
label = sprint.name
|
69
|
+
data = send(data_method, **{ sprint: sprint, change_data_for_sprint: change_data_by_sprint[sprint] })
|
70
|
+
data_sets << {
|
71
|
+
label: label,
|
72
|
+
data: data,
|
73
|
+
fill: false,
|
74
|
+
showLine: true,
|
75
|
+
borderColor: color,
|
76
|
+
backgroundColor: color,
|
77
|
+
stepped: true,
|
78
|
+
pointStyle: %w[rect circle] # First dot is visually different from the rest
|
79
|
+
}
|
80
|
+
end
|
81
|
+
|
82
|
+
legend = []
|
83
|
+
case data_method
|
84
|
+
when :data_set_by_story_counts
|
85
|
+
legend << '<b>Started</b>: Number of issues already in the sprint, when the sprint was started.'
|
86
|
+
legend << '<b>Completed</b>: Number of issues, completed during the sprint'
|
87
|
+
legend << '<b>Added</b>: Number of issues added in the middle of the sprint'
|
88
|
+
legend << '<b>Removed</b>: Number of issues removed while the sprint was in progress'
|
89
|
+
when :data_set_by_story_points
|
90
|
+
legend << '<b>Started</b>: Total count of story points when the sprint was started'
|
91
|
+
legend << '<b>Completed</b>: Count of story points completed during the sprint'
|
92
|
+
legend << '<b>Added</b>: Count of story points added in the middle of the sprint'
|
93
|
+
legend << '<b>Removed</b>: Count of story points removed while the sprint was in progress'
|
94
|
+
else
|
95
|
+
raise "Unexpected method #{data_method}"
|
96
|
+
end
|
97
|
+
|
98
|
+
result << render(binding, __FILE__)
|
99
|
+
end
|
100
|
+
|
101
|
+
result
|
102
|
+
end
|
103
|
+
|
104
|
+
# select all the changes that are relevant for the sprint. If this issue never appears in this sprint then return [].
|
105
|
+
def changes_for_one_issue issue:, sprint:
|
106
|
+
story_points = 0.0
|
107
|
+
ever_in_sprint = false
|
108
|
+
currently_in_sprint = false
|
109
|
+
change_data = []
|
110
|
+
|
111
|
+
issue_completed_time = issue.board.cycletime.stopped_time(issue)
|
112
|
+
completed_has_been_tracked = false
|
113
|
+
|
114
|
+
issue.changes.each do |change|
|
115
|
+
action = nil
|
116
|
+
value = nil
|
117
|
+
|
118
|
+
if change.sprint?
|
119
|
+
# We can get two sprint changes in a row that tell us the same thing so we have to verify
|
120
|
+
# that something actually changed.
|
121
|
+
in_change_item = sprint_in_change_item(sprint, change)
|
122
|
+
if currently_in_sprint == false && in_change_item
|
123
|
+
action = :enter_sprint
|
124
|
+
ever_in_sprint = true
|
125
|
+
value = story_points
|
126
|
+
elsif currently_in_sprint && in_change_item == false
|
127
|
+
action = :leave_sprint
|
128
|
+
value = -story_points
|
129
|
+
end
|
130
|
+
currently_in_sprint = in_change_item
|
131
|
+
elsif change.story_points? && (issue_completed_time.nil? || change.time < issue_completed_time)
|
132
|
+
action = :story_points
|
133
|
+
story_points = change.value&.to_f || 0.0
|
134
|
+
value = story_points - (change.old_value&.to_f || 0.0)
|
135
|
+
elsif completed_has_been_tracked == false && change.time == issue_completed_time
|
136
|
+
completed_has_been_tracked = true
|
137
|
+
action = :issue_stopped
|
138
|
+
value = -story_points
|
139
|
+
end
|
140
|
+
|
141
|
+
next unless action
|
142
|
+
|
143
|
+
change_data << SprintIssueChangeData.new(
|
144
|
+
time: change.time, issue: issue, action: action, value: value, story_points: story_points
|
145
|
+
)
|
146
|
+
end
|
147
|
+
|
148
|
+
return [] unless ever_in_sprint
|
149
|
+
|
150
|
+
change_data
|
151
|
+
end
|
152
|
+
|
153
|
+
def sprint_in_change_item sprint, change_item
|
154
|
+
change_item.raw['to'].split(/\s*,\s*/).any? { |id| id.to_i == sprint.id }
|
155
|
+
end
|
156
|
+
|
157
|
+
def data_set_by_story_points sprint:, change_data_for_sprint:
|
158
|
+
summary_stats = SprintSummaryStats.new
|
159
|
+
summary_stats.completed = 0.0
|
160
|
+
|
161
|
+
story_points = 0.0
|
162
|
+
start_data_written = false
|
163
|
+
data_set = []
|
164
|
+
|
165
|
+
issues_currently_in_sprint = []
|
166
|
+
|
167
|
+
change_data_for_sprint.each do |change_data|
|
168
|
+
if start_data_written == false && change_data.time >= sprint.start_time
|
169
|
+
data_set << {
|
170
|
+
y: story_points,
|
171
|
+
x: chart_format(sprint.start_time),
|
172
|
+
title: "Sprint started with #{story_points} points"
|
173
|
+
}
|
174
|
+
summary_stats.started = story_points
|
175
|
+
start_data_written = true
|
176
|
+
end
|
177
|
+
|
178
|
+
break if sprint.completed_time && change_data.time > sprint.completed_time
|
179
|
+
|
180
|
+
case change_data.action
|
181
|
+
when :enter_sprint
|
182
|
+
issues_currently_in_sprint << change_data.issue.key
|
183
|
+
story_points += change_data.story_points
|
184
|
+
when :leave_sprint
|
185
|
+
issues_currently_in_sprint.delete change_data.issue.key
|
186
|
+
story_points -= change_data.story_points
|
187
|
+
when :story_points
|
188
|
+
story_points += change_data.value if issues_currently_in_sprint.include? change_data.issue.key
|
189
|
+
end
|
190
|
+
|
191
|
+
next unless change_data.time >= sprint.start_time
|
192
|
+
|
193
|
+
message = nil
|
194
|
+
case change_data.action
|
195
|
+
when :story_points
|
196
|
+
next unless issues_currently_in_sprint.include? change_data.issue.key
|
197
|
+
|
198
|
+
old_story_points = change_data.story_points - change_data.value
|
199
|
+
message = "Story points changed from #{old_story_points} points to #{change_data.story_points} points"
|
200
|
+
summary_stats.points_values_changed = true
|
201
|
+
when :enter_sprint
|
202
|
+
message = "Added to sprint with #{change_data.story_points || 'no'} points"
|
203
|
+
summary_stats.added += change_data.story_points
|
204
|
+
when :issue_stopped
|
205
|
+
story_points -= change_data.story_points
|
206
|
+
message = "Completed with #{change_data.story_points || 'no'} points"
|
207
|
+
issues_currently_in_sprint.delete change_data.issue.key
|
208
|
+
summary_stats.completed += change_data.story_points
|
209
|
+
when :leave_sprint
|
210
|
+
message = "Removed from sprint with #{change_data.story_points || 'no'} points"
|
211
|
+
summary_stats.removed += change_data.story_points
|
212
|
+
else
|
213
|
+
raise "Unexpected action: #{change_data.action}"
|
214
|
+
end
|
215
|
+
|
216
|
+
data_set << {
|
217
|
+
y: story_points,
|
218
|
+
x: chart_format(change_data.time),
|
219
|
+
title: "#{change_data.issue.key} #{message}"
|
220
|
+
}
|
221
|
+
end
|
222
|
+
|
223
|
+
unless start_data_written
|
224
|
+
# There was nothing that triggered us to write the sprint started block so do it now.
|
225
|
+
data_set << {
|
226
|
+
y: story_points,
|
227
|
+
x: chart_format(sprint.start_time),
|
228
|
+
title: "Sprint started with #{story_points} points"
|
229
|
+
}
|
230
|
+
summary_stats.started = story_points
|
231
|
+
end
|
232
|
+
|
233
|
+
if sprint.completed_time
|
234
|
+
data_set << {
|
235
|
+
y: story_points,
|
236
|
+
x: chart_format(sprint.completed_time),
|
237
|
+
title: "Sprint ended with #{story_points} points unfinished"
|
238
|
+
}
|
239
|
+
summary_stats.remaining = story_points
|
240
|
+
end
|
241
|
+
|
242
|
+
unless sprint.completed_at?(time_range.end)
|
243
|
+
data_set << {
|
244
|
+
y: story_points,
|
245
|
+
x: chart_format(time_range.end),
|
246
|
+
title: "Sprint still active. #{story_points} points still in progress."
|
247
|
+
}
|
248
|
+
end
|
249
|
+
|
250
|
+
@summary_stats[sprint] = summary_stats
|
251
|
+
data_set
|
252
|
+
end
|
253
|
+
|
254
|
+
def data_set_by_story_counts sprint:, change_data_for_sprint:
|
255
|
+
summary_stats = SprintSummaryStats.new
|
256
|
+
|
257
|
+
data_set = []
|
258
|
+
issues_currently_in_sprint = []
|
259
|
+
start_data_written = false
|
260
|
+
|
261
|
+
change_data_for_sprint.each do |change_data|
|
262
|
+
if start_data_written == false && change_data.time >= sprint.start_time
|
263
|
+
data_set << {
|
264
|
+
y: issues_currently_in_sprint.size,
|
265
|
+
x: chart_format(sprint.start_time),
|
266
|
+
title: "Sprint started with #{issues_currently_in_sprint.size} stories"
|
267
|
+
}
|
268
|
+
summary_stats.started = issues_currently_in_sprint.size
|
269
|
+
start_data_written = true
|
270
|
+
end
|
271
|
+
|
272
|
+
break if sprint.completed_time && change_data.time > sprint.completed_time
|
273
|
+
|
274
|
+
case change_data.action
|
275
|
+
when :enter_sprint
|
276
|
+
issues_currently_in_sprint << change_data.issue.key
|
277
|
+
when :leave_sprint, :issue_stopped
|
278
|
+
issues_currently_in_sprint.delete change_data.issue.key
|
279
|
+
end
|
280
|
+
|
281
|
+
next unless change_data.time >= sprint.start_time
|
282
|
+
|
283
|
+
message = nil
|
284
|
+
case change_data.action
|
285
|
+
when :enter_sprint
|
286
|
+
message = 'Added to sprint'
|
287
|
+
summary_stats.added += 1
|
288
|
+
when :issue_stopped
|
289
|
+
message = 'Completed'
|
290
|
+
summary_stats.completed += 1
|
291
|
+
when :leave_sprint
|
292
|
+
message = 'Removed from sprint'
|
293
|
+
summary_stats.removed += 1
|
294
|
+
end
|
295
|
+
|
296
|
+
next unless message
|
297
|
+
|
298
|
+
data_set << {
|
299
|
+
y: issues_currently_in_sprint.size,
|
300
|
+
x: chart_format(change_data.time),
|
301
|
+
title: "#{change_data.issue.key} #{message}"
|
302
|
+
}
|
303
|
+
end
|
304
|
+
|
305
|
+
unless start_data_written
|
306
|
+
# There was nothing that triggered us to write the sprint started block so do it now.
|
307
|
+
data_set << {
|
308
|
+
y: issues_currently_in_sprint.size,
|
309
|
+
x: chart_format(sprint.start_time),
|
310
|
+
title: "Sprint started with #{issues_currently_in_sprint.size || 'no'} stories"
|
311
|
+
}
|
312
|
+
end
|
313
|
+
|
314
|
+
if sprint.completed_time
|
315
|
+
data_set << {
|
316
|
+
y: issues_currently_in_sprint.size,
|
317
|
+
x: chart_format(sprint.completed_time),
|
318
|
+
title: "Sprint ended with #{issues_currently_in_sprint.size} stories unfinished"
|
319
|
+
}
|
320
|
+
summary_stats.remaining = issues_currently_in_sprint.size
|
321
|
+
end
|
322
|
+
|
323
|
+
unless sprint.completed_at?(time_range.end)
|
324
|
+
# If the sprint is still active then we draw one final line to the end of the time range
|
325
|
+
data_set << {
|
326
|
+
y: issues_currently_in_sprint.size,
|
327
|
+
x: chart_format(time_range.end),
|
328
|
+
title: "Sprint still active. #{issues_currently_in_sprint.size} issues in progress."
|
329
|
+
}
|
330
|
+
end
|
331
|
+
|
332
|
+
@summary_stats[sprint] = summary_stats
|
333
|
+
data_set
|
334
|
+
end
|
335
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class SprintIssueChangeData
|
4
|
+
attr_reader :time, :action, :value, :issue, :story_points
|
5
|
+
|
6
|
+
def initialize time:, action:, value:, issue:, story_points:
|
7
|
+
@time = time
|
8
|
+
@action = action
|
9
|
+
@value = value
|
10
|
+
@issue = issue
|
11
|
+
@story_points = story_points
|
12
|
+
end
|
13
|
+
|
14
|
+
def eql?(other)
|
15
|
+
(other.class == self.class) && (other.state == state)
|
16
|
+
end
|
17
|
+
|
18
|
+
def state
|
19
|
+
instance_variables.map { |variable| instance_variable_get variable }
|
20
|
+
end
|
21
|
+
|
22
|
+
def inspect
|
23
|
+
result = String.new
|
24
|
+
result << 'SprintIssueChangeData('
|
25
|
+
result << instance_variables.collect do |variable|
|
26
|
+
"#{variable}=#{instance_variable_get(variable).inspect}"
|
27
|
+
end.sort.join(', ')
|
28
|
+
result << ')'
|
29
|
+
result
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Status
|
4
|
+
attr_reader :id, :type, :category_name, :category_id
|
5
|
+
attr_accessor :name
|
6
|
+
|
7
|
+
def initialize name:, id:, category_name:, category_id:
|
8
|
+
@name = name
|
9
|
+
@id = id
|
10
|
+
@category_name = category_name
|
11
|
+
@category_id = category_id
|
12
|
+
end
|
13
|
+
|
14
|
+
def to_s
|
15
|
+
"Status(name=#{@name.inspect}, id=#{@id.inspect}," \
|
16
|
+
" category_name=#{@category_name.inspect}, category_id=#{@category_id.inspect})"
|
17
|
+
end
|
18
|
+
|
19
|
+
def eql?(other)
|
20
|
+
(other.class == self.class) && (other.state == state)
|
21
|
+
end
|
22
|
+
|
23
|
+
def state
|
24
|
+
instance_variables.map { |variable| instance_variable_get variable }
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class StatusCollection
|
4
|
+
def initialize
|
5
|
+
@list = []
|
6
|
+
end
|
7
|
+
|
8
|
+
def filter_status_names category_name:, including: nil, excluding: nil
|
9
|
+
including = expand_statuses including
|
10
|
+
excluding = expand_statuses excluding
|
11
|
+
|
12
|
+
@list.collect do |status|
|
13
|
+
keep = status.category_name == category_name ||
|
14
|
+
including.any? { |s| s.name == status.name }
|
15
|
+
keep = false if excluding.any? { |s| s.name == status.name }
|
16
|
+
|
17
|
+
status.name if keep
|
18
|
+
end.compact
|
19
|
+
end
|
20
|
+
|
21
|
+
def expand_statuses names_or_ids
|
22
|
+
result = []
|
23
|
+
return result if names_or_ids.nil?
|
24
|
+
|
25
|
+
names_or_ids = [names_or_ids] unless names_or_ids.is_a? Array
|
26
|
+
|
27
|
+
names_or_ids.each do |name_or_id|
|
28
|
+
status = @list.find { |s| s.name == name_or_id || s.id == name_or_id }
|
29
|
+
if status.nil?
|
30
|
+
if block_given?
|
31
|
+
yield name_or_id
|
32
|
+
next
|
33
|
+
else
|
34
|
+
all_status_names = @list.collect { |s| "#{s.name.inspect}:#{s.id.inspect}" }.uniq.sort.join(', ')
|
35
|
+
raise "Status not found: #{name_or_id}. Possible statuses are: #{all_status_names}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
result << status
|
40
|
+
end
|
41
|
+
result
|
42
|
+
end
|
43
|
+
|
44
|
+
def todo including: nil, excluding: nil
|
45
|
+
filter_status_names category_name: 'To Do', including: including, excluding: excluding
|
46
|
+
end
|
47
|
+
|
48
|
+
def in_progress including: nil, excluding: nil
|
49
|
+
filter_status_names category_name: 'In Progress', including: including, excluding: excluding
|
50
|
+
end
|
51
|
+
|
52
|
+
def done including: nil, excluding: nil
|
53
|
+
filter_status_names category_name: 'Done', including: including, excluding: excluding
|
54
|
+
end
|
55
|
+
|
56
|
+
def find_by_name name
|
57
|
+
find { |status| status.name == name }
|
58
|
+
end
|
59
|
+
|
60
|
+
def find(&block)= @list.find(&block)
|
61
|
+
def collect(&block) = @list.collect(&block)
|
62
|
+
def each(&block) = @list.each(&block)
|
63
|
+
def select(&block) = @list.select(&block)
|
64
|
+
def <<(arg) = @list << arg
|
65
|
+
def empty? = @list.empty?
|
66
|
+
def clear = @list.clear
|
67
|
+
end
|
@@ -0,0 +1,139 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class StoryPointAccuracyChart < ChartBase
|
4
|
+
def initialize configuration_block = nil
|
5
|
+
super()
|
6
|
+
|
7
|
+
header_text 'Estimate Accuracy'
|
8
|
+
description_text <<-HTML
|
9
|
+
<p>
|
10
|
+
This chart graphs estimates against actual recorded cycle times. Since
|
11
|
+
estimates can change over time, we're graphing the estimate at the time that the story started.
|
12
|
+
</p>
|
13
|
+
<p>
|
14
|
+
The completed dots indicate cycletimes. The aging dots (if you turn them on) show the current
|
15
|
+
age of items, which will give you a hint as to where they might end up. If they're already
|
16
|
+
far to the right then you know you have a problem.
|
17
|
+
</p>
|
18
|
+
HTML
|
19
|
+
|
20
|
+
@y_axis_label = 'Story Point Estimates'
|
21
|
+
@y_axis_type = 'linear'
|
22
|
+
@y_axis_block = ->(issue, start_time) { story_points_at(issue: issue, start_time: start_time)&.to_f }
|
23
|
+
@y_axis_sort_order = nil
|
24
|
+
|
25
|
+
instance_eval(&configuration_block) if configuration_block
|
26
|
+
end
|
27
|
+
|
28
|
+
def run
|
29
|
+
data_sets = scan_issues
|
30
|
+
|
31
|
+
return '' if data_sets.empty?
|
32
|
+
|
33
|
+
wrap_and_render(binding, __FILE__)
|
34
|
+
end
|
35
|
+
|
36
|
+
def scan_issues
|
37
|
+
aging_hash = {}
|
38
|
+
completed_hash = {}
|
39
|
+
|
40
|
+
issues.each do |issue|
|
41
|
+
cycletime = issue.board.cycletime
|
42
|
+
start_time = cycletime.started_time(issue)
|
43
|
+
stop_time = cycletime.stopped_time(issue)
|
44
|
+
|
45
|
+
next unless start_time
|
46
|
+
|
47
|
+
hash = stop_time ? completed_hash : aging_hash
|
48
|
+
|
49
|
+
estimate = @y_axis_block.call issue, start_time
|
50
|
+
cycle_time = ((stop_time&.to_date || date_range.end) - start_time.to_date).to_i + 1
|
51
|
+
|
52
|
+
next if estimate.nil?
|
53
|
+
|
54
|
+
key = [estimate, cycle_time]
|
55
|
+
(hash[key] ||= []) << issue
|
56
|
+
end
|
57
|
+
|
58
|
+
[
|
59
|
+
[completed_hash, 'Completed', '#66FF99', 'green', false],
|
60
|
+
[aging_hash, 'Still in progress', '#FFCCCB', 'red', true]
|
61
|
+
].collect do |hash, label, fill_color, border_color, starts_hidden|
|
62
|
+
# We sort so that the smaller circles are in front of the bigger circles.
|
63
|
+
data = hash.sort(&hash_sorter).collect do |key, values|
|
64
|
+
estimate, cycle_time = *key
|
65
|
+
estimate_label = "#{estimate}#{'pts' if @y_axis_type == 'linear'}"
|
66
|
+
title = ["Estimate: #{estimate_label}, Cycletime: #{label_days(cycle_time)}, #{values.size} issues"] +
|
67
|
+
values.collect { |issue| "#{issue.key}: #{issue.summary}" }
|
68
|
+
{
|
69
|
+
'x' => cycle_time,
|
70
|
+
'y' => estimate,
|
71
|
+
'r' => values.size * 2,
|
72
|
+
'title' => title
|
73
|
+
}
|
74
|
+
end.compact
|
75
|
+
next if data.empty?
|
76
|
+
|
77
|
+
{
|
78
|
+
'label' => label,
|
79
|
+
'data' => data,
|
80
|
+
'fill' => false,
|
81
|
+
'showLine' => false,
|
82
|
+
'backgroundColor' => fill_color,
|
83
|
+
'borderColor' => border_color,
|
84
|
+
'hidden' => starts_hidden
|
85
|
+
}
|
86
|
+
end.compact
|
87
|
+
end
|
88
|
+
|
89
|
+
def hash_sorter
|
90
|
+
lambda do |arg1, arg2|
|
91
|
+
estimate1 = arg1[0][0]
|
92
|
+
estimate2 = arg2[0][0]
|
93
|
+
sample_count1 = arg1.size
|
94
|
+
sample_count2 = arg2.size
|
95
|
+
|
96
|
+
if @y_axis_sort_order
|
97
|
+
index1 = @y_axis_sort_order.index estimate1
|
98
|
+
index2 = @y_axis_sort_order.index estimate2
|
99
|
+
|
100
|
+
if index1.nil?
|
101
|
+
comparison = 1
|
102
|
+
elsif index2.nil?
|
103
|
+
comparison = -1
|
104
|
+
else
|
105
|
+
comparison = index1 <=> index2
|
106
|
+
end
|
107
|
+
return comparison unless comparison.zero?
|
108
|
+
end
|
109
|
+
|
110
|
+
sample_count2 <=> sample_count1
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def story_points_at issue:, start_time:
|
115
|
+
story_points = nil
|
116
|
+
issue.changes.each do |change|
|
117
|
+
return story_points if change.time >= start_time
|
118
|
+
|
119
|
+
story_points = change.value if change.story_points?
|
120
|
+
end
|
121
|
+
story_points
|
122
|
+
end
|
123
|
+
|
124
|
+
def grouping range:, color: # rubocop:disable Lint/UnusedMethodArgument
|
125
|
+
deprecated message: 'The grouping declaration is no longer supported on the StoryPointEstimateChart ' \
|
126
|
+
'as we now use a bubble chart rather than colors'
|
127
|
+
end
|
128
|
+
|
129
|
+
def y_axis label:, sort_order: nil, &block
|
130
|
+
@y_axis_sort_order = sort_order
|
131
|
+
@y_axis_label = label
|
132
|
+
if sort_order
|
133
|
+
@y_axis_type = 'category'
|
134
|
+
else
|
135
|
+
@y_axis_type = 'linear'
|
136
|
+
end
|
137
|
+
@y_axis_block = block
|
138
|
+
end
|
139
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class ThroughputChart < ChartBase
|
4
|
+
include GroupableIssueChart
|
5
|
+
|
6
|
+
attr_accessor :possible_statuses
|
7
|
+
|
8
|
+
def initialize block = nil
|
9
|
+
super()
|
10
|
+
|
11
|
+
header_text 'Throughput Chart'
|
12
|
+
description_text 'This chart shows how many items we completed per unit of time'
|
13
|
+
|
14
|
+
init_configuration_block(block) do
|
15
|
+
grouping_rules do |issue, rule|
|
16
|
+
rule.label = issue.type
|
17
|
+
rule.color = color_for type: issue.type
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def run
|
23
|
+
completed_issues = completed_issues_in_range include_unstarted: true
|
24
|
+
rules_to_issues = group_issues completed_issues
|
25
|
+
data_sets = []
|
26
|
+
if rules_to_issues.size > 1
|
27
|
+
data_sets << weekly_throughput_dataset(
|
28
|
+
completed_issues: completed_issues, label: 'Totals', color: 'gray', dashed: true
|
29
|
+
)
|
30
|
+
end
|
31
|
+
|
32
|
+
rules_to_issues.each_key do |rules|
|
33
|
+
data_sets << weekly_throughput_dataset(
|
34
|
+
completed_issues: rules_to_issues[rules], label: rules.label, color: rules.color
|
35
|
+
)
|
36
|
+
end
|
37
|
+
|
38
|
+
wrap_and_render(binding, __FILE__)
|
39
|
+
end
|
40
|
+
|
41
|
+
def calculate_time_periods
|
42
|
+
first_day = @date_range.begin
|
43
|
+
first_day = case first_day.wday
|
44
|
+
when 0 then first_day + 1
|
45
|
+
when 1 then first_day
|
46
|
+
else first_day + (8 - first_day.wday)
|
47
|
+
end
|
48
|
+
|
49
|
+
periods = []
|
50
|
+
|
51
|
+
loop do
|
52
|
+
last_day = first_day + 6
|
53
|
+
return periods unless @date_range.include? last_day
|
54
|
+
|
55
|
+
periods << (first_day..last_day)
|
56
|
+
first_day = last_day + 1
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def weekly_throughput_dataset completed_issues:, label:, color:, dashed: false
|
61
|
+
result = {
|
62
|
+
label: label,
|
63
|
+
data: throughput_dataset(periods: calculate_time_periods, completed_issues: completed_issues),
|
64
|
+
fill: false,
|
65
|
+
showLine: true,
|
66
|
+
borderColor: color,
|
67
|
+
lineTension: 0.4,
|
68
|
+
backgroundColor: color
|
69
|
+
}
|
70
|
+
result['borderDash'] = [10, 5] if dashed
|
71
|
+
result
|
72
|
+
end
|
73
|
+
|
74
|
+
def throughput_dataset periods:, completed_issues:
|
75
|
+
periods.collect do |period|
|
76
|
+
closed_issues = completed_issues.collect do |issue|
|
77
|
+
stop_date = issue.board.cycletime.stopped_time(issue)&.to_date
|
78
|
+
[stop_date, issue] if stop_date && period.include?(stop_date)
|
79
|
+
end.compact
|
80
|
+
|
81
|
+
date_label = "on #{period.end}"
|
82
|
+
date_label = "between #{period.begin} and #{period.end}" unless period.begin == period.end
|
83
|
+
|
84
|
+
{ y: closed_issues.size,
|
85
|
+
x: "#{period.end}T23:59:59",
|
86
|
+
title: ["#{closed_issues.size} items completed #{date_label}"] +
|
87
|
+
closed_issues.collect { |_stop_date, issue| "#{issue.key} : #{issue.summary}" }
|
88
|
+
}
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|