jirametrics 2.2.1 → 2.4pre1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/jirametrics/aggregate_config.rb +13 -25
- data/lib/jirametrics/aging_work_bar_chart.rb +57 -39
- data/lib/jirametrics/aging_work_in_progress_chart.rb +1 -1
- data/lib/jirametrics/aging_work_table.rb +9 -26
- data/lib/jirametrics/blocked_stalled_change.rb +24 -4
- data/lib/jirametrics/board_config.rb +2 -2
- data/lib/jirametrics/change_item.rb +13 -5
- data/lib/jirametrics/chart_base.rb +27 -39
- data/lib/jirametrics/columns_config.rb +4 -0
- data/lib/jirametrics/cycletime_histogram.rb +1 -1
- data/lib/jirametrics/cycletime_scatterplot.rb +1 -1
- data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +3 -16
- data/lib/jirametrics/daily_wip_chart.rb +1 -13
- data/lib/jirametrics/data_quality_report.rb +4 -1
- data/lib/jirametrics/dependency_chart.rb +1 -1
- data/lib/jirametrics/{story_point_accuracy_chart.rb → estimate_accuracy_chart.rb} +31 -25
- data/lib/jirametrics/examples/standard_project.rb +1 -1
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +3 -3
- data/lib/jirametrics/file_config.rb +12 -8
- data/lib/jirametrics/file_system.rb +11 -2
- data/lib/jirametrics/groupable_issue_chart.rb +2 -4
- data/lib/jirametrics/hierarchy_table.rb +4 -4
- data/lib/jirametrics/html/aging_work_table.erb +3 -3
- data/lib/jirametrics/html/index.erb +1 -0
- data/lib/jirametrics/html_report_config.rb +61 -74
- data/lib/jirametrics/issue.rb +129 -57
- data/lib/jirametrics/project_config.rb +13 -7
- data/lib/jirametrics/sprint_burndown.rb +11 -0
- data/lib/jirametrics/status_collection.rb +4 -1
- data/lib/jirametrics/throughput_chart.rb +1 -1
- data/lib/jirametrics.rb +1 -1
- metadata +5 -7
- data/lib/jirametrics/experimental/generator.rb +0 -210
- data/lib/jirametrics/experimental/info.rb +0 -77
- /data/lib/jirametrics/html/{story_point_accuracy_chart.erb → estimate_accuracy_chart.erb} +0 -0
data/lib/jirametrics/issue.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
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
|
-
|
232
|
-
|
233
|
-
this_day_start_time = date.to_time
|
213
|
+
results[current_date] = [winning_change, change]
|
214
|
+
end
|
234
215
|
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
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:
|
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 =
|
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(
|
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(
|
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(
|
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(
|
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(
|
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}#{
|
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 =
|
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
|
|
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/
|
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.
|
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-
|
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.
|
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
|