jirametrics 2.14 → 2.30
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/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/aggregate_config.rb +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
- data/lib/jirametrics/aging_work_table.rb +9 -7
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +96 -96
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +32 -8
- data/lib/jirametrics/board_config.rb +3 -1
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +2 -2
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +14 -6
- data/lib/jirametrics/chart_base.rb +139 -3
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +21 -4
- data/lib/jirametrics/cycletime_histogram.rb +15 -101
- data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
- data/lib/jirametrics/daily_view.rb +42 -31
- data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +30 -8
- data/lib/jirametrics/data_quality_report.rb +43 -12
- data/lib/jirametrics/dependency_chart.rb +6 -3
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +117 -100
- data/lib/jirametrics/downloader_for_cloud.rb +287 -0
- data/lib/jirametrics/downloader_for_data_center.rb +95 -0
- data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
- data/lib/jirametrics/examples/aggregated_project.rb +2 -2
- data/lib/jirametrics/examples/standard_project.rb +41 -28
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +26 -6
- data/lib/jirametrics/file_config.rb +9 -11
- data/lib/jirametrics/file_system.rb +59 -3
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +11 -1
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
- data/lib/jirametrics/html/aging_work_table.erb +5 -0
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +6 -14
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
- data/lib/jirametrics/html/index.css +244 -69
- data/lib/jirametrics/html/index.erb +9 -35
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +17 -15
- data/lib/jirametrics/html/throughput_chart.erb +42 -11
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +32 -0
- data/lib/jirametrics/html_report_config.rb +52 -57
- data/lib/jirametrics/issue.rb +302 -98
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +77 -17
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +108 -9
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +17 -0
- data/lib/jirametrics/settings.json +5 -1
- data/lib/jirametrics/sprint.rb +12 -0
- data/lib/jirametrics/sprint_burndown.rb +10 -4
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/stitcher.rb +81 -0
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +73 -23
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +107 -0
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +83 -69
- metadata +60 -6
|
@@ -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,12 +33,23 @@ class Downloader
|
|
|
12
33
|
# For testing only
|
|
13
34
|
attr_reader :start_date_in_query, :board_id_to_filter_id
|
|
14
35
|
|
|
15
|
-
def
|
|
36
|
+
def self.create download_config:, file_system:, jira_gateway:, github_pr_cache: {}
|
|
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
|
+
github_pr_cache: github_pr_cache
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize download_config:, file_system:, jira_gateway:, github_pr_cache: {}
|
|
16
47
|
@metadata = {}
|
|
17
48
|
@download_config = download_config
|
|
18
49
|
@target_path = @download_config.project_config.target_path
|
|
19
50
|
@file_system = file_system
|
|
20
51
|
@jira_gateway = jira_gateway
|
|
52
|
+
@github_pr_cache = github_pr_cache
|
|
21
53
|
@board_id_to_filter_id = {}
|
|
22
54
|
|
|
23
55
|
@issue_keys_downloaded_in_current_run = []
|
|
@@ -28,7 +60,6 @@ class Downloader
|
|
|
28
60
|
log '', both: true
|
|
29
61
|
log @download_config.project_config.name, both: true
|
|
30
62
|
|
|
31
|
-
init_gateway
|
|
32
63
|
load_metadata
|
|
33
64
|
|
|
34
65
|
if @metadata['no-download']
|
|
@@ -43,114 +74,41 @@ class Downloader
|
|
|
43
74
|
download_statuses
|
|
44
75
|
find_board_ids.each do |id|
|
|
45
76
|
board = download_board_configuration board_id: id
|
|
77
|
+
board.project_config = @download_config.project_config
|
|
46
78
|
download_issues board: board
|
|
47
79
|
end
|
|
48
80
|
download_users
|
|
49
81
|
|
|
50
82
|
save_metadata
|
|
51
|
-
|
|
52
|
-
|
|
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']
|
|
83
|
+
download_github_prs if @download_config.github_repos.any?
|
|
56
84
|
end
|
|
57
85
|
|
|
58
86
|
def log text, both: false
|
|
59
87
|
@file_system.log text, also_write_to_stderr: both
|
|
60
88
|
end
|
|
61
89
|
|
|
62
|
-
def
|
|
63
|
-
|
|
64
|
-
raise 'Board ids must be specified' if ids.empty?
|
|
65
|
-
|
|
66
|
-
ids
|
|
90
|
+
def log_start text
|
|
91
|
+
@file_system.log_start text
|
|
67
92
|
end
|
|
68
93
|
|
|
69
|
-
def
|
|
70
|
-
|
|
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
|
|
94
|
+
def start_progress
|
|
95
|
+
@file_system.start_progress
|
|
91
96
|
end
|
|
92
97
|
|
|
93
|
-
def
|
|
94
|
-
|
|
95
|
-
|
|
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"
|
|
98
|
+
def progress_dot message = nil
|
|
99
|
+
@file_system.log message if message
|
|
100
|
+
@file_system.progress_dot
|
|
101
|
+
end
|
|
117
102
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
103
|
+
def end_progress
|
|
104
|
+
@file_system.end_progress
|
|
105
|
+
end
|
|
121
106
|
|
|
122
|
-
|
|
123
|
-
|
|
107
|
+
def find_board_ids
|
|
108
|
+
ids = @download_config.project_config.board_configs.collect(&:id)
|
|
109
|
+
raise 'Board ids must be specified' if ids.empty?
|
|
124
110
|
|
|
125
|
-
|
|
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
|
|
111
|
+
ids
|
|
154
112
|
end
|
|
155
113
|
|
|
156
114
|
def identify_other_issues_to_be_downloaded raw_issue:, board:
|
|
@@ -178,6 +136,8 @@ class Downloader
|
|
|
178
136
|
end
|
|
179
137
|
|
|
180
138
|
def download_users
|
|
139
|
+
return unless @jira_gateway.cloud?
|
|
140
|
+
|
|
181
141
|
log ' Downloading all users', both: true
|
|
182
142
|
json = @jira_gateway.call_url relative_url: '/rest/api/2/users'
|
|
183
143
|
|
|
@@ -226,11 +186,28 @@ class Downloader
|
|
|
226
186
|
# actually look at the returned json.
|
|
227
187
|
@board_id_to_filter_id[board_id] = json['filter']['id'].to_i
|
|
228
188
|
|
|
189
|
+
if json['type'] == 'simple'
|
|
190
|
+
features_json = download_features board_id: board_id
|
|
191
|
+
if BoardFeature.from_raw(features_json).any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
|
|
192
|
+
download_sprints board_id: board_id
|
|
193
|
+
end
|
|
194
|
+
end
|
|
229
195
|
download_sprints board_id: board_id if json['type'] == 'scrum'
|
|
230
196
|
# TODO: Should be passing actual statuses, not empty list
|
|
231
197
|
Board.new raw: json, possible_statuses: StatusCollection.new
|
|
232
198
|
end
|
|
233
199
|
|
|
200
|
+
def download_features board_id:
|
|
201
|
+
log " Downloading features for board #{board_id}", both: true
|
|
202
|
+
json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/features"
|
|
203
|
+
|
|
204
|
+
@file_system.save_json(
|
|
205
|
+
json: json,
|
|
206
|
+
filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_features.json")
|
|
207
|
+
)
|
|
208
|
+
json
|
|
209
|
+
end
|
|
210
|
+
|
|
234
211
|
def download_sprints board_id:
|
|
235
212
|
log " Downloading sprints for board #{board_id}", both: true
|
|
236
213
|
max_results = 100
|
|
@@ -272,19 +249,29 @@ class Downloader
|
|
|
272
249
|
value = Date.parse(value) if value.is_a?(String) && value =~ /^\d{4}-\d{2}-\d{2}$/
|
|
273
250
|
@metadata[key] = value
|
|
274
251
|
end
|
|
252
|
+
|
|
275
253
|
end
|
|
276
254
|
|
|
277
255
|
# Even if this is the old format, we want to obey this one tag
|
|
278
256
|
@metadata['no-download'] = hash['no-download'] if hash['no-download']
|
|
279
257
|
end
|
|
280
258
|
|
|
259
|
+
def timezone_offset
|
|
260
|
+
@download_config.project_config.exporter.timezone_offset
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def today_in_project_timezone
|
|
264
|
+
Time.now.getlocal(timezone_offset).to_date
|
|
265
|
+
end
|
|
266
|
+
|
|
281
267
|
def save_metadata
|
|
282
268
|
@metadata['version'] = CURRENT_METADATA_VERSION
|
|
269
|
+
@metadata['rolling_date_count'] = @download_config.rolling_date_count
|
|
283
270
|
@metadata['date_start_from_last_query'] = @start_date_in_query if @start_date_in_query
|
|
284
271
|
|
|
285
272
|
if @download_date_range.nil?
|
|
286
273
|
log "Making up a date range in meta since one wasn't specified. You'll want to change that.", both: true
|
|
287
|
-
today =
|
|
274
|
+
today = today_in_project_timezone
|
|
288
275
|
@download_date_range = (today - 7)..today
|
|
289
276
|
end
|
|
290
277
|
|
|
@@ -319,7 +306,8 @@ class Downloader
|
|
|
319
306
|
end
|
|
320
307
|
end
|
|
321
308
|
|
|
322
|
-
def make_jql filter_id:, today:
|
|
309
|
+
def make_jql filter_id:, today: nil
|
|
310
|
+
today ||= today_in_project_timezone
|
|
323
311
|
segments = []
|
|
324
312
|
segments << "filter=#{filter_id}"
|
|
325
313
|
|
|
@@ -327,11 +315,7 @@ class Downloader
|
|
|
327
315
|
|
|
328
316
|
if start_date
|
|
329
317
|
@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']
|
|
318
|
+
@start_date_in_query = @download_date_range.begin
|
|
335
319
|
|
|
336
320
|
# Catch-all to pick up anything that's been around since before the range started but hasn't
|
|
337
321
|
# had an update during the range.
|
|
@@ -348,6 +332,39 @@ class Downloader
|
|
|
348
332
|
segments.join ' AND '
|
|
349
333
|
end
|
|
350
334
|
|
|
335
|
+
def download_github_prs
|
|
336
|
+
project_keys = extract_project_keys_from_downloaded_issues
|
|
337
|
+
if project_keys.empty?
|
|
338
|
+
log ' No project keys found in downloaded issues, skipping GitHub PR download', both: true
|
|
339
|
+
return
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
prs = @download_config.github_repos.flat_map do |repo|
|
|
343
|
+
GithubGateway.new(
|
|
344
|
+
repo: repo,
|
|
345
|
+
project_keys: project_keys,
|
|
346
|
+
file_system: @file_system,
|
|
347
|
+
raw_pr_cache: @github_pr_cache
|
|
348
|
+
).fetch_pull_requests(since: @download_date_range&.begin)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
@file_system.save_json(
|
|
352
|
+
json: prs.map(&:raw),
|
|
353
|
+
filename: File.join(@target_path, "#{file_prefix}_github_prs.json")
|
|
354
|
+
)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def extract_project_keys_from_downloaded_issues
|
|
358
|
+
path = File.join(@target_path, "#{file_prefix}_issues")
|
|
359
|
+
return [] unless @file_system.dir_exist?(path)
|
|
360
|
+
|
|
361
|
+
keys = []
|
|
362
|
+
@file_system.foreach(path) do |filename|
|
|
363
|
+
keys << filename.split('-').first if filename.match?(/^[A-Z][A-Z_0-9]+-\d+-\d+\.json$/)
|
|
364
|
+
end
|
|
365
|
+
keys.uniq
|
|
366
|
+
end
|
|
367
|
+
|
|
351
368
|
def file_prefix
|
|
352
369
|
@download_config.project_config.get_file_prefix
|
|
353
370
|
end
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class DownloaderForCloud < Downloader
|
|
4
|
+
def jira_instance_type
|
|
5
|
+
'Jira Cloud'
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def run
|
|
9
|
+
super
|
|
10
|
+
download_fix_versions
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def download_board_configuration board_id:
|
|
14
|
+
board = super
|
|
15
|
+
location = board.raw['location']
|
|
16
|
+
@project_key ||= location['key'] if location&.[]('type') == 'project'
|
|
17
|
+
board
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def download_fix_versions
|
|
21
|
+
return unless @project_key
|
|
22
|
+
|
|
23
|
+
log " Downloading fix versions for project #{@project_key}", both: true
|
|
24
|
+
max_results = 50
|
|
25
|
+
start_at = 0
|
|
26
|
+
all_versions = []
|
|
27
|
+
|
|
28
|
+
loop do
|
|
29
|
+
json = @jira_gateway.call_url(
|
|
30
|
+
relative_url: "/rest/api/3/project/#{@project_key}/version?" \
|
|
31
|
+
"startAt=#{start_at}&maxResults=#{max_results}"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
values = json['values'] || []
|
|
35
|
+
all_versions.concat(values)
|
|
36
|
+
break if json['isLast'] || values.empty?
|
|
37
|
+
|
|
38
|
+
start_at += values.size
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@file_system.save_json(
|
|
42
|
+
json: all_versions,
|
|
43
|
+
filename: File.join(@target_path, "#{file_prefix}_fix_versions.json")
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def search_for_issues jql:, board_id:, path:
|
|
48
|
+
log " JQL: #{jql}"
|
|
49
|
+
escaped_jql = CGI.escape jql
|
|
50
|
+
|
|
51
|
+
hash = {}
|
|
52
|
+
max_results = 5_000 # The maximum allowed by Jira
|
|
53
|
+
next_page_token = nil
|
|
54
|
+
issue_count = 0
|
|
55
|
+
|
|
56
|
+
start_progress
|
|
57
|
+
loop do
|
|
58
|
+
relative_url = +''
|
|
59
|
+
relative_url << '/rest/api/3/search/jql'
|
|
60
|
+
relative_url << "?jql=#{escaped_jql}&maxResults=#{max_results}"
|
|
61
|
+
relative_url << "&nextPageToken=#{next_page_token}" if next_page_token
|
|
62
|
+
relative_url << '&fields=updated'
|
|
63
|
+
|
|
64
|
+
json = @jira_gateway.call_url relative_url: relative_url
|
|
65
|
+
next_page_token = json['nextPageToken']
|
|
66
|
+
|
|
67
|
+
json['issues'].each do |i|
|
|
68
|
+
key = i['key']
|
|
69
|
+
data = DownloadIssueData.new key: key
|
|
70
|
+
data.key = key
|
|
71
|
+
data.last_modified = Time.parse i['fields']['updated']
|
|
72
|
+
data.found_in_primary_query = true
|
|
73
|
+
data.cache_path = File.join(path, "#{key}-#{board_id}.json")
|
|
74
|
+
data.up_to_date = last_modified(filename: data.cache_path) == data.last_modified
|
|
75
|
+
hash[key] = data
|
|
76
|
+
issue_count += 1
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
progress_dot " Found #{issue_count} issues"
|
|
80
|
+
|
|
81
|
+
break unless next_page_token
|
|
82
|
+
end
|
|
83
|
+
end_progress
|
|
84
|
+
|
|
85
|
+
hash
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def bulk_fetch_issues issue_datas:, board:, in_initial_query:
|
|
89
|
+
# We used to use the expand option to pull in the changelog directly. Unfortunately
|
|
90
|
+
# that only returns the "recent" changes, not all of them. So now we get the issue
|
|
91
|
+
# without changes and then make a second call for that changes. Then we insert it
|
|
92
|
+
# into the raw issue as if it had been there all along.
|
|
93
|
+
log " Downloading #{issue_datas.size} issues"
|
|
94
|
+
payload = {
|
|
95
|
+
'fields' => ['*all'],
|
|
96
|
+
'issueIdsOrKeys' => issue_datas.collect(&:key)
|
|
97
|
+
}
|
|
98
|
+
response = @jira_gateway.post_request(
|
|
99
|
+
relative_url: '/rest/api/3/issue/bulkfetch',
|
|
100
|
+
payload: JSON.generate(payload)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
attach_changelog_to_issues issue_datas: issue_datas, issue_jsons: response['issues']
|
|
104
|
+
|
|
105
|
+
response['issues'].each do |issue_json|
|
|
106
|
+
issue_json['exporter'] = {
|
|
107
|
+
'in_initial_query' => in_initial_query
|
|
108
|
+
}
|
|
109
|
+
issue = Issue.new(raw: issue_json, board: board)
|
|
110
|
+
data = issue_datas.find { |d| d.key == issue.key }
|
|
111
|
+
unless data
|
|
112
|
+
log " Skipping #{issue.key}: returned by Jira but key not in request (issue may have been moved)"
|
|
113
|
+
next
|
|
114
|
+
end
|
|
115
|
+
data.up_to_date = true
|
|
116
|
+
data.last_modified = issue.updated
|
|
117
|
+
data.issue = issue
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Mark any unmatched requests as up_to_date to prevent infinite re-fetching.
|
|
121
|
+
# This happens when Jira returns a different key (moved issue) leaving the original unmatched.
|
|
122
|
+
issue_datas.each do |data|
|
|
123
|
+
next if data.up_to_date
|
|
124
|
+
|
|
125
|
+
log " Skipping #{data.key}: not returned by Jira (issue may have been deleted or moved)"
|
|
126
|
+
data.up_to_date = true
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
issue_datas
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def attach_changelog_to_issues issue_datas:, issue_jsons:
|
|
133
|
+
max_results = 10_000 # The max jira accepts is 10K
|
|
134
|
+
payload = {
|
|
135
|
+
'issueIdsOrKeys' => issue_datas.collect(&:key),
|
|
136
|
+
'maxResults' => max_results
|
|
137
|
+
}
|
|
138
|
+
loop do
|
|
139
|
+
response = @jira_gateway.post_request(
|
|
140
|
+
relative_url: '/rest/api/3/changelog/bulkfetch',
|
|
141
|
+
payload: JSON.generate(payload)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
response['issueChangeLogs'].each do |issue_change_log|
|
|
145
|
+
issue_id = issue_change_log['issueId']
|
|
146
|
+
json = issue_jsons.find { |json| json['id'] == issue_id }
|
|
147
|
+
|
|
148
|
+
unless json['changelog']
|
|
149
|
+
# If this is our first time in, there won't be a changelog section
|
|
150
|
+
json['changelog'] = {
|
|
151
|
+
'startAt' => 0,
|
|
152
|
+
'maxResults' => max_results,
|
|
153
|
+
'total' => 0,
|
|
154
|
+
'histories' => []
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
new_changes = issue_change_log['changeHistories']
|
|
159
|
+
json['changelog']['total'] += new_changes.size
|
|
160
|
+
json['changelog']['histories'] += new_changes
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
next_page_token = response['nextPageToken']
|
|
164
|
+
payload['nextPageToken'] = next_page_token
|
|
165
|
+
break if next_page_token.nil?
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def download_issues board:
|
|
170
|
+
log " Downloading primary issues for board #{board.id} from #{jira_instance_type}", both: true
|
|
171
|
+
path = File.join(@target_path, "#{file_prefix}_issues/")
|
|
172
|
+
unless @file_system.dir_exist?(path)
|
|
173
|
+
log " Creating path #{path}"
|
|
174
|
+
@file_system.mkdir(path)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
filter_id = @board_id_to_filter_id[board.id]
|
|
178
|
+
jql = make_jql(filter_id: filter_id)
|
|
179
|
+
intercept_jql = @download_config.project_config.settings['intercept_jql']
|
|
180
|
+
jql = intercept_jql.call jql if intercept_jql
|
|
181
|
+
|
|
182
|
+
issue_data_hash = search_for_issues jql: jql, board_id: board.id, path: path
|
|
183
|
+
|
|
184
|
+
checked_for_related = Set.new
|
|
185
|
+
in_related_phase = false
|
|
186
|
+
|
|
187
|
+
loop do
|
|
188
|
+
related_issue_keys = Set.new
|
|
189
|
+
stale = issue_data_hash.values.reject { |data| data.up_to_date }
|
|
190
|
+
unless stale.empty?
|
|
191
|
+
log_start ' Downloading more issues ' unless in_related_phase
|
|
192
|
+
stale.each_slice(100) do |slice|
|
|
193
|
+
slice = bulk_fetch_issues(issue_datas: slice, board: board, in_initial_query: true)
|
|
194
|
+
progress_dot
|
|
195
|
+
slice.each do |data|
|
|
196
|
+
next unless data.issue
|
|
197
|
+
|
|
198
|
+
@file_system.save_json(
|
|
199
|
+
json: data.issue.raw, filename: data.cache_path
|
|
200
|
+
)
|
|
201
|
+
# Set the timestamp on the file to match the updated one so that we don't have
|
|
202
|
+
# to parse the file just to find the timestamp
|
|
203
|
+
@file_system.utime time: data.issue.updated, file: data.cache_path
|
|
204
|
+
|
|
205
|
+
collect_related_issue_keys issue: data.issue, related_issue_keys: related_issue_keys
|
|
206
|
+
checked_for_related << data.key
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end_progress unless in_related_phase
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Also scan up-to-date cached issues we haven't checked yet — they may reference
|
|
213
|
+
# related issues that are not in the primary query result.
|
|
214
|
+
issue_data_hash.each_value do |data|
|
|
215
|
+
next if checked_for_related.include?(data.key)
|
|
216
|
+
next unless @file_system.file_exist?(data.cache_path)
|
|
217
|
+
|
|
218
|
+
checked_for_related << data.key
|
|
219
|
+
raw = @file_system.load_json(data.cache_path)
|
|
220
|
+
collect_related_issue_keys issue: Issue.new(raw: raw, board: board), related_issue_keys: related_issue_keys
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Remove all the ones we already have
|
|
224
|
+
related_issue_keys.reject! { |key| issue_data_hash[key] }
|
|
225
|
+
|
|
226
|
+
related_issue_keys.each do |key|
|
|
227
|
+
data = DownloadIssueData.new key: key
|
|
228
|
+
data.found_in_primary_query = false
|
|
229
|
+
data.up_to_date = false
|
|
230
|
+
data.cache_path = File.join(path, "#{key}-#{board.id}.json")
|
|
231
|
+
issue_data_hash[key] = data
|
|
232
|
+
end
|
|
233
|
+
break if related_issue_keys.empty?
|
|
234
|
+
|
|
235
|
+
unless in_related_phase
|
|
236
|
+
in_related_phase = true
|
|
237
|
+
log " Identifying related issues (parents, subtasks, links) for board #{board.id}", both: true
|
|
238
|
+
log_start ' Downloading more issues '
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
end_progress if in_related_phase
|
|
243
|
+
|
|
244
|
+
delete_issues_from_cache_that_are_not_in_server(
|
|
245
|
+
issue_data_hash: issue_data_hash, path: path
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def delete_issues_from_cache_that_are_not_in_server issue_data_hash:, path:
|
|
250
|
+
# The gotcha with deleted issues is that they just stop being returned in queries
|
|
251
|
+
# and we have no way to know that they should be removed from our local cache.
|
|
252
|
+
# With the new approach, we ask for every issue that Jira knows about (within
|
|
253
|
+
# the parameters of the query) and then delete anything that's in our local cache
|
|
254
|
+
# but wasn't returned.
|
|
255
|
+
@file_system.foreach path do |file|
|
|
256
|
+
next if file.start_with? '.'
|
|
257
|
+
unless /^(?<key>\w+-\d+)-\d+\.json$/ =~ file
|
|
258
|
+
raise "Unexpected filename in #{path}: #{file}"
|
|
259
|
+
end
|
|
260
|
+
next if issue_data_hash[key] # Still in Jira
|
|
261
|
+
|
|
262
|
+
file_to_delete = File.join(path, file)
|
|
263
|
+
log " Removing #{file_to_delete} from local cache"
|
|
264
|
+
file_system.unlink file_to_delete
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def collect_related_issue_keys issue:, related_issue_keys:
|
|
269
|
+
parent_key = issue.parent_key(project_config: @download_config.project_config)
|
|
270
|
+
related_issue_keys << parent_key if parent_key
|
|
271
|
+
|
|
272
|
+
issue.raw['fields']['subtasks']&.each do |raw_subtask|
|
|
273
|
+
related_issue_keys << raw_subtask['key']
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
issue.raw['fields']['issuelinks']&.each do |link|
|
|
277
|
+
next if link['type']['name'] == 'Cloners'
|
|
278
|
+
|
|
279
|
+
linked = link['inwardIssue'] || link['outwardIssue']
|
|
280
|
+
related_issue_keys << linked['key'] if linked
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def last_modified filename:
|
|
285
|
+
File.mtime(filename) if File.exist?(filename)
|
|
286
|
+
end
|
|
287
|
+
end
|