jirametrics 2.22 → 2.27

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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +10 -2
  3. data/lib/jirametrics/aging_work_bar_chart.rb +20 -6
  4. data/lib/jirametrics/aging_work_table.rb +4 -5
  5. data/lib/jirametrics/anonymizer.rb +74 -1
  6. data/lib/jirametrics/atlassian_document_format.rb +93 -93
  7. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  8. data/lib/jirametrics/board.rb +20 -8
  9. data/lib/jirametrics/board_feature.rb +14 -0
  10. data/lib/jirametrics/board_movement_calculator.rb +2 -2
  11. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  12. data/lib/jirametrics/change_item.rb +4 -3
  13. data/lib/jirametrics/chart_base.rb +94 -2
  14. data/lib/jirametrics/css_variable.rb +1 -1
  15. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  16. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +1 -2
  17. data/lib/jirametrics/cycletime_histogram.rb +15 -103
  18. data/lib/jirametrics/cycletime_scatterplot.rb +13 -98
  19. data/lib/jirametrics/daily_view.rb +36 -12
  20. data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
  21. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +1 -1
  22. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  23. data/lib/jirametrics/daily_wip_chart.rb +29 -7
  24. data/lib/jirametrics/data_quality_report.rb +38 -12
  25. data/lib/jirametrics/dependency_chart.rb +2 -2
  26. data/lib/jirametrics/download_config.rb +15 -0
  27. data/lib/jirametrics/downloader.rb +87 -5
  28. data/lib/jirametrics/downloader_for_cloud.rb +52 -10
  29. data/lib/jirametrics/downloader_for_data_center.rb +2 -1
  30. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  31. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  32. data/lib/jirametrics/examples/standard_project.rb +29 -19
  33. data/lib/jirametrics/expedited_chart.rb +3 -1
  34. data/lib/jirametrics/exporter.rb +3 -1
  35. data/lib/jirametrics/file_system.rb +35 -2
  36. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  37. data/lib/jirametrics/github_gateway.rb +115 -0
  38. data/lib/jirametrics/groupable_issue_chart.rb +4 -0
  39. data/lib/jirametrics/grouping_rules.rb +26 -4
  40. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
  41. data/lib/jirametrics/html/aging_work_table.erb +3 -0
  42. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  43. data/lib/jirametrics/html/daily_wip_chart.erb +38 -5
  44. data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
  45. data/lib/jirametrics/html/expedited_chart.erb +3 -13
  46. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
  47. data/lib/jirametrics/html/index.css +117 -0
  48. data/lib/jirametrics/html/index.erb +6 -0
  49. data/lib/jirametrics/html/index.js +52 -2
  50. data/lib/jirametrics/html/sprint_burndown.erb +7 -13
  51. data/lib/jirametrics/html/throughput_chart.erb +40 -9
  52. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +59 -59
  53. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +11 -7
  54. data/lib/jirametrics/html_generator.rb +2 -1
  55. data/lib/jirametrics/html_report_config.rb +23 -16
  56. data/lib/jirametrics/issue.rb +101 -96
  57. data/lib/jirametrics/issue_printer.rb +97 -0
  58. data/lib/jirametrics/jira_gateway.rb +6 -3
  59. data/lib/jirametrics/mcp_server.rb +305 -0
  60. data/lib/jirametrics/project_config.rb +80 -7
  61. data/lib/jirametrics/pull_request.rb +30 -0
  62. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  63. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  64. data/lib/jirametrics/pull_request_review.rb +13 -0
  65. data/lib/jirametrics/raw_javascript.rb +4 -0
  66. data/lib/jirametrics/settings.json +3 -1
  67. data/lib/jirametrics/sprint_burndown.rb +3 -1
  68. data/lib/jirametrics/status.rb +1 -1
  69. data/lib/jirametrics/stitcher.rb +7 -1
  70. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  71. data/lib/jirametrics/throughput_chart.rb +73 -23
  72. data/lib/jirametrics/time_based_histogram.rb +139 -0
  73. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  74. data/lib/jirametrics.rb +28 -0
  75. metadata +47 -5
@@ -33,21 +33,23 @@ class Downloader
33
33
  # For testing only
34
34
  attr_reader :start_date_in_query, :board_id_to_filter_id
35
35
 
36
- def self.create download_config:, file_system:, jira_gateway:
36
+ def self.create download_config:, file_system:, jira_gateway:, github_pr_cache: {}
37
37
  is_cloud = jira_gateway.settings['jira_cloud'] || jira_gateway.cloud?
38
38
  (is_cloud ? DownloaderForCloud : DownloaderForDataCenter).new(
39
39
  download_config: download_config,
40
40
  file_system: file_system,
41
- jira_gateway: jira_gateway
41
+ jira_gateway: jira_gateway,
42
+ github_pr_cache: github_pr_cache
42
43
  )
43
44
  end
44
45
 
45
- def initialize download_config:, file_system:, jira_gateway:
46
+ def initialize download_config:, file_system:, jira_gateway:, github_pr_cache: {}
46
47
  @metadata = {}
47
48
  @download_config = download_config
48
49
  @target_path = @download_config.project_config.target_path
49
50
  @file_system = file_system
50
51
  @jira_gateway = jira_gateway
52
+ @github_pr_cache = github_pr_cache
51
53
  @board_id_to_filter_id = {}
52
54
 
53
55
  @issue_keys_downloaded_in_current_run = []
@@ -72,17 +74,36 @@ class Downloader
72
74
  download_statuses
73
75
  find_board_ids.each do |id|
74
76
  board = download_board_configuration board_id: id
77
+ board.project_config = @download_config.project_config
75
78
  download_issues board: board
76
79
  end
77
80
  download_users
78
81
 
79
82
  save_metadata
83
+ download_github_prs if @download_config.github_repos.any?
80
84
  end
81
85
 
82
86
  def log text, both: false
83
87
  @file_system.log text, also_write_to_stderr: both
84
88
  end
85
89
 
90
+ def log_start text
91
+ @file_system.log_start text
92
+ end
93
+
94
+ def start_progress
95
+ @file_system.start_progress
96
+ end
97
+
98
+ def progress_dot message = nil
99
+ @file_system.log message if message
100
+ @file_system.progress_dot
101
+ end
102
+
103
+ def end_progress
104
+ @file_system.end_progress
105
+ end
106
+
86
107
  def find_board_ids
87
108
  ids = @download_config.project_config.board_configs.collect(&:id)
88
109
  raise 'Board ids must be specified' if ids.empty?
@@ -165,11 +186,28 @@ class Downloader
165
186
  # actually look at the returned json.
166
187
  @board_id_to_filter_id[board_id] = json['filter']['id'].to_i
167
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
168
195
  download_sprints board_id: board_id if json['type'] == 'scrum'
169
196
  # TODO: Should be passing actual statuses, not empty list
170
197
  Board.new raw: json, possible_statuses: StatusCollection.new
171
198
  end
172
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
+
173
211
  def download_sprints board_id:
174
212
  log " Downloading sprints for board #{board_id}", both: true
175
213
  max_results = 100
@@ -211,19 +249,29 @@ class Downloader
211
249
  value = Date.parse(value) if value.is_a?(String) && value =~ /^\d{4}-\d{2}-\d{2}$/
212
250
  @metadata[key] = value
213
251
  end
252
+
214
253
  end
215
254
 
216
255
  # Even if this is the old format, we want to obey this one tag
217
256
  @metadata['no-download'] = hash['no-download'] if hash['no-download']
218
257
  end
219
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
+
220
267
  def save_metadata
221
268
  @metadata['version'] = CURRENT_METADATA_VERSION
269
+ @metadata['rolling_date_count'] = @download_config.rolling_date_count
222
270
  @metadata['date_start_from_last_query'] = @start_date_in_query if @start_date_in_query
223
271
 
224
272
  if @download_date_range.nil?
225
273
  log "Making up a date range in meta since one wasn't specified. You'll want to change that.", both: true
226
- today = Date.today
274
+ today = today_in_project_timezone
227
275
  @download_date_range = (today - 7)..today
228
276
  end
229
277
 
@@ -258,7 +306,8 @@ class Downloader
258
306
  end
259
307
  end
260
308
 
261
- def make_jql filter_id:, today: Date.today
309
+ def make_jql filter_id:, today: nil
310
+ today ||= today_in_project_timezone
262
311
  segments = []
263
312
  segments << "filter=#{filter_id}"
264
313
 
@@ -283,6 +332,39 @@ class Downloader
283
332
  segments.join ' AND '
284
333
  end
285
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
+
286
368
  def file_prefix
287
369
  @download_config.project_config.get_file_prefix
288
370
  end
@@ -5,6 +5,45 @@ class DownloaderForCloud < Downloader
5
5
  'Jira Cloud'
6
6
  end
7
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
+
8
47
  def search_for_issues jql:, board_id:, path:
9
48
  log " JQL: #{jql}"
10
49
  escaped_jql = CGI.escape jql
@@ -14,6 +53,7 @@ class DownloaderForCloud < Downloader
14
53
  next_page_token = nil
15
54
  issue_count = 0
16
55
 
56
+ start_progress
17
57
  loop do
18
58
  relative_url = +''
19
59
  relative_url << '/rest/api/3/search/jql'
@@ -36,11 +76,12 @@ class DownloaderForCloud < Downloader
36
76
  issue_count += 1
37
77
  end
38
78
 
39
- message = " Found #{issue_count} issues"
40
- log message, both: true
79
+ progress_dot " Found #{issue_count} issues"
41
80
 
42
81
  break unless next_page_token
43
82
  end
83
+ end_progress
84
+
44
85
  hash
45
86
  end
46
87
 
@@ -49,7 +90,7 @@ class DownloaderForCloud < Downloader
49
90
  # that only returns the "recent" changes, not all of them. So now we get the issue
50
91
  # without changes and then make a second call for that changes. Then we insert it
51
92
  # into the raw issue as if it had been there all along.
52
- log " Downloading #{issue_datas.size} issues", both: true
93
+ log " Downloading #{issue_datas.size} issues"
53
94
  payload = {
54
95
  'fields' => ['*all'],
55
96
  'issueIdsOrKeys' => issue_datas.collect(&:key)
@@ -129,13 +170,12 @@ class DownloaderForCloud < Downloader
129
170
 
130
171
  loop do
131
172
  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
- )
173
+ stale = issue_data_hash.values.reject { |data| data.up_to_date }
174
+ unless stale.empty?
175
+ log_start ' Downloading more issues '
176
+ stale.each_slice(100) do |slice|
177
+ slice = bulk_fetch_issues(issue_datas: slice, board: board, in_initial_query: true)
178
+ progress_dot
139
179
  slice.each do |data|
140
180
  @file_system.save_json(
141
181
  json: data.issue.raw, filename: data.cache_path
@@ -156,6 +196,8 @@ class DownloaderForCloud < Downloader
156
196
  end
157
197
  end
158
198
  end
199
+ end_progress
200
+ end
159
201
 
160
202
  # Remove all the ones we already downloaded
161
203
  related_issue_keys.reject! { |key| issue_data_hash[key] }
@@ -63,7 +63,8 @@ class DownloaderForDataCenter < Downloader
63
63
  end
64
64
  end
65
65
 
66
- def make_jql filter_id:, today: Date.today
66
+ def make_jql filter_id:, today: nil
67
+ today ||= today_in_project_timezone
67
68
  segments = []
68
69
  segments << "filter=#{filter_id}"
69
70
 
@@ -5,7 +5,7 @@ class EstimateAccuracyChart < ChartBase
5
5
  super()
6
6
 
7
7
  header_text 'Estimate Accuracy'
8
- description_text <<-HTML
8
+ description_text <<~HTML
9
9
  <div class="p">
10
10
  This chart graphs estimates against actual recorded cycle times. Since
11
11
  estimates can change over time, we're graphing the estimate at the time that the story started.
@@ -20,8 +20,18 @@ class EstimateAccuracyChart < ChartBase
20
20
  far to the right then you know you have a problem.
21
21
  <% end %>
22
22
  </div>
23
+ <% if @correlation_coefficient %>
24
+ <div class="p">
25
+ The completed items here have a correlation coefficient of <b><%= @correlation_coefficient.round(3) %></b>.
26
+ The closer it is to +1, the stronger the positive correlation. The closer it is to -1,
27
+ the stronger the negative collalation. Zero would mean no correlation at all.
28
+ </div>
29
+ <% end %>
23
30
  HTML
24
31
 
32
+ @x_axis_title = 'Cycletime (days)'
33
+ @y_axis_title = 'Estimate'
34
+
25
35
  @y_axis_type = 'linear'
26
36
  @y_axis_block = ->(issue, start_time) { estimate_at(issue: issue, start_time: start_time)&.to_f }
27
37
  @y_axis_sort_order = nil
@@ -30,9 +40,9 @@ class EstimateAccuracyChart < ChartBase
30
40
  end
31
41
 
32
42
  def run
33
- if @y_axis_label.nil?
43
+ if @y_axis_title.nil?
34
44
  text = current_board.estimation_configuration.units == :story_points ? 'Story Points' : 'Days'
35
- @y_axis_label = "Estimated #{text}"
45
+ @y_axis_title = "Estimated #{text}"
36
46
  end
37
47
  data_sets = scan_issues
38
48
 
@@ -43,7 +53,7 @@ class EstimateAccuracyChart < ChartBase
43
53
 
44
54
  def scan_issues
45
55
  completed_hash, aging_hash = split_into_completed_and_aging issues: issues
46
-
56
+ @correlation_coefficient = correlation_coefficient(completed_hash) unless completed_hash.empty?
47
57
  estimation_units = current_board.estimation_configuration.units
48
58
  @has_aging_data = !aging_hash.empty?
49
59
 
@@ -170,4 +180,32 @@ class EstimateAccuracyChart < ChartBase
170
180
  end
171
181
  @y_axis_block = block
172
182
  end
183
+
184
+ # Correlation coefficient is calculated using the Pearson Correlation Coefficient
185
+ # r = Σ((xi - x̄)(yi - ȳ)) / sqrt(Σ(xi - x̄)² · Σ(yi - ȳ)²)
186
+ def correlation_coefficient completed_hash
187
+ list1 = []
188
+ list2 = []
189
+ completed_hash.each do |(estimate, cycle_time), issues|
190
+ issues.size.times do
191
+ list1 << estimate
192
+ list2 << cycle_time
193
+ end
194
+ end
195
+
196
+ n = list1.size
197
+ return nil if n < 2
198
+
199
+ mean1 = list1.sum.to_f / n
200
+ mean2 = list2.sum.to_f / n
201
+
202
+ numerator = list1.zip(list2).sum { |x, y| (x - mean1) * (y - mean2) }
203
+ sum_sq1 = list1.sum { |x| (x - mean1)**2 }
204
+ sum_sq2 = list2.sum { |y| (y - mean2)**2 }
205
+
206
+ denominator = Math.sqrt(sum_sq1 * sum_sq2)
207
+ return nil if denominator.zero?
208
+
209
+ numerator / denominator
210
+ end
173
211
  end
@@ -10,9 +10,9 @@
10
10
  class Exporter
11
11
  def aggregated_project name:, project_names:, settings: {}
12
12
  project name: name do
13
- puts name
13
+ file_system.log name
14
14
  file_prefix name
15
- self.settings.merge! settings
15
+ self.settings.merge! stringify_keys(settings)
16
16
 
17
17
  aggregate do
18
18
  project_names.each do |project_name|
@@ -6,14 +6,14 @@ class Exporter
6
6
  def standard_project name:, file_prefix:, ignore_issues: nil, starting_status: nil, boards: {},
7
7
  default_board: nil, anonymize: false, settings: {}, status_category_mappings: {},
8
8
  rolling_date_count: 90, no_earlier_than: nil, ignore_types: %w[Sub-task Subtask Epic],
9
- show_experimental_charts: false
10
-
9
+ show_experimental_charts: false, github_repos: nil
10
+ exporter = self
11
11
  project name: name do
12
- puts name
12
+ file_system.log name
13
13
  file_prefix file_prefix
14
14
 
15
15
  self.anonymize if anonymize
16
- self.settings.merge! settings
16
+ self.settings.merge! stringify_keys(settings)
17
17
 
18
18
  boards.each_key do |board_id|
19
19
  block = boards[board_id]
@@ -35,19 +35,20 @@ class Exporter
35
35
  download do
36
36
  self.rolling_date_count(rolling_date_count) if rolling_date_count
37
37
  self.no_earlier_than(no_earlier_than) if no_earlier_than
38
+ github_repo *github_repos if github_repos
38
39
  end
39
40
 
40
41
  issues.reject! do |issue|
41
42
  ignore_types.include? issue.type
42
43
  end
43
44
 
45
+ exporter.filter_issues issues, ignore_issues
46
+
44
47
  discard_changes_before status_becomes: (starting_status || :backlog) # rubocop:disable Style/RedundantParentheses
45
48
 
46
49
  file do
47
50
  file_suffix '.html'
48
51
 
49
- issues.reject! { |issue| ignore_issues.include? issue.key } if ignore_issues
50
-
51
52
  html_report do
52
53
  board_id default_board if default_board
53
54
 
@@ -57,27 +58,27 @@ class Exporter
57
58
  html "<div><a href='#{board.url}'>#{id} #{board.name}</a> (#{board.board_type})</div>",
58
59
  type: :header
59
60
  end
60
-
61
61
  daily_view
62
-
62
+ cumulative_flow_diagram
63
63
  cycletime_scatterplot do
64
64
  show_trend_lines
65
65
  end
66
66
  cycletime_histogram
67
67
 
68
68
  throughput_chart do
69
- description_text '<h2>Number of items completed, grouped by issue type</h2>'
69
+ description_text <<~TEXT
70
+ <div>Throughput data is very useful for#{' '}
71
+ <a href="https://blog.mikebowler.ca/2024/06/02/probabilistic-forecasting/">probabilistic forecasting</a>,
72
+ to determine when we'll be done. Try it now with the
73
+ <a href="<%= throughput_forecaster_url %>" target="_blank" rel="noopener noreferrer">
74
+ Focused Objective throughput forecaster,</a> to see how long it would take to complete all of the
75
+ <%= @not_started_count %> items you currently have in your backlog.
76
+ </div>
77
+ <h2>Number of items completed, grouped by issue type</h2>'
78
+ TEXT
70
79
  end
71
- throughput_chart do
72
- header_text nil
80
+ throughput_by_completed_resolution_chart do
73
81
  description_text '<h2>Number of items completed, grouped by completion status and resolution</h2>'
74
- grouping_rules do |issue, rules|
75
- if issue.resolution
76
- rules.label = "#{issue.status.name}:#{issue.resolution}"
77
- else
78
- rules.label = issue.status.name
79
- end
80
- end
81
82
  end
82
83
 
83
84
  aging_work_in_progress_chart
@@ -87,7 +88,6 @@ class Exporter
87
88
  daily_wip_by_blocked_stalled_chart
88
89
  daily_wip_by_parent_chart
89
90
  flow_efficiency_scatterplot if show_experimental_charts
90
- expedited_chart
91
91
  sprint_burndown
92
92
  estimate_accuracy_chart
93
93
  dependency_chart
@@ -95,4 +95,14 @@ class Exporter
95
95
  end
96
96
  end
97
97
  end
98
+
99
+ # Extracted as a separate method so it can be tested independently, without needing to invoke
100
+ # the full standard_project DSL setup.
101
+ def filter_issues issues, ignore_issues
102
+ return unless ignore_issues
103
+
104
+ issues.reject! do |issue|
105
+ ignore_issues.is_a?(Proc) ? ignore_issues.call(issue) : ignore_issues.include?(issue.key)
106
+ end
107
+ end
98
108
  end
@@ -38,6 +38,8 @@ class ExpeditedChart < ChartBase
38
38
  </div>
39
39
  #{describe_non_working_days}
40
40
  HTML
41
+ @x_axis_title = 'Date'
42
+ @y_axis_title = 'Age in days'
41
43
 
42
44
  instance_eval(&block)
43
45
  end
@@ -48,7 +50,7 @@ class ExpeditedChart < ChartBase
48
50
  end
49
51
 
50
52
  if data_sets.empty?
51
- '<h1 class="foldable">Expedited work</h1><p>There is no expedited work in this time period.</p>'
53
+ '<h1 class="foldable">Expedited work</h1><div>There is no expedited work in this time period.</div>'
52
54
  else
53
55
  wrap_and_render(binding, __FILE__)
54
56
  end
@@ -40,6 +40,7 @@ class Exporter
40
40
 
41
41
  def download name_filter:
42
42
  @downloading = true
43
+ github_pr_cache = {}
43
44
  each_project_config(name_filter: name_filter) do |project|
44
45
  project.evaluate_next_level
45
46
  next if project.aggregated_project?
@@ -56,7 +57,8 @@ class Exporter
56
57
  downloader = Downloader.create(
57
58
  download_config: project.download_config,
58
59
  file_system: file_system,
59
- jira_gateway: gateway
60
+ jira_gateway: gateway,
61
+ github_pr_cache: github_pr_cache
60
62
  )
61
63
  downloader.run
62
64
  end
@@ -3,13 +3,14 @@
3
3
  require 'json'
4
4
 
5
5
  class FileSystem
6
- attr_accessor :logfile, :logfile_name
6
+ attr_accessor :logfile, :logfile_name, :log_only
7
7
 
8
8
  def initialize
9
9
  # In almost all cases, this will be immediately replaced in the Exporter
10
10
  # but if we fail before we get that far, this will at least let a useful
11
11
  # error show up on the console.
12
12
  @logfile = $stdout
13
+ @log_only = false
13
14
  end
14
15
 
15
16
  # Effectively the same as File.read except it forces the encoding to UTF-8
@@ -59,11 +60,43 @@ class FileSystem
59
60
 
60
61
  logfile.puts message
61
62
  logfile.puts more if more
62
- return unless also_write_to_stderr
63
+ return if log_only || !also_write_to_stderr
64
+
65
+ # Obscure edge-case where we're trying to log something before logging is even
66
+ # set up. Quick escape here so that we don't dump the error twice.
67
+ return if logfile == $stdout
63
68
 
64
69
  $stderr.puts message # rubocop:disable Style/StderrPuts
65
70
  end
66
71
 
72
+ def log_start message
73
+ logfile.puts message
74
+ return if log_only || logfile == $stdout
75
+
76
+ $stderr.print message
77
+ $stderr.flush
78
+ end
79
+
80
+ def start_progress
81
+ return if log_only
82
+
83
+ $stderr.print ' '
84
+ $stderr.flush
85
+ end
86
+
87
+ def progress_dot
88
+ return if log_only
89
+
90
+ $stderr.print '.'
91
+ $stderr.flush
92
+ end
93
+
94
+ def end_progress
95
+ return if log_only
96
+
97
+ $stderr.puts '' # rubocop:disable Style/StderrPuts
98
+ end
99
+
67
100
  # In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
68
101
  # cases where this simple compression will drop the filesize by half.
69
102
  def compress node
@@ -32,6 +32,8 @@ class FlowEfficiencyScatterplot < ChartBase
32
32
  So be aware that your team may have to change their behaviours if you want this chart to be useful.
33
33
  </div>
34
34
  HTML
35
+ @x_axis_title = 'Total time (days)'
36
+ @y_axis_title = 'Time adding value (days)'
35
37
 
36
38
  init_configuration_block block do
37
39
  grouping_rules do |issue, rule|
@@ -60,7 +62,9 @@ class FlowEfficiencyScatterplot < ChartBase
60
62
  create_dataset(issues: issues, label: rules.label, color: rules.color)
61
63
  end
62
64
 
63
- return "<h1 class='foldable'>#{@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
64
68
 
65
69
  wrap_and_render(binding, __FILE__)
66
70
  end