jirametrics 1.0.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/bin/jirametrics +1 -0
- data/lib/jirametrics/aggregate_config.rb +28 -25
- data/lib/jirametrics/aging_work_bar_chart.rb +82 -59
- data/lib/jirametrics/aging_work_in_progress_chart.rb +109 -43
- data/lib/jirametrics/aging_work_table.rb +78 -43
- data/lib/jirametrics/anonymizer.rb +9 -8
- data/lib/jirametrics/blocked_stalled_change.rb +27 -12
- data/lib/jirametrics/board.rb +61 -26
- 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 +105 -64
- 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 +71 -6
- data/lib/jirametrics/cycletime_scatterplot.rb +16 -17
- 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 +62 -15
- data/lib/jirametrics/data_quality_report.rb +225 -44
- data/lib/jirametrics/dependency_chart.rb +59 -27
- data/lib/jirametrics/download_config.rb +11 -18
- data/lib/jirametrics/downloader.rb +100 -123
- data/lib/jirametrics/{story_point_accuracy_chart.rb → estimate_accuracy_chart.rb} +49 -39
- data/lib/jirametrics/examples/aggregated_project.rb +49 -4
- data/lib/jirametrics/examples/standard_project.rb +31 -46
- data/lib/jirametrics/expedited_chart.rb +31 -28
- data/lib/jirametrics/exporter.rb +69 -50
- data/lib/jirametrics/file_config.rb +34 -13
- data/lib/jirametrics/file_system.rb +81 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
- data/lib/jirametrics/groupable_issue_chart.rb +2 -15
- data/lib/jirametrics/grouping_rules.rb +7 -1
- data/lib/jirametrics/hierarchy_table.rb +5 -5
- 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 +110 -113
- data/lib/jirametrics/issue.rb +361 -114
- data/lib/jirametrics/issue_link.rb +0 -7
- data/lib/jirametrics/jira_gateway.rb +77 -0
- data/lib/jirametrics/project_config.rb +277 -164
- data/lib/jirametrics/rules.rb +3 -22
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +10 -0
- data/lib/jirametrics/sprint_burndown.rb +29 -11
- data/lib/jirametrics/sprint_issue_change_data.rb +4 -9
- data/lib/jirametrics/status.rb +96 -9
- data/lib/jirametrics/status_collection.rb +81 -39
- data/lib/jirametrics/throughput_chart.rb +14 -6
- data/lib/jirametrics/trend_line_calculator.rb +4 -4
- data/lib/jirametrics/value_equality.rb +23 -0
- data/lib/jirametrics.rb +41 -8
- metadata +20 -30
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/experimental/generator.rb +0 -209
- data/lib/jirametrics/experimental/info.rb +0 -77
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
- data/lib/jirametrics/json_file_loader.rb +0 -9
|
@@ -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
|
|
@@ -28,9 +29,9 @@ class Anonymizer
|
|
|
28
29
|
# just try again. In every case we've seen, it's worked on the second attempt, but we'll be
|
|
29
30
|
# cautious and try five times.
|
|
30
31
|
5.times do |i|
|
|
31
|
-
return RandomWord.phrases.next.
|
|
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
|
|
|
@@ -45,7 +46,7 @@ class Anonymizer
|
|
|
45
46
|
|
|
46
47
|
issue.issue_links.each do |link|
|
|
47
48
|
other_issue = link.other_issue
|
|
48
|
-
next if other_issue.key
|
|
49
|
+
next if other_issue.key.match?(/^ANON-\d+$/) # Already anonymized?
|
|
49
50
|
|
|
50
51
|
other_issue.raw['key'] = "ANON-#{counter += 1}"
|
|
51
52
|
other_issue.raw['fields']['summary'] = random_phrase
|
|
@@ -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
|
|
@@ -179,7 +180,7 @@ class Anonymizer
|
|
|
179
180
|
end
|
|
180
181
|
|
|
181
182
|
def anonymize_board_names
|
|
182
|
-
@all_boards.
|
|
183
|
+
@all_boards.each_value do |board|
|
|
183
184
|
board.raw['name'] = "#{random_phrase} board"
|
|
184
185
|
end
|
|
185
186
|
end
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'jirametrics/value_equality'
|
|
4
|
+
|
|
3
5
|
class BlockedStalledChange
|
|
6
|
+
include ValueEquality
|
|
4
7
|
attr_reader :time, :blocking_issue_keys, :flag, :status, :stalled_days, :status_is_blocking
|
|
5
8
|
|
|
6
9
|
def initialize time:, flagged: nil, status: nil, status_is_blocking: true, blocking_issue_keys: nil, stalled_days: nil
|
|
@@ -12,20 +15,12 @@ class BlockedStalledChange
|
|
|
12
15
|
@time = time
|
|
13
16
|
end
|
|
14
17
|
|
|
15
|
-
def blocked? = @flag || blocked_by_status? || @blocking_issue_keys
|
|
16
|
-
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?)
|
|
17
20
|
def active? = !blocked? && !stalled?
|
|
18
21
|
|
|
19
|
-
def blocked_by_status? = (@status && @status_is_blocking)
|
|
20
|
-
def stalled_by_status? = (@status && !@status_is_blocking)
|
|
21
|
-
|
|
22
|
-
def ==(other)
|
|
23
|
-
(other.class == self.class) && (other.state == state)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def state
|
|
27
|
-
instance_variables.map { |variable| instance_variable_get variable }
|
|
28
|
-
end
|
|
22
|
+
def blocked_by_status? = !!(@status && @status_is_blocking)
|
|
23
|
+
def stalled_by_status? = !!(@status && !@status_is_blocking)
|
|
29
24
|
|
|
30
25
|
def reasons
|
|
31
26
|
result = []
|
|
@@ -40,4 +35,24 @@ class BlockedStalledChange
|
|
|
40
35
|
end
|
|
41
36
|
result.join(', ')
|
|
42
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
|
|
43
58
|
end
|
data/lib/jirametrics/board.rb
CHANGED
|
@@ -1,53 +1,53 @@
|
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
columns = columns.drop(1) if kanban?
|
|
20
|
+
|
|
21
|
+
@backlog_statuses = []
|
|
22
|
+
@visible_columns = columns.filter_map do |column|
|
|
23
|
+
# It's possible for a column to be defined without any statuses and in this case, it won't be visible.
|
|
24
|
+
BoardColumn.new column unless status_ids_from_column(column).empty?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
29
33
|
end
|
|
30
|
-
columns = columns[1..]
|
|
31
|
-
else
|
|
32
|
-
# We currently don't know how to get the backlog status for a Scrum board
|
|
33
|
-
@backlog_statuses = []
|
|
34
34
|
end
|
|
35
|
+
@backlog_statuses
|
|
36
|
+
end
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
def server_url_prefix
|
|
39
|
+
raise "Cannot parse self: #{@raw['self'].inspect}" unless @raw['self'] =~ /^(https?:\/\/.+)\/rest\//
|
|
40
|
+
|
|
41
|
+
$1
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
def url
|
|
43
45
|
# Strangely, the URL isn't anywhere in the returned data so we have to fabricate it.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"#{$1}/secure/RapidBoard.jspa?rapidView=#{id}"
|
|
46
|
+
"#{server_url_prefix}/secure/RapidBoard.jspa?rapidView=#{id}"
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def status_ids_from_column column
|
|
50
|
-
column['statuses']
|
|
50
|
+
column['statuses']&.collect { |status| status['id'].to_i } || []
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def status_ids_in_or_right_of_column column_name
|
|
@@ -61,7 +61,7 @@ class Board
|
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
unless found_it
|
|
64
|
-
column_names = @visible_columns.collect
|
|
64
|
+
column_names = @visible_columns.collect { |c| c.name.inspect }.join(', ')
|
|
65
65
|
raise "No visible column with name: #{column_name.inspect} Possible options are: #{column_names}"
|
|
66
66
|
end
|
|
67
67
|
status_ids
|
|
@@ -79,7 +79,42 @@ class Board
|
|
|
79
79
|
@raw['id'].to_i
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
def project_id
|
|
83
|
+
location = @raw['location']
|
|
84
|
+
return nil unless location
|
|
85
|
+
|
|
86
|
+
location['id'] if location['type'] == 'project'
|
|
87
|
+
end
|
|
88
|
+
|
|
82
89
|
def name
|
|
83
90
|
@raw['name']
|
|
84
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
|
|
85
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
|