jirametrics 2.4 → 2.30
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/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/aggregate_config.rb +16 -3
- data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
- data/lib/jirametrics/aging_work_table.rb +63 -19
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +160 -0
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +6 -4
- data/lib/jirametrics/board.rb +74 -22
- data/lib/jirametrics/board_config.rb +11 -3
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +54 -18
- data/lib/jirametrics/chart_base.rb +203 -30
- data/lib/jirametrics/css_variable.rb +2 -2
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/cycle_time_config.rb +137 -0
- data/lib/jirametrics/cycletime_histogram.rb +17 -38
- data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
- data/lib/jirametrics/daily_view.rb +306 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
- data/lib/jirametrics/daily_wip_chart.rb +36 -16
- data/lib/jirametrics/data_quality_report.rb +251 -42
- data/lib/jirametrics/dependency_chart.rb +42 -12
- data/lib/jirametrics/download_config.rb +27 -0
- data/lib/jirametrics/downloader.rb +185 -110
- data/lib/jirametrics/downloader_for_cloud.rb +287 -0
- data/lib/jirametrics/downloader_for_data_center.rb +95 -0
- data/lib/jirametrics/estimate_accuracy_chart.rb +75 -14
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +9 -23
- data/lib/jirametrics/examples/standard_project.rb +57 -58
- data/lib/jirametrics/expedited_chart.rb +11 -10
- data/lib/jirametrics/exporter.rb +51 -14
- data/lib/jirametrics/file_config.rb +21 -6
- data/lib/jirametrics/file_system.rb +96 -4
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +12 -4
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
- data/lib/jirametrics/html/aging_work_table.erb +13 -4
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +41 -15
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +7 -24
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
- data/lib/jirametrics/html/hierarchy_table.erb +1 -1
- data/lib/jirametrics/html/index.css +336 -62
- data/lib/jirametrics/html/index.erb +16 -21
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +18 -25
- data/lib/jirametrics/html/throughput_chart.erb +43 -21
- data/lib/jirametrics/html/time_based_histogram.erb +123 -0
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +32 -0
- data/lib/jirametrics/html_report_config.rb +83 -76
- data/lib/jirametrics/issue.rb +499 -91
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +96 -16
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +374 -130
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +17 -0
- data/lib/jirametrics/rules.rb +2 -2
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +10 -2
- data/lib/jirametrics/sprint.rb +13 -0
- data/lib/jirametrics/sprint_burndown.rb +47 -39
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +83 -38
- data/lib/jirametrics/stitcher.rb +81 -0
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +73 -23
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +107 -0
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +101 -66
- metadata +72 -16
- data/lib/jirametrics/cycletime_config.rb +0 -69
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
|
@@ -4,11 +4,9 @@ require 'time'
|
|
|
4
4
|
require 'jirametrics/status_collection'
|
|
5
5
|
|
|
6
6
|
class ProjectConfig
|
|
7
|
-
include DiscardChangesBefore
|
|
8
|
-
|
|
9
7
|
attr_reader :target_path, :jira_config, :all_boards, :possible_statuses,
|
|
10
8
|
:download_config, :file_configs, :exporter, :data_version, :name, :board_configs,
|
|
11
|
-
:settings, :aggregate_config
|
|
9
|
+
:settings, :aggregate_config, :discarded_changes_data, :users, :fix_versions
|
|
12
10
|
attr_accessor :time_range, :jira_url, :id
|
|
13
11
|
|
|
14
12
|
def initialize exporter:, jira_config:, block:, target_path: '.', name: '', id: nil
|
|
@@ -24,32 +22,59 @@ class ProjectConfig
|
|
|
24
22
|
@all_boards = {}
|
|
25
23
|
@settings = load_settings
|
|
26
24
|
@id = id
|
|
25
|
+
@has_loaded_data = false
|
|
26
|
+
@fix_versions = []
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def evaluate_next_level
|
|
30
30
|
instance_eval(&@block) if @block
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
def data_downloaded?
|
|
34
|
+
file_system.file_exist? File.join(@target_path, "#{get_file_prefix}_meta.json")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def load_data
|
|
38
|
+
return if @has_loaded_data
|
|
39
|
+
|
|
40
|
+
@has_loaded_data = true
|
|
41
|
+
@id = guess_project_id
|
|
42
|
+
load_project_metadata
|
|
43
|
+
load_sprints
|
|
44
|
+
load_fix_versions
|
|
45
|
+
load_users
|
|
46
|
+
resolve_blocked_stalled_status_settings
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def run load_only: false
|
|
50
|
+
return if @exporter.downloading?
|
|
51
|
+
|
|
52
|
+
load_data unless aggregated_project?
|
|
53
|
+
|
|
54
|
+
return if load_only
|
|
55
|
+
|
|
41
56
|
anonymize_data if @anonymizer_needed
|
|
42
57
|
|
|
43
|
-
@board_configs.each do |board_config|
|
|
44
|
-
board_config.run
|
|
45
|
-
end
|
|
46
58
|
@file_configs.each do |file_config|
|
|
47
59
|
file_config.run
|
|
48
60
|
end
|
|
49
61
|
end
|
|
50
62
|
|
|
51
63
|
def load_settings
|
|
52
|
-
|
|
64
|
+
# This is the weird exception that we don't ever want mocked out so we skip FileSystem entirely.
|
|
65
|
+
settings = JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
|
|
66
|
+
|
|
67
|
+
if settings['blocked_color']
|
|
68
|
+
file_system.deprecated message: 'blocked color should be set via css now', date: '2024-05-03'
|
|
69
|
+
end
|
|
70
|
+
if settings['stalled_color']
|
|
71
|
+
file_system.deprecated message: 'stalled color should be set via css now', date: '2024-05-03'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
settings['blocked_statuses'] = StatusCollection.new
|
|
75
|
+
settings['stalled_statuses'] = StatusCollection.new
|
|
76
|
+
|
|
77
|
+
stringify_keys(settings)
|
|
53
78
|
end
|
|
54
79
|
|
|
55
80
|
def guess_project_id
|
|
@@ -73,6 +98,12 @@ class ProjectConfig
|
|
|
73
98
|
!!@aggregate_config
|
|
74
99
|
end
|
|
75
100
|
|
|
101
|
+
def aggregate_project_names
|
|
102
|
+
return [] unless aggregated_project?
|
|
103
|
+
|
|
104
|
+
@aggregate_config.included_projects.filter_map(&:name)
|
|
105
|
+
end
|
|
106
|
+
|
|
76
107
|
def download &block
|
|
77
108
|
raise 'Not allowed to have multiple download blocks in one project' if @download_config
|
|
78
109
|
raise 'Not allowed to have both an aggregate and a download section. Pick only one.' if @aggregate_config
|
|
@@ -98,90 +129,226 @@ class ProjectConfig
|
|
|
98
129
|
|
|
99
130
|
def board id:, &block
|
|
100
131
|
config = BoardConfig.new(id: id, block: block, project_config: self)
|
|
132
|
+
config.run if data_downloaded?
|
|
101
133
|
@board_configs << config
|
|
102
134
|
end
|
|
103
135
|
|
|
104
|
-
def file_prefix prefix
|
|
105
|
-
|
|
136
|
+
def file_prefix prefix
|
|
137
|
+
# The file_prefix has to be set before almost everything else. It really should have been an attribute
|
|
138
|
+
# on the project declaration itself. Hindsight is 20/20.
|
|
139
|
+
|
|
140
|
+
# There can only be one of these
|
|
141
|
+
if @file_prefix
|
|
142
|
+
raise "file_prefix can only be set once. Was #{@file_prefix.inspect} and now changed to #{prefix.inspect}."
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
raise_if_prefix_already_used(prefix)
|
|
146
|
+
|
|
147
|
+
@file_prefix = prefix
|
|
148
|
+
|
|
149
|
+
# Yes, this is a wierd place to be initializing this. Unfortunately, it has to happen after the file_prefix
|
|
150
|
+
# is set but before anything inside the project block is run. If only we had made file_prefix an attribute
|
|
151
|
+
# on project, we wouldn't have this ugliness. 🤷♂️
|
|
152
|
+
load_status_category_mappings
|
|
153
|
+
load_status_history
|
|
154
|
+
load_all_boards
|
|
155
|
+
|
|
106
156
|
@file_prefix
|
|
107
157
|
end
|
|
108
158
|
|
|
109
|
-
def
|
|
110
|
-
|
|
159
|
+
def validate_discard_status status_name
|
|
160
|
+
return if status_name == :backlog
|
|
161
|
+
return if possible_statuses.empty? # not yet downloaded; skip validation
|
|
162
|
+
|
|
163
|
+
found = possible_statuses.find_all_by_name status_name
|
|
164
|
+
return unless found.empty?
|
|
165
|
+
|
|
166
|
+
raise "discard_changes_before: Status #{status_name.inspect} not found. " \
|
|
167
|
+
"Possible statuses are: #{possible_statuses}"
|
|
111
168
|
end
|
|
112
169
|
|
|
113
|
-
def
|
|
114
|
-
|
|
115
|
-
next unless
|
|
170
|
+
def raise_if_prefix_already_used prefix
|
|
171
|
+
@exporter.project_configs.each do |project|
|
|
172
|
+
next unless project.get_file_prefix(raise_if_not_set: false) == prefix && project.target_path == target_path
|
|
116
173
|
|
|
117
|
-
|
|
118
|
-
|
|
174
|
+
raise "Project #{name.inspect} specifies file prefix #{prefix.inspect}, " \
|
|
175
|
+
"but that is already used by project #{project.name.inspect} in the same target path #{target_path.inspect}. " \
|
|
176
|
+
'This is almost guaranteed to be too much copy and paste in your configuration. ' \
|
|
177
|
+
'File prefixes must be unique within a directory.'
|
|
119
178
|
end
|
|
120
|
-
raise "No boards found for #{@file_prefix} in #{@target_path.inspect}" if @all_boards.empty?
|
|
121
179
|
end
|
|
122
180
|
|
|
123
|
-
def
|
|
124
|
-
|
|
125
|
-
|
|
181
|
+
def get_file_prefix raise_if_not_set: true
|
|
182
|
+
if @file_prefix.nil? && raise_if_not_set
|
|
183
|
+
raise 'file_prefix has not been set yet. Move it to the top of the project declaration.'
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
@file_prefix
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Walk across all the issues and find any status with that name. Return a list of ids that match.
|
|
190
|
+
def find_ids_by_status_name_across_all_issues name
|
|
191
|
+
ids = Set.new
|
|
192
|
+
|
|
193
|
+
issues.each do |issue|
|
|
194
|
+
issue.changes.each do |change|
|
|
195
|
+
next unless change.status?
|
|
196
|
+
|
|
197
|
+
ids << change.value_id.to_i if change.value == name
|
|
198
|
+
ids << change.old_value_id.to_i if change.old_value == name
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
ids.to_a
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def status_category_mapping status:, category:
|
|
205
|
+
return if @exporter.downloading?
|
|
206
|
+
|
|
207
|
+
status, status_id = possible_statuses.parse_name_id status
|
|
208
|
+
category, category_id = possible_statuses.parse_name_id category
|
|
209
|
+
|
|
210
|
+
if status_id.nil?
|
|
211
|
+
guesses = find_ids_by_status_name_across_all_issues status
|
|
212
|
+
if guesses.empty?
|
|
213
|
+
file_system.warning "For status_category_mapping status: #{status.inspect}, category: #{category.inspect}\n" \
|
|
214
|
+
"Cannot guess status id for #{status.inspect} as no statuses found anywhere in the issues " \
|
|
215
|
+
"histories with that name. Since we can't find it, you probably don't need this mapping anymore so we're " \
|
|
216
|
+
"going to ignore it. If you really want it, then you'll need to specify a status id."
|
|
217
|
+
return
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
if guesses.size > 1
|
|
221
|
+
raise "Cannot guess status id as there are multiple ids for the name #{status.inspect}. Perhaps it's one " \
|
|
222
|
+
"of #{guesses.to_a.sort.inspect}. If you need this mapping then you must specify the status_id."
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
status_id = guesses.first
|
|
226
|
+
file_system.log "status_category_mapping for #{status.inspect} has been mapped to id #{status_id}. " \
|
|
227
|
+
"If that's incorrect then specify the status_id."
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
possible_categories = possible_statuses.find_all_categories_by_name category
|
|
231
|
+
if possible_categories.empty?
|
|
232
|
+
all = possible_statuses.find_all_categories.join(', ')
|
|
233
|
+
raise "No status categories found for name #{category.inspect} in [#{all}]. " \
|
|
234
|
+
'Either fix the name or add an ID.'
|
|
235
|
+
elsif possible_categories.size > 1
|
|
236
|
+
# Theoretically impossible and yet we've seen wierder things out of Jira so we're prepared.
|
|
237
|
+
raise "More than one status category found with the name #{category.inspect} in " \
|
|
238
|
+
"[#{possible_categories.join(', ')}]. Either fix the name or add an ID"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
found_category = possible_categories.first
|
|
242
|
+
|
|
243
|
+
if category_id && category_id != found_category.id
|
|
244
|
+
raise "ID is incorrect for status category #{category.inspect}. Did you mean #{found_category.id}?"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
add_possible_status(
|
|
248
|
+
Status.new(
|
|
249
|
+
name: status, id: status_id,
|
|
250
|
+
category_name: category, category_id: found_category.id, category_key: found_category.key
|
|
251
|
+
)
|
|
126
252
|
)
|
|
127
|
-
board.project_config = self
|
|
128
|
-
@all_boards[board_id] = board
|
|
129
253
|
end
|
|
130
254
|
|
|
131
|
-
def
|
|
132
|
-
|
|
133
|
-
message << "Could not determine categories for some of the statuses used in this data set.\n" \
|
|
134
|
-
"Use the 'status_category_mapping' declaration in your config to manually add one.\n" \
|
|
135
|
-
'The mappings we do know about are below:'
|
|
255
|
+
def add_possible_status status
|
|
256
|
+
existing_status = @possible_statuses.find_by_id status.id
|
|
136
257
|
|
|
137
|
-
|
|
138
|
-
|
|
258
|
+
if existing_status && existing_status.name != status.name
|
|
259
|
+
raise "Attempting to redefine the name for status #{status.id} from " \
|
|
260
|
+
"#{existing_status.name.inspect} to #{status.name.inspect}"
|
|
139
261
|
end
|
|
140
262
|
|
|
141
|
-
|
|
263
|
+
# If it isn't there, add it and go.
|
|
264
|
+
return @possible_statuses << status unless existing_status
|
|
142
265
|
|
|
143
|
-
|
|
144
|
-
|
|
266
|
+
unless status == existing_status
|
|
267
|
+
raise "Redefining status category for status #{status}. " \
|
|
268
|
+
"original: #{existing_status.category}, " \
|
|
269
|
+
"new: #{status.category}"
|
|
145
270
|
end
|
|
146
271
|
|
|
147
|
-
|
|
272
|
+
# We're registering one we already knew about. This may happen if someone specified a status_category_mapping
|
|
273
|
+
# for something that was already returned from jira.
|
|
274
|
+
#
|
|
275
|
+
# You may be looking at this code and thinking of changing it to spit out a warning since obviously
|
|
276
|
+
# the user has made a mistake. Unfortunately, they may not have made any mistake. Due to inconsistency with the
|
|
277
|
+
# status API, it's possible for two different people to make a request to the same API at the same time and get
|
|
278
|
+
# back a different set of statuses. So that means that some people might need more status/categories mappings than
|
|
279
|
+
# other people for exactly the same instance. See this article for more on that API:
|
|
280
|
+
# https://agiletechnicalexcellence.com/2024/04/12/jira-api-statuses.html
|
|
281
|
+
existing_status
|
|
148
282
|
end
|
|
149
283
|
|
|
150
|
-
def
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
issue.changes.each do |change|
|
|
154
|
-
next unless change.status?
|
|
284
|
+
def load_all_boards
|
|
285
|
+
Dir.foreach(@target_path) do |file|
|
|
286
|
+
next unless file =~ /^#{get_file_prefix}_board_(\d+)_configuration\.json$/
|
|
155
287
|
|
|
156
|
-
|
|
157
|
-
|
|
288
|
+
board_id = $1.to_i
|
|
289
|
+
load_board board_id: board_id, filename: "#{@target_path}#{file}"
|
|
158
290
|
end
|
|
159
|
-
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def load_board board_id:, filename:
|
|
294
|
+
raw = file_system.load_json(filename)
|
|
295
|
+
|
|
296
|
+
features_filename = File.join(@target_path, "#{get_file_prefix}_board_#{board_id}_features.json")
|
|
297
|
+
features = if file_system.file_exist?(features_filename)
|
|
298
|
+
BoardFeature.from_raw(file_system.load_json(features_filename))
|
|
299
|
+
else
|
|
300
|
+
[]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
board = Board.new(raw: raw, possible_statuses: @possible_statuses, features: features)
|
|
304
|
+
board.project_config = self
|
|
305
|
+
@all_boards[board_id] = board
|
|
160
306
|
end
|
|
161
307
|
|
|
162
308
|
def load_status_category_mappings
|
|
163
|
-
filename = "#{
|
|
164
|
-
|
|
165
|
-
return unless File.exist? filename
|
|
309
|
+
filename = File.join @target_path, "#{get_file_prefix}_statuses.json"
|
|
310
|
+
return unless file_system.file_exist? filename
|
|
166
311
|
|
|
167
|
-
|
|
168
|
-
.
|
|
169
|
-
|
|
170
|
-
.find_all { |status| status.global? }
|
|
171
|
-
.each { |status| add_possible_status status }
|
|
172
|
-
statuses
|
|
173
|
-
.find_all { |status| status.project_scoped? }
|
|
312
|
+
file_system
|
|
313
|
+
.load_json(filename)
|
|
314
|
+
.map { |snippet| Status.from_raw(snippet) }
|
|
174
315
|
.each { |status| add_possible_status status }
|
|
175
316
|
end
|
|
176
317
|
|
|
318
|
+
def load_status_history
|
|
319
|
+
filename = File.join @target_path, "#{get_file_prefix}_status_history.json"
|
|
320
|
+
return unless file_system.file_exist? filename
|
|
321
|
+
|
|
322
|
+
file_system.log ' Loading historical statuses', also_write_to_stderr: true
|
|
323
|
+
file_system
|
|
324
|
+
.load_json(filename)
|
|
325
|
+
.map { |snippet| Status.from_raw(snippet) }
|
|
326
|
+
.each { |status| possible_statuses.historical_status_mappings[status.to_s] = status.category }
|
|
327
|
+
|
|
328
|
+
possible_statuses
|
|
329
|
+
rescue => e # rubocop:disable Style/RescueStandardError
|
|
330
|
+
file_system.warning "Unable to load status history due to #{e.message.inspect}. If this is because of a " \
|
|
331
|
+
'malformed file then it should be fixed on the next download.'
|
|
332
|
+
end
|
|
333
|
+
|
|
177
334
|
def load_sprints
|
|
178
|
-
|
|
179
|
-
next unless file =~
|
|
335
|
+
file_system.foreach(@target_path) do |file|
|
|
336
|
+
next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
|
|
180
337
|
|
|
181
338
|
board_id = $1.to_i
|
|
339
|
+
file_path = File.join(@target_path, file)
|
|
340
|
+
board = @all_boards[board_id]
|
|
341
|
+
unless board
|
|
342
|
+
@exporter.file_system.log(
|
|
343
|
+
'Found sprint data but can\'t find a matching board in config. ' \
|
|
344
|
+
"File: #{file_path}, Boards: #{@all_boards.keys.sort}"
|
|
345
|
+
)
|
|
346
|
+
next
|
|
347
|
+
end
|
|
348
|
+
|
|
182
349
|
timezone_offset = exporter.timezone_offset
|
|
183
|
-
|
|
184
|
-
|
|
350
|
+
file_system.load_json(file_path)['values']&.each do |json|
|
|
351
|
+
board.sprints << Sprint.new(raw: json, timezone_offset: timezone_offset)
|
|
185
352
|
end
|
|
186
353
|
end
|
|
187
354
|
|
|
@@ -190,61 +357,66 @@ class ProjectConfig
|
|
|
190
357
|
end
|
|
191
358
|
end
|
|
192
359
|
|
|
193
|
-
def
|
|
194
|
-
|
|
360
|
+
def load_fix_versions
|
|
361
|
+
filename = File.join(@target_path, "#{get_file_prefix}_fix_versions.json")
|
|
362
|
+
return unless file_system.file_exist?(filename)
|
|
195
363
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
# our project or not.
|
|
199
|
-
return if existing_status && existing_status.category_name == status.category_name
|
|
364
|
+
@fix_versions = file_system.load_json(filename).map { |raw| FixVersion.new(raw) }
|
|
365
|
+
end
|
|
200
366
|
|
|
201
|
-
|
|
367
|
+
def load_project_metadata
|
|
368
|
+
filename = File.join @target_path, "#{get_file_prefix}_meta.json"
|
|
369
|
+
json = file_system.load_json(filename)
|
|
202
370
|
|
|
203
|
-
|
|
204
|
-
return unless status.project_id == @id
|
|
371
|
+
@data_version = json['version'] || 1
|
|
205
372
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
@possible_statuses << status
|
|
209
|
-
return
|
|
210
|
-
end
|
|
373
|
+
start = to_time(json['date_start'] || json['time_start']) # date_start is the current format. Time is the old.
|
|
374
|
+
stop = to_time(json['date_end'] || json['time_end'], end_of_day: true)
|
|
211
375
|
|
|
212
|
-
# If
|
|
213
|
-
|
|
376
|
+
# If no_earlier_than was set then make sure it's applied here.
|
|
377
|
+
if download_config
|
|
378
|
+
download_config.run
|
|
379
|
+
no_earlier = download_config.no_earlier_than
|
|
380
|
+
if no_earlier
|
|
381
|
+
no_earlier = to_time(no_earlier.to_s)
|
|
382
|
+
start = no_earlier if start < no_earlier
|
|
383
|
+
end
|
|
384
|
+
end
|
|
214
385
|
|
|
215
|
-
|
|
216
|
-
# did happen with the project specific one.
|
|
217
|
-
return if status.category_name == existing_status.category_name
|
|
386
|
+
@time_range = start..stop
|
|
218
387
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
388
|
+
@jira_url = json['jira_url']
|
|
389
|
+
rescue Errno::ENOENT
|
|
390
|
+
file_system.log "Can't load #{filename}. Have you done a download?", also_write_to_stderr: true
|
|
391
|
+
raise
|
|
222
392
|
end
|
|
223
393
|
|
|
224
|
-
def
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
end
|
|
394
|
+
def load_users
|
|
395
|
+
@users = []
|
|
396
|
+
filename = File.join @target_path, "#{get_file_prefix}_users.json"
|
|
397
|
+
return unless File.exist? filename
|
|
229
398
|
|
|
230
|
-
|
|
231
|
-
@
|
|
399
|
+
json = file_system.load_json(filename)
|
|
400
|
+
json.each { |user_data| @users << User.new(raw: user_data) }
|
|
232
401
|
end
|
|
233
402
|
|
|
234
|
-
def
|
|
235
|
-
filename = "#{
|
|
236
|
-
|
|
403
|
+
def attach_github_prs
|
|
404
|
+
filename = File.join(@target_path, "#{get_file_prefix}_github_prs.json")
|
|
405
|
+
return unless File.exist?(filename)
|
|
237
406
|
|
|
238
|
-
|
|
407
|
+
prs_by_issue_key = Hash.new { |h, k| h[k] = [] }
|
|
408
|
+
file_system.load_json(filename).each do |raw|
|
|
409
|
+
pr = PullRequest.new(raw: raw)
|
|
410
|
+
pr.issue_keys.each { |key| prs_by_issue_key[key] << pr }
|
|
411
|
+
end
|
|
239
412
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
@time_range = to_time(start)..to_time(stop, end_of_day: true)
|
|
413
|
+
@issues.each { |issue| issue.github_prs = prs_by_issue_key[issue.key] }
|
|
414
|
+
end
|
|
243
415
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
416
|
+
def atlassian_document_format
|
|
417
|
+
@atlassian_document_format ||= AtlassianDocumentFormat.new(
|
|
418
|
+
users: @users, timezone_offset: exporter.timezone_offset
|
|
419
|
+
)
|
|
248
420
|
end
|
|
249
421
|
|
|
250
422
|
def to_time string, end_of_day: false
|
|
@@ -259,7 +431,7 @@ class ProjectConfig
|
|
|
259
431
|
unless all_boards&.size == 1
|
|
260
432
|
message = "If the board_id isn't set then we look for all board configurations in the target" \
|
|
261
433
|
' directory. '
|
|
262
|
-
if all_boards.
|
|
434
|
+
if all_boards.empty?
|
|
263
435
|
message += ' In this case, we couldn\'t find any configuration files in the target directory.'
|
|
264
436
|
else
|
|
265
437
|
message += 'If there is only one, we use that. In this case we found configurations for' \
|
|
@@ -280,8 +452,8 @@ class ProjectConfig
|
|
|
280
452
|
|
|
281
453
|
# To be used by the aggregate_config only. Not intended to be part of the public API
|
|
282
454
|
def add_issues issues_list
|
|
283
|
-
@issues =
|
|
284
|
-
@all_boards
|
|
455
|
+
@issues = IssueCollection.new if @issues.nil?
|
|
456
|
+
@all_boards ||= {}
|
|
285
457
|
|
|
286
458
|
issues_list.each do |issue|
|
|
287
459
|
@issues << issue
|
|
@@ -291,21 +463,25 @@ class ProjectConfig
|
|
|
291
463
|
end
|
|
292
464
|
|
|
293
465
|
def issues
|
|
294
|
-
raise "issues are being loaded before boards in project #{name.inspect}" if all_boards.nil? && !aggregated_project?
|
|
295
|
-
|
|
296
466
|
unless @issues
|
|
297
|
-
if
|
|
467
|
+
if aggregated_project?
|
|
298
468
|
raise 'This is an aggregated project and issues should have been included with the include_issues_from ' \
|
|
299
469
|
'declaration but none are here. Check your config.'
|
|
300
470
|
end
|
|
301
471
|
|
|
472
|
+
return @issues = IssueCollection.new if @exporter.downloading?
|
|
473
|
+
raise 'No data found. Must do a download before an export' unless data_downloaded?
|
|
474
|
+
|
|
475
|
+
load_data if all_boards.empty?
|
|
476
|
+
|
|
302
477
|
timezone_offset = exporter.timezone_offset
|
|
303
478
|
|
|
304
|
-
issues_path =
|
|
479
|
+
issues_path = File.join @target_path, "#{get_file_prefix}_issues"
|
|
305
480
|
if File.exist?(issues_path) && File.directory?(issues_path)
|
|
306
481
|
issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
|
|
307
482
|
else
|
|
308
|
-
|
|
483
|
+
file_system.log "Can't find directory #{issues_path}. Has a download been done?", also_write_to_stderr: true
|
|
484
|
+
return IssueCollection.new
|
|
309
485
|
end
|
|
310
486
|
|
|
311
487
|
# Attach related issues
|
|
@@ -317,7 +493,9 @@ class ProjectConfig
|
|
|
317
493
|
|
|
318
494
|
# We'll have some issues that are in the list that weren't part of the initial query. Once we've
|
|
319
495
|
# attached them in the appropriate places, remove any that aren't part of that initial set.
|
|
320
|
-
|
|
496
|
+
issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
|
|
497
|
+
@issues = issues
|
|
498
|
+
attach_github_prs
|
|
321
499
|
end
|
|
322
500
|
|
|
323
501
|
@issues
|
|
@@ -351,18 +529,18 @@ class ProjectConfig
|
|
|
351
529
|
raise "No boards found for project #{name.inspect}" if all_boards.empty?
|
|
352
530
|
|
|
353
531
|
if all_boards.size != 1
|
|
354
|
-
|
|
355
|
-
"Picked #{default_board.name.inspect} to attach issues to."
|
|
532
|
+
file_system.log "Multiple boards are in use for project #{name.inspect}. " \
|
|
533
|
+
"Picked #{default_board.name.inspect} to attach issues to.", also_write_to_stderr: true
|
|
356
534
|
end
|
|
357
535
|
default_board
|
|
358
536
|
end
|
|
359
537
|
|
|
360
538
|
def load_issues_from_issues_directory path:, timezone_offset:
|
|
361
|
-
issues =
|
|
539
|
+
issues = IssueCollection.new
|
|
362
540
|
default_board = nil
|
|
363
541
|
|
|
364
542
|
group_filenames_and_board_ids(path: path).each do |filename, board_ids|
|
|
365
|
-
content = file_system.
|
|
543
|
+
content = file_system.load_json(File.join(path, filename))
|
|
366
544
|
if board_ids == :unknown
|
|
367
545
|
boards = [(default_board ||= find_default_board)]
|
|
368
546
|
else
|
|
@@ -370,7 +548,11 @@ class ProjectConfig
|
|
|
370
548
|
end
|
|
371
549
|
|
|
372
550
|
boards.each do |board|
|
|
373
|
-
|
|
551
|
+
if board.cycletime.nil?
|
|
552
|
+
raise "The board declaration for board #{board.id} must come before the " \
|
|
553
|
+
"first usage of 'issues' in the configuration"
|
|
554
|
+
end
|
|
555
|
+
issues << Issue.new(raw: content, timezone_offset: timezone_offset, board: board)
|
|
374
556
|
end
|
|
375
557
|
end
|
|
376
558
|
|
|
@@ -382,7 +564,7 @@ class ProjectConfig
|
|
|
382
564
|
# board ids appropriately.
|
|
383
565
|
def group_filenames_and_board_ids path:
|
|
384
566
|
hash = {}
|
|
385
|
-
|
|
567
|
+
file_system.foreach(path) do |filename|
|
|
386
568
|
# Matches either FAKE-123.json or FAKE-123-456.json
|
|
387
569
|
if /^(?<key>[^-]+-\d+)(?<_>-(?<board_id>\d+))?\.json$/ =~ filename
|
|
388
570
|
(hash[key] ||= []) << [filename, board_id&.to_i || :unknown]
|
|
@@ -424,21 +606,83 @@ class ProjectConfig
|
|
|
424
606
|
Anonymizer.new(project_config: self).run
|
|
425
607
|
end
|
|
426
608
|
|
|
427
|
-
def
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
609
|
+
def file_system
|
|
610
|
+
@exporter.file_system
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def discard_changes_before status_becomes: nil, &block
|
|
614
|
+
cycletimes_touched = Set.new
|
|
615
|
+
if status_becomes
|
|
616
|
+
status_becomes = [status_becomes] unless status_becomes.is_a? Array
|
|
617
|
+
|
|
618
|
+
status_becomes.each { |status_name| validate_discard_status status_name }
|
|
619
|
+
|
|
620
|
+
block = lambda do |issue|
|
|
621
|
+
trigger_statuses = status_becomes.collect do |status_name|
|
|
622
|
+
if status_name == :backlog
|
|
623
|
+
issue.board.backlog_statuses
|
|
624
|
+
else
|
|
625
|
+
possible_statuses.find_all_by_name status_name
|
|
626
|
+
end
|
|
627
|
+
end.flatten
|
|
628
|
+
|
|
629
|
+
next if trigger_statuses.empty?
|
|
630
|
+
|
|
631
|
+
trigger_status_ids = trigger_statuses.collect(&:id)
|
|
632
|
+
|
|
633
|
+
time = nil
|
|
634
|
+
issue.status_changes.each do |change|
|
|
635
|
+
time = change.time if trigger_status_ids.include?(change.value_id) # && change.artificial? == false
|
|
636
|
+
end
|
|
637
|
+
time
|
|
435
638
|
end
|
|
436
|
-
exporter.file_system.log message
|
|
437
639
|
end
|
|
438
|
-
|
|
640
|
+
|
|
641
|
+
issues.each do |issue|
|
|
642
|
+
cutoff_time = block.call(issue)
|
|
643
|
+
next if cutoff_time.nil?
|
|
644
|
+
|
|
645
|
+
original_start_time = issue.started_stopped_times.first
|
|
646
|
+
next if original_start_time.nil?
|
|
647
|
+
|
|
648
|
+
issue.discard_changes_before cutoff_time
|
|
649
|
+
cycletimes_touched << issue.board.cycletime
|
|
650
|
+
|
|
651
|
+
next unless cutoff_time
|
|
652
|
+
next if original_start_time > cutoff_time # ie the cutoff would have made no difference.
|
|
653
|
+
|
|
654
|
+
(@discarded_changes_data ||= []) << {
|
|
655
|
+
cutoff_time: cutoff_time,
|
|
656
|
+
original_start_time: original_start_time,
|
|
657
|
+
issue: issue
|
|
658
|
+
}
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
cycletimes_touched.each { |c| c.flush_cache }
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
def stringify_keys value
|
|
665
|
+
case value
|
|
666
|
+
when Hash then value.transform_keys(&:to_s).transform_values { |v| stringify_keys(v) }
|
|
667
|
+
when Array then value.map { |v| stringify_keys(v) }
|
|
668
|
+
else value
|
|
669
|
+
end
|
|
439
670
|
end
|
|
440
671
|
|
|
441
|
-
def
|
|
442
|
-
|
|
672
|
+
def resolve_blocked_stalled_status_settings
|
|
673
|
+
%w[blocked_statuses stalled_statuses].each do |key|
|
|
674
|
+
next if @settings[key].is_a?(StatusCollection)
|
|
675
|
+
|
|
676
|
+
collection = StatusCollection.new
|
|
677
|
+
@settings[key].each do |identifier|
|
|
678
|
+
statuses = @possible_statuses.find_all_by_name(identifier)
|
|
679
|
+
if statuses.empty?
|
|
680
|
+
file_system.warning "Status #{identifier.inspect} in #{key} not found. Ignoring."
|
|
681
|
+
else
|
|
682
|
+
statuses.each { |status| collection << status }
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
@settings[key] = collection
|
|
686
|
+
end
|
|
443
687
|
end
|
|
444
688
|
end
|