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
+189
new file mode 100644
index 0000000..82e89d2
--- /dev/null
+++ b/app/jobs/github_pr_review_job.rb
@@ -0,0 +1,189 @@
+# frozen_string_literal: true
+
+# Reviews one head SHA of one github.com pull request and posts the result:
+# a walkthrough summary plus line-level comments, in a single review. Enqueued
+# by GithubWebhooksController; all guards re-run here because the queue can
+# hold events for a while (kill switch flipped, PR closed, newer push).
+#
+# Idempotency: the (repo, pr, sha) row in github_pr_reviews is claimed before
+# any work. Transient failures re-queue by resetting the claim to pending;
+# exhausted retries mark it failed. A review is never half-posted — posting is
+# one GitHub call, with summary-only and plain-comment fallbacks on 422.
+class GithubPrReviewJob < ApplicationJob
+ queue_as :default
+
+ RETRY_ATTEMPTS = 3
+
+ retry_on OndeCloudService::UpstreamError, GithubAppService::ApiError,
+ wait: :polynomially_longer, attempts: RETRY_ATTEMPTS do |job, error|
+ job.send(:fail_review!, error)
+ end
+
+ def perform(installation_id:, repo_full_name:, pr_number:, head_sha:)
+ return unless GithubReviewConfig.enabled?
+
+ installation = GithubAppInstallation.active.find_by(id: installation_id)
+ return if installation.nil?
+
+ @review = claim_review(installation, repo_full_name, pr_number, head_sha)
+ return if @review.nil?
+
+ run_review(installation, repo_full_name, pr_number, head_sha)
+ rescue GithubReviewPrompt::ParseError => e
+ # The model reply didn't survive parsing; never post garbled output, and
+ # a retry would likely fail the same way.
+ Rails.logger.error("GitHub PR review parse failure for #{repo_full_name}##{pr_number}: #{e.message}")
+ fail_review!(e)
+ rescue OndeCloudService::UpstreamError, GithubAppService::ApiError => e
+ release_claim!(e)
+ raise # retry_on schedules the retry; its block marks the row failed on exhaustion
+ rescue StandardError => e
+ fail_review!(e)
+ raise
+ end
+
+ private
+
+ # Claims the (repo, pr, sha) row, or returns nil when another execution owns
+ # it. The row lock closes the race between two workers claiming at once; a
+ # long-stale `running` claim (worker killed mid-review) is re-entered.
+ def claim_review(installation, repo_full_name, pr_number, head_sha)
+ review = GithubPrReview.create_or_find_by(
+ repo_full_name: repo_full_name, pr_number: pr_number, head_sha: head_sha
+ ) { |r| r.github_app_installation = installation }
+
+ review.with_lock do
+ return nil unless review.claimable?
+
+ review.update!(status: "running", started_at: Time.current, error_message: nil)
+ end
+ review
+ end
+
+ def run_review(installation, repo_full_name, pr_number, head_sha)
+ pull_request = fetch_pull_request(installation, repo_full_name, pr_number)
+ return if pull_request.nil?
+ return @review.skip!("draft") if pull_request["draft"]
+ return @review.skip!("not_open") if pull_request["state"] != "open"
+ return @review.skip!("superseded") if pull_request.dig("head", "sha") != head_sha
+
+ files = github(installation) do
+ GithubAppService.pull_request_files(installation, repo_full_name, pr_number)
+ end
+ return @review.skip!("empty_diff") if files.blank?
+
+ selected, skipped = GithubReviewPrompt.select_files(files)
+ if GithubReviewPrompt.too_large?(files, selected)
+ post_too_large_note(installation, repo_full_name, pr_number, head_sha, files.size)
+ return @review.skip!("too_large")
+ end
+
+ model = GithubReviewConfig.model
+ response = OndeCloudService.create(
+ GithubReviewPrompt.payload(pull_request: pull_request, files: selected, skipped: skipped, model: model)
+ )
+ parsed = GithubReviewPrompt.parse_response(response.dig("choices", 0, "message", "content"))
+
+ comments = review_comments(parsed[:findings], GithubPatchIndex.new(selected))
+ body = parsed[:summary] + GithubReviewPrompt.summary_footer(head_sha)
+ post_review(installation, repo_full_name, pr_number, head_sha, body, comments)
+
+ @review.update!(
+ status: "completed", completed_at: Time.current,
+ comment_count: comments.size, model: model
+ )
+ end
+
+ # nil means the review row is already resolved (PR gone).
+ def fetch_pull_request(installation, repo_full_name, pr_number)
+ github(installation) { GithubAppService.pull_request(installation, repo_full_name, pr_number) }
+ rescue GithubAppService::ApiError => e
+ raise unless e.status == 404
+
+ @review.skip!("pr_gone")
+ nil
+ end
+
+ # Findings that survive diff validation, as GitHub review comments. Lines
+ # are clamped to the nearest commentable line; findings that miss the diff
+ # entirely are dropped (never risk the 422).
+ def review_comments(findings, patch_index)
+ findings.filter_map do |finding|
+ line = patch_index.clamp(finding[:path], finding[:line])
+ next nil if line.nil?
+
+ {
+ "path" => finding[:path],
+ "line" => line,
+ "side" => "RIGHT",
+ "body" => "**#{finding[:severity]}** — #{finding[:comment]}"
+ }
+ end.uniq { |comment| comment.values_at("path", "line") }
+ end
+
+ # One atomic review; on 422 (GitHub rejects the whole review if any comment
+ # is off-diff — defense in depth behind the patch-index validation) degrade
+ # to summary-only, then to a plain issue comment.
+ def post_review(installation, repo_full_name, pr_number, head_sha, body, comments)
+ github(installation) do
+ GithubAppService.create_review(installation, repo_full_name, pr_number,
+ commit_id: head_sha, body: body, comments: comments)
+ end
+ rescue GithubAppService::ApiError => e
+ raise unless e.status == 422
+
+ Rails.logger.warn("GitHub review rejected (422) for #{repo_full_name}##{pr_number}: #{e.body.to_s.byteslice(0, 500)}")
+ begin
+ if comments.any?
+ github(installation) do
+ GithubAppService.create_review(installation, repo_full_name, pr_number,
+ commit_id: head_sha, body: body, comments: [])
+ end
+ else
+ raise e
+ end
+ rescue GithubAppService::ApiError => fallback_error
+ raise unless fallback_error.status == 422
+
+ github(installation) do
+ GithubAppService.create_issue_comment(installation, repo_full_name, pr_number, body: body)
+ end
+ end
+ end
+
+ def post_too_large_note(installation, repo_full_name, pr_number, head_sha, files_count)
+ body = GithubReviewPrompt.too_large_body(files_count) + GithubReviewPrompt.summary_footer(head_sha)
+ github(installation) do
+ GithubAppService.create_issue_comment(installation, repo_full_name, pr_number, body: body)
+ end
+ end
+
+ # Runs a GitHub call, retrying once with a fresh installation token on 401
+ # (the cached token can expire between the margin check and the call, or be
+ # revoked by a reinstall).
+ def github(installation)
+ yield
+ rescue GithubAppService::ApiError => e
+ raise unless e.status == 401
+
+ installation.clear_access_token!
+ yield
+ end
+
+ # Transient failure: release the claim so the scheduled retry can re-enter.
+ def release_claim!(error)
+ @review&.update(status: "pending", error_message: error.message.to_s.truncate(1000))
+ end
+
+ # Terminal failure: mark the row failed. Called directly for non-retryable
+ # errors and from the retry_on block when attempts are exhausted.
+ def fail_review!(error)
+ review = @review || GithubPrReview.find_by(
+ arguments.first.slice(:repo_full_name, :pr_number, :head_sha)
+ )
+ review&.update(
+ status: "failed", completed_at: Time.current,
+ error_message: error.message.to_s.truncate(1000)
+ )
+ end
+end