lex-github 0.2.4 → 0.3.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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +3 -3
  3. data/.rubocop.yml +2 -53
  4. data/CHANGELOG.md +55 -0
  5. data/CLAUDE.md +45 -19
  6. data/Gemfile +1 -0
  7. data/README.md +155 -83
  8. data/lex-github.gemspec +2 -0
  9. data/lib/legion/extensions/github/app/actor/token_refresh.rb +68 -0
  10. data/lib/legion/extensions/github/app/actor/webhook_poller.rb +65 -0
  11. data/lib/legion/extensions/github/app/hooks/setup.rb +19 -0
  12. data/lib/legion/extensions/github/app/hooks/webhook.rb +19 -0
  13. data/lib/legion/extensions/github/app/runners/auth.rb +48 -0
  14. data/lib/legion/extensions/github/app/runners/credential_store.rb +46 -0
  15. data/lib/legion/extensions/github/app/runners/installations.rb +56 -0
  16. data/lib/legion/extensions/github/app/runners/manifest.rb +65 -0
  17. data/lib/legion/extensions/github/app/runners/webhooks.rb +118 -0
  18. data/lib/legion/extensions/github/app/transport/exchanges/app.rb +17 -0
  19. data/lib/legion/extensions/github/app/transport/messages/event.rb +18 -0
  20. data/lib/legion/extensions/github/app/transport/queues/auth.rb +18 -0
  21. data/lib/legion/extensions/github/app/transport/queues/webhooks.rb +18 -0
  22. data/lib/legion/extensions/github/cli/app.rb +57 -0
  23. data/lib/legion/extensions/github/cli/auth.rb +99 -0
  24. data/lib/legion/extensions/github/client.rb +24 -0
  25. data/lib/legion/extensions/github/errors.rb +44 -0
  26. data/lib/legion/extensions/github/helpers/browser_auth.rb +106 -0
  27. data/lib/legion/extensions/github/helpers/cache.rb +99 -0
  28. data/lib/legion/extensions/github/helpers/callback_server.rb +89 -0
  29. data/lib/legion/extensions/github/helpers/client.rb +292 -2
  30. data/lib/legion/extensions/github/helpers/scope_registry.rb +91 -0
  31. data/lib/legion/extensions/github/helpers/token_cache.rb +86 -0
  32. data/lib/legion/extensions/github/middleware/credential_fallback.rb +76 -0
  33. data/lib/legion/extensions/github/middleware/rate_limit.rb +40 -0
  34. data/lib/legion/extensions/github/middleware/scope_probe.rb +37 -0
  35. data/lib/legion/extensions/github/oauth/actor/token_refresh.rb +76 -0
  36. data/lib/legion/extensions/github/oauth/hooks/callback.rb +19 -0
  37. data/lib/legion/extensions/github/oauth/runners/auth.rb +111 -0
  38. data/lib/legion/extensions/github/oauth/transport/exchanges/oauth.rb +17 -0
  39. data/lib/legion/extensions/github/oauth/transport/queues/auth.rb +18 -0
  40. data/lib/legion/extensions/github/runners/actions.rb +100 -0
  41. data/lib/legion/extensions/github/runners/branches.rb +8 -6
  42. data/lib/legion/extensions/github/runners/checks.rb +84 -0
  43. data/lib/legion/extensions/github/runners/comments.rb +15 -9
  44. data/lib/legion/extensions/github/runners/commits.rb +13 -8
  45. data/lib/legion/extensions/github/runners/contents.rb +6 -4
  46. data/lib/legion/extensions/github/runners/deployments.rb +76 -0
  47. data/lib/legion/extensions/github/runners/gists.rb +11 -6
  48. data/lib/legion/extensions/github/runners/issues.rb +18 -11
  49. data/lib/legion/extensions/github/runners/labels.rb +18 -11
  50. data/lib/legion/extensions/github/runners/organizations.rb +12 -10
  51. data/lib/legion/extensions/github/runners/pull_requests.rb +26 -16
  52. data/lib/legion/extensions/github/runners/releases.rb +89 -0
  53. data/lib/legion/extensions/github/runners/repositories.rb +19 -12
  54. data/lib/legion/extensions/github/runners/repository_webhooks.rb +76 -0
  55. data/lib/legion/extensions/github/runners/search.rb +13 -10
  56. data/lib/legion/extensions/github/runners/users.rb +14 -10
  57. data/lib/legion/extensions/github/version.rb +1 -1
  58. data/lib/legion/extensions/github.rb +23 -1
  59. metadata +63 -1
@@ -1,21 +1,311 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'faraday'
4
+ require 'legion/extensions/github/helpers/token_cache'
5
+ require 'legion/extensions/github/helpers/scope_registry'
6
+ require 'legion/extensions/github/middleware/credential_fallback'
4
7
 
5
8
  module Legion
6
9
  module Extensions
7
10
  module Github
8
11
  module Helpers
9
12
  module Client
10
- def connection(api_url: 'https://api.github.com', token: nil, **_opts)
13
+ include TokenCache
14
+ include ScopeRegistry
15
+
16
+ CREDENTIAL_RESOLVERS = %i[
17
+ resolve_vault_delegated resolve_settings_delegated
18
+ resolve_vault_app resolve_settings_app
19
+ resolve_vault_pat resolve_settings_pat
20
+ resolve_gh_cli resolve_env
21
+ ].freeze
22
+
23
+ def connection(owner: nil, repo: nil, api_url: 'https://api.github.com', token: nil, **_opts)
24
+ resolved = token ? { token: token } : resolve_credential(owner: owner, repo: repo)
25
+ resolved_token = resolved&.dig(:token)
26
+ @current_credential = resolved
27
+ @skipped_fingerprints = []
28
+
11
29
  Faraday.new(url: api_url) do |conn|
30
+ conn.use :github_credential_fallback, resolver: self
12
31
  conn.request :json
13
32
  conn.response :json, content_type: /\bjson$/
33
+ conn.response :github_rate_limit, handler: self
34
+ conn.response :github_scope_probe, handler: self
14
35
  conn.headers['Accept'] = 'application/vnd.github+json'
15
- conn.headers['Authorization'] = "Bearer #{token}" if token
36
+ conn.headers['Authorization'] = "Bearer #{resolved_token}" if resolved_token
16
37
  conn.headers['X-GitHub-Api-Version'] = '2022-11-28'
17
38
  end
18
39
  end
40
+
41
+ def resolve_next_credential(owner: nil, repo: nil)
42
+ fingerprint = @current_credential&.dig(:metadata, :credential_fingerprint)
43
+ @skipped_fingerprints ||= []
44
+ @skipped_fingerprints << fingerprint if fingerprint
45
+
46
+ CREDENTIAL_RESOLVERS.each do |method|
47
+ next unless respond_to?(method, true)
48
+
49
+ result = send(method)
50
+ next unless result
51
+
52
+ fp = result.dig(:metadata, :credential_fingerprint)
53
+ next if fp && @skipped_fingerprints.include?(fp)
54
+ next if fp && rate_limited?(fingerprint: fp)
55
+
56
+ if owner && fp
57
+ scope = scope_status(fingerprint: fp, owner: owner, repo: repo)
58
+ next if scope == :denied
59
+ end
60
+
61
+ @current_credential = result
62
+ return result
63
+ end
64
+ nil
65
+ end
66
+
67
+ def max_fallback_retries
68
+ CREDENTIAL_RESOLVERS.size
69
+ end
70
+
71
+ def on_rate_limit(remaining:, reset_at:, status:, url:, **) # rubocop:disable Lint/UnusedMethodArgument
72
+ fingerprint = @current_credential&.dig(:metadata, :credential_fingerprint)
73
+ return unless fingerprint
74
+
75
+ mark_rate_limited(fingerprint: fingerprint, reset_at: reset_at)
76
+ end
77
+
78
+ def on_scope_denied(status:, url:, path:, **) # rubocop:disable Lint/UnusedMethodArgument
79
+ fingerprint = @current_credential&.dig(:metadata, :credential_fingerprint)
80
+ owner, repo = extract_owner_repo(path)
81
+ return unless fingerprint && owner
82
+
83
+ register_scope(fingerprint: fingerprint, owner: owner, repo: repo, status: :denied)
84
+ end
85
+
86
+ def on_scope_authorized(status:, url:, path:, **) # rubocop:disable Lint/UnusedMethodArgument
87
+ fingerprint = @current_credential&.dig(:metadata, :credential_fingerprint)
88
+ owner, repo = extract_owner_repo(path)
89
+ return unless fingerprint && owner
90
+
91
+ register_scope(fingerprint: fingerprint, owner: owner, repo: repo, status: :authorized)
92
+ end
93
+
94
+ def resolve_credential(owner: nil, repo: nil)
95
+ CREDENTIAL_RESOLVERS.each do |method|
96
+ next unless respond_to?(method, true)
97
+
98
+ result = send(method)
99
+ next unless result
100
+
101
+ fingerprint = result.dig(:metadata, :credential_fingerprint)
102
+
103
+ next if fingerprint && rate_limited?(fingerprint: fingerprint)
104
+
105
+ if owner && fingerprint
106
+ scope = scope_status(fingerprint: fingerprint, owner: owner, repo: repo)
107
+ next if scope == :denied
108
+ end
109
+
110
+ return result
111
+ end
112
+ nil
113
+ end
114
+
115
+ def resolve_vault_delegated
116
+ return nil unless defined?(Legion::Crypt)
117
+
118
+ token_data = vault_get('github/oauth/delegated/token')
119
+ return nil unless token_data&.dig('access_token')
120
+
121
+ fp = credential_fingerprint(auth_type: :oauth_user, identifier: 'vault_delegated')
122
+ { token: token_data['access_token'], auth_type: :oauth_user,
123
+ expires_at: token_data['expires_at'],
124
+ metadata: { source: :vault, credential_fingerprint: fp } }
125
+ rescue StandardError => _e
126
+ nil
127
+ end
128
+
129
+ def resolve_settings_delegated
130
+ return nil unless defined?(Legion::Settings)
131
+
132
+ token = Legion::Settings.dig(:github, :oauth, :access_token)
133
+ return nil unless token
134
+
135
+ fp = credential_fingerprint(auth_type: :oauth_user, identifier: 'settings_delegated')
136
+ { token: token, auth_type: :oauth_user,
137
+ metadata: { source: :settings, credential_fingerprint: fp } }
138
+ rescue StandardError => _e
139
+ nil
140
+ end
141
+
142
+ def resolve_vault_app
143
+ return nil unless defined?(Legion::Crypt)
144
+
145
+ private_key = begin
146
+ vault_get('github/app/private_key')
147
+ rescue StandardError => _e
148
+ nil
149
+ end
150
+ return nil unless private_key
151
+
152
+ app_id = begin
153
+ vault_get('github/app/app_id')
154
+ rescue StandardError => _e
155
+ nil
156
+ end
157
+ installation_id = begin
158
+ vault_get('github/app/installation_id')
159
+ rescue StandardError => _e
160
+ nil
161
+ end
162
+ return nil unless app_id && installation_id
163
+
164
+ fp = credential_fingerprint(auth_type: :app_installation, identifier: "vault_app_#{app_id}")
165
+ cached = fetch_token(auth_type: :app_installation, installation_id: installation_id)
166
+ return cached.merge(metadata: { source: :vault, credential_fingerprint: fp }) if cached
167
+
168
+ jwt = generate_jwt(app_id: app_id, private_key: private_key)[:result]
169
+ token_data = create_installation_token(jwt: jwt, installation_id: installation_id)[:result]
170
+ return nil unless token_data&.dig('token')
171
+
172
+ expires_at = begin
173
+ Time.parse(token_data['expires_at'])
174
+ rescue StandardError => _e
175
+ Time.now + 3600
176
+ end
177
+ result = { token: token_data['token'], auth_type: :app_installation,
178
+ expires_at: expires_at, installation_id: installation_id,
179
+ metadata: { source: :vault, installation_id: installation_id,
180
+ credential_fingerprint: fp } }
181
+ store_token(**result)
182
+ result
183
+ rescue StandardError => _e
184
+ nil
185
+ end
186
+
187
+ def resolve_settings_app
188
+ return nil unless defined?(Legion::Settings)
189
+
190
+ app_id = begin
191
+ Legion::Settings.dig(:github, :app, :app_id)
192
+ rescue StandardError => _e
193
+ nil
194
+ end
195
+ return nil unless app_id
196
+
197
+ fp = credential_fingerprint(auth_type: :app_installation, identifier: "settings_app_#{app_id}")
198
+
199
+ key_path = begin
200
+ Legion::Settings.dig(:github, :app, :private_key_path)
201
+ rescue StandardError => _e
202
+ nil
203
+ end
204
+ installation_id = begin
205
+ Legion::Settings.dig(:github, :app, :installation_id)
206
+ rescue StandardError => _e
207
+ nil
208
+ end
209
+ return nil unless key_path && installation_id
210
+
211
+ cached = fetch_token(auth_type: :app_installation, installation_id: installation_id)
212
+ return cached.merge(metadata: { source: :settings, credential_fingerprint: fp }) if cached
213
+
214
+ private_key = ::File.read(key_path)
215
+ jwt = generate_jwt(app_id: app_id, private_key: private_key)[:result]
216
+ token_data = create_installation_token(jwt: jwt, installation_id: installation_id)[:result]
217
+ return nil unless token_data&.dig('token')
218
+
219
+ expires_at = begin
220
+ Time.parse(token_data['expires_at'])
221
+ rescue StandardError => _e
222
+ Time.now + 3600
223
+ end
224
+ result = { token: token_data['token'], auth_type: :app_installation,
225
+ expires_at: expires_at, installation_id: installation_id,
226
+ metadata: { source: :settings, installation_id: installation_id,
227
+ credential_fingerprint: fp } }
228
+ store_token(**result)
229
+ result
230
+ rescue StandardError => _e
231
+ nil
232
+ end
233
+
234
+ def resolve_vault_pat
235
+ return nil unless defined?(Legion::Crypt)
236
+
237
+ token = vault_get('github/token')
238
+ return nil unless token
239
+
240
+ fp = credential_fingerprint(auth_type: :pat, identifier: 'vault_pat')
241
+ { token: token, auth_type: :pat, metadata: { source: :vault, credential_fingerprint: fp } }
242
+ rescue StandardError => _e
243
+ nil
244
+ end
245
+
246
+ def resolve_settings_pat
247
+ return nil unless defined?(Legion::Settings)
248
+
249
+ token = Legion::Settings.dig(:github, :token)
250
+ return nil unless token
251
+
252
+ fp = credential_fingerprint(auth_type: :pat, identifier: 'settings_pat')
253
+ { token: token, auth_type: :pat, metadata: { source: :settings, credential_fingerprint: fp } }
254
+ rescue StandardError => _e
255
+ nil
256
+ end
257
+
258
+ def resolve_gh_cli
259
+ if cache_connected? || local_cache_connected?
260
+ cached = cache_connected? ? cache_get('github:cli_token') : local_cache_get('github:cli_token')
261
+ return cached if cached
262
+ end
263
+
264
+ output = gh_cli_token_output
265
+ return nil unless output
266
+
267
+ fp = credential_fingerprint(auth_type: :cli, identifier: 'gh_cli')
268
+ result = { token: output, auth_type: :cli, metadata: { source: :gh_cli, credential_fingerprint: fp } }
269
+ cache_set('github:cli_token', result, ttl: 300) if cache_connected?
270
+ local_cache_set('github:cli_token', result, ttl: 300) if local_cache_connected?
271
+ result
272
+ rescue StandardError => _e
273
+ nil
274
+ end
275
+
276
+ def gh_cli_token_output
277
+ output = `gh auth token 2>/dev/null`.strip
278
+ return nil unless $?&.success? && !output.empty? # rubocop:disable Style/SpecialGlobalVars
279
+
280
+ output
281
+ rescue StandardError => _e
282
+ nil
283
+ end
284
+
285
+ def resolve_env
286
+ token = ENV.fetch('GITHUB_TOKEN', nil)
287
+ return nil if token.nil? || token.empty?
288
+
289
+ fp = credential_fingerprint(auth_type: :env, identifier: 'env')
290
+ { token: token, auth_type: :env, metadata: { source: :env, credential_fingerprint: fp } }
291
+ end
292
+
293
+ private
294
+
295
+ def extract_owner_repo(path)
296
+ match = path.match(%r{^/repos/([^/]+)/([^/]+)})
297
+ return [nil, nil] unless match
298
+
299
+ [match[1], match[2]]
300
+ end
301
+
302
+ def credential_fallback?
303
+ return true unless defined?(Legion::Settings)
304
+
305
+ Legion::Settings.dig(:github, :credential_fallback) != false
306
+ rescue StandardError => _e
307
+ true
308
+ end
19
309
  end
20
310
  end
21
311
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Github
8
+ module Helpers
9
+ module ScopeRegistry
10
+ def credential_fingerprint(auth_type:, identifier:)
11
+ Digest::SHA256.hexdigest("#{auth_type}:#{identifier}")[0, 16]
12
+ end
13
+
14
+ def scope_status(fingerprint:, owner:, repo: nil)
15
+ if repo
16
+ status = scope_cache_get("github:scope:#{fingerprint}:#{owner}/#{repo}")
17
+ return status if status
18
+ end
19
+
20
+ scope_cache_get("github:scope:#{fingerprint}:#{owner}") || :unknown
21
+ end
22
+
23
+ def register_scope(fingerprint:, owner:, status:, repo: nil)
24
+ key = repo ? "github:scope:#{fingerprint}:#{owner}/#{repo}" : "github:scope:#{fingerprint}:#{owner}"
25
+ ttl = if status == :denied
26
+ scope_denied_ttl
27
+ else
28
+ (repo ? scope_repo_ttl : scope_org_ttl)
29
+ end
30
+ cache_set(key, status, ttl: ttl) if cache_connected?
31
+ local_cache_set(key, status, ttl: ttl) if local_cache_connected?
32
+ end
33
+
34
+ def rate_limited?(fingerprint:)
35
+ entry = scope_cache_get("github:rate_limit:#{fingerprint}")
36
+ return false unless entry
37
+
38
+ entry[:reset_at] > Time.now
39
+ end
40
+
41
+ def mark_rate_limited(fingerprint:, reset_at:)
42
+ ttl = [(reset_at - Time.now).ceil, 1].max
43
+ value = { reset_at: reset_at, remaining: 0 }
44
+ cache_set("github:rate_limit:#{fingerprint}", value, ttl: ttl) if cache_connected?
45
+ local_cache_set("github:rate_limit:#{fingerprint}", value, ttl: ttl) if local_cache_connected?
46
+ end
47
+
48
+ def invalidate_scope(fingerprint:, owner:, repo: nil)
49
+ key = repo ? "github:scope:#{fingerprint}:#{owner}/#{repo}" : "github:scope:#{fingerprint}:#{owner}"
50
+ cache_delete(key) if cache_connected?
51
+ local_cache_delete(key) if local_cache_connected?
52
+ end
53
+
54
+ private
55
+
56
+ def scope_cache_get(key)
57
+ if cache_connected?
58
+ result = cache_get(key)
59
+ return result if result
60
+ end
61
+ local_cache_get(key) if local_cache_connected?
62
+ end
63
+
64
+ def scope_org_ttl
65
+ return 3600 unless defined?(Legion::Settings)
66
+
67
+ Legion::Settings.dig(:github, :scope_registry, :org_ttl) || 3600
68
+ rescue StandardError => _e
69
+ 3600
70
+ end
71
+
72
+ def scope_repo_ttl
73
+ return 300 unless defined?(Legion::Settings)
74
+
75
+ Legion::Settings.dig(:github, :scope_registry, :repo_ttl) || 300
76
+ rescue StandardError => _e
77
+ 300
78
+ end
79
+
80
+ def scope_denied_ttl
81
+ return 300 unless defined?(Legion::Settings)
82
+
83
+ Legion::Settings.dig(:github, :scope_registry, :denied_ttl) || 300
84
+ rescue StandardError => _e
85
+ 300
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'legion/cache/helper'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Github
9
+ module Helpers
10
+ module TokenCache
11
+ include Legion::Cache::Helper
12
+
13
+ TOKEN_BUFFER_SECONDS = 300
14
+
15
+ def store_token(token:, auth_type:, expires_at:, installation_id: nil, metadata: {}, **)
16
+ entry = { token: token, auth_type: auth_type,
17
+ expires_at: expires_at.respond_to?(:iso8601) ? expires_at.iso8601 : expires_at,
18
+ installation_id: installation_id, metadata: metadata }
19
+ ttl = [(expires_at.respond_to?(:to_i) ? expires_at.to_i - Time.now.to_i : 3600), 60].max
20
+ key = token_cache_key(auth_type, installation_id)
21
+ cache_set(key, entry, ttl: ttl) if cache_connected?
22
+ local_cache_set(key, entry, ttl: ttl) if local_cache_connected?
23
+ end
24
+
25
+ def fetch_token(auth_type:, installation_id: nil, **)
26
+ key = token_cache_key(auth_type, installation_id)
27
+ entry = token_cache_read(key)
28
+
29
+ entry = token_cache_read(token_cache_key(auth_type, nil)) if entry.nil? && installation_id
30
+
31
+ return nil unless entry
32
+
33
+ expires = begin
34
+ Time.parse(entry[:expires_at].to_s)
35
+ rescue StandardError => _e
36
+ nil
37
+ end
38
+ return nil if expires && expires < Time.now + TOKEN_BUFFER_SECONDS
39
+
40
+ entry
41
+ end
42
+
43
+ def mark_rate_limited(auth_type:, reset_at:, **)
44
+ entry = { reset_at: reset_at.respond_to?(:iso8601) ? reset_at.iso8601 : reset_at }
45
+ ttl = [(reset_at.respond_to?(:to_i) ? reset_at.to_i - Time.now.to_i : 300), 10].max
46
+ key = "github:rate_limit:#{auth_type}"
47
+ cache_set(key, entry, ttl: ttl) if cache_connected?
48
+ local_cache_set(key, entry, ttl: ttl) if local_cache_connected?
49
+ end
50
+
51
+ def rate_limited?(auth_type:, **)
52
+ key = "github:rate_limit:#{auth_type}"
53
+ entry = if cache_connected?
54
+ cache_get(key)
55
+ elsif local_cache_connected?
56
+ local_cache_get(key)
57
+ end
58
+ return false unless entry
59
+
60
+ reset = begin
61
+ Time.parse(entry[:reset_at].to_s)
62
+ rescue StandardError => _e
63
+ nil
64
+ end
65
+ reset.nil? || reset > Time.now
66
+ end
67
+
68
+ private
69
+
70
+ def token_cache_key(auth_type, installation_id)
71
+ base = "github:token:#{auth_type}"
72
+ installation_id ? "#{base}:#{installation_id}" : base
73
+ end
74
+
75
+ def token_cache_read(key)
76
+ if cache_connected?
77
+ cache_get(key)
78
+ elsif local_cache_connected?
79
+ local_cache_get(key)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Github
8
+ module Middleware
9
+ class CredentialFallback < ::Faraday::Middleware
10
+ RETRYABLE_STATUSES = [403, 429].freeze
11
+ IDEMPOTENT_METHODS = %w[GET HEAD OPTIONS PUT DELETE].freeze
12
+
13
+ def initialize(app, resolver: nil)
14
+ super(app)
15
+ @resolver = resolver
16
+ end
17
+
18
+ def call(env)
19
+ response = @app.call(env)
20
+ return response unless should_retry?(response)
21
+
22
+ retries = 0
23
+ max = @resolver.respond_to?(:max_fallback_retries) ? @resolver.max_fallback_retries : 3
24
+
25
+ while retries < max && should_retry?(response)
26
+ notify_resolver(response)
27
+
28
+ owner, repo = extract_owner_repo_from_env(env)
29
+ next_credential = @resolver&.resolve_next_credential(owner: owner, repo: repo)
30
+ break unless next_credential
31
+
32
+ env[:request_headers]['Authorization'] = "Bearer #{next_credential[:token]}"
33
+
34
+ response = @app.call(env)
35
+ retries += 1
36
+ end
37
+
38
+ response
39
+ end
40
+
41
+ private
42
+
43
+ def should_retry?(response)
44
+ return false unless @resolver.respond_to?(:credential_fallback?)
45
+ return false unless @resolver.credential_fallback?
46
+ return false unless IDEMPOTENT_METHODS.include?(response.env[:method].to_s.upcase)
47
+
48
+ RETRYABLE_STATUSES.include?(response.status)
49
+ end
50
+
51
+ def extract_owner_repo_from_env(env)
52
+ path = env.url&.path.to_s
53
+ match = path.match(%r{^/repos/([^/]+)/([^/]+)})
54
+ match ? [match[1], match[2]] : [nil, nil]
55
+ end
56
+
57
+ def notify_resolver(response)
58
+ if response.status == 429 && @resolver.respond_to?(:on_rate_limit)
59
+ reset = response.headers['x-ratelimit-reset']
60
+ reset_at = reset ? Time.at(reset.to_i) : Time.now + 60
61
+ @resolver.on_rate_limit(remaining: 0, reset_at: reset_at,
62
+ status: 429, url: response.env.url.to_s)
63
+ elsif response.status == 403 && @resolver.respond_to?(:on_scope_denied)
64
+ @resolver.on_scope_denied(status: 403, url: response.env.url.to_s,
65
+ path: response.env.url.path)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ Faraday::Middleware.register_middleware(
75
+ github_credential_fallback: Legion::Extensions::Github::Middleware::CredentialFallback
76
+ )
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Github
8
+ module Middleware
9
+ class RateLimit < ::Faraday::Middleware
10
+ def initialize(app, handler: nil)
11
+ super(app)
12
+ @handler = handler
13
+ end
14
+
15
+ def on_complete(env)
16
+ remaining = env.response_headers['x-ratelimit-remaining']
17
+ reset = env.response_headers['x-ratelimit-reset']
18
+ return unless remaining
19
+
20
+ remaining_int = remaining.to_i
21
+ return unless remaining_int.zero? || env.status == 429
22
+ return unless @handler.respond_to?(:on_rate_limit)
23
+
24
+ reset_at = reset ? Time.at(reset.to_i) : Time.now + 60
25
+ @handler.on_rate_limit(
26
+ remaining: remaining_int,
27
+ reset_at: reset_at,
28
+ status: env.status,
29
+ url: env.url.to_s
30
+ )
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ Faraday::Response.register_middleware(
39
+ github_rate_limit: Legion::Extensions::Github::Middleware::RateLimit
40
+ )
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Github
8
+ module Middleware
9
+ class ScopeProbe < ::Faraday::Middleware
10
+ REPO_PATH_PATTERN = %r{^/repos/([^/]+)/([^/]+)}
11
+
12
+ def initialize(app, handler: nil)
13
+ super(app)
14
+ @handler = handler
15
+ end
16
+
17
+ def on_complete(env)
18
+ return unless @handler
19
+ return unless env.url.path.match?(REPO_PATH_PATTERN)
20
+
21
+ info = { status: env.status, url: env.url.to_s, path: env.url.path }
22
+
23
+ if [403, 404].include?(env.status)
24
+ @handler.on_scope_denied(info) if @handler.respond_to?(:on_scope_denied)
25
+ elsif env.status >= 200 && env.status < 300
26
+ @handler.on_scope_authorized(info) if @handler.respond_to?(:on_scope_authorized)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ Faraday::Response.register_middleware(
36
+ github_scope_probe: Legion::Extensions::Github::Middleware::ScopeProbe
37
+ )