| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | # Receives GitHub App webhooks for siGit Code PR reviews. Public endpoint, |
| 4 | # authenticated by the HMAC signature — never a user token. |
| 5 | # |
| 6 | # GitHub gives a delivery 10 seconds and never retries on its own, so this |
| 7 | # controller only verifies, syncs installation state, and enqueues; the review |
| 8 | # itself runs in GithubPrReviewJob. |
| 9 | class GithubWebhooksController < ActionController::API |
| 10 | REVIEWABLE_PR_ACTIONS = %w[opened reopened ready_for_review synchronize].freeze |
| 11 | |
| 12 | def create |
| 13 | payload = request.body.read |
| 14 | |
| 15 | unless GithubAppService.configured? |
| 16 | Rails.logger.error("GitHub webhook received but the GitHub App is not configured") |
| 17 | return head :service_unavailable |
| 18 | end |
| 19 | |
| 20 | unless GithubAppService.verify_webhook_signature?(payload, request.headers["X-Hub-Signature-256"]) |
| 21 | Rails.logger.warn("GitHub webhook rejected: bad signature") |
| 22 | return head :unauthorized |
| 23 | end |
| 24 | |
| 25 | handle(request.headers["X-GitHub-Event"], JSON.parse(payload)) |
| 26 | head :ok |
| 27 | rescue JSON::ParserError |
| 28 | head :bad_request |
| 29 | rescue StandardError => e |
| 30 | Rails.logger.error("GitHub webhook handling failed: #{e.class}: #{e.message}") |
| 31 | head :ok # GitHub never retries; nothing gained by 500ing a poison event. We logged it. |
| 32 | end |
| 33 | |
| 34 | private |
| 35 | |
| 36 | def handle(event, payload) |
| 37 | case event |
| 38 | when "installation" then sync_installation(payload) |
| 39 | when "installation_repositories" then update_repository_selection(payload) |
| 40 | when "pull_request" then handle_pull_request(payload) |
| 41 | else Rails.logger.debug { "GitHub webhook ignored: #{event}" } |
| 42 | end |
| 43 | end |
| 44 | |
| 45 | def sync_installation(payload) |
| 46 | installation = upsert_installation(payload["installation"]) |
| 47 | return if installation.nil? |
| 48 | |
| 49 | case payload["action"] |
| 50 | when "deleted" |
| 51 | installation.update!(deleted_at: Time.current) |
| 52 | installation.clear_access_token! |
| 53 | when "suspend" |
| 54 | installation.update!(suspended_at: Time.current) |
| 55 | when "unsuspend" |
| 56 | installation.update!(suspended_at: nil) |
| 57 | end |
| 58 | end |
| 59 | |
| 60 | def update_repository_selection(payload) |
| 61 | upsert_installation(payload["installation"]) |
| 62 | end |
| 63 | |
| 64 | def handle_pull_request(payload) |
| 65 | return unless REVIEWABLE_PR_ACTIONS.include?(payload["action"]) |
| 66 | return if payload.dig("pull_request", "draft") |
| 67 | |
| 68 | unless GithubReviewConfig.enabled? |
| 69 | Rails.logger.info("GitHub PR review skipped: reviews are disabled") |
| 70 | return |
| 71 | end |
| 72 | |
| 73 | installation = upsert_installation(payload["installation"]) |
| 74 | return if installation.nil? || !installation.active? |
| 75 | |
| 76 | GithubPrReviewJob.perform_later( |
| 77 | installation_id: installation.id, |
| 78 | repo_full_name: payload.dig("repository", "full_name"), |
| 79 | pr_number: payload.dig("pull_request", "number"), |
| 80 | head_sha: payload.dig("pull_request", "head", "sha") |
| 81 | ) |
| 82 | end |
| 83 | |
| 84 | # Upserts the local mirror from a webhook's `installation` object. Creating |
| 85 | # from PR events too is the safety net for missed/late installation events; |
| 86 | # a resurfacing installation (reinstall) clears the soft delete. |
| 87 | def upsert_installation(data) |
| 88 | return nil if data.nil? || data["id"].blank? |
| 89 | |
| 90 | installation = GithubAppInstallation.find_or_initialize_by(installation_id: data["id"]) |
| 91 | installation.deleted_at = nil |
| 92 | installation.assign_attributes( |
| 93 | account_login: data.dig("account", "login") || installation.account_login || "unknown", |
| 94 | account_type: data.dig("account", "type") || installation.account_type, |
| 95 | account_id: data.dig("account", "id") || installation.account_id, |
| 96 | repository_selection: data["repository_selection"] || installation.repository_selection |
| 97 | ) |
| 98 | installation.save! |
| 99 | installation |
| 100 | end |
| 101 | end |