jirametrics 2.13 → 2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +10 -2
  4. data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
  6. data/lib/jirametrics/aging_work_table.rb +9 -7
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +101 -97
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  11. data/lib/jirametrics/board.rb +32 -8
  12. data/lib/jirametrics/board_config.rb +4 -1
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +2 -2
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +14 -6
  17. data/lib/jirametrics/chart_base.rb +141 -3
  18. data/lib/jirametrics/css_variable.rb +1 -1
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +21 -4
  21. data/lib/jirametrics/cycletime_histogram.rb +15 -101
  22. data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
  23. data/lib/jirametrics/daily_view.rb +85 -53
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  27. data/lib/jirametrics/daily_wip_chart.rb +30 -8
  28. data/lib/jirametrics/data_quality_report.rb +43 -12
  29. data/lib/jirametrics/dependency_chart.rb +6 -3
  30. data/lib/jirametrics/download_config.rb +15 -0
  31. data/lib/jirametrics/downloader.rb +117 -100
  32. data/lib/jirametrics/downloader_for_cloud.rb +287 -0
  33. data/lib/jirametrics/downloader_for_data_center.rb +95 -0
  34. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  35. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  36. data/lib/jirametrics/examples/standard_project.rb +41 -28
  37. data/lib/jirametrics/expedited_chart.rb +3 -1
  38. data/lib/jirametrics/exporter.rb +26 -6
  39. data/lib/jirametrics/file_config.rb +9 -11
  40. data/lib/jirametrics/file_system.rb +59 -3
  41. data/lib/jirametrics/fix_version.rb +13 -0
  42. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  43. data/lib/jirametrics/github_gateway.rb +115 -0
  44. data/lib/jirametrics/groupable_issue_chart.rb +11 -1
  45. data/lib/jirametrics/grouping_rules.rb +26 -4
  46. data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
  47. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
  48. data/lib/jirametrics/html/aging_work_table.erb +5 -0
  49. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  50. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  51. data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
  52. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  53. data/lib/jirametrics/html/expedited_chart.erb +6 -14
  54. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
  55. data/lib/jirametrics/html/index.css +249 -69
  56. data/lib/jirametrics/html/index.erb +9 -35
  57. data/lib/jirametrics/html/index.js +164 -0
  58. data/lib/jirametrics/html/legacy_colors.css +174 -0
  59. data/lib/jirametrics/html/sprint_burndown.erb +17 -15
  60. data/lib/jirametrics/html/throughput_chart.erb +42 -11
  61. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
  62. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
  63. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  64. data/lib/jirametrics/html_generator.rb +32 -0
  65. data/lib/jirametrics/html_report_config.rb +52 -57
  66. data/lib/jirametrics/issue.rb +304 -101
  67. data/lib/jirametrics/issue_printer.rb +97 -0
  68. data/lib/jirametrics/jira_gateway.rb +77 -17
  69. data/lib/jirametrics/mcp_server.rb +531 -0
  70. data/lib/jirametrics/project_config.rb +128 -12
  71. data/lib/jirametrics/pull_request.rb +30 -0
  72. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  73. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  74. data/lib/jirametrics/pull_request_review.rb +13 -0
  75. data/lib/jirametrics/raw_javascript.rb +17 -0
  76. data/lib/jirametrics/settings.json +5 -1
  77. data/lib/jirametrics/sprint.rb +12 -0
  78. data/lib/jirametrics/sprint_burndown.rb +10 -4
  79. data/lib/jirametrics/status.rb +1 -1
  80. data/lib/jirametrics/stitcher.rb +81 -0
  81. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  82. data/lib/jirametrics/throughput_chart.rb +73 -23
  83. data/lib/jirametrics/time_based_histogram.rb +139 -0
  84. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  85. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  86. data/lib/jirametrics.rb +83 -69
  87. metadata +60 -6
@@ -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
@@ -58,7 +62,19 @@ class ProjectConfig
58
62
 
59
63
  def load_settings
60
64
  # This is the weird exception that we don't ever want mocked out so we skip FileSystem entirely.
61
- JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
65
+ settings = JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
66
+
67
+ if settings['blocked_color']
68
+ file_system.deprecated message: 'blocked color should be set via css now', date: '2024-05-03'
69
+ end
70
+ if settings['stalled_color']
71
+ file_system.deprecated message: 'stalled color should be set via css now', date: '2024-05-03'
72
+ end
73
+
74
+ settings['blocked_statuses'] = StatusCollection.new
75
+ settings['stalled_statuses'] = StatusCollection.new
76
+
77
+ stringify_keys(settings)
62
78
  end
63
79
 
64
80
  def guess_project_id
@@ -82,6 +98,12 @@ class ProjectConfig
82
98
  !!@aggregate_config
83
99
  end
84
100
 
101
+ def aggregate_project_names
102
+ return [] unless aggregated_project?
103
+
104
+ @aggregate_config.included_projects.filter_map(&:name)
105
+ end
106
+
85
107
  def download &block
86
108
  raise 'Not allowed to have multiple download blocks in one project' if @download_config
87
109
  raise 'Not allowed to have both an aggregate and a download section. Pick only one.' if @aggregate_config
@@ -114,10 +136,14 @@ class ProjectConfig
114
136
  def file_prefix prefix
115
137
  # The file_prefix has to be set before almost everything else. It really should have been an attribute
116
138
  # on the project declaration itself. Hindsight is 20/20.
139
+
140
+ # There can only be one of these
117
141
  if @file_prefix
118
- raise "file_prefix should only be set once. Was #{@file_prefix.inspect} and now changed to #{prefix.inspect}."
142
+ raise "file_prefix can only be set once. Was #{@file_prefix.inspect} and now changed to #{prefix.inspect}."
119
143
  end
120
144
 
145
+ raise_if_prefix_already_used(prefix)
146
+
121
147
  @file_prefix = prefix
122
148
 
123
149
  # Yes, this is a wierd place to be initializing this. Unfortunately, it has to happen after the file_prefix
@@ -130,8 +156,32 @@ class ProjectConfig
130
156
  @file_prefix
131
157
  end
132
158
 
133
- def get_file_prefix # rubocop:disable Naming/AccessorMethodName
134
- raise 'file_prefix has not been set yet. Move it to the top of the project declaration.' unless @file_prefix
159
+ def validate_discard_status status_name
160
+ return if status_name == :backlog
161
+ return if possible_statuses.empty? # not yet downloaded; skip validation
162
+
163
+ found = possible_statuses.find_all_by_name status_name
164
+ return unless found.empty?
165
+
166
+ raise "discard_changes_before: Status #{status_name.inspect} not found. " \
167
+ "Possible statuses are: #{possible_statuses}"
168
+ end
169
+
170
+ def raise_if_prefix_already_used prefix
171
+ @exporter.project_configs.each do |project|
172
+ next unless project.get_file_prefix(raise_if_not_set: false) == prefix && project.target_path == target_path
173
+
174
+ raise "Project #{name.inspect} specifies file prefix #{prefix.inspect}, " \
175
+ "but that is already used by project #{project.name.inspect} in the same target path #{target_path.inspect}. " \
176
+ 'This is almost guaranteed to be too much copy and paste in your configuration. ' \
177
+ 'File prefixes must be unique within a directory.'
178
+ end
179
+ end
180
+
181
+ def get_file_prefix raise_if_not_set: true
182
+ if @file_prefix.nil? && raise_if_not_set
183
+ raise 'file_prefix has not been set yet. Move it to the top of the project declaration.'
184
+ end
135
185
 
136
186
  @file_prefix
137
187
  end
@@ -241,9 +291,16 @@ class ProjectConfig
241
291
  end
242
292
 
243
293
  def load_board board_id:, filename:
244
- board = Board.new(
245
- raw: file_system.load_json(filename), possible_statuses: @possible_statuses
246
- )
294
+ raw = file_system.load_json(filename)
295
+
296
+ features_filename = File.join(@target_path, "#{get_file_prefix}_board_#{board_id}_features.json")
297
+ features = if file_system.file_exist?(features_filename)
298
+ BoardFeature.from_raw(file_system.load_json(features_filename))
299
+ else
300
+ []
301
+ end
302
+
303
+ board = Board.new(raw: raw, possible_statuses: @possible_statuses, features: features)
247
304
  board.project_config = self
248
305
  @all_boards[board_id] = board
249
306
  end
@@ -278,8 +335,9 @@ class ProjectConfig
278
335
  file_system.foreach(@target_path) do |file|
279
336
  next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
280
337
 
338
+ board_id = $1.to_i
281
339
  file_path = File.join(@target_path, file)
282
- board = @all_boards[$1.to_i]
340
+ board = @all_boards[board_id]
283
341
  unless board
284
342
  @exporter.file_system.log(
285
343
  'Found sprint data but can\'t find a matching board in config. ' \
@@ -299,6 +357,13 @@ class ProjectConfig
299
357
  end
300
358
  end
301
359
 
360
+ def load_fix_versions
361
+ filename = File.join(@target_path, "#{get_file_prefix}_fix_versions.json")
362
+ return unless file_system.file_exist?(filename)
363
+
364
+ @fix_versions = file_system.load_json(filename).map { |raw| FixVersion.new(raw) }
365
+ end
366
+
302
367
  def load_project_metadata
303
368
  filename = File.join @target_path, "#{get_file_prefix}_meta.json"
304
369
  json = file_system.load_json(filename)
@@ -335,6 +400,25 @@ class ProjectConfig
335
400
  json.each { |user_data| @users << User.new(raw: user_data) }
336
401
  end
337
402
 
403
+ def attach_github_prs
404
+ filename = File.join(@target_path, "#{get_file_prefix}_github_prs.json")
405
+ return unless File.exist?(filename)
406
+
407
+ prs_by_issue_key = Hash.new { |h, k| h[k] = [] }
408
+ file_system.load_json(filename).each do |raw|
409
+ pr = PullRequest.new(raw: raw)
410
+ pr.issue_keys.each { |key| prs_by_issue_key[key] << pr }
411
+ end
412
+
413
+ @issues.each { |issue| issue.github_prs = prs_by_issue_key[issue.key] }
414
+ end
415
+
416
+ def atlassian_document_format
417
+ @atlassian_document_format ||= AtlassianDocumentFormat.new(
418
+ users: @users, timezone_offset: exporter.timezone_offset
419
+ )
420
+ end
421
+
338
422
  def to_time string, end_of_day: false
339
423
  time = end_of_day ? '23:59:59' : '00:00:00'
340
424
  string = "#{string}T#{time}#{exporter.timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
@@ -369,7 +453,7 @@ class ProjectConfig
369
453
  # To be used by the aggregate_config only. Not intended to be part of the public API
370
454
  def add_issues issues_list
371
455
  @issues = IssueCollection.new if @issues.nil?
372
- @all_boards = {}
456
+ @all_boards ||= {}
373
457
 
374
458
  issues_list.each do |issue|
375
459
  @issues << issue
@@ -411,6 +495,7 @@ class ProjectConfig
411
495
  # attached them in the appropriate places, remove any that aren't part of that initial set.
412
496
  issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
413
497
  @issues = issues
498
+ attach_github_prs
414
499
  end
415
500
 
416
501
  @issues
@@ -526,9 +611,12 @@ class ProjectConfig
526
611
  end
527
612
 
528
613
  def discard_changes_before status_becomes: nil, &block
614
+ cycletimes_touched = Set.new
529
615
  if status_becomes
530
616
  status_becomes = [status_becomes] unless status_becomes.is_a? Array
531
617
 
618
+ status_becomes.each { |status_name| validate_discard_status status_name }
619
+
532
620
  block = lambda do |issue|
533
621
  trigger_statuses = status_becomes.collect do |status_name|
534
622
  if status_name == :backlog
@@ -554,10 +642,11 @@ class ProjectConfig
554
642
  cutoff_time = block.call(issue)
555
643
  next if cutoff_time.nil?
556
644
 
557
- original_start_time = issue.board.cycletime.started_stopped_times(issue).first
645
+ original_start_time = issue.started_stopped_times.first
558
646
  next if original_start_time.nil?
559
647
 
560
648
  issue.discard_changes_before cutoff_time
649
+ cycletimes_touched << issue.board.cycletime
561
650
 
562
651
  next unless cutoff_time
563
652
  next if original_start_time > cutoff_time # ie the cutoff would have made no difference.
@@ -568,5 +657,32 @@ class ProjectConfig
568
657
  issue: issue
569
658
  }
570
659
  end
660
+
661
+ cycletimes_touched.each { |c| c.flush_cache }
662
+ end
663
+
664
+ def stringify_keys value
665
+ case value
666
+ when Hash then value.transform_keys(&:to_s).transform_values { |v| stringify_keys(v) }
667
+ when Array then value.map { |v| stringify_keys(v) }
668
+ else value
669
+ end
670
+ end
671
+
672
+ def resolve_blocked_stalled_status_settings
673
+ %w[blocked_statuses stalled_statuses].each do |key|
674
+ next if @settings[key].is_a?(StatusCollection)
675
+
676
+ collection = StatusCollection.new
677
+ @settings[key].each do |identifier|
678
+ statuses = @possible_statuses.find_all_by_name(identifier)
679
+ if statuses.empty?
680
+ file_system.warning "Status #{identifier.inspect} in #{key} not found. Ignoring."
681
+ else
682
+ statuses.each { |status| collection << status }
683
+ end
684
+ end
685
+ @settings[key] = collection
686
+ end
571
687
  end
572
688
  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,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/groupable_issue_chart'
4
+
5
+ class PullRequestCycleTimeHistogram < TimeBasedHistogram
6
+ def initialize block
7
+ super()
8
+
9
+ @cycletime_unit = :days
10
+ @x_axis_title = 'Cycle time in days'
11
+
12
+ header_text 'PR Histogram'
13
+ description_text <<-HTML
14
+ <div class="p">
15
+ This cycletime Histogram shows how many pull requests completed in a certain timeframe. This can be
16
+ useful for determining how many different types of work are flowing through, based on the
17
+ lengths of time they take.
18
+ </div>
19
+ HTML
20
+
21
+ init_configuration_block(block) do
22
+ grouping_rules do |pull_request, rule|
23
+ rule.label = pull_request.repo
24
+ end
25
+ end
26
+ end
27
+
28
+ def cycletime_unit unit
29
+ unless %i[minutes hours days].include?(unit)
30
+ raise ArgumentError, "cycletime_unit must be :minutes, :hours, or :days, got #{unit.inspect}"
31
+ end
32
+
33
+ @cycletime_unit = unit
34
+ @x_axis_title = "Cycle time in #{unit}"
35
+ end
36
+
37
+ def all_items
38
+ result = []
39
+ issues.each do |issue|
40
+ next unless issue.github_prs
41
+
42
+ issue.github_prs.each do |pr|
43
+ next unless pr.closed_at
44
+
45
+ result << pr
46
+ end
47
+ end
48
+ result.uniq
49
+ end
50
+
51
+ def value_for_item item
52
+ divisor = { minutes: 60.0, hours: 3600.0, days: 86_400.0 }[@cycletime_unit]
53
+ ((item.closed_at - item.opened_at) / divisor).ceil
54
+ end
55
+
56
+ def label_cycletime value
57
+ case @cycletime_unit
58
+ when :minutes then label_minutes(value)
59
+ when :hours then label_hours(value)
60
+ when :days then label_days(value)
61
+ end
62
+ end
63
+
64
+ def title_for_item count:, value:
65
+ "#{count} PR#{'s' unless count == 1} closed in #{label_cycletime value}"
66
+ end
67
+
68
+ def sort_items items
69
+ items.sort_by(&:opened_at)
70
+ end
71
+
72
+ def label_for_item item, hint:
73
+ label = "#{item.number} #{item.title}"
74
+ label << hint if hint
75
+ label
76
+ end
77
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/groupable_issue_chart'
4
+
5
+ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
6
+ def initialize block
7
+ super()
8
+
9
+ @cycletime_unit = :days
10
+ @y_axis_title = 'Cycle time in days'
11
+
12
+ header_text 'Pull Request (PR) Scatterplot'
13
+ description_text <<-HTML
14
+ <div class="p">
15
+ This graph shows the cycle time for all closed pull requests (time from opened to closed).
16
+ </div>
17
+ #{describe_non_working_days}
18
+ HTML
19
+
20
+ init_configuration_block(block) do
21
+ grouping_rules do |pull_request, rule|
22
+ rule.label = pull_request.repo
23
+ end
24
+ end
25
+ end
26
+
27
+ def cycletime_unit unit
28
+ unless %i[minutes hours days].include?(unit)
29
+ raise ArgumentError, "cycletime_unit must be :minutes, :hours, or :days, got #{unit.inspect}"
30
+ end
31
+
32
+ @cycletime_unit = unit
33
+ @y_axis_title = "Cycle time in #{unit}"
34
+ end
35
+
36
+ def all_items
37
+ result = []
38
+ issues.each do |issue|
39
+ issue.github_prs&.each do |pr|
40
+ result << pr if pr.closed_at
41
+ end
42
+ end
43
+ result
44
+ end
45
+
46
+ def x_value pull_request
47
+ pull_request.closed_at
48
+ end
49
+
50
+ def y_value pull_request
51
+ if @cycletime_unit == :days
52
+ tz = timezone_offset || '+00:00'
53
+ opened = pull_request.opened_at.getlocal(tz).to_date
54
+ closed = pull_request.closed_at.getlocal(tz).to_date
55
+ (closed - opened).to_i + 1
56
+ else
57
+ divisor = { minutes: 60, hours: 3600 }[@cycletime_unit]
58
+ ((pull_request.closed_at - pull_request.opened_at) / divisor).round
59
+ end
60
+ end
61
+
62
+ def label_cycletime value
63
+ case @cycletime_unit
64
+ when :minutes then label_minutes(value)
65
+ when :hours then label_hours(value)
66
+ when :days then label_days(value)
67
+ end
68
+ end
69
+
70
+ def title_value pull_request, rules: nil
71
+ age_label = label_cycletime y_value(pull_request)
72
+ keys = pull_request.issue_keys.join(', ')
73
+ "#{keys} | #{pull_request.title} | #{rules.label} | Age:#{age_label}#{lines_changed_text(pull_request)}"
74
+ end
75
+
76
+ def lines_changed_text pull_request
77
+ return '' unless pull_request.changed_files
78
+
79
+ additions = pull_request.additions || 0
80
+ deletions = pull_request.deletions || 0
81
+ text = +' | Lines changed: ['
82
+ text << "+#{to_human_readable additions}" unless additions.zero?
83
+ text << ' ' if additions != 0 && deletions != 0
84
+ text << "-#{to_human_readable deletions}" unless deletions.zero?
85
+ text << "], Files changed: #{to_human_readable pull_request.changed_files}"
86
+ text
87
+ end
88
+ 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
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # When strings are serialized into JSON, they're converted to actual strings. The purpose
4
+ # of this class is to allow raw javascript to be passed through.
5
+ class RawJavascript
6
+ def initialize content
7
+ @content = content
8
+ end
9
+
10
+ def to_json(*_args)
11
+ @content
12
+ end
13
+
14
+ def == other
15
+ other.is_a?(RawJavascript) && to_json == other.to_json
16
+ end
17
+ end
@@ -7,5 +7,9 @@
7
7
  "flagged_means_blocked": true,
8
8
 
9
9
  "expedited_priority_names": ["Critical", "Highest"],
10
- "priority_order": ["Lowest", "Low", "Medium", "High", "Highest"]
10
+ "priority_order": ["Lowest", "Low", "Medium", "High", "Highest"],
11
+
12
+ "cache_cycletime_calculations": true,
13
+
14
+ "date_annotations": []
11
15
  }
@@ -13,6 +13,7 @@ class Sprint
13
13
  def id = @raw['id']
14
14
  def active? = (@raw['state'] == 'active')
15
15
  def closed? = (@raw['state'] == 'closed')
16
+ def future? = (@raw['state'] == 'future')
16
17
 
17
18
  def completed_at? time
18
19
  completed_at = completed_time
@@ -36,6 +37,17 @@ class Sprint
36
37
  def goal = @raw['goal']
37
38
  def name = @raw['name']
38
39
 
40
+ def day_count
41
+ return '' if future?
42
+
43
+ if closed?
44
+ days = (completed_time.to_date - start_time.to_date).to_i + 1
45
+ else
46
+ days = (end_time.to_date - start_time.to_date).to_i + 1
47
+ end
48
+ "#{days} days"
49
+ end
50
+
39
51
  private
40
52
 
41
53
  def parse_time time_string
@@ -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
@@ -48,8 +50,9 @@ class SprintBurndown < ChartBase
48
50
  end
49
51
 
50
52
  def run
51
- sprints = sprints_in_time_range all_boards[board_id]
52
- return nil if sprints.empty?
53
+ return nil unless current_board.scrum?
54
+
55
+ sprints = sprints_in_time_range current_board
53
56
 
54
57
  change_data_by_sprint = {}
55
58
  sprints.each do |sprint|
@@ -63,7 +66,7 @@ class SprintBurndown < ChartBase
63
66
  result = +''
64
67
  result << render_top_text(binding)
65
68
 
66
- possible_colours = (1..5).collect { |i| CssVariable["--sprint-burndown-sprint-color-#{i}"] }
69
+ possible_colours = (1..ChartBase::OKABE_ITO_PALETTE.size).collect { |i| CssVariable["--sprint-burndown-sprint-color-#{i}"] }
67
70
  charts_to_generate = []
68
71
  charts_to_generate << [:data_set_by_story_points, 'Story Points'] if @use_story_points
69
72
  charts_to_generate << [:data_set_by_story_counts, 'Story Count'] if @use_story_counts
@@ -110,6 +113,9 @@ class SprintBurndown < ChartBase
110
113
 
111
114
  def sprints_in_time_range board
112
115
  board.sprints.select do |sprint|
116
+ # If it's never been started then it's just a holding area. Ignore it.
117
+ next if sprint.future?
118
+
113
119
  sprint_end_time = sprint.completed_time || sprint.end_time
114
120
  sprint_start_time = sprint.start_time
115
121
  next false if sprint_start_time.nil?
@@ -128,7 +134,7 @@ class SprintBurndown < ChartBase
128
134
 
129
135
  estimate_display_name = current_board.estimation_configuration.display_name
130
136
 
131
- issue_completed_time = issue.board.cycletime.started_stopped_times(issue).last
137
+ issue_completed_time = issue.started_stopped_times.last
132
138
  completed_has_been_tracked = false
133
139
 
134
140
  issue.changes.each do |change|
@@ -36,7 +36,7 @@ class Status
36
36
  end
37
37
 
38
38
  def self.from_raw raw
39
- raise "raw cannot be nil" if raw.nil?
39
+ raise 'raw cannot be nil' if raw.nil?
40
40
 
41
41
  category_config = raw['statusCategory']
42
42
  raise "statusCategory can't be nil in #{category_config.inspect}" if category_config.nil?
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Stitcher < HtmlGenerator
4
+ class StitchContent
5
+ include ValueEquality
6
+
7
+ attr_reader :file, :title, :content, :type
8
+
9
+ def initialize file:, title:, type:, content:
10
+ @file = file
11
+ @title = title
12
+ @content = content
13
+ @type = type
14
+ end
15
+ end
16
+
17
+ attr_reader :loaded_files, :all_stitches
18
+
19
+ def initialize file_system:
20
+ super()
21
+ self.file_system = file_system
22
+ @all_stitches = []
23
+ @loaded_files = []
24
+ end
25
+
26
+ def run stitch_file:
27
+ output_filename = make_output_filename stitch_file
28
+ file_system.log "Creating file #{output_filename.inspect}", also_write_to_stderr: true
29
+ erb = ERB.new file_system.load(stitch_file)
30
+ @sections = [[erb.result(binding), :body]]
31
+ create_html output_filename: output_filename, settings: {}
32
+ end
33
+
34
+ def make_output_filename input_filename
35
+ if /^(.+)\.erb$/ =~ input_filename
36
+ "#{$1}.html"
37
+ else
38
+ "#{input_filename}.html"
39
+ end
40
+ end
41
+
42
+ def grab_by_title title, from_file:, type: 'chart'
43
+ parse_file from_file
44
+ stitch_content = @all_stitches.find { |s| s.file == from_file && s.title == title && s.type == type }
45
+ return stitch_content.content if stitch_content
46
+
47
+ file_system.error "Unable to find content in file #{from_file.inspect} matching title: #{title.inspect}"
48
+ ''
49
+ end
50
+
51
+ def parse_file filename
52
+ return false if @loaded_files.include? filename
53
+
54
+ # To match: <!-- seam-start | chart78 | GithubPrScatterplot | PR Scatterplot | chart -->
55
+ regex = /^<!-- seam-(?<seam>start|end) \| (?<id>[^|]+) \| (?<clazz>[^|]+) \| (?<title>[^|]+) \| (?<type>[^|]+) -->$/
56
+ content = nil
57
+ file_system.load(filename).lines do |line|
58
+ matches = line.match(regex)
59
+ if matches
60
+ if matches[:seam] == 'start'
61
+ content = +''
62
+ else
63
+ if content.nil? || content.strip.empty?
64
+ file_system.warning "Seam found with no content in #{filename.inspect}: " \
65
+ "id=#{matches[:id].strip.inspect}, class=#{matches[:clazz].strip.inspect}, " \
66
+ "title=#{matches[:title].strip.inspect}"
67
+ end
68
+ @all_stitches << Stitcher::StitchContent.new(
69
+ file: filename, title: matches[:title], type: matches[:type], content: content
70
+ )
71
+ content = nil
72
+ end
73
+ elsif content
74
+ content << line
75
+ end
76
+ end
77
+
78
+ @loaded_files << filename
79
+ true
80
+ end
81
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/throughput_chart'
4
+
5
+ class ThroughputByCompletedResolutionChart < ThroughputChart
6
+ def initialize block
7
+ super
8
+ header_text 'Throughput, grouped by completion status and resolution'
9
+ description_text nil
10
+ end
11
+
12
+ def default_grouping_rules issue, rules
13
+ status, resolution = issue.status_resolution_at_done
14
+ if resolution
15
+ rules.label = "#{status.name}:#{resolution}"
16
+ rules.label_hint = "Status: #{status.name.inspect}:#{status.id}, resolution: #{resolution.inspect}"
17
+ else
18
+ rules.label = status.name
19
+ rules.label_hint = "Status: #{status.name.inspect}:#{status.id}"
20
+ end
21
+ end
22
+ end