jirametrics 2.12pre9 → 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 +4 -9
- data/lib/jirametrics/change_item.rb +29 -13
- 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/examples/standard_project.rb +2 -0
- data/lib/jirametrics/file_config.rb +1 -1
- data/lib/jirametrics/file_system.rb +0 -2
- 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 +32 -16
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/jira_gateway.rb +4 -1
- data/lib/jirametrics/project_config.rb +19 -7
- data/lib/jirametrics/settings.json +2 -1
- data/lib/jirametrics/sprint.rb +1 -0
- data/lib/jirametrics/status_collection.rb +6 -0
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics.rb +3 -0
- metadata +5 -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
|
@@ -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,24 +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 link? = (field == 'Link')
|
35
|
-
|
36
|
-
def labels? = (field == 'labels')
|
41
|
+
def status? = (field == 'status')
|
37
42
|
|
38
43
|
# An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
|
39
44
|
def to_time = @time
|
@@ -89,6 +94,17 @@ class ChangeItem
|
|
89
94
|
end
|
90
95
|
end
|
91
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
|
+
|
92
108
|
private
|
93
109
|
|
94
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
|
@@ -52,8 +52,6 @@ class FileSystem
|
|
52
52
|
# In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
|
53
53
|
# cases where this simple compression will drop the filesize by half.
|
54
54
|
def compress node
|
55
|
-
return node
|
56
|
-
|
57
55
|
if node.is_a? Hash
|
58
56
|
node.reject! { |_key, value| value.nil? || (value.is_a?(Array) && value.empty?) }
|
59
57
|
node.each_value { |value| compress value }
|
@@ -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)' %>
|
@@ -67,6 +67,9 @@
|
|
67
67
|
--sprint-burndown-sprint-color-4: red;
|
68
68
|
--sprint-burndown-sprint-color-5: brown;
|
69
69
|
|
70
|
+
--daily-view-selected-issue-background: lightgray;
|
71
|
+
--daily-view-issue-border: green;
|
72
|
+
--daily-view-selected-issue-border: red;
|
70
73
|
|
71
74
|
}
|
72
75
|
|
@@ -142,6 +145,64 @@ ul.quality_report {
|
|
142
145
|
border-top: 1px solid gray;
|
143
146
|
}
|
144
147
|
|
148
|
+
div.daily_issue:hover {
|
149
|
+
background: var(--daily-view-selected-issue-background);
|
150
|
+
border-color: var(--daily-view-selected-issue-border);
|
151
|
+
}
|
152
|
+
|
153
|
+
div.daily_issue {
|
154
|
+
border: 1px solid var(--daily-view-issue-border);
|
155
|
+
padding: 0.5em;
|
156
|
+
.heading {
|
157
|
+
vertical-align: middle;
|
158
|
+
display: flex;
|
159
|
+
flex-wrap: wrap;
|
160
|
+
column-gap: 0.5em;
|
161
|
+
align-items: center;
|
162
|
+
}
|
163
|
+
table {
|
164
|
+
margin-left: 1em;
|
165
|
+
td {
|
166
|
+
vertical-align: top;
|
167
|
+
}
|
168
|
+
.time {
|
169
|
+
white-space: nowrap;
|
170
|
+
font-size: 0.8em;
|
171
|
+
}
|
172
|
+
}
|
173
|
+
.icon {
|
174
|
+
width: 1em;
|
175
|
+
height: 1em;
|
176
|
+
}
|
177
|
+
.account_id {
|
178
|
+
font-weight: bold;
|
179
|
+
}
|
180
|
+
.field {
|
181
|
+
border: 1px solid black;
|
182
|
+
color: white;
|
183
|
+
background: black;
|
184
|
+
padding-left: 0.2em;
|
185
|
+
padding-right: 0.2em;
|
186
|
+
border-radius: 0.2em;
|
187
|
+
}
|
188
|
+
.label {
|
189
|
+
border: 1px solid black;
|
190
|
+
padding-left: 0.2em;
|
191
|
+
padding-right: 0.2em;
|
192
|
+
border-radius: 0.2em;
|
193
|
+
}
|
194
|
+
margin-bottom: 0.5em;
|
195
|
+
}
|
196
|
+
div.child_issue:hover {
|
197
|
+
background: var(--body-background);
|
198
|
+
}
|
199
|
+
div.child_issue {
|
200
|
+
border: 1px dashed green;
|
201
|
+
margin: 0.2em;
|
202
|
+
margin-left: 1.5em;
|
203
|
+
padding: 0.5em;
|
204
|
+
}
|
205
|
+
|
145
206
|
@media screen and (prefers-color-scheme: dark) {
|
146
207
|
:root {
|
147
208
|
--warning-banner: #9F2B00;
|
@@ -174,6 +235,8 @@ ul.quality_report {
|
|
174
235
|
--wip-chart-duration-two-weeks-or-less-color: #cf9400;
|
175
236
|
--wip-chart-duration-four-weeks-or-less-color: #c25e00;
|
176
237
|
--wip-chart-duration-more-than-four-weeks-color: #8e0000;
|
238
|
+
|
239
|
+
--daily-view-selected-issue-background: #474747;
|
177
240
|
}
|
178
241
|
|
179
242
|
h1 {
|
@@ -206,4 +269,12 @@ ul.quality_report {
|
|
206
269
|
div.color_block {
|
207
270
|
border: 1px solid lightgray;
|
208
271
|
}
|
272
|
+
|
273
|
+
div.daily_issue {
|
274
|
+
.field {
|
275
|
+
color: var(--default-text-color);
|
276
|
+
}
|
277
|
+
}
|
278
|
+
}
|
279
|
+
|
209
280
|
}
|
@@ -18,6 +18,23 @@
|
|
18
18
|
document.getElementById(issues_id).style.display = 'none'
|
19
19
|
}
|
20
20
|
}
|
21
|
+
|
22
|
+
function toggle_visibility(open_link_id, close_link_id, toggleable_id) {
|
23
|
+
let open_link = document.getElementById(open_link_id)
|
24
|
+
let close_link = document.getElementById(close_link_id)
|
25
|
+
let toggleable_element = document.getElementById(toggleable_id)
|
26
|
+
|
27
|
+
if(open_link.style.display == 'none') {
|
28
|
+
open_link.style.display = 'block'
|
29
|
+
close_link.style.display = 'none'
|
30
|
+
toggleable_element.style.display = 'none'
|
31
|
+
}
|
32
|
+
else {
|
33
|
+
open_link.style.display = 'none'
|
34
|
+
close_link.style.display = 'block'
|
35
|
+
toggleable_element.style.display = 'block'
|
36
|
+
}
|
37
|
+
}
|
21
38
|
// If we switch between light/dark mode then force a refresh so all charts will redraw correctly
|
22
39
|
// in the other colour scheme.
|
23
40
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
|
@@ -33,6 +33,7 @@ class HtmlReportConfig
|
|
33
33
|
define_chart name: 'estimate_accuracy_chart', classname: 'EstimateAccuracyChart'
|
34
34
|
define_chart name: 'hierarchy_table', classname: 'HierarchyTable'
|
35
35
|
define_chart name: 'flow_efficiency_scatterplot', classname: 'FlowEfficiencyScatterplot'
|
36
|
+
define_chart name: 'daily_view', classname: 'DailyView'
|
36
37
|
|
37
38
|
define_chart name: 'daily_wip_by_type', classname: 'DailyWipChart',
|
38
39
|
deprecated_warning: 'This is the same as daily_wip_chart. Please use that one', deprecated_date: '2024-05-23'
|
@@ -159,6 +160,7 @@ class HtmlReportConfig
|
|
159
160
|
chart.time_range = project_config.time_range
|
160
161
|
chart.timezone_offset = timezone_offset
|
161
162
|
chart.settings = settings
|
163
|
+
chart.users = project_config.users
|
162
164
|
|
163
165
|
chart.all_boards = project_config.all_boards
|
164
166
|
chart.board_id = find_board_id
|
data/lib/jirametrics/issue.rb
CHANGED
@@ -44,9 +44,11 @@ class Issue
|
|
44
44
|
def key = @raw['key']
|
45
45
|
|
46
46
|
def type = @raw['fields']['issuetype']['name']
|
47
|
-
|
48
47
|
def type_icon_url = @raw['fields']['issuetype']['iconUrl']
|
49
48
|
|
49
|
+
def priority_name = @raw['fields']['priority']['name']
|
50
|
+
def priority_url = @raw['fields']['priority']['iconUrl']
|
51
|
+
|
50
52
|
def summary = @raw['fields']['summary']
|
51
53
|
|
52
54
|
def labels = @raw['fields']['labels'] || []
|
@@ -205,6 +207,10 @@ class Issue
|
|
205
207
|
nil
|
206
208
|
end
|
207
209
|
|
210
|
+
def first_time_visible_on_board
|
211
|
+
first_time_in_status(*board.visible_columns.collect(&:status_ids).flatten)
|
212
|
+
end
|
213
|
+
|
208
214
|
def parse_time text
|
209
215
|
Time.parse(text).getlocal(@timezone_offset)
|
210
216
|
end
|
@@ -230,6 +236,10 @@ class Issue
|
|
230
236
|
@raw['fields']&.[]('assignee')&.[]('displayName')
|
231
237
|
end
|
232
238
|
|
239
|
+
def assigned_to_icon_url
|
240
|
+
@raw['fields']&.[]('assignee')&.[]('avatarUrls')&.[]('16x16')
|
241
|
+
end
|
242
|
+
|
233
243
|
# Many test failures are simply unreadable because the default inspect on this class goes
|
234
244
|
# on for pages. Shorten it up.
|
235
245
|
def inspect
|
@@ -315,7 +325,7 @@ class Issue
|
|
315
325
|
|
316
326
|
# This mock change is to force the writing of one last entry at the end of the time range.
|
317
327
|
# By doing this, we're able to eliminate a lot of duplicated code in charts.
|
318
|
-
mock_change = ChangeItem.new time: end_time,
|
328
|
+
mock_change = ChangeItem.new time: end_time, artificial: true, raw: { 'field' => '' }, author_raw: nil
|
319
329
|
|
320
330
|
(changes + [mock_change]).each do |change|
|
321
331
|
previous_was_active = false if check_for_stalled(
|
@@ -462,8 +472,6 @@ class Issue
|
|
462
472
|
end
|
463
473
|
|
464
474
|
def expedited?
|
465
|
-
return false unless @board&.project_config
|
466
|
-
|
467
475
|
names = @board.project_config.settings['expedited_priority_names']
|
468
476
|
return false unless names
|
469
477
|
|
@@ -580,7 +588,7 @@ class Issue
|
|
580
588
|
/(?<project_code1>[^-]+)-(?<id1>.+)/ =~ key
|
581
589
|
/(?<project_code2>[^-]+)-(?<id2>.+)/ =~ other.key
|
582
590
|
comparison = project_code1 <=> project_code2
|
583
|
-
comparison = id1 <=> id2 if comparison.zero?
|
591
|
+
comparison = id1.to_i <=> id2.to_i if comparison.zero?
|
584
592
|
comparison
|
585
593
|
end
|
586
594
|
|
@@ -685,34 +693,40 @@ class Issue
|
|
685
693
|
@changes.select { |change| change.status? }
|
686
694
|
end
|
687
695
|
|
688
|
-
|
696
|
+
def sprints
|
697
|
+
sprint_ids = []
|
698
|
+
|
699
|
+
changes.each do |change|
|
700
|
+
next unless change.sprint?
|
701
|
+
|
702
|
+
sprint_ids << change.raw['to'].split(/\s*,\s*/).collect { |id| id.to_i }
|
703
|
+
end
|
704
|
+
sprint_ids.flatten!
|
689
705
|
|
690
|
-
|
691
|
-
raw['author']&.[]('displayName') || raw['author']&.[]('name') || 'Unknown author'
|
706
|
+
board.sprints.select { |s| sprint_ids.include? s.id }
|
692
707
|
end
|
693
708
|
|
709
|
+
private
|
710
|
+
|
694
711
|
def load_history_into_changes
|
695
712
|
@raw['changelog']['histories']&.each do |history|
|
696
713
|
created = parse_time(history['created'])
|
697
714
|
|
698
|
-
# It should be impossible to not have an author but we've seen it in production
|
699
|
-
author = assemble_author history
|
700
715
|
history['items']&.each do |item|
|
701
|
-
@changes << ChangeItem.new(raw: item, time: created,
|
716
|
+
@changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
|
702
717
|
end
|
703
718
|
end
|
704
719
|
end
|
705
720
|
|
706
721
|
def load_comments_into_changes
|
707
722
|
@raw['fields']['comment']['comments']&.each do |comment|
|
708
|
-
raw = {
|
723
|
+
raw = comment.merge({
|
709
724
|
'field' => 'comment',
|
710
725
|
'to' => comment['id'],
|
711
726
|
'toString' => comment['body']
|
712
|
-
}
|
713
|
-
author = assemble_author comment
|
727
|
+
})
|
714
728
|
created = parse_time(comment['created'])
|
715
|
-
@changes << ChangeItem.new(raw: raw, time: created,
|
729
|
+
@changes << ChangeItem.new(raw: raw, time: created, artificial: true, author_raw: comment['author'])
|
716
730
|
end
|
717
731
|
end
|
718
732
|
|
@@ -754,7 +768,9 @@ class Issue
|
|
754
768
|
first_status = first_change.old_value
|
755
769
|
first_status_id = first_change.old_value_id
|
756
770
|
end
|
757
|
-
|
771
|
+
|
772
|
+
creator = raw['fields']['creator']
|
773
|
+
ChangeItem.new time: created_time, artificial: true, author_raw: creator, raw: {
|
758
774
|
'field' => field_name,
|
759
775
|
'to' => first_status_id,
|
760
776
|
'toString' => first_status
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class IssueCollection < Array
|
4
|
+
attr_reader :hidden
|
5
|
+
|
6
|
+
def self.[] *issues
|
7
|
+
collection = new
|
8
|
+
issues.each { |i| collection << i }
|
9
|
+
collection
|
10
|
+
end
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
super
|
14
|
+
@hidden = []
|
15
|
+
end
|
16
|
+
|
17
|
+
def reject! &block
|
18
|
+
select(&block).each do |issue|
|
19
|
+
@hidden << issue
|
20
|
+
end
|
21
|
+
super
|
22
|
+
end
|
23
|
+
|
24
|
+
def find_by_key key:, include_hidden: false
|
25
|
+
block = ->(issue) { issue.key == key }
|
26
|
+
issue = find(&block)
|
27
|
+
issue = hidden.find(&block) if issue.nil? && include_hidden
|
28
|
+
issue
|
29
|
+
end
|
30
|
+
def clone
|
31
|
+
raise 'baboom'
|
32
|
+
end
|
33
|
+
end
|
@@ -26,7 +26,10 @@ class JiraGateway
|
|
26
26
|
end
|
27
27
|
|
28
28
|
def call_command command
|
29
|
-
|
29
|
+
log_entry = " #{command.gsub(/\s+/, ' ')}"
|
30
|
+
log_entry = log_entry.gsub(@jira_api_token, '[API_TOKEN]') if @jira_api_token
|
31
|
+
@file_system.log log_entry
|
32
|
+
|
30
33
|
result = `#{command}`
|
31
34
|
@file_system.log result unless $CHILD_STATUS.success?
|
32
35
|
return result if $CHILD_STATUS.success?
|
@@ -6,7 +6,7 @@ require 'jirametrics/status_collection'
|
|
6
6
|
class ProjectConfig
|
7
7
|
attr_reader :target_path, :jira_config, :all_boards, :possible_statuses,
|
8
8
|
:download_config, :file_configs, :exporter, :data_version, :name, :board_configs,
|
9
|
-
:settings, :aggregate_config, :discarded_changes_data
|
9
|
+
:settings, :aggregate_config, :discarded_changes_data, :users
|
10
10
|
attr_accessor :time_range, :jira_url, :id
|
11
11
|
|
12
12
|
def initialize exporter:, jira_config:, block:, target_path: '.', name: '', id: nil
|
@@ -40,6 +40,7 @@ class ProjectConfig
|
|
40
40
|
@id = guess_project_id
|
41
41
|
load_project_metadata
|
42
42
|
load_sprints
|
43
|
+
load_users
|
43
44
|
end
|
44
45
|
|
45
46
|
def run load_only: false
|
@@ -325,6 +326,15 @@ class ProjectConfig
|
|
325
326
|
raise
|
326
327
|
end
|
327
328
|
|
329
|
+
def load_users
|
330
|
+
@users = []
|
331
|
+
filename = File.join @target_path, "#{get_file_prefix}_users.json"
|
332
|
+
return unless File.exist? filename
|
333
|
+
|
334
|
+
json = file_system.load_json(filename)
|
335
|
+
json.each { |user_data| @users << User.new(raw: user_data) }
|
336
|
+
end
|
337
|
+
|
328
338
|
def to_time string, end_of_day: false
|
329
339
|
time = end_of_day ? '23:59:59' : '00:00:00'
|
330
340
|
string = "#{string}T#{time}#{exporter.timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
|
@@ -358,7 +368,7 @@ class ProjectConfig
|
|
358
368
|
|
359
369
|
# To be used by the aggregate_config only. Not intended to be part of the public API
|
360
370
|
def add_issues issues_list
|
361
|
-
@issues =
|
371
|
+
@issues = IssueCollection.new if @issues.nil?
|
362
372
|
@all_boards = {}
|
363
373
|
|
364
374
|
issues_list.each do |issue|
|
@@ -375,7 +385,7 @@ class ProjectConfig
|
|
375
385
|
'declaration but none are here. Check your config.'
|
376
386
|
end
|
377
387
|
|
378
|
-
return @issues =
|
388
|
+
return @issues = IssueCollection.new if @exporter.downloading?
|
379
389
|
raise 'No data found. Must do a download before an export' unless data_downloaded?
|
380
390
|
|
381
391
|
load_data if all_boards.empty?
|
@@ -387,7 +397,7 @@ class ProjectConfig
|
|
387
397
|
issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
|
388
398
|
else
|
389
399
|
file_system.log "Can't find directory #{issues_path}. Has a download been done?", also_write_to_stderr: true
|
390
|
-
return
|
400
|
+
return IssueCollection.new
|
391
401
|
end
|
392
402
|
|
393
403
|
# Attach related issues
|
@@ -399,7 +409,8 @@ class ProjectConfig
|
|
399
409
|
|
400
410
|
# We'll have some issues that are in the list that weren't part of the initial query. Once we've
|
401
411
|
# attached them in the appropriate places, remove any that aren't part of that initial set.
|
402
|
-
|
412
|
+
issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
|
413
|
+
@issues = issues
|
403
414
|
end
|
404
415
|
|
405
416
|
@issues
|
@@ -440,7 +451,7 @@ class ProjectConfig
|
|
440
451
|
end
|
441
452
|
|
442
453
|
def load_issues_from_issues_directory path:, timezone_offset:
|
443
|
-
issues =
|
454
|
+
issues = IssueCollection.new
|
444
455
|
default_board = nil
|
445
456
|
|
446
457
|
group_filenames_and_board_ids(path: path).each do |filename, board_ids|
|
@@ -453,7 +464,8 @@ class ProjectConfig
|
|
453
464
|
|
454
465
|
boards.each do |board|
|
455
466
|
if board.cycletime.nil?
|
456
|
-
raise "The board declaration for board #{board.id} must come before the
|
467
|
+
raise "The board declaration for board #{board.id} must come before the " \
|
468
|
+
"first usage of 'issues' in the configuration"
|
457
469
|
end
|
458
470
|
issues << Issue.new(raw: content, timezone_offset: timezone_offset, board: board)
|
459
471
|
end
|
data/lib/jirametrics/sprint.rb
CHANGED
@@ -16,6 +16,12 @@ class StatusCollection
|
|
16
16
|
@list.find { |status| status.id == id }
|
17
17
|
end
|
18
18
|
|
19
|
+
def find_by_id! id
|
20
|
+
status = @list.find { |status| status.id == id }
|
21
|
+
raise "Can't find any status for id #{id} in #{self}" unless status
|
22
|
+
status
|
23
|
+
end
|
24
|
+
|
19
25
|
def find_all_by_name identifier
|
20
26
|
name, id = parse_name_id identifier
|
21
27
|
|
data/lib/jirametrics.rb
CHANGED
@@ -69,6 +69,7 @@ class JiraMetrics < Thor
|
|
69
69
|
require 'jirametrics/daily_wip_chart'
|
70
70
|
require 'jirametrics/groupable_issue_chart'
|
71
71
|
require 'jirametrics/css_variable'
|
72
|
+
require 'jirametrics/issue_collection'
|
72
73
|
|
73
74
|
require 'jirametrics/aggregate_config'
|
74
75
|
require 'jirametrics/expedited_chart'
|
@@ -114,6 +115,8 @@ class JiraMetrics < Thor
|
|
114
115
|
require 'jirametrics/hierarchy_table'
|
115
116
|
require 'jirametrics/estimation_configuration'
|
116
117
|
require 'jirametrics/board'
|
118
|
+
require 'jirametrics/daily_view'
|
119
|
+
require 'jirametrics/user'
|
117
120
|
load config_file
|
118
121
|
end
|
119
122
|
end
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: jirametrics
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.12.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Mike Bowler
|
8
8
|
bindir: bin
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-
|
10
|
+
date: 2025-07-13 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: random-word
|
@@ -77,6 +77,7 @@ files:
|
|
77
77
|
- lib/jirametrics/cycletime_config.rb
|
78
78
|
- lib/jirametrics/cycletime_histogram.rb
|
79
79
|
- lib/jirametrics/cycletime_scatterplot.rb
|
80
|
+
- lib/jirametrics/daily_view.rb
|
80
81
|
- lib/jirametrics/daily_wip_by_age_chart.rb
|
81
82
|
- lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb
|
82
83
|
- lib/jirametrics/daily_wip_by_parent_chart.rb
|
@@ -115,6 +116,7 @@ files:
|
|
115
116
|
- lib/jirametrics/html/throughput_chart.erb
|
116
117
|
- lib/jirametrics/html_report_config.rb
|
117
118
|
- lib/jirametrics/issue.rb
|
119
|
+
- lib/jirametrics/issue_collection.rb
|
118
120
|
- lib/jirametrics/issue_link.rb
|
119
121
|
- lib/jirametrics/jira_gateway.rb
|
120
122
|
- lib/jirametrics/project_config.rb
|
@@ -129,6 +131,7 @@ files:
|
|
129
131
|
- lib/jirametrics/throughput_chart.rb
|
130
132
|
- lib/jirametrics/tree_organizer.rb
|
131
133
|
- lib/jirametrics/trend_line_calculator.rb
|
134
|
+
- lib/jirametrics/user.rb
|
132
135
|
- lib/jirametrics/value_equality.rb
|
133
136
|
homepage: https://jirametrics.org
|
134
137
|
licenses:
|