jirametrics 2.4 → 2.11
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 +9 -4
- data/lib/jirametrics/aging_work_bar_chart.rb +13 -11
- data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
- data/lib/jirametrics/aging_work_table.rb +54 -7
- data/lib/jirametrics/blocked_stalled_change.rb +1 -1
- data/lib/jirametrics/board.rb +44 -15
- data/lib/jirametrics/board_config.rb +7 -3
- data/lib/jirametrics/board_movement_calculator.rb +147 -0
- data/lib/jirametrics/change_item.rb +19 -6
- data/lib/jirametrics/chart_base.rb +63 -27
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cycletime_config.rb +59 -8
- data/lib/jirametrics/cycletime_histogram.rb +68 -3
- data/lib/jirametrics/cycletime_scatterplot.rb +3 -6
- data/lib/jirametrics/daily_wip_by_age_chart.rb +2 -4
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +2 -2
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +0 -4
- data/lib/jirametrics/daily_wip_chart.rb +7 -9
- data/lib/jirametrics/data_quality_report.rb +219 -41
- data/lib/jirametrics/dependency_chart.rb +37 -10
- data/lib/jirametrics/download_config.rb +12 -0
- data/lib/jirametrics/downloader.rb +68 -50
- data/lib/jirametrics/estimate_accuracy_chart.rb +1 -2
- data/lib/jirametrics/examples/aggregated_project.rb +7 -21
- data/lib/jirametrics/examples/standard_project.rb +18 -34
- data/lib/jirametrics/expedited_chart.rb +8 -9
- data/lib/jirametrics/exporter.rb +28 -11
- data/lib/jirametrics/file_config.rb +23 -6
- data/lib/jirametrics/file_system.rb +39 -3
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
- data/lib/jirametrics/groupable_issue_chart.rb +1 -3
- data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
- data/lib/jirametrics/html/aging_work_table.erb +6 -4
- data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
- data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
- data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
- data/lib/jirametrics/html/expedited_chart.erb +1 -10
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
- data/lib/jirametrics/html/hierarchy_table.erb +1 -1
- data/lib/jirametrics/html/index.css +28 -5
- data/lib/jirametrics/html/index.erb +8 -4
- data/lib/jirametrics/html/sprint_burndown.erb +1 -10
- data/lib/jirametrics/html/throughput_chart.erb +1 -10
- data/lib/jirametrics/html_report_config.rb +33 -23
- data/lib/jirametrics/issue.rb +232 -47
- data/lib/jirametrics/jira_gateway.rb +16 -3
- data/lib/jirametrics/project_config.rb +245 -134
- data/lib/jirametrics/rules.rb +2 -2
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +5 -2
- data/lib/jirametrics/sprint_burndown.rb +3 -3
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +77 -39
- data/lib/jirametrics/throughput_chart.rb +1 -1
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics.rb +22 -6
- metadata +10 -13
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
|
@@ -5,16 +5,15 @@ require 'jirametrics/self_or_issue_dispatcher'
|
|
|
5
5
|
|
|
6
6
|
class HtmlReportConfig
|
|
7
7
|
include SelfOrIssueDispatcher
|
|
8
|
-
include DiscardChangesBefore
|
|
9
8
|
|
|
10
|
-
attr_reader :file_config, :sections
|
|
9
|
+
attr_reader :file_config, :sections, :charts
|
|
11
10
|
|
|
12
11
|
def self.define_chart name:, classname:, deprecated_warning: nil, deprecated_date: nil
|
|
13
12
|
lines = []
|
|
14
13
|
lines << "def #{name} &block"
|
|
15
14
|
lines << ' block = ->(_) {} unless block'
|
|
16
15
|
if deprecated_warning
|
|
17
|
-
lines << " deprecated date: #{deprecated_date.inspect}, message: #{deprecated_warning.inspect}"
|
|
16
|
+
lines << " file_system.deprecated date: #{deprecated_date.inspect}, message: #{deprecated_warning.inspect}"
|
|
18
17
|
end
|
|
19
18
|
lines << " execute_chart #{classname}.new(block)"
|
|
20
19
|
lines << 'end'
|
|
@@ -33,6 +32,7 @@ class HtmlReportConfig
|
|
|
33
32
|
define_chart name: 'cycletime_histogram', classname: 'CycletimeHistogram'
|
|
34
33
|
define_chart name: 'estimate_accuracy_chart', classname: 'EstimateAccuracyChart'
|
|
35
34
|
define_chart name: 'hierarchy_table', classname: 'HierarchyTable'
|
|
35
|
+
define_chart name: 'flow_efficiency_scatterplot', classname: 'FlowEfficiencyScatterplot'
|
|
36
36
|
|
|
37
37
|
define_chart name: 'daily_wip_by_type', classname: 'DailyWipChart',
|
|
38
38
|
deprecated_warning: 'This is the same as daily_wip_chart. Please use that one', deprecated_date: '2024-05-23'
|
|
@@ -42,14 +42,15 @@ class HtmlReportConfig
|
|
|
42
42
|
def initialize file_config:, block:
|
|
43
43
|
@file_config = file_config
|
|
44
44
|
@block = block
|
|
45
|
-
@sections = []
|
|
45
|
+
@sections = [] # Where we store the chunks of text that will be assembled into the HTML
|
|
46
|
+
@charts = [] # Where we store all the charts we executed so we can assert against them.
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
def cycletime label = nil, &block
|
|
49
50
|
@file_config.project_config.all_boards.each_value do |board|
|
|
50
51
|
raise 'Multiple cycletimes not supported' if board.cycletime
|
|
51
52
|
|
|
52
|
-
board.cycletime = CycleTimeConfig.new(parent_config: self, label: label, block: block)
|
|
53
|
+
board.cycletime = CycleTimeConfig.new(parent_config: self, label: label, block: block, file_system: file_system)
|
|
53
54
|
end
|
|
54
55
|
end
|
|
55
56
|
|
|
@@ -63,9 +64,11 @@ class HtmlReportConfig
|
|
|
63
64
|
|
|
64
65
|
# The quality report has to be generated last because otherwise cycletime won't have been
|
|
65
66
|
# set. Then we have to rotate it to the first position so it's at the top of the report.
|
|
66
|
-
execute_chart DataQualityReport.new(
|
|
67
|
+
execute_chart DataQualityReport.new(file_config.project_config.discarded_changes_data)
|
|
67
68
|
@sections.rotate!(-1)
|
|
68
69
|
|
|
70
|
+
html create_footer
|
|
71
|
+
|
|
69
72
|
html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
|
|
70
73
|
css = load_css html_directory: html_directory
|
|
71
74
|
erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
|
|
@@ -98,9 +101,8 @@ class HtmlReportConfig
|
|
|
98
101
|
base_css
|
|
99
102
|
end
|
|
100
103
|
|
|
101
|
-
def board_id id
|
|
102
|
-
@board_id = id
|
|
103
|
-
@board_id
|
|
104
|
+
def board_id id
|
|
105
|
+
@board_id = id
|
|
104
106
|
end
|
|
105
107
|
|
|
106
108
|
def timezone_offset
|
|
@@ -140,19 +142,6 @@ class HtmlReportConfig
|
|
|
140
142
|
end
|
|
141
143
|
end
|
|
142
144
|
|
|
143
|
-
def discard_changes_before_hook issues_cutoff_times
|
|
144
|
-
# raise 'Cycletime must be defined before using discard_changes_before' unless @cycletime
|
|
145
|
-
|
|
146
|
-
@original_issue_times = {}
|
|
147
|
-
issues_cutoff_times.each do |issue, cutoff_time|
|
|
148
|
-
started = issue.board.cycletime.started_time(issue)
|
|
149
|
-
if started && started <= cutoff_time
|
|
150
|
-
# We only need to log this if data was discarded
|
|
151
|
-
@original_issue_times[issue] = { cutoff_time: cutoff_time, started_time: started }
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
145
|
def dependency_chart &block
|
|
157
146
|
execute_chart DependencyChart.new block
|
|
158
147
|
end
|
|
@@ -172,7 +161,7 @@ class HtmlReportConfig
|
|
|
172
161
|
chart.settings = settings
|
|
173
162
|
|
|
174
163
|
chart.all_boards = project_config.all_boards
|
|
175
|
-
chart.board_id = find_board_id
|
|
164
|
+
chart.board_id = find_board_id
|
|
176
165
|
chart.holiday_dates = project_config.exporter.holiday_dates
|
|
177
166
|
|
|
178
167
|
time_range = @file_config.project_config.time_range
|
|
@@ -181,6 +170,7 @@ class HtmlReportConfig
|
|
|
181
170
|
|
|
182
171
|
after_init_block&.call chart
|
|
183
172
|
|
|
173
|
+
@charts << chart
|
|
184
174
|
html chart.run
|
|
185
175
|
end
|
|
186
176
|
|
|
@@ -201,4 +191,24 @@ class HtmlReportConfig
|
|
|
201
191
|
def boards
|
|
202
192
|
@file_config.project_config.board_configs.collect(&:id).collect { |id| find_board id }
|
|
203
193
|
end
|
|
194
|
+
|
|
195
|
+
def create_footer now: DateTime.now
|
|
196
|
+
now = now.new_offset(timezone_offset)
|
|
197
|
+
version = Gem.loaded_specs['jirametrics']&.version || 'Next'
|
|
198
|
+
|
|
199
|
+
<<~HTML
|
|
200
|
+
<section id="footer">
|
|
201
|
+
Report generated on <b>#{now.strftime('%Y-%b-%d')}</b> at <b>#{now.strftime('%I:%M:%S%P %Z')}</b>
|
|
202
|
+
with <a href="https://jirametrics.org">JiraMetrics</a> <b>v#{version}</b>
|
|
203
|
+
</section>
|
|
204
|
+
HTML
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def discard_changes_before status_becomes: nil, &block
|
|
208
|
+
file_system.deprecated(
|
|
209
|
+
date: '2025-01-09',
|
|
210
|
+
message: 'discard_changes_before is now only supported at the project level'
|
|
211
|
+
)
|
|
212
|
+
file_config.project_config.discard_changes_before status_becomes: status_becomes, &block
|
|
213
|
+
end
|
|
204
214
|
end
|
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -13,6 +13,12 @@ class Issue
|
|
|
13
13
|
@changes = []
|
|
14
14
|
@board = board
|
|
15
15
|
|
|
16
|
+
# We only check for this here because if a board isn't passed in then things will fail much
|
|
17
|
+
# later and be hard to find. Let's find out early.
|
|
18
|
+
raise "No board for issue #{key}" if board.nil?
|
|
19
|
+
|
|
20
|
+
# There are cases where we create an Issue of fragments like linked issues and those won't have
|
|
21
|
+
# changelogs.
|
|
16
22
|
return unless @raw['changelog']
|
|
17
23
|
|
|
18
24
|
load_history_into_changes
|
|
@@ -43,14 +49,26 @@ class Issue
|
|
|
43
49
|
|
|
44
50
|
def summary = @raw['fields']['summary']
|
|
45
51
|
|
|
46
|
-
def status = Status.new(raw: @raw['fields']['status'])
|
|
47
|
-
|
|
48
52
|
def labels = @raw['fields']['labels'] || []
|
|
49
53
|
|
|
50
54
|
def author = @raw['fields']['creator']&.[]('displayName') || ''
|
|
51
55
|
|
|
52
56
|
def resolution = @raw['fields']['resolution']&.[]('name')
|
|
53
57
|
|
|
58
|
+
def status
|
|
59
|
+
@status = Status.from_raw(@raw['fields']['status']) unless @status
|
|
60
|
+
@status
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def status= status
|
|
64
|
+
@status = status
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def due_date
|
|
68
|
+
text = @raw['fields']['duedate']
|
|
69
|
+
text.nil? ? nil : Date.parse(text)
|
|
70
|
+
end
|
|
71
|
+
|
|
54
72
|
def url
|
|
55
73
|
# Strangely, the URL isn't anywhere in the returned data so we have to fabricate it.
|
|
56
74
|
"#{@board.server_url_prefix}/browse/#{key}"
|
|
@@ -65,35 +83,43 @@ class Issue
|
|
|
65
83
|
end
|
|
66
84
|
|
|
67
85
|
def first_time_in_status *status_names
|
|
68
|
-
@changes.find { |change| change.current_status_matches(*status_names) }
|
|
86
|
+
@changes.find { |change| change.current_status_matches(*status_names) }
|
|
69
87
|
end
|
|
70
88
|
|
|
71
89
|
def first_time_not_in_status *status_names
|
|
72
|
-
@changes.find { |change| change.status? && status_names.include?(change.value) == false }
|
|
90
|
+
@changes.find { |change| change.status? && status_names.include?(change.value) == false }
|
|
73
91
|
end
|
|
74
92
|
|
|
75
93
|
def first_time_in_or_right_of_column column_name
|
|
76
94
|
first_time_in_status(*board.status_ids_in_or_right_of_column(column_name))
|
|
77
95
|
end
|
|
78
96
|
|
|
97
|
+
def first_time_label_added *labels
|
|
98
|
+
@changes.each do |change|
|
|
99
|
+
next unless change.labels?
|
|
100
|
+
|
|
101
|
+
change_labels = change.value.split
|
|
102
|
+
return change if change_labels.any? { |l| labels.include?(l) }
|
|
103
|
+
end
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
79
107
|
def still_in_or_right_of_column column_name
|
|
80
108
|
still_in_status(*board.status_ids_in_or_right_of_column(column_name))
|
|
81
109
|
end
|
|
82
110
|
|
|
83
111
|
def still_in
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
next unless change.status?
|
|
87
|
-
|
|
112
|
+
result = nil
|
|
113
|
+
status_changes.each do |change|
|
|
88
114
|
current_status_matched = yield change
|
|
89
115
|
|
|
90
|
-
if current_status_matched &&
|
|
91
|
-
|
|
92
|
-
elsif !current_status_matched &&
|
|
93
|
-
|
|
116
|
+
if current_status_matched && result.nil?
|
|
117
|
+
result = change
|
|
118
|
+
elsif !current_status_matched && result
|
|
119
|
+
result = nil
|
|
94
120
|
end
|
|
95
121
|
end
|
|
96
|
-
|
|
122
|
+
result
|
|
97
123
|
end
|
|
98
124
|
private :still_in
|
|
99
125
|
|
|
@@ -106,50 +132,75 @@ class Issue
|
|
|
106
132
|
|
|
107
133
|
# If it ever entered one of these categories and it's still there then what was the last time it entered
|
|
108
134
|
def still_in_status_category *category_names
|
|
135
|
+
category_ids = find_status_category_ids_by_names category_names
|
|
136
|
+
|
|
109
137
|
still_in do |change|
|
|
110
|
-
status =
|
|
111
|
-
|
|
138
|
+
status = find_or_create_status id: change.value_id, name: change.value
|
|
139
|
+
category_ids.include? status.category.id
|
|
112
140
|
end
|
|
113
141
|
end
|
|
114
142
|
|
|
115
143
|
def most_recent_status_change
|
|
116
|
-
|
|
144
|
+
# Any issue that we loaded from its own file will always have a status as we artificially insert a status
|
|
145
|
+
# change to represent creation. Issues that were just fragments referenced from a main issue (ie a linked issue)
|
|
146
|
+
# may not have any status changes as we have no idea when it was created. This will be nil in that case
|
|
147
|
+
status_changes.last
|
|
117
148
|
end
|
|
118
149
|
|
|
119
|
-
# Are we currently in this status? If yes, then return the
|
|
150
|
+
# Are we currently in this status? If yes, then return the most recent status change.
|
|
120
151
|
def currently_in_status *status_names
|
|
121
152
|
change = most_recent_status_change
|
|
122
153
|
return false if change.nil?
|
|
123
154
|
|
|
124
|
-
change
|
|
155
|
+
change if change.current_status_matches(*status_names)
|
|
125
156
|
end
|
|
126
157
|
|
|
127
|
-
# Are we currently in this status category? If yes, then return the
|
|
158
|
+
# Are we currently in this status category? If yes, then return the most recent status change.
|
|
128
159
|
def currently_in_status_category *category_names
|
|
160
|
+
category_ids = find_status_category_ids_by_names category_names
|
|
161
|
+
|
|
129
162
|
change = most_recent_status_change
|
|
130
163
|
return false if change.nil?
|
|
131
164
|
|
|
132
|
-
status =
|
|
133
|
-
change
|
|
165
|
+
status = find_or_create_status id: change.value_id, name: change.value
|
|
166
|
+
change if status && category_ids.include?(status.category.id)
|
|
134
167
|
end
|
|
135
168
|
|
|
136
|
-
def
|
|
137
|
-
status = board.possible_statuses.
|
|
138
|
-
return status if status
|
|
169
|
+
def find_or_create_status id:, name:
|
|
170
|
+
status = board.possible_statuses.find_by_id(id)
|
|
139
171
|
|
|
140
|
-
|
|
172
|
+
unless status
|
|
173
|
+
# Have to pull this list before the call to fabricate or else the warning will incorrectly
|
|
174
|
+
# list this status as one it actually found
|
|
175
|
+
found_statuses = board.possible_statuses.to_s
|
|
176
|
+
|
|
177
|
+
status = board.possible_statuses.fabricate_status_for id: id, name: name
|
|
178
|
+
|
|
179
|
+
message = +'The history for issue '
|
|
180
|
+
message << key
|
|
181
|
+
message << ' references the status ('
|
|
182
|
+
message << "#{name.inspect}:#{id.inspect}"
|
|
183
|
+
message << ') that can\'t be found. We are guessing that this belongs to the '
|
|
184
|
+
message << status.category.to_s
|
|
185
|
+
message << ' status category but that may be wrong. See https://jirametrics.org/faq/#q1 for more '
|
|
186
|
+
message << 'details on defining statuses.'
|
|
187
|
+
board.project_config.file_system.warning message, more: "The statuses we did find are: #{found_statuses}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
status
|
|
141
191
|
end
|
|
142
192
|
|
|
143
193
|
def first_status_change_after_created
|
|
144
|
-
|
|
194
|
+
status_changes.find { |change| change.artificial? == false }
|
|
145
195
|
end
|
|
146
196
|
|
|
147
197
|
def first_time_in_status_category *category_names
|
|
148
|
-
|
|
149
|
-
next unless change.status?
|
|
198
|
+
category_ids = find_status_category_ids_by_names category_names
|
|
150
199
|
|
|
151
|
-
|
|
152
|
-
|
|
200
|
+
status_changes.each do |change|
|
|
201
|
+
to_status = find_or_create_status(id: change.value_id, name: change.value)
|
|
202
|
+
id = to_status.category.id
|
|
203
|
+
return change if category_ids.include? id
|
|
153
204
|
end
|
|
154
205
|
nil
|
|
155
206
|
end
|
|
@@ -168,11 +219,11 @@ class Issue
|
|
|
168
219
|
end
|
|
169
220
|
|
|
170
221
|
def first_resolution
|
|
171
|
-
@changes.find { |change| change.resolution? }
|
|
222
|
+
@changes.find { |change| change.resolution? }
|
|
172
223
|
end
|
|
173
224
|
|
|
174
225
|
def last_resolution
|
|
175
|
-
@changes.reverse.find { |change| change.resolution? }
|
|
226
|
+
@changes.reverse.find { |change| change.resolution? }
|
|
176
227
|
end
|
|
177
228
|
|
|
178
229
|
def assigned_to
|
|
@@ -251,6 +302,7 @@ class Issue
|
|
|
251
302
|
|
|
252
303
|
blocked_link_texts = settings['blocked_link_text']
|
|
253
304
|
stalled_threshold = settings['stalled_threshold_days']
|
|
305
|
+
flagged_means_blocked = !!settings['flagged_means_blocked'] # rubocop:disable Style/DoubleNegation
|
|
254
306
|
|
|
255
307
|
blocking_issue_keys = []
|
|
256
308
|
|
|
@@ -273,7 +325,7 @@ class Issue
|
|
|
273
325
|
blocking_stalled_changes: result
|
|
274
326
|
)
|
|
275
327
|
|
|
276
|
-
if change.flagged?
|
|
328
|
+
if change.flagged? && flagged_means_blocked
|
|
277
329
|
flag = change.value
|
|
278
330
|
flag = nil if change.value == ''
|
|
279
331
|
elsif change.status?
|
|
@@ -362,6 +414,45 @@ class Issue
|
|
|
362
414
|
inserted_stalled
|
|
363
415
|
end
|
|
364
416
|
|
|
417
|
+
# return [number of active seconds, total seconds] that this issue had up to the end_time.
|
|
418
|
+
# It does not include data before issue start or after issue end
|
|
419
|
+
def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
|
|
420
|
+
issue_start, issue_stop = @board.cycletime.started_stopped_times(self)
|
|
421
|
+
return [0.0, 0.0] if !issue_start || issue_start > end_time
|
|
422
|
+
|
|
423
|
+
value_add_time = 0.0
|
|
424
|
+
end_time = issue_stop if issue_stop && issue_stop < end_time
|
|
425
|
+
|
|
426
|
+
active_start = nil
|
|
427
|
+
blocked_stalled_changes(end_time: end_time, settings: settings).each_with_index do |change, index|
|
|
428
|
+
break if change.time > end_time
|
|
429
|
+
|
|
430
|
+
if index.zero?
|
|
431
|
+
active_start = change.time if change.active?
|
|
432
|
+
next
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Already active and we just got another active.
|
|
436
|
+
next if active_start && change.active?
|
|
437
|
+
|
|
438
|
+
if change.active?
|
|
439
|
+
active_start = change.time
|
|
440
|
+
elsif active_start && change.time >= issue_start
|
|
441
|
+
# Not active now but we have been. Record the active time.
|
|
442
|
+
change_delta = change.time - [issue_start, active_start].max
|
|
443
|
+
value_add_time += change_delta
|
|
444
|
+
active_start = nil
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
if active_start
|
|
449
|
+
change_delta = end_time - [issue_start, active_start].max
|
|
450
|
+
value_add_time += change_delta if change_delta.positive?
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
[value_add_time, end_time - issue_start]
|
|
454
|
+
end
|
|
455
|
+
|
|
365
456
|
def all_subtask_activity_times
|
|
366
457
|
subtask_activity_times = []
|
|
367
458
|
@subtasks.each do |subtask|
|
|
@@ -371,7 +462,9 @@ class Issue
|
|
|
371
462
|
end
|
|
372
463
|
|
|
373
464
|
def expedited?
|
|
374
|
-
|
|
465
|
+
return false unless @board&.project_config
|
|
466
|
+
|
|
467
|
+
names = @board.project_config.settings['expedited_priority_names']
|
|
375
468
|
return false unless names
|
|
376
469
|
|
|
377
470
|
current_priority = raw['fields']['priority']&.[]('name')
|
|
@@ -380,7 +473,9 @@ class Issue
|
|
|
380
473
|
|
|
381
474
|
def expedited_on_date? date
|
|
382
475
|
expedited_start = nil
|
|
383
|
-
|
|
476
|
+
return false unless @board&.project_config
|
|
477
|
+
|
|
478
|
+
expedited_names = @board.project_config.settings['expedited_priority_names']
|
|
384
479
|
|
|
385
480
|
changes.each do |change|
|
|
386
481
|
next unless change.priority?
|
|
@@ -433,6 +528,10 @@ class Issue
|
|
|
433
528
|
@fix_versions
|
|
434
529
|
end
|
|
435
530
|
|
|
531
|
+
def looks_like_issue_key? key
|
|
532
|
+
!!(key.is_a?(String) && key =~ /^[^-]+-\d+$/)
|
|
533
|
+
end
|
|
534
|
+
|
|
436
535
|
def parent_key project_config: @board.project_config
|
|
437
536
|
# Although Atlassian is trying to standardize on one way to determine the parent, today it's a mess.
|
|
438
537
|
# We try a variety of ways to get the parent and hopefully one of them will work. See this link:
|
|
@@ -454,8 +553,13 @@ class Issue
|
|
|
454
553
|
|
|
455
554
|
custom_field_names&.each do |field_name|
|
|
456
555
|
parent = fields[field_name]
|
|
457
|
-
|
|
458
|
-
|
|
556
|
+
next if parent.nil?
|
|
557
|
+
break if looks_like_issue_key? parent
|
|
558
|
+
|
|
559
|
+
project_config.file_system.log(
|
|
560
|
+
"Custom field #{field_name.inspect} should point to a parent id but found #{parent.inspect}"
|
|
561
|
+
)
|
|
562
|
+
parent = nil
|
|
459
563
|
end
|
|
460
564
|
end
|
|
461
565
|
|
|
@@ -480,6 +584,20 @@ class Issue
|
|
|
480
584
|
comparison
|
|
481
585
|
end
|
|
482
586
|
|
|
587
|
+
def discard_changes_before cutoff_time
|
|
588
|
+
rejected_any = false
|
|
589
|
+
@changes.reject! do |change|
|
|
590
|
+
reject = change.status? && change.time <= cutoff_time && change.artificial? == false
|
|
591
|
+
if reject
|
|
592
|
+
(@discarded_changes ||= []) << change
|
|
593
|
+
rejected_any = true
|
|
594
|
+
end
|
|
595
|
+
reject
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
(@discarded_change_times ||= []) << cutoff_time if rejected_any
|
|
599
|
+
end
|
|
600
|
+
|
|
483
601
|
def dump
|
|
484
602
|
result = +''
|
|
485
603
|
result << "#{key} (#{type}): #{compact_text summary, 200}\n"
|
|
@@ -487,24 +605,82 @@ class Issue
|
|
|
487
605
|
assignee = raw['fields']['assignee']
|
|
488
606
|
result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
|
|
489
607
|
|
|
490
|
-
raw['fields']['issuelinks']
|
|
608
|
+
raw['fields']['issuelinks']&.each do |link|
|
|
491
609
|
result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
|
|
492
610
|
result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
|
|
493
611
|
end
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
612
|
+
history = [] # time, type, detail
|
|
613
|
+
|
|
614
|
+
started_at, stopped_at = board.cycletime.started_stopped_times(self)
|
|
615
|
+
history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
|
|
616
|
+
history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
|
|
497
617
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
618
|
+
@discarded_change_times&.each do |time|
|
|
619
|
+
history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
(changes + (@discarded_changes || [])).each do |change|
|
|
623
|
+
if change.status?
|
|
624
|
+
value = "#{change.value.inspect}:#{change.value_id.inspect}"
|
|
625
|
+
old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
|
|
626
|
+
else
|
|
627
|
+
value = compact_text(change.value).inspect
|
|
628
|
+
old_value = change.old_value ? compact_text(change.old_value).inspect : nil
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
message = +''
|
|
632
|
+
message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
|
|
633
|
+
message << value
|
|
634
|
+
if change.artificial?
|
|
635
|
+
message << ' (Artificial entry)' if change.artificial?
|
|
636
|
+
else
|
|
637
|
+
message << " (Author: #{change.author})"
|
|
638
|
+
end
|
|
639
|
+
history << [change.time, change.field, message, change.artificial?]
|
|
504
640
|
end
|
|
641
|
+
|
|
642
|
+
result << " History:\n"
|
|
643
|
+
type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
|
|
644
|
+
history.sort! do |a, b|
|
|
645
|
+
if a[0] == b[0]
|
|
646
|
+
if a[1].nil?
|
|
647
|
+
1
|
|
648
|
+
elsif b[1].nil?
|
|
649
|
+
-1
|
|
650
|
+
else
|
|
651
|
+
a[1] <=> b[1]
|
|
652
|
+
end
|
|
653
|
+
else
|
|
654
|
+
a[0] <=> b[0]
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
history.each do |time, type, detail, _artificial|
|
|
658
|
+
if type.nil?
|
|
659
|
+
type = '-' * type_width
|
|
660
|
+
else
|
|
661
|
+
type = (' ' * (type_width - type.length)) << type
|
|
662
|
+
end
|
|
663
|
+
result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
|
|
664
|
+
end
|
|
665
|
+
|
|
505
666
|
result
|
|
506
667
|
end
|
|
507
668
|
|
|
669
|
+
def done?
|
|
670
|
+
if artificial? || board.cycletime.nil?
|
|
671
|
+
# This was probably loaded as a linked issue, which means we don't know what board it really
|
|
672
|
+
# belonged to. The best we can do is look at the status category. This case should be rare but
|
|
673
|
+
# it can happen.
|
|
674
|
+
status.category.name == 'Done'
|
|
675
|
+
else
|
|
676
|
+
board.cycletime.done? self
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def status_changes
|
|
681
|
+
@changes.select { |change| change.status? }
|
|
682
|
+
end
|
|
683
|
+
|
|
508
684
|
private
|
|
509
685
|
|
|
510
686
|
def assemble_author raw
|
|
@@ -580,4 +756,13 @@ class Issue
|
|
|
580
756
|
'toString' => first_status
|
|
581
757
|
}
|
|
582
758
|
end
|
|
759
|
+
|
|
760
|
+
def find_status_category_ids_by_names category_names
|
|
761
|
+
category_names.filter_map do |name|
|
|
762
|
+
list = board.possible_statuses.find_all_categories_by_name name
|
|
763
|
+
raise "No status categories found for name: #{name}" if list.empty?
|
|
764
|
+
|
|
765
|
+
list
|
|
766
|
+
end.flatten.collect(&:id)
|
|
767
|
+
end
|
|
583
768
|
end
|
|
@@ -14,9 +14,15 @@ class JiraGateway
|
|
|
14
14
|
def call_url relative_url:
|
|
15
15
|
command = make_curl_command url: "#{@jira_url}#{relative_url}"
|
|
16
16
|
result = call_command command
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
begin
|
|
18
|
+
json = JSON.parse(result)
|
|
19
|
+
rescue # rubocop:disable Style/RescueStandardError
|
|
20
|
+
raise "Error when parsing result: #{result.inspect}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
raise "Download failed with: #{JSON.pretty_generate(json)}" unless json_successful?(json)
|
|
24
|
+
|
|
25
|
+
json
|
|
20
26
|
end
|
|
21
27
|
|
|
22
28
|
def call_command command
|
|
@@ -61,4 +67,11 @@ class JiraGateway
|
|
|
61
67
|
command << " --url \"#{url}\""
|
|
62
68
|
command
|
|
63
69
|
end
|
|
70
|
+
|
|
71
|
+
def json_successful? json
|
|
72
|
+
return false if json.is_a?(Hash) && (json['error'] || json['errorMessages'] || json['errorMessage'])
|
|
73
|
+
return false if json.is_a?(Array) && json.first == 'errorMessage'
|
|
74
|
+
|
|
75
|
+
true
|
|
76
|
+
end
|
|
64
77
|
end
|