jirametrics 2.0 → 2.11
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 +78 -43
- data/lib/jirametrics/anonymizer.rb +6 -5
- data/lib/jirametrics/blocked_stalled_change.rb +24 -4
- data/lib/jirametrics/board.rb +44 -15
- data/lib/jirametrics/board_config.rb +8 -4
- data/lib/jirametrics/board_movement_calculator.rb +147 -0
- data/lib/jirametrics/change_item.rb +31 -10
- data/lib/jirametrics/chart_base.rb +102 -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_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 +76 -57
- data/lib/jirametrics/{story_point_accuracy_chart.rb → estimate_accuracy_chart.rb} +48 -33
- data/lib/jirametrics/examples/aggregated_project.rb +22 -39
- data/lib/jirametrics/examples/standard_project.rb +25 -49
- data/lib/jirametrics/expedited_chart.rb +28 -25
- data/lib/jirametrics/exporter.rb +59 -32
- data/lib/jirametrics/file_config.rb +34 -13
- 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 +19 -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 +209 -0
- data/lib/jirametrics/html/index.erb +16 -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 +108 -86
- data/lib/jirametrics/issue.rb +357 -96
- data/lib/jirametrics/jira_gateway.rb +29 -11
- data/lib/jirametrics/project_config.rb +256 -144
- data/lib/jirametrics/rules.rb +2 -2
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +10 -0
- data/lib/jirametrics/sprint_burndown.rb +24 -7
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +80 -39
- data/lib/jirametrics/throughput_chart.rb +12 -4
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics.rb +25 -7
- metadata +16 -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
|
@@ -3,39 +3,62 @@
|
|
|
3
3
|
require 'jirametrics/chart_base'
|
|
4
4
|
|
|
5
5
|
class AgingWorkTable < ChartBase
|
|
6
|
-
attr_accessor :today
|
|
6
|
+
attr_accessor :today
|
|
7
|
+
attr_reader :any_scrum_boards
|
|
7
8
|
|
|
8
9
|
def initialize block
|
|
9
10
|
super()
|
|
10
|
-
@blocked_icon = '🛑'
|
|
11
|
-
@expedited_icon = '🔥'
|
|
12
|
-
@stalled_icon = '🟧'
|
|
13
11
|
@stalled_threshold = 5
|
|
14
|
-
@dead_icon = '⬛'
|
|
15
12
|
@dead_threshold = 45
|
|
16
13
|
@age_cutoff = 0
|
|
17
14
|
|
|
18
|
-
|
|
15
|
+
header_text 'Aging Work Table'
|
|
16
|
+
description_text <<-TEXT
|
|
17
|
+
<p>
|
|
18
|
+
This chart shows all active (started but not completed) work, ordered from oldest at the top to
|
|
19
|
+
newest at the bottom.
|
|
20
|
+
</p>
|
|
21
|
+
<p>
|
|
22
|
+
If there are expedited items that haven't yet started then they're at the bottom of the table.
|
|
23
|
+
By the very definition of expedited, if we haven't started them already, we'd better get on that.
|
|
24
|
+
</p>
|
|
25
|
+
<p>
|
|
26
|
+
Legend:
|
|
27
|
+
<ul>
|
|
28
|
+
<li><b>E:</b> Whether this item is <b>E</b>xpedited.</li>
|
|
29
|
+
<li><b>B/S:</b> Whether this item is either <b>B</b>locked or <b>S</b>talled.</li>
|
|
30
|
+
<li><b>Forecast:</b> A forecast of how long it is likely to take to finish this work item.</li>
|
|
31
|
+
</ul>
|
|
32
|
+
</p>
|
|
33
|
+
TEXT
|
|
34
|
+
|
|
35
|
+
instance_eval(&block)
|
|
19
36
|
end
|
|
20
37
|
|
|
21
38
|
def run
|
|
22
|
-
|
|
23
|
-
aging_issues = select_aging_issues
|
|
39
|
+
initialize_calculator
|
|
40
|
+
aging_issues = select_aging_issues + expedited_but_not_started
|
|
24
41
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
42
|
+
wrap_and_render(binding, __FILE__)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# This is its own method simply so the tests can initialize the calculator without doing a full run.
|
|
46
|
+
def initialize_calculator
|
|
47
|
+
@today = date_range.end
|
|
48
|
+
@calculator = BoardMovementCalculator.new board: current_board, issues: issues, today: @today
|
|
49
|
+
end
|
|
30
50
|
|
|
31
|
-
|
|
51
|
+
def expedited_but_not_started
|
|
52
|
+
@issues.select do |issue|
|
|
53
|
+
started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
|
|
54
|
+
started_time.nil? && stopped_time.nil? && issue.expedited?
|
|
55
|
+
end.sort_by(&:created)
|
|
32
56
|
end
|
|
33
57
|
|
|
34
58
|
def select_aging_issues
|
|
35
59
|
aging_issues = @issues.select do |issue|
|
|
36
60
|
cycletime = issue.board.cycletime
|
|
37
|
-
started = cycletime.
|
|
38
|
-
stopped = cycletime.stopped_time(issue)
|
|
61
|
+
started, stopped = cycletime.started_stopped_times(issue)
|
|
39
62
|
next false if started.nil? || stopped
|
|
40
63
|
next true if issue.blocked_on_date?(@today, end_time: time_range.end) || issue.expedited?
|
|
41
64
|
|
|
@@ -46,47 +69,33 @@ class AgingWorkTable < ChartBase
|
|
|
46
69
|
aging_issues.sort { |a, b| b.board.cycletime.age(b, today: @today) <=> a.board.cycletime.age(a, today: @today) }
|
|
47
70
|
end
|
|
48
71
|
|
|
49
|
-
def icon_span title:, icon:
|
|
50
|
-
"<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
|
|
51
|
-
end
|
|
52
|
-
|
|
53
72
|
def expedited_text issue
|
|
54
73
|
return unless issue.expedited?
|
|
55
74
|
|
|
56
75
|
name = issue.raw['fields']['priority']['name']
|
|
57
|
-
|
|
76
|
+
color_block '--expedited-color', title: "Expedited: Has a priority of "#{name}""
|
|
58
77
|
end
|
|
59
78
|
|
|
60
79
|
def blocked_text issue
|
|
61
|
-
started_time = issue.board.cycletime.
|
|
80
|
+
started_time, _stopped_time = issue.board.cycletime.started_stopped_times(issue)
|
|
62
81
|
return nil if started_time.nil?
|
|
63
82
|
|
|
64
83
|
current = issue.blocked_stalled_changes(end_time: time_range.end)[-1]
|
|
65
84
|
if current.blocked?
|
|
66
|
-
|
|
85
|
+
color_block '--blocked-color', title: current.reasons
|
|
67
86
|
elsif current.stalled?
|
|
68
87
|
if current.stalled_days && current.stalled_days > @dead_threshold
|
|
69
|
-
|
|
88
|
+
color_block(
|
|
89
|
+
'--dead-color',
|
|
70
90
|
title: "Dead? Hasn't had any activity in #{label_days current.stalled_days}. " \
|
|
71
|
-
'Does anyone still care about this?'
|
|
72
|
-
icon: @dead_icon
|
|
91
|
+
'Does anyone still care about this?'
|
|
73
92
|
)
|
|
74
93
|
else
|
|
75
|
-
|
|
76
|
-
title: current.reasons,
|
|
77
|
-
icon: @stalled_icon
|
|
78
|
-
)
|
|
94
|
+
color_block '--stalled-color', title: current.reasons
|
|
79
95
|
end
|
|
80
96
|
end
|
|
81
97
|
end
|
|
82
98
|
|
|
83
|
-
def unmapped_status_text issue
|
|
84
|
-
icon_span(
|
|
85
|
-
title: "The status #{issue.status.name.inspect} is not mapped to any column and will not be visible",
|
|
86
|
-
icon: ' ⁉️'
|
|
87
|
-
)
|
|
88
|
-
end
|
|
89
|
-
|
|
90
99
|
def fix_versions_text issue
|
|
91
100
|
issue.fix_versions.collect do |fix|
|
|
92
101
|
if fix.released?
|
|
@@ -119,8 +128,38 @@ class AgingWorkTable < ChartBase
|
|
|
119
128
|
end.join('<br />')
|
|
120
129
|
end
|
|
121
130
|
|
|
122
|
-
def
|
|
123
|
-
|
|
131
|
+
def dates_text issue
|
|
132
|
+
date = date_range.end
|
|
133
|
+
due = issue.due_date
|
|
134
|
+
message = nil
|
|
135
|
+
|
|
136
|
+
days_remaining, error = @calculator.forecasted_days_remaining_and_message issue: issue, today: @today
|
|
137
|
+
|
|
138
|
+
unless error
|
|
139
|
+
if due
|
|
140
|
+
if due < date
|
|
141
|
+
message = "Due: <b>#{due}</b> (#{label_days (@today - due).to_i} ago)"
|
|
142
|
+
error = 'Overdue'
|
|
143
|
+
elsif due == date
|
|
144
|
+
message = 'Due: <b>today</b>'
|
|
145
|
+
else
|
|
146
|
+
error = 'Due date at risk' if date_range.end + days_remaining > due
|
|
147
|
+
message = "Due: <b>#{due}</b> (#{label_days (due - @today).to_i})"
|
|
148
|
+
end
|
|
149
|
+
else
|
|
150
|
+
"#{label_days days_remaining} left."
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
text = +''
|
|
155
|
+
text << "<span title='#{error}' style='color: red'>ⓘ </span>" if error
|
|
156
|
+
if days_remaining
|
|
157
|
+
text << "#{label_days days_remaining} left"
|
|
158
|
+
else
|
|
159
|
+
text << 'Unable to forecast'
|
|
160
|
+
end
|
|
161
|
+
text << ' | ' << message if message
|
|
162
|
+
text
|
|
124
163
|
end
|
|
125
164
|
|
|
126
165
|
def age_cutoff age = nil
|
|
@@ -128,10 +167,6 @@ class AgingWorkTable < ChartBase
|
|
|
128
167
|
@age_cutoff
|
|
129
168
|
end
|
|
130
169
|
|
|
131
|
-
def any_scrum_boards?
|
|
132
|
-
@any_scrum_boards
|
|
133
|
-
end
|
|
134
|
-
|
|
135
170
|
def parent_hierarchy issue
|
|
136
171
|
result = []
|
|
137
172
|
|
|
@@ -12,6 +12,7 @@ class Anonymizer
|
|
|
12
12
|
@all_boards = @project_config.all_boards
|
|
13
13
|
@possible_statuses = @project_config.possible_statuses
|
|
14
14
|
@date_adjustment = date_adjustment
|
|
15
|
+
@file_system = project_config.exporter.file_system
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def run
|
|
@@ -20,7 +21,7 @@ class Anonymizer
|
|
|
20
21
|
# anonymize_issue_statuses
|
|
21
22
|
anonymize_board_names
|
|
22
23
|
shift_all_dates unless @date_adjustment.zero?
|
|
23
|
-
|
|
24
|
+
@file_system.log 'Anonymize done'
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
def random_phrase
|
|
@@ -30,7 +31,7 @@ class Anonymizer
|
|
|
30
31
|
5.times do |i|
|
|
31
32
|
return RandomWord.phrases.next.tr('_', ' ')
|
|
32
33
|
rescue # rubocop:disable Style/RescueStandardError We don't care what exception was thrown.
|
|
33
|
-
|
|
34
|
+
@file_system.log "Random word blew up on attempt #{i + 1}"
|
|
34
35
|
end
|
|
35
36
|
end
|
|
36
37
|
|
|
@@ -55,7 +56,7 @@ class Anonymizer
|
|
|
55
56
|
|
|
56
57
|
def anonymize_column_names
|
|
57
58
|
@all_boards.each_key do |board_id|
|
|
58
|
-
|
|
59
|
+
@file_system.log "Anonymizing column names for board #{board_id}"
|
|
59
60
|
|
|
60
61
|
column_name = 'Column-A'
|
|
61
62
|
@all_boards[board_id].visible_columns.each do |column|
|
|
@@ -93,7 +94,7 @@ class Anonymizer
|
|
|
93
94
|
end
|
|
94
95
|
|
|
95
96
|
def anonymize_issue_statuses
|
|
96
|
-
|
|
97
|
+
@file_system.log 'Anonymizing issue statuses and status categories'
|
|
97
98
|
status_name_hash = build_status_name_hash
|
|
98
99
|
|
|
99
100
|
@issues.each do |issue|
|
|
@@ -130,7 +131,7 @@ class Anonymizer
|
|
|
130
131
|
end
|
|
131
132
|
|
|
132
133
|
def shift_all_dates
|
|
133
|
-
|
|
134
|
+
@file_system.log "Shifting all dates by #{@date_adjustment} days"
|
|
134
135
|
@issues.each do |issue|
|
|
135
136
|
issue.changes.each do |change|
|
|
136
137
|
change.time = change.time + @date_adjustment
|
|
@@ -15,12 +15,12 @@ class BlockedStalledChange
|
|
|
15
15
|
@time = time
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def blocked? = @flag || blocked_by_status? || @blocking_issue_keys
|
|
19
|
-
def stalled? = @stalled_days || stalled_by_status?
|
|
18
|
+
def blocked? = !!(@flag || blocked_by_status? || @blocking_issue_keys)
|
|
19
|
+
def stalled? = !!(@stalled_days || stalled_by_status?)
|
|
20
20
|
def active? = !blocked? && !stalled?
|
|
21
21
|
|
|
22
|
-
def blocked_by_status? = @status && @status_is_blocking
|
|
23
|
-
def stalled_by_status? = @status && !@status_is_blocking
|
|
22
|
+
def blocked_by_status? = !!(@status && @status_is_blocking)
|
|
23
|
+
def stalled_by_status? = !!(@status && !@status_is_blocking)
|
|
24
24
|
|
|
25
25
|
def reasons
|
|
26
26
|
result = []
|
|
@@ -35,4 +35,24 @@ class BlockedStalledChange
|
|
|
35
35
|
end
|
|
36
36
|
result.join(', ')
|
|
37
37
|
end
|
|
38
|
+
|
|
39
|
+
def as_symbol
|
|
40
|
+
if blocked?
|
|
41
|
+
:blocked
|
|
42
|
+
elsif stalled?
|
|
43
|
+
:stalled
|
|
44
|
+
else
|
|
45
|
+
:active
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def inspect
|
|
50
|
+
text = "BlockedStalledChange(time: '#{@time}', "
|
|
51
|
+
if active?
|
|
52
|
+
text << 'Active'
|
|
53
|
+
else
|
|
54
|
+
text << reasons
|
|
55
|
+
end
|
|
56
|
+
text << ')'
|
|
57
|
+
end
|
|
38
58
|
end
|
data/lib/jirametrics/board.rb
CHANGED
|
@@ -1,39 +1,40 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Board
|
|
4
|
-
attr_reader :visible_columns, :raw, :possible_statuses, :sprints, :
|
|
5
|
-
attr_accessor :cycletime, :project_config
|
|
4
|
+
attr_reader :visible_columns, :raw, :possible_statuses, :sprints, :board_type
|
|
5
|
+
attr_accessor :cycletime, :project_config
|
|
6
6
|
|
|
7
|
-
def initialize raw:, possible_statuses:
|
|
7
|
+
def initialize raw:, possible_statuses:
|
|
8
8
|
@raw = raw
|
|
9
9
|
@board_type = raw['type']
|
|
10
10
|
@possible_statuses = possible_statuses
|
|
11
11
|
@sprints = []
|
|
12
|
-
@expedited_priority_names = []
|
|
13
12
|
|
|
14
13
|
columns = raw['columnConfig']['columns']
|
|
14
|
+
ensure_uniqueness_of_column_names! columns
|
|
15
15
|
|
|
16
16
|
# For a Kanban board, the first column here will always be called 'Backlog' and will NOT be
|
|
17
17
|
# visible on the board. If the board is configured to have a kanban backlog then it will have
|
|
18
18
|
# statuses matched to it and otherwise, there will be no statuses.
|
|
19
|
-
if kanban?
|
|
20
|
-
@backlog_statuses = @possible_statuses.expand_statuses(status_ids_from_column columns[0]) do |unknown_status|
|
|
21
|
-
# There is a status defined as being 'backlog' that is no longer being returned in statuses.
|
|
22
|
-
# We used to display a warning for this but honestly, there is nothing that anyone can do about it
|
|
23
|
-
# so now we just quietly ignore it.
|
|
24
|
-
end
|
|
25
|
-
columns = columns[1..]
|
|
26
|
-
else
|
|
27
|
-
# We currently don't know how to get the backlog status for a Scrum board
|
|
28
|
-
@backlog_statuses = []
|
|
29
|
-
end
|
|
19
|
+
columns = columns.drop(1) if kanban?
|
|
30
20
|
|
|
21
|
+
@backlog_statuses = []
|
|
31
22
|
@visible_columns = columns.filter_map do |column|
|
|
32
23
|
# It's possible for a column to be defined without any statuses and in this case, it won't be visible.
|
|
33
24
|
BoardColumn.new column unless status_ids_from_column(column).empty?
|
|
34
25
|
end
|
|
35
26
|
end
|
|
36
27
|
|
|
28
|
+
def backlog_statuses
|
|
29
|
+
if @backlog_statuses.empty? && kanban?
|
|
30
|
+
status_ids = status_ids_from_column raw['columnConfig']['columns'].first
|
|
31
|
+
@backlog_statuses = status_ids.filter_map do |id|
|
|
32
|
+
@possible_statuses.find_by_id id
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
@backlog_statuses
|
|
36
|
+
end
|
|
37
|
+
|
|
37
38
|
def server_url_prefix
|
|
38
39
|
raise "Cannot parse self: #{@raw['self'].inspect}" unless @raw['self'] =~ /^(https?:\/\/.+)\/rest\//
|
|
39
40
|
|
|
@@ -88,4 +89,32 @@ class Board
|
|
|
88
89
|
def name
|
|
89
90
|
@raw['name']
|
|
90
91
|
end
|
|
92
|
+
|
|
93
|
+
def accumulated_status_ids_per_column
|
|
94
|
+
accumulated_status_ids = []
|
|
95
|
+
visible_columns.reverse.filter_map do |column|
|
|
96
|
+
next if column == @fake_column
|
|
97
|
+
|
|
98
|
+
accumulated_status_ids += column.status_ids
|
|
99
|
+
[column.name, accumulated_status_ids.dup]
|
|
100
|
+
end.reverse
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def ensure_uniqueness_of_column_names! json
|
|
104
|
+
all_names = []
|
|
105
|
+
json.each do |column_json|
|
|
106
|
+
name = column_json['name']
|
|
107
|
+
if all_names.include? name
|
|
108
|
+
(2..).each do |i|
|
|
109
|
+
new_name = "#{name}-#{i}"
|
|
110
|
+
next if all_names.include?(new_name)
|
|
111
|
+
|
|
112
|
+
name = new_name
|
|
113
|
+
column_json['name'] = new_name
|
|
114
|
+
break
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
all_names << name
|
|
118
|
+
end
|
|
119
|
+
end
|
|
91
120
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class BoardConfig
|
|
4
|
-
attr_reader :id, :project_config
|
|
4
|
+
attr_reader :id, :project_config, :board
|
|
5
5
|
|
|
6
6
|
def initialize id:, block:, project_config:
|
|
7
7
|
@id = id
|
|
@@ -11,7 +11,6 @@ class BoardConfig
|
|
|
11
11
|
|
|
12
12
|
def run
|
|
13
13
|
@board = @project_config.all_boards[id]
|
|
14
|
-
@board.expedited_priority_names = []
|
|
15
14
|
|
|
16
15
|
instance_eval(&@block)
|
|
17
16
|
end
|
|
@@ -22,10 +21,15 @@ class BoardConfig
|
|
|
22
21
|
'If so, remove it from there.'
|
|
23
22
|
end
|
|
24
23
|
|
|
25
|
-
@board.cycletime = CycleTimeConfig.new(
|
|
24
|
+
@board.cycletime = CycleTimeConfig.new(
|
|
25
|
+
parent_config: self, label: label, block: block, file_system: project_config.file_system
|
|
26
|
+
)
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def expedited_priority_names *priority_names
|
|
29
|
-
|
|
30
|
+
project_config.exporter.file_system.deprecated(
|
|
31
|
+
date: '2024-09-15', message: 'Expedited priority names are now specified in settings'
|
|
32
|
+
)
|
|
33
|
+
@project_config.settings['expedited_priority_names'] = priority_names
|
|
30
34
|
end
|
|
31
35
|
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class BoardMovementCalculator
|
|
4
|
+
attr_reader :board, :issues, :today
|
|
5
|
+
|
|
6
|
+
def initialize board:, issues:, today:
|
|
7
|
+
@board = board
|
|
8
|
+
@issues = issues.select { |issue| issue.board == board && issue.done? && !moves_backwards?(issue) }
|
|
9
|
+
@today = today
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def moves_backwards? issue
|
|
13
|
+
started, stopped = issue.board.cycletime.started_stopped_times(issue)
|
|
14
|
+
return false unless started
|
|
15
|
+
|
|
16
|
+
previous_column = nil
|
|
17
|
+
issue.status_changes.each do |change|
|
|
18
|
+
column = board.visible_columns.index { |c| c.status_ids.include?(change.value_id) }
|
|
19
|
+
next if change.time < started
|
|
20
|
+
next if column.nil? # It disappeared from the board for a bit
|
|
21
|
+
return true if previous_column && column && column < previous_column
|
|
22
|
+
|
|
23
|
+
previous_column = column
|
|
24
|
+
end
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stacked_age_data_for percentages:
|
|
29
|
+
data_list = percentages.sort.collect do |percentage|
|
|
30
|
+
[percentage, age_data_for(percentage: percentage)]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
stack_data data_list
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def stack_data data_list
|
|
37
|
+
remainder = nil
|
|
38
|
+
data_list.collect do |percentage, data|
|
|
39
|
+
unless remainder.nil?
|
|
40
|
+
data = (0...data.length).collect do |i|
|
|
41
|
+
data[i] - remainder[i]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
end
|
|
45
|
+
remainder = data
|
|
46
|
+
|
|
47
|
+
[percentage, data]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def age_data_for percentage:
|
|
52
|
+
data = []
|
|
53
|
+
board.visible_columns.each_with_index do |_column, column_index|
|
|
54
|
+
ages = ages_of_issues_when_leaving_column column_index: column_index, today: today
|
|
55
|
+
|
|
56
|
+
if ages.empty?
|
|
57
|
+
data << 0
|
|
58
|
+
else
|
|
59
|
+
index = ((ages.size - 1) * percentage / 100).to_i
|
|
60
|
+
data << ages[index]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
data
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ages_of_issues_when_leaving_column column_index:, today:
|
|
67
|
+
this_column = board.visible_columns[column_index]
|
|
68
|
+
next_column = board.visible_columns[column_index + 1]
|
|
69
|
+
|
|
70
|
+
@issues.filter_map do |issue|
|
|
71
|
+
this_column_start = issue.first_time_in_or_right_of_column(this_column.name)&.time
|
|
72
|
+
next_column_start = next_column.nil? ? nil : issue.first_time_in_or_right_of_column(next_column.name)&.time
|
|
73
|
+
issue_start, issue_done = issue.board.cycletime.started_stopped_times(issue)
|
|
74
|
+
|
|
75
|
+
# Skip if we can't tell when it started.
|
|
76
|
+
next if issue_start.nil?
|
|
77
|
+
|
|
78
|
+
# Skip if it never entered this column
|
|
79
|
+
next if this_column_start.nil?
|
|
80
|
+
|
|
81
|
+
# Skip if it left this column before the item is considered started.
|
|
82
|
+
next 0 if next_column_start && next_column_start <= issue_start
|
|
83
|
+
|
|
84
|
+
# Skip if it was already done by the time it got to this column or it became done when it got to this column
|
|
85
|
+
next if issue_done && issue_done <= this_column_start
|
|
86
|
+
|
|
87
|
+
end_date = case # rubocop:disable Style/EmptyCaseCondition
|
|
88
|
+
when next_column_start.nil?
|
|
89
|
+
# If this is the last column then base age against today
|
|
90
|
+
today
|
|
91
|
+
when issue_done && issue_done < next_column_start
|
|
92
|
+
# it completed while in this column
|
|
93
|
+
issue_done.to_date
|
|
94
|
+
else
|
|
95
|
+
# It passed through this whole column
|
|
96
|
+
next_column_start.to_date
|
|
97
|
+
end
|
|
98
|
+
(end_date - issue_start.to_date).to_i + 1
|
|
99
|
+
end.sort
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Figure out what column this is issue is currently in and what time it entered that column. We need this for
|
|
103
|
+
# aging and forecasting purposes
|
|
104
|
+
def find_current_column_and_entry_time_in_column issue
|
|
105
|
+
column = board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
|
|
106
|
+
return [] if column.nil? # This issue isn't visible on the board
|
|
107
|
+
|
|
108
|
+
status_ids = column.status_ids
|
|
109
|
+
|
|
110
|
+
entry_at = issue.changes.reverse.find { |change| change.status? && status_ids.include?(change.value_id) }&.time
|
|
111
|
+
|
|
112
|
+
[column.name, entry_at]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def label_days days
|
|
116
|
+
"#{days} day#{'s' unless days == 1}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def forecasted_days_remaining_and_message issue:, today:
|
|
120
|
+
return [nil, 'Already done'] if issue.done?
|
|
121
|
+
|
|
122
|
+
likely_age_data = age_data_for percentage: 85
|
|
123
|
+
|
|
124
|
+
column_name, entry_time = find_current_column_and_entry_time_in_column issue
|
|
125
|
+
return [nil, 'This issue is not visible on the board. No way to predict when it will be done.'] if column_name.nil?
|
|
126
|
+
|
|
127
|
+
age_in_column = (today - entry_time.to_date).to_i + 1
|
|
128
|
+
|
|
129
|
+
message = nil
|
|
130
|
+
column_index = board.visible_columns.index { |c| c.name == column_name }
|
|
131
|
+
|
|
132
|
+
last_non_zero_datapoint = likely_age_data.reverse.find { |d| !d.zero? }
|
|
133
|
+
return [nil, 'There is no historical data for this board. No forecast can be made.'] if last_non_zero_datapoint.nil?
|
|
134
|
+
|
|
135
|
+
remaining_in_current_column = likely_age_data[column_index] - age_in_column
|
|
136
|
+
if remaining_in_current_column.negative?
|
|
137
|
+
message = "This item is an outlier at #{label_days issue.board.cycletime.age(issue, today: today)} " \
|
|
138
|
+
"in the #{column_name.inspect} column. Most items on this board have left this column in " \
|
|
139
|
+
"#{label_days likely_age_data[column_index]} or less, so we cannot forecast when it will be done."
|
|
140
|
+
remaining_in_current_column = 0
|
|
141
|
+
return [nil, message]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
forecasted_days = last_non_zero_datapoint - likely_age_data[column_index] + remaining_in_current_column
|
|
145
|
+
[forecasted_days, message]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class ChangeItem
|
|
4
|
-
attr_reader :field, :value_id, :old_value_id, :raw, :author
|
|
5
|
-
attr_accessor :value, :old_value
|
|
4
|
+
attr_reader :field, :value_id, :old_value_id, :raw, :author, :time
|
|
5
|
+
attr_accessor :value, :old_value
|
|
6
6
|
|
|
7
7
|
def initialize raw:, time:, author:, artificial: false
|
|
8
|
-
# raw will only ever be nil in a test and in that case field and value should be passed in
|
|
9
8
|
@raw = raw
|
|
10
9
|
@time = time
|
|
11
|
-
raise
|
|
10
|
+
raise 'ChangeItem.new() time cannot be nil' if time.nil?
|
|
11
|
+
raise "Time must be an object of type Time in the correct timezone: #{@time.inspect}" unless @time.is_a? Time
|
|
12
12
|
|
|
13
|
-
@field =
|
|
14
|
-
@value =
|
|
13
|
+
@field = @raw['field']
|
|
14
|
+
@value = @raw['toString']
|
|
15
15
|
@value_id = @raw['to'].to_i
|
|
16
16
|
@old_value = @raw['fromString']
|
|
17
17
|
@old_value_id = @raw['from']&.to_i
|
|
@@ -35,17 +35,30 @@ class ChangeItem
|
|
|
35
35
|
|
|
36
36
|
def link? = (field == 'Link')
|
|
37
37
|
|
|
38
|
+
def labels? = (field == 'labels')
|
|
39
|
+
|
|
40
|
+
# An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
|
|
41
|
+
def to_time = @time
|
|
42
|
+
|
|
38
43
|
def to_s
|
|
39
|
-
message =
|
|
40
|
-
message
|
|
41
|
-
message
|
|
44
|
+
message = +''
|
|
45
|
+
message << "ChangeItem(field: #{field.inspect}"
|
|
46
|
+
message << ", value: #{value.inspect}"
|
|
47
|
+
message << ':' << value_id.inspect if status?
|
|
48
|
+
if old_value
|
|
49
|
+
message << ", old_value: #{old_value.inspect}"
|
|
50
|
+
message << ':' << old_value_id.inspect if status?
|
|
51
|
+
end
|
|
52
|
+
message << ", time: #{time_to_s(@time).inspect}"
|
|
53
|
+
message << ', artificial' if artificial?
|
|
54
|
+
message << ')'
|
|
42
55
|
message
|
|
43
56
|
end
|
|
44
57
|
|
|
45
58
|
def inspect = to_s
|
|
46
59
|
|
|
47
60
|
def == other
|
|
48
|
-
field.eql?(other.field) && value.eql?(other.value) && time.
|
|
61
|
+
field.eql?(other.field) && value.eql?(other.value) && time_to_s(time).eql?(time_to_s(other.time))
|
|
49
62
|
end
|
|
50
63
|
|
|
51
64
|
def current_status_matches *status_names_or_ids
|
|
@@ -77,4 +90,12 @@ class ChangeItem
|
|
|
77
90
|
end
|
|
78
91
|
end
|
|
79
92
|
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def time_to_s time
|
|
97
|
+
# MRI and JRuby return different strings for to_s() so we have to explicitly provide a full
|
|
98
|
+
# format so that tests work under both environments.
|
|
99
|
+
time.strftime '%Y-%m-%d %H:%M:%S %z'
|
|
100
|
+
end
|
|
80
101
|
end
|