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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +10 -2
  3. data/lib/jirametrics/aging_work_bar_chart.rb +15 -1
  4. data/lib/jirametrics/aging_work_table.rb +1 -1
  5. data/lib/jirametrics/anonymizer.rb +74 -1
  6. data/lib/jirametrics/atlassian_document_format.rb +104 -93
  7. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  8. data/lib/jirametrics/board.rb +20 -8
  9. data/lib/jirametrics/board_feature.rb +14 -0
  10. data/lib/jirametrics/change_item.rb +4 -3
  11. data/lib/jirametrics/chart_base.rb +87 -1
  12. data/lib/jirametrics/css_variable.rb +1 -1
  13. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +1 -2
  14. data/lib/jirametrics/cycletime_histogram.rb +15 -103
  15. data/lib/jirametrics/cycletime_scatterplot.rb +8 -97
  16. data/lib/jirametrics/daily_view.rb +32 -9
  17. data/lib/jirametrics/daily_wip_chart.rb +27 -7
  18. data/lib/jirametrics/data_quality_report.rb +31 -7
  19. data/lib/jirametrics/download_config.rb +15 -0
  20. data/lib/jirametrics/downloader.rb +76 -5
  21. data/lib/jirametrics/downloader_for_cloud.rb +39 -0
  22. data/lib/jirametrics/downloader_for_data_center.rb +2 -1
  23. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  24. data/lib/jirametrics/examples/aggregated_project.rb +1 -1
  25. data/lib/jirametrics/examples/standard_project.rb +20 -9
  26. data/lib/jirametrics/expedited_chart.rb +2 -0
  27. data/lib/jirametrics/exporter.rb +3 -1
  28. data/lib/jirametrics/file_system.rb +4 -0
  29. data/lib/jirametrics/flow_efficiency_scatterplot.rb +2 -0
  30. data/lib/jirametrics/github_gateway.rb +106 -0
  31. data/lib/jirametrics/groupable_issue_chart.rb +2 -0
  32. data/lib/jirametrics/grouping_rules.rb +21 -3
  33. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
  34. data/lib/jirametrics/html/aging_work_table.erb +3 -0
  35. data/lib/jirametrics/html/daily_wip_chart.erb +5 -4
  36. data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
  37. data/lib/jirametrics/html/expedited_chart.erb +3 -13
  38. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
  39. data/lib/jirametrics/html/index.css +114 -0
  40. data/lib/jirametrics/html/index.erb +5 -0
  41. data/lib/jirametrics/html/index.js +52 -2
  42. data/lib/jirametrics/html/sprint_burndown.erb +7 -13
  43. data/lib/jirametrics/html/throughput_chart.erb +5 -8
  44. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +57 -59
  45. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +3 -4
  46. data/lib/jirametrics/html_report_config.rb +2 -0
  47. data/lib/jirametrics/issue.rb +84 -95
  48. data/lib/jirametrics/issue_printer.rb +97 -0
  49. data/lib/jirametrics/jira_gateway.rb +6 -3
  50. data/lib/jirametrics/project_config.rb +66 -6
  51. data/lib/jirametrics/pull_request.rb +30 -0
  52. data/lib/jirametrics/pull_request_review.rb +13 -0
  53. data/lib/jirametrics/raw_javascript.rb +4 -0
  54. data/lib/jirametrics/settings.json +3 -1
  55. data/lib/jirametrics/sprint_burndown.rb +2 -0
  56. data/lib/jirametrics/stitcher.rb +2 -1
  57. data/lib/jirametrics/throughput_chart.rb +7 -1
  58. data/lib/jirametrics/time_based_histogram.rb +139 -0
  59. data/lib/jirametrics/time_based_scatterplot.rb +100 -0
  60. metadata +12 -5
@@ -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
- return [sprint.start_time, sprint.completed_time] if sprint
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, stalled_statuses = blocked_stalled_statuses(settings)
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.value
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
- if blocked_statuses.include?(change.value) || stalled_statuses.include?(change.value)
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? || blocked_statuses.include?(blocking_status),
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
- result = +''
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
- # We can't effectively compact it but we can convert it into a string.
811
- text = @board.project_config.atlassian_document_format.to_html(text)
812
- else
813
- text = text.gsub(/\s+/, ' ').strip
814
- text = "#{text[0...max]}..." if text.length > max
815
- end
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.log "Failed call with exit status #{status.exitstatus}!"
32
- @file_system.log "Returned (stdout): #{stdout.inspect}"
33
- @file_system.log "Returned (stderr): #{stderr.inspect}"
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
- board = Board.new(
271
- raw: file_system.load_json(filename), possible_statuses: @possible_statuses
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
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ class PullRequestReview
6
+ def initialize raw:
7
+ @raw = raw
8
+ end
9
+
10
+ def author = @raw['author']
11
+ def state = @raw['state']
12
+ def submitted_at = Time.parse(@raw['submitted_at'])
13
+ end
@@ -10,4 +10,8 @@ class RawJavascript
10
10
  def to_json(*_args)
11
11
  @content
12
12
  end
13
+
14
+ def == other
15
+ other.is_a?(RawJavascript) && to_json == other.to_json
16
+ end
13
17
  end
@@ -9,5 +9,7 @@
9
9
  "expedited_priority_names": ["Critical", "Highest"],
10
10
  "priority_order": ["Lowest", "Low", "Medium", "High", "Highest"],
11
11
 
12
- "cache_cycletime_calculations": true
12
+ "cache_cycletime_calculations": true,
13
+
14
+ "date_annotations": []
13
15
  }
@@ -29,6 +29,8 @@ class SprintBurndown < ChartBase
29
29
  </div>
30
30
  #{describe_non_working_days}
31
31
  TEXT
32
+ @x_axis_title = 'Date'
33
+ @y_axis_title = 'Items remaining'
32
34
  end
33
35
 
34
36
  def options= arg
@@ -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
- raise "Unable to find content in file #{from_file.inspect} matching title: #{title.inspect}"
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 { |_stop_date, issue| "#{issue.key} : #{issue.summary}" }
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