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.
- checksums.yaml +4 -4
- data/lib/jirametrics/aging_work_bar_chart.rb +2 -4
- data/lib/jirametrics/aging_work_in_progress_chart.rb +26 -26
- data/lib/jirametrics/atlassian_document_format.rb +22 -14
- data/lib/jirametrics/board_movement_calculator.rb +0 -23
- data/lib/jirametrics/change_item.rb +4 -8
- data/lib/jirametrics/chart_base.rb +44 -15
- data/lib/jirametrics/chart_format.rb +15 -0
- data/lib/jirametrics/daily_view.rb +6 -7
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +1 -1
- data/lib/jirametrics/data_quality_report.rb +3 -3
- data/lib/jirametrics/dependency_chart.rb +109 -20
- data/lib/jirametrics/estimate_accuracy_chart.rb +37 -8
- data/lib/jirametrics/expedited_chart.rb +5 -6
- data/lib/jirametrics/exporter.rb +24 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +3 -5
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +10 -7
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +34 -1
- data/lib/jirametrics/html/index.css +55 -9
- data/lib/jirametrics/html/index.js +13 -0
- data/lib/jirametrics/html/legacy_colors.css +18 -0
- data/lib/jirametrics/html/time_based_histogram.erb +9 -5
- data/lib/jirametrics/html/time_based_scatterplot.erb +2 -1
- data/lib/jirametrics/issue.rb +5 -7
- data/lib/jirametrics/issue_printer.rb +5 -3
- data/lib/jirametrics/jira_gateway.rb +5 -7
- data/lib/jirametrics/sprint_burndown.rb +2 -2
- data/lib/jirametrics/testing/mock_board.rb +34 -0
- data/lib/jirametrics/testing/mock_change_item.rb +112 -0
- data/lib/jirametrics/testing/mock_cycle_time_config.rb +73 -0
- data/lib/jirametrics/testing/mock_issue.rb +108 -0
- data/lib/jirametrics/testing.rb +88 -0
- data/lib/jirametrics/time_based_histogram.rb +16 -4
- data/lib/jirametrics/time_based_scatterplot.rb +2 -4
- data/lib/jirametrics/user.rb +16 -2
- data/lib/jirametrics/wip_by_column_chart.rb +1 -2
- data/lib/jirametrics.rb +6 -1
- 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 '–' 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
|
data/lib/jirametrics/user.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|