| 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 |