jirametrics 2.22 → 2.23

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 (50) 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 +11 -0
  4. data/lib/jirametrics/aging_work_table.rb +1 -1
  5. data/lib/jirametrics/anonymizer.rb +74 -1
  6. data/lib/jirametrics/atlassian_document_format.rb +104 -93
  7. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  8. data/lib/jirametrics/board.rb +17 -3
  9. data/lib/jirametrics/change_item.rb +4 -3
  10. data/lib/jirametrics/chart_base.rb +80 -1
  11. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +1 -2
  12. data/lib/jirametrics/cycletime_histogram.rb +15 -103
  13. data/lib/jirametrics/cycletime_scatterplot.rb +8 -97
  14. data/lib/jirametrics/daily_wip_chart.rb +27 -7
  15. data/lib/jirametrics/download_config.rb +15 -0
  16. data/lib/jirametrics/downloader.rb +76 -5
  17. data/lib/jirametrics/downloader_for_cloud.rb +39 -0
  18. data/lib/jirametrics/downloader_for_data_center.rb +2 -1
  19. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  20. data/lib/jirametrics/examples/standard_project.rb +15 -5
  21. data/lib/jirametrics/expedited_chart.rb +2 -0
  22. data/lib/jirametrics/exporter.rb +3 -1
  23. data/lib/jirametrics/file_system.rb +4 -0
  24. data/lib/jirametrics/flow_efficiency_scatterplot.rb +2 -0
  25. data/lib/jirametrics/github_gateway.rb +99 -0
  26. data/lib/jirametrics/groupable_issue_chart.rb +2 -0
  27. data/lib/jirametrics/grouping_rules.rb +1 -1
  28. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
  29. data/lib/jirametrics/html/daily_wip_chart.erb +5 -4
  30. data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
  31. data/lib/jirametrics/html/expedited_chart.erb +3 -13
  32. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
  33. data/lib/jirametrics/html/sprint_burndown.erb +7 -13
  34. data/lib/jirametrics/html/throughput_chart.erb +5 -8
  35. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +57 -59
  36. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +3 -4
  37. data/lib/jirametrics/html_report_config.rb +1 -0
  38. data/lib/jirametrics/issue.rb +37 -74
  39. data/lib/jirametrics/issue_printer.rb +97 -0
  40. data/lib/jirametrics/project_config.rb +32 -5
  41. data/lib/jirametrics/pull_request.rb +30 -0
  42. data/lib/jirametrics/pull_request_review.rb +13 -0
  43. data/lib/jirametrics/raw_javascript.rb +4 -0
  44. data/lib/jirametrics/settings.json +3 -1
  45. data/lib/jirametrics/sprint_burndown.rb +2 -0
  46. data/lib/jirametrics/stitcher.rb +2 -1
  47. data/lib/jirametrics/throughput_chart.rb +7 -1
  48. data/lib/jirametrics/time_based_histogram.rb +139 -0
  49. data/lib/jirametrics/time_based_scatterplot.rb +100 -0
  50. metadata +11 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40a0ee85ee8d7d0d2ff071357afdea2aefdfeea8734f96eb789721d4a9f2607b
4
- data.tar.gz: 11008f97848d8e3034cf95c5f615496c5677d8057f40b06d2724247d6087318d
3
+ metadata.gz: 2145950e91bf010e3c2790151b32f859fc479a871002f2057e2070ef23a6e1ae
4
+ data.tar.gz: 00bff5cffee6fc49862ae15fdc78f142982337a03abb4ea33735b07a2f0f3426
5
5
  SHA512:
6
- metadata.gz: 1e5ad6c1d5dddf5a89cc63498f1967f35c4761b6418c90019c8c6756599efb5b8badaf0ac4d50f94be4644508a97641430f178b3d40217cb02560aa017f33b80
7
- data.tar.gz: a6a7f74dadbb8a2f7961a02e396a39dd994ff1a11756f57a5eadfd60af5c71bd79c3df65b7bfd77ca998059018f60cd6306b2006512b7cf5f760d75e2d0dfdf4
6
+ metadata.gz: d16248d2502890619c3da712ebb7f0b6aa864994013a599d29e6795f687ccbd782d9e7f33f253f7f5db81a3c78f1be0898bf27cd4e8a981137f0a3ca55da8c8f
7
+ data.tar.gz: ee3e5d4fdd783a025c9666a6080f587f82cd70f0fb6aeeda9a12a862c454baab64fb635bdf2727b821760e75822638378c87b18e59b68fa8903b3bd87a05f9f8
@@ -65,8 +65,16 @@ class AggregateConfig
65
65
 
66
66
  if issues.nil?
67
67
  file_system.warning "No issues found for #{project_name}"
68
- else
69
- @project_config.add_issues issues
68
+ return
69
+ end
70
+
71
+ @project_config.add_issues issues
72
+
73
+ # Bring fix versions over
74
+ project.fix_versions.each do |fix_version|
75
+ unless @project_config.fix_versions.find { |fv| fv.id == fix_version.id }
76
+ @project_config.fix_versions << fix_version
77
+ end
70
78
  end
71
79
  end
72
80
 
@@ -83,6 +83,8 @@ class AgingWorkBarChart < ChartBase
83
83
  ]
84
84
  bar_data << ['sprints', collect_sprint_ranges(issue: issue)] if current_board.scrum?
85
85
 
86
+ bar_data.each { |entry| clip_ranges_to_start_time(ranges: entry.last, issue_start_time: issue_start_time) }
87
+
86
88
  issue_label = "[#{label_days cycletime.age(issue, today: today)}] #{issue.key}: #{issue.summary}"[0..60]
87
89
  bar_data.collect do |stack, ranges|
88
90
  bar_chart_range_to_data_set y_value: issue_label, ranges: ranges, stack: stack, issue_start_time: issue_start_time
@@ -114,6 +116,13 @@ class AgingWorkBarChart < ChartBase
114
116
  @canvas_height = preferred_height if @canvas_height.nil? || @canvas_height < preferred_height
115
117
  end
116
118
 
119
+ def clip_ranges_to_start_time ranges:, issue_start_time:
120
+ return if issue_start_time.nil?
121
+
122
+ ranges.each { |range| range.start = issue_start_time if range.start < issue_start_time }
123
+ ranges.reject! { |range| range.start >= range.stop }
124
+ end
125
+
117
126
  def collect_status_ranges issue:, now:
118
127
  ranges = []
119
128
  issue_started_time = issue.board.cycletime.started_stopped_times(issue).first
@@ -263,6 +272,8 @@ class AgingWorkBarChart < ChartBase
263
272
  end
264
273
 
265
274
  open_sprints.each_value do |data|
275
+ next if data[:sprint].future?
276
+
266
277
  stop = data[:sprint].completed_time || time_range.end
267
278
  results << BarChartRange.new(
268
279
  start: data[:start_time], stop: stop,
@@ -174,6 +174,6 @@ class AgingWorkTable < ChartBase
174
174
  end
175
175
 
176
176
  def priority_text issue
177
- "<img src='#{issue.priority_url}' title='Priority: #{issue.priority_name}' />"
177
+ "<img src='#{issue.priority_url}' title='Priority: #{issue.priority_name}' style='max-width: 1em;'/>"
178
178
  end
179
179
  end
@@ -21,6 +21,10 @@ class Anonymizer < ChartBase
21
21
  anonymize_column_names
22
22
  # anonymize_issue_statuses
23
23
  anonymize_board_names
24
+ anonymize_labels_and_components
25
+ anonymize_sprints
26
+ anonymize_fix_versions
27
+ anonymize_server_url
24
28
  shift_all_dates unless @date_adjustment.zero?
25
29
  @file_system.log 'Anonymize done'
26
30
  end
@@ -38,13 +42,25 @@ class Anonymizer < ChartBase
38
42
 
39
43
  def anonymize_issue_keys_and_titles issues: @issues
40
44
  counter = 0
45
+ seen_author_raws = {}
41
46
  issues.each do |issue|
42
47
  new_key = "ANON-#{counter += 1}"
43
48
 
44
49
  issue.raw['key'] = new_key
45
50
  issue.raw['fields']['summary'] = random_phrase
51
+ issue.raw['fields']['description'] = nil
46
52
  issue.raw['fields']['assignee']['displayName'] = random_name unless issue.raw['fields']['assignee'].nil?
47
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
+
48
64
  issue.issue_links.each do |link|
49
65
  other_issue = link.other_issue
50
66
  next if other_issue.key.match?(/^ANON-\d+$/) # Already anonymized?
@@ -55,6 +71,49 @@ class Anonymizer < ChartBase
55
71
  end
56
72
  end
57
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
+
58
117
  def anonymize_column_names
59
118
  @all_boards.each_key do |board_id|
60
119
  @file_system.log "Anonymizing column names for board #{board_id}"
@@ -143,7 +202,7 @@ class Anonymizer < ChartBase
143
202
  end
144
203
 
145
204
  range = @project_config.time_range
146
- @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)
147
206
  end
148
207
 
149
208
  def random_name
@@ -186,4 +245,18 @@ class Anonymizer < ChartBase
186
245
  board.raw['name'] = "#{random_phrase} board"
187
246
  end
188
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
189
262
  end
@@ -23,105 +23,106 @@ 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' then ['<li>', '</li>']
48
+ when 'decisionList' then ['<div>Decisions<ul>', '</ul></div>']
49
+ when 'emoji' 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 'listItem' then ['<li>', '</li>']
59
+ when 'media'
60
+ text = node_attrs['alt'] || node_attrs['id']
61
+ ["Media: #{text}", nil]
62
+ when 'mediaSingle', 'mediaGroup' then ['<div>', '</div>']
63
+ when 'mention' then ["<b>#{node_attrs['text']}</b>", nil]
64
+ when 'orderedList' then ['<ol>', '</ol>']
65
+ when 'panel' then ["<div>#{node_attrs['panelType'].upcase}</div>", nil]
66
+ when 'paragraph' then ['<p>', '</p>']
67
+ when 'rule' then ['<hr />', nil]
68
+ when 'status' then [node_attrs['text'], nil]
69
+ when 'table' then ['<table>', '</table>']
70
+ when 'tableCell' then ['<td>', '</td>']
71
+ when 'tableHeader' then ['<th>', '</th>']
72
+ when 'tableRow' then ['<tr>', '</tr>']
73
+ when 'text'
74
+ marks = adf_marks_to_html(n['marks'])
75
+ [marks.collect(&:first).join + n['text'], marks.collect(&:last).join]
76
+ when 'taskItem'
77
+ state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
78
+ ["<li>#{state} ", '</li>']
79
+ when 'taskList' then ["<ul class='taskList'>", '</ul>']
80
+ else
81
+ ["<p>Unparseable section: #{n['type']}</p>", nil]
82
+ end
117
83
  end
84
+ end
118
85
 
119
- node['content']&.each do |child|
120
- result << adf_node_to_html(child)
86
+ def adf_node_to_text node # rubocop:disable Metrics/CyclomaticComplexity
87
+ adf_node_render(node) do |n|
88
+ node_attrs = n['attrs']
89
+ case n['type']
90
+ when 'blockquote' then ['', nil]
91
+ when 'bulletList' then ['', nil]
92
+ when 'codeBlock' then ['', nil]
93
+ when 'date'
94
+ [Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
95
+ when 'decisionItem' then ['- ', "\n"]
96
+ when 'decisionList' then ["Decisions:\n", nil]
97
+ when 'emoji' then [node_attrs['text'], nil]
98
+ when 'expand' then ["#{node_attrs['title']}\n", nil]
99
+ when 'hardBreak' then ["\n", nil]
100
+ when 'heading' then ['', "\n"]
101
+ when 'inlineCard' then [node_attrs['url'], nil]
102
+ when 'listItem' then ['- ', nil]
103
+ when 'media'
104
+ text = node_attrs['alt'] || node_attrs['id']
105
+ ["Media: #{text}", nil]
106
+ when 'mediaSingle', 'mediaGroup' then ['', nil]
107
+ when 'mention' then [node_attrs['text'], nil]
108
+ when 'orderedList' then ['', nil]
109
+ when 'panel' then ["#{node_attrs['panelType'].upcase}\n", nil]
110
+ when 'paragraph' then ['', "\n"]
111
+ when 'rule' then ["---\n", nil]
112
+ when 'status' then [node_attrs['text'], nil]
113
+ when 'table' then ['', nil]
114
+ when 'tableCell' then ['', "\t"]
115
+ when 'tableHeader' then ['', "\t"]
116
+ when 'tableRow' then ['', "\n"]
117
+ when 'text' then [n['text'], nil]
118
+ when 'taskItem'
119
+ state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
120
+ ["#{state} ", "\n"]
121
+ when 'taskList' then ['', nil]
122
+ else
123
+ ["[Unparseable: #{n['type']}]\n", nil]
124
+ end
121
125
  end
122
-
123
- result << closing_tag if closing_tag
124
- result
125
126
  end
126
127
 
127
128
  def adf_marks_to_html list
@@ -157,4 +158,14 @@ class AtlassianDocumentFormat
157
158
  text = "@#{user.display_name}" if user
158
159
  "<span class='account_id'>#{text}</span>"
159
160
  end
161
+
162
+ private
163
+
164
+ def adf_node_render node, &render_node
165
+ prefix, suffix = render_node.call(node)
166
+ result = +(prefix || '')
167
+ node['content']&.each { |child| result << adf_node_render(child, &render_node) }
168
+ result << suffix if suffix
169
+ result
170
+ end
160
171
  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,10 +4,11 @@ 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_raw: nil
8
8
  @raw = raw
9
9
  @possible_statuses = possible_statuses
10
10
  @sprints = []
11
+ @features_raw = features_raw
11
12
 
12
13
  columns = raw['columnConfig']['columns']
13
14
  ensure_uniqueness_of_column_names! columns
@@ -67,8 +68,21 @@ class Board
67
68
  end
68
69
 
69
70
  def board_type = raw['type']
70
- def kanban? = (board_type == 'kanban')
71
- def scrum? = (board_type == 'scrum')
71
+
72
+ def scrum?
73
+ return true if board_type == 'scrum'
74
+ return false unless board_type == 'simple'
75
+
76
+ @features_raw&.[]('features')
77
+ &.any? { |f| f['feature'] == 'jsw.agility.sprints' && f['state'] == 'ENABLED' } || false
78
+ end
79
+
80
+ def kanban?
81
+ return true if board_type == 'kanban'
82
+ return false unless board_type == 'simple'
83
+
84
+ !scrum?
85
+ end
72
86
 
73
87
  def id
74
88
  @raw['id'].to_i
@@ -18,7 +18,7 @@ class ChangeItem
18
18
  @value_id = @raw['to'].split(', ').collect(&:to_i)
19
19
  @old_value_id = (@raw['from'] || '').split(', ').collect(&:to_i)
20
20
  else
21
- @value_id = @raw['to'].to_i
21
+ @value_id = @raw['to']&.to_i
22
22
  @old_value_id = @raw['from']&.to_i
23
23
  end
24
24
  @field_id = @raw['fieldId']
@@ -46,6 +46,7 @@ class ChangeItem
46
46
  def resolution? = (field == 'resolution')
47
47
  def sprint? = (field == 'Sprint')
48
48
  def status? = (field == 'status')
49
+ def fix_version? = (field == 'Fix Version')
49
50
 
50
51
  # An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
51
52
  def to_time = @time
@@ -54,10 +55,10 @@ class ChangeItem
54
55
  message = +''
55
56
  message << "ChangeItem(field: #{field.inspect}"
56
57
  message << ", value: #{value.inspect}"
57
- message << ':' << value_id.inspect if status?
58
+ message << ':' << value_id.inspect if value_id
58
59
  if old_value
59
60
  message << ", old_value: #{old_value.inspect}"
60
- message << ':' << old_value_id.inspect if status?
61
+ message << ':' << old_value_id.inspect if old_value_id
61
62
  end
62
63
  message << ", time: #{time_to_s(@time).inspect}"
63
64
  message << ", field_id: #{@field_id.inspect}" if @field_id
@@ -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
 
@@ -80,10 +80,20 @@ class ChartBase
80
80
  "#{days} day#{'s' unless days == 1}"
81
81
  end
82
82
 
83
+ def label_hours hours
84
+ return 'unknown' if hours.nil?
85
+
86
+ "#{hours} hour#{'s' unless hours == 1}"
87
+ end
88
+
83
89
  def label_issues count
84
90
  "#{count} issue#{'s' unless count == 1}"
85
91
  end
86
92
 
93
+ def to_human_readable number
94
+ number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
95
+ end
96
+
87
97
  def daily_chart_dataset date_issues_list:, color:, label:, positive: true
88
98
  {
89
99
  type: 'bar',
@@ -155,6 +165,56 @@ class ChartBase
155
165
  end.join
156
166
  end
157
167
 
168
+ LABEL_POSITIONS = %w[5% 25% 45% 65%].freeze
169
+
170
+ def date_annotation
171
+ annotations = settings['date_annotations'] || []
172
+ in_range = annotations
173
+ .map { |a| [a, normalize_annotation_datetime(a['date'])] }
174
+ .select { |(_, dt)| date_range.cover?(Date.parse(dt)) }
175
+ .sort_by { |(_, dt)| dt }
176
+
177
+ positions = stagger_label_positions(in_range.map { |(_, dt)| dt })
178
+
179
+ in_range.each_with_index.collect do |(a, normalized), index|
180
+ <<~TEXT
181
+ dateAnnotation#{index}: {
182
+ type: 'line',
183
+ xMin: #{normalized.to_json},
184
+ xMax: #{normalized.to_json},
185
+ borderColor: 'rgba(0,0,0,0.7)',
186
+ borderWidth: 1,
187
+ label: {
188
+ display: true,
189
+ content: #{a['label'].to_json},
190
+ position: #{positions[index].to_json}
191
+ }
192
+ },
193
+ TEXT
194
+ end.join
195
+ end
196
+
197
+ def stagger_label_positions datetimes
198
+ return [] if datetimes.empty?
199
+
200
+ threshold_days = (date_range.end - date_range.begin).to_f / 5.0
201
+ slot = 0
202
+ [LABEL_POSITIONS[0]] + datetimes.each_cons(2).map do |a, b|
203
+ days_apart = (Date.parse(b) - Date.parse(a)).to_f.abs
204
+ slot = days_apart < threshold_days ? slot + 1 : 0
205
+ LABEL_POSITIONS[slot % LABEL_POSITIONS.size]
206
+ end
207
+ end
208
+
209
+ def normalize_annotation_datetime value
210
+ offset = timezone_offset || '+00:00'
211
+ if value.include?('T')
212
+ value.match?(/([+-]\d{2}:\d{2}|Z)$/) ? value : "#{value}#{offset}"
213
+ else
214
+ "#{value}T00:00:00#{offset}"
215
+ end
216
+ end
217
+
158
218
  # Return only the board columns for the current board.
159
219
  def current_board
160
220
  if @board_id.nil?
@@ -310,4 +370,23 @@ class ChartBase
310
370
  def seam_end type = 'chart'
311
371
  "\n<!-- seam-end | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->"
312
372
  end
373
+
374
+ def render_axis_title axis_direction
375
+ text = case axis_direction
376
+ when :x
377
+ x_axis_title
378
+ when :y
379
+ y_axis_title
380
+ else
381
+ raise "Unexpected axis_direction: #{axis_direction}"
382
+ end
383
+ return '' unless text
384
+
385
+ <<~CONTENT
386
+ title: {
387
+ display: true,
388
+ text: "#{text}"
389
+ },
390
+ CONTENT
391
+ end
313
392
  end
@@ -6,10 +6,9 @@ require 'date'
6
6
  class CycleTimeConfig
7
7
  include SelfOrIssueDispatcher
8
8
 
9
- attr_reader :label, :possible_statuses, :settings, :file_system
9
+ attr_reader :label, :settings, :file_system
10
10
 
11
11
  def initialize possible_statuses:, label:, block:, settings:, file_system: nil, today: Date.today
12
-
13
12
  @possible_statuses = possible_statuses
14
13
  @label = label
15
14
  @today = today