lex-github 0.2.5 → 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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/CLAUDE.md +45 -19
  4. data/README.md +155 -83
  5. data/lex-github.gemspec +2 -0
  6. data/lib/legion/extensions/github/app/actor/token_refresh.rb +68 -0
  7. data/lib/legion/extensions/github/app/actor/webhook_poller.rb +65 -0
  8. data/lib/legion/extensions/github/app/hooks/setup.rb +19 -0
  9. data/lib/legion/extensions/github/app/hooks/webhook.rb +19 -0
  10. data/lib/legion/extensions/github/app/runners/auth.rb +48 -0
  11. data/lib/legion/extensions/github/app/runners/credential_store.rb +46 -0
  12. data/lib/legion/extensions/github/app/runners/installations.rb +56 -0
  13. data/lib/legion/extensions/github/app/runners/manifest.rb +65 -0
  14. data/lib/legion/extensions/github/app/runners/webhooks.rb +118 -0
  15. data/lib/legion/extensions/github/app/transport/exchanges/app.rb +17 -0
  16. data/lib/legion/extensions/github/app/transport/messages/event.rb +18 -0
  17. data/lib/legion/extensions/github/app/transport/queues/auth.rb +18 -0
  18. data/lib/legion/extensions/github/app/transport/queues/webhooks.rb +18 -0
  19. data/lib/legion/extensions/github/cli/app.rb +57 -0
  20. data/lib/legion/extensions/github/cli/auth.rb +99 -0
  21. data/lib/legion/extensions/github/client.rb +24 -0
  22. data/lib/legion/extensions/github/errors.rb +44 -0
  23. data/lib/legion/extensions/github/helpers/browser_auth.rb +106 -0
  24. data/lib/legion/extensions/github/helpers/cache.rb +99 -0
  25. data/lib/legion/extensions/github/helpers/callback_server.rb +89 -0
  26. data/lib/legion/extensions/github/helpers/client.rb +292 -2
  27. data/lib/legion/extensions/github/helpers/scope_registry.rb +91 -0
  28. data/lib/legion/extensions/github/helpers/token_cache.rb +86 -0
  29. data/lib/legion/extensions/github/middleware/credential_fallback.rb +76 -0
  30. data/lib/legion/extensions/github/middleware/rate_limit.rb +40 -0
  31. data/lib/legion/extensions/github/middleware/scope_probe.rb +37 -0
  32. data/lib/legion/extensions/github/oauth/actor/token_refresh.rb +76 -0
  33. data/lib/legion/extensions/github/oauth/hooks/callback.rb +19 -0
  34. data/lib/legion/extensions/github/oauth/runners/auth.rb +111 -0
  35. data/lib/legion/extensions/github/oauth/transport/exchanges/oauth.rb +17 -0
  36. data/lib/legion/extensions/github/oauth/transport/queues/auth.rb +18 -0
  37. data/lib/legion/extensions/github/runners/actions.rb +100 -0
  38. data/lib/legion/extensions/github/runners/branches.rb +5 -3
  39. data/lib/legion/extensions/github/runners/checks.rb +84 -0
  40. data/lib/legion/extensions/github/runners/comments.rb +13 -7
  41. data/lib/legion/extensions/github/runners/commits.rb +11 -6
  42. data/lib/legion/extensions/github/runners/contents.rb +3 -1
  43. data/lib/legion/extensions/github/runners/deployments.rb +76 -0
  44. data/lib/legion/extensions/github/runners/gists.rb +9 -4
  45. data/lib/legion/extensions/github/runners/issues.rb +16 -9
  46. data/lib/legion/extensions/github/runners/labels.rb +16 -9
  47. data/lib/legion/extensions/github/runners/organizations.rb +10 -8
  48. data/lib/legion/extensions/github/runners/pull_requests.rb +24 -14
  49. data/lib/legion/extensions/github/runners/releases.rb +89 -0
  50. data/lib/legion/extensions/github/runners/repositories.rb +17 -10
  51. data/lib/legion/extensions/github/runners/repository_webhooks.rb +76 -0
  52. data/lib/legion/extensions/github/runners/search.rb +11 -8
  53. data/lib/legion/extensions/github/runners/users.rb +12 -8
  54. data/lib/legion/extensions/github/version.rb +1 -1
  55. data/lib/legion/extensions/github.rb +22 -0
  56. metadata +63 -1
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'rbconfig'
5
+ require 'legion/extensions/github/oauth/runners/auth'
6
+ require 'legion/extensions/github/helpers/callback_server'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module Github
11
+ module Helpers
12
+ class BrowserAuth
13
+ DEFAULT_SCOPES = 'repo admin:org admin:repo_hook read:user'
14
+
15
+ attr_reader :client_id, :client_secret, :scopes
16
+
17
+ def initialize(client_id:, client_secret:, scopes: DEFAULT_SCOPES, auth: nil, **)
18
+ @client_id = client_id
19
+ @client_secret = client_secret
20
+ @scopes = scopes
21
+ @auth = auth || Object.new.extend(OAuth::Runners::Auth)
22
+ end
23
+
24
+ def authenticate
25
+ if gui_available?
26
+ authenticate_browser
27
+ else
28
+ authenticate_device_code
29
+ end
30
+ end
31
+
32
+ def gui_available?
33
+ os = host_os
34
+ return true if /darwin|mswin|mingw/.match?(os)
35
+
36
+ !ENV['DISPLAY'].nil? || !ENV['WAYLAND_DISPLAY'].nil?
37
+ end
38
+
39
+ def open_browser(url)
40
+ cmd = case host_os
41
+ when /darwin/ then 'open'
42
+ when /linux/ then 'xdg-open'
43
+ when /mswin|mingw/ then 'start'
44
+ end
45
+ return false unless cmd
46
+
47
+ system(cmd, url)
48
+ end
49
+
50
+ private
51
+
52
+ def host_os
53
+ RbConfig::CONFIG['host_os']
54
+ end
55
+
56
+ def authenticate_browser
57
+ pkce = @auth.generate_pkce[:result]
58
+ state = SecureRandom.hex(32)
59
+
60
+ server = CallbackServer.new
61
+ server.start
62
+ callback_uri = server.redirect_uri
63
+
64
+ url = @auth.authorize_url(
65
+ client_id: client_id, redirect_uri: callback_uri,
66
+ scope: scopes, state: state,
67
+ code_challenge: pkce[:challenge],
68
+ code_challenge_method: pkce[:challenge_method]
69
+ )[:result]
70
+
71
+ return authenticate_device_code unless open_browser(url)
72
+
73
+ result = server.wait_for_callback(timeout: 120)
74
+
75
+ return { error: 'timeout', description: 'No callback received within timeout' } unless result&.dig(:code)
76
+
77
+ return { error: 'state_mismatch', description: 'CSRF state parameter mismatch' } unless result[:state] == state
78
+
79
+ @auth.exchange_code(
80
+ client_id: client_id, client_secret: client_secret,
81
+ code: result[:code], redirect_uri: callback_uri,
82
+ code_verifier: pkce[:verifier]
83
+ )
84
+ ensure
85
+ server&.shutdown
86
+ end
87
+
88
+ def authenticate_device_code
89
+ dc = @auth.request_device_code(client_id: client_id, scope: scopes)
90
+ return { error: dc[:error], description: dc[:description] } if dc[:error]
91
+
92
+ body = dc[:result]
93
+ warn "Go to: #{body[:verification_uri]}"
94
+ warn "Code: #{body[:user_code]}"
95
+ open_browser(body[:verification_uri]) if gui_available?
96
+
97
+ @auth.poll_device_code(
98
+ client_id: client_id,
99
+ device_code: body[:device_code]
100
+ )
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/cache/helper'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Github
8
+ module Helpers
9
+ module Cache
10
+ include Legion::Cache::Helper
11
+
12
+ DEFAULT_TTLS = {
13
+ repo: 600, issue: 120, pull_request: 60, commit: 86_400,
14
+ branch: 120, user: 3600, org: 3600, search: 60
15
+ }.freeze
16
+
17
+ DEFAULT_TTL = 300
18
+
19
+ def cached_get(cache_key, ttl: nil)
20
+ if cache_connected?
21
+ result = cache_get(cache_key)
22
+ return result if result
23
+ end
24
+
25
+ if local_cache_connected?
26
+ result = local_cache_get(cache_key)
27
+ return result if result
28
+ end
29
+
30
+ result = yield
31
+ effective_ttl = ttl || github_ttl_for(cache_key)
32
+ cache_set(cache_key, result, ttl: effective_ttl) if cache_connected?
33
+ local_cache_set(cache_key, result, ttl: effective_ttl) if local_cache_connected?
34
+ result
35
+ end
36
+
37
+ def cache_write(cache_key, value, ttl: nil)
38
+ effective_ttl = ttl || github_ttl_for(cache_key)
39
+ cache_set(cache_key, value, ttl: effective_ttl) if cache_connected?
40
+ local_cache_set(cache_key, value, ttl: effective_ttl) if local_cache_connected?
41
+ end
42
+
43
+ def cache_invalidate(cache_key)
44
+ cache_delete(cache_key) if cache_connected?
45
+ local_cache_delete(cache_key) if local_cache_connected?
46
+ end
47
+
48
+ def github_ttl_for(cache_key)
49
+ configured_ttls = github_cache_ttls
50
+ case cache_key
51
+ when /:commits:/ then configured_ttls[:commit]
52
+ when /:pulls:/ then configured_ttls[:pull_request]
53
+ when /:issues:/ then configured_ttls[:issue]
54
+ when /:branches:/ then configured_ttls[:branch]
55
+ when /\Agithub:user:/ then configured_ttls[:user]
56
+ when /\Agithub:org:/ then configured_ttls[:org]
57
+ when /\Agithub:repo:[^:]+\z/ then configured_ttls[:repo]
58
+ when /:search:/ then configured_ttls[:search]
59
+ else configured_ttls.fetch(:default, DEFAULT_TTL)
60
+ end
61
+ end
62
+
63
+ def cache_connected?
64
+ ::Legion::Cache.connected?
65
+ rescue StandardError => _e
66
+ false
67
+ end
68
+
69
+ def local_cache_connected?
70
+ false
71
+ end
72
+
73
+ def local_cache_get(_key)
74
+ nil
75
+ end
76
+
77
+ def local_cache_set(_key, _value, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument
78
+ nil
79
+ end
80
+
81
+ def local_cache_delete(_key)
82
+ nil
83
+ end
84
+
85
+ private
86
+
87
+ def github_cache_ttls
88
+ return DEFAULT_TTLS.merge(default: DEFAULT_TTL) unless defined?(Legion::Settings)
89
+
90
+ overrides = Legion::Settings.dig(:github, :cache, :ttls) || {}
91
+ DEFAULT_TTLS.merge(default: DEFAULT_TTL).merge(overrides.transform_keys(&:to_sym))
92
+ rescue StandardError => _e
93
+ DEFAULT_TTLS.merge(default: DEFAULT_TTL)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'uri'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Github
9
+ module Helpers
10
+ class CallbackServer
11
+ RESPONSE_HTML = <<~HTML
12
+ <html><body style="font-family:sans-serif;text-align:center;padding:40px;">
13
+ <h2>GitHub authentication complete</h2><p>You can close this window.</p></body></html>
14
+ HTML
15
+
16
+ attr_reader :port
17
+
18
+ def initialize
19
+ @server = nil
20
+ @port = nil
21
+ @result = nil
22
+ @mutex = Mutex.new
23
+ @cv = ConditionVariable.new
24
+ end
25
+
26
+ def start
27
+ @server = TCPServer.new('127.0.0.1', 0)
28
+ @port = @server.addr[1]
29
+ @thread = Thread.new { listen } # rubocop:disable ThreadSafety/NewThread
30
+ end
31
+
32
+ def wait_for_callback(timeout: 120)
33
+ @mutex.synchronize do
34
+ @cv.wait(@mutex, timeout) unless @result
35
+ @result
36
+ end
37
+ end
38
+
39
+ def shutdown
40
+ @server&.close
41
+ rescue StandardError => _e
42
+ nil
43
+ ensure
44
+ @thread&.join(2)
45
+ @thread&.kill
46
+ end
47
+
48
+ def redirect_uri
49
+ "http://127.0.0.1:#{@port}/callback"
50
+ end
51
+
52
+ private
53
+
54
+ def listen
55
+ loop do
56
+ client = @server.accept
57
+ request_line = client.gets
58
+ loop do
59
+ line = client.gets
60
+ break if line.nil? || line.strip.empty?
61
+ end
62
+
63
+ if request_line&.include?('/callback?')
64
+ query = request_line.split[1].split('?', 2).last
65
+ params = URI.decode_www_form(query).to_h
66
+
67
+ @mutex.synchronize do
68
+ @result = { code: params['code'], state: params['state'] }
69
+ @cv.broadcast
70
+ end
71
+ end
72
+
73
+ client.print "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n#{RESPONSE_HTML}"
74
+ client.close
75
+ break if @result
76
+ end
77
+ rescue IOError # rubocop:disable Legion/RescueLogging/NoCapture
78
+ nil
79
+ rescue StandardError => e
80
+ @mutex.synchronize do
81
+ @result ||= { error: e.message }
82
+ @cv.broadcast
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -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