Add GitHub App REST client and webhook endpoint

GithubAppService mints RS256 App JWTs, exchanges them for per- installation access tokens (cached on the installation row), and covers the five REST calls the reviewer needs; timeouts follow the OndeCloudService availability discipline. GithubWebhooksController verifies X-Hub-Signature-256, syncs installation state, and enqueues GithubPrReviewJob for reviewable pull_request actions — GitHub gives a delivery 10s and never retries, so nothing slow happens in-request. GithubReviewConfig holds the kill switch and model tier. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WhPzinYJUYgBed63XMvJX1

Claude committed Jul 2, 2026 at 12:24 UTC 8d552a71b0a99455be8a2986d6ca192be1fb4321
4 files changed +355
app/controllers/github_webhooks_controller.rb
+101
new file mode 100644 index 0000000..7b32523 --- /dev/null +++ b/app/controllers/github_webhooks_controller.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +# Receives GitHub App webhooks for siGit Code PR reviews. Public endpoint, +# authenticated by the HMAC signature — never a user token. +# +# GitHub gives a delivery 10 seconds and never retries on its own, so this +# controller only verifies, syncs installation state, and enqueues; the review +# itself runs in GithubPrReviewJob. +class GithubWebhooksController < ActionController::API + REVIEWABLE_PR_ACTIONS = %w[opened reopened ready_for_review synchronize].freeze + + def create + payload = request.body.read + + unless GithubAppService.configured? + Rails.logger.error("GitHub webhook received but the GitHub App is not configured") + return head :service_unavailable + end + + unless GithubAppService.verify_webhook_signature?(payload, request.headers["X-Hub-Signature-256"]) + Rails.logger.warn("GitHub webhook rejected: bad signature") + return head :unauthorized + end + + handle(request.headers["X-GitHub-Event"], JSON.parse(payload)) + head :ok + rescue JSON::ParserError + head :bad_request + rescue StandardError => e + Rails.logger.error("GitHub webhook handling failed: #{e.class}: #{e.message}") + head :ok # GitHub never retries; nothing gained by 500ing a poison event. We logged it. + end + + private + + def handle(event, payload) + case event + when "installation" then sync_installation(payload) + when "installation_repositories" then update_repository_selection(payload) + when "pull_request" then handle_pull_request(payload) + else Rails.logger.debug { "GitHub webhook ignored: #{event}" } + end + end + + def sync_installation(payload) + installation = upsert_installation(payload["installation"]) + return if installation.nil? + + case payload["action"] + when "deleted" + installation.update!(deleted_at: Time.current) + installation.clear_access_token! + when "suspend" + installation.update!(suspended_at: Time.current) + when "unsuspend" + installation.update!(suspended_at: nil) + end + end + + def update_repository_selection(payload) + upsert_installation(payload["installation"]) + end + + def handle_pull_request(payload) + return unless REVIEWABLE_PR_ACTIONS.include?(payload["action"]) + return if payload.dig("pull_request", "draft") + + unless GithubReviewConfig.enabled? + Rails.logger.info("GitHub PR review skipped: reviews are disabled") + return + end + + installation = upsert_installation(payload["installation"]) + return if installation.nil? || !installation.active? + + GithubPrReviewJob.perform_later( + installation_id: installation.id, + repo_full_name: payload.dig("repository", "full_name"), + pr_number: payload.dig("pull_request", "number"), + head_sha: payload.dig("pull_request", "head", "sha") + ) + end + + # Upserts the local mirror from a webhook's `installation` object. Creating + # from PR events too is the safety net for missed/late installation events; + # a resurfacing installation (reinstall) clears the soft delete. + def upsert_installation(data) + return nil if data.nil? || data["id"].blank? + + installation = GithubAppInstallation.find_or_initialize_by(installation_id: data["id"]) + installation.deleted_at = nil + installation.assign_attributes( + account_login: data.dig("account", "login") || installation.account_login || "unknown", + account_type: data.dig("account", "type") || installation.account_type, + account_id: data.dig("account", "id") || installation.account_id, + repository_selection: data["repository_selection"] || installation.repository_selection + ) + installation.save! + installation + end +end
app/services/github_app_service.rb
+235
new file mode 100644 index 0000000..b726667 --- /dev/null +++ b/app/services/github_app_service.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +require "net/http" +require "json" +require "openssl" + +# GitHub REST client for the siGit Code GitHub App (the hosted PR-review bot). +# +# Authentication is the two-step GitHub App scheme: a short-lived RS256 JWT +# signed with the App's private key identifies the App itself, and is exchanged +# per installation for a 1-hour installation access token that authorizes API +# calls on the repos the installation covers. Installation tokens are cached on +# the GithubAppInstallation row (see #installation_token) so the Puma and jobs +# processes share them. +# +# Config resolution follows the Stripe pattern: Rails credentials +# (`github_app.app_id` / `.private_key` / `.webhook_secret`) first, ENV +# fallback (`GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_WEBHOOK_SECRET`). +# The ENV private key may carry literal "\n" escapes (prod env vars are +# single-line); prefer the multi-line credentials entry in production. +class GithubAppService + # Raised on any non-2xx GitHub response. `status` and `body` let callers + # branch: 5xx retryable, 401 token-expired, 422 invalid review comments, + # 404 PR/installation gone. + class ApiError < StandardError + attr_reader :status, :body + + def initialize(message, status:, body: nil) + super(message) + @status = status + @body = body + end + end + + API_ROOT = "https://api.github.com" + + # Same availability discipline as OndeCloudService: bounded timeouts so a + # hung GitHub call can never pin a worker thread indefinitely. + OPEN_TIMEOUT = 5 + WRITE_TIMEOUT = 15 + READ_TIMEOUT = 30 + + # App JWTs must expire within 10 minutes; back-date iat to absorb clock skew. + JWT_LIFETIME = 9.minutes + JWT_BACKDATE = 60 + + # /pulls/{n}/files pagination cap — 4 pages of 100 files is far beyond the + # review size budget anyway (GithubReviewPrompt::MAX_FILES). + MAX_FILE_PAGES = 4 + + class << self + def configured? + app_id.present? && private_key_pem.present? && webhook_secret.present? + end + + def app_id + Rails.application.credentials.dig(:github_app, :app_id).presence || + ENV["GITHUB_APP_ID"].presence + end + + def webhook_secret + Rails.application.credentials.dig(:github_app, :webhook_secret).presence || + ENV["GITHUB_APP_WEBHOOK_SECRET"].presence + end + + # Constant-time HMAC check of a webhook delivery. `signature_header` is the + # X-Hub-Signature-256 value ("sha256=<hexdigest>"). + def verify_webhook_signature?(payload, signature_header) + return false if webhook_secret.blank? || signature_header.blank? + + expected = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", webhook_secret, payload)}" + ActiveSupport::SecurityUtils.secure_compare(expected, signature_header) + end + + # The cached-or-minted installation access token for API calls on this + # installation's repos. + def installation_token(installation) + installation.usable_access_token || mint_installation_token(installation) + end + + # GET /repos/{repo}/pulls/{number} + def pull_request(installation, repo_full_name, number) + get(installation, "/repos/#{repo_full_name}/pulls/#{number}") + end + + # GET /repos/{repo}/pulls/{number}/files — follows Link: rel="next" + # pagination up to MAX_FILE_PAGES pages of 100. + def pull_request_files(installation, repo_full_name, number) + files = [] + path = "/repos/#{repo_full_name}/pulls/#{number}/files?per_page=100" + + MAX_FILE_PAGES.times do + page, next_path = get_with_next(installation, path) + files.concat(page) + return files if next_path.blank? + + path = next_path + end + + files + end + + # POST /repos/{repo}/pulls/{number}/reviews — one atomic call carrying the + # walkthrough body plus line comments ({path:, line:, side:, body:}). + def create_review(installation, repo_full_name, number, commit_id:, body:, comments: []) + post(installation, "/repos/#{repo_full_name}/pulls/#{number}/reviews", + "commit_id" => commit_id, + "event" => "COMMENT", + "body" => body, + "comments" => comments) + end + + # POST /repos/{repo}/issues/{number}/comments — the fallback surface when + # a review can't be created. + def create_issue_comment(installation, repo_full_name, number, body:) + post(installation, "/repos/#{repo_full_name}/issues/#{number}/comments", + "body" => body) + end + + # ── internals ────────────────────────────────────────────────────────────── + + def private_key_pem + credentials_pem = Rails.application.credentials.dig(:github_app, :private_key).presence + return credentials_pem if credentials_pem + + ENV["GITHUB_APP_PRIVATE_KEY"].presence&.gsub('\n', "\n") + end + + def app_jwt + now = Time.current.to_i + payload = { iat: now - JWT_BACKDATE, exp: now + JWT_LIFETIME.to_i, iss: app_id.to_s } + JWT.encode(payload, OpenSSL::PKey::RSA.new(private_key_pem), "RS256") + end + + # POST /app/installations/{id}/access_tokens with the App JWT, persisting + # the token on the row. Concurrent mints are harmless (GitHub allows + # multiple live tokens), so no locking. + def mint_installation_token(installation) + response = request( + Net::HTTP::Post.new(api_uri("/app/installations/#{installation.installation_id}/access_tokens")), + bearer: app_jwt + ) + data = parse_json(response) + installation.update!( + access_token: data.fetch("token"), + access_token_expires_at: Time.iso8601(data.fetch("expires_at")) + ) + data.fetch("token") + end + + def get(installation, path) + parse_json(installation_request(installation, Net::HTTP::Get.new(api_uri(path)))) + end + + # GET that also returns the next page's path from the Link header, or nil. + def get_with_next(installation, path) + response = installation_request(installation, Net::HTTP::Get.new(api_uri(path))) + [ parse_json(response), next_page_path(response["Link"]) ] + end + + def post(installation, path, payload) + request = Net::HTTP::Post.new(api_uri(path)) + request.body = payload.to_json + parse_json(installation_request(installation, request)) + end + + def installation_request(installation, request) + request(request, bearer: installation_token(installation)) + end + + def request(request, bearer:) + request["Authorization"] = "Bearer #{bearer}" + request["Accept"] = "application/vnd.github+json" + request["X-GitHub-Api-Version"] = "2022-11-28" + request["User-Agent"] = "sigit-code-review" + request["Content-Type"] = "application/json" if request.request_body_permitted? + + uri = request.uri + response = http_start(uri) { |http| http.request(request) } + ensure_success!(response) + response + end + + def ensure_success!(response) + return if response.is_a?(Net::HTTPSuccess) + + body = response.body.to_s + Rails.logger.error("GitHub API #{response.code}: #{body.byteslice(0, 500)}") + raise ApiError.new("GitHub API error (#{response.code})", + status: response.code.to_i, body: body) + end + + def http_start(uri, &block) + Net::HTTP.start( + uri.hostname, + uri.port, + use_ssl: uri.scheme == "https", + open_timeout: OPEN_TIMEOUT, + write_timeout: WRITE_TIMEOUT, + read_timeout: READ_TIMEOUT, + &block + ) + rescue StandardError => e + Rails.logger.error("GitHub API connection error: #{e.message}") + raise ApiError.new("GitHub API is unreachable", status: 502) + end + + def parse_json(response) + JSON.parse(response.body) + end + + def api_uri(path) + URI("#{API_ROOT}#{path}") + end + + # Extracts the path+query of the rel="next" link from a Link header. + def next_page_path(link_header) + return nil if link_header.blank? + + link_header.split(",").each do |part| + url, rel = part.split(";", 2) + next unless rel.to_s.include?('rel="next"') + + uri = URI(url.strip.delete_prefix("<").delete_suffix(">")) + return "#{uri.path}?#{uri.query}" + end + nil + end + + private :private_key_pem, :app_jwt, :mint_installation_token, :get, + :get_with_next, :post, :installation_request, :request, + :ensure_success!, :http_start, :parse_json, :api_uri, + :next_page_path + end +end
app/services/github_review_config.rb
+16
new file mode 100644 index 0000000..ff49744 --- /dev/null +++ b/app/services/github_review_config.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Runtime switches for the GitHub App PR reviewer. Reviews are on whenever the +# App is configured; flipping SIGIT_GITHUB_REVIEWS_ENABLED=false is the kill +# switch — it stops new enqueues (webhook controller) AND drains the queued +# backlog as no-ops (the job checks again). +class GithubReviewConfig + def self.enabled? + GithubAppService.configured? && ENV.fetch("SIGIT_GITHUB_REVIEWS_ENABLED", "true") != "false" + end + + # Onde tier used for reviews (see CloudCatalog). + def self.model + ENV.fetch("SIGIT_GITHUB_REVIEWS_MODEL", "onde-large") + end +end
config/routes.rb
+3
index 60e4f60..fc2aa5c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -116,6 +116,9 @@ Rails.application.routes.draw do # Stripe webhooks for siGit Code billing (authenticated by Stripe signature). post "/stripe/webhooks", to: "stripe_webhooks#create" + # GitHub App webhooks for siGit Code PR reviews (authenticated by HMAC signature). + post "/github/webhooks", to: "github_webhooks#create" + # Git Smart HTTP — clone/fetch over token-authenticated HTTPS. Declared before # the catch-all "/:username" routes; the `*.git` constraint keeps them from # matching normal repo-browsing URLs.