jirametrics 2.11 → 2.14
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/atlassian_document_format.rb +160 -0
- data/lib/jirametrics/board.rb +8 -9
- data/lib/jirametrics/board_config.rb +2 -0
- data/lib/jirametrics/board_movement_calculator.rb +8 -0
- data/lib/jirametrics/change_item.rb +30 -15
- data/lib/jirametrics/chart_base.rb +5 -4
- data/lib/jirametrics/cycletime_config.rb +1 -1
- data/lib/jirametrics/daily_view.rb +295 -0
- data/lib/jirametrics/downloader.rb +61 -21
- 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 +76 -0
- data/lib/jirametrics/html/index.erb +19 -2
- data/lib/jirametrics/html_report_config.rb +2 -0
- data/lib/jirametrics/issue.rb +42 -23
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/jira_gateway.rb +8 -1
- data/lib/jirametrics/project_config.rb +44 -10
- 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 +7 -0
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics.rb +5 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d5b1b9e8d837f6d74990d377db007ed5e55670de77738ab38a04c6d023d865c3
|
|
4
|
+
data.tar.gz: 99e8ef3e85a3dfa2bd6d845a32d974718c0e9884d2f4750891ba491fd884ab0b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 804a25c8a19df9ae9862e2f95539183ece53e3841e590c64b8deb7048601bfb2b42ad5b75c075b85058abb7cea0cc875d517f4208bd5edbf98e2129e5567af59
|
|
7
|
+
data.tar.gz: '0059cde9746423c5baf3ca1f47243b6489ccf77b8fd409255f7301f638eb1975b8b2815d266f720cd9f1cd51e2cf0bc9e33cbad3e34110ca1dedc4ad7fc9ff5b'
|
|
@@ -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
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AtlassianDocumentFormat
|
|
4
|
+
attr_reader :users
|
|
5
|
+
|
|
6
|
+
def initialize users:, timezone_offset:
|
|
7
|
+
@users = users
|
|
8
|
+
@timezone_offset = timezone_offset
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_html input
|
|
12
|
+
if input.is_a? String
|
|
13
|
+
input
|
|
14
|
+
.gsub(/{color:(#\w{6})}([^{]+){color}/, '<span style="color: \1">\2</span>') # Colours
|
|
15
|
+
.gsub(/\[~accountid:([^\]]+)\]/) { expand_account_id $1 } # Tagged people
|
|
16
|
+
.gsub(/\[([^\|]+)\|(https?[^\]]+)\]/, '<a href="\2">\1</a>') # URLs
|
|
17
|
+
.gsub("\n", '<br />')
|
|
18
|
+
elsif input['content']
|
|
19
|
+
input['content'].collect { |element| adf_node_to_html element }.join("\n")
|
|
20
|
+
else
|
|
21
|
+
# We have an actual ADF document with no content.
|
|
22
|
+
''
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# ADF is Atlassian Document Format
|
|
27
|
+
# https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
|
|
28
|
+
def adf_node_to_html node # rubocop:disable Metrics/CyclomaticComplexity
|
|
29
|
+
closing_tag = nil
|
|
30
|
+
node_attrs = node['attrs']
|
|
31
|
+
|
|
32
|
+
result = +''
|
|
33
|
+
case node['type']
|
|
34
|
+
when 'blockquote'
|
|
35
|
+
result << '<blockquote>'
|
|
36
|
+
closing_tag = '</blockquote>'
|
|
37
|
+
when 'bulletList'
|
|
38
|
+
result << '<ul>'
|
|
39
|
+
closing_tag = '</ul>'
|
|
40
|
+
when 'codeBlock'
|
|
41
|
+
result << '<code>'
|
|
42
|
+
closing_tag = '</code>'
|
|
43
|
+
when 'date'
|
|
44
|
+
result << Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s
|
|
45
|
+
when 'decisionItem'
|
|
46
|
+
result << '<li>'
|
|
47
|
+
closing_tag = '</li>'
|
|
48
|
+
when 'decisionList'
|
|
49
|
+
result << '<div>Decisions<ul>'
|
|
50
|
+
closing_tag = '</ul></div>'
|
|
51
|
+
when 'emoji'
|
|
52
|
+
result << node_attrs['text']
|
|
53
|
+
when 'expand'
|
|
54
|
+
# TODO: Maybe, someday, make this actually expandable. For now it's always open
|
|
55
|
+
result << "<div>#{node_attrs['title']}</div>"
|
|
56
|
+
when 'hardBreak'
|
|
57
|
+
result << '<br />'
|
|
58
|
+
when 'heading'
|
|
59
|
+
level = node_attrs['level']
|
|
60
|
+
result << "<h#{level}>"
|
|
61
|
+
closing_tag = "</h#{level}>"
|
|
62
|
+
when 'inlineCard'
|
|
63
|
+
url = node_attrs['url']
|
|
64
|
+
result << "[Inline card]: <a href='#{url}'>#{url}</a>"
|
|
65
|
+
when 'listItem'
|
|
66
|
+
result << '<li>'
|
|
67
|
+
closing_tag = '</li>'
|
|
68
|
+
when 'media'
|
|
69
|
+
text = node_attrs['alt'] || node_attrs['id']
|
|
70
|
+
result << "Media: #{text}"
|
|
71
|
+
when 'mediaSingle', 'mediaGroup'
|
|
72
|
+
result << '<div>'
|
|
73
|
+
closing_tag = '</div>'
|
|
74
|
+
when 'mention'
|
|
75
|
+
user = node_attrs['text']
|
|
76
|
+
result << "<b>#{user}</b>"
|
|
77
|
+
when 'orderedList'
|
|
78
|
+
result << '<ol>'
|
|
79
|
+
closing_tag = '</ol>'
|
|
80
|
+
when 'panel'
|
|
81
|
+
type = node_attrs['panelType']
|
|
82
|
+
result << "<div>#{type.upcase}</div>"
|
|
83
|
+
when 'paragraph'
|
|
84
|
+
result << '<p>'
|
|
85
|
+
closing_tag = '</p>'
|
|
86
|
+
when 'rule'
|
|
87
|
+
result << '<hr />'
|
|
88
|
+
when 'status'
|
|
89
|
+
text = node_attrs['text']
|
|
90
|
+
result << text
|
|
91
|
+
when 'table'
|
|
92
|
+
result << '<table>'
|
|
93
|
+
closing_tag = '</table>'
|
|
94
|
+
when 'tableCell'
|
|
95
|
+
result << '<td>'
|
|
96
|
+
closing_tag = '</td>'
|
|
97
|
+
when 'tableHeader'
|
|
98
|
+
result << '<th>'
|
|
99
|
+
closing_tag = '</th>'
|
|
100
|
+
when 'tableRow'
|
|
101
|
+
result << '<tr>'
|
|
102
|
+
closing_tag = '</tr>'
|
|
103
|
+
when 'text'
|
|
104
|
+
marks = adf_marks_to_html node['marks']
|
|
105
|
+
result << marks.collect(&:first).join
|
|
106
|
+
result << node['text']
|
|
107
|
+
result << marks.collect(&:last).join
|
|
108
|
+
when 'taskItem'
|
|
109
|
+
state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
|
|
110
|
+
result << "<li>#{state} "
|
|
111
|
+
closing_tag = '</li>'
|
|
112
|
+
when 'taskList'
|
|
113
|
+
result << "<ul class='taskList'>"
|
|
114
|
+
closing_tag = '</ul>'
|
|
115
|
+
else
|
|
116
|
+
result << "<p>Unparseable section: #{node['type']}</p>"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
node['content']&.each do |child|
|
|
120
|
+
result << adf_node_to_html(child)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
result << closing_tag if closing_tag
|
|
124
|
+
result
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def adf_marks_to_html list
|
|
128
|
+
return [] if list.nil?
|
|
129
|
+
|
|
130
|
+
mappings = [
|
|
131
|
+
['strong', '<b>', '</b>'],
|
|
132
|
+
['code', '<code>', '</code>'],
|
|
133
|
+
['em', '<em>', '</em>'],
|
|
134
|
+
['strike', '<s>', '</s>'],
|
|
135
|
+
['underline', '<u>', '</u>']
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
list.filter_map do |mark|
|
|
139
|
+
type = mark['type']
|
|
140
|
+
if type == 'textColor'
|
|
141
|
+
color = mark['attrs']['color']
|
|
142
|
+
["<span style='color: #{color}'>", '</span>']
|
|
143
|
+
elsif type == 'link'
|
|
144
|
+
href = mark['attrs']['href']
|
|
145
|
+
title = mark['attrs']['title']
|
|
146
|
+
["<a href='#{href}' title='#{title}'>", '</a>']
|
|
147
|
+
else
|
|
148
|
+
line = mappings.find { |key, _open, _close| key == type }
|
|
149
|
+
[line[1], line[2]] if line
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def expand_account_id account_id
|
|
155
|
+
user = @users.find { |u| u.account_id == account_id }
|
|
156
|
+
text = account_id
|
|
157
|
+
text = "@#{user.display_name}" if user
|
|
158
|
+
"<span class='account_id'>#{text}</span>"
|
|
159
|
+
end
|
|
160
|
+
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
|
|
@@ -11,8 +11,10 @@ class BoardConfig
|
|
|
11
11
|
|
|
12
12
|
def run
|
|
13
13
|
@board = @project_config.all_boards[id]
|
|
14
|
+
raise "Can't find board #{id.inspect} in #{@project_config.all_boards.keys.inspect}" unless @board
|
|
14
15
|
|
|
15
16
|
instance_eval(&@block)
|
|
17
|
+
raise "Must specify a cycletime for board #{@id}" if @board.cycletime.nil?
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
def cycletime label = nil, &block
|
|
@@ -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,29 @@ 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 description? = (field == 'description')
|
|
34
|
+
def due_date? = (field == 'duedate')
|
|
35
|
+
def flagged? = (field == 'Flagged')
|
|
36
|
+
def issue_type? = field == 'issuetype'
|
|
37
|
+
def labels? = (field == 'labels')
|
|
38
|
+
def link? = (field == 'Link')
|
|
26
39
|
def priority? = (field == 'priority')
|
|
27
|
-
|
|
28
40
|
def resolution? = (field == 'resolution')
|
|
29
|
-
|
|
30
|
-
def artificial? = @artificial
|
|
31
|
-
|
|
32
41
|
def sprint? = (field == 'Sprint')
|
|
33
|
-
|
|
34
|
-
def story_points? = (field == 'Story Points')
|
|
35
|
-
|
|
36
|
-
def link? = (field == 'Link')
|
|
37
|
-
|
|
38
|
-
def labels? = (field == 'labels')
|
|
42
|
+
def status? = (field == 'status')
|
|
39
43
|
|
|
40
44
|
# An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
|
|
41
45
|
def to_time = @time
|
|
@@ -91,6 +95,17 @@ class ChangeItem
|
|
|
91
95
|
end
|
|
92
96
|
end
|
|
93
97
|
|
|
98
|
+
def field_as_human_readable
|
|
99
|
+
case @field
|
|
100
|
+
when 'duedate' then 'Due date'
|
|
101
|
+
when 'timeestimate' then 'Time estimate'
|
|
102
|
+
when 'timeoriginalestimate' then 'Time original estimate'
|
|
103
|
+
when 'issuetype' then 'Issue type'
|
|
104
|
+
when 'IssueParentAssociation' then 'Issue parent association'
|
|
105
|
+
else @field.capitalize
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
94
109
|
private
|
|
95
110
|
|
|
96
111
|
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
|
|
@@ -67,6 +66,8 @@ class ChartBase
|
|
|
67
66
|
end
|
|
68
67
|
|
|
69
68
|
def label_days days
|
|
69
|
+
return 'unknown' if days.nil?
|
|
70
|
+
|
|
70
71
|
"#{days} day#{'s' unless days == 1}"
|
|
71
72
|
end
|
|
72
73
|
|
|
@@ -227,8 +228,8 @@ class ChartBase
|
|
|
227
228
|
icon: ' 👀'
|
|
228
229
|
)
|
|
229
230
|
end
|
|
230
|
-
text = is_category ? status.category
|
|
231
|
-
"<span title='Category: #{status.category
|
|
231
|
+
text = is_category ? status.category : status
|
|
232
|
+
"<span title='Category: #{status.category}'>#{color_block color.name} #{text}</span>#{visibility}"
|
|
232
233
|
end
|
|
233
234
|
|
|
234
235
|
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,295 @@
|
|
|
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 atlassian_document_format
|
|
37
|
+
@atlassian_document_format ||= AtlassianDocumentFormat.new(users: users, timezone_offset: timezone_offset)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def select_aging_issues
|
|
41
|
+
aging_issues = issues.select do |issue|
|
|
42
|
+
started_at, stopped_at = issue.board.cycletime.started_stopped_times(issue)
|
|
43
|
+
started_at && !stopped_at
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
today = date_range.end
|
|
47
|
+
aging_issues.collect do |issue|
|
|
48
|
+
[issue, issue.priority_name, issue.board.cycletime.age(issue, today: today)]
|
|
49
|
+
end.sort(&issue_sorter).collect(&:first)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def issue_sorter
|
|
53
|
+
priority_names = settings['priority_order']
|
|
54
|
+
lambda do |a, b|
|
|
55
|
+
a_issue, a_priority, a_age = *a
|
|
56
|
+
b_issue, b_priority, b_age = *b
|
|
57
|
+
|
|
58
|
+
a_priority_index = priority_names.index(a_priority)
|
|
59
|
+
b_priority_index = priority_names.index(b_priority)
|
|
60
|
+
|
|
61
|
+
if a_priority_index.nil? && b_priority_index.nil?
|
|
62
|
+
result = a_priority <=> b_priority
|
|
63
|
+
elsif a_priority_index.nil?
|
|
64
|
+
result = 1
|
|
65
|
+
elsif b_priority_index.nil?
|
|
66
|
+
result = -1
|
|
67
|
+
else
|
|
68
|
+
result = b_priority_index <=> a_priority_index
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
result = b_age <=> a_age if result.zero?
|
|
72
|
+
result = a_issue <=> b_issue if result.zero?
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def make_blocked_stalled_lines issue
|
|
78
|
+
today = date_range.end
|
|
79
|
+
started_date = issue.board.cycletime.started_stopped_times(issue).first&.to_date
|
|
80
|
+
return [] unless started_date
|
|
81
|
+
|
|
82
|
+
blocked_stalled = issue.blocked_stalled_by_date(
|
|
83
|
+
date_range: today..today, chart_end_time: time_range.end, settings: settings
|
|
84
|
+
)[today]
|
|
85
|
+
return [] if blocked_stalled.active?
|
|
86
|
+
|
|
87
|
+
lines = []
|
|
88
|
+
if blocked_stalled.blocked?
|
|
89
|
+
marker = color_block '--blocked-color'
|
|
90
|
+
lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
|
|
91
|
+
lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
|
|
92
|
+
blocked_stalled.blocking_issue_keys&.each do |key|
|
|
93
|
+
lines << ["#{marker} Blocked by issue: #{key}"]
|
|
94
|
+
blocking_issue = issues.find { |i| i.key == key }
|
|
95
|
+
lines << blocking_issue if blocking_issue
|
|
96
|
+
end
|
|
97
|
+
elsif blocked_stalled.stalled_by_status?
|
|
98
|
+
lines << ["#{color_block '--stalled-color'} Stalled by status: #{blocked_stalled.status}"]
|
|
99
|
+
else
|
|
100
|
+
lines << ["#{color_block '--stalled-color'} Stalled by inactivity: #{blocked_stalled.stalled_days} days"]
|
|
101
|
+
end
|
|
102
|
+
lines
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def make_issue_label issue:, done:
|
|
106
|
+
label = "<img src='#{issue.type_icon_url}' title='#{issue.type}' class='icon' /> "
|
|
107
|
+
label << '<s>' if done
|
|
108
|
+
label << "<b><a href='#{issue.url}'>#{issue.key}</a></b> <i>#{issue.summary}</i>"
|
|
109
|
+
label << '</s>' if done
|
|
110
|
+
label
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def make_title_line issue:, done:
|
|
114
|
+
title_line = +''
|
|
115
|
+
title_line << color_block('--expedited-color', title: 'Expedited') if issue.expedited?
|
|
116
|
+
title_line << make_issue_label(issue: issue, done: done)
|
|
117
|
+
title_line
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def make_parent_lines issue
|
|
121
|
+
lines = []
|
|
122
|
+
parent_key = issue.parent_key
|
|
123
|
+
if parent_key
|
|
124
|
+
parent = issues.find_by_key key: parent_key, include_hidden: true
|
|
125
|
+
text = parent ? make_issue_label(issue: parent, done: parent.done?) : parent_key
|
|
126
|
+
lines << ["Parent: #{text}"]
|
|
127
|
+
end
|
|
128
|
+
lines
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def make_stats_lines issue:, done:
|
|
132
|
+
line = []
|
|
133
|
+
|
|
134
|
+
line << "<img src='#{issue.priority_url}' class='icon' /> <b>#{issue.priority_name}</b>"
|
|
135
|
+
|
|
136
|
+
if done
|
|
137
|
+
cycletime = issue.board.cycletime.cycletime(issue)
|
|
138
|
+
|
|
139
|
+
line << "Cycletime: <b>#{label_days cycletime}</b>"
|
|
140
|
+
else
|
|
141
|
+
age = issue.board.cycletime.age(issue, today: date_range.end)
|
|
142
|
+
line << "Age: <b>#{age ? label_days(age) : '(Not Started)'}</b>"
|
|
143
|
+
end
|
|
144
|
+
line << "Status: <b>#{format_status issue.status, board: issue.board}</b>"
|
|
145
|
+
|
|
146
|
+
column = issue.board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
|
|
147
|
+
line << "Column: <b>#{column&.name || '(not visible on board)'}</b>"
|
|
148
|
+
|
|
149
|
+
if issue.assigned_to
|
|
150
|
+
line << "Assignee: <img src='#{issue.assigned_to_icon_url}' class='icon' /> <b>#{issue.assigned_to}</b>"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
line << "Due: <b>#{issue.due_date}</b>" if issue.due_date
|
|
154
|
+
|
|
155
|
+
block = lambda do |collection, label|
|
|
156
|
+
unless collection.empty?
|
|
157
|
+
text = collection.collect { |l| "<span class='label'>#{l}</span>" }.join(' ')
|
|
158
|
+
line << "#{label} #{text}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
block.call issue.labels, 'Labels:'
|
|
162
|
+
block.call issue.component_names, 'Components:'
|
|
163
|
+
|
|
164
|
+
[line]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def make_child_lines issue
|
|
168
|
+
lines = []
|
|
169
|
+
subtasks = issue.subtasks
|
|
170
|
+
|
|
171
|
+
return lines if subtasks.empty?
|
|
172
|
+
|
|
173
|
+
id = next_id
|
|
174
|
+
lines <<
|
|
175
|
+
"<a href=\"javascript:toggle_visibility('open#{id}', 'close#{id}', 'section#{id}');\">" \
|
|
176
|
+
"<span id='open#{id}' style='display: none'>▶ Child issues</span>" \
|
|
177
|
+
"<span id='close#{id}'>▼ Child issues</span></a>"
|
|
178
|
+
lines << "<section id='section#{id}'>"
|
|
179
|
+
|
|
180
|
+
lines += subtasks
|
|
181
|
+
lines << '</section>'
|
|
182
|
+
|
|
183
|
+
lines
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def make_history_lines issue
|
|
187
|
+
history = issue.changes.reverse
|
|
188
|
+
lines = []
|
|
189
|
+
|
|
190
|
+
id = next_id
|
|
191
|
+
lines << [
|
|
192
|
+
"<a href=\"javascript:toggle_visibility('open#{id}', 'close#{id}', 'table#{id}');\">" \
|
|
193
|
+
"<span id='open#{id}'>▶ Issue History</span>" \
|
|
194
|
+
"<span id='close#{id}' style='display: none'>▼ Issue History</span></a>"
|
|
195
|
+
]
|
|
196
|
+
table = +''
|
|
197
|
+
table << "<table id='table#{id}' style='display: none'>"
|
|
198
|
+
history.each do |c|
|
|
199
|
+
time = c.time.strftime '%b %d, %I:%M%P'
|
|
200
|
+
|
|
201
|
+
table << '<tr>'
|
|
202
|
+
table << "<td><span class='time' title='Timestamp: #{c.time}'>#{time}</span></td>"
|
|
203
|
+
table << "<td><img src='#{c.author_icon_url}' class='icon' title='#{c.author}' /></td>"
|
|
204
|
+
text = history_text change: c, board: issue.board
|
|
205
|
+
table << "<td><span class='field'>#{c.field_as_human_readable}</span> #{text}</td>"
|
|
206
|
+
table << '</tr>'
|
|
207
|
+
end
|
|
208
|
+
table << '</table>'
|
|
209
|
+
lines << [table]
|
|
210
|
+
lines
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def history_text change:, board:
|
|
214
|
+
convertor = ->(value, _id) { value.inspect }
|
|
215
|
+
convertor = ->(_value, id) { format_status(board.possible_statuses.find_by_id(id), board: board) } if change.status?
|
|
216
|
+
|
|
217
|
+
if change.comment? || change.description?
|
|
218
|
+
atlassian_document_format.to_html(change.value)
|
|
219
|
+
elsif %w[status priority assignee duedate issuetype].include?(change.field)
|
|
220
|
+
to = convertor.call(change.value, change.value_id)
|
|
221
|
+
if change.old_value
|
|
222
|
+
from = convertor.call(change.old_value, change.old_value_id)
|
|
223
|
+
"Changed from #{from} to #{to}"
|
|
224
|
+
else
|
|
225
|
+
"Set to #{to}"
|
|
226
|
+
end
|
|
227
|
+
elsif change.flagged?
|
|
228
|
+
change.value == '' ? 'Off' : 'On'
|
|
229
|
+
else
|
|
230
|
+
change.value
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def make_sprints_lines issue
|
|
235
|
+
return [] unless issue.board.scrum?
|
|
236
|
+
|
|
237
|
+
sprint_names = issue.sprints.collect do |sprint|
|
|
238
|
+
if sprint.closed?
|
|
239
|
+
"<s>#{sprint.name}</s>"
|
|
240
|
+
else
|
|
241
|
+
sprint.name
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
return [['Sprints: NONE']] if sprint_names.empty?
|
|
246
|
+
|
|
247
|
+
[[+'Sprints: ' << sprint_names
|
|
248
|
+
.collect { |name| "<span class='label'>#{name}</span>" }
|
|
249
|
+
.join(' ')]]
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def make_description_lines issue
|
|
253
|
+
description = issue.raw['fields']['description']
|
|
254
|
+
result = []
|
|
255
|
+
result << [atlassian_document_format.to_html(description)] if description
|
|
256
|
+
result
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def assemble_issue_lines issue, child:
|
|
260
|
+
done = issue.done?
|
|
261
|
+
|
|
262
|
+
lines = []
|
|
263
|
+
lines << [make_title_line(issue: issue, done: done)]
|
|
264
|
+
lines += make_parent_lines(issue) unless child
|
|
265
|
+
lines += make_stats_lines(issue: issue, done: done)
|
|
266
|
+
unless done
|
|
267
|
+
lines += make_description_lines(issue)
|
|
268
|
+
lines += make_sprints_lines(issue)
|
|
269
|
+
lines += make_blocked_stalled_lines(issue)
|
|
270
|
+
lines += make_child_lines(issue)
|
|
271
|
+
lines += make_history_lines(issue)
|
|
272
|
+
end
|
|
273
|
+
lines
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def render_issue issue, child:
|
|
277
|
+
css_class = child ? 'child_issue' : 'daily_issue'
|
|
278
|
+
result = +''
|
|
279
|
+
result << "<div class='#{css_class}'>"
|
|
280
|
+
assemble_issue_lines(issue, child: child).each do |row|
|
|
281
|
+
if row.is_a? Issue
|
|
282
|
+
result << render_issue(row, child: true)
|
|
283
|
+
elsif row.is_a?(String)
|
|
284
|
+
result << row
|
|
285
|
+
else
|
|
286
|
+
result << '<div class="heading">'
|
|
287
|
+
row.each do |chunk|
|
|
288
|
+
result << "<div>#{chunk}</div>"
|
|
289
|
+
end
|
|
290
|
+
result << '</div>'
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
result << '</div>'
|
|
294
|
+
end
|
|
295
|
+
end
|