Class: ProjectConfig

Inherits:
Object show all
Includes:
DiscardChangesBefore
Defined in:
lib/jirametrics/project_config.rb

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from DiscardChangesBefore

#discard_changes_before

Constructor Details

#initialize(exporter:, jira_config:, block:, target_path: '.', name: '') ⇒ ProjectConfig

Returns a new instance of ProjectConfig.



13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/jirametrics/project_config.rb', line 13

def initialize exporter:, jira_config:, block:, target_path: '.', name: ''
  @exporter = exporter
  @block = block
  @file_configs = []
  @download_config = nil
  @target_path = target_path
  @jira_config = jira_config
  @possible_statuses = StatusCollection.new
  @name = name
  @board_configs = []
  @settings = {
    'stalled_threshold' => 5,
    'blocked_statuses' => [],
    'stalled_statuses' => [],
    'blocked_link_text' => [],

    'colors' => {
      'stalled' => 'orange',
      'blocked' => '#FF7400'
    }
  }
end

Instance Attribute Details

#all_boardsObject (readonly)

Returns the value of attribute all_boards.



9
10
11
# File 'lib/jirametrics/project_config.rb', line 9

def all_boards
  @all_boards
end

#board_configsObject (readonly)

Returns the value of attribute board_configs.



9
10
11
# File 'lib/jirametrics/project_config.rb', line 9

def board_configs
  @board_configs
end

#data_versionObject (readonly)

Returns the value of attribute data_version.



9
10
11
# File 'lib/jirametrics/project_config.rb', line 9

def data_version
  @data_version
end

#download_configObject (readonly)

Returns the value of attribute download_config.



9
10
11
# File 'lib/jirametrics/project_config.rb', line 9

def download_config
  @download_config
end

#exporterObject (readonly)

Returns the value of attribute exporter.



9
10
11
# File 'lib/jirametrics/project_config.rb', line 9

def exporter
  @exporter
end

#file_configsObject (readonly)

Returns the value of attribute file_configs.



9
10
11
# File 'lib/jirametrics/project_config.rb', line 9

def file_configs
  @file_configs
end

#jira_configObject (readonly)

Returns the value of attribute jira_config.



9
10
11
# File 'lib/jirametrics/project_config.rb', line 9

def jira_config
  @jira_config
end

#jira_urlObject

Returns the value of attribute jira_url.



11
12
13
# File 'lib/jirametrics/project_config.rb', line 11

def jira_url
  @jira_url
end

#nameObject (readonly)

Returns the value of attribute name.



9
10
11
# File 'lib/jirametrics/project_config.rb', line 9

def name
  @name
end

#possible_statusesObject (readonly)

Returns the value of attribute possible_statuses.



9
10
11
# File 'lib/jirametrics/project_config.rb', line 9

def possible_statuses
  @possible_statuses
end

#settingsObject (readonly)

Returns the value of attribute settings.



9
10
11
# File 'lib/jirametrics/project_config.rb', line 9

def settings
  @settings
end

#target_pathObject (readonly)

Returns the value of attribute target_path.



9
10
11
# File 'lib/jirametrics/project_config.rb', line 9

def target_path
  @target_path
end

#time_rangeObject

Returns the value of attribute time_range.



11
12
13
# File 'lib/jirametrics/project_config.rb', line 11

def time_range
  @time_range
end

Instance Method Details

#add_issues(issues_list) ⇒ Object

To be used by the aggregate_config only. Not intended to be part of the public API



265
266
267
268
269
270
271
272
273
274
# File 'lib/jirametrics/project_config.rb', line 265

def add_issues issues_list
  @issues = [] if @issues.nil?
  @all_boards = {} if @all_boards.nil?

  issues_list.each do |issue|
    @issues << issue
    board = issue.board
    @all_boards[board.id] = board unless @all_boards[board.id]
  end
end

#add_possible_status(status) ⇒ Object



200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/jirametrics/project_config.rb', line 200

def add_possible_status status
  existing_status = find_status(name: status.name)

  if existing_status
    if existing_status.category_name != status.category_name
      raise "Redefining status category #{status} with #{existing_status}. Was one set in the config?"
    end

    return
  end

  @possible_statuses << status
end

#aggregate(&block) ⇒ Object



73
74
75
76
77
78
79
80
81
# File 'lib/jirametrics/project_config.rb', line 73

def aggregate &block
  raise 'Not allowed to have multiple aggregate blocks in one project' if @aggregate_config
  raise 'Not allowed to have both an aggregate and a download section. Pick only one.' if @download_config

  @aggregate_config = AggregateConfig.new project_config: self, block: block
  return if @exporter.downloading?

  @aggregate_config.evaluate_next_level
end

#aggregated_project?Boolean

Returns:

  • (Boolean)


58
59
60
# File 'lib/jirametrics/project_config.rb', line 58

def aggregated_project?
  !!@aggregate_config
end

#anonymizeObject



421
422
423
# File 'lib/jirametrics/project_config.rb', line 421

def anonymize
  @anonymizer_needed = true
end

#anonymize_dataObject



425
426
427
# File 'lib/jirametrics/project_config.rb', line 425

def anonymize_data
  Anonymizer.new(project_config: self).run
end

#attach_linked_issues(issue:, all_issues:) ⇒ Object



325
326
327
328
329
330
331
332
# File 'lib/jirametrics/project_config.rb', line 325

def attach_linked_issues issue:, all_issues:
  issue.issue_links.each do |link|
    if link.other_issue.artificial?
      other = all_issues.find { |i| i.key == link.other_issue.key }
      link.other_issue = other if other
    end
  end
end

#attach_parent(issue:, all_issues:) ⇒ Object



319
320
321
322
323
# File 'lib/jirametrics/project_config.rb', line 319

def attach_parent issue:, all_issues:
  parent_key = issue.parent_key
  parent = all_issues.find { |i| i.key == parent_key }
  issue.parent = parent if parent
end

#attach_subtasks(issue:, all_issues:) ⇒ Object



311
312
313
314
315
316
317
# File 'lib/jirametrics/project_config.rb', line 311

def attach_subtasks issue:, all_issues:
  issue.raw['fields']['subtasks']&.each do |subtask_element|
    subtask_key = subtask_element['key']
    subtask = all_issues.find { |i| i.key == subtask_key }
    issue.subtasks << subtask if subtask
  end
end

#board(id:, &block) ⇒ Object



83
84
85
86
# File 'lib/jirametrics/project_config.rb', line 83

def board id:, &block
  config = BoardConfig.new(id: id, block: block, project_config: self)
  @board_configs << config
end

#discard_changes_before_hook(issues_cutoff_times) ⇒ Object



429
430
431
432
433
434
435
436
437
438
439
440
441
# File 'lib/jirametrics/project_config.rb', line 429

def discard_changes_before_hook issues_cutoff_times
  issues_cutoff_times.each do |issue, cutoff_time|
    days = (cutoff_time.to_date - issue.changes.first.time.to_date).to_i + 1
    message = "#{issue.key}(#{issue.type}) discarding #{days} "
    if days == 1
      message << "day of data on #{cutoff_time.to_date}"
    else
      message << "days of data from #{issue.changes.first.time.to_date} to #{cutoff_time.to_date}"
    end
    puts message
  end
  puts "Discarded data from #{issues_cutoff_times.count} issues out of a total #{issues.size}"
end

#download(&block) ⇒ Object



62
63
64
65
66
67
# File 'lib/jirametrics/project_config.rb', line 62

def download &block
  raise 'Not allowed to have multiple download blocks in one project' if @download_config
  raise 'Not allowed to have both an aggregate and a download section. Pick only one.' if @aggregate_config

  @download_config = DownloadConfig.new project_config: self, block: block
end

#evaluate_next_levelObject



36
37
38
# File 'lib/jirametrics/project_config.rb', line 36

def evaluate_next_level
  instance_eval(&@block)
end

#file(&block) ⇒ Object



69
70
71
# File 'lib/jirametrics/project_config.rb', line 69

def file &block
  @file_configs << FileConfig.new(project_config: self, block: block)
end

#file_prefix(prefix = nil) ⇒ Object



88
89
90
91
# File 'lib/jirametrics/project_config.rb', line 88

def file_prefix prefix = nil
  @file_prefix = prefix unless prefix.nil?
  @file_prefix
end

#find_board_by_id(board_id = nil) ⇒ Object



256
257
258
259
260
261
262
# File 'lib/jirametrics/project_config.rb', line 256

def find_board_by_id board_id = nil
  board = all_boards[board_id || guess_board_id]

  raise "Unable to find configuration for board_id: #{board_id}" if board.nil?

  board
end

#find_default_boardObject



334
335
336
337
338
339
340
341
342
# File 'lib/jirametrics/project_config.rb', line 334

def find_default_board
  default_board = all_boards.values.first
  raise "No boards found for project #{name.inspect}" if all_boards.empty?

  if all_boards.size != 1
    puts "Multiple boards are in use for project #{name.inspect}. Picked #{(default_board.name).inspect} to attach issues to."
  end
  default_board
end

#find_status(name:) ⇒ Object



214
215
216
# File 'lib/jirametrics/project_config.rb', line 214

def find_status name:
  @possible_statuses.find_by_name name
end

#group_filenames_and_board_ids(path:) ⇒ Object

Scan through the issues directory (path), select the filenames to be loaded and map them to board ids. It’s ok if there are multiple files for the same issue. We load the newest one and map all the other board ids appropriately.



385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
# File 'lib/jirametrics/project_config.rb', line 385

def group_filenames_and_board_ids path:
  hash = {}
  Dir.foreach(path) do |filename|
    # Matches either FAKE-123.json or FAKE-123-456.json
    if /^(?<key>[^-]+-\d+)(?<_>-(?<board_id>\d+))?\.json$/ =~ filename
      (hash[key] ||= []) << [filename, board_id&.to_i || :unknown]
    end
  end

  result = {}
  hash.values.collect do |list|
    if list.size == 1
      filename, board_id = *list.first
      result[filename] = board_id == :unknown ? board_id : [board_id]
    else
      max_time = nil
      max_board_id = nil
      max_filename = nil
      all_board_ids = []

      list.each do |filename, board_id|
        mtime = File.mtime(File.join(path, filename))
        if max_time.nil? || mtime > max_time
          max_time = mtime
          max_board_id = board_id
          max_filename = filename
        end
        all_board_ids << board_id unless board_id == :unknown
      end

      result[max_filename] = all_board_ids
    end
  end
  result
end

#guess_board_idObject



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'lib/jirametrics/project_config.rb', line 239

def guess_board_id
  return nil if aggregated_project?

  unless all_boards&.size == 1
    message = "If the board_id isn't set then we look for all board configurations in the target" \
      ' directory. '
    if all_boards.nil? || all_boards.empty?
      message += ' In this case, we couldn\'t find any configuration files in the target directory.'
    else
      message += 'If there is only one, we use that. In this case we found configurations for' \
        " the following board ids and this is ambiguous: #{all_boards.keys}"
    end
    raise message
  end
  all_boards.keys[0]
end

#issuesObject



276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# File 'lib/jirametrics/project_config.rb', line 276

def issues
  raise "issues are being loaded before boards in project #{name.inspect}" if all_boards.nil? && !aggregated_project?

  unless @issues
    if @aggregate_config
      raise 'This is an aggregated project and issues should have been included with the include_issues_from ' \
        'declaration but none are here. Check your config.'
    end

    timezone_offset = exporter.timezone_offset

    issues_path = "#{@target_path}#{file_prefix}_issues/"
    if File.exist?(issues_path) && File.directory?(issues_path)
      issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
    elsif File.exist?(@target_path) && File.directory?(@target_path)
      issues = load_issues_from_target_directory path: @target_path, timezone_offset: timezone_offset
    else
      puts "Can't find issues in either #{path} or #{@target_path}"
    end

    # Attach related issues
    issues.each do |i|
      attach_subtasks issue: i, all_issues: issues
      attach_parent issue: i, all_issues: issues
      attach_linked_issues issue: i, all_issues: issues
    end

    # We'll have some issues that are in the list that weren't part of the initial query. Once we've
    # attached them in the appropriate places, remove any that aren't part of that initial set.
    @issues = issues.select { |i| i.in_initial_query? }
  end

  @issues
end

#load_all_boardsObject



105
106
107
108
109
110
111
112
113
# File 'lib/jirametrics/project_config.rb', line 105

def load_all_boards
  Dir.foreach(@target_path) do |file|
    next unless file =~ /^#{@file_prefix}_board_(\d+)_configuration\.json$/

    board_id = $1.to_i
    load_board board_id: board_id, filename: "#{@target_path}#{file}"
  end
  raise "No boards found in #{@target_path.inspect}" if @all_boards.nil?
end

#load_board(board_id:, filename:) ⇒ Object



115
116
117
118
119
120
121
# File 'lib/jirametrics/project_config.rb', line 115

def load_board board_id:, filename:
  board = Board.new(
    raw: JSON.parse(File.read(filename)), possible_statuses: @possible_statuses
  )
  board.project_config = self
  (@all_boards ||= {})[board_id] = board
end

#load_issues_from_issues_directory(path:, timezone_offset:) ⇒ Object



362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
# File 'lib/jirametrics/project_config.rb', line 362

def load_issues_from_issues_directory path:, timezone_offset:
  issues = []
  default_board = nil

  group_filenames_and_board_ids(path: path).each do |filename, board_ids|
    content = File.read(File.join(path, filename))
    if board_ids == :unknown
      boards = [(default_board ||= find_default_board)]
    else
      boards = board_ids.collect { |b| all_boards[b] }
    end

    boards.each do |board|
      issues << Issue.new(raw: JSON.parse(content), timezone_offset: timezone_offset, board: board)
    end
  end

  issues
end

#load_issues_from_target_directory(path:, timezone_offset:) ⇒ Object



344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
# File 'lib/jirametrics/project_config.rb', line 344

def load_issues_from_target_directory path:, timezone_offset:
  puts "Deprecated: issues in the target directory for project #{@name}. " \
    'Download again and this should fix itself.'

  default_board = find_default_board

  issues = []
  Dir.foreach(path) do |filename|
    if filename =~ /#{file_prefix}_\d+\.json/
      content = JSON.parse File.read("#{path}#{filename}")
      content['issues'].each do |issue|
        issues << Issue.new(raw: issue, timezone_offset: timezone_offset, board: default_board)
      end
    end
  end
  issues
end

#load_project_metadataObject



218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/jirametrics/project_config.rb', line 218

def 
  filename = "#{@target_path}/#{file_prefix}_meta.json"
  json = JSON.parse(File.read(filename))

  @data_version = json['version'] || 1

  start = json['date_start'] || json['time_start'] # date_start is the current format. Time is the old.
  stop  = json['date_end'] || json['time_end']
  @time_range = to_time(start)..to_time(stop)

  @jira_url = json['jira_url']
rescue Errno::ENOENT
  puts "== Can't load files from the target directory. Did you forget to download first? =="
  raise
end

#load_sprintsObject



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/jirametrics/project_config.rb', line 184

def load_sprints
  Dir.foreach(@target_path) do |file|
    next unless file =~ /#{file_prefix}_board_(\d+)_sprints_\d+/

    board_id = $1.to_i
    timezone_offset = exporter.timezone_offset
    JSON.parse(File.read("#{target_path}#{file}"))['values'].each do |json|
      @all_boards[board_id].sprints << Sprint.new(raw: json, timezone_offset: timezone_offset)
    end
  end

  @all_boards.each_value do |board|
    board.sprints.sort_by!(&:id)
  end
end

#load_status_category_mappingsObject



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/jirametrics/project_config.rb', line 154

def load_status_category_mappings
  filename = "#{@target_path}/#{file_prefix}_statuses.json"
  # We may not always have this file. Load it if we can.
  return unless File.exist? filename

  status_json_snippets = []

  json = JSON.parse(File.read(filename))
  if json[0]['statuses']
    # Response from /api/2/{project_code}/status
    json.each do |type_config|
      status_json_snippets += type_config['statuses']
    end
  else
    # Response from /api/2/status
    status_json_snippets = json
  end

  status_json_snippets.each do |snippet|
    category_config = snippet['statusCategory']
    status_name = snippet['name']
    add_possible_status Status.new(
      name: status_name,
      id: snippet['id'].to_i,
      category_name: category_config['name'],
      category_id: category_config['id'].to_i
    )
  end
end

#raise_with_message_about_missing_category_informationObject



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/jirametrics/project_config.rb', line 123

def raise_with_message_about_missing_category_information
  message = String.new
  message << 'Could not determine categories for some of the statuses used in this data set.\n\n' \
    'If you specify a project: then we\'ll ask Jira for those mappings. If you\'ve done that' \
    ' and we still don\'t have the right mapping, which is possible, then use the' \
    " 'status_category_mapping' declaration in your config to manually add one.\n\n" \
    ' The mappings we do know about are below:'

  @possible_statuses.each do |status|
    message << "\n  type: #{status.type.inspect}, status: #{status.name.inspect}, " \
      "category: #{status.category_name.inspect}'"
  end

  message << "\n\nThe ones we're missing are the following:"

  missing_statuses = []
  issues.each do |issue|
    issue.changes.each do |change|
      next unless change.status?

      missing_statuses << change.value unless find_status(name: change.value)
    end
  end

  missing_statuses.uniq.each do |status_name|
    message << "\n  status: #{status_name.inspect}, category: <unknown>"
  end

  raise message
end

#runObject



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/jirametrics/project_config.rb', line 40

def run
  unless aggregated_project?
    load_status_category_mappings
    load_all_boards
    
    load_sprints
  end
  anonymize_data if @anonymizer_needed

  @board_configs.each do |board_config|
    board_config.run
  end
  @file_configs.each do |file_config|
    file_config.run
  end

end

#status_category_mapping(status:, category:, type: nil) ⇒ Object



93
94
95
96
97
98
99
100
101
102
103
# File 'lib/jirametrics/project_config.rb', line 93

def status_category_mapping status:, category:, type: nil
  puts "Deprecated: ProjectConfig.status_category_mapping no longer needs a type: #{type.inspect}" if type

  status_object = find_status(name: status)
  if status_object
    puts "Status/Category mapping was already present. Ignoring redefinition: #{status_object}"
    return
  end

  add_possible_status Status.new(name: status, id: nil, category_name: category, category_id: nil)
end

#to_time(string) ⇒ Object



234
235
236
237
# File 'lib/jirametrics/project_config.rb', line 234

def to_time string
  string = "#{string}T00:00:00#{@timezone_offset}" if string =~ /^\d{4}-\d{2}\d{2}$/
  Time.parse string
end