jirametrics 2.12.1 → 2.22
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/aging_work_bar_chart.rb +176 -134
- data/lib/jirametrics/anonymizer.rb +8 -6
- data/lib/jirametrics/atlassian_document_format.rb +160 -0
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/board.rb +4 -0
- data/lib/jirametrics/board_config.rb +4 -1
- data/lib/jirametrics/change_item.rb +12 -4
- data/lib/jirametrics/chart_base.rb +36 -2
- data/lib/jirametrics/cycletime_config.rb +22 -4
- data/lib/jirametrics/cycletime_histogram.rb +3 -1
- data/lib/jirametrics/cycletime_scatterplot.rb +36 -17
- data/lib/jirametrics/daily_view.rb +57 -53
- data/lib/jirametrics/daily_wip_by_age_chart.rb +3 -4
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +13 -3
- data/lib/jirametrics/daily_wip_chart.rb +1 -1
- data/lib/jirametrics/data_quality_report.rb +8 -3
- data/lib/jirametrics/dependency_chart.rb +4 -1
- data/lib/jirametrics/downloader.rb +34 -70
- data/lib/jirametrics/downloader_for_cloud.rb +202 -0
- data/lib/jirametrics/downloader_for_data_center.rb +94 -0
- data/lib/jirametrics/examples/standard_project.rb +9 -9
- data/lib/jirametrics/expedited_chart.rb +1 -1
- data/lib/jirametrics/exporter.rb +12 -5
- data/lib/jirametrics/file_system.rb +24 -1
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
- data/lib/jirametrics/groupable_issue_chart.rb +7 -1
- data/lib/jirametrics/html/aging_work_bar_chart.erb +2 -1
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
- data/lib/jirametrics/html/aging_work_table.erb +2 -0
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cycletime_histogram.erb +4 -2
- data/lib/jirametrics/html/cycletime_scatterplot.erb +6 -6
- data/lib/jirametrics/html/daily_wip_chart.erb +2 -0
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -0
- data/lib/jirametrics/html/expedited_chart.erb +3 -1
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -0
- data/lib/jirametrics/html/index.css +21 -9
- data/lib/jirametrics/html/index.erb +5 -37
- data/lib/jirametrics/html/index.js +114 -0
- data/lib/jirametrics/html/sprint_burndown.erb +11 -3
- data/lib/jirametrics/html/throughput_chart.erb +2 -2
- data/lib/jirametrics/html_generator.rb +31 -0
- data/lib/jirametrics/html_report_config.rb +8 -25
- data/lib/jirametrics/issue.rb +128 -23
- data/lib/jirametrics/jira_gateway.rb +59 -17
- data/lib/jirametrics/project_config.rb +42 -5
- data/lib/jirametrics/raw_javascript.rb +13 -0
- data/lib/jirametrics/settings.json +3 -1
- data/lib/jirametrics/sprint.rb +12 -0
- data/lib/jirametrics/sprint_burndown.rb +6 -2
- data/lib/jirametrics/status_collection.rb +1 -0
- data/lib/jirametrics/stitcher.rb +75 -0
- data/lib/jirametrics.rb +26 -69
- metadata +11 -3
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -19,9 +19,10 @@ class Issue
|
|
|
19
19
|
|
|
20
20
|
# There are cases where we create an Issue of fragments like linked issues and those won't have
|
|
21
21
|
# changelogs.
|
|
22
|
-
|
|
22
|
+
load_history_into_changes if @raw['changelog']
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# As above with fragments, there may not be a fields section
|
|
25
|
+
return unless @raw['fields']
|
|
25
26
|
|
|
26
27
|
# If this is an older pull of data then comments may not be there.
|
|
27
28
|
load_comments_into_changes if @raw['fields']['comment']
|
|
@@ -152,7 +153,7 @@ class Issue
|
|
|
152
153
|
# Are we currently in this status? If yes, then return the most recent status change.
|
|
153
154
|
def currently_in_status *status_names
|
|
154
155
|
change = most_recent_status_change
|
|
155
|
-
return
|
|
156
|
+
return nil if change.nil?
|
|
156
157
|
|
|
157
158
|
change if change.current_status_matches(*status_names)
|
|
158
159
|
end
|
|
@@ -162,7 +163,7 @@ class Issue
|
|
|
162
163
|
category_ids = find_status_category_ids_by_names category_names
|
|
163
164
|
|
|
164
165
|
change = most_recent_status_change
|
|
165
|
-
return
|
|
166
|
+
return nil if change.nil?
|
|
166
167
|
|
|
167
168
|
status = find_or_create_status id: change.value_id, name: change.value
|
|
168
169
|
change if status && category_ids.include?(status.category.id)
|
|
@@ -211,8 +212,91 @@ class Issue
|
|
|
211
212
|
first_time_in_status(*board.visible_columns.collect(&:status_ids).flatten)
|
|
212
213
|
end
|
|
213
214
|
|
|
215
|
+
# If this issue will ever be in an active sprint then return the time that it
|
|
216
|
+
# was first added to that sprint, whether or not the sprint was active at that
|
|
217
|
+
# time. Although it seems like an odd thing to calculate, it's a reasonable proxy
|
|
218
|
+
# for 'ready' in cases where the team doesn't have an explicit 'ready' status.
|
|
219
|
+
# You'd be better off with an explicit 'ready' but sometimes that's not an option.
|
|
220
|
+
def first_time_added_to_active_sprint
|
|
221
|
+
unless board.scrum?
|
|
222
|
+
raise 'first_time_added_to_active_sprint() can only be used with Scrum boards: ' \
|
|
223
|
+
"issue=#{key}, board=#{board.inspect}"
|
|
224
|
+
end
|
|
225
|
+
data_clazz = Struct.new(:sprint_id, :sprint_start, :sprint_stop, :change)
|
|
226
|
+
|
|
227
|
+
matching_changes = []
|
|
228
|
+
all_datas = []
|
|
229
|
+
|
|
230
|
+
@changes.each do |change|
|
|
231
|
+
next unless change.sprint?
|
|
232
|
+
|
|
233
|
+
added_sprint_ids = change.value_id - change.old_value_id
|
|
234
|
+
added_sprint_ids.each do |id|
|
|
235
|
+
data = data_clazz.new
|
|
236
|
+
data.sprint_id = id
|
|
237
|
+
data.change = change
|
|
238
|
+
data.sprint_start, data.sprint_stop = find_sprint_start_end(sprint_id: id, change: change)
|
|
239
|
+
all_datas << data
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
removed_sprint_ids = change.old_value_id - change.value_id
|
|
243
|
+
removed_sprint_ids.each do |id|
|
|
244
|
+
data = all_datas.find { |d| d.sprint_id == id }
|
|
245
|
+
# It's possible for an issue to be created inside a sprint and therefore for
|
|
246
|
+
# that add-to-sprint not show in the history.
|
|
247
|
+
next unless data
|
|
248
|
+
|
|
249
|
+
all_datas.delete(data)
|
|
250
|
+
next if data.sprint_start.nil? || data.sprint_start >= change.time
|
|
251
|
+
|
|
252
|
+
matching_changes << data.change
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# There can't be any more removes so whatever is left is a valid option
|
|
257
|
+
# Now all we care about is if the sprint has started.
|
|
258
|
+
all_datas.each do |data|
|
|
259
|
+
matching_changes << data.change if data.sprint_start
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
matching_changes.min_by(&:time)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def find_sprint_start_end sprint_id:, change:
|
|
266
|
+
# There are two different places that sprint data could be found. In theory all
|
|
267
|
+
# sprints would be found in both places. In practice, sometimes what we need is
|
|
268
|
+
# in one or the other but not both.
|
|
269
|
+
|
|
270
|
+
# First look in the actual sprints json. If any issues are in this sprint then it should
|
|
271
|
+
# be here.
|
|
272
|
+
sprint = board.sprints.find { |s| s.id == sprint_id }
|
|
273
|
+
return [sprint.start_time, sprint.completed_time] if sprint
|
|
274
|
+
|
|
275
|
+
# Then look at the sprints inside the issue. Even though the field id may be specified,
|
|
276
|
+
# that custom field may not be present. This happens if it was in that sprint but was
|
|
277
|
+
# then removed, whether or not that sprint had ever started.
|
|
278
|
+
sprint_data = raw['fields'][change.field_id]&.find { |sd| sd['id'].to_i == sprint_id }
|
|
279
|
+
if sprint_data
|
|
280
|
+
start = parse_time(sprint_data['startDate'])
|
|
281
|
+
stop = parse_time(sprint_data['completeDate'])
|
|
282
|
+
return [start, stop]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# If we got this far then the sprint can't be found anywhere, so we pretend that it never
|
|
286
|
+
# started. Is this guaranteed to be true? No. In theory if all issues were removed from
|
|
287
|
+
# an active sprint then it would also disappear, even though it had started. Nothing we
|
|
288
|
+
# can do to detect that edge-case though.
|
|
289
|
+
[nil, nil]
|
|
290
|
+
end
|
|
291
|
+
|
|
214
292
|
def parse_time text
|
|
215
|
-
|
|
293
|
+
if text.nil?
|
|
294
|
+
nil
|
|
295
|
+
elsif text.is_a? String
|
|
296
|
+
Time.parse(text).getlocal(@timezone_offset)
|
|
297
|
+
else
|
|
298
|
+
Time.at(text / 1000).getlocal(@timezone_offset)
|
|
299
|
+
end
|
|
216
300
|
end
|
|
217
301
|
|
|
218
302
|
def created
|
|
@@ -220,6 +304,10 @@ class Issue
|
|
|
220
304
|
parse_time @raw['fields']['created'] if @raw['fields']['created']
|
|
221
305
|
end
|
|
222
306
|
|
|
307
|
+
def time_created
|
|
308
|
+
@changes.first
|
|
309
|
+
end
|
|
310
|
+
|
|
223
311
|
def updated
|
|
224
312
|
parse_time @raw['fields']['updated']
|
|
225
313
|
end
|
|
@@ -233,11 +321,11 @@ class Issue
|
|
|
233
321
|
end
|
|
234
322
|
|
|
235
323
|
def assigned_to
|
|
236
|
-
@raw['fields']
|
|
324
|
+
@raw['fields']['assignee']&.[]('displayName')
|
|
237
325
|
end
|
|
238
326
|
|
|
239
327
|
def assigned_to_icon_url
|
|
240
|
-
@raw['fields']
|
|
328
|
+
@raw['fields']['assignee']&.[]('avatarUrls')&.[]('16x16')
|
|
241
329
|
end
|
|
242
330
|
|
|
243
331
|
# Many test failures are simply unreadable because the default inspect on this class goes
|
|
@@ -300,9 +388,7 @@ class Issue
|
|
|
300
388
|
results
|
|
301
389
|
end
|
|
302
390
|
|
|
303
|
-
def
|
|
304
|
-
settings ||= @board.project_config.settings
|
|
305
|
-
|
|
391
|
+
def blocked_stalled_statuses settings
|
|
306
392
|
blocked_statuses = settings['blocked_statuses']
|
|
307
393
|
stalled_statuses = settings['stalled_statuses']
|
|
308
394
|
unless blocked_statuses.is_a?(Array) && stalled_statuses.is_a?(Array)
|
|
@@ -310,6 +396,14 @@ class Issue
|
|
|
310
396
|
"stalled_statuses(#{stalled_statuses.inspect}) must both be arrays"
|
|
311
397
|
end
|
|
312
398
|
|
|
399
|
+
[blocked_statuses, stalled_statuses]
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def blocked_stalled_changes end_time:, settings: nil
|
|
403
|
+
settings ||= @board.project_config.settings
|
|
404
|
+
|
|
405
|
+
blocked_statuses, stalled_statuses = blocked_stalled_statuses(settings)
|
|
406
|
+
|
|
313
407
|
blocked_link_texts = settings['blocked_link_text']
|
|
314
408
|
stalled_threshold = settings['stalled_threshold_days']
|
|
315
409
|
flagged_means_blocked = !!settings['flagged_means_blocked'] # rubocop:disable Style/DoubleNegation
|
|
@@ -345,7 +439,7 @@ class Issue
|
|
|
345
439
|
end
|
|
346
440
|
elsif change.link?
|
|
347
441
|
# Example: "This issue is satisfied by ANON-30465"
|
|
348
|
-
unless /^This issue (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
|
|
442
|
+
unless /^This (?<_>issue|work item) (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
|
|
349
443
|
puts "Issue(#{key}) Can't parse link text: #{change.value || change.old_value}"
|
|
350
444
|
next
|
|
351
445
|
end
|
|
@@ -608,7 +702,7 @@ class Issue
|
|
|
608
702
|
|
|
609
703
|
def dump
|
|
610
704
|
result = +''
|
|
611
|
-
result << "#{key} (#{type}): #{compact_text summary, 200}\n"
|
|
705
|
+
result << "#{key} (#{type}): #{compact_text summary, max: 200}\n"
|
|
612
706
|
|
|
613
707
|
assignee = raw['fields']['assignee']
|
|
614
708
|
result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
|
|
@@ -681,9 +775,8 @@ class Issue
|
|
|
681
775
|
def done?
|
|
682
776
|
if artificial? || board.cycletime.nil?
|
|
683
777
|
# This was probably loaded as a linked issue, which means we don't know what board it really
|
|
684
|
-
# belonged to. The best we can do is look at the status
|
|
685
|
-
|
|
686
|
-
status.category.name == 'Done'
|
|
778
|
+
# belonged to. The best we can do is look at the status key
|
|
779
|
+
status.category.done?
|
|
687
780
|
else
|
|
688
781
|
board.cycletime.done? self
|
|
689
782
|
end
|
|
@@ -706,6 +799,23 @@ class Issue
|
|
|
706
799
|
board.sprints.select { |s| sprint_ids.include? s.id }
|
|
707
800
|
end
|
|
708
801
|
|
|
802
|
+
def started_sprints
|
|
803
|
+
sprints.reject { |sprint| sprint.future? }
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def compact_text text, max: 60
|
|
807
|
+
return '' if text.nil?
|
|
808
|
+
|
|
809
|
+
if text.is_a? Hash
|
|
810
|
+
# We can't effectively compact it but we can convert it into a string.
|
|
811
|
+
text = @board.project_config.atlassian_document_format.to_html(text)
|
|
812
|
+
else
|
|
813
|
+
text = text.gsub(/\s+/, ' ').strip
|
|
814
|
+
text = "#{text[0...max]}..." if text.length > max
|
|
815
|
+
end
|
|
816
|
+
text
|
|
817
|
+
end
|
|
818
|
+
|
|
709
819
|
private
|
|
710
820
|
|
|
711
821
|
def load_history_into_changes
|
|
@@ -730,14 +840,6 @@ class Issue
|
|
|
730
840
|
end
|
|
731
841
|
end
|
|
732
842
|
|
|
733
|
-
def compact_text text, max = 60
|
|
734
|
-
return nil if text.nil?
|
|
735
|
-
|
|
736
|
-
text = text.gsub(/\s+/, ' ').strip
|
|
737
|
-
text = "#{text[0..max]}..." if text.length > max
|
|
738
|
-
text
|
|
739
|
-
end
|
|
740
|
-
|
|
741
843
|
def sort_changes!
|
|
742
844
|
@changes.sort! do |a, b|
|
|
743
845
|
# It's common that a resolved will happen at the same time as a status change.
|
|
@@ -755,6 +857,9 @@ class Issue
|
|
|
755
857
|
first_status = nil
|
|
756
858
|
first_status_id = nil
|
|
757
859
|
|
|
860
|
+
# There won't be a created timestamp in cases where this was a linked issue
|
|
861
|
+
return unless @raw['fields']['created']
|
|
862
|
+
|
|
758
863
|
created_time = parse_time @raw['fields']['created']
|
|
759
864
|
first_change = @changes.find { |change| change.field == field_name }
|
|
760
865
|
if first_change.nil?
|
|
@@ -3,21 +3,61 @@
|
|
|
3
3
|
require 'cgi'
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'English'
|
|
6
|
+
require 'open3'
|
|
6
7
|
|
|
7
8
|
class JiraGateway
|
|
8
|
-
attr_accessor :ignore_ssl_errors
|
|
9
|
+
attr_accessor :ignore_ssl_errors
|
|
10
|
+
attr_reader :jira_url, :settings, :file_system
|
|
9
11
|
|
|
10
|
-
def initialize file_system:
|
|
12
|
+
def initialize file_system:, jira_config:, settings:
|
|
11
13
|
@file_system = file_system
|
|
14
|
+
load_jira_config(jira_config)
|
|
15
|
+
@settings = settings
|
|
16
|
+
@ignore_ssl_errors = settings['ignore_ssl_errors']
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def post_request relative_url:, payload:
|
|
20
|
+
command = make_curl_command url: "#{@jira_url}#{relative_url}", method: 'POST'
|
|
21
|
+
exec_and_parse_response command: command, stdin_data: payload
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def exec_and_parse_response command:, stdin_data:
|
|
25
|
+
log_entry = " #{command.gsub(/\s+/, ' ')}"
|
|
26
|
+
log_entry = sanitize_message log_entry
|
|
27
|
+
@file_system.log log_entry
|
|
28
|
+
|
|
29
|
+
stdout, stderr, status = capture3(command, stdin_data: stdin_data)
|
|
30
|
+
unless status.success?
|
|
31
|
+
@file_system.log "Failed call with exit status #{status.exitstatus}!"
|
|
32
|
+
@file_system.log "Returned (stdout): #{stdout.inspect}"
|
|
33
|
+
@file_system.log "Returned (stderr): #{stderr.inspect}"
|
|
34
|
+
raise "Failed call with exit status #{status.exitstatus}. " \
|
|
35
|
+
"See #{@file_system.logfile_name} for details"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@file_system.log "Returned (stderr): #{stderr.inspect}" unless stderr == ''
|
|
39
|
+
raise 'no response from curl on stdout' if stdout == ''
|
|
40
|
+
|
|
41
|
+
parse_response(command: command, result: stdout)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def capture3 command, stdin_data:
|
|
45
|
+
# In it's own method so we can mock it out in tests
|
|
46
|
+
Open3.capture3(command, stdin_data: stdin_data)
|
|
12
47
|
end
|
|
13
48
|
|
|
14
49
|
def call_url relative_url:
|
|
15
50
|
command = make_curl_command url: "#{@jira_url}#{relative_url}"
|
|
16
|
-
|
|
51
|
+
exec_and_parse_response command: command, stdin_data: nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse_response command:, result:
|
|
17
55
|
begin
|
|
18
56
|
json = JSON.parse(result)
|
|
19
57
|
rescue # rubocop:disable Style/RescueStandardError
|
|
20
|
-
|
|
58
|
+
message = "Unable to parse results from #{sanitize_message(command)}"
|
|
59
|
+
@file_system.error message, more: result
|
|
60
|
+
raise message
|
|
21
61
|
end
|
|
22
62
|
|
|
23
63
|
raise "Download failed with: #{JSON.pretty_generate(json)}" unless json_successful?(json)
|
|
@@ -25,18 +65,11 @@ class JiraGateway
|
|
|
25
65
|
json
|
|
26
66
|
end
|
|
27
67
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@file_system.log log_entry
|
|
32
|
-
|
|
33
|
-
result = `#{command}`
|
|
34
|
-
@file_system.log result unless $CHILD_STATUS.success?
|
|
35
|
-
return result if $CHILD_STATUS.success?
|
|
68
|
+
def sanitize_message message
|
|
69
|
+
token = @jira_api_token || @jira_personal_access_token
|
|
70
|
+
return message unless token # cookie based authentication
|
|
36
71
|
|
|
37
|
-
|
|
38
|
-
raise "Failed call with exit status #{$CHILD_STATUS.exitstatus}. " \
|
|
39
|
-
"See #{@file_system.logfile_name} for details"
|
|
72
|
+
message.gsub(token, '[API_TOKEN]')
|
|
40
73
|
end
|
|
41
74
|
|
|
42
75
|
def load_jira_config jira_config
|
|
@@ -56,7 +89,7 @@ class JiraGateway
|
|
|
56
89
|
@cookies = (jira_config['cookies'] || []).collect { |key, value| "#{key}=#{value}" }.join(';')
|
|
57
90
|
end
|
|
58
91
|
|
|
59
|
-
def make_curl_command url:
|
|
92
|
+
def make_curl_command url:, method: 'GET'
|
|
60
93
|
command = +''
|
|
61
94
|
command << 'curl'
|
|
62
95
|
command << ' -L' # follow redirects
|
|
@@ -65,8 +98,13 @@ class JiraGateway
|
|
|
65
98
|
command << " --cookie #{@cookies.inspect}" unless @cookies.empty?
|
|
66
99
|
command << " --user #{@jira_email}:#{@jira_api_token}" if @jira_api_token
|
|
67
100
|
command << " -H \"Authorization: Bearer #{@jira_personal_access_token}\"" if @jira_personal_access_token
|
|
68
|
-
command <<
|
|
101
|
+
command << " --request #{method}"
|
|
102
|
+
if method == 'POST'
|
|
103
|
+
command << ' --data @-'
|
|
104
|
+
command << ' --header "Content-Type: application/json"'
|
|
105
|
+
end
|
|
69
106
|
command << ' --header "Accept: application/json"'
|
|
107
|
+
command << ' --show-error --fail' # Better diagnostics when the server returns an error
|
|
70
108
|
command << " --url \"#{url}\""
|
|
71
109
|
command
|
|
72
110
|
end
|
|
@@ -77,4 +115,8 @@ class JiraGateway
|
|
|
77
115
|
|
|
78
116
|
true
|
|
79
117
|
end
|
|
118
|
+
|
|
119
|
+
def cloud?
|
|
120
|
+
@jira_url.downcase.end_with? '.atlassian.net'
|
|
121
|
+
end
|
|
80
122
|
end
|
|
@@ -58,7 +58,16 @@ class ProjectConfig
|
|
|
58
58
|
|
|
59
59
|
def load_settings
|
|
60
60
|
# This is the weird exception that we don't ever want mocked out so we skip FileSystem entirely.
|
|
61
|
-
JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
|
|
61
|
+
settings = JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
|
|
62
|
+
|
|
63
|
+
if settings['blocked_color']
|
|
64
|
+
file_system.deprecated message: 'blocked color should be set via css now', date: '2024-05-03'
|
|
65
|
+
end
|
|
66
|
+
if settings['stalled_color']
|
|
67
|
+
file_system.deprecated message: 'stalled color should be set via css now', date: '2024-05-03'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
settings
|
|
62
71
|
end
|
|
63
72
|
|
|
64
73
|
def guess_project_id
|
|
@@ -114,10 +123,14 @@ class ProjectConfig
|
|
|
114
123
|
def file_prefix prefix
|
|
115
124
|
# The file_prefix has to be set before almost everything else. It really should have been an attribute
|
|
116
125
|
# on the project declaration itself. Hindsight is 20/20.
|
|
126
|
+
|
|
127
|
+
# There can only be one of these
|
|
117
128
|
if @file_prefix
|
|
118
|
-
raise "file_prefix
|
|
129
|
+
raise "file_prefix can only be set once. Was #{@file_prefix.inspect} and now changed to #{prefix.inspect}."
|
|
119
130
|
end
|
|
120
131
|
|
|
132
|
+
raise_if_prefix_already_used(prefix)
|
|
133
|
+
|
|
121
134
|
@file_prefix = prefix
|
|
122
135
|
|
|
123
136
|
# Yes, this is a wierd place to be initializing this. Unfortunately, it has to happen after the file_prefix
|
|
@@ -130,8 +143,21 @@ class ProjectConfig
|
|
|
130
143
|
@file_prefix
|
|
131
144
|
end
|
|
132
145
|
|
|
133
|
-
def
|
|
134
|
-
|
|
146
|
+
def raise_if_prefix_already_used prefix
|
|
147
|
+
@exporter.project_configs.each do |project|
|
|
148
|
+
next unless project.get_file_prefix(raise_if_not_set: false) == prefix && project.target_path == target_path
|
|
149
|
+
|
|
150
|
+
raise "Project #{name.inspect} specifies file prefix #{prefix.inspect}, " \
|
|
151
|
+
"but that is already used by project #{project.name.inspect} in the same target path #{target_path.inspect}. " \
|
|
152
|
+
'This is almost guaranteed to be too much copy and paste in your configuration. ' \
|
|
153
|
+
'File prefixes must be unique within a directory.'
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def get_file_prefix raise_if_not_set: true
|
|
158
|
+
if @file_prefix.nil? && raise_if_not_set
|
|
159
|
+
raise 'file_prefix has not been set yet. Move it to the top of the project declaration.'
|
|
160
|
+
end
|
|
135
161
|
|
|
136
162
|
@file_prefix
|
|
137
163
|
end
|
|
@@ -278,8 +304,9 @@ class ProjectConfig
|
|
|
278
304
|
file_system.foreach(@target_path) do |file|
|
|
279
305
|
next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
|
|
280
306
|
|
|
307
|
+
board_id = $1.to_i
|
|
281
308
|
file_path = File.join(@target_path, file)
|
|
282
|
-
board = @all_boards[
|
|
309
|
+
board = @all_boards[board_id]
|
|
283
310
|
unless board
|
|
284
311
|
@exporter.file_system.log(
|
|
285
312
|
'Found sprint data but can\'t find a matching board in config. ' \
|
|
@@ -335,6 +362,12 @@ class ProjectConfig
|
|
|
335
362
|
json.each { |user_data| @users << User.new(raw: user_data) }
|
|
336
363
|
end
|
|
337
364
|
|
|
365
|
+
def atlassian_document_format
|
|
366
|
+
@atlassian_document_format ||= AtlassianDocumentFormat.new(
|
|
367
|
+
users: @users, timezone_offset: exporter.timezone_offset
|
|
368
|
+
)
|
|
369
|
+
end
|
|
370
|
+
|
|
338
371
|
def to_time string, end_of_day: false
|
|
339
372
|
time = end_of_day ? '23:59:59' : '00:00:00'
|
|
340
373
|
string = "#{string}T#{time}#{exporter.timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
|
|
@@ -526,6 +559,7 @@ class ProjectConfig
|
|
|
526
559
|
end
|
|
527
560
|
|
|
528
561
|
def discard_changes_before status_becomes: nil, &block
|
|
562
|
+
cycletimes_touched = Set.new
|
|
529
563
|
if status_becomes
|
|
530
564
|
status_becomes = [status_becomes] unless status_becomes.is_a? Array
|
|
531
565
|
|
|
@@ -558,6 +592,7 @@ class ProjectConfig
|
|
|
558
592
|
next if original_start_time.nil?
|
|
559
593
|
|
|
560
594
|
issue.discard_changes_before cutoff_time
|
|
595
|
+
cycletimes_touched << issue.board.cycletime
|
|
561
596
|
|
|
562
597
|
next unless cutoff_time
|
|
563
598
|
next if original_start_time > cutoff_time # ie the cutoff would have made no difference.
|
|
@@ -568,5 +603,7 @@ class ProjectConfig
|
|
|
568
603
|
issue: issue
|
|
569
604
|
}
|
|
570
605
|
end
|
|
606
|
+
|
|
607
|
+
cycletimes_touched.each { |c| c.flush_cache }
|
|
571
608
|
end
|
|
572
609
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# When strings are serialized into JSON, they're converted to actual strings. The purpose
|
|
4
|
+
# of this class is to allow raw javascript to be passed through.
|
|
5
|
+
class RawJavascript
|
|
6
|
+
def initialize content
|
|
7
|
+
@content = content
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def to_json(*_args)
|
|
11
|
+
@content
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -7,5 +7,7 @@
|
|
|
7
7
|
"flagged_means_blocked": true,
|
|
8
8
|
|
|
9
9
|
"expedited_priority_names": ["Critical", "Highest"],
|
|
10
|
-
"priority_order": ["Lowest", "Low", "Medium", "High", "Highest"]
|
|
10
|
+
"priority_order": ["Lowest", "Low", "Medium", "High", "Highest"],
|
|
11
|
+
|
|
12
|
+
"cache_cycletime_calculations": true
|
|
11
13
|
}
|
data/lib/jirametrics/sprint.rb
CHANGED
|
@@ -13,6 +13,7 @@ class Sprint
|
|
|
13
13
|
def id = @raw['id']
|
|
14
14
|
def active? = (@raw['state'] == 'active')
|
|
15
15
|
def closed? = (@raw['state'] == 'closed')
|
|
16
|
+
def future? = (@raw['state'] == 'future')
|
|
16
17
|
|
|
17
18
|
def completed_at? time
|
|
18
19
|
completed_at = completed_time
|
|
@@ -36,6 +37,17 @@ class Sprint
|
|
|
36
37
|
def goal = @raw['goal']
|
|
37
38
|
def name = @raw['name']
|
|
38
39
|
|
|
40
|
+
def day_count
|
|
41
|
+
return '' if future?
|
|
42
|
+
|
|
43
|
+
if closed?
|
|
44
|
+
days = (completed_time.to_date - start_time.to_date).to_i + 1
|
|
45
|
+
else
|
|
46
|
+
days = (end_time.to_date - start_time.to_date).to_i + 1
|
|
47
|
+
end
|
|
48
|
+
"#{days} days"
|
|
49
|
+
end
|
|
50
|
+
|
|
39
51
|
private
|
|
40
52
|
|
|
41
53
|
def parse_time time_string
|
|
@@ -48,8 +48,9 @@ class SprintBurndown < ChartBase
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def run
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
return nil unless current_board.scrum?
|
|
52
|
+
|
|
53
|
+
sprints = sprints_in_time_range current_board
|
|
53
54
|
|
|
54
55
|
change_data_by_sprint = {}
|
|
55
56
|
sprints.each do |sprint|
|
|
@@ -110,6 +111,9 @@ class SprintBurndown < ChartBase
|
|
|
110
111
|
|
|
111
112
|
def sprints_in_time_range board
|
|
112
113
|
board.sprints.select do |sprint|
|
|
114
|
+
# If it's never been started then it's just a holding area. Ignore it.
|
|
115
|
+
next if sprint.future?
|
|
116
|
+
|
|
113
117
|
sprint_end_time = sprint.completed_time || sprint.end_time
|
|
114
118
|
sprint_start_time = sprint.start_time
|
|
115
119
|
next false if sprint_start_time.nil?
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Stitcher < HtmlGenerator
|
|
4
|
+
class StitchContent
|
|
5
|
+
include ValueEquality
|
|
6
|
+
|
|
7
|
+
attr_reader :file, :title, :content, :type
|
|
8
|
+
|
|
9
|
+
def initialize file:, title:, type:, content:
|
|
10
|
+
@file = file
|
|
11
|
+
@title = title
|
|
12
|
+
@content = content
|
|
13
|
+
@type = type
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :loaded_files, :all_stitches
|
|
18
|
+
|
|
19
|
+
def initialize file_system:
|
|
20
|
+
super()
|
|
21
|
+
self.file_system = file_system
|
|
22
|
+
@all_stitches = []
|
|
23
|
+
@loaded_files = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run stitch_file:
|
|
27
|
+
output_filename = make_output_filename stitch_file
|
|
28
|
+
file_system.log "Creating file #{output_filename.inspect}", also_write_to_stderr: true
|
|
29
|
+
erb = ERB.new file_system.load(stitch_file)
|
|
30
|
+
@sections = [[erb.result(binding), :body]]
|
|
31
|
+
create_html output_filename: output_filename, settings: {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def make_output_filename input_filename
|
|
35
|
+
if /^(.+)\.erb$/ =~ input_filename
|
|
36
|
+
"#{$1}.html"
|
|
37
|
+
else
|
|
38
|
+
"#{input_filename}.html"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def grab_by_title title, from_file:, type: 'chart'
|
|
43
|
+
parse_file from_file
|
|
44
|
+
stitch_content = @all_stitches.find { |s| s.file == from_file && s.title == title && s.type == type }
|
|
45
|
+
return stitch_content.content if stitch_content
|
|
46
|
+
|
|
47
|
+
raise "Unable to find content in file #{from_file.inspect} matching title: #{title.inspect}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse_file filename
|
|
51
|
+
return false if @loaded_files.include? filename
|
|
52
|
+
|
|
53
|
+
# To match: <!-- seam-start | chart78 | GithubPrScatterplot | PR Scatterplot | chart -->
|
|
54
|
+
regex = /^<!-- seam-(?<seam>start|end) \| (?<id>[^|]+) \| (?<clazz>[^|]+) \| (?<title>[^|]+) \| (?<type>[^|]+) -->$/
|
|
55
|
+
content = nil
|
|
56
|
+
file_system.load(filename).lines do |line|
|
|
57
|
+
matches = line.match(regex)
|
|
58
|
+
if matches
|
|
59
|
+
if matches[:seam] == 'start'
|
|
60
|
+
content = +''
|
|
61
|
+
else
|
|
62
|
+
@all_stitches << Stitcher::StitchContent.new(
|
|
63
|
+
file: filename, title: matches[:title], type: matches[:type], content: content
|
|
64
|
+
)
|
|
65
|
+
content = nil
|
|
66
|
+
end
|
|
67
|
+
elsif content
|
|
68
|
+
content << line
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
@loaded_files << filename
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
end
|