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
@@ -2,11 +2,12 @@
2
2
 
3
3
  require 'random-word'
4
4
 
5
- class Anonymizer
5
+ class Anonymizer < ChartBase
6
6
  # needed for testing
7
7
  attr_reader :project_config, :issues
8
8
 
9
9
  def initialize project_config:, date_adjustment: -200
10
+ super()
10
11
  @project_config = project_config
11
12
  @issues = @project_config.issues
12
13
  @all_boards = @project_config.all_boards
@@ -20,6 +21,10 @@ class Anonymizer
20
21
  anonymize_column_names
21
22
  # anonymize_issue_statuses
22
23
  anonymize_board_names
24
+ anonymize_labels_and_components
25
+ anonymize_sprints
26
+ anonymize_fix_versions
27
+ anonymize_server_url
23
28
  shift_all_dates unless @date_adjustment.zero?
24
29
  @file_system.log 'Anonymize done'
25
30
  end
@@ -37,13 +42,25 @@ class Anonymizer
37
42
 
38
43
  def anonymize_issue_keys_and_titles issues: @issues
39
44
  counter = 0
45
+ seen_author_raws = {}
40
46
  issues.each do |issue|
41
47
  new_key = "ANON-#{counter += 1}"
42
48
 
43
49
  issue.raw['key'] = new_key
44
50
  issue.raw['fields']['summary'] = random_phrase
51
+ issue.raw['fields']['description'] = nil
45
52
  issue.raw['fields']['assignee']['displayName'] = random_name unless issue.raw['fields']['assignee'].nil?
46
53
 
54
+ anonymize_author_raw(issue.raw['fields']['creator'], seen_author_raws)
55
+
56
+ issue.changes.each do |change|
57
+ anonymize_author_raw(change.author_raw, seen_author_raws)
58
+ if change.comment? || change.description?
59
+ change.value = nil
60
+ change.old_value = nil
61
+ end
62
+ end
63
+
47
64
  issue.issue_links.each do |link|
48
65
  other_issue = link.other_issue
49
66
  next if other_issue.key.match?(/^ANON-\d+$/) # Already anonymized?
@@ -54,6 +71,49 @@ class Anonymizer
54
71
  end
55
72
  end
56
73
 
74
+ def anonymize_labels_and_components
75
+ @issues.each do |issue|
76
+ issue.raw['fields']['labels'] = []
77
+ issue.raw['fields']['components'] = []
78
+ end
79
+ end
80
+
81
+ def anonymize_sprints
82
+ sprint_counter = 0
83
+ sprint_name_map = {}
84
+ @all_boards.each_value do |board|
85
+ board.sprints.each do |sprint|
86
+ name = sprint.raw['name']
87
+ unless sprint_name_map[name]
88
+ sprint_counter += 1
89
+ sprint_name_map[name] = "Sprint-#{sprint_counter}"
90
+ end
91
+ sprint.raw['name'] = sprint_name_map[name]
92
+ end
93
+ end
94
+ end
95
+
96
+ def anonymize_fix_versions
97
+ version_counter = 0
98
+ version_name_map = {}
99
+ @issues.each do |issue|
100
+ issue.raw['fields']['fixVersions']&.each do |fix_version|
101
+ name = fix_version['name']
102
+ unless version_name_map[name]
103
+ version_counter += 1
104
+ version_name_map[name] = "Version-#{version_counter}"
105
+ end
106
+ fix_version['name'] = version_name_map[name]
107
+ end
108
+ end
109
+ end
110
+
111
+ def anonymize_server_url
112
+ @all_boards.each_value do |board|
113
+ board.raw['self'] = board.raw['self']&.sub(/^https?:\/\/[^\/]+/, 'https://anon.example.com')
114
+ end
115
+ end
116
+
57
117
  def anonymize_column_names
58
118
  @all_boards.each_key do |board_id|
59
119
  @file_system.log "Anonymizing column names for board #{board_id}"
@@ -130,18 +190,19 @@ class Anonymizer
130
190
  end
131
191
  end
132
192
 
133
- def shift_all_dates
134
- @file_system.log "Shifting all dates by #{@date_adjustment} days"
193
+ def shift_all_dates date_adjustment: @date_adjustment
194
+ adjustment_in_seconds = 60 * 60 * 24 * date_adjustment
195
+ @file_system.log "Shifting all dates by #{label_days date_adjustment}"
135
196
  @issues.each do |issue|
136
197
  issue.changes.each do |change|
137
- change.time = change.time + @date_adjustment
198
+ change.time = change.time + adjustment_in_seconds
138
199
  end
139
200
 
140
- issue.raw['fields']['updated'] = (issue.updated + @date_adjustment).to_s
201
+ issue.raw['fields']['updated'] = (issue.updated + adjustment_in_seconds).to_s
141
202
  end
142
203
 
143
204
  range = @project_config.time_range
144
- @project_config.time_range = (range.begin + @date_adjustment)..(range.end + @date_adjustment)
205
+ @project_config.time_range = (range.begin + adjustment_in_seconds)..(range.end + adjustment_in_seconds)
145
206
  end
146
207
 
147
208
  def random_name
@@ -184,4 +245,18 @@ class Anonymizer
184
245
  board.raw['name'] = "#{random_phrase} board"
185
246
  end
186
247
  end
248
+
249
+ private
250
+
251
+ def anonymize_author_raw author_raw, seen
252
+ return unless author_raw
253
+ return if seen[author_raw.object_id]
254
+
255
+ seen[author_raw.object_id] = true
256
+ name = random_name
257
+ author_raw['displayName'] = name
258
+ author_raw['name'] = name
259
+ author_raw.delete('emailAddress')
260
+ author_raw.delete('avatarUrls')
261
+ end
187
262
  end
@@ -5,6 +5,7 @@ class AtlassianDocumentFormat
5
5
 
6
6
  def initialize users:, timezone_offset:
7
7
  @users = users
8
+ @timezone_offset = timezone_offset
8
9
  end
9
10
 
10
11
  def to_html input
@@ -12,112 +13,105 @@ class AtlassianDocumentFormat
12
13
  input
13
14
  .gsub(/{color:(#\w{6})}([^{]+){color}/, '<span style="color: \1">\2</span>') # Colours
14
15
  .gsub(/\[~accountid:([^\]]+)\]/) { expand_account_id $1 } # Tagged people
15
- .gsub(/\[([^\|]+)\|(https?[^\]]+)\]/, '<a href="\2">\1</a>') # URLs
16
+ .gsub(/\[([^|]+)\|(https?[^\]]+)\]/, '<a href="\2">\1</a>') # URLs
16
17
  .gsub("\n", '<br />')
17
- else
18
+ elsif input&.[]('content')
18
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
+ ''
19
23
  end
20
24
  end
21
25
 
22
- # ADF is Atlassian Document Format
23
- # https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
24
- def adf_node_to_html node
25
- closing_tag = nil
26
- node_attrs = node['attrs']
27
-
28
- result = +''
29
- case node['type']
30
- when 'blockquote'
31
- result << '<blockquote>'
32
- closing_tag = '</blockquote>'
33
- when 'bulletList'
34
- result << '<ul>'
35
- closing_tag = '</ul>'
36
- when 'codeBlock'
37
- result << '<code>'
38
- closing_tag = '</code>'
39
- when 'date'
40
- result << Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s
41
- when 'decisionItem'
42
- result << '<li>'
43
- closing_tag = '</li>'
44
- when 'decisionList'
45
- result << '<div>Decisions<ul>'
46
- closing_tag = '</ul></div>'
47
- when 'emoji'
48
- result << node_attrs['text']
49
- when 'expand'
50
- # TODO: Maybe, someday, make this actually expandable. For now it's always open
51
- result << "<div>#{node_attrs['title']}</div>"
52
- when 'hardBreak'
53
- result << '<br />'
54
- when 'heading'
55
- level = node_attrs['level']
56
- result << "<h#{level}>"
57
- closing_tag = "</h#{level}>"
58
- when 'inlineCard'
59
- url = node_attrs['url']
60
- result << "[Inline card]: <a href='#{url}'>#{url}</a>"
61
- when 'listItem'
62
- result << '<li>'
63
- closing_tag = '</li>'
64
- when 'media'
65
- text = node_attrs['alt'] || node_attrs['id']
66
- result << "Media: #{text}"
67
- when 'mediaSingle', 'mediaGroup'
68
- result << '<div>'
69
- closing_tag = '</div>'
70
- when 'mention'
71
- user = node_attrs['text']
72
- result << "<b>#{user}</b>"
73
- when 'orderedList'
74
- result << '<ol>'
75
- closing_tag = '</ol>'
76
- when 'panel'
77
- type = node_attrs['panelType']
78
- result << "<div>#{type.upcase}</div>"
79
- when 'paragraph'
80
- result << '<p>'
81
- closing_tag = '</p>'
82
- when 'rule'
83
- result << '<hr />'
84
- when 'status'
85
- text = node_attrs['text']
86
- result << text
87
- when 'table'
88
- result << '<table>'
89
- closing_tag = '</table>'
90
- when 'tableCell'
91
- result << '<td>'
92
- closing_tag = '</td>'
93
- when 'tableHeader'
94
- result << '<th>'
95
- closing_tag = '</th>'
96
- when 'tableRow'
97
- result << '<tr>'
98
- closing_tag = '</tr>'
99
- when 'text'
100
- marks = adf_marks_to_html node['marks']
101
- result << marks.collect(&:first).join
102
- result << node['text']
103
- result << marks.collect(&:last).join
104
- when 'taskItem'
105
- state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
106
- result << "<li>#{state} "
107
- closing_tag = '</li>'
108
- when 'taskList'
109
- result << "<ul class='taskList'>"
110
- closing_tag = '</ul>'
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
111
31
  else
112
- result << "<p>Unparseable section: #{node['type']}</p>"
32
+ ''
113
33
  end
34
+ end
114
35
 
115
- node['content']&.each do |child|
116
- result << adf_node_to_html(child)
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
117
81
  end
82
+ end
118
83
 
119
- result << closing_tag if closing_tag
120
- result
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
121
115
  end
122
116
 
123
117
  def adf_marks_to_html list
@@ -153,4 +147,14 @@ class AtlassianDocumentFormat
153
147
  text = "@#{user.display_name}" if user
154
148
  "<span class='account_id'>#{text}</span>"
155
149
  end
156
- 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?
@@ -4,18 +4,18 @@ class Board
4
4
  attr_reader :visible_columns, :raw, :possible_statuses, :sprints
5
5
  attr_accessor :cycletime, :project_config
6
6
 
7
- def initialize raw:, possible_statuses:
7
+ def initialize raw:, possible_statuses:, features: []
8
8
  @raw = raw
9
9
  @possible_statuses = possible_statuses
10
10
  @sprints = []
11
+ @features = features
11
12
 
12
13
  columns = raw['columnConfig']['columns']
13
14
  ensure_uniqueness_of_column_names! columns
14
15
 
15
- # For a Kanban board, the first column here will always be called 'Backlog' and will NOT be
16
- # visible on the board. If the board is configured to have a kanban backlog then it will have
17
- # statuses matched to it and otherwise, there will be no statuses.
18
- columns = columns.drop(1) if kanban?
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'
19
19
 
20
20
  @backlog_statuses = []
21
21
  @visible_columns = columns.filter_map do |column|
@@ -25,7 +25,7 @@ class Board
25
25
  end
26
26
 
27
27
  def backlog_statuses
28
- if @backlog_statuses.empty? && kanban?
28
+ if @backlog_statuses.empty? && board_type == 'kanban'
29
29
  status_ids = status_ids_from_column raw['columnConfig']['columns'].first
30
30
  @backlog_statuses = status_ids.filter_map do |id|
31
31
  @possible_statuses.find_by_id id
@@ -67,8 +67,28 @@ class Board
67
67
  end
68
68
 
69
69
  def board_type = raw['type']
70
- def kanban? = (board_type == 'kanban')
71
- def scrum? = (board_type == 'scrum')
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
+
78
+ def kanban?
79
+ return true if board_type == 'kanban'
80
+ return false unless board_type == 'simple'
81
+
82
+ !scrum?
83
+ end
84
+
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? }
91
+ end
72
92
 
73
93
  def id
74
94
  @raw['id'].to_i
@@ -116,4 +136,8 @@ class Board
116
136
  def estimation_configuration
117
137
  EstimationConfiguration.new raw: raw['estimation']
118
138
  end
139
+
140
+ def inspect
141
+ "Board(id: #{id}, name: #{name.inspect}, board_type: #{board_type.inspect})"
142
+ end
119
143
  end
@@ -11,6 +11,7 @@ class BoardConfig
11
11
 
12
12
  def run
13
13
  @board = @project_config.all_boards[id]
14
+ raise "Can't find board #{id.inspect} in #{@project_config.all_boards.keys.inspect}" unless @board
14
15
 
15
16
  instance_eval(&@block)
16
17
  raise "Must specify a cycletime for board #{@id}" if @board.cycletime.nil?
@@ -23,7 +24,9 @@ class BoardConfig
23
24
  end
24
25
 
25
26
  @board.cycletime = CycleTimeConfig.new(
26
- parent_config: self, label: label, block: block, file_system: project_config.file_system
27
+ possible_statuses: project_config.possible_statuses,
28
+ label: label, block: block, file_system: project_config.file_system,
29
+ settings: project_config.settings
27
30
  )
28
31
  end
29
32
 
@@ -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
@@ -10,7 +10,7 @@ class BoardMovementCalculator
10
10
  end
11
11
 
12
12
  def moves_backwards? issue
13
- started, stopped = issue.board.cycletime.started_stopped_times(issue)
13
+ started, stopped = issue.started_stopped_times
14
14
  return false unless started
15
15
 
16
16
  previous_column = nil
@@ -70,7 +70,7 @@ class BoardMovementCalculator
70
70
  @issues.filter_map do |issue|
71
71
  this_column_start = issue.first_time_in_or_right_of_column(this_column.name)&.time
72
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.board.cycletime.started_stopped_times(issue)
73
+ issue_start, issue_done = issue.started_stopped_times
74
74
 
75
75
  # Skip if we can't tell when it started.
76
76
  next if issue_start.nil?
@@ -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