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,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/extensions/github/helpers/client'
4
+ require 'legion/extensions/github/helpers/cache'
4
5
  require 'legion/extensions/github/runners/repositories'
5
6
  require 'legion/extensions/github/runners/issues'
6
7
  require 'legion/extensions/github/runners/pull_requests'
@@ -13,12 +14,24 @@ require 'legion/extensions/github/runners/labels'
13
14
  require 'legion/extensions/github/runners/comments'
14
15
  require 'legion/extensions/github/runners/branches'
15
16
  require 'legion/extensions/github/runners/contents'
17
+ require 'legion/extensions/github/runners/actions'
18
+ require 'legion/extensions/github/runners/checks'
19
+ require 'legion/extensions/github/runners/releases'
20
+ require 'legion/extensions/github/runners/deployments'
21
+ require 'legion/extensions/github/runners/repository_webhooks'
22
+ require 'legion/extensions/github/app/runners/auth'
23
+ require 'legion/extensions/github/app/runners/webhooks'
24
+ require 'legion/extensions/github/app/runners/manifest'
25
+ require 'legion/extensions/github/app/runners/installations'
26
+ require 'legion/extensions/github/app/runners/credential_store'
27
+ require 'legion/extensions/github/oauth/runners/auth'
16
28
 
17
29
  module Legion
18
30
  module Extensions
19
31
  module Github
20
32
  class Client
21
33
  include Helpers::Client
34
+ include Helpers::Cache
22
35
  include Runners::Repositories
23
36
  include Runners::Issues
24
37
  include Runners::PullRequests
@@ -31,6 +44,17 @@ module Legion
31
44
  include Runners::Comments
32
45
  include Runners::Branches
33
46
  include Runners::Contents
47
+ include Runners::Actions
48
+ include Runners::Checks
49
+ include Runners::Releases
50
+ include Runners::Deployments
51
+ include Runners::RepositoryWebhooks
52
+ include App::Runners::Auth
53
+ include App::Runners::Webhooks
54
+ include App::Runners::Manifest
55
+ include App::Runners::Installations
56
+ include App::Runners::CredentialStore
57
+ include OAuth::Runners::Auth
34
58
 
35
59
  attr_reader :opts
36
60
 
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Github
6
+ class Error < StandardError; end
7
+
8
+ class RateLimitError < Error
9
+ attr_reader :reset_at, :credential_fingerprint
10
+
11
+ def initialize(message = 'GitHub API rate limit exceeded', reset_at: nil, credential_fingerprint: nil)
12
+ @reset_at = reset_at
13
+ @credential_fingerprint = credential_fingerprint
14
+ super(message)
15
+ end
16
+ end
17
+
18
+ class AuthorizationError < Error
19
+ attr_reader :owner, :repo, :attempted_sources
20
+
21
+ def initialize(message = 'No authorized credential available', owner: nil, repo: nil,
22
+ attempted_sources: [])
23
+ @owner = owner
24
+ @repo = repo
25
+ @attempted_sources = attempted_sources
26
+ super(message)
27
+ end
28
+ end
29
+
30
+ class ScopeDeniedError < Error
31
+ attr_reader :owner, :repo, :credential_fingerprint, :auth_type
32
+
33
+ def initialize(message = 'Credential not authorized for this scope',
34
+ owner: nil, repo: nil, credential_fingerprint: nil, auth_type: nil)
35
+ @owner = owner
36
+ @repo = repo
37
+ @credential_fingerprint = credential_fingerprint
38
+ @auth_type = auth_type
39
+ super(message)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -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