jirametrics 2.2.1 → 2.4pre1

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