jirametrics 1.0.0

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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/bin/jirametrics +4 -0
  3. data/lib/jirametrics/aggregate_config.rb +89 -0
  4. data/lib/jirametrics/aging_work_bar_chart.rb +235 -0
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +148 -0
  6. data/lib/jirametrics/aging_work_table.rb +149 -0
  7. data/lib/jirametrics/anonymizer.rb +186 -0
  8. data/lib/jirametrics/blocked_stalled_change.rb +43 -0
  9. data/lib/jirametrics/board.rb +85 -0
  10. data/lib/jirametrics/board_column.rb +14 -0
  11. data/lib/jirametrics/board_config.rb +31 -0
  12. data/lib/jirametrics/change_item.rb +80 -0
  13. data/lib/jirametrics/chart_base.rb +239 -0
  14. data/lib/jirametrics/columns_config.rb +42 -0
  15. data/lib/jirametrics/cycletime_config.rb +69 -0
  16. data/lib/jirametrics/cycletime_histogram.rb +74 -0
  17. data/lib/jirametrics/cycletime_scatterplot.rb +128 -0
  18. data/lib/jirametrics/daily_wip_by_age_chart.rb +88 -0
  19. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +77 -0
  20. data/lib/jirametrics/daily_wip_chart.rb +123 -0
  21. data/lib/jirametrics/data_quality_report.rb +278 -0
  22. data/lib/jirametrics/dependency_chart.rb +217 -0
  23. data/lib/jirametrics/discard_changes_before.rb +37 -0
  24. data/lib/jirametrics/download_config.rb +41 -0
  25. data/lib/jirametrics/downloader.rb +337 -0
  26. data/lib/jirametrics/examples/aggregated_project.rb +36 -0
  27. data/lib/jirametrics/examples/standard_project.rb +111 -0
  28. data/lib/jirametrics/expedited_chart.rb +169 -0
  29. data/lib/jirametrics/experimental/generator.rb +209 -0
  30. data/lib/jirametrics/experimental/info.rb +77 -0
  31. data/lib/jirametrics/exporter.rb +127 -0
  32. data/lib/jirametrics/file_config.rb +119 -0
  33. data/lib/jirametrics/fix_version.rb +21 -0
  34. data/lib/jirametrics/groupable_issue_chart.rb +44 -0
  35. data/lib/jirametrics/grouping_rules.rb +13 -0
  36. data/lib/jirametrics/hierarchy_table.rb +31 -0
  37. data/lib/jirametrics/html/aging_work_bar_chart.erb +72 -0
  38. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +52 -0
  39. data/lib/jirametrics/html/aging_work_table.erb +60 -0
  40. data/lib/jirametrics/html/collapsible_issues_panel.erb +32 -0
  41. data/lib/jirametrics/html/cycletime_histogram.erb +41 -0
  42. data/lib/jirametrics/html/cycletime_scatterplot.erb +103 -0
  43. data/lib/jirametrics/html/daily_wip_chart.erb +63 -0
  44. data/lib/jirametrics/html/data_quality_report.erb +126 -0
  45. data/lib/jirametrics/html/expedited_chart.erb +67 -0
  46. data/lib/jirametrics/html/hierarchy_table.erb +29 -0
  47. data/lib/jirametrics/html/index.erb +66 -0
  48. data/lib/jirametrics/html/sprint_burndown.erb +116 -0
  49. data/lib/jirametrics/html/story_point_accuracy_chart.erb +57 -0
  50. data/lib/jirametrics/html/throughput_chart.erb +65 -0
  51. data/lib/jirametrics/html_report_config.rb +217 -0
  52. data/lib/jirametrics/issue.rb +521 -0
  53. data/lib/jirametrics/issue_link.rb +60 -0
  54. data/lib/jirametrics/json_file_loader.rb +9 -0
  55. data/lib/jirametrics/project_config.rb +442 -0
  56. data/lib/jirametrics/rules.rb +34 -0
  57. data/lib/jirametrics/self_or_issue_dispatcher.rb +15 -0
  58. data/lib/jirametrics/sprint.rb +43 -0
  59. data/lib/jirametrics/sprint_burndown.rb +335 -0
  60. data/lib/jirametrics/sprint_issue_change_data.rb +31 -0
  61. data/lib/jirametrics/status.rb +26 -0
  62. data/lib/jirametrics/status_collection.rb +67 -0
  63. data/lib/jirametrics/story_point_accuracy_chart.rb +139 -0
  64. data/lib/jirametrics/throughput_chart.rb +91 -0
  65. data/lib/jirametrics/tree_organizer.rb +96 -0
  66. data/lib/jirametrics/trend_line_calculator.rb +74 -0
  67. data/lib/jirametrics.rb +85 -0
  68. metadata +167 -0
@@ -0,0 +1,442 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'jirametrics/status_collection'
5
+
6
+ class ProjectConfig
7
+ include DiscardChangesBefore
8
+
9
+ attr_reader :target_path, :jira_config, :all_boards, :possible_statuses,
10
+ :download_config, :file_configs, :exporter, :data_version, :name, :board_configs, :settings
11
+ attr_accessor :time_range, :jira_url
12
+
13
+ def initialize exporter:, jira_config:, block:, target_path: '.', name: ''
14
+ @exporter = exporter
15
+ @block = block
16
+ @file_configs = []
17
+ @download_config = nil
18
+ @target_path = target_path
19
+ @jira_config = jira_config
20
+ @possible_statuses = StatusCollection.new
21
+ @name = name
22
+ @board_configs = []
23
+ @settings = {
24
+ 'stalled_threshold' => 5,
25
+ 'blocked_statuses' => [],
26
+ 'stalled_statuses' => [],
27
+ 'blocked_link_text' => [],
28
+
29
+ 'colors' => {
30
+ 'stalled' => 'orange',
31
+ 'blocked' => '#FF7400'
32
+ }
33
+ }
34
+ end
35
+
36
+ def evaluate_next_level
37
+ instance_eval(&@block)
38
+ end
39
+
40
+ def run
41
+ unless aggregated_project?
42
+ load_status_category_mappings
43
+ load_all_boards
44
+ load_project_metadata
45
+ load_sprints
46
+ end
47
+ anonymize_data if @anonymizer_needed
48
+
49
+ @board_configs.each do |board_config|
50
+ board_config.run
51
+ end
52
+ @file_configs.each do |file_config|
53
+ file_config.run
54
+ end
55
+
56
+ end
57
+
58
+ def aggregated_project?
59
+ !!@aggregate_config
60
+ end
61
+
62
+ def download &block
63
+ raise 'Not allowed to have multiple download blocks in one project' if @download_config
64
+ raise 'Not allowed to have both an aggregate and a download section. Pick only one.' if @aggregate_config
65
+
66
+ @download_config = DownloadConfig.new project_config: self, block: block
67
+ end
68
+
69
+ def file &block
70
+ @file_configs << FileConfig.new(project_config: self, block: block)
71
+ end
72
+
73
+ def aggregate &block
74
+ raise 'Not allowed to have multiple aggregate blocks in one project' if @aggregate_config
75
+ raise 'Not allowed to have both an aggregate and a download section. Pick only one.' if @download_config
76
+
77
+ @aggregate_config = AggregateConfig.new project_config: self, block: block
78
+ return if @exporter.downloading?
79
+
80
+ @aggregate_config.evaluate_next_level
81
+ end
82
+
83
+ def board id:, &block
84
+ config = BoardConfig.new(id: id, block: block, project_config: self)
85
+ @board_configs << config
86
+ end
87
+
88
+ def file_prefix prefix = nil
89
+ @file_prefix = prefix unless prefix.nil?
90
+ @file_prefix
91
+ end
92
+
93
+ def status_category_mapping status:, category:, type: nil
94
+ puts "Deprecated: ProjectConfig.status_category_mapping no longer needs a type: #{type.inspect}" if type
95
+
96
+ status_object = find_status(name: status)
97
+ if status_object
98
+ puts "Status/Category mapping was already present. Ignoring redefinition: #{status_object}"
99
+ return
100
+ end
101
+
102
+ add_possible_status Status.new(name: status, id: nil, category_name: category, category_id: nil)
103
+ end
104
+
105
+ def load_all_boards
106
+ Dir.foreach(@target_path) do |file|
107
+ next unless file =~ /^#{@file_prefix}_board_(\d+)_configuration\.json$/
108
+
109
+ board_id = $1.to_i
110
+ load_board board_id: board_id, filename: "#{@target_path}#{file}"
111
+ end
112
+ raise "No boards found in #{@target_path.inspect}" if @all_boards.nil?
113
+ end
114
+
115
+ def load_board board_id:, filename:
116
+ board = Board.new(
117
+ raw: JSON.parse(File.read(filename)), possible_statuses: @possible_statuses
118
+ )
119
+ board.project_config = self
120
+ (@all_boards ||= {})[board_id] = board
121
+ end
122
+
123
+ def raise_with_message_about_missing_category_information
124
+ message = String.new
125
+ message << 'Could not determine categories for some of the statuses used in this data set.\n\n' \
126
+ 'If you specify a project: then we\'ll ask Jira for those mappings. If you\'ve done that' \
127
+ ' and we still don\'t have the right mapping, which is possible, then use the' \
128
+ " 'status_category_mapping' declaration in your config to manually add one.\n\n" \
129
+ ' The mappings we do know about are below:'
130
+
131
+ @possible_statuses.each do |status|
132
+ message << "\n type: #{status.type.inspect}, status: #{status.name.inspect}, " \
133
+ "category: #{status.category_name.inspect}'"
134
+ end
135
+
136
+ message << "\n\nThe ones we're missing are the following:"
137
+
138
+ missing_statuses = []
139
+ issues.each do |issue|
140
+ issue.changes.each do |change|
141
+ next unless change.status?
142
+
143
+ missing_statuses << change.value unless find_status(name: change.value)
144
+ end
145
+ end
146
+
147
+ missing_statuses.uniq.each do |status_name|
148
+ message << "\n status: #{status_name.inspect}, category: <unknown>"
149
+ end
150
+
151
+ raise message
152
+ end
153
+
154
+ def load_status_category_mappings
155
+ filename = "#{@target_path}/#{file_prefix}_statuses.json"
156
+ # We may not always have this file. Load it if we can.
157
+ return unless File.exist? filename
158
+
159
+ status_json_snippets = []
160
+
161
+ json = JSON.parse(File.read(filename))
162
+ if json[0]['statuses']
163
+ # Response from /api/2/{project_code}/status
164
+ json.each do |type_config|
165
+ status_json_snippets += type_config['statuses']
166
+ end
167
+ else
168
+ # Response from /api/2/status
169
+ status_json_snippets = json
170
+ end
171
+
172
+ status_json_snippets.each do |snippet|
173
+ category_config = snippet['statusCategory']
174
+ status_name = snippet['name']
175
+ add_possible_status Status.new(
176
+ name: status_name,
177
+ id: snippet['id'].to_i,
178
+ category_name: category_config['name'],
179
+ category_id: category_config['id'].to_i
180
+ )
181
+ end
182
+ end
183
+
184
+ def load_sprints
185
+ Dir.foreach(@target_path) do |file|
186
+ next unless file =~ /#{file_prefix}_board_(\d+)_sprints_\d+/
187
+
188
+ board_id = $1.to_i
189
+ timezone_offset = exporter.timezone_offset
190
+ JSON.parse(File.read("#{target_path}#{file}"))['values'].each do |json|
191
+ @all_boards[board_id].sprints << Sprint.new(raw: json, timezone_offset: timezone_offset)
192
+ end
193
+ end
194
+
195
+ @all_boards.each_value do |board|
196
+ board.sprints.sort_by!(&:id)
197
+ end
198
+ end
199
+
200
+ def add_possible_status status
201
+ existing_status = find_status(name: status.name)
202
+
203
+ if existing_status
204
+ if existing_status.category_name != status.category_name
205
+ raise "Redefining status category #{status} with #{existing_status}. Was one set in the config?"
206
+ end
207
+
208
+ return
209
+ end
210
+
211
+ @possible_statuses << status
212
+ end
213
+
214
+ def find_status name:
215
+ @possible_statuses.find_by_name name
216
+ end
217
+
218
+ def load_project_metadata
219
+ filename = "#{@target_path}/#{file_prefix}_meta.json"
220
+ json = JSON.parse(File.read(filename))
221
+
222
+ @data_version = json['version'] || 1
223
+
224
+ start = json['date_start'] || json['time_start'] # date_start is the current format. Time is the old.
225
+ stop = json['date_end'] || json['time_end']
226
+ @time_range = to_time(start)..to_time(stop)
227
+
228
+ @jira_url = json['jira_url']
229
+ rescue Errno::ENOENT
230
+ puts "== Can't load files from the target directory. Did you forget to download first? =="
231
+ raise
232
+ end
233
+
234
+ def to_time string
235
+ string = "#{string}T00:00:00#{@timezone_offset}" if string =~ /^\d{4}-\d{2}\d{2}$/
236
+ Time.parse string
237
+ end
238
+
239
+ def guess_board_id
240
+ return nil if aggregated_project?
241
+
242
+ unless all_boards&.size == 1
243
+ message = "If the board_id isn't set then we look for all board configurations in the target" \
244
+ ' directory. '
245
+ if all_boards.nil? || all_boards.empty?
246
+ message += ' In this case, we couldn\'t find any configuration files in the target directory.'
247
+ else
248
+ message += 'If there is only one, we use that. In this case we found configurations for' \
249
+ " the following board ids and this is ambiguous: #{all_boards.keys}"
250
+ end
251
+ raise message
252
+ end
253
+ all_boards.keys[0]
254
+ end
255
+
256
+ def find_board_by_id board_id = nil
257
+ board = all_boards[board_id || guess_board_id]
258
+
259
+ raise "Unable to find configuration for board_id: #{board_id}" if board.nil?
260
+
261
+ board
262
+ end
263
+
264
+ # To be used by the aggregate_config only. Not intended to be part of the public API
265
+ def add_issues issues_list
266
+ @issues = [] if @issues.nil?
267
+ @all_boards = {} if @all_boards.nil?
268
+
269
+ issues_list.each do |issue|
270
+ @issues << issue
271
+ board = issue.board
272
+ @all_boards[board.id] = board unless @all_boards[board.id]
273
+ end
274
+ end
275
+
276
+ def issues
277
+ raise "issues are being loaded before boards in project #{name.inspect}" if all_boards.nil? && !aggregated_project?
278
+
279
+ unless @issues
280
+ if @aggregate_config
281
+ raise 'This is an aggregated project and issues should have been included with the include_issues_from ' \
282
+ 'declaration but none are here. Check your config.'
283
+ end
284
+
285
+ timezone_offset = exporter.timezone_offset
286
+
287
+ issues_path = "#{@target_path}#{file_prefix}_issues/"
288
+ if File.exist?(issues_path) && File.directory?(issues_path)
289
+ issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
290
+ elsif File.exist?(@target_path) && File.directory?(@target_path)
291
+ issues = load_issues_from_target_directory path: @target_path, timezone_offset: timezone_offset
292
+ else
293
+ puts "Can't find issues in either #{path} or #{@target_path}"
294
+ end
295
+
296
+ # Attach related issues
297
+ issues.each do |i|
298
+ attach_subtasks issue: i, all_issues: issues
299
+ attach_parent issue: i, all_issues: issues
300
+ attach_linked_issues issue: i, all_issues: issues
301
+ end
302
+
303
+ # We'll have some issues that are in the list that weren't part of the initial query. Once we've
304
+ # attached them in the appropriate places, remove any that aren't part of that initial set.
305
+ @issues = issues.select { |i| i.in_initial_query? }
306
+ end
307
+
308
+ @issues
309
+ end
310
+
311
+ def attach_subtasks issue:, all_issues:
312
+ issue.raw['fields']['subtasks']&.each do |subtask_element|
313
+ subtask_key = subtask_element['key']
314
+ subtask = all_issues.find { |i| i.key == subtask_key }
315
+ issue.subtasks << subtask if subtask
316
+ end
317
+ end
318
+
319
+ def attach_parent issue:, all_issues:
320
+ parent_key = issue.parent_key
321
+ parent = all_issues.find { |i| i.key == parent_key }
322
+ issue.parent = parent if parent
323
+ end
324
+
325
+ def attach_linked_issues issue:, all_issues:
326
+ issue.issue_links.each do |link|
327
+ if link.other_issue.artificial?
328
+ other = all_issues.find { |i| i.key == link.other_issue.key }
329
+ link.other_issue = other if other
330
+ end
331
+ end
332
+ end
333
+
334
+ def find_default_board
335
+ default_board = all_boards.values.first
336
+ raise "No boards found for project #{name.inspect}" if all_boards.empty?
337
+
338
+ if all_boards.size != 1
339
+ puts "Multiple boards are in use for project #{name.inspect}. Picked #{(default_board.name).inspect} to attach issues to."
340
+ end
341
+ default_board
342
+ end
343
+
344
+ def load_issues_from_target_directory path:, timezone_offset:
345
+ puts "Deprecated: issues in the target directory for project #{@name}. " \
346
+ 'Download again and this should fix itself.'
347
+
348
+ default_board = find_default_board
349
+
350
+ issues = []
351
+ Dir.foreach(path) do |filename|
352
+ if filename =~ /#{file_prefix}_\d+\.json/
353
+ content = JSON.parse File.read("#{path}#{filename}")
354
+ content['issues'].each do |issue|
355
+ issues << Issue.new(raw: issue, timezone_offset: timezone_offset, board: default_board)
356
+ end
357
+ end
358
+ end
359
+ issues
360
+ end
361
+
362
+ def load_issues_from_issues_directory path:, timezone_offset:
363
+ issues = []
364
+ default_board = nil
365
+
366
+ group_filenames_and_board_ids(path: path).each do |filename, board_ids|
367
+ content = File.read(File.join(path, filename))
368
+ if board_ids == :unknown
369
+ boards = [(default_board ||= find_default_board)]
370
+ else
371
+ boards = board_ids.collect { |b| all_boards[b] }
372
+ end
373
+
374
+ boards.each do |board|
375
+ issues << Issue.new(raw: JSON.parse(content), timezone_offset: timezone_offset, board: board)
376
+ end
377
+ end
378
+
379
+ issues
380
+ end
381
+
382
+ # Scan through the issues directory (path), select the filenames to be loaded and map them to board ids.
383
+ # It's ok if there are multiple files for the same issue. We load the newest one and map all the other
384
+ # board ids appropriately.
385
+ def group_filenames_and_board_ids path:
386
+ hash = {}
387
+ Dir.foreach(path) do |filename|
388
+ # Matches either FAKE-123.json or FAKE-123-456.json
389
+ if /^(?<key>[^-]+-\d+)(?<_>-(?<board_id>\d+))?\.json$/ =~ filename
390
+ (hash[key] ||= []) << [filename, board_id&.to_i || :unknown]
391
+ end
392
+ end
393
+
394
+ result = {}
395
+ hash.values.collect do |list|
396
+ if list.size == 1
397
+ filename, board_id = *list.first
398
+ result[filename] = board_id == :unknown ? board_id : [board_id]
399
+ else
400
+ max_time = nil
401
+ max_board_id = nil
402
+ max_filename = nil
403
+ all_board_ids = []
404
+
405
+ list.each do |filename, board_id|
406
+ mtime = File.mtime(File.join(path, filename))
407
+ if max_time.nil? || mtime > max_time
408
+ max_time = mtime
409
+ max_board_id = board_id
410
+ max_filename = filename
411
+ end
412
+ all_board_ids << board_id unless board_id == :unknown
413
+ end
414
+
415
+ result[max_filename] = all_board_ids
416
+ end
417
+ end
418
+ result
419
+ end
420
+
421
+ def anonymize
422
+ @anonymizer_needed = true
423
+ end
424
+
425
+ def anonymize_data
426
+ Anonymizer.new(project_config: self).run
427
+ end
428
+
429
+ def discard_changes_before_hook issues_cutoff_times
430
+ issues_cutoff_times.each do |issue, cutoff_time|
431
+ days = (cutoff_time.to_date - issue.changes.first.time.to_date).to_i + 1
432
+ message = "#{issue.key}(#{issue.type}) discarding #{days} "
433
+ if days == 1
434
+ message << "day of data on #{cutoff_time.to_date}"
435
+ else
436
+ message << "days of data from #{issue.changes.first.time.to_date} to #{cutoff_time.to_date}"
437
+ end
438
+ puts message
439
+ end
440
+ puts "Discarded data from #{issues_cutoff_times.count} issues out of a total #{issues.size}"
441
+ end
442
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rules
4
+ def ignore
5
+ @ignore = true
6
+ end
7
+
8
+ def ignored?
9
+ @ignore == true
10
+ end
11
+
12
+ def eql?(other)
13
+ (other.class == self.class) && (other.state == state)
14
+ end
15
+
16
+ def state
17
+ instance_variables.map { |variable| instance_variable_get variable }
18
+ end
19
+
20
+ def hash
21
+ 2 # TODO: While this work, it's not performant
22
+ end
23
+
24
+ def inspect
25
+ result = String.new
26
+ result << "#{self.class}("
27
+ result << instance_variables.collect do |variable|
28
+ "#{variable}=#{instance_variable_get(variable).inspect}"
29
+ end.join(', ')
30
+ result << ')'
31
+ result
32
+ end
33
+
34
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SelfOrIssueDispatcher
4
+ def method_missing method_name, *args, &block
5
+ raise "#{method_name} isn't a method on Issue or #{self.class}" unless ::Issue.method_defined? method_name.to_sym
6
+
7
+ ->(issue) do # rubocop:disable Style/Lambda
8
+ issue.__send__ method_name, *args, &block
9
+ end
10
+ end
11
+
12
+ def respond_to_missing?(method_name, include_all = false)
13
+ ::Issue.method_defined?(method_name.to_sym) || super
14
+ end
15
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ class Sprint
6
+ attr_reader :raw
7
+
8
+ def initialize raw:, timezone_offset:
9
+ @raw = raw
10
+ @timezone_offset = timezone_offset
11
+ end
12
+
13
+ def id = @raw['id']
14
+ def active? = (@raw['state'] == 'active')
15
+
16
+ def completed_at? time
17
+ completed_at = completed_time
18
+ completed_at && completed_at <= time
19
+ end
20
+
21
+ def start_time
22
+ parse_time(@raw['activatedDate'] || @raw['startDate'])
23
+ end
24
+
25
+ # The time that was anticipated that the sprint would close
26
+ def end_time
27
+ parse_time(@raw['endDate'])
28
+ end
29
+
30
+ # The time that the sprint was actually closed
31
+ def completed_time
32
+ parse_time(@raw['completeDate'])
33
+ end
34
+
35
+ def goal = @raw['goal']
36
+ def name = @raw['name']
37
+
38
+ private
39
+
40
+ def parse_time time_string
41
+ Time.parse(time_string).localtime(@timezone_offset) if time_string
42
+ end
43
+ end