jirametrics 2.22 → 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 (81) 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 +26 -10
  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 +74 -1
  8. data/lib/jirametrics/atlassian_document_format.rb +93 -93
  9. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  10. data/lib/jirametrics/board.rb +28 -8
  11. data/lib/jirametrics/board_feature.rb +14 -0
  12. data/lib/jirametrics/board_movement_calculator.rb +2 -2
  13. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  14. data/lib/jirametrics/change_item.rb +4 -3
  15. data/lib/jirametrics/chart_base.rb +107 -3
  16. data/lib/jirametrics/css_variable.rb +1 -1
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  18. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +1 -2
  19. data/lib/jirametrics/cycletime_histogram.rb +15 -103
  20. data/lib/jirametrics/cycletime_scatterplot.rb +13 -98
  21. data/lib/jirametrics/daily_view.rb +38 -13
  22. data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
  23. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +1 -1
  24. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  25. data/lib/jirametrics/daily_wip_chart.rb +29 -7
  26. data/lib/jirametrics/data_quality_report.rb +38 -12
  27. data/lib/jirametrics/dependency_chart.rb +2 -2
  28. data/lib/jirametrics/download_config.rb +15 -0
  29. data/lib/jirametrics/downloader.rb +87 -5
  30. data/lib/jirametrics/downloader_for_cloud.rb +107 -22
  31. data/lib/jirametrics/downloader_for_data_center.rb +3 -2
  32. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  33. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  34. data/lib/jirametrics/examples/standard_project.rb +32 -19
  35. data/lib/jirametrics/expedited_chart.rb +3 -1
  36. data/lib/jirametrics/exporter.rb +15 -2
  37. data/lib/jirametrics/file_config.rb +9 -11
  38. data/lib/jirametrics/file_system.rb +35 -2
  39. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  40. data/lib/jirametrics/github_gateway.rb +115 -0
  41. data/lib/jirametrics/groupable_issue_chart.rb +4 -0
  42. data/lib/jirametrics/grouping_rules.rb +26 -4
  43. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
  44. data/lib/jirametrics/html/aging_work_table.erb +3 -0
  45. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  46. data/lib/jirametrics/html/daily_wip_chart.erb +38 -5
  47. data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
  48. data/lib/jirametrics/html/expedited_chart.erb +3 -13
  49. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
  50. data/lib/jirametrics/html/index.css +228 -60
  51. data/lib/jirametrics/html/index.erb +6 -0
  52. data/lib/jirametrics/html/index.js +53 -3
  53. data/lib/jirametrics/html/legacy_colors.css +174 -0
  54. data/lib/jirametrics/html/sprint_burndown.erb +7 -13
  55. data/lib/jirametrics/html/throughput_chart.erb +40 -9
  56. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +59 -59
  57. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +11 -7
  58. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  59. data/lib/jirametrics/html_generator.rb +2 -1
  60. data/lib/jirametrics/html_report_config.rb +45 -33
  61. data/lib/jirametrics/issue.rb +197 -99
  62. data/lib/jirametrics/issue_printer.rb +97 -0
  63. data/lib/jirametrics/jira_gateway.rb +32 -10
  64. data/lib/jirametrics/mcp_server.rb +531 -0
  65. data/lib/jirametrics/project_config.rb +87 -8
  66. data/lib/jirametrics/pull_request.rb +30 -0
  67. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  68. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  69. data/lib/jirametrics/pull_request_review.rb +13 -0
  70. data/lib/jirametrics/raw_javascript.rb +4 -0
  71. data/lib/jirametrics/settings.json +3 -1
  72. data/lib/jirametrics/sprint_burndown.rb +4 -2
  73. data/lib/jirametrics/status.rb +1 -1
  74. data/lib/jirametrics/stitcher.rb +7 -1
  75. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  76. data/lib/jirametrics/throughput_chart.rb +73 -23
  77. data/lib/jirametrics/time_based_histogram.rb +139 -0
  78. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  79. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  80. data/lib/jirametrics.rb +58 -0
  81. metadata +52 -5
@@ -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
@@ -91,6 +98,12 @@ class ProjectConfig
91
98
  !!@aggregate_config
92
99
  end
93
100
 
101
+ def aggregate_project_names
102
+ return [] unless aggregated_project?
103
+
104
+ @aggregate_config.included_projects.filter_map(&:name)
105
+ end
106
+
94
107
  def download &block
95
108
  raise 'Not allowed to have multiple download blocks in one project' if @download_config
96
109
  raise 'Not allowed to have both an aggregate and a download section. Pick only one.' if @aggregate_config
@@ -143,6 +156,17 @@ class ProjectConfig
143
156
  @file_prefix
144
157
  end
145
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
+
146
170
  def raise_if_prefix_already_used prefix
147
171
  @exporter.project_configs.each do |project|
148
172
  next unless project.get_file_prefix(raise_if_not_set: false) == prefix && project.target_path == target_path
@@ -267,9 +291,16 @@ class ProjectConfig
267
291
  end
268
292
 
269
293
  def load_board board_id:, filename:
270
- board = Board.new(
271
- raw: file_system.load_json(filename), possible_statuses: @possible_statuses
272
- )
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)
273
304
  board.project_config = self
274
305
  @all_boards[board_id] = board
275
306
  end
@@ -326,6 +357,13 @@ class ProjectConfig
326
357
  end
327
358
  end
328
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
+
329
367
  def load_project_metadata
330
368
  filename = File.join @target_path, "#{get_file_prefix}_meta.json"
331
369
  json = file_system.load_json(filename)
@@ -362,6 +400,19 @@ class ProjectConfig
362
400
  json.each { |user_data| @users << User.new(raw: user_data) }
363
401
  end
364
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
+
365
416
  def atlassian_document_format
366
417
  @atlassian_document_format ||= AtlassianDocumentFormat.new(
367
418
  users: @users, timezone_offset: exporter.timezone_offset
@@ -402,7 +453,7 @@ class ProjectConfig
402
453
  # To be used by the aggregate_config only. Not intended to be part of the public API
403
454
  def add_issues issues_list
404
455
  @issues = IssueCollection.new if @issues.nil?
405
- @all_boards = {}
456
+ @all_boards ||= {}
406
457
 
407
458
  issues_list.each do |issue|
408
459
  @issues << issue
@@ -444,6 +495,7 @@ class ProjectConfig
444
495
  # attached them in the appropriate places, remove any that aren't part of that initial set.
445
496
  issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
446
497
  @issues = issues
498
+ attach_github_prs
447
499
  end
448
500
 
449
501
  @issues
@@ -563,6 +615,8 @@ class ProjectConfig
563
615
  if status_becomes
564
616
  status_becomes = [status_becomes] unless status_becomes.is_a? Array
565
617
 
618
+ status_becomes.each { |status_name| validate_discard_status status_name }
619
+
566
620
  block = lambda do |issue|
567
621
  trigger_statuses = status_becomes.collect do |status_name|
568
622
  if status_name == :backlog
@@ -588,7 +642,7 @@ class ProjectConfig
588
642
  cutoff_time = block.call(issue)
589
643
  next if cutoff_time.nil?
590
644
 
591
- original_start_time = issue.board.cycletime.started_stopped_times(issue).first
645
+ original_start_time = issue.started_stopped_times.first
592
646
  next if original_start_time.nil?
593
647
 
594
648
  issue.discard_changes_before cutoff_time
@@ -606,4 +660,29 @@ class ProjectConfig
606
660
 
607
661
  cycletimes_touched.each { |c| c.flush_cache }
608
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
687
+ end
609
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
@@ -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
@@ -64,7 +66,7 @@ class SprintBurndown < ChartBase
64
66
  result = +''
65
67
  result << render_top_text(binding)
66
68
 
67
- 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}"] }
68
70
  charts_to_generate = []
69
71
  charts_to_generate << [:data_set_by_story_points, 'Story Points'] if @use_story_points
70
72
  charts_to_generate << [:data_set_by_story_counts, 'Story Count'] if @use_story_counts
@@ -132,7 +134,7 @@ class SprintBurndown < ChartBase
132
134
 
133
135
  estimate_display_name = current_board.estimation_configuration.display_name
134
136
 
135
- issue_completed_time = issue.board.cycletime.started_stopped_times(issue).last
137
+ issue_completed_time = issue.started_stopped_times.last
136
138
  completed_has_been_tracked = false
137
139
 
138
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?
@@ -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
@@ -59,6 +60,11 @@ class Stitcher < HtmlGenerator
59
60
  if matches[:seam] == 'start'
60
61
  content = +''
61
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
62
68
  @all_stitches << Stitcher::StitchContent.new(
63
69
  file: filename, title: matches[:title], type: matches[:type], content: content
64
70
  )
@@ -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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'cgi'
4
+
3
5
  class ThroughputChart < ChartBase
4
6
  include GroupableIssueChart
5
7
 
@@ -10,42 +12,54 @@ class ThroughputChart < ChartBase
10
12
 
11
13
  header_text 'Throughput Chart'
12
14
  description_text <<-TEXT
13
- <div class="p">
14
- This chart shows how many items we completed per week
15
+ <div>Throughput data is very useful for#{' '}
16
+ <a href="https://blog.mikebowler.ca/2024/06/02/probabilistic-forecasting/">probabilistic forecasting</a>,
17
+ to determine when we'll be done. Try it now with the
18
+ <a href="<%= throughput_forecaster_url %>" target="_blank" rel="noopener noreferrer">
19
+ Focused Objective throughput forecaster,</a> to see how long it would take to complete all of the
20
+ <%= @not_started_count %> items you currently have in your backlog.
15
21
  </div>
16
22
  #{describe_non_working_days}
17
23
  TEXT
24
+ @x_axis_title = nil
25
+ @y_axis_title = 'Count of items'
18
26
 
19
27
  init_configuration_block(block) do
20
- grouping_rules do |issue, rule|
21
- rule.label = issue.type
22
- rule.color = color_for type: issue.type
23
- end
28
+ grouping_rules { |issue, rule| default_grouping_rules(issue, rule) }
24
29
  end
25
30
  end
26
31
 
27
32
  def run
33
+ # This is saved as an instance variable so that it's accessible later when rendering the description text
34
+ @not_started_count = issues.count { |issue| issue.started_stopped_times.first.nil? }
35
+
28
36
  completed_issues = completed_issues_in_range include_unstarted: true
29
37
  rules_to_issues = group_issues completed_issues
30
38
  data_sets = []
31
- if rules_to_issues.size > 1
32
- data_sets << weekly_throughput_dataset(
33
- completed_issues: completed_issues,
34
- label: 'Totals',
35
- color: CssVariable['--throughput_chart_total_line_color'],
36
- dashed: true
37
- )
38
- end
39
+ total_data_set = weekly_throughput_dataset(
40
+ completed_issues: completed_issues,
41
+ label: 'Totals',
42
+ color: CssVariable['--throughput_chart_total_line_color'],
43
+ dashed: true
44
+ )
45
+ @throughput_samples = total_data_set[:data].collect { |d| d[:y] }
46
+ data_sets << total_data_set if rules_to_issues.size > 1
39
47
 
40
48
  rules_to_issues.each_key do |rules|
41
49
  data_sets << weekly_throughput_dataset(
42
- completed_issues: rules_to_issues[rules], label: rules.label, color: rules.color
50
+ completed_issues: rules_to_issues[rules], label: rules.label, color: rules.color,
51
+ label_hint: rules.label_hint
43
52
  )
44
53
  end
45
54
 
46
55
  wrap_and_render(binding, __FILE__)
47
56
  end
48
57
 
58
+ def default_grouping_rules issue, rule
59
+ rule.label = issue.type
60
+ rule.color = color_for type: issue.type
61
+ end
62
+
49
63
  def calculate_time_periods
50
64
  first_day = @date_range.begin
51
65
  first_day = case first_day.wday
@@ -65,10 +79,22 @@ class ThroughputChart < ChartBase
65
79
  end
66
80
  end
67
81
 
68
- def weekly_throughput_dataset completed_issues:, label:, color:, dashed: false
82
+ def calculate_custom_periods
83
+ last_days = @issue_periods.values.compact.uniq.sort
84
+ last_days.each_with_index.map do |last_day, i|
85
+ first_day = i.zero? ? @date_range.begin : last_days[i - 1] + 1
86
+ first_day..last_day
87
+ end
88
+ end
89
+
90
+ def weekly_throughput_dataset completed_issues:, label:, color:, dashed: false, label_hint: nil
91
+ periods = @issue_periods&.values&.any? ? calculate_custom_periods : calculate_time_periods
69
92
  result = {
70
93
  label: label,
71
- data: throughput_dataset(periods: calculate_time_periods, completed_issues: completed_issues),
94
+ label_hint: label_hint,
95
+ data: throughput_dataset(
96
+ periods: periods, completed_issues: completed_issues, label_hint: label_hint
97
+ ),
72
98
  fill: false,
73
99
  showLine: true,
74
100
  borderColor: color,
@@ -79,20 +105,44 @@ class ThroughputChart < ChartBase
79
105
  result
80
106
  end
81
107
 
82
- def throughput_dataset periods:, completed_issues:
108
+ def throughput_forecaster_url
109
+ params = {
110
+ throughputMode: 'data',
111
+ samplesText: @throughput_samples.join(','),
112
+ storyLow: @not_started_count,
113
+ storyHigh: @not_started_count
114
+ }
115
+
116
+ query = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join('&')
117
+ "https://focusedobjective.com/throughput?#{query}"
118
+ end
119
+
120
+ def throughput_dataset periods:, completed_issues:, label_hint: nil
121
+ custom_mode = @issue_periods&.values&.any?
83
122
  periods.collect do |period|
84
123
  closed_issues = completed_issues.filter_map do |issue|
85
- stop_date = issue.board.cycletime.started_stopped_dates(issue).last
86
- [stop_date, issue] if stop_date && period.include?(stop_date)
124
+ stop_date = issue.started_stopped_dates.last
125
+ next unless stop_date
126
+
127
+ if custom_mode
128
+ [stop_date, issue] if @issue_periods[issue] == period.end
129
+ elsif period.include?(stop_date)
130
+ [stop_date, issue]
131
+ end
87
132
  end
88
133
 
89
134
  date_label = "on #{period.end}"
90
135
  date_label = "between #{period.begin} and #{period.end}" unless period.begin == period.end
91
136
 
92
- { y: closed_issues.size,
137
+ with_label_hint = label_hint ? " with #{label_hint}" : ''
138
+ {
139
+ y: closed_issues.size,
93
140
  x: "#{period.end}T23:59:59",
94
- title: ["#{closed_issues.size} items completed #{date_label}"] +
95
- closed_issues.collect { |_stop_date, issue| "#{issue.key} : #{issue.summary}" }
141
+ title: ["#{closed_issues.size} items closed#{with_label_hint} #{date_label}"] +
142
+ closed_issues.collect do |_stop_date, issue|
143
+ hint = @issue_hints&.fetch(issue, nil)
144
+ "#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
145
+ end
96
146
  }
97
147
  end
98
148
  end