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.
- 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
|