jirametrics 2.20.1 → 2.25

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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +10 -2
  3. data/lib/jirametrics/aging_work_bar_chart.rb +189 -133
  4. data/lib/jirametrics/aging_work_table.rb +4 -5
  5. data/lib/jirametrics/anonymizer.rb +74 -1
  6. data/lib/jirametrics/atlassian_document_format.rb +93 -93
  7. data/lib/jirametrics/bar_chart_range.rb +17 -0
  8. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  9. data/lib/jirametrics/board.rb +24 -8
  10. data/lib/jirametrics/board_config.rb +2 -1
  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 +103 -0
  14. data/lib/jirametrics/change_item.rb +13 -5
  15. data/lib/jirametrics/chart_base.rb +124 -1
  16. data/lib/jirametrics/css_variable.rb +1 -1
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +200 -0
  18. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +4 -6
  19. data/lib/jirametrics/cycletime_histogram.rb +15 -103
  20. data/lib/jirametrics/cycletime_scatterplot.rb +15 -85
  21. data/lib/jirametrics/daily_view.rb +35 -11
  22. data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
  23. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
  24. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  25. data/lib/jirametrics/daily_wip_chart.rb +30 -8
  26. data/lib/jirametrics/data_quality_report.rb +37 -11
  27. data/lib/jirametrics/dependency_chart.rb +1 -1
  28. data/lib/jirametrics/download_config.rb +15 -0
  29. data/lib/jirametrics/downloader.rb +76 -5
  30. data/lib/jirametrics/downloader_for_cloud.rb +39 -0
  31. data/lib/jirametrics/downloader_for_data_center.rb +2 -1
  32. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  33. data/lib/jirametrics/examples/aggregated_project.rb +1 -1
  34. data/lib/jirametrics/examples/standard_project.rb +28 -18
  35. data/lib/jirametrics/expedited_chart.rb +3 -1
  36. data/lib/jirametrics/exporter.rb +7 -3
  37. data/lib/jirametrics/file_system.rb +4 -0
  38. data/lib/jirametrics/fix_version.rb +13 -0
  39. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  40. data/lib/jirametrics/github_gateway.rb +106 -0
  41. data/lib/jirametrics/groupable_issue_chart.rb +9 -1
  42. data/lib/jirametrics/grouping_rules.rb +21 -3
  43. data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
  44. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +2 -0
  45. data/lib/jirametrics/html/aging_work_table.erb +5 -0
  46. data/lib/jirametrics/html/cumulative_flow_diagram.erb +504 -0
  47. data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
  48. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  49. data/lib/jirametrics/html/expedited_chart.erb +6 -14
  50. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
  51. data/lib/jirametrics/html/index.css +134 -0
  52. data/lib/jirametrics/html/index.erb +6 -1
  53. data/lib/jirametrics/html/index.js +76 -2
  54. data/lib/jirametrics/html/sprint_burndown.erb +12 -12
  55. data/lib/jirametrics/html/throughput_chart.erb +42 -11
  56. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
  57. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +8 -9
  58. data/lib/jirametrics/html_generator.rb +31 -0
  59. data/lib/jirametrics/html_report_config.rb +26 -39
  60. data/lib/jirametrics/issue.rb +186 -88
  61. data/lib/jirametrics/issue_printer.rb +97 -0
  62. data/lib/jirametrics/jira_gateway.rb +6 -3
  63. data/lib/jirametrics/project_config.rb +78 -8
  64. data/lib/jirametrics/pull_request.rb +30 -0
  65. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  66. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +81 -0
  67. data/lib/jirametrics/pull_request_review.rb +13 -0
  68. data/lib/jirametrics/raw_javascript.rb +17 -0
  69. data/lib/jirametrics/settings.json +3 -1
  70. data/lib/jirametrics/sprint.rb +12 -0
  71. data/lib/jirametrics/sprint_burndown.rb +9 -3
  72. data/lib/jirametrics/status.rb +1 -1
  73. data/lib/jirametrics/stitcher.rb +76 -0
  74. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  75. data/lib/jirametrics/throughput_chart.rb +56 -22
  76. data/lib/jirametrics/time_based_histogram.rb +139 -0
  77. data/lib/jirametrics/time_based_scatterplot.rb +100 -0
  78. data/lib/jirametrics.rb +8 -1
  79. metadata +22 -5
@@ -23,105 +23,95 @@ class AtlassianDocumentFormat
23
23
  end
24
24
  end
25
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
+
26
36
  # ADF is Atlassian Document Format
27
37
  # https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
28
38
  def adf_node_to_html node # rubocop:disable Metrics/CyclomaticComplexity
29
- closing_tag = nil
30
- node_attrs = node['attrs']
31
-
32
- result = +''
33
- case node['type']
34
- when 'blockquote'
35
- result << '<blockquote>'
36
- closing_tag = '</blockquote>'
37
- when 'bulletList'
38
- result << '<ul>'
39
- closing_tag = '</ul>'
40
- when 'codeBlock'
41
- result << '<code>'
42
- closing_tag = '</code>'
43
- when 'date'
44
- result << Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s
45
- when 'decisionItem'
46
- result << '<li>'
47
- closing_tag = '</li>'
48
- when 'decisionList'
49
- result << '<div>Decisions<ul>'
50
- closing_tag = '</ul></div>'
51
- when 'emoji'
52
- result << node_attrs['text']
53
- when 'expand'
54
- # TODO: Maybe, someday, make this actually expandable. For now it's always open
55
- result << "<div>#{node_attrs['title']}</div>"
56
- when 'hardBreak'
57
- result << '<br />'
58
- when 'heading'
59
- level = node_attrs['level']
60
- result << "<h#{level}>"
61
- closing_tag = "</h#{level}>"
62
- when 'inlineCard'
63
- url = node_attrs['url']
64
- result << "[Inline card]: <a href='#{url}'>#{url}</a>"
65
- when 'listItem'
66
- result << '<li>'
67
- closing_tag = '</li>'
68
- when 'media'
69
- text = node_attrs['alt'] || node_attrs['id']
70
- result << "Media: #{text}"
71
- when 'mediaSingle', 'mediaGroup'
72
- result << '<div>'
73
- closing_tag = '</div>'
74
- when 'mention'
75
- user = node_attrs['text']
76
- result << "<b>#{user}</b>"
77
- when 'orderedList'
78
- result << '<ol>'
79
- closing_tag = '</ol>'
80
- when 'panel'
81
- type = node_attrs['panelType']
82
- result << "<div>#{type.upcase}</div>"
83
- when 'paragraph'
84
- result << '<p>'
85
- closing_tag = '</p>'
86
- when 'rule'
87
- result << '<hr />'
88
- when 'status'
89
- text = node_attrs['text']
90
- result << text
91
- when 'table'
92
- result << '<table>'
93
- closing_tag = '</table>'
94
- when 'tableCell'
95
- result << '<td>'
96
- closing_tag = '</td>'
97
- when 'tableHeader'
98
- result << '<th>'
99
- closing_tag = '</th>'
100
- when 'tableRow'
101
- result << '<tr>'
102
- closing_tag = '</tr>'
103
- when 'text'
104
- marks = adf_marks_to_html node['marks']
105
- result << marks.collect(&:first).join
106
- result << node['text']
107
- result << marks.collect(&:last).join
108
- when 'taskItem'
109
- state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
110
- result << "<li>#{state} "
111
- closing_tag = '</li>'
112
- when 'taskList'
113
- result << "<ul class='taskList'>"
114
- closing_tag = '</ul>'
115
- else
116
- result << "<p>Unparseable section: #{node['type']}</p>"
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
- node['content']&.each do |child|
120
- result << adf_node_to_html(child)
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
121
114
  end
122
-
123
- result << closing_tag if closing_tag
124
- result
125
115
  end
126
116
 
127
117
  def adf_marks_to_html list
@@ -157,4 +147,14 @@ class AtlassianDocumentFormat
157
147
  text = "@#{user.display_name}" if user
158
148
  "<span class='account_id'>#{text}</span>"
159
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
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,20 @@ 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
+ @features.any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
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
72
84
 
73
85
  def id
74
86
  @raw['id'].to_i
@@ -116,4 +128,8 @@ class Board
116
128
  def estimation_configuration
117
129
  EstimationConfiguration.new raw: raw['estimation']
118
130
  end
131
+
132
+ def inspect
133
+ "Board(id: #{id}, name: #{name.inspect}, board_type: #{board_type.inspect})"
134
+ end
119
135
  end
@@ -24,7 +24,8 @@ class BoardConfig
24
24
  end
25
25
 
26
26
  @board.cycletime = CycleTimeConfig.new(
27
- 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,
28
29
  settings: project_config.settings
29
30
  )
30
31
  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
@@ -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,103 @@
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
+ high_water_mark = nil
35
+ correction_open_since = nil
36
+ correction_windows = []
37
+ hwm_timeline = [] # sorted chronologically by date
38
+
39
+ issue.status_changes.each do |change|
40
+ col_index = column_map[change.value_id]
41
+ next if col_index.nil?
42
+
43
+ if high_water_mark.nil? || col_index > high_water_mark
44
+ # Forward movement: advance hwm, close any open correction window, record timeline entry
45
+ if correction_open_since
46
+ correction_windows << {
47
+ start_date: correction_open_since,
48
+ end_date: change.time.to_date,
49
+ column_index: high_water_mark
50
+ }
51
+ correction_open_since = nil
52
+ end
53
+ high_water_mark = col_index
54
+ hwm_timeline << [change.time.to_date, high_water_mark]
55
+ elsif col_index == high_water_mark && correction_open_since
56
+ # Same-column recovery: close the correction window without changing hwm or adding timeline entry
57
+ correction_windows << {
58
+ start_date: correction_open_since,
59
+ end_date: change.time.to_date,
60
+ column_index: high_water_mark
61
+ }
62
+ correction_open_since = nil
63
+ elsif col_index < high_water_mark
64
+ # Backwards movement: open correction window if not already open
65
+ correction_open_since ||= change.time.to_date
66
+ end
67
+ end
68
+
69
+ if correction_open_since
70
+ correction_windows << {
71
+ start_date: correction_open_since,
72
+ end_date: @date_range.end,
73
+ column_index: high_water_mark
74
+ }
75
+ end
76
+
77
+ { hwm_timeline: hwm_timeline, correction_windows: correction_windows }
78
+ end
79
+
80
+ def hwm_at hwm_timeline, date
81
+ result = nil
82
+ hwm_timeline.each do |timeline_date, hwm|
83
+ break if timeline_date > date
84
+
85
+ result = hwm
86
+ end
87
+ result
88
+ end
89
+
90
+ def build_daily_counts issue_states
91
+ column_count = @columns.size
92
+ @date_range.each_with_object({}) do |date, result|
93
+ counts = Array.new(column_count, 0)
94
+ issue_states.each do |state|
95
+ hwm = hwm_at(state[:hwm_timeline], date)
96
+ next if hwm.nil?
97
+
98
+ (0..hwm).each { |i| counts[i] += 1 }
99
+ end
100
+ result[date] = counts
101
+ end
102
+ end
103
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ChangeItem
4
- attr_reader :field, :value_id, :old_value_id, :raw, :author_raw
4
+ attr_reader :field, :value_id, :old_value_id, :raw, :author_raw, :field_id
5
5
  attr_accessor :value, :old_value, :time
6
6
 
7
7
  def initialize raw:, author_raw:, time:, artificial: false
@@ -13,9 +13,15 @@ class ChangeItem
13
13
 
14
14
  @field = @raw['field']
15
15
  @value = @raw['toString']
16
- @value_id = @raw['to'].to_i
17
16
  @old_value = @raw['fromString']
18
- @old_value_id = @raw['from']&.to_i
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
24
+ @field_id = @raw['fieldId']
19
25
  @artificial = artificial
20
26
  end
21
27
 
@@ -40,6 +46,7 @@ class ChangeItem
40
46
  def resolution? = (field == 'resolution')
41
47
  def sprint? = (field == 'Sprint')
42
48
  def status? = (field == 'status')
49
+ def fix_version? = (field == 'Fix Version')
43
50
 
44
51
  # An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
45
52
  def to_time = @time
@@ -48,12 +55,13 @@ class ChangeItem
48
55
  message = +''
49
56
  message << "ChangeItem(field: #{field.inspect}"
50
57
  message << ", value: #{value.inspect}"
51
- message << ':' << value_id.inspect if status?
58
+ message << ':' << value_id.inspect if value_id
52
59
  if old_value
53
60
  message << ", old_value: #{old_value.inspect}"
54
- message << ':' << old_value_id.inspect if status?
61
+ message << ':' << old_value_id.inspect if old_value_id
55
62
  end
56
63
  message << ", time: #{time_to_s(@time).inspect}"
64
+ message << ", field_id: #{@field_id.inspect}" if @field_id
57
65
  message << ', artificial' if artificial?
58
66
  message << ')'
59
67
  message
@@ -3,7 +3,7 @@
3
3
  class ChartBase
4
4
  attr_accessor :timezone_offset, :board_id, :all_boards, :date_range,
5
5
  :time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system,
6
- :atlassian_document_format
6
+ :atlassian_document_format, :x_axis_title, :y_axis_title, :fix_versions
7
7
  attr_writer :aggregated_project
8
8
  attr_reader :canvas_width, :canvas_height
9
9
 
@@ -22,6 +22,14 @@ class ChartBase
22
22
  @canvas_responsive = true
23
23
  end
24
24
 
25
+ def call_before_run &proc
26
+ (@call_before_run_procs ||= []) << proc
27
+ end
28
+
29
+ def before_run
30
+ @call_before_run_procs&.each { |proc| proc.call }
31
+ end
32
+
25
33
  def aggregated_project?
26
34
  @aggregated_project
27
35
  end
@@ -72,10 +80,26 @@ class ChartBase
72
80
  "#{days} day#{'s' unless days == 1}"
73
81
  end
74
82
 
83
+ def label_hours hours
84
+ return 'unknown' if hours.nil?
85
+
86
+ "#{hours} hour#{'s' unless hours == 1}"
87
+ end
88
+
89
+ def label_minutes minutes
90
+ return 'unknown' if minutes.nil?
91
+
92
+ "#{minutes} minute#{'s' unless minutes == 1}"
93
+ end
94
+
75
95
  def label_issues count
76
96
  "#{count} issue#{'s' unless count == 1}"
77
97
  end
78
98
 
99
+ def to_human_readable number
100
+ number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
101
+ end
102
+
79
103
  def daily_chart_dataset date_issues_list:, color:, label:, positive: true
80
104
  {
81
105
  type: 'bar',
@@ -147,6 +171,56 @@ class ChartBase
147
171
  end.join
148
172
  end
149
173
 
174
+ LABEL_POSITIONS = %w[5% 25% 45% 65%].freeze
175
+
176
+ def date_annotation
177
+ annotations = settings['date_annotations'] || []
178
+ in_range = annotations
179
+ .map { |a| [a, normalize_annotation_datetime(a['date'])] }
180
+ .select { |(_, dt)| date_range.cover?(Date.parse(dt)) }
181
+ .sort_by { |(_, dt)| dt }
182
+
183
+ positions = stagger_label_positions(in_range.map { |(_, dt)| dt })
184
+
185
+ in_range.each_with_index.collect do |(a, normalized), index|
186
+ <<~TEXT
187
+ dateAnnotation#{index}: {
188
+ type: 'line',
189
+ xMin: #{normalized.to_json},
190
+ xMax: #{normalized.to_json},
191
+ borderColor: 'rgba(0,0,0,0.7)',
192
+ borderWidth: 1,
193
+ label: {
194
+ display: true,
195
+ content: #{a['label'].to_json},
196
+ position: #{positions[index].to_json}
197
+ }
198
+ },
199
+ TEXT
200
+ end.join
201
+ end
202
+
203
+ def stagger_label_positions datetimes
204
+ return [] if datetimes.empty?
205
+
206
+ threshold_days = (date_range.end - date_range.begin).to_f / 5.0
207
+ slot = 0
208
+ [LABEL_POSITIONS[0]] + datetimes.each_cons(2).map do |a, b|
209
+ days_apart = (Date.parse(b) - Date.parse(a)).to_f.abs
210
+ slot = days_apart < threshold_days ? slot + 1 : 0
211
+ LABEL_POSITIONS[slot % LABEL_POSITIONS.size]
212
+ end
213
+ end
214
+
215
+ def normalize_annotation_datetime value
216
+ offset = timezone_offset || '+00:00'
217
+ if value.include?('T')
218
+ value.match?(/([+-]\d{2}:\d{2}|Z)$/) ? value : "#{value}#{offset}"
219
+ else
220
+ "#{value}T00:00:00#{offset}"
221
+ end
222
+ end
223
+
150
224
  # Return only the board columns for the current board.
151
225
  def current_board
152
226
  if @board_id.nil?
@@ -237,6 +311,13 @@ class ChartBase
237
311
  "<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
238
312
  end
239
313
 
314
+ def not_visible_text issue
315
+ reasons = issue.reasons_not_visible_on_board
316
+ return nil if reasons.empty?
317
+
318
+ "<span style='background: var(--warning-banner)'>Not visible on board: #{reasons.join(', ')}</span>"
319
+ end
320
+
240
321
  def status_category_color status
241
322
  case status.category.key
242
323
  when 'new' then CssVariable['--status-category-todo-color']
@@ -279,4 +360,46 @@ class ChartBase
279
360
  </div>
280
361
  TEXT
281
362
  end
363
+
364
+ # Set a cycletime for just this one chart, overriding the one for the report.
365
+ def cycletime &block
366
+ call_before_run do
367
+ @cycletime = CycleTimeConfig.new(
368
+ possible_statuses: possible_statuses, label: nil, block: block, file_system: file_system,
369
+ settings: settings
370
+ )
371
+ end
372
+ end
373
+
374
+ # Returns the cycletime in use right now, which may be specific to the chart or across the report.
375
+ def cycletime_for_issue issue
376
+ @cycletime || issue.board.cycletime
377
+ end
378
+
379
+ def seam_start type = 'chart'
380
+ "\n<!-- seam-start | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->"
381
+ end
382
+
383
+ def seam_end type = 'chart'
384
+ "\n<!-- seam-end | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->"
385
+ end
386
+
387
+ def render_axis_title axis_direction
388
+ text = case axis_direction
389
+ when :x
390
+ x_axis_title
391
+ when :y
392
+ y_axis_title
393
+ else
394
+ raise "Unexpected axis_direction: #{axis_direction}"
395
+ end
396
+ return '' unless text
397
+
398
+ <<~CONTENT
399
+ title: {
400
+ display: true,
401
+ text: "#{text}"
402
+ },
403
+ CONTENT
404
+ end
282
405
  end