jirametrics 2.22 → 2.24

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 (60) 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 +15 -1
  4. data/lib/jirametrics/aging_work_table.rb +1 -1
  5. data/lib/jirametrics/anonymizer.rb +74 -1
  6. data/lib/jirametrics/atlassian_document_format.rb +104 -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/change_item.rb +4 -3
  11. data/lib/jirametrics/chart_base.rb +87 -1
  12. data/lib/jirametrics/css_variable.rb +1 -1
  13. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +1 -2
  14. data/lib/jirametrics/cycletime_histogram.rb +15 -103
  15. data/lib/jirametrics/cycletime_scatterplot.rb +8 -97
  16. data/lib/jirametrics/daily_view.rb +32 -9
  17. data/lib/jirametrics/daily_wip_chart.rb +27 -7
  18. data/lib/jirametrics/data_quality_report.rb +31 -7
  19. data/lib/jirametrics/download_config.rb +15 -0
  20. data/lib/jirametrics/downloader.rb +76 -5
  21. data/lib/jirametrics/downloader_for_cloud.rb +39 -0
  22. data/lib/jirametrics/downloader_for_data_center.rb +2 -1
  23. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  24. data/lib/jirametrics/examples/aggregated_project.rb +1 -1
  25. data/lib/jirametrics/examples/standard_project.rb +20 -9
  26. data/lib/jirametrics/expedited_chart.rb +2 -0
  27. data/lib/jirametrics/exporter.rb +3 -1
  28. data/lib/jirametrics/file_system.rb +4 -0
  29. data/lib/jirametrics/flow_efficiency_scatterplot.rb +2 -0
  30. data/lib/jirametrics/github_gateway.rb +106 -0
  31. data/lib/jirametrics/groupable_issue_chart.rb +2 -0
  32. data/lib/jirametrics/grouping_rules.rb +21 -3
  33. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
  34. data/lib/jirametrics/html/aging_work_table.erb +3 -0
  35. data/lib/jirametrics/html/daily_wip_chart.erb +5 -4
  36. data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
  37. data/lib/jirametrics/html/expedited_chart.erb +3 -13
  38. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
  39. data/lib/jirametrics/html/index.css +114 -0
  40. data/lib/jirametrics/html/index.erb +5 -0
  41. data/lib/jirametrics/html/index.js +52 -2
  42. data/lib/jirametrics/html/sprint_burndown.erb +7 -13
  43. data/lib/jirametrics/html/throughput_chart.erb +5 -8
  44. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +57 -59
  45. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +3 -4
  46. data/lib/jirametrics/html_report_config.rb +2 -0
  47. data/lib/jirametrics/issue.rb +84 -95
  48. data/lib/jirametrics/issue_printer.rb +97 -0
  49. data/lib/jirametrics/jira_gateway.rb +6 -3
  50. data/lib/jirametrics/project_config.rb +66 -6
  51. data/lib/jirametrics/pull_request.rb +30 -0
  52. data/lib/jirametrics/pull_request_review.rb +13 -0
  53. data/lib/jirametrics/raw_javascript.rb +4 -0
  54. data/lib/jirametrics/settings.json +3 -1
  55. data/lib/jirametrics/sprint_burndown.rb +2 -0
  56. data/lib/jirametrics/stitcher.rb +2 -1
  57. data/lib/jirametrics/throughput_chart.rb +7 -1
  58. data/lib/jirametrics/time_based_histogram.rb +139 -0
  59. data/lib/jirametrics/time_based_scatterplot.rb +100 -0
  60. metadata +12 -5
@@ -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
@@ -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 = []
@@ -77,6 +79,7 @@ class Downloader
77
79
  download_users
78
80
 
79
81
  save_metadata
82
+ download_github_prs if @download_config.github_repos.any?
80
83
  end
81
84
 
82
85
  def log text, both: false
@@ -165,11 +168,28 @@ class Downloader
165
168
  # actually look at the returned json.
166
169
  @board_id_to_filter_id[board_id] = json['filter']['id'].to_i
167
170
 
171
+ if json['type'] == 'simple'
172
+ features_json = download_features board_id: board_id
173
+ if BoardFeature.from_raw(features_json).any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
174
+ download_sprints board_id: board_id
175
+ end
176
+ end
168
177
  download_sprints board_id: board_id if json['type'] == 'scrum'
169
178
  # TODO: Should be passing actual statuses, not empty list
170
179
  Board.new raw: json, possible_statuses: StatusCollection.new
171
180
  end
172
181
 
182
+ def download_features board_id:
183
+ log " Downloading features for board #{board_id}", both: true
184
+ json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/features"
185
+
186
+ @file_system.save_json(
187
+ json: json,
188
+ filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_features.json")
189
+ )
190
+ json
191
+ end
192
+
173
193
  def download_sprints board_id:
174
194
  log " Downloading sprints for board #{board_id}", both: true
175
195
  max_results = 100
@@ -211,19 +231,36 @@ class Downloader
211
231
  value = Date.parse(value) if value.is_a?(String) && value =~ /^\d{4}-\d{2}-\d{2}$/
212
232
  @metadata[key] = value
213
233
  end
234
+
235
+ # If rolling_date_count has changed, we may be missing data outside the previous range,
236
+ # so force a full re-download.
237
+ if @metadata['rolling_date_count'] != @download_config.rolling_date_count
238
+ log ' rolling_date_count has changed. Forcing a full download.', both: true
239
+ @cached_data_format_is_current = false
240
+ @metadata = {}
241
+ end
214
242
  end
215
243
 
216
244
  # Even if this is the old format, we want to obey this one tag
217
245
  @metadata['no-download'] = hash['no-download'] if hash['no-download']
218
246
  end
219
247
 
248
+ def timezone_offset
249
+ @download_config.project_config.exporter.timezone_offset
250
+ end
251
+
252
+ def today_in_project_timezone
253
+ Time.now.getlocal(timezone_offset).to_date
254
+ end
255
+
220
256
  def save_metadata
221
257
  @metadata['version'] = CURRENT_METADATA_VERSION
258
+ @metadata['rolling_date_count'] = @download_config.rolling_date_count
222
259
  @metadata['date_start_from_last_query'] = @start_date_in_query if @start_date_in_query
223
260
 
224
261
  if @download_date_range.nil?
225
262
  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
263
+ today = today_in_project_timezone
227
264
  @download_date_range = (today - 7)..today
228
265
  end
229
266
 
@@ -258,7 +295,8 @@ class Downloader
258
295
  end
259
296
  end
260
297
 
261
- def make_jql filter_id:, today: Date.today
298
+ def make_jql filter_id:, today: nil
299
+ today ||= today_in_project_timezone
262
300
  segments = []
263
301
  segments << "filter=#{filter_id}"
264
302
 
@@ -283,6 +321,39 @@ class Downloader
283
321
  segments.join ' AND '
284
322
  end
285
323
 
324
+ def download_github_prs
325
+ project_keys = extract_project_keys_from_downloaded_issues
326
+ if project_keys.empty?
327
+ log ' No project keys found in downloaded issues, skipping GitHub PR download', both: true
328
+ return
329
+ end
330
+
331
+ prs = @download_config.github_repos.flat_map do |repo|
332
+ GithubGateway.new(
333
+ repo: repo,
334
+ project_keys: project_keys,
335
+ file_system: @file_system,
336
+ raw_pr_cache: @github_pr_cache
337
+ ).fetch_pull_requests(since: @download_date_range&.begin)
338
+ end
339
+
340
+ @file_system.save_json(
341
+ json: prs.map(&:raw),
342
+ filename: File.join(@target_path, "#{file_prefix}_github_prs.json")
343
+ )
344
+ end
345
+
346
+ def extract_project_keys_from_downloaded_issues
347
+ path = File.join(@target_path, "#{file_prefix}_issues")
348
+ return [] unless @file_system.dir_exist?(path)
349
+
350
+ keys = []
351
+ @file_system.foreach(path) do |filename|
352
+ keys << filename.split('-').first if filename.match?(/^[A-Z][A-Z_0-9]+-\d+-\d+\.json$/)
353
+ end
354
+ keys.uniq
355
+ end
356
+
286
357
  def file_prefix
287
358
  @download_config.project_config.get_file_prefix
288
359
  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
@@ -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
@@ -12,7 +12,7 @@ class Exporter
12
12
  project name: name do
13
13
  puts 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
12
  puts 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
 
@@ -72,10 +73,11 @@ class Exporter
72
73
  header_text nil
73
74
  description_text '<h2>Number of items completed, grouped by completion status and resolution</h2>'
74
75
  grouping_rules do |issue, rules|
75
- if issue.resolution
76
- rules.label = "#{issue.status.name}:#{issue.resolution}"
76
+ status, resolution = issue.status_resolution_at_done
77
+ if resolution
78
+ rules.label = "#{status.name}:#{resolution}"
77
79
  else
78
- rules.label = issue.status.name
80
+ rules.label = status.name
79
81
  end
80
82
  end
81
83
  end
@@ -87,7 +89,6 @@ class Exporter
87
89
  daily_wip_by_blocked_stalled_chart
88
90
  daily_wip_by_parent_chart
89
91
  flow_efficiency_scatterplot if show_experimental_charts
90
- expedited_chart
91
92
  sprint_burndown
92
93
  estimate_accuracy_chart
93
94
  dependency_chart
@@ -95,4 +96,14 @@ class Exporter
95
96
  end
96
97
  end
97
98
  end
99
+
100
+ # Extracted as a separate method so it can be tested independently, without needing to invoke
101
+ # the full standard_project DSL setup.
102
+ def filter_issues issues, ignore_issues
103
+ return unless ignore_issues
104
+
105
+ issues.reject! do |issue|
106
+ ignore_issues.is_a?(Proc) ? ignore_issues.call(issue) : ignore_issues.include?(issue.key)
107
+ end
108
+ end
98
109
  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
@@ -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
@@ -61,6 +61,10 @@ class FileSystem
61
61
  logfile.puts more if more
62
62
  return unless also_write_to_stderr
63
63
 
64
+ # Obscure edge-case where we're trying to log something before logging is even
65
+ # set up. Quick escape here so that we don't dump the error twice.
66
+ return if logfile == $stdout
67
+
64
68
  $stderr.puts message # rubocop:disable Style/StderrPuts
65
69
  end
66
70
 
@@ -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|
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'json'
5
+
6
+ class GithubGateway
7
+ attr_reader :repo
8
+
9
+ def initialize repo:, project_keys:, file_system:, raw_pr_cache: {}
10
+ @repo = repo
11
+ @project_keys = project_keys
12
+ @file_system = file_system
13
+ @raw_pr_cache = raw_pr_cache
14
+ @issue_key_pattern = build_issue_key_pattern
15
+ end
16
+
17
+ def fetch_pull_requests since: nil
18
+ raw_prs = @raw_pr_cache[[@repo, since]] ||= fetch_raw_pull_requests(since: since)
19
+ raw_prs.filter_map { |pr| build_pr_data(pr) }
20
+ end
21
+
22
+ def fetch_raw_pull_requests since: nil
23
+ # Note: 'commits' is intentionally excluded — including it triggers GitHub's GraphQL node
24
+ # limit (authors sub-connection × PRs × commits exceeds 500,000 nodes). Branch name,
25
+ # title, and body are sufficient for issue key extraction in the vast majority of cases.
26
+ json_fields = %w[number title body headRefName createdAt closedAt mergedAt
27
+ url state reviews additions deletions changedFiles].join(',')
28
+ args = ['pr', 'list', '--state', 'all', '--limit', '5000', '--json', json_fields]
29
+ args += ['--repo', @repo]
30
+ args += ['--search', "updated:>=#{since}"] if since
31
+
32
+ @file_system.log " Downloading pull requests from #{@repo}", also_write_to_stderr: true
33
+ run_command(args)
34
+ end
35
+
36
+ def build_pr_data raw_pr
37
+ issue_keys = extract_issue_keys(raw_pr)
38
+ return nil if issue_keys.empty?
39
+
40
+ PullRequest.new(raw: {
41
+ 'number' => raw_pr['number'],
42
+ 'repo' => @repo,
43
+ 'url' => raw_pr['url'],
44
+ 'title' => raw_pr['title'],
45
+ 'branch' => raw_pr['headRefName'],
46
+ 'opened_at' => raw_pr['createdAt'],
47
+ 'closed_at' => raw_pr['closedAt'],
48
+ 'merged_at' => raw_pr['mergedAt'],
49
+ 'state' => raw_pr['state'],
50
+ 'issue_keys' => issue_keys,
51
+ 'reviews' => extract_reviews(raw_pr['reviews'] || []),
52
+ 'additions' => raw_pr['additions'],
53
+ 'deletions' => raw_pr['deletions'],
54
+ 'changed_files' => raw_pr['changedFiles']
55
+ })
56
+ end
57
+
58
+ def extract_issue_keys raw_pr
59
+ return [] if @issue_key_pattern.nil?
60
+
61
+ sources = [
62
+ raw_pr['headRefName'],
63
+ raw_pr['title'],
64
+ raw_pr['body']
65
+ ]
66
+
67
+ sources.compact
68
+ .flat_map { |s| s.scan(@issue_key_pattern) }
69
+ .uniq
70
+ end
71
+
72
+ def extract_reviews raw_reviews
73
+ raw_reviews
74
+ .select { |r| %w[APPROVED CHANGES_REQUESTED].include?(r['state']) }
75
+ .map do |r|
76
+ {
77
+ 'author' => r.dig('author', 'login'),
78
+ 'submitted_at' => r['submittedAt'],
79
+ 'state' => r['state']
80
+ }
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def build_issue_key_pattern
87
+ return nil if @project_keys.empty?
88
+
89
+ keys_pattern = @project_keys.map { |k| Regexp.escape(k) }.join('|')
90
+ Regexp.new("\\b(?:#{keys_pattern})-\\d+\\b")
91
+ end
92
+
93
+ def run_command args
94
+ stdout, stderr, status = Open3.capture3('gh', *args)
95
+
96
+ # This extra check seems to only matter on Windows. On the mac, auth failures don't pass status.success?
97
+ if stderr.include?('SAML enforcement')
98
+ raise "GitHub CLI is not authorized to access #{@repo}. " \
99
+ "Run: gh auth refresh -h github.com -s read:org"
100
+ end
101
+
102
+ raise "GitHub CLI command failed for #{@repo}: #{stderr}" unless status.success?
103
+
104
+ JSON.parse(stdout)
105
+ end
106
+ end
@@ -16,6 +16,7 @@ module GroupableIssueChart
16
16
  def group_issues completed_issues
17
17
  result = {}
18
18
  ignored_issues = []
19
+ @issue_hints = {}
19
20
  completed_issues.each do |issue|
20
21
  rules = GroupingRules.new
21
22
  @group_by_block.call(issue, rules)
@@ -24,6 +25,7 @@ module GroupableIssueChart
24
25
  next
25
26
  end
26
27
 
28
+ @issue_hints[issue] = rules.issue_hint
27
29
  (result[rules] ||= []) << issue
28
30
  end
29
31
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class GroupingRules < Rules
4
- attr_accessor :label
4
+ attr_accessor :label, :issue_hint
5
5
  attr_reader :color
6
6
 
7
7
  def eql? other
@@ -13,7 +13,25 @@ class GroupingRules < Rules
13
13
  end
14
14
 
15
15
  def color= color
16
- color = CssVariable[color] unless color.is_a?(CssVariable)
17
- @color = color
16
+ if color.is_a?(Array)
17
+ raise ArgumentError, 'Color pair must have exactly two elements: [light_color, dark_color]' unless color.size == 2
18
+ raise ArgumentError, 'Color pair elements must be strings' unless color.all?(String)
19
+
20
+ if color.any? { |c| c.start_with?('--') }
21
+ raise ArgumentError,
22
+ 'CSS variable references are not supported as color pair elements; use a literal color value instead'
23
+ end
24
+
25
+ light, dark = color
26
+ @color = RawJavascript.new(
27
+ "(document.documentElement.dataset.theme === 'dark' || " \
28
+ '(!document.documentElement.dataset.theme && ' \
29
+ "window.matchMedia('(prefers-color-scheme: dark)').matches)) " \
30
+ "? #{dark.to_json} : #{light.to_json}"
31
+ )
32
+ else
33
+ color = CssVariable[color] unless color.is_a?(CssVariable)
34
+ @color = color
35
+ end
18
36
  end
19
37
  end
@@ -16,11 +16,9 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
16
16
  x: {
17
17
  type: 'time',
18
18
  min: '<%= @date_range.begin.to_s %>',
19
- max: '<%= (@date_range.end ).to_s %>',
19
+ max: '<%= (@date_range.end + 1).to_s %>',
20
20
  stacked: false,
21
- title: {
22
- display: false
23
- },
21
+ <%= render_axis_title :x %>
24
22
  grid: {
25
23
  color: <%= CssVariable['--grid-line-color'].to_json %>
26
24
  },
@@ -31,6 +29,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
31
29
  ticks: {
32
30
  display: true
33
31
  },
32
+ <%= render_axis_title :y %>
34
33
  grid: {
35
34
  color: <%= CssVariable['--grid-line-color'].to_json %>
36
35
  },
@@ -41,6 +41,9 @@
41
41
  <%= link_to_issue parent, style: "color: #{color}" %>
42
42
  </span>
43
43
  <i><%= parent.summary.strip.inspect %></i>
44
+ <% if parent == issue && (text = not_visible_text(issue)) %>
45
+ <br /><%= text %>
46
+ <% end %>
44
47
  </div>
45
48
  <% end %>
46
49
  </td>
@@ -21,7 +21,10 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
21
21
  time: {
22
22
  unit: 'day'
23
23
  },
24
+ min: "<%= date_range.begin.to_s %>",
25
+ max: "<%= (date_range.end + 1).to_s %>",
24
26
  stacked: true,
27
+ <%= render_axis_title :x %>
25
28
  grid: {
26
29
  color: <%= CssVariable['--grid-line-color'].to_json %>
27
30
  },
@@ -32,10 +35,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
32
35
  display: true,
33
36
  labelString: 'WIP'
34
37
  },
35
- title: {
36
- display: true,
37
- text: 'Count of items'
38
- },
38
+ <%= render_axis_title :y %>
39
39
  grid: {
40
40
  color: <%= CssVariable['--grid-line-color'].to_json %>
41
41
  },
@@ -52,6 +52,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
52
52
  annotation: {
53
53
  annotations: {
54
54
  <%= working_days_annotation %>
55
+ <%= date_annotation %>
55
56
  }
56
57
  },
57
58
  legend: {