jirametrics 2.14 → 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 +3 -3
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/board.rb +4 -0
- data/lib/jirametrics/board_config.rb +3 -1
- data/lib/jirametrics/change_item.rb +11 -4
- data/lib/jirametrics/chart_base.rb +34 -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 +6 -20
- 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 -99
- 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 +16 -9
- data/lib/jirametrics/html/index.erb +3 -35
- 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 +125 -19
- data/lib/jirametrics/jira_gateway.rb +55 -17
- data/lib/jirametrics/project_config.rb +22 -2
- 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/stitcher.rb +75 -0
- data/lib/jirametrics.rb +26 -70
- metadata +10 -3
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<h2>Burndown by <%= y_axis_title %></h2>
|
|
2
2
|
|
|
3
|
+
<%= seam_start %>
|
|
3
4
|
<div class="chart">
|
|
4
5
|
<canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
|
|
5
6
|
</div>
|
|
@@ -63,16 +64,20 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
63
64
|
}
|
|
64
65
|
});
|
|
65
66
|
</script>
|
|
67
|
+
<%= seam_end %>
|
|
66
68
|
|
|
67
69
|
<%
|
|
68
70
|
link_id = next_id
|
|
69
71
|
issues_id = next_id
|
|
70
72
|
%>
|
|
71
|
-
|
|
72
|
-
<div
|
|
73
|
+
<section>
|
|
74
|
+
<div class='foldable startFolded'>Show statistics</div>
|
|
75
|
+
<div id="<%= issues_id %>">
|
|
76
|
+
<%= seam_start 'stats_table' %>
|
|
73
77
|
<table class='standard' style="margin-left: 1em;">
|
|
74
78
|
<thead>
|
|
75
79
|
<th>Sprint</th>
|
|
80
|
+
<th>Length</th>
|
|
76
81
|
<th>State</th>
|
|
77
82
|
<th>Started</th>
|
|
78
83
|
<th>Completed</th>
|
|
@@ -85,6 +90,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
85
90
|
<% @summary_stats.keys.sort_by(&:start_time).each do |sprint| %>
|
|
86
91
|
<tr>
|
|
87
92
|
<td><%= sprint.name %></td>
|
|
93
|
+
<td><%= sprint.day_count %></td>
|
|
88
94
|
<td><%= sprint.raw['state'] %></td>
|
|
89
95
|
<% stats = @summary_stats[sprint] %>
|
|
90
96
|
<td><%= stats.started %></td>
|
|
@@ -101,6 +107,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
101
107
|
<% end %>
|
|
102
108
|
</tbody>
|
|
103
109
|
</table>
|
|
110
|
+
<%= seam_end 'stats_table' %>
|
|
104
111
|
|
|
105
112
|
<p>Legend:
|
|
106
113
|
<ul>
|
|
@@ -109,4 +116,5 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
109
116
|
<% end %>
|
|
110
117
|
</ul>
|
|
111
118
|
</p>
|
|
112
|
-
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</section>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
<%= seam_start %>
|
|
2
2
|
<div class="chart">
|
|
3
3
|
<canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
|
|
4
4
|
</div>
|
|
@@ -59,4 +59,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
59
59
|
}
|
|
60
60
|
});
|
|
61
61
|
</script>
|
|
62
|
-
|
|
62
|
+
<%= seam_end %>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class HtmlGenerator
|
|
4
|
+
attr_accessor :file_system, :settings
|
|
5
|
+
|
|
6
|
+
def create_html output_filename:, settings:
|
|
7
|
+
@settings = settings
|
|
8
|
+
html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
|
|
9
|
+
css = load_css html_directory: html_directory
|
|
10
|
+
javascript = file_system.load(File.join(html_directory, 'index.js'))
|
|
11
|
+
erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
|
|
12
|
+
file_system.save_file content: erb.result(binding), filename: output_filename
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def load_css html_directory:
|
|
16
|
+
base_css_filename = File.join(html_directory, 'index.css')
|
|
17
|
+
base_css = file_system.load(base_css_filename)
|
|
18
|
+
|
|
19
|
+
extra_css_filename = settings['include_css']
|
|
20
|
+
if extra_css_filename
|
|
21
|
+
if File.exist?(extra_css_filename)
|
|
22
|
+
base_css << "\n\n" << file_system.load(extra_css_filename)
|
|
23
|
+
log("Loaded CSS: #{extra_css_filename}")
|
|
24
|
+
else
|
|
25
|
+
log("Unable to find specified CSS file: #{extra_css_filename}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
base_css
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require 'erb'
|
|
4
4
|
require 'jirametrics/self_or_issue_dispatcher'
|
|
5
5
|
|
|
6
|
-
class HtmlReportConfig
|
|
6
|
+
class HtmlReportConfig < HtmlGenerator
|
|
7
7
|
include SelfOrIssueDispatcher
|
|
8
8
|
|
|
9
9
|
attr_reader :file_config, :sections, :charts
|
|
@@ -51,7 +51,10 @@ class HtmlReportConfig
|
|
|
51
51
|
@file_config.project_config.all_boards.each_value do |board|
|
|
52
52
|
raise 'Multiple cycletimes not supported' if board.cycletime
|
|
53
53
|
|
|
54
|
-
board.cycletime = CycleTimeConfig.new(
|
|
54
|
+
board.cycletime = CycleTimeConfig.new(
|
|
55
|
+
possible_statuses: file_config.project_config, label: label, block: block,
|
|
56
|
+
file_system: file_system, settings: settings
|
|
57
|
+
)
|
|
55
58
|
end
|
|
56
59
|
end
|
|
57
60
|
|
|
@@ -70,10 +73,7 @@ class HtmlReportConfig
|
|
|
70
73
|
|
|
71
74
|
html create_footer
|
|
72
75
|
|
|
73
|
-
|
|
74
|
-
css = load_css html_directory: html_directory
|
|
75
|
-
erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
|
|
76
|
-
file_system.save_file content: erb.result(binding), filename: @file_config.output_filename
|
|
76
|
+
create_html output_filename: @file_config.output_filename, settings: settings
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
def file_system
|
|
@@ -84,24 +84,6 @@ class HtmlReportConfig
|
|
|
84
84
|
file_system.log message
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
-
def load_css html_directory:
|
|
88
|
-
base_css_filename = File.join(html_directory, 'index.css')
|
|
89
|
-
base_css = file_system.load(base_css_filename)
|
|
90
|
-
log("Loaded CSS: #{base_css_filename}")
|
|
91
|
-
|
|
92
|
-
extra_css_filename = settings['include_css']
|
|
93
|
-
if extra_css_filename
|
|
94
|
-
if File.exist?(extra_css_filename)
|
|
95
|
-
base_css << "\n\n" << file_system.load(extra_css_filename)
|
|
96
|
-
log("Loaded CSS: #{extra_css_filename}")
|
|
97
|
-
else
|
|
98
|
-
log("Unable to find specified CSS file: #{extra_css_filename}")
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
base_css
|
|
103
|
-
end
|
|
104
|
-
|
|
105
87
|
def board_id id
|
|
106
88
|
@board_id = id
|
|
107
89
|
end
|
|
@@ -160,7 +142,7 @@ class HtmlReportConfig
|
|
|
160
142
|
chart.time_range = project_config.time_range
|
|
161
143
|
chart.timezone_offset = timezone_offset
|
|
162
144
|
chart.settings = settings
|
|
163
|
-
chart.
|
|
145
|
+
chart.atlassian_document_format = project_config.atlassian_document_format
|
|
164
146
|
|
|
165
147
|
chart.all_boards = project_config.all_boards
|
|
166
148
|
chart.board_id = find_board_id
|
|
@@ -173,6 +155,7 @@ class HtmlReportConfig
|
|
|
173
155
|
after_init_block&.call chart
|
|
174
156
|
|
|
175
157
|
@charts << chart
|
|
158
|
+
chart.before_run
|
|
176
159
|
html chart.run
|
|
177
160
|
end
|
|
178
161
|
|
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
|
|
@@ -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?
|
|
@@ -705,6 +799,23 @@ class Issue
|
|
|
705
799
|
board.sprints.select { |s| sprint_ids.include? s.id }
|
|
706
800
|
end
|
|
707
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
|
+
|
|
708
819
|
private
|
|
709
820
|
|
|
710
821
|
def load_history_into_changes
|
|
@@ -729,14 +840,6 @@ class Issue
|
|
|
729
840
|
end
|
|
730
841
|
end
|
|
731
842
|
|
|
732
|
-
def compact_text text, max = 60
|
|
733
|
-
return nil if text.nil?
|
|
734
|
-
|
|
735
|
-
text = text.gsub(/\s+/, ' ').strip
|
|
736
|
-
text = "#{text[0..max]}..." if text.length > max
|
|
737
|
-
text
|
|
738
|
-
end
|
|
739
|
-
|
|
740
843
|
def sort_changes!
|
|
741
844
|
@changes.sort! do |a, b|
|
|
742
845
|
# It's common that a resolved will happen at the same time as a status change.
|
|
@@ -754,6 +857,9 @@ class Issue
|
|
|
754
857
|
first_status = nil
|
|
755
858
|
first_status_id = nil
|
|
756
859
|
|
|
860
|
+
# There won't be a created timestamp in cases where this was a linked issue
|
|
861
|
+
return unless @raw['fields']['created']
|
|
862
|
+
|
|
757
863
|
created_time = parse_time @raw['fields']['created']
|
|
758
864
|
first_change = @changes.find { |change| change.field == field_name }
|
|
759
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
|
|
68
|
+
def sanitize_message message
|
|
69
|
+
token = @jira_api_token || @jira_personal_access_token
|
|
70
|
+
return message unless token # cookie based authentication
|
|
32
71
|
|
|
33
|
-
|
|
34
|
-
@file_system.log result unless $CHILD_STATUS.success?
|
|
35
|
-
return result if $CHILD_STATUS.success?
|
|
36
|
-
|
|
37
|
-
@file_system.log "Failed call with exit status #{$CHILD_STATUS.exitstatus}."
|
|
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
|
|
@@ -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
|
|
@@ -295,8 +304,9 @@ class ProjectConfig
|
|
|
295
304
|
file_system.foreach(@target_path) do |file|
|
|
296
305
|
next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
|
|
297
306
|
|
|
307
|
+
board_id = $1.to_i
|
|
298
308
|
file_path = File.join(@target_path, file)
|
|
299
|
-
board = @all_boards[
|
|
309
|
+
board = @all_boards[board_id]
|
|
300
310
|
unless board
|
|
301
311
|
@exporter.file_system.log(
|
|
302
312
|
'Found sprint data but can\'t find a matching board in config. ' \
|
|
@@ -352,6 +362,12 @@ class ProjectConfig
|
|
|
352
362
|
json.each { |user_data| @users << User.new(raw: user_data) }
|
|
353
363
|
end
|
|
354
364
|
|
|
365
|
+
def atlassian_document_format
|
|
366
|
+
@atlassian_document_format ||= AtlassianDocumentFormat.new(
|
|
367
|
+
users: @users, timezone_offset: exporter.timezone_offset
|
|
368
|
+
)
|
|
369
|
+
end
|
|
370
|
+
|
|
355
371
|
def to_time string, end_of_day: false
|
|
356
372
|
time = end_of_day ? '23:59:59' : '00:00:00'
|
|
357
373
|
string = "#{string}T#{time}#{exporter.timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
|
|
@@ -543,6 +559,7 @@ class ProjectConfig
|
|
|
543
559
|
end
|
|
544
560
|
|
|
545
561
|
def discard_changes_before status_becomes: nil, &block
|
|
562
|
+
cycletimes_touched = Set.new
|
|
546
563
|
if status_becomes
|
|
547
564
|
status_becomes = [status_becomes] unless status_becomes.is_a? Array
|
|
548
565
|
|
|
@@ -575,6 +592,7 @@ class ProjectConfig
|
|
|
575
592
|
next if original_start_time.nil?
|
|
576
593
|
|
|
577
594
|
issue.discard_changes_before cutoff_time
|
|
595
|
+
cycletimes_touched << issue.board.cycletime
|
|
578
596
|
|
|
579
597
|
next unless cutoff_time
|
|
580
598
|
next if original_start_time > cutoff_time # ie the cutoff would have made no difference.
|
|
@@ -585,5 +603,7 @@ class ProjectConfig
|
|
|
585
603
|
issue: issue
|
|
586
604
|
}
|
|
587
605
|
end
|
|
606
|
+
|
|
607
|
+
cycletimes_touched.each { |c| c.flush_cache }
|
|
588
608
|
end
|
|
589
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?
|