jirametrics 2.13 → 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 +9 -7
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +101 -97
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +32 -8
- data/lib/jirametrics/board_config.rb +4 -1
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +2 -2
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +14 -6
- data/lib/jirametrics/chart_base.rb +141 -3
- 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} +21 -4
- data/lib/jirametrics/cycletime_histogram.rb +15 -101
- data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
- data/lib/jirametrics/daily_view.rb +85 -53
- 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 +117 -100
- 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 +42 -4
- data/lib/jirametrics/examples/aggregated_project.rb +2 -2
- data/lib/jirametrics/examples/standard_project.rb +41 -28
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +26 -6
- data/lib/jirametrics/file_config.rb +9 -11
- 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 +5 -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 +249 -69
- data/lib/jirametrics/html/index.erb +9 -35
- 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 -57
- data/lib/jirametrics/issue.rb +304 -101
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +77 -17
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +128 -12
- 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 +5 -1
- data/lib/jirametrics/sprint.rb +12 -0
- data/lib/jirametrics/sprint_burndown.rb +10 -4
- data/lib/jirametrics/status.rb +1 -1
- 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/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +83 -69
- metadata +60 -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']
|
|
@@ -46,8 +48,8 @@ class Issue
|
|
|
46
48
|
def type = @raw['fields']['issuetype']['name']
|
|
47
49
|
def type_icon_url = @raw['fields']['issuetype']['iconUrl']
|
|
48
50
|
|
|
49
|
-
def priority_name = @raw
|
|
50
|
-
def priority_url = @raw
|
|
51
|
+
def priority_name = @raw.dig('fields', 'priority', 'name')
|
|
52
|
+
def priority_url = @raw.dig('fields', 'priority', 'iconUrl')
|
|
51
53
|
|
|
52
54
|
def summary = @raw['fields']['summary']
|
|
53
55
|
|
|
@@ -152,7 +154,7 @@ class Issue
|
|
|
152
154
|
# Are we currently in this status? If yes, then return the most recent status change.
|
|
153
155
|
def currently_in_status *status_names
|
|
154
156
|
change = most_recent_status_change
|
|
155
|
-
return
|
|
157
|
+
return nil if change.nil?
|
|
156
158
|
|
|
157
159
|
change if change.current_status_matches(*status_names)
|
|
158
160
|
end
|
|
@@ -162,7 +164,7 @@ class Issue
|
|
|
162
164
|
category_ids = find_status_category_ids_by_names category_names
|
|
163
165
|
|
|
164
166
|
change = most_recent_status_change
|
|
165
|
-
return
|
|
167
|
+
return nil if change.nil?
|
|
166
168
|
|
|
167
169
|
status = find_or_create_status id: change.value_id, name: change.value
|
|
168
170
|
change if status && category_ids.include?(status.category.id)
|
|
@@ -208,11 +210,131 @@ class Issue
|
|
|
208
210
|
end
|
|
209
211
|
|
|
210
212
|
def first_time_visible_on_board
|
|
211
|
-
|
|
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]
|
|
212
328
|
end
|
|
213
329
|
|
|
214
330
|
def parse_time text
|
|
215
|
-
|
|
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
|
|
216
338
|
end
|
|
217
339
|
|
|
218
340
|
def created
|
|
@@ -220,6 +342,10 @@ class Issue
|
|
|
220
342
|
parse_time @raw['fields']['created'] if @raw['fields']['created']
|
|
221
343
|
end
|
|
222
344
|
|
|
345
|
+
def time_created
|
|
346
|
+
@changes.first
|
|
347
|
+
end
|
|
348
|
+
|
|
223
349
|
def updated
|
|
224
350
|
parse_time @raw['fields']['updated']
|
|
225
351
|
end
|
|
@@ -233,11 +359,11 @@ class Issue
|
|
|
233
359
|
end
|
|
234
360
|
|
|
235
361
|
def assigned_to
|
|
236
|
-
@raw['fields']
|
|
362
|
+
@raw['fields']['assignee']&.[]('displayName')
|
|
237
363
|
end
|
|
238
364
|
|
|
239
365
|
def assigned_to_icon_url
|
|
240
|
-
@raw['fields']
|
|
366
|
+
@raw['fields']['assignee']&.[]('avatarUrls')&.[]('16x16')
|
|
241
367
|
end
|
|
242
368
|
|
|
243
369
|
# Many test failures are simply unreadable because the default inspect on this class goes
|
|
@@ -305,10 +431,6 @@ class Issue
|
|
|
305
431
|
|
|
306
432
|
blocked_statuses = settings['blocked_statuses']
|
|
307
433
|
stalled_statuses = settings['stalled_statuses']
|
|
308
|
-
unless blocked_statuses.is_a?(Array) && stalled_statuses.is_a?(Array)
|
|
309
|
-
raise "blocked_statuses(#{blocked_statuses.inspect}) and " \
|
|
310
|
-
"stalled_statuses(#{stalled_statuses.inspect}) must both be arrays"
|
|
311
|
-
end
|
|
312
434
|
|
|
313
435
|
blocked_link_texts = settings['blocked_link_text']
|
|
314
436
|
stalled_threshold = settings['stalled_threshold_days']
|
|
@@ -321,7 +443,9 @@ class Issue
|
|
|
321
443
|
previous_change_time = created
|
|
322
444
|
|
|
323
445
|
blocking_status = nil
|
|
446
|
+
blocking_is_blocked = false
|
|
324
447
|
flag = nil
|
|
448
|
+
flag_reason = nil
|
|
325
449
|
|
|
326
450
|
# This mock change is to force the writing of one last entry at the end of the time range.
|
|
327
451
|
# By doing this, we're able to eliminate a lot of duplicated code in charts.
|
|
@@ -336,11 +460,14 @@ class Issue
|
|
|
336
460
|
)
|
|
337
461
|
|
|
338
462
|
if change.flagged? && flagged_means_blocked
|
|
339
|
-
flag = change
|
|
340
|
-
flag = nil if change.value == ''
|
|
463
|
+
flag, flag_reason = blocked_stalled_changes_flag_logic change
|
|
341
464
|
elsif change.status?
|
|
342
465
|
blocking_status = nil
|
|
343
|
-
|
|
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)
|
|
344
471
|
blocking_status = change.value
|
|
345
472
|
end
|
|
346
473
|
elsif change.link?
|
|
@@ -361,8 +488,9 @@ class Issue
|
|
|
361
488
|
|
|
362
489
|
new_change = BlockedStalledChange.new(
|
|
363
490
|
flagged: flag,
|
|
491
|
+
flag_reason: flag_reason,
|
|
364
492
|
status: blocking_status,
|
|
365
|
-
status_is_blocking: blocking_status.nil? ||
|
|
493
|
+
status_is_blocking: blocking_status.nil? || blocking_is_blocked,
|
|
366
494
|
blocking_issue_keys: (blocking_issue_keys.empty? ? nil : blocking_issue_keys.dup),
|
|
367
495
|
time: change.time
|
|
368
496
|
)
|
|
@@ -381,6 +509,7 @@ class Issue
|
|
|
381
509
|
hack = result.pop
|
|
382
510
|
result << BlockedStalledChange.new(
|
|
383
511
|
flagged: hack.flag,
|
|
512
|
+
flag_reason: hack.flag_reason,
|
|
384
513
|
status: hack.status,
|
|
385
514
|
status_is_blocking: hack.status_is_blocking,
|
|
386
515
|
blocking_issue_keys: hack.blocking_issue_keys,
|
|
@@ -392,6 +521,28 @@ class Issue
|
|
|
392
521
|
result
|
|
393
522
|
end
|
|
394
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
|
+
|
|
395
546
|
def check_for_stalled change_time:, previous_change_time:, stalled_threshold:, blocking_stalled_changes:
|
|
396
547
|
stalled_threshold_seconds = stalled_threshold * 60 * 60 * 24
|
|
397
548
|
|
|
@@ -427,7 +578,7 @@ class Issue
|
|
|
427
578
|
# return [number of active seconds, total seconds] that this issue had up to the end_time.
|
|
428
579
|
# It does not include data before issue start or after issue end
|
|
429
580
|
def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
|
|
430
|
-
issue_start, issue_stop =
|
|
581
|
+
issue_start, issue_stop = started_stopped_times
|
|
431
582
|
return [0.0, 0.0] if !issue_start || issue_start > end_time
|
|
432
583
|
|
|
433
584
|
value_add_time = 0.0
|
|
@@ -607,92 +758,49 @@ class Issue
|
|
|
607
758
|
end
|
|
608
759
|
|
|
609
760
|
def dump
|
|
610
|
-
|
|
611
|
-
result << "#{key} (#{type}): #{compact_text summary, 200}\n"
|
|
612
|
-
|
|
613
|
-
assignee = raw['fields']['assignee']
|
|
614
|
-
result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
|
|
615
|
-
|
|
616
|
-
raw['fields']['issuelinks']&.each do |link|
|
|
617
|
-
result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
|
|
618
|
-
result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
|
|
619
|
-
end
|
|
620
|
-
history = [] # time, type, detail
|
|
621
|
-
|
|
622
|
-
if board.cycletime
|
|
623
|
-
started_at, stopped_at = board.cycletime.started_stopped_times(self)
|
|
624
|
-
history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
|
|
625
|
-
history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
|
|
626
|
-
else
|
|
627
|
-
result << " Unable to determine start/end times as board #{board.id} has no cycletime specified\n"
|
|
628
|
-
end
|
|
629
|
-
|
|
630
|
-
@discarded_change_times&.each do |time|
|
|
631
|
-
history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
|
|
632
|
-
end
|
|
633
|
-
|
|
634
|
-
(changes + (@discarded_changes || [])).each do |change|
|
|
635
|
-
if change.status?
|
|
636
|
-
value = "#{change.value.inspect}:#{change.value_id.inspect}"
|
|
637
|
-
old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
|
|
638
|
-
else
|
|
639
|
-
value = compact_text(change.value).inspect
|
|
640
|
-
old_value = change.old_value ? compact_text(change.old_value).inspect : nil
|
|
641
|
-
end
|
|
642
|
-
|
|
643
|
-
message = +''
|
|
644
|
-
message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
|
|
645
|
-
message << value
|
|
646
|
-
if change.artificial?
|
|
647
|
-
message << ' (Artificial entry)' if change.artificial?
|
|
648
|
-
else
|
|
649
|
-
message << " (Author: #{change.author})"
|
|
650
|
-
end
|
|
651
|
-
history << [change.time, change.field, message, change.artificial?]
|
|
652
|
-
end
|
|
653
|
-
|
|
654
|
-
result << " History:\n"
|
|
655
|
-
type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
|
|
656
|
-
history.sort! do |a, b|
|
|
657
|
-
if a[0] == b[0]
|
|
658
|
-
if a[1].nil?
|
|
659
|
-
1
|
|
660
|
-
elsif b[1].nil?
|
|
661
|
-
-1
|
|
662
|
-
else
|
|
663
|
-
a[1] <=> b[1]
|
|
664
|
-
end
|
|
665
|
-
else
|
|
666
|
-
a[0] <=> b[0]
|
|
667
|
-
end
|
|
668
|
-
end
|
|
669
|
-
history.each do |time, type, detail, _artificial|
|
|
670
|
-
if type.nil?
|
|
671
|
-
type = '-' * type_width
|
|
672
|
-
else
|
|
673
|
-
type = (' ' * (type_width - type.length)) << type
|
|
674
|
-
end
|
|
675
|
-
result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
|
|
676
|
-
end
|
|
677
|
-
|
|
678
|
-
result
|
|
761
|
+
IssuePrinter.new(self).to_s
|
|
679
762
|
end
|
|
680
763
|
|
|
681
764
|
def done?
|
|
682
765
|
if artificial? || board.cycletime.nil?
|
|
683
766
|
# This was probably loaded as a linked issue, which means we don't know what board it really
|
|
684
|
-
# belonged to. The best we can do is look at the status
|
|
685
|
-
|
|
686
|
-
status.category.name == 'Done'
|
|
767
|
+
# belonged to. The best we can do is look at the status key
|
|
768
|
+
status.category.done?
|
|
687
769
|
else
|
|
688
770
|
board.cycletime.done? self
|
|
689
771
|
end
|
|
690
772
|
end
|
|
691
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
|
+
|
|
692
782
|
def status_changes
|
|
693
783
|
@changes.select { |change| change.status? }
|
|
694
784
|
end
|
|
695
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
|
+
|
|
696
804
|
def sprints
|
|
697
805
|
sprint_ids = []
|
|
698
806
|
|
|
@@ -706,13 +814,113 @@ class Issue
|
|
|
706
814
|
board.sprints.select { |s| sprint_ids.include? s.id }
|
|
707
815
|
end
|
|
708
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
|
+
|
|
709
834
|
private
|
|
710
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
|
|
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)
|
|
900
|
+
end
|
|
901
|
+
|
|
711
902
|
def load_history_into_changes
|
|
712
903
|
@raw['changelog']['histories']&.each do |history|
|
|
713
904
|
created = parse_time(history['created'])
|
|
714
905
|
|
|
715
906
|
history['items']&.each do |item|
|
|
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
|
+
|
|
716
924
|
@changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
|
|
717
925
|
end
|
|
718
926
|
end
|
|
@@ -730,14 +938,6 @@ class Issue
|
|
|
730
938
|
end
|
|
731
939
|
end
|
|
732
940
|
|
|
733
|
-
def compact_text text, max = 60
|
|
734
|
-
return nil if text.nil?
|
|
735
|
-
|
|
736
|
-
text = text.gsub(/\s+/, ' ').strip
|
|
737
|
-
text = "#{text[0..max]}..." if text.length > max
|
|
738
|
-
text
|
|
739
|
-
end
|
|
740
|
-
|
|
741
941
|
def sort_changes!
|
|
742
942
|
@changes.sort! do |a, b|
|
|
743
943
|
# It's common that a resolved will happen at the same time as a status change.
|
|
@@ -755,6 +955,9 @@ class Issue
|
|
|
755
955
|
first_status = nil
|
|
756
956
|
first_status_id = nil
|
|
757
957
|
|
|
958
|
+
# There won't be a created timestamp in cases where this was a linked issue
|
|
959
|
+
return unless @raw['fields']['created']
|
|
960
|
+
|
|
758
961
|
created_time = parse_time @raw['fields']['created']
|
|
759
962
|
first_change = @changes.find { |change| change.field == field_name }
|
|
760
963
|
if first_change.nil?
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class IssuePrinter
|
|
4
|
+
def initialize issue
|
|
5
|
+
@issue = issue
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def to_s
|
|
9
|
+
issue = @issue
|
|
10
|
+
result = +''
|
|
11
|
+
result << "#{issue.key} (#{issue.type}): #{issue.compact_text issue.summary, max: 200}\n"
|
|
12
|
+
|
|
13
|
+
assignee = issue.raw['fields']['assignee']
|
|
14
|
+
result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
|
|
15
|
+
|
|
16
|
+
issue.raw['fields']['issuelinks']&.each do |link|
|
|
17
|
+
result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
|
|
18
|
+
result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
history = [] # time, type, detail
|
|
22
|
+
|
|
23
|
+
if issue.board.cycletime
|
|
24
|
+
started_at, stopped_at = issue.started_stopped_times
|
|
25
|
+
history << [started_at, nil, 'vvvv Started here vvvv', true] if started_at
|
|
26
|
+
history << [stopped_at, nil, '^^^^ Finished here ^^^^', true] if stopped_at
|
|
27
|
+
else
|
|
28
|
+
result << " Unable to determine start/end times as board #{issue.board.id} has no cycletime specified\n"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
issue.discarded_change_times&.each do |time|
|
|
32
|
+
history << [time, nil, '^^^^ Changes discarded ^^^^', true]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
(issue.changes + (issue.discarded_changes || [])).each do |change|
|
|
36
|
+
history << [change.time, change.field, create_change_message(change: change, issue: issue), change.artificial?]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
result << " History:\n"
|
|
40
|
+
type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
|
|
41
|
+
sort_history!(history)
|
|
42
|
+
history.each do |time, type, detail, _artificial|
|
|
43
|
+
type = type.nil? ? '-' * type_width : type.rjust(type_width)
|
|
44
|
+
result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
result
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def create_change_message change:, issue:
|
|
51
|
+
value, old_value = format_change_values(change: change, issue: issue)
|
|
52
|
+
|
|
53
|
+
message = +''
|
|
54
|
+
message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
|
|
55
|
+
message << value
|
|
56
|
+
if change.artificial?
|
|
57
|
+
message << ' (Artificial entry)'
|
|
58
|
+
else
|
|
59
|
+
message << " (Author: #{change.author})"
|
|
60
|
+
end
|
|
61
|
+
message
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def format_change_values change:, issue:
|
|
65
|
+
if change.status?
|
|
66
|
+
value = "#{change.value.inspect}:#{change.value_id.inspect}"
|
|
67
|
+
old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
|
|
68
|
+
elsif change.sprint?
|
|
69
|
+
added = change.value_id - change.old_value_id
|
|
70
|
+
removed = change.old_value_id - change.value_id
|
|
71
|
+
value = "#{change.value.inspect} #{change.value_id}"
|
|
72
|
+
value << " (added: #{added})" unless added.empty?
|
|
73
|
+
value << " (removed: #{removed})" unless removed.empty?
|
|
74
|
+
old_value = nil
|
|
75
|
+
else
|
|
76
|
+
value = issue.compact_text(change.value).inspect
|
|
77
|
+
old_value = change.old_value ? issue.compact_text(change.old_value).inspect : nil
|
|
78
|
+
end
|
|
79
|
+
[value, old_value]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def sort_history! history
|
|
83
|
+
history.sort! do |a, b|
|
|
84
|
+
if a[0] == b[0]
|
|
85
|
+
if a[1].nil?
|
|
86
|
+
1
|
|
87
|
+
elsif b[1].nil?
|
|
88
|
+
-1
|
|
89
|
+
else
|
|
90
|
+
a[1] <=> b[1]
|
|
91
|
+
end
|
|
92
|
+
else
|
|
93
|
+
a[0] <=> b[0]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|