jirametrics 2.31 → 3.0

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +42 -29
  3. data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
  5. data/lib/jirametrics/aging_work_table.rb +36 -33
  6. data/lib/jirametrics/anonymizer.rb +31 -86
  7. data/lib/jirametrics/atlassian_document_format.rb +11 -4
  8. data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
  9. data/lib/jirametrics/blocked_stalled_change.rb +5 -1
  10. data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
  11. data/lib/jirametrics/board.rb +23 -9
  12. data/lib/jirametrics/board_config.rb +0 -7
  13. data/lib/jirametrics/board_movement_calculator.rb +24 -21
  14. data/lib/jirametrics/cfd_data_builder.rb +46 -44
  15. data/lib/jirametrics/change_item.rb +19 -7
  16. data/lib/jirametrics/chart_base.rb +50 -64
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +59 -46
  18. data/lib/jirametrics/cycle_time_config.rb +35 -46
  19. data/lib/jirametrics/cycletime_histogram.rb +7 -0
  20. data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
  21. data/lib/jirametrics/daily_view.rb +83 -61
  22. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
  23. data/lib/jirametrics/daily_wip_chart.rb +72 -45
  24. data/lib/jirametrics/data_quality_report.rb +61 -63
  25. data/lib/jirametrics/dependency_chart.rb +40 -31
  26. data/lib/jirametrics/download_config.rb +0 -3
  27. data/lib/jirametrics/downloader.rb +8 -7
  28. data/lib/jirametrics/downloader_for_cloud.rb +108 -72
  29. data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
  30. data/lib/jirametrics/examples/standard_project.rb +2 -2
  31. data/lib/jirametrics/expedited_chart.rb +47 -37
  32. data/lib/jirametrics/exporter.rb +19 -10
  33. data/lib/jirametrics/file_config.rb +19 -12
  34. data/lib/jirametrics/file_system.rb +16 -10
  35. data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
  36. data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
  37. data/lib/jirametrics/github_gateway.rb +23 -9
  38. data/lib/jirametrics/groupable_issue_chart.rb +3 -0
  39. data/lib/jirametrics/html/index.css +6 -0
  40. data/lib/jirametrics/html_report_config.rb +9 -29
  41. data/lib/jirametrics/issue.rb +290 -389
  42. data/lib/jirametrics/issue_collection.rb +1 -0
  43. data/lib/jirametrics/issue_printer.rb +60 -23
  44. data/lib/jirametrics/jira_gateway.rb +29 -18
  45. data/lib/jirametrics/mcp_server.rb +225 -209
  46. data/lib/jirametrics/project_config.rb +158 -134
  47. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
  48. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
  49. data/lib/jirametrics/rules.rb +1 -0
  50. data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
  51. data/lib/jirametrics/sprint_burndown.rb +142 -242
  52. data/lib/jirametrics/sprint_count_measure.rb +42 -0
  53. data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
  54. data/lib/jirametrics/sprint_points_measure.rb +62 -0
  55. data/lib/jirametrics/sprint_summary_stats.rb +16 -0
  56. data/lib/jirametrics/status_collection.rb +2 -2
  57. data/lib/jirametrics/stitcher.rb +21 -16
  58. data/lib/jirametrics/throughput_chart.rb +38 -24
  59. data/lib/jirametrics/time_based_chart.rb +65 -0
  60. data/lib/jirametrics/time_based_histogram.rb +30 -23
  61. data/lib/jirametrics/time_based_scatterplot.rb +11 -2
  62. data/lib/jirametrics/trend_line_calculator.rb +2 -2
  63. data/lib/jirametrics/wip_by_column_chart.rb +73 -32
  64. data/lib/jirametrics.rb +6 -2
  65. metadata +13 -20
@@ -12,7 +12,9 @@ class AtlassianDocumentFormat
12
12
  if input.is_a? String
13
13
  input
14
14
  .gsub(/{color:(#\w{6})}([^{]+){color}/, '<span style="color: \1">\2</span>') # Colours
15
+ # rubocop:disable Style/PerlBackrefs -- in a gsub block $1 reads cleaner than Regexp.last_match[:...]
15
16
  .gsub(/\[~accountid:([^\]]+)\]/) { expand_account_id $1 } # Tagged people
17
+ # rubocop:enable Style/PerlBackrefs
16
18
  .gsub(/\[([^|]+)\|(https?[^\]]+)\]/, '<a href="\2">\1</a>') # URLs
17
19
  .gsub("\n", '<br />')
18
20
  elsif input&.[]('content')
@@ -35,7 +37,11 @@ class AtlassianDocumentFormat
35
37
 
36
38
  # ADF is Atlassian Document Format
37
39
  # https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
38
- def adf_node_to_html node # rubocop:disable Metrics/CyclomaticComplexity
40
+ #
41
+ # Flat node-type dispatch: ~25 trivial branches mapping an ADF node type to its [prefix, suffix]
42
+ # pair (adf_node_render does the recursion). The complexity here is breadth, not depth — a lookup
43
+ # table would only trade a scannable case for a hash plus indirection, so we keep the case.
44
+ def adf_node_to_html node # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
39
45
  adf_node_render(node) do |n|
40
46
  node_attrs = n['attrs']
41
47
  case n['type']
@@ -74,20 +80,21 @@ class AtlassianDocumentFormat
74
80
  when 'taskItem'
75
81
  state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
76
82
  ["<li>#{state} ", '</li>']
77
- when 'taskList' then ["<ul class='taskList'>", '</ul>']
83
+ when 'taskList' then ["<ul class='taskList'>", '</ul>']
78
84
  else
79
85
  ["<p>Unparseable section: #{n['type']}</p>", nil]
80
86
  end
81
87
  end
82
88
  end
83
89
 
90
+ # Flat node-type dispatch, same reasoning as adf_node_to_html: breadth, not depth. Kept as a case.
84
91
  def adf_node_to_text node # rubocop:disable Metrics/CyclomaticComplexity
85
92
  adf_node_render(node) do |n|
86
93
  node_attrs = n['attrs']
87
94
  case n['type']
88
95
  when 'blockquote', 'bulletList', 'codeBlock',
89
96
  'mediaSingle', 'mediaGroup',
90
- 'orderedList', 'table', 'taskList' then ['', nil]
97
+ 'orderedList', 'table', 'taskList' then ['', nil]
91
98
  when 'date'
92
99
  [Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
93
100
  when 'decisionItem' then ['- ', "\n"]
@@ -151,7 +158,7 @@ class AtlassianDocumentFormat
151
158
  private
152
159
 
153
160
  def adf_node_render node, &render_node
154
- prefix, suffix = render_node.call(node)
161
+ prefix, suffix = yield(node)
155
162
  result = +(prefix || '')
156
163
  node['content']&.each { |child| result << adf_node_render(child, &render_node) }
157
164
  result << suffix if suffix
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rolls a stream of BlockedStalledChange entries up into a single winning entry per date across
4
+ # the requested date range. It's handed the already-computed stream so it never reaches back
5
+ # through the issue's collaborators.
6
+ class BlockedStalledByDateBuilder
7
+ def initialize blocked_stalled_changes:, date_range:
8
+ @blocked_stalled_changes = blocked_stalled_changes
9
+ @date_range = date_range
10
+ end
11
+
12
+ def build
13
+ results = winners_and_last_by_date
14
+ fill_gaps results
15
+ results = results.transform_values(&:first)
16
+ extrapolate_across_range results
17
+
18
+ # We've been accumulating data for every date to keep the code simple. Now drop anything
19
+ # that isn't in the requested date_range.
20
+ results.select! { |date, _value| @date_range.include? date }
21
+ results
22
+ end
23
+
24
+ # For each date we track both the winning change (the one that best represents the day) and the
25
+ # last change seen (used to carry state forward into days that had no changes of their own).
26
+ def winners_and_last_by_date
27
+ results = {}
28
+ @blocked_stalled_changes.each do |change|
29
+ date = change.time.to_date
30
+ winning_change, = results[date]
31
+ winning_change = change if winning_change.nil? || wins_the_day?(change, winning_change)
32
+ results[date] = [winning_change, change]
33
+ end
34
+ results
35
+ end
36
+
37
+ def wins_the_day? change, winning_change
38
+ change.blocked? ||
39
+ (change.active? && (winning_change.active? || winning_change.stalled?)) ||
40
+ (change.stalled? && winning_change.stalled?)
41
+ end
42
+
43
+ def fill_gaps results
44
+ last_populated_date = nil
45
+ (results.keys.min..results.keys.max).each do |date|
46
+ if results.key? date
47
+ last_populated_date = date
48
+ else
49
+ _winner, last = results[last_populated_date]
50
+ results[date] = [last, last]
51
+ end
52
+ end
53
+ end
54
+
55
+ def extrapolate_across_range results
56
+ # The requested date range may span outside the actual changes we find in the changelog.
57
+ date_of_first_change = @blocked_stalled_changes[0].time.to_date
58
+ date_of_last_change = @blocked_stalled_changes[-1].time.to_date
59
+ @date_range.each do |date|
60
+ results[date] = @blocked_stalled_changes[0] if date < date_of_first_change
61
+ results[date] = @blocked_stalled_changes[-1] if date > date_of_last_change
62
+ end
63
+ end
64
+ end
@@ -4,6 +4,7 @@ require 'jirametrics/value_equality'
4
4
 
5
5
  class BlockedStalledChange
6
6
  include ValueEquality
7
+
7
8
  attr_reader :time, :blocking_issue_keys, :flag, :flag_reason, :status, :stalled_days, :status_is_blocking
8
9
 
9
10
  def initialize time:, flagged: nil, flag_reason: nil, status: nil, status_is_blocking: true,
@@ -24,7 +25,10 @@ class BlockedStalledChange
24
25
  def blocked_by_status? = !!(@status && @status_is_blocking)
25
26
  def stalled_by_status? = !!(@status && !@status_is_blocking)
26
27
 
27
- def reasons
28
+ def reasons # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
29
+ # A flat list of why an item is blocked or stalled: each branch is a single guarded append.
30
+ # Pulling these apart into helpers would scatter the story across single-use methods without
31
+ # reading any clearer, so we keep it whole.
28
32
  result = []
29
33
  if blocked?
30
34
  result << (@flag_reason ? "Blocked by flag: #{@flag_reason}" : 'Blocked by flag') if @flag
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Builds the time-ordered stream of BlockedStalledChange entries for a single issue.
4
+ # Everything it needs is passed in explicitly (changes, settings, and a few resolved values)
5
+ # so it never reaches back through the issue's collaborators.
6
+ class BlockedStalledChangeStreamBuilder
7
+ # Mutable state threaded through the change loop as we walk the issue's history. Each branch
8
+ # only overwrites the fields it owns, so a flag/status/link value persists until the next change
9
+ # of that kind replaces it.
10
+ BlockingState = Struct.new(
11
+ :flag, :flag_reason, :status, :is_blocked, :blocking_issue_keys, keyword_init: true
12
+ )
13
+
14
+ def initialize changes:, settings:, created:, key:, subtask_activity_times:, atlassian_document_format:
15
+ @changes = changes
16
+ @settings = settings
17
+ @created = created
18
+ @key = key
19
+ @subtask_activity_times = subtask_activity_times
20
+ @atlassian_document_format = atlassian_document_format
21
+ end
22
+
23
+ def build end_time:
24
+ result = []
25
+ state = BlockingState.new(flag: nil, flag_reason: nil, status: nil, is_blocked: false, blocking_issue_keys: [])
26
+ previous_was_active = false # Must start as false so that the creation will insert an :active
27
+ previous_change_time = @created
28
+
29
+ # This mock change is to force the writing of one last entry at the end of the time range.
30
+ # By doing this, we're able to eliminate a lot of duplicated code in charts.
31
+ mock_change = ChangeItem.new time: end_time, artificial: true, raw: { 'field' => '' }, author_raw: nil
32
+
33
+ (@changes + [mock_change]).each do |change|
34
+ previous_was_active = false if check_for_stalled(
35
+ change_time: change.time,
36
+ previous_change_time: previous_change_time,
37
+ stalled_threshold: @settings['stalled_threshold_days'],
38
+ blocking_stalled_changes: result
39
+ )
40
+
41
+ update_blocking_state state, change
42
+
43
+ new_change = new_blocked_stalled_change state, change.time
44
+
45
+ # We don't want to dump two actives in a row as that would just be noise. Unless this is
46
+ # the mock change, which we always want to dump
47
+ result << new_change if record_change? new_change, previous_was_active, change, mock_change
48
+
49
+ previous_was_active = new_change.active?
50
+ previous_change_time = change.time
51
+ end
52
+
53
+ finalize_stalled_tail result
54
+ result
55
+ end
56
+
57
+ def update_blocking_state state, change
58
+ if change.flagged? && flagged_means_blocked?
59
+ state.flag, state.flag_reason = flag_logic change
60
+ elsif change.status?
61
+ state.status, state.is_blocked = status_change change
62
+ elsif change.link?
63
+ apply_link_change change, state.blocking_issue_keys
64
+ end
65
+ end
66
+
67
+ def flagged_means_blocked?
68
+ !!@settings['flagged_means_blocked']
69
+ end
70
+
71
+ def new_blocked_stalled_change state, time
72
+ BlockedStalledChange.new(
73
+ flagged: state.flag,
74
+ flag_reason: state.flag_reason,
75
+ status: state.status,
76
+ status_is_blocking: state.status.nil? || state.is_blocked,
77
+ blocking_issue_keys: (state.blocking_issue_keys.empty? ? nil : state.blocking_issue_keys.dup),
78
+ time: time
79
+ )
80
+ end
81
+
82
+ def record_change? candidate, previous_was_active, change, mock_change
83
+ !candidate.active? || !previous_was_active || change == mock_change
84
+ end
85
+
86
+ def status_change change
87
+ if @settings['blocked_statuses'].find_by_id(change.value_id)
88
+ [change.value, true]
89
+ elsif @settings['stalled_statuses'].find_by_id(change.value_id)
90
+ [change.value, false]
91
+ else
92
+ [nil, false]
93
+ end
94
+ end
95
+
96
+ def apply_link_change change, blocking_issue_keys
97
+ link_value = change.value || change.old_value
98
+ # Example: "This issue is satisfied by ANON-30465"
99
+ unless /^This (?<_>issue|work item) (?<link_text>.+) (?<issue_key>.+)$/ =~ link_value
100
+ puts "Issue(#{@key}) Can't parse link text: #{link_value}"
101
+ return
102
+ end
103
+
104
+ return unless @settings['blocked_link_text'].include? link_text
105
+
106
+ if change.value
107
+ blocking_issue_keys << issue_key
108
+ else
109
+ blocking_issue_keys.delete issue_key
110
+ end
111
+ end
112
+
113
+ def flag_logic change
114
+ flag = change.value
115
+ flag = nil if change.value == ''
116
+ [flag, flag ? flag_reason_from_comment(change) : nil]
117
+ end
118
+
119
+ def flag_reason_from_comment change
120
+ comment_change = comment_near change.time
121
+ flag_reason = comment_change && @atlassian_document_format.to_text(comment_change.value)
122
+ # Newer Jira instances may add this extra text but older instances did not. Strip it out if found.
123
+ flag_reason = flag_reason&.sub(/\A:flag_on: Flag added\s*/m, '')&.strip
124
+ flag_reason = nil if flag_reason && flag_reason.empty?
125
+ flag_reason
126
+ end
127
+
128
+ def comment_near flag_time
129
+ # When the user is adding a comment to explain why a flag was set, the flag is set immediately
130
+ # and the comment is inserted after the user hits enter, which means that there is some time
131
+ # gap. If a comment happened shortly after the flag was set, we assume they're linked. This
132
+ # won't always be true and so there will be false positives, but it's a reasonable assumption.
133
+ max_seconds_between_flag_and_comment = 30
134
+ @changes.find do |c|
135
+ c.comment? && c.time >= flag_time && (c.time - flag_time) <= max_seconds_between_flag_and_comment
136
+ end
137
+ end
138
+
139
+ def finalize_stalled_tail result
140
+ return unless result.size >= 2
141
+
142
+ # The existence of the mock entry will mess with the stalled count as it will wake everything
143
+ # back up. This hack will clean up appropriately.
144
+ hack = result.pop
145
+ result << BlockedStalledChange.new(
146
+ flagged: hack.flag,
147
+ flag_reason: hack.flag_reason,
148
+ status: hack.status,
149
+ status_is_blocking: hack.status_is_blocking,
150
+ blocking_issue_keys: hack.blocking_issue_keys,
151
+ time: hack.time,
152
+ stalled_days: result[-1].stalled_days
153
+ )
154
+ end
155
+
156
+ def check_for_stalled change_time:, previous_change_time:, stalled_threshold:, blocking_stalled_changes:
157
+ stalled_threshold_seconds = stalled_threshold * 60 * 60 * 24
158
+
159
+ # The most common case will be nothing to split so quick escape.
160
+ return false if (change_time - previous_change_time).to_i < stalled_threshold_seconds
161
+
162
+ # If the last identified change was blocked then it doesn't matter now long we've waited, we're still blocked.
163
+ return false if blocking_stalled_changes[-1]&.blocked?
164
+
165
+ ranges = split_range_by_subtask_activity previous_change_time..change_time
166
+ insert_stalled_changes ranges, stalled_threshold_seconds, blocking_stalled_changes
167
+ end
168
+
169
+ def split_range_by_subtask_activity initial_range
170
+ list = [initial_range]
171
+ @subtask_activity_times.each do |time|
172
+ matching_range = list.find { |range| time.between?(range.begin, range.end) }
173
+ next unless matching_range
174
+
175
+ list.delete matching_range
176
+ list << (matching_range.begin..time)
177
+ list << (time..matching_range.end)
178
+ end
179
+ list
180
+ end
181
+
182
+ def insert_stalled_changes ranges, stalled_threshold_seconds, blocking_stalled_changes
183
+ inserted_stalled = false
184
+ ranges.sort_by(&:begin).each do |range|
185
+ seconds = (range.end - range.begin).to_i
186
+ next if seconds < stalled_threshold_seconds
187
+
188
+ an_hour_later = range.begin + (60 * 60)
189
+ blocking_stalled_changes << BlockedStalledChange.new(stalled_days: seconds / (24 * 60 * 60), time: an_hour_later)
190
+ inserted_stalled = true
191
+ end
192
+ inserted_stalled
193
+ end
194
+ end
@@ -35,9 +35,9 @@ class Board
35
35
  end
36
36
 
37
37
  def server_url_prefix
38
- raise "Cannot parse self: #{@raw['self'].inspect}" unless @raw['self'] =~ /^(https?:\/\/.+)\/rest\//
38
+ raise "Cannot parse self: #{@raw['self'].inspect}" unless %r{^(?<prefix>https?://.+)/rest/} =~ @raw['self']
39
39
 
40
- $1
40
+ prefix
41
41
  end
42
42
 
43
43
  def url
@@ -59,20 +59,34 @@ class Board
59
59
  status_ids += column.status_ids if found_it
60
60
  end
61
61
 
62
- unless found_it
63
- column_names = @visible_columns.collect { |c| c.name.inspect }.join(', ')
64
- raise "No visible column with name: #{column_name.inspect} Possible options are: #{column_names}"
65
- end
62
+ raise column_not_found_message(column_name) unless found_it
63
+
66
64
  status_ids
67
65
  end
68
66
 
67
+ # Column-name matching is case-sensitive, so a config asking for "Done" against a board whose column
68
+ # is "DONE" fails -- and that's the mistake people actually make. When only the case differs, name the
69
+ # likely intended column outright instead of making the reader spot it in the list themselves.
70
+ def column_not_found_message column_name
71
+ names = @visible_columns.collect(&:name)
72
+ suggestion = names.find { |name| name.casecmp?(column_name) }
73
+ unless suggestion
74
+ return "No visible column named #{column_name.inspect}. " \
75
+ "The visible columns are: #{names.collect(&:inspect).join(', ')}."
76
+ end
77
+
78
+ others = (names - [suggestion]).collect(&:inspect).join(', ')
79
+ "You specified #{column_name.inspect} but probably meant #{suggestion.inspect}. Column names are " \
80
+ "case-sensitive. If that's not it, the other visible columns are: #{others}."
81
+ end
82
+
69
83
  def board_type = raw['type']
70
84
 
71
85
  def scrum?
72
86
  return true if board_type == 'scrum'
73
87
  return false unless board_type == 'simple'
74
88
 
75
- has_sprints_feature?
89
+ sprints_feature?
76
90
  end
77
91
 
78
92
  def kanban?
@@ -83,10 +97,10 @@ class Board
83
97
  end
84
98
 
85
99
  def team_managed_kanban?
86
- board_type == 'simple' && !has_sprints_feature?
100
+ board_type == 'simple' && !sprints_feature?
87
101
  end
88
102
 
89
- def has_sprints_feature?
103
+ def sprints_feature?
90
104
  @features.any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
91
105
  end
92
106
 
@@ -29,11 +29,4 @@ class BoardConfig
29
29
  settings: project_config.settings
30
30
  )
31
31
  end
32
-
33
- def 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
38
- end
39
32
  end
@@ -10,19 +10,16 @@ class BoardMovementCalculator
10
10
  end
11
11
 
12
12
  def moves_backwards? issue
13
- started, stopped = issue.started_stopped_times
13
+ started, = issue.started_stopped_times
14
14
  return false unless started
15
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
16
+ # filter_map drops any status that isn't on a visible column (it disappeared from the board for a bit),
17
+ # so a dip either side of that gap is still seen as consecutive columns.
18
+ columns = issue.status_changes
19
+ .select { |change| change.time >= started }
20
+ .filter_map { |change| board.visible_columns.index { |c| c.status_ids.include?(change.value_id) } }
22
21
 
23
- previous_column = column
24
- end
25
- false
22
+ columns.each_cons(2).any? { |previous, current| current < previous }
26
23
  end
27
24
 
28
25
  def stacked_age_data_for percentages:
@@ -63,7 +60,13 @@ class BoardMovementCalculator
63
60
  data
64
61
  end
65
62
 
63
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
66
64
  def ages_of_issues_when_leaving_column column_index:, today:
65
+ # Why are complexity warnings disabled? This is an inherently sequential run of guard clauses
66
+ # feeding a single age calculation. Pulling pieces out (the skip rules, the end-date case) would
67
+ # scatter the linear "compute the column times -> apply the skip rules -> pick the end date -> age"
68
+ # story across single-use methods and read worse, so we keep it whole.
69
+
67
70
  this_column = board.visible_columns[column_index]
68
71
  next_column = board.visible_columns[column_index + 1]
69
72
 
@@ -84,20 +87,20 @@ class BoardMovementCalculator
84
87
  # 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
88
  next if issue_done && issue_done <= this_column_start
86
89
 
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
90
+ end_date = if next_column_start.nil?
91
+ # If this is the last column then base age against today
92
+ today
93
+ elsif issue_done && issue_done < next_column_start
94
+ # it completed while in this column
95
+ issue_done.to_date
96
+ else
97
+ # It passed through this whole column
98
+ next_column_start.to_date
99
+ end
98
100
  (end_date - issue_start.to_date).to_i + 1
99
101
  end.sort
100
102
  end
103
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
101
104
 
102
105
  # Figure out what column this is issue is currently in and what time it entered that column. We need this for
103
106
  # aging and forecasting purposes
@@ -29,65 +29,67 @@ class CfdDataBuilder
29
29
  map
30
30
  end
31
31
 
32
- # Returns { hwm_timeline: [[date, hwm_value], ...], correction_windows: [...] }
32
+ # Tracks an issue's furthest progress (the high water mark) and, whenever it slips behind that mark,
33
+ # the correction window that stays open until it recovers. Mutated in place as we walk the changes.
34
+ CorrectionState = Struct.new(
35
+ :high_water_mark, :correction_open_since, :correction_windows, :high_water_mark_timeline,
36
+ keyword_init: true
37
+ )
38
+ private_constant :CorrectionState
39
+
40
+ # Returns { high_water_mark_timeline: [[date, high_water_mark], ...], correction_windows: [...] }
33
41
  def process_issue issue, column_map
34
42
  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
43
+ return { high_water_mark_timeline: [], correction_windows: [] } if start_time.nil?
41
44
 
45
+ state = CorrectionState.new(
46
+ high_water_mark: nil, correction_open_since: nil, correction_windows: [], high_water_mark_timeline: []
47
+ )
42
48
  issue.status_changes.each do |change|
43
49
  next if change.time < start_time
44
50
 
45
51
  col_index = column_map[change.value_id]
46
52
  next if col_index.nil?
47
53
 
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
54
+ apply_change(state, col_index, change.time.to_date)
72
55
  end
56
+ # A window still open at the end never recovered, so it runs to the end of the range.
57
+ close_correction(state, @date_range.end) if state.correction_open_since
58
+
59
+ { high_water_mark_timeline: state.high_water_mark_timeline, correction_windows: state.correction_windows }
60
+ end
73
61
 
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
- }
62
+ def apply_change state, col_index, date
63
+ if state.high_water_mark.nil? || col_index > state.high_water_mark
64
+ advance_high_water_mark(state, col_index, date)
65
+ elsif col_index == state.high_water_mark && state.correction_open_since
66
+ close_correction(state, date) # recovered back to the high water mark
67
+ elsif col_index < state.high_water_mark
68
+ state.correction_open_since ||= date # slipped behind; open a window if one isn't already open
80
69
  end
70
+ end
71
+
72
+ def advance_high_water_mark state, col_index, date
73
+ close_correction(state, date) if state.correction_open_since
74
+ state.high_water_mark = col_index
75
+ state.high_water_mark_timeline << [date, col_index]
76
+ end
81
77
 
82
- { hwm_timeline: hwm_timeline, correction_windows: correction_windows }
78
+ def close_correction state, end_date
79
+ state.correction_windows << {
80
+ start_date: state.correction_open_since,
81
+ end_date: end_date,
82
+ column_index: state.high_water_mark
83
+ }
84
+ state.correction_open_since = nil
83
85
  end
84
86
 
85
- def hwm_at hwm_timeline, date
87
+ def high_water_mark_at high_water_mark_timeline, date
86
88
  result = nil
87
- hwm_timeline.each do |timeline_date, hwm|
89
+ high_water_mark_timeline.each do |timeline_date, high_water_mark|
88
90
  break if timeline_date > date
89
91
 
90
- result = hwm
92
+ result = high_water_mark
91
93
  end
92
94
  result
93
95
  end
@@ -97,10 +99,10 @@ class CfdDataBuilder
97
99
  @date_range.each_with_object({}) do |date, result|
98
100
  counts = Array.new(column_count, 0)
99
101
  issue_states.each do |state|
100
- hwm = hwm_at(state[:hwm_timeline], date)
101
- next if hwm.nil?
102
+ high_water_mark = high_water_mark_at(state[:high_water_mark_timeline], date)
103
+ next if high_water_mark.nil?
102
104
 
103
- (0..hwm).each { |i| counts[i] += 1 }
105
+ (0..high_water_mark).each { |i| counts[i] += 1 }
104
106
  end
105
107
  result[date] = counts
106
108
  end
@@ -14,17 +14,29 @@ class ChangeItem
14
14
  @field = @raw['field']
15
15
  @value = @raw['toString']
16
16
  @old_value = @raw['fromString']
17
- if sprint?
18
- @value_id = @raw['to'].split(', ').collect(&:to_i)
19
- @old_value_id = (@raw['from'] || '').split(', ').collect(&:to_i)
20
- else
21
- @value_id = @raw['to']&.to_i
22
- @old_value_id = @raw['from']&.to_i
23
- end
17
+ @value_id, @old_value_id = parse_value_ids
24
18
  @field_id = @raw['fieldId']
25
19
  @artificial = artificial
26
20
  end
27
21
 
22
+ # Sprints carry a comma-separated list of ids in 'to'/'from', so those become arrays. Every other
23
+ # field is treated as a single id: to_i grabs the leading number, or 0 for text/bracketed values like
24
+ # Watchers or Markets (whose value_id is never used as a real id anyway).
25
+ def parse_value_ids
26
+ to = @raw['to']
27
+ from = @raw['from']
28
+ return [to&.to_i, from&.to_i] unless sprint?
29
+
30
+ [parse_multiple_integer_values(to), parse_multiple_integer_values(from)]
31
+ end
32
+
33
+ # Parses a comma-separated string of integers into a list. Sprint is the only field we currently parse
34
+ # this way, but the operation is generic - other fields may turn out to carry multiple ids too. to_s
35
+ # turns a nil (no value) into an empty list.
36
+ def parse_multiple_integer_values raw_value
37
+ raw_value.to_s.split(', ').collect(&:to_i)
38
+ end
39
+
28
40
  def author
29
41
  @author_raw&.[]('displayName') || @author_raw&.[]('name') || 'Unknown author'
30
42
  end