jirametrics 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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