diff --git a/CHANGELOG.md b/CHANGELOG.md index bc8ca0d..2905e6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog +## [Unreleased] + +### Added +- GitHub App authentication (JWT generation, installation tokens, manifest flow) +- OAuth delegated authentication (Authorization Code + PKCE, device code fallback) +- Scope-aware credential resolution chain (8 sources, rate limit + scope fallback) +- `ScopeRegistry` for caching credential-to-owner/repo authorization status +- `CredentialFallback` Faraday middleware (transparent 403/429 retry with next credential) +- `RateLimit` Faraday middleware with automatic credential exhaustion tracking +- `ScopeProbe` Faraday middleware for passive scope learning from API responses +- `Helpers::Cache` for two-tier API response caching (global Redis + local in-memory) +- `Helpers::TokenCache` for token lifecycle management with per-installation keying +- `App::Runners::Auth` (JWT generation, installation token exchange) +- `App::Runners::Webhooks` (signature verification, event parsing, scope invalidation) +- `App::Runners::Manifest` (GitHub App manifest flow) +- `App::Runners::Installations` (list, get, suspend, unsuspend, delete) +- `App::Runners::CredentialStore` (Vault persistence after manifest flow) +- `OAuth::Runners::Auth` (authorize_url, exchange_code, refresh, device_code, revoke) +- `Runners::Actions` (GitHub Actions workflow management) +- `Runners::Checks` (check runs and check suites) +- `Runners::Releases` (release and asset management) +- `Runners::Deployments` (deployment and status management) +- `Runners::RepositoryWebhooks` (programmatic webhook management) +- `Helpers::CallbackServer` for standalone OAuth redirect handling +- `Helpers::BrowserAuth` for browser-based OAuth with PKCE +- `CLI::Auth` for `legion lex exec github auth login/status` +- `CLI::App` for `legion lex exec github app setup` +- `RateLimitError`, `AuthorizationError`, `ScopeDeniedError` error classes +- `jwt` (~> 2.7) and `base64` (>= 0.1) runtime dependencies + +### Changed +- `Helpers::Client` now uses scope-aware credential resolution (`owner:`, `repo:` context) +- All existing runners forward `owner:` and `repo:` to `connection()` for scope-aware resolution +- All existing runners now include `Helpers::Cache` for two-tier API response caching +- `Client` class includes App and OAuth runner modules +- Version bump to 0.3.0 + +## [0.3.0] - 2026-03-30 + +### Added +- GitHub App authentication (JWT generation, installation tokens via `App::Runners::Auth`) +- OAuth delegated user authentication (Authorization Code + PKCE, device code flow via `OAuth::Runners::Auth`) +- GitHub App manifest flow for streamlined app registration (`App::Runners::Manifest`) +- Webhook signature verification and event parsing (`App::Runners::Webhooks`) +- 8-source credential resolution chain: Vault delegated → Settings delegated → Vault App → Settings App → Vault PAT → Settings PAT → GH CLI → ENV (`Helpers::Client`) +- Rate limit fallback across credential sources with scope-aware skipping (`Helpers::ScopeRegistry`) +- Token lifecycle management with expiry tracking and rate limit recording (`Helpers::TokenCache`) +- Two-tier API response caching (global Redis + local in-memory) with configurable per-resource TTLs (`Helpers::Cache`) +- `jwt` (~> 2.7) and `base64` (>= 0.1) runtime dependencies + ## [0.2.5] - 2026-03-30 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index b502554..c0239d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,11 +6,11 @@ ## Purpose -Legion Extension that connects LegionIO to GitHub. Provides runners for interacting with the GitHub REST API covering repositories, issues, pull requests, users, organizations, gists, search, labels, comments, commits, branches, and file contents. +Legion Extension that connects LegionIO to GitHub. Provides runners for interacting with the GitHub REST API covering repositories, issues, pull requests, users, organizations, gists, search, labels, comments, commits, branches, file contents, GitHub App authentication, OAuth delegated auth, and webhook handling. **GitHub**: https://github.com/LegionIO/lex-github **License**: MIT -**Version**: 0.2.4 +**Version**: 0.3.0 ## Architecture @@ -29,40 +29,66 @@ Legion::Extensions::Github │ ├── Commits # List, get, compare commits │ ├── Branches # Create branches via Git Data API │ └── Contents # Commit multiple files via Git Data API +├── App/ +│ └── Runners/ +│ ├── Auth # JWT generation, installation token exchange, list/get installations +│ ├── Webhooks # HMAC signature verification, event parsing +│ ├── Manifest # GitHub App manifest flow (generate, exchange code, manifest URL) +│ └── Installations # Full installation management (list repos, suspend, delete) +├── OAuth/ +│ └── Runners/ +│ └── Auth # PKCE + Authorization Code, device code, refresh, revoke ├── Helpers/ -│ └── Client # Faraday connection builder (GitHub API v3) +│ ├── Client # 8-source scope-aware credential resolution chain + Faraday builder +│ ├── Cache # Two-tier read-through/write-through API response caching +│ ├── TokenCache # Token lifecycle management (store, fetch, expiry, rate limits) +│ └── ScopeRegistry # Credential-to-scope authorization cache (org/repo level) └── Client # Standalone client class (includes all runners) ``` +### Credential Resolution Chain (8 sources, in priority order) + +1. `resolve_vault_delegated` — OAuth user token from Vault (`github/oauth/delegated/token`) +2. `resolve_settings_delegated` — OAuth user token from `Legion::Settings[:github][:oauth][:access_token]` +3. `resolve_vault_app` — GitHub App installation token (requires cached token from `TokenCache`) +4. `resolve_settings_app` — App token from settings (requires cached token) +5. `resolve_vault_pat` — PAT from Vault (`github/token`) +6. `resolve_settings_pat` — PAT from `Legion::Settings[:github][:token]` +7. `resolve_gh_cli` — Token from `gh auth token` CLI command (cached 300s) +8. `resolve_env` — `GITHUB_TOKEN` environment variable + +Rate-limited credentials are skipped. Scope-denied credentials (for a given owner/repo) are skipped. + ## Dependencies | Gem | Purpose | |-----|---------| | `faraday` | HTTP client for GitHub REST API | +| `jwt` (~> 2.7) | RS256 JWT generation for GitHub App authentication | +| `base64` (>= 0.1) | PKCE code challenge computation | +| `legion-cache` | Two-tier caching (global Redis + local in-memory) | +| `legion-crypt` | Vault secret resolution for credentials | +| `legion-settings` | Settings-based credential resolution | ## Key Files | File | Purpose | |------|---------| -| `lib/legion/extensions/github.rb` | Extension entry point, requires all runners | -| `lib/legion/extensions/github/client.rb` | Standalone client class | -| `lib/legion/extensions/github/helpers/client.rb` | Faraday connection builder | -| `lib/legion/extensions/github/runners/repositories.rb` | Repo CRUD, branches, tags | -| `lib/legion/extensions/github/runners/issues.rb` | Issue CRUD | -| `lib/legion/extensions/github/runners/pull_requests.rb` | PR CRUD, merge, files, reviews | -| `lib/legion/extensions/github/runners/users.rb` | User lookup, followers/following | -| `lib/legion/extensions/github/runners/organizations.rb` | Org info, repos, members | -| `lib/legion/extensions/github/runners/gists.rb` | Gist CRUD | -| `lib/legion/extensions/github/runners/search.rb` | Search repos/issues/users/code | -| `lib/legion/extensions/github/runners/labels.rb` | Label CRUD, add/remove on issues | -| `lib/legion/extensions/github/runners/comments.rb` | Issue/PR comment CRUD | -| `lib/legion/extensions/github/runners/commits.rb` | List, get, compare commits | -| `lib/legion/extensions/github/runners/branches.rb` | Create branches via Git Data API | -| `lib/legion/extensions/github/runners/contents.rb` | Commit multiple files via Git Data API | +| `lib/legion/extensions/github.rb` | Extension entry point, requires all modules | +| `lib/legion/extensions/github/client.rb` | Standalone client class (includes all runners) | +| `lib/legion/extensions/github/helpers/client.rb` | Credential resolution chain + Faraday builder | +| `lib/legion/extensions/github/helpers/cache.rb` | Two-tier API response caching | +| `lib/legion/extensions/github/helpers/token_cache.rb` | Token lifecycle + rate limit tracking | +| `lib/legion/extensions/github/helpers/scope_registry.rb` | Credential-to-scope authorization cache | +| `lib/legion/extensions/github/app/runners/auth.rb` | JWT generation, installation tokens | +| `lib/legion/extensions/github/app/runners/webhooks.rb` | Webhook signature verification, event parsing | +| `lib/legion/extensions/github/app/runners/manifest.rb` | GitHub App manifest registration flow | +| `lib/legion/extensions/github/app/runners/installations.rb` | Installation management | +| `lib/legion/extensions/github/oauth/runners/auth.rb` | OAuth PKCE, device code, token refresh/revoke | ## Testing -57 specs across 14 spec files. +131 specs across 23 spec files (growing with each new runner). ```bash bundle install diff --git a/README.md b/README.md index 419db6e..939a8b3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # lex-github -GitHub integration for [LegionIO](https://github.com/LegionIO/LegionIO). Provides runners for interacting with the GitHub REST API including repositories, issues, pull requests, labels, comments, commits, users, organizations, gists, and search. +GitHub integration for [LegionIO](https://github.com/LegionIO/LegionIO). Provides runners for interacting with the GitHub REST API including repositories, issues, pull requests, labels, comments, commits, users, organizations, gists, search, Actions workflows, checks, releases, deployments, webhooks, and full GitHub App + OAuth authentication. ## Installation @@ -8,6 +8,62 @@ GitHub integration for [LegionIO](https://github.com/LegionIO/LegionIO). Provide gem install lex-github ``` +## Authentication + +### Personal Access Token (PAT) + +```ruby +client = Legion::Extensions::Github::Client.new(token: 'ghp_your_token') +``` + +### GitHub App (JWT + Installation Token) + +```ruby +# Set in Legion::Settings +# github.app.app_id: '12345' +# github.app.private_key_path: '/path/to/private-key.pem' +# github.app.installation_id: '67890' + +client = Legion::Extensions::Github::Client.new +# Credentials resolved automatically from settings +``` + +Or via Vault: +``` +vault write secret/github/app/app_id value='12345' +vault write secret/github/app/private_key value='-----BEGIN RSA PRIVATE KEY-----...' +vault write secret/github/app/installation_id value='67890' +``` + +### OAuth Delegated (browser-based login) + +```bash +# CLI login +legion lex exec github auth login +``` + +```ruby +# Programmatic (using the CLI::Auth mixin) +auth = Object.new.extend(Legion::Extensions::Github::CLI::Auth) +result = auth.login(client_id: 'Iv1.abc', client_secret: 'secret') +# Opens browser → PKCE flow; token can be stored in Vault if Vault integration is configured +``` + +### Credential Resolution Chain + +lex-github resolves credentials automatically in priority order: + +1. Vault OAuth delegated token +2. Settings OAuth access token +3. Vault GitHub App installation token (auto-generates on miss) +4. Settings GitHub App installation token +5. Vault PAT +6. Settings PAT (`github.token`) +7. `gh` CLI token (`gh auth token`) +8. `GITHUB_TOKEN` environment variable + +Rate-limited credentials are skipped automatically. Scope-denied credentials (`403`) are skipped for the specific owner/repo and retried with the next source. + ## Standalone Usage ```ruby @@ -28,117 +84,133 @@ client.create_issue(owner: 'octocat', repo: 'Hello-World', title: 'Bug report') client.list_pull_requests(owner: 'octocat', repo: 'Hello-World') client.create_pull_request(owner: 'octocat', repo: 'Hello-World', title: 'Fix', head: 'fix-branch', base: 'main') client.merge_pull_request(owner: 'octocat', repo: 'Hello-World', pull_number: 42) -client.list_pull_request_reviews(owner: 'octocat', repo: 'Hello-World', pull_number: 42) - -# Labels -client.list_labels(owner: 'octocat', repo: 'Hello-World') -client.create_label(owner: 'octocat', repo: 'Hello-World', name: 'bug', color: 'd73a4a') -client.add_labels_to_issue(owner: 'octocat', repo: 'Hello-World', issue_number: 1, labels: ['bug']) - -# Comments -client.list_comments(owner: 'octocat', repo: 'Hello-World', issue_number: 1) -client.create_comment(owner: 'octocat', repo: 'Hello-World', issue_number: 1, body: 'Looks good!') -client.update_comment(owner: 'octocat', repo: 'Hello-World', comment_id: 42, body: 'Updated text') -client.delete_comment(owner: 'octocat', repo: 'Hello-World', comment_id: 42) - -# Users -client.get_authenticated_user -client.get_user(username: 'octocat') - -# Organizations -client.get_org(org: 'github') -client.list_org_repos(org: 'github') - -# Gists -client.list_gists -client.create_gist(files: { 'hello.rb' => { content: 'puts "hello"' } }) - -# Search -client.search_repositories(query: 'ruby language:ruby') -client.search_issues(query: 'bug label:bug') + +# GitHub Actions +client.list_workflows(owner: 'octocat', repo: 'Hello-World') +client.trigger_workflow(owner: 'octocat', repo: 'Hello-World', workflow_id: 'ci.yml', ref: 'main') +client.get_workflow_run(owner: 'octocat', repo: 'Hello-World', run_id: 12345) + +# Check Runs (CI status) +client.create_check_run(owner: 'octocat', repo: 'Hello-World', name: 'CI', head_sha: 'abc123') +client.update_check_run(owner: 'octocat', repo: 'Hello-World', check_run_id: 1, + status: 'completed', conclusion: 'success') + +# Releases +client.list_releases(owner: 'octocat', repo: 'Hello-World') +client.create_release(owner: 'octocat', repo: 'Hello-World', tag_name: 'v1.0.0') + +# Deployments +client.create_deployment(owner: 'octocat', repo: 'Hello-World', ref: 'main', environment: 'production') +client.create_deployment_status(owner: 'octocat', repo: 'Hello-World', deployment_id: 1, state: 'success') + +# Webhooks +client.list_webhooks(owner: 'octocat', repo: 'Hello-World') +client.create_webhook(owner: 'octocat', repo: 'Hello-World', + config: { url: 'https://example.com/webhook', content_type: 'json' }) + +# GitHub App +jwt_token = client.generate_jwt(app_id: '12345', private_key: File.read('private-key.pem')) +client.create_installation_token(jwt: jwt_token, installation_id: '67890') +client.list_installations(jwt: jwt_token) + +# Webhook verification +client.verify_signature(payload: request.body.read, signature: request.env['HTTP_X_HUB_SIGNATURE_256'], + secret: 'webhook_secret') ``` ## Functions ### Repositories -- `list_repos` - List repositories for a user -- `get_repo` - Get a single repository -- `create_repo` - Create a new repository -- `update_repo` - Update repository settings -- `delete_repo` - Delete a repository -- `list_branches` - List branches -- `list_tags` - List tags +- `list_repos`, `get_repo`, `create_repo`, `update_repo`, `delete_repo`, `list_branches`, `list_tags` ### Issues -- `list_issues` - List issues for a repository -- `get_issue` - Get a single issue -- `create_issue` - Create a new issue -- `update_issue` - Update an issue -- `list_issue_comments` - List comments on an issue -- `create_issue_comment` - Create a comment on an issue +- `list_issues`, `get_issue`, `create_issue`, `update_issue`, `list_issue_comments`, `create_issue_comment` ### Pull Requests -- `list_pull_requests` - List pull requests -- `get_pull_request` - Get a single pull request -- `create_pull_request` - Create a pull request -- `update_pull_request` - Update a pull request -- `merge_pull_request` - Merge a pull request -- `list_pull_request_commits` - List commits on a PR -- `list_pull_request_files` - List files changed in a PR -- `list_pull_request_reviews` - List reviews on a PR +- `list_pull_requests`, `get_pull_request`, `create_pull_request`, `update_pull_request`, `merge_pull_request` +- `list_pull_request_commits`, `list_pull_request_files`, `list_pull_request_reviews`, `create_review` ### Labels -- `list_labels` - List labels for a repository -- `get_label` - Get a single label by name -- `create_label` - Create a new label -- `update_label` - Update a label -- `delete_label` - Delete a label -- `add_labels_to_issue` - Add labels to an issue -- `remove_label_from_issue` - Remove a label from an issue +- `list_labels`, `get_label`, `create_label`, `update_label`, `delete_label` +- `add_labels_to_issue`, `remove_label_from_issue` ### Comments -- `list_comments` - List comments on an issue or PR -- `get_comment` - Get a single comment by ID -- `create_comment` - Create a comment on an issue or PR -- `update_comment` - Update a comment -- `delete_comment` - Delete a comment +- `list_comments`, `get_comment`, `create_comment`, `update_comment`, `delete_comment` ### Users -- `get_authenticated_user` - Get the authenticated user -- `get_user` - Get a user by username -- `list_followers` - List followers -- `list_following` - List following +- `get_authenticated_user`, `get_user`, `list_followers`, `list_following` ### Organizations -- `list_user_orgs` - List organizations for a user -- `get_org` - Get an organization -- `list_org_repos` - List repos in an organization -- `list_org_members` - List organization members +- `list_user_orgs`, `get_org`, `list_org_repos`, `list_org_members` ### Gists -- `list_gists` - List gists -- `get_gist` - Get a single gist -- `create_gist` - Create a gist -- `update_gist` - Update a gist -- `delete_gist` - Delete a gist +- `list_gists`, `get_gist`, `create_gist`, `update_gist`, `delete_gist` ### Search -- `search_repositories` - Search repositories -- `search_issues` - Search issues and PRs -- `search_users` - Search users -- `search_code` - Search code +- `search_repositories`, `search_issues`, `search_users`, `search_code` ### Commits -- `list_commits` - List commits on a repository -- `get_commit` - Get a single commit by SHA -- `compare_commits` - Compare two commits, branches, or tags +- `list_commits`, `get_commit`, `compare_commits` + +### Branches +- `create_branch` + +### Contents +- `commit_files` + +### GitHub Actions +- `list_workflows`, `get_workflow`, `list_workflow_runs`, `get_workflow_run`, `trigger_workflow` +- `cancel_workflow_run`, `rerun_workflow`, `rerun_failed_jobs` +- `list_workflow_run_jobs`, `download_workflow_run_logs`, `list_workflow_run_artifacts` + +### Checks +- `create_check_run`, `update_check_run`, `get_check_run` +- `list_check_runs_for_ref`, `list_check_suites_for_ref`, `get_check_suite` +- `rerequest_check_suite`, `list_check_run_annotations` + +### Releases +- `list_releases`, `get_release`, `get_latest_release`, `get_release_by_tag` +- `create_release`, `update_release`, `delete_release` +- `list_release_assets`, `delete_release_asset` + +### Deployments +- `list_deployments`, `get_deployment`, `create_deployment`, `delete_deployment` +- `list_deployment_statuses`, `create_deployment_status`, `get_deployment_status` + +### Repository Webhooks +- `list_webhooks`, `get_webhook`, `create_webhook`, `update_webhook`, `delete_webhook` +- `ping_webhook`, `test_webhook`, `list_webhook_deliveries` + +### GitHub App Auth +- `generate_jwt`, `create_installation_token`, `list_installations`, `get_installation` +- `generate_manifest`, `exchange_manifest_code`, `manifest_url` +- `verify_signature`, `parse_event`, `receive_event` + +### OAuth +- `generate_pkce`, `authorize_url`, `exchange_code`, `refresh_token` +- `request_device_code`, `poll_device_code`, `revoke_token` + +## Error Handling + +```ruby +begin + client.get_repo(owner: 'org', repo: 'private-repo') +rescue Legion::Extensions::Github::RateLimitError => e + puts "Rate limited, resets at: #{e.reset_at}" +rescue Legion::Extensions::Github::ScopeDeniedError => e + puts "No credential authorized for #{e.owner}/#{e.repo}" +rescue Legion::Extensions::Github::AuthorizationError => e + puts "All credentials exhausted: #{e.attempted_sources}" +end +``` ## Requirements - Ruby >= 3.4 - [LegionIO](https://github.com/LegionIO/LegionIO) framework (optional for standalone client usage) -- GitHub personal access token or app token - `faraday` >= 2.0 +- `jwt` ~> 2.7 (for GitHub App authentication) +- `base64` >= 0.1 (for OAuth PKCE) ## License diff --git a/lex-github.gemspec b/lex-github.gemspec index 46f470d..3922178 100644 --- a/lex-github.gemspec +++ b/lex-github.gemspec @@ -26,7 +26,9 @@ Gem::Specification.new do |spec| end spec.require_paths = ['lib'] + spec.add_dependency 'base64', '>= 0.1' spec.add_dependency 'faraday', '>= 2.0' + spec.add_dependency 'jwt', '~> 2.7' spec.add_dependency 'legion-cache', '>= 1.3.11' spec.add_dependency 'legion-crypt', '>= 1.4.9' spec.add_dependency 'legion-data', '>= 1.4.17' diff --git a/lib/legion/extensions/github.rb b/lib/legion/extensions/github.rb index b2abdea..15fa885 100644 --- a/lib/legion/extensions/github.rb +++ b/lib/legion/extensions/github.rb @@ -1,7 +1,24 @@ # frozen_string_literal: true require 'legion/extensions/github/version' +require 'legion/extensions/github/errors' +require 'legion/extensions/github/middleware/rate_limit' +require 'legion/extensions/github/middleware/scope_probe' +require 'legion/extensions/github/middleware/credential_fallback' require 'legion/extensions/github/helpers/client' +require 'legion/extensions/github/helpers/cache' +require 'legion/extensions/github/helpers/token_cache' +require 'legion/extensions/github/helpers/scope_registry' +require 'legion/extensions/github/app/runners/auth' +require 'legion/extensions/github/app/runners/webhooks' +require 'legion/extensions/github/app/runners/manifest' +require 'legion/extensions/github/app/runners/installations' +require 'legion/extensions/github/app/runners/credential_store' +require 'legion/extensions/github/oauth/runners/auth' +require 'legion/extensions/github/helpers/callback_server' +require 'legion/extensions/github/helpers/browser_auth' +require 'legion/extensions/github/cli/auth' +require 'legion/extensions/github/cli/app' require 'legion/extensions/github/runners/repositories' require 'legion/extensions/github/runners/issues' require 'legion/extensions/github/runners/pull_requests' @@ -14,6 +31,11 @@ require 'legion/extensions/github/runners/comments' require 'legion/extensions/github/runners/branches' require 'legion/extensions/github/runners/contents' +require 'legion/extensions/github/runners/actions' +require 'legion/extensions/github/runners/checks' +require 'legion/extensions/github/runners/releases' +require 'legion/extensions/github/runners/deployments' +require 'legion/extensions/github/runners/repository_webhooks' require 'legion/extensions/github/client' module Legion diff --git a/lib/legion/extensions/github/app/actor/token_refresh.rb b/lib/legion/extensions/github/app/actor/token_refresh.rb new file mode 100644 index 0000000..80e16ac --- /dev/null +++ b/lib/legion/extensions/github/app/actor/token_refresh.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Github + module App + module Actor + class TokenRefresh < Legion::Extensions::Actors::Every # rubocop:disable Legion/Extension/SelfContainedActorRunnerClass,Legion/Extension/EveryActorRequiresTime + def use_runner? = false + def check_subtask? = false + def generate_task? = false + + def time + 45 * 60 + end + + # rubocop:disable Legion/Extension/ActorEnabledSideEffects + def enabled? + defined?(Legion::Extensions::Github::Helpers::TokenCache) + rescue StandardError => _e + false + end + # rubocop:enable Legion/Extension/ActorEnabledSideEffects + + def manual + log.info('App::Actor::TokenRefresh: refreshing installation token') + settings = github_app_settings + return unless settings[:app_id] && settings[:private_key] && settings[:installation_id] + + auth = Object.new.extend(Legion::Extensions::Github::App::Runners::Auth) + jwt_result = auth.generate_jwt(app_id: settings[:app_id], private_key: settings[:private_key]) + return unless jwt_result[:result] + + token_result = auth.create_installation_token( + jwt: jwt_result[:result], + installation_id: settings[:installation_id] + ) + return unless token_result.dig(:result, 'token') + + token_cache.store_token( + token: token_result[:result]['token'], + auth_type: :app_installation, + expires_at: Time.parse(token_result[:result]['expires_at']) + ) + log.info('App::Actor::TokenRefresh: installation token refreshed') + rescue StandardError => e + log.error("App::Actor::TokenRefresh: #{e.message}") + end + + private + + def github_app_settings + return {} unless defined?(Legion::Settings) + + Legion::Settings[:github]&.dig(:app) || {} + rescue StandardError => _e + {} + end + + def token_cache + Object.new.extend(Legion::Extensions::Github::Helpers::TokenCache) + end + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/app/actor/webhook_poller.rb b/lib/legion/extensions/github/app/actor/webhook_poller.rb new file mode 100644 index 0000000..b51d6dc --- /dev/null +++ b/lib/legion/extensions/github/app/actor/webhook_poller.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Github + module App + module Actor + class WebhookPoller < Legion::Extensions::Actors::Poll # rubocop:disable Legion/Extension/SelfContainedActorRunnerClass,Legion/Extension/EveryActorRequiresTime + def use_runner? = false + def check_subtask? = false + def generate_task? = false + + def time + 60 + end + + # rubocop:disable Legion/Extension/ActorEnabledSideEffects + def enabled? + github_poll_settings[:owner] && github_poll_settings[:repo] + rescue StandardError => _e + false + end + # rubocop:enable Legion/Extension/ActorEnabledSideEffects + + def manual + settings = github_poll_settings + owner = settings[:owner] + repo = settings[:repo] + return unless owner && repo + + client = Legion::Extensions::Github::Client.new + return unless client.respond_to?(:list_events) + + result = client.list_events(owner: owner, repo: repo) + events = result[:result] + return unless events.is_a?(Array) + + events.each do |event| + publish_event(event) + end + rescue StandardError => e + log.error("App::Actor::WebhookPoller: #{e.message}") + end + + private + + def github_poll_settings + return {} unless defined?(Legion::Settings) + + Legion::Settings[:github]&.dig(:webhook_poller) || {} + rescue StandardError => _e + {} + end + + def publish_event(event) + Legion::Extensions::Github::App::Transport::Messages::Event.new(event).publish + rescue StandardError => e + log.warn("WebhookPoller#publish_event: #{e.message}") + end + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/app/hooks/setup.rb b/lib/legion/extensions/github/app/hooks/setup.rb new file mode 100644 index 0000000..8a6833b --- /dev/null +++ b/lib/legion/extensions/github/app/hooks/setup.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Github + module App + module Hooks + class Setup < Legion::Extensions::Hooks::Base # rubocop:disable Legion/Extension/HookMissingRunnerClass + mount '/setup/callback' + + def self.runner_class + 'Legion::Extensions::Github::App::Runners::Manifest' + end + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/app/hooks/webhook.rb b/lib/legion/extensions/github/app/hooks/webhook.rb new file mode 100644 index 0000000..e19cfae --- /dev/null +++ b/lib/legion/extensions/github/app/hooks/webhook.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Github + module App + module Hooks + class Webhook < Legion::Extensions::Hooks::Base # rubocop:disable Legion/Extension/HookMissingRunnerClass + mount '/webhook' + + def self.runner_class + 'Legion::Extensions::Github::App::Runners::Webhooks' + end + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/app/runners/auth.rb b/lib/legion/extensions/github/app/runners/auth.rb new file mode 100644 index 0000000..0577c20 --- /dev/null +++ b/lib/legion/extensions/github/app/runners/auth.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'jwt' +require 'openssl' +require 'legion/extensions/github/helpers/client' + +module Legion + module Extensions + module Github + module App + module Runners + module Auth + include Legion::Extensions::Github::Helpers::Client + + def generate_jwt(app_id:, private_key:, **) + key = OpenSSL::PKey::RSA.new(private_key) + now = Time.now.to_i + payload = { iat: now - 60, exp: now + (10 * 60), iss: app_id.to_s } + token = JWT.encode(payload, key, 'RS256') + { result: token } + end + + def create_installation_token(jwt:, installation_id:, **) + conn = connection(token: jwt, **) + response = conn.post("/app/installations/#{installation_id}/access_tokens") + { result: response.body } + end + + def list_installations(jwt:, per_page: 30, page: 1, **) + conn = connection(token: jwt, **) + response = conn.get('/app/installations', per_page: per_page, page: page) + { result: response.body } + end + + def get_installation(jwt:, installation_id:, **) + conn = connection(token: jwt, **) + response = conn.get("/app/installations/#{installation_id}") + { result: response.body } + end + + include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && + Legion::Extensions::Helpers.const_defined?(:Lex, false) + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/app/runners/credential_store.rb b/lib/legion/extensions/github/app/runners/credential_store.rb new file mode 100644 index 0000000..8793f65 --- /dev/null +++ b/lib/legion/extensions/github/app/runners/credential_store.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'time' + +module Legion + module Extensions + module Github + module App + module Runners + module CredentialStore + def store_app_credentials(app_id:, private_key:, client_id:, client_secret:, webhook_secret:, **) + vault_set('github/app/app_id', app_id) + vault_set('github/app/private_key', private_key) + vault_set('github/app/client_id', client_id) + vault_set('github/app/client_secret', client_secret) + vault_set('github/app/webhook_secret', webhook_secret) + { result: true } + end + + def store_oauth_token(user:, access_token:, refresh_token:, expires_in: nil, scope: nil, **) + data = { 'access_token' => access_token, 'refresh_token' => refresh_token, + 'expires_in' => expires_in, 'scope' => scope, + 'stored_at' => Time.now.iso8601 }.compact + vault_set("github/oauth/#{user}/token", data) + # Also write to canonical delegated path so resolve_vault_delegated can discover the token + vault_set('github/oauth/delegated/token', data) + { result: true } + end + + def load_oauth_token(user:, **) + data = begin + vault_get("github/oauth/#{user}/token") + rescue StandardError => _e + nil + end + { result: data } + end + + include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && + Legion::Extensions::Helpers.const_defined?(:Lex, false) + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/app/runners/installations.rb b/lib/legion/extensions/github/app/runners/installations.rb new file mode 100644 index 0000000..6ee3bc0 --- /dev/null +++ b/lib/legion/extensions/github/app/runners/installations.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'legion/extensions/github/helpers/client' + +module Legion + module Extensions + module Github + module App + module Runners + module Installations + include Legion::Extensions::Github::Helpers::Client + + def list_installations(jwt:, per_page: 30, page: 1, **) + conn = connection(token: jwt, **) + response = conn.get('/app/installations', per_page: per_page, page: page) + { result: response.body } + end + + def get_installation(jwt:, installation_id:, **) + conn = connection(token: jwt, **) + response = conn.get("/app/installations/#{installation_id}") + { result: response.body } + end + + def list_installation_repos(per_page: 30, page: 1, **) + response = connection(**).get('/installation/repositories', + per_page: per_page, page: page) + { result: response.body } + end + + def suspend_installation(jwt:, installation_id:, **) + conn = connection(token: jwt, **) + response = conn.put("/app/installations/#{installation_id}/suspended") + { result: response.status == 204 } + end + + def unsuspend_installation(jwt:, installation_id:, **) + conn = connection(token: jwt, **) + response = conn.delete("/app/installations/#{installation_id}/suspended") + { result: response.status == 204 } + end + + def delete_installation(jwt:, installation_id:, **) + conn = connection(token: jwt, **) + response = conn.delete("/app/installations/#{installation_id}") + { result: response.status == 204 } + end + + include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && + Legion::Extensions::Helpers.const_defined?(:Lex, false) + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/app/runners/manifest.rb b/lib/legion/extensions/github/app/runners/manifest.rb new file mode 100644 index 0000000..dcc5270 --- /dev/null +++ b/lib/legion/extensions/github/app/runners/manifest.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'json' +require 'uri' +require 'legion/extensions/github/helpers/client' + +module Legion + module Extensions + module Github + module App + module Runners + module Manifest + include Legion::Extensions::Github::Helpers::Client + + DEFAULT_PERMISSIONS = { + contents: 'write', issues: 'write', pull_requests: 'write', + metadata: 'read', administration: 'write', members: 'read', + checks: 'write', statuses: 'write', actions: 'read', + workflows: 'write', webhooks: 'write', repository_hooks: 'write' + }.freeze + + DEFAULT_EVENTS = %w[ + push pull_request pull_request_review issues issue_comment + create delete check_run check_suite status workflow_run + repository installation + ].freeze + + def generate_manifest(name:, url:, webhook_url:, callback_url:, + permissions: DEFAULT_PERMISSIONS, events: DEFAULT_EVENTS, + public: true, **) + manifest = { + name: name, url: url, public: public, + hook_attributes: { url: webhook_url, active: true }, + setup_url: callback_url, + redirect_url: callback_url, + default_permissions: permissions, + default_events: events + } + { result: manifest } + end + + def exchange_manifest_code(code:, **) + conn = connection(**) + response = conn.post("/app-manifests/#{code}/conversions") + { result: response.body } + end + + def manifest_url(manifest:, org: nil, **) + base = if org + "https://github.com/organizations/#{org}/settings/apps/new" + else + 'https://github.com/settings/apps/new' + end + json_str = ::JSON.generate(manifest) + { result: "#{base}?manifest=#{URI.encode_www_form_component(json_str)}" } + end + + include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && + Legion::Extensions::Helpers.const_defined?(:Lex, false) + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/app/runners/webhooks.rb b/lib/legion/extensions/github/app/runners/webhooks.rb new file mode 100644 index 0000000..1009c00 --- /dev/null +++ b/lib/legion/extensions/github/app/runners/webhooks.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'json' +require 'openssl' +require 'legion/extensions/github/helpers/client' + +module Legion + module Extensions + module Github + module App + module Runners + module Webhooks + include Legion::Extensions::Github::Helpers::Client + + def verify_signature(payload:, signature:, secret:, **) + return { result: false } if signature.nil? || signature.empty? + + expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, payload)}" + # Use constant-time comparison to prevent timing side-channel attacks. + # Pad to equal length so fixed_length_secure_compare can be used safely. + result = expected.length == signature.length && + OpenSSL.fixed_length_secure_compare(expected, signature) + { result: result } + end + + def parse_event(payload:, event_type:, delivery_id:, **) + parsed = payload.is_a?(String) ? ::JSON.parse(payload) : payload + { result: { event_type: event_type, delivery_id: delivery_id, payload: parsed } } + end + + def receive_event(payload:, signature:, secret:, event_type:, delivery_id:, **) + verified = verify_signature(payload: payload, signature: signature, secret: secret)[:result] + unless verified + return { result: { verified: false, event_type: event_type, delivery_id: delivery_id, + payload: nil } } + end + + parsed = parse_event(payload: payload, event_type: event_type, delivery_id: delivery_id)[:result] + invalidate_scopes_for_event(event_type: event_type, payload: parsed[:payload]) + { result: parsed.merge(verified: true) } + end + + SCOPE_INVALIDATION_EVENTS = %w[installation installation_repositories].freeze + + def invalidate_scopes_for_event(event_type:, payload:, **) + return unless SCOPE_INVALIDATION_EVENTS.include?(event_type.to_s) + + owner = payload&.dig('installation', 'account', 'login') + return unless owner + + invalidate_all_scopes_for_owner(owner: owner) + end + + def invalidate_all_scopes_for_owner(owner:) + known_fingerprints = resolve_known_fingerprints + known_fingerprints.each do |fp| + invalidate_scope(fingerprint: fp, owner: owner) + end + end + + private + + def resolve_known_fingerprints + fingerprints = [] + + # Delegated (OAuth user) — check Vault and settings without resolving tokens + fingerprints << credential_fingerprint(auth_type: :oauth_user, identifier: 'vault_delegated') if vault_delegated_configured? + fingerprints << credential_fingerprint(auth_type: :oauth_user, identifier: 'settings_delegated') if settings_delegated_configured? + + # App installation — derive from app_id without generating installation tokens + if (vault_app_id = safe_vault_get('github/app/app_id')) + fingerprints << credential_fingerprint(auth_type: :app_installation, identifier: "vault_app_#{vault_app_id}") + end + if (settings_app_id = safe_settings_dig(:github, :app, :app_id)) + fingerprints << credential_fingerprint(auth_type: :app_installation, identifier: "settings_app_#{settings_app_id}") + end + + # PAT + fingerprints << credential_fingerprint(auth_type: :pat, identifier: 'vault_pat') if safe_vault_get('github/token') + fingerprints << credential_fingerprint(auth_type: :pat, identifier: 'settings_pat') if safe_settings_dig(:github, :token) + + # CLI and ENV + fingerprints << credential_fingerprint(auth_type: :cli, identifier: 'gh_cli') + fingerprints << credential_fingerprint(auth_type: :env, identifier: 'env') + + fingerprints.uniq + rescue StandardError => _e + [] + end + + def vault_delegated_configured? + defined?(Legion::Crypt) && safe_vault_get('github/oauth/delegated/token') + end + + def settings_delegated_configured? + defined?(Legion::Settings) && safe_settings_dig(:github, :oauth, :access_token) + end + + def safe_vault_get(path) + vault_get(path) if defined?(Legion::Crypt) + rescue StandardError => _e + nil + end + + def safe_settings_dig(*keys) + Legion::Settings.dig(*keys) if defined?(Legion::Settings) + rescue StandardError => _e + nil + end + + include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && + Legion::Extensions::Helpers.const_defined?(:Lex, false) + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/app/transport/exchanges/app.rb b/lib/legion/extensions/github/app/transport/exchanges/app.rb new file mode 100644 index 0000000..98d4d21 --- /dev/null +++ b/lib/legion/extensions/github/app/transport/exchanges/app.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Github + module App + module Transport + module Exchanges + class App < Legion::Transport::Exchange + def exchange_name = 'lex.github.app' + end + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/app/transport/messages/event.rb b/lib/legion/extensions/github/app/transport/messages/event.rb new file mode 100644 index 0000000..4a55150 --- /dev/null +++ b/lib/legion/extensions/github/app/transport/messages/event.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Github + module App + module Transport + module Messages + class Event < Legion::Transport::Message + def routing_key = 'lex.github.app.runners.webhooks' + def exchange = Legion::Extensions::Github::App::Transport::Exchanges::App + end + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/app/transport/queues/auth.rb b/lib/legion/extensions/github/app/transport/queues/auth.rb new file mode 100644 index 0000000..251792e --- /dev/null +++ b/lib/legion/extensions/github/app/transport/queues/auth.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Github + module App + module Transport + module Queues + class Auth < Legion::Transport::Queue + def queue_name = 'lex.github.app.runners.auth' + def queue_options = { auto_delete: false } + end + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/app/transport/queues/webhooks.rb b/lib/legion/extensions/github/app/transport/queues/webhooks.rb new file mode 100644 index 0000000..a78d3d9 --- /dev/null +++ b/lib/legion/extensions/github/app/transport/queues/webhooks.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Github + module App + module Transport + module Queues + class Webhooks < Legion::Transport::Queue + def queue_name = 'lex.github.app.runners.webhooks' + def queue_options = { auto_delete: false } + end + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/cli/app.rb b/lib/legion/extensions/github/cli/app.rb new file mode 100644 index 0000000..531af58 --- /dev/null +++ b/lib/legion/extensions/github/cli/app.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'legion/extensions/github/helpers/client' +require 'legion/extensions/github/helpers/callback_server' +require 'legion/extensions/github/app/runners/manifest' +require 'legion/extensions/github/app/runners/credential_store' + +module Legion + module Extensions + module Github + module CLI + module App + include Helpers::Client + include Github::App::Runners::Manifest + include Github::App::Runners::CredentialStore + + def setup(name:, url:, webhook_url:, org: nil, callback_timeout: 300, **) + server = Helpers::CallbackServer.new + server.start + callback_url = server.redirect_uri + + manifest = generate_manifest( + name: name, url: url, + webhook_url: webhook_url, + callback_url: callback_url + )[:result] + + url_result = manifest_url(manifest: manifest, org: org)[:result] + + { result: { manifest_url: url_result, callback_port: server.port, + message: 'Open the manifest URL in your browser to create the GitHub App', + callback: server.wait_for_callback(timeout: callback_timeout) } } + ensure + server&.shutdown + end + + def complete_setup(code:, **) + result = exchange_manifest_code(code: code)[:result] + return { error: 'exchange_failed' } unless result&.dig('id') + + if respond_to?(:store_app_credentials, true) + store_app_credentials( + app_id: result['id'].to_s, + private_key: result['pem'], + client_id: result['client_id'], + client_secret: result['client_secret'], + webhook_secret: result['webhook_secret'] + ) + end + + { result: result } + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/cli/auth.rb b/lib/legion/extensions/github/cli/auth.rb new file mode 100644 index 0000000..21785ec --- /dev/null +++ b/lib/legion/extensions/github/cli/auth.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'legion/extensions/github/helpers/client' +require 'legion/extensions/github/helpers/browser_auth' + +module Legion + module Extensions + module Github + module CLI + module Auth + include Helpers::Client + + def login(client_id: nil, client_secret: nil, scopes: nil, **) + cid = client_id || settings_client_id + csec = client_secret || settings_client_secret + sc = scopes || settings_scopes + + unless cid && csec + return { error: 'missing_config', + description: 'Set github.oauth.client_id or github.app.client_id and github.app.client_secret in settings or pass as arguments' } + end + + browser = Helpers::BrowserAuth.new(client_id: cid, client_secret: csec, scopes: sc) + result = browser.authenticate + + if result[:result]&.dig('access_token') && respond_to?(:store_oauth_token, true) + user = begin + current_user(token: result[:result]['access_token']) + rescue StandardError => _e + 'default' + end + store_oauth_token( + user: user, + access_token: result[:result]['access_token'], + refresh_token: result[:result]['refresh_token'], + expires_in: result[:result]['expires_in'] + ) + end + + result + end + + def status(**) + cred = resolve_credential + return { result: { authenticated: false } } unless cred + + user_info = {} + scopes = nil + + begin + response = connection(token: cred[:token]).get('/user') + user_info = response.body || {} + headers = response.respond_to?(:headers) ? response.headers : {} + scopes_header = headers['X-OAuth-Scopes'] || headers['x-oauth-scopes'] + scopes = scopes_header&.split(',')&.map(&:strip) + rescue StandardError => _e + user_info = {} + scopes = nil + end + + { result: { authenticated: true, auth_type: cred[:auth_type], + user: user_info['login'], scopes: scopes } } + end + + private + + def current_user(token:) + connection(token: token).get('/user').body['login'] + end + + def settings_client_id + return nil unless defined?(Legion::Settings) + + Legion::Settings.dig(:github, :oauth, :client_id) || + Legion::Settings.dig(:github, :app, :client_id) + rescue StandardError => _e + nil + end + + def settings_client_secret + return nil unless defined?(Legion::Settings) + + Legion::Settings.dig(:github, :app, :client_secret) + rescue StandardError => _e + nil + end + + def settings_scopes + return nil unless defined?(Legion::Settings) + + Legion::Settings.dig(:github, :oauth, :scopes) + rescue StandardError => _e + nil + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/client.rb b/lib/legion/extensions/github/client.rb index 1949534..52d1859 100644 --- a/lib/legion/extensions/github/client.rb +++ b/lib/legion/extensions/github/client.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'legion/extensions/github/helpers/client' +require 'legion/extensions/github/helpers/cache' require 'legion/extensions/github/runners/repositories' require 'legion/extensions/github/runners/issues' require 'legion/extensions/github/runners/pull_requests' @@ -13,12 +14,24 @@ require 'legion/extensions/github/runners/comments' require 'legion/extensions/github/runners/branches' require 'legion/extensions/github/runners/contents' +require 'legion/extensions/github/runners/actions' +require 'legion/extensions/github/runners/checks' +require 'legion/extensions/github/runners/releases' +require 'legion/extensions/github/runners/deployments' +require 'legion/extensions/github/runners/repository_webhooks' +require 'legion/extensions/github/app/runners/auth' +require 'legion/extensions/github/app/runners/webhooks' +require 'legion/extensions/github/app/runners/manifest' +require 'legion/extensions/github/app/runners/installations' +require 'legion/extensions/github/app/runners/credential_store' +require 'legion/extensions/github/oauth/runners/auth' module Legion module Extensions module Github class Client include Helpers::Client + include Helpers::Cache include Runners::Repositories include Runners::Issues include Runners::PullRequests @@ -31,6 +44,17 @@ class Client include Runners::Comments include Runners::Branches include Runners::Contents + include Runners::Actions + include Runners::Checks + include Runners::Releases + include Runners::Deployments + include Runners::RepositoryWebhooks + include App::Runners::Auth + include App::Runners::Webhooks + include App::Runners::Manifest + include App::Runners::Installations + include App::Runners::CredentialStore + include OAuth::Runners::Auth attr_reader :opts diff --git a/lib/legion/extensions/github/errors.rb b/lib/legion/extensions/github/errors.rb new file mode 100644 index 0000000..bfae3c9 --- /dev/null +++ b/lib/legion/extensions/github/errors.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Github + class Error < StandardError; end + + class RateLimitError < Error + attr_reader :reset_at, :credential_fingerprint + + def initialize(message = 'GitHub API rate limit exceeded', reset_at: nil, credential_fingerprint: nil) + @reset_at = reset_at + @credential_fingerprint = credential_fingerprint + super(message) + end + end + + class AuthorizationError < Error + attr_reader :owner, :repo, :attempted_sources + + def initialize(message = 'No authorized credential available', owner: nil, repo: nil, + attempted_sources: []) + @owner = owner + @repo = repo + @attempted_sources = attempted_sources + super(message) + end + end + + class ScopeDeniedError < Error + attr_reader :owner, :repo, :credential_fingerprint, :auth_type + + def initialize(message = 'Credential not authorized for this scope', + owner: nil, repo: nil, credential_fingerprint: nil, auth_type: nil) + @owner = owner + @repo = repo + @credential_fingerprint = credential_fingerprint + @auth_type = auth_type + super(message) + end + end + end + end +end diff --git a/lib/legion/extensions/github/helpers/browser_auth.rb b/lib/legion/extensions/github/helpers/browser_auth.rb new file mode 100644 index 0000000..93e448b --- /dev/null +++ b/lib/legion/extensions/github/helpers/browser_auth.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'securerandom' +require 'rbconfig' +require 'legion/extensions/github/oauth/runners/auth' +require 'legion/extensions/github/helpers/callback_server' + +module Legion + module Extensions + module Github + module Helpers + class BrowserAuth + DEFAULT_SCOPES = 'repo admin:org admin:repo_hook read:user' + + attr_reader :client_id, :client_secret, :scopes + + def initialize(client_id:, client_secret:, scopes: DEFAULT_SCOPES, auth: nil, **) + @client_id = client_id + @client_secret = client_secret + @scopes = scopes + @auth = auth || Object.new.extend(OAuth::Runners::Auth) + end + + def authenticate + if gui_available? + authenticate_browser + else + authenticate_device_code + end + end + + def gui_available? + os = host_os + return true if /darwin|mswin|mingw/.match?(os) + + !ENV['DISPLAY'].nil? || !ENV['WAYLAND_DISPLAY'].nil? + end + + def open_browser(url) + cmd = case host_os + when /darwin/ then 'open' + when /linux/ then 'xdg-open' + when /mswin|mingw/ then 'start' + end + return false unless cmd + + system(cmd, url) + end + + private + + def host_os + RbConfig::CONFIG['host_os'] + end + + def authenticate_browser + pkce = @auth.generate_pkce[:result] + state = SecureRandom.hex(32) + + server = CallbackServer.new + server.start + callback_uri = server.redirect_uri + + url = @auth.authorize_url( + client_id: client_id, redirect_uri: callback_uri, + scope: scopes, state: state, + code_challenge: pkce[:challenge], + code_challenge_method: pkce[:challenge_method] + )[:result] + + return authenticate_device_code unless open_browser(url) + + result = server.wait_for_callback(timeout: 120) + + return { error: 'timeout', description: 'No callback received within timeout' } unless result&.dig(:code) + + return { error: 'state_mismatch', description: 'CSRF state parameter mismatch' } unless result[:state] == state + + @auth.exchange_code( + client_id: client_id, client_secret: client_secret, + code: result[:code], redirect_uri: callback_uri, + code_verifier: pkce[:verifier] + ) + ensure + server&.shutdown + end + + def authenticate_device_code + dc = @auth.request_device_code(client_id: client_id, scope: scopes) + return { error: dc[:error], description: dc[:description] } if dc[:error] + + body = dc[:result] + warn "Go to: #{body[:verification_uri]}" + warn "Code: #{body[:user_code]}" + open_browser(body[:verification_uri]) if gui_available? + + @auth.poll_device_code( + client_id: client_id, + device_code: body[:device_code] + ) + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/helpers/cache.rb b/lib/legion/extensions/github/helpers/cache.rb new file mode 100644 index 0000000..ec106d3 --- /dev/null +++ b/lib/legion/extensions/github/helpers/cache.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'legion/cache/helper' + +module Legion + module Extensions + module Github + module Helpers + module Cache + include Legion::Cache::Helper + + DEFAULT_TTLS = { + repo: 600, issue: 120, pull_request: 60, commit: 86_400, + branch: 120, user: 3600, org: 3600, search: 60 + }.freeze + + DEFAULT_TTL = 300 + + def cached_get(cache_key, ttl: nil) + if cache_connected? + result = cache_get(cache_key) + return result if result + end + + if local_cache_connected? + result = local_cache_get(cache_key) + return result if result + end + + result = yield + effective_ttl = ttl || github_ttl_for(cache_key) + cache_set(cache_key, result, ttl: effective_ttl) if cache_connected? + local_cache_set(cache_key, result, ttl: effective_ttl) if local_cache_connected? + result + end + + def cache_write(cache_key, value, ttl: nil) + effective_ttl = ttl || github_ttl_for(cache_key) + cache_set(cache_key, value, ttl: effective_ttl) if cache_connected? + local_cache_set(cache_key, value, ttl: effective_ttl) if local_cache_connected? + end + + def cache_invalidate(cache_key) + cache_delete(cache_key) if cache_connected? + local_cache_delete(cache_key) if local_cache_connected? + end + + def github_ttl_for(cache_key) + configured_ttls = github_cache_ttls + case cache_key + when /:commits:/ then configured_ttls[:commit] + when /:pulls:/ then configured_ttls[:pull_request] + when /:issues:/ then configured_ttls[:issue] + when /:branches:/ then configured_ttls[:branch] + when /\Agithub:user:/ then configured_ttls[:user] + when /\Agithub:org:/ then configured_ttls[:org] + when /\Agithub:repo:[^:]+\z/ then configured_ttls[:repo] + when /:search:/ then configured_ttls[:search] + else configured_ttls.fetch(:default, DEFAULT_TTL) + end + end + + def cache_connected? + ::Legion::Cache.connected? + rescue StandardError => _e + false + end + + def local_cache_connected? + false + end + + def local_cache_get(_key) + nil + end + + def local_cache_set(_key, _value, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument + nil + end + + def local_cache_delete(_key) + nil + end + + private + + def github_cache_ttls + return DEFAULT_TTLS.merge(default: DEFAULT_TTL) unless defined?(Legion::Settings) + + overrides = Legion::Settings.dig(:github, :cache, :ttls) || {} + DEFAULT_TTLS.merge(default: DEFAULT_TTL).merge(overrides.transform_keys(&:to_sym)) + rescue StandardError => _e + DEFAULT_TTLS.merge(default: DEFAULT_TTL) + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/helpers/callback_server.rb b/lib/legion/extensions/github/helpers/callback_server.rb new file mode 100644 index 0000000..8e708d6 --- /dev/null +++ b/lib/legion/extensions/github/helpers/callback_server.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'socket' +require 'uri' + +module Legion + module Extensions + module Github + module Helpers + class CallbackServer + RESPONSE_HTML = <<~HTML +
+You can close this window.
+ HTML + + attr_reader :port + + def initialize + @server = nil + @port = nil + @result = nil + @mutex = Mutex.new + @cv = ConditionVariable.new + end + + def start + @server = TCPServer.new('127.0.0.1', 0) + @port = @server.addr[1] + @thread = Thread.new { listen } # rubocop:disable ThreadSafety/NewThread + end + + def wait_for_callback(timeout: 120) + @mutex.synchronize do + @cv.wait(@mutex, timeout) unless @result + @result + end + end + + def shutdown + @server&.close + rescue StandardError => _e + nil + ensure + @thread&.join(2) + @thread&.kill + end + + def redirect_uri + "http://127.0.0.1:#{@port}/callback" + end + + private + + def listen + loop do + client = @server.accept + request_line = client.gets + loop do + line = client.gets + break if line.nil? || line.strip.empty? + end + + if request_line&.include?('/callback?') + query = request_line.split[1].split('?', 2).last + params = URI.decode_www_form(query).to_h + + @mutex.synchronize do + @result = { code: params['code'], state: params['state'] } + @cv.broadcast + end + end + + client.print "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n#{RESPONSE_HTML}" + client.close + break if @result + end + rescue IOError # rubocop:disable Legion/RescueLogging/NoCapture + nil + rescue StandardError => e + @mutex.synchronize do + @result ||= { error: e.message } + @cv.broadcast + end + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/helpers/client.rb b/lib/legion/extensions/github/helpers/client.rb index 5b4bfef..66f7202 100644 --- a/lib/legion/extensions/github/helpers/client.rb +++ b/lib/legion/extensions/github/helpers/client.rb @@ -1,21 +1,311 @@ # frozen_string_literal: true require 'faraday' +require 'legion/extensions/github/helpers/token_cache' +require 'legion/extensions/github/helpers/scope_registry' +require 'legion/extensions/github/middleware/credential_fallback' module Legion module Extensions module Github module Helpers module Client - def connection(api_url: 'https://api.github.com', token: nil, **_opts) + include TokenCache + include ScopeRegistry + + CREDENTIAL_RESOLVERS = %i[ + resolve_vault_delegated resolve_settings_delegated + resolve_vault_app resolve_settings_app + resolve_vault_pat resolve_settings_pat + resolve_gh_cli resolve_env + ].freeze + + def connection(owner: nil, repo: nil, api_url: 'https://api.github.com', token: nil, **_opts) + resolved = token ? { token: token } : resolve_credential(owner: owner, repo: repo) + resolved_token = resolved&.dig(:token) + @current_credential = resolved + @skipped_fingerprints = [] + Faraday.new(url: api_url) do |conn| + conn.use :github_credential_fallback, resolver: self conn.request :json conn.response :json, content_type: /\bjson$/ + conn.response :github_rate_limit, handler: self + conn.response :github_scope_probe, handler: self conn.headers['Accept'] = 'application/vnd.github+json' - conn.headers['Authorization'] = "Bearer #{token}" if token + conn.headers['Authorization'] = "Bearer #{resolved_token}" if resolved_token conn.headers['X-GitHub-Api-Version'] = '2022-11-28' end end + + def resolve_next_credential(owner: nil, repo: nil) + fingerprint = @current_credential&.dig(:metadata, :credential_fingerprint) + @skipped_fingerprints ||= [] + @skipped_fingerprints << fingerprint if fingerprint + + CREDENTIAL_RESOLVERS.each do |method| + next unless respond_to?(method, true) + + result = send(method) + next unless result + + fp = result.dig(:metadata, :credential_fingerprint) + next if fp && @skipped_fingerprints.include?(fp) + next if fp && rate_limited?(fingerprint: fp) + + if owner && fp + scope = scope_status(fingerprint: fp, owner: owner, repo: repo) + next if scope == :denied + end + + @current_credential = result + return result + end + nil + end + + def max_fallback_retries + CREDENTIAL_RESOLVERS.size + end + + def on_rate_limit(remaining:, reset_at:, status:, url:, **) # rubocop:disable Lint/UnusedMethodArgument + fingerprint = @current_credential&.dig(:metadata, :credential_fingerprint) + return unless fingerprint + + mark_rate_limited(fingerprint: fingerprint, reset_at: reset_at) + end + + def on_scope_denied(status:, url:, path:, **) # rubocop:disable Lint/UnusedMethodArgument + fingerprint = @current_credential&.dig(:metadata, :credential_fingerprint) + owner, repo = extract_owner_repo(path) + return unless fingerprint && owner + + register_scope(fingerprint: fingerprint, owner: owner, repo: repo, status: :denied) + end + + def on_scope_authorized(status:, url:, path:, **) # rubocop:disable Lint/UnusedMethodArgument + fingerprint = @current_credential&.dig(:metadata, :credential_fingerprint) + owner, repo = extract_owner_repo(path) + return unless fingerprint && owner + + register_scope(fingerprint: fingerprint, owner: owner, repo: repo, status: :authorized) + end + + def resolve_credential(owner: nil, repo: nil) + CREDENTIAL_RESOLVERS.each do |method| + next unless respond_to?(method, true) + + result = send(method) + next unless result + + fingerprint = result.dig(:metadata, :credential_fingerprint) + + next if fingerprint && rate_limited?(fingerprint: fingerprint) + + if owner && fingerprint + scope = scope_status(fingerprint: fingerprint, owner: owner, repo: repo) + next if scope == :denied + end + + return result + end + nil + end + + def resolve_vault_delegated + return nil unless defined?(Legion::Crypt) + + token_data = vault_get('github/oauth/delegated/token') + return nil unless token_data&.dig('access_token') + + fp = credential_fingerprint(auth_type: :oauth_user, identifier: 'vault_delegated') + { token: token_data['access_token'], auth_type: :oauth_user, + expires_at: token_data['expires_at'], + metadata: { source: :vault, credential_fingerprint: fp } } + rescue StandardError => _e + nil + end + + def resolve_settings_delegated + return nil unless defined?(Legion::Settings) + + token = Legion::Settings.dig(:github, :oauth, :access_token) + return nil unless token + + fp = credential_fingerprint(auth_type: :oauth_user, identifier: 'settings_delegated') + { token: token, auth_type: :oauth_user, + metadata: { source: :settings, credential_fingerprint: fp } } + rescue StandardError => _e + nil + end + + def resolve_vault_app + return nil unless defined?(Legion::Crypt) + + private_key = begin + vault_get('github/app/private_key') + rescue StandardError => _e + nil + end + return nil unless private_key + + app_id = begin + vault_get('github/app/app_id') + rescue StandardError => _e + nil + end + installation_id = begin + vault_get('github/app/installation_id') + rescue StandardError => _e + nil + end + return nil unless app_id && installation_id + + fp = credential_fingerprint(auth_type: :app_installation, identifier: "vault_app_#{app_id}") + cached = fetch_token(auth_type: :app_installation, installation_id: installation_id) + return cached.merge(metadata: { source: :vault, credential_fingerprint: fp }) if cached + + jwt = generate_jwt(app_id: app_id, private_key: private_key)[:result] + token_data = create_installation_token(jwt: jwt, installation_id: installation_id)[:result] + return nil unless token_data&.dig('token') + + expires_at = begin + Time.parse(token_data['expires_at']) + rescue StandardError => _e + Time.now + 3600 + end + result = { token: token_data['token'], auth_type: :app_installation, + expires_at: expires_at, installation_id: installation_id, + metadata: { source: :vault, installation_id: installation_id, + credential_fingerprint: fp } } + store_token(**result) + result + rescue StandardError => _e + nil + end + + def resolve_settings_app + return nil unless defined?(Legion::Settings) + + app_id = begin + Legion::Settings.dig(:github, :app, :app_id) + rescue StandardError => _e + nil + end + return nil unless app_id + + fp = credential_fingerprint(auth_type: :app_installation, identifier: "settings_app_#{app_id}") + + key_path = begin + Legion::Settings.dig(:github, :app, :private_key_path) + rescue StandardError => _e + nil + end + installation_id = begin + Legion::Settings.dig(:github, :app, :installation_id) + rescue StandardError => _e + nil + end + return nil unless key_path && installation_id + + cached = fetch_token(auth_type: :app_installation, installation_id: installation_id) + return cached.merge(metadata: { source: :settings, credential_fingerprint: fp }) if cached + + private_key = ::File.read(key_path) + jwt = generate_jwt(app_id: app_id, private_key: private_key)[:result] + token_data = create_installation_token(jwt: jwt, installation_id: installation_id)[:result] + return nil unless token_data&.dig('token') + + expires_at = begin + Time.parse(token_data['expires_at']) + rescue StandardError => _e + Time.now + 3600 + end + result = { token: token_data['token'], auth_type: :app_installation, + expires_at: expires_at, installation_id: installation_id, + metadata: { source: :settings, installation_id: installation_id, + credential_fingerprint: fp } } + store_token(**result) + result + rescue StandardError => _e + nil + end + + def resolve_vault_pat + return nil unless defined?(Legion::Crypt) + + token = vault_get('github/token') + return nil unless token + + fp = credential_fingerprint(auth_type: :pat, identifier: 'vault_pat') + { token: token, auth_type: :pat, metadata: { source: :vault, credential_fingerprint: fp } } + rescue StandardError => _e + nil + end + + def resolve_settings_pat + return nil unless defined?(Legion::Settings) + + token = Legion::Settings.dig(:github, :token) + return nil unless token + + fp = credential_fingerprint(auth_type: :pat, identifier: 'settings_pat') + { token: token, auth_type: :pat, metadata: { source: :settings, credential_fingerprint: fp } } + rescue StandardError => _e + nil + end + + def resolve_gh_cli + if cache_connected? || local_cache_connected? + cached = cache_connected? ? cache_get('github:cli_token') : local_cache_get('github:cli_token') + return cached if cached + end + + output = gh_cli_token_output + return nil unless output + + fp = credential_fingerprint(auth_type: :cli, identifier: 'gh_cli') + result = { token: output, auth_type: :cli, metadata: { source: :gh_cli, credential_fingerprint: fp } } + cache_set('github:cli_token', result, ttl: 300) if cache_connected? + local_cache_set('github:cli_token', result, ttl: 300) if local_cache_connected? + result + rescue StandardError => _e + nil + end + + def gh_cli_token_output + output = `gh auth token 2>/dev/null`.strip + return nil unless $?&.success? && !output.empty? # rubocop:disable Style/SpecialGlobalVars + + output + rescue StandardError => _e + nil + end + + def resolve_env + token = ENV.fetch('GITHUB_TOKEN', nil) + return nil if token.nil? || token.empty? + + fp = credential_fingerprint(auth_type: :env, identifier: 'env') + { token: token, auth_type: :env, metadata: { source: :env, credential_fingerprint: fp } } + end + + private + + def extract_owner_repo(path) + match = path.match(%r{^/repos/([^/]+)/([^/]+)}) + return [nil, nil] unless match + + [match[1], match[2]] + end + + def credential_fallback? + return true unless defined?(Legion::Settings) + + Legion::Settings.dig(:github, :credential_fallback) != false + rescue StandardError => _e + true + end end end end diff --git a/lib/legion/extensions/github/helpers/scope_registry.rb b/lib/legion/extensions/github/helpers/scope_registry.rb new file mode 100644 index 0000000..63341ee --- /dev/null +++ b/lib/legion/extensions/github/helpers/scope_registry.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'digest' + +module Legion + module Extensions + module Github + module Helpers + module ScopeRegistry + def credential_fingerprint(auth_type:, identifier:) + Digest::SHA256.hexdigest("#{auth_type}:#{identifier}")[0, 16] + end + + def scope_status(fingerprint:, owner:, repo: nil) + if repo + status = scope_cache_get("github:scope:#{fingerprint}:#{owner}/#{repo}") + return status if status + end + + scope_cache_get("github:scope:#{fingerprint}:#{owner}") || :unknown + end + + def register_scope(fingerprint:, owner:, status:, repo: nil) + key = repo ? "github:scope:#{fingerprint}:#{owner}/#{repo}" : "github:scope:#{fingerprint}:#{owner}" + ttl = if status == :denied + scope_denied_ttl + else + (repo ? scope_repo_ttl : scope_org_ttl) + end + cache_set(key, status, ttl: ttl) if cache_connected? + local_cache_set(key, status, ttl: ttl) if local_cache_connected? + end + + def rate_limited?(fingerprint:) + entry = scope_cache_get("github:rate_limit:#{fingerprint}") + return false unless entry + + entry[:reset_at] > Time.now + end + + def mark_rate_limited(fingerprint:, reset_at:) + ttl = [(reset_at - Time.now).ceil, 1].max + value = { reset_at: reset_at, remaining: 0 } + cache_set("github:rate_limit:#{fingerprint}", value, ttl: ttl) if cache_connected? + local_cache_set("github:rate_limit:#{fingerprint}", value, ttl: ttl) if local_cache_connected? + end + + def invalidate_scope(fingerprint:, owner:, repo: nil) + key = repo ? "github:scope:#{fingerprint}:#{owner}/#{repo}" : "github:scope:#{fingerprint}:#{owner}" + cache_delete(key) if cache_connected? + local_cache_delete(key) if local_cache_connected? + end + + private + + def scope_cache_get(key) + if cache_connected? + result = cache_get(key) + return result if result + end + local_cache_get(key) if local_cache_connected? + end + + def scope_org_ttl + return 3600 unless defined?(Legion::Settings) + + Legion::Settings.dig(:github, :scope_registry, :org_ttl) || 3600 + rescue StandardError => _e + 3600 + end + + def scope_repo_ttl + return 300 unless defined?(Legion::Settings) + + Legion::Settings.dig(:github, :scope_registry, :repo_ttl) || 300 + rescue StandardError => _e + 300 + end + + def scope_denied_ttl + return 300 unless defined?(Legion::Settings) + + Legion::Settings.dig(:github, :scope_registry, :denied_ttl) || 300 + rescue StandardError => _e + 300 + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/helpers/token_cache.rb b/lib/legion/extensions/github/helpers/token_cache.rb new file mode 100644 index 0000000..c2ab37a --- /dev/null +++ b/lib/legion/extensions/github/helpers/token_cache.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'time' +require 'legion/cache/helper' + +module Legion + module Extensions + module Github + module Helpers + module TokenCache + include Legion::Cache::Helper + + TOKEN_BUFFER_SECONDS = 300 + + def store_token(token:, auth_type:, expires_at:, installation_id: nil, metadata: {}, **) + entry = { token: token, auth_type: auth_type, + expires_at: expires_at.respond_to?(:iso8601) ? expires_at.iso8601 : expires_at, + installation_id: installation_id, metadata: metadata } + ttl = [(expires_at.respond_to?(:to_i) ? expires_at.to_i - Time.now.to_i : 3600), 60].max + key = token_cache_key(auth_type, installation_id) + cache_set(key, entry, ttl: ttl) if cache_connected? + local_cache_set(key, entry, ttl: ttl) if local_cache_connected? + end + + def fetch_token(auth_type:, installation_id: nil, **) + key = token_cache_key(auth_type, installation_id) + entry = token_cache_read(key) + + entry = token_cache_read(token_cache_key(auth_type, nil)) if entry.nil? && installation_id + + return nil unless entry + + expires = begin + Time.parse(entry[:expires_at].to_s) + rescue StandardError => _e + nil + end + return nil if expires && expires < Time.now + TOKEN_BUFFER_SECONDS + + entry + end + + def mark_rate_limited(auth_type:, reset_at:, **) + entry = { reset_at: reset_at.respond_to?(:iso8601) ? reset_at.iso8601 : reset_at } + ttl = [(reset_at.respond_to?(:to_i) ? reset_at.to_i - Time.now.to_i : 300), 10].max + key = "github:rate_limit:#{auth_type}" + cache_set(key, entry, ttl: ttl) if cache_connected? + local_cache_set(key, entry, ttl: ttl) if local_cache_connected? + end + + def rate_limited?(auth_type:, **) + key = "github:rate_limit:#{auth_type}" + entry = if cache_connected? + cache_get(key) + elsif local_cache_connected? + local_cache_get(key) + end + return false unless entry + + reset = begin + Time.parse(entry[:reset_at].to_s) + rescue StandardError => _e + nil + end + reset.nil? || reset > Time.now + end + + private + + def token_cache_key(auth_type, installation_id) + base = "github:token:#{auth_type}" + installation_id ? "#{base}:#{installation_id}" : base + end + + def token_cache_read(key) + if cache_connected? + cache_get(key) + elsif local_cache_connected? + local_cache_get(key) + end + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/middleware/credential_fallback.rb b/lib/legion/extensions/github/middleware/credential_fallback.rb new file mode 100644 index 0000000..95e83ac --- /dev/null +++ b/lib/legion/extensions/github/middleware/credential_fallback.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'faraday' + +module Legion + module Extensions + module Github + module Middleware + class CredentialFallback < ::Faraday::Middleware + RETRYABLE_STATUSES = [403, 429].freeze + IDEMPOTENT_METHODS = %w[GET HEAD OPTIONS PUT DELETE].freeze + + def initialize(app, resolver: nil) + super(app) + @resolver = resolver + end + + def call(env) + response = @app.call(env) + return response unless should_retry?(response) + + retries = 0 + max = @resolver.respond_to?(:max_fallback_retries) ? @resolver.max_fallback_retries : 3 + + while retries < max && should_retry?(response) + notify_resolver(response) + + owner, repo = extract_owner_repo_from_env(env) + next_credential = @resolver&.resolve_next_credential(owner: owner, repo: repo) + break unless next_credential + + env[:request_headers]['Authorization'] = "Bearer #{next_credential[:token]}" + + response = @app.call(env) + retries += 1 + end + + response + end + + private + + def should_retry?(response) + return false unless @resolver.respond_to?(:credential_fallback?) + return false unless @resolver.credential_fallback? + return false unless IDEMPOTENT_METHODS.include?(response.env[:method].to_s.upcase) + + RETRYABLE_STATUSES.include?(response.status) + end + + def extract_owner_repo_from_env(env) + path = env.url&.path.to_s + match = path.match(%r{^/repos/([^/]+)/([^/]+)}) + match ? [match[1], match[2]] : [nil, nil] + end + + def notify_resolver(response) + if response.status == 429 && @resolver.respond_to?(:on_rate_limit) + reset = response.headers['x-ratelimit-reset'] + reset_at = reset ? Time.at(reset.to_i) : Time.now + 60 + @resolver.on_rate_limit(remaining: 0, reset_at: reset_at, + status: 429, url: response.env.url.to_s) + elsif response.status == 403 && @resolver.respond_to?(:on_scope_denied) + @resolver.on_scope_denied(status: 403, url: response.env.url.to_s, + path: response.env.url.path) + end + end + end + end + end + end +end + +Faraday::Middleware.register_middleware( + github_credential_fallback: Legion::Extensions::Github::Middleware::CredentialFallback +) diff --git a/lib/legion/extensions/github/middleware/rate_limit.rb b/lib/legion/extensions/github/middleware/rate_limit.rb new file mode 100644 index 0000000..9140596 --- /dev/null +++ b/lib/legion/extensions/github/middleware/rate_limit.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'faraday' + +module Legion + module Extensions + module Github + module Middleware + class RateLimit < ::Faraday::Middleware + def initialize(app, handler: nil) + super(app) + @handler = handler + end + + def on_complete(env) + remaining = env.response_headers['x-ratelimit-remaining'] + reset = env.response_headers['x-ratelimit-reset'] + return unless remaining + + remaining_int = remaining.to_i + return unless remaining_int.zero? || env.status == 429 + return unless @handler.respond_to?(:on_rate_limit) + + reset_at = reset ? Time.at(reset.to_i) : Time.now + 60 + @handler.on_rate_limit( + remaining: remaining_int, + reset_at: reset_at, + status: env.status, + url: env.url.to_s + ) + end + end + end + end + end +end + +Faraday::Response.register_middleware( + github_rate_limit: Legion::Extensions::Github::Middleware::RateLimit +) diff --git a/lib/legion/extensions/github/middleware/scope_probe.rb b/lib/legion/extensions/github/middleware/scope_probe.rb new file mode 100644 index 0000000..a5c96c3 --- /dev/null +++ b/lib/legion/extensions/github/middleware/scope_probe.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'faraday' + +module Legion + module Extensions + module Github + module Middleware + class ScopeProbe < ::Faraday::Middleware + REPO_PATH_PATTERN = %r{^/repos/([^/]+)/([^/]+)} + + def initialize(app, handler: nil) + super(app) + @handler = handler + end + + def on_complete(env) + return unless @handler + return unless env.url.path.match?(REPO_PATH_PATTERN) + + info = { status: env.status, url: env.url.to_s, path: env.url.path } + + if [403, 404].include?(env.status) + @handler.on_scope_denied(info) if @handler.respond_to?(:on_scope_denied) + elsif env.status >= 200 && env.status < 300 + @handler.on_scope_authorized(info) if @handler.respond_to?(:on_scope_authorized) + end + end + end + end + end + end +end + +Faraday::Response.register_middleware( + github_scope_probe: Legion::Extensions::Github::Middleware::ScopeProbe +) diff --git a/lib/legion/extensions/github/oauth/actor/token_refresh.rb b/lib/legion/extensions/github/oauth/actor/token_refresh.rb new file mode 100644 index 0000000..62827b5 --- /dev/null +++ b/lib/legion/extensions/github/oauth/actor/token_refresh.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Github + module OAuth + module Actor + class TokenRefresh < Legion::Extensions::Actors::Every # rubocop:disable Legion/Extension/SelfContainedActorRunnerClass,Legion/Extension/EveryActorRequiresTime + def use_runner? = false + def check_subtask? = false + def generate_task? = false + + def time + 3 * 60 * 60 + end + + # rubocop:disable Legion/Extension/ActorEnabledSideEffects + def enabled? + oauth_settings[:client_id] && oauth_settings[:client_secret] + rescue StandardError => _e + false + end + # rubocop:enable Legion/Extension/ActorEnabledSideEffects + + def manual + settings = oauth_settings + return unless settings[:client_id] && settings[:client_secret] + + token_entry = fetch_delegated_token + return unless token_entry&.dig(:refresh_token) + + auth = Object.new.extend(Legion::Extensions::Github::OAuth::Runners::Auth) + result = auth.refresh_token( + client_id: settings[:client_id], + client_secret: settings[:client_secret], + refresh_token: token_entry[:refresh_token] + ) + return unless result.dig(:result, 'access_token') + + store_delegated_token(result[:result]) + log.info('OAuth::Actor::TokenRefresh: delegated token refreshed') + rescue StandardError => e + log.error("OAuth::Actor::TokenRefresh: #{e.message}") + end + + private + + def oauth_settings + return {} unless defined?(Legion::Settings) + + Legion::Settings[:github]&.dig(:oauth) || {} + rescue StandardError => _e + {} + end + + def fetch_delegated_token + return nil unless defined?(Legion::Crypt) + + vault_get('github/oauth/delegated/token') + rescue StandardError => _e + nil + end + + def store_delegated_token(token_data) + return unless defined?(Legion::Crypt) + + vault_write('github/oauth/delegated/token', token_data) + rescue StandardError => e + log.warn("OAuth::Actor::TokenRefresh#store_delegated_token: #{e.message}") + end + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/oauth/hooks/callback.rb b/lib/legion/extensions/github/oauth/hooks/callback.rb new file mode 100644 index 0000000..6fba96f --- /dev/null +++ b/lib/legion/extensions/github/oauth/hooks/callback.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Github + module OAuth + module Hooks + class Callback < Legion::Extensions::Hooks::Base # rubocop:disable Legion/Extension/HookMissingRunnerClass + mount '/callback' + + def self.runner_class + 'Legion::Extensions::Github::OAuth::Runners::Auth' + end + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/oauth/runners/auth.rb b/lib/legion/extensions/github/oauth/runners/auth.rb new file mode 100644 index 0000000..37f6e89 --- /dev/null +++ b/lib/legion/extensions/github/oauth/runners/auth.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'base64' +require 'openssl' +require 'securerandom' +require 'uri' +require 'legion/extensions/github/helpers/client' + +module Legion + module Extensions + module Github + module OAuth + module Runners + module Auth + include Legion::Extensions::Github::Helpers::Client + + def generate_pkce(**) + verifier = SecureRandom.urlsafe_base64(32) + challenge = ::Base64.urlsafe_encode64( + OpenSSL::Digest::SHA256.digest(verifier), padding: false + ) + { result: { verifier: verifier, challenge: challenge, challenge_method: 'S256' } } + end + + def authorize_url(client_id:, redirect_uri:, scope:, state:, + code_challenge:, code_challenge_method: 'S256', **) + params = URI.encode_www_form( + client_id: client_id, redirect_uri: redirect_uri, + scope: scope, state: state, + code_challenge: code_challenge, + code_challenge_method: code_challenge_method + ) + { result: "https://github.com/login/oauth/authorize?#{params}" } + end + + def exchange_code(client_id:, client_secret:, code:, redirect_uri:, code_verifier:, **) + response = oauth_connection.post('/login/oauth/access_token', { + client_id: client_id, client_secret: client_secret, + code: code, redirect_uri: redirect_uri, + code_verifier: code_verifier + }) + { result: response.body } + end + + def refresh_token(client_id:, client_secret:, refresh_token:, **) + response = oauth_connection.post('/login/oauth/access_token', { + client_id: client_id, client_secret: client_secret, + refresh_token: refresh_token, + grant_type: 'refresh_token' + }) + { result: response.body } + end + + def request_device_code(client_id:, scope: 'repo', **) + response = oauth_connection.post('/login/device/code', { + client_id: client_id, scope: scope + }) + { result: response.body } + end + + def poll_device_code(client_id:, device_code:, interval: 5, timeout: 300, **) + deadline = Time.now + timeout + current_interval = interval + + loop do + response = oauth_connection.post('/login/oauth/access_token', { + client_id: client_id, + device_code: device_code, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code' + }) + body = response.body + return { result: body } if body[:access_token] + + error_key = body[:error] + case error_key + when 'authorization_pending' + return { error: 'timeout', description: "Device code flow timed out after #{timeout}s" } if Time.now > deadline + + sleep(current_interval) unless current_interval.zero? + when 'slow_down' + current_interval += 5 + sleep(current_interval) unless current_interval.zero? + else + return { error: error_key, description: body[:error_description] } + end + end + end + + def revoke_token(client_id:, client_secret:, access_token:, **) + conn = oauth_connection(client_id: client_id, client_secret: client_secret) + response = conn.delete("/applications/#{client_id}/token", { access_token: access_token }) + { result: response.status == 204 } + end + + def oauth_connection(client_id: nil, client_secret: nil, **) + Faraday.new(url: 'https://github.com') do |conn| + conn.request :json + conn.response :json, content_type: /\bjson$/ + conn.headers['Accept'] = 'application/json' + conn.request :authorization, :basic, client_id, client_secret if client_id && client_secret + end + end + + include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && + Legion::Extensions::Helpers.const_defined?(:Lex, false) + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/oauth/transport/exchanges/oauth.rb b/lib/legion/extensions/github/oauth/transport/exchanges/oauth.rb new file mode 100644 index 0000000..7484031 --- /dev/null +++ b/lib/legion/extensions/github/oauth/transport/exchanges/oauth.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Github + module OAuth + module Transport + module Exchanges + class Oauth < Legion::Transport::Exchange + def exchange_name = 'lex.github.oauth' + end + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/oauth/transport/queues/auth.rb b/lib/legion/extensions/github/oauth/transport/queues/auth.rb new file mode 100644 index 0000000..ed529af --- /dev/null +++ b/lib/legion/extensions/github/oauth/transport/queues/auth.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Github + module OAuth + module Transport + module Queues + class Auth < Legion::Transport::Queue + def queue_name = 'lex.github.oauth.runners.auth' + def queue_options = { auto_delete: false } + end + end + end + end + end + end +end diff --git a/lib/legion/extensions/github/runners/actions.rb b/lib/legion/extensions/github/runners/actions.rb new file mode 100644 index 0000000..96608fa --- /dev/null +++ b/lib/legion/extensions/github/runners/actions.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'legion/extensions/github/helpers/client' + +module Legion + module Extensions + module Github + module Runners + module Actions + include Legion::Extensions::Github::Helpers::Client + + def list_workflows(owner:, repo:, per_page: 30, page: 1, **) + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/actions/workflows", per_page: per_page, page: page + ) + { result: response.body } + end + + def get_workflow(owner:, repo:, workflow_id:, **) + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/actions/workflows/#{workflow_id}" + ) + { result: response.body } + end + + def list_workflow_runs(owner:, repo:, workflow_id:, status: nil, branch: nil, + per_page: 30, page: 1, **) + params = { per_page: per_page, page: page, status: status, branch: branch }.compact + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/actions/workflows/#{workflow_id}/runs", params + ) + { result: response.body } + end + + def get_workflow_run(owner:, repo:, run_id:, **) + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/actions/runs/#{run_id}" + ) + { result: response.body } + end + + def trigger_workflow(owner:, repo:, workflow_id:, ref:, inputs: {}, **) + payload = { ref: ref, inputs: inputs } + response = connection(owner: owner, repo: repo, **).post( + "/repos/#{owner}/#{repo}/actions/workflows/#{workflow_id}/dispatches", payload + ) + { result: response.status == 204 } + end + + def cancel_workflow_run(owner:, repo:, run_id:, **) + response = connection(owner: owner, repo: repo, **).post( + "/repos/#{owner}/#{repo}/actions/runs/#{run_id}/cancel" + ) + { result: [202, 204].include?(response.status) } + end + + def rerun_workflow(owner:, repo:, run_id:, **) + response = connection(owner: owner, repo: repo, **).post( + "/repos/#{owner}/#{repo}/actions/runs/#{run_id}/rerun" + ) + { result: [201, 204].include?(response.status) } + end + + def rerun_failed_jobs(owner:, repo:, run_id:, **) + response = connection(owner: owner, repo: repo, **).post( + "/repos/#{owner}/#{repo}/actions/runs/#{run_id}/rerun-failed-jobs" + ) + { result: [201, 204].include?(response.status) } + end + + def list_workflow_run_jobs(owner:, repo:, run_id:, filter: 'latest', per_page: 30, page: 1, **) + params = { filter: filter, per_page: per_page, page: page } + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/actions/runs/#{run_id}/jobs", params + ) + { result: response.body } + end + + def download_workflow_run_logs(owner:, repo:, run_id:, **) + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/actions/runs/#{run_id}/logs" + ) + { result: { status: response.status, headers: response.headers.to_h, body: response.body } } + end + + def list_workflow_run_artifacts(owner:, repo:, run_id:, per_page: 30, page: 1, **) + params = { per_page: per_page, page: page } + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/actions/runs/#{run_id}/artifacts", params + ) + { result: response.body } + end + + include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && + Legion::Extensions::Helpers.const_defined?(:Lex, false) + end + end + end + end +end diff --git a/lib/legion/extensions/github/runners/branches.rb b/lib/legion/extensions/github/runners/branches.rb index 7d788e1..0985973 100644 --- a/lib/legion/extensions/github/runners/branches.rb +++ b/lib/legion/extensions/github/runners/branches.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'legion/extensions/github/helpers/client' +require 'legion/extensions/github/helpers/cache' module Legion module Extensions @@ -8,13 +9,14 @@ module Github module Runners module Branches include Legion::Extensions::Github::Helpers::Client + include Legion::Extensions::Github::Helpers::Cache def create_branch(owner:, repo:, branch:, from_ref: 'main', **) - ref_response = connection(**).get("/repos/#{owner}/#{repo}/git/ref/heads/#{from_ref}") + ref_response = connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/git/ref/heads/#{from_ref}") sha = ref_response.body.dig('object', 'sha') - create_response = connection(**).post("/repos/#{owner}/#{repo}/git/refs", - { ref: "refs/heads/#{branch}", sha: sha }) + create_response = connection(owner: owner, repo: repo, **).post("/repos/#{owner}/#{repo}/git/refs", + { ref: "refs/heads/#{branch}", sha: sha }) { success: true, ref: create_response.body['ref'], sha: sha } rescue StandardError => e diff --git a/lib/legion/extensions/github/runners/checks.rb b/lib/legion/extensions/github/runners/checks.rb new file mode 100644 index 0000000..c7fb81e --- /dev/null +++ b/lib/legion/extensions/github/runners/checks.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'legion/extensions/github/helpers/client' + +module Legion + module Extensions + module Github + module Runners + module Checks + include Legion::Extensions::Github::Helpers::Client + + def create_check_run(owner:, repo:, name:, head_sha:, status: nil, # rubocop:disable Metrics/ParameterLists + conclusion: nil, output: nil, details_url: nil, **) + payload = { name: name, head_sha: head_sha, status: status, + conclusion: conclusion, output: output, details_url: details_url }.compact + response = connection(owner: owner, repo: repo, **).post( + "/repos/#{owner}/#{repo}/check-runs", payload + ) + { result: response.body } + end + + def update_check_run(owner:, repo:, check_run_id:, **opts) + payload = opts.slice(:name, :status, :conclusion, :output, :details_url, + :started_at, :completed_at) + response = connection(owner: owner, repo: repo, **opts).patch( + "/repos/#{owner}/#{repo}/check-runs/#{check_run_id}", payload + ) + { result: response.body } + end + + def get_check_run(owner:, repo:, check_run_id:, **) + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/check-runs/#{check_run_id}" + ) + { result: response.body } + end + + def list_check_runs_for_ref(owner:, repo:, ref:, check_name: nil, status: nil, + per_page: 30, page: 1, **) + params = { check_name: check_name, status: status, + per_page: per_page, page: page }.compact + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/commits/#{ref}/check-runs", params + ) + { result: response.body } + end + + def list_check_suites_for_ref(owner:, repo:, ref:, per_page: 30, page: 1, **) + params = { per_page: per_page, page: page } + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/commits/#{ref}/check-suites", params + ) + { result: response.body } + end + + def get_check_suite(owner:, repo:, check_suite_id:, **) + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/check-suites/#{check_suite_id}" + ) + { result: response.body } + end + + def rerequest_check_suite(owner:, repo:, check_suite_id:, **) + response = connection(owner: owner, repo: repo, **).post( + "/repos/#{owner}/#{repo}/check-suites/#{check_suite_id}/rerequest" + ) + { result: [201, 204].include?(response.status) } + end + + def list_check_run_annotations(owner:, repo:, check_run_id:, per_page: 30, page: 1, **) + params = { per_page: per_page, page: page } + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/check-runs/#{check_run_id}/annotations", params + ) + { result: response.body } + end + + include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && + Legion::Extensions::Helpers.const_defined?(:Lex, false) + end + end + end + end +end diff --git a/lib/legion/extensions/github/runners/comments.rb b/lib/legion/extensions/github/runners/comments.rb index 4e36c0e..4e94011 100644 --- a/lib/legion/extensions/github/runners/comments.rb +++ b/lib/legion/extensions/github/runners/comments.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'legion/extensions/github/helpers/client' +require 'legion/extensions/github/helpers/cache' module Legion module Extensions @@ -8,30 +9,35 @@ module Github module Runners module Comments include Legion::Extensions::Github::Helpers::Client + include Legion::Extensions::Github::Helpers::Cache def list_comments(owner:, repo:, issue_number:, per_page: 30, page: 1, **) params = { per_page: per_page, page: page } - response = connection(**).get("/repos/#{owner}/#{repo}/issues/#{issue_number}/comments", params) - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}:issues:#{issue_number}:comments:#{page}:#{per_page}") do + connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/issues/#{issue_number}/comments", params).body + end } end def get_comment(owner:, repo:, comment_id:, **) - response = connection(**).get("/repos/#{owner}/#{repo}/issues/comments/#{comment_id}") - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}:comments:#{comment_id}") do + connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/issues/comments/#{comment_id}").body + end } end def create_comment(owner:, repo:, issue_number:, body:, **) - response = connection(**).post("/repos/#{owner}/#{repo}/issues/#{issue_number}/comments", { body: body }) + response = connection(owner: owner, repo: repo, **).post("/repos/#{owner}/#{repo}/issues/#{issue_number}/comments", { body: body }) { result: response.body } end def update_comment(owner:, repo:, comment_id:, body:, **) - response = connection(**).patch("/repos/#{owner}/#{repo}/issues/comments/#{comment_id}", { body: body }) + response = connection(owner: owner, repo: repo, **).patch("/repos/#{owner}/#{repo}/issues/comments/#{comment_id}", { body: body }) + cache_write("github:repo:#{owner}/#{repo}:comments:#{comment_id}", response.body) if response.body['id'] { result: response.body } end def delete_comment(owner:, repo:, comment_id:, **) - response = connection(**).delete("/repos/#{owner}/#{repo}/issues/comments/#{comment_id}") + response = connection(owner: owner, repo: repo, **).delete("/repos/#{owner}/#{repo}/issues/comments/#{comment_id}") + cache_invalidate("github:repo:#{owner}/#{repo}:comments:#{comment_id}") if response.status == 204 { result: response.status == 204 } end diff --git a/lib/legion/extensions/github/runners/commits.rb b/lib/legion/extensions/github/runners/commits.rb index e3c5a83..18ea3cd 100644 --- a/lib/legion/extensions/github/runners/commits.rb +++ b/lib/legion/extensions/github/runners/commits.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'legion/extensions/github/helpers/client' +require 'legion/extensions/github/helpers/cache' module Legion module Extensions @@ -8,23 +9,27 @@ module Github module Runners module Commits include Legion::Extensions::Github::Helpers::Client + include Legion::Extensions::Github::Helpers::Cache def list_commits(owner:, repo:, sha: nil, per_page: 30, page: 1, **) params = { per_page: per_page, page: page } params[:sha] = sha if sha - response = connection(**).get("/repos/#{owner}/#{repo}/commits", params) - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}:commits:#{sha}:#{page}:#{per_page}") do + connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/commits", params).body + end } end def get_commit(owner:, repo:, ref:, **) - response = connection(**).get("/repos/#{owner}/#{repo}/commits/#{ref}") - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}:commits:#{ref}") do + connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/commits/#{ref}").body + end } end def compare_commits(owner:, repo:, base:, head:, per_page: 30, page: 1, **) params = { per_page: per_page, page: page } - response = connection(**).get("/repos/#{owner}/#{repo}/compare/#{base}...#{head}", params) - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}:commits:compare:#{base}...#{head}:#{page}:#{per_page}") do + connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/compare/#{base}...#{head}", params).body + end } end include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && diff --git a/lib/legion/extensions/github/runners/contents.rb b/lib/legion/extensions/github/runners/contents.rb index e164fa3..9e03240 100644 --- a/lib/legion/extensions/github/runners/contents.rb +++ b/lib/legion/extensions/github/runners/contents.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'legion/extensions/github/helpers/client' +require 'legion/extensions/github/helpers/cache' module Legion module Extensions @@ -8,9 +9,10 @@ module Github module Runners module Contents include Legion::Extensions::Github::Helpers::Client + include Legion::Extensions::Github::Helpers::Cache def commit_files(owner:, repo:, branch:, files:, message:, **) - conn = connection(**) + conn = connection(owner: owner, repo: repo, **) ref = conn.get("/repos/#{owner}/#{repo}/git/ref/heads/#{branch}") commit_sha = ref.body.dig('object', 'sha') diff --git a/lib/legion/extensions/github/runners/deployments.rb b/lib/legion/extensions/github/runners/deployments.rb new file mode 100644 index 0000000..47a9f73 --- /dev/null +++ b/lib/legion/extensions/github/runners/deployments.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'legion/extensions/github/helpers/client' + +module Legion + module Extensions + module Github + module Runners + module Deployments + include Legion::Extensions::Github::Helpers::Client + + def list_deployments(owner:, repo:, environment: nil, ref: nil, per_page: 30, page: 1, **) + params = { environment: environment, ref: ref, + per_page: per_page, page: page }.compact + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/deployments", params + ) + { result: response.body } + end + + def get_deployment(owner:, repo:, deployment_id:, **) + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/deployments/#{deployment_id}" + ) + { result: response.body } + end + + def create_deployment(owner:, repo:, ref:, environment: 'production', + description: nil, auto_merge: true, required_contexts: nil, **) + payload = { ref: ref, environment: environment, description: description, + auto_merge: auto_merge, required_contexts: required_contexts }.compact + response = connection(owner: owner, repo: repo, **).post( + "/repos/#{owner}/#{repo}/deployments", payload + ) + { result: response.body } + end + + def delete_deployment(owner:, repo:, deployment_id:, **) + response = connection(owner: owner, repo: repo, **).delete( + "/repos/#{owner}/#{repo}/deployments/#{deployment_id}" + ) + { result: response.status == 204 } + end + + def list_deployment_statuses(owner:, repo:, deployment_id:, per_page: 30, page: 1, **) + params = { per_page: per_page, page: page } + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/deployments/#{deployment_id}/statuses", params + ) + { result: response.body } + end + + def create_deployment_status(owner:, repo:, deployment_id:, state:, + description: nil, environment_url: nil, log_url: nil, **) + payload = { state: state, description: description, + environment_url: environment_url, log_url: log_url }.compact + response = connection(owner: owner, repo: repo, **).post( + "/repos/#{owner}/#{repo}/deployments/#{deployment_id}/statuses", payload + ) + { result: response.body } + end + + def get_deployment_status(owner:, repo:, deployment_id:, status_id:, **) + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/deployments/#{deployment_id}/statuses/#{status_id}" + ) + { result: response.body } + end + + include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && + Legion::Extensions::Helpers.const_defined?(:Lex, false) + end + end + end + end +end diff --git a/lib/legion/extensions/github/runners/gists.rb b/lib/legion/extensions/github/runners/gists.rb index 2f0a2a1..de3c1c2 100644 --- a/lib/legion/extensions/github/runners/gists.rb +++ b/lib/legion/extensions/github/runners/gists.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'legion/extensions/github/helpers/client' +require 'legion/extensions/github/helpers/cache' module Legion module Extensions @@ -8,31 +9,35 @@ module Github module Runners module Gists include Legion::Extensions::Github::Helpers::Client + include Legion::Extensions::Github::Helpers::Cache def list_gists(per_page: 30, page: 1, **) - response = connection(**).get('/gists', per_page: per_page, page: page) - { result: response.body } + cred = resolve_credential + fp = cred&.dig(:metadata, :credential_fingerprint) || 'anonymous' + { result: cached_get("github:user:gists:#{fp}:#{page}:#{per_page}") { connection(**).get('/gists', per_page: per_page, page: page).body } } end def get_gist(gist_id:, **) - response = connection(**).get("/gists/#{gist_id}") - { result: response.body } + { result: cached_get("github:gist:#{gist_id}") { connection(**).get("/gists/#{gist_id}").body } } end def create_gist(files:, description: nil, public: false, **) payload = { files: files, description: description, public: public } response = connection(**).post('/gists', payload) + cache_write("github:gist:#{response.body['id']}", response.body) if response.body['id'] { result: response.body } end def update_gist(gist_id:, files: nil, description: nil, **) payload = { files: files, description: description }.compact response = connection(**).patch("/gists/#{gist_id}", payload) + cache_write("github:gist:#{gist_id}", response.body) if response.body['id'] { result: response.body } end def delete_gist(gist_id:, **) response = connection(**).delete("/gists/#{gist_id}") + cache_invalidate("github:gist:#{gist_id}") if response.status == 204 { result: response.status == 204 } end diff --git a/lib/legion/extensions/github/runners/issues.rb b/lib/legion/extensions/github/runners/issues.rb index 412050d..a161b0a 100644 --- a/lib/legion/extensions/github/runners/issues.rb +++ b/lib/legion/extensions/github/runners/issues.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'legion/extensions/github/helpers/client' +require 'legion/extensions/github/helpers/cache' module Legion module Extensions @@ -8,38 +9,44 @@ module Github module Runners module Issues include Legion::Extensions::Github::Helpers::Client + include Legion::Extensions::Github::Helpers::Cache def list_issues(owner:, repo:, state: 'open', per_page: 30, page: 1, **) params = { state: state, per_page: per_page, page: page } - response = connection(**).get("/repos/#{owner}/#{repo}/issues", params) - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}:issues:#{state}:#{page}:#{per_page}") do + connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/issues", params).body + end } end def get_issue(owner:, repo:, issue_number:, **) - response = connection(**).get("/repos/#{owner}/#{repo}/issues/#{issue_number}") - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}:issues:#{issue_number}") do + connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/issues/#{issue_number}").body + end } end def create_issue(owner:, repo:, title:, body: nil, labels: [], assignees: [], **) payload = { title: title, body: body, labels: labels, assignees: assignees } - response = connection(**).post("/repos/#{owner}/#{repo}/issues", payload) + response = connection(owner: owner, repo: repo, **).post("/repos/#{owner}/#{repo}/issues", payload) + cache_write("github:repo:#{owner}/#{repo}:issues:#{response.body['number']}", response.body) if response.body['id'] { result: response.body } end def update_issue(owner:, repo:, issue_number:, **opts) payload = opts.slice(:title, :body, :state, :labels, :assignees) - response = connection(**opts).patch("/repos/#{owner}/#{repo}/issues/#{issue_number}", payload) + response = connection(owner: owner, repo: repo, **opts).patch("/repos/#{owner}/#{repo}/issues/#{issue_number}", payload) + cache_write("github:repo:#{owner}/#{repo}:issues:#{issue_number}", response.body) if response.body['id'] { result: response.body } end def list_issue_comments(owner:, repo:, issue_number:, per_page: 30, page: 1, **) params = { per_page: per_page, page: page } - response = connection(**).get("/repos/#{owner}/#{repo}/issues/#{issue_number}/comments", params) - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}:issues:#{issue_number}:comments:#{page}:#{per_page}") do + connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/issues/#{issue_number}/comments", params).body + end } end def create_issue_comment(owner:, repo:, issue_number:, body:, **) - response = connection(**).post("/repos/#{owner}/#{repo}/issues/#{issue_number}/comments", { body: body }) + response = connection(owner: owner, repo: repo, **).post("/repos/#{owner}/#{repo}/issues/#{issue_number}/comments", { body: body }) { result: response.body } end diff --git a/lib/legion/extensions/github/runners/labels.rb b/lib/legion/extensions/github/runners/labels.rb index 12a05cb..5612659 100644 --- a/lib/legion/extensions/github/runners/labels.rb +++ b/lib/legion/extensions/github/runners/labels.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'legion/extensions/github/helpers/client' +require 'legion/extensions/github/helpers/cache' module Legion module Extensions @@ -8,42 +9,48 @@ module Github module Runners module Labels include Legion::Extensions::Github::Helpers::Client + include Legion::Extensions::Github::Helpers::Cache def list_labels(owner:, repo:, per_page: 30, page: 1, **) params = { per_page: per_page, page: page } - response = connection(**).get("/repos/#{owner}/#{repo}/labels", params) - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}:labels:#{page}:#{per_page}") do + connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/labels", params).body + end } end def get_label(owner:, repo:, name:, **) - response = connection(**).get("/repos/#{owner}/#{repo}/labels/#{name}") - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}:labels:#{name}") do + connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/labels/#{name}").body + end } end def create_label(owner:, repo:, name:, color:, description: nil, **) payload = { name: name, color: color, description: description }.compact - response = connection(**).post("/repos/#{owner}/#{repo}/labels", payload) + response = connection(owner: owner, repo: repo, **).post("/repos/#{owner}/#{repo}/labels", payload) + cache_write("github:repo:#{owner}/#{repo}:labels:#{name}", response.body) if response.body['id'] { result: response.body } end def update_label(owner:, repo:, name:, new_name: nil, color: nil, description: nil, **) payload = { new_name: new_name, color: color, description: description }.compact - response = connection(**).patch("/repos/#{owner}/#{repo}/labels/#{name}", payload) + response = connection(owner: owner, repo: repo, **).patch("/repos/#{owner}/#{repo}/labels/#{name}", payload) + cache_write("github:repo:#{owner}/#{repo}:labels:#{name}", response.body) if response.body['id'] { result: response.body } end def delete_label(owner:, repo:, name:, **) - response = connection(**).delete("/repos/#{owner}/#{repo}/labels/#{name}") + response = connection(owner: owner, repo: repo, **).delete("/repos/#{owner}/#{repo}/labels/#{name}") + cache_invalidate("github:repo:#{owner}/#{repo}:labels:#{name}") if response.status == 204 { result: response.status == 204 } end def add_labels_to_issue(owner:, repo:, issue_number:, labels:, **) - response = connection(**).post("/repos/#{owner}/#{repo}/issues/#{issue_number}/labels", { labels: labels }) + response = connection(owner: owner, repo: repo, **).post("/repos/#{owner}/#{repo}/issues/#{issue_number}/labels", { labels: labels }) { result: response.body } end def remove_label_from_issue(owner:, repo:, issue_number:, name:, **) - response = connection(**).delete("/repos/#{owner}/#{repo}/issues/#{issue_number}/labels/#{name}") + response = connection(owner: owner, repo: repo, **).delete("/repos/#{owner}/#{repo}/issues/#{issue_number}/labels/#{name}") { result: response.status == 204 } end diff --git a/lib/legion/extensions/github/runners/organizations.rb b/lib/legion/extensions/github/runners/organizations.rb index 612b552..57b6e3d 100644 --- a/lib/legion/extensions/github/runners/organizations.rb +++ b/lib/legion/extensions/github/runners/organizations.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'legion/extensions/github/helpers/client' +require 'legion/extensions/github/helpers/cache' module Legion module Extensions @@ -8,26 +9,27 @@ module Github module Runners module Organizations include Legion::Extensions::Github::Helpers::Client + include Legion::Extensions::Github::Helpers::Cache def list_user_orgs(username:, per_page: 30, page: 1, **) - response = connection(**).get("/users/#{username}/orgs", per_page: per_page, page: page) - { result: response.body } + { result: cached_get("github:user:#{username}:orgs:#{page}:#{per_page}") do + connection(**).get("/users/#{username}/orgs", per_page: per_page, page: page).body + end } end def get_org(org:, **) - response = connection(**).get("/orgs/#{org}") - { result: response.body } + { result: cached_get("github:org:#{org}") { connection(owner: org, **).get("/orgs/#{org}").body } } end def list_org_repos(org:, type: 'all', per_page: 30, page: 1, **) params = { type: type, per_page: per_page, page: page } - response = connection(**).get("/orgs/#{org}/repos", params) - { result: response.body } + { result: cached_get("github:org:#{org}:repos:#{type}:#{page}:#{per_page}") { connection(owner: org, **).get("/orgs/#{org}/repos", params).body } } end def list_org_members(org:, per_page: 30, page: 1, **) - response = connection(**).get("/orgs/#{org}/members", per_page: per_page, page: page) - { result: response.body } + { result: cached_get("github:org:#{org}:members:#{page}:#{per_page}") do + connection(owner: org, **).get("/orgs/#{org}/members", per_page: per_page, page: page).body + end } end include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && diff --git a/lib/legion/extensions/github/runners/pull_requests.rb b/lib/legion/extensions/github/runners/pull_requests.rb index db00cb6..dab25b2 100644 --- a/lib/legion/extensions/github/runners/pull_requests.rb +++ b/lib/legion/extensions/github/runners/pull_requests.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'legion/extensions/github/helpers/client' +require 'legion/extensions/github/helpers/cache' module Legion module Extensions @@ -8,57 +9,66 @@ module Github module Runners module PullRequests include Legion::Extensions::Github::Helpers::Client + include Legion::Extensions::Github::Helpers::Cache def list_pull_requests(owner:, repo:, state: 'open', per_page: 30, page: 1, **) params = { state: state, per_page: per_page, page: page } - response = connection(**).get("/repos/#{owner}/#{repo}/pulls", params) - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}:pulls:#{state}:#{page}:#{per_page}") do + connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/pulls", params).body + end } end def get_pull_request(owner:, repo:, pull_number:, **) - response = connection(**).get("/repos/#{owner}/#{repo}/pulls/#{pull_number}") - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}:pulls:#{pull_number}") do + connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/pulls/#{pull_number}").body + end } end def create_pull_request(owner:, repo:, title:, head:, base:, body: nil, draft: false, **) payload = { title: title, head: head, base: base, body: body, draft: draft } - response = connection(**).post("/repos/#{owner}/#{repo}/pulls", payload) + response = connection(owner: owner, repo: repo, **).post("/repos/#{owner}/#{repo}/pulls", payload) + cache_write("github:repo:#{owner}/#{repo}:pulls:#{response.body['number']}", response.body) if response.body['id'] { result: response.body } end def update_pull_request(owner:, repo:, pull_number:, **opts) payload = opts.slice(:title, :body, :state, :base) - response = connection(**opts).patch("/repos/#{owner}/#{repo}/pulls/#{pull_number}", payload) + response = connection(owner: owner, repo: repo, **opts).patch("/repos/#{owner}/#{repo}/pulls/#{pull_number}", payload) + cache_write("github:repo:#{owner}/#{repo}:pulls:#{pull_number}", response.body) if response.body['id'] { result: response.body } end def merge_pull_request(owner:, repo:, pull_number:, commit_title: nil, merge_method: 'merge', **) payload = { commit_title: commit_title, merge_method: merge_method }.compact - response = connection(**).put("/repos/#{owner}/#{repo}/pulls/#{pull_number}/merge", payload) + response = connection(owner: owner, repo: repo, **).put("/repos/#{owner}/#{repo}/pulls/#{pull_number}/merge", payload) + cache_invalidate("github:repo:#{owner}/#{repo}:pulls:#{pull_number}") { result: response.body } end def list_pull_request_commits(owner:, repo:, pull_number:, per_page: 30, page: 1, **) params = { per_page: per_page, page: page } - response = connection(**).get("/repos/#{owner}/#{repo}/pulls/#{pull_number}/commits", params) - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}:pulls:#{pull_number}:commits:#{page}:#{per_page}") do + connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/pulls/#{pull_number}/commits", params).body + end } end def list_pull_request_files(owner:, repo:, pull_number:, per_page: 30, page: 1, **) params = { per_page: per_page, page: page } - response = connection(**).get("/repos/#{owner}/#{repo}/pulls/#{pull_number}/files", params) - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}:pulls:#{pull_number}:files:#{page}:#{per_page}") do + connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/pulls/#{pull_number}/files", params).body + end } end def list_pull_request_reviews(owner:, repo:, pull_number:, per_page: 30, page: 1, **) params = { per_page: per_page, page: page } - response = connection(**).get("/repos/#{owner}/#{repo}/pulls/#{pull_number}/reviews", params) - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}:pulls:#{pull_number}:reviews:#{page}:#{per_page}") do + connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/pulls/#{pull_number}/reviews", params).body + end } end def create_review(owner:, repo:, pull_number:, body:, comments: [], event: 'COMMENT', **) payload = { event: event, body: body, comments: comments } - response = connection(**).post("/repos/#{owner}/#{repo}/pulls/#{pull_number}/reviews", payload) + response = connection(owner: owner, repo: repo, **).post("/repos/#{owner}/#{repo}/pulls/#{pull_number}/reviews", payload) { result: response.body } end diff --git a/lib/legion/extensions/github/runners/releases.rb b/lib/legion/extensions/github/runners/releases.rb new file mode 100644 index 0000000..83c15e3 --- /dev/null +++ b/lib/legion/extensions/github/runners/releases.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'legion/extensions/github/helpers/client' + +module Legion + module Extensions + module Github + module Runners + module Releases + include Legion::Extensions::Github::Helpers::Client + + def list_releases(owner:, repo:, per_page: 30, page: 1, **) + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/releases", per_page: per_page, page: page + ) + { result: response.body } + end + + def get_release(owner:, repo:, release_id:, **) + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/releases/#{release_id}" + ) + { result: response.body } + end + + def get_latest_release(owner:, repo:, **) + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/releases/latest" + ) + { result: response.body } + end + + def get_release_by_tag(owner:, repo:, tag:, **) + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/releases/tags/#{tag}" + ) + { result: response.body } + end + + def create_release(owner:, repo:, tag_name:, name: nil, body: nil, # rubocop:disable Metrics/ParameterLists + target_commitish: nil, draft: false, prerelease: false, + generate_release_notes: false, **) + payload = { tag_name: tag_name, name: name, body: body, + target_commitish: target_commitish, draft: draft, + prerelease: prerelease, + generate_release_notes: generate_release_notes }.compact + response = connection(owner: owner, repo: repo, **).post( + "/repos/#{owner}/#{repo}/releases", payload + ) + { result: response.body } + end + + def update_release(owner:, repo:, release_id:, **opts) + payload = opts.slice(:tag_name, :name, :body, :draft, :prerelease, :target_commitish) + response = connection(owner: owner, repo: repo, **opts).patch( + "/repos/#{owner}/#{repo}/releases/#{release_id}", payload + ) + { result: response.body } + end + + def delete_release(owner:, repo:, release_id:, **) + response = connection(owner: owner, repo: repo, **).delete( + "/repos/#{owner}/#{repo}/releases/#{release_id}" + ) + { result: response.status == 204 } + end + + def list_release_assets(owner:, repo:, release_id:, per_page: 30, page: 1, **) + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/releases/#{release_id}/assets", + per_page: per_page, page: page + ) + { result: response.body } + end + + def delete_release_asset(owner:, repo:, asset_id:, **) + response = connection(owner: owner, repo: repo, **).delete( + "/repos/#{owner}/#{repo}/releases/assets/#{asset_id}" + ) + { result: response.status == 204 } + end + + include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && + Legion::Extensions::Helpers.const_defined?(:Lex, false) + end + end + end + end +end diff --git a/lib/legion/extensions/github/runners/repositories.rb b/lib/legion/extensions/github/runners/repositories.rb index c9cfcc0..041afde 100644 --- a/lib/legion/extensions/github/runners/repositories.rb +++ b/lib/legion/extensions/github/runners/repositories.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'legion/extensions/github/helpers/client' +require 'legion/extensions/github/helpers/cache' module Legion module Extensions @@ -8,42 +9,48 @@ module Github module Runners module Repositories include Legion::Extensions::Github::Helpers::Client + include Legion::Extensions::Github::Helpers::Cache def list_repos(username:, per_page: 30, page: 1, **) - response = connection(**).get("/users/#{username}/repos", per_page: per_page, page: page) - { result: response.body } + { result: cached_get("github:user:#{username}:repos:#{page}:#{per_page}") do + connection(**).get("/users/#{username}/repos", per_page: per_page, page: page).body + end } end def get_repo(owner:, repo:, **) - response = connection(**).get("/repos/#{owner}/#{repo}") - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}") { connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}").body } } end def create_repo(name:, description: nil, private: false, **) body = { name: name, description: description, private: private } response = connection(**).post('/user/repos', body) + cache_write("github:repo:#{response.body['full_name']}", response.body) if response.body['id'] { result: response.body } end def update_repo(owner:, repo:, **opts) body = opts.slice(:name, :description, :homepage, :private, :default_branch) - response = connection(**opts).patch("/repos/#{owner}/#{repo}", body) + response = connection(owner: owner, repo: repo, **opts).patch("/repos/#{owner}/#{repo}", body) + cache_write("github:repo:#{owner}/#{repo}", response.body) if response.body['id'] { result: response.body } end def delete_repo(owner:, repo:, **) - response = connection(**).delete("/repos/#{owner}/#{repo}") + response = connection(owner: owner, repo: repo, **).delete("/repos/#{owner}/#{repo}") + cache_invalidate("github:repo:#{owner}/#{repo}") if response.status == 204 { result: response.status == 204 } end def list_branches(owner:, repo:, per_page: 30, page: 1, **) - response = connection(**).get("/repos/#{owner}/#{repo}/branches", per_page: per_page, page: page) - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}:branches:#{page}:#{per_page}") do + connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/branches", per_page: per_page, page: page).body + end } end def list_tags(owner:, repo:, per_page: 30, page: 1, **) - response = connection(**).get("/repos/#{owner}/#{repo}/tags", per_page: per_page, page: page) - { result: response.body } + { result: cached_get("github:repo:#{owner}/#{repo}:tags:#{page}:#{per_page}") do + connection(owner: owner, repo: repo, **).get("/repos/#{owner}/#{repo}/tags", per_page: per_page, page: page).body + end } end include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && diff --git a/lib/legion/extensions/github/runners/repository_webhooks.rb b/lib/legion/extensions/github/runners/repository_webhooks.rb new file mode 100644 index 0000000..5627de4 --- /dev/null +++ b/lib/legion/extensions/github/runners/repository_webhooks.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'legion/extensions/github/helpers/client' + +module Legion + module Extensions + module Github + module Runners + module RepositoryWebhooks + include Legion::Extensions::Github::Helpers::Client + + def list_webhooks(owner:, repo:, per_page: 30, page: 1, **) + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/hooks", per_page: per_page, page: page + ) + { result: response.body } + end + + def get_webhook(owner:, repo:, hook_id:, **) + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/hooks/#{hook_id}" + ) + { result: response.body } + end + + def create_webhook(owner:, repo:, config:, events: ['push'], active: true, **) + payload = { config: config, events: events, active: active } + response = connection(owner: owner, repo: repo, **).post( + "/repos/#{owner}/#{repo}/hooks", payload + ) + { result: response.body } + end + + def update_webhook(owner:, repo:, hook_id:, **opts) + payload = opts.slice(:config, :events, :active, :add_events, :remove_events) + response = connection(owner: owner, repo: repo, **opts).patch( + "/repos/#{owner}/#{repo}/hooks/#{hook_id}", payload + ) + { result: response.body } + end + + def delete_webhook(owner:, repo:, hook_id:, **) + response = connection(owner: owner, repo: repo, **).delete( + "/repos/#{owner}/#{repo}/hooks/#{hook_id}" + ) + { result: response.status == 204 } + end + + def ping_webhook(owner:, repo:, hook_id:, **) + response = connection(owner: owner, repo: repo, **).post( + "/repos/#{owner}/#{repo}/hooks/#{hook_id}/pings" + ) + { result: response.status == 204 } + end + + def test_webhook(owner:, repo:, hook_id:, **) + response = connection(owner: owner, repo: repo, **).post( + "/repos/#{owner}/#{repo}/hooks/#{hook_id}/tests" + ) + { result: response.status == 204 } + end + + def list_webhook_deliveries(owner:, repo:, hook_id:, per_page: 30, **) + response = connection(owner: owner, repo: repo, **).get( + "/repos/#{owner}/#{repo}/hooks/#{hook_id}/deliveries", per_page: per_page + ) + { result: response.body } + end + + include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && + Legion::Extensions::Helpers.const_defined?(:Lex, false) + end + end + end + end +end diff --git a/lib/legion/extensions/github/runners/search.rb b/lib/legion/extensions/github/runners/search.rb index 2f63dc2..baa2201 100644 --- a/lib/legion/extensions/github/runners/search.rb +++ b/lib/legion/extensions/github/runners/search.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true +require 'digest' require 'legion/extensions/github/helpers/client' +require 'legion/extensions/github/helpers/cache' module Legion module Extensions @@ -8,29 +10,30 @@ module Github module Runners module Search include Legion::Extensions::Github::Helpers::Client + include Legion::Extensions::Github::Helpers::Cache def search_repositories(query:, sort: nil, order: 'desc', per_page: 30, page: 1, **) params = { q: query, sort: sort, order: order, per_page: per_page, page: page }.compact - response = connection(**).get('/search/repositories', params) - { result: response.body } + cache_key = "github:search:repositories:#{Digest::MD5.hexdigest(params.sort.to_s)}" + { result: cached_get(cache_key) { connection(**).get('/search/repositories', params).body } } end def search_issues(query:, sort: nil, order: 'desc', per_page: 30, page: 1, **) params = { q: query, sort: sort, order: order, per_page: per_page, page: page }.compact - response = connection(**).get('/search/issues', params) - { result: response.body } + cache_key = "github:search:issues:#{Digest::MD5.hexdigest(params.sort.to_s)}" + { result: cached_get(cache_key) { connection(**).get('/search/issues', params).body } } end def search_users(query:, sort: nil, order: 'desc', per_page: 30, page: 1, **) params = { q: query, sort: sort, order: order, per_page: per_page, page: page }.compact - response = connection(**).get('/search/users', params) - { result: response.body } + cache_key = "github:search:users:#{Digest::MD5.hexdigest(params.sort.to_s)}" + { result: cached_get(cache_key) { connection(**).get('/search/users', params).body } } end def search_code(query:, sort: nil, order: 'desc', per_page: 30, page: 1, **) params = { q: query, sort: sort, order: order, per_page: per_page, page: page }.compact - response = connection(**).get('/search/code', params) - { result: response.body } + cache_key = "github:search:code:#{Digest::MD5.hexdigest(params.sort.to_s)}" + { result: cached_get(cache_key) { connection(**).get('/search/code', params).body } } end include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && diff --git a/lib/legion/extensions/github/runners/users.rb b/lib/legion/extensions/github/runners/users.rb index 7d02e65..8c17b56 100644 --- a/lib/legion/extensions/github/runners/users.rb +++ b/lib/legion/extensions/github/runners/users.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'legion/extensions/github/helpers/client' +require 'legion/extensions/github/helpers/cache' module Legion module Extensions @@ -8,25 +9,28 @@ module Github module Runners module Users include Legion::Extensions::Github::Helpers::Client + include Legion::Extensions::Github::Helpers::Cache def get_authenticated_user(**) - response = connection(**).get('/user') - { result: response.body } + cred = resolve_credential + fp = cred&.dig(:metadata, :credential_fingerprint) || 'anonymous' + { result: cached_get("github:user:authenticated:#{fp}") { connection(**).get('/user').body } } end def get_user(username:, **) - response = connection(**).get("/users/#{username}") - { result: response.body } + { result: cached_get("github:user:#{username}") { connection(**).get("/users/#{username}").body } } end def list_followers(username:, per_page: 30, page: 1, **) - response = connection(**).get("/users/#{username}/followers", per_page: per_page, page: page) - { result: response.body } + { result: cached_get("github:user:#{username}:followers:#{page}:#{per_page}") do + connection(**).get("/users/#{username}/followers", per_page: per_page, page: page).body + end } end def list_following(username:, per_page: 30, page: 1, **) - response = connection(**).get("/users/#{username}/following", per_page: per_page, page: page) - { result: response.body } + { result: cached_get("github:user:#{username}:following:#{page}:#{per_page}") do + connection(**).get("/users/#{username}/following", per_page: per_page, page: page).body + end } end include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) && diff --git a/lib/legion/extensions/github/version.rb b/lib/legion/extensions/github/version.rb index e3a7007..ae898a4 100644 --- a/lib/legion/extensions/github/version.rb +++ b/lib/legion/extensions/github/version.rb @@ -3,7 +3,7 @@ module Legion module Extensions module Github - VERSION = '0.2.5' + VERSION = '0.3.0' end end end diff --git a/spec/legion/extensions/github/app/runners/auth_spec.rb b/spec/legion/extensions/github/app/runners/auth_spec.rb new file mode 100644 index 0000000..d4d057d --- /dev/null +++ b/spec/legion/extensions/github/app/runners/auth_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::App::Runners::Auth do + let(:runner) { Object.new.extend(described_class) } + let(:test_connection) do + Faraday.new(url: 'https://api.github.com') do |conn| + conn.request :json + conn.response :json, content_type: /\bjson$/ + conn.adapter :test, stubs + end + end + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:private_key) { OpenSSL::PKey::RSA.generate(2048) } + + before { allow(runner).to receive(:connection).and_return(test_connection) } + + describe '#generate_jwt' do + it 'generates a valid RS256 JWT with app_id as issuer' do + result = runner.generate_jwt(app_id: '12345', private_key: private_key.to_pem) + expect(result[:result]).to be_a(String) + + decoded = JWT.decode(result[:result], private_key.public_key, true, algorithm: 'RS256') + expect(decoded.first['iss']).to eq('12345') + end + + it 'sets iat to 60 seconds in the past' do + result = runner.generate_jwt(app_id: '12345', private_key: private_key.to_pem) + decoded = JWT.decode(result[:result], private_key.public_key, true, algorithm: 'RS256') + expect(decoded.first['iat']).to be_within(5).of(Time.now.to_i - 60) + end + + it 'sets exp to 10 minutes from now' do + result = runner.generate_jwt(app_id: '12345', private_key: private_key.to_pem) + decoded = JWT.decode(result[:result], private_key.public_key, true, algorithm: 'RS256') + expect(decoded.first['exp']).to be_within(5).of(Time.now.to_i + 600) + end + end + + describe '#create_installation_token' do + it 'exchanges a JWT for an installation access token' do + stubs.post('/app/installations/67890/access_tokens') do + [201, { 'Content-Type' => 'application/json' }, + { 'token' => 'ghs_test123', 'expires_at' => '2026-03-30T12:00:00Z' }] + end + + result = runner.create_installation_token(jwt: 'fake-jwt', installation_id: '67890') + expect(result[:result]['token']).to eq('ghs_test123') + expect(result[:result]['expires_at']).to eq('2026-03-30T12:00:00Z') + end + end + + describe '#list_installations' do + it 'lists installations for the authenticated app' do + stubs.get('/app/installations') do + [200, { 'Content-Type' => 'application/json' }, + [{ 'id' => 67_890, 'account' => { 'login' => 'LegionIO' } }]] + end + + result = runner.list_installations(jwt: 'fake-jwt') + expect(result[:result]).to be_an(Array) + expect(result[:result].first['id']).to eq(67_890) + end + end + + describe '#get_installation' do + it 'returns a single installation' do + stubs.get('/app/installations/67890') do + [200, { 'Content-Type' => 'application/json' }, + { 'id' => 67_890, 'account' => { 'login' => 'LegionIO' } }] + end + + result = runner.get_installation(jwt: 'fake-jwt', installation_id: '67890') + expect(result[:result]['id']).to eq(67_890) + end + end +end diff --git a/spec/legion/extensions/github/app/runners/credential_store_spec.rb b/spec/legion/extensions/github/app/runners/credential_store_spec.rb new file mode 100644 index 0000000..7f60986 --- /dev/null +++ b/spec/legion/extensions/github/app/runners/credential_store_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::App::Runners::CredentialStore do + let(:runner) { Object.new.extend(described_class) } + + describe '#store_app_credentials' do + it 'stores all app credentials from manifest exchange' do + expect(runner).to receive(:vault_set).with('github/app/app_id', '12345') + expect(runner).to receive(:vault_set).with('github/app/private_key', '-----BEGIN RSA...') + expect(runner).to receive(:vault_set).with('github/app/client_id', 'Iv1.abc') + expect(runner).to receive(:vault_set).with('github/app/client_secret', 'secret123') + expect(runner).to receive(:vault_set).with('github/app/webhook_secret', 'whsec123') + + runner.store_app_credentials( + app_id: '12345', private_key: '-----BEGIN RSA...', + client_id: 'Iv1.abc', client_secret: 'secret123', + webhook_secret: 'whsec123' + ) + end + + it 'returns success result' do + allow(runner).to receive(:vault_set) + result = runner.store_app_credentials( + app_id: '12345', private_key: 'key', + client_id: 'id', client_secret: 'secret', + webhook_secret: 'whsec' + ) + expect(result[:result]).to eq(true) + end + end + + describe '#store_oauth_token' do + it 'stores delegated token at user-scoped path and canonical delegated path' do + expect(runner).to receive(:vault_set).with( + 'github/oauth/matt/token', + hash_including('access_token' => 'ghu_test', 'refresh_token' => 'ghr_test') + ) + expect(runner).to receive(:vault_set).with( + 'github/oauth/delegated/token', + hash_including('access_token' => 'ghu_test', 'refresh_token' => 'ghr_test') + ) + runner.store_oauth_token( + user: 'matt', access_token: 'ghu_test', + refresh_token: 'ghr_test', expires_in: 28_800 + ) + end + end + + describe '#load_oauth_token' do + it 'loads delegated token from user-scoped path' do + allow(runner).to receive(:vault_get).with('github/oauth/matt/token') + .and_return({ 'access_token' => 'ghu_test' }) + result = runner.load_oauth_token(user: 'matt') + expect(result[:result]['access_token']).to eq('ghu_test') + end + + it 'returns nil when no token exists' do + allow(runner).to receive(:vault_get).and_return(nil) + result = runner.load_oauth_token(user: 'matt') + expect(result[:result]).to be_nil + end + end +end diff --git a/spec/legion/extensions/github/app/runners/installations_spec.rb b/spec/legion/extensions/github/app/runners/installations_spec.rb new file mode 100644 index 0000000..562cbf7 --- /dev/null +++ b/spec/legion/extensions/github/app/runners/installations_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::App::Runners::Installations do + let(:runner) { Object.new.extend(described_class) } + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:test_connection) do + Faraday.new(url: 'https://api.github.com') do |conn| + conn.request :json + conn.response :json, content_type: /\bjson$/ + conn.adapter :test, stubs + end + end + + before { allow(runner).to receive(:connection).and_return(test_connection) } + + describe '#list_installations' do + it 'lists all installations for the app' do + stubs.get('/app/installations') do + [200, { 'Content-Type' => 'application/json' }, + [{ 'id' => 1, 'account' => { 'login' => 'LegionIO' } }, + { 'id' => 2, 'account' => { 'login' => 'other-org' } }]] + end + result = runner.list_installations(jwt: 'fake-jwt') + expect(result[:result]).to be_an(Array) + expect(result[:result].length).to eq(2) + end + end + + describe '#get_installation' do + it 'returns a single installation' do + stubs.get('/app/installations/12345') do + [200, { 'Content-Type' => 'application/json' }, + { 'id' => 12_345, 'account' => { 'login' => 'LegionIO' }, + 'permissions' => { 'contents' => 'write' } }] + end + result = runner.get_installation(jwt: 'fake-jwt', installation_id: '12345') + expect(result[:result]['id']).to eq(12_345) + end + end + + describe '#list_installation_repos' do + it 'lists repos accessible to an installation' do + stubs.get('/installation/repositories') do + [200, { 'Content-Type' => 'application/json' }, + { 'total_count' => 1, 'repositories' => [{ 'full_name' => 'LegionIO/lex-github' }] }] + end + result = runner.list_installation_repos(token: 'ghs_test') + expect(result[:result]['repositories'].first['full_name']).to eq('LegionIO/lex-github') + end + end + + describe '#suspend_installation' do + it 'suspends an installation' do + stubs.put('/app/installations/12345/suspended') { [204, {}, ''] } + result = runner.suspend_installation(jwt: 'fake-jwt', installation_id: '12345') + expect(result[:result]).to be true + end + end + + describe '#unsuspend_installation' do + it 'unsuspends an installation' do + stubs.delete('/app/installations/12345/suspended') { [204, {}, ''] } + result = runner.unsuspend_installation(jwt: 'fake-jwt', installation_id: '12345') + expect(result[:result]).to be true + end + end + + describe '#delete_installation' do + it 'deletes an installation' do + stubs.delete('/app/installations/12345') { [204, {}, ''] } + result = runner.delete_installation(jwt: 'fake-jwt', installation_id: '12345') + expect(result[:result]).to be true + end + end +end diff --git a/spec/legion/extensions/github/app/runners/manifest_spec.rb b/spec/legion/extensions/github/app/runners/manifest_spec.rb new file mode 100644 index 0000000..27d20d3 --- /dev/null +++ b/spec/legion/extensions/github/app/runners/manifest_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::App::Runners::Manifest do + let(:runner) { Object.new.extend(described_class) } + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:test_connection) do + Faraday.new(url: 'https://api.github.com') do |conn| + conn.request :json + conn.response :json, content_type: /\bjson$/ + conn.adapter :test, stubs + end + end + + before { allow(runner).to receive(:connection).and_return(test_connection) } + + describe '#generate_manifest' do + it 'builds a manifest hash with required fields' do + result = runner.generate_manifest( + name: 'LegionIO Bot', + url: 'https://legionio.dev', + webhook_url: 'https://legion.example.com/api/hooks/lex/github/app/webhook', + callback_url: 'https://legion.example.com/api/hooks/lex/github/app/setup/callback' + ) + manifest = result[:result] + expect(manifest[:name]).to eq('LegionIO Bot') + expect(manifest[:url]).to eq('https://legionio.dev') + expect(manifest[:hook_attributes][:url]).to eq('https://legion.example.com/api/hooks/lex/github/app/webhook') + expect(manifest[:setup_url]).to include('setup/callback') + expect(manifest[:default_permissions]).to be_a(Hash) + expect(manifest[:default_events]).to be_an(Array) + end + end + + describe '#exchange_manifest_code' do + it 'converts a manifest code into app credentials' do + stubs.post('/app-manifests/test-code/conversions') do + [201, { 'Content-Type' => 'application/json' }, + { 'id' => 12_345, 'client_id' => 'Iv1.abc', 'client_secret' => 'secret', + 'pem' => '-----BEGIN RSA PRIVATE KEY-----...', 'webhook_secret' => 'whsec' }] + end + + result = runner.exchange_manifest_code(code: 'test-code') + expect(result[:result]['id']).to eq(12_345) + expect(result[:result]['pem']).to start_with('-----BEGIN') + end + end + + describe '#manifest_url' do + it 'returns the GitHub manifest creation URL' do + result = runner.generate_manifest( + name: 'Test', url: 'https://test.com', + webhook_url: 'https://test.com/webhook', + callback_url: 'https://test.com/callback' + ) + url = runner.manifest_url(manifest: result[:result]) + expect(url[:result]).to start_with('https://github.com/settings/apps/new') + end + + it 'supports org-scoped manifest URL' do + result = runner.generate_manifest( + name: 'Test', url: 'https://test.com', + webhook_url: 'https://test.com/webhook', + callback_url: 'https://test.com/callback' + ) + url = runner.manifest_url(manifest: result[:result], org: 'LegionIO') + expect(url[:result]).to include('/organizations/LegionIO/settings/apps/new') + end + end +end diff --git a/spec/legion/extensions/github/app/runners/webhooks_scope_invalidation_spec.rb b/spec/legion/extensions/github/app/runners/webhooks_scope_invalidation_spec.rb new file mode 100644 index 0000000..8c980b6 --- /dev/null +++ b/spec/legion/extensions/github/app/runners/webhooks_scope_invalidation_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +RSpec.describe 'Webhook scope invalidation' do + let(:runner) { Object.new.extend(Legion::Extensions::Github::App::Runners::Webhooks) } + + before do + allow(runner).to receive(:cache_connected?).and_return(true) + allow(runner).to receive(:local_cache_connected?).and_return(true) + allow(runner).to receive(:cache_delete) + allow(runner).to receive(:local_cache_delete) + end + + describe '#invalidate_scopes_for_event' do + it 'invalidates org scope on installation.created' do + payload = { + 'action' => 'created', + 'installation' => { + 'id' => 12_345, + 'account' => { 'login' => 'OrgZ', 'type' => 'Organization' } + } + } + expect(runner).to receive(:invalidate_all_scopes_for_owner).with(owner: 'OrgZ') + runner.invalidate_scopes_for_event(event_type: 'installation', payload: payload) + end + + it 'invalidates org scope on installation.deleted' do + payload = { + 'action' => 'deleted', + 'installation' => { + 'id' => 12_345, + 'account' => { 'login' => 'OrgZ', 'type' => 'Organization' } + } + } + expect(runner).to receive(:invalidate_all_scopes_for_owner).with(owner: 'OrgZ') + runner.invalidate_scopes_for_event(event_type: 'installation', payload: payload) + end + + it 'invalidates repo scopes on installation_repositories.added' do + payload = { + 'action' => 'added', + 'installation' => { + 'id' => 12_345, + 'account' => { 'login' => 'OrgZ' } + }, + 'repositories_added' => [ + { 'full_name' => 'OrgZ/repo1' }, + { 'full_name' => 'OrgZ/repo2' } + ] + } + expect(runner).to receive(:invalidate_all_scopes_for_owner).with(owner: 'OrgZ') + runner.invalidate_scopes_for_event(event_type: 'installation_repositories', payload: payload) + end + + it 'invalidates repo scopes on installation_repositories.removed' do + payload = { + 'action' => 'removed', + 'installation' => { + 'id' => 12_345, + 'account' => { 'login' => 'OrgZ' } + }, + 'repositories_removed' => [ + { 'full_name' => 'OrgZ/repo1' } + ] + } + expect(runner).to receive(:invalidate_all_scopes_for_owner).with(owner: 'OrgZ') + runner.invalidate_scopes_for_event(event_type: 'installation_repositories', payload: payload) + end + + it 'does nothing for unrelated events' do + expect(runner).not_to receive(:invalidate_all_scopes_for_owner) + runner.invalidate_scopes_for_event(event_type: 'push', payload: { 'ref' => 'refs/heads/main' }) + end + end +end diff --git a/spec/legion/extensions/github/app/runners/webhooks_spec.rb b/spec/legion/extensions/github/app/runners/webhooks_spec.rb new file mode 100644 index 0000000..4d05246 --- /dev/null +++ b/spec/legion/extensions/github/app/runners/webhooks_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::App::Runners::Webhooks do + let(:runner) { Object.new.extend(described_class) } + let(:webhook_secret) { 'test-webhook-secret' } + let(:payload) { '{"action":"opened","number":1}' } + let(:valid_signature) { "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', webhook_secret, payload)}" } + + describe '#verify_signature' do + it 'returns true for a valid signature' do + result = runner.verify_signature(payload: payload, signature: valid_signature, secret: webhook_secret) + expect(result[:result]).to be true + end + + it 'returns false for an invalid signature' do + result = runner.verify_signature(payload: payload, signature: 'sha256=invalid', secret: webhook_secret) + expect(result[:result]).to be false + end + + it 'returns false for a nil signature' do + result = runner.verify_signature(payload: payload, signature: nil, secret: webhook_secret) + expect(result[:result]).to be false + end + end + + describe '#parse_event' do + it 'parses a webhook payload with event metadata' do + result = runner.parse_event( + payload: payload, + event_type: 'pull_request', + delivery_id: 'abc-123' + ) + expect(result[:result][:event_type]).to eq('pull_request') + expect(result[:result][:delivery_id]).to eq('abc-123') + expect(result[:result][:payload]['action']).to eq('opened') + end + end + + describe '#receive_event' do + it 'verifies signature and parses event in one call' do + result = runner.receive_event( + payload: payload, + signature: valid_signature, + secret: webhook_secret, + event_type: 'issues', + delivery_id: 'def-456' + ) + expect(result[:result][:verified]).to be true + expect(result[:result][:event_type]).to eq('issues') + expect(result[:result][:payload]['action']).to eq('opened') + end + + it 'rejects events with invalid signatures' do + result = runner.receive_event( + payload: payload, + signature: 'sha256=bad', + secret: webhook_secret, + event_type: 'issues', + delivery_id: 'def-456' + ) + expect(result[:result][:verified]).to be false + expect(result[:result][:payload]).to be_nil + end + end + + describe '#resolve_known_fingerprints (no network calls)' do + before do + allow(runner).to receive(:cache_connected?).and_return(false) + allow(runner).to receive(:local_cache_connected?).and_return(false) + allow(runner).to receive(:safe_vault_get).and_return(nil) + allow(runner).to receive(:safe_settings_dig).and_return(nil) + end + + it 'always includes CLI and ENV fingerprints' do + fingerprints = runner.send(:resolve_known_fingerprints) + cli_fp = runner.credential_fingerprint(auth_type: :cli, identifier: 'gh_cli') + env_fp = runner.credential_fingerprint(auth_type: :env, identifier: 'env') + expect(fingerprints).to include(cli_fp) + expect(fingerprints).to include(env_fp) + end + + it 'includes vault app fingerprint when app_id is configured' do + allow(runner).to receive(:safe_vault_get).with('github/app/app_id').and_return('99999') + fingerprints = runner.send(:resolve_known_fingerprints) + expected_fp = runner.credential_fingerprint(auth_type: :app_installation, identifier: 'vault_app_99999') + expect(fingerprints).to include(expected_fp) + end + + it 'returns empty array on error' do + allow(runner).to receive(:credential_fingerprint).and_raise(StandardError, 'boom') + expect(runner.send(:resolve_known_fingerprints)).to eq([]) + end + end +end diff --git a/spec/legion/extensions/github/cli/app_spec.rb b/spec/legion/extensions/github/cli/app_spec.rb new file mode 100644 index 0000000..71405ee --- /dev/null +++ b/spec/legion/extensions/github/cli/app_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::CLI::App do + let(:cli) { Object.new.extend(described_class) } + let(:server) { instance_double(Legion::Extensions::Github::Helpers::CallbackServer) } + + before do + allow(Legion::Extensions::Github::Helpers::CallbackServer).to receive(:new).and_return(server) + allow(server).to receive(:start) + allow(server).to receive(:shutdown) + allow(server).to receive(:port).and_return(12_345) + allow(server).to receive(:redirect_uri).and_return('http://127.0.0.1:12345/callback') + allow(server).to receive(:wait_for_callback).and_return({ code: 'manifest-code', state: nil }) + end + + describe '#setup' do + it 'generates manifest and returns manifest URL' do + result = cli.setup( + name: 'LegionIO Bot', + url: 'https://legionio.dev', + webhook_url: 'https://legion.example.com/api/hooks/lex/github/app/webhook' + ) + expect(result[:result][:manifest_url]).to include('github.com/settings/apps/new') + end + + it 'supports org-scoped setup' do + result = cli.setup( + name: 'LegionIO Bot', + url: 'https://legionio.dev', + webhook_url: 'https://legion.example.com/webhook', + org: 'LegionIO' + ) + expect(result[:result][:manifest_url]).to include('/organizations/LegionIO/') + end + + it 'waits for callback before returning' do + cli.setup( + name: 'LegionIO Bot', + url: 'https://legionio.dev', + webhook_url: 'https://legion.example.com/webhook' + ) + expect(server).to have_received(:wait_for_callback) + end + + it 'includes callback result in response' do + result = cli.setup( + name: 'LegionIO Bot', + url: 'https://legionio.dev', + webhook_url: 'https://legion.example.com/webhook' + ) + expect(result[:result][:callback]).to eq({ code: 'manifest-code', state: nil }) + end + end + + describe '#complete_setup' do + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:test_connection) do + Faraday.new(url: 'https://api.github.com') do |conn| + conn.request :json + conn.response :json, content_type: /\bjson$/ + conn.adapter :test, stubs + end + end + + before { allow(cli).to receive(:connection).and_return(test_connection) } + + it 'exchanges manifest code and stores credentials' do + stubs.post('/app-manifests/test-code/conversions') do + [201, { 'Content-Type' => 'application/json' }, + { 'id' => 12_345, 'pem' => '-----BEGIN RSA...', 'client_id' => 'Iv1.abc', + 'client_secret' => 'secret', 'webhook_secret' => 'whsec' }] + end + allow(cli).to receive(:store_app_credentials) + + result = cli.complete_setup(code: 'test-code') + expect(result[:result]['id']).to eq(12_345) + end + end +end diff --git a/spec/legion/extensions/github/cli/auth_spec.rb b/spec/legion/extensions/github/cli/auth_spec.rb new file mode 100644 index 0000000..2d6c7d8 --- /dev/null +++ b/spec/legion/extensions/github/cli/auth_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::CLI::Auth do + let(:cli) { Object.new.extend(described_class) } + let(:browser_auth) { instance_double(Legion::Extensions::Github::Helpers::BrowserAuth) } + + before do + allow(Legion::Extensions::Github::Helpers::BrowserAuth).to receive(:new).and_return(browser_auth) + end + + describe '#login' do + it 'authenticates and returns token result' do + allow(browser_auth).to receive(:authenticate).and_return( + result: { 'access_token' => 'ghu_test', 'refresh_token' => 'ghr_test' } + ) + result = cli.login(client_id: 'Iv1.abc', client_secret: 'secret') + expect(result[:result]['access_token']).to eq('ghu_test') + end + end + + describe '#status' do + it 'returns current auth info when token available' do + allow(cli).to receive(:resolve_credential).and_return( + { token: 'ghp_test', auth_type: :pat } + ) + stubs = Faraday::Adapter::Test::Stubs.new + stubs.get('/user') do + [200, { 'Content-Type' => 'application/json' }, { 'login' => 'octocat' }] + end + conn = Faraday.new(url: 'https://api.github.com') do |f| + f.response :json, content_type: /\bjson$/ + f.adapter :test, stubs + end + allow(cli).to receive(:connection).and_return(conn) + + result = cli.status + expect(result[:result][:auth_type]).to eq(:pat) + expect(result[:result][:user]).to eq('octocat') + end + + it 'returns unauthenticated when no credentials' do + allow(cli).to receive(:resolve_credential).and_return(nil) + result = cli.status + expect(result[:result][:authenticated]).to be false + end + end +end diff --git a/spec/legion/extensions/github/client_spec.rb b/spec/legion/extensions/github/client_spec.rb index a930d55..d4ab932 100644 --- a/spec/legion/extensions/github/client_spec.rb +++ b/spec/legion/extensions/github/client_spec.rb @@ -16,4 +16,36 @@ it 'returns a Faraday connection' do expect(client.connection).to be_a(Faraday::Connection) end + + describe 'App runner inclusion' do + it 'responds to generate_jwt' do + expect(client).to respond_to(:generate_jwt) + end + + it 'responds to create_installation_token' do + expect(client).to respond_to(:create_installation_token) + end + + it 'responds to verify_signature' do + expect(client).to respond_to(:verify_signature) + end + + it 'responds to generate_manifest' do + expect(client).to respond_to(:generate_manifest) + end + end + + describe 'OAuth runner inclusion' do + it 'responds to authorize_url' do + expect(client).to respond_to(:authorize_url) + end + + it 'responds to exchange_code' do + expect(client).to respond_to(:exchange_code) + end + + it 'responds to generate_pkce' do + expect(client).to respond_to(:generate_pkce) + end + end end diff --git a/spec/legion/extensions/github/errors_spec.rb b/spec/legion/extensions/github/errors_spec.rb new file mode 100644 index 0000000..c7e9e9e --- /dev/null +++ b/spec/legion/extensions/github/errors_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::RateLimitError do + it 'stores reset_at timestamp' do + reset_at = Time.now + 300 + error = described_class.new('rate limited', reset_at: reset_at) + expect(error.reset_at).to eq(reset_at) + expect(error.message).to eq('rate limited') + end + + it 'stores credential_fingerprint' do + error = described_class.new('rate limited', reset_at: Time.now, credential_fingerprint: 'fp1') + expect(error.credential_fingerprint).to eq('fp1') + end +end + +RSpec.describe Legion::Extensions::Github::AuthorizationError do + it 'stores owner and repo context' do + error = described_class.new('no credential for OrgZ/repo1', owner: 'OrgZ', repo: 'repo1') + expect(error.owner).to eq('OrgZ') + expect(error.repo).to eq('repo1') + expect(error.message).to eq('no credential for OrgZ/repo1') + end + + it 'stores attempted_sources list' do + error = described_class.new('exhausted', owner: 'OrgZ', + attempted_sources: %i[oauth_user app_installation pat]) + expect(error.attempted_sources).to eq(%i[oauth_user app_installation pat]) + end +end + +RSpec.describe Legion::Extensions::Github::ScopeDeniedError do + it 'stores credential and scope context' do + error = described_class.new('forbidden', owner: 'OrgZ', repo: 'repo1', + credential_fingerprint: 'fp1', auth_type: :oauth_user) + expect(error.owner).to eq('OrgZ') + expect(error.credential_fingerprint).to eq('fp1') + expect(error.auth_type).to eq(:oauth_user) + end +end diff --git a/spec/legion/extensions/github/helpers/browser_auth_spec.rb b/spec/legion/extensions/github/helpers/browser_auth_spec.rb new file mode 100644 index 0000000..adc99f3 --- /dev/null +++ b/spec/legion/extensions/github/helpers/browser_auth_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::Helpers::BrowserAuth do + let(:oauth_runner) { Object.new.extend(Legion::Extensions::Github::OAuth::Runners::Auth) } + let(:auth) { described_class.new(client_id: 'Iv1.abc', client_secret: 'secret', auth: oauth_runner) } + + describe '#gui_available?' do + it 'returns true on macOS' do + allow(auth).to receive(:host_os).and_return('darwin23') + expect(auth.gui_available?).to be true + end + + it 'returns false on headless linux without DISPLAY' do + allow(auth).to receive(:host_os).and_return('linux-gnu') + allow(ENV).to receive(:[]).with('DISPLAY').and_return(nil) + allow(ENV).to receive(:[]).with('WAYLAND_DISPLAY').and_return(nil) + expect(auth.gui_available?).to be false + end + end + + describe '#authenticate' do + context 'without GUI' do + before do + allow(auth).to receive(:gui_available?).and_return(false) + end + + it 'falls back to device code flow' do + expect(oauth_runner).to receive(:request_device_code).and_return( + result: { 'device_code' => 'dc', 'user_code' => 'ABCD', + 'verification_uri' => 'https://github.com/login/device', + 'expires_in' => 900, 'interval' => 5 } + ) + expect(oauth_runner).to receive(:poll_device_code).and_return( + result: { 'access_token' => 'ghu_device' } + ) + result = auth.authenticate + expect(result[:result]['access_token']).to eq('ghu_device') + end + end + end +end diff --git a/spec/legion/extensions/github/helpers/cache_spec.rb b/spec/legion/extensions/github/helpers/cache_spec.rb new file mode 100644 index 0000000..9fee7e1 --- /dev/null +++ b/spec/legion/extensions/github/helpers/cache_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::Helpers::Cache do + let(:helper) { Object.new.extend(described_class) } + + before do + allow(helper).to receive(:cache_connected?).and_return(false) + allow(helper).to receive(:local_cache_connected?).and_return(false) + end + + describe '#cached_get' do + it 'calls the block when no cache is connected' do + result = helper.cached_get('github:repo:test/repo') { { 'name' => 'repo' } } + expect(result).to eq({ 'name' => 'repo' }) + end + + context 'with global cache connected' do + before do + allow(helper).to receive(:cache_connected?).and_return(true) + allow(helper).to receive(:cache_set) + end + + it 'returns cached value on hit' do + allow(helper).to receive(:cache_get).with('github:repo:test/repo').and_return({ 'name' => 'cached' }) + result = helper.cached_get('github:repo:test/repo') { { 'name' => 'fresh' } } + expect(result).to eq({ 'name' => 'cached' }) + end + + it 'calls block and writes to cache on miss' do + allow(helper).to receive(:cache_get).with('github:repo:test/repo').and_return(nil) + expect(helper).to receive(:cache_set).with('github:repo:test/repo', { 'name' => 'fresh' }, ttl: 600) + helper.cached_get('github:repo:test/repo', ttl: 600) { { 'name' => 'fresh' } } + end + end + + context 'with local cache connected' do + before do + allow(helper).to receive(:local_cache_connected?).and_return(true) + allow(helper).to receive(:local_cache_set) + end + + it 'returns local cached value on hit' do + allow(helper).to receive(:local_cache_get).with('github:repo:test/repo').and_return({ 'name' => 'local' }) + result = helper.cached_get('github:repo:test/repo') { { 'name' => 'fresh' } } + expect(result).to eq({ 'name' => 'local' }) + end + end + + context 'with both caches connected' do + before do + allow(helper).to receive(:cache_connected?).and_return(true) + allow(helper).to receive(:local_cache_connected?).and_return(true) + allow(helper).to receive(:cache_set) + allow(helper).to receive(:local_cache_set) + end + + it 'checks global first, then local' do + allow(helper).to receive(:cache_get).and_return(nil) + allow(helper).to receive(:local_cache_get).and_return({ 'name' => 'local' }) + result = helper.cached_get('github:repo:test/repo') { { 'name' => 'fresh' } } + expect(result).to eq({ 'name' => 'local' }) + end + + it 'writes to both caches on miss' do + allow(helper).to receive(:cache_get).and_return(nil) + allow(helper).to receive(:local_cache_get).and_return(nil) + expect(helper).to receive(:cache_set) + expect(helper).to receive(:local_cache_set) + helper.cached_get('github:repo:test/repo') { { 'name' => 'fresh' } } + end + end + end + + describe '#cache_write' do + it 'writes to both caches when connected' do + allow(helper).to receive(:cache_connected?).and_return(true) + allow(helper).to receive(:local_cache_connected?).and_return(true) + expect(helper).to receive(:cache_set).with('github:repo:test/repo', { 'name' => 'new' }, ttl: 300) + expect(helper).to receive(:local_cache_set).with('github:repo:test/repo', { 'name' => 'new' }, ttl: 300) + helper.cache_write('github:repo:test/repo', { 'name' => 'new' }, ttl: 300) + end + + it 'skips disconnected caches silently' do + helper.cache_write('github:repo:test/repo', { 'name' => 'new' }) + end + end + + describe '#cache_invalidate' do + it 'deletes from both caches when connected' do + allow(helper).to receive(:cache_connected?).and_return(true) + allow(helper).to receive(:local_cache_connected?).and_return(true) + expect(helper).to receive(:cache_delete).with('github:repo:test/repo') + expect(helper).to receive(:local_cache_delete).with('github:repo:test/repo') + helper.cache_invalidate('github:repo:test/repo') + end + end + + describe '#github_ttl_for' do + it 'returns default TTL for unknown key patterns' do + expect(helper.github_ttl_for('github:unknown:key')).to eq(300) + end + + it 'returns commit TTL for commit keys' do + expect(helper.github_ttl_for('github:repo:test/repo:commits:abc123')).to eq(86_400) + end + + it 'returns pull_request TTL for PR keys' do + expect(helper.github_ttl_for('github:repo:test/repo:pulls:1')).to eq(60) + end + end +end diff --git a/spec/legion/extensions/github/helpers/callback_server_spec.rb b/spec/legion/extensions/github/helpers/callback_server_spec.rb new file mode 100644 index 0000000..c25fe70 --- /dev/null +++ b/spec/legion/extensions/github/helpers/callback_server_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'net/http' +require 'uri' + +RSpec.describe Legion::Extensions::Github::Helpers::CallbackServer do + subject(:server) { described_class.new } + + describe '#start and #redirect_uri' do + it 'binds to a random port on localhost' do + server.start + expect(server.port).to be_a(Integer) + expect(server.port).to be > 0 + expect(server.redirect_uri).to match(%r{http://127\.0\.0\.1:\d+/callback}) + ensure + server.shutdown + end + end + + describe '#wait_for_callback' do + it 'returns code and state from callback request' do + server.start + Thread.new do + sleep 0.1 + Net::HTTP.get(URI("#{server.redirect_uri}?code=test-code&state=test-state")) + end + result = server.wait_for_callback(timeout: 5) + expect(result[:code]).to eq('test-code') + expect(result[:state]).to eq('test-state') + ensure + server.shutdown + end + + it 'returns nil on timeout' do + server.start + result = server.wait_for_callback(timeout: 0.1) + expect(result).to be_nil + ensure + server.shutdown + end + end +end diff --git a/spec/legion/extensions/github/helpers/client_spec.rb b/spec/legion/extensions/github/helpers/client_spec.rb new file mode 100644 index 0000000..cb9b7e4 --- /dev/null +++ b/spec/legion/extensions/github/helpers/client_spec.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::Helpers::Client do + let(:helper) { Object.new.extend(described_class) } + + before do + allow(helper).to receive(:cache_connected?).and_return(false) + allow(helper).to receive(:local_cache_connected?).and_return(false) + end + + describe '#connection' do + it 'returns a Faraday connection with explicit token' do + conn = helper.connection(token: 'ghp_explicit') + expect(conn).to be_a(Faraday::Connection) + expect(conn.headers['Authorization']).to eq('Bearer ghp_explicit') + end + + it 'returns a connection without auth when no token is provided and no sources available' do + allow(helper).to receive(:resolve_credential).and_return(nil) + conn = helper.connection + expect(conn.headers['Authorization']).to be_nil + end + + it 'accepts owner: and repo: for scope-aware resolution' do + allow(helper).to receive(:resolve_credential) + .with(owner: 'LegionIO', repo: 'lex-github') + .and_return({ token: 'ghp_scoped', auth_type: :oauth_user }) + conn = helper.connection(owner: 'LegionIO', repo: 'lex-github') + expect(conn.headers['Authorization']).to eq('Bearer ghp_scoped') + end + end + + describe '#resolve_credential' do + before do + allow(helper).to receive(:resolve_vault_delegated).and_return(nil) + allow(helper).to receive(:resolve_settings_delegated).and_return(nil) + allow(helper).to receive(:resolve_vault_app).and_return(nil) + allow(helper).to receive(:resolve_settings_app).and_return(nil) + allow(helper).to receive(:resolve_vault_pat).and_return(nil) + allow(helper).to receive(:resolve_settings_pat).and_return(nil) + allow(helper).to receive(:resolve_gh_cli).and_return(nil) + allow(helper).to receive(:resolve_env).and_return(nil) + allow(helper).to receive(:credential_fallback?).and_return(true) + end + + it 'returns nil when no credentials are available' do + expect(helper.resolve_credential).to be_nil + end + + it 'prefers delegated over app' do + delegated = { token: 'delegated', auth_type: :oauth_user, + metadata: { source: :vault, credential_fingerprint: 'fp_d' } } + app = { token: 'app', auth_type: :app_installation, + metadata: { source: :vault, credential_fingerprint: 'fp_a' } } + allow(helper).to receive(:resolve_vault_delegated).and_return(delegated) + allow(helper).to receive(:resolve_vault_app).and_return(app) + allow(helper).to receive(:rate_limited?).and_return(false) + allow(helper).to receive(:scope_status).and_return(:unknown) + result = helper.resolve_credential + expect(result[:auth_type]).to eq(:oauth_user) + end + + it 'falls back to env when nothing else is available' do + env = { token: 'env-token', auth_type: :env, + metadata: { source: :env, credential_fingerprint: 'fp_e' } } + allow(helper).to receive(:resolve_env).and_return(env) + allow(helper).to receive(:rate_limited?).and_return(false) + allow(helper).to receive(:scope_status).and_return(:unknown) + result = helper.resolve_credential + expect(result[:auth_type]).to eq(:env) + end + + it 'skips rate-limited credentials' do + delegated = { token: 'delegated', auth_type: :oauth_user, + metadata: { source: :vault, credential_fingerprint: 'fp_d' } } + app = { token: 'app', auth_type: :app_installation, + metadata: { source: :vault, credential_fingerprint: 'fp_a' } } + allow(helper).to receive(:resolve_vault_delegated).and_return(delegated) + allow(helper).to receive(:resolve_vault_app).and_return(app) + allow(helper).to receive(:rate_limited?).with(fingerprint: 'fp_d').and_return(true) + allow(helper).to receive(:rate_limited?).with(fingerprint: 'fp_a').and_return(false) + allow(helper).to receive(:scope_status).and_return(:unknown) + result = helper.resolve_credential + expect(result[:auth_type]).to eq(:app_installation) + end + + it 'skips scope-denied credentials for a given owner' do + delegated = { token: 'delegated', auth_type: :oauth_user, + metadata: { source: :vault, credential_fingerprint: 'fp_d' } } + app = { token: 'app', auth_type: :app_installation, + metadata: { source: :vault, credential_fingerprint: 'fp_a' } } + allow(helper).to receive(:resolve_vault_delegated).and_return(delegated) + allow(helper).to receive(:resolve_vault_app).and_return(app) + allow(helper).to receive(:rate_limited?).and_return(false) + allow(helper).to receive(:scope_status) + .with(fingerprint: 'fp_d', owner: 'OrgZ', repo: 'repo1').and_return(:denied) + allow(helper).to receive(:scope_status) + .with(fingerprint: 'fp_a', owner: 'OrgZ', repo: 'repo1').and_return(:authorized) + result = helper.resolve_credential(owner: 'OrgZ', repo: 'repo1') + expect(result[:auth_type]).to eq(:app_installation) + end + + it 'skips scope check when owner is nil' do + delegated = { token: 'delegated', auth_type: :oauth_user, + metadata: { source: :vault, credential_fingerprint: 'fp_d' } } + allow(helper).to receive(:resolve_vault_delegated).and_return(delegated) + allow(helper).to receive(:rate_limited?).and_return(false) + allow(helper).to receive(:scope_status) + result = helper.resolve_credential(owner: nil, repo: nil) + expect(result[:auth_type]).to eq(:oauth_user) + expect(helper).not_to have_received(:scope_status) + end + end + + describe '#resolve_next_credential' do + before do + allow(helper).to receive(:resolve_vault_delegated).and_return(nil) + allow(helper).to receive(:resolve_settings_delegated).and_return(nil) + allow(helper).to receive(:resolve_vault_app).and_return(nil) + allow(helper).to receive(:resolve_settings_app).and_return(nil) + allow(helper).to receive(:resolve_vault_pat).and_return(nil) + allow(helper).to receive(:resolve_settings_pat).and_return(nil) + allow(helper).to receive(:resolve_gh_cli).and_return(nil) + allow(helper).to receive(:resolve_env).and_return(nil) + end + + it 'returns nil when all resolvers are exhausted' do + helper.instance_variable_set(:@current_credential, nil) + helper.instance_variable_set(:@skipped_fingerprints, []) + expect(helper.resolve_next_credential).to be_nil + end + + it 'skips the current credential fingerprint' do + delegated = { token: 'del', auth_type: :oauth_user, + metadata: { source: :vault, credential_fingerprint: 'fp_d' } } + app = { token: 'app', auth_type: :app_installation, + metadata: { source: :vault, credential_fingerprint: 'fp_a' } } + helper.instance_variable_set(:@current_credential, + { metadata: { credential_fingerprint: 'fp_d' } }) + helper.instance_variable_set(:@skipped_fingerprints, []) + allow(helper).to receive(:resolve_vault_delegated).and_return(delegated) + allow(helper).to receive(:resolve_vault_app).and_return(app) + allow(helper).to receive(:rate_limited?).and_return(false) + allow(helper).to receive(:scope_status).and_return(:unknown) + result = helper.resolve_next_credential + expect(result[:auth_type]).to eq(:app_installation) + end + + it 'skips scope-denied credentials for a given owner/repo' do + delegated = { token: 'del', auth_type: :oauth_user, + metadata: { source: :vault, credential_fingerprint: 'fp_d' } } + app = { token: 'app', auth_type: :app_installation, + metadata: { source: :vault, credential_fingerprint: 'fp_a' } } + helper.instance_variable_set(:@current_credential, nil) + helper.instance_variable_set(:@skipped_fingerprints, []) + allow(helper).to receive(:resolve_vault_delegated).and_return(delegated) + allow(helper).to receive(:resolve_vault_app).and_return(app) + allow(helper).to receive(:rate_limited?).and_return(false) + allow(helper).to receive(:scope_status) + .with(fingerprint: 'fp_d', owner: 'OrgX', repo: 'repoY').and_return(:denied) + allow(helper).to receive(:scope_status) + .with(fingerprint: 'fp_a', owner: 'OrgX', repo: 'repoY').and_return(:unknown) + result = helper.resolve_next_credential(owner: 'OrgX', repo: 'repoY') + expect(result[:auth_type]).to eq(:app_installation) + end + end + + describe '#resolve_gh_cli' do + it 'returns token from gh auth token command' do + allow(helper).to receive(:gh_cli_token_output).and_return('ghp_cli123') + result = helper.resolve_gh_cli + expect(result[:token]).to eq('ghp_cli123') + expect(result[:auth_type]).to eq(:cli) + end + + it 'returns nil when gh is not installed' do + allow(helper).to receive(:gh_cli_token_output).and_return(nil) + expect(helper.resolve_gh_cli).to be_nil + end + end + + describe '#resolve_vault_app' do + before do + allow(helper).to receive(:vault_get).with('github/app/private_key').and_return('-----BEGIN RSA PRIVATE KEY-----...') + allow(helper).to receive(:vault_get).with('github/app/app_id').and_return('12345') + allow(helper).to receive(:vault_get).with('github/app/installation_id').and_return('67890') + allow(helper).to receive(:fetch_token).and_return(nil) + allow(helper).to receive(:store_token) + end + + it 'generates a fresh installation token on cache miss' do + stub_const('Legion::Crypt', double) + jwt_result = { result: 'fake-jwt' } + token_result = { result: { 'token' => 'ghs_fresh', 'expires_at' => '2026-03-30T13:00:00Z' } } + allow(helper).to receive(:generate_jwt).and_return(jwt_result) + allow(helper).to receive(:create_installation_token).and_return(token_result) + + result = helper.resolve_vault_app + expect(result[:token]).to eq('ghs_fresh') + expect(result[:auth_type]).to eq(:app_installation) + end + end + + describe '#resolve_settings_app' do + before do + allow(Legion::Settings).to receive(:dig).with(:github, :app, :app_id).and_return('12345') + allow(Legion::Settings).to receive(:dig).with(:github, :app, :private_key_path).and_return('/tmp/test.pem') + allow(Legion::Settings).to receive(:dig).with(:github, :app, :installation_id).and_return('67890') + allow(helper).to receive(:fetch_token).and_return(nil) + allow(helper).to receive(:store_token) + allow(File).to receive(:read).with('/tmp/test.pem').and_return('-----BEGIN RSA PRIVATE KEY-----...') + end + + it 'generates a fresh installation token from settings on cache miss' do + jwt_result = { result: 'fake-jwt' } + token_result = { result: { 'token' => 'ghs_settings', 'expires_at' => '2026-03-30T13:00:00Z' } } + allow(helper).to receive(:generate_jwt).and_return(jwt_result) + allow(helper).to receive(:create_installation_token).and_return(token_result) + + result = helper.resolve_settings_app + expect(result[:token]).to eq('ghs_settings') + expect(result[:auth_type]).to eq(:app_installation) + end + end + + describe '#resolve_env' do + it 'returns GITHUB_TOKEN from environment' do + allow(ENV).to receive(:fetch).with('GITHUB_TOKEN', nil).and_return('ghp_env456') + result = helper.resolve_env + expect(result[:token]).to eq('ghp_env456') + expect(result[:auth_type]).to eq(:env) + end + + it 'returns nil when GITHUB_TOKEN is not set' do + allow(ENV).to receive(:fetch).with('GITHUB_TOKEN', nil).and_return(nil) + expect(helper.resolve_env).to be_nil + end + end +end diff --git a/spec/legion/extensions/github/helpers/scope_registry_spec.rb b/spec/legion/extensions/github/helpers/scope_registry_spec.rb new file mode 100644 index 0000000..5c37a02 --- /dev/null +++ b/spec/legion/extensions/github/helpers/scope_registry_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::Helpers::ScopeRegistry do + let(:registry) { Object.new.extend(described_class) } + + before do + allow(registry).to receive(:cache_connected?).and_return(false) + allow(registry).to receive(:local_cache_connected?).and_return(false) + end + + describe '#credential_fingerprint' do + it 'generates a stable fingerprint from auth_type and identifier' do + fp = registry.credential_fingerprint(auth_type: :oauth_user, identifier: 'vault_delegated') + expect(fp).to be_a(String) + expect(fp).not_to be_empty + end + + it 'generates different fingerprints for different credentials' do + fp1 = registry.credential_fingerprint(auth_type: :oauth_user, identifier: 'vault') + fp2 = registry.credential_fingerprint(auth_type: :pat, identifier: 'vault') + expect(fp1).not_to eq(fp2) + end + end + + describe '#scope_status' do + it 'returns :unknown when no registry entry exists' do + result = registry.scope_status(fingerprint: 'fp1', owner: 'OrgZ') + expect(result).to eq(:unknown) + end + + it 'returns :authorized after registering authorization' do + allow(registry).to receive(:local_cache_connected?).and_return(true) + allow(registry).to receive(:local_cache_get).and_return(nil) + allow(registry).to receive(:local_cache_set) + registry.register_scope(fingerprint: 'fp1', owner: 'OrgZ', status: :authorized) + allow(registry).to receive(:local_cache_get) + .with('github:scope:fp1:OrgZ').and_return(:authorized) + expect(registry.scope_status(fingerprint: 'fp1', owner: 'OrgZ')).to eq(:authorized) + end + + it 'returns :denied after registering denial' do + allow(registry).to receive(:local_cache_connected?).and_return(true) + allow(registry).to receive(:local_cache_get).and_return(nil) + allow(registry).to receive(:local_cache_set) + registry.register_scope(fingerprint: 'fp1', owner: 'OrgZ', status: :denied) + allow(registry).to receive(:local_cache_get) + .with('github:scope:fp1:OrgZ').and_return(:denied) + expect(registry.scope_status(fingerprint: 'fp1', owner: 'OrgZ')).to eq(:denied) + end + + it 'checks repo-level scope when repo is provided' do + allow(registry).to receive(:local_cache_connected?).and_return(true) + allow(registry).to receive(:local_cache_get) + .with('github:scope:fp1:OrgZ/repo1').and_return(:authorized) + expect(registry.scope_status(fingerprint: 'fp1', owner: 'OrgZ', repo: 'repo1')) + .to eq(:authorized) + end + + it 'falls back to org-level when repo-level is unknown' do + allow(registry).to receive(:local_cache_connected?).and_return(true) + allow(registry).to receive(:local_cache_get) + .with('github:scope:fp1:OrgZ/repo1').and_return(nil) + allow(registry).to receive(:cache_connected?).and_return(false) + allow(registry).to receive(:local_cache_get) + .with('github:scope:fp1:OrgZ').and_return(:authorized) + expect(registry.scope_status(fingerprint: 'fp1', owner: 'OrgZ', repo: 'repo1')) + .to eq(:authorized) + end + end + + describe '#rate_limited?' do + it 'returns false when no rate limit is cached' do + expect(registry.rate_limited?(fingerprint: 'fp1')).to be false + end + + it 'returns true when rate limit is cached' do + allow(registry).to receive(:local_cache_connected?).and_return(true) + allow(registry).to receive(:local_cache_get) + .with('github:rate_limit:fp1').and_return({ reset_at: Time.now + 300 }) + expect(registry.rate_limited?(fingerprint: 'fp1')).to be true + end + end + + describe '#mark_rate_limited' do + it 'stores rate limit with TTL matching reset window' do + allow(registry).to receive(:local_cache_connected?).and_return(true) + reset_at = Time.now + 300 + expect(registry).to receive(:local_cache_set) + .with('github:rate_limit:fp1', hash_including(reset_at: reset_at), ttl: anything) + registry.mark_rate_limited(fingerprint: 'fp1', reset_at: reset_at) + end + end + + describe '#invalidate_scope' do + it 'deletes scope entries for owner' do + allow(registry).to receive(:cache_connected?).and_return(true) + allow(registry).to receive(:local_cache_connected?).and_return(true) + expect(registry).to receive(:cache_delete).with('github:scope:fp1:OrgZ') + expect(registry).to receive(:local_cache_delete).with('github:scope:fp1:OrgZ') + registry.invalidate_scope(fingerprint: 'fp1', owner: 'OrgZ') + end + end +end diff --git a/spec/legion/extensions/github/helpers/token_cache_spec.rb b/spec/legion/extensions/github/helpers/token_cache_spec.rb new file mode 100644 index 0000000..a6e2aa2 --- /dev/null +++ b/spec/legion/extensions/github/helpers/token_cache_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::Helpers::TokenCache do + let(:helper) { Object.new.extend(described_class) } + + before do + allow(helper).to receive(:cache_connected?).and_return(false) + allow(helper).to receive(:local_cache_connected?).and_return(true) + allow(helper).to receive(:local_cache_get).and_return(nil) + allow(helper).to receive(:local_cache_set) + end + + describe '#store_token' do + it 'stores a token with auth_type and expires_at' do + expect(helper).to receive(:local_cache_set).with( + 'github:token:app_installation', + hash_including(token: 'ghs_test', auth_type: :app_installation), + ttl: anything + ) + helper.store_token(token: 'ghs_test', auth_type: :app_installation, + expires_at: Time.now + 3600) + end + end + + describe '#fetch_token' do + it 'returns nil when no token is cached' do + expect(helper.fetch_token(auth_type: :app_installation)).to be_nil + end + + it 'returns the cached token when present and not expired' do + cached = { token: 'ghs_test', auth_type: :app_installation, + expires_at: (Time.now + 3600).iso8601 } + allow(helper).to receive(:local_cache_get).and_return(cached) + result = helper.fetch_token(auth_type: :app_installation) + expect(result[:token]).to eq('ghs_test') + end + + it 'returns nil when token is expired' do + cached = { token: 'ghs_test', auth_type: :app_installation, + expires_at: (Time.now - 60).iso8601 } + allow(helper).to receive(:local_cache_get).and_return(cached) + expect(helper.fetch_token(auth_type: :app_installation)).to be_nil + end + end + + describe '#mark_rate_limited' do + it 'stores rate limit info for a credential' do + expect(helper).to receive(:local_cache_set).with( + 'github:rate_limit:app_installation', + hash_including(reset_at: anything), + ttl: anything + ) + helper.mark_rate_limited(auth_type: :app_installation, + reset_at: Time.now + 300) + end + end + + describe '#store_token with installation_id' do + it 'stores tokens keyed by installation_id' do + expect(helper).to receive(:local_cache_set).with( + 'github:token:app_installation:67890', + hash_including(token: 'ghs_inst1'), + ttl: anything + ) + helper.store_token(token: 'ghs_inst1', auth_type: :app_installation, + expires_at: Time.now + 3600, installation_id: '67890') + end + end + + describe '#fetch_token with installation_id' do + it 'fetches token by installation_id' do + cached = { token: 'ghs_inst1', auth_type: :app_installation, + expires_at: (Time.now + 3600).iso8601 } + allow(helper).to receive(:local_cache_get) + .with('github:token:app_installation:67890') + .and_return(cached) + result = helper.fetch_token(auth_type: :app_installation, installation_id: '67890') + expect(result[:token]).to eq('ghs_inst1') + end + + it 'falls back to generic key when installation_id not found' do + cached = { token: 'ghs_generic', auth_type: :app_installation, + expires_at: (Time.now + 3600).iso8601 } + allow(helper).to receive(:local_cache_get) + .with('github:token:app_installation:99999') + .and_return(nil) + allow(helper).to receive(:local_cache_get) + .with('github:token:app_installation') + .and_return(cached) + result = helper.fetch_token(auth_type: :app_installation, installation_id: '99999') + expect(result[:token]).to eq('ghs_generic') + end + end + + describe '#rate_limited?' do + it 'returns false when no rate limit is recorded' do + expect(helper.rate_limited?(auth_type: :app_installation)).to be false + end + + it 'returns true when rate limited' do + allow(helper).to receive(:local_cache_get) + .with('github:rate_limit:app_installation') + .and_return({ reset_at: (Time.now + 300).iso8601 }) + expect(helper.rate_limited?(auth_type: :app_installation)).to be true + end + end +end diff --git a/spec/legion/extensions/github/middleware/credential_fallback_spec.rb b/spec/legion/extensions/github/middleware/credential_fallback_spec.rb new file mode 100644 index 0000000..008a8c0 --- /dev/null +++ b/spec/legion/extensions/github/middleware/credential_fallback_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::Middleware::CredentialFallback do + let(:resolver) { double('resolver') } + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:conn) do + s = stubs + Faraday.new(url: 'https://api.github.com') do |f| + f.use described_class, resolver: resolver + f.request :json + f.response :json, content_type: /\bjson$/ + f.adapter :test, s + end + end + + describe '403 with fallback enabled' do + it 'retries with next credential' do + attempt = 0 + stubs.get('/repos/OrgZ/repo1') do + attempt += 1 + if attempt == 1 + [403, { 'Content-Type' => 'application/json' }, + { 'message' => 'Resource not accessible by integration' }] + else + [200, { 'Content-Type' => 'application/json' }, { 'name' => 'repo1' }] + end + end + + allow(resolver).to receive(:credential_fallback?).and_return(true) + allow(resolver).to receive(:on_scope_denied) + allow(resolver).to receive(:resolve_next_credential) + .and_return({ token: 'ghp_fallback', auth_type: :app_installation, + metadata: { credential_fingerprint: 'fp2' } }) + allow(resolver).to receive(:max_fallback_retries).and_return(3) + + response = conn.get('/repos/OrgZ/repo1') + expect(response.status).to eq(200) + expect(response.body['name']).to eq('repo1') + end + end + + describe '429 with fallback enabled' do + it 'retries with next credential' do + attempt = 0 + stubs.get('/repos/OrgZ/repo1') do + attempt += 1 + if attempt == 1 + [429, { 'Content-Type' => 'application/json', + 'X-RateLimit-Remaining' => '0', + 'X-RateLimit-Reset' => (Time.now.to_i + 300).to_s }, + { 'message' => 'API rate limit exceeded' }] + else + [200, { 'Content-Type' => 'application/json' }, { 'name' => 'repo1' }] + end + end + + allow(resolver).to receive(:credential_fallback?).and_return(true) + allow(resolver).to receive(:on_rate_limit) + allow(resolver).to receive(:resolve_next_credential) + .and_return({ token: 'ghp_next', auth_type: :pat, + metadata: { credential_fingerprint: 'fp3' } }) + allow(resolver).to receive(:max_fallback_retries).and_return(3) + + response = conn.get('/repos/OrgZ/repo1') + expect(response.status).to eq(200) + end + end + + describe '403 with fallback disabled' do + it 'returns 403 without retry' do + stubs.get('/repos/OrgZ/repo1') do + [403, { 'Content-Type' => 'application/json' }, + { 'message' => 'Resource not accessible by integration' }] + end + + allow(resolver).to receive(:credential_fallback?).and_return(false) + + response = conn.get('/repos/OrgZ/repo1') + expect(response.status).to eq(403) + end + end + + describe 'exhaustion' do + it 'returns last error when all credentials exhausted' do + stubs.get('/repos/OrgZ/repo1') do + [403, { 'Content-Type' => 'application/json' }, + { 'message' => 'Resource not accessible by integration' }] + end + + allow(resolver).to receive(:credential_fallback?).and_return(true) + allow(resolver).to receive(:on_scope_denied) + allow(resolver).to receive(:resolve_next_credential).and_return(nil) + allow(resolver).to receive(:max_fallback_retries).and_return(3) + + response = conn.get('/repos/OrgZ/repo1') + expect(response.status).to eq(403) + end + end +end diff --git a/spec/legion/extensions/github/middleware/rate_limit_spec.rb b/spec/legion/extensions/github/middleware/rate_limit_spec.rb new file mode 100644 index 0000000..18ebf71 --- /dev/null +++ b/spec/legion/extensions/github/middleware/rate_limit_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::Middleware::RateLimit do + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:handler) { double('handler') } + let(:conn) do + Faraday.new do |f| + f.use described_class, handler: handler + f.request :json + f.response :json, content_type: /\bjson$/ + f.adapter :test, stubs + end + end + + describe 'normal response' do + it 'passes through without modification' do + stubs.get('/repos/test/repo') do + [200, { 'Content-Type' => 'application/json', + 'X-RateLimit-Remaining' => '4999', + 'X-RateLimit-Reset' => (Time.now.to_i + 3600).to_s }, { 'name' => 'repo' }] + end + response = conn.get('/repos/test/repo') + expect(response.status).to eq(200) + end + end + + describe '429 response' do + it 'calls on_rate_limit on the handler with fingerprint' do + reset_time = Time.now.to_i + 300 + stubs.get('/repos/test/repo') do + [429, { 'Content-Type' => 'application/json', + 'X-RateLimit-Remaining' => '0', + 'X-RateLimit-Reset' => reset_time.to_s }, + { 'message' => 'API rate limit exceeded' }] + end + expect(handler).to receive(:on_rate_limit).with( + hash_including(remaining: 0, reset_at: anything, status: 429) + ) + conn.get('/repos/test/repo') + end + end + + describe 'X-RateLimit-Remaining: 0 on 200' do + it 'calls on_rate_limit when remaining hits zero' do + reset_time = Time.now.to_i + 300 + stubs.get('/repos/test/repo') do + [200, { 'Content-Type' => 'application/json', + 'X-RateLimit-Remaining' => '0', + 'X-RateLimit-Reset' => reset_time.to_s }, { 'name' => 'repo' }] + end + expect(handler).to receive(:on_rate_limit).with(hash_including(remaining: 0)) + conn.get('/repos/test/repo') + end + end + + describe 'no rate limit headers' do + it 'does not call handler' do + stubs.get('/repos/test/repo') do + [200, { 'Content-Type' => 'application/json' }, { 'name' => 'repo' }] + end + expect(handler).not_to receive(:on_rate_limit) + conn.get('/repos/test/repo') + end + end +end diff --git a/spec/legion/extensions/github/middleware/scope_probe_spec.rb b/spec/legion/extensions/github/middleware/scope_probe_spec.rb new file mode 100644 index 0000000..08c64f2 --- /dev/null +++ b/spec/legion/extensions/github/middleware/scope_probe_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::Middleware::ScopeProbe do + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:handler) { double('handler') } + let(:conn) do + Faraday.new do |f| + f.use described_class, handler: handler + f.request :json + f.response :json, content_type: /\bjson$/ + f.adapter :test, stubs + end + end + + describe '403 response' do + it 'calls on_scope_denied on the handler' do + stubs.get('/repos/OrgZ/repo1') do + [403, { 'Content-Type' => 'application/json' }, + { 'message' => 'Resource not accessible by integration' }] + end + expect(handler).to receive(:on_scope_denied).with( + hash_including(status: 403, url: anything) + ) + conn.get('/repos/OrgZ/repo1') + end + end + + describe '2xx response' do + it 'calls on_scope_authorized on the handler' do + stubs.get('/repos/OrgZ/repo1') do + [200, { 'Content-Type' => 'application/json' }, { 'name' => 'repo1' }] + end + expect(handler).to receive(:on_scope_authorized).with( + hash_including(status: 200, url: anything) + ) + conn.get('/repos/OrgZ/repo1') + end + end + + describe '404 response' do + it 'calls on_scope_denied (repo not visible = not authorized)' do + stubs.get('/repos/OrgZ/private-repo') do + [404, { 'Content-Type' => 'application/json' }, + { 'message' => 'Not Found' }] + end + expect(handler).to receive(:on_scope_denied).with( + hash_including(status: 404) + ) + conn.get('/repos/OrgZ/private-repo') + end + end + + describe 'non-repo path' do + it 'does not call scope handlers for global endpoints' do + stubs.get('/user') do + [200, { 'Content-Type' => 'application/json' }, { 'login' => 'test' }] + end + expect(handler).not_to receive(:on_scope_denied) + expect(handler).not_to receive(:on_scope_authorized) + conn.get('/user') + end + end +end diff --git a/spec/legion/extensions/github/oauth/runners/auth_spec.rb b/spec/legion/extensions/github/oauth/runners/auth_spec.rb new file mode 100644 index 0000000..e0610c6 --- /dev/null +++ b/spec/legion/extensions/github/oauth/runners/auth_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::OAuth::Runners::Auth do + let(:runner) { Object.new.extend(described_class) } + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:oauth_connection) do + Faraday.new(url: 'https://github.com') do |conn| + conn.request :json + conn.response :json, content_type: /\bjson$/ + conn.adapter :test, stubs + end + end + + before { allow(runner).to receive(:oauth_connection).and_return(oauth_connection) } + + describe '#generate_pkce' do + it 'returns a verifier and challenge pair' do + result = runner.generate_pkce + expect(result[:result][:verifier]).to be_a(String) + expect(result[:result][:verifier].length).to be >= 43 + expect(result[:result][:challenge]).to be_a(String) + expect(result[:result][:challenge_method]).to eq('S256') + end + end + + describe '#authorize_url' do + it 'returns a properly formatted GitHub OAuth URL' do + url = runner.authorize_url( + client_id: 'Iv1.abc', + redirect_uri: 'http://localhost:12345/callback', + scope: 'repo admin:org', + state: 'random-state', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + ) + expect(url[:result]).to start_with('https://github.com/login/oauth/authorize?') + expect(url[:result]).to include('client_id=Iv1.abc') + expect(url[:result]).to include('scope=repo') + expect(url[:result]).to include('state=random-state') + end + end + + describe '#exchange_code' do + it 'exchanges an authorization code for tokens' do + stubs.post('/login/oauth/access_token') do + [200, { 'Content-Type' => 'application/json' }, + { 'access_token' => 'ghu_test', 'refresh_token' => 'ghr_test', + 'token_type' => 'bearer', 'expires_in' => 28_800 }] + end + + result = runner.exchange_code( + client_id: 'Iv1.abc', client_secret: 'secret', + code: 'auth-code', redirect_uri: 'http://localhost/callback', + code_verifier: 'verifier123' + ) + expect(result[:result]['access_token']).to eq('ghu_test') + expect(result[:result]['refresh_token']).to eq('ghr_test') + end + end + + describe '#refresh_token' do + it 'exchanges a refresh token for new tokens' do + stubs.post('/login/oauth/access_token') do + [200, { 'Content-Type' => 'application/json' }, + { 'access_token' => 'ghu_new', 'refresh_token' => 'ghr_new', + 'token_type' => 'bearer', 'expires_in' => 28_800 }] + end + + result = runner.refresh_token( + client_id: 'Iv1.abc', client_secret: 'secret', + refresh_token: 'ghr_test' + ) + expect(result[:result]['access_token']).to eq('ghu_new') + end + end + + describe '#request_device_code' do + it 'requests a device code for headless auth' do + stubs.post('/login/device/code') do + [200, { 'Content-Type' => 'application/json' }, + { 'device_code' => 'dc_123', 'user_code' => 'ABCD-1234', + 'verification_uri' => 'https://github.com/login/device', + 'expires_in' => 900, 'interval' => 5 }] + end + + result = runner.request_device_code(client_id: 'Iv1.abc', scope: 'repo') + expect(result[:result]['user_code']).to eq('ABCD-1234') + end + end + + describe '#poll_device_code' do + it 'returns token when authorization completes' do + stubs.post('/login/oauth/access_token') do + [200, { 'Content-Type' => 'application/json' }, + { access_token: 'ghu_device', token_type: 'bearer' }] + end + + result = runner.poll_device_code( + client_id: 'Iv1.abc', device_code: 'dc_123', + interval: 0, timeout: 5 + ) + expect(result[:result][:access_token]).to eq('ghu_device') + end + + it 'returns timeout error when deadline exceeded' do + stubs.post('/login/oauth/access_token') do + [200, { 'Content-Type' => 'application/json' }, + { error: 'authorization_pending' }] + end + + result = runner.poll_device_code( + client_id: 'Iv1.abc', device_code: 'dc_123', + interval: 0, timeout: 0 + ) + expect(result[:error]).to eq('timeout') + end + + it 'returns error when device code is denied' do + stubs.post('/login/oauth/access_token') do + [200, { 'Content-Type' => 'application/json' }, + { error: 'access_denied', error_description: 'User denied access' }] + end + + result = runner.poll_device_code( + client_id: 'Iv1.abc', device_code: 'dc_123', + interval: 0, timeout: 30 + ) + expect(result[:error]).to eq('access_denied') + expect(result[:description]).to eq('User denied access') + end + end + + describe '#revoke_token' do + it 'revokes an access token using Basic auth' do + basic_auth_stubs = Faraday::Adapter::Test::Stubs.new + basic_auth_connection = Faraday.new(url: 'https://github.com') do |conn| + conn.request :json + conn.response :json, content_type: /\bjson$/ + conn.adapter :test, basic_auth_stubs + end + allow(runner).to receive(:oauth_connection) + .with(client_id: 'Iv1.abc', client_secret: 'secret') + .and_return(basic_auth_connection) + + basic_auth_stubs.delete('/applications/Iv1.abc/token') do + [204, {}, ''] + end + + result = runner.revoke_token(client_id: 'Iv1.abc', client_secret: 'secret', access_token: 'ghu_test') + expect(result[:result]).to be true + end + end +end diff --git a/spec/legion/extensions/github/runners/actions_spec.rb b/spec/legion/extensions/github/runners/actions_spec.rb new file mode 100644 index 0000000..9b56cd1 --- /dev/null +++ b/spec/legion/extensions/github/runners/actions_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::Runners::Actions do + let(:client) { Legion::Extensions::Github::Client.new(token: 'test-token') } + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:test_connection) do + Faraday.new(url: 'https://api.github.com') do |conn| + conn.request :json + conn.response :json, content_type: /\bjson$/ + conn.adapter :test, stubs + end + end + + before { allow(client).to receive(:connection).and_return(test_connection) } + + describe '#list_workflows' do + it 'returns workflows for a repo' do + stubs.get('/repos/LegionIO/lex-github/actions/workflows') do + [200, { 'Content-Type' => 'application/json' }, + { 'total_count' => 1, 'workflows' => [{ 'id' => 1, 'name' => 'CI' }] }] + end + result = client.list_workflows(owner: 'LegionIO', repo: 'lex-github') + expect(result[:result]['workflows'].first['name']).to eq('CI') + end + end + + describe '#get_workflow' do + it 'returns a single workflow' do + stubs.get('/repos/LegionIO/lex-github/actions/workflows/1') do + [200, { 'Content-Type' => 'application/json' }, + { 'id' => 1, 'name' => 'CI', 'state' => 'active' }] + end + result = client.get_workflow(owner: 'LegionIO', repo: 'lex-github', workflow_id: 1) + expect(result[:result]['state']).to eq('active') + end + end + + describe '#list_workflow_runs' do + it 'returns runs for a workflow' do + stubs.get('/repos/LegionIO/lex-github/actions/workflows/1/runs') do + [200, { 'Content-Type' => 'application/json' }, + { 'total_count' => 1, 'workflow_runs' => [{ 'id' => 100, 'status' => 'completed' }] }] + end + result = client.list_workflow_runs(owner: 'LegionIO', repo: 'lex-github', workflow_id: 1) + expect(result[:result]['workflow_runs'].first['status']).to eq('completed') + end + end + + describe '#get_workflow_run' do + it 'returns a single run' do + stubs.get('/repos/LegionIO/lex-github/actions/runs/100') do + [200, { 'Content-Type' => 'application/json' }, + { 'id' => 100, 'status' => 'completed', 'conclusion' => 'success' }] + end + result = client.get_workflow_run(owner: 'LegionIO', repo: 'lex-github', run_id: 100) + expect(result[:result]['conclusion']).to eq('success') + end + end + + describe '#trigger_workflow' do + it 'dispatches a workflow run' do + stubs.post('/repos/LegionIO/lex-github/actions/workflows/1/dispatches') do + [204, {}, ''] + end + result = client.trigger_workflow(owner: 'LegionIO', repo: 'lex-github', + workflow_id: 1, ref: 'main') + expect(result[:result]).to be true + end + end + + describe '#cancel_workflow_run' do + it 'cancels a running workflow' do + stubs.post('/repos/LegionIO/lex-github/actions/runs/100/cancel') do + [202, { 'Content-Type' => 'application/json' }, {}] + end + result = client.cancel_workflow_run(owner: 'LegionIO', repo: 'lex-github', run_id: 100) + expect(result[:result]).to be true + end + end + + describe '#rerun_workflow' do + it 'reruns a workflow' do + stubs.post('/repos/LegionIO/lex-github/actions/runs/100/rerun') do + [201, { 'Content-Type' => 'application/json' }, {}] + end + result = client.rerun_workflow(owner: 'LegionIO', repo: 'lex-github', run_id: 100) + expect(result[:result]).to be true + end + end + + describe '#rerun_failed_jobs' do + it 'reruns only failed jobs in a workflow run' do + stubs.post('/repos/LegionIO/lex-github/actions/runs/100/rerun-failed-jobs') do + [201, { 'Content-Type' => 'application/json' }, {}] + end + result = client.rerun_failed_jobs(owner: 'LegionIO', repo: 'lex-github', run_id: 100) + expect(result[:result]).to be true + end + end + + describe '#list_workflow_run_jobs' do + it 'returns jobs for a run' do + stubs.get('/repos/LegionIO/lex-github/actions/runs/100/jobs') do + [200, { 'Content-Type' => 'application/json' }, + { 'total_count' => 1, 'jobs' => [{ 'id' => 200, 'name' => 'test', 'conclusion' => 'success' }] }] + end + result = client.list_workflow_run_jobs(owner: 'LegionIO', repo: 'lex-github', run_id: 100) + expect(result[:result]['jobs'].first['name']).to eq('test') + end + end + + describe '#download_workflow_run_logs' do + it 'returns the log download URL' do + stubs.get('/repos/LegionIO/lex-github/actions/runs/100/logs') do + [200, { 'Content-Type' => 'application/json', 'Location' => 'https://logs.example.com/100.zip' }, ''] + end + result = client.download_workflow_run_logs(owner: 'LegionIO', repo: 'lex-github', run_id: 100) + expect(result[:result]).to be_a(Hash) + end + end + + describe '#list_workflow_run_artifacts' do + it 'returns artifacts for a run' do + stubs.get('/repos/LegionIO/lex-github/actions/runs/100/artifacts') do + [200, { 'Content-Type' => 'application/json' }, + { 'total_count' => 1, 'artifacts' => [{ 'id' => 300, 'name' => 'coverage' }] }] + end + result = client.list_workflow_run_artifacts(owner: 'LegionIO', repo: 'lex-github', run_id: 100) + expect(result[:result]['artifacts'].first['name']).to eq('coverage') + end + end +end diff --git a/spec/legion/extensions/github/runners/checks_spec.rb b/spec/legion/extensions/github/runners/checks_spec.rb new file mode 100644 index 0000000..d1784bb --- /dev/null +++ b/spec/legion/extensions/github/runners/checks_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::Runners::Checks do + let(:client) { Legion::Extensions::Github::Client.new(token: 'test-token') } + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:test_connection) do + Faraday.new(url: 'https://api.github.com') do |conn| + conn.request :json + conn.response :json, content_type: /\bjson$/ + conn.adapter :test, stubs + end + end + + before { allow(client).to receive(:connection).and_return(test_connection) } + + describe '#create_check_run' do + it 'creates a check run' do + stubs.post('/repos/LegionIO/lex-github/check-runs') do + [201, { 'Content-Type' => 'application/json' }, + { 'id' => 1, 'name' => 'Legion CI', 'status' => 'queued' }] + end + result = client.create_check_run(owner: 'LegionIO', repo: 'lex-github', + name: 'Legion CI', head_sha: 'abc123') + expect(result[:result]['name']).to eq('Legion CI') + expect(result[:result]['status']).to eq('queued') + end + end + + describe '#update_check_run' do + it 'updates a check run with conclusion' do + stubs.patch('/repos/LegionIO/lex-github/check-runs/1') do + [200, { 'Content-Type' => 'application/json' }, + { 'id' => 1, 'status' => 'completed', 'conclusion' => 'success' }] + end + result = client.update_check_run(owner: 'LegionIO', repo: 'lex-github', + check_run_id: 1, status: 'completed', conclusion: 'success') + expect(result[:result]['conclusion']).to eq('success') + end + end + + describe '#get_check_run' do + it 'returns a check run' do + stubs.get('/repos/LegionIO/lex-github/check-runs/1') do + [200, { 'Content-Type' => 'application/json' }, + { 'id' => 1, 'name' => 'Legion CI', 'conclusion' => 'success' }] + end + result = client.get_check_run(owner: 'LegionIO', repo: 'lex-github', check_run_id: 1) + expect(result[:result]['conclusion']).to eq('success') + end + end + + describe '#list_check_runs_for_ref' do + it 'returns check runs for a commit ref' do + stubs.get('/repos/LegionIO/lex-github/commits/abc123/check-runs') do + [200, { 'Content-Type' => 'application/json' }, + { 'total_count' => 1, 'check_runs' => [{ 'id' => 1, 'name' => 'Legion CI' }] }] + end + result = client.list_check_runs_for_ref(owner: 'LegionIO', repo: 'lex-github', ref: 'abc123') + expect(result[:result]['check_runs'].first['name']).to eq('Legion CI') + end + end + + describe '#list_check_suites_for_ref' do + it 'returns check suites for a commit ref' do + stubs.get('/repos/LegionIO/lex-github/commits/abc123/check-suites') do + [200, { 'Content-Type' => 'application/json' }, + { 'total_count' => 1, 'check_suites' => [{ 'id' => 10, 'status' => 'completed' }] }] + end + result = client.list_check_suites_for_ref(owner: 'LegionIO', repo: 'lex-github', ref: 'abc123') + expect(result[:result]['check_suites'].first['status']).to eq('completed') + end + end + + describe '#get_check_suite' do + it 'returns a check suite' do + stubs.get('/repos/LegionIO/lex-github/check-suites/10') do + [200, { 'Content-Type' => 'application/json' }, + { 'id' => 10, 'status' => 'completed', 'conclusion' => 'success' }] + end + result = client.get_check_suite(owner: 'LegionIO', repo: 'lex-github', check_suite_id: 10) + expect(result[:result]['conclusion']).to eq('success') + end + end + + describe '#rerequest_check_suite' do + it 'rerequests a check suite' do + stubs.post('/repos/LegionIO/lex-github/check-suites/10/rerequest') do + [201, { 'Content-Type' => 'application/json' }, {}] + end + result = client.rerequest_check_suite(owner: 'LegionIO', repo: 'lex-github', check_suite_id: 10) + expect(result[:result]).to be true + end + end + + describe '#list_check_run_annotations' do + it 'returns annotations for a check run' do + stubs.get('/repos/LegionIO/lex-github/check-runs/1/annotations') do + [200, { 'Content-Type' => 'application/json' }, + [{ 'path' => 'lib/foo.rb', 'message' => 'Lint error', 'annotation_level' => 'warning' }]] + end + result = client.list_check_run_annotations(owner: 'LegionIO', repo: 'lex-github', check_run_id: 1) + expect(result[:result].first['annotation_level']).to eq('warning') + end + end +end diff --git a/spec/legion/extensions/github/runners/deployments_spec.rb b/spec/legion/extensions/github/runners/deployments_spec.rb new file mode 100644 index 0000000..df674bc --- /dev/null +++ b/spec/legion/extensions/github/runners/deployments_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::Runners::Deployments do + let(:client) { Legion::Extensions::Github::Client.new(token: 'test-token') } + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:test_connection) do + Faraday.new(url: 'https://api.github.com') do |conn| + conn.request :json + conn.response :json, content_type: /\bjson$/ + conn.adapter :test, stubs + end + end + + before { allow(client).to receive(:connection).and_return(test_connection) } + + describe '#list_deployments' do + it 'returns deployments for a repo' do + stubs.get('/repos/LegionIO/lex-github/deployments') do + [200, { 'Content-Type' => 'application/json' }, + [{ 'id' => 1, 'ref' => 'main', 'environment' => 'production' }]] + end + result = client.list_deployments(owner: 'LegionIO', repo: 'lex-github') + expect(result[:result].first['environment']).to eq('production') + end + end + + describe '#get_deployment' do + it 'returns a single deployment' do + stubs.get('/repos/LegionIO/lex-github/deployments/1') do + [200, { 'Content-Type' => 'application/json' }, + { 'id' => 1, 'ref' => 'main', 'environment' => 'production' }] + end + result = client.get_deployment(owner: 'LegionIO', repo: 'lex-github', deployment_id: 1) + expect(result[:result]['ref']).to eq('main') + end + end + + describe '#create_deployment' do + it 'creates a deployment' do + stubs.post('/repos/LegionIO/lex-github/deployments') do + [201, { 'Content-Type' => 'application/json' }, + { 'id' => 2, 'ref' => 'v0.3.0', 'environment' => 'staging' }] + end + result = client.create_deployment(owner: 'LegionIO', repo: 'lex-github', + ref: 'v0.3.0', environment: 'staging') + expect(result[:result]['environment']).to eq('staging') + end + end + + describe '#delete_deployment' do + it 'deletes a deployment' do + stubs.delete('/repos/LegionIO/lex-github/deployments/1') { [204, {}, ''] } + result = client.delete_deployment(owner: 'LegionIO', repo: 'lex-github', deployment_id: 1) + expect(result[:result]).to be true + end + end + + describe '#list_deployment_statuses' do + it 'returns statuses for a deployment' do + stubs.get('/repos/LegionIO/lex-github/deployments/1/statuses') do + [200, { 'Content-Type' => 'application/json' }, + [{ 'id' => 10, 'state' => 'success', 'description' => 'Deployed' }]] + end + result = client.list_deployment_statuses(owner: 'LegionIO', repo: 'lex-github', deployment_id: 1) + expect(result[:result].first['state']).to eq('success') + end + end + + describe '#create_deployment_status' do + it 'creates a deployment status' do + stubs.post('/repos/LegionIO/lex-github/deployments/1/statuses') do + [201, { 'Content-Type' => 'application/json' }, + { 'id' => 11, 'state' => 'in_progress', 'description' => 'Deploying...' }] + end + result = client.create_deployment_status(owner: 'LegionIO', repo: 'lex-github', + deployment_id: 1, state: 'in_progress', + description: 'Deploying...') + expect(result[:result]['state']).to eq('in_progress') + end + end + + describe '#get_deployment_status' do + it 'returns a single deployment status' do + stubs.get('/repos/LegionIO/lex-github/deployments/1/statuses/10') do + [200, { 'Content-Type' => 'application/json' }, + { 'id' => 10, 'state' => 'success' }] + end + result = client.get_deployment_status(owner: 'LegionIO', repo: 'lex-github', + deployment_id: 1, status_id: 10) + expect(result[:result]['state']).to eq('success') + end + end +end diff --git a/spec/legion/extensions/github/runners/releases_spec.rb b/spec/legion/extensions/github/runners/releases_spec.rb new file mode 100644 index 0000000..618d2ce --- /dev/null +++ b/spec/legion/extensions/github/runners/releases_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::Runners::Releases do + let(:client) { Legion::Extensions::Github::Client.new(token: 'test-token') } + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:test_connection) do + Faraday.new(url: 'https://api.github.com') do |conn| + conn.request :json + conn.response :json, content_type: /\bjson$/ + conn.adapter :test, stubs + end + end + + before { allow(client).to receive(:connection).and_return(test_connection) } + + describe '#list_releases' do + it 'returns releases for a repo' do + stubs.get('/repos/LegionIO/lex-github/releases') do + [200, { 'Content-Type' => 'application/json' }, + [{ 'id' => 1, 'tag_name' => 'v0.3.0' }]] + end + result = client.list_releases(owner: 'LegionIO', repo: 'lex-github') + expect(result[:result].first['tag_name']).to eq('v0.3.0') + end + end + + describe '#get_release' do + it 'returns a single release' do + stubs.get('/repos/LegionIO/lex-github/releases/1') do + [200, { 'Content-Type' => 'application/json' }, + { 'id' => 1, 'tag_name' => 'v0.3.0', 'name' => 'v0.3.0' }] + end + result = client.get_release(owner: 'LegionIO', repo: 'lex-github', release_id: 1) + expect(result[:result]['tag_name']).to eq('v0.3.0') + end + end + + describe '#get_latest_release' do + it 'returns the latest release' do + stubs.get('/repos/LegionIO/lex-github/releases/latest') do + [200, { 'Content-Type' => 'application/json' }, + { 'id' => 1, 'tag_name' => 'v0.3.0' }] + end + result = client.get_latest_release(owner: 'LegionIO', repo: 'lex-github') + expect(result[:result]['tag_name']).to eq('v0.3.0') + end + end + + describe '#get_release_by_tag' do + it 'returns a release by tag name' do + stubs.get('/repos/LegionIO/lex-github/releases/tags/v0.3.0') do + [200, { 'Content-Type' => 'application/json' }, + { 'id' => 1, 'tag_name' => 'v0.3.0' }] + end + result = client.get_release_by_tag(owner: 'LegionIO', repo: 'lex-github', tag: 'v0.3.0') + expect(result[:result]['tag_name']).to eq('v0.3.0') + end + end + + describe '#create_release' do + it 'creates a release' do + stubs.post('/repos/LegionIO/lex-github/releases') do + [201, { 'Content-Type' => 'application/json' }, + { 'id' => 2, 'tag_name' => 'v0.4.0', 'name' => 'v0.4.0' }] + end + result = client.create_release(owner: 'LegionIO', repo: 'lex-github', + tag_name: 'v0.4.0', name: 'v0.4.0') + expect(result[:result]['tag_name']).to eq('v0.4.0') + end + end + + describe '#update_release' do + it 'updates a release' do + stubs.patch('/repos/LegionIO/lex-github/releases/1') do + [200, { 'Content-Type' => 'application/json' }, + { 'id' => 1, 'name' => 'Updated Release' }] + end + result = client.update_release(owner: 'LegionIO', repo: 'lex-github', + release_id: 1, name: 'Updated Release') + expect(result[:result]['name']).to eq('Updated Release') + end + end + + describe '#delete_release' do + it 'deletes a release' do + stubs.delete('/repos/LegionIO/lex-github/releases/1') { [204, {}, ''] } + result = client.delete_release(owner: 'LegionIO', repo: 'lex-github', release_id: 1) + expect(result[:result]).to be true + end + end + + describe '#list_release_assets' do + it 'returns assets for a release' do + stubs.get('/repos/LegionIO/lex-github/releases/1/assets') do + [200, { 'Content-Type' => 'application/json' }, + [{ 'id' => 50, 'name' => 'lex-github-0.3.0.gem' }]] + end + result = client.list_release_assets(owner: 'LegionIO', repo: 'lex-github', release_id: 1) + expect(result[:result].first['name']).to eq('lex-github-0.3.0.gem') + end + end + + describe '#delete_release_asset' do + it 'deletes a release asset' do + stubs.delete('/repos/LegionIO/lex-github/releases/assets/50') { [204, {}, ''] } + result = client.delete_release_asset(owner: 'LegionIO', repo: 'lex-github', asset_id: 50) + expect(result[:result]).to be true + end + end +end diff --git a/spec/legion/extensions/github/runners/repositories_spec.rb b/spec/legion/extensions/github/runners/repositories_spec.rb index 7f47b2b..f288f02 100644 --- a/spec/legion/extensions/github/runners/repositories_spec.rb +++ b/spec/legion/extensions/github/runners/repositories_spec.rb @@ -55,4 +55,16 @@ expect(result[:result].first['name']).to eq('main') end end + + describe 'scope-aware connection' do + it 'forwards owner and repo to connection for credential resolution' do + expect(client).to receive(:connection) + .with(hash_including(owner: 'LegionIO', repo: 'lex-github')) + .and_return(test_connection) + stubs.get('/repos/LegionIO/lex-github') do + [200, { 'Content-Type' => 'application/json' }, { 'name' => 'lex-github' }] + end + client.get_repo(owner: 'LegionIO', repo: 'lex-github') + end + end end diff --git a/spec/legion/extensions/github/runners/repository_webhooks_spec.rb b/spec/legion/extensions/github/runners/repository_webhooks_spec.rb new file mode 100644 index 0000000..d5f77e3 --- /dev/null +++ b/spec/legion/extensions/github/runners/repository_webhooks_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Github::Runners::RepositoryWebhooks do + let(:client) { Legion::Extensions::Github::Client.new(token: 'test-token') } + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:test_connection) do + Faraday.new(url: 'https://api.github.com') do |conn| + conn.request :json + conn.response :json, content_type: /\bjson$/ + conn.adapter :test, stubs + end + end + + before { allow(client).to receive(:connection).and_return(test_connection) } + + describe '#list_webhooks' do + it 'returns webhooks for a repo' do + stubs.get('/repos/LegionIO/lex-github/hooks') do + [200, { 'Content-Type' => 'application/json' }, + [{ 'id' => 1, 'active' => true, 'events' => ['push'] }]] + end + result = client.list_webhooks(owner: 'LegionIO', repo: 'lex-github') + expect(result[:result].first['events']).to include('push') + end + end + + describe '#get_webhook' do + it 'returns a single webhook' do + stubs.get('/repos/LegionIO/lex-github/hooks/1') do + [200, { 'Content-Type' => 'application/json' }, + { 'id' => 1, 'config' => { 'url' => 'https://legion.example.com/webhook' } }] + end + result = client.get_webhook(owner: 'LegionIO', repo: 'lex-github', hook_id: 1) + expect(result[:result]['config']['url']).to include('legion') + end + end + + describe '#create_webhook' do + it 'creates a webhook' do + stubs.post('/repos/LegionIO/lex-github/hooks') do + [201, { 'Content-Type' => 'application/json' }, + { 'id' => 2, 'active' => true, 'events' => %w[push pull_request] }] + end + result = client.create_webhook( + owner: 'LegionIO', repo: 'lex-github', + config: { url: 'https://legion.example.com/webhook', content_type: 'json', secret: 'whsec' }, + events: %w[push pull_request] + ) + expect(result[:result]['events']).to include('pull_request') + end + end + + describe '#update_webhook' do + it 'updates a webhook' do + stubs.patch('/repos/LegionIO/lex-github/hooks/1') do + [200, { 'Content-Type' => 'application/json' }, + { 'id' => 1, 'active' => false }] + end + result = client.update_webhook(owner: 'LegionIO', repo: 'lex-github', + hook_id: 1, active: false) + expect(result[:result]['active']).to be false + end + end + + describe '#delete_webhook' do + it 'deletes a webhook' do + stubs.delete('/repos/LegionIO/lex-github/hooks/1') { [204, {}, ''] } + result = client.delete_webhook(owner: 'LegionIO', repo: 'lex-github', hook_id: 1) + expect(result[:result]).to be true + end + end + + describe '#ping_webhook' do + it 'pings a webhook' do + stubs.post('/repos/LegionIO/lex-github/hooks/1/pings') { [204, {}, ''] } + result = client.ping_webhook(owner: 'LegionIO', repo: 'lex-github', hook_id: 1) + expect(result[:result]).to be true + end + end + + describe '#test_webhook' do + it 'triggers a test push event' do + stubs.post('/repos/LegionIO/lex-github/hooks/1/tests') { [204, {}, ''] } + result = client.test_webhook(owner: 'LegionIO', repo: 'lex-github', hook_id: 1) + expect(result[:result]).to be true + end + end + + describe '#list_webhook_deliveries' do + it 'returns recent deliveries' do + stubs.get('/repos/LegionIO/lex-github/hooks/1/deliveries') do + [200, { 'Content-Type' => 'application/json' }, + [{ 'id' => 100, 'status_code' => 200, 'event' => 'push' }]] + end + result = client.list_webhook_deliveries(owner: 'LegionIO', repo: 'lex-github', hook_id: 1) + expect(result[:result].first['event']).to eq('push') + end + end +end