jirametrics 2.0 → 2.12.1
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/aggregate_config.rb +19 -26
- data/lib/jirametrics/aging_work_bar_chart.rb +79 -54
- data/lib/jirametrics/aging_work_in_progress_chart.rb +106 -40
- data/lib/jirametrics/aging_work_table.rb +84 -54
- data/lib/jirametrics/anonymizer.rb +6 -5
- data/lib/jirametrics/blocked_stalled_change.rb +24 -4
- data/lib/jirametrics/board.rb +51 -23
- data/lib/jirametrics/board_config.rb +9 -4
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/change_item.rb +56 -21
- data/lib/jirametrics/chart_base.rb +101 -61
- data/lib/jirametrics/columns_config.rb +4 -0
- data/lib/jirametrics/css_variable.rb +33 -0
- data/lib/jirametrics/cycletime_config.rb +59 -8
- data/lib/jirametrics/cycletime_histogram.rb +69 -4
- data/lib/jirametrics/cycletime_scatterplot.rb +11 -15
- data/lib/jirametrics/daily_view.rb +277 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +44 -20
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +37 -35
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +38 -0
- data/lib/jirametrics/daily_wip_chart.rb +61 -14
- data/lib/jirametrics/data_quality_report.rb +222 -41
- data/lib/jirametrics/dependency_chart.rb +54 -23
- data/lib/jirametrics/download_config.rb +12 -0
- data/lib/jirametrics/downloader.rb +86 -56
- data/lib/jirametrics/estimate_accuracy_chart.rb +173 -0
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +22 -39
- data/lib/jirametrics/examples/standard_project.rb +26 -48
- data/lib/jirametrics/expedited_chart.rb +28 -25
- data/lib/jirametrics/exporter.rb +59 -32
- data/lib/jirametrics/file_config.rb +35 -14
- data/lib/jirametrics/file_system.rb +48 -3
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
- data/lib/jirametrics/groupable_issue_chart.rb +2 -6
- data/lib/jirametrics/grouping_rules.rb +7 -1
- data/lib/jirametrics/hierarchy_table.rb +4 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +13 -16
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +28 -5
- data/lib/jirametrics/html/aging_work_table.erb +21 -25
- data/lib/jirametrics/html/cycletime_histogram.erb +83 -3
- data/lib/jirametrics/html/cycletime_scatterplot.erb +9 -12
- data/lib/jirametrics/html/daily_wip_chart.erb +17 -13
- data/lib/jirametrics/html/{story_point_accuracy_chart.erb → estimate_accuracy_chart.erb} +9 -4
- data/lib/jirametrics/html/expedited_chart.erb +10 -13
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
- data/lib/jirametrics/html/hierarchy_table.erb +2 -2
- data/lib/jirametrics/html/index.css +280 -0
- data/lib/jirametrics/html/index.erb +33 -39
- data/lib/jirametrics/html/sprint_burndown.erb +10 -14
- data/lib/jirametrics/html/throughput_chart.erb +10 -13
- data/lib/jirametrics/html_report_config.rb +110 -86
- data/lib/jirametrics/issue.rb +390 -109
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/jira_gateway.rb +33 -12
- data/lib/jirametrics/project_config.rb +276 -147
- data/lib/jirametrics/rules.rb +2 -2
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +11 -0
- data/lib/jirametrics/sprint.rb +1 -0
- data/lib/jirametrics/sprint_burndown.rb +59 -40
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +86 -39
- data/lib/jirametrics/throughput_chart.rb +12 -4
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics.rb +29 -7
- metadata +20 -17
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/experimental/generator.rb +0 -210
- data/lib/jirametrics/experimental/info.rb +0 -77
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
- data/lib/jirametrics/story_point_accuracy_chart.rb +0 -134
|
@@ -1,51 +1,67 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class ChangeItem
|
|
4
|
-
attr_reader :field, :value_id, :old_value_id, :raw, :
|
|
5
|
-
attr_accessor :value, :old_value
|
|
4
|
+
attr_reader :field, :value_id, :old_value_id, :raw, :time, :author_raw
|
|
5
|
+
attr_accessor :value, :old_value
|
|
6
6
|
|
|
7
|
-
def initialize raw:,
|
|
8
|
-
# raw will only ever be nil in a test and in that case field and value should be passed in
|
|
7
|
+
def initialize raw:, author_raw:, time:, artificial: false
|
|
9
8
|
@raw = raw
|
|
9
|
+
@author_raw = author_raw
|
|
10
10
|
@time = time
|
|
11
|
-
raise
|
|
11
|
+
raise 'ChangeItem.new() time cannot be nil' if time.nil?
|
|
12
|
+
raise "Time must be an object of type Time in the correct timezone: #{@time.inspect}" unless @time.is_a? Time
|
|
12
13
|
|
|
13
|
-
@field =
|
|
14
|
-
@value =
|
|
14
|
+
@field = @raw['field']
|
|
15
|
+
@value = @raw['toString']
|
|
15
16
|
@value_id = @raw['to'].to_i
|
|
16
17
|
@old_value = @raw['fromString']
|
|
17
18
|
@old_value_id = @raw['from']&.to_i
|
|
18
19
|
@artificial = artificial
|
|
19
|
-
@author = author
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def
|
|
22
|
+
def author
|
|
23
|
+
@author_raw&.[]('displayName') || @author_raw&.[]('name') || 'Unknown author'
|
|
24
|
+
end
|
|
23
25
|
|
|
24
|
-
def
|
|
26
|
+
def author_icon_url
|
|
27
|
+
@author_raw&.[]('avatarUrls')&.[]('16x16')
|
|
28
|
+
end
|
|
25
29
|
|
|
30
|
+
def artificial? = @artificial
|
|
31
|
+
def assignee? = (field == 'assignee')
|
|
32
|
+
def comment? = (field == 'comment')
|
|
33
|
+
def due_date? = (field == 'duedate')
|
|
34
|
+
def flagged? = (field == 'Flagged')
|
|
35
|
+
def issue_type? = field == 'issuetype'
|
|
36
|
+
def labels? = (field == 'labels')
|
|
37
|
+
def link? = (field == 'Link')
|
|
26
38
|
def priority? = (field == 'priority')
|
|
27
|
-
|
|
28
39
|
def resolution? = (field == 'resolution')
|
|
29
|
-
|
|
30
|
-
def artificial? = @artificial
|
|
31
|
-
|
|
32
40
|
def sprint? = (field == 'Sprint')
|
|
41
|
+
def status? = (field == 'status')
|
|
33
42
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def link? = (field == 'Link')
|
|
43
|
+
# An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
|
|
44
|
+
def to_time = @time
|
|
37
45
|
|
|
38
46
|
def to_s
|
|
39
|
-
message =
|
|
40
|
-
message
|
|
41
|
-
message
|
|
47
|
+
message = +''
|
|
48
|
+
message << "ChangeItem(field: #{field.inspect}"
|
|
49
|
+
message << ", value: #{value.inspect}"
|
|
50
|
+
message << ':' << value_id.inspect if status?
|
|
51
|
+
if old_value
|
|
52
|
+
message << ", old_value: #{old_value.inspect}"
|
|
53
|
+
message << ':' << old_value_id.inspect if status?
|
|
54
|
+
end
|
|
55
|
+
message << ", time: #{time_to_s(@time).inspect}"
|
|
56
|
+
message << ', artificial' if artificial?
|
|
57
|
+
message << ')'
|
|
42
58
|
message
|
|
43
59
|
end
|
|
44
60
|
|
|
45
61
|
def inspect = to_s
|
|
46
62
|
|
|
47
63
|
def == other
|
|
48
|
-
field.eql?(other.field) && value.eql?(other.value) && time.
|
|
64
|
+
field.eql?(other.field) && value.eql?(other.value) && time_to_s(time).eql?(time_to_s(other.time))
|
|
49
65
|
end
|
|
50
66
|
|
|
51
67
|
def current_status_matches *status_names_or_ids
|
|
@@ -77,4 +93,23 @@ class ChangeItem
|
|
|
77
93
|
end
|
|
78
94
|
end
|
|
79
95
|
end
|
|
96
|
+
|
|
97
|
+
def field_as_human_readable
|
|
98
|
+
case @field
|
|
99
|
+
when 'duedate' then 'Due date'
|
|
100
|
+
when 'timeestimate' then 'Time estimate'
|
|
101
|
+
when 'timeoriginalestimate' then 'Time original estimate'
|
|
102
|
+
when 'issuetype' then 'Issue type'
|
|
103
|
+
when 'IssueParentAssociation' then 'Issue parent association'
|
|
104
|
+
else @field.capitalize
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def time_to_s time
|
|
111
|
+
# MRI and JRuby return different strings for to_s() so we have to explicitly provide a full
|
|
112
|
+
# format so that tests work under both environments.
|
|
113
|
+
time.strftime '%Y-%m-%d %H:%M:%S %z'
|
|
114
|
+
end
|
|
80
115
|
end
|
|
@@ -2,25 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
class ChartBase
|
|
4
4
|
attr_accessor :timezone_offset, :board_id, :all_boards, :date_range,
|
|
5
|
-
:time_range, :data_quality, :holiday_dates, :settings
|
|
5
|
+
:time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system, :users
|
|
6
6
|
attr_writer :aggregated_project
|
|
7
|
-
attr_reader :
|
|
7
|
+
attr_reader :canvas_width, :canvas_height
|
|
8
8
|
|
|
9
9
|
@@chart_counter = 0
|
|
10
10
|
|
|
11
11
|
def initialize
|
|
12
12
|
@chart_colors = {
|
|
13
|
-
'
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
'
|
|
18
|
-
'light:Story' => '#90EE90',
|
|
19
|
-
'light:Task' => '#87CEFA',
|
|
20
|
-
'light:Bug' => '#ffdab9',
|
|
21
|
-
'light:Defect' => 'orange',
|
|
22
|
-
'light:Epic' => '#fafad2',
|
|
23
|
-
'light:Spike' => '#DDA0DD' # light purple
|
|
13
|
+
'Story' => CssVariable['--type-story-color'],
|
|
14
|
+
'Task' => CssVariable['--type-task-color'],
|
|
15
|
+
'Bug' => CssVariable['--type-bug-color'],
|
|
16
|
+
'Defect' => CssVariable['--type-bug-color'],
|
|
17
|
+
'Spike' => CssVariable['--type-spike-color']
|
|
24
18
|
}
|
|
25
19
|
@canvas_width = 800
|
|
26
20
|
@canvas_height = 200
|
|
@@ -31,6 +25,11 @@ class ChartBase
|
|
|
31
25
|
@aggregated_project
|
|
32
26
|
end
|
|
33
27
|
|
|
28
|
+
def html_directory
|
|
29
|
+
pathname = Pathname.new(File.realpath(__FILE__))
|
|
30
|
+
"#{pathname.dirname}/html"
|
|
31
|
+
end
|
|
32
|
+
|
|
34
33
|
def render caller_binding, file
|
|
35
34
|
pathname = Pathname.new(File.realpath(file))
|
|
36
35
|
basename = pathname.basename.to_s
|
|
@@ -39,16 +38,21 @@ class ChartBase
|
|
|
39
38
|
# Insert a incrementing chart_id so that all the chart names on the page are unique
|
|
40
39
|
caller_binding.eval "chart_id='chart#{next_id}'" # chart_id=chart3
|
|
41
40
|
|
|
42
|
-
|
|
43
|
-
erb = ERB.new File.read "#{@html_directory}/#{$1}.erb"
|
|
41
|
+
erb = ERB.new file_system.load "#{html_directory}/#{$1}.erb"
|
|
44
42
|
erb.result(caller_binding)
|
|
45
43
|
end
|
|
46
44
|
|
|
47
|
-
|
|
48
|
-
def wrap_and_render caller_binding, file
|
|
45
|
+
def render_top_text caller_binding
|
|
49
46
|
result = +''
|
|
50
47
|
result << "<h1>#{@header_text}</h1>" if @header_text
|
|
51
48
|
result << ERB.new(@description_text).result(caller_binding) if @description_text
|
|
49
|
+
result
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Render the file and then wrap it with standard headers and quality checks.
|
|
53
|
+
def wrap_and_render caller_binding, file
|
|
54
|
+
result = +''
|
|
55
|
+
result << render_top_text(caller_binding)
|
|
52
56
|
result << render(caller_binding, file)
|
|
53
57
|
result
|
|
54
58
|
end
|
|
@@ -57,8 +61,8 @@ class ChartBase
|
|
|
57
61
|
@@chart_counter += 1
|
|
58
62
|
end
|
|
59
63
|
|
|
60
|
-
def color_for type
|
|
61
|
-
@chart_colors[
|
|
64
|
+
def color_for type:
|
|
65
|
+
@chart_colors[type] ||= random_color
|
|
62
66
|
end
|
|
63
67
|
|
|
64
68
|
def label_days days
|
|
@@ -100,7 +104,7 @@ class ChartBase
|
|
|
100
104
|
issues_id = next_id
|
|
101
105
|
|
|
102
106
|
issue_descriptions.sort! { |a, b| a[0].key_as_i <=> b[0].key_as_i }
|
|
103
|
-
erb = ERB.new File.
|
|
107
|
+
erb = ERB.new file_system.load File.join(html_directory, 'collapsible_issues_panel.erb')
|
|
104
108
|
erb.result(binding)
|
|
105
109
|
end
|
|
106
110
|
|
|
@@ -125,6 +129,21 @@ class ChartBase
|
|
|
125
129
|
result
|
|
126
130
|
end
|
|
127
131
|
|
|
132
|
+
def working_days_annotation
|
|
133
|
+
holidays.each_with_index.collect do |range, index|
|
|
134
|
+
<<~TEXT
|
|
135
|
+
holiday#{index}: {
|
|
136
|
+
drawTime: 'beforeDraw',
|
|
137
|
+
type: 'box',
|
|
138
|
+
xMin: '#{range.begin}T00:00:00',
|
|
139
|
+
xMax: '#{range.end}T23:59:59',
|
|
140
|
+
backgroundColor: #{CssVariable.new('--non-working-days-color').to_json},
|
|
141
|
+
borderColor: #{CssVariable.new('--non-working-days-color').to_json}
|
|
142
|
+
},
|
|
143
|
+
TEXT
|
|
144
|
+
end.join
|
|
145
|
+
end
|
|
146
|
+
|
|
128
147
|
# Return only the board columns for the current board.
|
|
129
148
|
def current_board
|
|
130
149
|
if @board_id.nil?
|
|
@@ -144,8 +163,7 @@ class ChartBase
|
|
|
144
163
|
def completed_issues_in_range include_unstarted: false
|
|
145
164
|
issues.select do |issue|
|
|
146
165
|
cycletime = issue.board.cycletime
|
|
147
|
-
stopped_time = cycletime.
|
|
148
|
-
started_time = cycletime.started_time(issue)
|
|
166
|
+
started_time, stopped_time = cycletime.started_stopped_times(issue)
|
|
149
167
|
|
|
150
168
|
stopped_time &&
|
|
151
169
|
date_range.include?(stopped_time.to_date) && # Remove outside range
|
|
@@ -153,17 +171,6 @@ class ChartBase
|
|
|
153
171
|
end
|
|
154
172
|
end
|
|
155
173
|
|
|
156
|
-
def sprints_in_time_range board
|
|
157
|
-
board.sprints.select do |sprint|
|
|
158
|
-
sprint_end_time = sprint.completed_time || sprint.end_time
|
|
159
|
-
sprint_start_time = sprint.start_time
|
|
160
|
-
next false if sprint_start_time.nil?
|
|
161
|
-
|
|
162
|
-
time_range.include?(sprint_start_time) || time_range.include?(sprint_end_time) ||
|
|
163
|
-
(sprint_start_time < time_range.begin && sprint_end_time > time_range.end)
|
|
164
|
-
end || []
|
|
165
|
-
end
|
|
166
|
-
|
|
167
174
|
def chart_format object
|
|
168
175
|
if object.is_a? Time
|
|
169
176
|
# "2022-04-09T11:38:30-07:00"
|
|
@@ -173,41 +180,66 @@ class ChartBase
|
|
|
173
180
|
end
|
|
174
181
|
end
|
|
175
182
|
|
|
176
|
-
def header_text text
|
|
177
|
-
@header_text = text
|
|
183
|
+
def header_text text = :none
|
|
184
|
+
@header_text = text unless text == :none
|
|
185
|
+
@header_text
|
|
178
186
|
end
|
|
179
187
|
|
|
180
|
-
def description_text text
|
|
181
|
-
@description_text = text
|
|
188
|
+
def description_text text = :none
|
|
189
|
+
@description_text = text unless text == :none
|
|
190
|
+
@description_text
|
|
182
191
|
end
|
|
183
192
|
|
|
193
|
+
# Convert a number like 1234567 into the string "1,234,567"
|
|
184
194
|
def format_integer number
|
|
185
195
|
number.to_s.reverse.scan(/.{1,3}/).join(',').reverse
|
|
186
196
|
end
|
|
187
197
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
198
|
+
# object will be either a Status or a ChangeItem
|
|
199
|
+
# if it's a ChangeItem then use_old_status will specify whether we're using the new or old
|
|
200
|
+
# Either way, is_category will format the category rather than the status
|
|
201
|
+
def format_status object, board:, is_category: false, use_old_status: false
|
|
202
|
+
status = nil
|
|
203
|
+
error_message = nil
|
|
204
|
+
|
|
205
|
+
case object
|
|
206
|
+
when ChangeItem
|
|
207
|
+
id = use_old_status ? object.old_value_id : object.value_id
|
|
208
|
+
status = board.possible_statuses.find_by_id(id)
|
|
209
|
+
if status.nil?
|
|
210
|
+
error_message = use_old_status ? object.old_value : object.value
|
|
211
|
+
end
|
|
212
|
+
when Status
|
|
213
|
+
status = object
|
|
214
|
+
else
|
|
215
|
+
raise "Unexpected type: #{object.class}"
|
|
195
216
|
end
|
|
196
|
-
raise "Expected exactly one match and got #{statuses.inspect} for #{name_or_id.inspect}" if statuses.size > 1
|
|
197
217
|
|
|
198
|
-
|
|
218
|
+
return "<span style='color: red'>#{error_message}</span>" if error_message
|
|
219
|
+
|
|
199
220
|
color = status_category_color status
|
|
200
221
|
|
|
201
|
-
|
|
202
|
-
|
|
222
|
+
visibility = ''
|
|
223
|
+
if is_category == false && board.visible_columns.none? { |column| column.status_ids.include? status.id }
|
|
224
|
+
visibility = icon_span(
|
|
225
|
+
title: "Not visible: The status #{status.name.inspect} is not mapped to any column and will not be visible",
|
|
226
|
+
icon: ' 👀'
|
|
227
|
+
)
|
|
228
|
+
end
|
|
229
|
+
text = is_category ? status.category : status
|
|
230
|
+
"<span title='Category: #{status.category}'>#{color_block color.name} #{text}</span>#{visibility}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def icon_span title:, icon:
|
|
234
|
+
"<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
|
|
203
235
|
end
|
|
204
236
|
|
|
205
237
|
def status_category_color status
|
|
206
|
-
case status.
|
|
207
|
-
when
|
|
208
|
-
when '
|
|
209
|
-
when '
|
|
210
|
-
|
|
238
|
+
case status.category.key
|
|
239
|
+
when 'new' then CssVariable['--status-category-todo-color']
|
|
240
|
+
when 'indeterminate' then CssVariable['--status-category-inprogress-color']
|
|
241
|
+
when 'done' then CssVariable['--status-category-done-color']
|
|
242
|
+
else CssVariable['--status-category-unknown-color'] # Theoretically impossible but seen in prod.
|
|
211
243
|
end
|
|
212
244
|
end
|
|
213
245
|
|
|
@@ -225,15 +257,23 @@ class ChartBase
|
|
|
225
257
|
@canvas_responsive
|
|
226
258
|
end
|
|
227
259
|
|
|
228
|
-
def
|
|
229
|
-
|
|
260
|
+
def color_block color, title: nil
|
|
261
|
+
result = +''
|
|
262
|
+
result << "<div class='color_block' style='"
|
|
263
|
+
result << "background: #{CssVariable[color]};" if color
|
|
264
|
+
result << 'visibility: hidden;' unless color
|
|
265
|
+
result << "'"
|
|
266
|
+
result << " title=#{title.inspect}" if title
|
|
267
|
+
result << '></div>'
|
|
268
|
+
result
|
|
230
269
|
end
|
|
231
270
|
|
|
232
|
-
def
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
271
|
+
def describe_non_working_days
|
|
272
|
+
<<-TEXT
|
|
273
|
+
<div class='p'>
|
|
274
|
+
The #{color_block '--non-working-days-color'} vertical bars indicate non-working days; weekends
|
|
275
|
+
and any other holidays mentioned in the configuration.
|
|
276
|
+
</div>
|
|
277
|
+
TEXT
|
|
238
278
|
end
|
|
239
279
|
end
|
|
@@ -34,6 +34,10 @@ class ColumnsConfig
|
|
|
34
34
|
@columns << [:string, label, proc]
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
def integer label, proc
|
|
38
|
+
@columns << [:integer, label, proc]
|
|
39
|
+
end
|
|
40
|
+
|
|
37
41
|
def column_entry_times board_id: nil
|
|
38
42
|
@file_config.project_config.find_board_by_id(board_id).visible_columns.each do |column|
|
|
39
43
|
date column.name, first_time_in_status(*column.status_ids)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CssVariable
|
|
4
|
+
attr_reader :name
|
|
5
|
+
|
|
6
|
+
def self.[](name)
|
|
7
|
+
if name.is_a?(String) && name.start_with?('--')
|
|
8
|
+
CssVariable.new name
|
|
9
|
+
else
|
|
10
|
+
name
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize name
|
|
15
|
+
@name = name
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_json(*_args)
|
|
19
|
+
"getComputedStyle(document.body).getPropertyValue('#{@name}')"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_s
|
|
23
|
+
"var(#{@name})"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def inspect
|
|
27
|
+
"CssVariable['#{@name}']"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def == other
|
|
31
|
+
self.class == other.class && @name == other.name
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -8,10 +8,14 @@ class CycleTimeConfig
|
|
|
8
8
|
|
|
9
9
|
attr_reader :label, :parent_config
|
|
10
10
|
|
|
11
|
-
def initialize parent_config:, label:, block:, today: Date.today
|
|
11
|
+
def initialize parent_config:, label:, block:, file_system: nil, today: Date.today
|
|
12
12
|
@parent_config = parent_config
|
|
13
13
|
@label = label
|
|
14
14
|
@today = today
|
|
15
|
+
|
|
16
|
+
# If we hit something deprecated and this is nil then we'll blow up. Although it's ugly, this
|
|
17
|
+
# may make it easier to find problems in the test code ;-)
|
|
18
|
+
@file_system = file_system
|
|
15
19
|
instance_eval(&block) unless block.nil?
|
|
16
20
|
end
|
|
17
21
|
|
|
@@ -26,31 +30,78 @@ class CycleTimeConfig
|
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
def in_progress? issue
|
|
29
|
-
started_time
|
|
33
|
+
started_time, stopped_time = started_stopped_times(issue)
|
|
34
|
+
started_time && stopped_time.nil?
|
|
30
35
|
end
|
|
31
36
|
|
|
32
37
|
def done? issue
|
|
33
|
-
|
|
38
|
+
started_stopped_times(issue).last
|
|
34
39
|
end
|
|
35
40
|
|
|
36
41
|
def started_time issue
|
|
37
|
-
@
|
|
42
|
+
@file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
|
|
43
|
+
started_stopped_times(issue).first
|
|
38
44
|
end
|
|
39
45
|
|
|
40
46
|
def stopped_time issue
|
|
41
|
-
@
|
|
47
|
+
@file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
|
|
48
|
+
started_stopped_times(issue).last
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def fabricate_change_item time
|
|
52
|
+
@file_system.deprecated(
|
|
53
|
+
date: '2024-12-16', message: "This method should now return a ChangeItem not a #{time.class}", depth: 4
|
|
54
|
+
)
|
|
55
|
+
raw = {
|
|
56
|
+
'field' => 'Fabricated change',
|
|
57
|
+
'to' => '0',
|
|
58
|
+
'toString' => '',
|
|
59
|
+
'from' => '0',
|
|
60
|
+
'fromString' => ''
|
|
61
|
+
}
|
|
62
|
+
ChangeItem.new raw: raw, time: time, artificial: true, author_raw: nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def started_stopped_changes issue
|
|
66
|
+
started = @start_at.call(issue)
|
|
67
|
+
stopped = @stop_at.call(issue)
|
|
68
|
+
|
|
69
|
+
# Obscure edge case where some of the start_at and stop_at blocks might return false in place of nil.
|
|
70
|
+
# If they are false then explicitly make them nil.
|
|
71
|
+
started ||= nil
|
|
72
|
+
stopped ||= nil
|
|
73
|
+
|
|
74
|
+
# These are only here for backwards compatibility. Hopefully nobody will ever need them.
|
|
75
|
+
started = fabricate_change_item(started) if !started.nil? && !started.is_a?(ChangeItem)
|
|
76
|
+
stopped = fabricate_change_item(stopped) if !stopped.nil? && !stopped.is_a?(ChangeItem)
|
|
77
|
+
|
|
78
|
+
# In the case where started and stopped are exactly the same time, we pretend that
|
|
79
|
+
# it just stopped and never started. This allows us to have logic like 'in or right of'
|
|
80
|
+
# for the start and not have it conflict.
|
|
81
|
+
started = nil if started&.time == stopped&.time
|
|
82
|
+
|
|
83
|
+
[started, stopped]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def started_stopped_times issue
|
|
87
|
+
started, stopped = started_stopped_changes(issue)
|
|
88
|
+
[started&.time, stopped&.time]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def started_stopped_dates issue
|
|
92
|
+
started_time, stopped_time = started_stopped_times(issue)
|
|
93
|
+
[started_time&.to_date, stopped_time&.to_date]
|
|
42
94
|
end
|
|
43
95
|
|
|
44
96
|
def cycletime issue
|
|
45
|
-
start =
|
|
46
|
-
stop = stopped_time(issue)
|
|
97
|
+
start, stop = started_stopped_times(issue)
|
|
47
98
|
return nil if start.nil? || stop.nil?
|
|
48
99
|
|
|
49
100
|
(stop.to_date - start.to_date).to_i + 1
|
|
50
101
|
end
|
|
51
102
|
|
|
52
103
|
def age issue, today: nil
|
|
53
|
-
start =
|
|
104
|
+
start = started_stopped_times(issue).first
|
|
54
105
|
stop = today || @today || Date.today
|
|
55
106
|
return nil if start.nil? || stop.nil?
|
|
56
107
|
|
|
@@ -5,10 +5,14 @@ require 'jirametrics/groupable_issue_chart'
|
|
|
5
5
|
class CycletimeHistogram < ChartBase
|
|
6
6
|
include GroupableIssueChart
|
|
7
7
|
attr_accessor :possible_statuses
|
|
8
|
+
attr_reader :show_stats
|
|
8
9
|
|
|
9
|
-
def initialize block
|
|
10
|
+
def initialize block
|
|
10
11
|
super()
|
|
11
12
|
|
|
13
|
+
percentiles [50, 85, 98]
|
|
14
|
+
@show_stats = true
|
|
15
|
+
|
|
12
16
|
header_text 'Cycletime Histogram'
|
|
13
17
|
description_text <<-HTML
|
|
14
18
|
<p>
|
|
@@ -26,21 +30,40 @@ class CycletimeHistogram < ChartBase
|
|
|
26
30
|
end
|
|
27
31
|
end
|
|
28
32
|
|
|
33
|
+
def percentiles percs = nil
|
|
34
|
+
@percentiles = percs unless percs.nil?
|
|
35
|
+
@percentiles
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def disable_stats
|
|
39
|
+
@show_stats = false
|
|
40
|
+
end
|
|
41
|
+
|
|
29
42
|
def run
|
|
30
43
|
stopped_issues = completed_issues_in_range include_unstarted: true
|
|
31
44
|
|
|
32
45
|
# For the histogram, we only want to consider items that have both a start and a stop time.
|
|
33
|
-
histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.
|
|
46
|
+
histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
|
|
34
47
|
rules_to_issues = group_issues histogram_issues
|
|
35
48
|
|
|
49
|
+
the_stats = {}
|
|
50
|
+
|
|
51
|
+
overall_stats = stats_for histogram_data: histogram_data_for(issues: histogram_issues), percentiles: @percentiles
|
|
52
|
+
the_stats[:all] = overall_stats
|
|
36
53
|
data_sets = rules_to_issues.keys.collect do |rules|
|
|
54
|
+
the_issue_type = rules.label
|
|
55
|
+
the_histogram = histogram_data_for(issues: rules_to_issues[rules])
|
|
56
|
+
the_stats[the_issue_type] = stats_for histogram_data: the_histogram, percentiles: @percentiles if @show_stats
|
|
57
|
+
|
|
37
58
|
data_set_for(
|
|
38
|
-
histogram_data:
|
|
39
|
-
label:
|
|
59
|
+
histogram_data: the_histogram,
|
|
60
|
+
label: the_issue_type,
|
|
40
61
|
color: rules.color
|
|
41
62
|
)
|
|
42
63
|
end
|
|
43
64
|
|
|
65
|
+
return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
|
|
66
|
+
|
|
44
67
|
wrap_and_render(binding, __FILE__)
|
|
45
68
|
end
|
|
46
69
|
|
|
@@ -53,6 +76,48 @@ class CycletimeHistogram < ChartBase
|
|
|
53
76
|
count_hash
|
|
54
77
|
end
|
|
55
78
|
|
|
79
|
+
def stats_for histogram_data:, percentiles:
|
|
80
|
+
return {} if histogram_data.empty?
|
|
81
|
+
|
|
82
|
+
total_values = histogram_data.values.sum
|
|
83
|
+
|
|
84
|
+
# Calculate the average
|
|
85
|
+
weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
|
|
86
|
+
average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
|
|
87
|
+
|
|
88
|
+
# Find the mode (or modes!) and the spread of the distribution
|
|
89
|
+
sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
|
|
90
|
+
max_freq = sorted_histogram[-1][1]
|
|
91
|
+
mode = sorted_histogram.select { |_v, f| f == max_freq }
|
|
92
|
+
|
|
93
|
+
minmax = histogram_data.keys.minmax
|
|
94
|
+
|
|
95
|
+
# Calculate percentiles
|
|
96
|
+
sorted_values = histogram_data.keys.sort
|
|
97
|
+
cumulative_counts = {}
|
|
98
|
+
cumulative_sum = 0
|
|
99
|
+
|
|
100
|
+
sorted_values.each do |value|
|
|
101
|
+
cumulative_sum += histogram_data[value]
|
|
102
|
+
cumulative_counts[value] = cumulative_sum
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
percentile_results = {}
|
|
106
|
+
percentiles.each do |percentile|
|
|
107
|
+
rank = (percentile / 100.0) * total_values
|
|
108
|
+
percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
|
|
109
|
+
percentile_results[percentile] = percentile_value
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
average: average,
|
|
114
|
+
mode: mode.collect(&:first).sort,
|
|
115
|
+
min: minmax[0],
|
|
116
|
+
max: minmax[1],
|
|
117
|
+
percentiles: percentile_results
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
56
121
|
def data_set_for histogram_data:, label:, color:
|
|
57
122
|
keys = histogram_data.keys.sort
|
|
58
123
|
{
|