jirametrics 2.11 → 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 +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
- data/lib/jirametrics/aging_work_table.rb +14 -17
- 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 +5 -3
- data/lib/jirametrics/board.rb +34 -11
- data/lib/jirametrics/board_config.rb +5 -1
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +10 -2
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +43 -20
- data/lib/jirametrics/chart_base.rb +143 -6
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +22 -5
- data/lib/jirametrics/cycletime_histogram.rb +15 -101
- data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
- data/lib/jirametrics/daily_view.rb +306 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +30 -8
- data/lib/jirametrics/data_quality_report.rb +43 -12
- data/lib/jirametrics/dependency_chart.rb +6 -3
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +128 -71
- 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 +74 -12
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +2 -2
- data/lib/jirametrics/examples/standard_project.rb +42 -27
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +26 -6
- data/lib/jirametrics/file_config.rb +10 -12
- data/lib/jirametrics/file_system.rb +59 -3
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +11 -1
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
- data/lib/jirametrics/html/aging_work_table.erb +7 -0
- 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 +40 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +6 -14
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
- data/lib/jirametrics/html/index.css +320 -69
- data/lib/jirametrics/html/index.erb +11 -20
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +17 -15
- data/lib/jirametrics/html/throughput_chart.erb +42 -11
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
- 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 +52 -55
- data/lib/jirametrics/issue.rb +329 -106
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +81 -14
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +151 -18
- 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/settings.json +6 -1
- data/lib/jirametrics/sprint.rb +13 -0
- data/lib/jirametrics/sprint_burndown.rb +45 -37
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/status_collection.rb +7 -0
- 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/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +83 -64
- metadata +65 -6
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -3,14 +3,15 @@
|
|
|
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
|
|
@@ -19,9 +20,10 @@ class Issue
|
|
|
19
20
|
|
|
20
21
|
# There are cases where we create an Issue of fragments like linked issues and those won't have
|
|
21
22
|
# changelogs.
|
|
22
|
-
|
|
23
|
+
load_history_into_changes if @raw['changelog']
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
# As above with fragments, there may not be a fields section
|
|
26
|
+
return unless @raw['fields']
|
|
25
27
|
|
|
26
28
|
# If this is an older pull of data then comments may not be there.
|
|
27
29
|
load_comments_into_changes if @raw['fields']['comment']
|
|
@@ -44,9 +46,11 @@ class Issue
|
|
|
44
46
|
def key = @raw['key']
|
|
45
47
|
|
|
46
48
|
def type = @raw['fields']['issuetype']['name']
|
|
47
|
-
|
|
48
49
|
def type_icon_url = @raw['fields']['issuetype']['iconUrl']
|
|
49
50
|
|
|
51
|
+
def priority_name = @raw.dig('fields', 'priority', 'name')
|
|
52
|
+
def priority_url = @raw.dig('fields', 'priority', 'iconUrl')
|
|
53
|
+
|
|
50
54
|
def summary = @raw['fields']['summary']
|
|
51
55
|
|
|
52
56
|
def labels = @raw['fields']['labels'] || []
|
|
@@ -150,7 +154,7 @@ class Issue
|
|
|
150
154
|
# Are we currently in this status? If yes, then return the most recent status change.
|
|
151
155
|
def currently_in_status *status_names
|
|
152
156
|
change = most_recent_status_change
|
|
153
|
-
return
|
|
157
|
+
return nil if change.nil?
|
|
154
158
|
|
|
155
159
|
change if change.current_status_matches(*status_names)
|
|
156
160
|
end
|
|
@@ -160,7 +164,7 @@ class Issue
|
|
|
160
164
|
category_ids = find_status_category_ids_by_names category_names
|
|
161
165
|
|
|
162
166
|
change = most_recent_status_change
|
|
163
|
-
return
|
|
167
|
+
return nil if change.nil?
|
|
164
168
|
|
|
165
169
|
status = find_or_create_status id: change.value_id, name: change.value
|
|
166
170
|
change if status && category_ids.include?(status.category.id)
|
|
@@ -205,8 +209,132 @@ class Issue
|
|
|
205
209
|
nil
|
|
206
210
|
end
|
|
207
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
|
+
|
|
208
330
|
def parse_time text
|
|
209
|
-
|
|
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
|
|
210
338
|
end
|
|
211
339
|
|
|
212
340
|
def created
|
|
@@ -214,6 +342,10 @@ class Issue
|
|
|
214
342
|
parse_time @raw['fields']['created'] if @raw['fields']['created']
|
|
215
343
|
end
|
|
216
344
|
|
|
345
|
+
def time_created
|
|
346
|
+
@changes.first
|
|
347
|
+
end
|
|
348
|
+
|
|
217
349
|
def updated
|
|
218
350
|
parse_time @raw['fields']['updated']
|
|
219
351
|
end
|
|
@@ -227,7 +359,11 @@ class Issue
|
|
|
227
359
|
end
|
|
228
360
|
|
|
229
361
|
def assigned_to
|
|
230
|
-
@raw['fields']
|
|
362
|
+
@raw['fields']['assignee']&.[]('displayName')
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def assigned_to_icon_url
|
|
366
|
+
@raw['fields']['assignee']&.[]('avatarUrls')&.[]('16x16')
|
|
231
367
|
end
|
|
232
368
|
|
|
233
369
|
# Many test failures are simply unreadable because the default inspect on this class goes
|
|
@@ -295,10 +431,6 @@ class Issue
|
|
|
295
431
|
|
|
296
432
|
blocked_statuses = settings['blocked_statuses']
|
|
297
433
|
stalled_statuses = settings['stalled_statuses']
|
|
298
|
-
unless blocked_statuses.is_a?(Array) && stalled_statuses.is_a?(Array)
|
|
299
|
-
raise "blocked_statuses(#{blocked_statuses.inspect}) and " \
|
|
300
|
-
"stalled_statuses(#{stalled_statuses.inspect}) must both be arrays"
|
|
301
|
-
end
|
|
302
434
|
|
|
303
435
|
blocked_link_texts = settings['blocked_link_text']
|
|
304
436
|
stalled_threshold = settings['stalled_threshold_days']
|
|
@@ -311,11 +443,13 @@ class Issue
|
|
|
311
443
|
previous_change_time = created
|
|
312
444
|
|
|
313
445
|
blocking_status = nil
|
|
446
|
+
blocking_is_blocked = false
|
|
314
447
|
flag = nil
|
|
448
|
+
flag_reason = nil
|
|
315
449
|
|
|
316
450
|
# This mock change is to force the writing of one last entry at the end of the time range.
|
|
317
451
|
# By doing this, we're able to eliminate a lot of duplicated code in charts.
|
|
318
|
-
mock_change = ChangeItem.new time: end_time,
|
|
452
|
+
mock_change = ChangeItem.new time: end_time, artificial: true, raw: { 'field' => '' }, author_raw: nil
|
|
319
453
|
|
|
320
454
|
(changes + [mock_change]).each do |change|
|
|
321
455
|
previous_was_active = false if check_for_stalled(
|
|
@@ -326,16 +460,19 @@ class Issue
|
|
|
326
460
|
)
|
|
327
461
|
|
|
328
462
|
if change.flagged? && flagged_means_blocked
|
|
329
|
-
flag = change
|
|
330
|
-
flag = nil if change.value == ''
|
|
463
|
+
flag, flag_reason = blocked_stalled_changes_flag_logic change
|
|
331
464
|
elsif change.status?
|
|
332
465
|
blocking_status = nil
|
|
333
|
-
|
|
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)
|
|
334
471
|
blocking_status = change.value
|
|
335
472
|
end
|
|
336
473
|
elsif change.link?
|
|
337
474
|
# Example: "This issue is satisfied by ANON-30465"
|
|
338
|
-
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)
|
|
339
476
|
puts "Issue(#{key}) Can't parse link text: #{change.value || change.old_value}"
|
|
340
477
|
next
|
|
341
478
|
end
|
|
@@ -351,8 +488,9 @@ class Issue
|
|
|
351
488
|
|
|
352
489
|
new_change = BlockedStalledChange.new(
|
|
353
490
|
flagged: flag,
|
|
491
|
+
flag_reason: flag_reason,
|
|
354
492
|
status: blocking_status,
|
|
355
|
-
status_is_blocking: blocking_status.nil? ||
|
|
493
|
+
status_is_blocking: blocking_status.nil? || blocking_is_blocked,
|
|
356
494
|
blocking_issue_keys: (blocking_issue_keys.empty? ? nil : blocking_issue_keys.dup),
|
|
357
495
|
time: change.time
|
|
358
496
|
)
|
|
@@ -371,6 +509,7 @@ class Issue
|
|
|
371
509
|
hack = result.pop
|
|
372
510
|
result << BlockedStalledChange.new(
|
|
373
511
|
flagged: hack.flag,
|
|
512
|
+
flag_reason: hack.flag_reason,
|
|
374
513
|
status: hack.status,
|
|
375
514
|
status_is_blocking: hack.status_is_blocking,
|
|
376
515
|
blocking_issue_keys: hack.blocking_issue_keys,
|
|
@@ -382,6 +521,28 @@ class Issue
|
|
|
382
521
|
result
|
|
383
522
|
end
|
|
384
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
|
+
|
|
385
546
|
def check_for_stalled change_time:, previous_change_time:, stalled_threshold:, blocking_stalled_changes:
|
|
386
547
|
stalled_threshold_seconds = stalled_threshold * 60 * 60 * 24
|
|
387
548
|
|
|
@@ -417,7 +578,7 @@ class Issue
|
|
|
417
578
|
# return [number of active seconds, total seconds] that this issue had up to the end_time.
|
|
418
579
|
# It does not include data before issue start or after issue end
|
|
419
580
|
def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
|
|
420
|
-
issue_start, issue_stop =
|
|
581
|
+
issue_start, issue_stop = started_stopped_times
|
|
421
582
|
return [0.0, 0.0] if !issue_start || issue_start > end_time
|
|
422
583
|
|
|
423
584
|
value_add_time = 0.0
|
|
@@ -462,8 +623,6 @@ class Issue
|
|
|
462
623
|
end
|
|
463
624
|
|
|
464
625
|
def expedited?
|
|
465
|
-
return false unless @board&.project_config
|
|
466
|
-
|
|
467
626
|
names = @board.project_config.settings['expedited_priority_names']
|
|
468
627
|
return false unless names
|
|
469
628
|
|
|
@@ -580,7 +739,7 @@ class Issue
|
|
|
580
739
|
/(?<project_code1>[^-]+)-(?<id1>.+)/ =~ key
|
|
581
740
|
/(?<project_code2>[^-]+)-(?<id2>.+)/ =~ other.key
|
|
582
741
|
comparison = project_code1 <=> project_code2
|
|
583
|
-
comparison = id1 <=> id2 if comparison.zero?
|
|
742
|
+
comparison = id1.to_i <=> id2.to_i if comparison.zero?
|
|
584
743
|
comparison
|
|
585
744
|
end
|
|
586
745
|
|
|
@@ -599,127 +758,186 @@ class Issue
|
|
|
599
758
|
end
|
|
600
759
|
|
|
601
760
|
def dump
|
|
602
|
-
|
|
603
|
-
|
|
761
|
+
IssuePrinter.new(self).to_s
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
def done?
|
|
765
|
+
if artificial? || board.cycletime.nil?
|
|
766
|
+
# This was probably loaded as a linked issue, which means we don't know what board it really
|
|
767
|
+
# belonged to. The best we can do is look at the status key
|
|
768
|
+
status.category.done?
|
|
769
|
+
else
|
|
770
|
+
board.cycletime.done? self
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def started_stopped_times
|
|
775
|
+
board.cycletime.started_stopped_times(self)
|
|
776
|
+
end
|
|
604
777
|
|
|
605
|
-
|
|
606
|
-
|
|
778
|
+
def started_stopped_dates
|
|
779
|
+
board.cycletime.started_stopped_dates(self)
|
|
780
|
+
end
|
|
607
781
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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?
|
|
611
798
|
end
|
|
612
|
-
history = [] # time, type, detail
|
|
613
799
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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?
|
|
617
809
|
|
|
618
|
-
|
|
619
|
-
history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
|
|
810
|
+
sprint_ids << change.raw['to'].split(/\s*,\s*/).collect { |id| id.to_i }
|
|
620
811
|
end
|
|
812
|
+
sprint_ids.flatten!
|
|
621
813
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
value = "#{change.value.inspect}:#{change.value_id.inspect}"
|
|
625
|
-
old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
|
|
626
|
-
else
|
|
627
|
-
value = compact_text(change.value).inspect
|
|
628
|
-
old_value = change.old_value ? compact_text(change.old_value).inspect : nil
|
|
629
|
-
end
|
|
814
|
+
board.sprints.select { |s| sprint_ids.include? s.id }
|
|
815
|
+
end
|
|
630
816
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
+
|
|
834
|
+
private
|
|
835
|
+
|
|
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
|
|
638
849
|
end
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
-1
|
|
650
|
-
else
|
|
651
|
-
a[1] <=> b[1]
|
|
652
|
-
end
|
|
653
|
-
else
|
|
654
|
-
a[0] <=> b[0]
|
|
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)]
|
|
655
860
|
end
|
|
656
861
|
end
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
type = (' ' * (type_width - type.length)) << type
|
|
662
|
-
end
|
|
663
|
-
result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
|
|
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)]
|
|
664
866
|
end
|
|
665
867
|
|
|
666
|
-
|
|
868
|
+
events
|
|
667
869
|
end
|
|
668
870
|
|
|
669
|
-
def
|
|
670
|
-
if
|
|
671
|
-
# This was probably loaded as a linked issue, which means we don't know what board it really
|
|
672
|
-
# belonged to. The best we can do is look at the status category. This case should be rare but
|
|
673
|
-
# it can happen.
|
|
674
|
-
status.category.name == 'Done'
|
|
675
|
-
else
|
|
676
|
-
board.cycletime.done? self
|
|
677
|
-
end
|
|
678
|
-
end
|
|
871
|
+
def sprint_change_at effective_time, change
|
|
872
|
+
return change if effective_time == change.time
|
|
679
873
|
|
|
680
|
-
|
|
681
|
-
|
|
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
|
+
)
|
|
682
880
|
end
|
|
683
881
|
|
|
684
|
-
|
|
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
|
|
685
896
|
|
|
686
|
-
def
|
|
687
|
-
|
|
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)
|
|
688
900
|
end
|
|
689
901
|
|
|
690
902
|
def load_history_into_changes
|
|
691
903
|
@raw['changelog']['histories']&.each do |history|
|
|
692
904
|
created = parse_time(history['created'])
|
|
693
905
|
|
|
694
|
-
# It should be impossible to not have an author but we've seen it in production
|
|
695
|
-
author = assemble_author history
|
|
696
906
|
history['items']&.each do |item|
|
|
697
|
-
|
|
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'])
|
|
698
925
|
end
|
|
699
926
|
end
|
|
700
927
|
end
|
|
701
928
|
|
|
702
929
|
def load_comments_into_changes
|
|
703
930
|
@raw['fields']['comment']['comments']&.each do |comment|
|
|
704
|
-
raw = {
|
|
931
|
+
raw = comment.merge({
|
|
705
932
|
'field' => 'comment',
|
|
706
933
|
'to' => comment['id'],
|
|
707
934
|
'toString' => comment['body']
|
|
708
|
-
}
|
|
709
|
-
author = assemble_author comment
|
|
935
|
+
})
|
|
710
936
|
created = parse_time(comment['created'])
|
|
711
|
-
@changes << ChangeItem.new(raw: raw, time: created,
|
|
937
|
+
@changes << ChangeItem.new(raw: raw, time: created, artificial: true, author_raw: comment['author'])
|
|
712
938
|
end
|
|
713
939
|
end
|
|
714
940
|
|
|
715
|
-
def compact_text text, max = 60
|
|
716
|
-
return nil if text.nil?
|
|
717
|
-
|
|
718
|
-
text = text.gsub(/\s+/, ' ').strip
|
|
719
|
-
text = "#{text[0..max]}..." if text.length > max
|
|
720
|
-
text
|
|
721
|
-
end
|
|
722
|
-
|
|
723
941
|
def sort_changes!
|
|
724
942
|
@changes.sort! do |a, b|
|
|
725
943
|
# It's common that a resolved will happen at the same time as a status change.
|
|
@@ -737,6 +955,9 @@ class Issue
|
|
|
737
955
|
first_status = nil
|
|
738
956
|
first_status_id = nil
|
|
739
957
|
|
|
958
|
+
# There won't be a created timestamp in cases where this was a linked issue
|
|
959
|
+
return unless @raw['fields']['created']
|
|
960
|
+
|
|
740
961
|
created_time = parse_time @raw['fields']['created']
|
|
741
962
|
first_change = @changes.find { |change| change.field == field_name }
|
|
742
963
|
if first_change.nil?
|
|
@@ -750,7 +971,9 @@ class Issue
|
|
|
750
971
|
first_status = first_change.old_value
|
|
751
972
|
first_status_id = first_change.old_value_id
|
|
752
973
|
end
|
|
753
|
-
|
|
974
|
+
|
|
975
|
+
creator = raw['fields']['creator']
|
|
976
|
+
ChangeItem.new time: created_time, artificial: true, author_raw: creator, raw: {
|
|
754
977
|
'field' => field_name,
|
|
755
978
|
'to' => first_status_id,
|
|
756
979
|
'toString' => first_status
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class IssueCollection < Array
|
|
4
|
+
attr_reader :hidden
|
|
5
|
+
|
|
6
|
+
def self.[] *issues
|
|
7
|
+
collection = new
|
|
8
|
+
issues.each { |i| collection << i }
|
|
9
|
+
collection
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
super
|
|
14
|
+
@hidden = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def reject! &block
|
|
18
|
+
select(&block).each do |issue|
|
|
19
|
+
@hidden << issue
|
|
20
|
+
end
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def find_by_key key:, include_hidden: false
|
|
25
|
+
block = ->(issue) { issue.key == key }
|
|
26
|
+
issue = find(&block)
|
|
27
|
+
issue = hidden.find(&block) if issue.nil? && include_hidden
|
|
28
|
+
issue
|
|
29
|
+
end
|
|
30
|
+
def clone
|
|
31
|
+
raise 'baboom'
|
|
32
|
+
end
|
|
33
|
+
end
|