jirametrics 2.14 → 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 +96 -96
  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 +3 -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 +139 -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 +42 -31
  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 +244 -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 +302 -98
  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 +108 -9
  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
@@ -134,6 +156,17 @@ class ProjectConfig
134
156
  @file_prefix
135
157
  end
136
158
 
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
+
137
170
  def raise_if_prefix_already_used prefix
138
171
  @exporter.project_configs.each do |project|
139
172
  next unless project.get_file_prefix(raise_if_not_set: false) == prefix && project.target_path == target_path
@@ -258,9 +291,16 @@ class ProjectConfig
258
291
  end
259
292
 
260
293
  def load_board board_id:, filename:
261
- board = Board.new(
262
- raw: file_system.load_json(filename), possible_statuses: @possible_statuses
263
- )
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)
264
304
  board.project_config = self
265
305
  @all_boards[board_id] = board
266
306
  end
@@ -295,8 +335,9 @@ class ProjectConfig
295
335
  file_system.foreach(@target_path) do |file|
296
336
  next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
297
337
 
338
+ board_id = $1.to_i
298
339
  file_path = File.join(@target_path, file)
299
- board = @all_boards[$1.to_i]
340
+ board = @all_boards[board_id]
300
341
  unless board
301
342
  @exporter.file_system.log(
302
343
  'Found sprint data but can\'t find a matching board in config. ' \
@@ -316,6 +357,13 @@ class ProjectConfig
316
357
  end
317
358
  end
318
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
+
319
367
  def load_project_metadata
320
368
  filename = File.join @target_path, "#{get_file_prefix}_meta.json"
321
369
  json = file_system.load_json(filename)
@@ -352,6 +400,25 @@ class ProjectConfig
352
400
  json.each { |user_data| @users << User.new(raw: user_data) }
353
401
  end
354
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
+
355
422
  def to_time string, end_of_day: false
356
423
  time = end_of_day ? '23:59:59' : '00:00:00'
357
424
  string = "#{string}T#{time}#{exporter.timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
@@ -386,7 +453,7 @@ class ProjectConfig
386
453
  # To be used by the aggregate_config only. Not intended to be part of the public API
387
454
  def add_issues issues_list
388
455
  @issues = IssueCollection.new if @issues.nil?
389
- @all_boards = {}
456
+ @all_boards ||= {}
390
457
 
391
458
  issues_list.each do |issue|
392
459
  @issues << issue
@@ -428,6 +495,7 @@ class ProjectConfig
428
495
  # attached them in the appropriate places, remove any that aren't part of that initial set.
429
496
  issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
430
497
  @issues = issues
498
+ attach_github_prs
431
499
  end
432
500
 
433
501
  @issues
@@ -543,9 +611,12 @@ class ProjectConfig
543
611
  end
544
612
 
545
613
  def discard_changes_before status_becomes: nil, &block
614
+ cycletimes_touched = Set.new
546
615
  if status_becomes
547
616
  status_becomes = [status_becomes] unless status_becomes.is_a? Array
548
617
 
618
+ status_becomes.each { |status_name| validate_discard_status status_name }
619
+
549
620
  block = lambda do |issue|
550
621
  trigger_statuses = status_becomes.collect do |status_name|
551
622
  if status_name == :backlog
@@ -571,10 +642,11 @@ class ProjectConfig
571
642
  cutoff_time = block.call(issue)
572
643
  next if cutoff_time.nil?
573
644
 
574
- original_start_time = issue.board.cycletime.started_stopped_times(issue).first
645
+ original_start_time = issue.started_stopped_times.first
575
646
  next if original_start_time.nil?
576
647
 
577
648
  issue.discard_changes_before cutoff_time
649
+ cycletimes_touched << issue.board.cycletime
578
650
 
579
651
  next unless cutoff_time
580
652
  next if original_start_time > cutoff_time # ie the cutoff would have made no difference.
@@ -585,5 +657,32 @@ class ProjectConfig
585
657
  issue: issue
586
658
  }
587
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
588
687
  end
589
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