jirametrics 2.5 → 2.30
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/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/aggregate_config.rb +16 -3
- data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
- data/lib/jirametrics/aging_work_table.rb +63 -19
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +160 -0
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +6 -4
- data/lib/jirametrics/board.rb +73 -20
- data/lib/jirametrics/board_config.rb +10 -2
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +54 -18
- data/lib/jirametrics/chart_base.rb +203 -30
- data/lib/jirametrics/css_variable.rb +2 -2
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/cycle_time_config.rb +137 -0
- data/lib/jirametrics/cycletime_histogram.rb +17 -38
- data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
- data/lib/jirametrics/daily_view.rb +306 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
- data/lib/jirametrics/daily_wip_chart.rb +36 -16
- data/lib/jirametrics/data_quality_report.rb +251 -42
- data/lib/jirametrics/dependency_chart.rb +8 -6
- data/lib/jirametrics/download_config.rb +17 -2
- data/lib/jirametrics/downloader.rb +177 -108
- data/lib/jirametrics/downloader_for_cloud.rb +287 -0
- data/lib/jirametrics/downloader_for_data_center.rb +95 -0
- data/lib/jirametrics/estimate_accuracy_chart.rb +75 -14
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +5 -8
- data/lib/jirametrics/examples/standard_project.rb +54 -38
- data/lib/jirametrics/expedited_chart.rb +10 -9
- data/lib/jirametrics/exporter.rb +51 -16
- data/lib/jirametrics/file_config.rb +21 -6
- data/lib/jirametrics/file_system.rb +96 -4
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +12 -4
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
- data/lib/jirametrics/html/aging_work_table.erb +13 -4
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +41 -15
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +7 -24
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
- data/lib/jirametrics/html/hierarchy_table.erb +1 -1
- data/lib/jirametrics/html/index.css +336 -62
- data/lib/jirametrics/html/index.erb +16 -21
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +18 -25
- data/lib/jirametrics/html/throughput_chart.erb +43 -21
- data/lib/jirametrics/html/time_based_histogram.erb +123 -0
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +32 -0
- data/lib/jirametrics/html_report_config.rb +83 -76
- data/lib/jirametrics/issue.rb +481 -97
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +96 -16
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +374 -130
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +17 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +7 -1
- data/lib/jirametrics/sprint.rb +13 -0
- data/lib/jirametrics/sprint_burndown.rb +47 -39
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +83 -38
- data/lib/jirametrics/stitcher.rb +81 -0
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +73 -23
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +107 -0
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +101 -66
- metadata +72 -16
- data/lib/jirametrics/cycletime_config.rb +0 -69
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -3,19 +3,27 @@
|
|
|
3
3
|
require 'time'
|
|
4
4
|
|
|
5
5
|
class Issue
|
|
6
|
-
attr_reader :changes, :raw, :subtasks, :board
|
|
7
|
-
attr_accessor :parent
|
|
6
|
+
attr_reader :changes, :raw, :subtasks, :board, :discarded_changes, :discarded_change_times
|
|
7
|
+
attr_accessor :parent, :github_prs
|
|
8
8
|
|
|
9
9
|
def initialize raw:, board:, timezone_offset: '+00:00'
|
|
10
10
|
@raw = raw
|
|
11
11
|
@timezone_offset = timezone_offset
|
|
12
12
|
@subtasks = []
|
|
13
13
|
@changes = []
|
|
14
|
+
@github_prs = []
|
|
14
15
|
@board = board
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
# We only check for this here because if a board isn't passed in then things will fail much
|
|
18
|
+
# later and be hard to find. Let's find out early.
|
|
19
|
+
raise "No board for issue #{key}" if board.nil?
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
# There are cases where we create an Issue of fragments like linked issues and those won't have
|
|
22
|
+
# changelogs.
|
|
23
|
+
load_history_into_changes if @raw['changelog']
|
|
24
|
+
|
|
25
|
+
# As above with fragments, there may not be a fields section
|
|
26
|
+
return unless @raw['fields']
|
|
19
27
|
|
|
20
28
|
# If this is an older pull of data then comments may not be there.
|
|
21
29
|
load_comments_into_changes if @raw['fields']['comment']
|
|
@@ -38,12 +46,12 @@ class Issue
|
|
|
38
46
|
def key = @raw['key']
|
|
39
47
|
|
|
40
48
|
def type = @raw['fields']['issuetype']['name']
|
|
41
|
-
|
|
42
49
|
def type_icon_url = @raw['fields']['issuetype']['iconUrl']
|
|
43
50
|
|
|
44
|
-
def
|
|
51
|
+
def priority_name = @raw.dig('fields', 'priority', 'name')
|
|
52
|
+
def priority_url = @raw.dig('fields', 'priority', 'iconUrl')
|
|
45
53
|
|
|
46
|
-
def
|
|
54
|
+
def summary = @raw['fields']['summary']
|
|
47
55
|
|
|
48
56
|
def labels = @raw['fields']['labels'] || []
|
|
49
57
|
|
|
@@ -51,6 +59,20 @@ class Issue
|
|
|
51
59
|
|
|
52
60
|
def resolution = @raw['fields']['resolution']&.[]('name')
|
|
53
61
|
|
|
62
|
+
def status
|
|
63
|
+
@status = Status.from_raw(@raw['fields']['status']) unless @status
|
|
64
|
+
@status
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def status= status
|
|
68
|
+
@status = status
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def due_date
|
|
72
|
+
text = @raw['fields']['duedate']
|
|
73
|
+
text.nil? ? nil : Date.parse(text)
|
|
74
|
+
end
|
|
75
|
+
|
|
54
76
|
def url
|
|
55
77
|
# Strangely, the URL isn't anywhere in the returned data so we have to fabricate it.
|
|
56
78
|
"#{@board.server_url_prefix}/browse/#{key}"
|
|
@@ -65,35 +87,43 @@ class Issue
|
|
|
65
87
|
end
|
|
66
88
|
|
|
67
89
|
def first_time_in_status *status_names
|
|
68
|
-
@changes.find { |change| change.current_status_matches(*status_names) }
|
|
90
|
+
@changes.find { |change| change.current_status_matches(*status_names) }
|
|
69
91
|
end
|
|
70
92
|
|
|
71
93
|
def first_time_not_in_status *status_names
|
|
72
|
-
@changes.find { |change| change.status? && status_names.include?(change.value) == false }
|
|
94
|
+
@changes.find { |change| change.status? && status_names.include?(change.value) == false }
|
|
73
95
|
end
|
|
74
96
|
|
|
75
97
|
def first_time_in_or_right_of_column column_name
|
|
76
98
|
first_time_in_status(*board.status_ids_in_or_right_of_column(column_name))
|
|
77
99
|
end
|
|
78
100
|
|
|
101
|
+
def first_time_label_added *labels
|
|
102
|
+
@changes.each do |change|
|
|
103
|
+
next unless change.labels?
|
|
104
|
+
|
|
105
|
+
change_labels = change.value.split
|
|
106
|
+
return change if change_labels.any? { |l| labels.include?(l) }
|
|
107
|
+
end
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
79
111
|
def still_in_or_right_of_column column_name
|
|
80
112
|
still_in_status(*board.status_ids_in_or_right_of_column(column_name))
|
|
81
113
|
end
|
|
82
114
|
|
|
83
115
|
def still_in
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
next unless change.status?
|
|
87
|
-
|
|
116
|
+
result = nil
|
|
117
|
+
status_changes.each do |change|
|
|
88
118
|
current_status_matched = yield change
|
|
89
119
|
|
|
90
|
-
if current_status_matched &&
|
|
91
|
-
|
|
92
|
-
elsif !current_status_matched &&
|
|
93
|
-
|
|
120
|
+
if current_status_matched && result.nil?
|
|
121
|
+
result = change
|
|
122
|
+
elsif !current_status_matched && result
|
|
123
|
+
result = nil
|
|
94
124
|
end
|
|
95
125
|
end
|
|
96
|
-
|
|
126
|
+
result
|
|
97
127
|
end
|
|
98
128
|
private :still_in
|
|
99
129
|
|
|
@@ -106,56 +136,205 @@ class Issue
|
|
|
106
136
|
|
|
107
137
|
# If it ever entered one of these categories and it's still there then what was the last time it entered
|
|
108
138
|
def still_in_status_category *category_names
|
|
139
|
+
category_ids = find_status_category_ids_by_names category_names
|
|
140
|
+
|
|
109
141
|
still_in do |change|
|
|
110
|
-
status =
|
|
111
|
-
|
|
142
|
+
status = find_or_create_status id: change.value_id, name: change.value
|
|
143
|
+
category_ids.include? status.category.id
|
|
112
144
|
end
|
|
113
145
|
end
|
|
114
146
|
|
|
115
147
|
def most_recent_status_change
|
|
116
|
-
|
|
148
|
+
# Any issue that we loaded from its own file will always have a status as we artificially insert a status
|
|
149
|
+
# change to represent creation. Issues that were just fragments referenced from a main issue (ie a linked issue)
|
|
150
|
+
# may not have any status changes as we have no idea when it was created. This will be nil in that case
|
|
151
|
+
status_changes.last
|
|
117
152
|
end
|
|
118
153
|
|
|
119
|
-
# Are we currently in this status? If yes, then return the
|
|
154
|
+
# Are we currently in this status? If yes, then return the most recent status change.
|
|
120
155
|
def currently_in_status *status_names
|
|
121
156
|
change = most_recent_status_change
|
|
122
|
-
return
|
|
157
|
+
return nil if change.nil?
|
|
123
158
|
|
|
124
|
-
change
|
|
159
|
+
change if change.current_status_matches(*status_names)
|
|
125
160
|
end
|
|
126
161
|
|
|
127
|
-
# Are we currently in this status category? If yes, then return the
|
|
162
|
+
# Are we currently in this status category? If yes, then return the most recent status change.
|
|
128
163
|
def currently_in_status_category *category_names
|
|
164
|
+
category_ids = find_status_category_ids_by_names category_names
|
|
165
|
+
|
|
129
166
|
change = most_recent_status_change
|
|
130
|
-
return
|
|
167
|
+
return nil if change.nil?
|
|
131
168
|
|
|
132
|
-
status =
|
|
133
|
-
change
|
|
169
|
+
status = find_or_create_status id: change.value_id, name: change.value
|
|
170
|
+
change if status && category_ids.include?(status.category.id)
|
|
134
171
|
end
|
|
135
172
|
|
|
136
|
-
def
|
|
137
|
-
status = board.possible_statuses.
|
|
138
|
-
return status if status
|
|
173
|
+
def find_or_create_status id:, name:
|
|
174
|
+
status = board.possible_statuses.find_by_id(id)
|
|
139
175
|
|
|
140
|
-
|
|
176
|
+
unless status
|
|
177
|
+
# Have to pull this list before the call to fabricate or else the warning will incorrectly
|
|
178
|
+
# list this status as one it actually found
|
|
179
|
+
found_statuses = board.possible_statuses.to_s
|
|
180
|
+
|
|
181
|
+
status = board.possible_statuses.fabricate_status_for id: id, name: name
|
|
182
|
+
|
|
183
|
+
message = +'The history for issue '
|
|
184
|
+
message << key
|
|
185
|
+
message << ' references the status ('
|
|
186
|
+
message << "#{name.inspect}:#{id.inspect}"
|
|
187
|
+
message << ') that can\'t be found. We are guessing that this belongs to the '
|
|
188
|
+
message << status.category.to_s
|
|
189
|
+
message << ' status category but that may be wrong. See https://jirametrics.org/faq/#q1 for more '
|
|
190
|
+
message << 'details on defining statuses.'
|
|
191
|
+
board.project_config.file_system.warning message, more: "The statuses we did find are: #{found_statuses}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
status
|
|
141
195
|
end
|
|
142
196
|
|
|
143
197
|
def first_status_change_after_created
|
|
144
|
-
|
|
198
|
+
status_changes.find { |change| change.artificial? == false }
|
|
145
199
|
end
|
|
146
200
|
|
|
147
201
|
def first_time_in_status_category *category_names
|
|
148
|
-
|
|
149
|
-
next unless change.status?
|
|
202
|
+
category_ids = find_status_category_ids_by_names category_names
|
|
150
203
|
|
|
151
|
-
|
|
152
|
-
|
|
204
|
+
status_changes.each do |change|
|
|
205
|
+
to_status = find_or_create_status(id: change.value_id, name: change.value)
|
|
206
|
+
id = to_status.category.id
|
|
207
|
+
return change if category_ids.include? id
|
|
153
208
|
end
|
|
154
209
|
nil
|
|
155
210
|
end
|
|
156
211
|
|
|
212
|
+
def first_time_visible_on_board
|
|
213
|
+
visible_status_ids = board.visible_columns.collect(&:status_ids).flatten
|
|
214
|
+
return first_time_in_status(*visible_status_ids) unless board.scrum?
|
|
215
|
+
|
|
216
|
+
# For scrum boards, an issue is only visible when BOTH conditions are true simultaneously:
|
|
217
|
+
# 1. Its status is in a visible column
|
|
218
|
+
# 2. It is in an active sprint
|
|
219
|
+
# At each moment one condition becomes true, check if the other is already true.
|
|
220
|
+
candidates = []
|
|
221
|
+
|
|
222
|
+
status_changes.each do |change|
|
|
223
|
+
next unless visible_status_ids.include?(change.value_id)
|
|
224
|
+
candidates << change if in_active_sprint_at?(change.time)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
sprint_entry_events.each do |effective_time, representative_change|
|
|
228
|
+
candidates << representative_change if in_visible_status_at?(effective_time, visible_status_ids)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
candidates.min_by(&:time)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def reasons_not_visible_on_board
|
|
235
|
+
reasons = []
|
|
236
|
+
reasons << 'Not in an active sprint' if board.scrum? && sprints.none?(&:active?)
|
|
237
|
+
unless board.visible_columns.any? { |c| c.status_ids.include?(status.id) }
|
|
238
|
+
reasons << 'Status is not configured for any visible column on the board'
|
|
239
|
+
end
|
|
240
|
+
reasons
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def visible_on_board?
|
|
244
|
+
reasons_not_visible_on_board.empty?
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# If this issue will ever be in an active sprint then return the time that it
|
|
248
|
+
# was first added to that sprint, whether or not the sprint was active at that
|
|
249
|
+
# time. Although it seems like an odd thing to calculate, it's a reasonable proxy
|
|
250
|
+
# for 'ready' in cases where the team doesn't have an explicit 'ready' status.
|
|
251
|
+
# You'd be better off with an explicit 'ready' but sometimes that's not an option.
|
|
252
|
+
def first_time_added_to_active_sprint
|
|
253
|
+
unless board.scrum?
|
|
254
|
+
raise 'first_time_added_to_active_sprint() can only be used with Scrum boards: ' \
|
|
255
|
+
"issue=#{key}, board=#{board.inspect}"
|
|
256
|
+
end
|
|
257
|
+
data_clazz = Struct.new(:sprint_id, :sprint_start, :sprint_stop, :change)
|
|
258
|
+
|
|
259
|
+
matching_changes = []
|
|
260
|
+
all_datas = []
|
|
261
|
+
|
|
262
|
+
@changes.each do |change|
|
|
263
|
+
next unless change.sprint?
|
|
264
|
+
|
|
265
|
+
added_sprint_ids = change.value_id - change.old_value_id
|
|
266
|
+
added_sprint_ids.each do |id|
|
|
267
|
+
data = data_clazz.new
|
|
268
|
+
data.sprint_id = id
|
|
269
|
+
data.change = change
|
|
270
|
+
data.sprint_start, data.sprint_stop = find_sprint_start_end(sprint_id: id, change: change)
|
|
271
|
+
all_datas << data
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
removed_sprint_ids = change.old_value_id - change.value_id
|
|
275
|
+
removed_sprint_ids.each do |id|
|
|
276
|
+
data = all_datas.find { |d| d.sprint_id == id }
|
|
277
|
+
# It's possible for an issue to be created inside a sprint and therefore for
|
|
278
|
+
# that add-to-sprint not show in the history.
|
|
279
|
+
next unless data
|
|
280
|
+
|
|
281
|
+
all_datas.delete(data)
|
|
282
|
+
next if data.sprint_start.nil? || data.sprint_start >= change.time
|
|
283
|
+
|
|
284
|
+
matching_changes << data.change
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# There can't be any more removes so whatever is left is a valid option
|
|
289
|
+
# Now all we care about is if the sprint has started.
|
|
290
|
+
all_datas.each do |data|
|
|
291
|
+
matching_changes << data.change if data.sprint_start
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
matching_changes.min_by(&:time)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def find_sprint_start_end sprint_id:, change:
|
|
298
|
+
# There are two different places that sprint data could be found. In theory all
|
|
299
|
+
# sprints would be found in both places. In practice, sometimes what we need is
|
|
300
|
+
# in one or the other but not both.
|
|
301
|
+
|
|
302
|
+
# First look in the actual sprints json. If any issues are in this sprint then it should
|
|
303
|
+
# be here.
|
|
304
|
+
sprint = board.sprints.find { |s| s.id == sprint_id }
|
|
305
|
+
if sprint
|
|
306
|
+
return [nil, nil] if sprint.future?
|
|
307
|
+
|
|
308
|
+
return [sprint.start_time, sprint.completed_time]
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Then look at the sprints inside the issue. Even though the field id may be specified,
|
|
312
|
+
# that custom field may not be present. This happens if it was in that sprint but was
|
|
313
|
+
# then removed, whether or not that sprint had ever started.
|
|
314
|
+
sprint_data = raw['fields'][change.field_id]&.find { |sd| sd['id'].to_i == sprint_id }
|
|
315
|
+
if sprint_data
|
|
316
|
+
return [nil, nil] if sprint_data['state'] == 'future'
|
|
317
|
+
|
|
318
|
+
start = parse_time(sprint_data['startDate'])
|
|
319
|
+
stop = parse_time(sprint_data['completeDate'])
|
|
320
|
+
return [start, stop]
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# If we got this far then the sprint can't be found anywhere, so we pretend that it never
|
|
324
|
+
# started. Is this guaranteed to be true? No. In theory if all issues were removed from
|
|
325
|
+
# an active sprint then it would also disappear, even though it had started. Nothing we
|
|
326
|
+
# can do to detect that edge-case though.
|
|
327
|
+
[nil, nil]
|
|
328
|
+
end
|
|
329
|
+
|
|
157
330
|
def parse_time text
|
|
158
|
-
|
|
331
|
+
if text.nil?
|
|
332
|
+
nil
|
|
333
|
+
elsif text.is_a? String
|
|
334
|
+
Time.parse(text).getlocal(@timezone_offset)
|
|
335
|
+
else
|
|
336
|
+
Time.at(text / 1000).getlocal(@timezone_offset)
|
|
337
|
+
end
|
|
159
338
|
end
|
|
160
339
|
|
|
161
340
|
def created
|
|
@@ -163,20 +342,28 @@ class Issue
|
|
|
163
342
|
parse_time @raw['fields']['created'] if @raw['fields']['created']
|
|
164
343
|
end
|
|
165
344
|
|
|
345
|
+
def time_created
|
|
346
|
+
@changes.first
|
|
347
|
+
end
|
|
348
|
+
|
|
166
349
|
def updated
|
|
167
350
|
parse_time @raw['fields']['updated']
|
|
168
351
|
end
|
|
169
352
|
|
|
170
353
|
def first_resolution
|
|
171
|
-
@changes.find { |change| change.resolution? }
|
|
354
|
+
@changes.find { |change| change.resolution? }
|
|
172
355
|
end
|
|
173
356
|
|
|
174
357
|
def last_resolution
|
|
175
|
-
@changes.reverse.find { |change| change.resolution? }
|
|
358
|
+
@changes.reverse.find { |change| change.resolution? }
|
|
176
359
|
end
|
|
177
360
|
|
|
178
361
|
def assigned_to
|
|
179
|
-
@raw['fields']
|
|
362
|
+
@raw['fields']['assignee']&.[]('displayName')
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def assigned_to_icon_url
|
|
366
|
+
@raw['fields']['assignee']&.[]('avatarUrls')&.[]('16x16')
|
|
180
367
|
end
|
|
181
368
|
|
|
182
369
|
# Many test failures are simply unreadable because the default inspect on this class goes
|
|
@@ -244,13 +431,10 @@ class Issue
|
|
|
244
431
|
|
|
245
432
|
blocked_statuses = settings['blocked_statuses']
|
|
246
433
|
stalled_statuses = settings['stalled_statuses']
|
|
247
|
-
unless blocked_statuses.is_a?(Array) && stalled_statuses.is_a?(Array)
|
|
248
|
-
raise "blocked_statuses(#{blocked_statuses.inspect}) and " \
|
|
249
|
-
"stalled_statuses(#{stalled_statuses.inspect}) must both be arrays"
|
|
250
|
-
end
|
|
251
434
|
|
|
252
435
|
blocked_link_texts = settings['blocked_link_text']
|
|
253
436
|
stalled_threshold = settings['stalled_threshold_days']
|
|
437
|
+
flagged_means_blocked = !!settings['flagged_means_blocked'] # rubocop:disable Style/DoubleNegation
|
|
254
438
|
|
|
255
439
|
blocking_issue_keys = []
|
|
256
440
|
|
|
@@ -259,11 +443,13 @@ class Issue
|
|
|
259
443
|
previous_change_time = created
|
|
260
444
|
|
|
261
445
|
blocking_status = nil
|
|
446
|
+
blocking_is_blocked = false
|
|
262
447
|
flag = nil
|
|
448
|
+
flag_reason = nil
|
|
263
449
|
|
|
264
450
|
# This mock change is to force the writing of one last entry at the end of the time range.
|
|
265
451
|
# By doing this, we're able to eliminate a lot of duplicated code in charts.
|
|
266
|
-
mock_change = ChangeItem.new time: end_time,
|
|
452
|
+
mock_change = ChangeItem.new time: end_time, artificial: true, raw: { 'field' => '' }, author_raw: nil
|
|
267
453
|
|
|
268
454
|
(changes + [mock_change]).each do |change|
|
|
269
455
|
previous_was_active = false if check_for_stalled(
|
|
@@ -273,17 +459,20 @@ class Issue
|
|
|
273
459
|
blocking_stalled_changes: result
|
|
274
460
|
)
|
|
275
461
|
|
|
276
|
-
if change.flagged?
|
|
277
|
-
flag = change
|
|
278
|
-
flag = nil if change.value == ''
|
|
462
|
+
if change.flagged? && flagged_means_blocked
|
|
463
|
+
flag, flag_reason = blocked_stalled_changes_flag_logic change
|
|
279
464
|
elsif change.status?
|
|
280
465
|
blocking_status = nil
|
|
281
|
-
|
|
466
|
+
blocking_is_blocked = false
|
|
467
|
+
if blocked_statuses.find_by_id(change.value_id)
|
|
468
|
+
blocking_status = change.value
|
|
469
|
+
blocking_is_blocked = true
|
|
470
|
+
elsif stalled_statuses.find_by_id(change.value_id)
|
|
282
471
|
blocking_status = change.value
|
|
283
472
|
end
|
|
284
473
|
elsif change.link?
|
|
285
474
|
# Example: "This issue is satisfied by ANON-30465"
|
|
286
|
-
unless /^This issue (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
|
|
475
|
+
unless /^This (?<_>issue|work item) (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
|
|
287
476
|
puts "Issue(#{key}) Can't parse link text: #{change.value || change.old_value}"
|
|
288
477
|
next
|
|
289
478
|
end
|
|
@@ -299,8 +488,9 @@ class Issue
|
|
|
299
488
|
|
|
300
489
|
new_change = BlockedStalledChange.new(
|
|
301
490
|
flagged: flag,
|
|
491
|
+
flag_reason: flag_reason,
|
|
302
492
|
status: blocking_status,
|
|
303
|
-
status_is_blocking: blocking_status.nil? ||
|
|
493
|
+
status_is_blocking: blocking_status.nil? || blocking_is_blocked,
|
|
304
494
|
blocking_issue_keys: (blocking_issue_keys.empty? ? nil : blocking_issue_keys.dup),
|
|
305
495
|
time: change.time
|
|
306
496
|
)
|
|
@@ -319,6 +509,7 @@ class Issue
|
|
|
319
509
|
hack = result.pop
|
|
320
510
|
result << BlockedStalledChange.new(
|
|
321
511
|
flagged: hack.flag,
|
|
512
|
+
flag_reason: hack.flag_reason,
|
|
322
513
|
status: hack.status,
|
|
323
514
|
status_is_blocking: hack.status_is_blocking,
|
|
324
515
|
blocking_issue_keys: hack.blocking_issue_keys,
|
|
@@ -330,6 +521,28 @@ class Issue
|
|
|
330
521
|
result
|
|
331
522
|
end
|
|
332
523
|
|
|
524
|
+
def blocked_stalled_changes_flag_logic change
|
|
525
|
+
flag = change.value
|
|
526
|
+
flag = nil if change.value == ''
|
|
527
|
+
if flag
|
|
528
|
+
# When the user is adding a comment to explain why a flag was set, the flag is set immediately
|
|
529
|
+
# and the comment is inserted after the user hits enter, which means that there is some time
|
|
530
|
+
# gap. If a comment happened shortly after the flag was set, we assume they're linked. This
|
|
531
|
+
# won't always be true and so there will be false positives, but it's a reasonable assumption.
|
|
532
|
+
max_seconds_between_flag_and_comment = 30
|
|
533
|
+
comment_change = changes.find do |c|
|
|
534
|
+
c.comment? && c.time >= change.time && (c.time - change.time) <= max_seconds_between_flag_and_comment
|
|
535
|
+
end
|
|
536
|
+
flag_reason = comment_change && @board.project_config.atlassian_document_format.to_text(comment_change.value)
|
|
537
|
+
# Newer Jira instances may add this extra text but older instances did not. Strip it out if found.
|
|
538
|
+
flag_reason = flag_reason&.sub(/\A:flag_on: Flag added\s*/m, '')&.strip
|
|
539
|
+
flag_reason = nil if flag_reason&.empty?
|
|
540
|
+
else
|
|
541
|
+
flag_reason = nil
|
|
542
|
+
end
|
|
543
|
+
[flag, flag_reason]
|
|
544
|
+
end
|
|
545
|
+
|
|
333
546
|
def check_for_stalled change_time:, previous_change_time:, stalled_threshold:, blocking_stalled_changes:
|
|
334
547
|
stalled_threshold_seconds = stalled_threshold * 60 * 60 * 24
|
|
335
548
|
|
|
@@ -362,6 +575,45 @@ class Issue
|
|
|
362
575
|
inserted_stalled
|
|
363
576
|
end
|
|
364
577
|
|
|
578
|
+
# return [number of active seconds, total seconds] that this issue had up to the end_time.
|
|
579
|
+
# It does not include data before issue start or after issue end
|
|
580
|
+
def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
|
|
581
|
+
issue_start, issue_stop = started_stopped_times
|
|
582
|
+
return [0.0, 0.0] if !issue_start || issue_start > end_time
|
|
583
|
+
|
|
584
|
+
value_add_time = 0.0
|
|
585
|
+
end_time = issue_stop if issue_stop && issue_stop < end_time
|
|
586
|
+
|
|
587
|
+
active_start = nil
|
|
588
|
+
blocked_stalled_changes(end_time: end_time, settings: settings).each_with_index do |change, index|
|
|
589
|
+
break if change.time > end_time
|
|
590
|
+
|
|
591
|
+
if index.zero?
|
|
592
|
+
active_start = change.time if change.active?
|
|
593
|
+
next
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Already active and we just got another active.
|
|
597
|
+
next if active_start && change.active?
|
|
598
|
+
|
|
599
|
+
if change.active?
|
|
600
|
+
active_start = change.time
|
|
601
|
+
elsif active_start && change.time >= issue_start
|
|
602
|
+
# Not active now but we have been. Record the active time.
|
|
603
|
+
change_delta = change.time - [issue_start, active_start].max
|
|
604
|
+
value_add_time += change_delta
|
|
605
|
+
active_start = nil
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
if active_start
|
|
610
|
+
change_delta = end_time - [issue_start, active_start].max
|
|
611
|
+
value_add_time += change_delta if change_delta.positive?
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
[value_add_time, end_time - issue_start]
|
|
615
|
+
end
|
|
616
|
+
|
|
365
617
|
def all_subtask_activity_times
|
|
366
618
|
subtask_activity_times = []
|
|
367
619
|
@subtasks.each do |subtask|
|
|
@@ -371,8 +623,6 @@ class Issue
|
|
|
371
623
|
end
|
|
372
624
|
|
|
373
625
|
def expedited?
|
|
374
|
-
return false unless @board&.project_config
|
|
375
|
-
|
|
376
626
|
names = @board.project_config.settings['expedited_priority_names']
|
|
377
627
|
return false unless names
|
|
378
628
|
|
|
@@ -489,85 +739,205 @@ class Issue
|
|
|
489
739
|
/(?<project_code1>[^-]+)-(?<id1>.+)/ =~ key
|
|
490
740
|
/(?<project_code2>[^-]+)-(?<id2>.+)/ =~ other.key
|
|
491
741
|
comparison = project_code1 <=> project_code2
|
|
492
|
-
comparison = id1 <=> id2 if comparison.zero?
|
|
742
|
+
comparison = id1.to_i <=> id2.to_i if comparison.zero?
|
|
493
743
|
comparison
|
|
494
744
|
end
|
|
495
745
|
|
|
496
|
-
def
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
|
|
746
|
+
def discard_changes_before cutoff_time
|
|
747
|
+
rejected_any = false
|
|
748
|
+
@changes.reject! do |change|
|
|
749
|
+
reject = change.status? && change.time <= cutoff_time && change.artificial? == false
|
|
750
|
+
if reject
|
|
751
|
+
(@discarded_changes ||= []) << change
|
|
752
|
+
rejected_any = true
|
|
753
|
+
end
|
|
754
|
+
reject
|
|
506
755
|
end
|
|
507
|
-
changes.each do |change|
|
|
508
|
-
value = change.value
|
|
509
|
-
old_value = change.old_value
|
|
510
756
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
result << message << "\n"
|
|
517
|
-
end
|
|
518
|
-
result
|
|
757
|
+
(@discarded_change_times ||= []) << cutoff_time if rejected_any
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def dump
|
|
761
|
+
IssuePrinter.new(self).to_s
|
|
519
762
|
end
|
|
520
763
|
|
|
521
764
|
def done?
|
|
522
765
|
if artificial? || board.cycletime.nil?
|
|
523
766
|
# This was probably loaded as a linked issue, which means we don't know what board it really
|
|
524
|
-
# belonged to. The best we can do is look at the status
|
|
525
|
-
|
|
526
|
-
status.category_name == 'Done'
|
|
767
|
+
# belonged to. The best we can do is look at the status key
|
|
768
|
+
status.category.done?
|
|
527
769
|
else
|
|
528
770
|
board.cycletime.done? self
|
|
529
771
|
end
|
|
530
772
|
end
|
|
531
773
|
|
|
774
|
+
def started_stopped_times
|
|
775
|
+
board.cycletime.started_stopped_times(self)
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def started_stopped_dates
|
|
779
|
+
board.cycletime.started_stopped_dates(self)
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
def status_changes
|
|
783
|
+
@changes.select { |change| change.status? }
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
def status_resolution_at_done
|
|
787
|
+
done_time = started_stopped_times.last
|
|
788
|
+
return [nil, nil] if done_time.nil?
|
|
789
|
+
|
|
790
|
+
status_change = nil
|
|
791
|
+
resolution = nil
|
|
792
|
+
|
|
793
|
+
@changes.each do |change|
|
|
794
|
+
break if change.time > done_time
|
|
795
|
+
|
|
796
|
+
status_change = change if change.status?
|
|
797
|
+
resolution = change.value if change.resolution?
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
status = status_change ? find_or_create_status(id: status_change.value_id, name: status_change.value) : nil
|
|
801
|
+
[status, resolution]
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
def sprints
|
|
805
|
+
sprint_ids = []
|
|
806
|
+
|
|
807
|
+
changes.each do |change|
|
|
808
|
+
next unless change.sprint?
|
|
809
|
+
|
|
810
|
+
sprint_ids << change.raw['to'].split(/\s*,\s*/).collect { |id| id.to_i }
|
|
811
|
+
end
|
|
812
|
+
sprint_ids.flatten!
|
|
813
|
+
|
|
814
|
+
board.sprints.select { |s| sprint_ids.include? s.id }
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
def started_sprints
|
|
818
|
+
sprints.reject { |sprint| sprint.future? }
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
def compact_text text, max: 60
|
|
822
|
+
return '' if text.nil?
|
|
823
|
+
|
|
824
|
+
text = if text.is_a? Hash
|
|
825
|
+
@board.project_config.atlassian_document_format.to_text(text)
|
|
826
|
+
else
|
|
827
|
+
text
|
|
828
|
+
end
|
|
829
|
+
text = text.gsub(/\s+/, ' ').strip
|
|
830
|
+
text = "#{text[0...max]}..." if text.length > max
|
|
831
|
+
text
|
|
832
|
+
end
|
|
833
|
+
|
|
532
834
|
private
|
|
533
835
|
|
|
534
|
-
|
|
535
|
-
|
|
836
|
+
# Returns [[effective_time, change_item]] for each moment the issue entered an active sprint.
|
|
837
|
+
# Skips sprints that were removed before they activated.
|
|
838
|
+
def sprint_entry_events
|
|
839
|
+
data_clazz = Struct.new(:sprint_id, :sprint_start, :add_time, :change)
|
|
840
|
+
events = []
|
|
841
|
+
in_sprint = []
|
|
842
|
+
|
|
843
|
+
@changes.each do |change|
|
|
844
|
+
next unless change.sprint?
|
|
845
|
+
|
|
846
|
+
(change.value_id - change.old_value_id).each do |sprint_id|
|
|
847
|
+
sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
|
|
848
|
+
in_sprint << data_clazz.new(sprint_id, sprint_start, change.time, change) if sprint_start
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
(change.old_value_id - change.value_id).each do |sprint_id|
|
|
852
|
+
data = in_sprint.find { |d| d.sprint_id == sprint_id }
|
|
853
|
+
next unless data
|
|
854
|
+
|
|
855
|
+
in_sprint.delete(data)
|
|
856
|
+
next if data.sprint_start >= change.time # sprint hadn't activated before removal
|
|
857
|
+
|
|
858
|
+
effective_time = [data.add_time, data.sprint_start].max
|
|
859
|
+
events << [effective_time, sprint_change_at(effective_time, data.change)]
|
|
860
|
+
end
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
in_sprint.each do |data|
|
|
864
|
+
effective_time = [data.add_time, data.sprint_start].max
|
|
865
|
+
events << [effective_time, sprint_change_at(effective_time, data.change)]
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
events
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
def sprint_change_at effective_time, change
|
|
872
|
+
return change if effective_time == change.time
|
|
873
|
+
|
|
874
|
+
ChangeItem.new(
|
|
875
|
+
raw: { 'field' => 'Sprint', 'toString' => 'Sprint activated', 'to' => '0', 'from' => nil, 'fromString' => nil },
|
|
876
|
+
author_raw: nil,
|
|
877
|
+
time: effective_time,
|
|
878
|
+
artificial: true
|
|
879
|
+
)
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
def in_active_sprint_at? time
|
|
883
|
+
active_ids = []
|
|
884
|
+
@changes.each do |change|
|
|
885
|
+
break if change.time > time
|
|
886
|
+
next unless change.sprint?
|
|
887
|
+
|
|
888
|
+
(change.value_id - change.old_value_id).each do |sprint_id|
|
|
889
|
+
sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
|
|
890
|
+
active_ids << sprint_id if sprint_start && sprint_start <= time
|
|
891
|
+
end
|
|
892
|
+
(change.old_value_id - change.value_id).each { |id| active_ids.delete(id) }
|
|
893
|
+
end
|
|
894
|
+
active_ids.any?
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
def in_visible_status_at? time, visible_status_ids
|
|
898
|
+
last = status_changes.reverse.find { |c| c.time <= time }
|
|
899
|
+
last && visible_status_ids.include?(last.value_id)
|
|
536
900
|
end
|
|
537
901
|
|
|
538
902
|
def load_history_into_changes
|
|
539
903
|
@raw['changelog']['histories']&.each do |history|
|
|
540
904
|
created = parse_time(history['created'])
|
|
541
905
|
|
|
542
|
-
# It should be impossible to not have an author but we've seen it in production
|
|
543
|
-
author = assemble_author history
|
|
544
906
|
history['items']&.each do |item|
|
|
545
|
-
|
|
907
|
+
if item['field'] == 'status' && item['to'].nil?
|
|
908
|
+
to_name = item['toString']
|
|
909
|
+
matches = board.possible_statuses.find_all_by_name(to_name)
|
|
910
|
+
guessed_id, id_note = if matches.length == 1
|
|
911
|
+
[matches.first.id.to_s, "Guessed id #{matches.first.id} from status name."]
|
|
912
|
+
elsif matches.length > 1
|
|
913
|
+
['0', "Multiple statuses named #{to_name.inspect} exist (ids: #{matches.map(&:id).join(', ')}); cannot disambiguate. Using id 0."]
|
|
914
|
+
else
|
|
915
|
+
['0', "No known status named #{to_name.inspect}. Using id 0."]
|
|
916
|
+
end
|
|
917
|
+
board.project_config.file_system.warning(
|
|
918
|
+
"Issue #{key} has a status change without a 'to' id " \
|
|
919
|
+
"(from #{item['fromString'].inspect} to #{to_name.inspect}). #{id_note}"
|
|
920
|
+
)
|
|
921
|
+
item = item.merge('to' => guessed_id)
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
@changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
|
|
546
925
|
end
|
|
547
926
|
end
|
|
548
927
|
end
|
|
549
928
|
|
|
550
929
|
def load_comments_into_changes
|
|
551
930
|
@raw['fields']['comment']['comments']&.each do |comment|
|
|
552
|
-
raw = {
|
|
931
|
+
raw = comment.merge({
|
|
553
932
|
'field' => 'comment',
|
|
554
933
|
'to' => comment['id'],
|
|
555
934
|
'toString' => comment['body']
|
|
556
|
-
}
|
|
557
|
-
author = assemble_author comment
|
|
935
|
+
})
|
|
558
936
|
created = parse_time(comment['created'])
|
|
559
|
-
@changes << ChangeItem.new(raw: raw, time: created,
|
|
937
|
+
@changes << ChangeItem.new(raw: raw, time: created, artificial: true, author_raw: comment['author'])
|
|
560
938
|
end
|
|
561
939
|
end
|
|
562
940
|
|
|
563
|
-
def compact_text text, max = 60
|
|
564
|
-
return nil if text.nil?
|
|
565
|
-
|
|
566
|
-
text = text.gsub(/\s+/, ' ').strip
|
|
567
|
-
text = "#{text[0..max]}..." if text.length > max
|
|
568
|
-
text
|
|
569
|
-
end
|
|
570
|
-
|
|
571
941
|
def sort_changes!
|
|
572
942
|
@changes.sort! do |a, b|
|
|
573
943
|
# It's common that a resolved will happen at the same time as a status change.
|
|
@@ -585,6 +955,9 @@ class Issue
|
|
|
585
955
|
first_status = nil
|
|
586
956
|
first_status_id = nil
|
|
587
957
|
|
|
958
|
+
# There won't be a created timestamp in cases where this was a linked issue
|
|
959
|
+
return unless @raw['fields']['created']
|
|
960
|
+
|
|
588
961
|
created_time = parse_time @raw['fields']['created']
|
|
589
962
|
first_change = @changes.find { |change| change.field == field_name }
|
|
590
963
|
if first_change.nil?
|
|
@@ -598,10 +971,21 @@ class Issue
|
|
|
598
971
|
first_status = first_change.old_value
|
|
599
972
|
first_status_id = first_change.old_value_id
|
|
600
973
|
end
|
|
601
|
-
|
|
974
|
+
|
|
975
|
+
creator = raw['fields']['creator']
|
|
976
|
+
ChangeItem.new time: created_time, artificial: true, author_raw: creator, raw: {
|
|
602
977
|
'field' => field_name,
|
|
603
978
|
'to' => first_status_id,
|
|
604
979
|
'toString' => first_status
|
|
605
980
|
}
|
|
606
981
|
end
|
|
982
|
+
|
|
983
|
+
def find_status_category_ids_by_names category_names
|
|
984
|
+
category_names.filter_map do |name|
|
|
985
|
+
list = board.possible_statuses.find_all_categories_by_name name
|
|
986
|
+
raise "No status categories found for name: #{name}" if list.empty?
|
|
987
|
+
|
|
988
|
+
list
|
|
989
|
+
end.flatten.collect(&:id)
|
|
990
|
+
end
|
|
607
991
|
end
|