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
@@ -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
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Support for writing tests against jirametrics from outside this repo, for anyone building their
4
+ # own charts or other extensions.
5
+ #
6
+ # Require this and you get the whole library loaded along with it, so a client test file needs
7
+ # nothing else:
8
+ #
9
+ # require 'jirametrics/testing'
10
+ #
11
+ # What is supported is exactly what this module holds: the Mock* classes below, plus the methods
12
+ # to_time, to_date and empty_config_block. The require above loads the rest of the library too,
13
+ # and that is a side effect of loading rather than a promise about any of it.
14
+ #
15
+ # The methods, and only the methods, arrive through include. Constants do not, because constant
16
+ # lookup in an example block runs through the block's lexical scope rather than the ancestors of
17
+ # the class it runs against:
18
+ #
19
+ # RSpec.configure { |config| config.include JiraMetrics::Testing }
20
+ #
21
+ # to_time '2024-01-01' # works, include supplies it
22
+ # JiraMetrics::Testing::MockIssue.empty(...) # classes are named in full
23
+ #
24
+ # Add your own alias if the full name grates. That is your namespace to spend, so we don't spend
25
+ # it for you:
26
+ #
27
+ # MockIssue = JiraMetrics::Testing::MockIssue
28
+ #
29
+ # The require of 'jirametrics' below is not optional. This file nests inside the JiraMetrics class,
30
+ # and reopening it before Thor has defined it raises a superclass mismatch.
31
+ require 'jirametrics'
32
+ require 'require_all'
33
+ require_rel '.'
34
+
35
+ class JiraMetrics
36
+ module Testing
37
+ # Accepts the date formats a test is likely to write, rather than only what Time.parse takes.
38
+ # A bare date means midnight, and a missing offset means UTC:
39
+ #
40
+ # to_time '2024-01-01' => 2024-01-01 00:00:00 +0000
41
+ # to_time '2024-01-01T12:34:56' => 2024-01-01 12:34:56 +0000
42
+ # to_time '2024-01-01T12:34:56.789' => 2024-01-01 12:34:56.789 +0000
43
+ # to_time '2024-01-01T12:34:56 +10:00' => 2024-01-01 12:34:56 +1000
44
+ TIME_PATTERN = /
45
+ ^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})
46
+ (?<remainder>T(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(?<fraction>\.\d+)?
47
+ \s*(?<offset>[+-]\d{2}:?\d{2})?)?$
48
+ /x
49
+ private_constant :TIME_PATTERN
50
+
51
+ extend self
52
+
53
+ def to_time input
54
+ return input unless input.is_a? String
55
+
56
+ matches = input.match TIME_PATTERN
57
+ raise "Can't parse string: #{input.inspect}" unless matches
58
+
59
+ Time.parse format_matched_time(matches)
60
+ end
61
+
62
+ # The companion to to_time, for the many places a test wants a Date rather than a Time. Passes a
63
+ # Date straight through for the same reason to_time passes a Time through.
64
+ def to_date input
65
+ input.is_a?(Date) ? input : Date.parse(input)
66
+ end
67
+
68
+ # Every chart is constructed with a configuration block, so a test that only wants to exercise
69
+ # the chart itself still has to supply one. This is that block, doing nothing:
70
+ #
71
+ # chart = MyCustomChart.new empty_config_block
72
+ # chart.issues = [issue]
73
+ def empty_config_block = ->(_) {}
74
+
75
+ private
76
+
77
+ # Every optional part gets a default, which is what makes this long rather than interesting.
78
+ def format_matched_time matches
79
+ format(
80
+ '%<year>04d-%<month>02d-%<day>02dT-%<hour>02d:%<minute>02d:%<second>02d%<fraction>s%<offset>s',
81
+ year: matches[:year].to_i, month: matches[:month].to_i, day: matches[:day].to_i,
82
+ hour: (matches[:hour] || 0).to_i, minute: (matches[:minute] || 0).to_i,
83
+ second: (matches[:second] || 0).to_i,
84
+ fraction: matches[:fraction] || '', offset: matches[:offset] || '+0000'
85
+ )
86
+ end
87
+ end
88
+ end
@@ -13,6 +13,7 @@ class TimeBasedHistogram < TimeBasedChart
13
13
  def initialize
14
14
  super
15
15
 
16
+ no_data_text '<%= render_header %><div>No data matched the selected criteria. Nothing to show.</div>'
16
17
  percentiles [50, 85, 98]
17
18
  @show_stats = true
18
19
  end
@@ -77,10 +78,7 @@ class TimeBasedHistogram < TimeBasedChart
77
78
  )
78
79
  end
79
80
 
80
- if data_sets.empty?
81
- return "<h1 class='foldable'>#{@header_text}</h1>" \
82
- '<div>No data matched the selected criteria. Nothing to show.</div>'
83
- end
81
+ return render_no_data if data_sets.empty?
84
82
 
85
83
  wrap_and_render(binding, __FILE__)
86
84
  end
@@ -94,6 +92,20 @@ class TimeBasedHistogram < TimeBasedChart
94
92
  items_hash
95
93
  end
96
94
 
95
+ # One cell of the statistics table. A group can survive grouping and still have nothing to plot,
96
+ # when every item in it was excluded for having no measurable cycle time, and then there are no
97
+ # statistics to show for it at all. Dashing the cells keeps the group visible: dropping the row
98
+ # would make it look like the group had never existed.
99
+ def stats_cell value
100
+ return '&ndash;' if value.nil?
101
+
102
+ block_given? ? yield(value) : value
103
+ end
104
+
105
+ def any_empty_stats? the_stats
106
+ the_stats.any? { |_label, stats| stats.empty? }
107
+ end
108
+
97
109
  def stats_for histogram_data:, percentiles:
98
110
  return {} if histogram_data.empty?
99
111
 
@@ -22,6 +22,7 @@ class TimeBasedScatterplot < TimeBasedChart
22
22
  def initialize
23
23
  super
24
24
 
25
+ no_data_text '<%= render_header %><div>No data matched the selected criteria. Nothing to show.</div>'
25
26
  @percentage_lines = []
26
27
  @highest_y_value = 0
27
28
  @percentiles = [85]
@@ -56,10 +57,7 @@ class TimeBasedScatterplot < TimeBasedChart
56
57
  }
57
58
  end
58
59
 
59
- if data_sets.empty?
60
- return "<h1 class='foldable'>#{@header_text}</h1>" \
61
- '<div>No data matched the selected criteria. Nothing to show.</div>'
62
- end
60
+ return render_no_data if data_sets.empty?
63
61
 
64
62
  wrap_and_render(binding, __FILE__)
65
63
  end
@@ -1,12 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class User
4
+ RENDERED_AVATAR_SIZE = '16x16'
5
+
6
+ # Jira omits the user entirely when there isn't one, so callers get nil rather than a User that
7
+ # raises on every question.
8
+ def self.from_raw raw
9
+ raw && new(raw: raw)
10
+ end
11
+
4
12
  def initialize raw:
5
13
  @raw = raw
6
14
  end
7
15
 
8
16
  def account_id = @raw['accountId']
9
- def avatar_url = @raw['avatarUrls']['16x16']
10
17
  def active? = @raw['active']
11
- def display_name = @raw['displayName']
18
+ def email_address = @raw['emailAddress']
19
+
20
+ # Nil when there's no name to show, in either spelling. Callers want different things in that
21
+ # case, so the default belongs at the call site. 'name' is what older Jira called displayName,
22
+ # and newer versions of Cloud don't return it.
23
+ def display_name = @raw['displayName'] || @raw['name']
24
+
25
+ def avatar_url = @raw['avatarUrls']&.[](RENDERED_AVATAR_SIZE)
12
26
  end
@@ -10,7 +10,7 @@ class WipByColumnChart < ChartBase
10
10
  # Long only because of the inline description_text heredoc and one-time setup; splitting wouldn't help.
11
11
  def initialize block # rubocop:disable Metrics/MethodLength
12
12
  super()
13
- header_text 'WIP by column'
13
+ header_text 'WIP by column on board: <%= current_board.name %>'
14
14
  description_text <<-HTML
15
15
  <p>
16
16
  This chart shows how much time each board column has spent at different WIP (Work in Progress) levels.
@@ -58,7 +58,6 @@ class WipByColumnChart < ChartBase
58
58
  end
59
59
 
60
60
  def run
61
- @header_text += " on board: #{current_board.name}"
62
61
  stats = column_stats
63
62
  @column_names = stats.collect(&:name)
64
63
  @wip_data = stats.collect do |stat|
data/lib/jirametrics.rb CHANGED
@@ -157,7 +157,12 @@ class JiraMetrics < Thor
157
157
  exit 1
158
158
  end
159
159
 
160
- require_rel 'jirametrics'
160
+ # The testing surface has no place in a production run, and the examples are opt-in: a config
161
+ # that wants standard_project requires it by name. Matching on the prefix also excludes
162
+ # testing.rb, which has to go with its directory because it require_rel's the whole tree.
163
+ not_autoloaded = %w[testing examples].collect { |name| File.join __dir__, 'jirametrics', name }
164
+ require_all(Dir[File.join(__dir__, 'jirametrics', '**', '*.rb')]
165
+ .reject { |file| not_autoloaded.any? { |prefix| file.start_with? prefix } })
161
166
  # Set only after the require above, so Exporter is defined. The config file we load below calls
162
167
  # Exporter.configure, which opens this log; the MCP server passes its own name so it doesn't
163
168
  # truncate jirametrics.log.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jirametrics
3
3
  version: !ruby/object:Gem::Version
4
- version: '3.3'
4
+ version: '3.4'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Bowler
@@ -122,6 +122,7 @@ files:
122
122
  - lib/jirametrics/cfd_data_builder.rb
123
123
  - lib/jirametrics/change_item.rb
124
124
  - lib/jirametrics/chart_base.rb
125
+ - lib/jirametrics/chart_format.rb
125
126
  - lib/jirametrics/color_palette.rb
126
127
  - lib/jirametrics/columns_config.rb
127
128
  - lib/jirametrics/css_variable.rb
@@ -201,6 +202,11 @@ files:
201
202
  - lib/jirametrics/status.rb
202
203
  - lib/jirametrics/status_collection.rb
203
204
  - lib/jirametrics/stitcher.rb
205
+ - lib/jirametrics/testing.rb
206
+ - lib/jirametrics/testing/mock_board.rb
207
+ - lib/jirametrics/testing/mock_change_item.rb
208
+ - lib/jirametrics/testing/mock_cycle_time_config.rb
209
+ - lib/jirametrics/testing/mock_issue.rb
204
210
  - lib/jirametrics/throughput_by_completed_resolution_chart.rb
205
211
  - lib/jirametrics/throughput_chart.rb
206
212
  - lib/jirametrics/time_based_chart.rb