jirametrics 2.25 → 2.30
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/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/aging_work_bar_chart.rb +10 -8
- data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
- data/lib/jirametrics/aging_work_table.rb +5 -2
- data/lib/jirametrics/board.rb +9 -1
- data/lib/jirametrics/cfd_data_builder.rb +5 -0
- data/lib/jirametrics/chart_base.rb +14 -2
- data/lib/jirametrics/cumulative_flow_diagram.rb +8 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +4 -0
- data/lib/jirametrics/daily_view.rb +5 -4
- data/lib/jirametrics/data_quality_report.rb +3 -1
- data/lib/jirametrics/dependency_chart.rb +1 -1
- data/lib/jirametrics/downloader.rb +18 -7
- data/lib/jirametrics/downloader_for_cloud.rb +68 -22
- data/lib/jirametrics/downloader_for_data_center.rb +1 -1
- data/lib/jirametrics/examples/aggregated_project.rb +1 -1
- data/lib/jirametrics/examples/standard_project.rb +5 -2
- data/lib/jirametrics/exporter.rb +12 -1
- data/lib/jirametrics/file_config.rb +9 -11
- data/lib/jirametrics/file_system.rb +31 -2
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
- data/lib/jirametrics/github_gateway.rb +13 -4
- data/lib/jirametrics/groupable_issue_chart.rb +2 -0
- data/lib/jirametrics/grouping_rules.rb +5 -1
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +7 -8
- data/lib/jirametrics/html/index.css +139 -88
- data/lib/jirametrics/html/index.erb +1 -0
- data/lib/jirametrics/html/index.js +1 -1
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/time_based_scatterplot.erb +8 -3
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +2 -1
- data/lib/jirametrics/html_report_config.rb +33 -27
- data/lib/jirametrics/issue.rb +99 -6
- data/lib/jirametrics/jira_gateway.rb +26 -7
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +20 -1
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +2 -2
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +13 -6
- data/lib/jirametrics/sprint_burndown.rb +1 -1
- data/lib/jirametrics/stitcher.rb +5 -0
- data/lib/jirametrics/throughput_chart.rb +18 -2
- data/lib/jirametrics/time_based_scatterplot.rb +9 -2
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +58 -0
- metadata +36 -2
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/* Legacy color scheme for jirametrics
|
|
2
|
+
*
|
|
3
|
+
* The default colors were updated to improve accessibility for people with
|
|
4
|
+
* color vision deficiencies. If you prefer the original colors, add this to
|
|
5
|
+
* your project config:
|
|
6
|
+
*
|
|
7
|
+
* setting['include_css'] = './legacy_colors.css'
|
|
8
|
+
*
|
|
9
|
+
* and copy this file to the same directory as your config file.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/* Light mode */
|
|
13
|
+
:root,
|
|
14
|
+
html[data-theme="light"] {
|
|
15
|
+
--body-background: white;
|
|
16
|
+
--default-text-color: black;
|
|
17
|
+
--grid-line-color: lightgray;
|
|
18
|
+
--warning-banner: yellow;
|
|
19
|
+
|
|
20
|
+
--cycletime-scatterplot-overall-trendline-color: gray;
|
|
21
|
+
|
|
22
|
+
--non-working-days-color: #F0F0F0;
|
|
23
|
+
--expedited-color: red;
|
|
24
|
+
--blocked-color: #FF7400;
|
|
25
|
+
--stalled-color: orange;
|
|
26
|
+
--dead-color: black;
|
|
27
|
+
|
|
28
|
+
--type-story-color: #4bc14b;
|
|
29
|
+
--type-task-color: blue;
|
|
30
|
+
--type-bug-color: orange;
|
|
31
|
+
--type-spike-color: #9400D3;
|
|
32
|
+
|
|
33
|
+
--status-category-todo-color: gray;
|
|
34
|
+
--status-category-inprogress-color: #2663ff;
|
|
35
|
+
--status-category-done-color: #00ff00;
|
|
36
|
+
--status-category-unknown-color: black;
|
|
37
|
+
|
|
38
|
+
--aging-work-bar-chart-percentage-line-color: red;
|
|
39
|
+
--aging-work-bar-chart-separator-color: white;
|
|
40
|
+
|
|
41
|
+
--throughput_chart_total_line_color: gray;
|
|
42
|
+
|
|
43
|
+
--aging-work-in-progress-chart-shading-color: lightgray;
|
|
44
|
+
--aging-work-in-progress-chart-shading-50-color: #2E8BC0;
|
|
45
|
+
--aging-work-in-progress-chart-shading-85-color: #ADD8E6;
|
|
46
|
+
--aging-work-in-progress-chart-shading-98-color: #FF8A8A;
|
|
47
|
+
--aging-work-in-progress-chart-shading-100-color: #FF2E2E;
|
|
48
|
+
|
|
49
|
+
--aging-work-in-progress-by-age-trend-line-color: gray;
|
|
50
|
+
|
|
51
|
+
--aging-work-table-date-in-jeopardy: yellow;
|
|
52
|
+
--aging-work-table-date-overdue: red;
|
|
53
|
+
|
|
54
|
+
--hierarchy-table-inactive-item-text-color: gray;
|
|
55
|
+
|
|
56
|
+
--wip-chart-completed-color: #00ff00;
|
|
57
|
+
--wip-chart-completed-but-not-started-color: #99FF99;
|
|
58
|
+
--wip-chart-duration-less-than-day-color: #ffef41;
|
|
59
|
+
--wip-chart-duration-week-or-less-color: #dcc900;
|
|
60
|
+
--wip-chart-duration-two-weeks-or-less-color: #dfa000;
|
|
61
|
+
--wip-chart-duration-four-weeks-or-less-color: #eb7200;
|
|
62
|
+
--wip-chart-duration-more-than-four-weeks-color: #e70000;
|
|
63
|
+
--wip-chart-active-color: #326cff;
|
|
64
|
+
--wip-chart-border-color: gray;
|
|
65
|
+
|
|
66
|
+
--wip-by-column-chart-bar-fill-color: #0072B2;
|
|
67
|
+
--wip-by-column-chart-bar-text-color: #ffffff;
|
|
68
|
+
--wip-by-column-chart-limit-line-color: #D55E00;
|
|
69
|
+
--wip-by-column-chart-recommendation-color: #009E73;
|
|
70
|
+
|
|
71
|
+
--estimate-accuracy-chart-completed-fill-color: #00ff00;
|
|
72
|
+
--estimate-accuracy-chart-completed-border-color: green;
|
|
73
|
+
--estimate-accuracy-chart-active-fill-color: #FFCCCB;
|
|
74
|
+
--estimate-accuracy-chart-active-border-color: red;
|
|
75
|
+
|
|
76
|
+
--expedited-chart-no-longer-expedited: gray;
|
|
77
|
+
--expedited-chart-dot-issue-started-color: orange;
|
|
78
|
+
--expedited-chart-dot-issue-stopped-color: green;
|
|
79
|
+
--expedited-chart-dot-expedite-started-color: red;
|
|
80
|
+
--expedited-chart-dot-expedite-stopped-color: green;
|
|
81
|
+
|
|
82
|
+
--sprint-burndown-sprint-color-1: blue;
|
|
83
|
+
--sprint-burndown-sprint-color-2: orange;
|
|
84
|
+
--sprint-burndown-sprint-color-3: green;
|
|
85
|
+
--sprint-burndown-sprint-color-4: red;
|
|
86
|
+
--sprint-burndown-sprint-color-5: brown;
|
|
87
|
+
--sprint-burndown-sprint-color-6: blue; /* wraps back to color-1 (legacy had only 5) */
|
|
88
|
+
--sprint-burndown-sprint-color-7: orange; /* wraps back to color-2 (legacy had only 5) */
|
|
89
|
+
|
|
90
|
+
--sprint-color: lightblue;
|
|
91
|
+
|
|
92
|
+
--daily-view-selected-issue-background: lightgray;
|
|
93
|
+
--daily-view-issue-border: green;
|
|
94
|
+
--daily-view-selected-issue-border: red;
|
|
95
|
+
|
|
96
|
+
--priority-color-highest: #dc2626;
|
|
97
|
+
--priority-color-high: #ea580c;
|
|
98
|
+
--priority-color-medium: #9ca3af;
|
|
99
|
+
--priority-color-low: #0891b2;
|
|
100
|
+
--priority-color-lowest: #64748b;
|
|
101
|
+
--priority-color-notset: gray;
|
|
102
|
+
--priority-color-critical: red;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* Dark mode */
|
|
106
|
+
html[data-theme="dark"] {
|
|
107
|
+
--warning-banner: #9F2B00;
|
|
108
|
+
--non-working-days-color: #2f2f2f;
|
|
109
|
+
--type-story-color: #6fb86f;
|
|
110
|
+
--type-task-color: #0021b3;
|
|
111
|
+
--type-bug-color: #bb5603;
|
|
112
|
+
--body-background: #343434;
|
|
113
|
+
--default-text-color: #aaa;
|
|
114
|
+
--grid-line-color: #424242;
|
|
115
|
+
--expedited-color: #b90000;
|
|
116
|
+
--blocked-color: #c75b02;
|
|
117
|
+
--stalled-color: #ae7202;
|
|
118
|
+
--wip-chart-active-color: #2551c1;
|
|
119
|
+
--status-category-inprogress-color: #1c49bb;
|
|
120
|
+
--hierarchy-table-inactive-item-text-color: #939393;
|
|
121
|
+
--wip-by-column-chart-bar-fill-color: #56B4E9;
|
|
122
|
+
--wip-by-column-chart-bar-text-color: #000000;
|
|
123
|
+
--wip-by-column-chart-limit-line-color: #E69F00;
|
|
124
|
+
--wip-by-column-chart-recommendation-color: #2DCB9A;
|
|
125
|
+
--wip-chart-completed-color: #03cb03;
|
|
126
|
+
--wip-chart-duration-less-than-day-color: #d2d988;
|
|
127
|
+
--wip-chart-duration-week-or-less-color: #dfcd00;
|
|
128
|
+
--wip-chart-duration-two-weeks-or-less-color: #cf9400;
|
|
129
|
+
--wip-chart-duration-four-weeks-or-less-color: #c25e00;
|
|
130
|
+
--wip-chart-duration-more-than-four-weeks-color: #8e0000;
|
|
131
|
+
--daily-view-selected-issue-background: #474747;
|
|
132
|
+
--priority-color-highest: #ef4444;
|
|
133
|
+
--priority-color-high: #f97316;
|
|
134
|
+
--priority-color-low: #06b6d4;
|
|
135
|
+
--priority-color-lowest: #94a3b8;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@media screen and (prefers-color-scheme: dark) {
|
|
139
|
+
:root {
|
|
140
|
+
--warning-banner: #9F2B00;
|
|
141
|
+
--non-working-days-color: #2f2f2f;
|
|
142
|
+
--type-story-color: #6fb86f;
|
|
143
|
+
--type-task-color: #0021b3;
|
|
144
|
+
--type-bug-color: #bb5603;
|
|
145
|
+
--body-background: #343434;
|
|
146
|
+
--default-text-color: #aaa;
|
|
147
|
+
--grid-line-color: #424242;
|
|
148
|
+
--expedited-color: #b90000;
|
|
149
|
+
--blocked-color: #c75b02;
|
|
150
|
+
--stalled-color: #ae7202;
|
|
151
|
+
--dead-color: black;
|
|
152
|
+
--wip-chart-active-color: #2551c1;
|
|
153
|
+
--status-category-inprogress-color: #1c49bb;
|
|
154
|
+
--cycletime-scatterplot-overall-trendline-color: gray;
|
|
155
|
+
--hierarchy-table-inactive-item-text-color: #939393;
|
|
156
|
+
--wip-by-column-chart-bar-fill-color: #56B4E9;
|
|
157
|
+
--wip-by-column-chart-bar-text-color: #000000;
|
|
158
|
+
--wip-by-column-chart-limit-line-color: #E69F00;
|
|
159
|
+
--wip-by-column-chart-recommendation-color: #2DCB9A;
|
|
160
|
+
--wip-chart-completed-color: #03cb03;
|
|
161
|
+
--wip-chart-completed-but-not-started-color: #99FF99;
|
|
162
|
+
--wip-chart-duration-less-than-day-color: #d2d988;
|
|
163
|
+
--wip-chart-duration-week-or-less-color: #dfcd00;
|
|
164
|
+
--wip-chart-duration-two-weeks-or-less-color: #cf9400;
|
|
165
|
+
--wip-chart-duration-four-weeks-or-less-color: #c25e00;
|
|
166
|
+
--wip-chart-duration-more-than-four-weeks-color: #8e0000;
|
|
167
|
+
--daily-view-selected-issue-background: #474747;
|
|
168
|
+
--priority-color-highest: #ef4444;
|
|
169
|
+
--priority-color-high: #f97316;
|
|
170
|
+
--priority-color-medium: #9ca3af;
|
|
171
|
+
--priority-color-low: #06b6d4;
|
|
172
|
+
--priority-color-lowest: #94a3b8;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -28,15 +28,20 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
28
28
|
max: "<%= (date_range.end + 1).to_s %>"
|
|
29
29
|
},
|
|
30
30
|
y: {
|
|
31
|
+
min: 0,
|
|
32
|
+
max: <%= (@highest_y_value * 1.1).ceil %>,
|
|
31
33
|
scaleLabel: {
|
|
32
|
-
display: true
|
|
33
|
-
min: 0,
|
|
34
|
-
max: <%= @highest_y_value %>
|
|
34
|
+
display: true
|
|
35
35
|
},
|
|
36
36
|
<%= render_axis_title :y %>
|
|
37
37
|
grid: {
|
|
38
38
|
color: <%= CssVariable['--grid-line-color'].to_json %>
|
|
39
39
|
},
|
|
40
|
+
ticks: {
|
|
41
|
+
callback: function(value, index, ticks) {
|
|
42
|
+
return index === ticks.length - 1 ? null : value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
40
45
|
}
|
|
41
46
|
},
|
|
42
47
|
plugins: {
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
<%= seam_start %>
|
|
2
|
+
<div class="chart" style="position:relative;">
|
|
3
|
+
<canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
|
|
4
|
+
<div id="<%= chart_id %>-tooltip" style="
|
|
5
|
+
display:none; position:absolute; pointer-events:none;
|
|
6
|
+
background:rgba(0,0,0,0.75); color:#fff; border-radius:4px;
|
|
7
|
+
padding:4px 8px; font:12px sans-serif; white-space:nowrap;
|
|
8
|
+
"></div>
|
|
9
|
+
</div>
|
|
10
|
+
<script>
|
|
11
|
+
(function() {
|
|
12
|
+
var wipData = <%= @wip_data.to_json %>;
|
|
13
|
+
var wipLimits = <%= @wip_limits.to_json %>;
|
|
14
|
+
var recommendations = <%= @recommendations.to_json %>;
|
|
15
|
+
var maxWip = <%= @max_wip %>;
|
|
16
|
+
var gridColor = <%= CssVariable['--grid-line-color'].to_json %>;
|
|
17
|
+
var barFillColor = <%= CssVariable['--wip-by-column-chart-bar-fill-color'].to_json %>;
|
|
18
|
+
var barTextColor = <%= CssVariable['--wip-by-column-chart-bar-text-color'].to_json %>;
|
|
19
|
+
var limitColor = <%= CssVariable['--wip-by-column-chart-limit-line-color'].to_json %>;
|
|
20
|
+
var recColor = <%= CssVariable['--wip-by-column-chart-recommendation-color'].to_json %>;
|
|
21
|
+
var tooltipEl = document.getElementById(<%= "#{chart_id}-tooltip".inspect %>);
|
|
22
|
+
|
|
23
|
+
var hitAreas = [];
|
|
24
|
+
|
|
25
|
+
var rectPlugin = {
|
|
26
|
+
id: 'wipRects',
|
|
27
|
+
afterDraw: function(chart) {
|
|
28
|
+
var ctx = chart.ctx;
|
|
29
|
+
var xScale = chart.scales['x'];
|
|
30
|
+
var yScale = chart.scales['y'];
|
|
31
|
+
var slotWidth = xScale.width / Math.max(xScale.ticks.length, 1);
|
|
32
|
+
|
|
33
|
+
hitAreas = [];
|
|
34
|
+
|
|
35
|
+
// 1. Draw y-axis gridlines at integer band boundaries
|
|
36
|
+
ctx.save();
|
|
37
|
+
ctx.beginPath();
|
|
38
|
+
ctx.rect(xScale.left, yScale.top, xScale.right - xScale.left, yScale.bottom - yScale.top);
|
|
39
|
+
ctx.clip();
|
|
40
|
+
ctx.strokeStyle = gridColor;
|
|
41
|
+
ctx.lineWidth = 1;
|
|
42
|
+
ctx.setLineDash([]);
|
|
43
|
+
for (var gi = 0; gi <= maxWip + 1; gi++) {
|
|
44
|
+
var gy = yScale.getPixelForValue(gi);
|
|
45
|
+
ctx.beginPath();
|
|
46
|
+
ctx.moveTo(xScale.left, gy);
|
|
47
|
+
ctx.lineTo(xScale.right, gy);
|
|
48
|
+
ctx.stroke();
|
|
49
|
+
}
|
|
50
|
+
ctx.restore();
|
|
51
|
+
|
|
52
|
+
// 2. Draw WIP limit lines (behind rectangles)
|
|
53
|
+
wipLimits.forEach(function(limits, colIndex) {
|
|
54
|
+
var xCenter = xScale.getPixelForValue(colIndex);
|
|
55
|
+
var halfSlot = slotWidth * 0.45;
|
|
56
|
+
|
|
57
|
+
[['min', 'bottom'], ['max', 'top']].forEach(function(pair) {
|
|
58
|
+
var type = pair[0];
|
|
59
|
+
var baseline = pair[1];
|
|
60
|
+
var val = limits[type];
|
|
61
|
+
if (val === null || val === undefined) return;
|
|
62
|
+
|
|
63
|
+
var y = yScale.getPixelForValue(val + 0.5);
|
|
64
|
+
|
|
65
|
+
ctx.save();
|
|
66
|
+
ctx.strokeStyle = limitColor;
|
|
67
|
+
ctx.lineWidth = 2;
|
|
68
|
+
ctx.setLineDash([5, 3]);
|
|
69
|
+
ctx.beginPath();
|
|
70
|
+
ctx.moveTo(xCenter - halfSlot, y);
|
|
71
|
+
ctx.lineTo(xCenter + halfSlot, y);
|
|
72
|
+
ctx.stroke();
|
|
73
|
+
ctx.restore();
|
|
74
|
+
|
|
75
|
+
ctx.save();
|
|
76
|
+
ctx.fillStyle = limitColor;
|
|
77
|
+
ctx.font = 'bold 10px sans-serif';
|
|
78
|
+
ctx.textAlign = 'right';
|
|
79
|
+
ctx.textBaseline = baseline;
|
|
80
|
+
ctx.fillText(type + ': ' + val, xCenter + halfSlot, baseline === 'bottom' ? y - 2 : y + 2);
|
|
81
|
+
ctx.restore();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
<% if @show_recommendations %>
|
|
86
|
+
// 3. Draw recommendation lines (behind rectangles, label on left)
|
|
87
|
+
recommendations.forEach(function(rec, colIndex) {
|
|
88
|
+
if (rec === null || rec === undefined || rec === 0) return;
|
|
89
|
+
var xCenter = xScale.getPixelForValue(colIndex);
|
|
90
|
+
var halfSlot = slotWidth * 0.45;
|
|
91
|
+
var y = yScale.getPixelForValue(rec + 0.5);
|
|
92
|
+
|
|
93
|
+
ctx.save();
|
|
94
|
+
ctx.strokeStyle = recColor;
|
|
95
|
+
ctx.lineWidth = 2;
|
|
96
|
+
ctx.setLineDash([5, 3]);
|
|
97
|
+
ctx.beginPath();
|
|
98
|
+
ctx.moveTo(xCenter - halfSlot, y);
|
|
99
|
+
ctx.lineTo(xCenter + halfSlot, y);
|
|
100
|
+
ctx.stroke();
|
|
101
|
+
ctx.restore();
|
|
102
|
+
|
|
103
|
+
ctx.save();
|
|
104
|
+
ctx.fillStyle = recColor;
|
|
105
|
+
ctx.font = 'bold 10px sans-serif';
|
|
106
|
+
ctx.textAlign = 'left';
|
|
107
|
+
ctx.textBaseline = 'top';
|
|
108
|
+
ctx.fillText('rec: ' + rec, xCenter - halfSlot, y + 2);
|
|
109
|
+
ctx.restore();
|
|
110
|
+
});
|
|
111
|
+
<% end %>
|
|
112
|
+
|
|
113
|
+
// 4. Draw WIP rectangles centered in their bands (wip + 0.5)
|
|
114
|
+
var yStep = Math.abs(yScale.getPixelForValue(0.5) - yScale.getPixelForValue(1.5));
|
|
115
|
+
|
|
116
|
+
wipData.forEach(function(colData, colIndex) {
|
|
117
|
+
var xCenter = xScale.getPixelForValue(colIndex);
|
|
118
|
+
|
|
119
|
+
colData.forEach(function(entry) {
|
|
120
|
+
var wip = entry['wip'];
|
|
121
|
+
var pct = entry['pct'];
|
|
122
|
+
var rectWidth = slotWidth * pct / 100;
|
|
123
|
+
var rectHeight = yStep * 0.8;
|
|
124
|
+
var yCenter = yScale.getPixelForValue(wip + 0.5);
|
|
125
|
+
var x1 = xCenter - rectWidth / 2;
|
|
126
|
+
var y1 = yCenter - rectHeight / 2;
|
|
127
|
+
|
|
128
|
+
ctx.save();
|
|
129
|
+
ctx.fillStyle = barFillColor;
|
|
130
|
+
ctx.strokeStyle = barFillColor;
|
|
131
|
+
ctx.lineWidth = 1;
|
|
132
|
+
ctx.fillRect(x1, y1, rectWidth, rectHeight);
|
|
133
|
+
ctx.strokeRect(x1, y1, rectWidth, rectHeight);
|
|
134
|
+
|
|
135
|
+
ctx.fillStyle = barTextColor;
|
|
136
|
+
ctx.font = '11px sans-serif';
|
|
137
|
+
ctx.textAlign = 'center';
|
|
138
|
+
ctx.textBaseline = 'middle';
|
|
139
|
+
if (rectWidth > 25) {
|
|
140
|
+
ctx.fillText(pct + '%', xCenter, yCenter);
|
|
141
|
+
}
|
|
142
|
+
ctx.restore();
|
|
143
|
+
|
|
144
|
+
var hitWidth = Math.max(rectWidth, slotWidth);
|
|
145
|
+
hitAreas.push({
|
|
146
|
+
x1: xCenter - hitWidth / 2, y1: y1,
|
|
147
|
+
x2: xCenter + hitWidth / 2, y2: y1 + rectHeight,
|
|
148
|
+
label: 'WIP ' + wip + ': ' + pct + '%'
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
var canvas = document.getElementById(<%= chart_id.inspect %>);
|
|
156
|
+
|
|
157
|
+
canvas.addEventListener('mousemove', function(e) {
|
|
158
|
+
var rect = canvas.getBoundingClientRect();
|
|
159
|
+
var mx = e.clientX - rect.left;
|
|
160
|
+
var my = e.clientY - rect.top;
|
|
161
|
+
|
|
162
|
+
var hit = null;
|
|
163
|
+
for (var i = 0; i < hitAreas.length; i++) {
|
|
164
|
+
var a = hitAreas[i];
|
|
165
|
+
if (mx >= a.x1 && mx <= a.x2 && my >= a.y1 && my <= a.y2) {
|
|
166
|
+
hit = a;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (hit) {
|
|
172
|
+
tooltipEl.textContent = hit.label;
|
|
173
|
+
tooltipEl.style.display = 'block';
|
|
174
|
+
tooltipEl.style.left = (mx + 10) + 'px';
|
|
175
|
+
tooltipEl.style.top = (my - 20) + 'px';
|
|
176
|
+
} else {
|
|
177
|
+
tooltipEl.style.display = 'none';
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
canvas.addEventListener('mouseleave', function() {
|
|
182
|
+
tooltipEl.style.display = 'none';
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
new Chart(canvas.getContext('2d'),
|
|
186
|
+
{
|
|
187
|
+
type: 'bar',
|
|
188
|
+
plugins: [rectPlugin],
|
|
189
|
+
data: {
|
|
190
|
+
labels: <%= @column_names.to_json %>,
|
|
191
|
+
datasets: [{
|
|
192
|
+
data: [],
|
|
193
|
+
backgroundColor: 'transparent'
|
|
194
|
+
}]
|
|
195
|
+
},
|
|
196
|
+
options: {
|
|
197
|
+
responsive: <%= canvas_responsive? %>,
|
|
198
|
+
scales: {
|
|
199
|
+
x: {
|
|
200
|
+
grid: {
|
|
201
|
+
color: gridColor,
|
|
202
|
+
z: 1
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
y: {
|
|
206
|
+
title: {
|
|
207
|
+
display: true,
|
|
208
|
+
text: 'WIP'
|
|
209
|
+
},
|
|
210
|
+
grid: {
|
|
211
|
+
display: false
|
|
212
|
+
},
|
|
213
|
+
min: 0,
|
|
214
|
+
max: <%= @max_wip + 1 %>,
|
|
215
|
+
afterBuildTicks: function(scale) {
|
|
216
|
+
scale.ticks = [];
|
|
217
|
+
for (var i = 0; i <= maxWip; i++) {
|
|
218
|
+
scale.ticks.push({ value: i + 0.5 });
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
ticks: {
|
|
222
|
+
callback: function(value) {
|
|
223
|
+
return Math.round(value - 0.5);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
plugins: {
|
|
229
|
+
legend: {
|
|
230
|
+
display: false
|
|
231
|
+
},
|
|
232
|
+
tooltip: {
|
|
233
|
+
enabled: false
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
})();
|
|
239
|
+
</script>
|
|
240
|
+
<%= seam_end %>
|
|
241
|
+
<% unless @recommendation_texts.empty? %>
|
|
242
|
+
<div style="margin-top: 0.5em;">
|
|
243
|
+
<strong>WIP limit recommendations</strong>
|
|
244
|
+
<ul style="margin: 0.3em 0 0 0; padding-left: 1.5em;">
|
|
245
|
+
<% @recommendation_texts.each do |text| %>
|
|
246
|
+
<li><%= text %></li>
|
|
247
|
+
<% end %>
|
|
248
|
+
</ul>
|
|
249
|
+
</div>
|
|
250
|
+
<% end %>
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
class HtmlGenerator
|
|
4
4
|
attr_accessor :file_system, :settings
|
|
5
5
|
|
|
6
|
-
def create_html output_filename:, settings:
|
|
6
|
+
def create_html output_filename:, settings:, project_name: ''
|
|
7
7
|
@settings = settings
|
|
8
|
+
project_name = project_name.to_s
|
|
8
9
|
html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
|
|
9
10
|
css = load_css html_directory: html_directory
|
|
10
11
|
javascript = file_system.load(File.join(html_directory, 'index.js'))
|
|
@@ -33,23 +33,43 @@ class HtmlReportConfig < HtmlGenerator
|
|
|
33
33
|
@charts = [] # Where we store all the charts we executed so we can assert against them.
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
def method_missing name, &block
|
|
36
|
+
def method_missing name, *_args, board_id: nil, **_kwargs, &block
|
|
37
37
|
class_name = name.to_s.split('_').map(&:capitalize).join
|
|
38
|
-
klass =
|
|
39
|
-
|
|
38
|
+
klass = resolve_chart_class(class_name)
|
|
39
|
+
return super if klass.nil?
|
|
40
40
|
|
|
41
41
|
block ||= ->(_) {}
|
|
42
|
-
|
|
42
|
+
|
|
43
|
+
if klass.instance_method(:board_id=).owner == klass
|
|
44
|
+
execute_chart_per_board klass: klass, block: block, board_id: board_id
|
|
45
|
+
else
|
|
46
|
+
execute_chart klass.new(block)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolve_chart_class class_name
|
|
51
|
+
klass = Object.const_get(class_name)
|
|
52
|
+
klass < ChartBase ? klass : nil
|
|
43
53
|
rescue NameError
|
|
44
|
-
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def execute_chart_per_board klass:, block:, board_id:
|
|
58
|
+
all_boards = @file_config.project_config.all_boards
|
|
59
|
+
ids = board_id ? [board_id] : issues.collect { |i| i.board.id }.uniq
|
|
60
|
+
ids = ids.sort_by { |id| all_boards[id]&.name || '' }
|
|
61
|
+
ids.each_with_index do |id, index|
|
|
62
|
+
execute_chart(klass.new(block)) do |chart|
|
|
63
|
+
chart.board_id = id
|
|
64
|
+
# We're showing the description only on the first one in order to reduce noise on the report
|
|
65
|
+
chart.description_text nil unless index.zero?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
45
68
|
end
|
|
46
69
|
|
|
47
70
|
def respond_to_missing? name, include_private = false
|
|
48
71
|
class_name = name.to_s.split('_').map(&:capitalize).join
|
|
49
|
-
|
|
50
|
-
klass < ChartBase
|
|
51
|
-
rescue NameError
|
|
52
|
-
super
|
|
72
|
+
!resolve_chart_class(class_name).nil? || super
|
|
53
73
|
end
|
|
54
74
|
|
|
55
75
|
def cycletime label = nil, &block
|
|
@@ -78,7 +98,8 @@ class HtmlReportConfig < HtmlGenerator
|
|
|
78
98
|
|
|
79
99
|
html create_footer
|
|
80
100
|
|
|
81
|
-
create_html output_filename: @file_config.output_filename, settings: settings
|
|
101
|
+
create_html output_filename: @file_config.output_filename, settings: settings,
|
|
102
|
+
project_name: @file_config.project_config.name
|
|
82
103
|
end
|
|
83
104
|
|
|
84
105
|
def file_system
|
|
@@ -97,24 +118,9 @@ class HtmlReportConfig < HtmlGenerator
|
|
|
97
118
|
@file_config.project_config.exporter.timezone_offset
|
|
98
119
|
end
|
|
99
120
|
|
|
100
|
-
def aging_work_in_progress_chart board_id: nil, &block
|
|
101
|
-
block ||= ->(_) {}
|
|
102
|
-
|
|
103
|
-
if board_id.nil?
|
|
104
|
-
ids = issues.collect { |i| i.board.id }.uniq.sort
|
|
105
|
-
else
|
|
106
|
-
ids = [board_id]
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
ids.each do |id|
|
|
110
|
-
execute_chart(AgingWorkInProgressChart.new(block)) do |chart|
|
|
111
|
-
chart.board_id = id
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
|
|
116
121
|
def random_color
|
|
117
|
-
|
|
122
|
+
@palette_index = (@palette_index || -1) + 1
|
|
123
|
+
ChartBase::OKABE_ITO_PALETTE[@palette_index % ChartBase::OKABE_ITO_PALETTE.size]
|
|
118
124
|
end
|
|
119
125
|
|
|
120
126
|
def html string, type: :body
|
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -48,8 +48,8 @@ class Issue
|
|
|
48
48
|
def type = @raw['fields']['issuetype']['name']
|
|
49
49
|
def type_icon_url = @raw['fields']['issuetype']['iconUrl']
|
|
50
50
|
|
|
51
|
-
def priority_name = @raw
|
|
52
|
-
def priority_url = @raw
|
|
51
|
+
def priority_name = @raw.dig('fields', 'priority', 'name')
|
|
52
|
+
def priority_url = @raw.dig('fields', 'priority', 'iconUrl')
|
|
53
53
|
|
|
54
54
|
def summary = @raw['fields']['summary']
|
|
55
55
|
|
|
@@ -210,7 +210,25 @@ class Issue
|
|
|
210
210
|
end
|
|
211
211
|
|
|
212
212
|
def first_time_visible_on_board
|
|
213
|
-
|
|
213
|
+
visible_status_ids = board.visible_columns.collect(&:status_ids).flatten
|
|
214
|
+
return first_time_in_status(*visible_status_ids) unless board.scrum?
|
|
215
|
+
|
|
216
|
+
# For scrum boards, an issue is only visible when BOTH conditions are true simultaneously:
|
|
217
|
+
# 1. Its status is in a visible column
|
|
218
|
+
# 2. It is in an active sprint
|
|
219
|
+
# At each moment one condition becomes true, check if the other is already true.
|
|
220
|
+
candidates = []
|
|
221
|
+
|
|
222
|
+
status_changes.each do |change|
|
|
223
|
+
next unless visible_status_ids.include?(change.value_id)
|
|
224
|
+
candidates << change if in_active_sprint_at?(change.time)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
sprint_entry_events.each do |effective_time, representative_change|
|
|
228
|
+
candidates << representative_change if in_visible_status_at?(effective_time, visible_status_ids)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
candidates.min_by(&:time)
|
|
214
232
|
end
|
|
215
233
|
|
|
216
234
|
def reasons_not_visible_on_board
|
|
@@ -815,17 +833,92 @@ class Issue
|
|
|
815
833
|
|
|
816
834
|
private
|
|
817
835
|
|
|
836
|
+
# Returns [[effective_time, change_item]] for each moment the issue entered an active sprint.
|
|
837
|
+
# Skips sprints that were removed before they activated.
|
|
838
|
+
def sprint_entry_events
|
|
839
|
+
data_clazz = Struct.new(:sprint_id, :sprint_start, :add_time, :change)
|
|
840
|
+
events = []
|
|
841
|
+
in_sprint = []
|
|
842
|
+
|
|
843
|
+
@changes.each do |change|
|
|
844
|
+
next unless change.sprint?
|
|
845
|
+
|
|
846
|
+
(change.value_id - change.old_value_id).each do |sprint_id|
|
|
847
|
+
sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
|
|
848
|
+
in_sprint << data_clazz.new(sprint_id, sprint_start, change.time, change) if sprint_start
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
(change.old_value_id - change.value_id).each do |sprint_id|
|
|
852
|
+
data = in_sprint.find { |d| d.sprint_id == sprint_id }
|
|
853
|
+
next unless data
|
|
854
|
+
|
|
855
|
+
in_sprint.delete(data)
|
|
856
|
+
next if data.sprint_start >= change.time # sprint hadn't activated before removal
|
|
857
|
+
|
|
858
|
+
effective_time = [data.add_time, data.sprint_start].max
|
|
859
|
+
events << [effective_time, sprint_change_at(effective_time, data.change)]
|
|
860
|
+
end
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
in_sprint.each do |data|
|
|
864
|
+
effective_time = [data.add_time, data.sprint_start].max
|
|
865
|
+
events << [effective_time, sprint_change_at(effective_time, data.change)]
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
events
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
def sprint_change_at effective_time, change
|
|
872
|
+
return change if effective_time == change.time
|
|
873
|
+
|
|
874
|
+
ChangeItem.new(
|
|
875
|
+
raw: { 'field' => 'Sprint', 'toString' => 'Sprint activated', 'to' => '0', 'from' => nil, 'fromString' => nil },
|
|
876
|
+
author_raw: nil,
|
|
877
|
+
time: effective_time,
|
|
878
|
+
artificial: true
|
|
879
|
+
)
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
def in_active_sprint_at? time
|
|
883
|
+
active_ids = []
|
|
884
|
+
@changes.each do |change|
|
|
885
|
+
break if change.time > time
|
|
886
|
+
next unless change.sprint?
|
|
887
|
+
|
|
888
|
+
(change.value_id - change.old_value_id).each do |sprint_id|
|
|
889
|
+
sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
|
|
890
|
+
active_ids << sprint_id if sprint_start && sprint_start <= time
|
|
891
|
+
end
|
|
892
|
+
(change.old_value_id - change.value_id).each { |id| active_ids.delete(id) }
|
|
893
|
+
end
|
|
894
|
+
active_ids.any?
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
def in_visible_status_at? time, visible_status_ids
|
|
898
|
+
last = status_changes.reverse.find { |c| c.time <= time }
|
|
899
|
+
last && visible_status_ids.include?(last.value_id)
|
|
900
|
+
end
|
|
901
|
+
|
|
818
902
|
def load_history_into_changes
|
|
819
903
|
@raw['changelog']['histories']&.each do |history|
|
|
820
904
|
created = parse_time(history['created'])
|
|
821
905
|
|
|
822
906
|
history['items']&.each do |item|
|
|
823
907
|
if item['field'] == 'status' && item['to'].nil?
|
|
824
|
-
|
|
908
|
+
to_name = item['toString']
|
|
909
|
+
matches = board.possible_statuses.find_all_by_name(to_name)
|
|
910
|
+
guessed_id, id_note = if matches.length == 1
|
|
911
|
+
[matches.first.id.to_s, "Guessed id #{matches.first.id} from status name."]
|
|
912
|
+
elsif matches.length > 1
|
|
913
|
+
['0', "Multiple statuses named #{to_name.inspect} exist (ids: #{matches.map(&:id).join(', ')}); cannot disambiguate. Using id 0."]
|
|
914
|
+
else
|
|
915
|
+
['0', "No known status named #{to_name.inspect}. Using id 0."]
|
|
916
|
+
end
|
|
917
|
+
board.project_config.file_system.warning(
|
|
825
918
|
"Issue #{key} has a status change without a 'to' id " \
|
|
826
|
-
"(from #{item['fromString'].inspect} to #{
|
|
919
|
+
"(from #{item['fromString'].inspect} to #{to_name.inspect}). #{id_note}"
|
|
827
920
|
)
|
|
828
|
-
item = item.merge('to' =>
|
|
921
|
+
item = item.merge('to' => guessed_id)
|
|
829
922
|
end
|
|
830
923
|
|
|
831
924
|
@changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
|