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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7565c5b6320b9c4375a3feb27fcaf80f96d9c058041b71584f287e6a3d7eb399
4
- data.tar.gz: 4554c7edbc7c42e01ad4838094ab922819c2e71818a46907f25740e35c327aa6
3
+ metadata.gz: c8045db95ffbb1368c3df8be3ee88c88ddb1a3d2fee46c989ba9d94e8fdb4ac3
4
+ data.tar.gz: a4a245da182c96238ec99918eefaf097c3fcc3cd3f4a09f85d4fbdd25020b713
5
5
  SHA512:
6
- metadata.gz: 021c1f989117b1fbb8708183d9ad8e79b049060989c1f20d31121a486d25f1ae2d1c036e013279479cd3ac6f5b101a48d8c9f4943570107c9cc4b40485ea6a4f
7
- data.tar.gz: 7f7e334a2161e23ef5ae8a754aa3f12524c1bb130893ca3df3e8574f7a44286ef33c8a03b2ac2790ccb8e8b792ce0fb6c17b1d69d2da068ea48dbce1b4c514a1
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, :users
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
- return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
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 [] unless blocked_stalled
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
- "<b><a href='#{issue.url}'>#{issue.key}</a></b> &nbsp;<i>#{issue.summary}</i>"
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> &nbsp;<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
- age = issue.board.cycletime.age(issue, today: date_range.end)
130
- line << "Age: <b>#{age ? label_days(age) : '(Not Started)'}</b>"
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.reject { |i| i.done? }
165
+ subtasks = issue.subtasks
158
166
 
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
167
+ return lines if subtasks.empty?
166
168
 
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
169
+ lines << '<section><div class="foldable">Child issues</div>'
170
+ lines += subtasks
171
+ lines << '</section>'
174
172
 
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>"
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
- 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
- ]
180
+ lines << '<section><div class="foldable startFolded">Issue history</div>'
192
181
  table = +''
193
- table << "<table id='table#{id}' style='display: none'>"
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
- 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)
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
- lines += make_sprints_lines(issue)
254
- lines += make_blocked_stalled_lines(issue)
255
- lines += make_child_lines(issue)
256
- lines += make_history_lines(issue)
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
- return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if dot_graph.nil?
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
- max_results = 100
101
- start_at = 0
102
- total = 1
103
- while start_at < total
104
- json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
105
- "?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
106
-
107
- json['issues'].each do |issue_json|
108
- issue_json['exporter'] = {
109
- 'in_initial_query' => initial_query
110
- }
111
- identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
112
- file = "#{issue_json['key']}-#{board.id}.json"
113
-
114
- @file_system.save_json(json: issue_json, filename: File.join(path, file))
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
- [<a id='<%= link_id %>' href="#" onclick='expand_collapse("<%= link_id %>", "<%= issues_id %>"); return false;'>Show details</a>]
2
- <table class='standard' id='<%= issues_id %>' style='display: none;'>
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
- [<a id='<%= link_id %>' href="#" onclick='expand_collapse("<%= link_id %>", "<%= issues_id %>"); return false;'>Show details</a>]
10
- <div id="<%= issues_id %>" style="display: none;">
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
- function expand_collapse(link_id, issues_id) {
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: gray; color: white; font-size: 2em;">
54
- Javascript is currently disabled and that means that almost all of the charts in this report won't render. If you'd loaded this from a folder on SharePoint then save it locally and load it again.
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
- [<a id='<%= link_id %>' href="#" onclick='expand_collapse("<%= link_id %>", "<%= issues_id %>"); return false;'>Show details</a>]
72
- <div id="<%= issues_id %>" style="display: none;">
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.users = project_config.users
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
@@ -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 category. This case should be rare but
685
- # it can happen.
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
- raise "Error when parsing result: #{result.inspect}"
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.gsub(@jira_api_token, '[API_TOKEN]') if @jira_api_token
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 should only be set once. Was #{@file_prefix.inspect} and now changed to #{prefix.inspect}."
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 get_file_prefix # rubocop:disable Naming/AccessorMethodName
134
- raise 'file_prefix has not been set yet. Move it to the top of the project declaration.' unless @file_prefix
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}$/)
@@ -19,6 +19,7 @@ class StatusCollection
19
19
  def find_by_id! id
20
20
  status = @list.find { |status| status.id == id }
21
21
  raise "Can't find any status for id #{id} in #{self}" unless status
22
+
22
23
  status
23
24
  end
24
25
 
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
- private
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
- def load_config config_file
54
- config_file = './config.rb' if config_file.nil?
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
- if File.exist? config_file
57
- # The fact that File.exist can see the file does not mean that require will be
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.12.1
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-07-13 00:00:00.000000000 Z
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