jirametrics 2.22 → 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 +26 -10
- 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 +74 -1
- data/lib/jirametrics/atlassian_document_format.rb +93 -93
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +28 -8
- 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 +4 -3
- data/lib/jirametrics/chart_base.rb +107 -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} +1 -2
- data/lib/jirametrics/cycletime_histogram.rb +15 -103
- data/lib/jirametrics/cycletime_scatterplot.rb +13 -98
- data/lib/jirametrics/daily_view.rb +38 -13
- data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +1 -1
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +29 -7
- data/lib/jirametrics/data_quality_report.rb +38 -12
- data/lib/jirametrics/dependency_chart.rb +2 -2
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +87 -5
- data/lib/jirametrics/downloader_for_cloud.rb +107 -22
- data/lib/jirametrics/downloader_for_data_center.rb +3 -2
- 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 +32 -19
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +15 -2
- data/lib/jirametrics/file_config.rb +9 -11
- data/lib/jirametrics/file_system.rb +35 -2
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +4 -0
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
- data/lib/jirametrics/html/aging_work_table.erb +3 -0
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +38 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
- data/lib/jirametrics/html/expedited_chart.erb +3 -13
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
- data/lib/jirametrics/html/index.css +228 -60
- data/lib/jirametrics/html/index.erb +6 -0
- data/lib/jirametrics/html/index.js +53 -3
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +7 -13
- data/lib/jirametrics/html/throughput_chart.erb +40 -9
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +59 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +11 -7
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +2 -1
- data/lib/jirametrics/html_report_config.rb +45 -33
- data/lib/jirametrics/issue.rb +197 -99
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +32 -10
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +87 -8
- 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 +4 -0
- data/lib/jirametrics/settings.json +3 -1
- data/lib/jirametrics/sprint_burndown.rb +4 -2
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/stitcher.rb +7 -1
- 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 +58 -0
- metadata +52 -5
|
@@ -20,33 +20,58 @@ class HtmlReportConfig < HtmlGenerator
|
|
|
20
20
|
module_eval lines.join("\n"), __FILE__, __LINE__
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
define_chart name: 'aging_work_bar_chart', classname: 'AgingWorkBarChart'
|
|
24
|
-
define_chart name: 'aging_work_table', classname: 'AgingWorkTable'
|
|
25
|
-
define_chart name: 'cycletime_scatterplot', classname: 'CycletimeScatterplot'
|
|
26
|
-
define_chart name: 'daily_wip_chart', classname: 'DailyWipChart'
|
|
27
|
-
define_chart name: 'daily_wip_by_age_chart', classname: 'DailyWipByAgeChart'
|
|
28
|
-
define_chart name: 'daily_wip_by_blocked_stalled_chart', classname: 'DailyWipByBlockedStalledChart'
|
|
29
|
-
define_chart name: 'daily_wip_by_parent_chart', classname: 'DailyWipByParentChart'
|
|
30
|
-
define_chart name: 'throughput_chart', classname: 'ThroughputChart'
|
|
31
|
-
define_chart name: 'expedited_chart', classname: 'ExpeditedChart'
|
|
32
|
-
define_chart name: 'cycletime_histogram', classname: 'CycletimeHistogram'
|
|
33
|
-
define_chart name: 'estimate_accuracy_chart', classname: 'EstimateAccuracyChart'
|
|
34
|
-
define_chart name: 'hierarchy_table', classname: 'HierarchyTable'
|
|
35
|
-
define_chart name: 'flow_efficiency_scatterplot', classname: 'FlowEfficiencyScatterplot'
|
|
36
|
-
define_chart name: 'daily_view', classname: 'DailyView'
|
|
37
|
-
|
|
38
23
|
define_chart name: 'daily_wip_by_type', classname: 'DailyWipChart',
|
|
39
24
|
deprecated_warning: 'This is the same as daily_wip_chart. Please use that one', deprecated_date: '2024-05-23'
|
|
40
25
|
define_chart name: 'story_point_accuracy_chart', classname: 'EstimateAccuracyChart',
|
|
41
26
|
deprecated_warning: 'Renamed to estimate_accuracy_chart. Please use that one', deprecated_date: '2024-05-23'
|
|
42
27
|
|
|
43
28
|
def initialize file_config:, block:
|
|
29
|
+
super()
|
|
44
30
|
@file_config = file_config
|
|
45
31
|
@block = block
|
|
46
32
|
@sections = [] # Where we store the chunks of text that will be assembled into the HTML
|
|
47
33
|
@charts = [] # Where we store all the charts we executed so we can assert against them.
|
|
48
34
|
end
|
|
49
35
|
|
|
36
|
+
def method_missing name, *_args, board_id: nil, **_kwargs, &block
|
|
37
|
+
class_name = name.to_s.split('_').map(&:capitalize).join
|
|
38
|
+
klass = resolve_chart_class(class_name)
|
|
39
|
+
return super if klass.nil?
|
|
40
|
+
|
|
41
|
+
block ||= ->(_) {}
|
|
42
|
+
|
|
43
|
+
if klass.instance_method(:board_id=).owner == klass
|
|
44
|
+
execute_chart_per_board klass: klass, block: block, board_id: board_id
|
|
45
|
+
else
|
|
46
|
+
execute_chart klass.new(block)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolve_chart_class class_name
|
|
51
|
+
klass = Object.const_get(class_name)
|
|
52
|
+
klass < ChartBase ? klass : nil
|
|
53
|
+
rescue NameError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def execute_chart_per_board klass:, block:, board_id:
|
|
58
|
+
all_boards = @file_config.project_config.all_boards
|
|
59
|
+
ids = board_id ? [board_id] : issues.collect { |i| i.board.id }.uniq
|
|
60
|
+
ids = ids.sort_by { |id| all_boards[id]&.name || '' }
|
|
61
|
+
ids.each_with_index do |id, index|
|
|
62
|
+
execute_chart(klass.new(block)) do |chart|
|
|
63
|
+
chart.board_id = id
|
|
64
|
+
# We're showing the description only on the first one in order to reduce noise on the report
|
|
65
|
+
chart.description_text nil unless index.zero?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def respond_to_missing? name, include_private = false
|
|
71
|
+
class_name = name.to_s.split('_').map(&:capitalize).join
|
|
72
|
+
!resolve_chart_class(class_name).nil? || super
|
|
73
|
+
end
|
|
74
|
+
|
|
50
75
|
def cycletime label = nil, &block
|
|
51
76
|
@file_config.project_config.all_boards.each_value do |board|
|
|
52
77
|
raise 'Multiple cycletimes not supported' if board.cycletime
|
|
@@ -73,7 +98,8 @@ class HtmlReportConfig < HtmlGenerator
|
|
|
73
98
|
|
|
74
99
|
html create_footer
|
|
75
100
|
|
|
76
|
-
create_html output_filename: @file_config.output_filename, settings: settings
|
|
101
|
+
create_html output_filename: @file_config.output_filename, settings: settings,
|
|
102
|
+
project_name: @file_config.project_config.name
|
|
77
103
|
end
|
|
78
104
|
|
|
79
105
|
def file_system
|
|
@@ -92,24 +118,9 @@ class HtmlReportConfig < HtmlGenerator
|
|
|
92
118
|
@file_config.project_config.exporter.timezone_offset
|
|
93
119
|
end
|
|
94
120
|
|
|
95
|
-
def aging_work_in_progress_chart board_id: nil, &block
|
|
96
|
-
block ||= ->(_) {}
|
|
97
|
-
|
|
98
|
-
if board_id.nil?
|
|
99
|
-
ids = issues.collect { |i| i.board.id }.uniq.sort
|
|
100
|
-
else
|
|
101
|
-
ids = [board_id]
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
ids.each do |id|
|
|
105
|
-
execute_chart(AgingWorkInProgressChart.new(block)) do |chart|
|
|
106
|
-
chart.board_id = id
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
121
|
def random_color
|
|
112
|
-
|
|
122
|
+
@palette_index = (@palette_index || -1) + 1
|
|
123
|
+
ChartBase::OKABE_ITO_PALETTE[@palette_index % ChartBase::OKABE_ITO_PALETTE.size]
|
|
113
124
|
end
|
|
114
125
|
|
|
115
126
|
def html string, type: :body
|
|
@@ -147,6 +158,7 @@ class HtmlReportConfig < HtmlGenerator
|
|
|
147
158
|
chart.all_boards = project_config.all_boards
|
|
148
159
|
chart.board_id = find_board_id
|
|
149
160
|
chart.holiday_dates = project_config.exporter.holiday_dates
|
|
161
|
+
chart.fix_versions = project_config.fix_versions
|
|
150
162
|
|
|
151
163
|
time_range = @file_config.project_config.time_range
|
|
152
164
|
chart.date_range = time_range.begin.to_date..time_range.end.to_date
|
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
|
|
@@ -47,8 +48,8 @@ class Issue
|
|
|
47
48
|
def type = @raw['fields']['issuetype']['name']
|
|
48
49
|
def type_icon_url = @raw['fields']['issuetype']['iconUrl']
|
|
49
50
|
|
|
50
|
-
def priority_name = @raw
|
|
51
|
-
def priority_url = @raw
|
|
51
|
+
def priority_name = @raw.dig('fields', 'priority', 'name')
|
|
52
|
+
def priority_url = @raw.dig('fields', 'priority', 'iconUrl')
|
|
52
53
|
|
|
53
54
|
def summary = @raw['fields']['summary']
|
|
54
55
|
|
|
@@ -209,7 +210,38 @@ class Issue
|
|
|
209
210
|
end
|
|
210
211
|
|
|
211
212
|
def first_time_visible_on_board
|
|
212
|
-
|
|
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?
|
|
213
245
|
end
|
|
214
246
|
|
|
215
247
|
# If this issue will ever be in an active sprint then return the time that it
|
|
@@ -270,13 +302,19 @@ class Issue
|
|
|
270
302
|
# First look in the actual sprints json. If any issues are in this sprint then it should
|
|
271
303
|
# be here.
|
|
272
304
|
sprint = board.sprints.find { |s| s.id == sprint_id }
|
|
273
|
-
|
|
305
|
+
if sprint
|
|
306
|
+
return [nil, nil] if sprint.future?
|
|
307
|
+
|
|
308
|
+
return [sprint.start_time, sprint.completed_time]
|
|
309
|
+
end
|
|
274
310
|
|
|
275
311
|
# Then look at the sprints inside the issue. Even though the field id may be specified,
|
|
276
312
|
# that custom field may not be present. This happens if it was in that sprint but was
|
|
277
313
|
# then removed, whether or not that sprint had ever started.
|
|
278
314
|
sprint_data = raw['fields'][change.field_id]&.find { |sd| sd['id'].to_i == sprint_id }
|
|
279
315
|
if sprint_data
|
|
316
|
+
return [nil, nil] if sprint_data['state'] == 'future'
|
|
317
|
+
|
|
280
318
|
start = parse_time(sprint_data['startDate'])
|
|
281
319
|
stop = parse_time(sprint_data['completeDate'])
|
|
282
320
|
return [start, stop]
|
|
@@ -388,21 +426,11 @@ class Issue
|
|
|
388
426
|
results
|
|
389
427
|
end
|
|
390
428
|
|
|
391
|
-
def blocked_stalled_statuses settings
|
|
392
|
-
blocked_statuses = settings['blocked_statuses']
|
|
393
|
-
stalled_statuses = settings['stalled_statuses']
|
|
394
|
-
unless blocked_statuses.is_a?(Array) && stalled_statuses.is_a?(Array)
|
|
395
|
-
raise "blocked_statuses(#{blocked_statuses.inspect}) and " \
|
|
396
|
-
"stalled_statuses(#{stalled_statuses.inspect}) must both be arrays"
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
[blocked_statuses, stalled_statuses]
|
|
400
|
-
end
|
|
401
|
-
|
|
402
429
|
def blocked_stalled_changes end_time:, settings: nil
|
|
403
430
|
settings ||= @board.project_config.settings
|
|
404
431
|
|
|
405
|
-
blocked_statuses
|
|
432
|
+
blocked_statuses = settings['blocked_statuses']
|
|
433
|
+
stalled_statuses = settings['stalled_statuses']
|
|
406
434
|
|
|
407
435
|
blocked_link_texts = settings['blocked_link_text']
|
|
408
436
|
stalled_threshold = settings['stalled_threshold_days']
|
|
@@ -415,7 +443,9 @@ class Issue
|
|
|
415
443
|
previous_change_time = created
|
|
416
444
|
|
|
417
445
|
blocking_status = nil
|
|
446
|
+
blocking_is_blocked = false
|
|
418
447
|
flag = nil
|
|
448
|
+
flag_reason = nil
|
|
419
449
|
|
|
420
450
|
# This mock change is to force the writing of one last entry at the end of the time range.
|
|
421
451
|
# By doing this, we're able to eliminate a lot of duplicated code in charts.
|
|
@@ -430,11 +460,14 @@ class Issue
|
|
|
430
460
|
)
|
|
431
461
|
|
|
432
462
|
if change.flagged? && flagged_means_blocked
|
|
433
|
-
flag = change
|
|
434
|
-
flag = nil if change.value == ''
|
|
463
|
+
flag, flag_reason = blocked_stalled_changes_flag_logic change
|
|
435
464
|
elsif change.status?
|
|
436
465
|
blocking_status = nil
|
|
437
|
-
|
|
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)
|
|
438
471
|
blocking_status = change.value
|
|
439
472
|
end
|
|
440
473
|
elsif change.link?
|
|
@@ -455,8 +488,9 @@ class Issue
|
|
|
455
488
|
|
|
456
489
|
new_change = BlockedStalledChange.new(
|
|
457
490
|
flagged: flag,
|
|
491
|
+
flag_reason: flag_reason,
|
|
458
492
|
status: blocking_status,
|
|
459
|
-
status_is_blocking: blocking_status.nil? ||
|
|
493
|
+
status_is_blocking: blocking_status.nil? || blocking_is_blocked,
|
|
460
494
|
blocking_issue_keys: (blocking_issue_keys.empty? ? nil : blocking_issue_keys.dup),
|
|
461
495
|
time: change.time
|
|
462
496
|
)
|
|
@@ -475,6 +509,7 @@ class Issue
|
|
|
475
509
|
hack = result.pop
|
|
476
510
|
result << BlockedStalledChange.new(
|
|
477
511
|
flagged: hack.flag,
|
|
512
|
+
flag_reason: hack.flag_reason,
|
|
478
513
|
status: hack.status,
|
|
479
514
|
status_is_blocking: hack.status_is_blocking,
|
|
480
515
|
blocking_issue_keys: hack.blocking_issue_keys,
|
|
@@ -486,6 +521,28 @@ class Issue
|
|
|
486
521
|
result
|
|
487
522
|
end
|
|
488
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
|
+
|
|
489
546
|
def check_for_stalled change_time:, previous_change_time:, stalled_threshold:, blocking_stalled_changes:
|
|
490
547
|
stalled_threshold_seconds = stalled_threshold * 60 * 60 * 24
|
|
491
548
|
|
|
@@ -521,7 +578,7 @@ class Issue
|
|
|
521
578
|
# return [number of active seconds, total seconds] that this issue had up to the end_time.
|
|
522
579
|
# It does not include data before issue start or after issue end
|
|
523
580
|
def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
|
|
524
|
-
issue_start, issue_stop =
|
|
581
|
+
issue_start, issue_stop = started_stopped_times
|
|
525
582
|
return [0.0, 0.0] if !issue_start || issue_start > end_time
|
|
526
583
|
|
|
527
584
|
value_add_time = 0.0
|
|
@@ -701,75 +758,7 @@ class Issue
|
|
|
701
758
|
end
|
|
702
759
|
|
|
703
760
|
def dump
|
|
704
|
-
|
|
705
|
-
result << "#{key} (#{type}): #{compact_text summary, max: 200}\n"
|
|
706
|
-
|
|
707
|
-
assignee = raw['fields']['assignee']
|
|
708
|
-
result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
|
|
709
|
-
|
|
710
|
-
raw['fields']['issuelinks']&.each do |link|
|
|
711
|
-
result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
|
|
712
|
-
result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
|
|
713
|
-
end
|
|
714
|
-
history = [] # time, type, detail
|
|
715
|
-
|
|
716
|
-
if board.cycletime
|
|
717
|
-
started_at, stopped_at = board.cycletime.started_stopped_times(self)
|
|
718
|
-
history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
|
|
719
|
-
history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
|
|
720
|
-
else
|
|
721
|
-
result << " Unable to determine start/end times as board #{board.id} has no cycletime specified\n"
|
|
722
|
-
end
|
|
723
|
-
|
|
724
|
-
@discarded_change_times&.each do |time|
|
|
725
|
-
history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
|
|
726
|
-
end
|
|
727
|
-
|
|
728
|
-
(changes + (@discarded_changes || [])).each do |change|
|
|
729
|
-
if change.status?
|
|
730
|
-
value = "#{change.value.inspect}:#{change.value_id.inspect}"
|
|
731
|
-
old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
|
|
732
|
-
else
|
|
733
|
-
value = compact_text(change.value).inspect
|
|
734
|
-
old_value = change.old_value ? compact_text(change.old_value).inspect : nil
|
|
735
|
-
end
|
|
736
|
-
|
|
737
|
-
message = +''
|
|
738
|
-
message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
|
|
739
|
-
message << value
|
|
740
|
-
if change.artificial?
|
|
741
|
-
message << ' (Artificial entry)' if change.artificial?
|
|
742
|
-
else
|
|
743
|
-
message << " (Author: #{change.author})"
|
|
744
|
-
end
|
|
745
|
-
history << [change.time, change.field, message, change.artificial?]
|
|
746
|
-
end
|
|
747
|
-
|
|
748
|
-
result << " History:\n"
|
|
749
|
-
type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
|
|
750
|
-
history.sort! do |a, b|
|
|
751
|
-
if a[0] == b[0]
|
|
752
|
-
if a[1].nil?
|
|
753
|
-
1
|
|
754
|
-
elsif b[1].nil?
|
|
755
|
-
-1
|
|
756
|
-
else
|
|
757
|
-
a[1] <=> b[1]
|
|
758
|
-
end
|
|
759
|
-
else
|
|
760
|
-
a[0] <=> b[0]
|
|
761
|
-
end
|
|
762
|
-
end
|
|
763
|
-
history.each do |time, type, detail, _artificial|
|
|
764
|
-
if type.nil?
|
|
765
|
-
type = '-' * type_width
|
|
766
|
-
else
|
|
767
|
-
type = (' ' * (type_width - type.length)) << type
|
|
768
|
-
end
|
|
769
|
-
result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
|
|
770
|
-
end
|
|
771
|
-
|
|
772
|
-
result
|
|
761
|
+
IssuePrinter.new(self).to_s
|
|
773
762
|
end
|
|
774
763
|
|
|
775
764
|
def done?
|
|
@@ -782,10 +771,36 @@ class Issue
|
|
|
782
771
|
end
|
|
783
772
|
end
|
|
784
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
|
+
|
|
785
782
|
def status_changes
|
|
786
783
|
@changes.select { |change| change.status? }
|
|
787
784
|
end
|
|
788
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
|
+
|
|
789
804
|
def sprints
|
|
790
805
|
sprint_ids = []
|
|
791
806
|
|
|
@@ -806,23 +821,106 @@ class Issue
|
|
|
806
821
|
def compact_text text, max: 60
|
|
807
822
|
return '' if text.nil?
|
|
808
823
|
|
|
809
|
-
if text.is_a? Hash
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
|
816
831
|
text
|
|
817
832
|
end
|
|
818
833
|
|
|
819
834
|
private
|
|
820
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
|
+
|
|
821
902
|
def load_history_into_changes
|
|
822
903
|
@raw['changelog']['histories']&.each do |history|
|
|
823
904
|
created = parse_time(history['created'])
|
|
824
905
|
|
|
825
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
|
+
|
|
826
924
|
@changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
|
|
827
925
|
end
|
|
828
926
|
end
|
|
@@ -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
|