jirametrics 2.14 → 2.20.1

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/anonymizer.rb +8 -6
  3. data/lib/jirametrics/atlassian_document_format.rb +3 -3
  4. data/lib/jirametrics/board_config.rb +2 -1
  5. data/lib/jirametrics/change_item.rb +2 -2
  6. data/lib/jirametrics/chart_base.rb +3 -2
  7. data/lib/jirametrics/cycletime_config.rb +22 -3
  8. data/lib/jirametrics/cycletime_histogram.rb +3 -1
  9. data/lib/jirametrics/daily_view.rb +6 -20
  10. data/lib/jirametrics/data_quality_report.rb +6 -3
  11. data/lib/jirametrics/dependency_chart.rb +4 -1
  12. data/lib/jirametrics/downloader.rb +34 -99
  13. data/lib/jirametrics/downloader_for_cloud.rb +202 -0
  14. data/lib/jirametrics/downloader_for_data_center.rb +94 -0
  15. data/lib/jirametrics/examples/standard_project.rb +9 -9
  16. data/lib/jirametrics/expedited_chart.rb +1 -1
  17. data/lib/jirametrics/exporter.rb +10 -5
  18. data/lib/jirametrics/file_system.rb +24 -1
  19. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  20. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +1 -1
  21. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  22. data/lib/jirametrics/html/cycletime_histogram.erb +2 -2
  23. data/lib/jirametrics/html/index.css +0 -10
  24. data/lib/jirametrics/html/index.erb +2 -34
  25. data/lib/jirametrics/html/index.js +90 -0
  26. data/lib/jirametrics/html/sprint_burndown.erb +5 -3
  27. data/lib/jirametrics/html_report_config.rb +5 -3
  28. data/lib/jirametrics/issue.rb +29 -16
  29. data/lib/jirametrics/jira_gateway.rb +55 -17
  30. data/lib/jirametrics/project_config.rb +10 -0
  31. data/lib/jirametrics/settings.json +3 -1
  32. data/lib/jirametrics.rb +19 -70
  33. metadata +6 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d5b1b9e8d837f6d74990d377db007ed5e55670de77738ab38a04c6d023d865c3
4
- data.tar.gz: 99e8ef3e85a3dfa2bd6d845a32d974718c0e9884d2f4750891ba491fd884ab0b
3
+ metadata.gz: cbe1101b082615d38939850c0adc688aedefe02a74537503bcd390cdf11d0d4e
4
+ data.tar.gz: eeffbda7c7ba8280273e0d749ede1ed3c1caa33d06a1f50a2c47b2331035d5af
5
5
  SHA512:
6
- metadata.gz: 804a25c8a19df9ae9862e2f95539183ece53e3841e590c64b8deb7048601bfb2b42ad5b75c075b85058abb7cea0cc875d517f4208bd5edbf98e2129e5567af59
7
- data.tar.gz: '0059cde9746423c5baf3ca1f47243b6489ccf77b8fd409255f7301f638eb1975b8b2815d266f720cd9f1cd51e2cf0bc9e33cbad3e34110ca1dedc4ad7fc9ff5b'
6
+ metadata.gz: b73533c90e457c2c5f7a7f2d1759ccd44d218ebe984052fcb581d8ab88225abf43b5612923217b4fa7467ed11e82ba5b4fe2cc656a324f269b71c2497bf67659
7
+ data.tar.gz: 57cbc54fe6c739d0c85f68cc0efcfbc6005975af0b40174ed9ee35790a83dac9b5e524601b770dffc6a3fefbd10f0d577d19b840560b3acfb63b0b542728d5fc
@@ -2,11 +2,12 @@
2
2
 
3
3
  require 'random-word'
4
4
 
5
- class Anonymizer
5
+ class Anonymizer < ChartBase
6
6
  # needed for testing
7
7
  attr_reader :project_config, :issues
8
8
 
9
9
  def initialize project_config:, date_adjustment: -200
10
+ super()
10
11
  @project_config = project_config
11
12
  @issues = @project_config.issues
12
13
  @all_boards = @project_config.all_boards
@@ -130,18 +131,19 @@ class Anonymizer
130
131
  end
131
132
  end
132
133
 
133
- def shift_all_dates
134
- @file_system.log "Shifting all dates by #{@date_adjustment} days"
134
+ def shift_all_dates date_adjustment: @date_adjustment
135
+ adjustment_in_seconds = 60 * 60 * 24 * date_adjustment
136
+ @file_system.log "Shifting all dates by #{label_days date_adjustment}"
135
137
  @issues.each do |issue|
136
138
  issue.changes.each do |change|
137
- change.time = change.time + @date_adjustment
139
+ change.time = change.time + adjustment_in_seconds
138
140
  end
139
141
 
140
- issue.raw['fields']['updated'] = (issue.updated + @date_adjustment).to_s
142
+ issue.raw['fields']['updated'] = (issue.updated + adjustment_in_seconds).to_s
141
143
  end
142
144
 
143
145
  range = @project_config.time_range
144
- @project_config.time_range = (range.begin + @date_adjustment)..(range.end + @date_adjustment)
146
+ @project_config.time_range = (range.begin + date_adjustment)..(range.end + date_adjustment)
145
147
  end
146
148
 
147
149
  def random_name
@@ -13,9 +13,9 @@ class AtlassianDocumentFormat
13
13
  input
14
14
  .gsub(/{color:(#\w{6})}([^{]+){color}/, '<span style="color: \1">\2</span>') # Colours
15
15
  .gsub(/\[~accountid:([^\]]+)\]/) { expand_account_id $1 } # Tagged people
16
- .gsub(/\[([^\|]+)\|(https?[^\]]+)\]/, '<a href="\2">\1</a>') # URLs
16
+ .gsub(/\[([^|]+)\|(https?[^\]]+)\]/, '<a href="\2">\1</a>') # URLs
17
17
  .gsub("\n", '<br />')
18
- elsif input['content']
18
+ elsif input&.[]('content')
19
19
  input['content'].collect { |element| adf_node_to_html element }.join("\n")
20
20
  else
21
21
  # We have an actual ADF document with no content.
@@ -157,4 +157,4 @@ class AtlassianDocumentFormat
157
157
  text = "@#{user.display_name}" if user
158
158
  "<span class='account_id'>#{text}</span>"
159
159
  end
160
- end
160
+ end
@@ -24,7 +24,8 @@ class BoardConfig
24
24
  end
25
25
 
26
26
  @board.cycletime = CycleTimeConfig.new(
27
- parent_config: self, label: label, block: block, file_system: project_config.file_system
27
+ parent_config: self, label: label, block: block, file_system: project_config.file_system,
28
+ settings: project_config.settings
28
29
  )
29
30
  end
30
31
 
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ChangeItem
4
- attr_reader :field, :value_id, :old_value_id, :raw, :time, :author_raw
5
- attr_accessor :value, :old_value
4
+ attr_reader :field, :value_id, :old_value_id, :raw, :author_raw
5
+ attr_accessor :value, :old_value, :time
6
6
 
7
7
  def initialize raw:, author_raw:, time:, artificial: false
8
8
  @raw = raw
@@ -2,7 +2,8 @@
2
2
 
3
3
  class ChartBase
4
4
  attr_accessor :timezone_offset, :board_id, :all_boards, :date_range,
5
- :time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system, :users
5
+ :time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system,
6
+ :atlassian_document_format
6
7
  attr_writer :aggregated_project
7
8
  attr_reader :canvas_width, :canvas_height
8
9
 
@@ -44,7 +45,7 @@ class ChartBase
44
45
 
45
46
  def render_top_text caller_binding
46
47
  result = +''
47
- result << "<h1>#{@header_text}</h1>" if @header_text
48
+ result << "<h1 class='foldable'>#{@header_text}</h1>" if @header_text
48
49
  result << ERB.new(@description_text).result(caller_binding) if @description_text
49
50
  result
50
51
  end
@@ -6,12 +6,15 @@ require 'date'
6
6
  class CycleTimeConfig
7
7
  include SelfOrIssueDispatcher
8
8
 
9
- attr_reader :label, :parent_config
9
+ attr_reader :label, :parent_config, :settings, :file_system
10
+
11
+ def initialize parent_config:, label:, block:, settings:, file_system: nil, today: Date.today
10
12
 
11
- def initialize parent_config:, label:, block:, file_system: nil, today: Date.today
12
13
  @parent_config = parent_config
13
14
  @label = label
14
15
  @today = today
16
+ @settings = settings
17
+ @cache_cycletime_calculations = settings['cache_cycletime_calculations']
15
18
 
16
19
  # If we hit something deprecated and this is nil then we'll blow up. Although it's ugly, this
17
20
  # may make it easier to find problems in the test code ;-)
@@ -63,6 +66,10 @@ class CycleTimeConfig
63
66
  end
64
67
 
65
68
  def started_stopped_changes issue
69
+ cache_key = "#{issue.key}:#{issue.board.id}"
70
+ last_result = (@cache ||= {})[cache_key]
71
+ return *last_result if last_result && @cache_cycletime_calculations
72
+
66
73
  started = @start_at.call(issue)
67
74
  stopped = @stop_at.call(issue)
68
75
 
@@ -80,7 +87,15 @@ class CycleTimeConfig
80
87
  # for the start and not have it conflict.
81
88
  started = nil if started&.time == stopped&.time
82
89
 
83
- [started, stopped]
90
+ result = [started, stopped]
91
+ if last_result && result != last_result
92
+ @file_system.error(
93
+ "Calculation mismatch; this could break caching. #{issue.inspect} new=#{result.inspect}, " \
94
+ "previous=#{last_result.inspect}"
95
+ )
96
+ end
97
+ @cache[cache_key] = result
98
+ result
84
99
  end
85
100
 
86
101
  def started_stopped_times issue
@@ -88,6 +103,10 @@ class CycleTimeConfig
88
103
  [started&.time, stopped&.time]
89
104
  end
90
105
 
106
+ def flush_cache
107
+ @cache = nil
108
+ end
109
+
91
110
  def started_stopped_dates issue
92
111
  started_time, stopped_time = started_stopped_times(issue)
93
112
  [started_time&.to_date, stopped_time&.to_date]
@@ -62,7 +62,9 @@ class CycletimeHistogram < ChartBase
62
62
  )
63
63
  end
64
64
 
65
- return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
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
@@ -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
- id = next_id
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
- id = next_id
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 << "<table id='table#{id}' style='display: none'>"
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
 
@@ -410,14 +410,17 @@ class DataQualityReport < ChartBase
410
410
  def render_status_not_on_board problems
411
411
  <<-HTML
412
412
  #{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.
413
+ timings as the work was likely to have been forgotten if it wasn't visible. What does "not visible"
414
+ mean in this context? The issue was in a status that is not mapped to any visible column on the board.
415
+ Look in "unmapped statuses" on your board.
414
416
  HTML
415
417
  end
416
418
 
417
419
  def render_created_in_wrong_status problems
418
420
  <<-HTML
419
- #{label_issues problems.size} were created in a status not designated as Backlog. This will impact
420
- the measurement of start times and will therefore impact whether it's shown as in progress or not.
421
+ #{label_issues problems.size} were created in a status that is not considered to be some varient
422
+ of To Do. Most likely this means that the issue was created from one of the columns on the board,
423
+ rather than in the backlog. Why Jira allows this is still a mystery.
421
424
  HTML
422
425
  end
423
426
 
@@ -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
- return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if dot_graph.nil?
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 = 4
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