jirametrics 2.8 → 2.10
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 +1 -1
- data/lib/jirametrics/aging_work_bar_chart.rb +7 -5
- data/lib/jirametrics/board.rb +2 -3
- data/lib/jirametrics/board_config.rb +6 -2
- data/lib/jirametrics/chart_base.rb +36 -9
- data/lib/jirametrics/cycletime_config.rb +10 -4
- data/lib/jirametrics/cycletime_histogram.rb +65 -2
- data/lib/jirametrics/data_quality_report.rb +53 -34
- data/lib/jirametrics/downloader.rb +0 -14
- data/lib/jirametrics/examples/standard_project.rb +2 -2
- data/lib/jirametrics/exporter.rb +10 -20
- data/lib/jirametrics/file_config.rb +21 -4
- data/lib/jirametrics/file_system.rb +23 -4
- 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_table.erb +1 -1
- 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/hierarchy_table.erb +1 -1
- 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 +18 -23
- data/lib/jirametrics/issue.rb +51 -27
- data/lib/jirametrics/jira_gateway.rb +16 -3
- data/lib/jirametrics/project_config.rb +102 -45
- data/lib/jirametrics/status.rb +23 -7
- data/lib/jirametrics/status_collection.rb +69 -68
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics.rb +0 -1
- metadata +4 -9
- data/lib/jirametrics/discard_changes_before.rb +0 -37
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f6d6dd1e6c2811b1395ef0ce25d1458db4c3b88c2e4ab774e6c2db045549e51e
|
|
4
|
+
data.tar.gz: 53d614e48e61a7fad285d72de82f6f59a0c9024c33f39b36478c7c01866150c2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '079f3d1f72a7d4cfd3d3da08d508cacfe4f04d6926e3674d5c6654cb290b8f1a1d7bbdb585bc587b743a888b85a19714294cdd15a9f891e2a7b52f5303b70d6f'
|
|
7
|
+
data.tar.gz: 9a6bdfbe92dc04d8264efad3d6be1569383f604968fa3c83fbda33fb20b25210b545cb86102a1e51926348f58aad97328c119be772e15f72090932fc40fff5d4
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
require 'jirametrics/chart_base'
|
|
4
4
|
|
|
5
5
|
class AgingWorkBarChart < ChartBase
|
|
6
|
-
@@next_id = 0
|
|
7
|
-
|
|
8
6
|
def initialize block
|
|
9
7
|
super()
|
|
10
8
|
|
|
@@ -116,7 +114,7 @@ class AgingWorkBarChart < ChartBase
|
|
|
116
114
|
issue.changes.each do |change|
|
|
117
115
|
next unless change.status?
|
|
118
116
|
|
|
119
|
-
status = issue.
|
|
117
|
+
status = issue.find_or_create_status id: change.value_id, name: change.value
|
|
120
118
|
|
|
121
119
|
unless previous_start.nil? || previous_start < issue_started_time
|
|
122
120
|
hash = {
|
|
@@ -162,8 +160,12 @@ class AgingWorkBarChart < ChartBase
|
|
|
162
160
|
end
|
|
163
161
|
|
|
164
162
|
def one_block_change_data_set starting_change:, ending_time:, issue_label:, stack:, issue_start_time:
|
|
165
|
-
|
|
166
|
-
|
|
163
|
+
if settings['blocked_color']
|
|
164
|
+
file_system.deprecated message: 'blocked color should be set via css now', date: '2024-05-03'
|
|
165
|
+
end
|
|
166
|
+
if settings['stalled_color']
|
|
167
|
+
file_system.deprecated message: 'stalled color should be set via css now', date: '2024-05-03'
|
|
168
|
+
end
|
|
167
169
|
|
|
168
170
|
color = settings['blocked_color'] || '--blocked-color'
|
|
169
171
|
color = settings['stalled_color'] || '--stalled-color' if starting_change.stalled?
|
data/lib/jirametrics/board.rb
CHANGED
|
@@ -27,9 +27,8 @@ class Board
|
|
|
27
27
|
def backlog_statuses
|
|
28
28
|
if @backlog_statuses.empty? && kanban?
|
|
29
29
|
status_ids = status_ids_from_column raw['columnConfig']['columns'].first
|
|
30
|
-
@backlog_statuses =
|
|
31
|
-
|
|
32
|
-
# we can do about it. Ignore it.
|
|
30
|
+
@backlog_statuses = status_ids.filter_map do |id|
|
|
31
|
+
@possible_statuses.find_by_id id
|
|
33
32
|
end
|
|
34
33
|
end
|
|
35
34
|
@backlog_statuses
|
|
@@ -21,11 +21,15 @@ class BoardConfig
|
|
|
21
21
|
'If so, remove it from there.'
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
@board.cycletime = CycleTimeConfig.new(
|
|
24
|
+
@board.cycletime = CycleTimeConfig.new(
|
|
25
|
+
parent_config: self, label: label, block: block, file_system: project_config.file_system
|
|
26
|
+
)
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def expedited_priority_names *priority_names
|
|
28
|
-
deprecated
|
|
30
|
+
project_config.exporter.file_system.deprecated(
|
|
31
|
+
date: '2024-09-15', message: 'Expedited priority names are now specified in settings'
|
|
32
|
+
)
|
|
29
33
|
@project_config.settings['expedited_priority_names'] = priority_names
|
|
30
34
|
end
|
|
31
35
|
end
|
|
@@ -27,9 +27,6 @@ class ChartBase
|
|
|
27
27
|
|
|
28
28
|
def html_directory
|
|
29
29
|
pathname = Pathname.new(File.realpath(__FILE__))
|
|
30
|
-
# basename = pathname.basename.to_s
|
|
31
|
-
# raise "Unexpected filename #{basename.inspect}" unless basename.match?(/^(.+)\.rb$/)
|
|
32
|
-
|
|
33
30
|
"#{pathname.dirname}/html"
|
|
34
31
|
end
|
|
35
32
|
|
|
@@ -133,6 +130,21 @@ class ChartBase
|
|
|
133
130
|
result
|
|
134
131
|
end
|
|
135
132
|
|
|
133
|
+
def working_days_annotation
|
|
134
|
+
holidays.each_with_index.collect do |range, index|
|
|
135
|
+
<<~TEXT
|
|
136
|
+
holiday#{index}: {
|
|
137
|
+
drawTime: 'beforeDraw',
|
|
138
|
+
type: 'box',
|
|
139
|
+
xMin: '#{range.begin}T00:00:00',
|
|
140
|
+
xMax: '#{range.end}T23:59:59',
|
|
141
|
+
backgroundColor: #{CssVariable.new('--non-working-days-color').to_json},
|
|
142
|
+
borderColor: #{CssVariable.new('--non-working-days-color').to_json}
|
|
143
|
+
},
|
|
144
|
+
TEXT
|
|
145
|
+
end.join
|
|
146
|
+
end
|
|
147
|
+
|
|
136
148
|
# Return only the board columns for the current board.
|
|
137
149
|
def current_board
|
|
138
150
|
if @board_id.nil?
|
|
@@ -179,18 +191,33 @@ class ChartBase
|
|
|
179
191
|
@description_text
|
|
180
192
|
end
|
|
181
193
|
|
|
194
|
+
# Convert a number like 1234567 into the string "1,234,567"
|
|
182
195
|
def format_integer number
|
|
183
196
|
number.to_s.reverse.scan(/.{1,3}/).join(',').reverse
|
|
184
197
|
end
|
|
185
198
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
199
|
+
# object will be either a Status or a ChangeItem
|
|
200
|
+
# if it's a ChangeItem then use_old_status will specify whether we're using the new or old
|
|
201
|
+
# Either way, is_category will format the category rather than the status
|
|
202
|
+
def format_status object, board:, is_category: false, use_old_status: false
|
|
203
|
+
status = nil
|
|
204
|
+
error_message = nil
|
|
205
|
+
|
|
206
|
+
case object
|
|
207
|
+
when ChangeItem
|
|
208
|
+
id = use_old_status ? object.old_value_id : object.value_id
|
|
209
|
+
status = board.possible_statuses.find_by_id(id)
|
|
210
|
+
if status.nil?
|
|
211
|
+
error_message = use_old_status ? object.old_value : object.value
|
|
212
|
+
end
|
|
213
|
+
when Status
|
|
214
|
+
status = object
|
|
215
|
+
else
|
|
216
|
+
raise "Unexpected type: #{object.class}"
|
|
191
217
|
end
|
|
192
218
|
|
|
193
|
-
|
|
219
|
+
return "<span style='color: red'>#{error_message}</span>" if error_message
|
|
220
|
+
|
|
194
221
|
color = status_category_color status
|
|
195
222
|
|
|
196
223
|
visibility = ''
|
|
@@ -8,10 +8,14 @@ class CycleTimeConfig
|
|
|
8
8
|
|
|
9
9
|
attr_reader :label, :parent_config
|
|
10
10
|
|
|
11
|
-
def initialize parent_config:, label:, block:, today: Date.today
|
|
11
|
+
def initialize parent_config:, label:, block:, file_system: nil, today: Date.today
|
|
12
12
|
@parent_config = parent_config
|
|
13
13
|
@label = label
|
|
14
14
|
@today = today
|
|
15
|
+
|
|
16
|
+
# If we hit something deprecated and this is nil then we'll blow up. Although it's ugly, this
|
|
17
|
+
# may make it easier to find problems in the test code ;-)
|
|
18
|
+
@file_system = file_system
|
|
15
19
|
instance_eval(&block) unless block.nil?
|
|
16
20
|
end
|
|
17
21
|
|
|
@@ -35,17 +39,19 @@ class CycleTimeConfig
|
|
|
35
39
|
end
|
|
36
40
|
|
|
37
41
|
def started_time issue
|
|
38
|
-
deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
|
|
42
|
+
@file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
|
|
39
43
|
started_stopped_times(issue).first
|
|
40
44
|
end
|
|
41
45
|
|
|
42
46
|
def stopped_time issue
|
|
43
|
-
deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
|
|
47
|
+
@file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
|
|
44
48
|
started_stopped_times(issue).last
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
def fabricate_change_item time
|
|
48
|
-
deprecated
|
|
52
|
+
@file_system.deprecated(
|
|
53
|
+
date: '2024-12-16', message: "This method should now return a ChangeItem not a #{time.class}", depth: 4
|
|
54
|
+
)
|
|
49
55
|
raw = {
|
|
50
56
|
'field' => 'Fabricated change',
|
|
51
57
|
'to' => '0',
|
|
@@ -5,10 +5,14 @@ require 'jirametrics/groupable_issue_chart'
|
|
|
5
5
|
class CycletimeHistogram < ChartBase
|
|
6
6
|
include GroupableIssueChart
|
|
7
7
|
attr_accessor :possible_statuses
|
|
8
|
+
attr_reader :show_stats
|
|
8
9
|
|
|
9
10
|
def initialize block
|
|
10
11
|
super()
|
|
11
12
|
|
|
13
|
+
percentiles [50, 85, 98]
|
|
14
|
+
@show_stats = true
|
|
15
|
+
|
|
12
16
|
header_text 'Cycletime Histogram'
|
|
13
17
|
description_text <<-HTML
|
|
14
18
|
<p>
|
|
@@ -26,6 +30,15 @@ class CycletimeHistogram < ChartBase
|
|
|
26
30
|
end
|
|
27
31
|
end
|
|
28
32
|
|
|
33
|
+
def percentiles percs = nil
|
|
34
|
+
@percentiles = percs unless percs.nil?
|
|
35
|
+
@percentiles
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def disable_stats
|
|
39
|
+
@show_stats = false
|
|
40
|
+
end
|
|
41
|
+
|
|
29
42
|
def run
|
|
30
43
|
stopped_issues = completed_issues_in_range include_unstarted: true
|
|
31
44
|
|
|
@@ -33,10 +46,18 @@ class CycletimeHistogram < ChartBase
|
|
|
33
46
|
histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
|
|
34
47
|
rules_to_issues = group_issues histogram_issues
|
|
35
48
|
|
|
49
|
+
the_stats = {}
|
|
50
|
+
|
|
51
|
+
overall_stats = stats_for histogram_data: histogram_data_for(issues: histogram_issues), percentiles: @percentiles
|
|
52
|
+
the_stats[:all] = overall_stats
|
|
36
53
|
data_sets = rules_to_issues.keys.collect do |rules|
|
|
54
|
+
the_issue_type = rules.label
|
|
55
|
+
the_histogram = histogram_data_for(issues: rules_to_issues[rules])
|
|
56
|
+
the_stats[the_issue_type] = stats_for histogram_data: the_histogram, percentiles: @percentiles if @show_stats
|
|
57
|
+
|
|
37
58
|
data_set_for(
|
|
38
|
-
histogram_data:
|
|
39
|
-
label:
|
|
59
|
+
histogram_data: the_histogram,
|
|
60
|
+
label: the_issue_type,
|
|
40
61
|
color: rules.color
|
|
41
62
|
)
|
|
42
63
|
end
|
|
@@ -55,6 +76,48 @@ class CycletimeHistogram < ChartBase
|
|
|
55
76
|
count_hash
|
|
56
77
|
end
|
|
57
78
|
|
|
79
|
+
def stats_for histogram_data:, percentiles:
|
|
80
|
+
return {} if histogram_data.empty?
|
|
81
|
+
|
|
82
|
+
total_values = histogram_data.values.sum
|
|
83
|
+
|
|
84
|
+
# Calculate the average
|
|
85
|
+
weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
|
|
86
|
+
average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
|
|
87
|
+
|
|
88
|
+
# Find the mode (or modes!) and the spread of the distribution
|
|
89
|
+
sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
|
|
90
|
+
max_freq = sorted_histogram[-1][1]
|
|
91
|
+
mode = sorted_histogram.select { |_v, f| f == max_freq }
|
|
92
|
+
|
|
93
|
+
minmax = histogram_data.keys.minmax
|
|
94
|
+
|
|
95
|
+
# Calculate percentiles
|
|
96
|
+
sorted_values = histogram_data.keys.sort
|
|
97
|
+
cumulative_counts = {}
|
|
98
|
+
cumulative_sum = 0
|
|
99
|
+
|
|
100
|
+
sorted_values.each do |value|
|
|
101
|
+
cumulative_sum += histogram_data[value]
|
|
102
|
+
cumulative_counts[value] = cumulative_sum
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
percentile_results = {}
|
|
106
|
+
percentiles.each do |percentile|
|
|
107
|
+
rank = (percentile / 100.0) * total_values
|
|
108
|
+
percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
|
|
109
|
+
percentile_results[percentile] = percentile_value
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
average: average,
|
|
114
|
+
mode: mode.collect(&:first).sort,
|
|
115
|
+
min: minmax[0],
|
|
116
|
+
max: minmax[1],
|
|
117
|
+
percentiles: percentile_results
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
58
121
|
def data_set_for histogram_data:, label:, color:
|
|
59
122
|
keys = histogram_data.keys.sort
|
|
60
123
|
{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class DataQualityReport < ChartBase
|
|
4
|
-
attr_reader :
|
|
4
|
+
attr_reader :discarded_changes_data, :entries # Both for testing purposes only
|
|
5
5
|
attr_accessor :board_id
|
|
6
6
|
|
|
7
7
|
class Entry
|
|
@@ -19,10 +19,10 @@ class DataQualityReport < ChartBase
|
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def initialize
|
|
22
|
+
def initialize discarded_changes_data
|
|
23
23
|
super()
|
|
24
24
|
|
|
25
|
-
@
|
|
25
|
+
@discarded_changes_data = discarded_changes_data
|
|
26
26
|
|
|
27
27
|
header_text 'Data Quality Report'
|
|
28
28
|
description_text <<-HTML
|
|
@@ -50,6 +50,7 @@ class DataQualityReport < ChartBase
|
|
|
50
50
|
scan_for_issues_not_started_with_subtasks_that_have entry: entry
|
|
51
51
|
scan_for_incomplete_subtasks_when_issue_done entry: entry
|
|
52
52
|
scan_for_discarded_data entry: entry
|
|
53
|
+
scan_for_items_blocked_on_closed_tickets entry: entry
|
|
53
54
|
end
|
|
54
55
|
|
|
55
56
|
scan_for_issues_on_multiple_boards entries: @entries
|
|
@@ -73,6 +74,7 @@ class DataQualityReport < ChartBase
|
|
|
73
74
|
result << render_problem_type(:issue_not_started_but_subtasks_have)
|
|
74
75
|
result << render_problem_type(:incomplete_subtasks_when_issue_done)
|
|
75
76
|
result << render_problem_type(:issue_on_multiple_boards)
|
|
77
|
+
result << render_problem_type(:items_blocked_on_closed_tickets)
|
|
76
78
|
result << '</ul>'
|
|
77
79
|
|
|
78
80
|
result
|
|
@@ -102,9 +104,13 @@ class DataQualityReport < ChartBase
|
|
|
102
104
|
|
|
103
105
|
# Return a format that's easier to assert against
|
|
104
106
|
def testable_entries
|
|
105
|
-
|
|
107
|
+
formatter = ->(time) { time&.strftime('%Y-%m-%d %H:%M:%S %z') || '' }
|
|
106
108
|
@entries.collect do |entry|
|
|
107
|
-
[
|
|
109
|
+
[
|
|
110
|
+
formatter.call(entry.started),
|
|
111
|
+
formatter.call(entry.stopped),
|
|
112
|
+
entry.issue
|
|
113
|
+
]
|
|
108
114
|
end
|
|
109
115
|
end
|
|
110
116
|
|
|
@@ -112,10 +118,6 @@ class DataQualityReport < ChartBase
|
|
|
112
118
|
@entries.reject { |entry| entry.problems.empty? }
|
|
113
119
|
end
|
|
114
120
|
|
|
115
|
-
def category_name_for status_id:, board:
|
|
116
|
-
board.possible_statuses.find_by_id(status_id)&.category&.name
|
|
117
|
-
end
|
|
118
|
-
|
|
119
121
|
def initialize_entries
|
|
120
122
|
@entries = @issues.filter_map do |issue|
|
|
121
123
|
started, stopped = issue.board.cycletime.started_stopped_times(issue)
|
|
@@ -139,10 +141,8 @@ class DataQualityReport < ChartBase
|
|
|
139
141
|
def scan_for_completed_issues_without_a_start_time entry:
|
|
140
142
|
return unless entry.stopped && entry.started.nil?
|
|
141
143
|
|
|
142
|
-
status_names = entry.issue.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
format_status change.value, board: entry.issue.board
|
|
144
|
+
status_names = entry.issue.status_changes.filter_map do |change|
|
|
145
|
+
format_status change, board: entry.issue.board
|
|
146
146
|
end
|
|
147
147
|
|
|
148
148
|
entry.report(
|
|
@@ -157,14 +157,14 @@ class DataQualityReport < ChartBase
|
|
|
157
157
|
changes_after_done = entry.issue.changes.select do |change|
|
|
158
158
|
change.status? && change.time >= entry.stopped
|
|
159
159
|
end
|
|
160
|
-
done_status = changes_after_done.shift
|
|
160
|
+
done_status = changes_after_done.shift
|
|
161
161
|
|
|
162
162
|
return if changes_after_done.empty?
|
|
163
163
|
|
|
164
164
|
board = entry.issue.board
|
|
165
165
|
problem = "Completed on #{entry.stopped.to_date} with status #{format_status done_status, board: board}."
|
|
166
166
|
changes_after_done.each do |change|
|
|
167
|
-
problem << " Changed to #{format_status change
|
|
167
|
+
problem << " Changed to #{format_status change, board: board} on #{change.time.to_date}."
|
|
168
168
|
end
|
|
169
169
|
entry.report(
|
|
170
170
|
problem_key: :status_changes_after_done,
|
|
@@ -186,9 +186,9 @@ class DataQualityReport < ChartBase
|
|
|
186
186
|
# If it's a backlog status then ignore it. Not supposed to be visible.
|
|
187
187
|
next if entry.issue.board.backlog_statuses.include?(board.possible_statuses.find_by_id(change.value_id))
|
|
188
188
|
|
|
189
|
-
detail = "Status #{format_status change
|
|
190
|
-
if issue.board.possible_statuses.
|
|
191
|
-
detail = "Status #{format_status change
|
|
189
|
+
detail = "Status #{format_status change, board: board} is not on the board"
|
|
190
|
+
if issue.board.possible_statuses.find_by_id(change.value_id).nil?
|
|
191
|
+
detail = "Status #{format_status change, board: board} cannot be found at all. Was it deleted?"
|
|
192
192
|
end
|
|
193
193
|
|
|
194
194
|
# If it's been moved back to backlog then it's on a different report. Ignore it here.
|
|
@@ -198,24 +198,24 @@ class DataQualityReport < ChartBase
|
|
|
198
198
|
elsif change.old_value.nil?
|
|
199
199
|
# Do nothing
|
|
200
200
|
elsif index < last_index
|
|
201
|
-
new_category =
|
|
202
|
-
old_category =
|
|
201
|
+
new_category = board.possible_statuses.find_by_id(change.value_id).category.name
|
|
202
|
+
old_category = board.possible_statuses.find_by_id(change.old_value_id).category.name
|
|
203
203
|
|
|
204
204
|
if new_category == old_category
|
|
205
205
|
entry.report(
|
|
206
206
|
problem_key: :backwords_through_statuses,
|
|
207
|
-
detail: "Moved from #{format_status change
|
|
208
|
-
" to #{format_status change
|
|
207
|
+
detail: "Moved from #{format_status change, use_old_status: true, board: board}" \
|
|
208
|
+
" to #{format_status change, board: board}" \
|
|
209
209
|
" on #{change.time.to_date}"
|
|
210
210
|
)
|
|
211
211
|
else
|
|
212
212
|
entry.report(
|
|
213
213
|
problem_key: :backwards_through_status_categories,
|
|
214
|
-
detail: "Moved from #{format_status change
|
|
215
|
-
" to #{format_status change
|
|
216
|
-
" on #{change.time.to_date},
|
|
217
|
-
" crossing from category #{format_status
|
|
218
|
-
" to #{format_status
|
|
214
|
+
detail: "Moved from #{format_status change, use_old_status: true, board: board}" \
|
|
215
|
+
" to #{format_status change, board: board}" \
|
|
216
|
+
" on #{change.time.to_date}," \
|
|
217
|
+
" crossing from category #{format_status change, use_old_status: true, board: board, is_category: true}" \
|
|
218
|
+
" to #{format_status change, board: board, is_category: true}."
|
|
219
219
|
)
|
|
220
220
|
end
|
|
221
221
|
end
|
|
@@ -224,16 +224,14 @@ class DataQualityReport < ChartBase
|
|
|
224
224
|
end
|
|
225
225
|
|
|
226
226
|
def scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
|
|
227
|
-
return if backlog_statuses.empty?
|
|
228
|
-
|
|
229
227
|
creation_change = entry.issue.changes.find { |issue| issue.status? }
|
|
230
228
|
|
|
231
229
|
return if backlog_statuses.any? { |status| status.id == creation_change.value_id }
|
|
232
230
|
|
|
233
|
-
status_string = backlog_statuses.collect { |s| format_status s
|
|
231
|
+
status_string = backlog_statuses.collect { |s| format_status s, board: entry.issue.board }.join(', ')
|
|
234
232
|
entry.report(
|
|
235
233
|
problem_key: :created_in_wrong_status,
|
|
236
|
-
detail: "Created in #{format_status creation_change
|
|
234
|
+
detail: "Created in #{format_status creation_change, board: entry.issue.board}, " \
|
|
237
235
|
"which is not one of the backlog statuses for this board: #{status_string}"
|
|
238
236
|
)
|
|
239
237
|
end
|
|
@@ -266,6 +264,20 @@ class DataQualityReport < ChartBase
|
|
|
266
264
|
)
|
|
267
265
|
end
|
|
268
266
|
|
|
267
|
+
def scan_for_items_blocked_on_closed_tickets entry:
|
|
268
|
+
entry.issue.issue_links.each do |link|
|
|
269
|
+
this_active = !entry.stopped
|
|
270
|
+
other_active = !link.other_issue.board.cycletime.started_stopped_times(link.other_issue).last
|
|
271
|
+
next unless this_active && !other_active
|
|
272
|
+
|
|
273
|
+
entry.report(
|
|
274
|
+
problem_key: :items_blocked_on_closed_tickets,
|
|
275
|
+
detail: "#{entry.issue.key} thinks it's blocked by #{link.other_issue.key}, " \
|
|
276
|
+
"except #{link.other_issue.key} is closed."
|
|
277
|
+
)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
269
281
|
def subtask_label subtask
|
|
270
282
|
"<img src='#{subtask.type_icon_url}' /> #{link_to_issue(subtask)} #{subtask.summary[..50].inspect}"
|
|
271
283
|
end
|
|
@@ -314,10 +326,10 @@ class DataQualityReport < ChartBase
|
|
|
314
326
|
end
|
|
315
327
|
|
|
316
328
|
def scan_for_discarded_data entry:
|
|
317
|
-
hash = @
|
|
329
|
+
hash = @discarded_changes_data&.find { |a| a[:issue] == entry.issue }
|
|
318
330
|
return if hash.nil?
|
|
319
331
|
|
|
320
|
-
old_start_time = hash[:
|
|
332
|
+
old_start_time = hash[:original_start_time]
|
|
321
333
|
cutoff_time = hash[:cutoff_time]
|
|
322
334
|
|
|
323
335
|
old_start_date = old_start_time.to_date
|
|
@@ -352,7 +364,7 @@ class DataQualityReport < ChartBase
|
|
|
352
364
|
<<-HTML
|
|
353
365
|
#{label_issues problems.size} have had information discarded. This configuration is set
|
|
354
366
|
to "reset the clock" if an item is moved back to the backlog after it's been started. This hides important
|
|
355
|
-
information and makes the data less accurate. <b>Moving items back to the backlog is strongly discouraged.</b>
|
|
367
|
+
information and makes the data less accurate. <b>Moving items back to the backlog is strongly discouraged.</b>
|
|
356
368
|
HTML
|
|
357
369
|
end
|
|
358
370
|
|
|
@@ -437,4 +449,11 @@ class DataQualityReport < ChartBase
|
|
|
437
449
|
could result in more data points showing up on a chart then there really should be.
|
|
438
450
|
HTML
|
|
439
451
|
end
|
|
452
|
+
|
|
453
|
+
def render_items_blocked_on_closed_tickets problems
|
|
454
|
+
<<-HTML
|
|
455
|
+
For #{label_issues problems.size}, the issue is identified as being blocked by another issue. Yet,
|
|
456
|
+
that other issue is already completed so, by definition, it can't still be blocking.
|
|
457
|
+
HTML
|
|
458
|
+
end
|
|
440
459
|
end
|
|
@@ -103,8 +103,6 @@ class Downloader
|
|
|
103
103
|
json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
|
|
104
104
|
"?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
|
|
105
105
|
|
|
106
|
-
exit_if_call_failed json
|
|
107
|
-
|
|
108
106
|
json['issues'].each do |issue_json|
|
|
109
107
|
issue_json['exporter'] = {
|
|
110
108
|
'in_initial_query' => initial_query
|
|
@@ -139,15 +137,6 @@ class Downloader
|
|
|
139
137
|
end
|
|
140
138
|
end
|
|
141
139
|
|
|
142
|
-
def exit_if_call_failed json
|
|
143
|
-
# Sometimes Jira returns the singular form of errorMessage and sometimes the plural. Consistency FTW.
|
|
144
|
-
return unless json['error'] || json['errorMessages'] || json['errorMessage']
|
|
145
|
-
|
|
146
|
-
log "Download failed. See #{@file_system.logfile_name} for details.", both: true
|
|
147
|
-
log " #{JSON.pretty_generate(json)}"
|
|
148
|
-
exit 1
|
|
149
|
-
end
|
|
150
|
-
|
|
151
140
|
def download_statuses
|
|
152
141
|
log ' Downloading all statuses', both: true
|
|
153
142
|
json = @jira_gateway.call_url relative_url: '/rest/api/2/status'
|
|
@@ -188,8 +177,6 @@ class Downloader
|
|
|
188
177
|
log " Downloading board configuration for board #{board_id}", both: true
|
|
189
178
|
json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
|
|
190
179
|
|
|
191
|
-
exit_if_call_failed json
|
|
192
|
-
|
|
193
180
|
@file_system.save_json(
|
|
194
181
|
json: json,
|
|
195
182
|
filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_configuration.json")
|
|
@@ -213,7 +200,6 @@ class Downloader
|
|
|
213
200
|
while is_last == false
|
|
214
201
|
json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/sprint?" \
|
|
215
202
|
"maxResults=#{max_results}&startAt=#{start_at}"
|
|
216
|
-
exit_if_call_failed json
|
|
217
203
|
|
|
218
204
|
@file_system.save_json(
|
|
219
205
|
json: json,
|
|
@@ -28,8 +28,8 @@ class Exporter
|
|
|
28
28
|
block = boards[board_id]
|
|
29
29
|
if block == :default
|
|
30
30
|
block = lambda do |_|
|
|
31
|
-
start_at first_time_in_status_category(
|
|
32
|
-
stop_at still_in_status_category(
|
|
31
|
+
start_at first_time_in_status_category(:indeterminate)
|
|
32
|
+
stop_at still_in_status_category(:done)
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
board id: board_id do
|
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -2,18 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
|
|
5
|
-
class Object
|
|
6
|
-
def deprecated message:, date:, depth: 2
|
|
7
|
-
text = +''
|
|
8
|
-
text << "Deprecated(#{date}): "
|
|
9
|
-
text << message
|
|
10
|
-
caller(1..depth).each do |line|
|
|
11
|
-
text << "\n-> Called from #{line}"
|
|
12
|
-
end
|
|
13
|
-
warn text
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
|
|
17
5
|
class Exporter
|
|
18
6
|
attr_reader :project_configs
|
|
19
7
|
attr_accessor :file_system
|
|
@@ -76,12 +64,8 @@ class Exporter
|
|
|
76
64
|
selected = []
|
|
77
65
|
each_project_config(name_filter: name_filter) do |project|
|
|
78
66
|
project.evaluate_next_level
|
|
79
|
-
# next if project.aggregated_project?
|
|
80
67
|
|
|
81
68
|
project.run load_only: true
|
|
82
|
-
project.board_configs.each do |board_config|
|
|
83
|
-
board_config.run
|
|
84
|
-
end
|
|
85
69
|
project.issues.each do |issue|
|
|
86
70
|
selected << [project, issue] if keys.include? issue.key
|
|
87
71
|
end
|
|
@@ -91,9 +75,13 @@ class Exporter
|
|
|
91
75
|
raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
|
|
92
76
|
end
|
|
93
77
|
|
|
94
|
-
selected.
|
|
95
|
-
|
|
96
|
-
|
|
78
|
+
if selected.empty?
|
|
79
|
+
file_system.log "No issues found to match #{keys.collect(&:inspect).join(', ')}"
|
|
80
|
+
else
|
|
81
|
+
selected.each do |project, issue|
|
|
82
|
+
file_system.log "\nProject #{project.name}"
|
|
83
|
+
file_system.log issue.dump
|
|
84
|
+
end
|
|
97
85
|
end
|
|
98
86
|
end
|
|
99
87
|
|
|
@@ -128,7 +116,9 @@ class Exporter
|
|
|
128
116
|
|
|
129
117
|
def jira_config filename = nil
|
|
130
118
|
if filename
|
|
131
|
-
@jira_config = file_system.load_json(filename)
|
|
119
|
+
@jira_config = file_system.load_json(filename, fail_on_error: false)
|
|
120
|
+
raise "Unable to load Jira configuration file and cannot continue: #{filename.inspect}" if @jira_config.nil?
|
|
121
|
+
|
|
132
122
|
@jira_config['url'] = $1 if @jira_config['url'] =~ /^(.+)\/+$/
|
|
133
123
|
end
|
|
134
124
|
@jira_config
|
|
@@ -66,15 +66,20 @@ class FileConfig
|
|
|
66
66
|
# is that all empty values in the first column should be at the bottom.
|
|
67
67
|
def sort_output all_lines
|
|
68
68
|
all_lines.sort do |a, b|
|
|
69
|
+
result = nil
|
|
69
70
|
if a[0] == b[0]
|
|
70
|
-
a[1..] <=> b[1..]
|
|
71
|
+
result = a[1..] <=> b[1..]
|
|
71
72
|
elsif a[0].nil?
|
|
72
|
-
1
|
|
73
|
+
result = 1
|
|
73
74
|
elsif b[0].nil?
|
|
74
|
-
-1
|
|
75
|
+
result = -1
|
|
75
76
|
else
|
|
76
|
-
a[0] <=> b[0]
|
|
77
|
+
result = a[0] <=> b[0]
|
|
77
78
|
end
|
|
79
|
+
|
|
80
|
+
# This will only happen if one of the objects isn't comparable. Seen in production.
|
|
81
|
+
result = -1 if result.nil?
|
|
82
|
+
result
|
|
78
83
|
end
|
|
79
84
|
end
|
|
80
85
|
|
|
@@ -85,6 +90,11 @@ class FileConfig
|
|
|
85
90
|
|
|
86
91
|
def html_report &block
|
|
87
92
|
assert_only_one_filetype_config_set
|
|
93
|
+
if block.nil?
|
|
94
|
+
project_config.file_system.warning 'No charts were specified for the report. This is almost certainly a mistake.'
|
|
95
|
+
block = ->(_) {}
|
|
96
|
+
end
|
|
97
|
+
|
|
88
98
|
@html_report = HtmlReportConfig.new file_config: self, block: block
|
|
89
99
|
end
|
|
90
100
|
|
|
@@ -120,4 +130,11 @@ class FileConfig
|
|
|
120
130
|
@file_suffix = suffix unless suffix.nil?
|
|
121
131
|
@file_suffix
|
|
122
132
|
end
|
|
133
|
+
|
|
134
|
+
def children
|
|
135
|
+
result = []
|
|
136
|
+
result << @columns if @columns
|
|
137
|
+
result << @html_report if @html_report
|
|
138
|
+
result
|
|
139
|
+
end
|
|
123
140
|
end
|