jirametrics 2.31 → 3.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +42 -29
  3. data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
  5. data/lib/jirametrics/aging_work_table.rb +36 -33
  6. data/lib/jirametrics/anonymizer.rb +31 -86
  7. data/lib/jirametrics/atlassian_document_format.rb +11 -4
  8. data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
  9. data/lib/jirametrics/blocked_stalled_change.rb +5 -1
  10. data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
  11. data/lib/jirametrics/board.rb +23 -9
  12. data/lib/jirametrics/board_config.rb +0 -7
  13. data/lib/jirametrics/board_movement_calculator.rb +24 -21
  14. data/lib/jirametrics/cfd_data_builder.rb +46 -44
  15. data/lib/jirametrics/change_item.rb +19 -7
  16. data/lib/jirametrics/chart_base.rb +50 -64
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +59 -46
  18. data/lib/jirametrics/cycle_time_config.rb +35 -46
  19. data/lib/jirametrics/cycletime_histogram.rb +7 -0
  20. data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
  21. data/lib/jirametrics/daily_view.rb +83 -61
  22. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
  23. data/lib/jirametrics/daily_wip_chart.rb +72 -45
  24. data/lib/jirametrics/data_quality_report.rb +61 -63
  25. data/lib/jirametrics/dependency_chart.rb +40 -31
  26. data/lib/jirametrics/download_config.rb +0 -3
  27. data/lib/jirametrics/downloader.rb +8 -7
  28. data/lib/jirametrics/downloader_for_cloud.rb +108 -72
  29. data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
  30. data/lib/jirametrics/examples/standard_project.rb +2 -2
  31. data/lib/jirametrics/expedited_chart.rb +47 -37
  32. data/lib/jirametrics/exporter.rb +19 -10
  33. data/lib/jirametrics/file_config.rb +19 -12
  34. data/lib/jirametrics/file_system.rb +16 -10
  35. data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
  36. data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
  37. data/lib/jirametrics/github_gateway.rb +23 -9
  38. data/lib/jirametrics/groupable_issue_chart.rb +3 -0
  39. data/lib/jirametrics/html/index.css +6 -0
  40. data/lib/jirametrics/html_report_config.rb +9 -29
  41. data/lib/jirametrics/issue.rb +290 -389
  42. data/lib/jirametrics/issue_collection.rb +1 -0
  43. data/lib/jirametrics/issue_printer.rb +60 -23
  44. data/lib/jirametrics/jira_gateway.rb +29 -18
  45. data/lib/jirametrics/mcp_server.rb +225 -209
  46. data/lib/jirametrics/project_config.rb +158 -134
  47. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
  48. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
  49. data/lib/jirametrics/rules.rb +1 -0
  50. data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
  51. data/lib/jirametrics/sprint_burndown.rb +142 -242
  52. data/lib/jirametrics/sprint_count_measure.rb +42 -0
  53. data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
  54. data/lib/jirametrics/sprint_points_measure.rb +62 -0
  55. data/lib/jirametrics/sprint_summary_stats.rb +16 -0
  56. data/lib/jirametrics/status_collection.rb +2 -2
  57. data/lib/jirametrics/stitcher.rb +21 -16
  58. data/lib/jirametrics/throughput_chart.rb +38 -24
  59. data/lib/jirametrics/time_based_chart.rb +65 -0
  60. data/lib/jirametrics/time_based_histogram.rb +30 -23
  61. data/lib/jirametrics/time_based_scatterplot.rb +11 -2
  62. data/lib/jirametrics/trend_line_calculator.rb +2 -2
  63. data/lib/jirametrics/wip_by_column_chart.rb +73 -32
  64. data/lib/jirametrics.rb +6 -2
  65. metadata +13 -20
@@ -1,20 +1,7 @@
1
1
  # frozen_string_literal: true
2
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
3
  class SprintBurndown < ChartBase
17
- attr_reader :use_story_points, :use_story_counts
4
+ attr_reader :use_story_points, :use_story_counts, :summary_stats
18
5
  attr_accessor :board_id
19
6
 
20
7
  def initialize
@@ -53,112 +40,118 @@ class SprintBurndown < ChartBase
53
40
  return nil unless current_board.scrum?
54
41
 
55
42
  sprints = sprints_in_time_range current_board
56
-
57
- change_data_by_sprint = {}
58
- sprints.each do |sprint|
59
- change_data = []
60
- issues.each do |issue|
61
- change_data += changes_for_one_issue(issue: issue, sprint: sprint)
62
- end
63
- change_data_by_sprint[sprint] = change_data.sort_by(&:time)
64
- end
43
+ change_data_by_sprint = gather_change_data_by_sprint sprints
65
44
 
66
45
  result = +''
67
46
  result << render_top_text(binding)
68
47
 
69
- possible_colours = (1..ChartBase::OKABE_ITO_PALETTE.size).collect { |i| CssVariable["--sprint-burndown-sprint-color-#{i}"] }
70
- charts_to_generate = []
71
- charts_to_generate << [:data_set_by_story_points, 'Story Points'] if @use_story_points
72
- charts_to_generate << [:data_set_by_story_counts, 'Story Count'] if @use_story_counts
48
+ # HashEachMethods misreads this array of [method, title] pairs as a hash and thinks y_axis_title
49
+ # is unused; in fact the ERB template reads it (and data_method) from the binding we pass to render.
73
50
  charts_to_generate.each do |data_method, y_axis_title| # rubocop:disable Style/HashEachMethods
74
51
  @summary_stats.clear
75
- data_sets = []
76
- sprints.each_with_index do |sprint, index|
77
- color = possible_colours[index % possible_colours.size]
78
- label = sprint.name
79
- data = send(data_method, sprint: sprint, change_data_for_sprint: change_data_by_sprint[sprint])
80
- data_sets << {
81
- label: label,
82
- data: data,
83
- fill: false,
84
- showLine: true,
85
- borderColor: color,
86
- backgroundColor: color,
87
- stepped: true,
88
- pointStyle: %w[rect circle] # First dot is visually different from the rest
89
- }
90
- end
91
-
92
- legend = []
93
- case data_method
94
- when :data_set_by_story_counts
95
- legend << '<b>Started</b>: Number of issues already in the sprint, when the sprint was started.'
96
- legend << '<b>Completed</b>: Number of issues, completed during the sprint'
97
- legend << '<b>Added</b>: Number of issues added in the middle of the sprint'
98
- legend << '<b>Removed</b>: Number of issues removed while the sprint was in progress'
99
- when :data_set_by_story_points
100
- legend << '<b>Started</b>: Total count of story points when the sprint was started'
101
- legend << '<b>Completed</b>: Count of story points completed during the sprint'
102
- legend << '<b>Added</b>: Count of story points added in the middle of the sprint'
103
- legend << '<b>Removed</b>: Count of story points removed while the sprint was in progress'
104
- else
105
- raise "Unexpected method #{data_method}"
106
- end
107
-
52
+ data_sets = sprint_data_sets(
53
+ data_method: data_method, sprints: sprints, change_data_by_sprint: change_data_by_sprint
54
+ )
55
+ legend = legend_for data_method
108
56
  result << render(binding, __FILE__)
109
57
  end
110
58
 
111
59
  result
112
60
  end
113
61
 
62
+ # Every sprint's changes, flattened across all issues and sorted into time order.
63
+ def gather_change_data_by_sprint sprints
64
+ sprints.to_h do |sprint|
65
+ change_data = issues.flat_map { |issue| changes_for_one_issue(issue: issue, sprint: sprint) }
66
+ [sprint, change_data.sort_by(&:time)]
67
+ end
68
+ end
69
+
70
+ # The [data-builder method, y-axis label] pairs for whichever burndown measures are turned on.
71
+ def charts_to_generate
72
+ charts = []
73
+ charts << [:data_set_by_story_points, 'Story Points'] if @use_story_points
74
+ charts << [:data_set_by_story_counts, 'Story Count'] if @use_story_counts
75
+ charts
76
+ end
77
+
78
+ # One Chart.js data set per sprint, each in its own colour from the palette.
79
+ def sprint_data_sets data_method:, sprints:, change_data_by_sprint:
80
+ possible_colours = (1..ChartBase::OKABE_ITO_PALETTE.size).collect do |i|
81
+ CssVariable["--sprint-burndown-sprint-color-#{i}"]
82
+ end
83
+ sprints.each_with_index.collect do |sprint, index|
84
+ color = possible_colours[index % possible_colours.size]
85
+ {
86
+ label: sprint.name,
87
+ data: send(data_method, sprint: sprint, change_data_for_sprint: change_data_by_sprint[sprint]),
88
+ fill: false,
89
+ showLine: true,
90
+ borderColor: color,
91
+ backgroundColor: color,
92
+ stepped: true,
93
+ pointStyle: %w[rect circle] # First dot is visually different from the rest
94
+ }
95
+ end
96
+ end
97
+
98
+ def legend_for data_method
99
+ case data_method
100
+ when :data_set_by_story_counts
101
+ ['<b>Started</b>: Number of issues already in the sprint, when the sprint was started.',
102
+ '<b>Completed</b>: Number of issues, completed during the sprint',
103
+ '<b>Added</b>: Number of issues added in the middle of the sprint',
104
+ '<b>Removed</b>: Number of issues removed while the sprint was in progress']
105
+ when :data_set_by_story_points
106
+ ['<b>Started</b>: Total count of story points when the sprint was started',
107
+ '<b>Completed</b>: Count of story points completed during the sprint',
108
+ '<b>Added</b>: Count of story points added in the middle of the sprint',
109
+ '<b>Removed</b>: Count of story points removed while the sprint was in progress']
110
+ else
111
+ raise "Unexpected method #{data_method}"
112
+ end
113
+ end
114
+
114
115
  def sprints_in_time_range board
115
- board.sprints.select do |sprint|
116
- # If it's never been started then it's just a holding area. Ignore it.
117
- next if sprint.future?
116
+ board.sprints.select { |sprint| sprint_in_time_range? sprint }
117
+ end
118
118
 
119
- sprint_end_time = sprint.completed_time || sprint.end_time
120
- sprint_start_time = sprint.start_time
121
- next false if sprint_start_time.nil?
119
+ # A sprint counts when it has actually started (future and never-started sprints are just holding
120
+ # areas, so we ignore them) and its active span overlaps the chart's time range.
121
+ def sprint_in_time_range? sprint
122
+ return false if sprint.future?
122
123
 
123
- time_range.include?(sprint_start_time) || time_range.include?(sprint_end_time) ||
124
- (sprint_start_time < time_range.begin && sprint_end_time > time_range.end)
125
- end || []
124
+ sprint_start_time = sprint.start_time
125
+ return false if sprint_start_time.nil?
126
+
127
+ sprint_end_time = sprint.completed_time || sprint.end_time
128
+ time_range.include?(sprint_start_time) || time_range.include?(sprint_end_time) ||
129
+ (sprint_start_time < time_range.begin && sprint_end_time > time_range.end)
126
130
  end
127
131
 
128
132
  # select all the changes that are relevant for the sprint. If this issue never appears in this sprint then return [].
129
133
  def changes_for_one_issue issue:, sprint:
130
134
  estimate = 0.0
131
- ever_in_sprint = false
132
135
  currently_in_sprint = false
136
+ completed_has_been_tracked = false
133
137
  change_data = []
134
138
 
135
139
  estimate_display_name = current_board.estimation_configuration.display_name
136
-
137
140
  issue_completed_time = issue.started_stopped_times.last
138
- completed_has_been_tracked = false
139
141
 
140
142
  issue.changes.each do |change|
141
143
  action = nil
142
144
  value = nil
143
145
 
144
146
  if change.sprint?
145
- # We can get two sprint changes in a row that tell us the same thing so we have to verify
146
- # that something actually changed.
147
147
  in_change_item = sprint_in_change_item(sprint, change)
148
- if currently_in_sprint == false && in_change_item
149
- action = :enter_sprint
150
- ever_in_sprint = true
151
- value = estimate
152
- elsif currently_in_sprint && in_change_item == false
153
- action = :leave_sprint
154
- value = -estimate
155
- end
148
+ action, value = sprint_change_action(in_change_item, currently_in_sprint, estimate)
156
149
  currently_in_sprint = in_change_item
157
- elsif change.field == estimate_display_name && (issue_completed_time.nil? || change.time < issue_completed_time)
150
+ elsif estimate_change_before_completion?(change, estimate_display_name, issue_completed_time)
158
151
  action = :story_points
159
152
  estimate = change.value.to_f
160
153
  value = estimate - change.old_value.to_f
161
- elsif completed_has_been_tracked == false && change.time == issue_completed_time
154
+ elsif reached_completion?(completed_has_been_tracked, change, issue_completed_time)
162
155
  completed_has_been_tracked = true
163
156
  action = :issue_stopped
164
157
  value = -estimate
@@ -171,191 +164,98 @@ class SprintBurndown < ChartBase
171
164
  )
172
165
  end
173
166
 
174
- return [] unless ever_in_sprint
167
+ ever_entered_sprint?(change_data) ? change_data : []
168
+ end
175
169
 
176
- change_data
170
+ # Two sprint changes in a row can say the same thing, so an event only fires when the membership
171
+ # actually flips: entering (was out, now in) or leaving (was in, now out).
172
+ def sprint_change_action in_change_item, currently_in_sprint, estimate
173
+ return [:enter_sprint, estimate] if currently_in_sprint == false && in_change_item
174
+ return [:leave_sprint, -estimate] if currently_in_sprint && in_change_item == false
175
+
176
+ [nil, nil]
177
+ end
178
+
179
+ def estimate_change_before_completion? change, estimate_display_name, issue_completed_time
180
+ change.field == estimate_display_name && (issue_completed_time.nil? || change.time < issue_completed_time)
181
+ end
182
+
183
+ def reached_completion? completed_has_been_tracked, change, issue_completed_time
184
+ completed_has_been_tracked == false && change.time == issue_completed_time
185
+ end
186
+
187
+ def ever_entered_sprint? change_data
188
+ change_data.any? { |data| data.action == :enter_sprint }
177
189
  end
178
190
 
179
191
  def sprint_in_change_item sprint, change_item
180
192
  change_item.raw['to'].split(/\s*,\s*/).any? { |id| id.to_i == sprint.id }
181
193
  end
182
194
 
183
- def data_set_by_story_points sprint:, change_data_for_sprint: # rubocop:disable Metrics/CyclomaticComplexity
184
- summary_stats = SprintSummaryStats.new
185
- summary_stats.completed = 0.0
195
+ def data_set_by_story_points sprint:, change_data_for_sprint:
196
+ build_sprint_data_set(
197
+ sprint: sprint, change_data_for_sprint: change_data_for_sprint, measure: SprintPointsMeasure.new
198
+ )
199
+ end
186
200
 
187
- estimate = 0.0
188
- start_data_written = false
189
- data_set = []
201
+ def data_set_by_story_counts sprint:, change_data_for_sprint:
202
+ build_sprint_data_set(
203
+ sprint: sprint, change_data_for_sprint: change_data_for_sprint, measure: SprintCountMeasure.new
204
+ )
205
+ end
190
206
 
191
- issues_currently_in_sprint = []
207
+ # Walks the sprint's change stream once. The measure decides how each change accumulates and reads
208
+ # out; the loop structure is shared by both the story-points and story-counts views.
209
+ def build_sprint_data_set sprint:, change_data_for_sprint:, measure:
210
+ data_set = []
211
+ start_written = false
192
212
 
193
213
  change_data_for_sprint.each do |change_data|
194
- if start_data_written == false && change_data.time >= sprint.start_time
195
- data_set << {
196
- y: estimate,
197
- x: chart_format(sprint.start_time),
198
- title: "Sprint started with #{estimate} points"
199
- }
200
- summary_stats.started = estimate
201
- start_data_written = true
202
- end
203
-
204
- break if sprint.completed_time && change_data.time > sprint.completed_time
205
-
206
- case change_data.action
207
- when :enter_sprint
208
- issues_currently_in_sprint << change_data.issue.key
209
- estimate += change_data.estimate
210
- when :leave_sprint
211
- issues_currently_in_sprint.delete change_data.issue.key
212
- estimate -= change_data.estimate
213
- when :story_points
214
- estimate += change_data.value if issues_currently_in_sprint.include? change_data.issue.key
214
+ if start_pending?(start_written, change_data, sprint)
215
+ data_set << sprint_start_point(measure, sprint)
216
+ start_written = true
215
217
  end
218
+ break if past_sprint_end?(change_data, sprint)
216
219
 
220
+ measure.update_state change_data
217
221
  next unless change_data.time >= sprint.start_time
218
222
 
219
- message = nil
220
- case change_data.action
221
- when :story_points
222
- next unless issues_currently_in_sprint.include? change_data.issue.key
223
-
224
- old_estimate = change_data.estimate - change_data.value
225
- message = "Story points changed from #{old_estimate} points to #{change_data.estimate} points"
226
- summary_stats.points_values_changed = true
227
- when :enter_sprint
228
- message = "Added to sprint with #{change_data.estimate || 'no'} points"
229
- summary_stats.added += change_data.estimate
230
- when :issue_stopped
231
- estimate -= change_data.estimate
232
- message = "Completed with #{change_data.estimate || 'no'} points"
233
- issues_currently_in_sprint.delete change_data.issue.key
234
- summary_stats.completed += change_data.estimate
235
- when :leave_sprint
236
- message = "Removed from sprint with #{change_data.estimate || 'no'} points"
237
- summary_stats.removed += change_data.estimate
238
- else
239
- raise "Unexpected action: #{change_data.action}"
240
- end
241
-
242
- data_set << {
243
- y: estimate,
244
- x: chart_format(change_data.time),
245
- title: "#{change_data.issue.key} #{message}"
246
- }
247
- end
248
-
249
- unless start_data_written
250
- # There was nothing that triggered us to write the sprint started block so do it now.
251
- data_set << {
252
- y: estimate,
253
- x: chart_format(sprint.start_time),
254
- title: "Sprint started with #{estimate} points"
255
- }
256
- summary_stats.started = estimate
257
- end
258
-
259
- if sprint.completed_time
260
- data_set << {
261
- y: estimate,
262
- x: chart_format(sprint.completed_time),
263
- title: "Sprint ended with #{estimate} points unfinished"
264
- }
265
- summary_stats.remaining = estimate
223
+ message = measure.record change_data
224
+ data_set << change_point(measure, change_data, message) if message
266
225
  end
267
226
 
268
- unless sprint.completed_at?(time_range.end)
269
- data_set << {
270
- y: estimate,
271
- x: chart_format(time_range.end),
272
- title: "Sprint still active. #{estimate} points still in progress."
273
- }
274
- end
275
-
276
- @summary_stats[sprint] = summary_stats
227
+ append_closing_points data_set, measure, sprint, start_written
228
+ @summary_stats[sprint] = measure.summary_stats
277
229
  data_set
278
230
  end
279
231
 
280
- def data_set_by_story_counts sprint:, change_data_for_sprint:
281
- summary_stats = SprintSummaryStats.new
282
-
283
- data_set = []
284
- issues_currently_in_sprint = []
285
- start_data_written = false
286
-
287
- change_data_for_sprint.each do |change_data|
288
- if start_data_written == false && change_data.time >= sprint.start_time
289
- data_set << {
290
- y: issues_currently_in_sprint.size,
291
- x: chart_format(sprint.start_time),
292
- title: "Sprint started with #{issues_currently_in_sprint.size} stories"
293
- }
294
- summary_stats.started = issues_currently_in_sprint.size
295
- start_data_written = true
296
- end
297
-
298
- break if sprint.completed_time && change_data.time > sprint.completed_time
299
-
300
- case change_data.action
301
- when :enter_sprint
302
- issues_currently_in_sprint << change_data.issue.key
303
- when :leave_sprint, :issue_stopped
304
- issues_currently_in_sprint.delete change_data.issue.key
305
- end
306
-
307
- next unless change_data.time >= sprint.start_time
232
+ def start_pending? start_written, change_data, sprint
233
+ !start_written && change_data.time >= sprint.start_time
234
+ end
308
235
 
309
- message = nil
310
- case change_data.action
311
- when :enter_sprint
312
- message = 'Added to sprint'
313
- summary_stats.added += 1
314
- when :issue_stopped
315
- message = 'Completed'
316
- summary_stats.completed += 1
317
- when :leave_sprint
318
- message = 'Removed from sprint'
319
- summary_stats.removed += 1
320
- end
236
+ def past_sprint_end? change_data, sprint
237
+ sprint.completed_time && change_data.time > sprint.completed_time
238
+ end
321
239
 
322
- next unless message
240
+ def sprint_start_point measure, sprint
241
+ measure.summary_stats.started = measure.value
242
+ { y: measure.value, x: chart_format(sprint.start_time), title: measure.started_title }
243
+ end
323
244
 
324
- data_set << {
325
- y: issues_currently_in_sprint.size,
326
- x: chart_format(change_data.time),
327
- title: "#{change_data.issue.key} #{message}"
328
- }
329
- end
245
+ def change_point measure, change_data, message
246
+ { y: measure.value, x: chart_format(change_data.time), title: "#{change_data.issue.key} #{message}" }
247
+ end
330
248
 
331
- unless start_data_written
332
- # There was nothing that triggered us to write the sprint started block so do it now.
333
- data_set << {
334
- y: issues_currently_in_sprint.size,
335
- x: chart_format(sprint.start_time),
336
- title: "Sprint started with #{issues_currently_in_sprint.size || 'no'} stories"
337
- }
338
- end
249
+ def append_closing_points data_set, measure, sprint, start_written
250
+ # Nothing in the change stream triggered the sprint-start marker, so write it now.
251
+ data_set << sprint_start_point(measure, sprint) unless start_written
339
252
 
340
253
  if sprint.completed_time
341
- data_set << {
342
- y: issues_currently_in_sprint.size,
343
- x: chart_format(sprint.completed_time),
344
- title: "Sprint ended with #{issues_currently_in_sprint.size} stories unfinished"
345
- }
346
- summary_stats.remaining = issues_currently_in_sprint.size
254
+ data_set << { y: measure.value, x: chart_format(sprint.completed_time), title: measure.ended_title }
255
+ measure.summary_stats.remaining = measure.value
347
256
  end
257
+ return if sprint.completed_at?(time_range.end)
348
258
 
349
- unless sprint.completed_at?(time_range.end)
350
- # If the sprint is still active then we draw one final line to the end of the time range
351
- data_set << {
352
- y: issues_currently_in_sprint.size,
353
- x: chart_format(time_range.end),
354
- title: "Sprint still active. #{issues_currently_in_sprint.size} issues in progress."
355
- }
356
- end
357
-
358
- @summary_stats[sprint] = summary_stats
359
- data_set
259
+ data_set << { y: measure.value, x: chart_format(time_range.end), title: measure.active_title }
360
260
  end
361
261
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The story-count view of a sprint burndown: simply how many issues are in the sprint at any point.
4
+ # It plugs into SprintBurndown#build_sprint_data_set, which shares its loop with the story-points
5
+ # view (SprintPointsMeasure).
6
+ class SprintCountMeasure
7
+ attr_reader :summary_stats
8
+
9
+ def initialize
10
+ @summary_stats = SprintSummaryStats.new
11
+ @issues_currently_in_sprint = []
12
+ end
13
+
14
+ def value = @issues_currently_in_sprint.size
15
+
16
+ def update_state change_data
17
+ case change_data.action
18
+ when :enter_sprint
19
+ @issues_currently_in_sprint << change_data.issue.key
20
+ when :leave_sprint, :issue_stopped
21
+ @issues_currently_in_sprint.delete change_data.issue.key
22
+ end
23
+ end
24
+
25
+ def record change_data
26
+ case change_data.action
27
+ when :enter_sprint
28
+ @summary_stats.added += 1
29
+ 'Added to sprint'
30
+ when :issue_stopped
31
+ @summary_stats.completed += 1
32
+ 'Completed'
33
+ when :leave_sprint
34
+ @summary_stats.removed += 1
35
+ 'Removed from sprint'
36
+ end
37
+ end
38
+
39
+ def started_title = "Sprint started with #{value} stories"
40
+ def ended_title = "Sprint ended with #{value} stories unfinished"
41
+ def active_title = "Sprint still active. #{value} issues in progress."
42
+ end
@@ -4,6 +4,7 @@ require 'jirametrics/value_equality'
4
4
 
5
5
  class SprintIssueChangeData
6
6
  include ValueEquality
7
+
7
8
  attr_reader :time, :action, :value, :issue, :estimate
8
9
 
9
10
  def initialize time:, action:, value:, issue:, estimate:
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The story-points view of a sprint burndown: a running total of estimate as issues enter, leave,
4
+ # complete, or have their points changed. It plugs into SprintBurndown#build_sprint_data_set, which
5
+ # shares its loop with the story-count view (SprintCountMeasure).
6
+ class SprintPointsMeasure
7
+ attr_reader :summary_stats
8
+
9
+ def initialize
10
+ @summary_stats = SprintSummaryStats.new
11
+ @summary_stats.completed = 0.0
12
+ @estimate = 0.0
13
+ @issues_currently_in_sprint = []
14
+ end
15
+
16
+ def value = @estimate
17
+
18
+ def update_state change_data
19
+ case change_data.action
20
+ when :enter_sprint
21
+ @issues_currently_in_sprint << change_data.issue.key
22
+ @estimate += change_data.estimate
23
+ when :leave_sprint
24
+ @issues_currently_in_sprint.delete change_data.issue.key
25
+ @estimate -= change_data.estimate
26
+ when :story_points
27
+ @estimate += change_data.value if @issues_currently_in_sprint.include? change_data.issue.key
28
+ end
29
+ end
30
+
31
+ def record change_data
32
+ case change_data.action
33
+ when :story_points
34
+ return nil unless @issues_currently_in_sprint.include? change_data.issue.key
35
+
36
+ @summary_stats.points_values_changed = true
37
+ old_estimate = change_data.estimate - change_data.value
38
+ "Story points changed from #{old_estimate} points to #{change_data.estimate} points"
39
+ when :enter_sprint
40
+ @summary_stats.added += change_data.estimate
41
+ "Added to sprint with #{points_label change_data.estimate}"
42
+ when :issue_stopped
43
+ @estimate -= change_data.estimate
44
+ @issues_currently_in_sprint.delete change_data.issue.key
45
+ @summary_stats.completed += change_data.estimate
46
+ "Completed with #{points_label change_data.estimate}"
47
+ when :leave_sprint
48
+ @summary_stats.removed += change_data.estimate
49
+ "Removed from sprint with #{points_label change_data.estimate}"
50
+ else
51
+ raise "Unexpected action: #{change_data.action}"
52
+ end
53
+ end
54
+
55
+ def points_label estimate
56
+ "#{estimate || 'no'} points"
57
+ end
58
+
59
+ def started_title = "Sprint started with #{@estimate} points"
60
+ def ended_title = "Sprint ended with #{@estimate} points unfinished"
61
+ def active_title = "Sprint still active. #{@estimate} points still in progress."
62
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The roll-up numbers for a single sprint's burndown: how many points/issues the sprint started
4
+ # with, how many were added/removed/completed while it ran, and how many remained at the end.
5
+ class SprintSummaryStats
6
+ attr_accessor :started, :added, :changed, :removed, :completed, :remaining, :points_values_changed
7
+
8
+ def initialize
9
+ @added = 0
10
+ @completed = 0
11
+ @removed = 0
12
+ @started = 0
13
+ @remaining = 0
14
+ @points_values_changed = false
15
+ end
16
+ end
@@ -49,8 +49,8 @@ class StatusCollection
49
49
 
50
50
  def parse_name_id name
51
51
  # Names could arrive in one of the following formats: "Done:3", "3", "Done"
52
- if name =~ /^(.*):(\d+)$/
53
- [$1, $2.to_i]
52
+ if /^(?<status_name>.*):(?<status_id>\d+)$/ =~ name
53
+ [status_name, status_id.to_i]
54
54
  elsif name.match?(/^\d+$/)
55
55
  [nil, name.to_i]
56
56
  else
@@ -32,8 +32,8 @@ class Stitcher < HtmlGenerator
32
32
  end
33
33
 
34
34
  def make_output_filename input_filename
35
- if /^(.+)\.erb$/ =~ input_filename
36
- "#{$1}.html"
35
+ if /^(?<base_name>.+)\.erb$/ =~ input_filename
36
+ "#{base_name}.html"
37
37
  else
38
38
  "#{input_filename}.html"
39
39
  end
@@ -48,7 +48,8 @@ class Stitcher < HtmlGenerator
48
48
  ''
49
49
  end
50
50
 
51
- def parse_file filename
51
+ # Imperative: it parses the file. The boolean is a "was it newly parsed" status, not a question.
52
+ def parse_file filename # rubocop:disable Naming/PredicateMethod
52
53
  return false if @loaded_files.include? filename
53
54
 
54
55
  # To match: <!-- seam-start | chart78 | GithubPrScatterplot | PR Scatterplot | chart -->
@@ -57,19 +58,7 @@ class Stitcher < HtmlGenerator
57
58
  file_system.load(filename).lines do |line|
58
59
  matches = line.match(regex)
59
60
  if matches
60
- if matches[:seam] == 'start'
61
- content = +''
62
- else
63
- if content.nil? || content.strip.empty?
64
- file_system.warning "Seam found with no content in #{filename.inspect}: " \
65
- "id=#{matches[:id].strip.inspect}, class=#{matches[:clazz].strip.inspect}, " \
66
- "title=#{matches[:title].strip.inspect}"
67
- end
68
- @all_stitches << Stitcher::StitchContent.new(
69
- file: filename, title: matches[:title], type: matches[:type], content: content
70
- )
71
- content = nil
72
- end
61
+ content = handle_seam matches, filename: filename, content: content
73
62
  elsif content
74
63
  content << line
75
64
  end
@@ -78,4 +67,20 @@ class Stitcher < HtmlGenerator
78
67
  @loaded_files << filename
79
68
  true
80
69
  end
70
+
71
+ # Handle one seam marker line, returning the new accumulator state: a fresh buffer for a start
72
+ # seam, or nil for an end seam (after recording the content collected since the start).
73
+ def handle_seam matches, filename:, content:
74
+ return +'' if matches[:seam] == 'start'
75
+
76
+ if content.nil? || content.strip.empty?
77
+ file_system.warning "Seam found with no content in #{filename.inspect}: " \
78
+ "id=#{matches[:id].strip.inspect}, class=#{matches[:clazz].strip.inspect}, " \
79
+ "title=#{matches[:title].strip.inspect}"
80
+ end
81
+ @all_stitches << Stitcher::StitchContent.new(
82
+ file: filename, title: matches[:title], type: matches[:type], content: content
83
+ )
84
+ nil
85
+ end
81
86
  end