jirametrics 1.0.0
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 +7 -0
- data/bin/jirametrics +4 -0
- data/lib/jirametrics/aggregate_config.rb +89 -0
- data/lib/jirametrics/aging_work_bar_chart.rb +235 -0
- data/lib/jirametrics/aging_work_in_progress_chart.rb +148 -0
- data/lib/jirametrics/aging_work_table.rb +149 -0
- data/lib/jirametrics/anonymizer.rb +186 -0
- data/lib/jirametrics/blocked_stalled_change.rb +43 -0
- data/lib/jirametrics/board.rb +85 -0
- data/lib/jirametrics/board_column.rb +14 -0
- data/lib/jirametrics/board_config.rb +31 -0
- data/lib/jirametrics/change_item.rb +80 -0
- data/lib/jirametrics/chart_base.rb +239 -0
- data/lib/jirametrics/columns_config.rb +42 -0
- data/lib/jirametrics/cycletime_config.rb +69 -0
- data/lib/jirametrics/cycletime_histogram.rb +74 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +128 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +88 -0
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +77 -0
- data/lib/jirametrics/daily_wip_chart.rb +123 -0
- data/lib/jirametrics/data_quality_report.rb +278 -0
- data/lib/jirametrics/dependency_chart.rb +217 -0
- data/lib/jirametrics/discard_changes_before.rb +37 -0
- data/lib/jirametrics/download_config.rb +41 -0
- data/lib/jirametrics/downloader.rb +337 -0
- data/lib/jirametrics/examples/aggregated_project.rb +36 -0
- data/lib/jirametrics/examples/standard_project.rb +111 -0
- data/lib/jirametrics/expedited_chart.rb +169 -0
- data/lib/jirametrics/experimental/generator.rb +209 -0
- data/lib/jirametrics/experimental/info.rb +77 -0
- data/lib/jirametrics/exporter.rb +127 -0
- data/lib/jirametrics/file_config.rb +119 -0
- data/lib/jirametrics/fix_version.rb +21 -0
- data/lib/jirametrics/groupable_issue_chart.rb +44 -0
- data/lib/jirametrics/grouping_rules.rb +13 -0
- data/lib/jirametrics/hierarchy_table.rb +31 -0
- data/lib/jirametrics/html/aging_work_bar_chart.erb +72 -0
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +52 -0
- data/lib/jirametrics/html/aging_work_table.erb +60 -0
- data/lib/jirametrics/html/collapsible_issues_panel.erb +32 -0
- data/lib/jirametrics/html/cycletime_histogram.erb +41 -0
- data/lib/jirametrics/html/cycletime_scatterplot.erb +103 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +63 -0
- data/lib/jirametrics/html/data_quality_report.erb +126 -0
- data/lib/jirametrics/html/expedited_chart.erb +67 -0
- data/lib/jirametrics/html/hierarchy_table.erb +29 -0
- data/lib/jirametrics/html/index.erb +66 -0
- data/lib/jirametrics/html/sprint_burndown.erb +116 -0
- data/lib/jirametrics/html/story_point_accuracy_chart.erb +57 -0
- data/lib/jirametrics/html/throughput_chart.erb +65 -0
- data/lib/jirametrics/html_report_config.rb +217 -0
- data/lib/jirametrics/issue.rb +521 -0
- data/lib/jirametrics/issue_link.rb +60 -0
- data/lib/jirametrics/json_file_loader.rb +9 -0
- data/lib/jirametrics/project_config.rb +442 -0
- data/lib/jirametrics/rules.rb +34 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +15 -0
- data/lib/jirametrics/sprint.rb +43 -0
- data/lib/jirametrics/sprint_burndown.rb +335 -0
- data/lib/jirametrics/sprint_issue_change_data.rb +31 -0
- data/lib/jirametrics/status.rb +26 -0
- data/lib/jirametrics/status_collection.rb +67 -0
- data/lib/jirametrics/story_point_accuracy_chart.rb +139 -0
- data/lib/jirametrics/throughput_chart.rb +91 -0
- data/lib/jirametrics/tree_organizer.rb +96 -0
- data/lib/jirametrics/trend_line_calculator.rb +74 -0
- data/lib/jirametrics.rb +85 -0
- metadata +167 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 0dbe04bbc480c61f2bb1d09cdab71e9ed47fb8265336b2a1f05551bff78dec17
|
4
|
+
data.tar.gz: 69c590731144d58f3235e72fb04f7d766b47a635c47301c5c87f1dbfc2cb13d5
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 494a2760b3f32fab17432735527e20fdd60105ca670437bb6cf382bd2844d78313f985db7cf3e1534f5bc67f7f2def4d598a58d46009018d14dbbb0ab70e2ce2
|
7
|
+
data.tar.gz: c77baf2ff091972595cc126de4a03dea02f756b01db33cf90144583d65de81d07f5b37856a920d3870d72f4b7100709a243099e29fe79f78b52d96d207641545
|
data/bin/jirametrics
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'date'
|
4
|
+
|
5
|
+
class AggregateConfig
|
6
|
+
attr_reader :project_config
|
7
|
+
|
8
|
+
def initialize project_config:, block:
|
9
|
+
@project_config = project_config
|
10
|
+
@block = block
|
11
|
+
|
12
|
+
@included_projects = []
|
13
|
+
end
|
14
|
+
|
15
|
+
def evaluate_next_level
|
16
|
+
instance_eval(&@block)
|
17
|
+
|
18
|
+
if @included_projects.empty?
|
19
|
+
raise "#{@project_config.name}: When aggregating, you must include at least one other project"
|
20
|
+
end
|
21
|
+
|
22
|
+
# If the date range wasn't set then calculate it now
|
23
|
+
@project_config.time_range = find_time_range projects: @included_projects if @project_config.time_range.nil?
|
24
|
+
|
25
|
+
adjust_issue_links
|
26
|
+
end
|
27
|
+
|
28
|
+
def adjust_issue_links
|
29
|
+
issues = @project_config.issues
|
30
|
+
issues.each do |issue|
|
31
|
+
issue.issue_links.each do |link|
|
32
|
+
other_issue_key = link.other_issue.key
|
33
|
+
other_issue = issues.find { |i| i.key == other_issue_key }
|
34
|
+
|
35
|
+
link.other_issue = other_issue if other_issue
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def include_issues_from project_name
|
41
|
+
project = @project_config.exporter.project_configs.find { |p| p.name == project_name }
|
42
|
+
if project.nil?
|
43
|
+
puts "Warning: Aggregated project #{@project_config.name.inspect} is attempting to load " \
|
44
|
+
"project #{project_name.inspect} but it can't be found. Is it disabled?"
|
45
|
+
return
|
46
|
+
end
|
47
|
+
|
48
|
+
@project_config.jira_url = project.jira_url if @project_config.jira_url.nil?
|
49
|
+
unless @project_config.jira_url == project.jira_url
|
50
|
+
raise 'Not allowed to aggregate projects from different Jira instances: ' \
|
51
|
+
"#{@project_config.jira_url.inspect} and #{project.jira_url.inspect}"
|
52
|
+
end
|
53
|
+
|
54
|
+
@included_projects << project
|
55
|
+
@project_config.add_issues project.issues
|
56
|
+
end
|
57
|
+
|
58
|
+
def date_range range
|
59
|
+
@project_config.time_range = date_range_to_time_range(
|
60
|
+
date_range: range, timezone_offset: project_config.exporter.timezone_offset
|
61
|
+
)
|
62
|
+
end
|
63
|
+
|
64
|
+
def date_range_to_time_range date_range:, timezone_offset:
|
65
|
+
start_of_first_day = Time.new(
|
66
|
+
date_range.begin.year, date_range.begin.month, date_range.begin.day, 0, 0, 0, timezone_offset
|
67
|
+
)
|
68
|
+
end_of_last_day = Time.new(
|
69
|
+
date_range.end.year, date_range.end.month, date_range.end.day, 23, 59, 59, timezone_offset
|
70
|
+
)
|
71
|
+
|
72
|
+
start_of_first_day..end_of_last_day
|
73
|
+
end
|
74
|
+
|
75
|
+
def find_time_range projects:
|
76
|
+
raise "Can't calculate aggregated range as no projects were included." if projects.empty?
|
77
|
+
|
78
|
+
earliest = nil
|
79
|
+
latest = nil
|
80
|
+
projects.each do |project|
|
81
|
+
range = project.time_range
|
82
|
+
earliest = range.begin if earliest.nil? || range.begin < earliest
|
83
|
+
latest = range.end if latest.nil? || range.end > latest
|
84
|
+
end
|
85
|
+
|
86
|
+
raise "Can't calculate range" if earliest.nil? || latest.nil?
|
87
|
+
earliest..latest
|
88
|
+
end
|
89
|
+
end
|
@@ -0,0 +1,235 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'jirametrics/chart_base'
|
4
|
+
|
5
|
+
class AgingWorkBarChart < ChartBase
|
6
|
+
@@next_id = 0
|
7
|
+
|
8
|
+
def initialize block = nil
|
9
|
+
super()
|
10
|
+
|
11
|
+
header_text 'Aging Work Bar Chart'
|
12
|
+
description_text <<-HTML
|
13
|
+
<p>
|
14
|
+
This chart shows all active (started but not completed) work, ordered from oldest at the top to
|
15
|
+
newest at the bottom.
|
16
|
+
</p>
|
17
|
+
<p>
|
18
|
+
There are potentially three bars for each issue, although a bar may be missing if the issue has no
|
19
|
+
information relevant to that. Hovering over any of the bars will provide more details.
|
20
|
+
<ol><li>The top bar tells you what status the issue is in at any time. Any statuses in the status
|
21
|
+
category of "To Do" will be in blue. Any in the category of "In Progress" will be in a
|
22
|
+
yellow and any in "Done" will be green.</li>
|
23
|
+
<li>The middle bar indicates blocked and stalled states. A lighter orange is stalled and a darker,
|
24
|
+
reddish colour is blocked.</li>
|
25
|
+
<li>The bottom bar indicated an expedited state.</li></ol>
|
26
|
+
</p>
|
27
|
+
<p>
|
28
|
+
The gray backgrounds indicate weekends and the red vertical line indicates the 85% point for all
|
29
|
+
items in this time period. Anything that started to the left of that is now an outlier.
|
30
|
+
</p>
|
31
|
+
HTML
|
32
|
+
|
33
|
+
# Because this one will size itself as needed, we start with a smaller default size
|
34
|
+
@canvas_height = 80
|
35
|
+
|
36
|
+
instance_eval(&block) if block
|
37
|
+
end
|
38
|
+
|
39
|
+
def run
|
40
|
+
aging_issues = @issues.select do |issue|
|
41
|
+
cycletime = issue.board.cycletime
|
42
|
+
cycletime.started_time(issue) && cycletime.stopped_time(issue).nil?
|
43
|
+
end
|
44
|
+
|
45
|
+
grow_chart_height_if_too_many_issues aging_issues.size
|
46
|
+
|
47
|
+
today = date_range.end
|
48
|
+
aging_issues.sort! do |a, b|
|
49
|
+
a.board.cycletime.age(b, today: today) <=> b.board.cycletime.age(a, today: today)
|
50
|
+
end
|
51
|
+
data_sets = []
|
52
|
+
aging_issues.each do |issue|
|
53
|
+
cycletime = issue.board.cycletime
|
54
|
+
issue_start_time = cycletime.started_time(issue)
|
55
|
+
issue_start_date = issue_start_time.to_date
|
56
|
+
issue_label = "[#{label_days cycletime.age(issue, today: today)}] #{issue.key}: #{issue.summary}"[0..60]
|
57
|
+
[
|
58
|
+
status_data_sets(issue: issue, label: issue_label, today: today),
|
59
|
+
blocked_data_sets(
|
60
|
+
issue: issue,
|
61
|
+
issue_label: issue_label,
|
62
|
+
stack: 'blocked',
|
63
|
+
issue_start_time: issue_start_time
|
64
|
+
),
|
65
|
+
data_set_by_block(
|
66
|
+
issue: issue,
|
67
|
+
issue_label: issue_label,
|
68
|
+
title_label: 'Expedited',
|
69
|
+
stack: 'expedited',
|
70
|
+
color: 'red',
|
71
|
+
start_date: issue_start_date
|
72
|
+
) { |day| issue.expedited_on_date?(day) }
|
73
|
+
].compact.flatten.each do |data|
|
74
|
+
data_sets << data
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
percentage = calculate_percent_line
|
79
|
+
percentage_line_x = date_range.end - calculate_percent_line if percentage
|
80
|
+
|
81
|
+
wrap_and_render(binding, __FILE__)
|
82
|
+
end
|
83
|
+
|
84
|
+
def grow_chart_height_if_too_many_issues aging_issue_count
|
85
|
+
px_per_bar = 8
|
86
|
+
bars_per_issue = 3
|
87
|
+
preferred_height = aging_issue_count * px_per_bar * bars_per_issue
|
88
|
+
@canvas_height = preferred_height if @canvas_height.nil? || @canvas_height < preferred_height
|
89
|
+
end
|
90
|
+
|
91
|
+
def status_data_sets issue:, label:, today:
|
92
|
+
cycletime = issue.board.cycletime
|
93
|
+
|
94
|
+
issue_started_time = cycletime.started_time(issue)
|
95
|
+
|
96
|
+
previous_start = nil
|
97
|
+
previous_status = nil
|
98
|
+
|
99
|
+
data_sets = []
|
100
|
+
issue.changes.each do |change|
|
101
|
+
next unless change.status?
|
102
|
+
|
103
|
+
status = issue.find_status_by_name change.value
|
104
|
+
|
105
|
+
unless previous_start.nil? || previous_start < issue_started_time
|
106
|
+
hash = {
|
107
|
+
type: 'bar',
|
108
|
+
data: [{
|
109
|
+
x: [chart_format(previous_start), chart_format(change.time)],
|
110
|
+
y: label,
|
111
|
+
title: "#{issue.type} : #{change.value}"
|
112
|
+
}],
|
113
|
+
backgroundColor: status_category_color(status),
|
114
|
+
borderColor: 'white',
|
115
|
+
borderWidth: {
|
116
|
+
top: 0,
|
117
|
+
right: 1,
|
118
|
+
bottom: 0,
|
119
|
+
left: 0
|
120
|
+
},
|
121
|
+
stacked: true,
|
122
|
+
stack: 'status'
|
123
|
+
}
|
124
|
+
data_sets << hash if date_range.include?(change.time.to_date)
|
125
|
+
end
|
126
|
+
|
127
|
+
previous_start = change.time
|
128
|
+
previous_status = status
|
129
|
+
end
|
130
|
+
|
131
|
+
if previous_start
|
132
|
+
data_sets << {
|
133
|
+
type: 'bar',
|
134
|
+
data: [{
|
135
|
+
x: [chart_format(previous_start), chart_format("#{today}T00:00:00#{@timezone_offset}")],
|
136
|
+
y: label,
|
137
|
+
title: "#{issue.type} : #{previous_status.name}"
|
138
|
+
}],
|
139
|
+
backgroundColor: status_category_color(previous_status),
|
140
|
+
stacked: true,
|
141
|
+
stack: 'status'
|
142
|
+
}
|
143
|
+
end
|
144
|
+
|
145
|
+
data_sets
|
146
|
+
end
|
147
|
+
|
148
|
+
def one_block_change_data_set starting_change:, ending_time:, issue_label:, stack:, issue_start_time:
|
149
|
+
color = settings['colors']['blocked']
|
150
|
+
color = settings['colors']['stalled'] if starting_change.stalled?
|
151
|
+
{
|
152
|
+
backgroundColor: color,
|
153
|
+
data: [
|
154
|
+
{
|
155
|
+
title: starting_change.reasons,
|
156
|
+
x: [chart_format([issue_start_time, starting_change.time].max), chart_format(ending_time)],
|
157
|
+
y: issue_label
|
158
|
+
}
|
159
|
+
],
|
160
|
+
stack: stack,
|
161
|
+
stacked: true,
|
162
|
+
type: 'bar'
|
163
|
+
}
|
164
|
+
end
|
165
|
+
|
166
|
+
def blocked_data_sets issue:, issue_label:, issue_start_time:, stack:
|
167
|
+
data_sets = []
|
168
|
+
starting_change = nil
|
169
|
+
|
170
|
+
issue.blocked_stalled_changes(end_time: time_range.end).each do |change|
|
171
|
+
if starting_change.nil? || starting_change.active?
|
172
|
+
starting_change = change
|
173
|
+
next
|
174
|
+
end
|
175
|
+
|
176
|
+
if change.time >= issue_start_time
|
177
|
+
data_sets << one_block_change_data_set(
|
178
|
+
starting_change: starting_change, ending_time: change.time,
|
179
|
+
issue_label: issue_label, stack: stack, issue_start_time: issue_start_time
|
180
|
+
)
|
181
|
+
end
|
182
|
+
|
183
|
+
starting_change = change
|
184
|
+
end
|
185
|
+
|
186
|
+
data_sets
|
187
|
+
end
|
188
|
+
|
189
|
+
def data_set_by_block(
|
190
|
+
issue:, issue_label:, title_label:, stack:, color:, start_date:, end_date: date_range.end, &block
|
191
|
+
)
|
192
|
+
started = nil
|
193
|
+
ended = nil
|
194
|
+
data = []
|
195
|
+
|
196
|
+
(start_date..end_date).each do |day|
|
197
|
+
if block.call(day)
|
198
|
+
started = day if started.nil?
|
199
|
+
ended = day
|
200
|
+
elsif ended
|
201
|
+
data << {
|
202
|
+
x: [chart_format(started), chart_format(ended)],
|
203
|
+
y: issue_label,
|
204
|
+
title: "#{issue.type} : #{title_label} #{label_days (ended - started).to_i + 1}"
|
205
|
+
}
|
206
|
+
|
207
|
+
started = nil
|
208
|
+
ended = nil
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
if started
|
213
|
+
data << {
|
214
|
+
x: [chart_format(started), chart_format(ended)],
|
215
|
+
y: issue_label,
|
216
|
+
title: "#{issue.type} : #{title_label} #{label_days (end_date - started).to_i + 1}"
|
217
|
+
}
|
218
|
+
end
|
219
|
+
|
220
|
+
{
|
221
|
+
type: 'bar',
|
222
|
+
data: data,
|
223
|
+
backgroundColor: color,
|
224
|
+
stacked: true,
|
225
|
+
stack: stack
|
226
|
+
}
|
227
|
+
end
|
228
|
+
|
229
|
+
def calculate_percent_line percentage: 85
|
230
|
+
days = completed_issues_in_range.collect { |issue| issue.board.cycletime.cycletime(issue) }.compact.sort
|
231
|
+
return nil if days.empty?
|
232
|
+
|
233
|
+
days[days.length * percentage / 100]
|
234
|
+
end
|
235
|
+
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'jirametrics/chart_base'
|
4
|
+
require 'jirametrics/groupable_issue_chart'
|
5
|
+
|
6
|
+
class AgingWorkInProgressChart < ChartBase
|
7
|
+
include GroupableIssueChart
|
8
|
+
attr_accessor :possible_statuses, :board_id
|
9
|
+
attr_reader :board_columns
|
10
|
+
|
11
|
+
def initialize block = nil
|
12
|
+
super()
|
13
|
+
header_text 'Aging Work in Progress'
|
14
|
+
description_text <<-HTML
|
15
|
+
<p>
|
16
|
+
This chart shows only work items that have started but not completed, grouped by the column
|
17
|
+
they're currently in. Hovering over a dot will show you the ID of that work item.
|
18
|
+
</p>
|
19
|
+
<p>
|
20
|
+
The gray area indicates the 85% mark for work items that have passed through here - 85% of
|
21
|
+
previous work items left this column while still inside the gray area. Any work items above
|
22
|
+
the gray area are outliers and they are the items that you should pay special attention to.
|
23
|
+
</p>
|
24
|
+
HTML
|
25
|
+
init_configuration_block(block) do
|
26
|
+
grouping_rules do |issue, rule|
|
27
|
+
rule.label = issue.type
|
28
|
+
rule.color = color_for type: issue.type
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def run
|
34
|
+
determine_board_columns
|
35
|
+
|
36
|
+
@header_text += " on board: #{@all_boards[@board_id].name}"
|
37
|
+
data_sets = make_data_sets
|
38
|
+
column_headings = @board_columns.collect(&:name)
|
39
|
+
|
40
|
+
adjust_visibility_of_unmapped_status_column data_sets: data_sets, column_headings: column_headings
|
41
|
+
|
42
|
+
wrap_and_render(binding, __FILE__)
|
43
|
+
end
|
44
|
+
|
45
|
+
def determine_board_columns
|
46
|
+
unmapped_statuses = current_board.possible_statuses.collect(&:id)
|
47
|
+
|
48
|
+
columns = current_board.visible_columns
|
49
|
+
columns.each { |c| unmapped_statuses -= c.status_ids }
|
50
|
+
|
51
|
+
@fake_column = BoardColumn.new({
|
52
|
+
'name' => '[Unmapped Statuses]',
|
53
|
+
'statuses' => unmapped_statuses.collect { |id| { 'id' => id.to_s } }.uniq
|
54
|
+
})
|
55
|
+
@board_columns = columns + [@fake_column]
|
56
|
+
end
|
57
|
+
|
58
|
+
def make_data_sets
|
59
|
+
aging_issues = @issues.select do |issue|
|
60
|
+
board = issue.board
|
61
|
+
board.id == @board_id && board.cycletime.in_progress?(issue)
|
62
|
+
end
|
63
|
+
|
64
|
+
percentage = 85
|
65
|
+
rules_to_issues = group_issues aging_issues
|
66
|
+
data_sets = rules_to_issues.keys.collect do |rules|
|
67
|
+
{
|
68
|
+
'type' => 'line',
|
69
|
+
'label' => rules.label,
|
70
|
+
'data' => rules_to_issues[rules].collect do |issue|
|
71
|
+
age = issue.board.cycletime.age(issue, today: date_range.end)
|
72
|
+
column = column_for issue: issue
|
73
|
+
next if column.nil?
|
74
|
+
|
75
|
+
{ 'y' => age,
|
76
|
+
'x' => column.name,
|
77
|
+
'title' => ["#{issue.key} : #{issue.summary} (#{label_days age})"]
|
78
|
+
}
|
79
|
+
end.compact,
|
80
|
+
'fill' => false,
|
81
|
+
'showLine' => false,
|
82
|
+
'backgroundColor' => rules.color
|
83
|
+
}
|
84
|
+
end
|
85
|
+
data_sets << {
|
86
|
+
'type' => 'bar',
|
87
|
+
'label' => "#{percentage}%",
|
88
|
+
'barPercentage' => 1.0,
|
89
|
+
'categoryPercentage' => 1.0,
|
90
|
+
'data' => days_at_percentage_threshold_for_all_columns(percentage: percentage, issues: @issues).drop(1)
|
91
|
+
}
|
92
|
+
end
|
93
|
+
|
94
|
+
def days_at_percentage_threshold_for_all_columns percentage:, issues:
|
95
|
+
accumulated_status_ids_per_column.collect do |_column, status_ids|
|
96
|
+
ages = ages_of_issues_that_crossed_column_boundary issues: issues, status_ids: status_ids
|
97
|
+
index = ages.size * percentage / 100
|
98
|
+
ages.sort[index.to_i] || 0
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def accumulated_status_ids_per_column
|
103
|
+
accumulated_status_ids = []
|
104
|
+
@board_columns.reverse.collect do |column|
|
105
|
+
next if column == @fake_column
|
106
|
+
|
107
|
+
accumulated_status_ids += column.status_ids
|
108
|
+
[column.name, accumulated_status_ids.dup]
|
109
|
+
end.compact.reverse
|
110
|
+
end
|
111
|
+
|
112
|
+
def ages_of_issues_that_crossed_column_boundary issues:, status_ids:
|
113
|
+
issues.collect do |issue|
|
114
|
+
stop = issue.first_time_in_status(*status_ids)
|
115
|
+
start = issue.board.cycletime.started_time(issue)
|
116
|
+
|
117
|
+
# Skip if either it hasn't crossed the boundary or we can't tell when it started.
|
118
|
+
next if stop.nil? || start.nil?
|
119
|
+
next if stop < start
|
120
|
+
|
121
|
+
(stop.to_date - start.to_date).to_i + 1
|
122
|
+
end.compact
|
123
|
+
end
|
124
|
+
|
125
|
+
def column_for issue:
|
126
|
+
@board_columns.find do |board_column|
|
127
|
+
board_column.status_ids.include? issue.status.id
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def adjust_visibility_of_unmapped_status_column data_sets:, column_headings:
|
132
|
+
column_name = @fake_column.name
|
133
|
+
|
134
|
+
has_unmapped = data_sets.any? do |set|
|
135
|
+
set['data'].any? do |data|
|
136
|
+
data['x'] == column_name if data.is_a? Hash
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
if has_unmapped
|
141
|
+
@description_text += "<p>The items shown in #{column_name.inspect} are not visible on the " \
|
142
|
+
'board but are still active. Most likely everyone has forgotten about them.</p>'
|
143
|
+
else
|
144
|
+
column_headings.pop
|
145
|
+
@board_columns.pop
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
@@ -0,0 +1,149 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'jirametrics/chart_base'
|
4
|
+
|
5
|
+
class AgingWorkTable < ChartBase
|
6
|
+
attr_accessor :today, :board_id
|
7
|
+
|
8
|
+
def initialize block
|
9
|
+
super()
|
10
|
+
@blocked_icon = '🛑'
|
11
|
+
@expedited_icon = '🔥'
|
12
|
+
@stalled_icon = '🟧'
|
13
|
+
@stalled_threshold = 5
|
14
|
+
@dead_icon = '⬛'
|
15
|
+
@dead_threshold = 45
|
16
|
+
@age_cutoff = 0
|
17
|
+
|
18
|
+
instance_eval(&block) if block
|
19
|
+
end
|
20
|
+
|
21
|
+
def run
|
22
|
+
@today = date_range.end
|
23
|
+
aging_issues = select_aging_issues
|
24
|
+
|
25
|
+
expedited_but_not_started = @issues.select do |issue|
|
26
|
+
cycletime = issue.board.cycletime
|
27
|
+
cycletime.started_time(issue).nil? && cycletime.stopped_time(issue).nil? && issue.expedited?
|
28
|
+
end
|
29
|
+
aging_issues += expedited_but_not_started.sort_by(&:created)
|
30
|
+
|
31
|
+
render(binding, __FILE__)
|
32
|
+
end
|
33
|
+
|
34
|
+
def select_aging_issues
|
35
|
+
aging_issues = @issues.select do |issue|
|
36
|
+
cycletime = issue.board.cycletime
|
37
|
+
started = cycletime.started_time(issue)
|
38
|
+
stopped = cycletime.stopped_time(issue)
|
39
|
+
next false if started.nil? || stopped
|
40
|
+
next true if issue.blocked_on_date?(@today, end_time: time_range.end) || issue.expedited?
|
41
|
+
|
42
|
+
age = (@today - started.to_date).to_i + 1
|
43
|
+
age > @age_cutoff
|
44
|
+
end
|
45
|
+
@any_scrum_boards = aging_issues.any? { |issue| issue.board.scrum? }
|
46
|
+
aging_issues.sort { |a, b| b.board.cycletime.age(b, today: @today) <=> a.board.cycletime.age(a, today: @today) }
|
47
|
+
end
|
48
|
+
|
49
|
+
def icon_span title:, icon:
|
50
|
+
"<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
|
51
|
+
end
|
52
|
+
|
53
|
+
def expedited_text issue
|
54
|
+
return unless issue.expedited?
|
55
|
+
|
56
|
+
name = issue.raw['fields']['priority']['name']
|
57
|
+
icon_span(title: "Expedited: Has a priority of "#{name}"", icon: @expedited_icon)
|
58
|
+
end
|
59
|
+
|
60
|
+
def blocked_text issue
|
61
|
+
started_time = issue.board.cycletime.started_time(issue)
|
62
|
+
return nil if started_time.nil?
|
63
|
+
|
64
|
+
current = issue.blocked_stalled_changes(end_time: time_range.end)[-1]
|
65
|
+
if current.blocked?
|
66
|
+
icon_span title: current.reasons, icon: @blocked_icon
|
67
|
+
elsif current.stalled?
|
68
|
+
if current.stalled_days && current.stalled_days > @dead_threshold
|
69
|
+
icon_span(
|
70
|
+
title: "Dead? Hasn't had any activity in #{label_days current.stalled_days}. " \
|
71
|
+
'Does anyone still care about this?',
|
72
|
+
icon: @dead_icon
|
73
|
+
)
|
74
|
+
else
|
75
|
+
icon_span(
|
76
|
+
title: current.reasons,
|
77
|
+
icon: @stalled_icon
|
78
|
+
)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
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
|
+
def fix_versions_text issue
|
91
|
+
issue.fix_versions.collect do |fix|
|
92
|
+
if fix.released?
|
93
|
+
icon_text = icon_span title: 'Released. Likely not on the board anymore.', icon: '✅'
|
94
|
+
"#{fix.name} #{icon_text}"
|
95
|
+
else
|
96
|
+
fix.name
|
97
|
+
end
|
98
|
+
end.join('<br />')
|
99
|
+
end
|
100
|
+
|
101
|
+
def sprints_text issue
|
102
|
+
sprint_ids = []
|
103
|
+
|
104
|
+
issue.changes.each do |change|
|
105
|
+
next unless change.sprint?
|
106
|
+
|
107
|
+
sprint_ids << change.raw['to'].split(/\s*,\s*/).collect { |id| id.to_i }
|
108
|
+
end
|
109
|
+
sprint_ids.flatten!
|
110
|
+
|
111
|
+
issue.board.sprints.select { |s| sprint_ids.include? s.id }.collect do |sprint|
|
112
|
+
icon_text = nil
|
113
|
+
if sprint.active?
|
114
|
+
icon_text = icon_span title: 'Active sprint', icon: '➡️'
|
115
|
+
else
|
116
|
+
icon_text = icon_span title: 'Sprint closed', icon: '✅'
|
117
|
+
end
|
118
|
+
"#{sprint.name} #{icon_text}"
|
119
|
+
end.join('<br />')
|
120
|
+
end
|
121
|
+
|
122
|
+
def current_status_visible? issue
|
123
|
+
issue.board.visible_columns.any? { |column| column.status_ids.include? issue.status.id }
|
124
|
+
end
|
125
|
+
|
126
|
+
def age_cutoff age = nil
|
127
|
+
@age_cutoff = age.to_i if age
|
128
|
+
@age_cutoff
|
129
|
+
end
|
130
|
+
|
131
|
+
def any_scrum_boards?
|
132
|
+
@any_scrum_boards
|
133
|
+
end
|
134
|
+
|
135
|
+
def parent_hierarchy issue
|
136
|
+
result = []
|
137
|
+
|
138
|
+
while issue
|
139
|
+
cyclical_parent_links = result.include? issue
|
140
|
+
result << issue
|
141
|
+
|
142
|
+
break if cyclical_parent_links
|
143
|
+
|
144
|
+
issue = issue.parent
|
145
|
+
end
|
146
|
+
|
147
|
+
result.reverse
|
148
|
+
end
|
149
|
+
end
|