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.