jirametrics 2.13 → 2.20.1
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 +4 -4
- data/lib/jirametrics/anonymizer.rb +8 -6
- data/lib/jirametrics/atlassian_document_format.rb +8 -4
- data/lib/jirametrics/board_config.rb +3 -1
- data/lib/jirametrics/change_item.rb +2 -2
- data/lib/jirametrics/chart_base.rb +5 -2
- data/lib/jirametrics/cycletime_config.rb +22 -3
- data/lib/jirametrics/cycletime_histogram.rb +3 -1
- data/lib/jirametrics/daily_view.rb +49 -42
- data/lib/jirametrics/data_quality_report.rb +6 -3
- data/lib/jirametrics/dependency_chart.rb +4 -1
- data/lib/jirametrics/downloader.rb +34 -99
- data/lib/jirametrics/downloader_for_cloud.rb +202 -0
- data/lib/jirametrics/downloader_for_data_center.rb +94 -0
- data/lib/jirametrics/examples/standard_project.rb +9 -9
- data/lib/jirametrics/expedited_chart.rb +1 -1
- data/lib/jirametrics/exporter.rb +10 -5
- data/lib/jirametrics/file_system.rb +24 -1
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +1 -1
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cycletime_histogram.erb +2 -2
- data/lib/jirametrics/html/index.css +5 -10
- data/lib/jirametrics/html/index.erb +2 -34
- data/lib/jirametrics/html/index.js +90 -0
- data/lib/jirametrics/html/sprint_burndown.erb +5 -3
- data/lib/jirametrics/html_report_config.rb +5 -3
- data/lib/jirametrics/issue.rb +31 -19
- data/lib/jirametrics/jira_gateway.rb +55 -17
- data/lib/jirametrics/project_config.rb +30 -3
- data/lib/jirametrics/settings.json +3 -1
- data/lib/jirametrics.rb +19 -70
- metadata +6 -3
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class DownloaderForCloud < Downloader
|
|
4
|
+
def jira_instance_type
|
|
5
|
+
'Jira Cloud'
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def search_for_issues jql:, board_id:, path:
|
|
9
|
+
log " JQL: #{jql}"
|
|
10
|
+
escaped_jql = CGI.escape jql
|
|
11
|
+
|
|
12
|
+
hash = {}
|
|
13
|
+
max_results = 5_000 # The maximum allowed by Jira
|
|
14
|
+
next_page_token = nil
|
|
15
|
+
issue_count = 0
|
|
16
|
+
|
|
17
|
+
loop do
|
|
18
|
+
relative_url = +''
|
|
19
|
+
relative_url << '/rest/api/3/search/jql'
|
|
20
|
+
relative_url << "?jql=#{escaped_jql}&maxResults=#{max_results}"
|
|
21
|
+
relative_url << "&nextPageToken=#{next_page_token}" if next_page_token
|
|
22
|
+
relative_url << '&fields=updated'
|
|
23
|
+
|
|
24
|
+
json = @jira_gateway.call_url relative_url: relative_url
|
|
25
|
+
next_page_token = json['nextPageToken']
|
|
26
|
+
|
|
27
|
+
json['issues'].each do |i|
|
|
28
|
+
key = i['key']
|
|
29
|
+
data = DownloadIssueData.new key: key
|
|
30
|
+
data.key = key
|
|
31
|
+
data.last_modified = Time.parse i['fields']['updated']
|
|
32
|
+
data.found_in_primary_query = true
|
|
33
|
+
data.cache_path = File.join(path, "#{key}-#{board_id}.json")
|
|
34
|
+
data.up_to_date = last_modified(filename: data.cache_path) == data.last_modified
|
|
35
|
+
hash[key] = data
|
|
36
|
+
issue_count += 1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
message = " Found #{issue_count} issues"
|
|
40
|
+
log message, both: true
|
|
41
|
+
|
|
42
|
+
break unless next_page_token
|
|
43
|
+
end
|
|
44
|
+
hash
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def bulk_fetch_issues issue_datas:, board:, in_initial_query:
|
|
48
|
+
# We used to use the expand option to pull in the changelog directly. Unfortunately
|
|
49
|
+
# that only returns the "recent" changes, not all of them. So now we get the issue
|
|
50
|
+
# without changes and then make a second call for that changes. Then we insert it
|
|
51
|
+
# into the raw issue as if it had been there all along.
|
|
52
|
+
log " Downloading #{issue_datas.size} issues", both: true
|
|
53
|
+
payload = {
|
|
54
|
+
'fields' => ['*all'],
|
|
55
|
+
'issueIdsOrKeys' => issue_datas.collect(&:key)
|
|
56
|
+
}
|
|
57
|
+
response = @jira_gateway.post_request(
|
|
58
|
+
relative_url: '/rest/api/3/issue/bulkfetch',
|
|
59
|
+
payload: JSON.generate(payload)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
attach_changelog_to_issues issue_datas: issue_datas, issue_jsons: response['issues']
|
|
63
|
+
|
|
64
|
+
response['issues'].each do |issue_json|
|
|
65
|
+
issue_json['exporter'] = {
|
|
66
|
+
'in_initial_query' => in_initial_query
|
|
67
|
+
}
|
|
68
|
+
issue = Issue.new(raw: issue_json, board: board)
|
|
69
|
+
data = issue_datas.find { |d| d.key == issue.key }
|
|
70
|
+
data.up_to_date = true
|
|
71
|
+
data.last_modified = issue.updated
|
|
72
|
+
data.issue = issue
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
issue_datas
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def attach_changelog_to_issues issue_datas:, issue_jsons:
|
|
79
|
+
max_results = 10_000 # The max jira accepts is 10K
|
|
80
|
+
payload = {
|
|
81
|
+
'issueIdsOrKeys' => issue_datas.collect(&:key),
|
|
82
|
+
'maxResults' => max_results
|
|
83
|
+
}
|
|
84
|
+
loop do
|
|
85
|
+
response = @jira_gateway.post_request(
|
|
86
|
+
relative_url: '/rest/api/3/changelog/bulkfetch',
|
|
87
|
+
payload: JSON.generate(payload)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
response['issueChangeLogs'].each do |issue_change_log|
|
|
91
|
+
issue_id = issue_change_log['issueId']
|
|
92
|
+
json = issue_jsons.find { |json| json['id'] == issue_id }
|
|
93
|
+
|
|
94
|
+
unless json['changelog']
|
|
95
|
+
# If this is our first time in, there won't be a changelog section
|
|
96
|
+
json['changelog'] = {
|
|
97
|
+
'startAt' => 0,
|
|
98
|
+
'maxResults' => max_results,
|
|
99
|
+
'total' => 0,
|
|
100
|
+
'histories' => []
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
new_changes = issue_change_log['changeHistories']
|
|
105
|
+
json['changelog']['total'] += new_changes.size
|
|
106
|
+
json['changelog']['histories'] += new_changes
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
next_page_token = response['nextPageToken']
|
|
110
|
+
payload['nextPageToken'] = next_page_token
|
|
111
|
+
break if next_page_token.nil?
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def download_issues board:
|
|
116
|
+
log " Downloading primary issues for board #{board.id} from #{jira_instance_type}", both: true
|
|
117
|
+
path = File.join(@target_path, "#{file_prefix}_issues/")
|
|
118
|
+
unless @file_system.dir_exist?(path)
|
|
119
|
+
log " Creating path #{path}"
|
|
120
|
+
@file_system.mkdir(path)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
filter_id = @board_id_to_filter_id[board.id]
|
|
124
|
+
jql = make_jql(filter_id: filter_id)
|
|
125
|
+
intercept_jql = @download_config.project_config.settings['intercept_jql']
|
|
126
|
+
jql = intercept_jql.call jql if intercept_jql
|
|
127
|
+
|
|
128
|
+
issue_data_hash = search_for_issues jql: jql, board_id: board.id, path: path
|
|
129
|
+
|
|
130
|
+
loop do
|
|
131
|
+
related_issue_keys = Set.new
|
|
132
|
+
issue_data_hash
|
|
133
|
+
.values
|
|
134
|
+
.reject { |data| data.up_to_date }
|
|
135
|
+
.each_slice(100) do |slice|
|
|
136
|
+
slice = bulk_fetch_issues(
|
|
137
|
+
issue_datas: slice, board: board, in_initial_query: true
|
|
138
|
+
)
|
|
139
|
+
slice.each do |data|
|
|
140
|
+
@file_system.save_json(
|
|
141
|
+
json: data.issue.raw, filename: data.cache_path
|
|
142
|
+
)
|
|
143
|
+
# Set the timestamp on the file to match the updated one so that we don't have
|
|
144
|
+
# to parse the file just to find the timestamp
|
|
145
|
+
@file_system.utime time: data.issue.updated, file: data.cache_path
|
|
146
|
+
|
|
147
|
+
issue = data.issue
|
|
148
|
+
next unless issue
|
|
149
|
+
|
|
150
|
+
parent_key = issue.parent_key(project_config: @download_config.project_config)
|
|
151
|
+
related_issue_keys << parent_key if parent_key
|
|
152
|
+
|
|
153
|
+
# Sub-tasks
|
|
154
|
+
issue.raw['fields']['subtasks']&.each do |raw_subtask|
|
|
155
|
+
related_issue_keys << raw_subtask['key']
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Remove all the ones we already downloaded
|
|
161
|
+
related_issue_keys.reject! { |key| issue_data_hash[key] }
|
|
162
|
+
|
|
163
|
+
related_issue_keys.each do |key|
|
|
164
|
+
data = DownloadIssueData.new key: key
|
|
165
|
+
data.found_in_primary_query = false
|
|
166
|
+
data.up_to_date = false
|
|
167
|
+
data.cache_path = File.join(path, "#{key}-#{board.id}.json")
|
|
168
|
+
issue_data_hash[key] = data
|
|
169
|
+
end
|
|
170
|
+
break if related_issue_keys.empty?
|
|
171
|
+
|
|
172
|
+
log " Downloading linked issues for board #{board.id}", both: true
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
delete_issues_from_cache_that_are_not_in_server(
|
|
176
|
+
issue_data_hash: issue_data_hash, path: path
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def delete_issues_from_cache_that_are_not_in_server issue_data_hash:, path:
|
|
181
|
+
# The gotcha with deleted issues is that they just stop being returned in queries
|
|
182
|
+
# and we have no way to know that they should be removed from our local cache.
|
|
183
|
+
# With the new approach, we ask for every issue that Jira knows about (within
|
|
184
|
+
# the parameters of the query) and then delete anything that's in our local cache
|
|
185
|
+
# but wasn't returned.
|
|
186
|
+
@file_system.foreach path do |file|
|
|
187
|
+
next if file.start_with? '.'
|
|
188
|
+
unless /^(?<key>\w+-\d+)-\d+\.json$/ =~ file
|
|
189
|
+
raise "Unexpected filename in #{path}: #{file}"
|
|
190
|
+
end
|
|
191
|
+
next if issue_data_hash[key] # Still in Jira
|
|
192
|
+
|
|
193
|
+
file_to_delete = File.join(path, file)
|
|
194
|
+
log " Removing #{file_to_delete} from local cache"
|
|
195
|
+
file_system.unlink file_to_delete
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def last_modified filename:
|
|
200
|
+
File.mtime(filename) if File.exist?(filename)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class DownloaderForDataCenter < Downloader
|
|
4
|
+
def jira_instance_type
|
|
5
|
+
'Jira DataCenter'
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def download_issues board:
|
|
9
|
+
log " Downloading primary issues for board #{board.id}", both: true
|
|
10
|
+
path = File.join(@target_path, "#{file_prefix}_issues/")
|
|
11
|
+
unless Dir.exist?(path)
|
|
12
|
+
log " Creating path #{path}"
|
|
13
|
+
Dir.mkdir(path)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
filter_id = board_id_to_filter_id[board.id]
|
|
17
|
+
jql = make_jql(filter_id: filter_id)
|
|
18
|
+
jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
|
|
19
|
+
|
|
20
|
+
log " Downloading linked issues for board #{board.id}", both: true
|
|
21
|
+
loop do
|
|
22
|
+
@issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
|
|
23
|
+
break if @issue_keys_pending_download.empty?
|
|
24
|
+
|
|
25
|
+
keys_to_request = @issue_keys_pending_download[0..99]
|
|
26
|
+
@issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
|
|
27
|
+
jql = "key in (#{keys_to_request.join(', ')})"
|
|
28
|
+
jira_search_by_jql(jql: jql, initial_query: false, board: board, path: path)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def jira_search_by_jql jql:, initial_query:, board:, path:
|
|
33
|
+
intercept_jql = @download_config.project_config.settings['intercept_jql']
|
|
34
|
+
jql = intercept_jql.call jql if intercept_jql
|
|
35
|
+
|
|
36
|
+
log " JQL: #{jql}"
|
|
37
|
+
escaped_jql = CGI.escape jql
|
|
38
|
+
|
|
39
|
+
max_results = 100
|
|
40
|
+
start_at = 0
|
|
41
|
+
total = 1
|
|
42
|
+
while start_at < total
|
|
43
|
+
json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
|
|
44
|
+
"?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
|
|
45
|
+
|
|
46
|
+
json['issues'].each do |issue_json|
|
|
47
|
+
issue_json['exporter'] = {
|
|
48
|
+
'in_initial_query' => initial_query
|
|
49
|
+
}
|
|
50
|
+
identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
|
|
51
|
+
file = "#{issue_json['key']}-#{board.id}.json"
|
|
52
|
+
|
|
53
|
+
@file_system.save_json(json: issue_json, filename: File.join(path, file))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
total = json['total'].to_i
|
|
57
|
+
max_results = json['maxResults']
|
|
58
|
+
|
|
59
|
+
message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
|
|
60
|
+
log message, both: true
|
|
61
|
+
|
|
62
|
+
start_at += json['issues'].size
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def make_jql filter_id:, today: Date.today
|
|
67
|
+
segments = []
|
|
68
|
+
segments << "filter=#{filter_id}"
|
|
69
|
+
|
|
70
|
+
start_date = @download_config.start_date today: today
|
|
71
|
+
|
|
72
|
+
if start_date
|
|
73
|
+
@download_date_range = start_date..today.to_date
|
|
74
|
+
|
|
75
|
+
# For an incremental download, we want to query from the end of the previous one, not from the
|
|
76
|
+
# beginning of the full range.
|
|
77
|
+
@start_date_in_query = metadata['date_end'] || @download_date_range.begin
|
|
78
|
+
log " Incremental download only. Pulling from #{@start_date_in_query}", both: true if metadata['date_end']
|
|
79
|
+
|
|
80
|
+
# Catch-all to pick up anything that's been around since before the range started but hasn't
|
|
81
|
+
# had an update during the range.
|
|
82
|
+
catch_all = '((status changed OR Sprint is not EMPTY) AND statusCategory != Done)'
|
|
83
|
+
|
|
84
|
+
# Pick up any issues that had a status change in the range
|
|
85
|
+
start_date_text = @start_date_in_query.strftime '%Y-%m-%d'
|
|
86
|
+
# find_in_range = %((status changed DURING ("#{start_date_text} 00:00","#{end_date_text} 23:59")))
|
|
87
|
+
find_in_range = %(updated >= "#{start_date_text} 00:00")
|
|
88
|
+
|
|
89
|
+
segments << "(#{find_in_range} OR #{catch_all})"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
segments.join ' AND '
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -15,15 +15,6 @@ class Exporter
|
|
|
15
15
|
self.anonymize if anonymize
|
|
16
16
|
self.settings.merge! settings
|
|
17
17
|
|
|
18
|
-
status_category_mappings.each do |status, category|
|
|
19
|
-
status_category_mapping status: status, category: category
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
download do
|
|
23
|
-
self.rolling_date_count(rolling_date_count) if rolling_date_count
|
|
24
|
-
self.no_earlier_than(no_earlier_than) if no_earlier_than
|
|
25
|
-
end
|
|
26
|
-
|
|
27
18
|
boards.each_key do |board_id|
|
|
28
19
|
block = boards[board_id]
|
|
29
20
|
if block == :default
|
|
@@ -37,6 +28,15 @@ class Exporter
|
|
|
37
28
|
end
|
|
38
29
|
end
|
|
39
30
|
|
|
31
|
+
status_category_mappings.each do |status, category|
|
|
32
|
+
status_category_mapping status: status, category: category
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
download do
|
|
36
|
+
self.rolling_date_count(rolling_date_count) if rolling_date_count
|
|
37
|
+
self.no_earlier_than(no_earlier_than) if no_earlier_than
|
|
38
|
+
end
|
|
39
|
+
|
|
40
40
|
issues.reject! do |issue|
|
|
41
41
|
ignore_types.include? issue.type
|
|
42
42
|
end
|
|
@@ -48,7 +48,7 @@ class ExpeditedChart < ChartBase
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
if data_sets.empty?
|
|
51
|
-
'<h1>Expedited work</h1>There is no expedited work in this time period
|
|
51
|
+
'<h1 class="foldable">Expedited work</h1><p>There is no expedited work in this time period.</p>'
|
|
52
52
|
else
|
|
53
53
|
wrap_and_render(binding, __FILE__)
|
|
54
54
|
end
|
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -50,24 +50,29 @@ class Exporter
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
project.download_config.run
|
|
53
|
-
|
|
53
|
+
# load_jira_config(download_config.project_config.jira_config)
|
|
54
|
+
# @ignore_ssl_errors = download_config.project_config.settings['ignore_ssl_errors']
|
|
55
|
+
gateway = JiraGateway.new(
|
|
56
|
+
file_system: file_system, jira_config: project.jira_config, settings: project.settings
|
|
57
|
+
)
|
|
58
|
+
downloader = Downloader.create(
|
|
54
59
|
download_config: project.download_config,
|
|
55
60
|
file_system: file_system,
|
|
56
|
-
jira_gateway:
|
|
61
|
+
jira_gateway: gateway
|
|
57
62
|
)
|
|
58
63
|
downloader.run
|
|
59
64
|
end
|
|
60
65
|
puts "Full output from downloader in #{file_system.logfile_name}"
|
|
61
66
|
end
|
|
62
67
|
|
|
63
|
-
def info
|
|
68
|
+
def info key, name_filter:
|
|
64
69
|
selected = []
|
|
65
70
|
each_project_config(name_filter: name_filter) do |project|
|
|
66
71
|
project.evaluate_next_level
|
|
67
72
|
|
|
68
73
|
project.run load_only: true
|
|
69
74
|
project.issues.each do |issue|
|
|
70
|
-
selected << [project, issue] if
|
|
75
|
+
selected << [project, issue] if key == issue.key
|
|
71
76
|
end
|
|
72
77
|
rescue => e # rubocop:disable Style/RescueStandardError
|
|
73
78
|
# This happens when we're attempting to load an aggregated project because it hasn't been
|
|
@@ -76,7 +81,7 @@ class Exporter
|
|
|
76
81
|
end
|
|
77
82
|
|
|
78
83
|
if selected.empty?
|
|
79
|
-
file_system.log "No issues found to match #{
|
|
84
|
+
file_system.log "No issues found to match #{key.inspect}"
|
|
80
85
|
else
|
|
81
86
|
selected.each do |project, issue|
|
|
82
87
|
file_system.log "\nProject #{project.name}", also_write_to_stderr: true
|
|
@@ -5,6 +5,13 @@ require 'json'
|
|
|
5
5
|
class FileSystem
|
|
6
6
|
attr_accessor :logfile, :logfile_name
|
|
7
7
|
|
|
8
|
+
def initialize
|
|
9
|
+
# In almost all cases, this will be immediately replaced in the Exporter
|
|
10
|
+
# but if we fail before we get that far, this will at least let a useful
|
|
11
|
+
# error show up on the console.
|
|
12
|
+
@logfile = $stdout
|
|
13
|
+
end
|
|
14
|
+
|
|
8
15
|
# Effectively the same as File.read except it forces the encoding to UTF-8
|
|
9
16
|
def load filename, supress_deprecation: false
|
|
10
17
|
if filename.end_with?('.json') && !supress_deprecation
|
|
@@ -31,6 +38,14 @@ class FileSystem
|
|
|
31
38
|
File.write(filename, content)
|
|
32
39
|
end
|
|
33
40
|
|
|
41
|
+
def mkdir path
|
|
42
|
+
FileUtils.mkdir_p path
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def utime file:, time:
|
|
46
|
+
File.utime time, time, file
|
|
47
|
+
end
|
|
48
|
+
|
|
34
49
|
def warning message, more: nil
|
|
35
50
|
log "Warning: #{message}", more: more, also_write_to_stderr: true
|
|
36
51
|
end
|
|
@@ -66,7 +81,15 @@ class FileSystem
|
|
|
66
81
|
end
|
|
67
82
|
|
|
68
83
|
def file_exist? filename
|
|
69
|
-
File.exist? filename
|
|
84
|
+
File.exist?(filename) && File.file?(filename)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def dir_exist? path
|
|
88
|
+
File.exist?(path) && File.directory?(path)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def unlink filename
|
|
92
|
+
File.unlink filename
|
|
70
93
|
end
|
|
71
94
|
|
|
72
95
|
def deprecated message:, date:, depth: 2
|
|
@@ -60,7 +60,7 @@ class FlowEfficiencyScatterplot < ChartBase
|
|
|
60
60
|
create_dataset(issues: issues, label: rules.label, color: rules.color)
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
|
|
63
|
+
return "<h1 class='foldable'>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
|
|
64
64
|
|
|
65
65
|
wrap_and_render(binding, __FILE__)
|
|
66
66
|
end
|
|
@@ -40,7 +40,7 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
|
|
|
40
40
|
color: <%= CssVariable['--grid-line-color'].to_json %>,
|
|
41
41
|
z: 1 // draw the grid lines on top of the bars
|
|
42
42
|
},
|
|
43
|
-
stacked:
|
|
43
|
+
stacked: false,
|
|
44
44
|
max: <%= (@max_age * 1.1).to_i %>
|
|
45
45
|
}
|
|
46
46
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
<table class='standard' id='<%= issues_id %>'
|
|
1
|
+
<div class='foldable startFolded'>Show details</div>
|
|
2
|
+
<table class='standard' id='<%= issues_id %>'>
|
|
3
3
|
<thead>
|
|
4
4
|
<tr>
|
|
5
5
|
<th>Issue</th>
|
|
@@ -6,8 +6,8 @@ if show_stats
|
|
|
6
6
|
link_id = next_id
|
|
7
7
|
issues_id = next_id
|
|
8
8
|
%>
|
|
9
|
-
|
|
10
|
-
<div id="<%= issues_id %>" style="
|
|
9
|
+
<div class='foldable' style="padding-left: 1em;">Statistics</div>
|
|
10
|
+
<div id="<%= issues_id %>" style="padding-left: 1em;">
|
|
11
11
|
<div>
|
|
12
12
|
<table class="standard">
|
|
13
13
|
<tr>
|
|
@@ -78,11 +78,6 @@ body {
|
|
|
78
78
|
color: var(--default-text-color);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
h1 {
|
|
82
|
-
border: 1px solid black;
|
|
83
|
-
background: lightgray;
|
|
84
|
-
padding-left: 0.2em;
|
|
85
|
-
}
|
|
86
81
|
dl, dd, dt {
|
|
87
82
|
padding: 0;
|
|
88
83
|
margin: 0;
|
|
@@ -191,6 +186,11 @@ div.daily_issue {
|
|
|
191
186
|
padding-right: 0.2em;
|
|
192
187
|
border-radius: 0.2em;
|
|
193
188
|
}
|
|
189
|
+
h1 {
|
|
190
|
+
border: none;
|
|
191
|
+
background: none;
|
|
192
|
+
padding-left: 0;
|
|
193
|
+
}
|
|
194
194
|
margin-bottom: 0.5em;
|
|
195
195
|
}
|
|
196
196
|
div.child_issue:hover {
|
|
@@ -239,11 +239,6 @@ div.child_issue {
|
|
|
239
239
|
--daily-view-selected-issue-background: #474747;
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
h1 {
|
|
243
|
-
color: #e0e0e0;
|
|
244
|
-
background-color: #656565;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
242
|
a[href] {
|
|
248
243
|
color: #1e8ad6;
|
|
249
244
|
}
|
|
@@ -5,41 +5,9 @@
|
|
|
5
5
|
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.js"></script>
|
|
6
6
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
7
7
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@^1"></script>
|
|
8
|
-
<script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-annotation/1.
|
|
8
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-annotation/3.1.0/chartjs-plugin-annotation.min.js"></script>
|
|
9
9
|
<script type="text/javascript">
|
|
10
|
-
|
|
11
|
-
link_text = document.getElementById(link_id).textContent
|
|
12
|
-
if( link_text == 'Show details') {
|
|
13
|
-
document.getElementById(link_id).textContent = 'Hide details'
|
|
14
|
-
document.getElementById(issues_id).style.display = 'block'
|
|
15
|
-
}
|
|
16
|
-
else {
|
|
17
|
-
document.getElementById(link_id).textContent = 'Show details'
|
|
18
|
-
document.getElementById(issues_id).style.display = 'none'
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function toggle_visibility(open_link_id, close_link_id, toggleable_id) {
|
|
23
|
-
let open_link = document.getElementById(open_link_id)
|
|
24
|
-
let close_link = document.getElementById(close_link_id)
|
|
25
|
-
let toggleable_element = document.getElementById(toggleable_id)
|
|
26
|
-
|
|
27
|
-
if(open_link.style.display == 'none') {
|
|
28
|
-
open_link.style.display = 'block'
|
|
29
|
-
close_link.style.display = 'none'
|
|
30
|
-
toggleable_element.style.display = 'none'
|
|
31
|
-
}
|
|
32
|
-
else {
|
|
33
|
-
open_link.style.display = 'none'
|
|
34
|
-
close_link.style.display = 'block'
|
|
35
|
-
toggleable_element.style.display = 'block'
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
// If we switch between light/dark mode then force a refresh so all charts will redraw correctly
|
|
39
|
-
// in the other colour scheme.
|
|
40
|
-
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
|
|
41
|
-
location.reload()
|
|
42
|
-
})
|
|
10
|
+
<%= javascript %>
|
|
43
11
|
</script>
|
|
44
12
|
<style>
|
|
45
13
|
<%= css %>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
function makeFoldable() {
|
|
2
|
+
// Get all elements with the "foldable" class
|
|
3
|
+
const foldableElements = document.querySelectorAll('.foldable');
|
|
4
|
+
|
|
5
|
+
if (foldableElements.length === 0) {
|
|
6
|
+
return; // No foldable elements found
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Process each foldable element
|
|
10
|
+
foldableElements.forEach((element, index) => {
|
|
11
|
+
// Skip if this is the footer element
|
|
12
|
+
if (element.id === 'footer') {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Create a unique ID for this section
|
|
17
|
+
const sectionId = `foldable-section-${index}`;
|
|
18
|
+
const toggleId = `foldable-toggle-${index}`;
|
|
19
|
+
|
|
20
|
+
// Create a container div for the foldable element and its content
|
|
21
|
+
const container = document.createElement('div');
|
|
22
|
+
container.className = 'foldable-section';
|
|
23
|
+
container.id = sectionId;
|
|
24
|
+
|
|
25
|
+
// Create a toggle button
|
|
26
|
+
const toggleButton = document.createElement(element.tagName); //'button');
|
|
27
|
+
toggleButton.id = toggleId;
|
|
28
|
+
toggleButton.className = 'foldable-toggle-btn';
|
|
29
|
+
toggleButton.innerHTML = '▼ ' + element.textContent;
|
|
30
|
+
|
|
31
|
+
// Create a content container
|
|
32
|
+
const contentContainer = document.createElement('div');
|
|
33
|
+
contentContainer.className = 'foldable-content';
|
|
34
|
+
contentContainer.style.cssText = `
|
|
35
|
+
border-left: 2px solid #ccc;
|
|
36
|
+
padding-left: 15px;
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
// Move the foldable element into the container and replace it with the toggle button
|
|
40
|
+
element.parentNode.insertBefore(container, element);
|
|
41
|
+
container.appendChild(toggleButton);
|
|
42
|
+
container.appendChild(contentContainer);
|
|
43
|
+
|
|
44
|
+
// Move all elements between this foldable element and the next foldable element (or end of document) into the content container
|
|
45
|
+
let nextElement = element.nextElementSibling;
|
|
46
|
+
while (nextElement && !nextElement.classList.contains('foldable')) {
|
|
47
|
+
// Skip the footer element
|
|
48
|
+
if (nextElement.id === 'footer') {
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const temp = nextElement.nextElementSibling;
|
|
53
|
+
contentContainer.appendChild(nextElement);
|
|
54
|
+
nextElement = temp;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Remove the original foldable element
|
|
58
|
+
element.remove();
|
|
59
|
+
|
|
60
|
+
// Add click event to toggle visibility
|
|
61
|
+
toggleButton.addEventListener('click', function() {
|
|
62
|
+
const content = this.nextElementSibling;
|
|
63
|
+
if (content.style.display === 'none') {
|
|
64
|
+
content.style.display = 'block';
|
|
65
|
+
this.innerHTML = '▼ ' + this.innerHTML.substring(2);
|
|
66
|
+
} else {
|
|
67
|
+
content.style.display = 'none';
|
|
68
|
+
this.innerHTML = '▶ ' + this.innerHTML.substring(2);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Initially show the content (you can change this to 'none' if you want sections collapsed by default)
|
|
73
|
+
contentContainer.style.display = 'block';
|
|
74
|
+
if(element.classList.contains('startFolded')) {
|
|
75
|
+
toggleButton.click();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Auto-initialize when DOM is loaded
|
|
81
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
82
|
+
makeFoldable();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
// If we switch between light/dark mode then force a refresh so all charts will redraw correctly
|
|
87
|
+
// in the other colour scheme.
|
|
88
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
|
|
89
|
+
location.reload()
|
|
90
|
+
})
|
|
@@ -68,8 +68,9 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
68
68
|
link_id = next_id
|
|
69
69
|
issues_id = next_id
|
|
70
70
|
%>
|
|
71
|
-
|
|
72
|
-
<div
|
|
71
|
+
<section>
|
|
72
|
+
<div class='foldable startFolded'>Show statistics</div>
|
|
73
|
+
<div id="<%= issues_id %>">
|
|
73
74
|
<table class='standard' style="margin-left: 1em;">
|
|
74
75
|
<thead>
|
|
75
76
|
<th>Sprint</th>
|
|
@@ -109,4 +110,5 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
109
110
|
<% end %>
|
|
110
111
|
</ul>
|
|
111
112
|
</p>
|
|
112
|
-
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</section>
|