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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aging_work_bar_chart.rb +10 -8
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
  5. data/lib/jirametrics/aging_work_table.rb +5 -2
  6. data/lib/jirametrics/board.rb +9 -1
  7. data/lib/jirametrics/cfd_data_builder.rb +5 -0
  8. data/lib/jirametrics/chart_base.rb +14 -2
  9. data/lib/jirametrics/cumulative_flow_diagram.rb +8 -0
  10. data/lib/jirametrics/cycletime_scatterplot.rb +4 -0
  11. data/lib/jirametrics/daily_view.rb +5 -4
  12. data/lib/jirametrics/data_quality_report.rb +3 -1
  13. data/lib/jirametrics/dependency_chart.rb +1 -1
  14. data/lib/jirametrics/downloader.rb +18 -7
  15. data/lib/jirametrics/downloader_for_cloud.rb +68 -22
  16. data/lib/jirametrics/downloader_for_data_center.rb +1 -1
  17. data/lib/jirametrics/examples/aggregated_project.rb +1 -1
  18. data/lib/jirametrics/examples/standard_project.rb +5 -2
  19. data/lib/jirametrics/exporter.rb +12 -1
  20. data/lib/jirametrics/file_config.rb +9 -11
  21. data/lib/jirametrics/file_system.rb +31 -2
  22. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  23. data/lib/jirametrics/github_gateway.rb +13 -4
  24. data/lib/jirametrics/groupable_issue_chart.rb +2 -0
  25. data/lib/jirametrics/grouping_rules.rb +5 -1
  26. data/lib/jirametrics/html/cumulative_flow_diagram.erb +7 -8
  27. data/lib/jirametrics/html/index.css +139 -88
  28. data/lib/jirametrics/html/index.erb +1 -0
  29. data/lib/jirametrics/html/index.js +1 -1
  30. data/lib/jirametrics/html/legacy_colors.css +174 -0
  31. data/lib/jirametrics/html/time_based_scatterplot.erb +8 -3
  32. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  33. data/lib/jirametrics/html_generator.rb +2 -1
  34. data/lib/jirametrics/html_report_config.rb +33 -27
  35. data/lib/jirametrics/issue.rb +99 -6
  36. data/lib/jirametrics/jira_gateway.rb +26 -7
  37. data/lib/jirametrics/mcp_server.rb +531 -0
  38. data/lib/jirametrics/project_config.rb +20 -1
  39. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +2 -2
  40. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +13 -6
  41. data/lib/jirametrics/sprint_burndown.rb +1 -1
  42. data/lib/jirametrics/stitcher.rb +5 -0
  43. data/lib/jirametrics/throughput_chart.rb +18 -2
  44. data/lib/jirametrics/time_based_scatterplot.rb +9 -2
  45. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  46. data/lib/jirametrics.rb +58 -0
  47. 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 = Object.const_get(class_name)
39
- raise NameError unless klass < ChartBase
38
+ klass = resolve_chart_class(class_name)
39
+ return super if klass.nil?
40
40
 
41
41
  block ||= ->(_) {}
42
- execute_chart klass.new(block)
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
- super
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
- klass = Object.const_get(class_name)
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
- "##{Random.bytes(3).unpack1('H*')}"
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
@@ -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['fields']['priority']['name']
52
- def priority_url = @raw['fields']['priority']['iconUrl']
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
- first_time_in_status(*board.visible_columns.collect(&:status_ids).flatten)
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
- board.project_config.file_system.log(
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 #{item['toString'].inspect}). Using id 0."
919
+ "(from #{item['fromString'].inspect} to #{to_name.inspect}). #{id_note}"
827
920
  )
828
- item = item.merge('to' => '0')
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'])