jirametrics 2.14 → 2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +10 -2
  4. data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
  6. data/lib/jirametrics/aging_work_table.rb +9 -7
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +96 -96
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  11. data/lib/jirametrics/board.rb +32 -8
  12. data/lib/jirametrics/board_config.rb +3 -1
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +2 -2
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +14 -6
  17. data/lib/jirametrics/chart_base.rb +139 -3
  18. data/lib/jirametrics/css_variable.rb +1 -1
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +21 -4
  21. data/lib/jirametrics/cycletime_histogram.rb +15 -101
  22. data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
  23. data/lib/jirametrics/daily_view.rb +42 -31
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  27. data/lib/jirametrics/daily_wip_chart.rb +30 -8
  28. data/lib/jirametrics/data_quality_report.rb +43 -12
  29. data/lib/jirametrics/dependency_chart.rb +6 -3
  30. data/lib/jirametrics/download_config.rb +15 -0
  31. data/lib/jirametrics/downloader.rb +117 -100
  32. data/lib/jirametrics/downloader_for_cloud.rb +287 -0
  33. data/lib/jirametrics/downloader_for_data_center.rb +95 -0
  34. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  35. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  36. data/lib/jirametrics/examples/standard_project.rb +41 -28
  37. data/lib/jirametrics/expedited_chart.rb +3 -1
  38. data/lib/jirametrics/exporter.rb +26 -6
  39. data/lib/jirametrics/file_config.rb +9 -11
  40. data/lib/jirametrics/file_system.rb +59 -3
  41. data/lib/jirametrics/fix_version.rb +13 -0
  42. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  43. data/lib/jirametrics/github_gateway.rb +115 -0
  44. data/lib/jirametrics/groupable_issue_chart.rb +11 -1
  45. data/lib/jirametrics/grouping_rules.rb +26 -4
  46. data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
  47. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
  48. data/lib/jirametrics/html/aging_work_table.erb +5 -0
  49. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  50. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  51. data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
  52. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  53. data/lib/jirametrics/html/expedited_chart.erb +6 -14
  54. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
  55. data/lib/jirametrics/html/index.css +244 -69
  56. data/lib/jirametrics/html/index.erb +9 -35
  57. data/lib/jirametrics/html/index.js +164 -0
  58. data/lib/jirametrics/html/legacy_colors.css +174 -0
  59. data/lib/jirametrics/html/sprint_burndown.erb +17 -15
  60. data/lib/jirametrics/html/throughput_chart.erb +42 -11
  61. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
  62. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
  63. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  64. data/lib/jirametrics/html_generator.rb +32 -0
  65. data/lib/jirametrics/html_report_config.rb +52 -57
  66. data/lib/jirametrics/issue.rb +302 -98
  67. data/lib/jirametrics/issue_printer.rb +97 -0
  68. data/lib/jirametrics/jira_gateway.rb +77 -17
  69. data/lib/jirametrics/mcp_server.rb +531 -0
  70. data/lib/jirametrics/project_config.rb +108 -9
  71. data/lib/jirametrics/pull_request.rb +30 -0
  72. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  73. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  74. data/lib/jirametrics/pull_request_review.rb +13 -0
  75. data/lib/jirametrics/raw_javascript.rb +17 -0
  76. data/lib/jirametrics/settings.json +5 -1
  77. data/lib/jirametrics/sprint.rb +12 -0
  78. data/lib/jirametrics/sprint_burndown.rb +10 -4
  79. data/lib/jirametrics/status.rb +1 -1
  80. data/lib/jirametrics/stitcher.rb +81 -0
  81. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  82. data/lib/jirametrics/throughput_chart.rb +73 -23
  83. data/lib/jirametrics/time_based_histogram.rb +139 -0
  84. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  85. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  86. data/lib/jirametrics.rb +83 -69
  87. metadata +60 -6
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DownloaderForDataCenter < Downloader
4
+ def jira_instance_type
5
+ 'Jira DataCenter'
6
+ end
7
+
8
+ def download_issues board:
9
+ log " Downloading primary issues for board #{board.id}", both: true
10
+ path = File.join(@target_path, "#{file_prefix}_issues/")
11
+ unless Dir.exist?(path)
12
+ log " Creating path #{path}"
13
+ Dir.mkdir(path)
14
+ end
15
+
16
+ filter_id = board_id_to_filter_id[board.id]
17
+ jql = make_jql(filter_id: filter_id)
18
+ jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
19
+
20
+ log " Downloading linked issues for board #{board.id}", both: true
21
+ loop do
22
+ @issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
23
+ break if @issue_keys_pending_download.empty?
24
+
25
+ keys_to_request = @issue_keys_pending_download[0..99]
26
+ @issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
27
+ jql = "key in (#{keys_to_request.join(', ')})"
28
+ jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
29
+ end
30
+ end
31
+
32
+ def jira_search_by_jql jql:, initial_query:, board:, path:
33
+ intercept_jql = @download_config.project_config.settings['intercept_jql']
34
+ jql = intercept_jql.call jql if intercept_jql
35
+
36
+ log " JQL: #{jql}"
37
+ escaped_jql = CGI.escape jql
38
+
39
+ max_results = 100
40
+ start_at = 0
41
+ total = 1
42
+ while start_at < total
43
+ json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
44
+ "?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
45
+
46
+ json['issues'].each do |issue_json|
47
+ issue_json['exporter'] = {
48
+ 'in_initial_query' => initial_query
49
+ }
50
+ identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
51
+ file = "#{issue_json['key']}-#{board.id}.json"
52
+
53
+ @file_system.save_json(json: issue_json, filename: File.join(path, file))
54
+ end
55
+
56
+ total = json['total'].to_i
57
+ max_results = json['maxResults']
58
+
59
+ message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
60
+ log message, both: true
61
+
62
+ start_at += json['issues'].size
63
+ end
64
+ end
65
+
66
+ def make_jql filter_id:, today: nil
67
+ today ||= today_in_project_timezone
68
+ segments = []
69
+ segments << "filter=#{filter_id}"
70
+
71
+ start_date = @download_config.start_date today: today
72
+
73
+ if start_date
74
+ @download_date_range = start_date..today.to_date
75
+
76
+ # For an incremental download, we want to query from the end of the previous one, not from the
77
+ # beginning of the full range.
78
+ @start_date_in_query = metadata['date_end'] || @download_date_range.begin
79
+ log " Incremental download only. Pulling from #{@start_date_in_query}", both: true if metadata['date_end']
80
+
81
+ # Catch-all to pick up anything that's been around since before the range started but hasn't
82
+ # had an update during the range.
83
+ catch_all = '((status changed OR Sprint is not EMPTY) AND statusCategory != Done)'
84
+
85
+ # Pick up any issues that had a status change in the range
86
+ start_date_text = @start_date_in_query.strftime '%Y-%m-%d'
87
+ # find_in_range = %((status changed DURING ("#{start_date_text} 00:00","#{end_date_text} 23:59")))
88
+ find_in_range = %(updated >= "#{start_date_text} 00:00")
89
+
90
+ segments << "(#{find_in_range} OR #{catch_all})"
91
+ end
92
+
93
+ segments.join ' AND '
94
+ end
95
+ end
@@ -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,23 +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, also_write_to_stderr: true
13
13
  file_prefix file_prefix
14
14
 
15
15
  self.anonymize if anonymize
16
- self.settings.merge! settings
17
-
18
- status_category_mappings.each do |status, category|
19
- status_category_mapping status: status, category: category
20
- end
21
-
22
- download do
23
- self.rolling_date_count(rolling_date_count) if rolling_date_count
24
- self.no_earlier_than(no_earlier_than) if no_earlier_than
25
- end
16
+ self.settings.merge! stringify_keys(settings)
26
17
 
27
18
  boards.each_key do |board_id|
28
19
  block = boards[board_id]
@@ -37,17 +28,27 @@ class Exporter
37
28
  end
38
29
  end
39
30
 
31
+ status_category_mappings.each do |status, category|
32
+ status_category_mapping status: status, category: category
33
+ end
34
+
35
+ download do
36
+ self.rolling_date_count(rolling_date_count) if rolling_date_count
37
+ self.no_earlier_than(no_earlier_than) if no_earlier_than
38
+ github_repo *github_repos if github_repos
39
+ end
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,37 +58,39 @@ 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
85
+ wip_by_column_chart do
86
+ show_recommendations
87
+ end
84
88
  aging_work_bar_chart
85
89
  aging_work_table
86
90
  daily_wip_by_age_chart
87
91
  daily_wip_by_blocked_stalled_chart
88
92
  daily_wip_by_parent_chart
89
93
  flow_efficiency_scatterplot if show_experimental_charts
90
- expedited_chart
91
94
  sprint_burndown
92
95
  estimate_accuracy_chart
93
96
  dependency_chart
@@ -95,4 +98,14 @@ class Exporter
95
98
  end
96
99
  end
97
100
  end
101
+
102
+ # Extracted as a separate method so it can be tested independently, without needing to invoke
103
+ # the full standard_project DSL setup.
104
+ def filter_issues issues, ignore_issues
105
+ return unless ignore_issues
106
+
107
+ issues.reject! do |issue|
108
+ ignore_issues.is_a?(Proc) ? ignore_issues.call(issue) : ignore_issues.include?(issue.key)
109
+ end
110
+ end
98
111
  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>Expedited work</h1>There is no expedited work in this time period.'
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
@@ -8,7 +8,13 @@ class Exporter
8
8
 
9
9
  def self.configure &block
10
10
  logfile_name = 'jirametrics.log'
11
- logfile = File.open logfile_name, 'w'
11
+ logfile = File.open(logfile_name, 'w')
12
+ rescue Errno::EACCES
13
+ # FileSystem can't be used here — it hasn't been created yet (it depends on this logfile).
14
+ warn "Error: Cannot write to #{File.expand_path(logfile_name)}. " \
15
+ 'Please ensure the current directory is writable.'
16
+ exit 1
17
+ else
12
18
  file_system = FileSystem.new
13
19
  file_system.logfile = logfile
14
20
  file_system.logfile_name = logfile_name
@@ -40,6 +46,7 @@ class Exporter
40
46
 
41
47
  def download name_filter:
42
48
  @downloading = true
49
+ github_pr_cache = {}
43
50
  each_project_config(name_filter: name_filter) do |project|
44
51
  project.evaluate_next_level
45
52
  next if project.aggregated_project?
@@ -50,33 +57,42 @@ class Exporter
50
57
  end
51
58
 
52
59
  project.download_config.run
53
- downloader = Downloader.new(
60
+ gateway = JiraGateway.new(
61
+ file_system: file_system, jira_config: project.jira_config, settings: project.settings
62
+ )
63
+ downloader = Downloader.create(
54
64
  download_config: project.download_config,
55
65
  file_system: file_system,
56
- jira_gateway: JiraGateway.new(file_system: file_system)
66
+ jira_gateway: gateway,
67
+ github_pr_cache: github_pr_cache
57
68
  )
58
69
  downloader.run
59
70
  end
60
71
  puts "Full output from downloader in #{file_system.logfile_name}"
61
72
  end
62
73
 
63
- def info keys, name_filter:
74
+ def info key, name_filter:
64
75
  selected = []
76
+ file_system.log_only = true
65
77
  each_project_config(name_filter: name_filter) do |project|
66
78
  project.evaluate_next_level
67
79
 
68
80
  project.run load_only: true
69
81
  project.issues.each do |issue|
70
- selected << [project, issue] if keys.include? issue.key
82
+ selected << [project, issue] if key == issue.key
83
+ issue.subtasks.each do |subtask|
84
+ selected << [project, subtask] if key == subtask.key
85
+ end
71
86
  end
72
87
  rescue => e # rubocop:disable Style/RescueStandardError
73
88
  # This happens when we're attempting to load an aggregated project because it hasn't been
74
89
  # properly initialized. Since we don't care about aggregated projects, we just ignore it.
75
90
  raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
76
91
  end
92
+ file_system.log_only = false
77
93
 
78
94
  if selected.empty?
79
- file_system.log "No issues found to match #{keys.collect(&:inspect).join(', ')}"
95
+ file_system.log "No issues found to match #{key.inspect}"
80
96
  else
81
97
  selected.each do |project, issue|
82
98
  file_system.log "\nProject #{project.name}", also_write_to_stderr: true
@@ -85,6 +101,10 @@ class Exporter
85
101
  end
86
102
  end
87
103
 
104
+ def stitch stitch_file
105
+ Stitcher.new(file_system: file_system).run(stitch_file: stitch_file)
106
+ end
107
+
88
108
  def each_project_config name_filter:
89
109
  @project_configs.each do |project|
90
110
  yield project if project.name.nil? || File.fnmatch(name_filter, project.name)
@@ -65,22 +65,20 @@ class FileConfig
65
65
  # most common usecase - the Team Dashboard from FocusedObjective.com. The rule for that one
66
66
  # is that all empty values in the first column should be at the bottom.
67
67
  def sort_output all_lines
68
- all_lines.sort do |a, b|
69
- result = nil
70
- if a[0] == b[0]
71
- result = a[1..] <=> b[1..]
68
+ all_lines.each_with_index.sort do |(a, a_idx), (b, b_idx)|
69
+ result = if a[0] == b[0]
70
+ a[1..] <=> b[1..]
72
71
  elsif a[0].nil?
73
- result = 1
72
+ 1
74
73
  elsif b[0].nil?
75
- result = -1
74
+ -1
76
75
  else
77
- result = a[0] <=> b[0]
76
+ a[0] <=> b[0]
78
77
  end
79
78
 
80
- # This will only happen if one of the objects isn't comparable. Seen in production.
81
- result = -1 if result.nil?
82
- result
83
- end
79
+ # When objects aren't comparable, preserve original order for a stable sort.
80
+ result.nil? || result.zero? ? a_idx <=> b_idx : result
81
+ end.map(&:first)
84
82
  end
85
83
 
86
84
  def columns &block
@@ -3,7 +3,15 @@
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
+
8
+ def initialize
9
+ # In almost all cases, this will be immediately replaced in the Exporter
10
+ # but if we fail before we get that far, this will at least let a useful
11
+ # error show up on the console.
12
+ @logfile = $stdout
13
+ @log_only = false
14
+ end
7
15
 
8
16
  # Effectively the same as File.read except it forces the encoding to UTF-8
9
17
  def load filename, supress_deprecation: false
@@ -31,6 +39,14 @@ class FileSystem
31
39
  File.write(filename, content)
32
40
  end
33
41
 
42
+ def mkdir path
43
+ FileUtils.mkdir_p path
44
+ end
45
+
46
+ def utime file:, time:
47
+ File.utime time, time, file
48
+ end
49
+
34
50
  def warning message, more: nil
35
51
  log "Warning: #{message}", more: more, also_write_to_stderr: true
36
52
  end
@@ -44,11 +60,43 @@ class FileSystem
44
60
 
45
61
  logfile.puts message
46
62
  logfile.puts more if more
47
- 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
48
68
 
49
69
  $stderr.puts message # rubocop:disable Style/StderrPuts
50
70
  end
51
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
+
52
100
  # In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
53
101
  # cases where this simple compression will drop the filesize by half.
54
102
  def compress node
@@ -66,7 +114,15 @@ class FileSystem
66
114
  end
67
115
 
68
116
  def file_exist? filename
69
- File.exist? filename
117
+ File.exist?(filename) && File.file?(filename)
118
+ end
119
+
120
+ def dir_exist? path
121
+ File.exist?(path) && File.directory?(path)
122
+ end
123
+
124
+ def unlink filename
125
+ File.unlink filename
70
126
  end
71
127
 
72
128
  def deprecated message:, date:, depth: 2
@@ -11,11 +11,24 @@ class FixVersion
11
11
  @raw['name']
12
12
  end
13
13
 
14
+ def description
15
+ @raw['description']
16
+ end
17
+
14
18
  def id
15
19
  @raw['id'].to_i
16
20
  end
17
21
 
22
+ def release_date
23
+ text = @raw['releaseDate']
24
+ text.nil? ? nil : Date.parse(text)
25
+ end
26
+
18
27
  def released?
19
28
  @raw['released']
20
29
  end
30
+
31
+ def archived?
32
+ @raw['archived']
33
+ end
21
34
  end
@@ -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>#{@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