jirametrics 2.4 → 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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +16 -3
  4. data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
  6. data/lib/jirametrics/aging_work_table.rb +63 -19
  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 +6 -4
  11. data/lib/jirametrics/board.rb +74 -22
  12. data/lib/jirametrics/board_config.rb +11 -3
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +54 -18
  17. data/lib/jirametrics/chart_base.rb +203 -30
  18. data/lib/jirametrics/css_variable.rb +2 -2
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/cycle_time_config.rb +137 -0
  21. data/lib/jirametrics/cycletime_histogram.rb +17 -38
  22. data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
  23. data/lib/jirametrics/daily_view.rb +306 -0
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
  27. data/lib/jirametrics/daily_wip_chart.rb +36 -16
  28. data/lib/jirametrics/data_quality_report.rb +251 -42
  29. data/lib/jirametrics/dependency_chart.rb +42 -12
  30. data/lib/jirametrics/download_config.rb +27 -0
  31. data/lib/jirametrics/downloader.rb +185 -110
  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 +75 -14
  35. data/lib/jirametrics/estimation_configuration.rb +25 -0
  36. data/lib/jirametrics/examples/aggregated_project.rb +9 -23
  37. data/lib/jirametrics/examples/standard_project.rb +57 -58
  38. data/lib/jirametrics/expedited_chart.rb +11 -10
  39. data/lib/jirametrics/exporter.rb +51 -14
  40. data/lib/jirametrics/file_config.rb +21 -6
  41. data/lib/jirametrics/file_system.rb +96 -4
  42. data/lib/jirametrics/fix_version.rb +13 -0
  43. data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
  44. data/lib/jirametrics/github_gateway.rb +115 -0
  45. data/lib/jirametrics/groupable_issue_chart.rb +12 -4
  46. data/lib/jirametrics/grouping_rules.rb +26 -4
  47. data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
  48. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
  49. data/lib/jirametrics/html/aging_work_table.erb +13 -4
  50. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  51. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  52. data/lib/jirametrics/html/daily_wip_chart.erb +41 -15
  53. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  54. data/lib/jirametrics/html/expedited_chart.erb +7 -24
  55. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
  56. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  57. data/lib/jirametrics/html/index.css +336 -62
  58. data/lib/jirametrics/html/index.erb +16 -21
  59. data/lib/jirametrics/html/index.js +164 -0
  60. data/lib/jirametrics/html/legacy_colors.css +174 -0
  61. data/lib/jirametrics/html/sprint_burndown.erb +18 -25
  62. data/lib/jirametrics/html/throughput_chart.erb +43 -21
  63. data/lib/jirametrics/html/time_based_histogram.erb +123 -0
  64. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
  65. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  66. data/lib/jirametrics/html_generator.rb +32 -0
  67. data/lib/jirametrics/html_report_config.rb +83 -76
  68. data/lib/jirametrics/issue.rb +499 -91
  69. data/lib/jirametrics/issue_collection.rb +33 -0
  70. data/lib/jirametrics/issue_printer.rb +97 -0
  71. data/lib/jirametrics/jira_gateway.rb +96 -16
  72. data/lib/jirametrics/mcp_server.rb +531 -0
  73. data/lib/jirametrics/project_config.rb +374 -130
  74. data/lib/jirametrics/pull_request.rb +30 -0
  75. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  76. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  77. data/lib/jirametrics/pull_request_review.rb +13 -0
  78. data/lib/jirametrics/raw_javascript.rb +17 -0
  79. data/lib/jirametrics/rules.rb +2 -2
  80. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  81. data/lib/jirametrics/settings.json +10 -2
  82. data/lib/jirametrics/sprint.rb +13 -0
  83. data/lib/jirametrics/sprint_burndown.rb +47 -39
  84. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  85. data/lib/jirametrics/status.rb +84 -19
  86. data/lib/jirametrics/status_collection.rb +83 -38
  87. data/lib/jirametrics/stitcher.rb +81 -0
  88. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  89. data/lib/jirametrics/throughput_chart.rb +73 -23
  90. data/lib/jirametrics/time_based_histogram.rb +139 -0
  91. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  92. data/lib/jirametrics/user.rb +12 -0
  93. data/lib/jirametrics/value_equality.rb +2 -2
  94. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  95. data/lib/jirametrics.rb +101 -66
  96. metadata +72 -16
  97. data/lib/jirametrics/cycletime_config.rb +0 -69
  98. data/lib/jirametrics/discard_changes_before.rb +0 -37
  99. data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
  100. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -42,23 +42,22 @@ class DependencyChart < ChartBase
42
42
  HTML
43
43
 
44
44
  @rules_block = rules_block
45
- @link_rules_block = ->(link_name, link_rules) {}
46
45
 
47
- issue_rules do |issue, rules|
48
- key = issue.key
49
- key = "<S>#{key} </S> " if issue.status.category_name == 'Done'
50
- rules.label = "<#{key} [#{issue.type}]<BR/>#{word_wrap issue.summary}>"
51
- end
46
+ issue_rules(&default_issue_rules)
47
+ link_rules(&default_link_rules)
52
48
  end
53
49
 
54
50
  def run
55
- instance_eval(&@rules_block)
51
+ instance_eval(&@rules_block) if @rules_block
56
52
 
57
53
  dot_graph = build_dot_graph
58
- 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
59
58
 
60
59
  svg = execute_graphviz(dot_graph.join("\n"))
61
- "<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>"
62
61
  end
63
62
 
64
63
  def link_rules &block
@@ -187,9 +186,8 @@ class DependencyChart < ChartBase
187
186
  return stdout.read
188
187
  end
189
188
  rescue # rubocop:disable Style/RescueStandardError
190
- message = "Unable to execute the command 'dot' which is part of graphviz. " \
191
- 'Ensure that graphviz is installed and that dot is in your path.'
192
- puts message
189
+ message = 'Unable to generate the dependency chart because graphviz could not be found in the path.'
190
+ file_system.log message, also_write_to_stderr: true
193
191
  message
194
192
  end
195
193
 
@@ -219,4 +217,36 @@ class DependencyChart < ChartBase
219
217
  end
220
218
  end.join(separator)
221
219
  end
220
+
221
+ def default_issue_rules
222
+ chart = self
223
+ lambda do |issue, rules|
224
+ is_done = issue.done?
225
+
226
+ key = issue.key
227
+ key = "<S>#{key} </S> " if is_done
228
+ line2 = +'<BR/>'
229
+ if issue.artificial?
230
+ line2 << '(unknown state)' # Shouldn't happen if we've done a full download but is still possible.
231
+ elsif is_done
232
+ line2 << 'Done'
233
+ else
234
+ started_at = issue.started_stopped_times.first
235
+ if started_at.nil?
236
+ line2 << 'Not started'
237
+ else
238
+ line2 << "Age: #{issue.board.cycletime.age(issue, today: chart.date_range.end)} days"
239
+ end
240
+ end
241
+ rules.label = "<#{key} [#{issue.type}]#{line2}<BR/>#{word_wrap issue.summary}>"
242
+ end
243
+ end
244
+
245
+ def default_link_rules
246
+ lambda do |link, rules|
247
+ rules.ignore if link.origin.done? && link.other_issue.done?
248
+ rules.ignore if link.name == 'Cloners'
249
+ rules.merge_bidirectional keep: 'outward'
250
+ end
251
+ end
222
252
  end
@@ -19,4 +19,31 @@ class DownloadConfig
19
19
  @rolling_date_count = count unless count.nil?
20
20
  @rolling_date_count
21
21
  end
22
+
23
+ def no_earlier_than date = :not_set
24
+ @no_earlier_than = Date.parse(date) unless date == :not_set
25
+ @no_earlier_than
26
+ end
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
+
36
+ def start_date today:
37
+ date = today.to_date - @rolling_date_count if @rolling_date_count
38
+ date = [date, @no_earlier_than].max if date && @no_earlier_than
39
+ date = @no_earlier_than if date.nil? && @no_earlier_than
40
+ date
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
22
49
  end
@@ -3,21 +3,53 @@
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
- attr_accessor :metadata, :quiet_mode
30
+ attr_accessor :metadata
10
31
  attr_reader :file_system
11
32
 
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']
@@ -39,94 +70,49 @@ class Downloader
39
70
  # board_ids = @download_config.board_ids
40
71
 
41
72
  remove_old_files
73
+ update_status_history_file
42
74
  download_statuses
43
75
  find_board_ids.each do |id|
44
- download_board_configuration board_id: id
45
- download_issues board_id: id
76
+ board = download_board_configuration board_id: id
77
+ board.project_config = @download_config.project_config
78
+ download_issues board: board
46
79
  end
80
+ download_users
47
81
 
48
82
  save_metadata
49
- end
50
-
51
- def init_gateway
52
- @jira_gateway.load_jira_config(@download_config.project_config.jira_config)
53
- @jira_gateway.ignore_ssl_errors = @download_config.project_config.settings['ignore_ssl_errors']
83
+ download_github_prs if @download_config.github_repos.any?
54
84
  end
55
85
 
56
86
  def log text, both: false
57
- @file_system.log text
58
- puts text if both && !@quiet_mode
87
+ @file_system.log text, also_write_to_stderr: both
59
88
  end
60
89
 
61
- def find_board_ids
62
- ids = @download_config.project_config.board_configs.collect(&:id)
63
- raise 'Board ids must be specified' if ids.empty?
64
-
65
- ids
90
+ def log_start text
91
+ @file_system.log_start text
66
92
  end
67
93
 
68
- def download_issues board_id:
69
- log " Downloading primary issues for board #{board_id}", both: true
70
- path = "#{@target_path}#{@download_config.project_config.file_prefix}_issues/"
71
- unless Dir.exist?(path)
72
- log " Creating path #{path}"
73
- Dir.mkdir(path)
74
- end
75
-
76
- filter_id = @board_id_to_filter_id[board_id]
77
- jql = make_jql(filter_id: filter_id)
78
- jira_search_by_jql(jql: jql, initial_query: true, board_id: board_id, path: path)
79
-
80
- log " Downloading linked issues for board #{board_id}", both: true
81
- loop do
82
- @issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
83
- break if @issue_keys_pending_download.empty?
84
-
85
- keys_to_request = @issue_keys_pending_download[0..99]
86
- @issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
87
- jql = "key in (#{keys_to_request.join(', ')})"
88
- jira_search_by_jql(jql: jql, initial_query: false, board_id: board_id, path: path)
89
- end
94
+ def start_progress
95
+ @file_system.start_progress
90
96
  end
91
97
 
92
- def jira_search_by_jql jql:, initial_query:, board_id:, path:
93
- intercept_jql = @download_config.project_config.settings['intercept_jql']
94
- jql = intercept_jql.call jql if intercept_jql
95
-
96
- log " JQL: #{jql}"
97
- escaped_jql = CGI.escape jql
98
-
99
- max_results = 100
100
- start_at = 0
101
- total = 1
102
- while start_at < total
103
- json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
104
- "?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
105
-
106
- exit_if_call_failed json
107
-
108
- json['issues'].each do |issue_json|
109
- issue_json['exporter'] = {
110
- 'in_initial_query' => initial_query
111
- }
112
- identify_other_issues_to_be_downloaded issue_json
113
- file = "#{issue_json['key']}-#{board_id}.json"
114
-
115
- @file_system.save_json(json: issue_json, filename: File.join(path, file))
116
- end
98
+ def progress_dot message = nil
99
+ @file_system.log message if message
100
+ @file_system.progress_dot
101
+ end
117
102
 
118
- total = json['total'].to_i
119
- max_results = json['maxResults']
103
+ def end_progress
104
+ @file_system.end_progress
105
+ end
120
106
 
121
- message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
122
- 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?
123
110
 
124
- start_at += json['issues'].size
125
- end
111
+ ids
126
112
  end
127
113
 
128
- def identify_other_issues_to_be_downloaded raw_issue
129
- issue = Issue.new raw: raw_issue, board: nil
114
+ def identify_other_issues_to_be_downloaded raw_issue:, board:
115
+ issue = Issue.new raw: raw_issue, board: board
130
116
  @issue_keys_downloaded_in_current_run << issue.key
131
117
 
132
118
  # Parent
@@ -137,22 +123,6 @@ class Downloader
137
123
  issue.raw['fields']['subtasks']&.each do |raw_subtask|
138
124
  @issue_keys_pending_download << raw_subtask['key']
139
125
  end
140
-
141
- # Links
142
- # We shouldn't blindly follow links as some, like cloners, aren't valuable and are just wasting time/effort
143
- # to download
144
- # issue.raw['fields']['issuelinks'].each do |raw_link|
145
- # @issue_keys_pending_download << IssueLink(raw: raw_link).other_issue.key
146
- # end
147
- end
148
-
149
- def exit_if_call_failed json
150
- # Sometimes Jira returns the singular form of errorMessage and sometimes the plural. Consistency FTW.
151
- return unless json['error'] || json['errorMessages'] || json['errorMessage']
152
-
153
- log "Download failed. See #{@file_system.logfile_name} for details.", both: true
154
- log " #{JSON.pretty_generate(json)}"
155
- exit 1
156
126
  end
157
127
 
158
128
  def download_statuses
@@ -161,29 +131,85 @@ class Downloader
161
131
 
162
132
  @file_system.save_json(
163
133
  json: json,
164
- filename: "#{@target_path}#{@download_config.project_config.file_prefix}_statuses.json"
134
+ filename: File.join(@target_path, "#{file_prefix}_statuses.json")
165
135
  )
166
136
  end
167
137
 
138
+ def download_users
139
+ return unless @jira_gateway.cloud?
140
+
141
+ log ' Downloading all users', both: true
142
+ json = @jira_gateway.call_url relative_url: '/rest/api/2/users'
143
+
144
+ @file_system.save_json(
145
+ json: json,
146
+ filename: File.join(@target_path, "#{file_prefix}_users.json")
147
+ )
148
+ end
149
+
150
+ def update_status_history_file
151
+ status_filename = File.join(@target_path, "#{file_prefix}_statuses.json")
152
+ return unless file_system.file_exist? status_filename
153
+
154
+ status_json = file_system.load_json(status_filename)
155
+
156
+ history_filename = File.join(@target_path, "#{file_prefix}_status_history.json")
157
+ history_json = file_system.load_json(history_filename) if file_system.file_exist? history_filename
158
+
159
+ if history_json
160
+ file_system.log ' Updating status history file', also_write_to_stderr: true
161
+ else
162
+ file_system.log ' Creating status history file', also_write_to_stderr: true
163
+ history_json = []
164
+ end
165
+
166
+ status_json.each do |status_item|
167
+ id = status_item['id']
168
+ history_item = history_json.find { |s| s['id'] == id }
169
+ history_json.delete(history_item) if history_item
170
+ history_json << status_item
171
+ end
172
+
173
+ file_system.save_json(filename: history_filename, json: history_json)
174
+ end
175
+
168
176
  def download_board_configuration board_id:
169
177
  log " Downloading board configuration for board #{board_id}", both: true
170
178
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
171
179
 
172
- exit_if_call_failed json
173
-
174
- file_prefix = @download_config.project_config.file_prefix
175
- @file_system.save_json json: json, filename: "#{@target_path}#{file_prefix}_board_#{board_id}_configuration.json"
180
+ @file_system.save_json(
181
+ json: json,
182
+ filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_configuration.json")
183
+ )
176
184
 
177
185
  # We have a reported bug that blew up on this line. Moved it after the save so we can
178
186
  # actually look at the returned json.
179
187
  @board_id_to_filter_id[board_id] = json['filter']['id'].to_i
180
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
181
195
  download_sprints board_id: board_id if json['type'] == 'scrum'
196
+ # TODO: Should be passing actual statuses, not empty list
197
+ Board.new raw: json, possible_statuses: StatusCollection.new
198
+ end
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
182
209
  end
183
210
 
184
211
  def download_sprints board_id:
185
212
  log " Downloading sprints for board #{board_id}", both: true
186
- file_prefix = @download_config.project_config.file_prefix
187
213
  max_results = 100
188
214
  start_at = 0
189
215
  is_last = false
@@ -191,20 +217,23 @@ class Downloader
191
217
  while is_last == false
192
218
  json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/sprint?" \
193
219
  "maxResults=#{max_results}&startAt=#{start_at}"
194
- exit_if_call_failed json
195
220
 
196
221
  @file_system.save_json(
197
222
  json: json,
198
- filename: "#{@target_path}#{file_prefix}_board_#{board_id}_sprints_#{start_at}.json"
223
+ filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_sprints_#{start_at}.json")
199
224
  )
200
225
  is_last = json['isLast']
201
226
  max_results = json['maxResults']
202
- start_at += json['values'].size
227
+ if json['values']
228
+ start_at += json['values'].size
229
+ else
230
+ log " No sprints found for board #{board_id}"
231
+ end
203
232
  end
204
233
  end
205
234
 
206
235
  def metadata_pathname
207
- "#{@target_path}#{@download_config.project_config.file_prefix}_meta.json"
236
+ File.join(@target_path, "#{file_prefix}_meta.json")
208
237
  end
209
238
 
210
239
  def load_metadata
@@ -220,19 +249,29 @@ class Downloader
220
249
  value = Date.parse(value) if value.is_a?(String) && value =~ /^\d{4}-\d{2}-\d{2}$/
221
250
  @metadata[key] = value
222
251
  end
252
+
223
253
  end
224
254
 
225
255
  # Even if this is the old format, we want to obey this one tag
226
256
  @metadata['no-download'] = hash['no-download'] if hash['no-download']
227
257
  end
228
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
+
229
267
  def save_metadata
230
268
  @metadata['version'] = CURRENT_METADATA_VERSION
269
+ @metadata['rolling_date_count'] = @download_config.rolling_date_count
231
270
  @metadata['date_start_from_last_query'] = @start_date_in_query if @start_date_in_query
232
271
 
233
272
  if @download_date_range.nil?
234
273
  log "Making up a date range in meta since one wasn't specified. You'll want to change that.", both: true
235
- today = Date.today
274
+ today = today_in_project_timezone
236
275
  @download_date_range = (today - 7)..today
237
276
  end
238
277
 
@@ -247,17 +286,17 @@ class Downloader
247
286
  end
248
287
 
249
288
  def remove_old_files
250
- file_prefix = @download_config.project_config.file_prefix
251
289
  Dir.foreach @target_path do |file|
252
290
  next unless file.match?(/^#{file_prefix}_\d+\.json$/)
291
+ next if file == "#{file_prefix}_status_history.json"
253
292
 
254
- File.unlink "#{@target_path}#{file}"
293
+ File.unlink File.join(@target_path, file)
255
294
  end
256
295
 
257
296
  return if @cached_data_format_is_current
258
297
 
259
298
  # Also throw away all the previously downloaded issues.
260
- path = File.join @target_path, "#{file_prefix}_issues"
299
+ path = File.join(@target_path, "#{file_prefix}_issues")
261
300
  return unless File.exist? path
262
301
 
263
302
  Dir.foreach path do |file|
@@ -267,17 +306,16 @@ class Downloader
267
306
  end
268
307
  end
269
308
 
270
- def make_jql filter_id:, today: Date.today
309
+ def make_jql filter_id:, today: nil
310
+ today ||= today_in_project_timezone
271
311
  segments = []
272
312
  segments << "filter=#{filter_id}"
273
313
 
274
- unless @download_config.rolling_date_count.nil?
275
- @download_date_range = (today.to_date - @download_config.rolling_date_count)..today.to_date
314
+ start_date = @download_config.start_date today: today
276
315
 
277
- # For an incremental download, we want to query from the end of the previous one, not from the
278
- # beginning of the full range.
279
- @start_date_in_query = metadata['date_end'] || @download_date_range.begin
280
- log " Incremental download only. Pulling from #{@start_date_in_query}", both: true if metadata['date_end']
316
+ if start_date
317
+ @download_date_range = start_date..today.to_date
318
+ @start_date_in_query = @download_date_range.begin
281
319
 
282
320
  # Catch-all to pick up anything that's been around since before the range started but hasn't
283
321
  # had an update during the range.
@@ -293,4 +331,41 @@ class Downloader
293
331
 
294
332
  segments.join ' AND '
295
333
  end
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
+
368
+ def file_prefix
369
+ @download_config.project_config.get_file_prefix
370
+ end
296
371
  end