jirametrics 2.2.1 → 2.4pre1

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +13 -25
  3. data/lib/jirametrics/aging_work_bar_chart.rb +57 -39
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +1 -1
  5. data/lib/jirametrics/aging_work_table.rb +9 -26
  6. data/lib/jirametrics/blocked_stalled_change.rb +24 -4
  7. data/lib/jirametrics/board_config.rb +2 -2
  8. data/lib/jirametrics/change_item.rb +13 -5
  9. data/lib/jirametrics/chart_base.rb +27 -39
  10. data/lib/jirametrics/columns_config.rb +4 -0
  11. data/lib/jirametrics/cycletime_histogram.rb +1 -1
  12. data/lib/jirametrics/cycletime_scatterplot.rb +1 -1
  13. data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
  14. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +3 -16
  15. data/lib/jirametrics/daily_wip_chart.rb +1 -13
  16. data/lib/jirametrics/data_quality_report.rb +4 -1
  17. data/lib/jirametrics/dependency_chart.rb +1 -1
  18. data/lib/jirametrics/{story_point_accuracy_chart.rb → estimate_accuracy_chart.rb} +31 -25
  19. data/lib/jirametrics/examples/standard_project.rb +1 -1
  20. data/lib/jirametrics/expedited_chart.rb +3 -1
  21. data/lib/jirametrics/exporter.rb +3 -3
  22. data/lib/jirametrics/file_config.rb +12 -8
  23. data/lib/jirametrics/file_system.rb +11 -2
  24. data/lib/jirametrics/groupable_issue_chart.rb +2 -4
  25. data/lib/jirametrics/hierarchy_table.rb +4 -4
  26. data/lib/jirametrics/html/aging_work_table.erb +3 -3
  27. data/lib/jirametrics/html/index.erb +1 -0
  28. data/lib/jirametrics/html_report_config.rb +61 -74
  29. data/lib/jirametrics/issue.rb +129 -57
  30. data/lib/jirametrics/project_config.rb +13 -7
  31. data/lib/jirametrics/sprint_burndown.rb +11 -0
  32. data/lib/jirametrics/status_collection.rb +4 -1
  33. data/lib/jirametrics/throughput_chart.rb +1 -1
  34. data/lib/jirametrics.rb +1 -1
  35. metadata +5 -7
  36. data/lib/jirametrics/experimental/generator.rb +0 -210
  37. data/lib/jirametrics/experimental/info.rb +0 -77
  38. /data/lib/jirametrics/html/{story_point_accuracy_chart.erb → estimate_accuracy_chart.erb} +0 -0
@@ -35,16 +35,6 @@ class Issue
35
35
  raise "Unable to initialize #{raw['key']}"
36
36
  end
37
37
 
38
- def sort_changes!
39
- @changes.sort! do |a, b|
40
- # It's common that a resolved will happen at the same time as a status change.
41
- # Put them in a defined order so tests can be deterministic.
42
- compare = a.time <=> b.time
43
- compare = 1 if compare.zero? && a.resolution?
44
- compare
45
- end
46
- end
47
-
48
38
  def key = @raw['key']
49
39
 
50
40
  def type = @raw['fields']['issuetype']['name']
@@ -53,9 +43,7 @@ class Issue
53
43
 
54
44
  def summary = @raw['fields']['summary']
55
45
 
56
- def status
57
- Status.new raw: @raw['fields']['status']
58
- end
46
+ def status = Status.new(raw: @raw['fields']['status'])
59
47
 
60
48
  def labels = @raw['fields']['labels'] || []
61
49
 
@@ -69,37 +57,13 @@ class Issue
69
57
  end
70
58
 
71
59
  def key_as_i
72
- $1.to_i if key =~ /-(\d+)$/
60
+ key =~ /-(\d+)$/ ? $1.to_i : 0
73
61
  end
74
62
 
75
63
  def component_names
76
64
  @raw['fields']['components']&.collect { |component| component['name'] } || []
77
65
  end
78
66
 
79
- def fabricate_change field_name:
80
- first_status = nil
81
- first_status_id = nil
82
-
83
- created_time = parse_time @raw['fields']['created']
84
- first_change = @changes.find { |change| change.field == field_name }
85
- if first_change.nil?
86
- # There have been no changes of this type yet so we have to look at the current one
87
- return nil unless @raw['fields'][field_name]
88
-
89
- first_status = @raw['fields'][field_name]['name']
90
- first_status_id = @raw['fields'][field_name]['id'].to_i
91
- else
92
- # Otherwise, we look at what the first one had changed away from.
93
- first_status = first_change.old_value
94
- first_status_id = first_change.old_value_id
95
- end
96
- ChangeItem.new time: created_time, artificial: true, author: author, raw: {
97
- 'field' => field_name,
98
- 'to' => first_status_id,
99
- 'toString' => first_status
100
- }
101
- end
102
-
103
67
  def first_time_in_status *status_names
104
68
  @changes.find { |change| change.current_status_matches(*status_names) }&.time
105
69
  end
@@ -195,7 +159,7 @@ class Issue
195
159
  end
196
160
 
197
161
  def created
198
- # This shouldn't be necessary and yet we've seen one case where it was.
162
+ # This nil check shouldn't be necessary and yet we've seen one case where it was.
199
163
  parse_time @raw['fields']['created'] if @raw['fields']['created']
200
164
  end
201
165
 
@@ -222,29 +186,62 @@ class Issue
222
186
  end
223
187
 
224
188
  def blocked_on_date? date, end_time:
225
- blocked_stalled_changes_on_date(date: date, end_time: end_time) do |change|
226
- return true if change.blocked?
227
- end
228
- false
229
- end
189
+ (blocked_stalled_by_date date_range: date..date, chart_end_time: end_time)[date].blocked?
190
+ end
191
+
192
+ # For any day in the day range...
193
+ # If the issue was blocked at any point in this day, the whole day is blocked.
194
+ # If the issue was active at any point in this day, the whole day is active
195
+ # If the day was stalled for the entire day then it's stalled
196
+ # If there was no activity at all on this day then the last change from the previous day carries over
197
+ def blocked_stalled_by_date date_range:, chart_end_time:, settings: nil
198
+ results = {}
199
+ current_date = nil
200
+ blocked_stalled_changes = blocked_stalled_changes(end_time: chart_end_time, settings: settings)
201
+ blocked_stalled_changes.each do |change|
202
+ current_date = change.time.to_date
203
+
204
+ winning_change, _last_change = results[current_date]
205
+ if winning_change.nil? ||
206
+ change.blocked? ||
207
+ (change.active? && (winning_change.active? || winning_change.stalled?)) ||
208
+ (change.stalled? && winning_change.stalled?)
209
+
210
+ winning_change = change
211
+ end
230
212
 
231
- def blocked_stalled_changes_on_date date:, end_time:
232
- next_day_start_time = (date + 1).to_time
233
- this_day_start_time = date.to_time
213
+ results[current_date] = [winning_change, change]
214
+ end
234
215
 
235
- # changes_affecting_date = []
236
- previous_change_time = nil
237
- blocked_stalled_changes(end_time: end_time).each do |change|
238
- if previous_change_time.nil?
239
- previous_change_time = change.time
240
- next
216
+ last_populated_date = nil
217
+ (results.keys.min..results.keys.max).each do |date|
218
+ if results.key? date
219
+ last_populated_date = date
220
+ else
221
+ _winner, last = results[last_populated_date]
222
+ results[date] = [last, last]
241
223
  end
242
-
243
- yield change if previous_change_time < next_day_start_time && change.time >= this_day_start_time
244
224
  end
225
+ results = results.transform_values(&:first)
226
+
227
+ # The requested date range may span outside the actual changes we find in the changelog
228
+ date_of_first_change = blocked_stalled_changes[0].time.to_date
229
+ date_of_last_change = blocked_stalled_changes[-1].time.to_date
230
+ date_range.each do |date|
231
+ results[date] = blocked_stalled_changes[0] if date < date_of_first_change
232
+ results[date] = blocked_stalled_changes[-1] if date > date_of_last_change
233
+ end
234
+
235
+ # To make the code simpler, we've been accumulating data for every date. Now remove anything
236
+ # that isn't in the requested date_range
237
+ results.select! { |date, _value| date_range.include? date }
238
+
239
+ results
245
240
  end
246
241
 
247
- def blocked_stalled_changes end_time:, settings: @board.project_config.settings
242
+ def blocked_stalled_changes end_time:, settings: nil
243
+ settings ||= @board.project_config.settings
244
+
248
245
  blocked_statuses = settings['blocked_statuses']
249
246
  stalled_statuses = settings['stalled_statuses']
250
247
  unless blocked_statuses.is_a?(Array) && stalled_statuses.is_a?(Array)
@@ -258,7 +255,7 @@ class Issue
258
255
  blocking_issue_keys = []
259
256
 
260
257
  result = []
261
- previous_was_active = true
258
+ previous_was_active = false # Must start as false so that the creation will insert an :active
262
259
  previous_change_time = created
263
260
 
264
261
  blocking_status = nil
@@ -267,6 +264,7 @@ class Issue
267
264
  # This mock change is to force the writing of one last entry at the end of the time range.
268
265
  # By doing this, we're able to eliminate a lot of duplicated code in charts.
269
266
  mock_change = ChangeItem.new time: end_time, author: '', artificial: true, raw: { 'field' => '' }
267
+
270
268
  (changes + [mock_change]).each do |change|
271
269
  previous_was_active = false if check_for_stalled(
272
270
  change_time: change.time,
@@ -328,6 +326,7 @@ class Issue
328
326
  stalled_days: result[-1].stalled_days
329
327
  )
330
328
  end
329
+
331
330
  result
332
331
  end
333
332
 
@@ -337,6 +336,9 @@ class Issue
337
336
  # The most common case will be nothing to split so quick escape.
338
337
  return false if (change_time - previous_change_time).to_i < stalled_threshold_seconds
339
338
 
339
+ # If the last identified change was blocked then it doesn't matter now long we've waited, we're still blocked.
340
+ return false if blocking_stalled_changes[-1]&.blocked?
341
+
340
342
  list = [previous_change_time..change_time]
341
343
  all_subtask_activity_times.each do |time|
342
344
  matching_range = list.find { |range| time >= range.begin && time <= range.end }
@@ -478,6 +480,31 @@ class Issue
478
480
  comparison
479
481
  end
480
482
 
483
+ def dump
484
+ result = +''
485
+ result << "#{key} (#{type}): #{compact_text summary, 200}\n"
486
+
487
+ assignee = raw['fields']['assignee']
488
+ result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
489
+
490
+ raw['fields']['issuelinks'].each do |link|
491
+ result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
492
+ result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
493
+ end
494
+ changes.each do |change|
495
+ value = change.value
496
+ old_value = change.old_value
497
+
498
+ message = " [change] #{change.time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{change.field}] "
499
+ message << "#{compact_text(old_value).inspect} -> " unless old_value.nil? || old_value.empty?
500
+ message << compact_text(value).inspect
501
+ message << " (#{change.author})"
502
+ message << ' <<artificial entry>>' if change.artificial?
503
+ result << message << "\n"
504
+ end
505
+ result
506
+ end
507
+
481
508
  private
482
509
 
483
510
  def assemble_author raw
@@ -508,4 +535,49 @@ class Issue
508
535
  @changes << ChangeItem.new(raw: raw, time: created, author: author, artificial: true)
509
536
  end
510
537
  end
538
+
539
+ def compact_text text, max = 60
540
+ return nil if text.nil?
541
+
542
+ text = text.gsub(/\s+/, ' ').strip
543
+ text = "#{text[0..max]}..." if text.length > max
544
+ text
545
+ end
546
+
547
+ def sort_changes!
548
+ @changes.sort! do |a, b|
549
+ # It's common that a resolved will happen at the same time as a status change.
550
+ # Put them in a defined order so tests can be deterministic.
551
+ compare = a.time <=> b.time
552
+ if compare.zero?
553
+ compare = 1 if a.resolution?
554
+ compare = -1 if b.resolution?
555
+ end
556
+ compare
557
+ end
558
+ end
559
+
560
+ def fabricate_change field_name:
561
+ first_status = nil
562
+ first_status_id = nil
563
+
564
+ created_time = parse_time @raw['fields']['created']
565
+ first_change = @changes.find { |change| change.field == field_name }
566
+ if first_change.nil?
567
+ # There have been no changes of this type yet so we have to look at the current one
568
+ return nil unless @raw['fields'][field_name]
569
+
570
+ first_status = @raw['fields'][field_name]['name']
571
+ first_status_id = @raw['fields'][field_name]['id'].to_i
572
+ else
573
+ # Otherwise, we look at what the first one had changed away from.
574
+ first_status = first_change.old_value
575
+ first_status_id = first_change.old_value_id
576
+ end
577
+ ChangeItem.new time: created_time, artificial: true, author: author, raw: {
578
+ 'field' => field_name,
579
+ 'to' => first_status_id,
580
+ 'toString' => first_status
581
+ }
582
+ end
511
583
  end
@@ -49,7 +49,7 @@ class ProjectConfig
49
49
  end
50
50
 
51
51
  def load_settings
52
- JSON.parse(File.read(File.join(__dir__, 'settings.json')))
52
+ JSON.parse(file_system.load(File.join(__dir__, 'settings.json')))
53
53
  end
54
54
 
55
55
  def guess_project_id
@@ -89,6 +89,8 @@ class ProjectConfig
89
89
  raise 'Not allowed to have both an aggregate and a download section. Pick only one.' if @download_config
90
90
 
91
91
  @aggregate_config = AggregateConfig.new project_config: self, block: block
92
+
93
+ # Processing of aggregates should only happen during the export
92
94
  return if @exporter.downloading?
93
95
 
94
96
  @aggregate_config.evaluate_next_level
@@ -120,7 +122,7 @@ class ProjectConfig
120
122
 
121
123
  def load_board board_id:, filename:
122
124
  board = Board.new(
123
- raw: JSON.parse(File.read(filename)), possible_statuses: @possible_statuses
125
+ raw: JSON.parse(file_system.load(filename)), possible_statuses: @possible_statuses
124
126
  )
125
127
  board.project_config = self
126
128
  @all_boards[board_id] = board
@@ -162,7 +164,7 @@ class ProjectConfig
162
164
  # We may not always have this file. Load it if we can.
163
165
  return unless File.exist? filename
164
166
 
165
- statuses = JSON.parse(File.read(filename))
167
+ statuses = JSON.parse(file_system.load(filename))
166
168
  .map { |snippet| Status.new(raw: snippet) }
167
169
  statuses
168
170
  .find_all { |status| status.global? }
@@ -178,7 +180,7 @@ class ProjectConfig
178
180
 
179
181
  board_id = $1.to_i
180
182
  timezone_offset = exporter.timezone_offset
181
- JSON.parse(File.read("#{target_path}#{file}"))['values'].each do |json|
183
+ JSON.parse(file_system.load("#{target_path}#{file}"))['values'].each do |json|
182
184
  @all_boards[board_id].sprints << Sprint.new(raw: json, timezone_offset: timezone_offset)
183
185
  end
184
186
  end
@@ -231,7 +233,7 @@ class ProjectConfig
231
233
 
232
234
  def load_project_metadata
233
235
  filename = "#{@target_path}/#{file_prefix}_meta.json"
234
- json = JSON.parse(File.read(filename))
236
+ json = JSON.parse(file_system.load(filename))
235
237
 
236
238
  @data_version = json['version'] || 1
237
239
 
@@ -247,7 +249,7 @@ class ProjectConfig
247
249
 
248
250
  def to_time string, end_of_day: false
249
251
  time = end_of_day ? '23:59:59' : '00:00:00'
250
- string = "#{string}T#{time}#{@timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
252
+ string = "#{string}T#{time}#{exporter.timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
251
253
  Time.parse string
252
254
  end
253
255
 
@@ -360,7 +362,7 @@ class ProjectConfig
360
362
  default_board = nil
361
363
 
362
364
  group_filenames_and_board_ids(path: path).each do |filename, board_ids|
363
- content = File.read(File.join(path, filename))
365
+ content = file_system.load(File.join(path, filename))
364
366
  if board_ids == :unknown
365
367
  boards = [(default_board ||= find_default_board)]
366
368
  else
@@ -435,4 +437,8 @@ class ProjectConfig
435
437
  end
436
438
  exporter.file_system.log "Discarded data from #{issues_cutoff_times.count} issues out of a total #{issues.size}"
437
439
  end
440
+
441
+ def file_system
442
+ @exporter.file_system
443
+ end
438
444
  end
@@ -108,6 +108,17 @@ class SprintBurndown < ChartBase
108
108
  result
109
109
  end
110
110
 
111
+ def sprints_in_time_range board
112
+ board.sprints.select do |sprint|
113
+ sprint_end_time = sprint.completed_time || sprint.end_time
114
+ sprint_start_time = sprint.start_time
115
+ next false if sprint_start_time.nil?
116
+
117
+ time_range.include?(sprint_start_time) || time_range.include?(sprint_end_time) ||
118
+ (sprint_start_time < time_range.begin && sprint_end_time > time_range.end)
119
+ end || []
120
+ end
121
+
111
122
  # select all the changes that are relevant for the sprint. If this issue never appears in this sprint then return [].
112
123
  def changes_for_one_issue issue:, sprint:
113
124
  story_points = 0.0
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ class StatusNotFoundError < StandardError
4
+ end
5
+
3
6
  class StatusCollection
4
7
  def initialize
5
8
  @list = []
@@ -32,7 +35,7 @@ class StatusCollection
32
35
  next
33
36
  else
34
37
  all_status_names = @list.collect { |s| "#{s.name.inspect}:#{s.id.inspect}" }.uniq.sort.join(', ')
35
- raise "Status not found: \"#{name_or_id}\". Possible statuses are: #{all_status_names}"
38
+ raise StatusNotFoundError, "Status not found: \"#{name_or_id}\". Possible statuses are: #{all_status_names}"
36
39
  end
37
40
  end
38
41
 
@@ -5,7 +5,7 @@ class ThroughputChart < ChartBase
5
5
 
6
6
  attr_accessor :possible_statuses
7
7
 
8
- def initialize block = nil
8
+ def initialize block
9
9
  super()
10
10
 
11
11
  header_text 'Throughput Chart'
data/lib/jirametrics.rb CHANGED
@@ -61,7 +61,7 @@ class JiraMetrics < Thor
61
61
  require 'jirametrics/trend_line_calculator'
62
62
  require 'jirametrics/status'
63
63
  require 'jirametrics/issue_link'
64
- require 'jirametrics/story_point_accuracy_chart'
64
+ require 'jirametrics/estimate_accuracy_chart'
65
65
  require 'jirametrics/status_collection'
66
66
  require 'jirametrics/sprint'
67
67
  require 'jirametrics/issue'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jirametrics
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.1
4
+ version: 2.4pre1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Bowler
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-05-16 00:00:00.000000000 Z
11
+ date: 2024-07-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: random-word
@@ -87,11 +87,10 @@ files:
87
87
  - lib/jirametrics/discard_changes_before.rb
88
88
  - lib/jirametrics/download_config.rb
89
89
  - lib/jirametrics/downloader.rb
90
+ - lib/jirametrics/estimate_accuracy_chart.rb
90
91
  - lib/jirametrics/examples/aggregated_project.rb
91
92
  - lib/jirametrics/examples/standard_project.rb
92
93
  - lib/jirametrics/expedited_chart.rb
93
- - lib/jirametrics/experimental/generator.rb
94
- - lib/jirametrics/experimental/info.rb
95
94
  - lib/jirametrics/exporter.rb
96
95
  - lib/jirametrics/file_config.rb
97
96
  - lib/jirametrics/file_system.rb
@@ -107,12 +106,12 @@ files:
107
106
  - lib/jirametrics/html/cycletime_scatterplot.erb
108
107
  - lib/jirametrics/html/daily_wip_chart.erb
109
108
  - lib/jirametrics/html/data_quality_report.erb
109
+ - lib/jirametrics/html/estimate_accuracy_chart.erb
110
110
  - lib/jirametrics/html/expedited_chart.erb
111
111
  - lib/jirametrics/html/hierarchy_table.erb
112
112
  - lib/jirametrics/html/index.css
113
113
  - lib/jirametrics/html/index.erb
114
114
  - lib/jirametrics/html/sprint_burndown.erb
115
- - lib/jirametrics/html/story_point_accuracy_chart.erb
116
115
  - lib/jirametrics/html/throughput_chart.erb
117
116
  - lib/jirametrics/html_report_config.rb
118
117
  - lib/jirametrics/issue.rb
@@ -127,7 +126,6 @@ files:
127
126
  - lib/jirametrics/sprint_issue_change_data.rb
128
127
  - lib/jirametrics/status.rb
129
128
  - lib/jirametrics/status_collection.rb
130
- - lib/jirametrics/story_point_accuracy_chart.rb
131
129
  - lib/jirametrics/throughput_chart.rb
132
130
  - lib/jirametrics/tree_organizer.rb
133
131
  - lib/jirametrics/trend_line_calculator.rb
@@ -155,7 +153,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
155
153
  - !ruby/object:Gem::Version
156
154
  version: '0'
157
155
  requirements: []
158
- rubygems_version: 3.5.10
156
+ rubygems_version: 3.5.15
159
157
  signing_key:
160
158
  specification_version: 4
161
159
  summary: Extract Jira metrics
@@ -1,210 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'random-word'
4
- require 'require_all'
5
- require_all 'lib'
6
-
7
- def to_time date
8
- Time.new date.year, date.month, date.day, rand(0..23), rand(0..59), rand(0..59)
9
- end
10
-
11
- class FakeIssue
12
- @@issue_number = 1
13
- attr_reader :effort, :raw, :worker
14
-
15
- def initialize date:, type:, worker:
16
- @raw = {
17
- key: "FAKE-#{@@issue_number += 1}",
18
- changelog: {
19
- histories: []
20
- },
21
- fields: {
22
- created: to_time(date),
23
- updated: to_time(date),
24
- creator: {
25
- displayName: 'George Jetson'
26
- },
27
- issuetype: {
28
- name: type
29
- },
30
- status: {
31
- name: 'To Do',
32
- id: 1,
33
- statusCategory: {
34
- id: 2,
35
- name: 'To Do'
36
- }
37
- },
38
- priority: {
39
- name: ''
40
- },
41
- summary: RandomWord.phrases.next.gsub(_, ' '),
42
- issuelinks: [],
43
- fixVersions: []
44
- }
45
- }
46
-
47
- @workers = [worker]
48
- @effort = case type
49
- when 'Story'
50
- [1, 2, 3, 3, 3, 3, 4, 4, 4, 5, 6].sample
51
- else
52
- [1, 2, 3].sample
53
- end
54
- unblock
55
- @done = false
56
- @last_status = 'To Do'
57
- @last_status_id = 1
58
- change_status new_status: 'In Progress', new_status_id: 3, date: date
59
- end
60
-
61
- def blocked? = @blocked
62
- def block = @blocked = true
63
- def unblock = @blocked = false
64
-
65
- def key = @raw[:key]
66
-
67
- def do_work date:, effort:
68
- raise 'Already done' if done?
69
-
70
- @effort -= effort
71
- return unless done?
72
-
73
- change_status new_status: 'Done', new_status_id: 5, date: date
74
- # fix_change_timestamps
75
- end
76
-
77
- def fix_change_timestamps
78
- # since the timestamps have random hours, it's possible for them to be issued out of order. Sort them now
79
- changes = @raw[:changelog][:histories]
80
- times = [@raw[:fields][:created]] + changes.collect { |change| change[:created] }
81
- times.sort!
82
-
83
- @raw[:fields][:created] = times.shift
84
- @raw[:fields][:updated] = times[-1]
85
- changes.each do |change|
86
- change[:created] = times.shift
87
- end
88
- end
89
-
90
- def done? = @effort <= 0
91
-
92
- def change_status date:, new_status:, new_status_id:
93
- @raw[:changelog][:histories] << {
94
- author: {
95
- emailAddress: 'george@jetson.com',
96
- displayName: 'George Jetson'
97
- },
98
- created: to_time(date),
99
- items: [
100
- {
101
- field: 'status',
102
- fieldtype: 'jira',
103
- fieldId: 'status',
104
- from: @last_status_id,
105
- fromString: @last_status,
106
- to: new_status_id,
107
- toString: new_status
108
- }
109
- ]
110
- }
111
-
112
- @last_status = new_status
113
- @last_status_id = new_status_id
114
- end
115
- end
116
-
117
- class Worker
118
- attr_accessor :issue
119
- end
120
-
121
- class Generator
122
- def initialize
123
- @random = Random.new
124
- @file_prefix = 'fake'
125
- @target_path = 'target/'
126
-
127
- # @probability_work_will_be_pushed = 20
128
- @probability_unblocked_work_becomes_blocked = 20
129
- @probability_blocked_work_becomes_unblocked = 20
130
- @date_range = (Date.today - 500)..Date.today
131
- @issues = []
132
- @workers = []
133
- 5.times { @workers << Worker.new }
134
- end
135
-
136
- def run
137
- remove_old_files
138
- @date_range.each_with_index do |date, day|
139
- yield date, day if block_given?
140
- process_date(date, day) if (1..5).cover? date.wday # Weekday
141
- end
142
-
143
- @issues.each do |issue|
144
- issue.fix_change_timestamps
145
- File.open "target/fake_issues/#{issue.key}.json", 'w' do |file|
146
- file.puts JSON.pretty_generate(issue.raw)
147
- end
148
- end
149
-
150
- File.write 'target/fake_meta.json', JSON.pretty_generate({
151
- time_start: (@date_range.end - 90).to_time,
152
- time_end: @date_range.end.to_time,
153
- 'no-download': true
154
- })
155
- puts "Created #{@issues.size} fake issues"
156
- end
157
-
158
- def remove_old_files
159
- path = "#{@target_path}#{@file_prefix}_issues"
160
- Dir.foreach path do |file|
161
- next unless file.match?(/-\d+\.json$/)
162
-
163
- filename = "#{path}/#{file}"
164
- File.unlink filename
165
- end
166
- end
167
-
168
- def lucky? probability
169
- @random.rand(1..100) <= probability
170
- end
171
-
172
- def next_issue_for worker:, date:, type:
173
- # First look for something I already started
174
- issue = @issues.find { |i| i.worker == worker && !i.done? && !i.blocked? }
175
-
176
- # Then look for something that someone else started
177
- issue = @issues.find { |i| i.worker != worker && !i.done? && !i.blocked? } if issue.nil? && lucky?(40)
178
-
179
- # Then start new work
180
- issue = FakeIssue.new(date: date, type: type, worker: worker) if issue.nil?
181
-
182
- issue
183
- end
184
-
185
- def process_date date, _simulation_day
186
- @issues.each do |issue|
187
- if issue.blocked?
188
- issue.unblock if lucky? @probability_blocked_work_becomes_unblocked
189
- elsif lucky? @probability_unblocked_work_becomes_blocked
190
- issue.block
191
- end
192
- end
193
-
194
- possible_capacities = [0, 1, 1, 1, 2]
195
- @workers.each do |worker|
196
- worker_capacity = possible_capacities.sample
197
- if worker.issue.nil? || worker.issue.done?
198
- type = lucky?(89) ? 'Story' : 'Bug'
199
- worker.issue = next_issue_for worker: worker, date: date, type: type
200
- @issues << worker.issue
201
- end
202
-
203
- worker.issue = next_issue_for worker: worker, date: date, type: type if worker.issue.blocked?
204
- worker.issue.do_work date: date, effort: worker_capacity
205
- worker.issue = nil if worker.issue.done?
206
- end
207
- end
208
- end
209
-
210
- Generator.new.run if __FILE__ == $PROGRAM_NAME