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