jirametrics 3.3.1 → 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 (36) 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 +3 -5
  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 +10 -9
  20. data/lib/jirametrics/html/index.js +13 -0
  21. data/lib/jirametrics/html/time_based_scatterplot.erb +2 -1
  22. data/lib/jirametrics/issue.rb +5 -7
  23. data/lib/jirametrics/issue_printer.rb +5 -3
  24. data/lib/jirametrics/jira_gateway.rb +5 -7
  25. data/lib/jirametrics/sprint_burndown.rb +2 -2
  26. data/lib/jirametrics/testing/mock_board.rb +34 -0
  27. data/lib/jirametrics/testing/mock_change_item.rb +112 -0
  28. data/lib/jirametrics/testing/mock_cycle_time_config.rb +73 -0
  29. data/lib/jirametrics/testing/mock_issue.rb +108 -0
  30. data/lib/jirametrics/testing.rb +88 -0
  31. data/lib/jirametrics/time_based_histogram.rb +2 -4
  32. data/lib/jirametrics/time_based_scatterplot.rb +2 -4
  33. data/lib/jirametrics/user.rb +16 -2
  34. data/lib/jirametrics/wip_by_column_chart.rb +1 -2
  35. data/lib/jirametrics.rb +6 -1
  36. metadata +7 -1
@@ -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
@@ -106,10 +106,16 @@
106
106
  --wip-by-column-chart-limit-line-color: #D55E00; /* Okabe-Ito vermilion */
107
107
  --wip-by-column-chart-recommendation-color: #009E73; /* Okabe-Ito bluish green */
108
108
 
109
- --estimate-accuracy-chart-completed-fill-color: #92D9C0; /* light bluish green */
110
- --estimate-accuracy-chart-completed-border-color: #009E73; /* Okabe-Ito bluish green */
111
- --estimate-accuracy-chart-active-fill-color: #F4C6AD; /* light vermilion */
112
- --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);
113
119
 
114
120
  --expedited-chart-no-longer-expedited: gray;
115
121
  --expedited-chart-dot-issue-started-color: #E69F00; /* Okabe-Ito orange */
@@ -315,8 +321,6 @@ html[data-theme="dark"] {
315
321
  band. Those are the two ends of the duration ramp, so confusing them is the worst case. This
316
322
  stays in the same warm family so the ramp still reads cool to warm as work ages. */
317
323
  --wip-chart-duration-more-than-four-weeks-color: #B16BAE;
318
- --estimate-accuracy-chart-completed-border-color: #2DCB9A;
319
- --estimate-accuracy-chart-active-border-color: #E69F00;
320
324
  --expedited-chart-dot-issue-stopped-color: #2DCB9A;
321
325
  --expedited-chart-dot-expedite-started-color: #E69F00;
322
326
  --expedited-chart-dot-expedite-stopped-color: #2DCB9A;
@@ -481,9 +485,6 @@ html[data-theme="light"] {
481
485
  --wip-chart-duration-four-weeks-or-less-color: #D55E00;
482
486
  --wip-chart-duration-more-than-four-weeks-color: #B16BAE; /* see jirametrics-3fg */
483
487
 
484
- --estimate-accuracy-chart-completed-border-color: #2DCB9A;
485
- --estimate-accuracy-chart-active-border-color: #E69F00;
486
-
487
488
  --expedited-chart-dot-issue-stopped-color: #2DCB9A;
488
489
  --expedited-chart-dot-expedite-started-color: #E69F00;
489
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')
@@ -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
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ class JiraMetrics
4
+ module Testing
5
+ # Builds a ChangeItem for tests without going through Jira's changelog format.
6
+ #
7
+ # Normalises the arguments a caller is likely to get slightly wrong (a status given as a name when
8
+ # an id is wanted, a time given as a string) and, for status changes with an issue, validates the
9
+ # status against that issue's board so a typo surfaces loudly rather than silently passing.
10
+ class MockChangeItem
11
+ def initialize(
12
+ field:, value:, time:, value_id: nil, old_value: nil, old_value_id: nil,
13
+ artificial: false, issue: nil, field_id: nil
14
+ )
15
+ @field = field
16
+ @value = value
17
+ # Callers write dates as strings far more often than as Times.
18
+ @time = JiraMetrics::Testing.to_time time
19
+ @value_id = value_id
20
+ @old_value = old_value
21
+ @old_value_id = old_value_id
22
+ @artificial = artificial
23
+ @issue = issue
24
+ @field_id = field_id
25
+ end
26
+
27
+ def to_change_item
28
+ normalize_status_arguments
29
+ validate_status_change if @field == 'status' && @issue
30
+
31
+ ChangeItem.new time: @time, artificial: @artificial, author_raw: nil, raw: {
32
+ 'field' => @field,
33
+ 'to' => @value_id,
34
+ 'toString' => @value,
35
+ 'from' => @old_value_id,
36
+ 'fromString' => @old_value,
37
+ 'fieldId' => @field_id
38
+ }
39
+ end
40
+
41
+ # If either value or old_value is a Status object then pull the name and id off it.
42
+ private
43
+
44
+ def normalize_status_arguments
45
+ if @value.is_a? Status
46
+ @value_id = @value.id
47
+ @value = @value.name
48
+ end
49
+ return unless @old_value.is_a? Status
50
+
51
+ @old_value_id = @old_value.id
52
+ @old_value = @old_value.name
53
+ end
54
+
55
+ # Status names aren't unique, so a status name always has to be paired with an explicit id.
56
+ def validate_status_change
57
+ require_value_id!
58
+ require_old_value_id!
59
+ verify_value_id!
60
+ verify_old_value_id!
61
+ end
62
+
63
+ def require_value_id!
64
+ return unless @value && !@value_id
65
+
66
+ guesses = possible_statuses.find_all_by_name(@value).collect(&:id)
67
+ message = "ID was not specified for new status #{@value.inspect}. "
68
+ if guesses.empty?
69
+ message << "No statuses with name #{@value.inspect} but did find these: #{possible_statuses.inspect}"
70
+ else
71
+ message << "Perhaps you meant one of #{guesses.inspect}"
72
+ end
73
+ raise message
74
+ end
75
+
76
+ def require_old_value_id!
77
+ return unless @old_value && !@old_value_id
78
+
79
+ guesses = possible_statuses.find_all_by_name(@old_value).collect(&:id)
80
+ raise "ID was not specified for old status #{@old_value.inspect}. Perhaps you meant one of #{guesses.inspect}"
81
+ end
82
+
83
+ def verify_value_id!
84
+ return unless @value_id
85
+
86
+ status = possible_statuses.find_by_id(@value_id)
87
+ raise "No status found for id: #{@value_id} (#{@value.inspect}) in #{possible_statuses.inspect}" unless status
88
+ return if status.name == @value
89
+
90
+ raise "Value passed to mock_change (#{@value.inspect}:#{@value_id.inspect}) " \
91
+ "doesn't match the status found in the board (#{status})"
92
+ end
93
+
94
+ def verify_old_value_id!
95
+ return unless @old_value_id
96
+
97
+ status = possible_statuses.find_by_id(@old_value_id)
98
+ unless status
99
+ raise "No status found for id: #{@old_value_id} (#{@old_value.inspect}) in #{possible_statuses.inspect}"
100
+ end
101
+ return if status.name == @old_value
102
+
103
+ raise "Old value passed to mock_change (#{@old_value.inspect}:#{@old_value_id.inspect}) " \
104
+ "doesn't match the status found in the board (#{status})"
105
+ end
106
+
107
+ def possible_statuses
108
+ @issue.board.possible_statuses
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ class JiraMetrics
4
+ module Testing
5
+ # Stubs are matched by the issue's KEY, not by object identity, so every issue in a test needs its
6
+ # own key. Building two issues with the same key and stubbing both raises.
7
+ #
8
+ # board.cycletime = MockCycleTimeConfig.new
9
+ # .stub(issue1, started: '2021-01-02')
10
+ # .stub(issue2, started: '2021-01-02', stopped: '2021-10-04')
11
+ #
12
+ # An issue with no stub at all reads as never started, which is a legitimate state and so cannot be
13
+ # distinguished from one you forgot.
14
+ class MockCycleTimeConfig < CycleTimeConfig
15
+ def initialize
16
+ super(possible_statuses: nil, label: nil, block: nil, settings: self.class.default_settings)
17
+ @stubs = []
18
+ end
19
+
20
+ # The same file ProjectConfig reads, resolved from this file's own location so that it works from
21
+ # wherever the caller happens to be rather than only from a checkout of this repo.
22
+ def self.default_settings
23
+ settings_file = File.expand_path '../settings.json', __dir__
24
+ JSON.parse(File.read(settings_file, encoding: 'UTF-8')).tap do |settings|
25
+ # A cached cycle time would outlive the stub that produced it, so a second stub for the same
26
+ # issue would appear to have no effect.
27
+ settings['cache_cycletime_calculations'] = false
28
+ end
29
+ end
30
+
31
+ # Returns self so that calls cascade.
32
+ def stub issue, started: nil, stopped: nil
33
+ key = issue.is_a?(Issue) ? issue.key : issue
34
+ assert_key_unused key
35
+ @stubs << [key, normalize_time(started), normalize_time(stopped)]
36
+ self
37
+ end
38
+
39
+ def started_stopped_changes(issue)
40
+ value = @stubs.find { |issue_key, _start, _stop| issue_key == issue.key }
41
+ return [nil, nil] unless value
42
+
43
+ [to_change(value[1]), to_change(value[2])]
44
+ end
45
+
46
+ private
47
+
48
+ def normalize_time value
49
+ value.is_a?(String) ? JiraMetrics::Testing.to_time(value) : value
50
+ end
51
+
52
+ # Only the first stub for a key is reachable, so a second one always means the test is asserting
53
+ # against something other than what it looks like.
54
+ def assert_key_unused key
55
+ return unless @stubs.any? { |existing, _start, _stop| existing == key }
56
+
57
+ raise "More than one stub for #{key}. Stubs are matched by issue key, so only the first would " \
58
+ 'ever be used. Give each issue its own key.'
59
+ end
60
+
61
+ def to_change change
62
+ case change
63
+ when nil
64
+ nil
65
+ when ChangeItem
66
+ change
67
+ else
68
+ MockChangeItem.new(field: 'status', value: 'fake', value_id: 1_000_001, time: change&.to_time).to_change_item
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ class JiraMetrics
4
+ module Testing
5
+ # An Issue you can build without a fixture file and then add history to.
6
+ #
7
+ # MockIssue.new keeps Issue's own signature (raw:, board:) so that a MockIssue is substitutable for
8
+ # an Issue everywhere, including MockCycleTimeConfig's is_a?(Issue) check. The friendly constructor
9
+ # is MockIssue.empty, which builds the minimal raw hash for you.
10
+ class MockIssue < Issue
11
+ # Generated keys start well clear of the hand-written SP-1 and SP-2 that fixtures use.
12
+ FIRST_GENERATED_KEY_NUMBER = 1000
13
+
14
+ # A leap day on purpose. Anything that does its own date arithmetic, assumes 365-day years, or
15
+ # round-trips through a format that cannot represent Feb 29 will trip over this default rather
16
+ # than sailing past on a date that hides the bug.
17
+ DEFAULT_CREATED = '2024-02-29'
18
+
19
+ # The change is built and appended in one step, and returned for tests that need to hold on to it.
20
+ def add_change field:, value:, time:, value_id: nil, old_value: nil, old_value_id: nil,
21
+ artificial: false, field_id: nil
22
+ change = MockChangeItem.new(
23
+ issue: self, field: field, time: time, value: value, value_id: value_id,
24
+ old_value: old_value, old_value_id: old_value_id, artificial: artificial, field_id: field_id
25
+ ).to_change_item
26
+ changes << change
27
+ change
28
+ end
29
+
30
+ class << self
31
+ # board has no default. Supplying one would mean either packaging a sample board or reading
32
+ # from this repo's spec directory, and neither belongs in a shipped gem. MockBoard.load builds
33
+ # one from the files jirametrics already downloads.
34
+ def empty board:, created: DEFAULT_CREATED, key: nil, creation_status: nil,
35
+ current_sprint_ids: nil
36
+ new(
37
+ raw: raw_for(
38
+ created: created,
39
+ key: key || next_generated_key,
40
+ creation_status: resolve_creation_status(creation_status, board),
41
+ current_sprint_ids: current_sprint_ids,
42
+ board_id: board.id
43
+ ),
44
+ board: board
45
+ )
46
+ end
47
+
48
+ private
49
+
50
+ def next_generated_key
51
+ @next_key_number ||= FIRST_GENERATED_KEY_NUMBER
52
+ "SP-#{@next_key_number}".tap { @next_key_number += 1 }
53
+ end
54
+
55
+ # A Status carries its category, so the issue cannot end up claiming to be Done while sitting in
56
+ # To Do. Specs that need a status the board does not have can build one with Status.new, which
57
+ # is explicit about being artificial rather than being smuggled in as a name and id.
58
+ def resolve_creation_status creation_status, board
59
+ return creation_status if creation_status.is_a? Status
60
+
61
+ raise "creation_status must be a Status, got #{creation_status.class}" unless creation_status.nil?
62
+
63
+ backlog_statuses = board.possible_statuses.find_all_by_name('Backlog')
64
+ raise 'No Backlog status found' if backlog_statuses.empty?
65
+
66
+ backlog_statuses.first
67
+ end
68
+
69
+ # Mimics an issue created directly inside a sprint: that membership lives only in the current
70
+ # Sprint custom field and never appears as a changelog transition.
71
+ #
72
+ # Only two keys are ever read back. Issue#current_sprint_ids takes the ids, and
73
+ # Issue#sprint_field_id finds the sprint field by looking for an array of hashes carrying a
74
+ # boardId. Anything else written here would be decoration that could contradict the board.
75
+ def raw_for created:, key:, creation_status:, current_sprint_ids:, board_id:
76
+ sprint_field =
77
+ if current_sprint_ids
78
+ { 'customfield_10020' => current_sprint_ids.collect { |id| { 'id' => id, 'boardId' => board_id } } }
79
+ else
80
+ {}
81
+ end
82
+ created_time = JiraMetrics::Testing.to_time(created).to_s
83
+ {
84
+ 'key' => key,
85
+ 'changelog' => { 'histories' => [] },
86
+ 'fields' => sprint_field.merge(
87
+ 'created' => created_time,
88
+ 'updated' => created_time,
89
+ 'status' => {
90
+ 'name' => creation_status.name,
91
+ 'id' => creation_status.id.to_s,
92
+ 'statusCategory' => {
93
+ 'name' => creation_status.category.name,
94
+ 'id' => creation_status.category.id,
95
+ 'key' => creation_status.category.key
96
+ }
97
+ },
98
+ 'priority' => { 'name' => 'Medium', 'id' => '3' },
99
+ 'issuetype' => { 'name' => 'Bug' },
100
+ 'creator' => { 'displayName' => 'Tolkien' },
101
+ 'summary' => 'Do the thing'
102
+ )
103
+ }
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end