jirametrics 2.11 → 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_table.rb +6 -11
- data/lib/jirametrics/board.rb +8 -9
- data/lib/jirametrics/board_config.rb +1 -0
- data/lib/jirametrics/board_movement_calculator.rb +8 -0
- data/lib/jirametrics/change_item.rb +29 -15
- data/lib/jirametrics/chart_base.rb +3 -4
- 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/file_config.rb +1 -1
- data/lib/jirametrics/html/aging_work_table.erb +2 -0
- data/lib/jirametrics/html/index.css +71 -0
- data/lib/jirametrics/html/index.erb +17 -0
- data/lib/jirametrics/html_report_config.rb +2 -0
- data/lib/jirametrics/issue.rb +39 -19
- 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_collection.rb +6 -0
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics.rb +4 -0
- metadata +6 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7565c5b6320b9c4375a3feb27fcaf80f96d9c058041b71584f287e6a3d7eb399
|
4
|
+
data.tar.gz: 4554c7edbc7c42e01ad4838094ab922819c2e71818a46907f25740e35c327aa6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 021c1f989117b1fbb8708183d9ad8e79b049060989c1f20d31121a486d25f1ae2d1c036e013279479cd3ac6f5b101a48d8c9f4943570107c9cc4b40485ea6a4f
|
7
|
+
data.tar.gz: 7f7e334a2161e23ef5ae8a754aa3f12524c1bb130893ca3df3e8574f7a44286ef33c8a03b2ac2790ccb8e8b792ce0fb6c17b1d69d2da068ea48dbce1b4c514a1
|
@@ -108,20 +108,11 @@ class AgingWorkTable < ChartBase
|
|
108
108
|
end
|
109
109
|
|
110
110
|
def sprints_text issue
|
111
|
-
|
112
|
-
|
113
|
-
issue.changes.each do |change|
|
114
|
-
next unless change.sprint?
|
115
|
-
|
116
|
-
sprint_ids << change.raw['to'].split(/\s*,\s*/).collect { |id| id.to_i }
|
117
|
-
end
|
118
|
-
sprint_ids.flatten!
|
119
|
-
|
120
|
-
issue.board.sprints.select { |s| sprint_ids.include? s.id }.collect do |sprint|
|
111
|
+
issue.sprints.collect do |sprint|
|
121
112
|
icon_text = nil
|
122
113
|
if sprint.active?
|
123
114
|
icon_text = icon_span title: 'Active sprint', icon: '➡️'
|
124
|
-
|
115
|
+
elsif sprint.closed?
|
125
116
|
icon_text = icon_span title: 'Sprint closed', icon: '✅'
|
126
117
|
end
|
127
118
|
"#{sprint.name} #{icon_text}"
|
@@ -181,4 +172,8 @@ class AgingWorkTable < ChartBase
|
|
181
172
|
|
182
173
|
result.reverse
|
183
174
|
end
|
175
|
+
|
176
|
+
def priority_text issue
|
177
|
+
"<img src='#{issue.priority_url}' title='Priority: #{issue.priority_name}' />"
|
178
|
+
end
|
184
179
|
end
|
data/lib/jirametrics/board.rb
CHANGED
@@ -1,12 +1,11 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
class Board
|
4
|
-
attr_reader :visible_columns, :raw, :possible_statuses, :sprints
|
4
|
+
attr_reader :visible_columns, :raw, :possible_statuses, :sprints
|
5
5
|
attr_accessor :cycletime, :project_config
|
6
6
|
|
7
7
|
def initialize raw:, possible_statuses:
|
8
8
|
@raw = raw
|
9
|
-
@board_type = raw['type']
|
10
9
|
@possible_statuses = possible_statuses
|
11
10
|
@sprints = []
|
12
11
|
|
@@ -67,13 +66,9 @@ class Board
|
|
67
66
|
status_ids
|
68
67
|
end
|
69
68
|
|
70
|
-
def
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
def scrum?
|
75
|
-
@board_type == 'scrum'
|
76
|
-
end
|
69
|
+
def board_type = raw['type']
|
70
|
+
def kanban? = (board_type == 'kanban')
|
71
|
+
def scrum? = (board_type == 'scrum')
|
77
72
|
|
78
73
|
def id
|
79
74
|
@raw['id'].to_i
|
@@ -117,4 +112,8 @@ class Board
|
|
117
112
|
all_names << name
|
118
113
|
end
|
119
114
|
end
|
115
|
+
|
116
|
+
def estimation_configuration
|
117
|
+
EstimationConfiguration.new raw: raw['estimation']
|
118
|
+
end
|
120
119
|
end
|
@@ -124,6 +124,14 @@ class BoardMovementCalculator
|
|
124
124
|
column_name, entry_time = find_current_column_and_entry_time_in_column issue
|
125
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
126
|
|
127
|
+
# This condition has been reported in production so we have a check for it. Having said that, we have no
|
128
|
+
# idea what conditions might make this possible and so there is no test for it.
|
129
|
+
if entry_time.nil?
|
130
|
+
message = "Unable to determine the time this issue entered column #{column_name.inspect} so no way to " \
|
131
|
+
'predict when it will be done'
|
132
|
+
return [nil, message]
|
133
|
+
end
|
134
|
+
|
127
135
|
age_in_column = (today - entry_time.to_date).to_i + 1
|
128
136
|
|
129
137
|
message = nil
|
@@ -1,11 +1,12 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
class ChangeItem
|
4
|
-
attr_reader :field, :value_id, :old_value_id, :raw, :
|
4
|
+
attr_reader :field, :value_id, :old_value_id, :raw, :time, :author_raw
|
5
5
|
attr_accessor :value, :old_value
|
6
6
|
|
7
|
-
def initialize raw:,
|
7
|
+
def initialize raw:, author_raw:, time:, artificial: false
|
8
8
|
@raw = raw
|
9
|
+
@author_raw = author_raw
|
9
10
|
@time = time
|
10
11
|
raise 'ChangeItem.new() time cannot be nil' if time.nil?
|
11
12
|
raise "Time must be an object of type Time in the correct timezone: #{@time.inspect}" unless @time.is_a? Time
|
@@ -16,26 +17,28 @@ class ChangeItem
|
|
16
17
|
@old_value = @raw['fromString']
|
17
18
|
@old_value_id = @raw['from']&.to_i
|
18
19
|
@artificial = artificial
|
19
|
-
@author = author
|
20
20
|
end
|
21
21
|
|
22
|
-
def
|
22
|
+
def author
|
23
|
+
@author_raw&.[]('displayName') || @author_raw&.[]('name') || 'Unknown author'
|
24
|
+
end
|
23
25
|
|
24
|
-
def
|
26
|
+
def author_icon_url
|
27
|
+
@author_raw&.[]('avatarUrls')&.[]('16x16')
|
28
|
+
end
|
25
29
|
|
30
|
+
def artificial? = @artificial
|
31
|
+
def assignee? = (field == 'assignee')
|
32
|
+
def comment? = (field == 'comment')
|
33
|
+
def due_date? = (field == 'duedate')
|
34
|
+
def flagged? = (field == 'Flagged')
|
35
|
+
def issue_type? = field == 'issuetype'
|
36
|
+
def labels? = (field == 'labels')
|
37
|
+
def link? = (field == 'Link')
|
26
38
|
def priority? = (field == 'priority')
|
27
|
-
|
28
39
|
def resolution? = (field == 'resolution')
|
29
|
-
|
30
|
-
def artificial? = @artificial
|
31
|
-
|
32
40
|
def sprint? = (field == 'Sprint')
|
33
|
-
|
34
|
-
def story_points? = (field == 'Story Points')
|
35
|
-
|
36
|
-
def link? = (field == 'Link')
|
37
|
-
|
38
|
-
def labels? = (field == 'labels')
|
41
|
+
def status? = (field == 'status')
|
39
42
|
|
40
43
|
# An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
|
41
44
|
def to_time = @time
|
@@ -91,6 +94,17 @@ class ChangeItem
|
|
91
94
|
end
|
92
95
|
end
|
93
96
|
|
97
|
+
def field_as_human_readable
|
98
|
+
case @field
|
99
|
+
when 'duedate' then 'Due date'
|
100
|
+
when 'timeestimate' then 'Time estimate'
|
101
|
+
when 'timeoriginalestimate' then 'Time original estimate'
|
102
|
+
when 'issuetype' then 'Issue type'
|
103
|
+
when 'IssueParentAssociation' then 'Issue parent association'
|
104
|
+
else @field.capitalize
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
94
108
|
private
|
95
109
|
|
96
110
|
def time_to_s time
|
@@ -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:
|
@@ -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
|
@@ -4,6 +4,7 @@
|
|
4
4
|
<th title="Age in days">Age</th>
|
5
5
|
<th title="Expedited">E</th>
|
6
6
|
<th title="Blocked / Stalled">B/S</th>
|
7
|
+
<th title="Priority">P</th>
|
7
8
|
<th>Issue</th>
|
8
9
|
<th>Status</th>
|
9
10
|
<th>Forecast</th>
|
@@ -29,6 +30,7 @@
|
|
29
30
|
<td style="text-align: right;"><%= issue_age || 'Not started' %></td>
|
30
31
|
<td><%= expedited_text(issue) %></td>
|
31
32
|
<td><%= blocked_text(issue) %></td>
|
33
|
+
<td><%= priority_text(issue) %></td>
|
32
34
|
<td>
|
33
35
|
<% parent_hierarchy(issue).each_with_index do |parent, index| %>
|
34
36
|
<% color = parent != issue ? "var(--hierarchy-table-inactive-item-text-color)" : 'var(--default-text-color)' %>
|