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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +3 -3
- data/.rubocop.yml +2 -53
- data/CHANGELOG.md +55 -0
- data/CLAUDE.md +45 -19
- data/Gemfile +1 -0
- data/README.md +155 -83
- data/lex-github.gemspec +2 -0
- data/lib/legion/extensions/github/app/actor/token_refresh.rb +68 -0
- data/lib/legion/extensions/github/app/actor/webhook_poller.rb +65 -0
- data/lib/legion/extensions/github/app/hooks/setup.rb +19 -0
- data/lib/legion/extensions/github/app/hooks/webhook.rb +19 -0
- data/lib/legion/extensions/github/app/runners/auth.rb +48 -0
- data/lib/legion/extensions/github/app/runners/credential_store.rb +46 -0
- data/lib/legion/extensions/github/app/runners/installations.rb +56 -0
- data/lib/legion/extensions/github/app/runners/manifest.rb +65 -0
- data/lib/legion/extensions/github/app/runners/webhooks.rb +118 -0
- data/lib/legion/extensions/github/app/transport/exchanges/app.rb +17 -0
- data/lib/legion/extensions/github/app/transport/messages/event.rb +18 -0
- data/lib/legion/extensions/github/app/transport/queues/auth.rb +18 -0
- data/lib/legion/extensions/github/app/transport/queues/webhooks.rb +18 -0
- data/lib/legion/extensions/github/cli/app.rb +57 -0
- data/lib/legion/extensions/github/cli/auth.rb +99 -0
- data/lib/legion/extensions/github/client.rb +24 -0
- data/lib/legion/extensions/github/errors.rb +44 -0
- data/lib/legion/extensions/github/helpers/browser_auth.rb +106 -0
- data/lib/legion/extensions/github/helpers/cache.rb +99 -0
- data/lib/legion/extensions/github/helpers/callback_server.rb +89 -0
- data/lib/legion/extensions/github/helpers/client.rb +292 -2
- data/lib/legion/extensions/github/helpers/scope_registry.rb +91 -0
- data/lib/legion/extensions/github/helpers/token_cache.rb +86 -0
- data/lib/legion/extensions/github/middleware/credential_fallback.rb +76 -0
- data/lib/legion/extensions/github/middleware/rate_limit.rb +40 -0
- data/lib/legion/extensions/github/middleware/scope_probe.rb +37 -0
- data/lib/legion/extensions/github/oauth/actor/token_refresh.rb +76 -0
- data/lib/legion/extensions/github/oauth/hooks/callback.rb +19 -0
- data/lib/legion/extensions/github/oauth/runners/auth.rb +111 -0
- data/lib/legion/extensions/github/oauth/transport/exchanges/oauth.rb +17 -0
- data/lib/legion/extensions/github/oauth/transport/queues/auth.rb +18 -0
- data/lib/legion/extensions/github/runners/actions.rb +100 -0
- data/lib/legion/extensions/github/runners/branches.rb +8 -6
- data/lib/legion/extensions/github/runners/checks.rb +84 -0
- data/lib/legion/extensions/github/runners/comments.rb +15 -9
- data/lib/legion/extensions/github/runners/commits.rb +13 -8
- data/lib/legion/extensions/github/runners/contents.rb +6 -4
- data/lib/legion/extensions/github/runners/deployments.rb +76 -0
- data/lib/legion/extensions/github/runners/gists.rb +11 -6
- data/lib/legion/extensions/github/runners/issues.rb +18 -11
- data/lib/legion/extensions/github/runners/labels.rb +18 -11
- data/lib/legion/extensions/github/runners/organizations.rb +12 -10
- data/lib/legion/extensions/github/runners/pull_requests.rb +26 -16
- data/lib/legion/extensions/github/runners/releases.rb +89 -0
- data/lib/legion/extensions/github/runners/repositories.rb +19 -12
- data/lib/legion/extensions/github/runners/repository_webhooks.rb +76 -0
- data/lib/legion/extensions/github/runners/search.rb +13 -10
- data/lib/legion/extensions/github/runners/users.rb +14 -10
- data/lib/legion/extensions/github/version.rb +1 -1
- data/lib/legion/extensions/github.rb +23 -1
- 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
|