prdigest 0.1.0

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.
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+ require "tzinfo"
6
+
7
+ module Prdigest
8
+ class Config
9
+ attr_reader :raw, :path
10
+
11
+ REPOSITORY_SEGMENT = /\A[A-Za-z0-9_.-]+\z/
12
+
13
+ def self.resolve_path(explicit: nil, env: ENV, system_path: "/etc/prdigest/config.yml")
14
+ return File.expand_path(explicit) if explicit && !explicit.to_s.empty?
15
+
16
+ from_env = env["PRDIGEST_CONFIG"].to_s
17
+ return File.expand_path(from_env) unless from_env.empty?
18
+ return system_path if File.file?(system_path)
19
+
20
+ raise ConfigError, "config path is required (--config, PRDIGEST_CONFIG, or /etc/prdigest/config.yml)"
21
+ end
22
+
23
+ def self.load(path)
24
+ path = File.expand_path(path)
25
+ raise ConfigError, "config not found: #{path}" unless File.file?(path)
26
+
27
+ raw = YAML.safe_load_file(path, aliases: true)
28
+ raise ConfigError, "config root must be a mapping" unless raw.is_a?(Hash)
29
+
30
+ new(raw, path: path).tap(&:validate!)
31
+ rescue ConfigError
32
+ raise
33
+ rescue StandardError => e
34
+ raise ConfigError, "cannot load config: #{e.class}"
35
+ end
36
+
37
+ def self.normalize_repos(values, label: "repository")
38
+ normalized = Array(values).map { |value| value.to_s.strip }
39
+ raise ConfigError, "#{label} must list at least one owner/name" if normalized.empty?
40
+
41
+ invalid = normalized.find do |repository|
42
+ parts = repository.split("/", -1)
43
+ parts.length != 2 ||
44
+ parts.any? { |part| part.empty? || %w[. ..].include?(part) || !part.match?(REPOSITORY_SEGMENT) }
45
+ end
46
+ raise ConfigError, "#{label} entry must be owner/name; got #{invalid.inspect}" if invalid
47
+
48
+ normalized.each_with_object({}) do |repository, unique|
49
+ unique[repository.downcase] ||= repository
50
+ end.values.freeze
51
+ end
52
+
53
+ def initialize(raw, path: nil)
54
+ @raw = raw
55
+ @path = path
56
+ end
57
+
58
+ def timezone
59
+ raw.fetch("timezone", "UTC").to_s
60
+ end
61
+
62
+ def repos
63
+ self.class.normalize_repos(raw.dig("github", "repos"), label: "github.repos")
64
+ end
65
+
66
+ def github_token(env = ENV)
67
+ env_name = raw.dig("github", "token_env") || "GITHUB_TOKEN"
68
+ env[env_name.to_s].to_s
69
+ end
70
+
71
+ def telegram_token(env = ENV)
72
+ env_name = raw.dig("telegram", "token_env") || "TELEGRAM_BOT_TOKEN"
73
+ env[env_name.to_s].to_s
74
+ end
75
+
76
+ def chat_id
77
+ Integer(raw.dig("telegram", "chat_id"))
78
+ rescue TypeError, ArgumentError
79
+ nil
80
+ end
81
+
82
+ def chat_id_allowlist
83
+ Array(raw.dig("telegram", "chat_id_allowlist")).map { |id| Integer(id) }
84
+ rescue TypeError, ArgumentError
85
+ []
86
+ end
87
+
88
+ def line_stats?
89
+ raw.dig("digest", "line_stats") != false
90
+ end
91
+
92
+ def send_empty?
93
+ raw.dig("digest", "send_empty") != false
94
+ end
95
+
96
+ def empty_message
97
+ raw.dig("digest", "empty_message") || "Merged PR digest — {date}\nTotal: 0 PRs"
98
+ end
99
+
100
+ def state_path
101
+ raw.dig("state", "path") || File.expand_path("~/.local/share/prdigest/state.json")
102
+ end
103
+
104
+ def delivery_state_path
105
+ raw.dig("state", "delivery_path") || File.join(File.dirname(state_path), "deliveries")
106
+ end
107
+
108
+ def schedule_cron
109
+ raw.dig("schedule", "cron") || "5 9 * * *"
110
+ end
111
+
112
+ def max_catchup_days
113
+ Integer(raw.dig("schedule", "max_catchup_days") || 7)
114
+ rescue TypeError, ArgumentError
115
+ raise ConfigError, "schedule.max_catchup_days must be an integer from 1 to 30"
116
+ end
117
+
118
+ def validate!
119
+ begin
120
+ TZInfo::Timezone.get(timezone)
121
+ rescue TZInfo::InvalidTimezoneIdentifier
122
+ raise ConfigError, "timezone must be a resolvable IANA identifier"
123
+ end
124
+ unless (1..30).cover?(max_catchup_days)
125
+ raise ConfigError, "schedule.max_catchup_days must be from 1 to 30"
126
+ end
127
+ repos
128
+ raise ConfigError, "telegram.chat_id is required" if chat_id.nil?
129
+ raise ConfigError, "telegram.chat_id_allowlist must not be empty" if chat_id_allowlist.empty?
130
+ unless chat_id_allowlist.include?(chat_id)
131
+ raise ConfigError, "telegram.chat_id must be present in chat_id_allowlist"
132
+ end
133
+ self
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "digest"
5
+ require "fileutils"
6
+ require "json"
7
+ require "time"
8
+
9
+ module Prdigest
10
+ class DeliveryCheckpointStore
11
+ SCHEMA = "prdigest-delivery-checkpoint"
12
+ SCHEMA_VERSION = 1
13
+
14
+ class Session
15
+ def initialize(path:, data:)
16
+ @path = path
17
+ @data = data
18
+ end
19
+
20
+ def chunks
21
+ @data.fetch("chunks")
22
+ end
23
+
24
+ def next_chunk
25
+ @data.fetch("next_chunk")
26
+ end
27
+
28
+ def completed?
29
+ @data.fetch("status") == "completed"
30
+ end
31
+
32
+ def begin_attempt(index)
33
+ require_next_chunk!(index)
34
+ @data["in_flight"] = { "chunk" => index, "started_at" => timestamp }
35
+ persist!
36
+ end
37
+
38
+ def accept(index)
39
+ require_in_flight!(index)
40
+ @data["next_chunk"] = index + 1
41
+ @data["in_flight"] = nil
42
+ @data["status"] = next_chunk == chunks.length ? "completed" : "pending"
43
+ @data["permanent_error"] = nil
44
+ persist!
45
+ end
46
+
47
+ def reject(index, kind:, message:, permanent:)
48
+ require_in_flight!(index)
49
+ @data["in_flight"] = nil
50
+ if permanent
51
+ @data["status"] = "blocked"
52
+ @data["permanent_error"] = {
53
+ "kind" => kind.to_s,
54
+ "message" => message.to_s,
55
+ "failed_chunk" => index
56
+ }
57
+ else
58
+ @data["permanent_error"] = nil
59
+ end
60
+ persist!
61
+ end
62
+
63
+ def delivery(failed_chunk: nil)
64
+ result = {
65
+ accepted_chunks: next_chunk,
66
+ total_chunks: chunks.length,
67
+ status: @data.fetch("status")
68
+ }
69
+ result[:failed_chunk] = failed_chunk unless failed_chunk.nil?
70
+ result
71
+ end
72
+
73
+ private
74
+
75
+ def require_next_chunk!(index)
76
+ return if index == next_chunk && @data["in_flight"].nil? && !completed?
77
+
78
+ raise StateError, "delivery checkpoint transition is invalid"
79
+ end
80
+
81
+ def require_in_flight!(index)
82
+ return if @data.dig("in_flight", "chunk") == index
83
+
84
+ raise StateError, "delivery checkpoint transition is invalid"
85
+ end
86
+
87
+ def persist!
88
+ @data["updated_at"] = timestamp
89
+ DeliveryCheckpointStore.atomic_write(@path, JSON.pretty_generate(@data))
90
+ rescue StateError
91
+ raise
92
+ rescue StandardError => e
93
+ raise StateError, "cannot persist delivery checkpoint (#{e.class})"
94
+ end
95
+
96
+ def timestamp
97
+ Time.now.utc.iso8601(6)
98
+ end
99
+ end
100
+
101
+ def initialize(root:)
102
+ @root = File.expand_path(root)
103
+ end
104
+
105
+ def with_checkpoint(date:, chat_id:, scope:, chunks:)
106
+ normalized_date = Date.iso8601(date.to_s)
107
+ ensure_root!
108
+ path = File.join(@root, "#{normalized_date}.json")
109
+ lock_path = File.join(@root, "#{normalized_date}.lock")
110
+
111
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
112
+ File.chmod(0o600, lock_path)
113
+ unless lock.flock(File::LOCK_EX | File::LOCK_NB)
114
+ raise SendError.new(
115
+ "delivery checkpoint is already active",
116
+ kind: "delivery_checkpoint",
117
+ delivery: { accepted_chunks: 0, total_chunks: Array(chunks).length, status: "pending" }
118
+ )
119
+ end
120
+
121
+ data = load_or_create(
122
+ path,
123
+ date: normalized_date,
124
+ chat_id: Integer(chat_id),
125
+ scope: Array(scope).map(&:to_s),
126
+ chunks: Array(chunks).map(&:to_s)
127
+ )
128
+ fail_if_unavailable!(data)
129
+ yield Session.new(path: path, data: data)
130
+ ensure
131
+ lock.flock(File::LOCK_UN)
132
+ end
133
+ rescue SendError, StateError
134
+ raise
135
+ rescue StandardError => e
136
+ raise StateError, "cannot access delivery checkpoint (#{e.class})"
137
+ end
138
+
139
+ def self.atomic_write(path, content)
140
+ directory = File.dirname(path)
141
+ temporary = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
142
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
143
+ file.write(content)
144
+ file.flush
145
+ file.fsync
146
+ end
147
+ File.rename(temporary, path)
148
+ File.chmod(0o600, path)
149
+ File.open(directory, File::RDONLY, &:fsync)
150
+ ensure
151
+ File.unlink(temporary) if temporary && File.exist?(temporary)
152
+ end
153
+
154
+ private
155
+
156
+ def ensure_root!
157
+ FileUtils.mkdir_p(@root, mode: 0o700)
158
+ File.chmod(0o700, @root)
159
+ end
160
+
161
+ def load_or_create(path, date:, chat_id:, scope:, chunks:)
162
+ if File.file?(path)
163
+ data = JSON.parse(File.read(path))
164
+ validate!(data, date: date, chat_id: chat_id, scope: scope)
165
+ data
166
+ else
167
+ data = {
168
+ "schema" => SCHEMA,
169
+ "schema_version" => SCHEMA_VERSION,
170
+ "date" => date.to_s,
171
+ "chat_id" => chat_id,
172
+ "scope" => scope,
173
+ "chunks" => chunks,
174
+ "chunks_sha256" => chunk_digest(chunks),
175
+ "next_chunk" => 0,
176
+ "status" => chunks.empty? ? "completed" : "pending",
177
+ "in_flight" => nil,
178
+ "permanent_error" => nil,
179
+ "created_at" => Time.now.utc.iso8601(6),
180
+ "updated_at" => Time.now.utc.iso8601(6)
181
+ }
182
+ self.class.atomic_write(path, JSON.pretty_generate(data))
183
+ data
184
+ end
185
+ rescue SendError
186
+ raise
187
+ rescue JSON::ParserError, KeyError, TypeError, ArgumentError => e
188
+ raise SendError.new(
189
+ "delivery checkpoint is invalid (#{e.class})",
190
+ kind: "delivery_checkpoint_permanent"
191
+ )
192
+ end
193
+
194
+ def validate!(data, date:, chat_id:, scope:)
195
+ expected = {
196
+ "schema" => SCHEMA,
197
+ "schema_version" => SCHEMA_VERSION,
198
+ "date" => date.to_s,
199
+ "chat_id" => chat_id,
200
+ "scope" => scope
201
+ }
202
+ unless expected.all? { |key, value| data.fetch(key) == value }
203
+ raise SendError.new(
204
+ "delivery checkpoint does not match this digest",
205
+ kind: "delivery_checkpoint_permanent"
206
+ )
207
+ end
208
+
209
+ chunks = data.fetch("chunks")
210
+ next_chunk = data.fetch("next_chunk")
211
+ status = data.fetch("status")
212
+ valid = chunks.is_a?(Array) && chunks.all? { |chunk| chunk.is_a?(String) } &&
213
+ data.fetch("chunks_sha256") == chunk_digest(chunks) &&
214
+ next_chunk.is_a?(Integer) && next_chunk.between?(0, chunks.length) &&
215
+ valid_transition_state?(data, status: status, next_chunk: next_chunk, total_chunks: chunks.length)
216
+ raise KeyError, "invalid delivery checkpoint fields" unless valid
217
+ end
218
+
219
+ def valid_transition_state?(data, status:, next_chunk:, total_chunks:)
220
+ in_flight = data.fetch("in_flight")
221
+ permanent_error = data.fetch("permanent_error")
222
+ case status
223
+ when "pending"
224
+ next_chunk < total_chunks &&
225
+ valid_in_flight?(in_flight, next_chunk) &&
226
+ permanent_error.nil?
227
+ when "completed"
228
+ next_chunk == total_chunks && in_flight.nil? && permanent_error.nil?
229
+ when "blocked"
230
+ next_chunk < total_chunks && in_flight.nil? &&
231
+ permanent_error.is_a?(Hash) &&
232
+ permanent_error["kind"].is_a?(String) &&
233
+ permanent_error["message"].is_a?(String) &&
234
+ permanent_error["failed_chunk"] == next_chunk
235
+ else
236
+ false
237
+ end
238
+ end
239
+
240
+ def valid_in_flight?(in_flight, next_chunk)
241
+ in_flight.nil? ||
242
+ (in_flight.is_a?(Hash) &&
243
+ in_flight["chunk"] == next_chunk &&
244
+ in_flight["started_at"].is_a?(String))
245
+ end
246
+
247
+ def fail_if_unavailable!(data)
248
+ session = Session.new(path: "", data: data)
249
+ if data["in_flight"]
250
+ failed_chunk = data.dig("in_flight", "chunk")
251
+ raise SendError.new(
252
+ "Telegram delivery outcome is ambiguous; operator reconciliation is required",
253
+ kind: "telegram_ambiguous",
254
+ delivery: session.delivery(failed_chunk: failed_chunk)
255
+ )
256
+ end
257
+
258
+ return unless data["status"] == "blocked"
259
+
260
+ failure = data.fetch("permanent_error")
261
+ raise SendError.new(
262
+ failure.fetch("message"),
263
+ kind: failure.fetch("kind"),
264
+ delivery: session.delivery(failed_chunk: failure.fetch("failed_chunk"))
265
+ )
266
+ end
267
+
268
+ def chunk_digest(chunks)
269
+ ::Digest::SHA256.hexdigest(JSON.generate(chunks))
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Prdigest
6
+ PullRequest = Data.define(
7
+ :repository, :number, :title, :url, :author, :merged_at,
8
+ :additions, :deletions, :commits
9
+ ) do
10
+ def initialize(repository:, number:, title:, url:, author:, merged_at:, additions: nil, deletions: nil, commits: nil)
11
+ super(
12
+ repository: repository.to_s.freeze,
13
+ number: Integer(number),
14
+ title: title.to_s.freeze,
15
+ url: url.to_s.freeze,
16
+ author: author.to_s.freeze,
17
+ merged_at: merged_at.utc.freeze,
18
+ additions: additions && Integer(additions),
19
+ deletions: deletions && Integer(deletions),
20
+ commits: commits && Integer(commits)
21
+ )
22
+ end
23
+ end
24
+
25
+ RepositoryDigest = Data.define(:name, :pull_requests)
26
+
27
+ DayDigest = Data.define(:date, :repositories, :line_stats) do
28
+ def self.build(date:, repository_order:, pulls:, line_stats: false)
29
+ grouped = pulls.group_by(&:repository)
30
+ sections = repository_order.map do |name|
31
+ sorted = Array(grouped[name]).sort_by { |pull| [pull.merged_at, pull.number] }.freeze
32
+ RepositoryDigest.new(name.to_s.freeze, sorted)
33
+ end.freeze
34
+ new(Date.iso8601(date.to_s), sections, line_stats == true)
35
+ end
36
+
37
+ def total_prs
38
+ repositories.sum { |section| section.pull_requests.length }
39
+ end
40
+
41
+ def total_additions
42
+ stat_total(:additions)
43
+ end
44
+
45
+ def total_deletions
46
+ stat_total(:deletions)
47
+ end
48
+
49
+ def total_commits
50
+ stat_total(:commits)
51
+ end
52
+
53
+ private
54
+
55
+ def stat_total(name)
56
+ return nil unless line_stats
57
+ repositories.sum { |section| section.pull_requests.sum { |pull| pull.public_send(name) } }
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "octokit"
4
+ require "set"
5
+ require "time"
6
+
7
+ module Prdigest
8
+ class GitHub
9
+ SEARCH_CAP = 1_000
10
+ MAX_ATTEMPTS = 3
11
+
12
+ def initialize(token:, client: nil, sleeper: ->(seconds) { sleep(seconds) }, now: -> { Time.now.to_i })
13
+ @token = token.to_s
14
+ @client = client || default_client
15
+ @sleeper = sleeper
16
+ @now = now
17
+ end
18
+
19
+ def fetch(date:, window:, repositories:, line_stats: false)
20
+ pulls = repositories.flat_map do |repository|
21
+ fetch_repository(repository, date, window, line_stats)
22
+ end
23
+ DayDigest.build(date: date, repository_order: repositories, pulls: pulls, line_stats: line_stats)
24
+ end
25
+
26
+ def inspect
27
+ "#<#{self.class} token=[REDACTED]>"
28
+ end
29
+
30
+ alias to_s inspect
31
+
32
+ private
33
+
34
+ def default_client
35
+ middleware = Octokit::Default::MIDDLEWARE.dup
36
+ middleware.handlers.reject! { |handler| retry_middleware?(handler.klass) }
37
+ Octokit::Client.new(
38
+ access_token: @token,
39
+ middleware: middleware,
40
+ connection_options: { request: { open_timeout: 10, timeout: 30, write_timeout: 30 } }
41
+ )
42
+ end
43
+
44
+ def retry_middleware?(middleware)
45
+ %w[Faraday::Request::Retry Faraday::Retry::Middleware].include?(middleware.name)
46
+ end
47
+
48
+ def fetch_repository(repository, date, window, line_stats)
49
+ return [] if window.zero_length?
50
+
51
+ query = build_query(repository, window)
52
+ page = 1
53
+ items = []
54
+ seen_numbers = Set.new
55
+ total = nil
56
+ loop do
57
+ response = request(repository, date) { @client.search_issues(query, per_page: 100, page: page) }
58
+ incomplete = field(response, :incomplete_results)
59
+ fail_fetch!(repository, date, "incomplete search results") if incomplete
60
+ response_total = Integer(field(response, :total_count))
61
+ fail_fetch!(repository, date, "search result cap exceeded") if response_total > SEARCH_CAP
62
+ total ||= response_total
63
+ fail_fetch!(repository, date, "search total changed during pagination") unless total == response_total
64
+
65
+ page_items = Array(field(response, :items))
66
+ page_items.each do |item|
67
+ number = Integer(field(item, :number))
68
+ fail_fetch!(repository, date, "pagination repeated a pull request") unless seen_numbers.add?(number)
69
+ end
70
+ items.concat(page_items)
71
+ fail_fetch!(repository, date, "pagination exceeded total") if items.length > total
72
+ break if items.length == total
73
+ fail_fetch!(repository, date, "pagination stopped before total") if page_items.empty?
74
+ page += 1
75
+ end
76
+
77
+ mapped = items.map { |item| map_item(item, repository, date, window) }
78
+ return mapped unless line_stats
79
+
80
+ mapped.map do |pull|
81
+ detail = request(repository, date) { @client.pull_request(repository, pull.number) }
82
+ PullRequest.new(
83
+ **pull.to_h,
84
+ additions: Integer(field(detail, :additions)),
85
+ deletions: Integer(field(detail, :deletions)),
86
+ commits: Integer(field(detail, :commits))
87
+ )
88
+ end
89
+ rescue FetchError
90
+ raise
91
+ rescue StandardError => e
92
+ fail_fetch!(repository, date, "malformed GitHub response (#{e.class})")
93
+ end
94
+
95
+ def build_query(repository, window)
96
+ finish = window.end_time - 1
97
+ "repo:#{repository} is:pr is:merged merged:#{timestamp(window.start_time)}..#{timestamp(finish)}"
98
+ end
99
+
100
+ def timestamp(time)
101
+ time.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
102
+ end
103
+
104
+ def map_item(item, repository, date, window)
105
+ actual_repository = repository_name(item)
106
+ merged_at = Time.iso8601(field(field(item, :pull_request), :merged_at).to_s).utc
107
+ unless actual_repository.casecmp?(repository) && window.cover?(merged_at)
108
+ fail_fetch!(repository, date, "result outside requested repository or UTC window")
109
+ end
110
+
111
+ PullRequest.new(
112
+ repository: repository,
113
+ number: Integer(field(item, :number)),
114
+ title: field(item, :title),
115
+ url: field(item, :html_url),
116
+ author: field(field(item, :user), :login),
117
+ merged_at: merged_at
118
+ )
119
+ end
120
+
121
+ def repository_name(item)
122
+ repository = optional_field(item, :repository)
123
+ full_name = optional_field(repository, :full_name) if repository
124
+ return full_name.to_s unless full_name.to_s.empty?
125
+
126
+ url = field(item, :repository_url).to_s
127
+ match = url.match(%r{/repos/([^/]+/[^/]+)\z})
128
+ raise KeyError, "repository identity missing" unless match
129
+ match[1]
130
+ end
131
+
132
+ def field(object, key)
133
+ value = optional_field(object, key)
134
+ raise KeyError, key if value.nil?
135
+ value
136
+ end
137
+
138
+ def optional_field(object, key)
139
+ if object.respond_to?(:key?)
140
+ return object[key] if object.key?(key)
141
+ return object[key.to_s] if object.key?(key.to_s)
142
+ end
143
+ return object.public_send(key) if object.respond_to?(key)
144
+ end
145
+
146
+ def request(repository, date)
147
+ attempts = 0
148
+ begin
149
+ attempts += 1
150
+ yield
151
+ rescue StandardError => e
152
+ unless transient?(e) && attempts < MAX_ATTEMPTS
153
+ fail_fetch!(repository, date, error_kind(e))
154
+ end
155
+ delay = retry_delay(e) || attempts
156
+ fail_fetch!(repository, date, "retry delay exceeds 60 seconds") if delay > 60
157
+ @sleeper.call(delay)
158
+ retry
159
+ end
160
+ end
161
+
162
+ def transient?(error)
163
+ error.is_a?(Octokit::ServerError) ||
164
+ rate_limited?(error) ||
165
+ (defined?(Faraday::ConnectionFailed) && error.is_a?(Faraday::ConnectionFailed)) ||
166
+ (defined?(Faraday::TimeoutError) && error.is_a?(Faraday::TimeoutError))
167
+ end
168
+
169
+ def rate_limited?(error)
170
+ error.is_a?(Octokit::TooManyRequests) ||
171
+ (error.respond_to?(:response_status) && Integer(error.response_status, exception: false) == 429)
172
+ end
173
+
174
+ def retry_delay(error)
175
+ headers = error.respond_to?(:response_headers) ? error.response_headers : nil
176
+ retry_after = Integer(response_header(headers, "retry-after"), exception: false)
177
+ return retry_after if retry_after
178
+
179
+ reset_at = Integer(response_header(headers, "x-ratelimit-reset"), exception: false)
180
+ [reset_at - Integer(@now.call), 0].max if reset_at
181
+ rescue StandardError
182
+ nil
183
+ end
184
+
185
+ def response_header(headers, name)
186
+ return unless headers
187
+
188
+ Faraday::Utils::Headers.from(headers)[name]
189
+ end
190
+
191
+ def error_kind(error)
192
+ transient?(error) ? "GitHub request retries exhausted" : "GitHub request refused"
193
+ end
194
+
195
+ def fail_fetch!(repository, date, reason)
196
+ raise FetchError.new("GitHub fetch failed for #{repository} on #{date}: #{reason}", kind: "github")
197
+ end
198
+ end
199
+ end