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