main
rb 189 lines 7.39 KB
Raw
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