jirametrics 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/bin/jirametrics +4 -0
  3. data/lib/jirametrics/aggregate_config.rb +89 -0
  4. data/lib/jirametrics/aging_work_bar_chart.rb +235 -0
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +148 -0
  6. data/lib/jirametrics/aging_work_table.rb +149 -0
  7. data/lib/jirametrics/anonymizer.rb +186 -0
  8. data/lib/jirametrics/blocked_stalled_change.rb +43 -0
  9. data/lib/jirametrics/board.rb +85 -0
  10. data/lib/jirametrics/board_column.rb +14 -0
  11. data/lib/jirametrics/board_config.rb +31 -0
  12. data/lib/jirametrics/change_item.rb +80 -0
  13. data/lib/jirametrics/chart_base.rb +239 -0
  14. data/lib/jirametrics/columns_config.rb +42 -0
  15. data/lib/jirametrics/cycletime_config.rb +69 -0
  16. data/lib/jirametrics/cycletime_histogram.rb +74 -0
  17. data/lib/jirametrics/cycletime_scatterplot.rb +128 -0
  18. data/lib/jirametrics/daily_wip_by_age_chart.rb +88 -0
  19. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +77 -0
  20. data/lib/jirametrics/daily_wip_chart.rb +123 -0
  21. data/lib/jirametrics/data_quality_report.rb +278 -0
  22. data/lib/jirametrics/dependency_chart.rb +217 -0
  23. data/lib/jirametrics/discard_changes_before.rb +37 -0
  24. data/lib/jirametrics/download_config.rb +41 -0
  25. data/lib/jirametrics/downloader.rb +337 -0
  26. data/lib/jirametrics/examples/aggregated_project.rb +36 -0
  27. data/lib/jirametrics/examples/standard_project.rb +111 -0
  28. data/lib/jirametrics/expedited_chart.rb +169 -0
  29. data/lib/jirametrics/experimental/generator.rb +209 -0
  30. data/lib/jirametrics/experimental/info.rb +77 -0
  31. data/lib/jirametrics/exporter.rb +127 -0
  32. data/lib/jirametrics/file_config.rb +119 -0
  33. data/lib/jirametrics/fix_version.rb +21 -0
  34. data/lib/jirametrics/groupable_issue_chart.rb +44 -0
  35. data/lib/jirametrics/grouping_rules.rb +13 -0
  36. data/lib/jirametrics/hierarchy_table.rb +31 -0
  37. data/lib/jirametrics/html/aging_work_bar_chart.erb +72 -0
  38. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +52 -0
  39. data/lib/jirametrics/html/aging_work_table.erb +60 -0
  40. data/lib/jirametrics/html/collapsible_issues_panel.erb +32 -0
  41. data/lib/jirametrics/html/cycletime_histogram.erb +41 -0
  42. data/lib/jirametrics/html/cycletime_scatterplot.erb +103 -0
  43. data/lib/jirametrics/html/daily_wip_chart.erb +63 -0
  44. data/lib/jirametrics/html/data_quality_report.erb +126 -0
  45. data/lib/jirametrics/html/expedited_chart.erb +67 -0
  46. data/lib/jirametrics/html/hierarchy_table.erb +29 -0
  47. data/lib/jirametrics/html/index.erb +66 -0
  48. data/lib/jirametrics/html/sprint_burndown.erb +116 -0
  49. data/lib/jirametrics/html/story_point_accuracy_chart.erb +57 -0
  50. data/lib/jirametrics/html/throughput_chart.erb +65 -0
  51. data/lib/jirametrics/html_report_config.rb +217 -0
  52. data/lib/jirametrics/issue.rb +521 -0
  53. data/lib/jirametrics/issue_link.rb +60 -0
  54. data/lib/jirametrics/json_file_loader.rb +9 -0
  55. data/lib/jirametrics/project_config.rb +442 -0
  56. data/lib/jirametrics/rules.rb +34 -0
  57. data/lib/jirametrics/self_or_issue_dispatcher.rb +15 -0
  58. data/lib/jirametrics/sprint.rb +43 -0
  59. data/lib/jirametrics/sprint_burndown.rb +335 -0
  60. data/lib/jirametrics/sprint_issue_change_data.rb +31 -0
  61. data/lib/jirametrics/status.rb +26 -0
  62. data/lib/jirametrics/status_collection.rb +67 -0
  63. data/lib/jirametrics/story_point_accuracy_chart.rb +139 -0
  64. data/lib/jirametrics/throughput_chart.rb +91 -0
  65. data/lib/jirametrics/tree_organizer.rb +96 -0
  66. data/lib/jirametrics/trend_line_calculator.rb +74 -0
  67. data/lib/jirametrics.rb +85 -0
  68. 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