Add specs, runbook, and env template for GitHub PR reviews
Covers HMAC verification and event dispatch, App JWT and installation- token caching, patch-hunk parsing and line clamping, prompt budgets and strict reply parsing, the job's guards and 422/401 fallbacks, and an end-to-end example from signed webhook through the job to the posted review. webmock blocks all real network in specs. docs/github-app-reviews.md is the operator runbook: App registration, credentials, first-deploy Solid Queue checks (including the deploy-hook supervisor stacking), and the kill switch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WhPzinYJUYgBed63XMvJX1
Claude committed
Jul 2, 2026 at 12:37 UTC
ab429aa9b4fa34710867bb02e00b3b562273489a
8 files changed
+890
.env.example
+23
index eb1c6d1..d843148 100644
--- a/.env.example
+++ b/.env.example
@@ -56,6 +56,29 @@ STRIPE_SECRET_KEY=sk_test_your-stripe-secret-key
STRIPE_WEBHOOK_SECRET=whsec_your-webhook-signing-secret
+# -----------------------------------------------------------------------------
+# GitHub App — siGit Code automatic PR reviews (the hosted bot). Register the
+# App and find these values per docs/github-app-reviews.md. In production,
+# prefer Rails credentials (github_app.*) — the private key is multi-line.
+# POST /github/webhooks uses these.
+# -----------------------------------------------------------------------------
+
+GITHUB_APP_ID=123456
+
+# Webhook signing secret set on the GitHub App.
+GITHUB_APP_WEBHOOK_SECRET=your-github-webhook-secret
+
+# The App's private key PEM with literal \n escapes in place of newlines.
+GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
+
+# Kill switch: set to "false" to stop automatic reviews (new webhooks AND the
+# queued backlog). Reviews are on by default when the App is configured.
+# SIGIT_GITHUB_REVIEWS_ENABLED=true
+
+# Onde tier used for reviews; defaults to onde-large.
+# SIGIT_GITHUB_REVIEWS_MODEL=onde-large
+
+
# -----------------------------------------------------------------------------
# Database (PostgreSQL)
# The defaults below work with a local Postgres.app installation.
docs/github-app-reviews.md
+108
new file mode 100644
index 0000000..94f86e6
--- /dev/null
+++ b/docs/github-app-reviews.md
@@ -0,0 +1,108 @@
+# siGit Code GitHub App — automatic PR reviews
+
+The hosted GitHub App behind siGit Code's pull-request reviews. Users install
+the App on their github.com repos; every reviewable pull-request event hits
+`POST /github/webhooks`, and `GithubPrReviewJob` posts a walkthrough summary
+plus line-level comments on the PR, powered by Onde Cloud inference.
+
+## How it fits together
+
+```
+github.com ──webhook──▶ GithubWebhooksController ──enqueue──▶ GithubPrReviewJob (Solid Queue)
+ │ verifies X-Hub-Signature-256 │ fetch PR + diff (GithubAppService)
+ │ syncs github_app_installations │ budget/annotate (GithubReviewPrompt)
+ └ <10s, never does slow work │ inference (OndeCloudService)
+ │ validate lines (GithubPatchIndex)
+ └ POST one review back to GitHub
+```
+
+State lives in two tables: `github_app_installations` (mirror of App
+installations, plus the cached 1-hour installation token) and
+`github_pr_reviews` (one row per repo + PR + head SHA — the idempotency
+guard, and the audit trail: status, skip reason, error, comment count).
+
+## Registering the GitHub App (one-time)
+
+1. GitHub → Settings → Developer settings → **GitHub Apps** → New GitHub App
+ (register under the org that owns the product).
+2. Basics:
+ - Name: **siGit Code**
+ - Homepage URL: `https://sigit.si/code`
+ - Webhook URL: `https://sigit.si/github/webhooks`
+ - Webhook secret: generate one (`bin/rails secret | head -c 48`) and save it
+ for step 4.
+3. Permissions and events:
+ - Repository permissions: **Pull requests: Read and write**,
+ **Contents: Read-only**, **Metadata: Read-only** (mandatory).
+ - Subscribe to events: **Pull request**. (`installation` and
+ `installation_repositories` events are always delivered to App webhooks;
+ there is no checkbox for them.)
+ - Where can this App be installed: **Any account**.
+4. After creating: note the **App ID**, then **Generate a private key**
+ (downloads a `.pem`).
+
+## Configuration
+
+Preferred in production: Rails credentials (`bin/rails credentials:edit`),
+because the private key is multi-line and prod env vars live in the `git`
+user's `~/.profile`, which is single-line only:
+
+```yaml
+github_app:
+ app_id: "123456"
+ webhook_secret: "..."
+ private_key: |
+ -----BEGIN RSA PRIVATE KEY-----
+ ...
+ -----END RSA PRIVATE KEY-----
+```
+
+ENV fallback (dev, or single-line values): `GITHUB_APP_ID`,
+`GITHUB_APP_WEBHOOK_SECRET`, and `GITHUB_APP_PRIVATE_KEY` with literal `\n`
+escapes in place of newlines. See `.env.example`.
+
+Switches:
+
+- `SIGIT_GITHUB_REVIEWS_ENABLED=false` — the kill switch. Stops new enqueues
+ *and* drains already-queued jobs as no-ops (the job re-checks). Reviews are
+ on by default whenever the App credentials are configured.
+- `SIGIT_GITHUB_REVIEWS_MODEL` — Onde tier for reviews (default `onde-large`).
+
+## Production rollout
+
+The deploy is the usual `git push` to the bare-repo hook (see
+`.agents/skills/deployment/SKILL.md`), but this feature is the first user of
+Solid Queue, so on the first deploy:
+
+1. Verify the queue schema loaded: the hook's `db:prepare` should load
+ `db/queue_schema.rb` into `sigitsi_production_queue` (that DB exists but
+ was empty — a known issue in the deployment skill). Check with
+ `psql sigitsi_production_queue -c '\dt'` — expect `solid_queue_*` tables.
+2. **Amend the post-receive hook before relying on `bin/jobs start`**: the
+ hook starts a jobs supervisor on every deploy but never stops the previous
+ one, so supervisors stack. Add something like
+ `pkill -f 'solid_queue' || true` before the `bin/jobs start` line.
+3. Add the App credentials (step above), restart Puma.
+4. Install the App on a test repo, open a PR, and watch:
+ - the jobs log (`output-jobs.log`) for the review run,
+ - the `github_pr_reviews` row (`status`, `skip_reason`, `error_message`),
+ - the PR itself for the summary + line comments.
+
+## Operational notes
+
+- **GitHub never retries webhook deliveries** (10-second timeout, fire-once).
+ A missed delivery is recovered by the next push (`synchronize`) or manually:
+ App settings → Advanced → Recent Deliveries → Redeliver (kept 30 days).
+- **Skipped, not failed**: drafts, closed/superseded PRs, PRs over the size
+ budget (> `GithubReviewPrompt::MAX_FILES` files or nothing reviewable) are
+ recorded as `skipped` with a `skip_reason`; over-budget PRs get a short
+ explanatory comment instead of a review.
+- **422 from GitHub on review creation** means a comment targeted a line
+ outside the diff. The job degrades automatically: full review →
+ summary-only review → plain issue comment. If these show up in the logs,
+ look at `GithubPatchIndex` first.
+- **One review per head SHA**: redelivered webhooks and rapid pushes collapse
+ via the unique `(repo_full_name, pr_number, head_sha)` index; a newer push
+ supersedes queued reviews of older SHAs.
+- Reviews are **ungated** in v1 — any installation gets them. Billing or
+ account linking is future work.
spec/jobs/github_pr_review_job_spec.rb
+156
new file mode 100644
index 0000000..017dfa3
--- /dev/null
+++ b/spec/jobs/github_pr_review_job_spec.rb
@@ -0,0 +1,156 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe GithubPrReviewJob do
+ let(:installation) do
+ GithubAppInstallation.create!(installation_id: 555, account_login: "octocat")
+ end
+
+ let(:pull_request) do
+ { "number" => 7, "state" => "open", "draft" => false,
+ "title" => "Fix login", "body" => "Corrects the token check.",
+ "base" => { "ref" => "main" }, "head" => { "ref" => "fix-login", "sha" => "headsha1" } }
+ end
+
+ let(:files) do
+ [ { "filename" => "app/a.rb", "status" => "modified", "additions" => 1, "deletions" => 0,
+ "patch" => "@@ -1,2 +1,3 @@\n line one\n+line two\n line three" } ]
+ end
+
+ let(:model_reply) do
+ { "summary" => "Tightens the token check.",
+ "findings" => [
+ { "path" => "app/a.rb", "line" => 2, "severity" => "warning", "comment" => "Handle nil token." },
+ { "path" => "app/a.rb", "line" => 90, "severity" => "nit", "comment" => "Off the diff." }
+ ] }.to_json
+ end
+
+ def onde_response(content) = { "choices" => [ { "message" => { "content" => content } } ] }
+
+ def perform
+ described_class.perform_now(
+ installation_id: installation.id, repo_full_name: "octocat/hello",
+ pr_number: 7, head_sha: "headsha1"
+ )
+ end
+
+ before do
+ allow(GithubReviewConfig).to receive_messages(enabled?: true, model: "onde-large")
+ allow(GithubAppService).to receive_messages(
+ pull_request: pull_request, pull_request_files: files,
+ create_review: {}, create_issue_comment: {}
+ )
+ allow(OndeCloudService).to receive(:create).and_return(onde_response(model_reply))
+ end
+
+ it "posts one review with the summary and only diff-valid comments, then completes" do
+ perform
+
+ expect(GithubAppService).to have_received(:create_review) do |_inst, repo, number, commit_id:, body:, comments:|
+ expect([ repo, number, commit_id ]).to eq([ "octocat/hello", 7, "headsha1" ])
+ expect(body).to include("Tightens the token check.").and include("siGit Code")
+ expect(comments).to eq([ { "path" => "app/a.rb", "line" => 2, "side" => "RIGHT",
+ "body" => "**warning** — Handle nil token." } ])
+ end
+
+ review = GithubPrReview.find_by!(repo_full_name: "octocat/hello", pr_number: 7, head_sha: "headsha1")
+ expect(review).to have_attributes(status: "completed", comment_count: 1, model: "onde-large")
+ end
+
+ it "is idempotent: a second run for the same head SHA does nothing" do
+ perform
+ perform
+
+ expect(GithubAppService).to have_received(:create_review).once
+ end
+
+ it "no-ops when the kill switch is off, leaving the event reviewable later" do
+ allow(GithubReviewConfig).to receive(:enabled?).and_return(false)
+ perform
+
+ expect(OndeCloudService).not_to have_received(:create)
+ expect(GithubPrReview.count).to eq(0)
+ end
+
+ it "skips when a newer push superseded this SHA" do
+ pull_request["head"]["sha"] = "newersha"
+ perform
+
+ expect(OndeCloudService).not_to have_received(:create)
+ expect(GithubPrReview.last).to have_attributes(status: "skipped", skip_reason: "superseded")
+ end
+
+ it "skips a PR that turned draft after enqueue" do
+ pull_request["draft"] = true
+ perform
+
+ expect(GithubPrReview.last).to have_attributes(status: "skipped", skip_reason: "draft")
+ expect(OndeCloudService).not_to have_received(:create)
+ end
+
+ it "skips a PR that vanished (404)" do
+ allow(GithubAppService).to receive(:pull_request)
+ .and_raise(GithubAppService::ApiError.new("gone", status: 404))
+ perform
+
+ expect(GithubPrReview.last).to have_attributes(status: "skipped", skip_reason: "pr_gone")
+ end
+
+ it "posts a note instead of a review when the PR is over budget" do
+ too_many = (0..GithubReviewPrompt::MAX_FILES).map do |i|
+ { "filename" => "f#{i}.rb", "patch" => "@@ -1,1 +1,2 @@\n a\n+b" }
+ end
+ allow(GithubAppService).to receive(:pull_request_files).and_return(too_many)
+ perform
+
+ expect(GithubAppService).to have_received(:create_issue_comment)
+ expect(GithubAppService).not_to have_received(:create_review)
+ expect(OndeCloudService).not_to have_received(:create)
+ expect(GithubPrReview.last).to have_attributes(status: "skipped", skip_reason: "too_large")
+ end
+
+ it "degrades to summary-only, then an issue comment, when GitHub 422s the review" do
+ allow(GithubAppService).to receive(:create_review)
+ .and_raise(GithubAppService::ApiError.new("invalid", status: 422, body: "{}"))
+ perform
+
+ expect(GithubAppService).to have_received(:create_review).twice # full, then summary-only
+ expect(GithubAppService).to have_received(:create_issue_comment)
+ expect(GithubPrReview.last).to have_attributes(status: "completed")
+ end
+
+ it "retries once with a fresh token on 401" do
+ calls = 0
+ allow(GithubAppService).to receive(:pull_request) do
+ calls += 1
+ raise GithubAppService::ApiError.new("expired", status: 401) if calls == 1
+
+ pull_request
+ end
+ installation.update!(access_token: "ghs_stale", access_token_expires_at: 1.hour.from_now)
+
+ perform
+
+ expect(installation.reload.access_token).to be_nil
+ expect(GithubPrReview.last).to have_attributes(status: "completed")
+ end
+
+ it "marks the review failed and posts nothing when the model reply is unparseable" do
+ allow(OndeCloudService).to receive(:create).and_return(onde_response("not json at all"))
+ perform
+
+ expect(GithubAppService).not_to have_received(:create_review)
+ expect(GithubAppService).not_to have_received(:create_issue_comment)
+ expect(GithubPrReview.last.status).to eq("failed")
+ end
+
+ it "releases the claim and schedules a retry on a transient inference failure" do
+ ActiveJob::Base.queue_adapter = :test
+ allow(OndeCloudService).to receive(:create)
+ .and_raise(OndeCloudService::UpstreamError.new("bad gateway", status: 502))
+
+ expect { perform }.to have_enqueued_job(described_class) # retry_on re-enqueues
+ expect(GithubPrReview.last.status).to eq("pending") # claim released for the retry
+ end
+end
spec/rails_helper.rb
+4
index ef75d46..0903023 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -10,6 +10,10 @@ abort("The Rails environment is running in production mode!") if Rails.env.produ
require 'rspec/rails'
# Add additional requires below this line. Rails is not loaded until this point!
+# No spec may hit the network; GitHub REST calls are stubbed with webmock.
+require 'webmock/rspec'
+WebMock.disable_net_connect!(allow_localhost: true)
+
# Requires supporting ruby files with custom matchers and macros, etc, in
# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are
# run as spec files by default. This means that files in spec/support that end
spec/requests/github_webhooks_spec.rb
+205
new file mode 100644
index 0000000..3cfd868
--- /dev/null
+++ b/spec/requests/github_webhooks_spec.rb
@@ -0,0 +1,205 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "GitHub webhooks", type: :request do
+ before do
+ ActiveJob::Base.queue_adapter = :test
+ allow(GithubAppService).to receive_messages(configured?: true, webhook_secret: "hook-secret")
+ end
+
+ def deliver(event, payload, secret: "hook-secret")
+ body = payload.to_json
+ post "/github/webhooks", params: body, headers: {
+ "CONTENT_TYPE" => "application/json",
+ "X-GitHub-Event" => event,
+ "X-Hub-Signature-256" => "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", secret, body)}"
+ }
+ end
+
+ def installation_payload(action, id: 555, selection: "selected")
+ { action: action,
+ installation: { id: id, account: { login: "octocat", type: "User", id: 9 },
+ repository_selection: selection } }
+ end
+
+ def pull_request_payload(action, draft: false, number: 7, sha: "headsha1")
+ installation_payload(action).merge(
+ repository: { full_name: "octocat/hello" },
+ pull_request: { number: number, draft: draft, head: { sha: sha } }
+ )
+ end
+
+ describe "authentication" do
+ it "rejects a tampered body" do
+ body = installation_payload("created").to_json
+ post "/github/webhooks", params: body + " ", headers: {
+ "CONTENT_TYPE" => "application/json",
+ "X-GitHub-Event" => "installation",
+ "X-Hub-Signature-256" => "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", "hook-secret", body)}"
+ }
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(GithubAppInstallation.count).to eq(0)
+ end
+
+ it "rejects a signature under the wrong secret" do
+ deliver("installation", installation_payload("created"), secret: "wrong")
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it "returns 503 when the app is not configured" do
+ allow(GithubAppService).to receive(:configured?).and_return(false)
+ deliver("installation", installation_payload("created"))
+ expect(response).to have_http_status(:service_unavailable)
+ end
+ end
+
+ describe "installation events" do
+ it "upserts the installation on created" do
+ deliver("installation", installation_payload("created"))
+
+ expect(response).to have_http_status(:ok)
+ installation = GithubAppInstallation.find_by!(installation_id: 555)
+ expect(installation).to have_attributes(
+ account_login: "octocat", account_type: "User", account_id: 9,
+ repository_selection: "selected", deleted_at: nil
+ )
+ end
+
+ it "soft-deletes and clears the cached token on deleted" do
+ installation = GithubAppInstallation.create!(
+ installation_id: 555, account_login: "octocat",
+ access_token: "ghs_x", access_token_expires_at: 1.hour.from_now
+ )
+
+ deliver("installation", installation_payload("deleted"))
+
+ expect(installation.reload.deleted_at).to be_present
+ expect(installation.access_token).to be_nil
+ end
+
+ it "suspends and unsuspends" do
+ deliver("installation", installation_payload("suspend"))
+ installation = GithubAppInstallation.find_by!(installation_id: 555)
+ expect(installation.suspended_at).to be_present
+
+ deliver("installation", installation_payload("unsuspend"))
+ expect(installation.reload.suspended_at).to be_nil
+ end
+
+ it "refreshes repository_selection on installation_repositories" do
+ GithubAppInstallation.create!(installation_id: 555, account_login: "octocat",
+ repository_selection: "selected")
+ deliver("installation_repositories", installation_payload("added", selection: "all"))
+
+ expect(GithubAppInstallation.find_by!(installation_id: 555).repository_selection).to eq("all")
+ end
+ end
+
+ describe "pull_request events" do
+ it "enqueues a review for an opened PR (creating the installation if unseen)" do
+ expect do
+ deliver("pull_request", pull_request_payload("opened"))
+ end.to have_enqueued_job(GithubPrReviewJob).with(
+ installation_id: GithubAppInstallation.last&.id || kind_of(Integer),
+ repo_full_name: "octocat/hello", pr_number: 7, head_sha: "headsha1"
+ ).on_queue("default")
+
+ expect(response).to have_http_status(:ok)
+ expect(GithubAppInstallation.find_by(installation_id: 555)).to be_present
+ end
+
+ it "enqueues for synchronize, reopened, and ready_for_review" do
+ %w[synchronize reopened ready_for_review].each do |action|
+ expect { deliver("pull_request", pull_request_payload(action)) }
+ .to have_enqueued_job(GithubPrReviewJob)
+ end
+ end
+
+ it "ignores other PR actions" do
+ expect { deliver("pull_request", pull_request_payload("labeled")) }
+ .not_to have_enqueued_job
+ expect(response).to have_http_status(:ok)
+ end
+
+ it "skips draft PRs" do
+ expect { deliver("pull_request", pull_request_payload("opened", draft: true)) }
+ .not_to have_enqueued_job
+ end
+
+ it "skips when the kill switch is off" do
+ allow(GithubReviewConfig).to receive(:enabled?).and_return(false)
+ expect { deliver("pull_request", pull_request_payload("opened")) }
+ .not_to have_enqueued_job
+ end
+
+ it "skips suspended installations" do
+ GithubAppInstallation.create!(installation_id: 555, account_login: "octocat",
+ suspended_at: Time.current)
+ expect { deliver("pull_request", pull_request_payload("opened")) }
+ .not_to have_enqueued_job
+ end
+ end
+
+ describe "end to end: signed webhook through job to posted review" do
+ include ActiveJob::TestHelper
+
+ it "reviews the PR and completes the review row" do
+ allow(GithubAppService).to receive_messages(
+ pull_request: {
+ "number" => 7, "state" => "open", "draft" => false, "title" => "Fix login",
+ "body" => "", "base" => { "ref" => "main" },
+ "head" => { "ref" => "fix", "sha" => "headsha1" }
+ },
+ pull_request_files: [
+ { "filename" => "app/a.rb", "status" => "modified", "additions" => 1,
+ "deletions" => 0, "patch" => "@@ -1,2 +1,3 @@\n one\n+two\n three" }
+ ],
+ create_review: {}
+ )
+ allow(GithubReviewConfig).to receive_messages(enabled?: true, model: "onde-large")
+ allow(OndeCloudService).to receive(:create).and_return(
+ "choices" => [ { "message" => { "content" => {
+ "summary" => "Adds a line.",
+ "findings" => [ { "path" => "app/a.rb", "line" => 2, "severity" => "nit", "comment" => "Name it." } ]
+ }.to_json } } ]
+ )
+
+ perform_enqueued_jobs do
+ deliver("pull_request", pull_request_payload("opened"))
+ end
+
+ expect(GithubAppService).to have_received(:create_review) do |_inst, repo, number, commit_id:, comments:, **|
+ expect([ repo, number, commit_id ]).to eq([ "octocat/hello", 7, "headsha1" ])
+ expect(comments.first).to include("path" => "app/a.rb", "line" => 2, "side" => "RIGHT")
+ end
+ expect(GithubPrReview.last).to have_attributes(
+ status: "completed", repo_full_name: "octocat/hello", head_sha: "headsha1", comment_count: 1
+ )
+ end
+ end
+
+ describe "resilience" do
+ it "acks unknown events" do
+ deliver("star", { action: "created" })
+ expect(response).to have_http_status(:ok)
+ end
+
+ it "returns 400 for unparseable JSON" do
+ body = "{not json"
+ post "/github/webhooks", params: body, headers: {
+ "CONTENT_TYPE" => "application/json",
+ "X-GitHub-Event" => "installation",
+ "X-Hub-Signature-256" => "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", "hook-secret", body)}"
+ }
+ expect(response).to have_http_status(:bad_request)
+ end
+
+ it "still acks when handling raises, so GitHub doesn't mark the endpoint dead" do
+ allow(GithubAppInstallation).to receive(:find_or_initialize_by).and_raise("boom")
+ deliver("installation", installation_payload("created"))
+ expect(response).to have_http_status(:ok)
+ end
+ end
+end
spec/services/github_app_service_spec.rb
+137
new file mode 100644
index 0000000..db3bbce
--- /dev/null
+++ b/spec/services/github_app_service_spec.rb
@@ -0,0 +1,137 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe GithubAppService do
+ let(:rsa_key) { OpenSSL::PKey::RSA.new(2048) }
+
+ before do
+ allow(described_class).to receive_messages(
+ app_id: "12345",
+ webhook_secret: "hook-secret",
+ private_key_pem: rsa_key.to_pem
+ )
+ end
+
+ let(:installation) do
+ GithubAppInstallation.create!(installation_id: 555, account_login: "octocat")
+ end
+
+ def token_response(token: "ghs_fresh", expires_in: 1.hour)
+ { status: 201,
+ body: { token: token, expires_at: expires_in.from_now.utc.iso8601 }.to_json,
+ headers: { "Content-Type" => "application/json" } }
+ end
+
+ describe ".app_jwt" do
+ it "mints an RS256 JWT with the app id and a <10 minute lifetime" do
+ payload, header = JWT.decode(described_class.send(:app_jwt), rsa_key.public_key, true,
+ algorithm: "RS256")
+
+ expect(header["alg"]).to eq("RS256")
+ expect(payload["iss"]).to eq("12345")
+ expect(payload["iat"]).to be < Time.current.to_i # back-dated for clock skew
+ expect(payload["exp"] - Time.current.to_i).to be_between(1, 600)
+ end
+ end
+
+ describe ".installation_token" do
+ it "mints and caches a token on the installation row" do
+ mint = stub_request(:post, "https://api.github.com/app/installations/555/access_tokens")
+ .to_return(token_response)
+
+ expect(described_class.installation_token(installation)).to eq("ghs_fresh")
+ expect(installation.reload.access_token).to eq("ghs_fresh")
+ expect(installation.access_token_expires_at).to be_within(2.minutes).of(1.hour.from_now)
+
+ expect(described_class.installation_token(installation)).to eq("ghs_fresh")
+ expect(mint).to have_been_requested.once # second call served from the row
+ end
+
+ it "re-mints when the cached token is near expiry" do
+ installation.update!(access_token: "ghs_stale", access_token_expires_at: 2.minutes.from_now)
+ stub_request(:post, "https://api.github.com/app/installations/555/access_tokens")
+ .to_return(token_response(token: "ghs_new"))
+
+ expect(described_class.installation_token(installation)).to eq("ghs_new")
+ end
+ end
+
+ describe ".verify_webhook_signature?" do
+ let(:payload) { '{"zen":"Design for failure."}' }
+ let(:signature) { "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", "hook-secret", payload)}" }
+
+ it "accepts the correct HMAC" do
+ expect(described_class.verify_webhook_signature?(payload, signature)).to be true
+ end
+
+ it "rejects a tampered payload, a missing header, and a missing secret" do
+ expect(described_class.verify_webhook_signature?(payload + " ", signature)).to be false
+ expect(described_class.verify_webhook_signature?(payload, nil)).to be false
+
+ allow(described_class).to receive(:webhook_secret).and_return(nil)
+ expect(described_class.verify_webhook_signature?(payload, signature)).to be false
+ end
+ end
+
+ describe "REST calls" do
+ before do
+ installation.update!(access_token: "ghs_live", access_token_expires_at: 1.hour.from_now)
+ end
+
+ it "fetches a pull request with App headers" do
+ stub_request(:get, "https://api.github.com/repos/octocat/hello/pulls/7")
+ .with(headers: { "Authorization" => "Bearer ghs_live",
+ "X-GitHub-Api-Version" => "2022-11-28" })
+ .to_return(status: 200, body: { "number" => 7 }.to_json)
+
+ expect(described_class.pull_request(installation, "octocat/hello", 7)).to eq("number" => 7)
+ end
+
+ it "follows Link pagination when listing files" do
+ page_two = "https://api.github.com/repos/octocat/hello/pulls/7/files?per_page=100&page=2"
+ stub_request(:get, "https://api.github.com/repos/octocat/hello/pulls/7/files?per_page=100")
+ .to_return(status: 200, body: [ { "filename" => "a.rb" } ].to_json,
+ headers: { "Link" => "<#{page_two}>; rel=\"next\"" })
+ stub_request(:get, page_two)
+ .to_return(status: 200, body: [ { "filename" => "b.rb" } ].to_json)
+
+ files = described_class.pull_request_files(installation, "octocat/hello", 7)
+ expect(files.map { |f| f["filename"] }).to eq(%w[a.rb b.rb])
+ end
+
+ it "creates a review with the summary body and line comments" do
+ create = stub_request(:post, "https://api.github.com/repos/octocat/hello/pulls/7/reviews")
+ .with(body: hash_including(
+ "commit_id" => "abc123", "event" => "COMMENT", "body" => "Walkthrough",
+ "comments" => [ { "path" => "a.rb", "line" => 2, "side" => "RIGHT", "body" => "hm" } ]
+ ))
+ .to_return(status: 200, body: "{}")
+
+ described_class.create_review(installation, "octocat/hello", 7,
+ commit_id: "abc123", body: "Walkthrough",
+ comments: [ { "path" => "a.rb", "line" => 2, "side" => "RIGHT", "body" => "hm" } ])
+ expect(create).to have_been_requested
+ end
+
+ it "raises ApiError carrying status and body on 422" do
+ stub_request(:post, "https://api.github.com/repos/octocat/hello/pulls/7/reviews")
+ .to_return(status: 422, body: '{"message":"Unprocessable"}')
+
+ expect do
+ described_class.create_review(installation, "octocat/hello", 7,
+ commit_id: "abc123", body: "x")
+ end.to raise_error(described_class::ApiError) { |error|
+ expect(error.status).to eq(422)
+ expect(error.body).to include("Unprocessable")
+ }
+ end
+
+ it "wraps connection failures as a 502 ApiError" do
+ stub_request(:get, "https://api.github.com/repos/octocat/hello/pulls/7").to_timeout
+
+ expect { described_class.pull_request(installation, "octocat/hello", 7) }
+ .to raise_error(described_class::ApiError) { |error| expect(error.status).to eq(502) }
+ end
+ end
+end
spec/services/github_patch_index_spec.rb
+96
new file mode 100644
index 0000000..ba8a6ba
--- /dev/null
+++ b/spec/services/github_patch_index_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe GithubPatchIndex do
+ # a.rb: new-file lines 1 (context), 2 (added), 3 (context); line 4 removed only.
+ let(:single_hunk_patch) { "@@ -1,3 +1,3 @@\n line one\n+line two\n line three\n-line four" }
+
+ let(:multi_hunk_patch) do
+ "@@ -1,2 +1,3 @@\n a\n+b\n c\n@@ -10,2 +11,3 @@\n x\n+y\n z"
+ end
+
+ def index_for(patch, filename: "a.rb")
+ described_class.new([ { "filename" => filename, "patch" => patch } ])
+ end
+
+ describe "#valid_line?" do
+ it "accepts added and context lines by new-file number" do
+ index = index_for(single_hunk_patch)
+ expect(index.valid_line?("a.rb", 1)).to be true
+ expect(index.valid_line?("a.rb", 2)).to be true
+ expect(index.valid_line?("a.rb", 3)).to be true
+ end
+
+ it "rejects lines outside the hunks" do
+ index = index_for(single_hunk_patch)
+ expect(index.valid_line?("a.rb", 4)).to be false
+ expect(index.valid_line?("a.rb", 100)).to be false
+ end
+
+ it "rejects unknown files" do
+ expect(index_for(single_hunk_patch).valid_line?("other.rb", 1)).to be false
+ end
+
+ it "indexes every hunk of a multi-hunk patch" do
+ index = index_for(multi_hunk_patch)
+ expect(index.valid_line?("a.rb", 2)).to be true # + in hunk 1
+ expect(index.valid_line?("a.rb", 12)).to be true # + in hunk 2
+ expect(index.valid_line?("a.rb", 7)).to be false # between hunks
+ end
+
+ it "treats a file without a patch (binary) as having no commentable lines" do
+ index = described_class.new([ { "filename" => "logo.png", "patch" => nil } ])
+ expect(index.valid_line?("logo.png", 1)).to be false
+ end
+
+ it "handles a deleted file (only removed lines) with no commentable lines" do
+ index = index_for("@@ -1,2 +0,0 @@\n-gone\n-also gone", filename: "dead.rb")
+ expect(index.valid_line?("dead.rb", 1)).to be false
+ end
+ end
+
+ describe "#clamp" do
+ let(:index) { index_for(single_hunk_patch) }
+
+ it "returns a valid line unchanged" do
+ expect(index.clamp("a.rb", 2)).to eq(2)
+ end
+
+ it "snaps a near miss to the nearest commentable line" do
+ expect(index.clamp("a.rb", 5)).to eq(3)
+ end
+
+ it "drops a far miss" do
+ expect(index.clamp("a.rb", 50)).to be_nil
+ end
+
+ it "drops findings on unknown files or non-integer lines" do
+ expect(index.clamp("other.rb", 2)).to be_nil
+ expect(index.clamp("a.rb", nil)).to be_nil
+ end
+ end
+
+ describe ".annotate_patch" do
+ it "prefixes added and context lines with new-file numbers, leaves removed lines bare" do
+ annotated = described_class.annotate_patch(single_hunk_patch)
+ lines = annotated.lines.map(&:chomp)
+
+ expect(lines[0]).to eq("@@ -1,3 +1,3 @@")
+ expect(lines[1]).to match(/\A\s+1 line one\z/)
+ expect(lines[2]).to match(/\A\s+2 \+line two\z/)
+ expect(lines[3]).to match(/\A\s+3 line three\z/)
+ expect(lines[4]).to eq("-line four")
+ end
+
+ it "restarts numbering at each hunk header" do
+ annotated = described_class.annotate_patch(multi_hunk_patch)
+ expect(annotated).to include(" 11 x")
+ expect(annotated).to include(" 12 +y")
+ end
+
+ it "returns an empty string for a missing patch" do
+ expect(described_class.annotate_patch(nil)).to eq("")
+ end
+ end
+end
spec/services/github_review_prompt_spec.rb
+161
new file mode 100644
index 0000000..6d77ae6
--- /dev/null
+++ b/spec/services/github_review_prompt_spec.rb
@@ -0,0 +1,161 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe GithubReviewPrompt do
+ def file(name, patch: "@@ -1,1 +1,2 @@\n a\n+b", **extra)
+ { "filename" => name, "status" => "modified", "additions" => 1, "deletions" => 0,
+ "patch" => patch }.merge(extra)
+ end
+
+ describe ".select_files" do
+ it "keeps normal source files and reports nothing skipped" do
+ selected, skipped = described_class.select_files([ file("app/a.rb") ])
+ expect(selected.map { |f| f["filename"] }).to eq([ "app/a.rb" ])
+ expect(skipped).to be_empty
+ end
+
+ it "excludes lockfiles, vendored paths, minified assets, and db/schema.rb" do
+ files = [
+ file("Gemfile.lock"), file("sub/dir/Cargo.lock"), file("vendor/lib.rb"),
+ file("node_modules/x/index.js"), file("app.min.js"), file("db/schema.rb"),
+ file("app/real.rb")
+ ]
+ selected, skipped = described_class.select_files(files)
+
+ expect(selected.map { |f| f["filename"] }).to eq([ "app/real.rb" ])
+ expect(skipped.map { |f, reason| [ f["filename"], reason ] })
+ .to all(satisfy { |_name, reason| reason == "generated or vendored" })
+ expect(skipped.size).to eq(6)
+ end
+
+ it "skips binary files (no patch)" do
+ _, skipped = described_class.select_files([ file("logo.png", patch: nil) ])
+ expect(skipped).to eq([ [ file("logo.png", patch: nil), "no text diff" ] ])
+ end
+
+ it "skips a single file over the per-file budget" do
+ big = file("big.rb", patch: "@@ -1,1 +1,2 @@\n a\n+#{"x" * described_class::MAX_FILE_PATCH_BYTES}")
+ _, skipped = described_class.select_files([ big ])
+ expect(skipped.first.last).to eq("diff too large")
+ end
+
+ it "drops the largest files first when the total budget is exceeded" do
+ chunk = "@@ -1,1 +1,2 @@\n a\n+#{"y" * 11_000}"
+ files = (1..9).map { |i| file("f#{i}.rb", patch: chunk) }
+ selected, skipped = described_class.select_files(files)
+
+ expect(selected.sum { |f| f["patch"].bytesize }).to be <= described_class::MAX_TOTAL_PATCH_BYTES
+ expect(selected.size + skipped.size).to eq(9)
+ expect(skipped.map(&:last)).to all(eq("over the total diff budget"))
+ end
+
+ it "preserves GitHub's file ordering in the selection" do
+ files = [ file("z.rb"), file("a.rb") ]
+ selected, = described_class.select_files(files)
+ expect(selected.map { |f| f["filename"] }).to eq([ "z.rb", "a.rb" ])
+ end
+ end
+
+ describe ".too_large?" do
+ it "is true over the file-count cap" do
+ files = (0..described_class::MAX_FILES).map { |i| file("f#{i}.rb") }
+ selected, = described_class.select_files(files)
+ expect(described_class.too_large?(files, selected)).to be true
+ end
+
+ it "is true when nothing survives selection" do
+ files = [ file("Gemfile.lock") ]
+ expect(described_class.too_large?(files, [])).to be true
+ end
+
+ it "is false for a reviewable PR" do
+ files = [ file("a.rb") ]
+ expect(described_class.too_large?(files, files)).to be false
+ end
+ end
+
+ describe ".payload" do
+ let(:pull_request) do
+ { "title" => "Fix login", "body" => "Corrects the token check.",
+ "base" => { "ref" => "main" }, "head" => { "ref" => "fix-login", "sha" => "abc1234" } }
+ end
+
+ it "builds a non-streaming chat payload with annotated diffs" do
+ payload = described_class.payload(
+ pull_request: pull_request, files: [ file("app/a.rb") ],
+ skipped: [ [ file("Gemfile.lock"), "generated or vendored" ] ], model: "onde-large"
+ )
+
+ expect(payload["model"]).to eq("onde-large")
+ expect(payload["stream"]).to be false
+ system_message, user_message = payload["messages"]
+ expect(system_message["content"]).to include("siGit Code")
+ expect(user_message["content"]).to include("Fix login")
+ .and include("main <- fix-login")
+ .and include("Gemfile.lock (generated or vendored)")
+ .and include(" 2 +b") # annotated new-file line number
+ end
+ end
+
+ describe ".parse_response" do
+ let(:valid_json) do
+ '{"summary": "Adds a thing.", "findings": [{"path": "a.rb", "line": 2, "severity": "warning", "comment": "Check nil."}]}'
+ end
+
+ it "parses a clean JSON reply" do
+ parsed = described_class.parse_response(valid_json)
+ expect(parsed[:summary]).to eq("Adds a thing.")
+ expect(parsed[:findings]).to eq([ { path: "a.rb", line: 2, severity: "warning", comment: "Check nil." } ])
+ end
+
+ it "parses a fenced reply" do
+ parsed = described_class.parse_response("```json\n#{valid_json}\n```")
+ expect(parsed[:summary]).to eq("Adds a thing.")
+ end
+
+ it "parses a prose-wrapped reply" do
+ parsed = described_class.parse_response("Here is my review:\n#{valid_json}\nHope that helps!")
+ expect(parsed[:summary]).to eq("Adds a thing.")
+ end
+
+ it "raises ParseError on garbage" do
+ expect { described_class.parse_response("no json here") }
+ .to raise_error(described_class::ParseError)
+ end
+
+ it "raises ParseError when the summary is missing" do
+ expect { described_class.parse_response('{"findings": []}') }
+ .to raise_error(described_class::ParseError, /summary/)
+ end
+
+ it "drops malformed findings, coerces string lines, defaults bad severities" do
+ reply = {
+ "summary" => "ok",
+ "findings" => [
+ { "path" => "a.rb", "line" => "3", "severity" => "silly", "comment" => "x" },
+ { "path" => "", "line" => 1, "comment" => "no path" },
+ { "path" => "a.rb", "line" => 0, "comment" => "bad line" },
+ { "path" => "a.rb", "line" => 1 },
+ "not even a hash"
+ ]
+ }.to_json
+
+ findings = described_class.parse_response(reply)[:findings]
+ expect(findings).to eq([ { path: "a.rb", line: 3, severity: "warning", comment: "x" } ])
+ end
+
+ it "caps findings at MAX_FINDINGS" do
+ many = (1..20).map { |i| { "path" => "a.rb", "line" => i, "comment" => "c#{i}" } }
+ findings = described_class.parse_response({ "summary" => "ok", "findings" => many }.to_json)[:findings]
+ expect(findings.size).to eq(described_class::MAX_FINDINGS)
+ end
+ end
+
+ describe ".summary_footer" do
+ it "brands the review and cites the short SHA" do
+ footer = described_class.summary_footer("abcdef0123456789")
+ expect(footer).to include("siGit Code").and include("abcdef0")
+ end
+ end
+end