jirametrics 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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