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
|
@@ -62,7 +62,9 @@ class CycletimeHistogram < ChartBase
|
|
|
62
62
|
)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
if data_sets.empty?
|
|
66
|
+
return "<h1 class='foldable'>#{@header_text}</h1><div>No data matched the selected criteria. Nothing to show.</div>"
|
|
67
|
+
end
|
|
66
68
|
|
|
67
69
|
wrap_and_render(binding, __FILE__)
|
|
68
70
|
end
|
|
@@ -35,14 +35,33 @@ class CycletimeScatterplot < ChartBase
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
@percentage_lines = []
|
|
38
|
-
@
|
|
38
|
+
@highest_y_value = 0
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
def
|
|
42
|
-
|
|
41
|
+
def all_items
|
|
42
|
+
completed_issues_in_range include_unstarted: false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def x_value item
|
|
46
|
+
item.board.cycletime.started_stopped_times(item).last
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def y_value item
|
|
50
|
+
item.board.cycletime.cycletime(item)
|
|
51
|
+
end
|
|
43
52
|
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
def title_value item
|
|
54
|
+
"#{item.key} : #{item.summary} (#{label_days(y_value(item))})"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def y_axis_heading
|
|
58
|
+
'Cycle time in days'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run
|
|
62
|
+
items = all_items
|
|
63
|
+
data_sets = create_datasets items
|
|
64
|
+
overall_percent_line = calculate_percent_line(items)
|
|
46
65
|
@percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
|
|
47
66
|
|
|
48
67
|
return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
|
|
@@ -50,14 +69,14 @@ class CycletimeScatterplot < ChartBase
|
|
|
50
69
|
wrap_and_render(binding, __FILE__)
|
|
51
70
|
end
|
|
52
71
|
|
|
53
|
-
def create_datasets
|
|
72
|
+
def create_datasets items
|
|
54
73
|
data_sets = []
|
|
55
74
|
|
|
56
|
-
group_issues(
|
|
75
|
+
group_issues(items).each do |rules, completed_items_by_type|
|
|
57
76
|
label = rules.label
|
|
58
77
|
color = rules.color
|
|
59
|
-
percent_line = calculate_percent_line
|
|
60
|
-
data =
|
|
78
|
+
percent_line = calculate_percent_line completed_items_by_type
|
|
79
|
+
data = completed_items_by_type.filter_map { |issue| data_for_issue(issue) }
|
|
61
80
|
data_sets << {
|
|
62
81
|
label: "#{label} (85% at #{label_days(percent_line)})",
|
|
63
82
|
data: data,
|
|
@@ -86,7 +105,7 @@ class CycletimeScatterplot < ChartBase
|
|
|
86
105
|
calculator = TrendLineCalculator.new(points)
|
|
87
106
|
data_points = calculator.chart_datapoints(
|
|
88
107
|
range: time_range.begin.to_i..time_range.end.to_i,
|
|
89
|
-
max_y: @
|
|
108
|
+
max_y: @highest_y_value
|
|
90
109
|
)
|
|
91
110
|
data_points.each do |point_hash|
|
|
92
111
|
point_hash[:x] = chart_format Time.at(point_hash[:x])
|
|
@@ -106,21 +125,21 @@ class CycletimeScatterplot < ChartBase
|
|
|
106
125
|
}
|
|
107
126
|
end
|
|
108
127
|
|
|
109
|
-
def data_for_issue
|
|
110
|
-
cycle_time =
|
|
128
|
+
def data_for_issue item
|
|
129
|
+
cycle_time = y_value(item)
|
|
111
130
|
return nil if cycle_time < 1 # These will get called out on the quality report
|
|
112
131
|
|
|
113
|
-
@
|
|
132
|
+
@highest_y_value = cycle_time if @highest_y_value < cycle_time
|
|
114
133
|
|
|
115
134
|
{
|
|
116
135
|
y: cycle_time,
|
|
117
|
-
x: chart_format(
|
|
118
|
-
title: [
|
|
136
|
+
x: chart_format(x_value(item)),
|
|
137
|
+
title: [title_value(item)]
|
|
119
138
|
}
|
|
120
139
|
end
|
|
121
140
|
|
|
122
|
-
def calculate_percent_line
|
|
123
|
-
times =
|
|
141
|
+
def calculate_percent_line items
|
|
142
|
+
times = items.collect { |item| y_value(item) }
|
|
124
143
|
index = times.size * 85 / 100
|
|
125
144
|
times.sort[index]
|
|
126
145
|
end
|
|
@@ -23,7 +23,7 @@ class DailyView < ChartBase
|
|
|
23
23
|
def run
|
|
24
24
|
aging_issues = select_aging_issues
|
|
25
25
|
|
|
26
|
-
return "<h1>#{@header_text}</h1>There are no items currently in progress" if aging_issues.empty?
|
|
26
|
+
return "<h1 class='foldable'>#{@header_text}</h1>There are no items currently in progress" if aging_issues.empty?
|
|
27
27
|
|
|
28
28
|
result = +''
|
|
29
29
|
result << render_top_text(binding)
|
|
@@ -33,10 +33,6 @@ class DailyView < ChartBase
|
|
|
33
33
|
result
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
def atlassian_document_format
|
|
37
|
-
@atlassian_document_format ||= AtlassianDocumentFormat.new(users: users, timezone_offset: timezone_offset)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
36
|
def select_aging_issues
|
|
41
37
|
aging_issues = issues.select do |issue|
|
|
42
38
|
started_at, stopped_at = issue.board.cycletime.started_stopped_times(issue)
|
|
@@ -170,13 +166,7 @@ class DailyView < ChartBase
|
|
|
170
166
|
|
|
171
167
|
return lines if subtasks.empty?
|
|
172
168
|
|
|
173
|
-
|
|
174
|
-
lines <<
|
|
175
|
-
"<a href=\"javascript:toggle_visibility('open#{id}', 'close#{id}', 'section#{id}');\">" \
|
|
176
|
-
"<span id='open#{id}' style='display: none'>▶ Child issues</span>" \
|
|
177
|
-
"<span id='close#{id}'>▼ Child issues</span></a>"
|
|
178
|
-
lines << "<section id='section#{id}'>"
|
|
179
|
-
|
|
169
|
+
lines << '<section><div class="foldable">Child issues</div>'
|
|
180
170
|
lines += subtasks
|
|
181
171
|
lines << '</section>'
|
|
182
172
|
|
|
@@ -187,16 +177,11 @@ class DailyView < ChartBase
|
|
|
187
177
|
history = issue.changes.reverse
|
|
188
178
|
lines = []
|
|
189
179
|
|
|
190
|
-
|
|
191
|
-
lines << [
|
|
192
|
-
"<a href=\"javascript:toggle_visibility('open#{id}', 'close#{id}', 'table#{id}');\">" \
|
|
193
|
-
"<span id='open#{id}'>▶ Issue History</span>" \
|
|
194
|
-
"<span id='close#{id}' style='display: none'>▼ Issue History</span></a>"
|
|
195
|
-
]
|
|
180
|
+
lines << '<section><div class="foldable startFolded">Issue history</div>'
|
|
196
181
|
table = +''
|
|
197
|
-
table <<
|
|
182
|
+
table << '<table>'
|
|
198
183
|
history.each do |c|
|
|
199
|
-
time = c.time.strftime '%b %d, %I:%M%P'
|
|
184
|
+
time = c.time.strftime '%b %d, %Y @ %I:%M%P'
|
|
200
185
|
|
|
201
186
|
table << '<tr>'
|
|
202
187
|
table << "<td><span class='time' title='Timestamp: #{c.time}'>#{time}</span></td>"
|
|
@@ -207,6 +192,7 @@ class DailyView < ChartBase
|
|
|
207
192
|
end
|
|
208
193
|
table << '</table>'
|
|
209
194
|
lines << [table]
|
|
195
|
+
lines << '</section>'
|
|
210
196
|
lines
|
|
211
197
|
end
|
|
212
198
|
|
|
@@ -51,8 +51,6 @@ class DailyWipByAgeChart < DailyWipChart
|
|
|
51
51
|
def default_grouping_rules issue:, rules:
|
|
52
52
|
started, stopped = issue.board.cycletime.started_stopped_dates(issue)
|
|
53
53
|
|
|
54
|
-
rules.issue_hint = "(age: #{label_days (rules.current_date - started + 1).to_i})" if started
|
|
55
|
-
|
|
56
54
|
if stopped && started.nil? # We can't tell when it started
|
|
57
55
|
@has_completed_but_not_started = true
|
|
58
56
|
not_started stopped: stopped, rules: rules, created: issue.created.to_date
|
|
@@ -72,7 +70,7 @@ class DailyWipByAgeChart < DailyWipChart
|
|
|
72
70
|
rules.label = 'Start date unknown'
|
|
73
71
|
rules.color = '--body-background'
|
|
74
72
|
rules.group_priority = 11
|
|
75
|
-
created_days = rules.current_date - created
|
|
73
|
+
created_days = rules.current_date - created
|
|
76
74
|
rules.issue_hint = "(created: #{label_days created_days.to_i} earlier, stopped on #{stopped})"
|
|
77
75
|
end
|
|
78
76
|
end
|
|
@@ -84,7 +82,8 @@ class DailyWipByAgeChart < DailyWipChart
|
|
|
84
82
|
end
|
|
85
83
|
|
|
86
84
|
def group_by_age started:, rules:
|
|
87
|
-
age = rules.current_date - started + 1
|
|
85
|
+
age = (rules.current_date - started).to_i + 1
|
|
86
|
+
rules.issue_hint = "(age: #{label_days age})"
|
|
88
87
|
|
|
89
88
|
case age
|
|
90
89
|
when 1
|
|
@@ -41,21 +41,30 @@ class DailyWipByBlockedStalledChart < DailyWipChart
|
|
|
41
41
|
def default_grouping_rules issue:, rules:
|
|
42
42
|
started, stopped = issue.board.cycletime.started_stopped_times(issue)
|
|
43
43
|
stopped_date = stopped&.to_date
|
|
44
|
+
started_date = started&.to_date
|
|
44
45
|
|
|
45
46
|
date = rules.current_date
|
|
46
47
|
change = issue.blocked_stalled_by_date(date_range: date..date, chart_end_time: time_range.end)[date]
|
|
47
|
-
|
|
48
48
|
stopped_today = stopped_date == rules.current_date
|
|
49
49
|
|
|
50
|
+
days = nil
|
|
51
|
+
if started_date && stopped_date
|
|
52
|
+
days = (stopped_date - started_date).to_i + 1 # cycletime
|
|
53
|
+
elsif started_date
|
|
54
|
+
days = (time_range.end.to_date - started_date).to_i + 1 # age
|
|
55
|
+
end
|
|
56
|
+
|
|
50
57
|
if stopped_today && started.nil?
|
|
51
58
|
@has_completed_but_not_started = true
|
|
52
59
|
rules.label = 'Completed but not started'
|
|
53
60
|
rules.color = '--wip-chart-completed-but-not-started-color'
|
|
54
61
|
rules.group_priority = -1
|
|
62
|
+
rules.issue_hint = '(Cycle time: Unknown)'
|
|
55
63
|
elsif stopped_today
|
|
56
64
|
rules.label = 'Completed'
|
|
57
65
|
rules.color = '--wip-chart-completed-color'
|
|
58
66
|
rules.group_priority = -2
|
|
67
|
+
rules.issue_hint = "(Cycle time: #{label_days days})"
|
|
59
68
|
elsif started.nil?
|
|
60
69
|
rules.label = 'Start date unknown'
|
|
61
70
|
rules.color = '--body-background'
|
|
@@ -64,16 +73,17 @@ class DailyWipByBlockedStalledChart < DailyWipChart
|
|
|
64
73
|
rules.label = 'Blocked'
|
|
65
74
|
rules.color = '--blocked-color'
|
|
66
75
|
rules.group_priority = 1
|
|
67
|
-
rules.issue_hint = "(#{change.reasons})"
|
|
76
|
+
rules.issue_hint = "(Age: #{label_days days}, #{change.reasons})"
|
|
68
77
|
elsif change&.stalled?
|
|
69
78
|
rules.label = 'Stalled'
|
|
70
79
|
rules.color = '--stalled-color'
|
|
71
80
|
rules.group_priority = 2
|
|
72
|
-
rules.issue_hint = "(#{change.reasons})"
|
|
81
|
+
rules.issue_hint = "(Age: #{label_days days}, #{change.reasons})"
|
|
73
82
|
else
|
|
74
83
|
rules.label = 'Active'
|
|
75
84
|
rules.color = '--wip-chart-active-color'
|
|
76
85
|
rules.group_priority = 3
|
|
86
|
+
rules.issue_hint = "(Age: #{label_days days})"
|
|
77
87
|
end
|
|
78
88
|
end
|
|
79
89
|
end
|
|
@@ -66,7 +66,7 @@ class DailyWipChart < ChartBase
|
|
|
66
66
|
hash = {}
|
|
67
67
|
|
|
68
68
|
@issues.each do |issue|
|
|
69
|
-
start, stop = issue.
|
|
69
|
+
start, stop = cycletime_for_issue(issue).started_stopped_dates(issue)
|
|
70
70
|
next if start.nil? && stop.nil?
|
|
71
71
|
|
|
72
72
|
# If it stopped but never started then assume it started at creation so the data points
|
|
@@ -266,6 +266,8 @@ class DataQualityReport < ChartBase
|
|
|
266
266
|
|
|
267
267
|
def scan_for_items_blocked_on_closed_tickets entry:
|
|
268
268
|
entry.issue.issue_links.each do |link|
|
|
269
|
+
next unless settings['blocked_link_text'].include?(link.label)
|
|
270
|
+
|
|
269
271
|
this_active = !entry.stopped
|
|
270
272
|
other_active = !link.other_issue.board.cycletime.started_stopped_times(link.other_issue).last
|
|
271
273
|
next unless this_active && !other_active
|
|
@@ -410,14 +412,17 @@ class DataQualityReport < ChartBase
|
|
|
410
412
|
def render_status_not_on_board problems
|
|
411
413
|
<<-HTML
|
|
412
414
|
#{label_issues problems.size} were not visible on the board for some period of time. This may impact
|
|
413
|
-
timings as the work was likely to have been forgotten if it wasn't visible.
|
|
415
|
+
timings as the work was likely to have been forgotten if it wasn't visible. What does "not visible"
|
|
416
|
+
mean in this context? The issue was in a status that is not mapped to any visible column on the board.
|
|
417
|
+
Look in "unmapped statuses" on your board.
|
|
414
418
|
HTML
|
|
415
419
|
end
|
|
416
420
|
|
|
417
421
|
def render_created_in_wrong_status problems
|
|
418
422
|
<<-HTML
|
|
419
|
-
#{label_issues problems.size} were created in a status not
|
|
420
|
-
|
|
423
|
+
#{label_issues problems.size} were created in a status that is not considered to be some varient
|
|
424
|
+
of To Do. Most likely this means that the issue was created from one of the columns on the board,
|
|
425
|
+
rather than in the backlog. Why Jira allows this is still a mystery.
|
|
421
426
|
HTML
|
|
422
427
|
end
|
|
423
428
|
|
|
@@ -51,7 +51,10 @@ class DependencyChart < ChartBase
|
|
|
51
51
|
instance_eval(&@rules_block) if @rules_block
|
|
52
52
|
|
|
53
53
|
dot_graph = build_dot_graph
|
|
54
|
-
|
|
54
|
+
if dot_graph.nil?
|
|
55
|
+
return "<h1 class='foldable'>#{@header_text}</h1>" \
|
|
56
|
+
'<div>No data matched the selected criteria. Nothing to show.</div>'
|
|
57
|
+
end
|
|
55
58
|
|
|
56
59
|
svg = execute_graphviz(dot_graph.join("\n"))
|
|
57
60
|
"<h1>#{@header_text}</h1><div>#{@description_text}</div>#{shrink_svg svg}"
|
|
@@ -3,8 +3,29 @@
|
|
|
3
3
|
require 'cgi'
|
|
4
4
|
require 'json'
|
|
5
5
|
|
|
6
|
+
class DownloadIssueData
|
|
7
|
+
attr_accessor :key, :found_in_primary_query, :last_modified,
|
|
8
|
+
:up_to_date, :cache_path, :issue
|
|
9
|
+
|
|
10
|
+
def initialize(
|
|
11
|
+
key:,
|
|
12
|
+
found_in_primary_query: true,
|
|
13
|
+
last_modified: nil,
|
|
14
|
+
up_to_date: true,
|
|
15
|
+
cache_path: nil,
|
|
16
|
+
issue: nil
|
|
17
|
+
)
|
|
18
|
+
@key = key
|
|
19
|
+
@found_in_primary_query = found_in_primary_query
|
|
20
|
+
@last_modified = last_modified
|
|
21
|
+
@up_to_date = up_to_date
|
|
22
|
+
@cache_path = cache_path
|
|
23
|
+
@issue = issue
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
6
27
|
class Downloader
|
|
7
|
-
CURRENT_METADATA_VERSION =
|
|
28
|
+
CURRENT_METADATA_VERSION = 5
|
|
8
29
|
|
|
9
30
|
attr_accessor :metadata
|
|
10
31
|
attr_reader :file_system
|
|
@@ -12,6 +33,15 @@ class Downloader
|
|
|
12
33
|
# For testing only
|
|
13
34
|
attr_reader :start_date_in_query, :board_id_to_filter_id
|
|
14
35
|
|
|
36
|
+
def self.create download_config:, file_system:, jira_gateway:
|
|
37
|
+
is_cloud = jira_gateway.settings['jira_cloud'] || jira_gateway.cloud?
|
|
38
|
+
(is_cloud ? DownloaderForCloud : DownloaderForDataCenter).new(
|
|
39
|
+
download_config: download_config,
|
|
40
|
+
file_system: file_system,
|
|
41
|
+
jira_gateway: jira_gateway
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
15
45
|
def initialize download_config:, file_system:, jira_gateway:
|
|
16
46
|
@metadata = {}
|
|
17
47
|
@download_config = download_config
|
|
@@ -28,7 +58,6 @@ class Downloader
|
|
|
28
58
|
log '', both: true
|
|
29
59
|
log @download_config.project_config.name, both: true
|
|
30
60
|
|
|
31
|
-
init_gateway
|
|
32
61
|
load_metadata
|
|
33
62
|
|
|
34
63
|
if @metadata['no-download']
|
|
@@ -50,11 +79,6 @@ class Downloader
|
|
|
50
79
|
save_metadata
|
|
51
80
|
end
|
|
52
81
|
|
|
53
|
-
def init_gateway
|
|
54
|
-
@jira_gateway.load_jira_config(@download_config.project_config.jira_config)
|
|
55
|
-
@jira_gateway.ignore_ssl_errors = @download_config.project_config.settings['ignore_ssl_errors']
|
|
56
|
-
end
|
|
57
|
-
|
|
58
82
|
def log text, both: false
|
|
59
83
|
@file_system.log text, also_write_to_stderr: both
|
|
60
84
|
end
|
|
@@ -66,93 +90,6 @@ class Downloader
|
|
|
66
90
|
ids
|
|
67
91
|
end
|
|
68
92
|
|
|
69
|
-
def download_issues board:
|
|
70
|
-
log " Downloading primary issues for board #{board.id}", both: true
|
|
71
|
-
path = File.join(@target_path, "#{file_prefix}_issues/")
|
|
72
|
-
unless Dir.exist?(path)
|
|
73
|
-
log " Creating path #{path}"
|
|
74
|
-
Dir.mkdir(path)
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
filter_id = @board_id_to_filter_id[board.id]
|
|
78
|
-
jql = make_jql(filter_id: filter_id)
|
|
79
|
-
jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
|
|
80
|
-
|
|
81
|
-
log " Downloading linked issues for board #{board.id}", both: true
|
|
82
|
-
loop do
|
|
83
|
-
@issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
|
|
84
|
-
break if @issue_keys_pending_download.empty?
|
|
85
|
-
|
|
86
|
-
keys_to_request = @issue_keys_pending_download[0..99]
|
|
87
|
-
@issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
|
|
88
|
-
jql = "key in (#{keys_to_request.join(', ')})"
|
|
89
|
-
jira_search_by_jql(jql: jql, initial_query: false, board: board, path: path)
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def jira_search_by_jql jql:, initial_query:, board:, path:
|
|
94
|
-
intercept_jql = @download_config.project_config.settings['intercept_jql']
|
|
95
|
-
jql = intercept_jql.call jql if intercept_jql
|
|
96
|
-
|
|
97
|
-
log " JQL: #{jql}"
|
|
98
|
-
escaped_jql = CGI.escape jql
|
|
99
|
-
|
|
100
|
-
if @jira_gateway.cloud?
|
|
101
|
-
max_results = 5_000 # The maximum allowed by Jira
|
|
102
|
-
next_page_token = nil
|
|
103
|
-
issue_count = 0
|
|
104
|
-
|
|
105
|
-
loop do
|
|
106
|
-
json = @jira_gateway.call_url relative_url: '/rest/api/3/search/jql' \
|
|
107
|
-
"?jql=#{escaped_jql}&maxResults=#{max_results}&" \
|
|
108
|
-
"nextPageToken=#{next_page_token}&expand=changelog&fields=*all"
|
|
109
|
-
next_page_token = json['nextPageToken']
|
|
110
|
-
|
|
111
|
-
json['issues'].each do |issue_json|
|
|
112
|
-
issue_json['exporter'] = {
|
|
113
|
-
'in_initial_query' => initial_query
|
|
114
|
-
}
|
|
115
|
-
identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
|
|
116
|
-
file = "#{issue_json['key']}-#{board.id}.json"
|
|
117
|
-
|
|
118
|
-
@file_system.save_json(json: issue_json, filename: File.join(path, file))
|
|
119
|
-
issue_count += 1
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
message = " Downloaded #{issue_count} issues"
|
|
123
|
-
log message, both: true
|
|
124
|
-
|
|
125
|
-
break unless next_page_token
|
|
126
|
-
end
|
|
127
|
-
else
|
|
128
|
-
max_results = 100
|
|
129
|
-
start_at = 0
|
|
130
|
-
total = 1
|
|
131
|
-
while start_at < total
|
|
132
|
-
json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
|
|
133
|
-
"?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
|
|
134
|
-
|
|
135
|
-
json['issues'].each do |issue_json|
|
|
136
|
-
issue_json['exporter'] = {
|
|
137
|
-
'in_initial_query' => initial_query
|
|
138
|
-
}
|
|
139
|
-
identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
|
|
140
|
-
file = "#{issue_json['key']}-#{board.id}.json"
|
|
141
|
-
|
|
142
|
-
@file_system.save_json(json: issue_json, filename: File.join(path, file))
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
total = json['total'].to_i
|
|
146
|
-
max_results = json['maxResults']
|
|
147
|
-
|
|
148
|
-
message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
|
|
149
|
-
log message, both: true
|
|
150
|
-
|
|
151
|
-
start_at += json['issues'].size
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
93
|
def identify_other_issues_to_be_downloaded raw_issue:, board:
|
|
157
94
|
issue = Issue.new raw: raw_issue, board: board
|
|
158
95
|
@issue_keys_downloaded_in_current_run << issue.key
|
|
@@ -178,6 +115,8 @@ class Downloader
|
|
|
178
115
|
end
|
|
179
116
|
|
|
180
117
|
def download_users
|
|
118
|
+
return unless @jira_gateway.cloud?
|
|
119
|
+
|
|
181
120
|
log ' Downloading all users', both: true
|
|
182
121
|
json = @jira_gateway.call_url relative_url: '/rest/api/2/users'
|
|
183
122
|
|
|
@@ -327,11 +266,7 @@ class Downloader
|
|
|
327
266
|
|
|
328
267
|
if start_date
|
|
329
268
|
@download_date_range = start_date..today.to_date
|
|
330
|
-
|
|
331
|
-
# For an incremental download, we want to query from the end of the previous one, not from the
|
|
332
|
-
# beginning of the full range.
|
|
333
|
-
@start_date_in_query = metadata['date_end'] || @download_date_range.begin
|
|
334
|
-
log " Incremental download only. Pulling from #{@start_date_in_query}", both: true if metadata['date_end']
|
|
269
|
+
@start_date_in_query = @download_date_range.begin
|
|
335
270
|
|
|
336
271
|
# Catch-all to pick up anything that's been around since before the range started but hasn't
|
|
337
272
|
# had an update during the range.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class DownloaderForCloud < Downloader
|
|
4
|
+
def jira_instance_type
|
|
5
|
+
'Jira Cloud'
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def search_for_issues jql:, board_id:, path:
|
|
9
|
+
log " JQL: #{jql}"
|
|
10
|
+
escaped_jql = CGI.escape jql
|
|
11
|
+
|
|
12
|
+
hash = {}
|
|
13
|
+
max_results = 5_000 # The maximum allowed by Jira
|
|
14
|
+
next_page_token = nil
|
|
15
|
+
issue_count = 0
|
|
16
|
+
|
|
17
|
+
loop do
|
|
18
|
+
relative_url = +''
|
|
19
|
+
relative_url << '/rest/api/3/search/jql'
|
|
20
|
+
relative_url << "?jql=#{escaped_jql}&maxResults=#{max_results}"
|
|
21
|
+
relative_url << "&nextPageToken=#{next_page_token}" if next_page_token
|
|
22
|
+
relative_url << '&fields=updated'
|
|
23
|
+
|
|
24
|
+
json = @jira_gateway.call_url relative_url: relative_url
|
|
25
|
+
next_page_token = json['nextPageToken']
|
|
26
|
+
|
|
27
|
+
json['issues'].each do |i|
|
|
28
|
+
key = i['key']
|
|
29
|
+
data = DownloadIssueData.new key: key
|
|
30
|
+
data.key = key
|
|
31
|
+
data.last_modified = Time.parse i['fields']['updated']
|
|
32
|
+
data.found_in_primary_query = true
|
|
33
|
+
data.cache_path = File.join(path, "#{key}-#{board_id}.json")
|
|
34
|
+
data.up_to_date = last_modified(filename: data.cache_path) == data.last_modified
|
|
35
|
+
hash[key] = data
|
|
36
|
+
issue_count += 1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
message = " Found #{issue_count} issues"
|
|
40
|
+
log message, both: true
|
|
41
|
+
|
|
42
|
+
break unless next_page_token
|
|
43
|
+
end
|
|
44
|
+
hash
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def bulk_fetch_issues issue_datas:, board:, in_initial_query:
|
|
48
|
+
# We used to use the expand option to pull in the changelog directly. Unfortunately
|
|
49
|
+
# that only returns the "recent" changes, not all of them. So now we get the issue
|
|
50
|
+
# without changes and then make a second call for that changes. Then we insert it
|
|
51
|
+
# into the raw issue as if it had been there all along.
|
|
52
|
+
log " Downloading #{issue_datas.size} issues", both: true
|
|
53
|
+
payload = {
|
|
54
|
+
'fields' => ['*all'],
|
|
55
|
+
'issueIdsOrKeys' => issue_datas.collect(&:key)
|
|
56
|
+
}
|
|
57
|
+
response = @jira_gateway.post_request(
|
|
58
|
+
relative_url: '/rest/api/3/issue/bulkfetch',
|
|
59
|
+
payload: JSON.generate(payload)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
attach_changelog_to_issues issue_datas: issue_datas, issue_jsons: response['issues']
|
|
63
|
+
|
|
64
|
+
response['issues'].each do |issue_json|
|
|
65
|
+
issue_json['exporter'] = {
|
|
66
|
+
'in_initial_query' => in_initial_query
|
|
67
|
+
}
|
|
68
|
+
issue = Issue.new(raw: issue_json, board: board)
|
|
69
|
+
data = issue_datas.find { |d| d.key == issue.key }
|
|
70
|
+
data.up_to_date = true
|
|
71
|
+
data.last_modified = issue.updated
|
|
72
|
+
data.issue = issue
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
issue_datas
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def attach_changelog_to_issues issue_datas:, issue_jsons:
|
|
79
|
+
max_results = 10_000 # The max jira accepts is 10K
|
|
80
|
+
payload = {
|
|
81
|
+
'issueIdsOrKeys' => issue_datas.collect(&:key),
|
|
82
|
+
'maxResults' => max_results
|
|
83
|
+
}
|
|
84
|
+
loop do
|
|
85
|
+
response = @jira_gateway.post_request(
|
|
86
|
+
relative_url: '/rest/api/3/changelog/bulkfetch',
|
|
87
|
+
payload: JSON.generate(payload)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
response['issueChangeLogs'].each do |issue_change_log|
|
|
91
|
+
issue_id = issue_change_log['issueId']
|
|
92
|
+
json = issue_jsons.find { |json| json['id'] == issue_id }
|
|
93
|
+
|
|
94
|
+
unless json['changelog']
|
|
95
|
+
# If this is our first time in, there won't be a changelog section
|
|
96
|
+
json['changelog'] = {
|
|
97
|
+
'startAt' => 0,
|
|
98
|
+
'maxResults' => max_results,
|
|
99
|
+
'total' => 0,
|
|
100
|
+
'histories' => []
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
new_changes = issue_change_log['changeHistories']
|
|
105
|
+
json['changelog']['total'] += new_changes.size
|
|
106
|
+
json['changelog']['histories'] += new_changes
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
next_page_token = response['nextPageToken']
|
|
110
|
+
payload['nextPageToken'] = next_page_token
|
|
111
|
+
break if next_page_token.nil?
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def download_issues board:
|
|
116
|
+
log " Downloading primary issues for board #{board.id} from #{jira_instance_type}", both: true
|
|
117
|
+
path = File.join(@target_path, "#{file_prefix}_issues/")
|
|
118
|
+
unless @file_system.dir_exist?(path)
|
|
119
|
+
log " Creating path #{path}"
|
|
120
|
+
@file_system.mkdir(path)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
filter_id = @board_id_to_filter_id[board.id]
|
|
124
|
+
jql = make_jql(filter_id: filter_id)
|
|
125
|
+
intercept_jql = @download_config.project_config.settings['intercept_jql']
|
|
126
|
+
jql = intercept_jql.call jql if intercept_jql
|
|
127
|
+
|
|
128
|
+
issue_data_hash = search_for_issues jql: jql, board_id: board.id, path: path
|
|
129
|
+
|
|
130
|
+
loop do
|
|
131
|
+
related_issue_keys = Set.new
|
|
132
|
+
issue_data_hash
|
|
133
|
+
.values
|
|
134
|
+
.reject { |data| data.up_to_date }
|
|
135
|
+
.each_slice(100) do |slice|
|
|
136
|
+
slice = bulk_fetch_issues(
|
|
137
|
+
issue_datas: slice, board: board, in_initial_query: true
|
|
138
|
+
)
|
|
139
|
+
slice.each do |data|
|
|
140
|
+
@file_system.save_json(
|
|
141
|
+
json: data.issue.raw, filename: data.cache_path
|
|
142
|
+
)
|
|
143
|
+
# Set the timestamp on the file to match the updated one so that we don't have
|
|
144
|
+
# to parse the file just to find the timestamp
|
|
145
|
+
@file_system.utime time: data.issue.updated, file: data.cache_path
|
|
146
|
+
|
|
147
|
+
issue = data.issue
|
|
148
|
+
next unless issue
|
|
149
|
+
|
|
150
|
+
parent_key = issue.parent_key(project_config: @download_config.project_config)
|
|
151
|
+
related_issue_keys << parent_key if parent_key
|
|
152
|
+
|
|
153
|
+
# Sub-tasks
|
|
154
|
+
issue.raw['fields']['subtasks']&.each do |raw_subtask|
|
|
155
|
+
related_issue_keys << raw_subtask['key']
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Remove all the ones we already downloaded
|
|
161
|
+
related_issue_keys.reject! { |key| issue_data_hash[key] }
|
|
162
|
+
|
|
163
|
+
related_issue_keys.each do |key|
|
|
164
|
+
data = DownloadIssueData.new key: key
|
|
165
|
+
data.found_in_primary_query = false
|
|
166
|
+
data.up_to_date = false
|
|
167
|
+
data.cache_path = File.join(path, "#{key}-#{board.id}.json")
|
|
168
|
+
issue_data_hash[key] = data
|
|
169
|
+
end
|
|
170
|
+
break if related_issue_keys.empty?
|
|
171
|
+
|
|
172
|
+
log " Downloading linked issues for board #{board.id}", both: true
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
delete_issues_from_cache_that_are_not_in_server(
|
|
176
|
+
issue_data_hash: issue_data_hash, path: path
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def delete_issues_from_cache_that_are_not_in_server issue_data_hash:, path:
|
|
181
|
+
# The gotcha with deleted issues is that they just stop being returned in queries
|
|
182
|
+
# and we have no way to know that they should be removed from our local cache.
|
|
183
|
+
# With the new approach, we ask for every issue that Jira knows about (within
|
|
184
|
+
# the parameters of the query) and then delete anything that's in our local cache
|
|
185
|
+
# but wasn't returned.
|
|
186
|
+
@file_system.foreach path do |file|
|
|
187
|
+
next if file.start_with? '.'
|
|
188
|
+
unless /^(?<key>\w+-\d+)-\d+\.json$/ =~ file
|
|
189
|
+
raise "Unexpected filename in #{path}: #{file}"
|
|
190
|
+
end
|
|
191
|
+
next if issue_data_hash[key] # Still in Jira
|
|
192
|
+
|
|
193
|
+
file_to_delete = File.join(path, file)
|
|
194
|
+
log " Removing #{file_to_delete} from local cache"
|
|
195
|
+
file_system.unlink file_to_delete
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def last_modified filename:
|
|
200
|
+
File.mtime(filename) if File.exist?(filename)
|
|
201
|
+
end
|
|
202
|
+
end
|