jirametrics 1.0.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 +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
|