jirametrics 2.12.1 → 2.20
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 +160 -0
- data/lib/jirametrics/board_config.rb +3 -1
- data/lib/jirametrics/change_item.rb +3 -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 +57 -53
- data/lib/jirametrics/data_quality_report.rb +6 -3
- data/lib/jirametrics/dependency_chart.rb +4 -1
- data/lib/jirametrics/downloader.rb +34 -70
- 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 +4 -36
- 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 +32 -20
- data/lib/jirametrics/jira_gateway.rb +59 -17
- data/lib/jirametrics/project_config.rb +30 -3
- data/lib/jirametrics/settings.json +3 -1
- data/lib/jirametrics/status_collection.rb +1 -0
- data/lib/jirametrics.rb +19 -69
- metadata +7 -3
|
@@ -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 %>
|
|
@@ -50,8 +18,8 @@
|
|
|
50
18
|
</head>
|
|
51
19
|
<body>
|
|
52
20
|
<noscript>
|
|
53
|
-
<div style="padding: 1em; background:
|
|
54
|
-
Javascript is currently disabled and that means that almost all of the charts in this report won't render. If you'
|
|
21
|
+
<div style="padding: 1em; background: red; color: white; font-size: 2em;">
|
|
22
|
+
Javascript is currently disabled and that means that almost all of the charts in this report won't render. If you've loaded this from a folder on SharePoint then save it locally and load it again.
|
|
55
23
|
</div>
|
|
56
24
|
</noscript>
|
|
57
25
|
<%= "\n" + @sections.collect { |text, type| text if type == :header }.compact.join("\n\n") %>
|
|
@@ -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
|
|
@@ -345,7 +350,7 @@ class Issue
|
|
|
345
350
|
end
|
|
346
351
|
elsif change.link?
|
|
347
352
|
# Example: "This issue is satisfied by ANON-30465"
|
|
348
|
-
unless /^This issue (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
|
|
353
|
+
unless /^This (?<_>issue|work item) (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
|
|
349
354
|
puts "Issue(#{key}) Can't parse link text: #{change.value || change.old_value}"
|
|
350
355
|
next
|
|
351
356
|
end
|
|
@@ -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?
|
|
@@ -681,9 +686,8 @@ class Issue
|
|
|
681
686
|
def done?
|
|
682
687
|
if artificial? || board.cycletime.nil?
|
|
683
688
|
# This was probably loaded as a linked issue, which means we don't know what board it really
|
|
684
|
-
# belonged to. The best we can do is look at the status
|
|
685
|
-
|
|
686
|
-
status.category.name == 'Done'
|
|
689
|
+
# belonged to. The best we can do is look at the status key
|
|
690
|
+
status.category.done?
|
|
687
691
|
else
|
|
688
692
|
board.cycletime.done? self
|
|
689
693
|
end
|
|
@@ -706,6 +710,19 @@ class Issue
|
|
|
706
710
|
board.sprints.select { |s| sprint_ids.include? s.id }
|
|
707
711
|
end
|
|
708
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
|
+
|
|
709
726
|
private
|
|
710
727
|
|
|
711
728
|
def load_history_into_changes
|
|
@@ -730,14 +747,6 @@ class Issue
|
|
|
730
747
|
end
|
|
731
748
|
end
|
|
732
749
|
|
|
733
|
-
def compact_text text, max = 60
|
|
734
|
-
return nil if text.nil?
|
|
735
|
-
|
|
736
|
-
text = text.gsub(/\s+/, ' ').strip
|
|
737
|
-
text = "#{text[0..max]}..." if text.length > max
|
|
738
|
-
text
|
|
739
|
-
end
|
|
740
|
-
|
|
741
750
|
def sort_changes!
|
|
742
751
|
@changes.sort! do |a, b|
|
|
743
752
|
# It's common that a resolved will happen at the same time as a status change.
|
|
@@ -755,6 +764,9 @@ class Issue
|
|
|
755
764
|
first_status = nil
|
|
756
765
|
first_status_id = nil
|
|
757
766
|
|
|
767
|
+
# There won't be a created timestamp in cases where this was a linked issue
|
|
768
|
+
return unless @raw['fields']['created']
|
|
769
|
+
|
|
758
770
|
created_time = parse_time @raw['fields']['created']
|
|
759
771
|
first_change = @changes.find { |change| change.field == field_name }
|
|
760
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
|
|
32
|
-
|
|
33
|
-
result = `#{command}`
|
|
34
|
-
@file_system.log result unless $CHILD_STATUS.success?
|
|
35
|
-
return result if $CHILD_STATUS.success?
|
|
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
|
|
36
71
|
|
|
37
|
-
|
|
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
|
|
@@ -77,4 +115,8 @@ class JiraGateway
|
|
|
77
115
|
|
|
78
116
|
true
|
|
79
117
|
end
|
|
118
|
+
|
|
119
|
+
def cloud?
|
|
120
|
+
@jira_url.downcase.end_with? '.atlassian.net'
|
|
121
|
+
end
|
|
80
122
|
end
|
|
@@ -114,10 +114,14 @@ class ProjectConfig
|
|
|
114
114
|
def file_prefix prefix
|
|
115
115
|
# The file_prefix has to be set before almost everything else. It really should have been an attribute
|
|
116
116
|
# on the project declaration itself. Hindsight is 20/20.
|
|
117
|
+
|
|
118
|
+
# There can only be one of these
|
|
117
119
|
if @file_prefix
|
|
118
|
-
raise "file_prefix
|
|
120
|
+
raise "file_prefix can only be set once. Was #{@file_prefix.inspect} and now changed to #{prefix.inspect}."
|
|
119
121
|
end
|
|
120
122
|
|
|
123
|
+
raise_if_prefix_already_used(prefix)
|
|
124
|
+
|
|
121
125
|
@file_prefix = prefix
|
|
122
126
|
|
|
123
127
|
# Yes, this is a wierd place to be initializing this. Unfortunately, it has to happen after the file_prefix
|
|
@@ -130,8 +134,21 @@ class ProjectConfig
|
|
|
130
134
|
@file_prefix
|
|
131
135
|
end
|
|
132
136
|
|
|
133
|
-
def
|
|
134
|
-
|
|
137
|
+
def raise_if_prefix_already_used prefix
|
|
138
|
+
@exporter.project_configs.each do |project|
|
|
139
|
+
next unless project.get_file_prefix(raise_if_not_set: false) == prefix && project.target_path == target_path
|
|
140
|
+
|
|
141
|
+
raise "Project #{name.inspect} specifies file prefix #{prefix.inspect}, " \
|
|
142
|
+
"but that is already used by project #{project.name.inspect} in the same target path #{target_path.inspect}. " \
|
|
143
|
+
'This is almost guaranteed to be too much copy and paste in your configuration. ' \
|
|
144
|
+
'File prefixes must be unique within a directory.'
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def get_file_prefix raise_if_not_set: true
|
|
149
|
+
if @file_prefix.nil? && raise_if_not_set
|
|
150
|
+
raise 'file_prefix has not been set yet. Move it to the top of the project declaration.'
|
|
151
|
+
end
|
|
135
152
|
|
|
136
153
|
@file_prefix
|
|
137
154
|
end
|
|
@@ -335,6 +352,12 @@ class ProjectConfig
|
|
|
335
352
|
json.each { |user_data| @users << User.new(raw: user_data) }
|
|
336
353
|
end
|
|
337
354
|
|
|
355
|
+
def atlassian_document_format
|
|
356
|
+
@atlassian_document_format ||= AtlassianDocumentFormat.new(
|
|
357
|
+
users: @users, timezone_offset: exporter.timezone_offset
|
|
358
|
+
)
|
|
359
|
+
end
|
|
360
|
+
|
|
338
361
|
def to_time string, end_of_day: false
|
|
339
362
|
time = end_of_day ? '23:59:59' : '00:00:00'
|
|
340
363
|
string = "#{string}T#{time}#{exporter.timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
|
|
@@ -526,6 +549,7 @@ class ProjectConfig
|
|
|
526
549
|
end
|
|
527
550
|
|
|
528
551
|
def discard_changes_before status_becomes: nil, &block
|
|
552
|
+
cycletimes_touched = Set.new
|
|
529
553
|
if status_becomes
|
|
530
554
|
status_becomes = [status_becomes] unless status_becomes.is_a? Array
|
|
531
555
|
|
|
@@ -558,6 +582,7 @@ class ProjectConfig
|
|
|
558
582
|
next if original_start_time.nil?
|
|
559
583
|
|
|
560
584
|
issue.discard_changes_before cutoff_time
|
|
585
|
+
cycletimes_touched << issue.board.cycletime
|
|
561
586
|
|
|
562
587
|
next unless cutoff_time
|
|
563
588
|
next if original_start_time > cutoff_time # ie the cutoff would have made no difference.
|
|
@@ -568,5 +593,7 @@ class ProjectConfig
|
|
|
568
593
|
issue: issue
|
|
569
594
|
}
|
|
570
595
|
end
|
|
596
|
+
|
|
597
|
+
cycletimes_touched.each { |c| c.flush_cache }
|
|
571
598
|
end
|
|
572
599
|
end
|
|
@@ -7,5 +7,7 @@
|
|
|
7
7
|
"flagged_means_blocked": true,
|
|
8
8
|
|
|
9
9
|
"expedited_priority_names": ["Critical", "Highest"],
|
|
10
|
-
"priority_order": ["Lowest", "Low", "Medium", "High", "Highest"]
|
|
10
|
+
"priority_order": ["Lowest", "Low", "Medium", "High", "Highest"],
|
|
11
|
+
|
|
12
|
+
"cache_cycletime_calculations": true
|
|
11
13
|
}
|
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,80 +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
|
-
load config_file
|
|
121
71
|
end
|
|
122
72
|
end
|