jirametrics 1.0.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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/bin/jirametrics +4 -0
  3. data/lib/jirametrics/aggregate_config.rb +89 -0
  4. data/lib/jirametrics/aging_work_bar_chart.rb +235 -0
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +148 -0
  6. data/lib/jirametrics/aging_work_table.rb +149 -0
  7. data/lib/jirametrics/anonymizer.rb +186 -0
  8. data/lib/jirametrics/blocked_stalled_change.rb +43 -0
  9. data/lib/jirametrics/board.rb +85 -0
  10. data/lib/jirametrics/board_column.rb +14 -0
  11. data/lib/jirametrics/board_config.rb +31 -0
  12. data/lib/jirametrics/change_item.rb +80 -0
  13. data/lib/jirametrics/chart_base.rb +239 -0
  14. data/lib/jirametrics/columns_config.rb +42 -0
  15. data/lib/jirametrics/cycletime_config.rb +69 -0
  16. data/lib/jirametrics/cycletime_histogram.rb +74 -0
  17. data/lib/jirametrics/cycletime_scatterplot.rb +128 -0
  18. data/lib/jirametrics/daily_wip_by_age_chart.rb +88 -0
  19. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +77 -0
  20. data/lib/jirametrics/daily_wip_chart.rb +123 -0
  21. data/lib/jirametrics/data_quality_report.rb +278 -0
  22. data/lib/jirametrics/dependency_chart.rb +217 -0
  23. data/lib/jirametrics/discard_changes_before.rb +37 -0
  24. data/lib/jirametrics/download_config.rb +41 -0
  25. data/lib/jirametrics/downloader.rb +337 -0
  26. data/lib/jirametrics/examples/aggregated_project.rb +36 -0
  27. data/lib/jirametrics/examples/standard_project.rb +111 -0
  28. data/lib/jirametrics/expedited_chart.rb +169 -0
  29. data/lib/jirametrics/experimental/generator.rb +209 -0
  30. data/lib/jirametrics/experimental/info.rb +77 -0
  31. data/lib/jirametrics/exporter.rb +127 -0
  32. data/lib/jirametrics/file_config.rb +119 -0
  33. data/lib/jirametrics/fix_version.rb +21 -0
  34. data/lib/jirametrics/groupable_issue_chart.rb +44 -0
  35. data/lib/jirametrics/grouping_rules.rb +13 -0
  36. data/lib/jirametrics/hierarchy_table.rb +31 -0
  37. data/lib/jirametrics/html/aging_work_bar_chart.erb +72 -0
  38. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +52 -0
  39. data/lib/jirametrics/html/aging_work_table.erb +60 -0
  40. data/lib/jirametrics/html/collapsible_issues_panel.erb +32 -0
  41. data/lib/jirametrics/html/cycletime_histogram.erb +41 -0
  42. data/lib/jirametrics/html/cycletime_scatterplot.erb +103 -0
  43. data/lib/jirametrics/html/daily_wip_chart.erb +63 -0
  44. data/lib/jirametrics/html/data_quality_report.erb +126 -0
  45. data/lib/jirametrics/html/expedited_chart.erb +67 -0
  46. data/lib/jirametrics/html/hierarchy_table.erb +29 -0
  47. data/lib/jirametrics/html/index.erb +66 -0
  48. data/lib/jirametrics/html/sprint_burndown.erb +116 -0
  49. data/lib/jirametrics/html/story_point_accuracy_chart.erb +57 -0
  50. data/lib/jirametrics/html/throughput_chart.erb +65 -0
  51. data/lib/jirametrics/html_report_config.rb +217 -0
  52. data/lib/jirametrics/issue.rb +521 -0
  53. data/lib/jirametrics/issue_link.rb +60 -0
  54. data/lib/jirametrics/json_file_loader.rb +9 -0
  55. data/lib/jirametrics/project_config.rb +442 -0
  56. data/lib/jirametrics/rules.rb +34 -0
  57. data/lib/jirametrics/self_or_issue_dispatcher.rb +15 -0
  58. data/lib/jirametrics/sprint.rb +43 -0
  59. data/lib/jirametrics/sprint_burndown.rb +335 -0
  60. data/lib/jirametrics/sprint_issue_change_data.rb +31 -0
  61. data/lib/jirametrics/status.rb +26 -0
  62. data/lib/jirametrics/status_collection.rb +67 -0
  63. data/lib/jirametrics/story_point_accuracy_chart.rb +139 -0
  64. data/lib/jirametrics/throughput_chart.rb +91 -0
  65. data/lib/jirametrics/tree_organizer.rb +96 -0
  66. data/lib/jirametrics/trend_line_calculator.rb +74 -0
  67. data/lib/jirametrics.rb +85 -0
  68. metadata +167 -0
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'random-word'
4
+
5
+ class Anonymizer
6
+ # needed for testing
7
+ attr_reader :project_config, :issues
8
+
9
+ def initialize project_config:, date_adjustment: -200
10
+ @project_config = project_config
11
+ @issues = @project_config.issues
12
+ @all_boards = @project_config.all_boards
13
+ @possible_statuses = @project_config.possible_statuses
14
+ @date_adjustment = date_adjustment
15
+ end
16
+
17
+ def run
18
+ anonymize_issue_keys_and_titles
19
+ anonymize_column_names
20
+ # anonymize_issue_statuses
21
+ anonymize_board_names
22
+ shift_all_dates unless @date_adjustment.zero?
23
+ puts 'Anonymize done'
24
+ end
25
+
26
+ def random_phrase
27
+ # RandomWord periodically blows up for no reason we can determine. If it throws an exception then
28
+ # just try again. In every case we've seen, it's worked on the second attempt, but we'll be
29
+ # cautious and try five times.
30
+ 5.times do |i|
31
+ return RandomWord.phrases.next.gsub(/_/, ' ')
32
+ rescue # rubocop:disable Style/RescueStandardError We don't care what exception was thrown.
33
+ puts "Random word blew up on attempt #{i + 1}"
34
+ end
35
+ end
36
+
37
+ def anonymize_issue_keys_and_titles issues: @issues
38
+ counter = 0
39
+ issues.each do |issue|
40
+ new_key = "ANON-#{counter += 1}"
41
+
42
+ issue.raw['key'] = new_key
43
+ issue.raw['fields']['summary'] = random_phrase
44
+ issue.raw['fields']['assignee']['displayName'] = random_name unless issue.raw['fields']['assignee'].nil?
45
+
46
+ issue.issue_links.each do |link|
47
+ other_issue = link.other_issue
48
+ next if other_issue.key =~ /^ANON-\d+$/ # Already anonymized?
49
+
50
+ other_issue.raw['key'] = "ANON-#{counter += 1}"
51
+ other_issue.raw['fields']['summary'] = random_phrase
52
+ end
53
+ end
54
+ end
55
+
56
+ def anonymize_column_names
57
+ @all_boards.each_key do |board_id|
58
+ puts "Anonymizing column names for board #{board_id}"
59
+
60
+ column_name = 'Column-A'
61
+ @all_boards[board_id].visible_columns.each do |column|
62
+ column.name = column_name
63
+ column_name = column_name.next
64
+ end
65
+ end
66
+ end
67
+
68
+ def build_status_name_hash
69
+ next_status = 'a'
70
+ status_name_hash = {}
71
+ @issues.each do |issue|
72
+ issue.changes.each do |change|
73
+ next unless change.status?
74
+
75
+ # TODO: Do old value too
76
+ status_key = change.value
77
+ if status_name_hash[status_key].nil?
78
+ status_name_hash[status_key] = "status-#{next_status}"
79
+ next_status = next_status.next
80
+ end
81
+ end
82
+ end
83
+
84
+ @possible_statuses.each do |status|
85
+ status_key = status.name
86
+ if status_name_hash[status_key].nil?
87
+ status_name_hash[status_key] = "status-#{next_status}"
88
+ next_status = next_status.next
89
+ end
90
+ end
91
+
92
+ status_name_hash
93
+ end
94
+
95
+ def anonymize_issue_statuses
96
+ puts 'Anonymizing issue statuses and status categories'
97
+ status_name_hash = build_status_name_hash
98
+
99
+ @issues.each do |issue|
100
+ # This is where we create URL's from
101
+ issue.raw['self'] = nil
102
+
103
+ issue.changes.each do |change|
104
+ next unless change.status?
105
+
106
+ status_key = change.value
107
+ anonymized_value = status_name_hash[status_key]
108
+ raise "status_name_hash[#{status_key.inspect} is nil" if anonymized_value.nil?
109
+
110
+ change.value = anonymized_value
111
+
112
+ next if change.old_value.nil?
113
+
114
+ status_key = change.old_value
115
+ anonymized_value = status_name_hash[status_key]
116
+ raise "status_name_hash[#{status_key.inspect} is nil" if anonymized_value.nil?
117
+
118
+ change.old_value = anonymized_value
119
+ end
120
+ end
121
+
122
+ @possible_statuses.each do |status|
123
+ status_key = status.name
124
+ if status_name_hash[status_key].nil?
125
+ raise "Can't find status_key #{status_key.inspect} in #{status_name_hash.inspect}"
126
+ end
127
+
128
+ status.name = status_name_hash[status_key] unless status_name_hash[status_key].nil?
129
+ end
130
+ end
131
+
132
+ def shift_all_dates
133
+ puts "Shifting all dates by #{@date_adjustment} days"
134
+ @issues.each do |issue|
135
+ issue.changes.each do |change|
136
+ change.time = change.time + @date_adjustment
137
+ end
138
+
139
+ issue.raw['fields']['updated'] = (issue.updated + @date_adjustment).to_s
140
+ end
141
+
142
+ range = @project_config.time_range
143
+ @project_config.time_range = (range.begin + @date_adjustment)..(range.end + @date_adjustment)
144
+ end
145
+
146
+ def random_name
147
+ # Names generated from https://www.random-name-generator.com
148
+ [
149
+ 'Benjamin Pelletier',
150
+ 'Levi Scott',
151
+ 'Emilia Leblanc',
152
+ 'Victoria Singh',
153
+ 'Theodore King',
154
+ 'Amelia Kelly',
155
+ 'Samuel Jones',
156
+ 'Lucy Kelly',
157
+ 'Oliver Fortin',
158
+ 'Riley Murphy',
159
+ 'Elijah Stewart',
160
+ 'Elizabeth Murphy',
161
+ 'Declan Simard',
162
+ 'Myles Singh',
163
+ 'Jayden Smith',
164
+ 'Sophie Richard',
165
+ 'Levi Mitchell',
166
+ 'Alexander Davis',
167
+ 'Sebastian Thompson',
168
+ 'Logan Robinson',
169
+ 'Madison Girard',
170
+ 'Ellie King',
171
+ 'Aiden Miller',
172
+ 'Ethan Anderson',
173
+ 'Scarlett Murray',
174
+ 'Audrey Moore',
175
+ 'Emmett Reid',
176
+ 'Jacob Poirier',
177
+ 'Violet MacDonald'
178
+ ].sample
179
+ end
180
+
181
+ def anonymize_board_names
182
+ @all_boards.values.each do |board|
183
+ board.raw['name'] = "#{random_phrase} board"
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BlockedStalledChange
4
+ attr_reader :time, :blocking_issue_keys, :flag, :status, :stalled_days, :status_is_blocking
5
+
6
+ def initialize time:, flagged: nil, status: nil, status_is_blocking: true, blocking_issue_keys: nil, stalled_days: nil
7
+ @flag = flagged
8
+ @status = status
9
+ @status_is_blocking = status_is_blocking
10
+ @blocking_issue_keys = blocking_issue_keys
11
+ @stalled_days = stalled_days
12
+ @time = time
13
+ end
14
+
15
+ def blocked? = @flag || blocked_by_status? || @blocking_issue_keys
16
+ def stalled? = @stalled_days || stalled_by_status?
17
+ def active? = !blocked? && !stalled?
18
+
19
+ def blocked_by_status? = (@status && @status_is_blocking)
20
+ def stalled_by_status? = (@status && !@status_is_blocking)
21
+
22
+ def ==(other)
23
+ (other.class == self.class) && (other.state == state)
24
+ end
25
+
26
+ def state
27
+ instance_variables.map { |variable| instance_variable_get variable }
28
+ end
29
+
30
+ def reasons
31
+ result = []
32
+ if blocked?
33
+ result << 'Blocked by flag' if @flag
34
+ result << "Blocked by status: #{@status}" if blocked_by_status?
35
+ result << "Blocked by issues: #{@blocking_issue_keys.join(', ')}" if @blocking_issue_keys
36
+ elsif stalled_by_status?
37
+ result << "Stalled by status: #{@status}"
38
+ elsif @stalled_days
39
+ result << "Stalled by inactivity: #{@stalled_days} days"
40
+ end
41
+ result.join(', ')
42
+ end
43
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Board
4
+ attr_reader :visible_columns, :raw, :possible_statuses, :sprints, :backlog_statuses
5
+ attr_accessor :cycletime, :project_config, :expedited_priority_names
6
+
7
+ def initialize raw:, possible_statuses: StatusCollection.new
8
+ @raw = raw
9
+ @board_type = raw['type']
10
+ @possible_statuses = possible_statuses
11
+ @sprints = []
12
+ @expedited_priority_names = []
13
+
14
+ columns = raw['columnConfig']['columns']
15
+
16
+ # For a Kanban board, the first column here will always be called 'Backlog' and will NOT be
17
+ # visible on the board. If the board is configured to have a kanban backlog then it will have
18
+ # statuses matched to it and otherwise, there will be no statuses.
19
+ if kanban?
20
+ assert_jira_behaviour_true(columns[0]['name'] == 'Backlog') do
21
+ "Expected first column to be called Backlog: #{raw}"
22
+ end
23
+
24
+ @backlog_statuses = @possible_statuses.expand_statuses(status_ids_from_column columns[0]) do |unknown_status|
25
+ # Yet another "theoretically impossible and yet we've seen it in production" moment
26
+ puts "Status #{unknown_status.inspect} is defined as being in the backlog for board #{name.inspect}:#{id} " \
27
+ 'and yet it\'s not defined in the list of possible statuses available to the project. Check your Jira ' \
28
+ 'configuration'
29
+ end
30
+ columns = columns[1..]
31
+ else
32
+ # We currently don't know how to get the backlog status for a Scrum board
33
+ @backlog_statuses = []
34
+ end
35
+
36
+ @visible_columns = columns.collect do |column|
37
+ # It's possible for a column to be defined without any statuses and in this case, it won't be visible.
38
+ BoardColumn.new column unless status_ids_from_column(column).empty?
39
+ end.compact
40
+ end
41
+
42
+ def url
43
+ # Strangely, the URL isn't anywhere in the returned data so we have to fabricate it.
44
+ raise "Cannot parse self: #{@raw['self']}" unless @raw['self'] =~ /^(https?:\/\/[^\/]+)\//
45
+
46
+ "#{$1}/secure/RapidBoard.jspa?rapidView=#{id}"
47
+ end
48
+
49
+ def status_ids_from_column column
50
+ column['statuses'].collect { |status| status['id'].to_i }
51
+ end
52
+
53
+ def status_ids_in_or_right_of_column column_name
54
+ status_ids = []
55
+ found_it = false
56
+
57
+ @visible_columns.each do |column|
58
+ # Check both the current name and also the original raw name in case anonymization has happened.
59
+ found_it = true if column.name == column_name || column.raw['name'] == column_name
60
+ status_ids += column.status_ids if found_it
61
+ end
62
+
63
+ unless found_it
64
+ column_names = @visible_columns.collect(&:name).collect(&:inspect).join(', ')
65
+ raise "No visible column with name: #{column_name.inspect} Possible options are: #{column_names}"
66
+ end
67
+ status_ids
68
+ end
69
+
70
+ def kanban?
71
+ @board_type == 'kanban'
72
+ end
73
+
74
+ def scrum?
75
+ @board_type == 'scrum'
76
+ end
77
+
78
+ def id
79
+ @raw['id'].to_i
80
+ end
81
+
82
+ def name
83
+ @raw['name']
84
+ end
85
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BoardColumn
4
+ attr_reader :status_ids, :min, :max, :raw
5
+ attr_accessor :name
6
+
7
+ def initialize raw
8
+ @raw = raw
9
+ @name = raw['name']
10
+ @status_ids = raw['statuses'].collect { |status| status['id'].to_i }
11
+ @min = raw['min']&.to_i
12
+ @max = raw['max']&.to_i
13
+ end
14
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BoardConfig
4
+ attr_reader :id, :project_config
5
+
6
+ def initialize id:, block:, project_config:
7
+ @id = id
8
+ @block = block
9
+ @project_config = project_config
10
+ end
11
+
12
+ def run
13
+ @board = @project_config.all_boards[id]
14
+ @board.expedited_priority_names = []
15
+
16
+ instance_eval(&@block)
17
+ end
18
+
19
+ def cycletime label = nil, &block
20
+ if @board.cycletime
21
+ raise "Cycletime has already been set for board #{id}. Did you also set it inside the html_report? " \
22
+ 'If so, remove it from there.'
23
+ end
24
+
25
+ @board.cycletime = CycleTimeConfig.new(parent_config: self, label: label, block: block)
26
+ end
27
+
28
+ def expedited_priority_names *priority_names
29
+ @board.expedited_priority_names = priority_names unless priority_names.empty?
30
+ end
31
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ChangeItem
4
+ attr_reader :field, :value_id, :old_value_id, :raw, :author
5
+ attr_accessor :value, :old_value, :time
6
+
7
+ def initialize raw:, time:, author:, artificial: false
8
+ # raw will only ever be nil in a test and in that case field and value should be passed in
9
+ @raw = raw
10
+ @time = time
11
+ raise "Time must be an object of type Time in the correct timezone: #{@time}" if @time.is_a? String
12
+
13
+ @field = field || @raw['field']
14
+ @value = value || @raw['toString']
15
+ @value_id = @raw['to'].to_i
16
+ @old_value = @raw['fromString']
17
+ @old_value_id = @raw['from']&.to_i
18
+ @artificial = artificial
19
+ @author = author
20
+ end
21
+
22
+ def status? = (field == 'status')
23
+
24
+ def flagged? = (field == 'Flagged')
25
+
26
+ def priority? = (field == 'priority')
27
+
28
+ def resolution? = (field == 'resolution')
29
+
30
+ def artificial? = @artificial
31
+
32
+ def sprint? = (field == 'Sprint')
33
+
34
+ def story_points? = (field == 'Story Points')
35
+
36
+ def link? = (field == 'Link')
37
+
38
+ def to_s
39
+ message = "ChangeItem(field: #{field.inspect}, value: #{value.inspect}, time: \"#{@time}\""
40
+ message += ', artificial' if artificial?
41
+ message += ')'
42
+ message
43
+ end
44
+
45
+ def inspect = to_s
46
+
47
+ def == other
48
+ field.eql?(other.field) && value.eql?(other.value) && time.to_s.eql?(other.time.to_s)
49
+ end
50
+
51
+ def current_status_matches *status_names_or_ids
52
+ return false unless status?
53
+
54
+ status_names_or_ids.any? do |name_or_id|
55
+ case name_or_id
56
+ when Status
57
+ name_or_id.id == @value_id
58
+ when String
59
+ name_or_id == @value
60
+ else
61
+ name_or_id == @value_id
62
+ end
63
+ end
64
+ end
65
+
66
+ def old_status_matches *status_names_or_ids
67
+ return false unless status?
68
+
69
+ status_names_or_ids.any? do |name_or_id|
70
+ case name_or_id
71
+ when Status
72
+ name_or_id.id == @old_value_id
73
+ when String
74
+ name_or_id == @old_value
75
+ else
76
+ name_or_id == @old_value_id
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ChartBase
4
+ attr_accessor :timezone_offset, :board_id, :all_boards, :date_range,
5
+ :time_range, :data_quality, :holiday_dates, :settings
6
+ attr_writer :aggregated_project
7
+ attr_reader :issues, :canvas_width, :canvas_height
8
+
9
+ @@chart_counter = 0
10
+
11
+ def initialize
12
+ @chart_colors = {
13
+ 'dark:Story' => 'green',
14
+ 'dark:Task' => 'blue',
15
+ 'dark:Bug' => 'orange',
16
+ 'dark:Defect' => 'orange',
17
+ 'dark:Spike' => '#9400D3', # dark purple
18
+ 'light:Story' => '#90EE90',
19
+ 'light:Task' => '#87CEFA',
20
+ 'light:Bug' => '#ffdab9',
21
+ 'light:Defect' => 'orange',
22
+ 'light:Epic' => '#fafad2',
23
+ 'light:Spike' => '#DDA0DD' # light purple
24
+ }
25
+ @canvas_width = 800
26
+ @canvas_height = 200
27
+ @canvas_responsive = true
28
+ end
29
+
30
+ def aggregated_project?
31
+ @aggregated_project
32
+ end
33
+
34
+ def render caller_binding, file
35
+ pathname = Pathname.new(File.realpath(file))
36
+ basename = pathname.basename.to_s
37
+ raise "Unexpected filename #{basename.inspect}" unless basename =~ /^(.+)\.rb$/
38
+
39
+ # Insert a incrementing chart_id so that all the chart names on the page are unique
40
+ caller_binding.eval "chart_id='chart#{next_id}'" # chart_id=chart3
41
+
42
+ @html_directory = "#{pathname.dirname}/html"
43
+ erb = ERB.new File.read "#{@html_directory}/#{$1}.erb"
44
+ erb.result(caller_binding)
45
+ end
46
+
47
+ # Render the file and then wrap it with standard headers and quality checks.
48
+ def wrap_and_render caller_binding, file
49
+ result = String.new
50
+ result << "<h1>#{@header_text}</h1>" if @header_text
51
+ result << ERB.new(@description_text).result(caller_binding) if @description_text
52
+ result << render(caller_binding, file)
53
+ result
54
+ end
55
+
56
+ def next_id
57
+ @@chart_counter += 1
58
+ end
59
+
60
+ def color_for type:, shade: :dark
61
+ @chart_colors["#{shade}:#{type}"] ||= random_color
62
+ end
63
+
64
+ def label_days days
65
+ "#{days} day#{'s' unless days == 1}"
66
+ end
67
+
68
+ def label_issues count
69
+ "#{count} issue#{'s' unless count == 1}"
70
+ end
71
+
72
+ def daily_chart_dataset date_issues_list:, color:, label:, positive: true
73
+ {
74
+ type: 'bar',
75
+ label: label,
76
+ data: date_issues_list.collect do |date, issues|
77
+ issues.sort! { |a, b| a.key_as_i <=> b.key_as_i }
78
+ title = "#{label} (#{label_issues issues.size})"
79
+ {
80
+ x: date,
81
+ y: positive ? issues.size : -issues.size,
82
+ title: [title] + issues.collect { |i| "#{i.key} : #{i.summary.strip}#{" #{yield date, i}" if block_given?}" }
83
+ }
84
+ end,
85
+ backgroundColor: color,
86
+ borderRadius: positive ? 0 : 5
87
+ }
88
+ end
89
+
90
+ def link_to_issue issue, args = {}
91
+ attributes = { class: 'issue_key' }
92
+ .merge(args)
93
+ .collect { |key, value| "#{key}='#{value}'" }
94
+ .join(' ')
95
+ "<a href='#{issue.url}' #{attributes}>#{issue.key}</a>"
96
+ end
97
+
98
+ def collapsible_issues_panel issue_descriptions, *args
99
+ link_id = next_id
100
+ issues_id = next_id
101
+
102
+ issue_descriptions.sort! { |a, b| a[0].key_as_i <=> b[0].key_as_i }
103
+ erb = ERB.new File.read "#{@html_directory}/collapsible_issues_panel.erb"
104
+ erb.result(binding)
105
+ end
106
+
107
+ def holidays date_range: @date_range
108
+ result = []
109
+ start_date = nil
110
+ end_date = nil
111
+
112
+ date_range.each do |date|
113
+ if date.saturday? || date.sunday? || holiday_dates.include?(date)
114
+ if start_date.nil?
115
+ start_date = date
116
+ else
117
+ end_date = date
118
+ end
119
+ elsif start_date
120
+ result << (start_date..(end_date || start_date))
121
+ start_date = nil
122
+ end_date = nil
123
+ end
124
+ end
125
+ result
126
+ end
127
+
128
+ # Return only the board columns for the current board.
129
+ def current_board
130
+ if @board_id.nil?
131
+ case @all_boards.size
132
+ when 0
133
+ raise 'Couldn\'t find any board configurations. Ensure one is set'
134
+ when 1
135
+ return @all_boards.values[0]
136
+ else
137
+ raise "Must set board_id so we know which to use. Multiple boards found: #{@all_boards.keys.inspect}"
138
+ end
139
+ end
140
+
141
+ @all_boards[@board_id]
142
+ end
143
+
144
+ def completed_issues_in_range include_unstarted: false
145
+ issues.select do |issue|
146
+ cycletime = issue.board.cycletime
147
+ stopped_time = cycletime.stopped_time(issue)
148
+ started_time = cycletime.started_time(issue)
149
+
150
+ stopped_time &&
151
+ date_range.include?(stopped_time.to_date) && # Remove outside range
152
+ (include_unstarted || (started_time && (stopped_time >= started_time)))
153
+ end
154
+ end
155
+
156
+ def sprints_in_time_range board
157
+ board.sprints.select do |sprint|
158
+ sprint_end_time = sprint.completed_time || sprint.end_time
159
+ sprint_start_time = sprint.start_time
160
+ next false if sprint_start_time.nil?
161
+
162
+ time_range.include?(sprint_start_time) || time_range.include?(sprint_end_time) ||
163
+ (sprint_start_time < time_range.begin && sprint_end_time > time_range.end)
164
+ end || []
165
+ end
166
+
167
+ def chart_format object
168
+ if object.is_a? Time
169
+ # "2022-04-09T11:38:30-07:00"
170
+ object.strftime '%Y-%m-%dT%H:%M:%S%z'
171
+ else
172
+ object.to_s
173
+ end
174
+ end
175
+
176
+ def header_text text
177
+ @header_text = text
178
+ end
179
+
180
+ def description_text text
181
+ @description_text = text
182
+ end
183
+
184
+ def format_integer number
185
+ number.to_s.reverse.scan(/.{1,3}/).join(',').reverse
186
+ end
187
+
188
+ def format_status name_or_id, board:, is_category: false
189
+ begin
190
+ statuses = board.possible_statuses.expand_statuses([name_or_id])
191
+ rescue RuntimeError => e
192
+ return "<span style='color: red'>#{name_or_id}</span>" if e.message =~ /^Status not found:/
193
+
194
+ throw e
195
+ end
196
+ raise "Expected exactly one match and got #{statuses.inspect} for #{name_or_id.inspect}" if statuses.size > 1
197
+
198
+ status = statuses.first
199
+ color = status_category_color status
200
+
201
+ text = is_category ? status.category_name : status.name
202
+ "<span style='color: #{color}'>#{text}</span>"
203
+ end
204
+
205
+ def status_category_color status
206
+ case status.category_name
207
+ when nil then 'black'
208
+ when 'To Do' then 'gray'
209
+ when 'In Progress' then 'blue'
210
+ when 'Done' then 'green'
211
+ end
212
+ end
213
+
214
+ def random_color
215
+ "\##{Random.bytes(3).unpack1('H*')}"
216
+ end
217
+
218
+ def canvas width:, height:, responsive: true
219
+ @canvas_width = width
220
+ @canvas_height = height
221
+ @canvas_responsive = responsive
222
+ end
223
+
224
+ def canvas_responsive?
225
+ @canvas_responsive
226
+ end
227
+
228
+ def filter_issues &block
229
+ @filter_issues_block = block
230
+ end
231
+
232
+ def issues= issues
233
+ @issues = issues
234
+ return unless @filter_issues_block
235
+
236
+ @issues = issues.collect { |i| @filter_issues_block.call(i) }.compact.uniq
237
+ puts @issues.collect(&:key).join(', ')
238
+ end
239
+ end