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