jirametrics 2.6 → 2.12pre9
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 +9 -4
- data/lib/jirametrics/aging_work_bar_chart.rb +13 -11
- data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
- data/lib/jirametrics/aging_work_table.rb +54 -7
- data/lib/jirametrics/blocked_stalled_change.rb +1 -1
- data/lib/jirametrics/board.rb +47 -13
- data/lib/jirametrics/board_config.rb +7 -2
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/change_item.rb +19 -8
- data/lib/jirametrics/chart_base.rb +63 -27
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cycletime_config.rb +59 -8
- data/lib/jirametrics/cycletime_histogram.rb +68 -3
- data/lib/jirametrics/cycletime_scatterplot.rb +3 -6
- data/lib/jirametrics/daily_wip_by_age_chart.rb +2 -4
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +2 -2
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +0 -4
- data/lib/jirametrics/daily_wip_chart.rb +7 -9
- data/lib/jirametrics/data_quality_report.rb +219 -41
- data/lib/jirametrics/dependency_chart.rb +3 -4
- data/lib/jirametrics/download_config.rb +2 -2
- data/lib/jirametrics/downloader.rb +57 -37
- data/lib/jirametrics/estimate_accuracy_chart.rb +35 -12
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +3 -6
- data/lib/jirametrics/examples/standard_project.rb +14 -13
- data/lib/jirametrics/expedited_chart.rb +7 -8
- data/lib/jirametrics/exporter.rb +28 -13
- data/lib/jirametrics/file_config.rb +23 -6
- data/lib/jirametrics/file_system.rb +41 -4
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
- data/lib/jirametrics/groupable_issue_chart.rb +1 -3
- data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
- data/lib/jirametrics/html/aging_work_table.erb +6 -4
- data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
- data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
- data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
- data/lib/jirametrics/html/expedited_chart.erb +1 -10
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
- data/lib/jirametrics/html/hierarchy_table.erb +1 -1
- data/lib/jirametrics/html/index.css +28 -5
- data/lib/jirametrics/html/index.erb +8 -4
- data/lib/jirametrics/html/sprint_burndown.erb +1 -10
- data/lib/jirametrics/html/throughput_chart.erb +1 -10
- data/lib/jirametrics/html_report_config.rb +33 -23
- data/lib/jirametrics/issue.rb +170 -54
- data/lib/jirametrics/jira_gateway.rb +16 -3
- data/lib/jirametrics/project_config.rb +251 -135
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/sprint_burndown.rb +38 -36
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +81 -26
- data/lib/jirametrics/status_collection.rb +77 -39
- data/lib/jirametrics/throughput_chart.rb +1 -1
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics.rb +23 -6
- metadata +11 -13
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -13,6 +13,12 @@ class Issue
|
|
|
13
13
|
@changes = []
|
|
14
14
|
@board = board
|
|
15
15
|
|
|
16
|
+
# We only check for this here because if a board isn't passed in then things will fail much
|
|
17
|
+
# later and be hard to find. Let's find out early.
|
|
18
|
+
raise "No board for issue #{key}" if board.nil?
|
|
19
|
+
|
|
20
|
+
# There are cases where we create an Issue of fragments like linked issues and those won't have
|
|
21
|
+
# changelogs.
|
|
16
22
|
return unless @raw['changelog']
|
|
17
23
|
|
|
18
24
|
load_history_into_changes
|
|
@@ -43,14 +49,26 @@ class Issue
|
|
|
43
49
|
|
|
44
50
|
def summary = @raw['fields']['summary']
|
|
45
51
|
|
|
46
|
-
def status = Status.new(raw: @raw['fields']['status'])
|
|
47
|
-
|
|
48
52
|
def labels = @raw['fields']['labels'] || []
|
|
49
53
|
|
|
50
54
|
def author = @raw['fields']['creator']&.[]('displayName') || ''
|
|
51
55
|
|
|
52
56
|
def resolution = @raw['fields']['resolution']&.[]('name')
|
|
53
57
|
|
|
58
|
+
def status
|
|
59
|
+
@status = Status.from_raw(@raw['fields']['status']) unless @status
|
|
60
|
+
@status
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def status= status
|
|
64
|
+
@status = status
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def due_date
|
|
68
|
+
text = @raw['fields']['duedate']
|
|
69
|
+
text.nil? ? nil : Date.parse(text)
|
|
70
|
+
end
|
|
71
|
+
|
|
54
72
|
def url
|
|
55
73
|
# Strangely, the URL isn't anywhere in the returned data so we have to fabricate it.
|
|
56
74
|
"#{@board.server_url_prefix}/browse/#{key}"
|
|
@@ -65,35 +83,43 @@ class Issue
|
|
|
65
83
|
end
|
|
66
84
|
|
|
67
85
|
def first_time_in_status *status_names
|
|
68
|
-
@changes.find { |change| change.current_status_matches(*status_names) }
|
|
86
|
+
@changes.find { |change| change.current_status_matches(*status_names) }
|
|
69
87
|
end
|
|
70
88
|
|
|
71
89
|
def first_time_not_in_status *status_names
|
|
72
|
-
@changes.find { |change| change.status? && status_names.include?(change.value) == false }
|
|
90
|
+
@changes.find { |change| change.status? && status_names.include?(change.value) == false }
|
|
73
91
|
end
|
|
74
92
|
|
|
75
93
|
def first_time_in_or_right_of_column column_name
|
|
76
94
|
first_time_in_status(*board.status_ids_in_or_right_of_column(column_name))
|
|
77
95
|
end
|
|
78
96
|
|
|
97
|
+
def first_time_label_added *labels
|
|
98
|
+
@changes.each do |change|
|
|
99
|
+
next unless change.labels?
|
|
100
|
+
|
|
101
|
+
change_labels = change.value.split
|
|
102
|
+
return change if change_labels.any? { |l| labels.include?(l) }
|
|
103
|
+
end
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
79
107
|
def still_in_or_right_of_column column_name
|
|
80
108
|
still_in_status(*board.status_ids_in_or_right_of_column(column_name))
|
|
81
109
|
end
|
|
82
110
|
|
|
83
111
|
def still_in
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
next unless change.status?
|
|
87
|
-
|
|
112
|
+
result = nil
|
|
113
|
+
status_changes.each do |change|
|
|
88
114
|
current_status_matched = yield change
|
|
89
115
|
|
|
90
|
-
if current_status_matched &&
|
|
91
|
-
|
|
92
|
-
elsif !current_status_matched &&
|
|
93
|
-
|
|
116
|
+
if current_status_matched && result.nil?
|
|
117
|
+
result = change
|
|
118
|
+
elsif !current_status_matched && result
|
|
119
|
+
result = nil
|
|
94
120
|
end
|
|
95
121
|
end
|
|
96
|
-
|
|
122
|
+
result
|
|
97
123
|
end
|
|
98
124
|
private :still_in
|
|
99
125
|
|
|
@@ -106,58 +132,75 @@ class Issue
|
|
|
106
132
|
|
|
107
133
|
# If it ever entered one of these categories and it's still there then what was the last time it entered
|
|
108
134
|
def still_in_status_category *category_names
|
|
135
|
+
category_ids = find_status_category_ids_by_names category_names
|
|
136
|
+
|
|
109
137
|
still_in do |change|
|
|
110
|
-
status =
|
|
111
|
-
|
|
138
|
+
status = find_or_create_status id: change.value_id, name: change.value
|
|
139
|
+
category_ids.include? status.category.id
|
|
112
140
|
end
|
|
113
141
|
end
|
|
114
142
|
|
|
115
143
|
def most_recent_status_change
|
|
116
|
-
|
|
144
|
+
# Any issue that we loaded from its own file will always have a status as we artificially insert a status
|
|
145
|
+
# change to represent creation. Issues that were just fragments referenced from a main issue (ie a linked issue)
|
|
146
|
+
# may not have any status changes as we have no idea when it was created. This will be nil in that case
|
|
147
|
+
status_changes.last
|
|
117
148
|
end
|
|
118
149
|
|
|
119
|
-
# Are we currently in this status? If yes, then return the
|
|
150
|
+
# Are we currently in this status? If yes, then return the most recent status change.
|
|
120
151
|
def currently_in_status *status_names
|
|
121
152
|
change = most_recent_status_change
|
|
122
153
|
return false if change.nil?
|
|
123
154
|
|
|
124
|
-
change
|
|
155
|
+
change if change.current_status_matches(*status_names)
|
|
125
156
|
end
|
|
126
157
|
|
|
127
|
-
# Are we currently in this status category? If yes, then return the
|
|
158
|
+
# Are we currently in this status category? If yes, then return the most recent status change.
|
|
128
159
|
def currently_in_status_category *category_names
|
|
160
|
+
category_ids = find_status_category_ids_by_names category_names
|
|
161
|
+
|
|
129
162
|
change = most_recent_status_change
|
|
130
163
|
return false if change.nil?
|
|
131
164
|
|
|
132
|
-
status =
|
|
133
|
-
change
|
|
165
|
+
status = find_or_create_status id: change.value_id, name: change.value
|
|
166
|
+
change if status && category_ids.include?(status.category.id)
|
|
134
167
|
end
|
|
135
168
|
|
|
136
|
-
def
|
|
137
|
-
status = board.possible_statuses.
|
|
138
|
-
|
|
169
|
+
def find_or_create_status id:, name:
|
|
170
|
+
status = board.possible_statuses.find_by_id(id)
|
|
171
|
+
|
|
172
|
+
unless status
|
|
173
|
+
# Have to pull this list before the call to fabricate or else the warning will incorrectly
|
|
174
|
+
# list this status as one it actually found
|
|
175
|
+
found_statuses = board.possible_statuses.to_s
|
|
176
|
+
|
|
177
|
+
status = board.possible_statuses.fabricate_status_for id: id, name: name
|
|
178
|
+
|
|
179
|
+
message = +'The history for issue '
|
|
180
|
+
message << key
|
|
181
|
+
message << ' references the status ('
|
|
182
|
+
message << "#{name.inspect}:#{id.inspect}"
|
|
183
|
+
message << ') that can\'t be found. We are guessing that this belongs to the '
|
|
184
|
+
message << status.category.to_s
|
|
185
|
+
message << ' status category but that may be wrong. See https://jirametrics.org/faq/#q1 for more '
|
|
186
|
+
message << 'details on defining statuses.'
|
|
187
|
+
board.project_config.file_system.warning message, more: "The statuses we did find are: #{found_statuses}"
|
|
188
|
+
end
|
|
139
189
|
|
|
140
|
-
@board.project_config.file_system.log(
|
|
141
|
-
"Warning: Status name #{name.inspect} for issue #{key} not found in" \
|
|
142
|
-
" #{board.possible_statuses.collect(&:name).inspect}" \
|
|
143
|
-
"\n See Q1 in the FAQ for more details: https://github.com/mikebowler/jirametrics/wiki/FAQ\n",
|
|
144
|
-
also_write_to_stderr: true
|
|
145
|
-
)
|
|
146
|
-
status = Status.new(name: name, category_name: 'In Progress')
|
|
147
|
-
board.possible_statuses << status
|
|
148
190
|
status
|
|
149
191
|
end
|
|
150
192
|
|
|
151
193
|
def first_status_change_after_created
|
|
152
|
-
|
|
194
|
+
status_changes.find { |change| change.artificial? == false }
|
|
153
195
|
end
|
|
154
196
|
|
|
155
197
|
def first_time_in_status_category *category_names
|
|
156
|
-
|
|
157
|
-
next unless change.status?
|
|
198
|
+
category_ids = find_status_category_ids_by_names category_names
|
|
158
199
|
|
|
159
|
-
|
|
160
|
-
|
|
200
|
+
status_changes.each do |change|
|
|
201
|
+
to_status = find_or_create_status(id: change.value_id, name: change.value)
|
|
202
|
+
id = to_status.category.id
|
|
203
|
+
return change if category_ids.include? id
|
|
161
204
|
end
|
|
162
205
|
nil
|
|
163
206
|
end
|
|
@@ -176,11 +219,11 @@ class Issue
|
|
|
176
219
|
end
|
|
177
220
|
|
|
178
221
|
def first_resolution
|
|
179
|
-
@changes.find { |change| change.resolution? }
|
|
222
|
+
@changes.find { |change| change.resolution? }
|
|
180
223
|
end
|
|
181
224
|
|
|
182
225
|
def last_resolution
|
|
183
|
-
@changes.reverse.find { |change| change.resolution? }
|
|
226
|
+
@changes.reverse.find { |change| change.resolution? }
|
|
184
227
|
end
|
|
185
228
|
|
|
186
229
|
def assigned_to
|
|
@@ -259,7 +302,7 @@ class Issue
|
|
|
259
302
|
|
|
260
303
|
blocked_link_texts = settings['blocked_link_text']
|
|
261
304
|
stalled_threshold = settings['stalled_threshold_days']
|
|
262
|
-
flagged_means_blocked = !!settings['flagged_means_blocked']
|
|
305
|
+
flagged_means_blocked = !!settings['flagged_means_blocked'] # rubocop:disable Style/DoubleNegation
|
|
263
306
|
|
|
264
307
|
blocking_issue_keys = []
|
|
265
308
|
|
|
@@ -373,12 +416,11 @@ class Issue
|
|
|
373
416
|
|
|
374
417
|
# return [number of active seconds, total seconds] that this issue had up to the end_time.
|
|
375
418
|
# It does not include data before issue start or after issue end
|
|
376
|
-
def flow_efficiency_numbers end_time:, settings:
|
|
377
|
-
issue_start = @board.cycletime.
|
|
419
|
+
def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
|
|
420
|
+
issue_start, issue_stop = @board.cycletime.started_stopped_times(self)
|
|
378
421
|
return [0.0, 0.0] if !issue_start || issue_start > end_time
|
|
379
422
|
|
|
380
423
|
value_add_time = 0.0
|
|
381
|
-
issue_stop = @board.cycletime.stopped_time(self)
|
|
382
424
|
end_time = issue_stop if issue_stop && issue_stop < end_time
|
|
383
425
|
|
|
384
426
|
active_start = nil
|
|
@@ -542,6 +584,20 @@ class Issue
|
|
|
542
584
|
comparison
|
|
543
585
|
end
|
|
544
586
|
|
|
587
|
+
def discard_changes_before cutoff_time
|
|
588
|
+
rejected_any = false
|
|
589
|
+
@changes.reject! do |change|
|
|
590
|
+
reject = change.status? && change.time <= cutoff_time && change.artificial? == false
|
|
591
|
+
if reject
|
|
592
|
+
(@discarded_changes ||= []) << change
|
|
593
|
+
rejected_any = true
|
|
594
|
+
end
|
|
595
|
+
reject
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
(@discarded_change_times ||= []) << cutoff_time if rejected_any
|
|
599
|
+
end
|
|
600
|
+
|
|
545
601
|
def dump
|
|
546
602
|
result = +''
|
|
547
603
|
result << "#{key} (#{type}): #{compact_text summary, 200}\n"
|
|
@@ -549,21 +605,68 @@ class Issue
|
|
|
549
605
|
assignee = raw['fields']['assignee']
|
|
550
606
|
result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
|
|
551
607
|
|
|
552
|
-
raw['fields']['issuelinks']
|
|
608
|
+
raw['fields']['issuelinks']&.each do |link|
|
|
553
609
|
result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
|
|
554
610
|
result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
|
|
555
611
|
end
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
612
|
+
history = [] # time, type, detail
|
|
613
|
+
|
|
614
|
+
if board.cycletime
|
|
615
|
+
started_at, stopped_at = board.cycletime.started_stopped_times(self)
|
|
616
|
+
history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
|
|
617
|
+
history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
|
|
618
|
+
else
|
|
619
|
+
result << " Unable to determine start/end times as board #{board.id} has no cycletime specified\n"
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
@discarded_change_times&.each do |time|
|
|
623
|
+
history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
(changes + (@discarded_changes || [])).each do |change|
|
|
627
|
+
if change.status?
|
|
628
|
+
value = "#{change.value.inspect}:#{change.value_id.inspect}"
|
|
629
|
+
old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
|
|
630
|
+
else
|
|
631
|
+
value = compact_text(change.value).inspect
|
|
632
|
+
old_value = change.old_value ? compact_text(change.old_value).inspect : nil
|
|
633
|
+
end
|
|
559
634
|
|
|
560
|
-
message =
|
|
561
|
-
message << "#{
|
|
562
|
-
message <<
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
635
|
+
message = +''
|
|
636
|
+
message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
|
|
637
|
+
message << value
|
|
638
|
+
if change.artificial?
|
|
639
|
+
message << ' (Artificial entry)' if change.artificial?
|
|
640
|
+
else
|
|
641
|
+
message << " (Author: #{change.author})"
|
|
642
|
+
end
|
|
643
|
+
history << [change.time, change.field, message, change.artificial?]
|
|
566
644
|
end
|
|
645
|
+
|
|
646
|
+
result << " History:\n"
|
|
647
|
+
type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
|
|
648
|
+
history.sort! do |a, b|
|
|
649
|
+
if a[0] == b[0]
|
|
650
|
+
if a[1].nil?
|
|
651
|
+
1
|
|
652
|
+
elsif b[1].nil?
|
|
653
|
+
-1
|
|
654
|
+
else
|
|
655
|
+
a[1] <=> b[1]
|
|
656
|
+
end
|
|
657
|
+
else
|
|
658
|
+
a[0] <=> b[0]
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
history.each do |time, type, detail, _artificial|
|
|
662
|
+
if type.nil?
|
|
663
|
+
type = '-' * type_width
|
|
664
|
+
else
|
|
665
|
+
type = (' ' * (type_width - type.length)) << type
|
|
666
|
+
end
|
|
667
|
+
result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
|
|
668
|
+
end
|
|
669
|
+
|
|
567
670
|
result
|
|
568
671
|
end
|
|
569
672
|
|
|
@@ -572,12 +675,16 @@ class Issue
|
|
|
572
675
|
# This was probably loaded as a linked issue, which means we don't know what board it really
|
|
573
676
|
# belonged to. The best we can do is look at the status category. This case should be rare but
|
|
574
677
|
# it can happen.
|
|
575
|
-
status.
|
|
678
|
+
status.category.name == 'Done'
|
|
576
679
|
else
|
|
577
680
|
board.cycletime.done? self
|
|
578
681
|
end
|
|
579
682
|
end
|
|
580
683
|
|
|
684
|
+
def status_changes
|
|
685
|
+
@changes.select { |change| change.status? }
|
|
686
|
+
end
|
|
687
|
+
|
|
581
688
|
private
|
|
582
689
|
|
|
583
690
|
def assemble_author raw
|
|
@@ -653,4 +760,13 @@ class Issue
|
|
|
653
760
|
'toString' => first_status
|
|
654
761
|
}
|
|
655
762
|
end
|
|
763
|
+
|
|
764
|
+
def find_status_category_ids_by_names category_names
|
|
765
|
+
category_names.filter_map do |name|
|
|
766
|
+
list = board.possible_statuses.find_all_categories_by_name name
|
|
767
|
+
raise "No status categories found for name: #{name}" if list.empty?
|
|
768
|
+
|
|
769
|
+
list
|
|
770
|
+
end.flatten.collect(&:id)
|
|
771
|
+
end
|
|
656
772
|
end
|
|
@@ -14,9 +14,15 @@ class JiraGateway
|
|
|
14
14
|
def call_url relative_url:
|
|
15
15
|
command = make_curl_command url: "#{@jira_url}#{relative_url}"
|
|
16
16
|
result = call_command command
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
begin
|
|
18
|
+
json = JSON.parse(result)
|
|
19
|
+
rescue # rubocop:disable Style/RescueStandardError
|
|
20
|
+
raise "Error when parsing result: #{result.inspect}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
raise "Download failed with: #{JSON.pretty_generate(json)}" unless json_successful?(json)
|
|
24
|
+
|
|
25
|
+
json
|
|
20
26
|
end
|
|
21
27
|
|
|
22
28
|
def call_command command
|
|
@@ -61,4 +67,11 @@ class JiraGateway
|
|
|
61
67
|
command << " --url \"#{url}\""
|
|
62
68
|
command
|
|
63
69
|
end
|
|
70
|
+
|
|
71
|
+
def json_successful? json
|
|
72
|
+
return false if json.is_a?(Hash) && (json['error'] || json['errorMessages'] || json['errorMessage'])
|
|
73
|
+
return false if json.is_a?(Array) && json.first == 'errorMessage'
|
|
74
|
+
|
|
75
|
+
true
|
|
76
|
+
end
|
|
64
77
|
end
|