jirametrics 2.12.1 → 2.15
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/atlassian_document_format.rb +160 -0
- data/lib/jirametrics/board_config.rb +1 -0
- data/lib/jirametrics/change_item.rb +1 -0
- data/lib/jirametrics/chart_base.rb +5 -2
- data/lib/jirametrics/cycletime_histogram.rb +3 -1
- data/lib/jirametrics/daily_view.rb +56 -52
- data/lib/jirametrics/dependency_chart.rb +4 -1
- data/lib/jirametrics/downloader.rb +52 -23
- data/lib/jirametrics/expedited_chart.rb +1 -1
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cycletime_histogram.erb +2 -2
- data/lib/jirametrics/html/index.css +5 -10
- data/lib/jirametrics/html/index.erb +3 -35
- data/lib/jirametrics/html/index.js +90 -0
- data/lib/jirametrics/html/sprint_burndown.erb +5 -3
- data/lib/jirametrics/html_report_config.rb +2 -2
- data/lib/jirametrics/issue.rb +17 -13
- data/lib/jirametrics/jira_gateway.rb +15 -2
- data/lib/jirametrics/project_config.rb +26 -3
- data/lib/jirametrics/status_collection.rb +1 -0
- data/lib/jirametrics.rb +17 -67
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c8045db95ffbb1368c3df8be3ee88c88ddb1a3d2fee46c989ba9d94e8fdb4ac3
|
|
4
|
+
data.tar.gz: a4a245da182c96238ec99918eefaf097c3fcc3cd3f4a09f85d4fbdd25020b713
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cb301db36a63ec0e2da49c5f7ce1f7c58ee44f9c1ab1dad7811667a72d598a8a36282bccc795bebf755706ea1b0d1f0ebdf092f75fe89dc128203645182b9a30
|
|
7
|
+
data.tar.gz: e1d08efa142f9d824621831cfc465fd6d17ba3c374f0259d860798feec2706214989c9eac9e41c5eafbd66be0d4c147c29bdec638eec7e956b87858384bf6bb8
|
|
@@ -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
|
|
@@ -11,6 +11,7 @@ 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)
|
|
16
17
|
raise "Must specify a cycletime for board #{@id}" if @board.cycletime.nil?
|
|
@@ -30,6 +30,7 @@ class ChangeItem
|
|
|
30
30
|
def artificial? = @artificial
|
|
31
31
|
def assignee? = (field == 'assignee')
|
|
32
32
|
def comment? = (field == 'comment')
|
|
33
|
+
def description? = (field == 'description')
|
|
33
34
|
def due_date? = (field == 'duedate')
|
|
34
35
|
def flagged? = (field == 'Flagged')
|
|
35
36
|
def issue_type? = field == 'issuetype'
|
|
@@ -2,7 +2,8 @@
|
|
|
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,
|
|
6
|
+
:atlassian_document_format
|
|
6
7
|
attr_writer :aggregated_project
|
|
7
8
|
attr_reader :canvas_width, :canvas_height
|
|
8
9
|
|
|
@@ -44,7 +45,7 @@ class ChartBase
|
|
|
44
45
|
|
|
45
46
|
def render_top_text caller_binding
|
|
46
47
|
result = +''
|
|
47
|
-
result << "<h1>#{@header_text}</h1>" if @header_text
|
|
48
|
+
result << "<h1 class='foldable'>#{@header_text}</h1>" if @header_text
|
|
48
49
|
result << ERB.new(@description_text).result(caller_binding) if @description_text
|
|
49
50
|
result
|
|
50
51
|
end
|
|
@@ -66,6 +67,8 @@ class ChartBase
|
|
|
66
67
|
end
|
|
67
68
|
|
|
68
69
|
def label_days days
|
|
70
|
+
return 'unknown' if days.nil?
|
|
71
|
+
|
|
69
72
|
"#{days} day#{'s' unless days == 1}"
|
|
70
73
|
end
|
|
71
74
|
|
|
@@ -62,7 +62,9 @@ class CycletimeHistogram < ChartBase
|
|
|
62
62
|
)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
if data_sets.empty?
|
|
66
|
+
return "<h1 class='foldable'>#{@header_text}</h1><div>No data matched the selected criteria. Nothing to show.</div>"
|
|
67
|
+
end
|
|
66
68
|
|
|
67
69
|
wrap_and_render(binding, __FILE__)
|
|
68
70
|
end
|
|
@@ -23,7 +23,7 @@ class DailyView < ChartBase
|
|
|
23
23
|
def run
|
|
24
24
|
aging_issues = select_aging_issues
|
|
25
25
|
|
|
26
|
-
return "<h1>#{@header_text}</h1>There are no items currently in progress" if aging_issues.empty?
|
|
26
|
+
return "<h1 class='foldable'>#{@header_text}</h1>There are no items currently in progress" if aging_issues.empty?
|
|
27
27
|
|
|
28
28
|
result = +''
|
|
29
29
|
result << render_top_text(binding)
|
|
@@ -78,7 +78,7 @@ class DailyView < ChartBase
|
|
|
78
78
|
blocked_stalled = issue.blocked_stalled_by_date(
|
|
79
79
|
date_range: today..today, chart_end_time: time_range.end, settings: settings
|
|
80
80
|
)[today]
|
|
81
|
-
return []
|
|
81
|
+
return [] if blocked_stalled.active?
|
|
82
82
|
|
|
83
83
|
lines = []
|
|
84
84
|
if blocked_stalled.blocked?
|
|
@@ -98,15 +98,18 @@ class DailyView < ChartBase
|
|
|
98
98
|
lines
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
-
def make_issue_label issue
|
|
102
|
-
"<img src='#{issue.type_icon_url}' title='#{issue.type}' class='icon' /> "
|
|
103
|
-
|
|
101
|
+
def make_issue_label issue:, done:
|
|
102
|
+
label = "<img src='#{issue.type_icon_url}' title='#{issue.type}' class='icon' /> "
|
|
103
|
+
label << '<s>' if done
|
|
104
|
+
label << "<b><a href='#{issue.url}'>#{issue.key}</a></b> <i>#{issue.summary}</i>"
|
|
105
|
+
label << '</s>' if done
|
|
106
|
+
label
|
|
104
107
|
end
|
|
105
108
|
|
|
106
|
-
def make_title_line issue
|
|
109
|
+
def make_title_line issue:, done:
|
|
107
110
|
title_line = +''
|
|
108
111
|
title_line << color_block('--expedited-color', title: 'Expedited') if issue.expedited?
|
|
109
|
-
title_line << make_issue_label(issue)
|
|
112
|
+
title_line << make_issue_label(issue: issue, done: done)
|
|
110
113
|
title_line
|
|
111
114
|
end
|
|
112
115
|
|
|
@@ -115,20 +118,25 @@ class DailyView < ChartBase
|
|
|
115
118
|
parent_key = issue.parent_key
|
|
116
119
|
if parent_key
|
|
117
120
|
parent = issues.find_by_key key: parent_key, include_hidden: true
|
|
118
|
-
text = parent ? make_issue_label(parent) : parent_key
|
|
121
|
+
text = parent ? make_issue_label(issue: parent, done: parent.done?) : parent_key
|
|
119
122
|
lines << ["Parent: #{text}"]
|
|
120
123
|
end
|
|
121
124
|
lines
|
|
122
125
|
end
|
|
123
126
|
|
|
124
|
-
def make_stats_lines issue
|
|
127
|
+
def make_stats_lines issue:, done:
|
|
125
128
|
line = []
|
|
126
129
|
|
|
127
130
|
line << "<img src='#{issue.priority_url}' class='icon' /> <b>#{issue.priority_name}</b>"
|
|
128
131
|
|
|
129
|
-
|
|
130
|
-
|
|
132
|
+
if done
|
|
133
|
+
cycletime = issue.board.cycletime.cycletime(issue)
|
|
131
134
|
|
|
135
|
+
line << "Cycletime: <b>#{label_days cycletime}</b>"
|
|
136
|
+
else
|
|
137
|
+
age = issue.board.cycletime.age(issue, today: date_range.end)
|
|
138
|
+
line << "Age: <b>#{age ? label_days(age) : '(Not Started)'}</b>"
|
|
139
|
+
end
|
|
132
140
|
line << "Status: <b>#{format_status issue.status, board: issue.board}</b>"
|
|
133
141
|
|
|
134
142
|
column = issue.board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
|
|
@@ -154,43 +162,24 @@ class DailyView < ChartBase
|
|
|
154
162
|
|
|
155
163
|
def make_child_lines issue
|
|
156
164
|
lines = []
|
|
157
|
-
subtasks = issue.subtasks
|
|
165
|
+
subtasks = issue.subtasks
|
|
158
166
|
|
|
159
|
-
|
|
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
|
|
167
|
+
return lines if subtasks.empty?
|
|
166
168
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
.gsub(/\[~accountid:([^\]]+)\]/) { expand_account_id $1 } # Tagged people
|
|
171
|
-
.gsub(/\[([^\|]+)\|(https?[^\]]+)\]/, '<a href="\2">\1</a>') # URLs
|
|
172
|
-
.gsub("\n", '<br />')
|
|
173
|
-
end
|
|
169
|
+
lines << '<section><div class="foldable">Child issues</div>'
|
|
170
|
+
lines += subtasks
|
|
171
|
+
lines << '</section>'
|
|
174
172
|
|
|
175
|
-
|
|
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>"
|
|
173
|
+
lines
|
|
180
174
|
end
|
|
181
175
|
|
|
182
176
|
def make_history_lines issue
|
|
183
177
|
history = issue.changes.reverse
|
|
184
178
|
lines = []
|
|
185
179
|
|
|
186
|
-
|
|
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
|
-
]
|
|
180
|
+
lines << '<section><div class="foldable startFolded">Issue history</div>'
|
|
192
181
|
table = +''
|
|
193
|
-
table <<
|
|
182
|
+
table << '<table>'
|
|
194
183
|
history.each do |c|
|
|
195
184
|
time = c.time.strftime '%b %d, %I:%M%P'
|
|
196
185
|
|
|
@@ -203,23 +192,24 @@ class DailyView < ChartBase
|
|
|
203
192
|
end
|
|
204
193
|
table << '</table>'
|
|
205
194
|
lines << [table]
|
|
195
|
+
lines << '</section>'
|
|
206
196
|
lines
|
|
207
197
|
end
|
|
208
198
|
|
|
209
199
|
def history_text change:, board:
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
200
|
+
convertor = ->(value, _id) { value.inspect }
|
|
201
|
+
convertor = ->(_value, id) { format_status(board.possible_statuses.find_by_id(id), board: board) } if change.status?
|
|
202
|
+
|
|
203
|
+
if change.comment? || change.description?
|
|
204
|
+
atlassian_document_format.to_html(change.value)
|
|
205
|
+
elsif %w[status priority assignee duedate issuetype].include?(change.field)
|
|
206
|
+
to = convertor.call(change.value, change.value_id)
|
|
215
207
|
if change.old_value
|
|
216
|
-
from = convertor.call(change.old_value_id)
|
|
208
|
+
from = convertor.call(change.old_value, change.old_value_id)
|
|
217
209
|
"Changed from #{from} to #{to}"
|
|
218
210
|
else
|
|
219
211
|
"Set to #{to}"
|
|
220
212
|
end
|
|
221
|
-
elsif %w[priority assignee duedate issuetype].include?(change.field)
|
|
222
|
-
"Changed from \"#{change.old_value}\" to \"#{change.value}\""
|
|
223
213
|
elsif change.flagged?
|
|
224
214
|
change.value == '' ? 'Off' : 'On'
|
|
225
215
|
else
|
|
@@ -245,15 +235,27 @@ class DailyView < ChartBase
|
|
|
245
235
|
.join(' ')]]
|
|
246
236
|
end
|
|
247
237
|
|
|
238
|
+
def make_description_lines issue
|
|
239
|
+
description = issue.raw['fields']['description']
|
|
240
|
+
result = []
|
|
241
|
+
result << [atlassian_document_format.to_html(description)] if description
|
|
242
|
+
result
|
|
243
|
+
end
|
|
244
|
+
|
|
248
245
|
def assemble_issue_lines issue, child:
|
|
246
|
+
done = issue.done?
|
|
247
|
+
|
|
249
248
|
lines = []
|
|
250
|
-
lines << [make_title_line(issue)]
|
|
249
|
+
lines << [make_title_line(issue: issue, done: done)]
|
|
251
250
|
lines += make_parent_lines(issue) unless child
|
|
252
|
-
lines += make_stats_lines(issue)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
251
|
+
lines += make_stats_lines(issue: issue, done: done)
|
|
252
|
+
unless done
|
|
253
|
+
lines += make_description_lines(issue)
|
|
254
|
+
lines += make_sprints_lines(issue)
|
|
255
|
+
lines += make_blocked_stalled_lines(issue)
|
|
256
|
+
lines += make_child_lines(issue)
|
|
257
|
+
lines += make_history_lines(issue)
|
|
258
|
+
end
|
|
257
259
|
lines
|
|
258
260
|
end
|
|
259
261
|
|
|
@@ -264,6 +266,8 @@ class DailyView < ChartBase
|
|
|
264
266
|
assemble_issue_lines(issue, child: child).each do |row|
|
|
265
267
|
if row.is_a? Issue
|
|
266
268
|
result << render_issue(row, child: true)
|
|
269
|
+
elsif row.is_a?(String)
|
|
270
|
+
result << row
|
|
267
271
|
else
|
|
268
272
|
result << '<div class="heading">'
|
|
269
273
|
row.each do |chunk|
|
|
@@ -51,7 +51,10 @@ class DependencyChart < ChartBase
|
|
|
51
51
|
instance_eval(&@rules_block) if @rules_block
|
|
52
52
|
|
|
53
53
|
dot_graph = build_dot_graph
|
|
54
|
-
|
|
54
|
+
if dot_graph.nil?
|
|
55
|
+
return "<h1 class='foldable'>#{@header_text}</h1>" \
|
|
56
|
+
'<div>No data matched the selected criteria. Nothing to show.</div>'
|
|
57
|
+
end
|
|
55
58
|
|
|
56
59
|
svg = execute_graphviz(dot_graph.join("\n"))
|
|
57
60
|
"<h1>#{@header_text}</h1><div>#{@description_text}</div>#{shrink_svg svg}"
|
|
@@ -97,30 +97,59 @@ class Downloader
|
|
|
97
97
|
log " JQL: #{jql}"
|
|
98
98
|
escaped_jql = CGI.escape jql
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
100
|
+
if @jira_gateway.cloud?
|
|
101
|
+
max_results = 5_000 # The maximum allowed by Jira
|
|
102
|
+
next_page_token = nil
|
|
103
|
+
issue_count = 0
|
|
104
|
+
|
|
105
|
+
loop do
|
|
106
|
+
json = @jira_gateway.call_url relative_url: '/rest/api/3/search/jql' \
|
|
107
|
+
"?jql=#{escaped_jql}&maxResults=#{max_results}&" \
|
|
108
|
+
"nextPageToken=#{next_page_token}&expand=changelog&fields=*all"
|
|
109
|
+
next_page_token = json['nextPageToken']
|
|
110
|
+
|
|
111
|
+
json['issues'].each do |issue_json|
|
|
112
|
+
issue_json['exporter'] = {
|
|
113
|
+
'in_initial_query' => initial_query
|
|
114
|
+
}
|
|
115
|
+
identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
|
|
116
|
+
file = "#{issue_json['key']}-#{board.id}.json"
|
|
117
|
+
|
|
118
|
+
@file_system.save_json(json: issue_json, filename: File.join(path, file))
|
|
119
|
+
issue_count += 1
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
message = " Downloaded #{issue_count} issues"
|
|
123
|
+
log message, both: true
|
|
124
|
+
|
|
125
|
+
break unless next_page_token
|
|
126
|
+
end
|
|
127
|
+
else
|
|
128
|
+
max_results = 100
|
|
129
|
+
start_at = 0
|
|
130
|
+
total = 1
|
|
131
|
+
while start_at < total
|
|
132
|
+
json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
|
|
133
|
+
"?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
|
|
134
|
+
|
|
135
|
+
json['issues'].each do |issue_json|
|
|
136
|
+
issue_json['exporter'] = {
|
|
137
|
+
'in_initial_query' => initial_query
|
|
138
|
+
}
|
|
139
|
+
identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
|
|
140
|
+
file = "#{issue_json['key']}-#{board.id}.json"
|
|
141
|
+
|
|
142
|
+
@file_system.save_json(json: issue_json, filename: File.join(path, file))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
total = json['total'].to_i
|
|
146
|
+
max_results = json['maxResults']
|
|
147
|
+
|
|
148
|
+
message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
|
|
149
|
+
log message, both: true
|
|
150
|
+
|
|
151
|
+
start_at += json['issues'].size
|
|
115
152
|
end
|
|
116
|
-
|
|
117
|
-
total = json['total'].to_i
|
|
118
|
-
max_results = json['maxResults']
|
|
119
|
-
|
|
120
|
-
message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
|
|
121
|
-
log message, both: true
|
|
122
|
-
|
|
123
|
-
start_at += json['issues'].size
|
|
124
153
|
end
|
|
125
154
|
end
|
|
126
155
|
|
|
@@ -48,7 +48,7 @@ class ExpeditedChart < ChartBase
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
if data_sets.empty?
|
|
51
|
-
'<h1>Expedited work</h1>There is no expedited work in this time period
|
|
51
|
+
'<h1 class="foldable">Expedited work</h1><p>There is no expedited work in this time period.</p>'
|
|
52
52
|
else
|
|
53
53
|
wrap_and_render(binding, __FILE__)
|
|
54
54
|
end
|
|
@@ -60,7 +60,7 @@ class FlowEfficiencyScatterplot < ChartBase
|
|
|
60
60
|
create_dataset(issues: issues, label: rules.label, color: rules.color)
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
|
|
63
|
+
return "<h1 class='foldable'>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
|
|
64
64
|
|
|
65
65
|
wrap_and_render(binding, __FILE__)
|
|
66
66
|
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
<table class='standard' id='<%= issues_id %>'
|
|
1
|
+
<div class='foldable startFolded'>Show details</div>
|
|
2
|
+
<table class='standard' id='<%= issues_id %>'>
|
|
3
3
|
<thead>
|
|
4
4
|
<tr>
|
|
5
5
|
<th>Issue</th>
|
|
@@ -6,8 +6,8 @@ if show_stats
|
|
|
6
6
|
link_id = next_id
|
|
7
7
|
issues_id = next_id
|
|
8
8
|
%>
|
|
9
|
-
|
|
10
|
-
<div id="<%= issues_id %>" style="
|
|
9
|
+
<div class='foldable' style="padding-left: 1em;">Statistics</div>
|
|
10
|
+
<div id="<%= issues_id %>" style="padding-left: 1em;">
|
|
11
11
|
<div>
|
|
12
12
|
<table class="standard">
|
|
13
13
|
<tr>
|
|
@@ -78,11 +78,6 @@ body {
|
|
|
78
78
|
color: var(--default-text-color);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
h1 {
|
|
82
|
-
border: 1px solid black;
|
|
83
|
-
background: lightgray;
|
|
84
|
-
padding-left: 0.2em;
|
|
85
|
-
}
|
|
86
81
|
dl, dd, dt {
|
|
87
82
|
padding: 0;
|
|
88
83
|
margin: 0;
|
|
@@ -191,6 +186,11 @@ div.daily_issue {
|
|
|
191
186
|
padding-right: 0.2em;
|
|
192
187
|
border-radius: 0.2em;
|
|
193
188
|
}
|
|
189
|
+
h1 {
|
|
190
|
+
border: none;
|
|
191
|
+
background: none;
|
|
192
|
+
padding-left: 0;
|
|
193
|
+
}
|
|
194
194
|
margin-bottom: 0.5em;
|
|
195
195
|
}
|
|
196
196
|
div.child_issue:hover {
|
|
@@ -239,11 +239,6 @@ div.child_issue {
|
|
|
239
239
|
--daily-view-selected-issue-background: #474747;
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
h1 {
|
|
243
|
-
color: #e0e0e0;
|
|
244
|
-
background-color: #656565;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
242
|
a[href] {
|
|
248
243
|
color: #1e8ad6;
|
|
249
244
|
}
|
|
@@ -7,39 +7,7 @@
|
|
|
7
7
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@^1"></script>
|
|
8
8
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-annotation/1.2.2/chartjs-plugin-annotation.min.js" integrity="sha512-HycvvBSFvDEVyJ0tjE2rPmymkt6XqsP/Zo96XgLRjXwn6SecQqsn+6V/7KYev66OshZZ9+f9AttCGmYqmzytiw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
|
9
9
|
<script type="text/javascript">
|
|
10
|
-
|
|
11
|
-
link_text = document.getElementById(link_id).textContent
|
|
12
|
-
if( link_text == 'Show details') {
|
|
13
|
-
document.getElementById(link_id).textContent = 'Hide details'
|
|
14
|
-
document.getElementById(issues_id).style.display = 'block'
|
|
15
|
-
}
|
|
16
|
-
else {
|
|
17
|
-
document.getElementById(link_id).textContent = 'Show details'
|
|
18
|
-
document.getElementById(issues_id).style.display = 'none'
|
|
19
|
-
}
|
|
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
|
-
}
|
|
38
|
-
// If we switch between light/dark mode then force a refresh so all charts will redraw correctly
|
|
39
|
-
// in the other colour scheme.
|
|
40
|
-
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
|
|
41
|
-
location.reload()
|
|
42
|
-
})
|
|
10
|
+
<%= javascript %>
|
|
43
11
|
</script>
|
|
44
12
|
<style>
|
|
45
13
|
<%= css %>
|
|
@@ -50,8 +18,8 @@
|
|
|
50
18
|
</head>
|
|
51
19
|
<body>
|
|
52
20
|
<noscript>
|
|
53
|
-
<div style="padding: 1em; background:
|
|
54
|
-
Javascript is currently disabled and that means that almost all of the charts in this report won't render. If you'
|
|
21
|
+
<div style="padding: 1em; background: red; color: white; font-size: 2em;">
|
|
22
|
+
Javascript is currently disabled and that means that almost all of the charts in this report won't render. If you've loaded this from a folder on SharePoint then save it locally and load it again.
|
|
55
23
|
</div>
|
|
56
24
|
</noscript>
|
|
57
25
|
<%= "\n" + @sections.collect { |text, type| text if type == :header }.compact.join("\n\n") %>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
function makeFoldable() {
|
|
2
|
+
// Get all elements with the "foldable" class
|
|
3
|
+
const foldableElements = document.querySelectorAll('.foldable');
|
|
4
|
+
|
|
5
|
+
if (foldableElements.length === 0) {
|
|
6
|
+
return; // No foldable elements found
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Process each foldable element
|
|
10
|
+
foldableElements.forEach((element, index) => {
|
|
11
|
+
// Skip if this is the footer element
|
|
12
|
+
if (element.id === 'footer') {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Create a unique ID for this section
|
|
17
|
+
const sectionId = `foldable-section-${index}`;
|
|
18
|
+
const toggleId = `foldable-toggle-${index}`;
|
|
19
|
+
|
|
20
|
+
// Create a container div for the foldable element and its content
|
|
21
|
+
const container = document.createElement('div');
|
|
22
|
+
container.className = 'foldable-section';
|
|
23
|
+
container.id = sectionId;
|
|
24
|
+
|
|
25
|
+
// Create a toggle button
|
|
26
|
+
const toggleButton = document.createElement(element.tagName); //'button');
|
|
27
|
+
toggleButton.id = toggleId;
|
|
28
|
+
toggleButton.className = 'foldable-toggle-btn';
|
|
29
|
+
toggleButton.innerHTML = '▼ ' + element.textContent;
|
|
30
|
+
|
|
31
|
+
// Create a content container
|
|
32
|
+
const contentContainer = document.createElement('div');
|
|
33
|
+
contentContainer.className = 'foldable-content';
|
|
34
|
+
contentContainer.style.cssText = `
|
|
35
|
+
border-left: 2px solid #ccc;
|
|
36
|
+
padding-left: 15px;
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
// Move the foldable element into the container and replace it with the toggle button
|
|
40
|
+
element.parentNode.insertBefore(container, element);
|
|
41
|
+
container.appendChild(toggleButton);
|
|
42
|
+
container.appendChild(contentContainer);
|
|
43
|
+
|
|
44
|
+
// Move all elements between this foldable element and the next foldable element (or end of document) into the content container
|
|
45
|
+
let nextElement = element.nextElementSibling;
|
|
46
|
+
while (nextElement && !nextElement.classList.contains('foldable')) {
|
|
47
|
+
// Skip the footer element
|
|
48
|
+
if (nextElement.id === 'footer') {
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const temp = nextElement.nextElementSibling;
|
|
53
|
+
contentContainer.appendChild(nextElement);
|
|
54
|
+
nextElement = temp;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Remove the original foldable element
|
|
58
|
+
element.remove();
|
|
59
|
+
|
|
60
|
+
// Add click event to toggle visibility
|
|
61
|
+
toggleButton.addEventListener('click', function() {
|
|
62
|
+
const content = this.nextElementSibling;
|
|
63
|
+
if (content.style.display === 'none') {
|
|
64
|
+
content.style.display = 'block';
|
|
65
|
+
this.innerHTML = '▼ ' + this.innerHTML.substring(2);
|
|
66
|
+
} else {
|
|
67
|
+
content.style.display = 'none';
|
|
68
|
+
this.innerHTML = '▶ ' + this.innerHTML.substring(2);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Initially show the content (you can change this to 'none' if you want sections collapsed by default)
|
|
73
|
+
contentContainer.style.display = 'block';
|
|
74
|
+
if(element.classList.contains('startFolded')) {
|
|
75
|
+
toggleButton.click();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Auto-initialize when DOM is loaded
|
|
81
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
82
|
+
makeFoldable();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
// If we switch between light/dark mode then force a refresh so all charts will redraw correctly
|
|
87
|
+
// in the other colour scheme.
|
|
88
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
|
|
89
|
+
location.reload()
|
|
90
|
+
})
|
|
@@ -68,8 +68,9 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
68
68
|
link_id = next_id
|
|
69
69
|
issues_id = next_id
|
|
70
70
|
%>
|
|
71
|
-
|
|
72
|
-
<div
|
|
71
|
+
<section>
|
|
72
|
+
<div class='foldable startFolded'>Show statistics</div>
|
|
73
|
+
<div id="<%= issues_id %>">
|
|
73
74
|
<table class='standard' style="margin-left: 1em;">
|
|
74
75
|
<thead>
|
|
75
76
|
<th>Sprint</th>
|
|
@@ -109,4 +110,5 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
109
110
|
<% end %>
|
|
110
111
|
</ul>
|
|
111
112
|
</p>
|
|
112
|
-
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</section>
|
|
@@ -72,6 +72,7 @@ class HtmlReportConfig
|
|
|
72
72
|
|
|
73
73
|
html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
|
|
74
74
|
css = load_css html_directory: html_directory
|
|
75
|
+
javascript = file_system.load(File.join(html_directory, 'index.js'))
|
|
75
76
|
erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
|
|
76
77
|
file_system.save_file content: erb.result(binding), filename: @file_config.output_filename
|
|
77
78
|
end
|
|
@@ -87,7 +88,6 @@ class HtmlReportConfig
|
|
|
87
88
|
def load_css html_directory:
|
|
88
89
|
base_css_filename = File.join(html_directory, 'index.css')
|
|
89
90
|
base_css = file_system.load(base_css_filename)
|
|
90
|
-
log("Loaded CSS: #{base_css_filename}")
|
|
91
91
|
|
|
92
92
|
extra_css_filename = settings['include_css']
|
|
93
93
|
if extra_css_filename
|
|
@@ -160,7 +160,7 @@ class HtmlReportConfig
|
|
|
160
160
|
chart.time_range = project_config.time_range
|
|
161
161
|
chart.timezone_offset = timezone_offset
|
|
162
162
|
chart.settings = settings
|
|
163
|
-
chart.
|
|
163
|
+
chart.atlassian_document_format = project_config.atlassian_document_format
|
|
164
164
|
|
|
165
165
|
chart.all_boards = project_config.all_boards
|
|
166
166
|
chart.board_id = find_board_id
|
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -345,7 +345,7 @@ class Issue
|
|
|
345
345
|
end
|
|
346
346
|
elsif change.link?
|
|
347
347
|
# Example: "This issue is satisfied by ANON-30465"
|
|
348
|
-
unless /^This issue (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
|
|
348
|
+
unless /^This (?<_>issue|work item) (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
|
|
349
349
|
puts "Issue(#{key}) Can't parse link text: #{change.value || change.old_value}"
|
|
350
350
|
next
|
|
351
351
|
end
|
|
@@ -608,7 +608,7 @@ class Issue
|
|
|
608
608
|
|
|
609
609
|
def dump
|
|
610
610
|
result = +''
|
|
611
|
-
result << "#{key} (#{type}): #{compact_text summary, 200}\n"
|
|
611
|
+
result << "#{key} (#{type}): #{compact_text summary, max: 200}\n"
|
|
612
612
|
|
|
613
613
|
assignee = raw['fields']['assignee']
|
|
614
614
|
result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
|
|
@@ -681,9 +681,8 @@ class Issue
|
|
|
681
681
|
def done?
|
|
682
682
|
if artificial? || board.cycletime.nil?
|
|
683
683
|
# This was probably loaded as a linked issue, which means we don't know what board it really
|
|
684
|
-
# belonged to. The best we can do is look at the status
|
|
685
|
-
|
|
686
|
-
status.category.name == 'Done'
|
|
684
|
+
# belonged to. The best we can do is look at the status key
|
|
685
|
+
status.category.done?
|
|
687
686
|
else
|
|
688
687
|
board.cycletime.done? self
|
|
689
688
|
end
|
|
@@ -706,6 +705,19 @@ class Issue
|
|
|
706
705
|
board.sprints.select { |s| sprint_ids.include? s.id }
|
|
707
706
|
end
|
|
708
707
|
|
|
708
|
+
def compact_text text, max: 60
|
|
709
|
+
return '' if text.nil?
|
|
710
|
+
|
|
711
|
+
if text.is_a? Hash
|
|
712
|
+
# We can't effectively compact it but we can convert it into a string.
|
|
713
|
+
text = @board.project_config.atlassian_document_format.to_html(text)
|
|
714
|
+
else
|
|
715
|
+
text = text.gsub(/\s+/, ' ').strip
|
|
716
|
+
text = "#{text[0...max]}..." if text.length > max
|
|
717
|
+
end
|
|
718
|
+
text
|
|
719
|
+
end
|
|
720
|
+
|
|
709
721
|
private
|
|
710
722
|
|
|
711
723
|
def load_history_into_changes
|
|
@@ -730,14 +742,6 @@ class Issue
|
|
|
730
742
|
end
|
|
731
743
|
end
|
|
732
744
|
|
|
733
|
-
def compact_text text, max = 60
|
|
734
|
-
return nil if text.nil?
|
|
735
|
-
|
|
736
|
-
text = text.gsub(/\s+/, ' ').strip
|
|
737
|
-
text = "#{text[0..max]}..." if text.length > max
|
|
738
|
-
text
|
|
739
|
-
end
|
|
740
|
-
|
|
741
745
|
def sort_changes!
|
|
742
746
|
@changes.sort! do |a, b|
|
|
743
747
|
# It's common that a resolved will happen at the same time as a status change.
|
|
@@ -17,7 +17,9 @@ class JiraGateway
|
|
|
17
17
|
begin
|
|
18
18
|
json = JSON.parse(result)
|
|
19
19
|
rescue # rubocop:disable Style/RescueStandardError
|
|
20
|
-
|
|
20
|
+
message = "Unable to parse results from #{sanitize_message(command)}"
|
|
21
|
+
@file_system.error message, more: result
|
|
22
|
+
raise message
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
raise "Download failed with: #{JSON.pretty_generate(json)}" unless json_successful?(json)
|
|
@@ -25,9 +27,16 @@ class JiraGateway
|
|
|
25
27
|
json
|
|
26
28
|
end
|
|
27
29
|
|
|
30
|
+
def sanitize_message message
|
|
31
|
+
token = @jira_api_token || @jira_personal_access_token
|
|
32
|
+
raise 'Neither Jira API Token or personal access token has been set' unless token
|
|
33
|
+
|
|
34
|
+
message.gsub(@jira_api_token, '[API_TOKEN]')
|
|
35
|
+
end
|
|
36
|
+
|
|
28
37
|
def call_command command
|
|
29
38
|
log_entry = " #{command.gsub(/\s+/, ' ')}"
|
|
30
|
-
log_entry = log_entry
|
|
39
|
+
log_entry = sanitize_message log_entry if @jira_api_token
|
|
31
40
|
@file_system.log log_entry
|
|
32
41
|
|
|
33
42
|
result = `#{command}`
|
|
@@ -77,4 +86,8 @@ class JiraGateway
|
|
|
77
86
|
|
|
78
87
|
true
|
|
79
88
|
end
|
|
89
|
+
|
|
90
|
+
def cloud?
|
|
91
|
+
@jira_url.downcase.end_with? '.atlassian.net'
|
|
92
|
+
end
|
|
80
93
|
end
|
|
@@ -114,10 +114,14 @@ class ProjectConfig
|
|
|
114
114
|
def file_prefix prefix
|
|
115
115
|
# The file_prefix has to be set before almost everything else. It really should have been an attribute
|
|
116
116
|
# on the project declaration itself. Hindsight is 20/20.
|
|
117
|
+
|
|
118
|
+
# There can only be one of these
|
|
117
119
|
if @file_prefix
|
|
118
|
-
raise "file_prefix
|
|
120
|
+
raise "file_prefix can only be set once. Was #{@file_prefix.inspect} and now changed to #{prefix.inspect}."
|
|
119
121
|
end
|
|
120
122
|
|
|
123
|
+
raise_if_prefix_already_used(prefix)
|
|
124
|
+
|
|
121
125
|
@file_prefix = prefix
|
|
122
126
|
|
|
123
127
|
# Yes, this is a wierd place to be initializing this. Unfortunately, it has to happen after the file_prefix
|
|
@@ -130,8 +134,21 @@ class ProjectConfig
|
|
|
130
134
|
@file_prefix
|
|
131
135
|
end
|
|
132
136
|
|
|
133
|
-
def
|
|
134
|
-
|
|
137
|
+
def raise_if_prefix_already_used prefix
|
|
138
|
+
@exporter.project_configs.each do |project|
|
|
139
|
+
next unless project.get_file_prefix(raise_if_not_set: false) == prefix && project.target_path == target_path
|
|
140
|
+
|
|
141
|
+
raise "Project #{name.inspect} specifies file prefix #{prefix.inspect}, " \
|
|
142
|
+
"but that is already used by project #{project.name.inspect} in the same target path #{target_path.inspect}. " \
|
|
143
|
+
'This is almost guaranteed to be too much copy and paste in your configuration. ' \
|
|
144
|
+
'File prefixes must be unique within a directory.'
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def get_file_prefix raise_if_not_set: true
|
|
149
|
+
if @file_prefix.nil? && raise_if_not_set
|
|
150
|
+
raise 'file_prefix has not been set yet. Move it to the top of the project declaration.'
|
|
151
|
+
end
|
|
135
152
|
|
|
136
153
|
@file_prefix
|
|
137
154
|
end
|
|
@@ -335,6 +352,12 @@ class ProjectConfig
|
|
|
335
352
|
json.each { |user_data| @users << User.new(raw: user_data) }
|
|
336
353
|
end
|
|
337
354
|
|
|
355
|
+
def atlassian_document_format
|
|
356
|
+
@atlassian_document_format ||= AtlassianDocumentFormat.new(
|
|
357
|
+
users: @users, timezone_offset: exporter.timezone_offset
|
|
358
|
+
)
|
|
359
|
+
end
|
|
360
|
+
|
|
338
361
|
def to_time string, end_of_day: false
|
|
339
362
|
time = end_of_day ? '23:59:59' : '00:00:00'
|
|
340
363
|
string = "#{string}T#{time}#{exporter.timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
|
data/lib/jirametrics.rb
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'thor'
|
|
4
|
+
require 'require_all'
|
|
5
|
+
|
|
6
|
+
# This one does need to be loaded early. The rest will be loaded later.
|
|
7
|
+
require './lib/jirametrics/file_system'
|
|
4
8
|
|
|
5
9
|
class JiraMetrics < Thor
|
|
6
10
|
def self.exit_on_failure?
|
|
@@ -48,75 +52,21 @@ class JiraMetrics < Thor
|
|
|
48
52
|
Exporter.instance.info(keys, name_filter: options[:name] || '*')
|
|
49
53
|
end
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
no_commands do
|
|
56
|
+
def load_config config_file, file_system: FileSystem.new
|
|
57
|
+
config_file = './config.rb' if config_file.nil?
|
|
52
58
|
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
if File.exist? config_file
|
|
60
|
+
# The fact that File.exist can see the file does not mean that require will be
|
|
61
|
+
# able to load it. Convert this to an absolute pathname now for require.
|
|
62
|
+
config_file = File.absolute_path(config_file).to_s
|
|
63
|
+
else
|
|
64
|
+
file_system.error "Cannot find configuration file #{config_file.inspect}"
|
|
65
|
+
exit 1
|
|
66
|
+
end
|
|
55
67
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# able to load it. Convert this to an absolute pathname now for require.
|
|
59
|
-
config_file = File.absolute_path(config_file).to_s
|
|
60
|
-
else
|
|
61
|
-
puts "Cannot find configuration file #{config_file.inspect}"
|
|
62
|
-
exit 1
|
|
68
|
+
require_rel 'jirametrics'
|
|
69
|
+
load config_file
|
|
63
70
|
end
|
|
64
|
-
|
|
65
|
-
require 'jirametrics/value_equality'
|
|
66
|
-
require 'jirametrics/chart_base'
|
|
67
|
-
require 'jirametrics/rules'
|
|
68
|
-
require 'jirametrics/grouping_rules'
|
|
69
|
-
require 'jirametrics/daily_wip_chart'
|
|
70
|
-
require 'jirametrics/groupable_issue_chart'
|
|
71
|
-
require 'jirametrics/css_variable'
|
|
72
|
-
require 'jirametrics/issue_collection'
|
|
73
|
-
|
|
74
|
-
require 'jirametrics/aggregate_config'
|
|
75
|
-
require 'jirametrics/expedited_chart'
|
|
76
|
-
require 'jirametrics/board_config'
|
|
77
|
-
require 'jirametrics/file_config'
|
|
78
|
-
require 'jirametrics/jira_gateway'
|
|
79
|
-
require 'jirametrics/trend_line_calculator'
|
|
80
|
-
require 'jirametrics/status'
|
|
81
|
-
require 'jirametrics/issue_link'
|
|
82
|
-
require 'jirametrics/estimate_accuracy_chart'
|
|
83
|
-
require 'jirametrics/status_collection'
|
|
84
|
-
require 'jirametrics/sprint'
|
|
85
|
-
require 'jirametrics/issue'
|
|
86
|
-
require 'jirametrics/daily_wip_by_age_chart'
|
|
87
|
-
require 'jirametrics/daily_wip_by_parent_chart'
|
|
88
|
-
require 'jirametrics/aging_work_in_progress_chart'
|
|
89
|
-
require 'jirametrics/cycletime_scatterplot'
|
|
90
|
-
require 'jirametrics/flow_efficiency_scatterplot'
|
|
91
|
-
require 'jirametrics/sprint_issue_change_data'
|
|
92
|
-
require 'jirametrics/cycletime_histogram'
|
|
93
|
-
require 'jirametrics/daily_wip_by_blocked_stalled_chart'
|
|
94
|
-
require 'jirametrics/html_report_config'
|
|
95
|
-
require 'jirametrics/data_quality_report'
|
|
96
|
-
require 'jirametrics/aging_work_bar_chart'
|
|
97
|
-
require 'jirametrics/change_item'
|
|
98
|
-
require 'jirametrics/project_config'
|
|
99
|
-
require 'jirametrics/dependency_chart'
|
|
100
|
-
require 'jirametrics/cycletime_config'
|
|
101
|
-
require 'jirametrics/tree_organizer'
|
|
102
|
-
require 'jirametrics/aging_work_table'
|
|
103
|
-
require 'jirametrics/sprint_burndown'
|
|
104
|
-
require 'jirametrics/self_or_issue_dispatcher'
|
|
105
|
-
require 'jirametrics/throughput_chart'
|
|
106
|
-
require 'jirametrics/exporter'
|
|
107
|
-
require 'jirametrics/file_system'
|
|
108
|
-
require 'jirametrics/blocked_stalled_change'
|
|
109
|
-
require 'jirametrics/board_column'
|
|
110
|
-
require 'jirametrics/anonymizer'
|
|
111
|
-
require 'jirametrics/downloader'
|
|
112
|
-
require 'jirametrics/fix_version'
|
|
113
|
-
require 'jirametrics/download_config'
|
|
114
|
-
require 'jirametrics/columns_config'
|
|
115
|
-
require 'jirametrics/hierarchy_table'
|
|
116
|
-
require 'jirametrics/estimation_configuration'
|
|
117
|
-
require 'jirametrics/board'
|
|
118
|
-
require 'jirametrics/daily_view'
|
|
119
|
-
require 'jirametrics/user'
|
|
120
|
-
load config_file
|
|
121
71
|
end
|
|
122
72
|
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.15'
|
|
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-09-08 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: random-word
|
|
@@ -65,6 +65,7 @@ files:
|
|
|
65
65
|
- lib/jirametrics/aging_work_in_progress_chart.rb
|
|
66
66
|
- lib/jirametrics/aging_work_table.rb
|
|
67
67
|
- lib/jirametrics/anonymizer.rb
|
|
68
|
+
- lib/jirametrics/atlassian_document_format.rb
|
|
68
69
|
- lib/jirametrics/blocked_stalled_change.rb
|
|
69
70
|
- lib/jirametrics/board.rb
|
|
70
71
|
- lib/jirametrics/board_column.rb
|
|
@@ -112,6 +113,7 @@ files:
|
|
|
112
113
|
- lib/jirametrics/html/hierarchy_table.erb
|
|
113
114
|
- lib/jirametrics/html/index.css
|
|
114
115
|
- lib/jirametrics/html/index.erb
|
|
116
|
+
- lib/jirametrics/html/index.js
|
|
115
117
|
- lib/jirametrics/html/sprint_burndown.erb
|
|
116
118
|
- lib/jirametrics/html/throughput_chart.erb
|
|
117
119
|
- lib/jirametrics/html_report_config.rb
|