Add the GitHub PR review job
The orchestrator, and the app's first real ActiveJob: claim the (repo, pr, sha) row under a lock, re-run every guard (kill switch, draft, closed, superseded head), fetch and budget the diff, run inference through OndeCloudService, validate the model's findings against the patch index, and post one atomic review — degrading to summary-only and then a plain issue comment on 422. Transient upstream failures release the claim and retry with backoff; exhausted or non-retryable failures mark the row failed. A parse failure posts nothing: model output is untrusted and the comments are public. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WhPzinYJUYgBed63XMvJX1
Claude committed
Jul 2, 2026 at 12:28 UTC
3f0a43b58e4ef0b9840bc45d7e1721b9eac306f6
1 file changed
+189
app/jobs/github_pr_review_job.rb
new
+189
@@ -0,0 +1,189 @@
1
+# frozen_string_literal: true
2
+
3
+# Reviews one head SHA of one github.com pull request and posts the result:
4
+# a walkthrough summary plus line-level comments, in a single review. Enqueued
5
+# by GithubWebhooksController; all guards re-run here because the queue can
6
+# hold events for a while (kill switch flipped, PR closed, newer push).
7
+#
8
+# Idempotency: the (repo, pr, sha) row in github_pr_reviews is claimed before
9
+# any work. Transient failures re-queue by resetting the claim to pending;
10
+# exhausted retries mark it failed. A review is never half-posted — posting is
11
+# one GitHub call, with summary-only and plain-comment fallbacks on 422.
12
+class GithubPrReviewJob < ApplicationJob
13
+ queue_as :default
14
+
15
+ RETRY_ATTEMPTS = 3
16
+
17
+ retry_on OndeCloudService::UpstreamError, GithubAppService::ApiError,
18
+ wait: :polynomially_longer, attempts: RETRY_ATTEMPTS do |job, error|
19
+ job.send(:fail_review!, error)
20
+ end
21
+
22
+ def perform(installation_id:, repo_full_name:, pr_number:, head_sha:)
23
+ return unless GithubReviewConfig.enabled?
24
+
25
+ installation = GithubAppInstallation.active.find_by(id: installation_id)
26
+ return if installation.nil?
27
+
28
+ @review = claim_review(installation, repo_full_name, pr_number, head_sha)
29
+ return if @review.nil?
30
+
31
+ run_review(installation, repo_full_name, pr_number, head_sha)
32
+ rescue GithubReviewPrompt::ParseError => e
33
+ # The model reply didn't survive parsing; never post garbled output, and
34
+ # a retry would likely fail the same way.
35
+ Rails.logger.error("GitHub PR review parse failure for #{repo_full_name}##{pr_number}: #{e.message}")
36
+ fail_review!(e)
37
+ rescue OndeCloudService::UpstreamError, GithubAppService::ApiError => e
38
+ release_claim!(e)
39
+ raise # retry_on schedules the retry; its block marks the row failed on exhaustion
40
+ rescue StandardError => e
41
+ fail_review!(e)
42
+ raise
43
+ end
44
+
45
+ private
46
+
47
+ # Claims the (repo, pr, sha) row, or returns nil when another execution owns
48
+ # it. The row lock closes the race between two workers claiming at once; a
49
+ # long-stale `running` claim (worker killed mid-review) is re-entered.
50
+ def claim_review(installation, repo_full_name, pr_number, head_sha)
51
+ review = GithubPrReview.create_or_find_by(
52
+ repo_full_name: repo_full_name, pr_number: pr_number, head_sha: head_sha
53
+ ) { |r| r.github_app_installation = installation }
54
+
55
+ review.with_lock do
56
+ return nil unless review.claimable?
57
+
58
+ review.update!(status: "running", started_at: Time.current, error_message: nil)
59
+ end
60
+ review
61
+ end
62
+
63
+ def run_review(installation, repo_full_name, pr_number, head_sha)
64
+ pull_request = fetch_pull_request(installation, repo_full_name, pr_number)
65
+ return if pull_request.nil?
66
+ return @review.skip!("draft") if pull_request["draft"]
67
+ return @review.skip!("not_open") if pull_request["state"] != "open"
68
+ return @review.skip!("superseded") if pull_request.dig("head", "sha") != head_sha
69
+
70
+ files = github(installation) do
71
+ GithubAppService.pull_request_files(installation, repo_full_name, pr_number)
72
+ end
73
+ return @review.skip!("empty_diff") if files.blank?
74
+
75
+ selected, skipped = GithubReviewPrompt.select_files(files)
76
+ if GithubReviewPrompt.too_large?(files, selected)
77
+ post_too_large_note(installation, repo_full_name, pr_number, head_sha, files.size)
78
+ return @review.skip!("too_large")
79
+ end
80
+
81
+ model = GithubReviewConfig.model
82
+ response = OndeCloudService.create(
83
+ GithubReviewPrompt.payload(pull_request: pull_request, files: selected, skipped: skipped, model: model)
84
+ )
85
+ parsed = GithubReviewPrompt.parse_response(response.dig("choices", 0, "message", "content"))
86
+
87
+ comments = review_comments(parsed[:findings], GithubPatchIndex.new(selected))
88
+ body = parsed[:summary] + GithubReviewPrompt.summary_footer(head_sha)
89
+ post_review(installation, repo_full_name, pr_number, head_sha, body, comments)
90
+
91
+ @review.update!(
92
+ status: "completed", completed_at: Time.current,
93
+ comment_count: comments.size, model: model
94
+ )
95
+ end
96
+
97
+ # nil means the review row is already resolved (PR gone).
98
+ def fetch_pull_request(installation, repo_full_name, pr_number)
99
+ github(installation) { GithubAppService.pull_request(installation, repo_full_name, pr_number) }
100
+ rescue GithubAppService::ApiError => e
101
+ raise unless e.status == 404
102
+
103
+ @review.skip!("pr_gone")
104
+ nil
105
+ end
106
+
107
+ # Findings that survive diff validation, as GitHub review comments. Lines
108
+ # are clamped to the nearest commentable line; findings that miss the diff
109
+ # entirely are dropped (never risk the 422).
110
+ def review_comments(findings, patch_index)
111
+ findings.filter_map do |finding|
112
+ line = patch_index.clamp(finding[:path], finding[:line])
113
+ next nil if line.nil?
114
+
115
+ {
116
+ "path" => finding[:path],
117
+ "line" => line,
118
+ "side" => "RIGHT",
119
+ "body" => "**#{finding[:severity]}** — #{finding[:comment]}"
120
+ }
121
+ end.uniq { |comment| comment.values_at("path", "line") }
122
+ end
123
+
124
+ # One atomic review; on 422 (GitHub rejects the whole review if any comment
125
+ # is off-diff — defense in depth behind the patch-index validation) degrade
126
+ # to summary-only, then to a plain issue comment.
127
+ def post_review(installation, repo_full_name, pr_number, head_sha, body, comments)
128
+ github(installation) do
129
+ GithubAppService.create_review(installation, repo_full_name, pr_number,
130
+ commit_id: head_sha, body: body, comments: comments)
131
+ end
132
+ rescue GithubAppService::ApiError => e
133
+ raise unless e.status == 422
134
+
135
+ Rails.logger.warn("GitHub review rejected (422) for #{repo_full_name}##{pr_number}: #{e.body.to_s.byteslice(0, 500)}")
136
+ begin
137
+ if comments.any?
138
+ github(installation) do
139
+ GithubAppService.create_review(installation, repo_full_name, pr_number,
140
+ commit_id: head_sha, body: body, comments: [])
141
+ end
142
+ else
143
+ raise e
144
+ end
145
+ rescue GithubAppService::ApiError => fallback_error
146
+ raise unless fallback_error.status == 422
147
+
148
+ github(installation) do
149
+ GithubAppService.create_issue_comment(installation, repo_full_name, pr_number, body: body)
150
+ end
151
+ end
152
+ end
153
+
154
+ def post_too_large_note(installation, repo_full_name, pr_number, head_sha, files_count)
155
+ body = GithubReviewPrompt.too_large_body(files_count) + GithubReviewPrompt.summary_footer(head_sha)
156
+ github(installation) do
157
+ GithubAppService.create_issue_comment(installation, repo_full_name, pr_number, body: body)
158
+ end
159
+ end
160
+
161
+ # Runs a GitHub call, retrying once with a fresh installation token on 401
162
+ # (the cached token can expire between the margin check and the call, or be
163
+ # revoked by a reinstall).
164
+ def github(installation)
165
+ yield
166
+ rescue GithubAppService::ApiError => e
167
+ raise unless e.status == 401
168
+
169
+ installation.clear_access_token!
170
+ yield
171
+ end
172
+
173
+ # Transient failure: release the claim so the scheduled retry can re-enter.
174
+ def release_claim!(error)
175
+ @review&.update(status: "pending", error_message: error.message.to_s.truncate(1000))
176
+ end
177
+
178
+ # Terminal failure: mark the row failed. Called directly for non-retryable
179
+ # errors and from the retry_on block when attempts are exhausted.
180
+ def fail_review!(error)
181
+ review = @review || GithubPrReview.find_by(
182
+ arguments.first.slice(:repo_full_name, :pr_number, :head_sha)
183
+ )
184
+ review&.update(
185
+ status: "failed", completed_at: Time.current,
186
+ error_message: error.message.to_s.truncate(1000)
187
+ )
188
+ end
189
+end