jirametrics 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|