jirametrics 2.7.3 → 2.8
Sign up to get free protection for your applications and to get access to all the features.
- 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 +8 -0
- 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 +117 -91
- 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
|
@@ -31,6 +31,10 @@ class FileSystem
|
|
31
31
|
File.write(filename, content)
|
32
32
|
end
|
33
33
|
|
34
|
+
def warning message
|
35
|
+
log "Warning: #{message}", also_write_to_stderr: true
|
36
|
+
end
|
37
|
+
|
34
38
|
def log message, also_write_to_stderr: false
|
35
39
|
logfile.puts message
|
36
40
|
$stderr.puts message if also_write_to_stderr # rubocop:disable Style/StderrPuts
|
@@ -51,4 +55,8 @@ class FileSystem
|
|
51
55
|
def foreach root, &block
|
52
56
|
Dir.foreach root, &block
|
53
57
|
end
|
58
|
+
|
59
|
+
def file_exist? filename
|
60
|
+
File.exist? filename
|
61
|
+
end
|
54
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
|
data/lib/jirametrics/issue.rb
CHANGED
@@ -44,7 +44,7 @@ class Issue
|
|
44
44
|
|
45
45
|
def summary = @raw['fields']['summary']
|
46
46
|
|
47
|
-
def status = Status.
|
47
|
+
def status = Status.from_raw(@raw['fields']['status'])
|
48
48
|
|
49
49
|
def labels = @raw['fields']['labels'] || []
|
50
50
|
|
@@ -66,35 +66,45 @@ class Issue
|
|
66
66
|
end
|
67
67
|
|
68
68
|
def first_time_in_status *status_names
|
69
|
-
@changes.find { |change| change.current_status_matches(*status_names) }
|
69
|
+
@changes.find { |change| change.current_status_matches(*status_names) }
|
70
70
|
end
|
71
71
|
|
72
72
|
def first_time_not_in_status *status_names
|
73
|
-
@changes.find { |change| change.status? && status_names.include?(change.value) == false }
|
73
|
+
@changes.find { |change| change.status? && status_names.include?(change.value) == false }
|
74
74
|
end
|
75
75
|
|
76
76
|
def first_time_in_or_right_of_column column_name
|
77
77
|
first_time_in_status(*board.status_ids_in_or_right_of_column(column_name))
|
78
78
|
end
|
79
79
|
|
80
|
+
def first_time_label_added *labels
|
81
|
+
@changes.each do |change|
|
82
|
+
next unless change.labels?
|
83
|
+
|
84
|
+
change_labels = change.value.split
|
85
|
+
return change if change_labels.any? { |l| labels.include?(l) }
|
86
|
+
end
|
87
|
+
nil
|
88
|
+
end
|
89
|
+
|
80
90
|
def still_in_or_right_of_column column_name
|
81
91
|
still_in_status(*board.status_ids_in_or_right_of_column(column_name))
|
82
92
|
end
|
83
93
|
|
84
94
|
def still_in
|
85
|
-
|
95
|
+
result = nil
|
86
96
|
@changes.each do |change|
|
87
97
|
next unless change.status?
|
88
98
|
|
89
99
|
current_status_matched = yield change
|
90
100
|
|
91
|
-
if current_status_matched &&
|
92
|
-
|
93
|
-
elsif !current_status_matched &&
|
94
|
-
|
101
|
+
if current_status_matched && result.nil?
|
102
|
+
result = change
|
103
|
+
elsif !current_status_matched && result
|
104
|
+
result = nil
|
95
105
|
end
|
96
106
|
end
|
97
|
-
|
107
|
+
result
|
98
108
|
end
|
99
109
|
private :still_in
|
100
110
|
|
@@ -108,8 +118,8 @@ class Issue
|
|
108
118
|
# If it ever entered one of these categories and it's still there then what was the last time it entered
|
109
119
|
def still_in_status_category *category_names
|
110
120
|
still_in do |change|
|
111
|
-
status =
|
112
|
-
category_names.include?(status.
|
121
|
+
status = find_status_by_id change.value_id, name: change.value
|
122
|
+
category_names.include?(status.category.name) || category_names.include?(status.category.id)
|
113
123
|
end
|
114
124
|
end
|
115
125
|
|
@@ -117,48 +127,53 @@ class Issue
|
|
117
127
|
changes.reverse.find { |change| change.status? }
|
118
128
|
end
|
119
129
|
|
120
|
-
# Are we currently in this status? If yes, then return the
|
130
|
+
# Are we currently in this status? If yes, then return the most recent status change.
|
121
131
|
def currently_in_status *status_names
|
122
132
|
change = most_recent_status_change
|
123
133
|
return false if change.nil?
|
124
134
|
|
125
|
-
change
|
135
|
+
change if change.current_status_matches(*status_names)
|
126
136
|
end
|
127
137
|
|
128
|
-
# Are we currently in this status category? If yes, then return the
|
138
|
+
# Are we currently in this status category? If yes, then return the most recent status change.
|
129
139
|
def currently_in_status_category *category_names
|
130
140
|
change = most_recent_status_change
|
131
141
|
return false if change.nil?
|
132
142
|
|
133
|
-
status =
|
134
|
-
change
|
143
|
+
status = find_status_by_id change.value_id, name: change.value
|
144
|
+
change if status && category_names.include?(status.category.name)
|
135
145
|
end
|
136
146
|
|
137
|
-
def
|
138
|
-
status = board.possible_statuses.
|
147
|
+
def find_status_by_id id, name: nil
|
148
|
+
status = board.possible_statuses.find_by_id(id)
|
139
149
|
return status if status
|
140
150
|
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
151
|
+
status = board.possible_statuses.fabricate_status_for id: id, name: name
|
152
|
+
|
153
|
+
message = +'The history for issue '
|
154
|
+
message << key
|
155
|
+
message << ' references a status ('
|
156
|
+
message << name.inspect << ':' if name
|
157
|
+
message << id.to_s
|
158
|
+
message << ') that can\'t be found in ['
|
159
|
+
message << board.possible_statuses.collect(&:to_s).join(', ')
|
160
|
+
message << "]. We are guessing that this belongs to the #{status.category} status category "
|
161
|
+
message << 'and that may be wrong. See https://jirametrics.org/faq/#q1 for more details'
|
162
|
+
board.project_config.file_system.warning message
|
163
|
+
|
149
164
|
status
|
150
165
|
end
|
151
166
|
|
152
167
|
def first_status_change_after_created
|
153
|
-
@changes.find { |change| change.status? && change.artificial? == false }
|
168
|
+
@changes.find { |change| change.status? && change.artificial? == false }
|
154
169
|
end
|
155
170
|
|
156
171
|
def first_time_in_status_category *category_names
|
157
172
|
@changes.each do |change|
|
158
173
|
next unless change.status?
|
159
174
|
|
160
|
-
category =
|
161
|
-
return change
|
175
|
+
category = find_status_by_id(change.value_id).category.name
|
176
|
+
return change if category_names.include? category
|
162
177
|
end
|
163
178
|
nil
|
164
179
|
end
|
@@ -177,11 +192,11 @@ class Issue
|
|
177
192
|
end
|
178
193
|
|
179
194
|
def first_resolution
|
180
|
-
@changes.find { |change| change.resolution? }
|
195
|
+
@changes.find { |change| change.resolution? }
|
181
196
|
end
|
182
197
|
|
183
198
|
def last_resolution
|
184
|
-
@changes.reverse.find { |change| change.resolution? }
|
199
|
+
@changes.reverse.find { |change| change.resolution? }
|
185
200
|
end
|
186
201
|
|
187
202
|
def assigned_to
|
@@ -624,7 +639,7 @@ class Issue
|
|
624
639
|
# This was probably loaded as a linked issue, which means we don't know what board it really
|
625
640
|
# belonged to. The best we can do is look at the status category. This case should be rare but
|
626
641
|
# it can happen.
|
627
|
-
status.
|
642
|
+
status.category.name == 'Done'
|
628
643
|
else
|
629
644
|
board.cycletime.done? self
|
630
645
|
end
|
@@ -32,7 +32,7 @@ class ProjectConfig
|
|
32
32
|
end
|
33
33
|
|
34
34
|
def data_downloaded?
|
35
|
-
File.exist? File.join(@target_path, "#{
|
35
|
+
File.exist? File.join(@target_path, "#{get_file_prefix}_meta.json")
|
36
36
|
end
|
37
37
|
|
38
38
|
def load_data
|
@@ -41,7 +41,6 @@ class ProjectConfig
|
|
41
41
|
@has_loaded_data = true
|
42
42
|
load_all_boards
|
43
43
|
@id = guess_project_id
|
44
|
-
load_status_category_mappings
|
45
44
|
load_project_metadata
|
46
45
|
load_sprints
|
47
46
|
end
|
@@ -116,82 +115,139 @@ class ProjectConfig
|
|
116
115
|
@board_configs << config
|
117
116
|
end
|
118
117
|
|
119
|
-
def file_prefix prefix
|
120
|
-
|
118
|
+
def file_prefix prefix
|
119
|
+
# The file_prefix has to be set before almost everything else. It really should have been an attribute
|
120
|
+
# on the project declaration itself. Hindsight is 20/20.
|
121
|
+
if @file_prefix
|
122
|
+
raise "file_prefix should only be set once. Was #{@file_prefix.inspect} and now changed to #{prefix.inspect}."
|
123
|
+
end
|
124
|
+
|
125
|
+
@file_prefix = prefix
|
126
|
+
|
127
|
+
# Yes, this is a wierd place to be initializing this. Unfortunately, it has to happen after the file_prefix
|
128
|
+
# is set but before anything inside the project block is run. If only we had made file_prefix an attribute
|
129
|
+
# on project, we wouldn't have this ugliness. 🤷♂️
|
130
|
+
load_status_category_mappings
|
131
|
+
|
121
132
|
@file_prefix
|
122
133
|
end
|
123
134
|
|
124
|
-
def
|
125
|
-
|
135
|
+
def get_file_prefix # rubocop:disable Naming/AccessorMethodName
|
136
|
+
raise 'file_prefix has not been set yet. Move it to the top of the project declaration.' unless @file_prefix
|
137
|
+
|
138
|
+
@file_prefix
|
126
139
|
end
|
127
140
|
|
128
|
-
def
|
129
|
-
|
130
|
-
|
141
|
+
def status_guesses
|
142
|
+
statuses = {}
|
143
|
+
issues.each do |issue|
|
144
|
+
issue.changes.each do |change|
|
145
|
+
next unless change.status?
|
131
146
|
|
132
|
-
|
133
|
-
|
147
|
+
(statuses[change.value] ||= Set.new) << change.value_id
|
148
|
+
(statuses[change.old_value] ||= Set.new) << change.old_value_id if change.old_value
|
149
|
+
end
|
134
150
|
end
|
135
|
-
|
151
|
+
statuses
|
136
152
|
end
|
137
153
|
|
138
|
-
def
|
139
|
-
|
140
|
-
|
154
|
+
def status_category_mapping status:, category:
|
155
|
+
# Status might just be a name like "Review" or it might be a name:id pair like "Review:4"
|
156
|
+
parse_status = ->(line) { line =~ /^(.+):(\d+)$/ ? [$1, $2.to_i] : [line, nil] }
|
157
|
+
|
158
|
+
status, status_id = parse_status.call status
|
159
|
+
category, category_id = parse_status.call category
|
160
|
+
|
161
|
+
if status_id.nil?
|
162
|
+
guesses = status_guesses[status]
|
163
|
+
if guesses.nil?
|
164
|
+
file_system.warning "For status_category_mapping status: #{status.inspect}, category: #{category.inspect}\n" \
|
165
|
+
"Cannot guess status id for #{status.inspect} as no statuses found anywhere in the issues " \
|
166
|
+
"histories with that name. Since we can't find it, you probably don't need this mapping anymore so we're " \
|
167
|
+
"going to ignore it. If you really want it, then you'll need to specify a status id."
|
168
|
+
return
|
169
|
+
end
|
170
|
+
|
171
|
+
if guesses.size > 1
|
172
|
+
raise "Cannot guess status id as there are multiple ids for the name #{status.inspect}. Perhaps it's one " \
|
173
|
+
"of #{guesses.to_a.sort.inspect}. If you need this mapping then you must specify the status_id."
|
174
|
+
end
|
175
|
+
|
176
|
+
status_id = guesses.first
|
177
|
+
# TODO: This should be a deprecation instead but we can't currently assert against those
|
178
|
+
file_system.log "status_category_mapping for #{status.inspect} has been mapped to id #{status_id}. " \
|
179
|
+
"If that's incorrect then specify the status_id."
|
180
|
+
end
|
181
|
+
|
182
|
+
found_category = possible_statuses.find_category_by_name category
|
183
|
+
found_category_id = found_category&.id # possible_statuses.find_category_id_by_name category
|
184
|
+
if category_id && category_id != found_category_id
|
185
|
+
raise "ID is incorrect for status category #{category.inspect}. Did you mean #{found_category_id}?"
|
186
|
+
end
|
187
|
+
|
188
|
+
add_possible_status(
|
189
|
+
Status.new(
|
190
|
+
name: status, id: status_id,
|
191
|
+
category_name: category, category_id: found_category_id, category_key: found_category.key
|
192
|
+
)
|
141
193
|
)
|
142
|
-
board.project_config = self
|
143
|
-
@all_boards[board_id] = board
|
144
194
|
end
|
145
195
|
|
146
|
-
def
|
147
|
-
|
148
|
-
message << "Could not determine categories for some of the statuses used in this data set.\n" \
|
149
|
-
"Use the 'status_category_mapping' declaration in your config to manually add one.\n" \
|
150
|
-
'The mappings we do know about are below:'
|
196
|
+
def add_possible_status status
|
197
|
+
existing_status = @possible_statuses.find_by_id status.id
|
151
198
|
|
152
|
-
|
153
|
-
|
199
|
+
if existing_status && existing_status.name != status.name
|
200
|
+
raise "Attempting to redefine the name for status #{status.id} from " \
|
201
|
+
"#{existing_status.name.inspect} to #{status.name.inspect}"
|
154
202
|
end
|
155
203
|
|
156
|
-
|
204
|
+
# If it isn't there, add it and go.
|
205
|
+
return @possible_statuses << status unless existing_status
|
157
206
|
|
158
|
-
|
159
|
-
|
207
|
+
unless status == existing_status
|
208
|
+
raise "Redefining status category for status #{status}. " \
|
209
|
+
"original: #{existing_status.category}, " \
|
210
|
+
"new: #{status.category}"
|
160
211
|
end
|
161
212
|
|
162
|
-
|
213
|
+
# We're registering one we already knew about. This may happen if someone specified a status_category_mapping
|
214
|
+
# for something that was already returned from jira. This isn't problem so just ignore it.
|
215
|
+
existing_status
|
163
216
|
end
|
164
217
|
|
165
|
-
def
|
166
|
-
|
167
|
-
|
168
|
-
issue.changes.each do |change|
|
169
|
-
next unless change.status?
|
218
|
+
def load_all_boards
|
219
|
+
Dir.foreach(@target_path) do |file|
|
220
|
+
next unless file =~ /^#{get_file_prefix}_board_(\d+)_configuration\.json$/
|
170
221
|
|
171
|
-
|
172
|
-
|
222
|
+
board_id = $1.to_i
|
223
|
+
load_board board_id: board_id, filename: "#{@target_path}#{file}"
|
173
224
|
end
|
174
|
-
|
225
|
+
raise "No boards found for #{get_file_prefix.inspect} in #{@target_path.inspect}" if @all_boards.empty?
|
226
|
+
end
|
227
|
+
|
228
|
+
def load_board board_id:, filename:
|
229
|
+
board = Board.new(
|
230
|
+
raw: file_system.load_json(filename), possible_statuses: @possible_statuses
|
231
|
+
)
|
232
|
+
board.project_config = self
|
233
|
+
@all_boards[board_id] = board
|
175
234
|
end
|
176
235
|
|
177
236
|
def load_status_category_mappings
|
178
|
-
filename = "#{
|
237
|
+
filename = File.join @target_path, "#{get_file_prefix}_statuses.json"
|
238
|
+
|
179
239
|
# We may not always have this file. Load it if we can.
|
180
240
|
return unless File.exist? filename
|
181
241
|
|
182
|
-
|
183
|
-
.
|
184
|
-
|
185
|
-
.find_all { |status| status.global? }
|
186
|
-
.each { |status| add_possible_status status }
|
187
|
-
statuses
|
188
|
-
.find_all { |status| status.project_scoped? }
|
242
|
+
file_system
|
243
|
+
.load_json(filename)
|
244
|
+
.map { |snippet| Status.from_raw(snippet) }
|
189
245
|
.each { |status| add_possible_status status }
|
190
246
|
end
|
191
247
|
|
192
248
|
def load_sprints
|
193
249
|
file_system.foreach(@target_path) do |file|
|
194
|
-
next unless file =~ /^#{
|
250
|
+
next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
|
195
251
|
|
196
252
|
file_path = File.join(@target_path, file)
|
197
253
|
board = @all_boards[$1.to_i]
|
@@ -214,56 +270,26 @@ class ProjectConfig
|
|
214
270
|
end
|
215
271
|
end
|
216
272
|
|
217
|
-
def add_possible_status status
|
218
|
-
existing_status = find_status(name: status.name)
|
219
|
-
|
220
|
-
if status.project_scoped?
|
221
|
-
# If the project specific status doesn't change anything then we don't care whether it's
|
222
|
-
# our project or not.
|
223
|
-
return if existing_status && existing_status.category_name == status.category_name
|
224
|
-
|
225
|
-
raise_ambiguous_project_id if @id.nil?
|
226
|
-
|
227
|
-
# Not our project, ignore it.
|
228
|
-
return unless status.project_id == @id
|
229
|
-
|
230
|
-
# Replace the old one with this
|
231
|
-
@possible_statuses.delete(existing_status)
|
232
|
-
@possible_statuses << status
|
233
|
-
return
|
234
|
-
end
|
235
|
-
|
236
|
-
# If it isn't there, add it and go.
|
237
|
-
return @possible_statuses << status unless existing_status
|
238
|
-
|
239
|
-
# We're registering the same one twice. Shouldn't be possible with the new status API but it
|
240
|
-
# did happen with the project specific one.
|
241
|
-
return if status.category_name == existing_status.category_name
|
242
|
-
|
243
|
-
# If we got this far then someone has called status_category_mapping and is attempting to
|
244
|
-
# change the category.
|
245
|
-
raise "Redefining status category #{status} with #{existing_status}. Was one set in the config?"
|
246
|
-
end
|
247
|
-
|
248
|
-
def raise_ambiguous_project_id
|
249
|
-
raise 'Ambiguous project id: There is a project specific status that could affect out calculations. ' \
|
250
|
-
'We are unable to automatically detect the id of the project so you will have to set it manually ' \
|
251
|
-
'in the configuration like: "project id: 5"'
|
252
|
-
end
|
253
|
-
|
254
|
-
def find_status name:
|
255
|
-
@possible_statuses.find_by_name name
|
256
|
-
end
|
257
|
-
|
258
273
|
def load_project_metadata
|
259
|
-
filename = File.join @target_path, "#{
|
274
|
+
filename = File.join @target_path, "#{get_file_prefix}_meta.json"
|
260
275
|
json = file_system.load_json(filename)
|
261
276
|
|
262
277
|
@data_version = json['version'] || 1
|
263
278
|
|
264
|
-
start = json['date_start'] || json['time_start'] # date_start is the current format. Time is the old.
|
265
|
-
stop = json['date_end'] || json['time_end']
|
266
|
-
|
279
|
+
start = to_time(json['date_start'] || json['time_start']) # date_start is the current format. Time is the old.
|
280
|
+
stop = to_time(json['date_end'] || json['time_end'], end_of_day: true)
|
281
|
+
|
282
|
+
# If no_earlier_than was set then make sure it's applied here.
|
283
|
+
if download_config
|
284
|
+
download_config.run
|
285
|
+
no_earlier = download_config.no_earlier_than
|
286
|
+
if no_earlier
|
287
|
+
no_earlier = to_time(no_earlier.to_s)
|
288
|
+
start = no_earlier if start < no_earlier
|
289
|
+
end
|
290
|
+
end
|
291
|
+
|
292
|
+
@time_range = start..stop
|
267
293
|
|
268
294
|
@jira_url = json['jira_url']
|
269
295
|
rescue Errno::ENOENT
|
@@ -328,7 +354,7 @@ class ProjectConfig
|
|
328
354
|
|
329
355
|
timezone_offset = exporter.timezone_offset
|
330
356
|
|
331
|
-
issues_path = File.join @target_path, "#{
|
357
|
+
issues_path = File.join @target_path, "#{get_file_prefix}_issues"
|
332
358
|
if File.exist?(issues_path) && File.directory?(issues_path)
|
333
359
|
issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
|
334
360
|
else
|
@@ -172,7 +172,7 @@ class SprintBurndown < ChartBase
|
|
172
172
|
change_item.raw['to'].split(/\s*,\s*/).any? { |id| id.to_i == sprint.id }
|
173
173
|
end
|
174
174
|
|
175
|
-
def data_set_by_story_points sprint:, change_data_for_sprint:
|
175
|
+
def data_set_by_story_points sprint:, change_data_for_sprint: # rubocop:disable Metrics/CyclomaticComplexity
|
176
176
|
summary_stats = SprintSummaryStats.new
|
177
177
|
summary_stats.completed = 0.0
|
178
178
|
|
data/lib/jirametrics/status.rb
CHANGED
@@ -4,29 +4,57 @@ require 'jirametrics/value_equality'
|
|
4
4
|
|
5
5
|
class Status
|
6
6
|
include ValueEquality
|
7
|
-
attr_reader :id, :
|
7
|
+
attr_reader :id, :project_id, :category
|
8
8
|
attr_accessor :name
|
9
9
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
10
|
+
class Category
|
11
|
+
attr_reader :id, :name, :key
|
12
|
+
|
13
|
+
def initialize id:, name:, key:
|
14
|
+
@id = id
|
15
|
+
@name = name
|
16
|
+
@key = key
|
17
|
+
end
|
16
18
|
|
17
|
-
|
19
|
+
def to_s
|
20
|
+
"#{name.inspect}:#{id.inspect}"
|
21
|
+
end
|
18
22
|
|
19
|
-
|
20
|
-
|
21
|
-
|
23
|
+
def new? = (@key == 'new')
|
24
|
+
def indeterminate? = (@key == 'indeterminate')
|
25
|
+
def done? = (@key == 'done')
|
26
|
+
end
|
22
27
|
|
28
|
+
def self.from_raw raw
|
23
29
|
category_config = raw['statusCategory']
|
24
|
-
@category_name = category_config['name']
|
25
|
-
@category_id = category_config['id'].to_i
|
26
30
|
|
27
|
-
|
28
|
-
|
29
|
-
|
31
|
+
legal_keys = %w[new indeterminate done]
|
32
|
+
unless legal_keys.include? category_config['key']
|
33
|
+
puts "Category key #{category_config['key'].inspect} should be one of #{legal_keys.inspect}. Found:\n" \
|
34
|
+
"#{category_config}"
|
35
|
+
end
|
36
|
+
|
37
|
+
Status.new(
|
38
|
+
name: raw['name'],
|
39
|
+
id: raw['id'].to_i,
|
40
|
+
category_name: category_config['name'],
|
41
|
+
category_id: category_config['id'].to_i,
|
42
|
+
category_key: category_config['key'],
|
43
|
+
project_id: raw['scope']&.[]('project')&.[]('id'),
|
44
|
+
artificial: false
|
45
|
+
)
|
46
|
+
end
|
47
|
+
|
48
|
+
def initialize name:, id:, category_name:, category_id:, category_key:, project_id: nil, artificial: true
|
49
|
+
# These checks are needed because nils used to be possible and now they aren't.
|
50
|
+
raise 'id cannot be nil' if id.nil?
|
51
|
+
raise 'category_id cannot be nil' if category_id.nil?
|
52
|
+
|
53
|
+
@name = name
|
54
|
+
@id = id
|
55
|
+
@category = Category.new id: category_id, name: category_name, key: category_key
|
56
|
+
@project_id = project_id
|
57
|
+
@artificial = artificial
|
30
58
|
end
|
31
59
|
|
32
60
|
def project_scoped?
|
@@ -38,18 +66,26 @@ class Status
|
|
38
66
|
end
|
39
67
|
|
40
68
|
def to_s
|
41
|
-
|
42
|
-
" id=#{@id.inspect}," \
|
43
|
-
" category_name=#{@category_name.inspect}," \
|
44
|
-
" category_id=#{@category_id.inspect}," \
|
45
|
-
" project_id=#{@project_id}"
|
46
|
-
result << ' artificial' if artificial?
|
47
|
-
result << ')'
|
48
|
-
result
|
69
|
+
"#{name.inspect}:#{id.inspect}"
|
49
70
|
end
|
50
71
|
|
51
72
|
def artificial?
|
52
|
-
@
|
73
|
+
@artificial
|
74
|
+
end
|
75
|
+
|
76
|
+
def == other
|
77
|
+
@id == other.id && @name == other.name && @category.id == other.category.id && @category.name == other.category.name
|
78
|
+
end
|
79
|
+
|
80
|
+
def inspect
|
81
|
+
result = []
|
82
|
+
result << "Status(name: #{@name.inspect}"
|
83
|
+
result << "id: #{@id.inspect}"
|
84
|
+
result << "project_id: #{@project_id}" if @project_id
|
85
|
+
category = self.category
|
86
|
+
result << "category: {name:#{category.name.inspect}, id: #{category.id.inspect}, key: #{category.key.inspect}}"
|
87
|
+
result << 'artificial' if artificial?
|
88
|
+
result.join(', ') << ')'
|
53
89
|
end
|
54
90
|
|
55
91
|
def value_equality_ignored_variables
|
@@ -13,7 +13,7 @@ class StatusCollection
|
|
13
13
|
excluding = expand_statuses excluding
|
14
14
|
|
15
15
|
@list.filter_map do |status|
|
16
|
-
keep = status.
|
16
|
+
keep = status.category.name == category_name ||
|
17
17
|
including.any? { |s| s.name == status.name }
|
18
18
|
keep = false if excluding.any? { |s| s.name == status.name }
|
19
19
|
|
@@ -56,12 +56,45 @@ class StatusCollection
|
|
56
56
|
filter_status_names category_name: 'Done', including: including, excluding: excluding
|
57
57
|
end
|
58
58
|
|
59
|
-
|
60
|
-
|
59
|
+
# Return the status matching this id or nil if it can't be found.
|
60
|
+
def find_by_id id
|
61
|
+
@list.find { |status| status.id == id }
|
62
|
+
end
|
63
|
+
|
64
|
+
def find_all_by_name name
|
65
|
+
@list.select { |status| status.name == name }
|
66
|
+
end
|
67
|
+
|
68
|
+
def find_category_by_name name
|
69
|
+
category = @list.find { |status| status.category.name == name }&.category
|
70
|
+
unless category
|
71
|
+
set = Set.new
|
72
|
+
@list.each do |status|
|
73
|
+
set << status.category.to_s
|
74
|
+
end
|
75
|
+
raise "Unable to find status category #{name.inspect} in [#{set.to_a.sort.join(', ')}]"
|
76
|
+
end
|
77
|
+
category
|
78
|
+
end
|
79
|
+
|
80
|
+
# This is used to create a status that was found in the history but has since been deleted.
|
81
|
+
def fabricate_status_for id:, name:
|
82
|
+
first_in_progress_status = @list.find { |s| s.category.indeterminate? }
|
83
|
+
raise "Can't find even one in-progress status in [#{set.to_a.sort.join(', ')}]" unless first_in_progress_status
|
84
|
+
|
85
|
+
status = Status.new(
|
86
|
+
name: name,
|
87
|
+
id: id,
|
88
|
+
category_name: first_in_progress_status.category.name,
|
89
|
+
category_id: first_in_progress_status.category.id,
|
90
|
+
category_key: first_in_progress_status.category.key
|
91
|
+
)
|
92
|
+
self << status
|
93
|
+
status
|
61
94
|
end
|
62
95
|
|
63
|
-
def find(&block)= @list.find(&block)
|
64
96
|
def collect(&block) = @list.collect(&block)
|
97
|
+
def find(&block) = @list.find(&block)
|
65
98
|
def each(&block) = @list.each(&block)
|
66
99
|
def select(&block) = @list.select(&block)
|
67
100
|
def <<(arg) = @list << arg
|
@@ -70,6 +103,6 @@ class StatusCollection
|
|
70
103
|
def delete(object) = @list.delete(object)
|
71
104
|
|
72
105
|
def inspect
|
73
|
-
"StatusCollection(#{@list.
|
106
|
+
"StatusCollection(#{@list.join(', ')})"
|
74
107
|
end
|
75
108
|
end
|
@@ -82,7 +82,7 @@ class ThroughputChart < ChartBase
|
|
82
82
|
def throughput_dataset periods:, completed_issues:
|
83
83
|
periods.collect do |period|
|
84
84
|
closed_issues = completed_issues.filter_map do |issue|
|
85
|
-
stop_date = issue.board.cycletime.
|
85
|
+
stop_date = issue.board.cycletime.started_stopped_dates(issue).last
|
86
86
|
[stop_date, issue] if stop_date && period.include?(stop_date)
|
87
87
|
end
|
88
88
|
|
data/lib/jirametrics.rb
CHANGED
@@ -7,6 +7,13 @@ class JiraMetrics < Thor
|
|
7
7
|
true
|
8
8
|
end
|
9
9
|
|
10
|
+
map %w[--version -v] => :__print_version
|
11
|
+
|
12
|
+
desc '--version, -v', 'print the version'
|
13
|
+
def __print_version
|
14
|
+
puts Gem.loaded_specs['jirametrics'].version
|
15
|
+
end
|
16
|
+
|
10
17
|
option :config
|
11
18
|
option :name
|
12
19
|
desc 'export', "Export data into either reports or CSV's as per the configuration"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: jirametrics
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: '2.8'
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Mike Bowler
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-12-30 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: random-word
|