jirametrics 2.7.1 → 2.8

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: d5bb71d8f3c6ab4cf1fa17ad93ad1c00ba1b8381c196bc15a054095a5a91917a
4
- data.tar.gz: 790abb0404719d6b55d833ad76d34121875997f3ec3967f69f5d467fae203752
3
+ metadata.gz: f5bec8084c47e2383d25676b969891c6fc8e427d06427a1caf195378a830ff4f
4
+ data.tar.gz: e9364c2b43f66d90a651f50e115751c144f8dfc6f7d247b47dfd17495bdf167e
5
5
  SHA512:
6
- metadata.gz: f71c2ae123a13435a1147ceaadf7f10364a6cdf1421b4f8491633936189e3744e58da4d99be6c43c6e8034d5f27afc470498532157ac15a7d4459a0399f6fc12
7
- data.tar.gz: f33eb0ea450d6002b09d84311823fc20847327bb78dca5fa218c3f724a23f02d49f4ef0183e9dc02499e0eea1f5916147396538751192974926e1c197f4f0bb5
6
+ metadata.gz: 82c740fa8d23565eb33edf1e244cde1b8b8ef2d88f753091fd0f3cfb7e20aa67c32fc22dca70d4076c25ad40898a0872c13f8af14173a26b33054faf8838df14
7
+ data.tar.gz: 66d5db7495165aa4ac0ea36acd27e1b77d2d4b4d4ebb7912fbbffee57c9c160b8cee3fc919884564370db51618438d24fc6fde5e47e2343560aca52b53917d4b
@@ -41,7 +41,7 @@ class AggregateConfig
41
41
  def include_issues_from project_name
42
42
  project = @project_config.exporter.project_configs.find { |p| p.name == project_name }
43
43
  if project.nil?
44
- log "Warning: Aggregated project #{@project_config.name.inspect} is attempting to load " \
44
+ file_system.warning "Aggregated project #{@project_config.name.inspect} is attempting to load " \
45
45
  "project #{project_name.inspect} but it can't be found. Is it disabled?"
46
46
  return
47
47
  end
@@ -86,7 +86,7 @@ class AggregateConfig
86
86
 
87
87
  private
88
88
 
89
- def log message
90
- @project_config.exporter.file_system.log message
89
+ def file_system
90
+ @project_config.exporter.file_system
91
91
  end
92
92
  end
@@ -116,7 +116,7 @@ class AgingWorkBarChart < ChartBase
116
116
  issue.changes.each do |change|
117
117
  next unless change.status?
118
118
 
119
- status = issue.find_status_by_name change.value
119
+ status = issue.find_status_by_id change.value_id, name: change.value
120
120
 
121
121
  unless previous_start.nil? || previous_start < issue_started_time
122
122
  hash = {
@@ -113,7 +113,7 @@ class AgingWorkInProgressChart < ChartBase
113
113
 
114
114
  def ages_of_issues_that_crossed_column_boundary issues:, status_ids:
115
115
  issues.filter_map do |issue|
116
- stop = issue.first_time_in_status(*status_ids)
116
+ stop = issue.first_time_in_status(*status_ids)&.to_time
117
117
  start, = issue.board.cycletime.started_stopped_times(issue)
118
118
 
119
119
  # Skip if either it hasn't crossed the boundary or we can't tell when it started.
@@ -4,7 +4,7 @@ class Board
4
4
  attr_reader :visible_columns, :raw, :possible_statuses, :sprints, :board_type
5
5
  attr_accessor :cycletime, :project_config
6
6
 
7
- def initialize raw:, possible_statuses: StatusCollection.new
7
+ def initialize raw:, possible_statuses:
8
8
  @raw = raw
9
9
  @board_type = raw['type']
10
10
  @possible_statuses = possible_statuses
@@ -1,16 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ChangeItem
4
- attr_reader :field, :value_id, :old_value_id, :raw, :author
5
- attr_accessor :value, :old_value, :time
4
+ attr_reader :field, :value_id, :old_value_id, :raw, :author, :time
5
+ attr_accessor :value, :old_value
6
6
 
7
7
  def initialize raw:, time:, author:, artificial: false
8
8
  @raw = raw
9
9
  @time = time
10
- raise "Time must be an object of type Time in the correct timezone: #{@time}" if @time.is_a? String
10
+ raise 'ChangeItem.new() time cannot be nil' if time.nil?
11
+ raise "Time must be an object of type Time in the correct timezone: #{@time.inspect}" unless @time.is_a? Time
11
12
 
12
- @field = field || @raw['field']
13
- @value = value || @raw['toString']
13
+ @field = @raw['field']
14
+ @value = @raw['toString']
14
15
  @value_id = @raw['to'].to_i
15
16
  @old_value = @raw['fromString']
16
17
  @old_value_id = @raw['from']&.to_i
@@ -34,6 +35,11 @@ class ChangeItem
34
35
 
35
36
  def link? = (field == 'Link')
36
37
 
38
+ def labels? = (field == 'labels')
39
+
40
+ # An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
41
+ def to_time = @time
42
+
37
43
  def to_s
38
44
  message = +''
39
45
  message << "ChangeItem(field: #{field.inspect}, value: #{value.inspect}, time: #{time_to_s(@time).inspect}"
@@ -169,13 +169,13 @@ class ChartBase
169
169
  end
170
170
  end
171
171
 
172
- def header_text text = nil
173
- @header_text = text if text
172
+ def header_text text = :none
173
+ @header_text = text unless text == :none
174
174
  @header_text
175
175
  end
176
176
 
177
- def description_text text = nil
178
- @description_text = text if text
177
+ def description_text text = :none
178
+ @description_text = text unless text == :none
179
179
  @description_text
180
180
  end
181
181
 
@@ -200,8 +200,8 @@ class ChartBase
200
200
  icon: ' 👀'
201
201
  )
202
202
  end
203
- text = is_category ? status.category_name : status.name
204
- "<span title='Category: #{status.category_name}'>#{color_block color.name} #{text}</span>#{visibility}"
203
+ text = is_category ? status.category.name : status.name
204
+ "<span title='Category: #{status.category.name}'>#{color_block color.name} #{text}</span>#{visibility}"
205
205
  end
206
206
 
207
207
  def icon_span title:, icon:
@@ -209,11 +209,11 @@ class ChartBase
209
209
  end
210
210
 
211
211
  def status_category_color status
212
- case status.category_name
213
- when 'To Do' then CssVariable['--status-category-todo-color']
214
- when 'In Progress' then CssVariable['--status-category-inprogress-color']
215
- when 'Done' then CssVariable['--status-category-done-color']
216
- else 'black' # Theoretically impossible but seen in prod.
212
+ case status.category.key
213
+ when 'new' then CssVariable['--status-category-todo-color']
214
+ when 'indeterminate' then CssVariable['--status-category-inprogress-color']
215
+ when 'done' then CssVariable['--status-category-done-color']
216
+ else CssVariable['--status-category-unknown-color'] # Theoretically impossible but seen in prod.
217
217
  end
218
218
  end
219
219
 
@@ -44,18 +44,44 @@ class CycleTimeConfig
44
44
  started_stopped_times(issue).last
45
45
  end
46
46
 
47
- def started_stopped_times issue
47
+ def fabricate_change_item time
48
+ deprecated date: '2024-12-16', message: 'This method should now return a ChangeItem not a Time', depth: 4
49
+ raw = {
50
+ 'field' => 'Fabricated change',
51
+ 'to' => '0',
52
+ 'toString' => '',
53
+ 'from' => '0',
54
+ 'fromString' => ''
55
+ }
56
+ ChangeItem.new raw: raw, time: time, author: 'unknown', artificial: true
57
+ end
58
+
59
+ def started_stopped_changes issue
48
60
  started = @start_at.call(issue)
49
61
  stopped = @stop_at.call(issue)
50
62
 
63
+ # Obscure edge case where some of the start_at and stop_at blocks might return false in place of nil.
64
+ # If they are false then explicitly make them nil.
65
+ started ||= nil
66
+ stopped ||= nil
67
+
68
+ # These are only here for backwards compatibility. Hopefully nobody will ever need them.
69
+ started = fabricate_change_item(started) if !started.nil? && !started.is_a?(ChangeItem)
70
+ stopped = fabricate_change_item(stopped) if !stopped.nil? && !stopped.is_a?(ChangeItem)
71
+
51
72
  # In the case where started and stopped are exactly the same time, we pretend that
52
73
  # it just stopped and never started. This allows us to have logic like 'in or right of'
53
74
  # for the start and not have it conflict.
54
- started = nil if started == stopped
75
+ started = nil if started&.time == stopped&.time
55
76
 
56
77
  [started, stopped]
57
78
  end
58
79
 
80
+ def started_stopped_times issue
81
+ started, stopped = started_stopped_changes(issue)
82
+ [started&.time, stopped&.time]
83
+ end
84
+
59
85
  def started_stopped_dates issue
60
86
  started_time, stopped_time = started_stopped_times(issue)
61
87
  [started_time&.to_date, stopped_time&.to_date]
@@ -41,6 +41,8 @@ class CycletimeHistogram < ChartBase
41
41
  )
42
42
  end
43
43
 
44
+ return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
45
+
44
46
  wrap_and_render(binding, __FILE__)
45
47
  end
46
48
 
@@ -112,8 +112,8 @@ class DataQualityReport < ChartBase
112
112
  @entries.reject { |entry| entry.problems.empty? }
113
113
  end
114
114
 
115
- def category_name_for status_name:, board:
116
- board.possible_statuses.find { |status| status.name == status_name }&.category_name
115
+ def category_name_for status_id:, board:
116
+ board.possible_statuses.find_by_id(status_id)&.category&.name
117
117
  end
118
118
 
119
119
  def initialize_entries
@@ -184,7 +184,7 @@ class DataQualityReport < ChartBase
184
184
  index = entry.issue.board.visible_columns.find_index { |column| column.status_ids.include? change.value_id }
185
185
  if index.nil?
186
186
  # If it's a backlog status then ignore it. Not supposed to be visible.
187
- next if entry.issue.board.backlog_statuses.include? change.value_id
187
+ next if entry.issue.board.backlog_statuses.include?(board.possible_statuses.find_by_id(change.value_id))
188
188
 
189
189
  detail = "Status #{format_status change.value, board: board} is not on the board"
190
190
  if issue.board.possible_statuses.expand_statuses(change.value).empty?
@@ -198,8 +198,8 @@ class DataQualityReport < ChartBase
198
198
  elsif change.old_value.nil?
199
199
  # Do nothing
200
200
  elsif index < last_index
201
- new_category = category_name_for(status_name: change.value, board: board)
202
- old_category = category_name_for(status_name: change.old_value, board: board)
201
+ new_category = category_name_for(status_id: change.value_id, board: board)
202
+ old_category = category_name_for(status_id: change.old_value_id, board: board)
203
203
 
204
204
  if new_category == old_category
205
205
  entry.report(
@@ -365,8 +365,8 @@ class DataQualityReport < ChartBase
365
365
  if percentage_work_included < 85
366
366
  html << <<-HTML
367
367
  Consider whether looking at only #{percentage_work_included}% of the total data points is enough
368
- to come to any reasonable conclusions. See <a href="https://en.wikipedia.org/wiki/Survivorship_bias">
369
- Survivorship Bias</a>.
368
+ to come to any reasonable conclusions. See <a href="https://unconsciousagile.com/2024/11/19/survivor-bias.html">
369
+ Survivor Bias</a>.
370
370
  HTML
371
371
  end
372
372
  html
@@ -20,8 +20,8 @@ class DownloadConfig
20
20
  @rolling_date_count
21
21
  end
22
22
 
23
- def no_earlier_than date = nil
24
- @no_earlier_than = Date.parse(date) unless date.nil?
23
+ def no_earlier_than date = :not_set
24
+ @no_earlier_than = Date.parse(date) unless date == :not_set
25
25
  @no_earlier_than
26
26
  end
27
27
 
@@ -39,6 +39,7 @@ class Downloader
39
39
  # board_ids = @download_config.board_ids
40
40
 
41
41
  remove_old_files
42
+ update_status_history_file
42
43
  download_statuses
43
44
  find_board_ids.each do |id|
44
45
  board = download_board_configuration board_id: id
@@ -66,7 +67,7 @@ class Downloader
66
67
 
67
68
  def download_issues board:
68
69
  log " Downloading primary issues for board #{board.id}", both: true
69
- path = "#{@target_path}#{@download_config.project_config.file_prefix}_issues/"
70
+ path = File.join(@target_path, "#{file_prefix}_issues/")
70
71
  unless Dir.exist?(path)
71
72
  log " Creating path #{path}"
72
73
  Dir.mkdir(path)
@@ -153,30 +154,58 @@ class Downloader
153
154
 
154
155
  @file_system.save_json(
155
156
  json: json,
156
- filename: "#{@target_path}#{@download_config.project_config.file_prefix}_statuses.json"
157
+ filename: File.join(@target_path, "#{file_prefix}_statuses.json")
157
158
  )
158
159
  end
159
160
 
161
+ def update_status_history_file
162
+ status_filename = File.join(@target_path, "#{file_prefix}_statuses.json")
163
+ return unless file_system.file_exist? status_filename
164
+
165
+ status_json = file_system.load_json(status_filename)
166
+
167
+ history_filename = File.join(@target_path, "#{file_prefix}_status_history.json")
168
+ history_json = file_system.load_json(history_filename) if file_system.file_exist? history_filename
169
+
170
+ if history_json
171
+ file_system.log ' Updating status history file', also_write_to_stderr: true
172
+ else
173
+ file_system.log ' Creating status history file', also_write_to_stderr: true
174
+ history_json = []
175
+ end
176
+
177
+ status_json.each do |status_item|
178
+ id = status_item['id']
179
+ history_item = history_json.find { |s| s['id'] == id }
180
+ history_json.delete(history_item) if history_item
181
+ history_json << status_item
182
+ end
183
+
184
+ file_system.save_json(filename: history_filename, json: history_json)
185
+ end
186
+
160
187
  def download_board_configuration board_id:
161
188
  log " Downloading board configuration for board #{board_id}", both: true
162
189
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
163
190
 
164
191
  exit_if_call_failed json
165
192
 
166
- file_prefix = @download_config.project_config.file_prefix
167
- @file_system.save_json json: json, filename: "#{@target_path}#{file_prefix}_board_#{board_id}_configuration.json"
193
+ @file_system.save_json(
194
+ json: json,
195
+ filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_configuration.json")
196
+ )
168
197
 
169
198
  # We have a reported bug that blew up on this line. Moved it after the save so we can
170
199
  # actually look at the returned json.
171
200
  @board_id_to_filter_id[board_id] = json['filter']['id'].to_i
172
201
 
173
202
  download_sprints board_id: board_id if json['type'] == 'scrum'
174
- Board.new raw: json
203
+ # TODO: Should be passing actual statuses, not empty list
204
+ Board.new raw: json, possible_statuses: StatusCollection.new
175
205
  end
176
206
 
177
207
  def download_sprints board_id:
178
208
  log " Downloading sprints for board #{board_id}", both: true
179
- file_prefix = @download_config.project_config.file_prefix
180
209
  max_results = 100
181
210
  start_at = 0
182
211
  is_last = false
@@ -188,7 +217,7 @@ class Downloader
188
217
 
189
218
  @file_system.save_json(
190
219
  json: json,
191
- filename: "#{@target_path}#{file_prefix}_board_#{board_id}_sprints_#{start_at}.json"
220
+ filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_sprints_#{start_at}.json")
192
221
  )
193
222
  is_last = json['isLast']
194
223
  max_results = json['maxResults']
@@ -201,7 +230,7 @@ class Downloader
201
230
  end
202
231
 
203
232
  def metadata_pathname
204
- "#{@target_path}#{@download_config.project_config.file_prefix}_meta.json"
233
+ File.join(@target_path, "#{file_prefix}_meta.json")
205
234
  end
206
235
 
207
236
  def load_metadata
@@ -244,17 +273,17 @@ class Downloader
244
273
  end
245
274
 
246
275
  def remove_old_files
247
- file_prefix = @download_config.project_config.file_prefix
248
276
  Dir.foreach @target_path do |file|
249
277
  next unless file.match?(/^#{file_prefix}_\d+\.json$/)
278
+ next if file == "#{file_prefix}_status_history.json"
250
279
 
251
- File.unlink "#{@target_path}#{file}"
280
+ File.unlink File.join(@target_path, file)
252
281
  end
253
282
 
254
283
  return if @cached_data_format_is_current
255
284
 
256
285
  # Also throw away all the previously downloaded issues.
257
- path = File.join @target_path, "#{file_prefix}_issues"
286
+ path = File.join(@target_path, "#{file_prefix}_issues")
258
287
  return unless File.exist? path
259
288
 
260
289
  Dir.foreach path do |file|
@@ -292,4 +321,8 @@ class Downloader
292
321
 
293
322
  segments.join ' AND '
294
323
  end
324
+
325
+ def file_prefix
326
+ @download_config.project_config.get_file_prefix
327
+ end
295
328
  end
@@ -11,6 +11,7 @@ class Exporter
11
11
  def aggregated_project name:, project_names:, settings: {}
12
12
  project name: name do
13
13
  puts name
14
+ file_prefix name
14
15
  self.settings.merge! settings
15
16
 
16
17
  aggregate do
@@ -19,8 +20,6 @@ class Exporter
19
20
  end
20
21
  end
21
22
 
22
- file_prefix name
23
-
24
23
  file do
25
24
  file_suffix '.html'
26
25
  issues.reject! do |issue|
@@ -32,7 +31,7 @@ class Exporter
32
31
  board_lines = []
33
32
  included_projects.each do |project|
34
33
  project.all_boards.each_value do |board|
35
- board_lines << "<a href='#{project.file_prefix}.html'>#{board.name}</a> from project #{project.name}"
34
+ board_lines << "<a href='#{project.get_file_prefix}.html'>#{board.name}</a> from project #{project.name}"
36
35
  end
37
36
  end
38
37
  board_lines.sort.each { |line| html "<li>#{line}</li>", type: :header }
@@ -10,15 +10,15 @@ class Exporter
10
10
 
11
11
  project name: name do
12
12
  puts name
13
- self.anonymize if anonymize
13
+ file_prefix file_prefix
14
14
 
15
+ self.anonymize if anonymize
15
16
  self.settings.merge! settings
16
17
 
17
18
  status_category_mappings.each do |status, category|
18
19
  status_category_mapping status: status, category: category
19
20
  end
20
21
 
21
- file_prefix file_prefix
22
22
  download do
23
23
  self.rolling_date_count(rolling_date_count) if rolling_date_count
24
24
  self.no_earlier_than(no_earlier_than) if no_earlier_than
@@ -63,7 +63,7 @@ class ExpeditedChart < ChartBase
63
63
  next unless change.priority?
64
64
 
65
65
  if expedited_priority_names.include? change.value
66
- expedite_start = change.time
66
+ expedite_start = change.time.to_date
67
67
  elsif expedite_start
68
68
  start_date = expedite_start.to_date
69
69
  stop_date = change.time.to_date
@@ -72,7 +72,7 @@ class ExpeditedChart < ChartBase
72
72
  (start_date < date_range.begin && stop_date > date_range.end)
73
73
 
74
74
  result << [expedite_start, :expedite_start]
75
- result << [change.time, :expedite_stop]
75
+ result << [change.time.to_date, :expedite_stop]
76
76
  end
77
77
  expedite_start = nil
78
78
  end
@@ -109,11 +109,11 @@ class ExpeditedChart < ChartBase
109
109
 
110
110
  def make_expedite_lines_data_set issue:, expedite_data:
111
111
  cycletime = issue.board.cycletime
112
- started_time, stopped_time = cycletime.started_stopped_times(issue)
112
+ started_date, stopped_date = cycletime.started_stopped_dates(issue)
113
113
 
114
- expedite_data << [started_time, :issue_started] if started_time
115
- expedite_data << [stopped_time, :issue_stopped] if stopped_time
116
- expedite_data.sort_by! { |a| a[0] }
114
+ expedite_data << [started_date, :issue_started] if started_date
115
+ expedite_data << [stopped_date, :issue_stopped] if stopped_date
116
+ expedite_data.sort_by!(&:first)
117
117
 
118
118
  # If none of the data would be visible on the chart then skip it.
119
119
  return nil unless expedite_data.any? { |time, _action| time.to_date >= date_range.begin }
@@ -150,7 +150,7 @@ class ExpeditedChart < ChartBase
150
150
 
151
151
  unless expedite_data.empty?
152
152
  last_change_time = expedite_data[-1][0].to_date
153
- if last_change_time && last_change_time <= date_range.end && stopped_time.nil?
153
+ if last_change_time && last_change_time <= date_range.end && stopped_date.nil?
154
154
  data << make_point(issue: issue, time: date_range.end, label: 'Still ongoing', expedited: expedited)
155
155
  dot_colors << '' # It won't be visible so it doesn't matter
156
156
  point_styles << 'dash'
@@ -3,11 +3,11 @@
3
3
  require 'fileutils'
4
4
 
5
5
  class Object
6
- def deprecated message:, date:
6
+ def deprecated message:, date:, depth: 2
7
7
  text = +''
8
8
  text << "Deprecated(#{date}): "
9
9
  text << message
10
- caller(1..2).each do |line|
10
+ caller(1..depth).each do |line|
11
11
  text << "\n-> Called from #{line}"
12
12
  end
13
13
  warn text
@@ -56,7 +56,7 @@ class FileConfig
56
56
  def output_filename
57
57
  segments = []
58
58
  segments << project_config.target_path
59
- segments << project_config.file_prefix
59
+ segments << project_config.get_file_prefix
60
60
  segments << (@file_suffix || "-#{@today}.csv")
61
61
  segments.join
62
62
  end
@@ -103,7 +103,7 @@ class FileConfig
103
103
  def to_datetime object
104
104
  return nil if object.nil?
105
105
 
106
- object = object.to_datetime
106
+ object = object.to_time.to_datetime
107
107
  object = object.new_offset(@timezone_offset) if @timezone_offset
108
108
  object
109
109
  end
@@ -6,14 +6,18 @@ class FileSystem
6
6
  attr_accessor :logfile, :logfile_name
7
7
 
8
8
  # Effectively the same as File.read except it forces the encoding to UTF-8
9
- def load filename
9
+ def load filename, supress_deprecation: false
10
+ if filename.end_with?('.json') && !supress_deprecation
11
+ deprecated(message: 'call load_json instead', date: '2024-11-13')
12
+ end
13
+
10
14
  File.read filename, encoding: 'UTF-8'
11
15
  end
12
16
 
13
17
  def load_json filename, fail_on_error: true
14
18
  return nil if fail_on_error == false && File.exist?(filename) == false
15
19
 
16
- JSON.parse load(filename)
20
+ JSON.parse load(filename, supress_deprecation: true)
17
21
  end
18
22
 
19
23
  def save_json json:, filename:
@@ -27,6 +31,10 @@ class FileSystem
27
31
  File.write(filename, content)
28
32
  end
29
33
 
34
+ def warning message
35
+ log "Warning: #{message}", also_write_to_stderr: true
36
+ end
37
+
30
38
  def log message, also_write_to_stderr: false
31
39
  logfile.puts message
32
40
  $stderr.puts message if also_write_to_stderr # rubocop:disable Style/StderrPuts
@@ -43,4 +51,12 @@ class FileSystem
43
51
  end
44
52
  node
45
53
  end
54
+
55
+ def foreach root, &block
56
+ Dir.foreach root, &block
57
+ end
58
+
59
+ def file_exist? filename
60
+ File.exist? filename
61
+ end
46
62
  end
@@ -19,6 +19,7 @@
19
19
  --status-category-todo-color: gray;
20
20
  --status-category-inprogress-color: #2663ff;
21
21
  --status-category-done-color: #00ff00;
22
+ --status-category-unknown-color: black;
22
23
 
23
24
  --aging-work-bar-chart-percentage-line-color: red;
24
25
  --aging-work-bar-chart-separator-color: white;
@@ -126,6 +127,12 @@ ul.quality_report {
126
127
  }
127
128
  }
128
129
 
130
+ #footer {
131
+ text-align: center;
132
+ margin-top: 1em;
133
+ border-top: 1px solid gray;
134
+ }
135
+
129
136
  @media screen and (prefers-color-scheme: dark) {
130
137
  :root {
131
138
  --non-working-days-color: #2f2f2f;
@@ -30,9 +30,6 @@
30
30
  </style>
31
31
  </head>
32
32
  <body>
33
- <div>
34
- Page generated <%= (timezone_offset.nil? ? DateTime.now : DateTime.now.new_offset(timezone_offset)).strftime('%Y-%b-%d at %I:%M:%S%P') %>
35
- </div>
36
33
  <%= "\n" + @sections.collect { |text, type| text if type == :header }.compact.join("\n\n") %>
37
34
  <%= "\n" + @sections.collect { |text, type| text if type == :body }.compact.join("\n\n") %>
38
35
  </body>
@@ -67,6 +67,8 @@ class HtmlReportConfig
67
67
  execute_chart DataQualityReport.new(@original_issue_times || {})
68
68
  @sections.rotate!(-1)
69
69
 
70
+ html create_footer
71
+
70
72
  html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
71
73
  css = load_css html_directory: html_directory
72
74
  erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
@@ -202,4 +204,16 @@ class HtmlReportConfig
202
204
  def boards
203
205
  @file_config.project_config.board_configs.collect(&:id).collect { |id| find_board id }
204
206
  end
207
+
208
+ def create_footer now: DateTime.now
209
+ now = now.new_offset(timezone_offset)
210
+ version = Gem.loaded_specs['jirametrics']&.version || 'Next'
211
+
212
+ <<~HTML
213
+ <section id="footer">
214
+ Report generated on <b>#{now.strftime('%Y-%b-%d')}</b> at <b>#{now.strftime('%I:%M:%S%P %Z')}</b>
215
+ with <a href="https://jirametrics.org">JiraMetrics</a> <b>v#{version}</b>
216
+ </section>
217
+ HTML
218
+ end
205
219
  end