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.