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 new
+101
@@ -0,0 +1,101 @@
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
app/services/github_app_service.rb new
+235
@@ -0,0 +1,235 @@
1 +# frozen_string_literal: true
2 +
3 +require "net/http"
4 +require "json"
5 +require "openssl"
6 +
7 +# GitHub REST client for the siGit Code GitHub App (the hosted PR-review bot).
8 +#
9 +# Authentication is the two-step GitHub App scheme: a short-lived RS256 JWT
10 +# signed with the App's private key identifies the App itself, and is exchanged
11 +# per installation for a 1-hour installation access token that authorizes API
12 +# calls on the repos the installation covers. Installation tokens are cached on
13 +# the GithubAppInstallation row (see #installation_token) so the Puma and jobs
14 +# processes share them.
15 +#
16 +# Config resolution follows the Stripe pattern: Rails credentials
17 +# (`github_app.app_id` / `.private_key` / `.webhook_secret`) first, ENV
18 +# fallback (`GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_WEBHOOK_SECRET`).
19 +# The ENV private key may carry literal "\n" escapes (prod env vars are
20 +# single-line); prefer the multi-line credentials entry in production.
21 +class GithubAppService
22 + # Raised on any non-2xx GitHub response. `status` and `body` let callers
23 + # branch: 5xx retryable, 401 token-expired, 422 invalid review comments,
24 + # 404 PR/installation gone.
25 + class ApiError < StandardError
26 + attr_reader :status, :body
27 +
28 + def initialize(message, status:, body: nil)
29 + super(message)
30 + @status = status
31 + @body = body
32 + end
33 + end
34 +
35 + API_ROOT = "https://api.github.com"
36 +
37 + # Same availability discipline as OndeCloudService: bounded timeouts so a
38 + # hung GitHub call can never pin a worker thread indefinitely.
39 + OPEN_TIMEOUT = 5
40 + WRITE_TIMEOUT = 15
41 + READ_TIMEOUT = 30
42 +
43 + # App JWTs must expire within 10 minutes; back-date iat to absorb clock skew.
44 + JWT_LIFETIME = 9.minutes
45 + JWT_BACKDATE = 60
46 +
47 + # /pulls/{n}/files pagination cap — 4 pages of 100 files is far beyond the
48 + # review size budget anyway (GithubReviewPrompt::MAX_FILES).
49 + MAX_FILE_PAGES = 4
50 +
51 + class << self
52 + def configured?
53 + app_id.present? && private_key_pem.present? && webhook_secret.present?
54 + end
55 +
56 + def app_id
57 + Rails.application.credentials.dig(:github_app, :app_id).presence ||
58 + ENV["GITHUB_APP_ID"].presence
59 + end
60 +
61 + def webhook_secret
62 + Rails.application.credentials.dig(:github_app, :webhook_secret).presence ||
63 + ENV["GITHUB_APP_WEBHOOK_SECRET"].presence
64 + end
65 +
66 + # Constant-time HMAC check of a webhook delivery. `signature_header` is the
67 + # X-Hub-Signature-256 value ("sha256=<hexdigest>").
68 + def verify_webhook_signature?(payload, signature_header)
69 + return false if webhook_secret.blank? || signature_header.blank?
70 +
71 + expected = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", webhook_secret, payload)}"
72 + ActiveSupport::SecurityUtils.secure_compare(expected, signature_header)
73 + end
74 +
75 + # The cached-or-minted installation access token for API calls on this
76 + # installation's repos.
77 + def installation_token(installation)
78 + installation.usable_access_token || mint_installation_token(installation)
79 + end
80 +
81 + # GET /repos/{repo}/pulls/{number}
82 + def pull_request(installation, repo_full_name, number)
83 + get(installation, "/repos/#{repo_full_name}/pulls/#{number}")
84 + end
85 +
86 + # GET /repos/{repo}/pulls/{number}/files — follows Link: rel="next"
87 + # pagination up to MAX_FILE_PAGES pages of 100.
88 + def pull_request_files(installation, repo_full_name, number)
89 + files = []
90 + path = "/repos/#{repo_full_name}/pulls/#{number}/files?per_page=100"
91 +
92 + MAX_FILE_PAGES.times do
93 + page, next_path = get_with_next(installation, path)
94 + files.concat(page)
95 + return files if next_path.blank?
96 +
97 + path = next_path
98 + end
99 +
100 + files
101 + end
102 +
103 + # POST /repos/{repo}/pulls/{number}/reviews — one atomic call carrying the
104 + # walkthrough body plus line comments ({path:, line:, side:, body:}).
105 + def create_review(installation, repo_full_name, number, commit_id:, body:, comments: [])
106 + post(installation, "/repos/#{repo_full_name}/pulls/#{number}/reviews",
107 + "commit_id" => commit_id,
108 + "event" => "COMMENT",
109 + "body" => body,
110 + "comments" => comments)
111 + end
112 +
113 + # POST /repos/{repo}/issues/{number}/comments — the fallback surface when
114 + # a review can't be created.
115 + def create_issue_comment(installation, repo_full_name, number, body:)
116 + post(installation, "/repos/#{repo_full_name}/issues/#{number}/comments",
117 + "body" => body)
118 + end
119 +
120 + # ── internals ──────────────────────────────────────────────────────────────
121 +
122 + def private_key_pem
123 + credentials_pem = Rails.application.credentials.dig(:github_app, :private_key).presence
124 + return credentials_pem if credentials_pem
125 +
126 + ENV["GITHUB_APP_PRIVATE_KEY"].presence&.gsub('\n', "\n")
127 + end
128 +
129 + def app_jwt
130 + now = Time.current.to_i
131 + payload = { iat: now - JWT_BACKDATE, exp: now + JWT_LIFETIME.to_i, iss: app_id.to_s }
132 + JWT.encode(payload, OpenSSL::PKey::RSA.new(private_key_pem), "RS256")
133 + end
134 +
135 + # POST /app/installations/{id}/access_tokens with the App JWT, persisting
136 + # the token on the row. Concurrent mints are harmless (GitHub allows
137 + # multiple live tokens), so no locking.
138 + def mint_installation_token(installation)
139 + response = request(
140 + Net::HTTP::Post.new(api_uri("/app/installations/#{installation.installation_id}/access_tokens")),
141 + bearer: app_jwt
142 + )
143 + data = parse_json(response)
144 + installation.update!(
145 + access_token: data.fetch("token"),
146 + access_token_expires_at: Time.iso8601(data.fetch("expires_at"))
147 + )
148 + data.fetch("token")
149 + end
150 +
151 + def get(installation, path)
152 + parse_json(installation_request(installation, Net::HTTP::Get.new(api_uri(path))))
153 + end
154 +
155 + # GET that also returns the next page's path from the Link header, or nil.
156 + def get_with_next(installation, path)
157 + response = installation_request(installation, Net::HTTP::Get.new(api_uri(path)))
158 + [ parse_json(response), next_page_path(response["Link"]) ]
159 + end
160 +
161 + def post(installation, path, payload)
162 + request = Net::HTTP::Post.new(api_uri(path))
163 + request.body = payload.to_json
164 + parse_json(installation_request(installation, request))
165 + end
166 +
167 + def installation_request(installation, request)
168 + request(request, bearer: installation_token(installation))
169 + end
170 +
171 + def request(request, bearer:)
172 + request["Authorization"] = "Bearer #{bearer}"
173 + request["Accept"] = "application/vnd.github+json"
174 + request["X-GitHub-Api-Version"] = "2022-11-28"
175 + request["User-Agent"] = "sigit-code-review"
176 + request["Content-Type"] = "application/json" if request.request_body_permitted?
177 +
178 + uri = request.uri
179 + response = http_start(uri) { |http| http.request(request) }
180 + ensure_success!(response)
181 + response
182 + end
183 +
184 + def ensure_success!(response)
185 + return if response.is_a?(Net::HTTPSuccess)
186 +
187 + body = response.body.to_s
188 + Rails.logger.error("GitHub API #{response.code}: #{body.byteslice(0, 500)}")
189 + raise ApiError.new("GitHub API error (#{response.code})",
190 + status: response.code.to_i, body: body)
191 + end
192 +
193 + def http_start(uri, &block)
194 + Net::HTTP.start(
195 + uri.hostname,
196 + uri.port,
197 + use_ssl: uri.scheme == "https",
198 + open_timeout: OPEN_TIMEOUT,
199 + write_timeout: WRITE_TIMEOUT,
200 + read_timeout: READ_TIMEOUT,
201 + &block
202 + )
203 + rescue StandardError => e
204 + Rails.logger.error("GitHub API connection error: #{e.message}")
205 + raise ApiError.new("GitHub API is unreachable", status: 502)
206 + end
207 +
208 + def parse_json(response)
209 + JSON.parse(response.body)
210 + end
211 +
212 + def api_uri(path)
213 + URI("#{API_ROOT}#{path}")
214 + end
215 +
216 + # Extracts the path+query of the rel="next" link from a Link header.
217 + def next_page_path(link_header)
218 + return nil if link_header.blank?
219 +
220 + link_header.split(",").each do |part|
221 + url, rel = part.split(";", 2)
222 + next unless rel.to_s.include?('rel="next"')
223 +
224 + uri = URI(url.strip.delete_prefix("<").delete_suffix(">"))
225 + return "#{uri.path}?#{uri.query}"
226 + end
227 + nil
228 + end
229 +
230 + private :private_key_pem, :app_jwt, :mint_installation_token, :get,
231 + :get_with_next, :post, :installation_request, :request,
232 + :ensure_success!, :http_start, :parse_json, :api_uri,
233 + :next_page_path
234 + end
235 +end
app/services/github_review_config.rb new
+16
@@ -0,0 +1,16 @@
1 +# frozen_string_literal: true
2 +
3 +# Runtime switches for the GitHub App PR reviewer. Reviews are on whenever the
4 +# App is configured; flipping SIGIT_GITHUB_REVIEWS_ENABLED=false is the kill
5 +# switch — it stops new enqueues (webhook controller) AND drains the queued
6 +# backlog as no-ops (the job checks again).
7 +class GithubReviewConfig
8 + def self.enabled?
9 + GithubAppService.configured? && ENV.fetch("SIGIT_GITHUB_REVIEWS_ENABLED", "true") != "false"
10 + end
11 +
12 + # Onde tier used for reviews (see CloudCatalog).
13 + def self.model
14 + ENV.fetch("SIGIT_GITHUB_REVIEWS_MODEL", "onde-large")
15 + end
16 +end
config/routes.rb
+3
@@ -116,6 +116,9 @@ Rails.application.routes.draw do
116 # Stripe webhooks for siGit Code billing (authenticated by Stripe signature).
117 post "/stripe/webhooks", to: "stripe_webhooks#create"
118
119 + # GitHub App webhooks for siGit Code PR reviews (authenticated by HMAC signature).
120 + post "/github/webhooks", to: "github_webhooks#create"
121 +
122 # Git Smart HTTP — clone/fetch over token-authenticated HTTPS. Declared before
123 # the catch-all "/:username" routes; the `*.git` constraint keeps them from
124 # matching normal repo-browsing URLs.