jirametrics 2.10 → 2.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
- data/lib/jirametrics/aging_work_table.rb +56 -13
- data/lib/jirametrics/board.rb +38 -10
- data/lib/jirametrics/board_config.rb +1 -0
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/change_item.rb +37 -16
- data/lib/jirametrics/chart_base.rb +7 -5
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cycletime_config.rb +1 -1
- data/lib/jirametrics/daily_view.rb +277 -0
- data/lib/jirametrics/downloader.rb +11 -0
- data/lib/jirametrics/estimate_accuracy_chart.rb +34 -10
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/standard_project.rb +2 -0
- data/lib/jirametrics/exporter.rb +2 -2
- data/lib/jirametrics/file_config.rb +1 -1
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
- data/lib/jirametrics/html/aging_work_table.erb +7 -3
- data/lib/jirametrics/html/index.css +82 -2
- data/lib/jirametrics/html/index.erb +25 -1
- data/lib/jirametrics/html_report_config.rb +2 -0
- data/lib/jirametrics/issue.rb +68 -27
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/jira_gateway.rb +4 -1
- data/lib/jirametrics/project_config.rb +24 -7
- data/lib/jirametrics/settings.json +2 -1
- data/lib/jirametrics/sprint.rb +1 -0
- data/lib/jirametrics/sprint_burndown.rb +35 -33
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +3 -0
- data/lib/jirametrics/status_collection.rb +6 -0
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics.rb +4 -0
- metadata +7 -2
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
class ChartBase
|
|
4
4
|
attr_accessor :timezone_offset, :board_id, :all_boards, :date_range,
|
|
5
|
-
:time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system
|
|
5
|
+
:time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system, :users
|
|
6
6
|
attr_writer :aggregated_project
|
|
7
7
|
attr_reader :canvas_width, :canvas_height
|
|
8
8
|
|
|
@@ -38,7 +38,6 @@ class ChartBase
|
|
|
38
38
|
# Insert a incrementing chart_id so that all the chart names on the page are unique
|
|
39
39
|
caller_binding.eval "chart_id='chart#{next_id}'" # chart_id=chart3
|
|
40
40
|
|
|
41
|
-
# @html_directory = "#{pathname.dirname}/html"
|
|
42
41
|
erb = ERB.new file_system.load "#{html_directory}/#{$1}.erb"
|
|
43
42
|
erb.result(caller_binding)
|
|
44
43
|
end
|
|
@@ -227,8 +226,8 @@ class ChartBase
|
|
|
227
226
|
icon: ' 👀'
|
|
228
227
|
)
|
|
229
228
|
end
|
|
230
|
-
text = is_category ? status.category
|
|
231
|
-
"<span title='Category: #{status.category
|
|
229
|
+
text = is_category ? status.category : status
|
|
230
|
+
"<span title='Category: #{status.category}'>#{color_block color.name} #{text}</span>#{visibility}"
|
|
232
231
|
end
|
|
233
232
|
|
|
234
233
|
def icon_span title:, icon:
|
|
@@ -260,7 +259,10 @@ class ChartBase
|
|
|
260
259
|
|
|
261
260
|
def color_block color, title: nil
|
|
262
261
|
result = +''
|
|
263
|
-
result << "<div class='color_block' style='
|
|
262
|
+
result << "<div class='color_block' style='"
|
|
263
|
+
result << "background: #{CssVariable[color]};" if color
|
|
264
|
+
result << 'visibility: hidden;' unless color
|
|
265
|
+
result << "'"
|
|
264
266
|
result << " title=#{title.inspect}" if title
|
|
265
267
|
result << '></div>'
|
|
266
268
|
result
|
|
@@ -59,7 +59,7 @@ class CycleTimeConfig
|
|
|
59
59
|
'from' => '0',
|
|
60
60
|
'fromString' => ''
|
|
61
61
|
}
|
|
62
|
-
ChangeItem.new raw: raw, time: time,
|
|
62
|
+
ChangeItem.new raw: raw, time: time, artificial: true, author_raw: nil
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def started_stopped_changes issue
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class DailyView < ChartBase
|
|
4
|
+
attr_accessor :possible_statuses
|
|
5
|
+
|
|
6
|
+
def initialize _block
|
|
7
|
+
super()
|
|
8
|
+
|
|
9
|
+
header_text 'Daily View'
|
|
10
|
+
description_text <<-HTML
|
|
11
|
+
<div class="p">
|
|
12
|
+
This view shows all the items you'll want to discuss during your daily coordination meeting
|
|
13
|
+
(aka daily scrum, standup), in the order that you should be discussing them. The most important
|
|
14
|
+
items are at the top, and the least at the bottom.
|
|
15
|
+
</div>
|
|
16
|
+
<div class="p">
|
|
17
|
+
By default, we sort by priority first and then by age within each of those priorities.
|
|
18
|
+
Hover over the issue to make it stand out more.
|
|
19
|
+
</div>
|
|
20
|
+
HTML
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
aging_issues = select_aging_issues
|
|
25
|
+
|
|
26
|
+
return "<h1>#{@header_text}</h1>There are no items currently in progress" if aging_issues.empty?
|
|
27
|
+
|
|
28
|
+
result = +''
|
|
29
|
+
result << render_top_text(binding)
|
|
30
|
+
aging_issues.each do |issue|
|
|
31
|
+
result << render_issue(issue, child: false)
|
|
32
|
+
end
|
|
33
|
+
result
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def select_aging_issues
|
|
37
|
+
aging_issues = issues.select do |issue|
|
|
38
|
+
started_at, stopped_at = issue.board.cycletime.started_stopped_times(issue)
|
|
39
|
+
started_at && !stopped_at
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
today = date_range.end
|
|
43
|
+
aging_issues.collect do |issue|
|
|
44
|
+
[issue, issue.priority_name, issue.board.cycletime.age(issue, today: today)]
|
|
45
|
+
end.sort(&issue_sorter).collect(&:first)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def issue_sorter
|
|
49
|
+
priority_names = settings['priority_order']
|
|
50
|
+
lambda do |a, b|
|
|
51
|
+
a_issue, a_priority, a_age = *a
|
|
52
|
+
b_issue, b_priority, b_age = *b
|
|
53
|
+
|
|
54
|
+
a_priority_index = priority_names.index(a_priority)
|
|
55
|
+
b_priority_index = priority_names.index(b_priority)
|
|
56
|
+
|
|
57
|
+
if a_priority_index.nil? && b_priority_index.nil?
|
|
58
|
+
result = a_priority <=> b_priority
|
|
59
|
+
elsif a_priority_index.nil?
|
|
60
|
+
result = 1
|
|
61
|
+
elsif b_priority_index.nil?
|
|
62
|
+
result = -1
|
|
63
|
+
else
|
|
64
|
+
result = b_priority_index <=> a_priority_index
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
result = b_age <=> a_age if result.zero?
|
|
68
|
+
result = a_issue <=> b_issue if result.zero?
|
|
69
|
+
result
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def make_blocked_stalled_lines issue
|
|
74
|
+
today = date_range.end
|
|
75
|
+
started_date = issue.board.cycletime.started_stopped_times(issue).first&.to_date
|
|
76
|
+
return [] unless started_date
|
|
77
|
+
|
|
78
|
+
blocked_stalled = issue.blocked_stalled_by_date(
|
|
79
|
+
date_range: today..today, chart_end_time: time_range.end, settings: settings
|
|
80
|
+
)[today]
|
|
81
|
+
return [] unless blocked_stalled
|
|
82
|
+
|
|
83
|
+
lines = []
|
|
84
|
+
if blocked_stalled.blocked?
|
|
85
|
+
marker = color_block '--blocked-color'
|
|
86
|
+
lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
|
|
87
|
+
lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
|
|
88
|
+
blocked_stalled.blocking_issue_keys&.each do |key|
|
|
89
|
+
lines << ["#{marker} Blocked by issue: #{key}"]
|
|
90
|
+
blocking_issue = issues.find { |i| i.key == key }
|
|
91
|
+
lines << blocking_issue if blocking_issue
|
|
92
|
+
end
|
|
93
|
+
elsif blocked_stalled.stalled_by_status?
|
|
94
|
+
lines << ["#{color_block '--stalled-color'} Stalled by status: #{blocked_stalled.status}"]
|
|
95
|
+
else
|
|
96
|
+
lines << ["#{color_block '--stalled-color'} Stalled by inactivity: #{blocked_stalled.stalled_days} days"]
|
|
97
|
+
end
|
|
98
|
+
lines
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def make_issue_label issue
|
|
102
|
+
"<img src='#{issue.type_icon_url}' title='#{issue.type}' class='icon' /> " \
|
|
103
|
+
"<b><a href='#{issue.url}'>#{issue.key}</a></b> <i>#{issue.summary}</i>"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def make_title_line issue
|
|
107
|
+
title_line = +''
|
|
108
|
+
title_line << color_block('--expedited-color', title: 'Expedited') if issue.expedited?
|
|
109
|
+
title_line << make_issue_label(issue)
|
|
110
|
+
title_line
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def make_parent_lines issue
|
|
114
|
+
lines = []
|
|
115
|
+
parent_key = issue.parent_key
|
|
116
|
+
if parent_key
|
|
117
|
+
parent = issues.find_by_key key: parent_key, include_hidden: true
|
|
118
|
+
text = parent ? make_issue_label(parent) : parent_key
|
|
119
|
+
lines << ["Parent: #{text}"]
|
|
120
|
+
end
|
|
121
|
+
lines
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def make_stats_lines issue
|
|
125
|
+
line = []
|
|
126
|
+
|
|
127
|
+
line << "<img src='#{issue.priority_url}' class='icon' /> <b>#{issue.priority_name}</b>"
|
|
128
|
+
|
|
129
|
+
age = issue.board.cycletime.age(issue, today: date_range.end)
|
|
130
|
+
line << "Age: <b>#{age ? label_days(age) : '(Not Started)'}</b>"
|
|
131
|
+
|
|
132
|
+
line << "Status: <b>#{format_status issue.status, board: issue.board}</b>"
|
|
133
|
+
|
|
134
|
+
column = issue.board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
|
|
135
|
+
line << "Column: <b>#{column&.name || '(not visible on board)'}</b>"
|
|
136
|
+
|
|
137
|
+
if issue.assigned_to
|
|
138
|
+
line << "Assignee: <img src='#{issue.assigned_to_icon_url}' class='icon' /> <b>#{issue.assigned_to}</b>"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
line << "Due: <b>#{issue.due_date}</b>" if issue.due_date
|
|
142
|
+
|
|
143
|
+
block = lambda do |collection, label|
|
|
144
|
+
unless collection.empty?
|
|
145
|
+
text = collection.collect { |l| "<span class='label'>#{l}</span>" }.join(' ')
|
|
146
|
+
line << "#{label} #{text}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
block.call issue.labels, 'Labels:'
|
|
150
|
+
block.call issue.component_names, 'Components:'
|
|
151
|
+
|
|
152
|
+
[line]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def make_child_lines issue
|
|
156
|
+
lines = []
|
|
157
|
+
subtasks = issue.subtasks.reject { |i| i.done? }
|
|
158
|
+
|
|
159
|
+
unless subtasks.empty?
|
|
160
|
+
icon_urls = subtasks.collect(&:type_icon_url).uniq.collect { |url| "<img src='#{url}' class='icon' />" }
|
|
161
|
+
lines << (icon_urls << 'Incomplete child issues')
|
|
162
|
+
lines += subtasks
|
|
163
|
+
end
|
|
164
|
+
lines
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def jira_rich_text_to_html text
|
|
168
|
+
text
|
|
169
|
+
.gsub(/{color:(#\w{6})}([^{]+){color}/, '<span style="color: \1">\2</span>') # Colours
|
|
170
|
+
.gsub(/\[~accountid:([^\]]+)\]/) { expand_account_id $1 } # Tagged people
|
|
171
|
+
.gsub(/\[([^\|]+)\|(https?[^\]]+)\]/, '<a href="\2">\1</a>') # URLs
|
|
172
|
+
.gsub("\n", '<br />')
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def expand_account_id account_id
|
|
176
|
+
user = @users.find { |u| u.account_id == account_id }
|
|
177
|
+
text = account_id
|
|
178
|
+
text = "@#{user.display_name}" if user
|
|
179
|
+
"<span class='account_id'>#{text}</span>"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def make_history_lines issue
|
|
183
|
+
history = issue.changes.reverse
|
|
184
|
+
lines = []
|
|
185
|
+
|
|
186
|
+
id = next_id
|
|
187
|
+
lines << [
|
|
188
|
+
"<a href=\"javascript:toggle_visibility('open#{id}', 'close#{id}', 'table#{id}');\">" \
|
|
189
|
+
"<span id='open#{id}'>▶ Issue History</span>" \
|
|
190
|
+
"<span id='close#{id}' style='display: none'>▼ Issue History</span></a>"
|
|
191
|
+
]
|
|
192
|
+
table = +''
|
|
193
|
+
table << "<table id='table#{id}' style='display: none'>"
|
|
194
|
+
history.each do |c|
|
|
195
|
+
time = c.time.strftime '%b %d, %I:%M%P'
|
|
196
|
+
|
|
197
|
+
table << '<tr>'
|
|
198
|
+
table << "<td><span class='time' title='Timestamp: #{c.time}'>#{time}</span></td>"
|
|
199
|
+
table << "<td><img src='#{c.author_icon_url}' class='icon' title='#{c.author}' /></td>"
|
|
200
|
+
text = history_text change: c, board: issue.board
|
|
201
|
+
table << "<td><span class='field'>#{c.field_as_human_readable}</span> #{text}</td>"
|
|
202
|
+
table << '</tr>'
|
|
203
|
+
end
|
|
204
|
+
table << '</table>'
|
|
205
|
+
lines << [table]
|
|
206
|
+
lines
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def history_text change:, board:
|
|
210
|
+
if change.comment?
|
|
211
|
+
jira_rich_text_to_html(change.value)
|
|
212
|
+
elsif change.status?
|
|
213
|
+
convertor = ->(id) { format_status(board.possible_statuses.find_by_id(id), board: board) }
|
|
214
|
+
to = convertor.call(change.value_id)
|
|
215
|
+
if change.old_value
|
|
216
|
+
from = convertor.call(change.old_value_id)
|
|
217
|
+
"Changed from #{from} to #{to}"
|
|
218
|
+
else
|
|
219
|
+
"Set to #{to}"
|
|
220
|
+
end
|
|
221
|
+
elsif %w[priority assignee duedate issuetype].include?(change.field)
|
|
222
|
+
"Changed from \"#{change.old_value}\" to \"#{change.value}\""
|
|
223
|
+
elsif change.flagged?
|
|
224
|
+
change.value == '' ? 'Off' : 'On'
|
|
225
|
+
else
|
|
226
|
+
change.value
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def make_sprints_lines issue
|
|
231
|
+
return [] unless issue.board.scrum?
|
|
232
|
+
|
|
233
|
+
sprint_names = issue.sprints.collect do |sprint|
|
|
234
|
+
if sprint.closed?
|
|
235
|
+
"<s>#{sprint.name}</s>"
|
|
236
|
+
else
|
|
237
|
+
sprint.name
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
return [['Sprints: NONE']] if sprint_names.empty?
|
|
242
|
+
|
|
243
|
+
[[+'Sprints: ' << sprint_names
|
|
244
|
+
.collect { |name| "<span class='label'>#{name}</span>" }
|
|
245
|
+
.join(' ')]]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def assemble_issue_lines issue, child:
|
|
249
|
+
lines = []
|
|
250
|
+
lines << [make_title_line(issue)]
|
|
251
|
+
lines += make_parent_lines(issue) unless child
|
|
252
|
+
lines += make_stats_lines(issue)
|
|
253
|
+
lines += make_sprints_lines(issue)
|
|
254
|
+
lines += make_blocked_stalled_lines(issue)
|
|
255
|
+
lines += make_child_lines(issue)
|
|
256
|
+
lines += make_history_lines(issue)
|
|
257
|
+
lines
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def render_issue issue, child:
|
|
261
|
+
css_class = child ? 'child_issue' : 'daily_issue'
|
|
262
|
+
result = +''
|
|
263
|
+
result << "<div class='#{css_class}'>"
|
|
264
|
+
assemble_issue_lines(issue, child: child).each do |row|
|
|
265
|
+
if row.is_a? Issue
|
|
266
|
+
result << render_issue(row, child: true)
|
|
267
|
+
else
|
|
268
|
+
result << '<div class="heading">'
|
|
269
|
+
row.each do |chunk|
|
|
270
|
+
result << "<div>#{chunk}</div>"
|
|
271
|
+
end
|
|
272
|
+
result << '</div>'
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
result << '</div>'
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -45,6 +45,7 @@ class Downloader
|
|
|
45
45
|
board = download_board_configuration board_id: id
|
|
46
46
|
download_issues board: board
|
|
47
47
|
end
|
|
48
|
+
download_users
|
|
48
49
|
|
|
49
50
|
save_metadata
|
|
50
51
|
end
|
|
@@ -147,6 +148,16 @@ class Downloader
|
|
|
147
148
|
)
|
|
148
149
|
end
|
|
149
150
|
|
|
151
|
+
def download_users
|
|
152
|
+
log ' Downloading all users', both: true
|
|
153
|
+
json = @jira_gateway.call_url relative_url: '/rest/api/2/users'
|
|
154
|
+
|
|
155
|
+
@file_system.save_json(
|
|
156
|
+
json: json,
|
|
157
|
+
filename: File.join(@target_path, "#{file_prefix}_users.json")
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
150
161
|
def update_status_history_file
|
|
151
162
|
status_filename = File.join(@target_path, "#{file_prefix}_statuses.json")
|
|
152
163
|
return unless file_system.file_exist? status_filename
|
|
@@ -22,15 +22,18 @@ class EstimateAccuracyChart < ChartBase
|
|
|
22
22
|
</div>
|
|
23
23
|
HTML
|
|
24
24
|
|
|
25
|
-
@y_axis_label = 'Story Point Estimates'
|
|
26
25
|
@y_axis_type = 'linear'
|
|
27
|
-
@y_axis_block = ->(issue, start_time) {
|
|
26
|
+
@y_axis_block = ->(issue, start_time) { estimate_at(issue: issue, start_time: start_time)&.to_f }
|
|
28
27
|
@y_axis_sort_order = nil
|
|
29
28
|
|
|
30
29
|
instance_eval(&configuration_block)
|
|
31
30
|
end
|
|
32
31
|
|
|
33
32
|
def run
|
|
33
|
+
if @y_axis_label.nil?
|
|
34
|
+
text = current_board.estimation_configuration.units == :story_points ? 'Story Points' : 'Days'
|
|
35
|
+
@y_axis_label = "Estimated #{text}"
|
|
36
|
+
end
|
|
34
37
|
data_sets = scan_issues
|
|
35
38
|
|
|
36
39
|
return '' if data_sets.empty?
|
|
@@ -41,6 +44,7 @@ class EstimateAccuracyChart < ChartBase
|
|
|
41
44
|
def scan_issues
|
|
42
45
|
completed_hash, aging_hash = split_into_completed_and_aging issues: issues
|
|
43
46
|
|
|
47
|
+
estimation_units = current_board.estimation_configuration.units
|
|
44
48
|
@has_aging_data = !aging_hash.empty?
|
|
45
49
|
|
|
46
50
|
[
|
|
@@ -53,9 +57,13 @@ class EstimateAccuracyChart < ChartBase
|
|
|
53
57
|
# We sort so that the smaller circles are in front of the bigger circles.
|
|
54
58
|
data = hash.sort(&hash_sorter).collect do |key, values|
|
|
55
59
|
estimate, cycle_time = *key
|
|
56
|
-
|
|
57
|
-
title = [
|
|
58
|
-
|
|
60
|
+
|
|
61
|
+
title = [
|
|
62
|
+
"Estimate: #{estimate_label(estimate: estimate, estimation_units: estimation_units)}, " \
|
|
63
|
+
"Cycletime: #{label_days(cycle_time)}, " \
|
|
64
|
+
"#{values.size} issues"
|
|
65
|
+
] + values.collect { |issue| "#{issue.key}: #{issue.summary}" }
|
|
66
|
+
|
|
59
67
|
{
|
|
60
68
|
'x' => cycle_time,
|
|
61
69
|
'y' => estimate,
|
|
@@ -77,6 +85,18 @@ class EstimateAccuracyChart < ChartBase
|
|
|
77
85
|
end
|
|
78
86
|
end
|
|
79
87
|
|
|
88
|
+
def estimate_label estimate:, estimation_units:
|
|
89
|
+
if @y_axis_type == 'linear'
|
|
90
|
+
if estimation_units == :story_points
|
|
91
|
+
estimate_label = "#{estimate}pts"
|
|
92
|
+
elsif estimation_units == :seconds
|
|
93
|
+
estimate_label = label_days estimate
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
estimate_label = estimate.to_s if estimate_label.nil?
|
|
97
|
+
estimate_label
|
|
98
|
+
end
|
|
99
|
+
|
|
80
100
|
def split_into_completed_and_aging issues:
|
|
81
101
|
aging_hash = {}
|
|
82
102
|
completed_hash = {}
|
|
@@ -126,14 +146,18 @@ class EstimateAccuracyChart < ChartBase
|
|
|
126
146
|
end
|
|
127
147
|
end
|
|
128
148
|
|
|
129
|
-
def
|
|
130
|
-
|
|
149
|
+
def estimate_at issue:, start_time:, estimation_configuration: current_board.estimation_configuration
|
|
150
|
+
estimate = nil
|
|
151
|
+
|
|
131
152
|
issue.changes.each do |change|
|
|
132
|
-
return
|
|
153
|
+
return estimate if change.time >= start_time
|
|
133
154
|
|
|
134
|
-
|
|
155
|
+
if change.field == estimation_configuration.display_name || change.field == estimation_configuration.field_id
|
|
156
|
+
estimate = change.value
|
|
157
|
+
estimate = estimate.to_f / (24 * 60 * 60) if estimation_configuration.units == :seconds
|
|
158
|
+
end
|
|
135
159
|
end
|
|
136
|
-
|
|
160
|
+
estimate
|
|
137
161
|
end
|
|
138
162
|
|
|
139
163
|
def y_axis label:, sort_order: nil, &block
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class EstimationConfiguration
|
|
4
|
+
attr_reader :units, :display_name, :field_id
|
|
5
|
+
|
|
6
|
+
def initialize raw:
|
|
7
|
+
@units = :story_points
|
|
8
|
+
@display_name = 'Story Points'
|
|
9
|
+
|
|
10
|
+
# If there wasn't an estimation section they rely on all defaults
|
|
11
|
+
return if raw.nil?
|
|
12
|
+
|
|
13
|
+
if raw['type'] == 'field'
|
|
14
|
+
@field_id = raw['field']['fieldId']
|
|
15
|
+
@display_name = raw['field']['displayName']
|
|
16
|
+
if @field_id == 'timeoriginalestimate'
|
|
17
|
+
@units = :seconds
|
|
18
|
+
@display_name = 'Original estimate'
|
|
19
|
+
end
|
|
20
|
+
elsif raw['type'] == 'issueCount'
|
|
21
|
+
@display_name = 'Issue Count'
|
|
22
|
+
@units = :issue_count
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -79,8 +79,8 @@ class Exporter
|
|
|
79
79
|
file_system.log "No issues found to match #{keys.collect(&:inspect).join(', ')}"
|
|
80
80
|
else
|
|
81
81
|
selected.each do |project, issue|
|
|
82
|
-
file_system.log "\nProject #{project.name}"
|
|
83
|
-
file_system.log issue.dump
|
|
82
|
+
file_system.log "\nProject #{project.name}", also_write_to_stderr: true
|
|
83
|
+
file_system.log issue.dump, also_write_to_stderr: true
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
86
|
end
|
|
@@ -27,7 +27,7 @@ class FlowEfficiencyScatterplot < ChartBase
|
|
|
27
27
|
</mfrac>
|
|
28
28
|
</math>
|
|
29
29
|
</div>
|
|
30
|
-
<div style="background:
|
|
30
|
+
<div style="background: var(--warning-banner)">Note that for this calculation to be accurate, we must be moving items into a
|
|
31
31
|
blocked or stalled state the moment we stop working on it, and most teams don't do that.
|
|
32
32
|
So be aware that your team may have to change their behaviours if you want this chart to be useful.
|
|
33
33
|
</div>
|
|
@@ -6,7 +6,7 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
|
|
|
6
6
|
{
|
|
7
7
|
type: 'bar',
|
|
8
8
|
data: {
|
|
9
|
-
labels: [<%=
|
|
9
|
+
labels: [<%= @board_columns.collect { |c| c.name.inspect }.join(',') %>],
|
|
10
10
|
datasets: <%= JSON.generate(data_sets) %>
|
|
11
11
|
},
|
|
12
12
|
options: {
|
|
@@ -22,8 +22,10 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
|
|
|
22
22
|
labelString: 'Date Completed'
|
|
23
23
|
},
|
|
24
24
|
grid: {
|
|
25
|
-
color: <%= CssVariable['--grid-line-color'].to_json
|
|
25
|
+
color: <%= CssVariable['--grid-line-color'].to_json %>,
|
|
26
|
+
z: 1 // draw the grid lines on top of the bars
|
|
26
27
|
},
|
|
28
|
+
stacked: true
|
|
27
29
|
},
|
|
28
30
|
y: {
|
|
29
31
|
scaleLabel: {
|
|
@@ -35,8 +37,11 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
|
|
|
35
37
|
text: 'Age in days'
|
|
36
38
|
},
|
|
37
39
|
grid: {
|
|
38
|
-
color: <%= CssVariable['--grid-line-color'].to_json
|
|
40
|
+
color: <%= CssVariable['--grid-line-color'].to_json %>,
|
|
41
|
+
z: 1 // draw the grid lines on top of the bars
|
|
39
42
|
},
|
|
43
|
+
stacked: true,
|
|
44
|
+
max: <%= (@max_age * 1.1).to_i %>
|
|
40
45
|
}
|
|
41
46
|
},
|
|
42
47
|
plugins: {
|
|
@@ -44,14 +49,26 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
|
|
|
44
49
|
callbacks: {
|
|
45
50
|
label: function(context) {
|
|
46
51
|
if( typeof(context.dataset.data[context.dataIndex]) == "number" ) {
|
|
47
|
-
|
|
52
|
+
let full_data = <%= @bar_data.inspect %>;
|
|
53
|
+
let columnIndex = context.dataIndex;
|
|
54
|
+
let rowIndex = context.datasetIndex - <%= @row_index_offset %>;
|
|
55
|
+
return context.dataset.label + " of completed work items left this column in " +full_data[rowIndex][columnIndex] + " days or less";
|
|
48
56
|
}
|
|
49
57
|
else {
|
|
50
|
-
return context.dataset.data[context.dataIndex].title
|
|
58
|
+
return context.dataset.data[context.dataIndex].title;
|
|
51
59
|
}
|
|
52
60
|
}
|
|
53
61
|
}
|
|
62
|
+
},
|
|
63
|
+
legend: {
|
|
64
|
+
labels: {
|
|
65
|
+
filter: function(item, chart) {
|
|
66
|
+
// Logic to remove a particular legend item goes here
|
|
67
|
+
return !item.text.includes('%');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
54
70
|
}
|
|
71
|
+
|
|
55
72
|
}
|
|
56
73
|
}
|
|
57
74
|
});
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
<table class='standard'>
|
|
2
2
|
<thead>
|
|
3
3
|
<tr>
|
|
4
|
-
<th
|
|
5
|
-
<th>E</th>
|
|
6
|
-
<th>B</th>
|
|
4
|
+
<th title="Age in days">Age</th>
|
|
5
|
+
<th title="Expedited">E</th>
|
|
6
|
+
<th title="Blocked / Stalled">B/S</th>
|
|
7
|
+
<th title="Priority">P</th>
|
|
7
8
|
<th>Issue</th>
|
|
8
9
|
<th>Status</th>
|
|
10
|
+
<th>Forecast</th>
|
|
9
11
|
<th>Fix versions</th>
|
|
10
12
|
<% if any_scrum_boards %>
|
|
11
13
|
<th>Sprints</th>
|
|
@@ -28,6 +30,7 @@
|
|
|
28
30
|
<td style="text-align: right;"><%= issue_age || 'Not started' %></td>
|
|
29
31
|
<td><%= expedited_text(issue) %></td>
|
|
30
32
|
<td><%= blocked_text(issue) %></td>
|
|
33
|
+
<td><%= priority_text(issue) %></td>
|
|
31
34
|
<td>
|
|
32
35
|
<% parent_hierarchy(issue).each_with_index do |parent, index| %>
|
|
33
36
|
<% color = parent != issue ? "var(--hierarchy-table-inactive-item-text-color)" : 'var(--default-text-color)' %>
|
|
@@ -41,6 +44,7 @@
|
|
|
41
44
|
<% end %>
|
|
42
45
|
</td>
|
|
43
46
|
<td><%= format_status issue.status, board: issue.board %></td>
|
|
47
|
+
<td><%= dates_text(issue) %></td>
|
|
44
48
|
<td><%= fix_versions_text(issue) %></td>
|
|
45
49
|
<% if any_scrum_boards %>
|
|
46
50
|
<td><%= sprints_text(issue) %></td>
|