jirametrics 2.7.3 → 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 +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
|