jirametrics 2.14 → 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 +3 -3
- data/lib/jirametrics/board_config.rb +2 -1
- data/lib/jirametrics/change_item.rb +2 -2
- data/lib/jirametrics/chart_base.rb +3 -2
- data/lib/jirametrics/cycletime_config.rb +22 -3
- data/lib/jirametrics/cycletime_histogram.rb +3 -1
- data/lib/jirametrics/daily_view.rb +6 -20
- 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 +0 -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 +29 -16
- data/lib/jirametrics/jira_gateway.rb +55 -17
- data/lib/jirametrics/project_config.rb +10 -0
- data/lib/jirametrics/settings.json +3 -1
- data/lib/jirametrics.rb +19 -70
- metadata +6 -3
|
@@ -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;
|
|
@@ -244,11 +239,6 @@ div.child_issue {
|
|
|
244
239
|
--daily-view-selected-issue-background: #474747;
|
|
245
240
|
}
|
|
246
241
|
|
|
247
|
-
h1 {
|
|
248
|
-
color: #e0e0e0;
|
|
249
|
-
background-color: #656565;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
242
|
a[href] {
|
|
253
243
|
color: #1e8ad6;
|
|
254
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>
|
|
@@ -51,7 +51,9 @@ class HtmlReportConfig
|
|
|
51
51
|
@file_config.project_config.all_boards.each_value do |board|
|
|
52
52
|
raise 'Multiple cycletimes not supported' if board.cycletime
|
|
53
53
|
|
|
54
|
-
board.cycletime = CycleTimeConfig.new(
|
|
54
|
+
board.cycletime = CycleTimeConfig.new(
|
|
55
|
+
parent_config: self, label: label, block: block, file_system: file_system, settings: settings
|
|
56
|
+
)
|
|
55
57
|
end
|
|
56
58
|
end
|
|
57
59
|
|
|
@@ -72,6 +74,7 @@ class HtmlReportConfig
|
|
|
72
74
|
|
|
73
75
|
html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
|
|
74
76
|
css = load_css html_directory: html_directory
|
|
77
|
+
javascript = file_system.load(File.join(html_directory, 'index.js'))
|
|
75
78
|
erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
|
|
76
79
|
file_system.save_file content: erb.result(binding), filename: @file_config.output_filename
|
|
77
80
|
end
|
|
@@ -87,7 +90,6 @@ class HtmlReportConfig
|
|
|
87
90
|
def load_css html_directory:
|
|
88
91
|
base_css_filename = File.join(html_directory, 'index.css')
|
|
89
92
|
base_css = file_system.load(base_css_filename)
|
|
90
|
-
log("Loaded CSS: #{base_css_filename}")
|
|
91
93
|
|
|
92
94
|
extra_css_filename = settings['include_css']
|
|
93
95
|
if extra_css_filename
|
|
@@ -160,7 +162,7 @@ class HtmlReportConfig
|
|
|
160
162
|
chart.time_range = project_config.time_range
|
|
161
163
|
chart.timezone_offset = timezone_offset
|
|
162
164
|
chart.settings = settings
|
|
163
|
-
chart.
|
|
165
|
+
chart.atlassian_document_format = project_config.atlassian_document_format
|
|
164
166
|
|
|
165
167
|
chart.all_boards = project_config.all_boards
|
|
166
168
|
chart.board_id = find_board_id
|
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -19,9 +19,10 @@ class Issue
|
|
|
19
19
|
|
|
20
20
|
# There are cases where we create an Issue of fragments like linked issues and those won't have
|
|
21
21
|
# changelogs.
|
|
22
|
-
|
|
22
|
+
load_history_into_changes if @raw['changelog']
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# As above with fragments, there may not be a fields section
|
|
25
|
+
return unless @raw['fields']
|
|
25
26
|
|
|
26
27
|
# If this is an older pull of data then comments may not be there.
|
|
27
28
|
load_comments_into_changes if @raw['fields']['comment']
|
|
@@ -152,7 +153,7 @@ class Issue
|
|
|
152
153
|
# Are we currently in this status? If yes, then return the most recent status change.
|
|
153
154
|
def currently_in_status *status_names
|
|
154
155
|
change = most_recent_status_change
|
|
155
|
-
return
|
|
156
|
+
return nil if change.nil?
|
|
156
157
|
|
|
157
158
|
change if change.current_status_matches(*status_names)
|
|
158
159
|
end
|
|
@@ -162,7 +163,7 @@ class Issue
|
|
|
162
163
|
category_ids = find_status_category_ids_by_names category_names
|
|
163
164
|
|
|
164
165
|
change = most_recent_status_change
|
|
165
|
-
return
|
|
166
|
+
return nil if change.nil?
|
|
166
167
|
|
|
167
168
|
status = find_or_create_status id: change.value_id, name: change.value
|
|
168
169
|
change if status && category_ids.include?(status.category.id)
|
|
@@ -212,7 +213,11 @@ class Issue
|
|
|
212
213
|
end
|
|
213
214
|
|
|
214
215
|
def parse_time text
|
|
215
|
-
|
|
216
|
+
if text.is_a? String
|
|
217
|
+
Time.parse(text).getlocal(@timezone_offset)
|
|
218
|
+
else
|
|
219
|
+
Time.at(text / 1000).getlocal(@timezone_offset)
|
|
220
|
+
end
|
|
216
221
|
end
|
|
217
222
|
|
|
218
223
|
def created
|
|
@@ -233,11 +238,11 @@ class Issue
|
|
|
233
238
|
end
|
|
234
239
|
|
|
235
240
|
def assigned_to
|
|
236
|
-
@raw['fields']
|
|
241
|
+
@raw['fields']['assignee']&.[]('displayName')
|
|
237
242
|
end
|
|
238
243
|
|
|
239
244
|
def assigned_to_icon_url
|
|
240
|
-
@raw['fields']
|
|
245
|
+
@raw['fields']['assignee']&.[]('avatarUrls')&.[]('16x16')
|
|
241
246
|
end
|
|
242
247
|
|
|
243
248
|
# Many test failures are simply unreadable because the default inspect on this class goes
|
|
@@ -608,7 +613,7 @@ class Issue
|
|
|
608
613
|
|
|
609
614
|
def dump
|
|
610
615
|
result = +''
|
|
611
|
-
result << "#{key} (#{type}): #{compact_text summary, 200}\n"
|
|
616
|
+
result << "#{key} (#{type}): #{compact_text summary, max: 200}\n"
|
|
612
617
|
|
|
613
618
|
assignee = raw['fields']['assignee']
|
|
614
619
|
result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
|
|
@@ -705,6 +710,19 @@ class Issue
|
|
|
705
710
|
board.sprints.select { |s| sprint_ids.include? s.id }
|
|
706
711
|
end
|
|
707
712
|
|
|
713
|
+
def compact_text text, max: 60
|
|
714
|
+
return '' if text.nil?
|
|
715
|
+
|
|
716
|
+
if text.is_a? Hash
|
|
717
|
+
# We can't effectively compact it but we can convert it into a string.
|
|
718
|
+
text = @board.project_config.atlassian_document_format.to_html(text)
|
|
719
|
+
else
|
|
720
|
+
text = text.gsub(/\s+/, ' ').strip
|
|
721
|
+
text = "#{text[0...max]}..." if text.length > max
|
|
722
|
+
end
|
|
723
|
+
text
|
|
724
|
+
end
|
|
725
|
+
|
|
708
726
|
private
|
|
709
727
|
|
|
710
728
|
def load_history_into_changes
|
|
@@ -729,14 +747,6 @@ class Issue
|
|
|
729
747
|
end
|
|
730
748
|
end
|
|
731
749
|
|
|
732
|
-
def compact_text text, max = 60
|
|
733
|
-
return nil if text.nil?
|
|
734
|
-
|
|
735
|
-
text = text.gsub(/\s+/, ' ').strip
|
|
736
|
-
text = "#{text[0..max]}..." if text.length > max
|
|
737
|
-
text
|
|
738
|
-
end
|
|
739
|
-
|
|
740
750
|
def sort_changes!
|
|
741
751
|
@changes.sort! do |a, b|
|
|
742
752
|
# It's common that a resolved will happen at the same time as a status change.
|
|
@@ -754,6 +764,9 @@ class Issue
|
|
|
754
764
|
first_status = nil
|
|
755
765
|
first_status_id = nil
|
|
756
766
|
|
|
767
|
+
# There won't be a created timestamp in cases where this was a linked issue
|
|
768
|
+
return unless @raw['fields']['created']
|
|
769
|
+
|
|
757
770
|
created_time = parse_time @raw['fields']['created']
|
|
758
771
|
first_change = @changes.find { |change| change.field == field_name }
|
|
759
772
|
if first_change.nil?
|