jirametrics 2.13 → 2.22
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/aging_work_bar_chart.rb +176 -134
- data/lib/jirametrics/anonymizer.rb +8 -6
- data/lib/jirametrics/atlassian_document_format.rb +8 -4
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/board.rb +4 -0
- data/lib/jirametrics/board_config.rb +4 -1
- data/lib/jirametrics/change_item.rb +11 -4
- data/lib/jirametrics/chart_base.rb +36 -2
- data/lib/jirametrics/cycletime_config.rb +22 -4
- data/lib/jirametrics/cycletime_histogram.rb +3 -1
- data/lib/jirametrics/cycletime_scatterplot.rb +36 -17
- data/lib/jirametrics/daily_view.rb +49 -42
- data/lib/jirametrics/daily_wip_by_age_chart.rb +3 -4
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +13 -3
- data/lib/jirametrics/daily_wip_chart.rb +1 -1
- data/lib/jirametrics/data_quality_report.rb +8 -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 +12 -5
- data/lib/jirametrics/file_system.rb +24 -1
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
- data/lib/jirametrics/groupable_issue_chart.rb +7 -1
- data/lib/jirametrics/html/aging_work_bar_chart.erb +2 -1
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
- data/lib/jirametrics/html/aging_work_table.erb +2 -0
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cycletime_histogram.erb +4 -2
- data/lib/jirametrics/html/cycletime_scatterplot.erb +6 -6
- data/lib/jirametrics/html/daily_wip_chart.erb +2 -0
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -0
- data/lib/jirametrics/html/expedited_chart.erb +3 -1
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -0
- data/lib/jirametrics/html/index.css +21 -9
- data/lib/jirametrics/html/index.erb +3 -35
- data/lib/jirametrics/html/index.js +114 -0
- data/lib/jirametrics/html/sprint_burndown.erb +11 -3
- data/lib/jirametrics/html/throughput_chart.erb +2 -2
- data/lib/jirametrics/html_generator.rb +31 -0
- data/lib/jirametrics/html_report_config.rb +8 -25
- data/lib/jirametrics/issue.rb +127 -22
- data/lib/jirametrics/jira_gateway.rb +55 -17
- data/lib/jirametrics/project_config.rb +42 -5
- data/lib/jirametrics/raw_javascript.rb +13 -0
- data/lib/jirametrics/settings.json +3 -1
- data/lib/jirametrics/sprint.rb +12 -0
- data/lib/jirametrics/sprint_burndown.rb +6 -2
- data/lib/jirametrics/stitcher.rb +75 -0
- data/lib/jirametrics.rb +26 -70
- metadata +10 -3
|
@@ -67,10 +67,21 @@
|
|
|
67
67
|
--sprint-burndown-sprint-color-4: red;
|
|
68
68
|
--sprint-burndown-sprint-color-5: brown;
|
|
69
69
|
|
|
70
|
+
--sprint-color: lightblue;
|
|
71
|
+
|
|
70
72
|
--daily-view-selected-issue-background: lightgray;
|
|
71
73
|
--daily-view-issue-border: green;
|
|
72
74
|
--daily-view-selected-issue-border: red;
|
|
73
75
|
|
|
76
|
+
/* The first five are the standard priorities that Jira creates by default. */
|
|
77
|
+
--priority-color-highest: #dc2626; /* red-600 - urgent red */
|
|
78
|
+
--priority-color-high: #ea580c; /* orange-600 - warning orange */
|
|
79
|
+
--priority-color-medium: #9ca3af; /* gray-400 - neutral light gray */
|
|
80
|
+
--priority-color-low: #0891b2; /* cyan-600 - calm blue */
|
|
81
|
+
--priority-color-lowest: #64748b; /* slate-500 - muted slate */
|
|
82
|
+
/* Then here are some values we've seen in multiple instances. */
|
|
83
|
+
--priority-color-notset: gray;
|
|
84
|
+
--priority-color-critical: red;
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
body {
|
|
@@ -78,11 +89,6 @@ body {
|
|
|
78
89
|
color: var(--default-text-color);
|
|
79
90
|
}
|
|
80
91
|
|
|
81
|
-
h1 {
|
|
82
|
-
border: 1px solid black;
|
|
83
|
-
background: lightgray;
|
|
84
|
-
padding-left: 0.2em;
|
|
85
|
-
}
|
|
86
92
|
dl, dd, dt {
|
|
87
93
|
padding: 0;
|
|
88
94
|
margin: 0;
|
|
@@ -191,6 +197,11 @@ div.daily_issue {
|
|
|
191
197
|
padding-right: 0.2em;
|
|
192
198
|
border-radius: 0.2em;
|
|
193
199
|
}
|
|
200
|
+
h1 {
|
|
201
|
+
border: none;
|
|
202
|
+
background: none;
|
|
203
|
+
padding-left: 0;
|
|
204
|
+
}
|
|
194
205
|
margin-bottom: 0.5em;
|
|
195
206
|
}
|
|
196
207
|
div.child_issue:hover {
|
|
@@ -237,11 +248,12 @@ div.child_issue {
|
|
|
237
248
|
--wip-chart-duration-more-than-four-weeks-color: #8e0000;
|
|
238
249
|
|
|
239
250
|
--daily-view-selected-issue-background: #474747;
|
|
240
|
-
}
|
|
241
251
|
|
|
242
|
-
|
|
243
|
-
color: #
|
|
244
|
-
|
|
252
|
+
--priority-color-highest: #ef4444; /* red-500 - bright urgent red */
|
|
253
|
+
--priority-color-high: #f97316; /* orange-500 - bright orange */
|
|
254
|
+
--priority-color-medium: #9ca3af; /* gray-400 - neutral light gray */
|
|
255
|
+
--priority-color-low: #06b6d4; /* cyan-500 - bright calm blue */
|
|
256
|
+
--priority-color-lowest: #94a3b8; /* slate-400 - muted light slate */
|
|
245
257
|
}
|
|
246
258
|
|
|
247
259
|
a[href] {
|
|
@@ -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 %>
|
|
@@ -55,6 +23,6 @@
|
|
|
55
23
|
</div>
|
|
56
24
|
</noscript>
|
|
57
25
|
<%= "\n" + @sections.collect { |text, type| text if type == :header }.compact.join("\n\n") %>
|
|
58
|
-
<%= "\n" + @sections.collect { |text, type| text if type
|
|
26
|
+
<%= "\n" + @sections.collect { |text, type| text if type != :header }.compact.join("\n\n") %>
|
|
59
27
|
</body>
|
|
60
28
|
</html>
|
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
})
|
|
91
|
+
|
|
92
|
+
// Draw a diagonal pattern to highlight sections of a bar chart. Based on code found at:
|
|
93
|
+
// https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns
|
|
94
|
+
function createDiagonalPattern(color = 'black') {
|
|
95
|
+
// create a 5x5 px canvas for the pattern's base shape
|
|
96
|
+
let shape = document.createElement('canvas')
|
|
97
|
+
shape.width = 5
|
|
98
|
+
shape.height = 5
|
|
99
|
+
// get the context for drawing
|
|
100
|
+
let c = shape.getContext('2d')
|
|
101
|
+
// draw 1st line of the shape
|
|
102
|
+
c.strokeStyle = color
|
|
103
|
+
c.beginPath()
|
|
104
|
+
c.moveTo(1, 0)
|
|
105
|
+
c.lineTo(5, 4)
|
|
106
|
+
c.stroke()
|
|
107
|
+
// draw 2nd line of the shape
|
|
108
|
+
c.beginPath()
|
|
109
|
+
c.moveTo(0, 4)
|
|
110
|
+
c.lineTo(1, 5)
|
|
111
|
+
c.stroke()
|
|
112
|
+
// create the pattern from the shape
|
|
113
|
+
return c.createPattern(shape, 'repeat')
|
|
114
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<h2>Burndown by <%= y_axis_title %></h2>
|
|
2
2
|
|
|
3
|
+
<%= seam_start %>
|
|
3
4
|
<div class="chart">
|
|
4
5
|
<canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
|
|
5
6
|
</div>
|
|
@@ -63,16 +64,20 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
63
64
|
}
|
|
64
65
|
});
|
|
65
66
|
</script>
|
|
67
|
+
<%= seam_end %>
|
|
66
68
|
|
|
67
69
|
<%
|
|
68
70
|
link_id = next_id
|
|
69
71
|
issues_id = next_id
|
|
70
72
|
%>
|
|
71
|
-
|
|
72
|
-
<div
|
|
73
|
+
<section>
|
|
74
|
+
<div class='foldable startFolded'>Show statistics</div>
|
|
75
|
+
<div id="<%= issues_id %>">
|
|
76
|
+
<%= seam_start 'stats_table' %>
|
|
73
77
|
<table class='standard' style="margin-left: 1em;">
|
|
74
78
|
<thead>
|
|
75
79
|
<th>Sprint</th>
|
|
80
|
+
<th>Length</th>
|
|
76
81
|
<th>State</th>
|
|
77
82
|
<th>Started</th>
|
|
78
83
|
<th>Completed</th>
|
|
@@ -85,6 +90,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
85
90
|
<% @summary_stats.keys.sort_by(&:start_time).each do |sprint| %>
|
|
86
91
|
<tr>
|
|
87
92
|
<td><%= sprint.name %></td>
|
|
93
|
+
<td><%= sprint.day_count %></td>
|
|
88
94
|
<td><%= sprint.raw['state'] %></td>
|
|
89
95
|
<% stats = @summary_stats[sprint] %>
|
|
90
96
|
<td><%= stats.started %></td>
|
|
@@ -101,6 +107,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
101
107
|
<% end %>
|
|
102
108
|
</tbody>
|
|
103
109
|
</table>
|
|
110
|
+
<%= seam_end 'stats_table' %>
|
|
104
111
|
|
|
105
112
|
<p>Legend:
|
|
106
113
|
<ul>
|
|
@@ -109,4 +116,5 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
109
116
|
<% end %>
|
|
110
117
|
</ul>
|
|
111
118
|
</p>
|
|
112
|
-
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</section>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
<%= seam_start %>
|
|
2
2
|
<div class="chart">
|
|
3
3
|
<canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
|
|
4
4
|
</div>
|
|
@@ -59,4 +59,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
59
59
|
}
|
|
60
60
|
});
|
|
61
61
|
</script>
|
|
62
|
-
|
|
62
|
+
<%= seam_end %>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class HtmlGenerator
|
|
4
|
+
attr_accessor :file_system, :settings
|
|
5
|
+
|
|
6
|
+
def create_html output_filename:, settings:
|
|
7
|
+
@settings = settings
|
|
8
|
+
html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
|
|
9
|
+
css = load_css html_directory: html_directory
|
|
10
|
+
javascript = file_system.load(File.join(html_directory, 'index.js'))
|
|
11
|
+
erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
|
|
12
|
+
file_system.save_file content: erb.result(binding), filename: output_filename
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def load_css html_directory:
|
|
16
|
+
base_css_filename = File.join(html_directory, 'index.css')
|
|
17
|
+
base_css = file_system.load(base_css_filename)
|
|
18
|
+
|
|
19
|
+
extra_css_filename = settings['include_css']
|
|
20
|
+
if extra_css_filename
|
|
21
|
+
if File.exist?(extra_css_filename)
|
|
22
|
+
base_css << "\n\n" << file_system.load(extra_css_filename)
|
|
23
|
+
log("Loaded CSS: #{extra_css_filename}")
|
|
24
|
+
else
|
|
25
|
+
log("Unable to find specified CSS file: #{extra_css_filename}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
base_css
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require 'erb'
|
|
4
4
|
require 'jirametrics/self_or_issue_dispatcher'
|
|
5
5
|
|
|
6
|
-
class HtmlReportConfig
|
|
6
|
+
class HtmlReportConfig < HtmlGenerator
|
|
7
7
|
include SelfOrIssueDispatcher
|
|
8
8
|
|
|
9
9
|
attr_reader :file_config, :sections, :charts
|
|
@@ -51,7 +51,10 @@ 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
|
+
possible_statuses: file_config.project_config, label: label, block: block,
|
|
56
|
+
file_system: file_system, settings: settings
|
|
57
|
+
)
|
|
55
58
|
end
|
|
56
59
|
end
|
|
57
60
|
|
|
@@ -70,10 +73,7 @@ class HtmlReportConfig
|
|
|
70
73
|
|
|
71
74
|
html create_footer
|
|
72
75
|
|
|
73
|
-
|
|
74
|
-
css = load_css html_directory: html_directory
|
|
75
|
-
erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
|
|
76
|
-
file_system.save_file content: erb.result(binding), filename: @file_config.output_filename
|
|
76
|
+
create_html output_filename: @file_config.output_filename, settings: settings
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
def file_system
|
|
@@ -84,24 +84,6 @@ class HtmlReportConfig
|
|
|
84
84
|
file_system.log message
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
-
def load_css html_directory:
|
|
88
|
-
base_css_filename = File.join(html_directory, 'index.css')
|
|
89
|
-
base_css = file_system.load(base_css_filename)
|
|
90
|
-
log("Loaded CSS: #{base_css_filename}")
|
|
91
|
-
|
|
92
|
-
extra_css_filename = settings['include_css']
|
|
93
|
-
if extra_css_filename
|
|
94
|
-
if File.exist?(extra_css_filename)
|
|
95
|
-
base_css << "\n\n" << file_system.load(extra_css_filename)
|
|
96
|
-
log("Loaded CSS: #{extra_css_filename}")
|
|
97
|
-
else
|
|
98
|
-
log("Unable to find specified CSS file: #{extra_css_filename}")
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
base_css
|
|
103
|
-
end
|
|
104
|
-
|
|
105
87
|
def board_id id
|
|
106
88
|
@board_id = id
|
|
107
89
|
end
|
|
@@ -160,7 +142,7 @@ class HtmlReportConfig
|
|
|
160
142
|
chart.time_range = project_config.time_range
|
|
161
143
|
chart.timezone_offset = timezone_offset
|
|
162
144
|
chart.settings = settings
|
|
163
|
-
chart.
|
|
145
|
+
chart.atlassian_document_format = project_config.atlassian_document_format
|
|
164
146
|
|
|
165
147
|
chart.all_boards = project_config.all_boards
|
|
166
148
|
chart.board_id = find_board_id
|
|
@@ -173,6 +155,7 @@ class HtmlReportConfig
|
|
|
173
155
|
after_init_block&.call chart
|
|
174
156
|
|
|
175
157
|
@charts << chart
|
|
158
|
+
chart.before_run
|
|
176
159
|
html chart.run
|
|
177
160
|
end
|
|
178
161
|
|
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)
|
|
@@ -211,8 +212,91 @@ class Issue
|
|
|
211
212
|
first_time_in_status(*board.visible_columns.collect(&:status_ids).flatten)
|
|
212
213
|
end
|
|
213
214
|
|
|
215
|
+
# If this issue will ever be in an active sprint then return the time that it
|
|
216
|
+
# was first added to that sprint, whether or not the sprint was active at that
|
|
217
|
+
# time. Although it seems like an odd thing to calculate, it's a reasonable proxy
|
|
218
|
+
# for 'ready' in cases where the team doesn't have an explicit 'ready' status.
|
|
219
|
+
# You'd be better off with an explicit 'ready' but sometimes that's not an option.
|
|
220
|
+
def first_time_added_to_active_sprint
|
|
221
|
+
unless board.scrum?
|
|
222
|
+
raise 'first_time_added_to_active_sprint() can only be used with Scrum boards: ' \
|
|
223
|
+
"issue=#{key}, board=#{board.inspect}"
|
|
224
|
+
end
|
|
225
|
+
data_clazz = Struct.new(:sprint_id, :sprint_start, :sprint_stop, :change)
|
|
226
|
+
|
|
227
|
+
matching_changes = []
|
|
228
|
+
all_datas = []
|
|
229
|
+
|
|
230
|
+
@changes.each do |change|
|
|
231
|
+
next unless change.sprint?
|
|
232
|
+
|
|
233
|
+
added_sprint_ids = change.value_id - change.old_value_id
|
|
234
|
+
added_sprint_ids.each do |id|
|
|
235
|
+
data = data_clazz.new
|
|
236
|
+
data.sprint_id = id
|
|
237
|
+
data.change = change
|
|
238
|
+
data.sprint_start, data.sprint_stop = find_sprint_start_end(sprint_id: id, change: change)
|
|
239
|
+
all_datas << data
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
removed_sprint_ids = change.old_value_id - change.value_id
|
|
243
|
+
removed_sprint_ids.each do |id|
|
|
244
|
+
data = all_datas.find { |d| d.sprint_id == id }
|
|
245
|
+
# It's possible for an issue to be created inside a sprint and therefore for
|
|
246
|
+
# that add-to-sprint not show in the history.
|
|
247
|
+
next unless data
|
|
248
|
+
|
|
249
|
+
all_datas.delete(data)
|
|
250
|
+
next if data.sprint_start.nil? || data.sprint_start >= change.time
|
|
251
|
+
|
|
252
|
+
matching_changes << data.change
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# There can't be any more removes so whatever is left is a valid option
|
|
257
|
+
# Now all we care about is if the sprint has started.
|
|
258
|
+
all_datas.each do |data|
|
|
259
|
+
matching_changes << data.change if data.sprint_start
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
matching_changes.min_by(&:time)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def find_sprint_start_end sprint_id:, change:
|
|
266
|
+
# There are two different places that sprint data could be found. In theory all
|
|
267
|
+
# sprints would be found in both places. In practice, sometimes what we need is
|
|
268
|
+
# in one or the other but not both.
|
|
269
|
+
|
|
270
|
+
# First look in the actual sprints json. If any issues are in this sprint then it should
|
|
271
|
+
# be here.
|
|
272
|
+
sprint = board.sprints.find { |s| s.id == sprint_id }
|
|
273
|
+
return [sprint.start_time, sprint.completed_time] if sprint
|
|
274
|
+
|
|
275
|
+
# Then look at the sprints inside the issue. Even though the field id may be specified,
|
|
276
|
+
# that custom field may not be present. This happens if it was in that sprint but was
|
|
277
|
+
# then removed, whether or not that sprint had ever started.
|
|
278
|
+
sprint_data = raw['fields'][change.field_id]&.find { |sd| sd['id'].to_i == sprint_id }
|
|
279
|
+
if sprint_data
|
|
280
|
+
start = parse_time(sprint_data['startDate'])
|
|
281
|
+
stop = parse_time(sprint_data['completeDate'])
|
|
282
|
+
return [start, stop]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# If we got this far then the sprint can't be found anywhere, so we pretend that it never
|
|
286
|
+
# started. Is this guaranteed to be true? No. In theory if all issues were removed from
|
|
287
|
+
# an active sprint then it would also disappear, even though it had started. Nothing we
|
|
288
|
+
# can do to detect that edge-case though.
|
|
289
|
+
[nil, nil]
|
|
290
|
+
end
|
|
291
|
+
|
|
214
292
|
def parse_time text
|
|
215
|
-
|
|
293
|
+
if text.nil?
|
|
294
|
+
nil
|
|
295
|
+
elsif text.is_a? String
|
|
296
|
+
Time.parse(text).getlocal(@timezone_offset)
|
|
297
|
+
else
|
|
298
|
+
Time.at(text / 1000).getlocal(@timezone_offset)
|
|
299
|
+
end
|
|
216
300
|
end
|
|
217
301
|
|
|
218
302
|
def created
|
|
@@ -220,6 +304,10 @@ class Issue
|
|
|
220
304
|
parse_time @raw['fields']['created'] if @raw['fields']['created']
|
|
221
305
|
end
|
|
222
306
|
|
|
307
|
+
def time_created
|
|
308
|
+
@changes.first
|
|
309
|
+
end
|
|
310
|
+
|
|
223
311
|
def updated
|
|
224
312
|
parse_time @raw['fields']['updated']
|
|
225
313
|
end
|
|
@@ -233,11 +321,11 @@ class Issue
|
|
|
233
321
|
end
|
|
234
322
|
|
|
235
323
|
def assigned_to
|
|
236
|
-
@raw['fields']
|
|
324
|
+
@raw['fields']['assignee']&.[]('displayName')
|
|
237
325
|
end
|
|
238
326
|
|
|
239
327
|
def assigned_to_icon_url
|
|
240
|
-
@raw['fields']
|
|
328
|
+
@raw['fields']['assignee']&.[]('avatarUrls')&.[]('16x16')
|
|
241
329
|
end
|
|
242
330
|
|
|
243
331
|
# Many test failures are simply unreadable because the default inspect on this class goes
|
|
@@ -300,9 +388,7 @@ class Issue
|
|
|
300
388
|
results
|
|
301
389
|
end
|
|
302
390
|
|
|
303
|
-
def
|
|
304
|
-
settings ||= @board.project_config.settings
|
|
305
|
-
|
|
391
|
+
def blocked_stalled_statuses settings
|
|
306
392
|
blocked_statuses = settings['blocked_statuses']
|
|
307
393
|
stalled_statuses = settings['stalled_statuses']
|
|
308
394
|
unless blocked_statuses.is_a?(Array) && stalled_statuses.is_a?(Array)
|
|
@@ -310,6 +396,14 @@ class Issue
|
|
|
310
396
|
"stalled_statuses(#{stalled_statuses.inspect}) must both be arrays"
|
|
311
397
|
end
|
|
312
398
|
|
|
399
|
+
[blocked_statuses, stalled_statuses]
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def blocked_stalled_changes end_time:, settings: nil
|
|
403
|
+
settings ||= @board.project_config.settings
|
|
404
|
+
|
|
405
|
+
blocked_statuses, stalled_statuses = blocked_stalled_statuses(settings)
|
|
406
|
+
|
|
313
407
|
blocked_link_texts = settings['blocked_link_text']
|
|
314
408
|
stalled_threshold = settings['stalled_threshold_days']
|
|
315
409
|
flagged_means_blocked = !!settings['flagged_means_blocked'] # rubocop:disable Style/DoubleNegation
|
|
@@ -608,7 +702,7 @@ class Issue
|
|
|
608
702
|
|
|
609
703
|
def dump
|
|
610
704
|
result = +''
|
|
611
|
-
result << "#{key} (#{type}): #{compact_text summary, 200}\n"
|
|
705
|
+
result << "#{key} (#{type}): #{compact_text summary, max: 200}\n"
|
|
612
706
|
|
|
613
707
|
assignee = raw['fields']['assignee']
|
|
614
708
|
result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
|
|
@@ -681,9 +775,8 @@ class Issue
|
|
|
681
775
|
def done?
|
|
682
776
|
if artificial? || board.cycletime.nil?
|
|
683
777
|
# 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'
|
|
778
|
+
# belonged to. The best we can do is look at the status key
|
|
779
|
+
status.category.done?
|
|
687
780
|
else
|
|
688
781
|
board.cycletime.done? self
|
|
689
782
|
end
|
|
@@ -706,6 +799,23 @@ class Issue
|
|
|
706
799
|
board.sprints.select { |s| sprint_ids.include? s.id }
|
|
707
800
|
end
|
|
708
801
|
|
|
802
|
+
def started_sprints
|
|
803
|
+
sprints.reject { |sprint| sprint.future? }
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def compact_text text, max: 60
|
|
807
|
+
return '' if text.nil?
|
|
808
|
+
|
|
809
|
+
if text.is_a? Hash
|
|
810
|
+
# We can't effectively compact it but we can convert it into a string.
|
|
811
|
+
text = @board.project_config.atlassian_document_format.to_html(text)
|
|
812
|
+
else
|
|
813
|
+
text = text.gsub(/\s+/, ' ').strip
|
|
814
|
+
text = "#{text[0...max]}..." if text.length > max
|
|
815
|
+
end
|
|
816
|
+
text
|
|
817
|
+
end
|
|
818
|
+
|
|
709
819
|
private
|
|
710
820
|
|
|
711
821
|
def load_history_into_changes
|
|
@@ -730,14 +840,6 @@ class Issue
|
|
|
730
840
|
end
|
|
731
841
|
end
|
|
732
842
|
|
|
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
843
|
def sort_changes!
|
|
742
844
|
@changes.sort! do |a, b|
|
|
743
845
|
# It's common that a resolved will happen at the same time as a status change.
|
|
@@ -755,6 +857,9 @@ class Issue
|
|
|
755
857
|
first_status = nil
|
|
756
858
|
first_status_id = nil
|
|
757
859
|
|
|
860
|
+
# There won't be a created timestamp in cases where this was a linked issue
|
|
861
|
+
return unless @raw['fields']['created']
|
|
862
|
+
|
|
758
863
|
created_time = parse_time @raw['fields']['created']
|
|
759
864
|
first_change = @changes.find { |change| change.field == field_name }
|
|
760
865
|
if first_change.nil?
|