jirametrics 2.22 → 2.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/jirametrics/aggregate_config.rb +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +15 -1
- data/lib/jirametrics/aging_work_table.rb +1 -1
- data/lib/jirametrics/anonymizer.rb +74 -1
- data/lib/jirametrics/atlassian_document_format.rb +104 -93
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +20 -8
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/change_item.rb +4 -3
- data/lib/jirametrics/chart_base.rb +87 -1
- data/lib/jirametrics/css_variable.rb +1 -1
- 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 +8 -97
- data/lib/jirametrics/daily_view.rb +32 -9
- data/lib/jirametrics/daily_wip_chart.rb +27 -7
- data/lib/jirametrics/data_quality_report.rb +31 -7
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +76 -5
- data/lib/jirametrics/downloader_for_cloud.rb +39 -0
- data/lib/jirametrics/downloader_for_data_center.rb +2 -1
- data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
- data/lib/jirametrics/examples/aggregated_project.rb +1 -1
- data/lib/jirametrics/examples/standard_project.rb +20 -9
- data/lib/jirametrics/expedited_chart.rb +2 -0
- data/lib/jirametrics/exporter.rb +3 -1
- data/lib/jirametrics/file_system.rb +4 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +2 -0
- data/lib/jirametrics/github_gateway.rb +106 -0
- data/lib/jirametrics/groupable_issue_chart.rb +2 -0
- data/lib/jirametrics/grouping_rules.rb +21 -3
- 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/daily_wip_chart.erb +5 -4
- 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 +114 -0
- data/lib/jirametrics/html/index.erb +5 -0
- data/lib/jirametrics/html/index.js +52 -2
- data/lib/jirametrics/html/sprint_burndown.erb +7 -13
- data/lib/jirametrics/html/throughput_chart.erb +5 -8
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +57 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +3 -4
- data/lib/jirametrics/html_report_config.rb +2 -0
- data/lib/jirametrics/issue.rb +84 -95
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +6 -3
- data/lib/jirametrics/project_config.rb +66 -6
- data/lib/jirametrics/pull_request.rb +30 -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 +2 -0
- data/lib/jirametrics/stitcher.rb +2 -1
- data/lib/jirametrics/throughput_chart.rb +7 -1
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +100 -0
- metadata +12 -5
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
|
|
@@ -212,6 +213,19 @@ class Issue
|
|
|
212
213
|
first_time_in_status(*board.visible_columns.collect(&:status_ids).flatten)
|
|
213
214
|
end
|
|
214
215
|
|
|
216
|
+
def reasons_not_visible_on_board
|
|
217
|
+
reasons = []
|
|
218
|
+
reasons << 'Not in an active sprint' if board.scrum? && sprints.none?(&:active?)
|
|
219
|
+
unless board.visible_columns.any? { |c| c.status_ids.include?(status.id) }
|
|
220
|
+
reasons << 'Status is not configured for any visible column on the board'
|
|
221
|
+
end
|
|
222
|
+
reasons
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def visible_on_board?
|
|
226
|
+
reasons_not_visible_on_board.empty?
|
|
227
|
+
end
|
|
228
|
+
|
|
215
229
|
# If this issue will ever be in an active sprint then return the time that it
|
|
216
230
|
# was first added to that sprint, whether or not the sprint was active at that
|
|
217
231
|
# time. Although it seems like an odd thing to calculate, it's a reasonable proxy
|
|
@@ -270,13 +284,19 @@ class Issue
|
|
|
270
284
|
# First look in the actual sprints json. If any issues are in this sprint then it should
|
|
271
285
|
# be here.
|
|
272
286
|
sprint = board.sprints.find { |s| s.id == sprint_id }
|
|
273
|
-
|
|
287
|
+
if sprint
|
|
288
|
+
return [nil, nil] if sprint.future?
|
|
289
|
+
|
|
290
|
+
return [sprint.start_time, sprint.completed_time]
|
|
291
|
+
end
|
|
274
292
|
|
|
275
293
|
# Then look at the sprints inside the issue. Even though the field id may be specified,
|
|
276
294
|
# that custom field may not be present. This happens if it was in that sprint but was
|
|
277
295
|
# then removed, whether or not that sprint had ever started.
|
|
278
296
|
sprint_data = raw['fields'][change.field_id]&.find { |sd| sd['id'].to_i == sprint_id }
|
|
279
297
|
if sprint_data
|
|
298
|
+
return [nil, nil] if sprint_data['state'] == 'future'
|
|
299
|
+
|
|
280
300
|
start = parse_time(sprint_data['startDate'])
|
|
281
301
|
stop = parse_time(sprint_data['completeDate'])
|
|
282
302
|
return [start, stop]
|
|
@@ -388,21 +408,11 @@ class Issue
|
|
|
388
408
|
results
|
|
389
409
|
end
|
|
390
410
|
|
|
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
411
|
def blocked_stalled_changes end_time:, settings: nil
|
|
403
412
|
settings ||= @board.project_config.settings
|
|
404
413
|
|
|
405
|
-
blocked_statuses
|
|
414
|
+
blocked_statuses = settings['blocked_statuses']
|
|
415
|
+
stalled_statuses = settings['stalled_statuses']
|
|
406
416
|
|
|
407
417
|
blocked_link_texts = settings['blocked_link_text']
|
|
408
418
|
stalled_threshold = settings['stalled_threshold_days']
|
|
@@ -415,7 +425,9 @@ class Issue
|
|
|
415
425
|
previous_change_time = created
|
|
416
426
|
|
|
417
427
|
blocking_status = nil
|
|
428
|
+
blocking_is_blocked = false
|
|
418
429
|
flag = nil
|
|
430
|
+
flag_reason = nil
|
|
419
431
|
|
|
420
432
|
# This mock change is to force the writing of one last entry at the end of the time range.
|
|
421
433
|
# By doing this, we're able to eliminate a lot of duplicated code in charts.
|
|
@@ -430,11 +442,14 @@ class Issue
|
|
|
430
442
|
)
|
|
431
443
|
|
|
432
444
|
if change.flagged? && flagged_means_blocked
|
|
433
|
-
flag = change
|
|
434
|
-
flag = nil if change.value == ''
|
|
445
|
+
flag, flag_reason = blocked_stalled_changes_flag_logic change
|
|
435
446
|
elsif change.status?
|
|
436
447
|
blocking_status = nil
|
|
437
|
-
|
|
448
|
+
blocking_is_blocked = false
|
|
449
|
+
if blocked_statuses.find_by_id(change.value_id)
|
|
450
|
+
blocking_status = change.value
|
|
451
|
+
blocking_is_blocked = true
|
|
452
|
+
elsif stalled_statuses.find_by_id(change.value_id)
|
|
438
453
|
blocking_status = change.value
|
|
439
454
|
end
|
|
440
455
|
elsif change.link?
|
|
@@ -455,8 +470,9 @@ class Issue
|
|
|
455
470
|
|
|
456
471
|
new_change = BlockedStalledChange.new(
|
|
457
472
|
flagged: flag,
|
|
473
|
+
flag_reason: flag_reason,
|
|
458
474
|
status: blocking_status,
|
|
459
|
-
status_is_blocking: blocking_status.nil? ||
|
|
475
|
+
status_is_blocking: blocking_status.nil? || blocking_is_blocked,
|
|
460
476
|
blocking_issue_keys: (blocking_issue_keys.empty? ? nil : blocking_issue_keys.dup),
|
|
461
477
|
time: change.time
|
|
462
478
|
)
|
|
@@ -475,6 +491,7 @@ class Issue
|
|
|
475
491
|
hack = result.pop
|
|
476
492
|
result << BlockedStalledChange.new(
|
|
477
493
|
flagged: hack.flag,
|
|
494
|
+
flag_reason: hack.flag_reason,
|
|
478
495
|
status: hack.status,
|
|
479
496
|
status_is_blocking: hack.status_is_blocking,
|
|
480
497
|
blocking_issue_keys: hack.blocking_issue_keys,
|
|
@@ -486,6 +503,28 @@ class Issue
|
|
|
486
503
|
result
|
|
487
504
|
end
|
|
488
505
|
|
|
506
|
+
def blocked_stalled_changes_flag_logic change
|
|
507
|
+
flag = change.value
|
|
508
|
+
flag = nil if change.value == ''
|
|
509
|
+
if flag
|
|
510
|
+
# When the user is adding a comment to explain why a flag was set, the flag is set immediately
|
|
511
|
+
# and the comment is inserted after the user hits enter, which means that there is some time
|
|
512
|
+
# gap. If a comment happened shortly after the flag was set, we assume they're linked. This
|
|
513
|
+
# won't always be true and so there will be false positives, but it's a reasonable assumption.
|
|
514
|
+
max_seconds_between_flag_and_comment = 30
|
|
515
|
+
comment_change = changes.find do |c|
|
|
516
|
+
c.comment? && c.time >= change.time && (c.time - change.time) <= max_seconds_between_flag_and_comment
|
|
517
|
+
end
|
|
518
|
+
flag_reason = comment_change && @board.project_config.atlassian_document_format.to_text(comment_change.value)
|
|
519
|
+
# Newer Jira instances may add this extra text but older instances did not. Strip it out if found.
|
|
520
|
+
flag_reason = flag_reason&.sub(/\A:flag_on: Flag added\s*/m, '')&.strip
|
|
521
|
+
flag_reason = nil if flag_reason&.empty?
|
|
522
|
+
else
|
|
523
|
+
flag_reason = nil
|
|
524
|
+
end
|
|
525
|
+
[flag, flag_reason]
|
|
526
|
+
end
|
|
527
|
+
|
|
489
528
|
def check_for_stalled change_time:, previous_change_time:, stalled_threshold:, blocking_stalled_changes:
|
|
490
529
|
stalled_threshold_seconds = stalled_threshold * 60 * 60 * 24
|
|
491
530
|
|
|
@@ -701,75 +740,7 @@ class Issue
|
|
|
701
740
|
end
|
|
702
741
|
|
|
703
742
|
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
|
|
743
|
+
IssuePrinter.new(self).to_s
|
|
773
744
|
end
|
|
774
745
|
|
|
775
746
|
def done?
|
|
@@ -786,6 +757,24 @@ class Issue
|
|
|
786
757
|
@changes.select { |change| change.status? }
|
|
787
758
|
end
|
|
788
759
|
|
|
760
|
+
def status_resolution_at_done
|
|
761
|
+
done_time = board.cycletime.started_stopped_times(self).last
|
|
762
|
+
return [nil, nil] if done_time.nil?
|
|
763
|
+
|
|
764
|
+
status_change = nil
|
|
765
|
+
resolution = nil
|
|
766
|
+
|
|
767
|
+
@changes.each do |change|
|
|
768
|
+
break if change.time > done_time
|
|
769
|
+
|
|
770
|
+
status_change = change if change.status?
|
|
771
|
+
resolution = change.value if change.resolution?
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
status = status_change ? find_or_create_status(id: status_change.value_id, name: status_change.value) : nil
|
|
775
|
+
[status, resolution]
|
|
776
|
+
end
|
|
777
|
+
|
|
789
778
|
def sprints
|
|
790
779
|
sprint_ids = []
|
|
791
780
|
|
|
@@ -806,13 +795,13 @@ class Issue
|
|
|
806
795
|
def compact_text text, max: 60
|
|
807
796
|
return '' if text.nil?
|
|
808
797
|
|
|
809
|
-
if text.is_a? Hash
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
798
|
+
text = if text.is_a? Hash
|
|
799
|
+
@board.project_config.atlassian_document_format.to_text(text)
|
|
800
|
+
else
|
|
801
|
+
text
|
|
802
|
+
end
|
|
803
|
+
text = text.gsub(/\s+/, ' ').strip
|
|
804
|
+
text = "#{text[0...max]}..." if text.length > max
|
|
816
805
|
text
|
|
817
806
|
end
|
|
818
807
|
|
|
@@ -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.board.cycletime.started_stopped_times(issue)
|
|
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
|
|
@@ -28,9 +28,12 @@ class JiraGateway
|
|
|
28
28
|
|
|
29
29
|
stdout, stderr, status = capture3(command, stdin_data: stdin_data)
|
|
30
30
|
unless status.success?
|
|
31
|
-
@file_system.
|
|
32
|
-
@file_system.
|
|
33
|
-
@file_system.
|
|
31
|
+
@file_system.error "Failed call with exit status #{status.exitstatus}!"
|
|
32
|
+
@file_system.error "Returned (stdout): #{stdout.inspect}"
|
|
33
|
+
@file_system.error "Returned (stderr): #{stderr.inspect}"
|
|
34
|
+
if stderr.include?('401')
|
|
35
|
+
raise 'The request was not authorized. Verify that your authentication token hasn\'t expired'
|
|
36
|
+
end
|
|
34
37
|
raise "Failed call with exit status #{status.exitstatus}. " \
|
|
35
38
|
"See #{@file_system.logfile_name} for details"
|
|
36
39
|
end
|
|
@@ -6,7 +6,7 @@ require 'jirametrics/status_collection'
|
|
|
6
6
|
class ProjectConfig
|
|
7
7
|
attr_reader :target_path, :jira_config, :all_boards, :possible_statuses,
|
|
8
8
|
:download_config, :file_configs, :exporter, :data_version, :name, :board_configs,
|
|
9
|
-
:settings, :aggregate_config, :discarded_changes_data, :users
|
|
9
|
+
:settings, :aggregate_config, :discarded_changes_data, :users, :fix_versions
|
|
10
10
|
attr_accessor :time_range, :jira_url, :id
|
|
11
11
|
|
|
12
12
|
def initialize exporter:, jira_config:, block:, target_path: '.', name: '', id: nil
|
|
@@ -23,6 +23,7 @@ class ProjectConfig
|
|
|
23
23
|
@settings = load_settings
|
|
24
24
|
@id = id
|
|
25
25
|
@has_loaded_data = false
|
|
26
|
+
@fix_versions = []
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def evaluate_next_level
|
|
@@ -40,17 +41,20 @@ class ProjectConfig
|
|
|
40
41
|
@id = guess_project_id
|
|
41
42
|
load_project_metadata
|
|
42
43
|
load_sprints
|
|
44
|
+
load_fix_versions
|
|
43
45
|
load_users
|
|
46
|
+
resolve_blocked_stalled_status_settings
|
|
44
47
|
end
|
|
45
48
|
|
|
46
49
|
def run load_only: false
|
|
47
50
|
return if @exporter.downloading?
|
|
48
51
|
|
|
49
52
|
load_data unless aggregated_project?
|
|
50
|
-
anonymize_data if @anonymizer_needed
|
|
51
53
|
|
|
52
54
|
return if load_only
|
|
53
55
|
|
|
56
|
+
anonymize_data if @anonymizer_needed
|
|
57
|
+
|
|
54
58
|
@file_configs.each do |file_config|
|
|
55
59
|
file_config.run
|
|
56
60
|
end
|
|
@@ -67,7 +71,10 @@ class ProjectConfig
|
|
|
67
71
|
file_system.deprecated message: 'stalled color should be set via css now', date: '2024-05-03'
|
|
68
72
|
end
|
|
69
73
|
|
|
70
|
-
settings
|
|
74
|
+
settings['blocked_statuses'] = StatusCollection.new
|
|
75
|
+
settings['stalled_statuses'] = StatusCollection.new
|
|
76
|
+
|
|
77
|
+
stringify_keys(settings)
|
|
71
78
|
end
|
|
72
79
|
|
|
73
80
|
def guess_project_id
|
|
@@ -267,9 +274,16 @@ class ProjectConfig
|
|
|
267
274
|
end
|
|
268
275
|
|
|
269
276
|
def load_board board_id:, filename:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
)
|
|
277
|
+
raw = file_system.load_json(filename)
|
|
278
|
+
|
|
279
|
+
features_filename = File.join(@target_path, "#{get_file_prefix}_board_#{board_id}_features.json")
|
|
280
|
+
features = if file_system.file_exist?(features_filename)
|
|
281
|
+
BoardFeature.from_raw(file_system.load_json(features_filename))
|
|
282
|
+
else
|
|
283
|
+
[]
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
board = Board.new(raw: raw, possible_statuses: @possible_statuses, features: features)
|
|
273
287
|
board.project_config = self
|
|
274
288
|
@all_boards[board_id] = board
|
|
275
289
|
end
|
|
@@ -326,6 +340,13 @@ class ProjectConfig
|
|
|
326
340
|
end
|
|
327
341
|
end
|
|
328
342
|
|
|
343
|
+
def load_fix_versions
|
|
344
|
+
filename = File.join(@target_path, "#{get_file_prefix}_fix_versions.json")
|
|
345
|
+
return unless file_system.file_exist?(filename)
|
|
346
|
+
|
|
347
|
+
@fix_versions = file_system.load_json(filename).map { |raw| FixVersion.new(raw) }
|
|
348
|
+
end
|
|
349
|
+
|
|
329
350
|
def load_project_metadata
|
|
330
351
|
filename = File.join @target_path, "#{get_file_prefix}_meta.json"
|
|
331
352
|
json = file_system.load_json(filename)
|
|
@@ -362,6 +383,19 @@ class ProjectConfig
|
|
|
362
383
|
json.each { |user_data| @users << User.new(raw: user_data) }
|
|
363
384
|
end
|
|
364
385
|
|
|
386
|
+
def attach_github_prs
|
|
387
|
+
filename = File.join(@target_path, "#{get_file_prefix}_github_prs.json")
|
|
388
|
+
return unless File.exist?(filename)
|
|
389
|
+
|
|
390
|
+
prs_by_issue_key = Hash.new { |h, k| h[k] = [] }
|
|
391
|
+
file_system.load_json(filename).each do |raw|
|
|
392
|
+
pr = PullRequest.new(raw: raw)
|
|
393
|
+
pr.issue_keys.each { |key| prs_by_issue_key[key] << pr }
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
@issues.each { |issue| issue.github_prs = prs_by_issue_key[issue.key] }
|
|
397
|
+
end
|
|
398
|
+
|
|
365
399
|
def atlassian_document_format
|
|
366
400
|
@atlassian_document_format ||= AtlassianDocumentFormat.new(
|
|
367
401
|
users: @users, timezone_offset: exporter.timezone_offset
|
|
@@ -444,6 +478,7 @@ class ProjectConfig
|
|
|
444
478
|
# attached them in the appropriate places, remove any that aren't part of that initial set.
|
|
445
479
|
issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
|
|
446
480
|
@issues = issues
|
|
481
|
+
attach_github_prs
|
|
447
482
|
end
|
|
448
483
|
|
|
449
484
|
@issues
|
|
@@ -606,4 +641,29 @@ class ProjectConfig
|
|
|
606
641
|
|
|
607
642
|
cycletimes_touched.each { |c| c.flush_cache }
|
|
608
643
|
end
|
|
644
|
+
|
|
645
|
+
def stringify_keys value
|
|
646
|
+
case value
|
|
647
|
+
when Hash then value.transform_keys(&:to_s).transform_values { |v| stringify_keys(v) }
|
|
648
|
+
when Array then value.map { |v| stringify_keys(v) }
|
|
649
|
+
else value
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def resolve_blocked_stalled_status_settings
|
|
654
|
+
%w[blocked_statuses stalled_statuses].each do |key|
|
|
655
|
+
next if @settings[key].is_a?(StatusCollection)
|
|
656
|
+
|
|
657
|
+
collection = StatusCollection.new
|
|
658
|
+
@settings[key].each do |identifier|
|
|
659
|
+
statuses = @possible_statuses.find_all_by_name(identifier)
|
|
660
|
+
if statuses.empty?
|
|
661
|
+
file_system.warning "Status #{identifier.inspect} in #{key} not found. Ignoring."
|
|
662
|
+
else
|
|
663
|
+
statuses.each { |status| collection << status }
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
@settings[key] = collection
|
|
667
|
+
end
|
|
668
|
+
end
|
|
609
669
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'jirametrics/pull_request_review'
|
|
5
|
+
|
|
6
|
+
class PullRequest
|
|
7
|
+
attr_reader :raw
|
|
8
|
+
|
|
9
|
+
def initialize raw:
|
|
10
|
+
@raw = raw
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def number = @raw['number']
|
|
14
|
+
def repo = @raw['repo']
|
|
15
|
+
def url = @raw['url']
|
|
16
|
+
def title = @raw['title']
|
|
17
|
+
def branch = @raw['branch']
|
|
18
|
+
def state = @raw['state']
|
|
19
|
+
def issue_keys = @raw['issue_keys']
|
|
20
|
+
|
|
21
|
+
def opened_at = Time.parse(@raw['opened_at'])
|
|
22
|
+
def closed_at = @raw['closed_at'] ? Time.parse(@raw['closed_at']) : nil
|
|
23
|
+
def merged_at = @raw['merged_at'] ? Time.parse(@raw['merged_at']) : nil
|
|
24
|
+
|
|
25
|
+
def reviews = (@raw['reviews'] || []).map { |r| PullRequestReview.new(raw: r) }
|
|
26
|
+
def additions = @raw['additions']
|
|
27
|
+
def deletions = @raw['deletions']
|
|
28
|
+
def changed_files = @raw['changed_files']
|
|
29
|
+
def lines_changed = (additions || 0) + (deletions || 0)
|
|
30
|
+
end
|
data/lib/jirametrics/stitcher.rb
CHANGED
|
@@ -44,7 +44,8 @@ class Stitcher < HtmlGenerator
|
|
|
44
44
|
stitch_content = @all_stitches.find { |s| s.file == from_file && s.title == title && s.type == type }
|
|
45
45
|
return stitch_content.content if stitch_content
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
file_system.error "Unable to find content in file #{from_file.inspect} matching title: #{title.inspect}"
|
|
48
|
+
''
|
|
48
49
|
end
|
|
49
50
|
|
|
50
51
|
def parse_file filename
|
|
@@ -15,6 +15,9 @@ class ThroughputChart < ChartBase
|
|
|
15
15
|
</div>
|
|
16
16
|
#{describe_non_working_days}
|
|
17
17
|
TEXT
|
|
18
|
+
@x_axis_title = nil
|
|
19
|
+
@y_axis_title = 'Count of items'
|
|
20
|
+
|
|
18
21
|
|
|
19
22
|
init_configuration_block(block) do
|
|
20
23
|
grouping_rules do |issue, rule|
|
|
@@ -92,7 +95,10 @@ class ThroughputChart < ChartBase
|
|
|
92
95
|
{ y: closed_issues.size,
|
|
93
96
|
x: "#{period.end}T23:59:59",
|
|
94
97
|
title: ["#{closed_issues.size} items completed #{date_label}"] +
|
|
95
|
-
closed_issues.collect
|
|
98
|
+
closed_issues.collect do |_stop_date, issue|
|
|
99
|
+
hint = @issue_hints&.fetch(issue, nil)
|
|
100
|
+
"#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
|
|
101
|
+
end
|
|
96
102
|
}
|
|
97
103
|
end
|
|
98
104
|
end
|