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.
- checksums.yaml +4 -4
- data/lib/jirametrics/aggregate_config.rb +42 -29
- data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
- data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
- data/lib/jirametrics/aging_work_table.rb +36 -33
- data/lib/jirametrics/anonymizer.rb +31 -86
- data/lib/jirametrics/atlassian_document_format.rb +11 -4
- data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -1
- data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
- data/lib/jirametrics/board.rb +23 -9
- data/lib/jirametrics/board_config.rb +0 -7
- data/lib/jirametrics/board_movement_calculator.rb +24 -21
- data/lib/jirametrics/cfd_data_builder.rb +46 -44
- data/lib/jirametrics/change_item.rb +19 -7
- data/lib/jirametrics/chart_base.rb +50 -64
- data/lib/jirametrics/cumulative_flow_diagram.rb +59 -46
- data/lib/jirametrics/cycle_time_config.rb +35 -46
- data/lib/jirametrics/cycletime_histogram.rb +7 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
- data/lib/jirametrics/daily_view.rb +83 -61
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
- data/lib/jirametrics/daily_wip_chart.rb +72 -45
- data/lib/jirametrics/data_quality_report.rb +61 -63
- data/lib/jirametrics/dependency_chart.rb +40 -31
- data/lib/jirametrics/download_config.rb +0 -3
- data/lib/jirametrics/downloader.rb +8 -7
- data/lib/jirametrics/downloader_for_cloud.rb +108 -72
- data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
- data/lib/jirametrics/examples/standard_project.rb +2 -2
- data/lib/jirametrics/expedited_chart.rb +47 -37
- data/lib/jirametrics/exporter.rb +19 -10
- data/lib/jirametrics/file_config.rb +19 -12
- data/lib/jirametrics/file_system.rb +16 -10
- data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
- data/lib/jirametrics/github_gateway.rb +23 -9
- data/lib/jirametrics/groupable_issue_chart.rb +3 -0
- data/lib/jirametrics/html/index.css +6 -0
- data/lib/jirametrics/html_report_config.rb +9 -29
- data/lib/jirametrics/issue.rb +290 -389
- data/lib/jirametrics/issue_collection.rb +1 -0
- data/lib/jirametrics/issue_printer.rb +60 -23
- data/lib/jirametrics/jira_gateway.rb +29 -18
- data/lib/jirametrics/mcp_server.rb +225 -209
- data/lib/jirametrics/project_config.rb +158 -134
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
- data/lib/jirametrics/rules.rb +1 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
- data/lib/jirametrics/sprint_burndown.rb +142 -242
- data/lib/jirametrics/sprint_count_measure.rb +42 -0
- data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
- data/lib/jirametrics/sprint_points_measure.rb +62 -0
- data/lib/jirametrics/sprint_summary_stats.rb +16 -0
- data/lib/jirametrics/status_collection.rb +2 -2
- data/lib/jirametrics/stitcher.rb +21 -16
- data/lib/jirametrics/throughput_chart.rb +38 -24
- data/lib/jirametrics/time_based_chart.rb +65 -0
- data/lib/jirametrics/time_based_histogram.rb +30 -23
- data/lib/jirametrics/time_based_scatterplot.rb +11 -2
- data/lib/jirametrics/trend_line_calculator.rb +2 -2
- data/lib/jirametrics/wip_by_column_chart.rb +73 -32
- data/lib/jirametrics.rb +6 -2
- 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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
next if sprint.future?
|
|
116
|
+
board.sprints.select { |sprint| sprint_in_time_range? sprint }
|
|
117
|
+
end
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
167
|
+
ever_entered_sprint?(change_data) ? change_data : []
|
|
168
|
+
end
|
|
175
169
|
|
|
176
|
-
|
|
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:
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
|
195
|
-
data_set <<
|
|
196
|
-
|
|
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 =
|
|
220
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
|
281
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
53
|
-
[
|
|
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
|
data/lib/jirametrics/stitcher.rb
CHANGED
|
@@ -32,8 +32,8 @@ class Stitcher < HtmlGenerator
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def make_output_filename input_filename
|
|
35
|
-
if /^(
|
|
36
|
-
"#{
|
|
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
|
-
|
|
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
|
-
|
|
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
|