jirametrics 2.31 → 3.1

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +42 -29
  3. data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
  5. data/lib/jirametrics/aging_work_table.rb +36 -33
  6. data/lib/jirametrics/anonymizer.rb +31 -86
  7. data/lib/jirametrics/atlassian_document_format.rb +11 -4
  8. data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
  9. data/lib/jirametrics/blocked_stalled_change.rb +5 -1
  10. data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
  11. data/lib/jirametrics/board.rb +23 -9
  12. data/lib/jirametrics/board_config.rb +0 -7
  13. data/lib/jirametrics/board_movement_calculator.rb +24 -21
  14. data/lib/jirametrics/cfd_data_builder.rb +46 -44
  15. data/lib/jirametrics/change_item.rb +19 -7
  16. data/lib/jirametrics/chart_base.rb +51 -65
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +60 -47
  18. data/lib/jirametrics/cycle_time_config.rb +35 -46
  19. data/lib/jirametrics/cycletime_histogram.rb +7 -0
  20. data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
  21. data/lib/jirametrics/daily_view.rb +83 -61
  22. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
  23. data/lib/jirametrics/daily_wip_chart.rb +72 -45
  24. data/lib/jirametrics/data_quality_report.rb +70 -66
  25. data/lib/jirametrics/dependency_chart.rb +40 -31
  26. data/lib/jirametrics/download_config.rb +0 -3
  27. data/lib/jirametrics/downloader.rb +39 -14
  28. data/lib/jirametrics/downloader_for_cloud.rb +110 -74
  29. data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
  30. data/lib/jirametrics/examples/standard_project.rb +2 -2
  31. data/lib/jirametrics/expedited_chart.rb +47 -37
  32. data/lib/jirametrics/exporter.rb +203 -12
  33. data/lib/jirametrics/file_config.rb +19 -12
  34. data/lib/jirametrics/file_system.rb +16 -10
  35. data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
  36. data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
  37. data/lib/jirametrics/github_gateway.rb +52 -12
  38. data/lib/jirametrics/groupable_issue_chart.rb +3 -0
  39. data/lib/jirametrics/html/cumulative_flow_diagram.erb +3 -3
  40. data/lib/jirametrics/html/index.css +10 -4
  41. data/lib/jirametrics/html_report_config.rb +9 -29
  42. data/lib/jirametrics/issue.rb +293 -411
  43. data/lib/jirametrics/issue_collection.rb +1 -0
  44. data/lib/jirametrics/issue_printer.rb +60 -23
  45. data/lib/jirametrics/jira_gateway.rb +56 -18
  46. data/lib/jirametrics/mcp_server.rb +226 -210
  47. data/lib/jirametrics/project_config.rb +206 -133
  48. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
  49. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
  50. data/lib/jirametrics/rules.rb +1 -0
  51. data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
  52. data/lib/jirametrics/sprint_burndown.rb +142 -242
  53. data/lib/jirametrics/sprint_count_measure.rb +42 -0
  54. data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
  55. data/lib/jirametrics/sprint_points_measure.rb +62 -0
  56. data/lib/jirametrics/sprint_summary_stats.rb +16 -0
  57. data/lib/jirametrics/status_collection.rb +29 -5
  58. data/lib/jirametrics/stitcher.rb +21 -16
  59. data/lib/jirametrics/throughput_chart.rb +38 -24
  60. data/lib/jirametrics/time_based_chart.rb +65 -0
  61. data/lib/jirametrics/time_based_histogram.rb +30 -23
  62. data/lib/jirametrics/time_based_scatterplot.rb +11 -2
  63. data/lib/jirametrics/trend_line_calculator.rb +2 -2
  64. data/lib/jirametrics/wip_by_column_chart.rb +75 -34
  65. data/lib/jirametrics.rb +35 -4
  66. metadata +13 -20
@@ -6,11 +6,22 @@ class Exporter
6
6
  attr_reader :project_configs
7
7
  attr_accessor :file_system
8
8
 
9
+ def self.logfile_name
10
+ @logfile_name ||= 'jirametrics.log'
11
+ end
12
+
13
+ # The MCP server sets this to a separate file so starting the server doesn't clobber the
14
+ # export/debug log (jirametrics.log) that someone may be mid-debug on.
15
+ class << self
16
+ attr_writer :logfile_name
17
+ end
18
+
9
19
  def self.configure &block
10
- logfile_name = 'jirametrics.log'
11
- logfile = File.open(logfile_name, 'w')
20
+ # No block form: FileSystem holds this descriptor open for the whole run and writes to it as we
21
+ # go, so it must outlive this method.
22
+ logfile = File.open(logfile_name, 'w') # rubocop:disable Style/FileOpen
12
23
  rescue Errno::EACCES
13
- # FileSystem can't be used here — it hasn't been created yet (it depends on this logfile).
24
+ # FileSystem can't be used here - it hasn't been created yet (it depends on this logfile).
14
25
  warn "Error: Cannot write to #{File.expand_path(logfile_name)}. " \
15
26
  'Please ensure the current directory is writable.'
16
27
  exit 1
@@ -71,6 +82,179 @@ class Exporter
71
82
  puts "Full output from downloader in #{file_system.logfile_name}"
72
83
  end
73
84
 
85
+ def verify_jira_connections name_filter:
86
+ results = []
87
+ begin
88
+ # Probe under log_only so the gateway's raw curl chatter (and its error dumps) stay in the
89
+ # logfile rather than cluttering the console; we surface a clean summary line below.
90
+ file_system.log_only = true
91
+ jira_connections_to_verify(name_filter: name_filter).each do |config, settings|
92
+ gateway = JiraGateway.new(file_system: file_system, jira_config: config, settings: settings)
93
+ results << gateway.verify_connection
94
+ end
95
+ ensure
96
+ file_system.log_only = false
97
+ end
98
+
99
+ if results.empty?
100
+ file_system.error 'No Jira connection found in the configuration to verify'
101
+ return results
102
+ end
103
+ results.each { |result| file_system.log result.message, also_write_to_stderr: true }
104
+ results
105
+ end
106
+
107
+ # The [jira_config, settings] pairs to verify, deduplicated by URL. We do NOT evaluate_next_level:
108
+ # running a project block (e.g. standard_project) forces an issue-data load that doesn't exist
109
+ # before the first download, and verify must work pre-download. When no project supplied a
110
+ # connection we fall back to the top-level jira_config, so verify can run as a first setup step on
111
+ # just the credentials file. (The fallback has no per-project settings, so a config-block-only
112
+ # setting like ignore_ssl_errors isn't applied. That only affects self-signed
113
+ # Data Center instances.)
114
+ def jira_connections_to_verify name_filter:
115
+ connections = []
116
+ seen_urls = []
117
+ each_project_config(name_filter: name_filter) do |project|
118
+ url = project.jira_config && project.jira_config['url']
119
+ next if url.nil? || seen_urls.include?(url)
120
+
121
+ seen_urls << url
122
+ connections << [project.jira_config, project.settings]
123
+ end
124
+ connections << [jira_config, {}] if connections.empty? && jira_config
125
+ connections
126
+ end
127
+
128
+ def boards board_id:, name_filter: nil
129
+ gateway = JiraGateway.new(file_system: file_system, jira_config: jira_config, settings: {})
130
+ # Keep the gateway's raw curl chatter in the logfile; list_boards/describe_board switch this off
131
+ # right before they print their own clean output, and the rescue turns a failed Jira call into a
132
+ # one-line message with next steps instead of a stack trace.
133
+ file_system.log_only = true
134
+ if board_id.nil?
135
+ list_boards gateway, name_filter
136
+ else
137
+ describe_board gateway, board_id
138
+ end
139
+ true
140
+ rescue StandardError
141
+ file_system.log_only = false
142
+ file_system.error boards_error_message(board_id)
143
+ false
144
+ ensure
145
+ file_system.log_only = false
146
+ end
147
+
148
+ def boards_error_message board_id
149
+ if board_id
150
+ "Couldn't read board #{board_id} from Jira. Check the id (run `jirametrics boards` to list them) " \
151
+ "and your credentials (`jirametrics verify`). Details in #{file_system.logfile_name}."
152
+ else
153
+ "Couldn't list boards from Jira. Check your credentials with `jirametrics verify`. " \
154
+ "Details in #{file_system.logfile_name}."
155
+ end
156
+ end
157
+
158
+ def list_boards gateway, name_filter
159
+ boards = fetch_all_boards gateway
160
+ boards.select! { |board| File.fnmatch(name_filter, board['name'].to_s, File::FNM_CASEFOLD) } if name_filter
161
+ boards.sort_by! { |board| board['name'].to_s.strip.downcase }
162
+ file_system.log_only = false # gateway calls done; turn logging back on to print results
163
+
164
+ if boards.empty?
165
+ file_system.log(
166
+ name_filter ? "No boards match #{name_filter.inspect}." : 'No boards found for this Jira connection.',
167
+ also_write_to_stderr: true
168
+ )
169
+ return
170
+ end
171
+
172
+ lines = ['Boards you can access:', '']
173
+ boards.each { |board| lines << " #{board['id']}: #{board['name'].inspect} (#{board['type']})" }
174
+ lines << ''
175
+ lines << "Run `jirametrics boards <id>` to see a board's columns and choose cycletime points."
176
+ file_system.log lines.join("\n"), also_write_to_stderr: true
177
+ end
178
+
179
+ def fetch_all_boards gateway
180
+ boards = []
181
+ start_at = 0
182
+ loop do
183
+ json = gateway.call_url relative_url: "/rest/agile/1.0/board?startAt=#{start_at}&maxResults=50"
184
+ values = json['values'] || []
185
+ boards.concat values
186
+ break if json['isLast'] || values.empty?
187
+
188
+ start_at += values.length
189
+ end
190
+ boards
191
+ end
192
+
193
+ def describe_board gateway, board_id
194
+ statuses = StatusCollection.new
195
+ gateway.call_url(relative_url: '/rest/api/2/status').each do |snippet|
196
+ statuses << Status.from_raw(snippet)
197
+ end
198
+ raw = gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
199
+ features = board_features(gateway, board_id, raw)
200
+ file_system.log_only = false # gateway calls done; turn logging back on to print results
201
+ board = Board.new raw: raw, possible_statuses: statuses, features: features
202
+ file_system.log format_board(board, board_id), also_write_to_stderr: true
203
+ end
204
+
205
+ # Only a team-managed ("simple") board needs the features lookup to tell sprints from kanban; for
206
+ # classic scrum/kanban the board type alone settles it, so we skip the extra request.
207
+ def board_features gateway, board_id, raw
208
+ return [] unless raw['type'] == 'simple'
209
+
210
+ BoardFeature.from_raw gateway.call_url(relative_url: "/rest/agile/1.0/board/#{board_id}/features")
211
+ end
212
+
213
+ def format_board board, board_id
214
+ lines = ["Board #{board_id}: #{board.name.inspect} (#{board_kind_label board})", '']
215
+
216
+ backlog = board.backlog_statuses
217
+ unless backlog.empty?
218
+ lines << 'Not shown on the board (treated as not started):'
219
+ backlog.each { |status| lines << " - #{format_status status}" }
220
+ lines << ''
221
+ end
222
+
223
+ lines << 'Columns, left to right, with the statuses in each (shown as "name":id with its category):'
224
+ lines << ''
225
+ board.visible_columns.each do |column|
226
+ lines << " #{column.name}"
227
+ column.status_ids.each do |id|
228
+ status = board.possible_statuses.find_by_id id
229
+ lines << " - #{status ? format_status(status) : "unknown status id #{id}"}"
230
+ end
231
+ end
232
+
233
+ lines << ''
234
+ lines << 'To set cycle time, choose the column where work is "started" and where it is "finished":'
235
+ lines << " start_at first_time_in_or_right_of_column '<started column>'"
236
+ lines << " stop_at first_time_in_or_right_of_column '<finished column>'"
237
+ if board.scrum?
238
+ lines << ''
239
+ lines << 'This board uses sprints. If no column cleanly marks when work starts, you can start the'
240
+ lines << 'clock when an item is first added to a sprint instead:'
241
+ lines << ' start_at first_time_added_to_active_sprint'
242
+ end
243
+ lines.join "\n"
244
+ end
245
+
246
+ # A team-managed ("simple") board can be scrum- or kanban-flavoured depending on its sprints
247
+ # feature; the bare "simple" from Jira doesn't say which, so spell it out.
248
+ def board_kind_label board
249
+ return board.board_type unless board.board_type == 'simple'
250
+
251
+ board.scrum? ? 'team-managed, uses sprints' : 'team-managed, no sprints'
252
+ end
253
+
254
+ def format_status status
255
+ "#{status.name.inspect}:#{status.id} (#{status.category.name})"
256
+ end
257
+
74
258
  def info key, name_filter:
75
259
  selected = []
76
260
  file_system.log_only = true
@@ -78,12 +262,7 @@ class Exporter
78
262
  project.evaluate_next_level
79
263
 
80
264
  project.run load_only: true
81
- project.issues.each do |issue|
82
- selected << [project, issue] if key == issue.key
83
- issue.subtasks.each do |subtask|
84
- selected << [project, subtask] if key == subtask.key
85
- end
86
- end
265
+ selected.concat matching_issues_in(project, key)
87
266
  rescue => e # rubocop:disable Style/RescueStandardError
88
267
  # This happens when we're attempting to load an aggregated project because it hasn't been
89
268
  # properly initialized. Since we don't care about aggregated projects, we just ignore it.
@@ -101,6 +280,17 @@ class Exporter
101
280
  end
102
281
  end
103
282
 
283
+ def matching_issues_in project, key
284
+ matches = []
285
+ project.issues.each do |issue|
286
+ matches << [project, issue] if key == issue.key
287
+ issue.subtasks.each do |subtask|
288
+ matches << [project, subtask] if key == subtask.key
289
+ end
290
+ end
291
+ matches
292
+ end
293
+
104
294
  def stitch stitch_file
105
295
  Stitcher.new(file_system: file_system).run(stitch_file: stitch_file)
106
296
  end
@@ -139,7 +329,8 @@ class Exporter
139
329
  @jira_config = file_system.load_json(filename, fail_on_error: false)
140
330
  raise "Unable to load Jira configuration file and cannot continue: #{filename.inspect}" if @jira_config.nil?
141
331
 
142
- @jira_config['url'] = $1 if @jira_config['url'] =~ /^(.+)\/+$/
332
+ match = %r{^(?<base_url>.+)/+$}.match(@jira_config['url'])
333
+ @jira_config['url'] = match[:base_url] if match
143
334
  end
144
335
  @jira_config
145
336
  end
@@ -153,8 +344,8 @@ class Exporter
153
344
  unless args.empty?
154
345
  dates = []
155
346
  args.each do |arg|
156
- if arg =~ /^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/
157
- Date.parse($1).upto(Date.parse($2)).each { |date| dates << date }
347
+ if /^(?<from>\d{4}-\d{2}-\d{2})\.\.(?<to>\d{4}-\d{2}-\d{2})$/ =~ arg
348
+ Date.parse(from).upto(Date.parse(to)).each { |date| dates << date }
158
349
  else
159
350
  dates << Date.parse(arg)
160
351
  end
@@ -5,11 +5,12 @@ require 'csv'
5
5
  class FileConfig
6
6
  attr_reader :project_config, :issues
7
7
 
8
- def initialize project_config:, block:, today: Date.today
8
+ def initialize project_config:, block:, today: Date.today, issues: nil
9
9
  @project_config = project_config
10
10
  @block = block
11
11
  @columns = nil
12
12
  @today = today
13
+ @issues = issues
13
14
  end
14
15
 
15
16
  def run
@@ -65,22 +66,28 @@ class FileConfig
65
66
  # most common usecase - the Team Dashboard from FocusedObjective.com. The rule for that one
66
67
  # is that all empty values in the first column should be at the bottom.
67
68
  def sort_output all_lines
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..]
71
- elsif a[0].nil?
72
- 1
73
- elsif b[0].nil?
74
- -1
75
- else
76
- a[0] <=> b[0]
77
- end
69
+ all_lines.each_with_index.sort do |(left, left_idx), (right, right_idx)|
70
+ result = compare_rows left, right
78
71
 
79
72
  # When objects aren't comparable, preserve original order for a stable sort.
80
- result.nil? || result.zero? ? a_idx <=> b_idx : result
73
+ result.nil? || result.zero? ? left_idx <=> right_idx : result
81
74
  end.map(&:first)
82
75
  end
83
76
 
77
+ # Compare two rows by their first column, falling back to the remaining columns on a tie, and
78
+ # sorting nil first columns to the bottom.
79
+ def compare_rows left, right
80
+ if left[0] == right[0]
81
+ left[1..] <=> right[1..]
82
+ elsif left[0].nil?
83
+ 1
84
+ elsif right[0].nil?
85
+ -1
86
+ else
87
+ left[0] <=> right[0]
88
+ end
89
+ end
90
+
84
91
  def columns &block
85
92
  assert_only_one_filetype_config_set
86
93
  @columns = ColumnsConfig.new file_config: self, block: block
@@ -2,6 +2,10 @@
2
2
 
3
3
  require 'json'
4
4
 
5
+ # This is the stderr display/logging layer: it writes progress and log output straight to $stderr
6
+ # (see the $stderr.print/.flush calls). That output isn't warnings, so $stderr.puts is intentional
7
+ # throughout rather than warn.
8
+ # rubocop:disable Style/StderrPuts
5
9
  class FileSystem
6
10
  attr_accessor :logfile, :logfile_name, :log_only
7
11
 
@@ -14,18 +18,14 @@ class FileSystem
14
18
  end
15
19
 
16
20
  # Effectively the same as File.read except it forces the encoding to UTF-8
17
- def load filename, supress_deprecation: false
18
- if filename.end_with?('.json') && !supress_deprecation
19
- deprecated(message: 'call load_json instead', date: '2024-11-13')
20
- end
21
-
21
+ def load filename
22
22
  File.read filename, encoding: 'UTF-8'
23
23
  end
24
24
 
25
25
  def load_json filename, fail_on_error: true
26
26
  return nil if fail_on_error == false && File.exist?(filename) == false
27
27
 
28
- JSON.parse load(filename, supress_deprecation: true)
28
+ JSON.parse load(filename)
29
29
  end
30
30
 
31
31
  def save_json json:, filename:
@@ -70,7 +70,7 @@ class FileSystem
70
70
  # set up. Quick escape here so that we don't dump the error twice.
71
71
  return if logfile == $stdout
72
72
 
73
- $stderr.puts message # rubocop:disable Style/StderrPuts
73
+ $stderr.puts message
74
74
  end
75
75
 
76
76
  def log_start message
@@ -98,21 +98,26 @@ class FileSystem
98
98
  def end_progress
99
99
  return if log_only
100
100
 
101
- $stderr.puts '' # rubocop:disable Style/StderrPuts
101
+ $stderr.puts ''
102
102
  end
103
103
 
104
104
  # In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
105
105
  # cases where this simple compression will drop the filesize by half.
106
106
  def compress node
107
107
  if node.is_a? Hash
108
- node.reject! { |_key, value| value.nil? || (value.is_a?(Array) && value.empty?) }
108
+ node.reject! { |_key, value| prunable? value }
109
109
  node.each_value { |value| compress value }
110
110
  elsif node.is_a? Array
111
- node.each { |a| compress a }
111
+ node.each { |element| compress element }
112
112
  end
113
113
  node
114
114
  end
115
115
 
116
+ # A value worth dropping from the compressed output: nothing there, or an empty array.
117
+ def prunable? value
118
+ value.nil? || (value.is_a?(Array) && value.empty?)
119
+ end
120
+
116
121
  def foreach root, &block
117
122
  Dir.foreach root, &block
118
123
  end
@@ -139,3 +144,4 @@ class FileSystem
139
144
  log text, also_write_to_stderr: true
140
145
  end
141
146
  end
147
+ # rubocop:enable Style/StderrPuts
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Walks a stream of BlockedStalledChange entries and totals up the value-add (active) time between
4
+ # issue_start and end_time. It's handed the already-computed stream and the resolved start/end so it
5
+ # never reaches back through the issue's collaborators.
6
+ #
7
+ # Returns [value_add_seconds, total_seconds].
8
+ class FlowEfficiencyCalculator
9
+ def initialize blocked_stalled_changes:, issue_start:, end_time:
10
+ @blocked_stalled_changes = blocked_stalled_changes
11
+ @issue_start = issue_start
12
+ @end_time = end_time
13
+ end
14
+
15
+ def calculate
16
+ @active_start = nil
17
+ @value_add_time = 0.0
18
+
19
+ @blocked_stalled_changes.each_with_index do |change, index|
20
+ break if change.time > @end_time
21
+
22
+ process change, index
23
+ end
24
+
25
+ close_final_active_period
26
+
27
+ [@value_add_time, @end_time - @issue_start]
28
+ end
29
+
30
+ def process change, index
31
+ if index.zero?
32
+ @active_start = change.time if change.active?
33
+ return
34
+ end
35
+
36
+ process_transition change
37
+ end
38
+
39
+ def process_transition change
40
+ # Already active and we just got another active.
41
+ return if @active_start && change.active?
42
+
43
+ if change.active?
44
+ @active_start = change.time
45
+ elsif @active_start && change.time >= @issue_start
46
+ # Not active now but we have been. Record the active time.
47
+ record_active_period ending_at: change.time
48
+ @active_start = nil
49
+ end
50
+ end
51
+
52
+ def record_active_period ending_at:
53
+ @value_add_time += ending_at - [@issue_start, @active_start].max
54
+ end
55
+
56
+ def close_final_active_period
57
+ return unless @active_start
58
+
59
+ change_delta = @end_time - [@issue_start, @active_start].max
60
+ @value_add_time += change_delta if change_delta.positive?
61
+ end
62
+ end
@@ -7,7 +7,8 @@ class FlowEfficiencyScatterplot < ChartBase
7
7
 
8
8
  attr_accessor :possible_statuses
9
9
 
10
- def initialize block
10
+ # Long only because of the inline description_text heredoc and one-time setup; splitting wouldn't help.
11
+ def initialize block # rubocop:disable Metrics/MethodLength
11
12
  super()
12
13
 
13
14
  header_text 'Flow Efficiency'
@@ -63,7 +64,8 @@ class FlowEfficiencyScatterplot < ChartBase
63
64
  end
64
65
 
65
66
  if data_sets.empty?
66
- return "<h1 class='foldable'>#{@header_text}</h1><div>No data matched the selected criteria. Nothing to show.</div>"
67
+ return "<h1 class='foldable'>#{@header_text}</h1>" \
68
+ '<div>No data matched the selected criteria. Nothing to show.</div>'
67
69
  end
68
70
 
69
71
  wrap_and_render(binding, __FILE__)
@@ -28,12 +28,27 @@ class GithubGateway
28
28
 
29
29
  def fetch_pull_requests since: nil
30
30
  raw_prs = @raw_pr_cache[[@repo, since]] ||= fetch_raw_pull_requests(since: since)
31
+ verify_repo_reachable_when_empty(raw_prs, since: since)
31
32
  prefetch_commit_messages(raw_prs)
32
33
  raw_prs.filter_map { |pr| build_pr_data(pr) }
33
34
  end
34
35
 
36
+ # `gh pr list --search` (the path taken whenever a `since` date is set) goes through GitHub's
37
+ # search API, which returns an empty result with a SUCCESS status for a repo we can't actually
38
+ # access, rather than erroring the way a direct repo query does. Downstream that empty would
39
+ # overwrite previously downloaded PR data. So when a search comes back empty, confirm the repo is
40
+ # genuinely reachable; if it isn't, raise so the caller treats it as a failed download and keeps
41
+ # the existing data instead of replacing it with nothing.
42
+ def verify_repo_reachable_when_empty raw_prs, since:
43
+ return unless since && raw_prs.empty?
44
+ return if repo_reachable?
45
+
46
+ raise "GitHub returned no pull requests for #{@repo} and the repository is not reachable " \
47
+ '(check your access to it and the URL in github_repos)'
48
+ end
49
+
35
50
  def fetch_raw_pull_requests since: nil
36
- # NOTE: 'commits' is intentionally excluded — including it triggers GitHub's GraphQL node
51
+ # NOTE: 'commits' is intentionally excluded - including it triggers GitHub's GraphQL node
37
52
  # limit (authors sub-connection × PRs × commits exceeds 500,000 nodes). Branch name,
38
53
  # title, and body are sufficient for issue key extraction in the vast majority of cases.
39
54
  json_fields = %w[number title body headRefName createdAt closedAt mergedAt
@@ -139,14 +154,18 @@ class GithubGateway
139
154
  # fallback fetches the complete set rather than us caching a partial answer.
140
155
  next if commits['totalCount'] && commits['totalCount'] > nodes.size
141
156
 
142
- messages_by_number[number] = nodes.flat_map do |node|
143
- commit = node['commit'] || {}
144
- [commit['messageHeadline'], commit['messageBody']].compact
145
- end
157
+ messages_by_number[number] = commit_messages_from(nodes)
146
158
  end
147
159
  messages_by_number
148
160
  end
149
161
 
162
+ def commit_messages_from nodes
163
+ nodes.flat_map do |node|
164
+ commit = node['commit'] || {}
165
+ [commit['messageHeadline'], commit['messageBody']].compact
166
+ end
167
+ end
168
+
150
169
  def owner_and_name
151
170
  # @repo may be a full URL (https://github.com/owner/name.git) or an owner/name slug.
152
171
  @repo.sub(%r{\Ahttps?://[^/]+/}, '').delete_suffix('.git').split('/', 2)
@@ -173,12 +192,31 @@ class GithubGateway
173
192
  Regexp.new("\\b(?:#{keys_pattern})-\\d+(?![A-Za-z0-9])")
174
193
  end
175
194
 
195
+ # Cached per repo (reachability doesn't depend on the `since` window) so we probe at most once even
196
+ # when several projects share the same repo. Uses key? rather than ||= because a false verdict must
197
+ # stick and not trigger a re-probe on every project.
198
+ def repo_reachable?
199
+ key = [@repo, :reachable]
200
+ return @raw_pr_cache[key] if @raw_pr_cache.key?(key)
201
+
202
+ @raw_pr_cache[key] = probe_repo_reachable
203
+ end
204
+
205
+ def probe_repo_reachable
206
+ # This is expected to fail for an inaccessible repo, and the caller turns that into a single
207
+ # actionable warning, so suppress run_command's own failure warning to avoid stacking messages.
208
+ run_command(['repo', 'view', @repo, '--json', 'name'], warn_on_failure: false)
209
+ true
210
+ rescue StandardError
211
+ false
212
+ end
213
+
176
214
  def monotonic_time
177
215
  # In its own method so we can mock it out in tests
178
216
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
179
217
  end
180
218
 
181
- def run_command args
219
+ def run_command args, warn_on_failure: true
182
220
  attempts = 0
183
221
  loop do
184
222
  attempts += 1
@@ -195,6 +233,11 @@ class GithubGateway
195
233
  unless status.success?
196
234
  error_message = " GitHub CLI command failed for #{@repo} " \
197
235
  "(attempt #{attempts}/#{MAX_RETRIES}): #{stderr.strip}"
236
+ # stderr is a String (from Open3.capture3), so this include? is String#include? - a substring
237
+ # match ("does the error text contain this phrase?"), not Array membership. Style/ArrayIntersect
238
+ # can't tell the two apart; its `.intersect?(stderr)` rewrite would be an exact-equality check
239
+ # that never matches, silently killing the retry-on-transient-error logic.
240
+ # rubocop:disable Style/ArrayIntersect
198
241
  if attempts < MAX_RETRIES && TRANSIENT_ERROR_PATTERNS.any? { |pattern| stderr.include?(pattern) }
199
242
  delay = 2**attempts
200
243
  @file_system.log error_message
@@ -202,15 +245,12 @@ class GithubGateway
202
245
  sleep delay
203
246
  next
204
247
  end
205
- @file_system.warning error_message
248
+ # rubocop:enable Style/ArrayIntersect
249
+ @file_system.warning error_message if warn_on_failure
206
250
  raise "GitHub CLI command failed for #{@repo}: #{stderr}"
207
251
  end
208
252
 
209
- result = JSON.parse(stdout)
210
- if result.nil? || (result.is_a?(Array) && result.empty?)
211
- @file_system.warning "No data was found in GitHub for #{@repo}. Is that what you expected?"
212
- end
213
- return result
253
+ return JSON.parse(stdout)
214
254
  end
215
255
  end
216
256
  end
@@ -4,6 +4,9 @@ require 'jirametrics/rules'
4
4
  require 'jirametrics/grouping_rules'
5
5
 
6
6
  module GroupableIssueChart
7
+ attr_accessor :issue_hints, :issue_periods
8
+ attr_reader :group_by_block
9
+
7
10
  def init_configuration_block user_provided_block, &default_block
8
11
  instance_eval(&user_provided_block)
9
12
  instance_eval(&default_block) unless @group_by_block
@@ -67,7 +67,7 @@ if (!Chart.Tooltip.positioners.legendItem) {
67
67
  const endX = chart.scales.x.getPixelForValue(new Date(win.end_date).getTime());
68
68
 
69
69
  // Draw hatched slices over the correction window.
70
- // For stacked line charts, PointElement has no .base — derive the band bottom from the
70
+ // For stacked line charts, PointElement has no .base - derive the band bottom from the
71
71
  // dataset directly below in the visual stack (dataset_index - 1, since datasets are
72
72
  // stored reversed), or chart.chartArea.bottom for the lowest dataset.
73
73
  // Use a trapezoid clip path per slice so hatching stays within the actual band boundary
@@ -330,7 +330,7 @@ if (!Chart.Tooltip.positioners.legendItem) {
330
330
  const parent = canvas.parentNode;
331
331
  if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative';
332
332
  // Read canvas.offsetTop/Left after parent is positioned so they are
333
- // relative to parent — this accounts for the label/checkbox above the
333
+ // relative to parent - this accounts for the label/checkbox above the
334
334
  // canvas that would otherwise shift the overlay upward.
335
335
  const overlay = document.createElement('canvas');
336
336
  overlay.width = canvas.width;
@@ -426,7 +426,7 @@ if (!Chart.Tooltip.positioners.legendItem) {
426
426
  const fm = chart._flowMetrics;
427
427
  if (!fm) return;
428
428
  drawTrendLines(chart, fm);
429
- // Triangle is on the overlay canvas — no drawing needed here.
429
+ // Triangle is on the overlay canvas - no drawing needed here.
430
430
  }
431
431
  };
432
432
  })();