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.
- checksums.yaml +7 -0
- data/bin/jirametrics +4 -0
- data/lib/jirametrics/aggregate_config.rb +89 -0
- data/lib/jirametrics/aging_work_bar_chart.rb +235 -0
- data/lib/jirametrics/aging_work_in_progress_chart.rb +148 -0
- data/lib/jirametrics/aging_work_table.rb +149 -0
- data/lib/jirametrics/anonymizer.rb +186 -0
- data/lib/jirametrics/blocked_stalled_change.rb +43 -0
- data/lib/jirametrics/board.rb +85 -0
- data/lib/jirametrics/board_column.rb +14 -0
- data/lib/jirametrics/board_config.rb +31 -0
- data/lib/jirametrics/change_item.rb +80 -0
- data/lib/jirametrics/chart_base.rb +239 -0
- data/lib/jirametrics/columns_config.rb +42 -0
- data/lib/jirametrics/cycletime_config.rb +69 -0
- data/lib/jirametrics/cycletime_histogram.rb +74 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +128 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +88 -0
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +77 -0
- data/lib/jirametrics/daily_wip_chart.rb +123 -0
- data/lib/jirametrics/data_quality_report.rb +278 -0
- data/lib/jirametrics/dependency_chart.rb +217 -0
- data/lib/jirametrics/discard_changes_before.rb +37 -0
- data/lib/jirametrics/download_config.rb +41 -0
- data/lib/jirametrics/downloader.rb +337 -0
- data/lib/jirametrics/examples/aggregated_project.rb +36 -0
- data/lib/jirametrics/examples/standard_project.rb +111 -0
- data/lib/jirametrics/expedited_chart.rb +169 -0
- data/lib/jirametrics/experimental/generator.rb +209 -0
- data/lib/jirametrics/experimental/info.rb +77 -0
- data/lib/jirametrics/exporter.rb +127 -0
- data/lib/jirametrics/file_config.rb +119 -0
- data/lib/jirametrics/fix_version.rb +21 -0
- data/lib/jirametrics/groupable_issue_chart.rb +44 -0
- data/lib/jirametrics/grouping_rules.rb +13 -0
- data/lib/jirametrics/hierarchy_table.rb +31 -0
- data/lib/jirametrics/html/aging_work_bar_chart.erb +72 -0
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +52 -0
- data/lib/jirametrics/html/aging_work_table.erb +60 -0
- data/lib/jirametrics/html/collapsible_issues_panel.erb +32 -0
- data/lib/jirametrics/html/cycletime_histogram.erb +41 -0
- data/lib/jirametrics/html/cycletime_scatterplot.erb +103 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +63 -0
- data/lib/jirametrics/html/data_quality_report.erb +126 -0
- data/lib/jirametrics/html/expedited_chart.erb +67 -0
- data/lib/jirametrics/html/hierarchy_table.erb +29 -0
- data/lib/jirametrics/html/index.erb +66 -0
- data/lib/jirametrics/html/sprint_burndown.erb +116 -0
- data/lib/jirametrics/html/story_point_accuracy_chart.erb +57 -0
- data/lib/jirametrics/html/throughput_chart.erb +65 -0
- data/lib/jirametrics/html_report_config.rb +217 -0
- data/lib/jirametrics/issue.rb +521 -0
- data/lib/jirametrics/issue_link.rb +60 -0
- data/lib/jirametrics/json_file_loader.rb +9 -0
- data/lib/jirametrics/project_config.rb +442 -0
- data/lib/jirametrics/rules.rb +34 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +15 -0
- data/lib/jirametrics/sprint.rb +43 -0
- data/lib/jirametrics/sprint_burndown.rb +335 -0
- data/lib/jirametrics/sprint_issue_change_data.rb +31 -0
- data/lib/jirametrics/status.rb +26 -0
- data/lib/jirametrics/status_collection.rb +67 -0
- data/lib/jirametrics/story_point_accuracy_chart.rb +139 -0
- data/lib/jirametrics/throughput_chart.rb +91 -0
- data/lib/jirametrics/tree_organizer.rb +96 -0
- data/lib/jirametrics/trend_line_calculator.rb +74 -0
- data/lib/jirametrics.rb +85 -0
- 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
|