jirametrics 2.14 → 2.19
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/change_item.rb +2 -2
- data/lib/jirametrics/chart_base.rb +3 -2
- data/lib/jirametrics/cycletime_histogram.rb +3 -1
- data/lib/jirametrics/daily_view.rb +6 -20
- 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/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/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 +1 -33
- data/lib/jirametrics/html/index.js +90 -0
- data/lib/jirametrics/html/sprint_burndown.erb +5 -3
- data/lib/jirametrics/html_report_config.rb +2 -2
- data/lib/jirametrics/issue.rb +29 -16
- data/lib/jirametrics/jira_gateway.rb +55 -17
- data/lib/jirametrics/project_config.rb +6 -0
- data/lib/jirametrics.rb +19 -70
- metadata +6 -3
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
|
|
@@ -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
|
}
|
|
@@ -7,39 +7,7 @@
|
|
|
7
7
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@^1"></script>
|
|
8
8
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-annotation/1.2.2/chartjs-plugin-annotation.min.js" integrity="sha512-HycvvBSFvDEVyJ0tjE2rPmymkt6XqsP/Zo96XgLRjXwn6SecQqsn+6V/7KYev66OshZZ9+f9AttCGmYqmzytiw==" crossorigin="anonymous" referrerpolicy="no-referrer"></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>
|
|
@@ -72,6 +72,7 @@ class HtmlReportConfig
|
|
|
72
72
|
|
|
73
73
|
html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
|
|
74
74
|
css = load_css html_directory: html_directory
|
|
75
|
+
javascript = file_system.load(File.join(html_directory, 'index.js'))
|
|
75
76
|
erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
|
|
76
77
|
file_system.save_file content: erb.result(binding), filename: @file_config.output_filename
|
|
77
78
|
end
|
|
@@ -87,7 +88,6 @@ class HtmlReportConfig
|
|
|
87
88
|
def load_css html_directory:
|
|
88
89
|
base_css_filename = File.join(html_directory, 'index.css')
|
|
89
90
|
base_css = file_system.load(base_css_filename)
|
|
90
|
-
log("Loaded CSS: #{base_css_filename}")
|
|
91
91
|
|
|
92
92
|
extra_css_filename = settings['include_css']
|
|
93
93
|
if extra_css_filename
|
|
@@ -160,7 +160,7 @@ class HtmlReportConfig
|
|
|
160
160
|
chart.time_range = project_config.time_range
|
|
161
161
|
chart.timezone_offset = timezone_offset
|
|
162
162
|
chart.settings = settings
|
|
163
|
-
chart.
|
|
163
|
+
chart.atlassian_document_format = project_config.atlassian_document_format
|
|
164
164
|
|
|
165
165
|
chart.all_boards = project_config.all_boards
|
|
166
166
|
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?
|
|
@@ -3,21 +3,61 @@
|
|
|
3
3
|
require 'cgi'
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'English'
|
|
6
|
+
require 'open3'
|
|
6
7
|
|
|
7
8
|
class JiraGateway
|
|
8
|
-
attr_accessor :ignore_ssl_errors
|
|
9
|
+
attr_accessor :ignore_ssl_errors
|
|
10
|
+
attr_reader :jira_url, :settings, :file_system
|
|
9
11
|
|
|
10
|
-
def initialize file_system:
|
|
12
|
+
def initialize file_system:, jira_config:, settings:
|
|
11
13
|
@file_system = file_system
|
|
14
|
+
load_jira_config(jira_config)
|
|
15
|
+
@settings = settings
|
|
16
|
+
@ignore_ssl_errors = settings['ignore_ssl_errors']
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def post_request relative_url:, payload:
|
|
20
|
+
command = make_curl_command url: "#{@jira_url}#{relative_url}", method: 'POST'
|
|
21
|
+
exec_and_parse_response command: command, stdin_data: payload
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def exec_and_parse_response command:, stdin_data:
|
|
25
|
+
log_entry = " #{command.gsub(/\s+/, ' ')}"
|
|
26
|
+
log_entry = sanitize_message log_entry
|
|
27
|
+
@file_system.log log_entry
|
|
28
|
+
|
|
29
|
+
stdout, stderr, status = capture3(command, stdin_data: stdin_data)
|
|
30
|
+
unless status.success?
|
|
31
|
+
@file_system.log "Failed call with exit status #{status.exitstatus}!"
|
|
32
|
+
@file_system.log "Returned (stdout): #{stdout.inspect}"
|
|
33
|
+
@file_system.log "Returned (stderr): #{stderr.inspect}"
|
|
34
|
+
raise "Failed call with exit status #{status.exitstatus}. " \
|
|
35
|
+
"See #{@file_system.logfile_name} for details"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@file_system.log "Returned (stderr): #{stderr.inspect}" unless stderr == ''
|
|
39
|
+
raise 'no response from curl on stdout' if stdout == ''
|
|
40
|
+
|
|
41
|
+
parse_response(command: command, result: stdout)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def capture3 command, stdin_data:
|
|
45
|
+
# In it's own method so we can mock it out in tests
|
|
46
|
+
Open3.capture3(command, stdin_data: stdin_data)
|
|
12
47
|
end
|
|
13
48
|
|
|
14
49
|
def call_url relative_url:
|
|
15
50
|
command = make_curl_command url: "#{@jira_url}#{relative_url}"
|
|
16
|
-
|
|
51
|
+
exec_and_parse_response command: command, stdin_data: nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse_response command:, result:
|
|
17
55
|
begin
|
|
18
56
|
json = JSON.parse(result)
|
|
19
57
|
rescue # rubocop:disable Style/RescueStandardError
|
|
20
|
-
|
|
58
|
+
message = "Unable to parse results from #{sanitize_message(command)}"
|
|
59
|
+
@file_system.error message, more: result
|
|
60
|
+
raise message
|
|
21
61
|
end
|
|
22
62
|
|
|
23
63
|
raise "Download failed with: #{JSON.pretty_generate(json)}" unless json_successful?(json)
|
|
@@ -25,18 +65,11 @@ class JiraGateway
|
|
|
25
65
|
json
|
|
26
66
|
end
|
|
27
67
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@file_system.log log_entry
|
|
68
|
+
def sanitize_message message
|
|
69
|
+
token = @jira_api_token || @jira_personal_access_token
|
|
70
|
+
raise 'Neither Jira API Token or personal access token has been set' unless token
|
|
32
71
|
|
|
33
|
-
|
|
34
|
-
@file_system.log result unless $CHILD_STATUS.success?
|
|
35
|
-
return result if $CHILD_STATUS.success?
|
|
36
|
-
|
|
37
|
-
@file_system.log "Failed call with exit status #{$CHILD_STATUS.exitstatus}."
|
|
38
|
-
raise "Failed call with exit status #{$CHILD_STATUS.exitstatus}. " \
|
|
39
|
-
"See #{@file_system.logfile_name} for details"
|
|
72
|
+
message.gsub(token, '[API_TOKEN]')
|
|
40
73
|
end
|
|
41
74
|
|
|
42
75
|
def load_jira_config jira_config
|
|
@@ -56,7 +89,7 @@ class JiraGateway
|
|
|
56
89
|
@cookies = (jira_config['cookies'] || []).collect { |key, value| "#{key}=#{value}" }.join(';')
|
|
57
90
|
end
|
|
58
91
|
|
|
59
|
-
def make_curl_command url:
|
|
92
|
+
def make_curl_command url:, method: 'GET'
|
|
60
93
|
command = +''
|
|
61
94
|
command << 'curl'
|
|
62
95
|
command << ' -L' # follow redirects
|
|
@@ -65,8 +98,13 @@ class JiraGateway
|
|
|
65
98
|
command << " --cookie #{@cookies.inspect}" unless @cookies.empty?
|
|
66
99
|
command << " --user #{@jira_email}:#{@jira_api_token}" if @jira_api_token
|
|
67
100
|
command << " -H \"Authorization: Bearer #{@jira_personal_access_token}\"" if @jira_personal_access_token
|
|
68
|
-
command <<
|
|
101
|
+
command << " --request #{method}"
|
|
102
|
+
if method == 'POST'
|
|
103
|
+
command << ' --data @-'
|
|
104
|
+
command << ' --header "Content-Type: application/json"'
|
|
105
|
+
end
|
|
69
106
|
command << ' --header "Accept: application/json"'
|
|
107
|
+
command << ' --show-error --fail' # Better diagnostics when the server returns an error
|
|
70
108
|
command << " --url \"#{url}\""
|
|
71
109
|
command
|
|
72
110
|
end
|
|
@@ -352,6 +352,12 @@ class ProjectConfig
|
|
|
352
352
|
json.each { |user_data| @users << User.new(raw: user_data) }
|
|
353
353
|
end
|
|
354
354
|
|
|
355
|
+
def atlassian_document_format
|
|
356
|
+
@atlassian_document_format ||= AtlassianDocumentFormat.new(
|
|
357
|
+
users: @users, timezone_offset: exporter.timezone_offset
|
|
358
|
+
)
|
|
359
|
+
end
|
|
360
|
+
|
|
355
361
|
def to_time string, end_of_day: false
|
|
356
362
|
time = end_of_day ? '23:59:59' : '00:00:00'
|
|
357
363
|
string = "#{string}T#{time}#{exporter.timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
|
data/lib/jirametrics.rb
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'thor'
|
|
4
|
+
require 'require_all'
|
|
5
|
+
|
|
6
|
+
# This one does need to be loaded early. The rest will be loaded later.
|
|
7
|
+
require 'jirametrics/file_system'
|
|
4
8
|
|
|
5
9
|
class JiraMetrics < Thor
|
|
6
10
|
def self.exit_on_failure?
|
|
@@ -43,81 +47,26 @@ class JiraMetrics < Thor
|
|
|
43
47
|
|
|
44
48
|
option :config
|
|
45
49
|
desc 'info', 'Dump information about one issue'
|
|
46
|
-
def info
|
|
50
|
+
def info key
|
|
47
51
|
load_config options[:config]
|
|
48
|
-
Exporter.instance.info(
|
|
52
|
+
Exporter.instance.info(key, name_filter: options[:name] || '*')
|
|
49
53
|
end
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
no_commands do
|
|
56
|
+
def load_config config_file, file_system: FileSystem.new
|
|
57
|
+
config_file = './config.rb' if config_file.nil?
|
|
52
58
|
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
if File.exist? config_file
|
|
60
|
+
# The fact that File.exist can see the file does not mean that require will be
|
|
61
|
+
# able to load it. Convert this to an absolute pathname now for require.
|
|
62
|
+
config_file = File.absolute_path(config_file).to_s
|
|
63
|
+
else
|
|
64
|
+
file_system.error "Cannot find configuration file #{config_file.inspect}"
|
|
65
|
+
exit 1
|
|
66
|
+
end
|
|
55
67
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# able to load it. Convert this to an absolute pathname now for require.
|
|
59
|
-
config_file = File.absolute_path(config_file).to_s
|
|
60
|
-
else
|
|
61
|
-
puts "Cannot find configuration file #{config_file.inspect}"
|
|
62
|
-
exit 1
|
|
68
|
+
require_rel 'jirametrics'
|
|
69
|
+
load config_file
|
|
63
70
|
end
|
|
64
|
-
|
|
65
|
-
require 'jirametrics/value_equality'
|
|
66
|
-
require 'jirametrics/chart_base'
|
|
67
|
-
require 'jirametrics/rules'
|
|
68
|
-
require 'jirametrics/grouping_rules'
|
|
69
|
-
require 'jirametrics/daily_wip_chart'
|
|
70
|
-
require 'jirametrics/groupable_issue_chart'
|
|
71
|
-
require 'jirametrics/css_variable'
|
|
72
|
-
require 'jirametrics/issue_collection'
|
|
73
|
-
|
|
74
|
-
require 'jirametrics/aggregate_config'
|
|
75
|
-
require 'jirametrics/expedited_chart'
|
|
76
|
-
require 'jirametrics/board_config'
|
|
77
|
-
require 'jirametrics/file_config'
|
|
78
|
-
require 'jirametrics/jira_gateway'
|
|
79
|
-
require 'jirametrics/trend_line_calculator'
|
|
80
|
-
require 'jirametrics/status'
|
|
81
|
-
require 'jirametrics/issue_link'
|
|
82
|
-
require 'jirametrics/estimate_accuracy_chart'
|
|
83
|
-
require 'jirametrics/status_collection'
|
|
84
|
-
require 'jirametrics/sprint'
|
|
85
|
-
require 'jirametrics/issue'
|
|
86
|
-
require 'jirametrics/daily_wip_by_age_chart'
|
|
87
|
-
require 'jirametrics/daily_wip_by_parent_chart'
|
|
88
|
-
require 'jirametrics/aging_work_in_progress_chart'
|
|
89
|
-
require 'jirametrics/cycletime_scatterplot'
|
|
90
|
-
require 'jirametrics/flow_efficiency_scatterplot'
|
|
91
|
-
require 'jirametrics/sprint_issue_change_data'
|
|
92
|
-
require 'jirametrics/cycletime_histogram'
|
|
93
|
-
require 'jirametrics/daily_wip_by_blocked_stalled_chart'
|
|
94
|
-
require 'jirametrics/html_report_config'
|
|
95
|
-
require 'jirametrics/data_quality_report'
|
|
96
|
-
require 'jirametrics/aging_work_bar_chart'
|
|
97
|
-
require 'jirametrics/change_item'
|
|
98
|
-
require 'jirametrics/project_config'
|
|
99
|
-
require 'jirametrics/dependency_chart'
|
|
100
|
-
require 'jirametrics/cycletime_config'
|
|
101
|
-
require 'jirametrics/tree_organizer'
|
|
102
|
-
require 'jirametrics/aging_work_table'
|
|
103
|
-
require 'jirametrics/sprint_burndown'
|
|
104
|
-
require 'jirametrics/self_or_issue_dispatcher'
|
|
105
|
-
require 'jirametrics/throughput_chart'
|
|
106
|
-
require 'jirametrics/exporter'
|
|
107
|
-
require 'jirametrics/file_system'
|
|
108
|
-
require 'jirametrics/blocked_stalled_change'
|
|
109
|
-
require 'jirametrics/board_column'
|
|
110
|
-
require 'jirametrics/anonymizer'
|
|
111
|
-
require 'jirametrics/downloader'
|
|
112
|
-
require 'jirametrics/fix_version'
|
|
113
|
-
require 'jirametrics/download_config'
|
|
114
|
-
require 'jirametrics/columns_config'
|
|
115
|
-
require 'jirametrics/hierarchy_table'
|
|
116
|
-
require 'jirametrics/estimation_configuration'
|
|
117
|
-
require 'jirametrics/board'
|
|
118
|
-
require 'jirametrics/daily_view'
|
|
119
|
-
require 'jirametrics/user'
|
|
120
|
-
require 'jirametrics/atlassian_document_format'
|
|
121
|
-
load config_file
|
|
122
71
|
end
|
|
123
72
|
end
|