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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +10 -2
  4. data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
  6. data/lib/jirametrics/aging_work_table.rb +9 -7
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +96 -96
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  11. data/lib/jirametrics/board.rb +32 -8
  12. data/lib/jirametrics/board_config.rb +3 -1
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +2 -2
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +14 -6
  17. data/lib/jirametrics/chart_base.rb +139 -3
  18. data/lib/jirametrics/css_variable.rb +1 -1
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +21 -4
  21. data/lib/jirametrics/cycletime_histogram.rb +15 -101
  22. data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
  23. data/lib/jirametrics/daily_view.rb +42 -31
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  27. data/lib/jirametrics/daily_wip_chart.rb +30 -8
  28. data/lib/jirametrics/data_quality_report.rb +43 -12
  29. data/lib/jirametrics/dependency_chart.rb +6 -3
  30. data/lib/jirametrics/download_config.rb +15 -0
  31. data/lib/jirametrics/downloader.rb +117 -100
  32. data/lib/jirametrics/downloader_for_cloud.rb +287 -0
  33. data/lib/jirametrics/downloader_for_data_center.rb +95 -0
  34. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  35. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  36. data/lib/jirametrics/examples/standard_project.rb +41 -28
  37. data/lib/jirametrics/expedited_chart.rb +3 -1
  38. data/lib/jirametrics/exporter.rb +26 -6
  39. data/lib/jirametrics/file_config.rb +9 -11
  40. data/lib/jirametrics/file_system.rb +59 -3
  41. data/lib/jirametrics/fix_version.rb +13 -0
  42. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  43. data/lib/jirametrics/github_gateway.rb +115 -0
  44. data/lib/jirametrics/groupable_issue_chart.rb +11 -1
  45. data/lib/jirametrics/grouping_rules.rb +26 -4
  46. data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
  47. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
  48. data/lib/jirametrics/html/aging_work_table.erb +5 -0
  49. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  50. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  51. data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
  52. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  53. data/lib/jirametrics/html/expedited_chart.erb +6 -14
  54. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
  55. data/lib/jirametrics/html/index.css +244 -69
  56. data/lib/jirametrics/html/index.erb +9 -35
  57. data/lib/jirametrics/html/index.js +164 -0
  58. data/lib/jirametrics/html/legacy_colors.css +174 -0
  59. data/lib/jirametrics/html/sprint_burndown.erb +17 -15
  60. data/lib/jirametrics/html/throughput_chart.erb +42 -11
  61. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
  62. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
  63. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  64. data/lib/jirametrics/html_generator.rb +32 -0
  65. data/lib/jirametrics/html_report_config.rb +52 -57
  66. data/lib/jirametrics/issue.rb +302 -98
  67. data/lib/jirametrics/issue_printer.rb +97 -0
  68. data/lib/jirametrics/jira_gateway.rb +77 -17
  69. data/lib/jirametrics/mcp_server.rb +531 -0
  70. data/lib/jirametrics/project_config.rb +108 -9
  71. data/lib/jirametrics/pull_request.rb +30 -0
  72. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  73. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  74. data/lib/jirametrics/pull_request_review.rb +13 -0
  75. data/lib/jirametrics/raw_javascript.rb +17 -0
  76. data/lib/jirametrics/settings.json +5 -1
  77. data/lib/jirametrics/sprint.rb +12 -0
  78. data/lib/jirametrics/sprint_burndown.rb +10 -4
  79. data/lib/jirametrics/status.rb +1 -1
  80. data/lib/jirametrics/stitcher.rb +81 -0
  81. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  82. data/lib/jirametrics/throughput_chart.rb +73 -23
  83. data/lib/jirametrics/time_based_histogram.rb +139 -0
  84. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  85. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  86. data/lib/jirametrics.rb +83 -69
  87. 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 = 4
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 initialize download_config:, file_system:, jira_gateway:
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
- end
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 find_board_ids
63
- ids = @download_config.project_config.board_configs.collect(&:id)
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 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
94
+ def start_progress
95
+ @file_system.start_progress
91
96
  end
92
97
 
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"
98
+ def progress_dot message = nil
99
+ @file_system.log message if message
100
+ @file_system.progress_dot
101
+ end
117
102
 
118
- @file_system.save_json(json: issue_json, filename: File.join(path, file))
119
- issue_count += 1
120
- end
103
+ def end_progress
104
+ @file_system.end_progress
105
+ end
121
106
 
122
- message = " Downloaded #{issue_count} issues"
123
- log message, both: true
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
- 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
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 = Date.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: Date.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