jirametrics 3.3 → 3.4

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aging_work_bar_chart.rb +2 -4
  3. data/lib/jirametrics/aging_work_in_progress_chart.rb +26 -26
  4. data/lib/jirametrics/atlassian_document_format.rb +22 -14
  5. data/lib/jirametrics/board_movement_calculator.rb +0 -23
  6. data/lib/jirametrics/change_item.rb +4 -8
  7. data/lib/jirametrics/chart_base.rb +44 -15
  8. data/lib/jirametrics/chart_format.rb +15 -0
  9. data/lib/jirametrics/daily_view.rb +6 -7
  10. data/lib/jirametrics/daily_wip_by_parent_chart.rb +1 -1
  11. data/lib/jirametrics/data_quality_report.rb +3 -3
  12. data/lib/jirametrics/dependency_chart.rb +109 -20
  13. data/lib/jirametrics/estimate_accuracy_chart.rb +37 -8
  14. data/lib/jirametrics/expedited_chart.rb +5 -6
  15. data/lib/jirametrics/exporter.rb +24 -0
  16. data/lib/jirametrics/flow_efficiency_scatterplot.rb +3 -5
  17. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +10 -7
  18. data/lib/jirametrics/html/estimate_accuracy_chart.erb +34 -1
  19. data/lib/jirametrics/html/index.css +55 -9
  20. data/lib/jirametrics/html/index.js +13 -0
  21. data/lib/jirametrics/html/legacy_colors.css +18 -0
  22. data/lib/jirametrics/html/time_based_histogram.erb +9 -5
  23. data/lib/jirametrics/html/time_based_scatterplot.erb +2 -1
  24. data/lib/jirametrics/issue.rb +5 -7
  25. data/lib/jirametrics/issue_printer.rb +5 -3
  26. data/lib/jirametrics/jira_gateway.rb +5 -7
  27. data/lib/jirametrics/sprint_burndown.rb +2 -2
  28. data/lib/jirametrics/testing/mock_board.rb +34 -0
  29. data/lib/jirametrics/testing/mock_change_item.rb +112 -0
  30. data/lib/jirametrics/testing/mock_cycle_time_config.rb +73 -0
  31. data/lib/jirametrics/testing/mock_issue.rb +108 -0
  32. data/lib/jirametrics/testing.rb +88 -0
  33. data/lib/jirametrics/time_based_histogram.rb +16 -4
  34. data/lib/jirametrics/time_based_scatterplot.rb +2 -4
  35. data/lib/jirametrics/user.rb +16 -2
  36. data/lib/jirametrics/wip_by_column_chart.rb +1 -2
  37. data/lib/jirametrics.rb +6 -1
  38. metadata +7 -1
@@ -14,17 +14,24 @@ class EstimateAccuracyChart < ChartBase
14
14
  The #{color_block '--estimate-accuracy-chart-completed-fill-color'} completed dots indicate
15
15
  cycletimes.
16
16
  <% if @has_aging_data %>
17
- The #{color_block '--estimate-accuracy-chart-active-fill-color'} aging dots
17
+ The #{color_block '--estimate-accuracy-chart-active-fill-color'} aging arrows
18
18
  (click on the legend to turn them on) show the current
19
- age of items, which will give you a hint as to where they might end up. If they're already
20
- far to the right then you know you have a problem.
19
+ age of items, which will give you a hint as to where they might end up. They point right
20
+ because those items haven't finished yet, so every one of them will keep moving that way
21
+ until it does. If they're already far to the right then you know you have a problem.
21
22
  <% end %>
22
23
  </div>
23
24
  <% if @correlation_coefficient %>
24
25
  <div class="p">
25
26
  The completed items here have a correlation coefficient of <b><%= @correlation_coefficient.round(3) %></b>.
26
27
  The closer it is to +1, the stronger the positive correlation. The closer it is to -1,
27
- the stronger the negative collalation. Zero would mean no correlation at all.
28
+ the stronger the negative correlation. Zero would mean no correlation at all.
29
+ </div>
30
+ <% elsif @estimates_are_numeric == false %>
31
+ <div class="p">
32
+ There's no correlation coefficient here because these estimates are categories rather than
33
+ numbers. Working one out means measuring the gaps between values, and there's no defined
34
+ gap between one category and the next, so any number we showed you would be invented.
28
35
  </div>
29
36
  <% end %>
30
37
  HTML
@@ -46,14 +53,15 @@ class EstimateAccuracyChart < ChartBase
46
53
  end
47
54
  data_sets = scan_issues
48
55
 
49
- return '' if data_sets.empty?
56
+ return render_no_data if data_sets.empty?
50
57
 
51
58
  wrap_and_render(binding, __FILE__)
52
59
  end
53
60
 
54
61
  def scan_issues
55
62
  completed_hash, aging_hash = split_into_completed_and_aging issues: issues
56
- @correlation_coefficient = correlation_coefficient(completed_hash) unless completed_hash.empty?
63
+ @estimates_are_numeric = numeric_estimates? completed_hash
64
+ @correlation_coefficient = correlation_coefficient(completed_hash) if @estimates_are_numeric
57
65
  estimation_units = current_board.estimation_configuration.units
58
66
  @has_aging_data = !aging_hash.empty?
59
67
 
@@ -77,7 +85,7 @@ class EstimateAccuracyChart < ChartBase
77
85
  {
78
86
  'x' => cycle_time,
79
87
  'y' => estimate,
80
- 'r' => values.size * 2,
88
+ 'r' => bubble_radius(values.size),
81
89
  'title' => title
82
90
  }
83
91
  end
@@ -90,11 +98,23 @@ class EstimateAccuracyChart < ChartBase
90
98
  'showLine' => false,
91
99
  'backgroundColor' => fill_color,
92
100
  'borderColor' => border_color,
93
- 'hidden' => starts_hidden
101
+ 'hidden' => starts_hidden,
102
+ # The active series is drawn as right pointing arrows rather than discs, because its
103
+ # cycletime is a lower bound that keeps growing. The erb needs to know which one that is.
104
+ 'still_in_progress' => completed_or_active == 'active'
94
105
  }
95
106
  end
96
107
  end
97
108
 
109
+ # Chart.js sizes a bubble by its radius but the eye reads it by area, so the radius has
110
+ # to go as the square root of the count. This multiplier is the radius of a single issue
111
+ # and everything else scales up from there.
112
+ SINGLE_ISSUE_RADIUS = 4
113
+
114
+ def bubble_radius issue_count
115
+ (SINGLE_ISSUE_RADIUS * Math.sqrt(issue_count)).round 1
116
+ end
117
+
98
118
  def estimate_label estimate:, estimation_units:
99
119
  if @y_axis_type == 'linear'
100
120
  if estimation_units == :story_points
@@ -183,6 +203,15 @@ class EstimateAccuracyChart < ChartBase
183
203
 
184
204
  # Correlation coefficient is calculated using the Pearson Correlation Coefficient
185
205
  # r = Σ((xi - x̄)(yi - ȳ)) / sqrt(Σ(xi - x̄)² · Σ(yi - ȳ)²)
206
+ # A Pearson coefficient is arithmetic on the distances between values, so it needs a scale where
207
+ # those distances mean something. T-shirt sizes are ordered but not spaced: nothing says the gap
208
+ # from M to L matches the gap from S to M. Summing them used to raise and take the export with it.
209
+ def numeric_estimates? completed_hash
210
+ return false if completed_hash.empty?
211
+
212
+ completed_hash.keys.all? { |estimate, _cycle_time| estimate.is_a? Numeric }
213
+ end
214
+
186
215
  def correlation_coefficient completed_hash
187
216
  list1 = []
188
217
  list2 = []
@@ -24,12 +24,13 @@ class ExpeditedChart < ChartBase
24
24
  super()
25
25
 
26
26
  header_text 'Expedited work'
27
+ no_data_text '<%= render_header %><div>There is no expedited work in this time period.</div>'
27
28
  description_text <<-HTML
28
29
  <div class="p">
29
30
  This chart only shows issues that have been expedited at some point. We care about these as
30
31
  any form of expedited work will affect the entire system and will slow down non-expedited work.
31
32
  Refer to this article on
32
- <a href="https://improvingflow.com/2021/06/16/classes-of-service.html">classes of service</a>
33
+ <a href="https://blog.mikebowler.ca/2021/06/16/classes-of-service/">classes of service</a>
33
34
  for a longer explanation on why we want to avoid expedited work.
34
35
  </div>
35
36
  <div class="p">
@@ -49,11 +50,9 @@ class ExpeditedChart < ChartBase
49
50
  make_expedite_lines_data_set(issue: issue, expedite_data: prepare_expedite_data(issue))
50
51
  end
51
52
 
52
- if data_sets.empty?
53
- '<h1 class="foldable">Expedited work</h1><div>There is no expedited work in this time period.</div>'
54
- else
55
- wrap_and_render(binding, __FILE__)
56
- end
53
+ return render_no_data if data_sets.empty?
54
+
55
+ wrap_and_render(binding, __FILE__)
57
56
  end
58
57
 
59
58
  def prepare_expedite_data issue
@@ -6,6 +6,30 @@ class Exporter
6
6
  attr_reader :project_configs
7
7
  attr_accessor :file_system
8
8
 
9
+ # Nothing in examples/ is loaded with the rest of the library, because an example is something you
10
+ # opt into rather than something the product provides. Each file there defines the method named
11
+ # after it. Derived from the directory rather than listed, so a new example is covered by existing.
12
+ EXAMPLE_NAMES = Dir[File.join(__dir__, 'examples', '*.rb')]
13
+ .collect { |file| File.basename(file, '.rb').to_sym }
14
+ .freeze
15
+
16
+ # Deliberately method_missing rather than stub methods that the real ones overwrite. Stubs depend
17
+ # on this file loading before the example, which require_all does not guarantee, and when it lost
18
+ # that race the stub replaced the working method.
19
+ def method_missing name, *args, **keywords, &block
20
+ return super unless EXAMPLE_NAMES.include? name
21
+
22
+ raise "#{name} is an example rather than part of jirametrics, so your config has to ask for " \
23
+ "it. Add this line to the top of your config file:\n\n" \
24
+ " require 'jirametrics/examples/#{name}'\n"
25
+ end
26
+
27
+ # These are not things we respond to. Calling one raises, so respond_to? should say false and the
28
+ # message above should be the only way anyone learns they exist.
29
+ def respond_to_missing? name, include_private = false
30
+ super
31
+ end
32
+
9
33
  def self.logfile_name
10
34
  @logfile_name ||= 'jirametrics.log'
11
35
  end
@@ -12,10 +12,11 @@ class FlowEfficiencyScatterplot < ChartBase
12
12
  super()
13
13
 
14
14
  header_text 'Flow Efficiency'
15
+ no_data_text '<%= render_header %><div>No data matched the selected criteria. Nothing to show.</div>'
15
16
  description_text <<-HTML
16
17
  <div class="p">
17
18
  This chart shows the active time against the the total time spent on a ticket.
18
- <a href="https://improvingflow.com/2024/07/06/flow-efficiency.html">Flow efficiency</a> is the ratio
19
+ <a href="https://blog.mikebowler.ca/2024/07/06/flow-efficiency/">Flow efficiency</a> is the ratio
19
20
  between these two numbers.
20
21
  </div>
21
22
  <div class="p">
@@ -63,10 +64,7 @@ class FlowEfficiencyScatterplot < ChartBase
63
64
  create_dataset(issues: issues, label: rules.label, color: rules.color)
64
65
  end
65
66
 
66
- if data_sets.empty?
67
- return "<h1 class='foldable'>#{@header_text}</h1>" \
68
- '<div>No data matched the selected criteria. Nothing to show.</div>'
69
- end
67
+ return render_no_data if data_sets.empty?
70
68
 
71
69
  wrap_and_render(binding, __FILE__)
72
70
  end
@@ -42,21 +42,24 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
42
42
  z: 1 // draw the grid lines on top of the bars
43
43
  },
44
44
  stacked: false,
45
- max: <%= (@max_age * 1.1).to_i %>
45
+ max: <%= (@max_age * 1.1).to_i %>,
46
+ ticks: {
47
+ // Uses labelExceptCrowdedCeiling() defined in index.js.
48
+ callback: labelExceptCrowdedCeiling
49
+ }
46
50
  }
47
51
  },
48
52
  plugins: {
49
53
  tooltip: {
50
54
  callbacks: {
51
55
  label: function(context) {
52
- if( typeof(context.dataset.data[context.dataIndex]) == "number" ) {
53
- let full_data = <%= @bar_data.inspect %>;
54
- let columnIndex = context.dataIndex;
55
- let rowIndex = context.datasetIndex - <%= @row_index_offset %>;
56
- return context.dataset.label + " of completed work items left this column in " +full_data[rowIndex][columnIndex] + " days or less";
56
+ let point = context.dataset.data[context.dataIndex];
57
+ // A band is a floating bar, [bottom, top]. A dot is an object carrying its own title.
58
+ if( Array.isArray(point) ) {
59
+ return context.dataset.label + " of completed work items left this column in " + point[1] + " days or less";
57
60
  }
58
61
  else {
59
- return context.dataset.data[context.dataIndex].title;
62
+ return point.title;
60
63
  }
61
64
  }
62
65
  }
@@ -6,7 +6,40 @@
6
6
  new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
7
7
  type: 'bubble',
8
8
  data: {
9
- datasets: <%= JSON.generate(data_sets) %>
9
+ datasets: (function() {
10
+ const dataSets = <%= JSON.generate(data_sets) %>;
11
+ // The still in progress series is drawn as right pointing arrows rather than discs. Those
12
+ // items have not finished, so their cycletime is a lower bound that keeps growing and the
13
+ // arrow says "this one is still moving that way". Same idea as the capped-value arrows on
14
+ // the scatterplot, turned to point along the axis that is actually still moving.
15
+ //
16
+ // Sized per point rather than at a fixed size, because on this chart the radius carries the
17
+ // number of issues stacked at that spot and we would be throwing that away.
18
+ function movementArrow(color, radius) {
19
+ const size = Math.max(12, Math.round(radius * 3));
20
+ const glyph = document.createElement('canvas');
21
+ glyph.width = size; glyph.height = size;
22
+ const pen = glyph.getContext('2d');
23
+ const middle = size / 2, head = size * 0.3, tip = size * 0.88;
24
+ pen.strokeStyle = color;
25
+ pen.lineWidth = Math.max(2, radius * 0.42);
26
+ pen.lineCap = 'round'; pen.lineJoin = 'round';
27
+ pen.beginPath();
28
+ pen.moveTo(size * 0.12, middle); pen.lineTo(tip, middle);
29
+ pen.moveTo(tip - head, middle - head);
30
+ pen.lineTo(tip, middle);
31
+ pen.lineTo(tip - head, middle + head);
32
+ pen.stroke();
33
+ return glyph;
34
+ }
35
+ dataSets.forEach(function(dataSet) {
36
+ if (!dataSet.still_in_progress) return;
37
+ dataSet.pointStyle = dataSet.data.map(function(point) {
38
+ return movementArrow(dataSet.backgroundColor, point.r);
39
+ });
40
+ });
41
+ return dataSets;
42
+ })()
10
43
  },
11
44
  options: {
12
45
  responsive: <%= canvas_responsive? %>, // If responsive is true then it fills the screen
@@ -33,6 +33,41 @@
33
33
  --type-bug-color: #D55E00; /* Okabe-Ito vermilion */
34
34
  --type-spike-color: #CC79A7; /* Okabe-Ito reddish purple */
35
35
 
36
+ /* The dependency chart is drawn by graphviz, which sets the label text inside the node rather
37
+ than beside it, so unlike --type-*-color above these are judged against the text and not
38
+ against the page. They are opaque, so the fill is the background the text sits on and there
39
+ is nothing for a dark theme to override.
40
+
41
+ These are Okabe-Ito hues rather than Okabe-Ito itself. At full strength they sit in a middle
42
+ band of lightness where NEITHER black nor white text is comfortable on them, which is the
43
+ thing to understand before changing any of this. Contrast ratio alone will mislead you here:
44
+ two earlier attempts cleared 7:1 and then 9:1, both of which WCAG calls comfortable, and both
45
+ still read as muddy in a real report. A mid-toned fill with dark text on it looks wrong at
46
+ almost any ratio. Going DARK and putting white text on it is what actually reads, so most of
47
+ these fills are dark, and each carries its own label colour: they are a pair, and changing a
48
+ fill without rechecking its label is how you get unreadable nodes.
49
+
50
+ They are not all dark, because the lightness spread is what does most of the work of keeping
51
+ them apart. Levelling them all to the same darkness measures about 3, which is unusable;
52
+ pastelling them all measures about 4.7, which is what the original colours here did. Run
53
+ `rake check_colors` for what they currently measure, against the full Okabe-Ito set as a
54
+ reference ceiling. */
55
+ --dependency-chart-story-color: #015C41; /* Okabe-Ito bluish green, darkened */
56
+ --dependency-chart-story-label-color: white;
57
+ --dependency-chart-task-color: #56B4E9; /* Okabe-Ito sky blue, unchanged */
58
+ --dependency-chart-task-label-color: black;
59
+ --dependency-chart-bug-color: #783200; /* Okabe-Ito vermilion, darkened. Also Defect */
60
+ --dependency-chart-bug-label-color: white;
61
+ --dependency-chart-epic-color: #F0E442; /* Okabe-Ito yellow, unchanged */
62
+ --dependency-chart-epic-label-color: black;
63
+ --dependency-chart-spike-color: #762A58; /* Okabe-Ito reddish purple, darkened */
64
+ --dependency-chart-spike-label-color: white;
65
+
66
+ /* For a node whose fill was set in the config rather than coming from the palette above, where
67
+ we cannot know what would read on it. */
68
+ --dependency-chart-label-color: black;
69
+ --dependency-chart-link-color: gray;
70
+
36
71
  --status-category-todo-color: gray;
37
72
  --status-category-inprogress-color: #0072B2; /* Okabe-Ito blue */
38
73
  --status-category-done-color: #009E73; /* Okabe-Ito bluish green */
@@ -71,10 +106,16 @@
71
106
  --wip-by-column-chart-limit-line-color: #D55E00; /* Okabe-Ito vermilion */
72
107
  --wip-by-column-chart-recommendation-color: #009E73; /* Okabe-Ito bluish green */
73
108
 
74
- --estimate-accuracy-chart-completed-fill-color: #92D9C0; /* light bluish green */
75
- --estimate-accuracy-chart-completed-border-color: #009E73; /* Okabe-Ito bluish green */
76
- --estimate-accuracy-chart-active-fill-color: #F4C6AD; /* light vermilion */
77
- --estimate-accuracy-chart-active-border-color: #D55E00; /* Okabe-Ito vermilion */
109
+ /* Full strength, not lightened. The pastel versions of these two collapsed into the same beige
110
+ under protanopia (4.45 apart, where the palette's own floor is 11.08) and measured 1.6 against
111
+ white, half the contrast WCAG asks of a shape you need to see. At full strength they are 18.45
112
+ apart and clear the contrast floor. The border is the page colour rather than a second hue, so
113
+ it separates overlapping bubbles instead of competing with the fill, and needs no dark mode
114
+ override. See jirametrics-7ss. */
115
+ --estimate-accuracy-chart-completed-fill-color: #009E73; /* Okabe-Ito bluish green */
116
+ --estimate-accuracy-chart-completed-border-color: var(--body-background);
117
+ --estimate-accuracy-chart-active-fill-color: #D55E00; /* Okabe-Ito vermilion */
118
+ --estimate-accuracy-chart-active-border-color: var(--body-background);
78
119
 
79
120
  --expedited-chart-no-longer-expedited: gray;
80
121
  --expedited-chart-dot-issue-started-color: #E69F00; /* Okabe-Ito orange */
@@ -248,6 +289,10 @@ div.child_issue {
248
289
  html[data-theme="dark"] {
249
290
  --warning-banner: #9F2B00;
250
291
  --non-working-days-color: #2f2f2f;
292
+
293
+ /* See the matching comment in the prefers-color-scheme block below. */
294
+ --dependency-chart-link-color: #999999;
295
+
251
296
  --type-story-color: #2DCB9A; /* lighter bluish green for dark bg */
252
297
  --type-task-color: #56B4E9; /* sky blue for dark bg */
253
298
  --type-bug-color: #E69F00; /* orange instead of vermilion for dark bg */
@@ -276,8 +321,6 @@ html[data-theme="dark"] {
276
321
  band. Those are the two ends of the duration ramp, so confusing them is the worst case. This
277
322
  stays in the same warm family so the ramp still reads cool to warm as work ages. */
278
323
  --wip-chart-duration-more-than-four-weeks-color: #B16BAE;
279
- --estimate-accuracy-chart-completed-border-color: #2DCB9A;
280
- --estimate-accuracy-chart-active-border-color: #E69F00;
281
324
  --expedited-chart-dot-issue-stopped-color: #2DCB9A;
282
325
  --expedited-chart-dot-expedite-started-color: #E69F00;
283
326
  --expedited-chart-dot-expedite-stopped-color: #2DCB9A;
@@ -400,6 +443,12 @@ html[data-theme="light"] {
400
443
  --warning-banner: #9F2B00;
401
444
 
402
445
  --non-working-days-color: #2f2f2f;
446
+
447
+ /* The only dependency chart colour needing a dark variant. The fills do not, because they
448
+ are opaque and are their own background, but link lines and their labels sit on the page.
449
+ Plain gray manages only 2.4:1 here against 3.94:1 in light mode, so it is lifted to match. */
450
+ --dependency-chart-link-color: #999999;
451
+
403
452
  --type-story-color: #2DCB9A; /* lighter bluish green for dark bg */
404
453
  --type-task-color: #56B4E9; /* sky blue for dark bg */
405
454
  --type-bug-color: #E69F00; /* orange instead of vermilion for dark bg */
@@ -436,9 +485,6 @@ html[data-theme="light"] {
436
485
  --wip-chart-duration-four-weeks-or-less-color: #D55E00;
437
486
  --wip-chart-duration-more-than-four-weeks-color: #B16BAE; /* see jirametrics-3fg */
438
487
 
439
- --estimate-accuracy-chart-completed-border-color: #2DCB9A;
440
- --estimate-accuracy-chart-active-border-color: #E69F00;
441
-
442
488
  --expedited-chart-dot-issue-stopped-color: #2DCB9A;
443
489
  --expedited-chart-dot-expedite-started-color: #E69F00;
444
490
  --expedited-chart-dot-expedite-stopped-color: #2DCB9A;
@@ -157,6 +157,19 @@ function withAlpha(color, alpha) {
157
157
  return resolved.replace(/^rgb\(/, 'rgba(').replace(/\)$/, `, ${alpha})`)
158
158
  }
159
159
 
160
+ // A y-axis tick callback for charts whose ceiling is deliberately set above their tallest value, so
161
+ // you can see that nothing is clipped. Chart.js labels that ceiling too, which gives you an arbitrary
162
+ // number like 169 sitting just above 160, or 62 above 50. Drop that label, unless the ceiling happens
163
+ // to land exactly on the regular grid, in which case it is a perfectly good label and worth keeping.
164
+ // Pass it straight to ticks.callback.
165
+ // ticks 0 20 .. 160 169 -> 169 dropped, it breaks the spacing
166
+ // ticks 0 20 .. 100 120 -> 120 kept, it continues the spacing
167
+ function labelExceptCrowdedCeiling(value, index, ticks) {
168
+ if (index !== ticks.length - 1 || index < 2) return value
169
+ let regularGap = ticks[index - 1].value - ticks[index - 2].value
170
+ return value - ticks[index - 1].value === regularGap ? value : null
171
+ }
172
+
160
173
  function createDiagonalPattern(color = 'black') {
161
174
  // create a 5x5 px canvas for the pattern's base shape
162
175
  let shape = document.createElement('canvas')
@@ -30,6 +30,22 @@ html[data-theme="light"] {
30
30
  --type-bug-color: orange;
31
31
  --type-spike-color: #9400D3;
32
32
 
33
+ /* The dependency chart's original pastels. They are pleasant but they are not safe: pale
34
+ colours sit close together, and green, peach and pale yellow are hard to separate for anyone
35
+ with red-green colour vision deficiency. Every one of them is light, so the label colours all
36
+ go back to black; the shipped set has dark fills that need white. */
37
+ --dependency-chart-story-color: #90EE90;
38
+ --dependency-chart-story-label-color: black;
39
+ --dependency-chart-task-color: #87CEFA;
40
+ --dependency-chart-task-label-color: black;
41
+ --dependency-chart-bug-color: #ffdab9;
42
+ --dependency-chart-bug-label-color: black;
43
+ --dependency-chart-epic-color: #fafad2;
44
+ --dependency-chart-epic-label-color: black;
45
+ --dependency-chart-spike-color: #DDA0DD;
46
+ --dependency-chart-spike-label-color: black;
47
+ --dependency-chart-link-color: gray;
48
+
33
49
  --status-category-todo-color: gray;
34
50
  --status-category-inprogress-color: #2663ff;
35
51
  --status-category-done-color: #00ff00;
@@ -106,6 +122,7 @@ html[data-theme="light"] {
106
122
  html[data-theme="dark"] {
107
123
  --warning-banner: #9F2B00;
108
124
  --non-working-days-color: #2f2f2f;
125
+ --dependency-chart-link-color: gray; /* the chart had no dark variant originally */
109
126
  --type-story-color: #6fb86f;
110
127
  --type-task-color: #0021b3;
111
128
  --type-bug-color: #bb5603;
@@ -139,6 +156,7 @@ html[data-theme="dark"] {
139
156
  :root {
140
157
  --warning-banner: #9F2B00;
141
158
  --non-working-days-color: #2f2f2f;
159
+ --dependency-chart-link-color: gray; /* the chart had no dark variant originally */
142
160
  --type-story-color: #6fb86f;
143
161
  --type-task-color: #0021b3;
144
162
  --type-bug-color: #bb5603;
@@ -100,16 +100,20 @@ if show_stats
100
100
  <% the_stats.each do |k, v| %>
101
101
  <tr>
102
102
  <td><%= k %></td>
103
- <td style="text-align: right;"><%= v[:min] %></td>
104
- <td style="text-align: right;"><%= v[:max] %></td>
105
- <td style="text-align: right;"><%= sprintf('%.2f', v[:average]) %></td>
106
- <td><%= v[:mode].join(', ') %></td>
103
+ <td style="text-align: right;"><%= stats_cell v[:min] %></td>
104
+ <td style="text-align: right;"><%= stats_cell v[:max] %></td>
105
+ <td style="text-align: right;"><%= stats_cell(v[:average]) { |average| format '%.2f', average } %></td>
106
+ <td><%= stats_cell(v[:mode]) { |modes| modes.join ', ' } %></td>
107
107
  <% percentiles.each do |percentile| %>
108
- <td style="text-align: right;"><%= v[:percentiles][percentile] %></td>
108
+ <td style="text-align: right;"><%= stats_cell v.dig(:percentiles, percentile) %></td>
109
109
  <% end %>
110
110
  </tr>
111
111
  <% end %>
112
112
  </table>
113
+ <% if any_empty_stats? the_stats %>
114
+ <p><b>&ndash;</b> means no usable data for that group. Every item in it was excluded from the histogram
115
+ because it had no measurable cycle time. The Data Quality report has the details.</p>
116
+ <% end %>
113
117
  </div>
114
118
  <div>
115
119
  <p>These statistics help understand the <i>"shape"</i> of the histogram distribution, to help us with predictions.</p>
@@ -82,9 +82,10 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
82
82
  color: <%= CssVariable['--grid-line-color'].to_json %>
83
83
  },
84
84
  ticks: {
85
+ // Uses labelExceptCrowdedCeiling() defined in index.js.
85
86
  callback: function(value, index, ticks) {
86
87
  <% if @cap %>if (value > <%= @cap[:cutoff] %>) return null;<% end %>
87
- return index === ticks.length - 1 ? null : value;
88
+ return labelExceptCrowdedCeiling(value, index, ticks);
88
89
  }
89
90
  }
90
91
  }
@@ -63,7 +63,7 @@ class Issue
63
63
 
64
64
  def labels = raw_fields['labels'] || []
65
65
 
66
- def author = raw_fields['creator']&.[]('displayName') || ''
66
+ def author = User.from_raw(raw_fields['creator'])&.display_name || ''
67
67
 
68
68
  def resolution = raw_fields['resolution']&.[]('name')
69
69
 
@@ -374,13 +374,11 @@ class Issue
374
374
  @changes.reverse.find(&:resolution?)
375
375
  end
376
376
 
377
- def assigned_to
378
- raw_fields['assignee']&.[]('displayName')
379
- end
377
+ # nil rather than '' when there's no assignee, and daily_view relies on it to decide whether to
378
+ # render an assignee line at all.
379
+ def assigned_to = User.from_raw(raw_fields['assignee'])&.display_name
380
380
 
381
- def assigned_to_icon_url
382
- raw_fields['assignee']&.[]('avatarUrls')&.[]('16x16')
383
- end
381
+ def assigned_to_icon_url = User.from_raw(raw_fields['assignee'])&.avatar_url
384
382
 
385
383
  # Many test failures are simply unreadable because the default inspect on this class goes
386
384
  # on for pages. Shorten it up.
@@ -19,10 +19,11 @@ class IssuePrinter
19
19
  end
20
20
 
21
21
  def assignee_line
22
- assignee = @issue.raw['fields']['assignee']
23
- return '' if assignee.nil?
22
+ user = User.from_raw @issue.raw['fields']['assignee']
23
+ return '' if user.nil?
24
24
 
25
- " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n"
25
+ email = user.email_address
26
+ " [assignee] #{user.display_name.inspect}#{" <#{email}>" if email}\n"
26
27
  end
27
28
 
28
29
  def links_section
@@ -78,6 +79,7 @@ class IssuePrinter
78
79
  def render_history history
79
80
  type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
80
81
  sort_history!(history)
82
+ # Not all ruby versions return the same string for to_s so we force to a known format.
81
83
  history.map do |time, type, detail, _artificial|
82
84
  type = type.nil? ? '-' * type_width : type.rjust(type_width)
83
85
  " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
@@ -109,15 +109,13 @@ class JiraGateway
109
109
 
110
110
  VerifyResult = Data.define(:ok, :url, :message)
111
111
 
112
- # Confirm that our credentials actually authenticate against Jira. We deliberately hit /myself,
113
- # which requires authentication on every Jira edition (anonymous callers get a 401). An endpoint
114
- # that also answers anonymously would happily "succeed" with bad credentials, which defeats the
115
- # whole point. Cloud speaks v3, Server/Data Center speaks v2. Returns a VerifyResult rather than
116
- # raising so the caller can report on every configured connection and set its own exit status.
117
112
  def verify_connection
113
+ # We use /myself because the only reason it can fail is if we aren't authenticated.
118
114
  json = call_url relative_url: "/rest/api/#{cloud? ? 3 : 2}/myself"
119
- user = json['displayName'] || json['name'] || json['emailAddress'] || 'unknown user'
120
- VerifyResult.new(ok: true, url: @jira_url, message: "Verified #{@jira_url} (authenticated as #{user})")
115
+
116
+ user = User.new raw: json
117
+ who = user.display_name || 'unknown user'
118
+ VerifyResult.new(ok: true, url: @jira_url, message: "Verified #{@jira_url} (authenticated as #{who})")
121
119
  rescue StandardError => e
122
120
  VerifyResult.new(ok: false, url: @jira_url, message: "Could not authenticate to #{@jira_url}: #{e.message}")
123
121
  end
@@ -37,13 +37,13 @@ class SprintBurndown < ChartBase
37
37
  end
38
38
 
39
39
  def run
40
- return nil unless current_board.scrum?
40
+ return render_no_data unless current_board.scrum?
41
41
 
42
42
  sprints = sprints_in_time_range current_board
43
43
  change_data_by_sprint = gather_change_data_by_sprint sprints
44
44
 
45
45
  result = +''
46
- result << render_top_text(binding)
46
+ result << render_top_text
47
47
 
48
48
  # HashEachMethods misreads this array of [method, title] pairs as a hash and thinks y_axis_title
49
49
  # is unused; in fact the ERB template reads it (and data_method) from the binding we pass to render.
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class JiraMetrics
4
+ module Testing
5
+ # Builds a Board from the two files jirametrics already downloads for you, so a test can use your
6
+ # own board rather than a fabricated one.
7
+ #
8
+ # board = MockBoard.load statuses: 'target/mine_statuses.json',
9
+ # configuration: 'target/mine_board_1_configuration.json'
10
+ #
11
+ # Takes filenames rather than packaging a sample board, because a board that resembles yours is
12
+ # worth more than one of ours, and because sample data would have to be carried in the gem forever.
13
+ class MockBoard
14
+ class << self
15
+ def load statuses:, configuration:
16
+ board = Board.new(
17
+ raw: JSON.parse(File.read(configuration, encoding: 'UTF-8')),
18
+ possible_statuses: load_statuses(statuses)
19
+ )
20
+ board.project_config = ProjectConfig.new(
21
+ exporter: Exporter.new, target_path: File.dirname(configuration), jira_config: nil, block: nil
22
+ )
23
+ board
24
+ end
25
+
26
+ def load_statuses filename
27
+ JSON.parse(File.read(filename, encoding: 'UTF-8')).each_with_object(StatusCollection.new) do |raw, collection|
28
+ collection << Status.from_raw(raw)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end