jirametrics 2.7 → 2.11
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 +4 -4
- data/lib/jirametrics/aging_work_bar_chart.rb +7 -5
- data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
- data/lib/jirametrics/aging_work_table.rb +50 -2
- data/lib/jirametrics/board.rb +33 -5
- data/lib/jirametrics/board_config.rb +6 -2
- data/lib/jirametrics/board_movement_calculator.rb +147 -0
- data/lib/jirametrics/change_item.rb +19 -6
- data/lib/jirametrics/chart_base.rb +59 -21
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cycletime_config.rb +37 -5
- data/lib/jirametrics/cycletime_histogram.rb +67 -2
- data/lib/jirametrics/data_quality_report.rb +174 -35
- data/lib/jirametrics/download_config.rb +2 -2
- data/lib/jirametrics/downloader.rb +44 -25
- data/lib/jirametrics/examples/aggregated_project.rb +2 -5
- data/lib/jirametrics/examples/standard_project.rb +4 -6
- data/lib/jirametrics/expedited_chart.rb +7 -7
- data/lib/jirametrics/exporter.rb +10 -20
- data/lib/jirametrics/file_config.rb +23 -6
- data/lib/jirametrics/file_system.rb +39 -4
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +2 -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_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/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 +32 -23
- data/lib/jirametrics/issue.rb +104 -44
- data/lib/jirametrics/jira_gateway.rb +16 -3
- data/lib/jirametrics/project_config.rb +223 -120
- data/lib/jirametrics/sprint_burndown.rb +1 -1
- data/lib/jirametrics/status.rb +81 -26
- data/lib/jirametrics/status_collection.rb +74 -40
- data/lib/jirametrics/throughput_chart.rb +1 -1
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics.rb +7 -1
- metadata +8 -13
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/html/data_quality_report.erb +0 -138
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -13,7 +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.
|
|
16
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.
|
|
17
22
|
return unless @raw['changelog']
|
|
18
23
|
|
|
19
24
|
load_history_into_changes
|
|
@@ -44,14 +49,26 @@ class Issue
|
|
|
44
49
|
|
|
45
50
|
def summary = @raw['fields']['summary']
|
|
46
51
|
|
|
47
|
-
def status = Status.new(raw: @raw['fields']['status'])
|
|
48
|
-
|
|
49
52
|
def labels = @raw['fields']['labels'] || []
|
|
50
53
|
|
|
51
54
|
def author = @raw['fields']['creator']&.[]('displayName') || ''
|
|
52
55
|
|
|
53
56
|
def resolution = @raw['fields']['resolution']&.[]('name')
|
|
54
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
|
+
|
|
55
72
|
def url
|
|
56
73
|
# Strangely, the URL isn't anywhere in the returned data so we have to fabricate it.
|
|
57
74
|
"#{@board.server_url_prefix}/browse/#{key}"
|
|
@@ -66,35 +83,43 @@ class Issue
|
|
|
66
83
|
end
|
|
67
84
|
|
|
68
85
|
def first_time_in_status *status_names
|
|
69
|
-
@changes.find { |change| change.current_status_matches(*status_names) }
|
|
86
|
+
@changes.find { |change| change.current_status_matches(*status_names) }
|
|
70
87
|
end
|
|
71
88
|
|
|
72
89
|
def first_time_not_in_status *status_names
|
|
73
|
-
@changes.find { |change| change.status? && status_names.include?(change.value) == false }
|
|
90
|
+
@changes.find { |change| change.status? && status_names.include?(change.value) == false }
|
|
74
91
|
end
|
|
75
92
|
|
|
76
93
|
def first_time_in_or_right_of_column column_name
|
|
77
94
|
first_time_in_status(*board.status_ids_in_or_right_of_column(column_name))
|
|
78
95
|
end
|
|
79
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
|
+
|
|
80
107
|
def still_in_or_right_of_column column_name
|
|
81
108
|
still_in_status(*board.status_ids_in_or_right_of_column(column_name))
|
|
82
109
|
end
|
|
83
110
|
|
|
84
111
|
def still_in
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
next unless change.status?
|
|
88
|
-
|
|
112
|
+
result = nil
|
|
113
|
+
status_changes.each do |change|
|
|
89
114
|
current_status_matched = yield change
|
|
90
115
|
|
|
91
|
-
if current_status_matched &&
|
|
92
|
-
|
|
93
|
-
elsif !current_status_matched &&
|
|
94
|
-
|
|
116
|
+
if current_status_matched && result.nil?
|
|
117
|
+
result = change
|
|
118
|
+
elsif !current_status_matched && result
|
|
119
|
+
result = nil
|
|
95
120
|
end
|
|
96
121
|
end
|
|
97
|
-
|
|
122
|
+
result
|
|
98
123
|
end
|
|
99
124
|
private :still_in
|
|
100
125
|
|
|
@@ -107,58 +132,75 @@ class Issue
|
|
|
107
132
|
|
|
108
133
|
# If it ever entered one of these categories and it's still there then what was the last time it entered
|
|
109
134
|
def still_in_status_category *category_names
|
|
135
|
+
category_ids = find_status_category_ids_by_names category_names
|
|
136
|
+
|
|
110
137
|
still_in do |change|
|
|
111
|
-
status =
|
|
112
|
-
|
|
138
|
+
status = find_or_create_status id: change.value_id, name: change.value
|
|
139
|
+
category_ids.include? status.category.id
|
|
113
140
|
end
|
|
114
141
|
end
|
|
115
142
|
|
|
116
143
|
def most_recent_status_change
|
|
117
|
-
|
|
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
|
|
118
148
|
end
|
|
119
149
|
|
|
120
|
-
# 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.
|
|
121
151
|
def currently_in_status *status_names
|
|
122
152
|
change = most_recent_status_change
|
|
123
153
|
return false if change.nil?
|
|
124
154
|
|
|
125
|
-
change
|
|
155
|
+
change if change.current_status_matches(*status_names)
|
|
126
156
|
end
|
|
127
157
|
|
|
128
|
-
# 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.
|
|
129
159
|
def currently_in_status_category *category_names
|
|
160
|
+
category_ids = find_status_category_ids_by_names category_names
|
|
161
|
+
|
|
130
162
|
change = most_recent_status_change
|
|
131
163
|
return false if change.nil?
|
|
132
164
|
|
|
133
|
-
status =
|
|
134
|
-
change
|
|
165
|
+
status = find_or_create_status id: change.value_id, name: change.value
|
|
166
|
+
change if status && category_ids.include?(status.category.id)
|
|
135
167
|
end
|
|
136
168
|
|
|
137
|
-
def
|
|
138
|
-
status = board.possible_statuses.
|
|
139
|
-
|
|
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
|
|
140
189
|
|
|
141
|
-
@board.project_config.file_system.log(
|
|
142
|
-
"Warning: Status name #{name.inspect} for issue #{key} not found in" \
|
|
143
|
-
" #{board.possible_statuses.collect(&:name).inspect}" \
|
|
144
|
-
"\n See Q1 in the FAQ for more details: https://github.com/mikebowler/jirametrics/wiki/FAQ\n",
|
|
145
|
-
also_write_to_stderr: true
|
|
146
|
-
)
|
|
147
|
-
status = Status.new(name: name, category_name: 'In Progress')
|
|
148
|
-
board.possible_statuses << status
|
|
149
190
|
status
|
|
150
191
|
end
|
|
151
192
|
|
|
152
193
|
def first_status_change_after_created
|
|
153
|
-
|
|
194
|
+
status_changes.find { |change| change.artificial? == false }
|
|
154
195
|
end
|
|
155
196
|
|
|
156
197
|
def first_time_in_status_category *category_names
|
|
157
|
-
|
|
158
|
-
next unless change.status?
|
|
198
|
+
category_ids = find_status_category_ids_by_names category_names
|
|
159
199
|
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
162
204
|
end
|
|
163
205
|
nil
|
|
164
206
|
end
|
|
@@ -177,11 +219,11 @@ class Issue
|
|
|
177
219
|
end
|
|
178
220
|
|
|
179
221
|
def first_resolution
|
|
180
|
-
@changes.find { |change| change.resolution? }
|
|
222
|
+
@changes.find { |change| change.resolution? }
|
|
181
223
|
end
|
|
182
224
|
|
|
183
225
|
def last_resolution
|
|
184
|
-
@changes.reverse.find { |change| change.resolution? }
|
|
226
|
+
@changes.reverse.find { |change| change.resolution? }
|
|
185
227
|
end
|
|
186
228
|
|
|
187
229
|
def assigned_to
|
|
@@ -578,12 +620,17 @@ class Issue
|
|
|
578
620
|
end
|
|
579
621
|
|
|
580
622
|
(changes + (@discarded_changes || [])).each do |change|
|
|
581
|
-
|
|
582
|
-
|
|
623
|
+
if change.status?
|
|
624
|
+
value = "#{change.value.inspect}:#{change.value_id.inspect}"
|
|
625
|
+
old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
|
|
626
|
+
else
|
|
627
|
+
value = compact_text(change.value).inspect
|
|
628
|
+
old_value = change.old_value ? compact_text(change.old_value).inspect : nil
|
|
629
|
+
end
|
|
583
630
|
|
|
584
631
|
message = +''
|
|
585
|
-
message << "#{
|
|
586
|
-
message <<
|
|
632
|
+
message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
|
|
633
|
+
message << value
|
|
587
634
|
if change.artificial?
|
|
588
635
|
message << ' (Artificial entry)' if change.artificial?
|
|
589
636
|
else
|
|
@@ -624,12 +671,16 @@ class Issue
|
|
|
624
671
|
# This was probably loaded as a linked issue, which means we don't know what board it really
|
|
625
672
|
# belonged to. The best we can do is look at the status category. This case should be rare but
|
|
626
673
|
# it can happen.
|
|
627
|
-
status.
|
|
674
|
+
status.category.name == 'Done'
|
|
628
675
|
else
|
|
629
676
|
board.cycletime.done? self
|
|
630
677
|
end
|
|
631
678
|
end
|
|
632
679
|
|
|
680
|
+
def status_changes
|
|
681
|
+
@changes.select { |change| change.status? }
|
|
682
|
+
end
|
|
683
|
+
|
|
633
684
|
private
|
|
634
685
|
|
|
635
686
|
def assemble_author raw
|
|
@@ -705,4 +756,13 @@ class Issue
|
|
|
705
756
|
'toString' => first_status
|
|
706
757
|
}
|
|
707
758
|
end
|
|
759
|
+
|
|
760
|
+
def find_status_category_ids_by_names category_names
|
|
761
|
+
category_names.filter_map do |name|
|
|
762
|
+
list = board.possible_statuses.find_all_categories_by_name name
|
|
763
|
+
raise "No status categories found for name: #{name}" if list.empty?
|
|
764
|
+
|
|
765
|
+
list
|
|
766
|
+
end.flatten.collect(&:id)
|
|
767
|
+
end
|
|
708
768
|
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
|