main
rb 235 lines 8.32 KB
Raw
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