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.
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