jirametrics 2.4 → 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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +16 -3
  4. data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
  6. data/lib/jirametrics/aging_work_table.rb +63 -19
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +160 -0
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +6 -4
  11. data/lib/jirametrics/board.rb +74 -22
  12. data/lib/jirametrics/board_config.rb +11 -3
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +54 -18
  17. data/lib/jirametrics/chart_base.rb +203 -30
  18. data/lib/jirametrics/css_variable.rb +2 -2
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/cycle_time_config.rb +137 -0
  21. data/lib/jirametrics/cycletime_histogram.rb +17 -38
  22. data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
  23. data/lib/jirametrics/daily_view.rb +306 -0
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
  27. data/lib/jirametrics/daily_wip_chart.rb +36 -16
  28. data/lib/jirametrics/data_quality_report.rb +251 -42
  29. data/lib/jirametrics/dependency_chart.rb +42 -12
  30. data/lib/jirametrics/download_config.rb +27 -0
  31. data/lib/jirametrics/downloader.rb +185 -110
  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 +75 -14
  35. data/lib/jirametrics/estimation_configuration.rb +25 -0
  36. data/lib/jirametrics/examples/aggregated_project.rb +9 -23
  37. data/lib/jirametrics/examples/standard_project.rb +57 -58
  38. data/lib/jirametrics/expedited_chart.rb +11 -10
  39. data/lib/jirametrics/exporter.rb +51 -14
  40. data/lib/jirametrics/file_config.rb +21 -6
  41. data/lib/jirametrics/file_system.rb +96 -4
  42. data/lib/jirametrics/fix_version.rb +13 -0
  43. data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
  44. data/lib/jirametrics/github_gateway.rb +115 -0
  45. data/lib/jirametrics/groupable_issue_chart.rb +12 -4
  46. data/lib/jirametrics/grouping_rules.rb +26 -4
  47. data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
  48. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
  49. data/lib/jirametrics/html/aging_work_table.erb +13 -4
  50. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  51. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  52. data/lib/jirametrics/html/daily_wip_chart.erb +41 -15
  53. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  54. data/lib/jirametrics/html/expedited_chart.erb +7 -24
  55. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
  56. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  57. data/lib/jirametrics/html/index.css +336 -62
  58. data/lib/jirametrics/html/index.erb +16 -21
  59. data/lib/jirametrics/html/index.js +164 -0
  60. data/lib/jirametrics/html/legacy_colors.css +174 -0
  61. data/lib/jirametrics/html/sprint_burndown.erb +18 -25
  62. data/lib/jirametrics/html/throughput_chart.erb +43 -21
  63. data/lib/jirametrics/html/time_based_histogram.erb +123 -0
  64. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
  65. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  66. data/lib/jirametrics/html_generator.rb +32 -0
  67. data/lib/jirametrics/html_report_config.rb +83 -76
  68. data/lib/jirametrics/issue.rb +499 -91
  69. data/lib/jirametrics/issue_collection.rb +33 -0
  70. data/lib/jirametrics/issue_printer.rb +97 -0
  71. data/lib/jirametrics/jira_gateway.rb +96 -16
  72. data/lib/jirametrics/mcp_server.rb +531 -0
  73. data/lib/jirametrics/project_config.rb +374 -130
  74. data/lib/jirametrics/pull_request.rb +30 -0
  75. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  76. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  77. data/lib/jirametrics/pull_request_review.rb +13 -0
  78. data/lib/jirametrics/raw_javascript.rb +17 -0
  79. data/lib/jirametrics/rules.rb +2 -2
  80. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  81. data/lib/jirametrics/settings.json +10 -2
  82. data/lib/jirametrics/sprint.rb +13 -0
  83. data/lib/jirametrics/sprint_burndown.rb +47 -39
  84. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  85. data/lib/jirametrics/status.rb +84 -19
  86. data/lib/jirametrics/status_collection.rb +83 -38
  87. data/lib/jirametrics/stitcher.rb +81 -0
  88. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  89. data/lib/jirametrics/throughput_chart.rb +73 -23
  90. data/lib/jirametrics/time_based_histogram.rb +139 -0
  91. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  92. data/lib/jirametrics/user.rb +12 -0
  93. data/lib/jirametrics/value_equality.rb +2 -2
  94. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  95. data/lib/jirametrics.rb +101 -66
  96. metadata +72 -16
  97. data/lib/jirametrics/cycletime_config.rb +0 -69
  98. data/lib/jirametrics/discard_changes_before.rb +0 -37
  99. data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
  100. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AtlassianDocumentFormat
4
+ attr_reader :users
5
+
6
+ def initialize users:, timezone_offset:
7
+ @users = users
8
+ @timezone_offset = timezone_offset
9
+ end
10
+
11
+ def to_html input
12
+ if input.is_a? String
13
+ input
14
+ .gsub(/{color:(#\w{6})}([^{]+){color}/, '<span style="color: \1">\2</span>') # Colours
15
+ .gsub(/\[~accountid:([^\]]+)\]/) { expand_account_id $1 } # Tagged people
16
+ .gsub(/\[([^|]+)\|(https?[^\]]+)\]/, '<a href="\2">\1</a>') # URLs
17
+ .gsub("\n", '<br />')
18
+ elsif input&.[]('content')
19
+ input['content'].collect { |element| adf_node_to_html element }.join("\n")
20
+ else
21
+ # We have an actual ADF document with no content.
22
+ ''
23
+ end
24
+ end
25
+
26
+ def to_text input
27
+ if input.is_a? String
28
+ input
29
+ elsif input&.[]('content')
30
+ input['content'].collect { |element| adf_node_to_text element }.join
31
+ else
32
+ ''
33
+ end
34
+ end
35
+
36
+ # ADF is Atlassian Document Format
37
+ # https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
38
+ def adf_node_to_html node # rubocop:disable Metrics/CyclomaticComplexity
39
+ adf_node_render(node) do |n|
40
+ node_attrs = n['attrs']
41
+ case n['type']
42
+ when 'blockquote' then ['<blockquote>', '</blockquote>']
43
+ when 'bulletList' then ['<ul>', '</ul>']
44
+ when 'codeBlock' then ['<code>', '</code>']
45
+ when 'date'
46
+ [Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
47
+ when 'decisionItem', 'listItem' then ['<li>', '</li>']
48
+ when 'decisionList' then ['<div>Decisions<ul>', '</ul></div>']
49
+ when 'emoji', 'status' then [node_attrs['text'], nil]
50
+ when 'expand' then ["<div>#{node_attrs['title']}</div>", nil]
51
+ when 'hardBreak' then ['<br />', nil]
52
+ when 'heading'
53
+ level = node_attrs['level']
54
+ ["<h#{level}>", "</h#{level}>"]
55
+ when 'inlineCard'
56
+ url = node_attrs['url']
57
+ ["[Inline card]: <a href='#{url}'>#{url}</a>", nil]
58
+ when 'media'
59
+ text = node_attrs['alt'] || node_attrs['id']
60
+ ["Media: #{text}", nil]
61
+ when 'mediaSingle', 'mediaGroup' then ['<div>', '</div>']
62
+ when 'mention' then ["<b>#{node_attrs['text']}</b>", nil]
63
+ when 'orderedList' then ['<ol>', '</ol>']
64
+ when 'panel' then ["<div>#{node_attrs['panelType'].upcase}</div>", nil]
65
+ when 'paragraph' then ['<p>', '</p>']
66
+ when 'rule' then ['<hr />', nil]
67
+ when 'table' then ['<table>', '</table>']
68
+ when 'tableCell' then ['<td>', '</td>']
69
+ when 'tableHeader' then ['<th>', '</th>']
70
+ when 'tableRow' then ['<tr>', '</tr>']
71
+ when 'text'
72
+ marks = adf_marks_to_html(n['marks'])
73
+ [marks.collect(&:first).join + n['text'], marks.collect(&:last).join]
74
+ when 'taskItem'
75
+ state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
76
+ ["<li>#{state} ", '</li>']
77
+ when 'taskList' then ["<ul class='taskList'>", '</ul>']
78
+ else
79
+ ["<p>Unparseable section: #{n['type']}</p>", nil]
80
+ end
81
+ end
82
+ end
83
+
84
+ def adf_node_to_text node # rubocop:disable Metrics/CyclomaticComplexity
85
+ adf_node_render(node) do |n|
86
+ node_attrs = n['attrs']
87
+ case n['type']
88
+ when 'blockquote', 'bulletList', 'codeBlock',
89
+ 'mediaSingle', 'mediaGroup',
90
+ 'orderedList', 'table', 'taskList' then ['', nil]
91
+ when 'date'
92
+ [Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
93
+ when 'decisionItem' then ['- ', "\n"]
94
+ when 'decisionList' then ["Decisions:\n", nil]
95
+ when 'emoji', 'mention', 'status' then [node_attrs['text'], nil]
96
+ when 'expand' then ["#{node_attrs['title']}\n", nil]
97
+ when 'hardBreak' then ["\n", nil]
98
+ when 'heading', 'paragraph', 'tableRow' then ['', "\n"]
99
+ when 'inlineCard' then [node_attrs['url'], nil]
100
+ when 'listItem' then ['- ', nil]
101
+ when 'media'
102
+ text = node_attrs['alt'] || node_attrs['id']
103
+ ["Media: #{text}", nil]
104
+ when 'panel' then ["#{node_attrs['panelType'].upcase}\n", nil]
105
+ when 'rule' then ["---\n", nil]
106
+ when 'tableCell', 'tableHeader' then ['', "\t"]
107
+ when 'text' then [n['text'], nil]
108
+ when 'taskItem'
109
+ state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
110
+ ["#{state} ", "\n"]
111
+ else
112
+ ["[Unparseable: #{n['type']}]\n", nil]
113
+ end
114
+ end
115
+ end
116
+
117
+ def adf_marks_to_html list
118
+ return [] if list.nil?
119
+
120
+ mappings = [
121
+ ['strong', '<b>', '</b>'],
122
+ ['code', '<code>', '</code>'],
123
+ ['em', '<em>', '</em>'],
124
+ ['strike', '<s>', '</s>'],
125
+ ['underline', '<u>', '</u>']
126
+ ]
127
+
128
+ list.filter_map do |mark|
129
+ type = mark['type']
130
+ if type == 'textColor'
131
+ color = mark['attrs']['color']
132
+ ["<span style='color: #{color}'>", '</span>']
133
+ elsif type == 'link'
134
+ href = mark['attrs']['href']
135
+ title = mark['attrs']['title']
136
+ ["<a href='#{href}' title='#{title}'>", '</a>']
137
+ else
138
+ line = mappings.find { |key, _open, _close| key == type }
139
+ [line[1], line[2]] if line
140
+ end
141
+ end
142
+ end
143
+
144
+ def expand_account_id account_id
145
+ user = @users.find { |u| u.account_id == account_id }
146
+ text = account_id
147
+ text = "@#{user.display_name}" if user
148
+ "<span class='account_id'>#{text}</span>"
149
+ end
150
+
151
+ private
152
+
153
+ def adf_node_render node, &render_node
154
+ prefix, suffix = render_node.call(node)
155
+ result = +(prefix || '')
156
+ node['content']&.each { |child| result << adf_node_render(child, &render_node) }
157
+ result << suffix if suffix
158
+ result
159
+ end
160
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/value_equality'
4
+
5
+ class BarChartRange
6
+ include ValueEquality
7
+
8
+ attr_accessor :start, :stop, :color, :title, :highlight
9
+
10
+ def initialize start:, stop:, color:, title:, highlight: false
11
+ @start = start
12
+ @stop = stop
13
+ @color = color
14
+ @title = title
15
+ @highlight = highlight
16
+ end
17
+ end
@@ -4,10 +4,12 @@ require 'jirametrics/value_equality'
4
4
 
5
5
  class BlockedStalledChange
6
6
  include ValueEquality
7
- attr_reader :time, :blocking_issue_keys, :flag, :status, :stalled_days, :status_is_blocking
7
+ attr_reader :time, :blocking_issue_keys, :flag, :flag_reason, :status, :stalled_days, :status_is_blocking
8
8
 
9
- def initialize time:, flagged: nil, status: nil, status_is_blocking: true, blocking_issue_keys: nil, stalled_days: nil
9
+ def initialize time:, flagged: nil, flag_reason: nil, status: nil, status_is_blocking: true,
10
+ blocking_issue_keys: nil, stalled_days: nil
10
11
  @flag = flagged
12
+ @flag_reason = flag_reason
11
13
  @status = status
12
14
  @status_is_blocking = status_is_blocking
13
15
  @blocking_issue_keys = blocking_issue_keys
@@ -25,7 +27,7 @@ class BlockedStalledChange
25
27
  def reasons
26
28
  result = []
27
29
  if blocked?
28
- result << 'Blocked by flag' if @flag
30
+ result << (@flag_reason ? "Blocked by flag: #{@flag_reason}" : 'Blocked by flag') if @flag
29
31
  result << "Blocked by status: #{@status}" if blocked_by_status?
30
32
  result << "Blocked by issues: #{@blocking_issue_keys.join(', ')}" if @blocking_issue_keys
31
33
  elsif stalled_by_status?
@@ -47,7 +49,7 @@ class BlockedStalledChange
47
49
  end
48
50
 
49
51
  def inspect
50
- text = +"BlockedStalledChange(time: '#{@time}', "
52
+ text = "BlockedStalledChange(time: '#{@time}', "
51
53
  if active?
52
54
  text << 'Active'
53
55
  else
@@ -1,39 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Board
4
- attr_reader :visible_columns, :raw, :possible_statuses, :sprints, :backlog_statuses
5
- attr_accessor :cycletime, :project_config, :expedited_priority_names
4
+ attr_reader :visible_columns, :raw, :possible_statuses, :sprints
5
+ attr_accessor :cycletime, :project_config
6
6
 
7
- def initialize raw:, possible_statuses: StatusCollection.new
7
+ def initialize raw:, possible_statuses:, features: []
8
8
  @raw = raw
9
- @board_type = raw['type']
10
9
  @possible_statuses = possible_statuses
11
10
  @sprints = []
12
- @expedited_priority_names = []
11
+ @features = features
13
12
 
14
13
  columns = raw['columnConfig']['columns']
14
+ ensure_uniqueness_of_column_names! columns
15
15
 
16
- # For a Kanban board, the first column here will always be called 'Backlog' and will NOT be
17
- # visible on the board. If the board is configured to have a kanban backlog then it will have
18
- # statuses matched to it and otherwise, there will be no statuses.
19
- if kanban?
20
- @backlog_statuses = @possible_statuses.expand_statuses(status_ids_from_column columns[0]) do |unknown_status|
21
- # There is a status defined as being 'backlog' that is no longer being returned in statuses.
22
- # We used to display a warning for this but honestly, there is nothing that anyone can do about it
23
- # so now we just quietly ignore it.
24
- end
25
- columns = columns[1..]
26
- else
27
- # We currently don't know how to get the backlog status for a Scrum board
28
- @backlog_statuses = []
29
- end
16
+ # For a classic Kanban board (type 'kanban'), the first column will always be called 'Backlog'
17
+ # and will NOT be visible on the board. This does not apply to team-managed boards (type 'simple').
18
+ columns = columns.drop(1) if board_type == 'kanban'
30
19
 
20
+ @backlog_statuses = []
31
21
  @visible_columns = columns.filter_map do |column|
32
22
  # It's possible for a column to be defined without any statuses and in this case, it won't be visible.
33
23
  BoardColumn.new column unless status_ids_from_column(column).empty?
34
24
  end
35
25
  end
36
26
 
27
+ def backlog_statuses
28
+ if @backlog_statuses.empty? && board_type == 'kanban'
29
+ status_ids = status_ids_from_column raw['columnConfig']['columns'].first
30
+ @backlog_statuses = status_ids.filter_map do |id|
31
+ @possible_statuses.find_by_id id
32
+ end
33
+ end
34
+ @backlog_statuses
35
+ end
36
+
37
37
  def server_url_prefix
38
38
  raise "Cannot parse self: #{@raw['self'].inspect}" unless @raw['self'] =~ /^(https?:\/\/.+)\/rest\//
39
39
 
@@ -66,12 +66,28 @@ class Board
66
66
  status_ids
67
67
  end
68
68
 
69
+ def board_type = raw['type']
70
+
71
+ def scrum?
72
+ return true if board_type == 'scrum'
73
+ return false unless board_type == 'simple'
74
+
75
+ has_sprints_feature?
76
+ end
77
+
69
78
  def kanban?
70
- @board_type == 'kanban'
79
+ return true if board_type == 'kanban'
80
+ return false unless board_type == 'simple'
81
+
82
+ !scrum?
71
83
  end
72
84
 
73
- def scrum?
74
- @board_type == 'scrum'
85
+ def team_managed_kanban?
86
+ board_type == 'simple' && !has_sprints_feature?
87
+ end
88
+
89
+ def has_sprints_feature?
90
+ @features.any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
75
91
  end
76
92
 
77
93
  def id
@@ -88,4 +104,40 @@ class Board
88
104
  def name
89
105
  @raw['name']
90
106
  end
107
+
108
+ def accumulated_status_ids_per_column
109
+ accumulated_status_ids = []
110
+ visible_columns.reverse.filter_map do |column|
111
+ next if column == @fake_column
112
+
113
+ accumulated_status_ids += column.status_ids
114
+ [column.name, accumulated_status_ids.dup]
115
+ end.reverse
116
+ end
117
+
118
+ def ensure_uniqueness_of_column_names! json
119
+ all_names = []
120
+ json.each do |column_json|
121
+ name = column_json['name']
122
+ if all_names.include? name
123
+ (2..).each do |i|
124
+ new_name = "#{name}-#{i}"
125
+ next if all_names.include?(new_name)
126
+
127
+ name = new_name
128
+ column_json['name'] = new_name
129
+ break
130
+ end
131
+ end
132
+ all_names << name
133
+ end
134
+ end
135
+
136
+ def estimation_configuration
137
+ EstimationConfiguration.new raw: raw['estimation']
138
+ end
139
+
140
+ def inspect
141
+ "Board(id: #{id}, name: #{name.inspect}, board_type: #{board_type.inspect})"
142
+ end
91
143
  end
@@ -11,9 +11,10 @@ class BoardConfig
11
11
 
12
12
  def run
13
13
  @board = @project_config.all_boards[id]
14
- @board.expedited_priority_names = []
14
+ raise "Can't find board #{id.inspect} in #{@project_config.all_boards.keys.inspect}" unless @board
15
15
 
16
16
  instance_eval(&@block)
17
+ raise "Must specify a cycletime for board #{@id}" if @board.cycletime.nil?
17
18
  end
18
19
 
19
20
  def cycletime label = nil, &block
@@ -22,10 +23,17 @@ class BoardConfig
22
23
  'If so, remove it from there.'
23
24
  end
24
25
 
25
- @board.cycletime = CycleTimeConfig.new(parent_config: self, label: label, block: block)
26
+ @board.cycletime = CycleTimeConfig.new(
27
+ possible_statuses: project_config.possible_statuses,
28
+ label: label, block: block, file_system: project_config.file_system,
29
+ settings: project_config.settings
30
+ )
26
31
  end
27
32
 
28
33
  def expedited_priority_names *priority_names
29
- @board.expedited_priority_names = priority_names
34
+ project_config.exporter.file_system.deprecated(
35
+ date: '2024-09-15', message: 'Expedited priority names are now specified in settings'
36
+ )
37
+ @project_config.settings['expedited_priority_names'] = priority_names
30
38
  end
31
39
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BoardFeature
4
+ def initialize raw:
5
+ @raw = raw
6
+ end
7
+
8
+ def name = @raw['feature']
9
+ def enabled? = (@raw['state'] == 'ENABLED')
10
+
11
+ def self.from_raw features_json
12
+ features_json['features']&.map { |f| new(raw: f) } || []
13
+ end
14
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BoardMovementCalculator
4
+ attr_reader :board, :issues, :today
5
+
6
+ def initialize board:, issues:, today:
7
+ @board = board
8
+ @issues = issues.select { |issue| issue.board == board && issue.done? && !moves_backwards?(issue) }
9
+ @today = today
10
+ end
11
+
12
+ def moves_backwards? issue
13
+ started, stopped = issue.started_stopped_times
14
+ return false unless started
15
+
16
+ previous_column = nil
17
+ issue.status_changes.each do |change|
18
+ column = board.visible_columns.index { |c| c.status_ids.include?(change.value_id) }
19
+ next if change.time < started
20
+ next if column.nil? # It disappeared from the board for a bit
21
+ return true if previous_column && column && column < previous_column
22
+
23
+ previous_column = column
24
+ end
25
+ false
26
+ end
27
+
28
+ def stacked_age_data_for percentages:
29
+ data_list = percentages.sort.collect do |percentage|
30
+ [percentage, age_data_for(percentage: percentage)]
31
+ end
32
+
33
+ stack_data data_list
34
+ end
35
+
36
+ def stack_data data_list
37
+ remainder = nil
38
+ data_list.collect do |percentage, data|
39
+ unless remainder.nil?
40
+ data = (0...data.length).collect do |i|
41
+ data[i] - remainder[i]
42
+ end
43
+
44
+ end
45
+ remainder = data
46
+
47
+ [percentage, data]
48
+ end
49
+ end
50
+
51
+ def age_data_for percentage:
52
+ data = []
53
+ board.visible_columns.each_with_index do |_column, column_index|
54
+ ages = ages_of_issues_when_leaving_column column_index: column_index, today: today
55
+
56
+ if ages.empty?
57
+ data << 0
58
+ else
59
+ index = ((ages.size - 1) * percentage / 100).to_i
60
+ data << ages[index]
61
+ end
62
+ end
63
+ data
64
+ end
65
+
66
+ def ages_of_issues_when_leaving_column column_index:, today:
67
+ this_column = board.visible_columns[column_index]
68
+ next_column = board.visible_columns[column_index + 1]
69
+
70
+ @issues.filter_map do |issue|
71
+ this_column_start = issue.first_time_in_or_right_of_column(this_column.name)&.time
72
+ next_column_start = next_column.nil? ? nil : issue.first_time_in_or_right_of_column(next_column.name)&.time
73
+ issue_start, issue_done = issue.started_stopped_times
74
+
75
+ # Skip if we can't tell when it started.
76
+ next if issue_start.nil?
77
+
78
+ # Skip if it never entered this column
79
+ next if this_column_start.nil?
80
+
81
+ # Skip if it left this column before the item is considered started.
82
+ next 0 if next_column_start && next_column_start <= issue_start
83
+
84
+ # Skip if it was already done by the time it got to this column or it became done when it got to this column
85
+ next if issue_done && issue_done <= this_column_start
86
+
87
+ end_date = case # rubocop:disable Style/EmptyCaseCondition
88
+ when next_column_start.nil?
89
+ # If this is the last column then base age against today
90
+ today
91
+ when issue_done && issue_done < next_column_start
92
+ # it completed while in this column
93
+ issue_done.to_date
94
+ else
95
+ # It passed through this whole column
96
+ next_column_start.to_date
97
+ end
98
+ (end_date - issue_start.to_date).to_i + 1
99
+ end.sort
100
+ end
101
+
102
+ # Figure out what column this is issue is currently in and what time it entered that column. We need this for
103
+ # aging and forecasting purposes
104
+ def find_current_column_and_entry_time_in_column issue
105
+ column = board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
106
+ return [] if column.nil? # This issue isn't visible on the board
107
+
108
+ status_ids = column.status_ids
109
+
110
+ entry_at = issue.changes.reverse.find { |change| change.status? && status_ids.include?(change.value_id) }&.time
111
+
112
+ [column.name, entry_at]
113
+ end
114
+
115
+ def label_days days
116
+ "#{days} day#{'s' unless days == 1}"
117
+ end
118
+
119
+ def forecasted_days_remaining_and_message issue:, today:
120
+ return [nil, 'Already done'] if issue.done?
121
+
122
+ likely_age_data = age_data_for percentage: 85
123
+
124
+ column_name, entry_time = find_current_column_and_entry_time_in_column issue
125
+ return [nil, 'This issue is not visible on the board. No way to predict when it will be done.'] if column_name.nil?
126
+
127
+ # This condition has been reported in production so we have a check for it. Having said that, we have no
128
+ # idea what conditions might make this possible and so there is no test for it.
129
+ if entry_time.nil?
130
+ message = "Unable to determine the time this issue entered column #{column_name.inspect} so no way to " \
131
+ 'predict when it will be done'
132
+ return [nil, message]
133
+ end
134
+
135
+ age_in_column = (today - entry_time.to_date).to_i + 1
136
+
137
+ message = nil
138
+ column_index = board.visible_columns.index { |c| c.name == column_name }
139
+
140
+ last_non_zero_datapoint = likely_age_data.reverse.find { |d| !d.zero? }
141
+ return [nil, 'There is no historical data for this board. No forecast can be made.'] if last_non_zero_datapoint.nil?
142
+
143
+ remaining_in_current_column = likely_age_data[column_index] - age_in_column
144
+ if remaining_in_current_column.negative?
145
+ message = "This item is an outlier at #{label_days issue.board.cycletime.age(issue, today: today)} " \
146
+ "in the #{column_name.inspect} column. Most items on this board have left this column in " \
147
+ "#{label_days likely_age_data[column_index]} or less, so we cannot forecast when it will be done."
148
+ remaining_in_current_column = 0
149
+ return [nil, message]
150
+ end
151
+
152
+ forecasted_days = last_non_zero_datapoint - likely_age_data[column_index] + remaining_in_current_column
153
+ [forecasted_days, message]
154
+ end
155
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CfdDataBuilder
4
+ def initialize board:, issues:, date_range:, columns: nil
5
+ @board = board
6
+ @issues = issues
7
+ @date_range = date_range
8
+ @columns = columns || board.visible_columns
9
+ end
10
+
11
+ def run
12
+ column_map = build_column_map
13
+ issue_states = @issues.map { |issue| process_issue(issue, column_map) }
14
+
15
+ {
16
+ columns: @columns.map(&:name),
17
+ daily_counts: build_daily_counts(issue_states),
18
+ correction_windows: issue_states.flat_map { |s| s[:correction_windows] }
19
+ }
20
+ end
21
+
22
+ private
23
+
24
+ def build_column_map
25
+ map = {}
26
+ @columns.each_with_index do |column, index|
27
+ column.status_ids.each { |id| map[id] = index }
28
+ end
29
+ map
30
+ end
31
+
32
+ # Returns { hwm_timeline: [[date, hwm_value], ...], correction_windows: [...] }
33
+ def process_issue issue, column_map
34
+ start_time = issue.started_stopped_times.first
35
+ return { hwm_timeline: [], correction_windows: [] } if start_time.nil?
36
+
37
+ high_water_mark = nil
38
+ correction_open_since = nil
39
+ correction_windows = []
40
+ hwm_timeline = [] # sorted chronologically by date
41
+
42
+ issue.status_changes.each do |change|
43
+ next if change.time < start_time
44
+
45
+ col_index = column_map[change.value_id]
46
+ next if col_index.nil?
47
+
48
+ if high_water_mark.nil? || col_index > high_water_mark
49
+ # Forward movement: advance hwm, close any open correction window, record timeline entry
50
+ if correction_open_since
51
+ correction_windows << {
52
+ start_date: correction_open_since,
53
+ end_date: change.time.to_date,
54
+ column_index: high_water_mark
55
+ }
56
+ correction_open_since = nil
57
+ end
58
+ high_water_mark = col_index
59
+ hwm_timeline << [change.time.to_date, high_water_mark]
60
+ elsif col_index == high_water_mark && correction_open_since
61
+ # Same-column recovery: close the correction window without changing hwm or adding timeline entry
62
+ correction_windows << {
63
+ start_date: correction_open_since,
64
+ end_date: change.time.to_date,
65
+ column_index: high_water_mark
66
+ }
67
+ correction_open_since = nil
68
+ elsif col_index < high_water_mark
69
+ # Backwards movement: open correction window if not already open
70
+ correction_open_since ||= change.time.to_date
71
+ end
72
+ end
73
+
74
+ if correction_open_since
75
+ correction_windows << {
76
+ start_date: correction_open_since,
77
+ end_date: @date_range.end,
78
+ column_index: high_water_mark
79
+ }
80
+ end
81
+
82
+ { hwm_timeline: hwm_timeline, correction_windows: correction_windows }
83
+ end
84
+
85
+ def hwm_at hwm_timeline, date
86
+ result = nil
87
+ hwm_timeline.each do |timeline_date, hwm|
88
+ break if timeline_date > date
89
+
90
+ result = hwm
91
+ end
92
+ result
93
+ end
94
+
95
+ def build_daily_counts issue_states
96
+ column_count = @columns.size
97
+ @date_range.each_with_object({}) do |date, result|
98
+ counts = Array.new(column_count, 0)
99
+ issue_states.each do |state|
100
+ hwm = hwm_at(state[:hwm_timeline], date)
101
+ next if hwm.nil?
102
+
103
+ (0..hwm).each { |i| counts[i] += 1 }
104
+ end
105
+ result[date] = counts
106
+ end
107
+ end
108
+ end