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 +4 -4
- data/lib/jirametrics/aggregate_config.rb +3 -3
- data/lib/jirametrics/aging_work_bar_chart.rb +1 -1
- data/lib/jirametrics/aging_work_in_progress_chart.rb +1 -1
- data/lib/jirametrics/board.rb +1 -1
- data/lib/jirametrics/change_item.rb +11 -5
- data/lib/jirametrics/chart_base.rb +11 -11
- data/lib/jirametrics/cycletime_config.rb +28 -2
- data/lib/jirametrics/cycletime_histogram.rb +2 -0
- data/lib/jirametrics/data_quality_report.rb +7 -7
- data/lib/jirametrics/download_config.rb +2 -2
- data/lib/jirametrics/downloader.rb +44 -11
- data/lib/jirametrics/examples/aggregated_project.rb +2 -3
- data/lib/jirametrics/examples/standard_project.rb +2 -2
- data/lib/jirametrics/expedited_chart.rb +7 -7
- data/lib/jirametrics/exporter.rb +2 -2
- data/lib/jirametrics/file_config.rb +2 -2
- data/lib/jirametrics/file_system.rb +18 -2
- data/lib/jirametrics/html/index.css +7 -0
- data/lib/jirametrics/html/index.erb +0 -3
- data/lib/jirametrics/html_report_config.rb +14 -0
- data/lib/jirametrics/issue.rb +47 -32
- data/lib/jirametrics/project_config.rb +143 -97
- data/lib/jirametrics/sprint_burndown.rb +1 -1
- data/lib/jirametrics/status.rb +61 -25
- data/lib/jirametrics/status_collection.rb +38 -5
- data/lib/jirametrics/throughput_chart.rb +1 -1
- data/lib/jirametrics.rb +7 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f5bec8084c47e2383d25676b969891c6fc8e427d06427a1caf195378a830ff4f
|
|
4
|
+
data.tar.gz: e9364c2b43f66d90a651f50e115751c144f8dfc6f7d247b47dfd17495bdf167e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
90
|
-
@project_config.exporter.file_system
|
|
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.
|
|
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.
|
data/lib/jirametrics/board.rb
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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 =
|
|
13
|
-
@value =
|
|
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 =
|
|
173
|
-
@header_text = 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 =
|
|
178
|
-
@description_text = 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.
|
|
204
|
-
"<span title='Category: #{status.
|
|
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.
|
|
213
|
-
when '
|
|
214
|
-
when '
|
|
215
|
-
when '
|
|
216
|
-
else '
|
|
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
|
|
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]
|
|
@@ -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
|
|
116
|
-
board.possible_statuses.
|
|
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?
|
|
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(
|
|
202
|
-
old_category = category_name_for(
|
|
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://
|
|
369
|
-
|
|
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 =
|
|
24
|
-
@no_earlier_than = Date.parse(date) unless date
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
112
|
+
started_date, stopped_date = cycletime.started_stopped_dates(issue)
|
|
113
113
|
|
|
114
|
-
expedite_data << [
|
|
115
|
-
expedite_data << [
|
|
116
|
-
expedite_data.sort_by!
|
|
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 &&
|
|
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'
|
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -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..
|
|
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.
|
|
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
|