jirametrics 2.22 → 2.27

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 (75) 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 +20 -6
  4. data/lib/jirametrics/aging_work_table.rb +4 -5
  5. data/lib/jirametrics/anonymizer.rb +74 -1
  6. data/lib/jirametrics/atlassian_document_format.rb +93 -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/board_movement_calculator.rb +2 -2
  11. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  12. data/lib/jirametrics/change_item.rb +4 -3
  13. data/lib/jirametrics/chart_base.rb +94 -2
  14. data/lib/jirametrics/css_variable.rb +1 -1
  15. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  16. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +1 -2
  17. data/lib/jirametrics/cycletime_histogram.rb +15 -103
  18. data/lib/jirametrics/cycletime_scatterplot.rb +13 -98
  19. data/lib/jirametrics/daily_view.rb +36 -12
  20. data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
  21. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +1 -1
  22. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  23. data/lib/jirametrics/daily_wip_chart.rb +29 -7
  24. data/lib/jirametrics/data_quality_report.rb +38 -12
  25. data/lib/jirametrics/dependency_chart.rb +2 -2
  26. data/lib/jirametrics/download_config.rb +15 -0
  27. data/lib/jirametrics/downloader.rb +87 -5
  28. data/lib/jirametrics/downloader_for_cloud.rb +52 -10
  29. data/lib/jirametrics/downloader_for_data_center.rb +2 -1
  30. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  31. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  32. data/lib/jirametrics/examples/standard_project.rb +29 -19
  33. data/lib/jirametrics/expedited_chart.rb +3 -1
  34. data/lib/jirametrics/exporter.rb +3 -1
  35. data/lib/jirametrics/file_system.rb +35 -2
  36. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  37. data/lib/jirametrics/github_gateway.rb +115 -0
  38. data/lib/jirametrics/groupable_issue_chart.rb +4 -0
  39. data/lib/jirametrics/grouping_rules.rb +26 -4
  40. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
  41. data/lib/jirametrics/html/aging_work_table.erb +3 -0
  42. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  43. data/lib/jirametrics/html/daily_wip_chart.erb +38 -5
  44. data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
  45. data/lib/jirametrics/html/expedited_chart.erb +3 -13
  46. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
  47. data/lib/jirametrics/html/index.css +117 -0
  48. data/lib/jirametrics/html/index.erb +6 -0
  49. data/lib/jirametrics/html/index.js +52 -2
  50. data/lib/jirametrics/html/sprint_burndown.erb +7 -13
  51. data/lib/jirametrics/html/throughput_chart.erb +40 -9
  52. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +59 -59
  53. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +11 -7
  54. data/lib/jirametrics/html_generator.rb +2 -1
  55. data/lib/jirametrics/html_report_config.rb +23 -16
  56. data/lib/jirametrics/issue.rb +101 -96
  57. data/lib/jirametrics/issue_printer.rb +97 -0
  58. data/lib/jirametrics/jira_gateway.rb +6 -3
  59. data/lib/jirametrics/mcp_server.rb +305 -0
  60. data/lib/jirametrics/project_config.rb +80 -7
  61. data/lib/jirametrics/pull_request.rb +30 -0
  62. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  63. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  64. data/lib/jirametrics/pull_request_review.rb +13 -0
  65. data/lib/jirametrics/raw_javascript.rb +4 -0
  66. data/lib/jirametrics/settings.json +3 -1
  67. data/lib/jirametrics/sprint_burndown.rb +3 -1
  68. data/lib/jirametrics/status.rb +1 -1
  69. data/lib/jirametrics/stitcher.rb +7 -1
  70. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  71. data/lib/jirametrics/throughput_chart.rb +73 -23
  72. data/lib/jirametrics/time_based_histogram.rb +139 -0
  73. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  74. data/lib/jirametrics.rb +28 -0
  75. metadata +47 -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
 
@@ -521,7 +560,7 @@ class Issue
521
560
  # return [number of active seconds, total seconds] that this issue had up to the end_time.
522
561
  # It does not include data before issue start or after issue end
523
562
  def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
524
- issue_start, issue_stop = @board.cycletime.started_stopped_times(self)
563
+ issue_start, issue_stop = started_stopped_times
525
564
  return [0.0, 0.0] if !issue_start || issue_start > end_time
526
565
 
527
566
  value_add_time = 0.0
@@ -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?
@@ -782,10 +753,36 @@ class Issue
782
753
  end
783
754
  end
784
755
 
756
+ def started_stopped_times
757
+ board.cycletime.started_stopped_times(self)
758
+ end
759
+
760
+ def started_stopped_dates
761
+ board.cycletime.started_stopped_dates(self)
762
+ end
763
+
785
764
  def status_changes
786
765
  @changes.select { |change| change.status? }
787
766
  end
788
767
 
768
+ def status_resolution_at_done
769
+ done_time = started_stopped_times.last
770
+ return [nil, nil] if done_time.nil?
771
+
772
+ status_change = nil
773
+ resolution = nil
774
+
775
+ @changes.each do |change|
776
+ break if change.time > done_time
777
+
778
+ status_change = change if change.status?
779
+ resolution = change.value if change.resolution?
780
+ end
781
+
782
+ status = status_change ? find_or_create_status(id: status_change.value_id, name: status_change.value) : nil
783
+ [status, resolution]
784
+ end
785
+
789
786
  def sprints
790
787
  sprint_ids = []
791
788
 
@@ -806,13 +803,13 @@ class Issue
806
803
  def compact_text text, max: 60
807
804
  return '' if text.nil?
808
805
 
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
806
+ text = if text.is_a? Hash
807
+ @board.project_config.atlassian_document_format.to_text(text)
808
+ else
809
+ text
810
+ end
811
+ text = text.gsub(/\s+/, ' ').strip
812
+ text = "#{text[0...max]}..." if text.length > max
816
813
  text
817
814
  end
818
815
 
@@ -823,6 +820,14 @@ class Issue
823
820
  created = parse_time(history['created'])
824
821
 
825
822
  history['items']&.each do |item|
823
+ if item['field'] == 'status' && item['to'].nil?
824
+ board.project_config.file_system.log(
825
+ "Issue #{key} has a status change without a 'to' id " \
826
+ "(from #{item['fromString'].inspect} to #{item['toString'].inspect}). Using id 0."
827
+ )
828
+ item = item.merge('to' => '0')
829
+ end
830
+
826
831
  @changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
827
832
  end
828
833
  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
@@ -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