jirametrics 2.12.1 → 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 (88) 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 +160 -0
  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 +4 -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 +15 -6
  17. data/lib/jirametrics/chart_base.rb +141 -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 +90 -61
  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 -71
  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 +249 -69
  56. data/lib/jirametrics/html/index.erb +11 -37
  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 +305 -102
  67. data/lib/jirametrics/issue_printer.rb +97 -0
  68. data/lib/jirametrics/jira_gateway.rb +81 -17
  69. data/lib/jirametrics/mcp_server.rb +531 -0
  70. data/lib/jirametrics/project_config.rb +128 -12
  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/status_collection.rb +1 -0
  81. data/lib/jirametrics/stitcher.rb +81 -0
  82. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  83. data/lib/jirametrics/throughput_chart.rb +73 -23
  84. data/lib/jirametrics/time_based_histogram.rb +139 -0
  85. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  86. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  87. data/lib/jirametrics.rb +83 -68
  88. metadata +61 -6
@@ -45,6 +45,7 @@ class DataQualityReport < ChartBase
45
45
  scan_for_completed_issues_without_a_start_time entry: entry
46
46
  scan_for_status_change_after_done entry: entry
47
47
  scan_for_backwards_movement entry: entry, backlog_statuses: backlog_statuses
48
+ scan_for_issue_not_in_active_sprint entry: entry
48
49
  scan_for_issues_not_created_in_a_backlog_status entry: entry, backlog_statuses: backlog_statuses
49
50
  scan_for_stopped_before_started entry: entry
50
51
  scan_for_issues_not_started_with_subtasks_that_have entry: entry
@@ -68,7 +69,7 @@ class DataQualityReport < ChartBase
68
69
  result << render_problem_type(:status_changes_after_done)
69
70
  result << render_problem_type(:backwards_through_status_categories)
70
71
  result << render_problem_type(:backwords_through_statuses)
71
- result << render_problem_type(:status_not_on_board)
72
+ result << render_problem_type(:issue_not_visible_on_board)
72
73
  result << render_problem_type(:created_in_wrong_status)
73
74
  result << render_problem_type(:stopped_before_started)
74
75
  result << render_problem_type(:issue_not_started_but_subtasks_have)
@@ -120,7 +121,7 @@ class DataQualityReport < ChartBase
120
121
 
121
122
  def initialize_entries
122
123
  @entries = @issues.filter_map do |issue|
123
- started, stopped = issue.board.cycletime.started_stopped_times(issue)
124
+ started, stopped = issue.started_stopped_times
124
125
  next if stopped && stopped < time_range.begin
125
126
  next if started && started > time_range.end
126
127
 
@@ -194,7 +195,7 @@ class DataQualityReport < ChartBase
194
195
  # If it's been moved back to backlog then it's on a different report. Ignore it here.
195
196
  detail = nil if backlog_statuses.any? { |s| s.name == change.value }
196
197
 
197
- entry.report(problem_key: :status_not_on_board, detail: detail) unless detail.nil?
198
+ entry.report(problem_key: :issue_not_visible_on_board, detail: detail) unless detail.nil?
198
199
  elsif change.old_value.nil?
199
200
  # Do nothing
200
201
  elsif index < last_index
@@ -223,6 +224,29 @@ class DataQualityReport < ChartBase
223
224
  end
224
225
  end
225
226
 
227
+ def scan_for_issue_not_in_active_sprint entry:
228
+ issue = entry.issue
229
+ return unless issue.board.scrum?
230
+ return if issue.sprints.any?(&:active?)
231
+
232
+ entry.report(problem_key: :issue_not_visible_on_board, detail: 'Issue is not in an active sprint')
233
+ end
234
+
235
+ def scan_for_issue_never_visible_on_board entry:
236
+ issue = entry.issue
237
+ ever_visible = issue.changes.any? do |change|
238
+ next unless change.status?
239
+
240
+ issue.board.visible_columns.any? { |col| col.status_ids.include?(change.value_id) }
241
+ end
242
+ return if ever_visible
243
+
244
+ entry.report(
245
+ problem_key: :issue_not_visible_on_board,
246
+ detail: 'Issue has never been in a status mapped to a visible column on the board'
247
+ )
248
+ end
249
+
226
250
  def scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
227
251
  creation_change = entry.issue.changes.find { |issue| issue.status? }
228
252
 
@@ -250,7 +274,7 @@ class DataQualityReport < ChartBase
250
274
 
251
275
  started_subtasks = []
252
276
  entry.issue.subtasks.each do |subtask|
253
- started_subtasks << subtask if subtask.board.cycletime.started_stopped_times(subtask).first
277
+ started_subtasks << subtask if subtask.started_stopped_times.first
254
278
  end
255
279
 
256
280
  return if started_subtasks.empty?
@@ -266,8 +290,10 @@ class DataQualityReport < ChartBase
266
290
 
267
291
  def scan_for_items_blocked_on_closed_tickets entry:
268
292
  entry.issue.issue_links.each do |link|
293
+ next unless settings['blocked_link_text'].include?(link.label)
294
+
269
295
  this_active = !entry.stopped
270
- other_active = !link.other_issue.board.cycletime.started_stopped_times(link.other_issue).last
296
+ other_active = !link.other_issue.started_stopped_times.last
271
297
  next unless this_active && !other_active
272
298
 
273
299
  entry.report(
@@ -293,14 +319,14 @@ class DataQualityReport < ChartBase
293
319
  return "#{delta} hours" if delta < 24
294
320
 
295
321
  delta /= 24
296
- "#{delta} days"
322
+ label_days delta
297
323
  end
298
324
 
299
325
  def scan_for_incomplete_subtasks_when_issue_done entry:
300
326
  return unless entry.stopped
301
327
 
302
328
  subtask_labels = entry.issue.subtasks.filter_map do |subtask|
303
- subtask_started, subtask_stopped = subtask.board.cycletime.started_stopped_times(subtask)
329
+ subtask_started, subtask_stopped = subtask.started_stopped_times
304
330
 
305
331
  if !subtask_started && !subtask_stopped
306
332
  "#{subtask_label subtask} (Not even started)"
@@ -407,17 +433,22 @@ class DataQualityReport < ChartBase
407
433
  HTML
408
434
  end
409
435
 
410
- def render_status_not_on_board problems
436
+ def render_issue_not_visible_on_board problems
437
+ unique_issue_count = problems.map(&:first).uniq.size
411
438
  <<-HTML
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.
439
+ #{problems.size} #{'time'.then { |w| problems.size == 1 ? w : "#{w}s" }} across #{label_issues unique_issue_count},
440
+ an item was not visible on the board. This may impact
441
+ timings as the work was likely to have been forgotten if it wasn't visible. An issue can be not visible
442
+ for two reasons: the issue was in a status that is not mapped to any visible column on the board
443
+ (look in "unmapped statuses" on your board), or for scrum boards, the issue was not in an active sprint.
414
444
  HTML
415
445
  end
416
446
 
417
447
  def render_created_in_wrong_status problems
418
448
  <<-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.
449
+ #{label_issues problems.size} were created in a status that is not considered to be some varient
450
+ of To Do. Most likely this means that the issue was created from one of the columns on the board,
451
+ rather than in the backlog. Why Jira allows this is still a mystery.
421
452
  HTML
422
453
  end
423
454
 
@@ -51,10 +51,13 @@ 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
- "<h1>#{@header_text}</h1><div>#{@description_text}</div>#{shrink_svg svg}"
60
+ "<h1 class='foldable'>#{@header_text}</h1><div>#{@description_text}#{shrink_svg svg}</div>"
58
61
  end
59
62
 
60
63
  def link_rules &block
@@ -228,7 +231,7 @@ class DependencyChart < ChartBase
228
231
  elsif is_done
229
232
  line2 << 'Done'
230
233
  else
231
- started_at = issue.board.cycletime.started_stopped_times(issue).first
234
+ started_at = issue.started_stopped_times.first
232
235
  if started_at.nil?
233
236
  line2 << 'Not started'
234
237
  else
@@ -25,10 +25,25 @@ class DownloadConfig
25
25
  @no_earlier_than
26
26
  end
27
27
 
28
+ def github_repos
29
+ @github_repos ||= []
30
+ end
31
+
32
+ def github_repo *repos
33
+ github_repos.concat(repos.map { |r| normalize_github_repo(r) })
34
+ end
35
+
28
36
  def start_date today:
29
37
  date = today.to_date - @rolling_date_count if @rolling_date_count
30
38
  date = [date, @no_earlier_than].max if date && @no_earlier_than
31
39
  date = @no_earlier_than if date.nil? && @no_earlier_than
32
40
  date
33
41
  end
42
+
43
+ private
44
+
45
+ def normalize_github_repo repo
46
+ match = repo.match(%r{github\.com/([^/]+/[^/]+?)/?$})
47
+ match ? match[1] : repo
48
+ end
34
49
  end
@@ -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,85 +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
- max_results = 100
101
- start_at = 0
102
- total = 1
103
- while start_at < total
104
- json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
105
- "?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
106
-
107
- json['issues'].each do |issue_json|
108
- issue_json['exporter'] = {
109
- 'in_initial_query' => initial_query
110
- }
111
- identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
112
- file = "#{issue_json['key']}-#{board.id}.json"
113
-
114
- @file_system.save_json(json: issue_json, filename: File.join(path, file))
115
- end
98
+ def progress_dot message = nil
99
+ @file_system.log message if message
100
+ @file_system.progress_dot
101
+ end
116
102
 
117
- total = json['total'].to_i
118
- max_results = json['maxResults']
103
+ def end_progress
104
+ @file_system.end_progress
105
+ end
119
106
 
120
- message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
121
- 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?
122
110
 
123
- start_at += json['issues'].size
124
- end
111
+ ids
125
112
  end
126
113
 
127
114
  def identify_other_issues_to_be_downloaded raw_issue:, board:
@@ -149,6 +136,8 @@ class Downloader
149
136
  end
150
137
 
151
138
  def download_users
139
+ return unless @jira_gateway.cloud?
140
+
152
141
  log ' Downloading all users', both: true
153
142
  json = @jira_gateway.call_url relative_url: '/rest/api/2/users'
154
143
 
@@ -197,11 +186,28 @@ class Downloader
197
186
  # actually look at the returned json.
198
187
  @board_id_to_filter_id[board_id] = json['filter']['id'].to_i
199
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
200
195
  download_sprints board_id: board_id if json['type'] == 'scrum'
201
196
  # TODO: Should be passing actual statuses, not empty list
202
197
  Board.new raw: json, possible_statuses: StatusCollection.new
203
198
  end
204
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
+
205
211
  def download_sprints board_id:
206
212
  log " Downloading sprints for board #{board_id}", both: true
207
213
  max_results = 100
@@ -243,19 +249,29 @@ class Downloader
243
249
  value = Date.parse(value) if value.is_a?(String) && value =~ /^\d{4}-\d{2}-\d{2}$/
244
250
  @metadata[key] = value
245
251
  end
252
+
246
253
  end
247
254
 
248
255
  # Even if this is the old format, we want to obey this one tag
249
256
  @metadata['no-download'] = hash['no-download'] if hash['no-download']
250
257
  end
251
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
+
252
267
  def save_metadata
253
268
  @metadata['version'] = CURRENT_METADATA_VERSION
269
+ @metadata['rolling_date_count'] = @download_config.rolling_date_count
254
270
  @metadata['date_start_from_last_query'] = @start_date_in_query if @start_date_in_query
255
271
 
256
272
  if @download_date_range.nil?
257
273
  log "Making up a date range in meta since one wasn't specified. You'll want to change that.", both: true
258
- today = Date.today
274
+ today = today_in_project_timezone
259
275
  @download_date_range = (today - 7)..today
260
276
  end
261
277
 
@@ -290,7 +306,8 @@ class Downloader
290
306
  end
291
307
  end
292
308
 
293
- def make_jql filter_id:, today: Date.today
309
+ def make_jql filter_id:, today: nil
310
+ today ||= today_in_project_timezone
294
311
  segments = []
295
312
  segments << "filter=#{filter_id}"
296
313
 
@@ -298,11 +315,7 @@ class Downloader
298
315
 
299
316
  if start_date
300
317
  @download_date_range = start_date..today.to_date
301
-
302
- # For an incremental download, we want to query from the end of the previous one, not from the
303
- # beginning of the full range.
304
- @start_date_in_query = metadata['date_end'] || @download_date_range.begin
305
- 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
306
319
 
307
320
  # Catch-all to pick up anything that's been around since before the range started but hasn't
308
321
  # had an update during the range.
@@ -319,6 +332,39 @@ class Downloader
319
332
  segments.join ' AND '
320
333
  end
321
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
+
322
368
  def file_prefix
323
369
  @download_config.project_config.get_file_prefix
324
370
  end